ExamplesPlaygroundReference Source

coral-spectrum/coral-component-tree/src/scripts/TreeItem.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 {BaseComponent} from '../../../coral-base-component';
  14. import {Collection} from '../../../coral-collection';
  15. import {Icon} from '../../../coral-component-icon';
  16. import treeItem from '../templates/treeItem';
  17. import {transform, commons, i18n, validate} from '../../../coral-utils';
  18. import {Decorator} from '../../../coral-decorator';
  19.  
  20. const CLASSNAME = '_coral-TreeView-item';
  21.  
  22. /**
  23. Enumeration for {@link TreeItem} variants.
  24.  
  25. @typedef {Object} TreeItemVariantEnum
  26.  
  27. @property {String} DRILLDOWN
  28. Default variant with icon to expand/collapse subtree.
  29. @property {String} LEAF
  30. Variant for leaf items. Icon to expand/collapse subtree is hidden.
  31. */
  32. const variant = {
  33. /* Default variant with icon to expand/collapse subtree. */
  34. DRILLDOWN: 'drilldown',
  35. /* Variant for leaf items. Icon to expand/collapse subtree is hidden. */
  36. LEAF: 'leaf'
  37. };
  38.  
  39. const ALL_VARIANT_CLASSES = [];
  40.  
  41. for (const variantValue in variant) {
  42. ALL_VARIANT_CLASSES.push(`${CLASSNAME}--${variant[variantValue]}`);
  43. }
  44.  
  45. const IS_TOUCH_DEVICE = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
  46.  
  47. /**
  48. @class Coral.Tree.Item
  49. @classdesc A Tree item component
  50. @htmltag coral-tree-item
  51. @extends {HTMLElement}
  52. @extends {BaseComponent}
  53. */
  54. const TreeItem = Decorator(class extends BaseComponent(HTMLElement) {
  55. /** @ignore */
  56. constructor() {
  57. super();
  58.  
  59. // Prepare templates
  60. this._elements = {
  61. // Create or fetch the content zones
  62. content: this.querySelector('coral-tree-item-content') || document.createElement('coral-tree-item-content')
  63. };
  64. treeItem.call(this._elements, {Icon, commons});
  65.  
  66. if (!this._elements.icon) {
  67. this._elements.icon = this._elements.header.querySelector('._coral-TreeView-indicator');
  68. }
  69.  
  70. // Tells the collection to automatically detect the items and handle the events
  71. this.items._startHandlingItems();
  72. }
  73.  
  74. /**
  75. The parent tree. Returns <code>null</code> if item is the root.
  76.  
  77. @type {HTMLElement}
  78. @readonly
  79. */
  80. get parent() {
  81. return this._parent || null;
  82. }
  83.  
  84. /**
  85. The content of this tree item.
  86.  
  87. @type {TreeItemContent}
  88. @contentzone
  89. */
  90. get content() {
  91. return this._getContentZone(this._elements.content);
  92. }
  93.  
  94. set content(value) {
  95. this._setContentZone('content', value, {
  96. handle: 'content',
  97. tagName: 'coral-tree-item-content',
  98. insert: function (content) {
  99. this._elements.header.appendChild(content);
  100. }
  101. });
  102. }
  103.  
  104. /**
  105. The Collection Interface that allows interacting with the items that the component contains.
  106.  
  107. @type {Collection}
  108. @readonly
  109. */
  110. get items() {
  111. // Construct the collection on first request
  112. if (!this._items) {
  113. this._items = new Collection({
  114. host: this,
  115. itemTagName: 'coral-tree-item',
  116. itemSelector: ':scope > coral-tree-item',
  117. onlyHandleChildren: true,
  118. container: this._elements.subTreeContainer,
  119. filter: this._filterItem.bind(this),
  120. onItemAdded: this._onItemAdded,
  121. onItemRemoved: this._onItemRemoved
  122. });
  123. }
  124.  
  125. return this._items;
  126. }
  127.  
  128. /**
  129. Whether the item is expanded. Expanded cannot be set to <code>true</code> if the item is disabled.
  130.  
  131. @type {Boolean}
  132. @default false
  133. @htmlattribute expanded
  134. @htmlattributereflected
  135. */
  136. get expanded() {
  137. return this._expanded || false;
  138. }
  139.  
  140. set expanded(value) {
  141. value = transform.booleanAttr(value);
  142. const triggerEvent = this.expanded !== value;
  143.  
  144. this._expanded = value;
  145. this._reflectAttribute('expanded', this._expanded);
  146.  
  147. const header = this._elements.header;
  148. const subTreeContainer = this._elements.subTreeContainer;
  149.  
  150. this.classList.toggle('is-open', this._expanded);
  151. this.classList.toggle('is-collapsed', !this._expanded);
  152.  
  153. if (this.variant !== variant.DRILLDOWN) {
  154. header.removeAttribute('aria-expanded');
  155. header.removeAttribute('aria-owns');
  156. } else if (this.items.length > 0) {
  157. header.setAttribute('aria-expanded', this._expanded);
  158. header.setAttribute('aria-owns', subTreeContainer.id);
  159. }
  160.  
  161. if (this._expanded) {
  162. subTreeContainer.removeAttribute('aria-hidden');
  163. } else {
  164. subTreeContainer.setAttribute('aria-hidden', !this._expanded);
  165. }
  166.  
  167. if (IS_TOUCH_DEVICE) {
  168. const icon = header.querySelector('._coral-TreeView-indicator');
  169. icon.setAttribute('aria-label', i18n.get(this._expanded ? 'Collapse' : 'Expand'));
  170. }
  171.  
  172. this.trigger('coral-tree-item:_expandedchanged');
  173.  
  174. // Do animation in next frame to avoid a forced reflow
  175. window.requestAnimationFrame(() => {
  176. // Don't animate on initialization
  177. if (this._animate) {
  178. // Remove height as we want the drawer to naturally grow if content is added later
  179. commons.transitionEnd(subTreeContainer, () => {
  180. if (this.expanded) {
  181. subTreeContainer.style.height = '';
  182. } else {
  183. subTreeContainer.hidden = true;
  184. }
  185.  
  186. // Trigger once the animation is over to inform coral-tree
  187. if (triggerEvent) {
  188. this.trigger('coral-tree-item:_afterexpandedchanged');
  189. }
  190. });
  191.  
  192. // Force height to enable transition
  193. if (!this.expanded) {
  194. subTreeContainer.style.height = `${subTreeContainer.scrollHeight}px`;
  195. } else {
  196. subTreeContainer.hidden = false;
  197. }
  198.  
  199. // We read the offset height to force a reflow, this is needed to start the transition between absolute values
  200. // https://blog.alexmaccaw.com/css-transitions under Redrawing
  201. // eslint-disable-next-line no-unused-vars
  202. const offsetHeight = subTreeContainer.offsetHeight;
  203.  
  204. subTreeContainer.style.height = this.expanded ? `${subTreeContainer.scrollHeight}px` : 0;
  205. } else {
  206. // Make sure it's animated next time
  207. this._animate = true;
  208.  
  209. // Hide it on initialization if closed
  210. if (!this.expanded) {
  211. subTreeContainer.style.height = 0;
  212. subTreeContainer.hidden = true;
  213. }
  214. }
  215. });
  216. }
  217.  
  218. /**
  219. The item's variant. See {@link TreeItemVariantEnum}.
  220.  
  221. @type {String}
  222. @default TreeItemVariant.DRILLDOWN
  223. @htmlattribute variant
  224. @htmlattributereflected
  225. */
  226. get variant() {
  227. return this._variant || variant.DRILLDOWN;
  228. }
  229.  
  230. set variant(value) {
  231. value = transform.string(value).toLowerCase();
  232. this._variant = validate.enumeration(variant, value) && value || variant.DRILLDOWN;
  233.  
  234. // removes every existing variant
  235. this.classList.remove(...ALL_VARIANT_CLASSES);
  236. this.classList.add(`${CLASSNAME}--${this._variant}`);
  237. }
  238.  
  239. /**
  240. Whether the item is selected.
  241.  
  242. @type {Boolean}
  243. @default false
  244. @htmlattribute selected
  245. @htmlattributereflected
  246. */
  247. get selected() {
  248. return this._selected || false;
  249. }
  250.  
  251. set selected(value) {
  252. this._selected = transform.booleanAttr(value);
  253. this._reflectAttribute('selected', this._selected);
  254.  
  255. this._elements.header.classList.toggle('is-selected', this._selected);
  256. this._elements.header.setAttribute('aria-selected', this._selected);
  257.  
  258. const selectedState = this._elements.selectedState;
  259. selectedState.textContent = i18n.get(this._selected ? 'selected' : 'not selected');
  260.  
  261. if (IS_TOUCH_DEVICE) {
  262. selectedState.setAttribute('aria-pressed', this._selected);
  263. }
  264.  
  265. this.trigger('coral-tree-item:_selectedchanged');
  266. }
  267.  
  268. /**
  269. Whether this item is disabled.
  270.  
  271. @type {Boolean}
  272. @default false
  273. @htmlattribute disabled
  274. @htmlattributereflected
  275. */
  276. get disabled() {
  277. return this._disabled || false;
  278. }
  279.  
  280. set disabled(value) {
  281. this._disabled = transform.booleanAttr(value);
  282. this._reflectAttribute('disabled', this._disabled);
  283.  
  284. this._elements.header.classList.toggle('is-disabled', this._disabled);
  285. this._elements.header[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
  286.  
  287. this.trigger('coral-tree-item:_disabledchanged');
  288. }
  289.  
  290. /**
  291. @ignore
  292. */
  293. get hidden() {
  294. return this.hasAttribute('hidden');
  295. }
  296.  
  297. set hidden(value) {
  298. this._reflectAttribute('hidden', transform.booleanAttr(value));
  299.  
  300. // We redefine hidden to trigger an event
  301. this.trigger('coral-tree-item:_hiddenchanged');
  302. }
  303.  
  304. /** @private */
  305. _filterItem(item) {
  306. // Handle nesting check for parent tree item
  307. // Use parentNode for added items
  308. // Use _parent for removed items
  309. return item.parentNode && item.parentNode.parentNode === this || item._parent === this;
  310. }
  311.  
  312. /** @private */
  313. _onItemAdded(item) {
  314. item._parent = this;
  315.  
  316. const header = this._elements.header;
  317. const subTreeContainer = this._elements.subTreeContainer;
  318. if (!header.hasAttribute('aria-owns')) {
  319. header.setAttribute('aria-owns', subTreeContainer.id);
  320. }
  321. }
  322.  
  323. /** @private */
  324. _onItemRemoved(item) {
  325. item._parent = undefined;
  326.  
  327. // If there are no items the subTreeContainer
  328. if (!this.items.length) {
  329. this._elements.header.removeAttribute('aria-owns');
  330. }
  331. }
  332.  
  333. /**
  334. Handles the focus of the item.
  335.  
  336. @ignore
  337. */
  338. focus() {
  339. this._elements.header.setAttribute('tabindex', '0');
  340. this._elements.header.focus();
  341. }
  342.  
  343. /**
  344. Returns {@link TreeItem} variants.
  345.  
  346. @return {TreeItemVariantEnum}
  347. */
  348. static get variant() {
  349. return variant;
  350. }
  351.  
  352. get _contentZones() {
  353. return {'coral-tree-item-content': 'content'};
  354. }
  355.  
  356. /** @ignore */
  357. static get observedAttributes() {
  358. return super.observedAttributes.concat(['selected', 'disabled', 'variant', 'expanded', 'hidden']);
  359. }
  360.  
  361. /** @ignore */
  362. render() {
  363. super.render();
  364.  
  365. this.classList.add(CLASSNAME);
  366.  
  367. const header = this._elements.header;
  368. const subTreeContainer = this._elements.subTreeContainer;
  369. const content = this._elements.content;
  370. const selectedState = this._elements.selectedState;
  371.  
  372. // a11ys
  373. content.id = content.id || commons.getUID();
  374. this.setAttribute('role', 'presentation');
  375. header.setAttribute('aria-labelledby', `${content.id} ${selectedState.id}`);
  376. header.setAttribute('aria-selected', this.selected);
  377. subTreeContainer.setAttribute('aria-labelledby', content.id);
  378. selectedState.textContent = i18n.get(this.selected ? 'selected' : 'not selected');
  379.  
  380. if (IS_TOUCH_DEVICE) {
  381. const icon = this._elements.icon || header.querySelector('._coral-TreeView-indicator');
  382. if (icon && !icon.id) {
  383. icon.id = commons.getUID();
  384. }
  385. icon.setAttribute('role', 'button');
  386. icon.setAttribute('tabindex', '-1');
  387. icon.setAttribute('aria-labelledby', icon.id + ' ' + content.id);
  388. icon.setAttribute('aria-label', i18n.get(this.expanded ? 'Collapse' : 'Expand'));
  389. icon.setAttribute('style', 'outline: none !important');
  390. icon.removeAttribute('aria-hidden');
  391.  
  392. selectedState.setAttribute('role', 'button');
  393. selectedState.setAttribute('tabindex', '-1');
  394. selectedState.setAttribute('aria-labelledby', content.id + ' ' + selectedState.id);
  395. selectedState.setAttribute('aria-pressed', this.selected);
  396. selectedState.setAttribute('style', 'outline: none !important');
  397. }
  398.  
  399. // Default reflected attributes
  400. if (!this._variant) {
  401. this.variant = variant.DRILLDOWN;
  402. }
  403. this.expanded = this.expanded;
  404.  
  405. // Render the template and set element references
  406. const frag = document.createDocumentFragment();
  407.  
  408. const templateHandleNames = ['header', 'icon', 'subTreeContainer'];
  409.  
  410. const subTree = this.querySelector('._coral-TreeView');
  411. if (subTree) {
  412. const items = subTree.querySelectorAll('coral-tree-item');
  413. for (let i = 0 ; i < items.length ; i++) {
  414. subTreeContainer.appendChild(items[i]);
  415. }
  416. }
  417.  
  418. // Add templates into the frag
  419. frag.appendChild(header);
  420. frag.appendChild(subTreeContainer);
  421.  
  422. // Assign the content zones, moving them into place in the process
  423. this.content = content;
  424.  
  425. // Move any remaining elements into the content sub-component
  426. while (this.firstChild) {
  427. const child = this.firstChild;
  428. if (child.nodeName === 'CORAL-TREE-ITEM') {
  429. // Adding parent attribute to access the parent directly
  430. child._parent = this;
  431. // Add tree items to the sub tree container
  432. subTreeContainer.appendChild(child);
  433. } else if (child.nodeType === Node.TEXT_NODE ||
  434. child.nodeType === Node.ELEMENT_NODE && templateHandleNames.indexOf(child.getAttribute('handle')) === -1) {
  435. // Add non-template elements to the content
  436. content.appendChild(child);
  437. } else {
  438. // Remove anything else element
  439. this.removeChild(child);
  440. }
  441. }
  442.  
  443. if (this.variant === variant.DRILLDOWN && this.items.length && !header.hasAttribute('aria-owns')) {
  444. header.setAttribute('aria-owns', subTreeContainer.id);
  445. }
  446.  
  447. // Lastly, add the fragment into the container
  448. this.appendChild(frag);
  449. }
  450.  
  451. /**
  452. Triggered when {@link TreeItem#selected} changed.
  453.  
  454. @typedef {CustomEvent} coral-tree-item:_selectedchanged
  455.  
  456. @private
  457. */
  458.  
  459. /**
  460. Triggered when {@link TreeItem#expanded} changed.
  461.  
  462. @typedef {CustomEvent} coral-tree-item:_expandedchanged
  463.  
  464. @private
  465. */
  466.  
  467. /**
  468. Triggered when {@link TreeItem#hidden} changed.
  469.  
  470. @typedef {CustomEvent} coral-tree-item:_hiddenchanged
  471.  
  472. @private
  473. */
  474.  
  475. /**
  476. Triggered when {@link TreeItem#disabled} changed.
  477.  
  478. @typedef {CustomEvent} coral-tree-item:_disabledchanged
  479.  
  480. @private
  481. */
  482. });
  483.  
  484. export default TreeItem;