coral-spectrum/coral-component-sidenav/src/scripts/SideNav.js
/**
* Copyright 2019 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {BaseComponent} from '../../../coral-base-component';
import {SelectableCollection} from '../../../coral-collection';
import {transform, validate, commons} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';
const CLASSNAME = '_coral-SideNav';
const isLevel = node => node.nodeName === 'CORAL-SIDENAV-LEVEL';
const isHeading = node => node.nodeName === 'CORAL-SIDENAV-HEADING';
const isItem = node => node.nodeName === 'A' && node.getAttribute('is') === 'coral-sidenav-item';
/**
Enumeration for {@link SideNav} variants.
@typedef {Object} SideNavVariantEnum
@property {String} DEFAULT
A default sidenav.
@property {String} MULTI_LEVEL
A sidenav with multiple levels of indentation.
*/
const variant = {
DEFAULT: 'default',
MULTI_LEVEL: 'multilevel',
};
/**
@class Coral.SideNav
@classdesc A Side Navigation component to navigate the entire content of a product or a section.
These can be used for a single level or a multi-level navigation.
@htmltag coral-sidenav
@extends {HTMLElement}
@extends {BaseComponent}
*/
const SideNav = Decorator(class extends BaseComponent(HTMLElement) {
/** @ignore */
constructor() {
super();
// Attach events
this._delegateEvents({
// Interaction
'click a[is="coral-sidenav-item"]': '_onItemClick',
// Accessibility
'capture:focus a[is="coral-sidenav-item"].focus-ring': '_onItemFocusIn',
'capture:blur a[is="coral-sidenav-item"]': '_onItemFocusOut',
// Private
'coral-sidenav-item:_selectedchanged': '_onItemSelectedChanged'
});
// Used for eventing
this._oldSelection = null;
// Level Collection
this._levels = this.getElementsByTagName('coral-sidenav-level');
// Heading Collection
this._headings = this.getElementsByTagName('coral-sidenav-heading');
// Init the collection mutation observer
this.items._startHandlingItems(true);
// Initialize content MO
this._observer = new MutationObserver(this._handleMutations.bind(this));
this._observer.observe(this, {
childList: true,
subtree: true
});
}
/**
The Collection Interface that allows interacting with the items that the component contains.
@type {Collection}
@readonly
*/
get items() {
// just init on demand
if (!this._items) {
this._items = new SelectableCollection({
host: this,
itemTagName: 'coral-sidenav-item',
itemBaseTagName: 'a',
onItemAdded: this._validateSelection,
onItemRemoved: this._validateSelection
});
}
return this._items;
}
/**
Returns the first selected item in the sidenav. The value <code>null</code> is returned if no element is
selected.
@type {SideNavItem}
@readonly
*/
get selectedItem() {
return this.items._getFirstSelected();
}
/**
The sidenav's variant. See {@link SideNavVariantEnum}.
@type {String}
@default SideNavVariantEnum.DEFAULT
@htmlattribute variant
@htmlattributereflected
*/
get variant() {
return this._variant || variant.DEFAULT;
}
set variant(value) {
value = transform.string(value).toLowerCase();
this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT;
this._reflectAttribute('variant', this._variant);
this.classList.toggle(`${CLASSNAME}--multiLevel`, this._variant === variant.MULTI_LEVEL);
if (this.variant === variant.MULTI_LEVEL) {
// Don't hide the selected item level
const selectedItem = this.selectedItem;
const ignoreLevel = selectedItem && selectedItem.parentNode;
// Hide every other level that doesn't contain the selected item
for (let i = 0 ; i < this._levels.length ; i++) {
if (this._levels[i] !== ignoreLevel) {
this._levels[i].setAttribute('_expanded', 'off');
}
}
}
}
_onItemClick(event) {
const item = event.matchedTarget;
if (!item.selected) {
item.selected = true;
}
}
_onItemFocusIn(event) {
const item = event.matchedTarget;
item._elements.container.classList.add('focus-ring');
}
_onItemFocusOut(event) {
const item = event.matchedTarget;
item._elements.container.classList.remove('focus-ring');
}
_onItemSelectedChanged(event) {
event.stopImmediatePropagation();
this._validateSelection(event.target);
}
_validateSelection(item) {
const selectedItems = this.items._getAllSelected();
// Last selected item wins if multiple selection while not allowed
item = item || selectedItems[selectedItems.length - 1];
// Deselect other selected items
if (item && item.hasAttribute('selected') && selectedItems.length > 1) {
selectedItems.forEach((selectedItem) => {
if (selectedItem !== item) {
// Don't trigger change events
this._preventTriggeringEvents = true;
selectedItem.removeAttribute('selected');
}
});
// We can trigger change events again
this._preventTriggeringEvents = false;
}
// Expand multi level
this._expandLevels();
// Notify of change
this._triggerChangeEvent();
}
_expandLevels() {
const selectedItem = this.selectedItem;
if (selectedItem) {
let level = selectedItem.closest('coral-sidenav-level');
// Expand until root
while (level) {
level.setAttribute('_expanded', 'on');
const prev = level.previousElementSibling;
if (prev && prev.matches('a[is="coral-sidenav-item"]')) {
prev.setAttribute('aria-expanded', 'true');
}
level = level.parentNode && level.parentNode.closest('coral-sidenav-level');
}
// Expand corresponding item level
level = selectedItem.nextElementSibling;
if (level && level.tagName === 'CORAL-SIDENAV-LEVEL') {
level.setAttribute('_expanded', 'on');
selectedItem.setAttribute('aria-expanded', 'true');
}
}
}
_triggerChangeEvent() {
const selectedItem = this.selectedItem;
const oldSelection = this._oldSelection;
if (!this._preventTriggeringEvents && selectedItem !== oldSelection) {
this.trigger('coral-sidenav:change', {
oldSelection: oldSelection,
selection: selectedItem
});
this._oldSelection = selectedItem;
}
}
_syncLevel(el, isRemoved) {
if (isRemoved) {
if (el.id && isLevel(el)) {
const item = this.querySelector(`a[is="coral-sidenav-item"][aria-controls="${el.id}"]`);
item && item.removeAttribute('aria-controls');
} else if (el.id && (isHeading(el) || isItem(el))) {
const level = this.querySelector(`coral-sidenav-level[aria-labelledby="${el.id}"]`);
level && level.removeAttribute('aria-labelledby');
this._syncLevel(level);
}
} else if (isLevel(el)) {
const prev = el.previousElementSibling;
if (prev && (isHeading(prev) || isItem(prev))) {
prev.id = prev.id || commons.getUID();
el.setAttribute('aria-labelledby', prev.id);
if (isItem(prev)) {
el.id = el.id || commons.getUID();
prev.setAttribute('aria-controls', el.id);
}
}
} else if (isHeading(el) || isItem(el)) {
const next = el.nextElementSibling;
if (next && isLevel(next)) {
el.id = el.id || commons.getUID();
next.setAttribute('aria-labelledby', el.id);
if (isItem(el)) {
next.id = next.id || commons.getUID();
el.setAttribute('aria-controls', next.id);
}
}
}
}
_syncHeading(heading) {
heading.classList.add(`${CLASSNAME}-heading`);
heading.setAttribute('role', 'heading');
}
_handleMutations(mutations) {
mutations.forEach((mutation) => {
// Sync added levels and headings
for (let i = 0 ; i < mutation.addedNodes.length ; i++) {
const addedNode = mutation.addedNodes[i];
// a11y
this._syncLevel(addedNode);
// a11y
if (isHeading(addedNode)) {
this._syncHeading(addedNode);
}
if (isLevel(addedNode)) {
this._validateSelection(addedNode.querySelector('a[is="coral-sidenav-item"][selected]'));
}
}
// Sync removed levels
for (let k = 0 ; k < mutation.removedNodes.length ; k++) {
const removedNode = mutation.removedNodes[k];
this._syncLevel(removedNode, true);
if (isLevel(removedNode)) {
this._validateSelection();
}
}
});
}
/**
Returns {@link SideNav} variants.
@return {SideNavVariantEnum}
*/
static get variant() {
return variant;
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat(['variant']);
}
/** @ignore */
render() {
super.render();
this.classList.add(CLASSNAME);
// Default reflected attributes
if (!this._variant) {
this.variant = variant.DEFAULT;
}
// a11y
for (let i = 0 ; i < this._levels.length ; i++) {
this._syncLevel(this._levels[i]);
}
// a11y
for (let i = 0 ; i < this._headings.length ; i++) {
this._syncHeading(this._headings[i]);
}
// Don't trigger events once connected
this._preventTriggeringEvents = true;
this._validateSelection();
this._preventTriggeringEvents = false;
this._oldSelection = this.selectedItem;
}
/**
Triggered when {@link SideNav} selected item has changed.
@typedef {CustomEvent} coral-sidenav:change
@property {SideNavItem} detail.oldSelection
The prior selected item.
@property {SideNavItem} detail.selection
The newly selected item.
*/
});
export default SideNav;