ExamplesPlaygroundReference Source

coral-spectrum/coral-component-columnview/src/scripts/ColumnViewItem.js

  1. /**
  2. * Copyright 2019 Adobe. All rights reserved.
  3. * This file is licensed to you under the Apache License, Version 2.0 (the "License");
  4. * you may not use this file except in compliance with the License. You may obtain a copy
  5. * of the License at http://www.apache.org/licenses/LICENSE-2.0
  6. *
  7. * Unless required by applicable law or agreed to in writing, software distributed under
  8. * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
  9. * OF ANY KIND, either express or implied. See the License for the specific language
  10. * governing permissions and limitations under the License.
  11. */
  12.  
  13. import accessibilityState from '../templates/accessibilityState';
  14. import {BaseComponent} from '../../../coral-base-component';
  15. import {BaseLabellable} from '../../../coral-base-labellable';
  16. import {Icon} from '../../../coral-component-icon';
  17. import {Checkbox} from '../../../coral-component-checkbox';
  18. import {commons, i18n, transform, validate} from '../../../coral-utils';
  19. import {Decorator} from '../../../coral-decorator';
  20.  
  21. const CLASSNAME = '_coral-AssetList-item';
  22.  
  23. /**
  24. Enumeration for {@link ColumnViewItem} variants.
  25.  
  26. @typedef {Object} ColumnViewItemVariantEnum
  27.  
  28. @property {String} DEFAULT
  29. Default item variant. Contains no special decorations.
  30. @property {String} DRILLDOWN
  31. An item with a right arrow indicating that the navigation will go one level down.
  32. */
  33. const variant = {
  34. DEFAULT: 'default',
  35. DRILLDOWN: 'drilldown'
  36. };
  37.  
  38. /**
  39. Utility that identifies Chrome on macOS, which announces drilldown items as "row 1 expanded" or "row 1 collapsed" when navigating between items.
  40. */
  41. const isChromeMacOS = !!window && !!window.chrome && /Mac/i.test(window.navigator.platform);
  42.  
  43. /**
  44. @class Coral.ColumnView.Item
  45. @classdesc A ColumnView Item component
  46. @htmltag coral-columnview-item
  47. @extends {HTMLElement}
  48. @extends {BaseComponent}
  49. */
  50. const ColumnViewItem = Decorator(class extends BaseLabellable(BaseComponent(HTMLElement)) {
  51. /** @ignore */
  52. constructor() {
  53. super();
  54.  
  55. // Content zone
  56. this._elements = {
  57. content: this.querySelector('coral-columnview-item-content') || document.createElement('coral-columnview-item-content'),
  58. thumbnail: this.querySelector('coral-columnview-item-thumbnail') || document.createElement('coral-columnview-item-thumbnail'),
  59. accessibilityState: this.querySelector('span[handle="accessibilityState"]')
  60. };
  61.  
  62. if (!this._elements.accessibilityState) {
  63. // Templates
  64. accessibilityState.call(this._elements, {commons});
  65. }
  66. }
  67.  
  68. /**
  69. The content of the item.
  70.  
  71. @type {ColumnViewItemContent}
  72. @contentzone
  73. */
  74. get content() {
  75. return this._getContentZone(this._elements.content);
  76. }
  77.  
  78. set content(value) {
  79. this._setContentZone('content', value, {
  80. handle: 'content',
  81. tagName: 'coral-columnview-item-content',
  82. insert: function (content) {
  83. content.classList.add(`${CLASSNAME}Label`);
  84. // Insert before chevron
  85. this.insertBefore(content, this.querySelector('._coral-AssetList-itemChildIndicator'));
  86. }
  87. });
  88. }
  89.  
  90. /**
  91. The thumbnail of the item. It is used to hold an icon or an image.
  92.  
  93. @type {ColumnViewItemThumbnail}
  94. @contentzone
  95. */
  96. get thumbnail() {
  97. return this._getContentZone(this._elements.thumbnail);
  98. }
  99.  
  100. set thumbnail(value) {
  101. this._setContentZone('thumbnail', value, {
  102. handle: 'thumbnail',
  103. tagName: 'coral-columnview-item-thumbnail',
  104. insert: function (thumbnail) {
  105. thumbnail.classList.add(`${CLASSNAME}Thumbnail`);
  106. // Insert before content
  107. this.insertBefore(thumbnail, this.content || null);
  108. }
  109. });
  110. }
  111.  
  112. /**
  113. The item's variant. See {@link ColumnViewItemVariantEnum}.
  114.  
  115. @type {String}
  116. @default ColumnViewItemVariantEnum.DEFAULT
  117. @htmlattribute variant
  118. @htmlattributereflected
  119. */
  120. get variant() {
  121. return this._variant || variant.DEFAULT;
  122. }
  123.  
  124. set variant(value) {
  125. value = transform.string(value).toLowerCase();
  126. value = validate.enumeration(variant)(value) && value || variant.DEFAULT;
  127.  
  128. this._reflectAttribute('variant', value);
  129.  
  130. if(validate.valueMustChange(this._variant, value)) {
  131. this._variant = value;
  132.  
  133. if (value === variant.DRILLDOWN) {
  134. // Render chevron on demand
  135. const childIndicator = this.querySelector('._coral-AssetList-itemChildIndicator');
  136. if (!childIndicator) {
  137. this.insertAdjacentHTML('beforeend', Icon._renderSVG('spectrum-css-icon-ChevronRightMedium', ['_coral-AssetList-itemChildIndicator', '_coral-UIIcon-ChevronRightMedium']));
  138. }
  139. this.classList.add('is-branch');
  140. // @a11y Update aria-expanded. Active drilldowns should be expanded.
  141. // Note: Omit aria-expanded on Chrome for macOS, because with VoiceOver tends
  142. // to announce drilldown items as "row 1 expanded" or "row 1 collapsed" when
  143. // navigating between items.
  144. if (this.selected || (isChromeMacOS && this.getAttribute('aria-level') === '1')) {
  145. this.removeAttribute('aria-expanded');
  146. } else {
  147. this.setAttribute('aria-expanded', this.active);
  148. }
  149. } else {
  150. this.classList.remove('is-branch');
  151. this.removeAttribute('aria-expanded');
  152. }
  153. }
  154. }
  155.  
  156. /**
  157. Specifies the icon that will be placed inside the thumbnail. The size of the icon is always controlled by the
  158. component.
  159.  
  160. @type {String}
  161. @default ""
  162. @htmlattribute icon
  163. @htmlattributereflected
  164. */
  165. get icon() {
  166. return this._icon || '';
  167. }
  168.  
  169. set icon(value) {
  170. value = transform.string(value);
  171. this._reflectAttribute('icon', value);
  172.  
  173. if(validate.valueMustChange(this._icon, value)) {
  174. this._icon = value;
  175.  
  176. // ignored if it is an empty string
  177. if (value) {
  178. // creates a new icon element
  179. if (!this._elements.icon) {
  180. this._elements.icon = new Icon();
  181. // register observer only if there present an icon field.
  182. super._observeLabel();
  183. }
  184. this._elements.icon.icon = this.icon;
  185. this._elements.icon.size = Icon.size.SMALL;
  186. // removes all the items, since the icon attribute has precedence
  187. this._elements.thumbnail.innerHTML = '';
  188. // adds the newly created icon
  189. this._elements.thumbnail.appendChild(this._elements.icon);
  190. }
  191. super._toggleIconAriaHidden();
  192. }
  193. }
  194.  
  195. /**
  196. Whether the item is selected.
  197.  
  198. @type {Boolean}
  199. @default false
  200. @htmlattribute selected
  201. @htmlattributereflected
  202. */
  203. get selected() {
  204. return this._selected || false;
  205. }
  206.  
  207. set selected(value) {
  208. value = transform.booleanAttr(value);
  209. this._reflectAttribute('selected', value);
  210.  
  211. if(validate.valueMustChange(this._selected, value)) {
  212. this._selected = value;
  213. this.trigger('coral-columnview-item:_selectedchanged');
  214. // wait a frame before updating attributes
  215. commons.nextFrame(() => {
  216. this.classList.toggle('is-selected', value);
  217. this.setAttribute('aria-selected', value);
  218. // @a11y Update aria-expanded. Active drilldowns should be expanded.
  219. // Note: Omit aria-expanded on Chrome for macOS, because with VoiceOver tends
  220. // to announce drilldown items as "row 1 expanded" or "row 1 collapsed" when
  221. // navigating between items.
  222. if (value === variant.DRILLDOWN) {
  223. if (this._selected || (isChromeMacOS && this.getAttribute('aria-level') === '1')) {
  224. this.removeAttribute('aria-expanded');
  225. } else {
  226. this.setAttribute('aria-expanded', this.active);
  227. }
  228. }
  229. let accessibilityState = this._elements.accessibilityState;
  230. if (value) {
  231. // @a11y Panels to right of selected item are removed, so remove aria-owns and aria-describedby attributes.
  232. this.removeAttribute('aria-owns');
  233. this.removeAttribute('aria-describedby');
  234. // @a11y Update content to ensure that checked state is announced by assistive technology when the item receives focus
  235. accessibilityState.innerHTML = i18n.get(', checked');
  236. // @a11y append live region content element
  237. if (!this.contains(accessibilityState)) {
  238. this.appendChild(accessibilityState);
  239. }
  240. }
  241. // @a11y If deselecting from checked state,
  242. else {
  243. // @a11y remove, but retain reference to accessibilityState state
  244. if (accessibilityState.parentNode) {
  245. this._elements.accessibilityState = accessibilityState.parentNode.removeChild(accessibilityState);
  246. }
  247. // @a11y Update content to remove checked state
  248. this._elements.accessibilityState.innerHTML = '';
  249. }
  250. // @a11y Item should be labelled by thumbnail, content, and if appropriate accessibility state.
  251. let ariaLabelledby = this._elements.thumbnail.id + ' ' + this._elements.content.id;
  252. this.setAttribute('aria-labelledby', this.selected ? `${ariaLabelledby} ${accessibilityState.id}` : ariaLabelledby);
  253. // Sync checkbox item selector
  254. const itemSelector = this.querySelector('coral-checkbox[coral-columnview-itemselect]');
  255. if (itemSelector) {
  256. itemSelector[value ? 'setAttribute' : 'removeAttribute']('checked', '');
  257. }
  258. });
  259. }
  260. }
  261.  
  262. /**
  263. Whether the item is active.
  264.  
  265. @type {Boolean}
  266. @default false
  267. @htmlattribut active
  268. @htmlattributereflected
  269. */
  270. get active() {
  271. return this._active || false;
  272. }
  273.  
  274. set active(value) {
  275. value = transform.booleanAttr(value);
  276. this._reflectAttribute('active', value);
  277.  
  278. if(validate.valueMustChange(this._active, value)) {
  279. this._active = value;
  280.  
  281. this.classList.toggle('is-navigated', value);
  282. this.setAttribute('aria-selected', this.hasAttribute('_selectable') ? this.selected : value);
  283. // @a11y Update aria-expanded. Active drilldowns should be expanded.
  284. // Note: Omit aria-expanded on Chrome for macOS, because with VoiceOver tends
  285. // to announce drilldown items as "row 1 expanded" or "row 1 collapsed" when
  286. // navigating between items.
  287. if (this.variant === variant.DRILLDOWN) {
  288. if (this.selected || (isChromeMacOS && this.getAttribute('aria-level') === '1')) {
  289. this.removeAttribute('aria-expanded');
  290. } else {
  291. this.setAttribute('aria-expanded', this.active);
  292. }
  293. }
  294. if (!value) {
  295. // @a11y Inactive items are not expanded, so remove aria-owns and aria-describedby attributes.
  296. this.removeAttribute('aria-owns');
  297. this.removeAttribute('aria-describedby');
  298. }
  299. this.trigger('coral-columnview-item:_activechanged');
  300. }
  301. }
  302.  
  303. get _contentZones() {
  304. return {
  305. 'coral-columnview-item-content': 'content',
  306. 'coral-columnview-item-thumbnail': 'thumbnail'
  307. };
  308. }
  309.  
  310. /** @ignore */
  311. attributeChangedCallback(name, oldValue, value) {
  312. if (name === '_selectable') {
  313. // Disable selection
  314. if (value === null) {
  315. this.classList.remove('is-selectable');
  316. }
  317. // Enable selection
  318. else {
  319. this.classList.add('is-selectable');
  320. let itemSelector = this.querySelector('[coral-columnview-itemselect]');
  321.  
  322. // Render checkbox on demand
  323. if (!itemSelector) {
  324. itemSelector = new Checkbox();
  325. itemSelector.setAttribute('coral-columnview-itemselect', '');
  326. if (this.classList.contains('is-selected')) {
  327. itemSelector.setAttribute('checked', '');
  328. }
  329. itemSelector._elements.input.tabIndex = -1;
  330. itemSelector.setAttribute('labelledby', this._elements.content.id);
  331.  
  332. // Add the item selector as first child
  333. this.insertBefore(itemSelector, this.firstChild);
  334. }
  335. }
  336. } else {
  337. super.attributeChangedCallback(name, oldValue, value);
  338. }
  339. }
  340.  
  341. /**
  342. Returns {@link ColumnViewItem} variants.
  343.  
  344. @return {ColumnViewItemVariantEnum}
  345. */
  346. static get variant() {
  347. return variant;
  348. }
  349.  
  350. /** @ignore */
  351. static get observedAttributes() {
  352. return super.observedAttributes.concat([
  353. 'variant',
  354. 'icon',
  355. 'selected',
  356. 'active',
  357. '_selectable'
  358. ]);
  359. }
  360.  
  361. /** @ignore */
  362. render() {
  363. super.render();
  364.  
  365. this.classList.add(CLASSNAME);
  366.  
  367. // @a11y
  368. this.setAttribute('role', 'treeitem');
  369.  
  370. this.id = this.id || commons.getUID();
  371.  
  372. // only set tabIndex if it is not already set
  373. if (!this.hasAttribute('tabindex')) {
  374. this.tabIndex = this.active || this.selected ? 0 : -1;
  375. }
  376.  
  377. // Default reflected attributes
  378. if (!this._variant) {
  379. this.variant = variant.DEFAULT;
  380. }
  381.  
  382. const thumbnail = this._elements.thumbnail;
  383. const content = this._elements.content;
  384.  
  385. const contentZoneProvided = content.parentNode || thumbnail.parentNode;
  386.  
  387. if (!contentZoneProvided) {
  388. // move the contents of the item into the content zone
  389. while (this.firstChild) {
  390. content.appendChild(this.firstChild);
  391. }
  392. }
  393.  
  394. // Assign content zones
  395. this.content = content;
  396. this.thumbnail = thumbnail;
  397.  
  398. // @a11y thumbnail img element should have alt attribute
  399. const thumbnailImg = thumbnail.querySelector('img:not([alt])');
  400. if (thumbnailImg) {
  401. thumbnailImg.setAttribute('alt', '');
  402. }
  403.  
  404. // @ally add aria-labelledby so that JAWS/IE announces item correctly
  405. thumbnail.id = thumbnail.id || commons.getUID();
  406.  
  407. content.id = content.id || commons.getUID();
  408.  
  409. // @a11y Add live region element to ensure announcement of selected state
  410. const accessibilityState = this._elements.accessibilityState;
  411.  
  412. // @a11y accessibility state string should announce in document lang, rather than item lang.
  413. accessibilityState.setAttribute('lang', i18n.locale);
  414.  
  415. // @a11y Item should be labelled by thumbnail, content, and accessibility state.
  416. this.setAttribute('aria-labelledby', thumbnail.id + ' ' + content.id);
  417.  
  418. //adding html title, on hovering over textcontent title will be visible
  419. this.setAttribute('title', this.content.textContent.trim());
  420. }
  421. });
  422.  
  423. export default ColumnViewItem;