UNPKG

@adobe/coral-spectrum

Version:

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

581 lines (465 loc) 16.6 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 '../../../coral-component-wait'; import loadIndicator from '../templates/loadIndicator'; import {transform, i18n} from '../../../coral-utils'; import {Decorator} from '../../../coral-decorator'; const KEYPRESS_TIMEOUT_DURATION = 1000; /** The distance, in pixels, from the bottom of the SelectList at which we trigger a scrollbottom event. For example, a value of 50 would indicate a scrollbottom event should be triggered when the user scrolls to within 50 pixels of the bottom. @type {Number} @ignore */ const SCROLL_BOTTOM_THRESHOLD = 50; /** The number of milliseconds for which scroll events should be debounced. @type {Number} @ignore */ const SCROLL_DEBOUNCE = 100; /** @ignore */ const ITEM_TAG_NAME = 'coral-selectlist-item'; /** @ignore */ const GROUP_TAG_NAME = 'coral-selectlist-group'; const CLASSNAME = '_coral-Menu'; /** @class Coral.SelectList @classdesc A SelectList component is a selectable list of items. @htmltag coral-selectlist @extends {HTMLElement} @extends {BaseComponent} */ const SelectList = Decorator(class extends BaseComponent(HTMLElement) { /** @ignore */ constructor() { super(); // Attach events this._delegateEvents({ 'scroll': '_onScroll', 'mouseenter': '_onMouseEnter', 'capture:blur': '_onBlur', 'click coral-selectlist-item': '_onItemClick', 'key:space coral-selectlist-item': '_onToggleItemKey', 'key:return coral-selectlist-item': '_onToggleItemKey', 'key:pageup coral-selectlist-item': '_focusPreviousItem', 'key:left coral-selectlist-item': '_focusPreviousItem', 'key:up coral-selectlist-item': '_focusPreviousItem', 'key:pagedown coral-selectlist-item': '_focusNextItem', 'key:right coral-selectlist-item': '_focusNextItem', 'key:down coral-selectlist-item': '_focusNextItem', 'key:home coral-selectlist-item': '_onHomeKey', 'key:end coral-selectlist-item': '_onEndKey', 'keypress coral-selectlist-item': '_onKeyPress', // private 'coral-selectlist-item:_selectedchanged': '_onItemSelectedChanged' }); this._keypressTimeoutDuration = KEYPRESS_TIMEOUT_DURATION; // Templates this._elements = {}; loadIndicator.call(this._elements); // Used for eventing this._oldSelection = []; // Used for interaction this._keypressTimeoutID = null; this._keypressSearchString = ''; // we correctly bind the scroll event this._onDebouncedScroll = this._onDebouncedScroll.bind(this); // Init the collection mutation observer this.items._startHandlingItems(true); } /** The Collection Interface that allows interacting with the {@link SelectListGroup} elements that the SelectList contains. This includes items nested inside groups. To manage items contained within a specific group, see {@link SelectListGroup#items}. @type {SelectableCollection} @readonly */ get groups() { // just init on demand if (!this._groups) { this._groups = new SelectableCollection({ host: this, itemTagName: GROUP_TAG_NAME, onItemAdded: this._validateSelection, onItemRemoved: this._validateSelection }); } return this._groups; } /** 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: ITEM_TAG_NAME, onItemAdded: this._validateSelection, onItemRemoved: this._validateSelection }); } return this._items; } /** The selected item in the SelectList. @type {HTMLElement} @readonly */ get selectedItem() { return this.items._getAllSelected()[0] || null; } /** The selected items of the SelectList. @type {Array.<HTMLElement>} @readonly */ get selectedItems() { return this.items._getAllSelected(); } /** Indicates whether the SelectList accepts multiple selected items. @type {Boolean} @default false @htmlattribute multiple @htmlattributereflected */ get multiple() { return this._multiple || false; } set multiple(value) { this._multiple = transform.booleanAttr(value); this._reflectAttribute('multiple', this._multiple); this[this._multiple ? 'setAttribute' : 'removeAttribute']('aria-multiselectable', this._multiple); this._validateSelection(); } /** Whether items are being loaded for the SelectList. Toggling this merely shows or hides a loading indicator. @default false @type {Boolean} @htmlattribute loading @htmlattributereflected */ get loading() { return this._loading || false; } set loading(value) { this._loading = transform.booleanAttr(value); this._reflectAttribute('loading', this._loading); const load = this._elements.loadIndicator; if (this.loading) { // we decide first if we need to scroll to the bottom since adding the load will change the dimentions const scrollToBottom = this.scrollTop >= this.scrollHeight - this.clientHeight; // inserts the item at the end this.appendChild(load); // we make the load indicator visible if (scrollToBottom) { /** @ignore */ this.scrollTop = this.scrollHeight; } } else { load.remove(); } } /** @private **/ get _tabTarget() { return this.__tabTarget || null; } set _tabTarget(value) { this.__tabTarget = value; // Set all but the current set _tabTarget to not be a tab target: this.items._getSelectableItems().forEach((item) => { item.setAttribute('tabindex', !value || item === value ? 0 : -1); }); } /** @private */ _toggleItemSelection(item) { if (item) { const beforeChangeEvent = this.trigger('coral-selectlist:beforechange', {item}); if (!beforeChangeEvent.defaultPrevented) { item[item.hasAttribute('selected') ? 'removeAttribute' : 'setAttribute']('selected', ''); } } } /** @private */ _onBlur() { // required otherwise the latest item that had the focus would get it again instead of the selected item this._resetTabTarget(); } /** @private */ _onItemClick(event) { event.preventDefault(); event.stopPropagation(); const item = event.matchedTarget; this._toggleItemSelection(item); this._focusItem(item); } /** @private */ _focusItem(item) { if (item) { item.focus(); } this._tabTarget = item; } /** @private */ _onToggleItemKey(event) { event.preventDefault(); event.stopPropagation(); const item = event.target; this._toggleItemSelection(item); this._focusItem(item); } /** @private */ _focusPreviousItem(event) { event.preventDefault(); this._focusItem(this.items._getPreviousSelectable(event.target)); } /** @private */ _focusNextItem(event) { event.preventDefault(); this._focusItem(this.items._getNextSelectable(event.target)); } _focusFirstItem() { this._focusItem(this.items._getFirstSelectable()); } _focusLastItem() { this._focusItem(this.items._getLastSelectable()); } /** @private */ _onHomeKey(event) { event.preventDefault(); this._focusFirstItem(); } /** @private */ _onEndKey(event) { event.preventDefault(); this._focusLastItem(); } /** Handles keypress event for alphanumeric search. @param {KeyboardEvent} event The keyboard event. @private */ _onKeyPress(event) { // The string entered when the key was pressed const newString = String.fromCharCode(event.which); // Clear the timeout before the _keypressSearchString is cleared window.clearTimeout(this._keypressTimeoutID); // If the character entered does not match the last character entered, append it to the _keypressSearchString if (newString !== this._keypressSearchString) { this._keypressSearchString += newString; } // Set a timeout so that _keypressSearchString is cleared after 1 second this._keypressTimeoutID = window.setTimeout(() => { this._keypressSearchString = ''; }, this._keypressTimeoutDuration); // Search within selectable items const selectableItems = this.items._getSelectableItems().filter(item => !item.hasAttribute('hidden') && item.offsetParent); // Remember the index of the focused item within the array of selectable items const currentIndex = selectableItems.indexOf(this._tabTarget); this._keypressSearchString = this._keypressSearchString.trim().toLowerCase(); let start; // If the currentIndex is -1, meaning no item has focus, start from the beginning if (currentIndex === -1) { start = 0; } else if (this._keypressSearchString.length === 1) { // Otherwise, if there is only one character to compare, start comparing from the next item after the currently // focused item. This allows us to iterate through items beginning with the same character start = currentIndex + 1; } else { start = currentIndex; } let newFocusItem; let comparison; let item; // Compare _keypressSearchString against item text until a match is found for (let i = start ; i < selectableItems.length ; i++) { item = selectableItems[i]; comparison = item.textContent.trim().toLowerCase(); if (comparison.indexOf(this._keypressSearchString) === 0) { newFocusItem = item; break; } } // If no match is found, continue searching for a match starting from the top if (!newFocusItem) { for (let j = 0 ; j < start ; j++) { item = selectableItems[j]; comparison = item.textContent.trim().toLowerCase(); if (comparison.indexOf(this._keypressSearchString) === 0) { newFocusItem = item; break; } } } // If a match has been found, focus the matched item if (newFocusItem !== undefined) { this._focusItem(newFocusItem); // Keyboard is being used so apply focus-ring const focusedItem = this.querySelector('.focus-ring'); if (focusedItem) { focusedItem.classList.remove('focus-ring'); } newFocusItem.classList.add('focus-ring'); } } _debounceResetTabTarget(forceFirst = false) { window.cancelAnimationFrame(this._resetTabTargetdebouncedId); this._resetTabTargetdebouncedId = window.requestAnimationFrame(() => { this._resetTabTarget(forceFirst); }); } /** Determine what item should get focus (if any) when the user tries to tab into the SelectList. This should be the first selected item, or the first selectable item otherwise. When neither is available, it cannot be tabbed into the SelectList. @private */ _resetTabTarget(forceFirst = false) { let items = this.items._getAllSelected().filter(item => !item.hasAttribute('hidden') && item.offsetParent); if (items.length === 0) { items = this.items._getSelectableItems().filter(item => !item.hasAttribute('hidden') && item.offsetParent); } this._tabTarget = forceFirst ? items[0] : (items.find(item => item.tabIndex === 0) || items[0]); } /** @private */ _onScroll() { window.clearTimeout(this._scrollTimeout); this._scrollTimeout = window.setTimeout(this._onDebouncedScroll, SCROLL_DEBOUNCE); } _onMouseEnter() { if (this.contains(document.activeElement)) { document.activeElement.blur(); } } /** @emits {coral-selectlist:scrollbottom} @private */ _onDebouncedScroll() { if (this.scrollTop >= this.scrollHeight - this.clientHeight - SCROLL_BOTTOM_THRESHOLD) { this.trigger('coral-selectlist:scrollbottom'); } } /** @private */ _onItemSelectedChanged(event) { event.stopImmediatePropagation(); this._validateSelection(event.target); } /** @private */ _validateSelection(item) { const selectedItems = this.selectedItems; if (!this.multiple) { // Last selected item wins if multiple selection while not allowed item = item || selectedItems[selectedItems.length - 1]; 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; } } this._debounceResetTabTarget(); this._triggerChangeEvent(); } /** @private */ _triggerChangeEvent() { const selectedItems = this.selectedItems; const oldSelection = this._oldSelection; if (!this._preventTriggeringEvents && this._arraysAreDifferent(selectedItems, oldSelection)) { // We differentiate whether multiple is on or off and return an array or HTMLElement respectively if (this.multiple) { this.trigger('coral-selectlist:change', { oldSelection: oldSelection, selection: selectedItems }); } else { // Return all items if we just switched from multiple=true to multiple=false and we had >1 selected items this.trigger('coral-selectlist:change', { oldSelection: oldSelection.length > 1 ? oldSelection : oldSelection[0] || null, selection: selectedItems[0] || null }); } this._oldSelection = selectedItems; } } /** @private */ _arraysAreDifferent(selection, oldSelection) { let diff = []; if (oldSelection.length === selection.length) { diff = oldSelection.filter((item) => selection.indexOf(item) === -1); } // since we guarantee that they are arrays, we can start by comparing their size return oldSelection.length !== selection.length || diff.length !== 0; } /** @ignore */ focus() { // Avoids moving the focus once it is already inside the component if (!this.contains(document.activeElement)) { this._resetTabTarget(); this._focusItem(this._tabTarget); } } /** @ignore */ static get observedAttributes() { return super.observedAttributes.concat(['loading', 'multiple']); } /** @ignore */ render() { this.classList.add(CLASSNAME); // adds the role to support accessibility if (!this.hasAttribute('role')) { this.setAttribute('role', 'listbox'); } if (!this.hasAttribute('aria-label')) { this.setAttribute('aria-label', i18n.get('List')); } // Don't trigger events once connected this._preventTriggeringEvents = true; this._validateSelection(); this._resetTabTarget(); this._preventTriggeringEvents = false; this._oldSelection = this.selectedItems; } /** Triggered when the user scrolls to near the bottom of the {@link SelectList}. This can be useful for when additional items can be loaded asynchronously (i.e., infinite scrolling). @typedef {CustomEvent} coral-selectlist:scrollbottom */ /** Triggered before the {@link SelectList} selected item is changed on user interaction. Can be used to cancel selection. @typedef {CustomEvent} coral-selectlist:change @property {SelectListItem} event.detail.item The selected item. */ /** Triggered when the {@link SelectList} selected item has changed. @typedef {CustomEvent} coral-selectlist:change @property {SelectListItem} detail.oldSelection The prior selected item(s). @property {SelectListItem} detail.selection The newly selected item(s). */ }); export default SelectList;