ExamplesPlaygroundReference Source

coral-spectrum/coral-component-sidenav/src/scripts/SideNav.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 {SelectableCollection} from '../../../coral-collection';
  15. import {transform, validate, commons} from '../../../coral-utils';
  16. import {Decorator} from '../../../coral-decorator';
  17.  
  18. const CLASSNAME = '_coral-SideNav';
  19.  
  20. const isLevel = node => node.nodeName === 'CORAL-SIDENAV-LEVEL';
  21. const isHeading = node => node.nodeName === 'CORAL-SIDENAV-HEADING';
  22. const isItem = node => node.nodeName === 'A' && node.getAttribute('is') === 'coral-sidenav-item';
  23.  
  24. /**
  25. Enumeration for {@link SideNav} variants.
  26.  
  27. @typedef {Object} SideNavVariantEnum
  28.  
  29. @property {String} DEFAULT
  30. A default sidenav.
  31. @property {String} MULTI_LEVEL
  32. A sidenav with multiple levels of indentation.
  33. */
  34. const variant = {
  35. DEFAULT: 'default',
  36. MULTI_LEVEL: 'multilevel',
  37. };
  38.  
  39. /**
  40. @class Coral.SideNav
  41. @classdesc A Side Navigation component to navigate the entire content of a product or a section.
  42. These can be used for a single level or a multi-level navigation.
  43. @htmltag coral-sidenav
  44. @extends {HTMLElement}
  45. @extends {BaseComponent}
  46. */
  47. const SideNav = Decorator(class extends BaseComponent(HTMLElement) {
  48. /** @ignore */
  49. constructor() {
  50. super();
  51.  
  52. // Attach events
  53. this._delegateEvents({
  54. // Interaction
  55. 'click a[is="coral-sidenav-item"]': '_onItemClick',
  56.  
  57. // Accessibility
  58. 'capture:focus a[is="coral-sidenav-item"].focus-ring': '_onItemFocusIn',
  59. 'capture:blur a[is="coral-sidenav-item"]': '_onItemFocusOut',
  60.  
  61. // Private
  62. 'coral-sidenav-item:_selectedchanged': '_onItemSelectedChanged'
  63. });
  64.  
  65. // Used for eventing
  66. this._oldSelection = null;
  67.  
  68. // Level Collection
  69. this._levels = this.getElementsByTagName('coral-sidenav-level');
  70.  
  71. // Heading Collection
  72. this._headings = this.getElementsByTagName('coral-sidenav-heading');
  73.  
  74. // Init the collection mutation observer
  75. this.items._startHandlingItems(true);
  76.  
  77. // Initialize content MO
  78. this._observer = new MutationObserver(this._handleMutations.bind(this));
  79. this._observer.observe(this, {
  80. childList: true,
  81. subtree: true
  82. });
  83. }
  84.  
  85. /**
  86. The Collection Interface that allows interacting with the items that the component contains.
  87.  
  88. @type {Collection}
  89. @readonly
  90. */
  91. get items() {
  92. // just init on demand
  93. if (!this._items) {
  94. this._items = new SelectableCollection({
  95. host: this,
  96. itemTagName: 'coral-sidenav-item',
  97. itemBaseTagName: 'a',
  98. onItemAdded: this._validateSelection,
  99. onItemRemoved: this._validateSelection
  100. });
  101. }
  102. return this._items;
  103. }
  104.  
  105. /**
  106. Returns the first selected item in the sidenav. The value <code>null</code> is returned if no element is
  107. selected.
  108.  
  109. @type {SideNavItem}
  110. @readonly
  111. */
  112. get selectedItem() {
  113. return this.items._getFirstSelected();
  114. }
  115.  
  116. /**
  117. The sidenav's variant. See {@link SideNavVariantEnum}.
  118.  
  119. @type {String}
  120. @default SideNavVariantEnum.DEFAULT
  121. @htmlattribute variant
  122. @htmlattributereflected
  123. */
  124. get variant() {
  125. return this._variant || variant.DEFAULT;
  126. }
  127.  
  128. set variant(value) {
  129. value = transform.string(value).toLowerCase();
  130. this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT;
  131. this._reflectAttribute('variant', this._variant);
  132.  
  133. this.classList.toggle(`${CLASSNAME}--multiLevel`, this._variant === variant.MULTI_LEVEL);
  134.  
  135. if (this.variant === variant.MULTI_LEVEL) {
  136. // Don't hide the selected item level
  137. const selectedItem = this.selectedItem;
  138. const ignoreLevel = selectedItem && selectedItem.parentNode;
  139.  
  140. // Hide every other level that doesn't contain the selected item
  141. for (let i = 0 ; i < this._levels.length ; i++) {
  142. if (this._levels[i] !== ignoreLevel) {
  143. this._levels[i].setAttribute('_expanded', 'off');
  144. }
  145. }
  146. }
  147. }
  148.  
  149. _onItemClick(event) {
  150. const item = event.matchedTarget;
  151.  
  152. if (!item.selected) {
  153. item.selected = true;
  154. }
  155. }
  156.  
  157. _onItemFocusIn(event) {
  158. const item = event.matchedTarget;
  159.  
  160. item._elements.container.classList.add('focus-ring');
  161. }
  162.  
  163. _onItemFocusOut(event) {
  164. const item = event.matchedTarget;
  165.  
  166. item._elements.container.classList.remove('focus-ring');
  167. }
  168.  
  169. _onItemSelectedChanged(event) {
  170. event.stopImmediatePropagation();
  171.  
  172. this._validateSelection(event.target);
  173. }
  174.  
  175. _validateSelection(item) {
  176. const selectedItems = this.items._getAllSelected();
  177. // Last selected item wins if multiple selection while not allowed
  178. item = item || selectedItems[selectedItems.length - 1];
  179.  
  180. // Deselect other selected items
  181. if (item && item.hasAttribute('selected') && selectedItems.length > 1) {
  182. selectedItems.forEach((selectedItem) => {
  183. if (selectedItem !== item) {
  184. // Don't trigger change events
  185. this._preventTriggeringEvents = true;
  186. selectedItem.removeAttribute('selected');
  187. }
  188. });
  189.  
  190. // We can trigger change events again
  191. this._preventTriggeringEvents = false;
  192. }
  193.  
  194. // Expand multi level
  195. this._expandLevels();
  196.  
  197. // Notify of change
  198. this._triggerChangeEvent();
  199. }
  200.  
  201. _expandLevels() {
  202. const selectedItem = this.selectedItem;
  203. if (selectedItem) {
  204. let level = selectedItem.closest('coral-sidenav-level');
  205.  
  206. // Expand until root
  207. while (level) {
  208. level.setAttribute('_expanded', 'on');
  209.  
  210. const prev = level.previousElementSibling;
  211. if (prev && prev.matches('a[is="coral-sidenav-item"]')) {
  212. prev.setAttribute('aria-expanded', 'true');
  213. }
  214.  
  215. level = level.parentNode && level.parentNode.closest('coral-sidenav-level');
  216. }
  217.  
  218. // Expand corresponding item level
  219. level = selectedItem.nextElementSibling;
  220. if (level && level.tagName === 'CORAL-SIDENAV-LEVEL') {
  221. level.setAttribute('_expanded', 'on');
  222. selectedItem.setAttribute('aria-expanded', 'true');
  223. }
  224. }
  225. }
  226.  
  227. _triggerChangeEvent() {
  228. const selectedItem = this.selectedItem;
  229. const oldSelection = this._oldSelection;
  230.  
  231. if (!this._preventTriggeringEvents && selectedItem !== oldSelection) {
  232. this.trigger('coral-sidenav:change', {
  233. oldSelection: oldSelection,
  234. selection: selectedItem
  235. });
  236.  
  237. this._oldSelection = selectedItem;
  238. }
  239. }
  240.  
  241. _syncLevel(el, isRemoved) {
  242. if (isRemoved) {
  243. if (el.id && isLevel(el)) {
  244. const item = this.querySelector(`a[is="coral-sidenav-item"][aria-controls="${el.id}"]`);
  245. item && item.removeAttribute('aria-controls');
  246. } else if (el.id && (isHeading(el) || isItem(el))) {
  247. const level = this.querySelector(`coral-sidenav-level[aria-labelledby="${el.id}"]`);
  248. level && level.removeAttribute('aria-labelledby');
  249. this._syncLevel(level);
  250. }
  251. } else if (isLevel(el)) {
  252. const prev = el.previousElementSibling;
  253. if (prev && (isHeading(prev) || isItem(prev))) {
  254. prev.id = prev.id || commons.getUID();
  255. el.setAttribute('aria-labelledby', prev.id);
  256.  
  257. if (isItem(prev)) {
  258. el.id = el.id || commons.getUID();
  259. prev.setAttribute('aria-controls', el.id);
  260. }
  261. }
  262. } else if (isHeading(el) || isItem(el)) {
  263. const next = el.nextElementSibling;
  264. if (next && isLevel(next)) {
  265. el.id = el.id || commons.getUID();
  266. next.setAttribute('aria-labelledby', el.id);
  267.  
  268. if (isItem(el)) {
  269. next.id = next.id || commons.getUID();
  270. el.setAttribute('aria-controls', next.id);
  271. }
  272. }
  273. }
  274. }
  275.  
  276. _syncHeading(heading) {
  277. heading.classList.add(`${CLASSNAME}-heading`);
  278. heading.setAttribute('role', 'heading');
  279. }
  280.  
  281. _handleMutations(mutations) {
  282. mutations.forEach((mutation) => {
  283. // Sync added levels and headings
  284. for (let i = 0 ; i < mutation.addedNodes.length ; i++) {
  285. const addedNode = mutation.addedNodes[i];
  286.  
  287. // a11y
  288. this._syncLevel(addedNode);
  289.  
  290. // a11y
  291. if (isHeading(addedNode)) {
  292. this._syncHeading(addedNode);
  293. }
  294.  
  295. if (isLevel(addedNode)) {
  296. this._validateSelection(addedNode.querySelector('a[is="coral-sidenav-item"][selected]'));
  297. }
  298. }
  299.  
  300. // Sync removed levels
  301. for (let k = 0 ; k < mutation.removedNodes.length ; k++) {
  302. const removedNode = mutation.removedNodes[k];
  303.  
  304. this._syncLevel(removedNode, true);
  305.  
  306. if (isLevel(removedNode)) {
  307. this._validateSelection();
  308. }
  309. }
  310. });
  311. }
  312.  
  313. /**
  314. Returns {@link SideNav} variants.
  315.  
  316. @return {SideNavVariantEnum}
  317. */
  318. static get variant() {
  319. return variant;
  320. }
  321.  
  322. /** @ignore */
  323. static get observedAttributes() {
  324. return super.observedAttributes.concat(['variant']);
  325. }
  326.  
  327. /** @ignore */
  328. render() {
  329. super.render();
  330.  
  331. this.classList.add(CLASSNAME);
  332.  
  333. // Default reflected attributes
  334. if (!this._variant) {
  335. this.variant = variant.DEFAULT;
  336. }
  337.  
  338. // a11y
  339. for (let i = 0 ; i < this._levels.length ; i++) {
  340. this._syncLevel(this._levels[i]);
  341. }
  342.  
  343. // a11y
  344. for (let i = 0 ; i < this._headings.length ; i++) {
  345. this._syncHeading(this._headings[i]);
  346. }
  347.  
  348. // Don't trigger events once connected
  349. this._preventTriggeringEvents = true;
  350. this._validateSelection();
  351. this._preventTriggeringEvents = false;
  352.  
  353. this._oldSelection = this.selectedItem;
  354. }
  355.  
  356. /**
  357. Triggered when {@link SideNav} selected item has changed.
  358.  
  359. @typedef {CustomEvent} coral-sidenav:change
  360.  
  361. @property {SideNavItem} detail.oldSelection
  362. The prior selected item.
  363. @property {SideNavItem} detail.selection
  364. The newly selected item.
  365. */
  366. });
  367.  
  368. export default SideNav;