UNPKG

@adobe/coral-spectrum

Version:

Coral Spectrum is a JavaScript library of Web Components following Spectrum design patterns.

521 lines (425 loc) 14.7 kB
/** * 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 line from '../templates/line'; import getTarget from './getTarget'; /** Enumeration for {@link TabList} sizes. @typedef {Object} TabListSizeEnum @property {String} SMALL A small-sized tablist. @property {String} MEDIUM A medium-sized tablist. This is the default. @property {String} LARGE A large-sized tablist. */ const size = { SMALL: 'S', MEDIUM: 'M', LARGE: 'L' }; /** Enumeration for {@link TabList} orientations. @typedef {Object} TabListOrientationEnum @property {String} HORIZONTAL Horizontal TabList, this is the default value. @property {String} VERTICAL Vertical TabList. */ const orientation = { HORIZONTAL: 'horizontal', VERTICAL: 'vertical' }; // the tablist's base classname const CLASSNAME = '_coral-Tabs'; /** @class Coral.TabList @classdesc A TabList component holds a collection of tabs. @htmltag coral-tablist @extends {HTMLElement} @extends {BaseComponent} */ class TabList extends BaseComponent(HTMLElement) { /** @ignore */ constructor() { super(); // Templates this._elements = {}; line.call(this._elements); // Attach events this._delegateEvents({ 'click > coral-tab': '_onTabClick', 'key:home > coral-tab': '_onHomeKey', 'key:end > coral-tab': '_onEndKey', 'key:pagedown > coral-tab': '_selectNextItem', 'key:right > coral-tab': '_selectNextItem', 'key:down > coral-tab': '_selectNextItem', 'key:pageup > coral-tab': '_selectPreviousItem', 'key:left > coral-tab': '_selectPreviousItem', 'key:up > coral-tab': '_selectPreviousItem', 'global:coral-commons:_webfontactive': '_setLine', // private 'coral-tab:_selectedchanged': '_onItemSelectedChanged', 'coral-tab:_validateselection': '_onValidateSelection', 'coral-tab:_sizechanged': '_setLine' }); // Used for eventing this._oldSelection = null; // Debounce timer this._timeout = null; // Debounce wait in milliseconds this._wait = 50; this._setLine = this._setLine.bind(this); // Init the collection mutation observer this.items._startHandlingItems(true); } /** The Collection Interface that allows interacting with the items that the component contains. @type {SelectableCollection} @readonly */ get items() { // just init on demand if (!this._items) { this._items = new SelectableCollection({ host: this, itemTagName: 'coral-tab', onItemAdded: this._onItemAdded, onItemRemoved: this._onItemRemoved }); } return this._items; } /** The selected item in the TabList. @type {HTMLElement} @readonly */ get selectedItem() { return this.items._getLastSelected(); } /** The target component that will be linked to the TabList. It accepts either a CSS selector or a DOM element. If a CSS Selector is provided, the first matching element will be used. Items will be selected based on the index. If both target and {@link Coral.Tab#target} are set, the second will have higher priority. @type {?HTMLElement|String} @default null @htmlattribute target */ get target() { return typeof this._target === 'string' ? this._target : this._target || null; } set target(value) { if (value === null || typeof value === 'string' || value instanceof Node) { this._target = value; window.cancelAnimationFrame(this._targetDebouncedId); // we do in case the target was not yet in the DOM this._targetDebouncedId = window.requestAnimationFrame(() => { const realTarget = getTarget(this._target); // we add proper accessibility if available if (realTarget) { const tabItems = this.items.getAll(); const panelItems = realTarget.items ? realTarget.items.getAll() : realTarget.children; // we need to add a11y to all component, no matter if they can be perfectly paired const maxItems = Math.max(tabItems.length, panelItems.length); for (let i = 0 ; i < maxItems ; i++) { let tab = tabItems[i]; let panel = panelItems[i]; // if the tab has its own target, we assume the target component will handle its own accessibility. if the // target is an empty string we simply ignore it if (tab && tab.target && tab.target.trim() !== '') { continue; } if (tab && panel) { // sets the required ids tab.id = tab.id || commons.getUID(); panel.id = panel.id || commons.getUID(); // creates a 2 way binding for accessibility tab.setAttribute('aria-controls', panel.id); panel.setAttribute('aria-labelledby', tab.id); // adds role to panel to support accessibility panel.setAttribute('role', 'tabpanel'); } else if (tab) { // cleans the aria since there is no matching panel tab.removeAttribute('aria-controls'); } else { // cleans the aria since there is no matching tab panel.removeAttribute('aria-labelledby'); } } } }); } } /** The size of the TabList. It accepts both lower and upper case sizes. Currently only "M" (the default) and "L" are available. See {@link TabListSizeEnum}. @type {String} @default TabListSizeEnum.MEDIUM @htmlattribute size @htmlattributereflected */ get size() { return this._size || size.MEDIUM; } set size(value) { value = transform.string(value).toUpperCase(); this._size = validate.enumeration(size)(value) && value || size.MEDIUM; this._reflectAttribute('size', this._size); // Remove all variant classes this.classList.remove(`${CLASSNAME}--compact`, `${CLASSNAME}--quiet`); if (this._size === size.SMALL) { this.classList.add(`${CLASSNAME}--compact`); } else if (this._size === size.LARGE) { this.classList.add(`${CLASSNAME}--quiet`); } } /** Orientation of the TabList. See {@link TabListOrientationEnum}. @type {String} @default TabListOrientationEnum.HORIZONTAL @htmlattribute orientation @htmlattributereflected */ get orientation() { return this._orientation || orientation.HORIZONTAL; } set orientation(value) { value = transform.string(value).toLowerCase(); const newValue = typeof this._orientation === 'undefined'; this._orientation = validate.enumeration(orientation)(value) && value || orientation.HORIZONTAL; if (newValue) { this._previousOrientation = this._orientation; } this._reflectAttribute('orientation', this._orientation); this.classList.toggle(`${CLASSNAME}--vertical`, this._orientation === orientation.VERTICAL); this.classList.toggle(`${CLASSNAME}--horizontal`, this._orientation === orientation.HORIZONTAL); this._setLine(); } /** @private */ _onItemAdded(item) { if (!this.selectedItem) { item.setAttribute('selected', ''); } else { this._validateSelection(item); } } /** @private */ _onItemRemoved() { if (!this.selectedItem) { this._selectFirstItem(); } } /** @private */ _onTabClick(event) { event.preventDefault(); const item = event.matchedTarget; this._toggleItemSelectionAndFocus(item); this._trackEvent('click', 'coral-tab', event, item); } /** @private */ _onHomeKey(event) { event.preventDefault(); const item = this.items._getFirstSelectable(); this._toggleItemSelectionAndFocus(item); } /** @private */ _onEndKey(event) { event.preventDefault(); const item = this.items._getLastSelectable(); this._toggleItemSelectionAndFocus(item); } /** @private */ _selectNextItem(event) { event.preventDefault(); const item = this.selectedItem; this._toggleItemSelectionAndFocus(this.items._getNextSelectable(item)); } /** @private */ _selectPreviousItem(event) { event.preventDefault(); const item = this.selectedItem; this._toggleItemSelectionAndFocus(this.items._getPreviousSelectable(item)); } /** @private */ _toggleItemSelectionAndFocus(item) { if (item && !item.hasAttribute('selected')) { item.setAttribute('selected', ''); item.focus(); } } /** @private */ _onItemSelectedChanged(event) { event.stopImmediatePropagation(); this._validateSelection(event.target); } /** @private */ _onValidateSelection(event) { event.stopImmediatePropagation(); this._validateSelection(); } /** @private */ _selectFirstItem() { const item = this.items._getFirstSelectable(); if (item) { item.setAttribute('selected', ''); } } /** @private */ _validateSelection(item) { const selectedItems = this.items._getAllSelected(); if (item) { // Deselected item if (!item.hasAttribute('selected') && !selectedItems.length) { const siblingItem = this.items._getNextSelectable(item); // Next selectable item is forced to be selected if selection is cleared if (item !== siblingItem) { siblingItem.setAttribute('selected', ''); } } // Selected item else if (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; } } else if (selectedItems.length > 1) { // If multiple items are selected, the last one wins item = selectedItems[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; } // First selectable item is forced to be selected if no selection at all else if (!selectedItems.length) { this._selectFirstItem(); } this._setLine(); this._triggerChangeEvent(); } _setLine() { window.requestAnimationFrame(() => { const selectedItem = this.selectedItem; // Position line under the selected item if (selectedItem) { if (this.orientation === orientation.HORIZONTAL) { const padding = window.parseInt(window.getComputedStyle(selectedItem).paddingLeft); const left = selectedItem.offsetLeft + padding; const width = selectedItem.clientWidth - padding * 2; // Orientation changed if (this._previousOrientation !== this.orientation) { this._elements.line.style.height = ''; } this._elements.line.style.width = `${width}px`; this._elements.line.style.transform = `translate(${left}px, 0)`; } else if (this.orientation === orientation.VERTICAL) { const top = selectedItem.offsetTop; const height = selectedItem.clientHeight; // Orientation changed if (this._previousOrientation !== this.orientation) { this._elements.line.style.width = ''; } this._elements.line.style.height = `${height}px`; this._elements.line.style.transform = `translate(0, ${top}px)`; } this._elements.line.hidden = false; } else { // Hide line if no selected item this._elements.line.hidden = true; } this._previousOrientation = this.orientation; }); } /** @private */ _triggerChangeEvent() { const selectedItem = this.selectedItem; const oldSelection = this._oldSelection; if (!this._preventTriggeringEvents && selectedItem !== oldSelection) { this.trigger('coral-tablist:change', { oldSelection: oldSelection, selection: selectedItem }); this._oldSelection = selectedItem; } } /** Returns {@link TabList} sizes. @return {TabListSizeEnum} */ static get size() { return size; } /** Returns {@link TabList} orientation options. @return {TabListOrientationEnum} */ static get orientation() { return orientation; } /** @ignore */ static get observedAttributes() { return super.observedAttributes.concat(['target', 'size', 'orientation']); } /** @ignore */ render() { super.render(); this.classList.add(CLASSNAME); // adds the role to support accessibility this.setAttribute('role', 'tablist'); this.setAttribute('aria-multiselectable', 'false'); // Default reflected attributes if (!this._size) { this.size = size.MEDIUM; } if (!this._orientation) { this.orientation = orientation.HORIZONTAL; } // Support cloneNode const template = this.querySelector('._coral-Tabs-selectionIndicator'); if (template) { template.remove(); } // Insert tab line this.appendChild(this._elements.line); // Don't trigger events once connected this._preventTriggeringEvents = true; this._validateSelection(); this._preventTriggeringEvents = false; this._oldSelection = this.selectedItem; // Display line once tabList is shown commons.addResizeListener(this, this._setLine); } /** Triggered when the {@link TabList} selected item has changed. @typedef {CustomEvent} coral-tablist:change @property {Tab} event.detail.oldSelection The prior selected item(s). @property {Tab} event.detail.selection The newly selected item(s). */ } export default TabList;