UNPKG

@adobe/coral-spectrum

Version:

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

1,513 lines (1,236 loc) 47.3 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 {BaseFormField} from '../../../coral-base-formfield'; import {SelectableCollection} from '../../../coral-collection'; import '../../../coral-component-button'; import {Tag} from '../../../coral-component-taglist'; import {SelectList} from '../../../coral-component-list'; import {Icon} from '../../../coral-component-icon'; import '../../../coral-component-popover'; import base from '../templates/base'; import {transform, validate, commons, i18n, Keys} from '../../../coral-utils'; import {Decorator} from '../../../coral-decorator'; /** Enumeration for {@link Select} variants. @typedef {Object} SelectVariantEnum @property {String} DEFAULT A default, gray Select. @property {String} QUIET A Select with no border or background. */ const variant = { DEFAULT: 'default', QUIET: 'quiet' }; const CLASSNAME = '_coral-Dropdown'; // used in 'auto' mode to determine if the client is on mobile. const IS_MOBILE_DEVICE = navigator.userAgent.match(/iPhone|iPad|iPod|Android/i) !== null; /** Extracts the value from the item in case no explicit value was provided. @param {HTMLElement} item the item whose value will be extracted. @returns {String} the value that will be submitted for this item. @private */ const itemValueFromDOM = function (item) { const attr = item.getAttribute('value'); // checking explicitely for null allows to differenciate between non set values and empty strings return attr !== null ? attr : item.textContent.replace(/\s{2,}/g, ' ').trim(); }; /** Calculates the difference between two given arrays. It returns the items that are in a that are not in b. @param {Array.<String>} a @param {Array.<String>} b @returns {Array.<String>} the difference between the arrays. */ const arrayDiff = function (a, b) { return a.filter((item) => !b.some((item2) => item === item2)); }; /** @class Coral.Select @classdesc A Select component is a form field that allows users to select from a list of options. If this component is shown on a mobile device, it will show a native select list, instead of the select list styled via Coral Spectrum. @htmltag coral-select @extends {HTMLElement} @extends {BaseComponent} @extends {BaseFormField} */ const Select = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) { /** @ignore */ constructor() { super(); // Templates this._elements = {}; base.call(this._elements, {commons, Icon, i18n}); const events = { 'global:click': '_onGlobalClick', 'global:touchstart': '_onGlobalClick', 'coral-collection:add coral-taglist': '_onInternalEvent', 'coral-collection:remove coral-taglist': '_onInternalEvent', 'change coral-taglist': '_onTagListChange', 'change select': '_onNativeSelectChange', 'click select': '_onNativeSelectClick', 'click > ._coral-Dropdown-trigger': '_onButtonClick', 'key:space > ._coral-Dropdown-trigger': '_onSpaceKey', 'key:enter > ._coral-Dropdown-trigger': '_onSpaceKey', 'key:return > ._coral-Dropdown-trigger': '_onSpaceKey', 'key:down > ._coral-Dropdown-trigger': '_onSpaceKey', // Messenger 'coral-select-item:_messengerconnected': '_onMessengerConnected', }; // Overlay const overlayId = this._elements.overlay.id; events[`global:capture:coral-collection:add #${overlayId} coral-selectlist`] = '_onSelectListItemAdd'; events[`global:capture:coral-collection:remove #${overlayId} coral-selectlist`] = '_onInternalEvent'; events[`global:capture:coral-selectlist:beforechange #${overlayId}`] = '_onSelectListBeforeChange'; events[`global:capture:coral-selectlist:change #${overlayId}`] = '_onSelectListChange'; events[`global:capture:coral-selectlist:scrollbottom #${overlayId}`] = '_onSelectListScrollBottom'; events[`global:capture:coral-overlay:close #${overlayId}`] = '_onOverlayToggle'; events[`global:capture:coral-overlay:open #${overlayId}`] = '_onOverlayToggle'; events[`global:capture:coral-overlay:positioned #${overlayId}`] = '_onOverlayPositioned'; events[`global:capture:coral-overlay:beforeopen #${overlayId}`] = '_onBeforeOpen'; events[`global:capture:coral-overlay:beforeclose #${overlayId}`] = '_onInternalEvent'; // Keyboard interaction events[`global:keypress #${overlayId}`] = '_onOverlayKeyPress'; // TODO for some reason this disables tabbing into the select // events[`global:key:tab #${overlayId} coral-selectlist-item`] = '_onTabKey'; // events[`global:key:tab+shift #${overlayId} coral-selectlist-item`] = '_onTabKey'; // Attach events this._delegateEvents(commons.extend(this._events, events)); // Pre-define labellable element this._labellableElement = this._elements.button; // default value of inner flag to process events this._bulkSelectionChange = false; // we only have AUTO mode. this._useNativeInput = IS_MOBILE_DEVICE; this._elements.taglist.reset = () => { // since reseting a form will call the reset on every component, we need to kill the behavior of the taglist // otherwise the state will not be accurate }; this._initialValues = []; // Init the collection mutation observer this.items._startHandlingItems(); } /** Returns the inner overlay to allow customization. @type {Popover} @readonly */ get overlay() { return this._elements.overlay; } /** The item collection. @type {SelectableCollection} @readonly */ get items() { // we do lazy initialization of the collection if (!this._items) { this._items = new SelectableCollection({ host: this, itemTagName: 'coral-select-item', onItemAdded: this._onItemAdded, onItemRemoved: this._onItemRemoved, onCollectionChange: this._onCollectionChange }); } return this._items; } /** Indicates whether the select accepts multiple selected values. @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); // taglist should not be in DOM if multiple === false if (!this._multiple) { this.removeChild(this._elements.taglist); } else { this.appendChild(this._elements.taglist); } // we need to remove and re-add the native select to loose the selection if (this._nativeInput) { this.removeChild(this._elements.nativeSelect); } this._elements.nativeSelect.multiple = this._multiple; this._elements.nativeSelect.selectedIndex = -1; if (this._nativeInput) { if (this._multiple) { // We might not be rendered yet if (this._elements.nativeSelect.parentNode) { this.insertBefore(this._elements.nativeSelect, this._elements.taglist); } } else { this.appendChild(this._elements.nativeSelect); } } this._elements.list.multiple = this._multiple; // sets the correct name for value submission this._setName(this.getAttribute('name') || ''); // we need to make sure the selection is valid this._setStateFromDOM(); // everytime multiple changes, the state of the selectlist and taglist need to be updated this.items.getAll().forEach((item) => { if (this._multiple && item.hasAttribute('selected')) { this._addTagToTagList(item); } else { // taglist is never used for multiple = false this._removeTagFromTagList(item); // when multiple = false and the item is selected, the value needs to be updated in the input if (item.hasAttribute('selected')) { this._elements.input.value = itemValueFromDOM(item); } } }); } /** Contains a hint to the user of what can be selected in the component. If no placeholder is provided, the first option will be displayed in the component. @type {String} @default "" @htmlattribute placeholder @htmlattributereflected */ // p = placeholder, m = multiple, se = selected // case 1: p + m + se = p // case 2: p + m + !se = p // case 3: !p + !m + se = se // case 4: !p + !m + !se = firstSelectable (native behavior) // case 5: p + !m + se = se // case 6: p + !m + !se = p // case 7: !p + m + se = 'Select' // case 8: !p + m + !se = 'Select' get placeholder() { return this._placeholder || ''; } set placeholder(value) { this._placeholder = transform.string(value); this._reflectAttribute('placeholder', this._placeholder); // case 1: p + m + se = p // case 2: p + m + !se = p // case 6: p + !m + !se = p if (this._placeholder && (this.hasAttribute('multiple') || !this.selectedItem)) { this._elements.label.classList.add('is-placeholder'); this._elements.label.textContent = this._placeholder; } // case 7: !p + m + se = 'Select' // case 8: !p + m + !se = 'Select' else if (this.hasAttribute('multiple')) { this._elements.label.classList.add('is-placeholder'); this._elements.label.textContent = i18n.get('Select'); } // case 4: !p + !m + !se = firstSelectable (native behavior) else if (!this.selectedItem) { // we clean the value because there is no selected item this._elements.input.value = ''; // gets the first candidate for selection const placeholderItem = this.items._getFirstSelectable(); this._elements.label.classList.remove('is-placeholder'); if (placeholderItem) { // selects using the attribute in case the item is not yet initialized placeholderItem.setAttribute('selected', ''); this._elements.label.innerHTML = placeholderItem.innerHTML; } else { // label must be cleared when there is no placeholder and no item to select this._elements.label.textContent = ''; } } } /** Name used to submit the data in a form. @type {String} @default "" @htmlattribute name @htmlattributereflected */ get name() { return this.multiple ? this._elements.taglist.name : this._elements.input.name; } set name(value) { this._setName(value); this._reflectAttribute('name', this.name); } /** This field's current value. @type {String} @default "" @htmlattribute value */ get value() { // we leverage the internal elements to know the value, this way we are always sure that the server submission // will be correct return this.multiple ? this._elements.taglist.value : this._elements.input.value; } set value(value) { // we rely on the the values property to handle this correctly this.values = [value]; } /** The current selected values, as submitted during form submission. When {@link Coral.Select#multiple} is <code>false</code>, this will be an array of length 1. @type {Array.<String>} */ get values() { if (this.multiple) { return this._elements.taglist.values; } // if there is a selection, we return whatever value it has assigned return this.selectedItem ? [this._elements.input.value] : []; } set values(values) { if (Array.isArray(values)) { // when multiple = false, we explicitely ignore the other values and just set the first one if (!this.multiple && values.length > 1) { values = [values[0]]; } // gets all the items const items = this.items.getAll(); let itemValue; // if multiple, we need to explicitely set the selection state of every item if (this.multiple) { items.forEach((item) => { // we use DOM API instead of properties in case the item is not yet initialized itemValue = itemValueFromDOM(item); // if the value is located inside the values array, then we set the item as selected item[values.indexOf(itemValue) !== -1 ? 'setAttribute' : 'removeAttribute']('selected', ''); }); } // if single selection, we find the first item that matches the value and deselect everything else. in case, // no item matches the value, we may need to find a selection candidate else { let targetItem; // since multiple = false, there is only 1 value value const value = values[0] || ''; items.forEach((item) => { // small optimization to avoid calculating the value from every item if (!targetItem) { itemValue = itemValueFromDOM(item); if (itemValue === value) { // selecting the item will cause the taglist or input to be updated item.setAttribute('selected', ''); // we store the first ocurrence, afterwards we deselect all items targetItem = item; // since we found our target item, we continue to avoid removing the selected attribute return; } } // every-non targetItem must be deselected item.removeAttribute('selected'); }); // if no targetItem was found, _setStateFromDOM will make sure that the state is valid if (!targetItem) { this._setStateFromDOM(); } } } } /** Whether this field is disabled or not. @type {Boolean} @default false @htmlattribute disabled @htmlattributereflected */ get disabled() { return this._disabled || false; } set disabled(value) { this._disabled = transform.booleanAttr(value); this._reflectAttribute('disabled', this._disabled); this[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled); this.classList.toggle('is-disabled', this._disabled); this._elements.button.disabled = this._disabled; this._elements.input.disabled = this._disabled; this._elements.taglist.disabled = this._disabled; } /** Inherited from {@link BaseFormField#invalid}. */ get invalid() { return super.invalid; } set invalid(value) { super.invalid = value; this.classList.toggle('is-invalid', this.invalid); this._elements.button.classList.toggle('is-invalid', this.invalid); this._elements.invalidIcon.hidden = !this.invalid; } /** Whether this field is required or not. @type {Boolean} @default false @htmlattribute required @htmlattributereflected */ get required() { return this._required || false; } set required(value) { this._required = transform.booleanAttr(value); this._reflectAttribute('required', this._required); this._elements.input.required = this._required; this._elements.taglist.required = this._required; } /** Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control. @type {Boolean} @default false @htmlattribute readonly @htmlattributereflected */ get readOnly() { return this._readOnly || false; } set readOnly(value) { this._readOnly = transform.booleanAttr(value); this._reflectAttribute('readonly', this._readOnly); this._elements.input.readOnly = this._readOnly; this._elements.taglist.readOnly = this._readOnly; this._elements.taglist.disabled = this._readOnly; } /** Inherited from {@link BaseFormField#labelled}. */ get labelled() { return super.labelled; } set labelled(value) { super.labelled = value; if (this.labelled) { if (!this.labelledBy) { this._elements.button.setAttribute('aria-labelledby', `${this._elements.button.id} ${this._elements.label.id} ${this.invalid ? this._elements.invalidIcon.id : ''}`); } this._elements.nativeSelect.setAttribute('aria-label', value); } else { this._elements.button.removeAttribute('aria-label'); this._elements.nativeSelect.removeAttribute('aria-label'); if (!this.labelledBy) { this._elements.button.removeAttribute('aria-labelledby'); } } this._elements.taglist.labelled = value; } /** Inherited from {@link BaseFormField#labelledBy}. */ get labelledBy() { return this._labelledBy; } set labelledBy(value) { super.labelledBy = value; this._labelledBy = super.labelledBy; if (this._labelledBy) { this._elements.button.setAttribute('aria-labelledby', `${this._labelledBy} ${this._elements.label.id} ${this.invalid ? this._elements.invalidIcon.id : ''}`); this._elements.nativeSelect.setAttribute('aria-labelledby', this._labelledBy); } else { this._elements.nativeSelect.removeAttribute('aria-labelledby'); // if the select is also labelled, make sure that aria-labelledby gets restored if (this.labelled) { this.labelled = this.labelled; } } this._elements.taglist.labelledBy = this._labelledBy; } /** Returns the first selected item in the Select. The value <code>null</code> is returned if no element is selected. @type {?HTMLElement} @readonly */ get selectedItem() { return this.hasAttribute('multiple') ? this.items._getFirstSelected() : this.items._getLastSelected(); } /** Returns an Array containing the set selected items. @type {Array.<HTMLElement>} @readonly */ get selectedItems() { if (this.hasAttribute('multiple')) { return this.items._getAllSelected(); } const item = this.selectedItem; return item ? [item] : []; } /** Indicates that the Select is currently loading remote data. This will set the wait indicator inside the list. @type {Boolean} @default false @htmlattribute loading */ get loading() { return this._elements.list.loading; } set loading(value) { this._elements.list.loading = value; } /** The Select's variant. See {@link SelectVariantEnum}. @type {SelectVariantEnum} @default SelectVariantEnum.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._elements.button.classList.toggle('_coral-FieldButton--quiet', this._variant === variant.QUIET); } /** @ignore */ _setName(value) { if (this.multiple) { this._elements.input.name = ''; this._elements.taglist.setAttribute('name', value); } else { this._elements.taglist.setAttribute('name', ''); this._elements.input.name = value; } } /** @param {Boolean} [checkAvailableSpace=false] If <code>true</code>, the event is triggered based on the available space. @private */ _showOptions(checkAvailableSpace) { if (checkAvailableSpace) { // threshold in pixels const ITEM_SIZE_THRESHOLD = 30; let scrollHeight = this._elements.list.scrollHeight; const viewportHeight = this._elements.list.clientHeight; const scrollTop = this._elements.list.scrollTop; // we should not do this, but it increases performance since we do not need to find the item const loadIndicator = this._elements.list._elements.loadIndicator; // we remove the size of the load indicator if (loadIndicator && loadIndicator.parentNode) { const outerHeight = function (el) { let height = el.offsetHeight; const style = getComputedStyle(el); height += parseInt(style.marginTop, 10) + parseInt(style.marginBottom, 10); return height; }; scrollHeight -= outerHeight(loadIndicator); } // if we are not close to the bottom scroll, we cancel triggering the event if (scrollTop + viewportHeight < scrollHeight - ITEM_SIZE_THRESHOLD) { return; } } // we do not show the list with native if (!this._useNativeInput) { if (!this._elements.overlay.open) { // Show the overlay this._elements.overlay.open = true; } // Force overlay repositioning (remote loading) requestAnimationFrame(() => { this._elements.overlay._onAnimate(); this._elements.overlay.reposition(); }); } // Trigger an event // @todo: maybe we should only trigger this event when the button is toggled and we have space for more items const event = this.trigger('coral-select:showitems', { // amount of items in the select start: this.items.length }); // while using native there is no need to show the loading if (!this._useNativeInput) { // if the default is prevented, we should the loading indicator this._elements.list.loading = event.defaultPrevented; } // communicate expanded state to assistive technology this._elements.button.setAttribute('aria-expanded', true); } /** @private */ _hideOptions() { // Don't close the overlay if selection = multiple if (!this.multiple) { this._elements.overlay.open = false; this.trigger('coral-select:hideitems'); } // communicate collapsed state to assistive technology this._elements.button.setAttribute('aria-expanded', false); } /** @ignore */ _onGlobalClick(event) { if (!this._elements.overlay.open) { return; } const eventTargetWithinOverlayTarget = this._elements.button.contains(event.target); const eventTargetWithinItself = this._elements.overlay.contains(event.target); if (!eventTargetWithinOverlayTarget && !eventTargetWithinItself) { this._hideOptions(); } } /** @private */ _onSelectListItemAdd(event) { // stops propagation cause the event is internal to the component event.stopImmediatePropagation(); // When items have been added, we are no longer loading this.loading = false; // Reset height this._elements.list.style.height = ''; // Measure actual height const style = window.getComputedStyle(this._elements.list); const height = parseInt(style.height, 10); const maxHeight = parseInt(style.maxHeight, 10); if (height < maxHeight) { // Make it scrollable this._elements.list.style.height = `${height - 1}px`; } } _onBeforeOpen(event) { event.stopImmediatePropagation(); // Prevent opening the overlay if select is readonly if (this.readOnly) { event.preventDefault(); } // focus first selected or tabbable item when the list expands this._elements.list._resetTabTarget(true); } /** @private */ _onInternalEvent(event) { // stops propagation cause the event is internal to the component event.stopImmediatePropagation(); } /** @ignore */ _onItemAdded(item) { const selectListItemParent = this._elements.list; const selectListItem = item._selectListItem || new SelectList.Item(); // @todo: Make sure it is added at the right index. selectListItemParent.appendChild(selectListItem); selectListItem.set({ value: item.value, content: { innerHTML: item.innerHTML }, disabled: item.disabled, selected: item.selected, trackingElement: item.trackingElement }, true); const nativeOption = item._nativeOption || new Option(); // @todo: make sure it is added at the right index. this._elements.nativeSelect.appendChild(nativeOption); // Need to store the initially selected values in the native select so that it can be reset if (this._initialValues.indexOf(item.value) !== -1) { nativeOption.setAttribute('selected', 'selected'); } nativeOption.selected = item.selected; nativeOption.value = item.value; nativeOption.disabled = item.disabled; nativeOption.innerHTML = item.innerHTML; if (this.multiple) { // in case it was selected before it was added if (item.selected) { this._addTagToTagList(item); } } // Make sure the input value is set to the selected item else if (item.selected) { this._elements.input.value = item.value; } item._selectListItem = selectListItem; item._nativeOption = nativeOption; selectListItem._selectItem = item; nativeOption._selectItem = item; const messenger = item._messenger; if (messenger && messenger.isConnected && messenger.listeners.length === 0) { // sometimes child get connected before parent, and listeners are not set yet, so we need to reconnect messenger._connected = false; messenger.connect(); } } /** @private */ _onItemRemoved(item) { if (item._selectListItem) { item._selectListItem.remove(); item._selectListItem._selectItem = undefined; item._selectListItem = undefined; } if (item._nativeOption) { this._elements.nativeSelect.removeChild(item._nativeOption); item._nativeOption._selectItem = undefined; item._nativeOption = undefined; } this._removeTagFromTagList(item, true); } /** @private */ _onItemSelected(item) { // in case the component is not in the DOM or the internals have not been created we force it if (!item._selectListItem || !item._selectListItem.parentNode) { this._onItemAdded(item); } item._selectListItem.selected = true; item._nativeOption.selected = true; if (this.multiple) { this._addTagToTagList(item); // @todo: what happens when ALL items have been selected // 1. a message is disabled (i18n?) // 2. we don't try to open the selectlist (native behavior). } else { this._elements.input.value = item.value; } } /** @private */ _onItemDeselected(item) { // in case the component is not in the DOM or the internals have not been created we force it if (!item._selectListItem || !item._selectListItem.parentNode) { this._onItemAdded(item); } item._selectListItem.selected = false; item._nativeOption.selected = false; if (this.multiple) { // we use the internal reference to remove the related tag from the taglist this._removeTagFromTagList(item); } } /** Detects when something is about to change inside the select. @private */ _onSelectListBeforeChange(event) { // stops propagation cause the event is internal to the component event.stopImmediatePropagation(); // We prevent the selection to change if we're in single selection and the clicked item is already selected if (!this.multiple && event.detail.item.selected) { event.preventDefault(); this._elements.overlay.open = false; } } /** Detects when something inside the select list changes. @private */ _onSelectListChange(event) { // stops propagation cause the event is internal to the component event.stopImmediatePropagation(); // avoids triggering unnecessary changes in the selectist because selecting items programatically will trigger // a change event if (this._bulkSelectionChange) { return; } let oldSelection = event.detail.oldSelection || []; oldSelection = !Array.isArray(oldSelection) ? [oldSelection] : oldSelection; let selection = event.detail.selection || []; selection = !Array.isArray(selection) ? [selection] : selection; // if the arrays are the same, there is no point in calculating the selection changes if (event.detail.oldSelection !== event.detail.selection) { this._bulkSelectionChange = true; // we deselect first the ones that have to go const removedSelection = arrayDiff(oldSelection, selection); removedSelection.forEach((listItem) => { // selectlist will report on removed items if (listItem._selectItem) { listItem._selectItem.removeAttribute('selected'); } }); // we only sync the items that changed const newSelection = arrayDiff(selection, oldSelection); newSelection.forEach((listItem) => { if (listItem._selectItem) { listItem._selectItem.setAttribute('selected', ''); } }); this._bulkSelectionChange = false; // hides the list since something was selected. if the overlay was open, it means there was user interaction so // the necessary events need to be triggered if (this._elements.overlay.open) { // closes and triggers the hideitems event this._hideOptions(); // if there is a change in the added or removed selection, we trigger a change event if (newSelection.length || removedSelection.length) { this.trigger('change'); } } } // in case they are the same, we just need to trigger the hideitems event when appropiate, and that is when the // overlay was previously open else if (this._elements.overlay.open) { // closes and triggers the hideitems event this._hideOptions(); } if (!this.multiple) { this._trackEvent('change', 'coral-select-item', event, this.selectedItem); } } /** @private */ _onTagListChange(event) { // cancels the change event from the taglist event.stopImmediatePropagation(); // avoids triggering unnecessary changes in the selectist because selecting items programatically will trigger // a change event if (this._bulkSelectionChange) { return; } this._bulkSelectionChange = true; const values = event.target.values; // we use the selected items, because they are the only possible items that may change let itemValue; this.items._getAllSelected().forEach((item) => { // we use DOM API instead of properties in case the item is not yet initialized itemValue = itemValueFromDOM(item); // if the item is inside the values array, then it has to be selected item[values.indexOf(itemValue) !== -1 ? 'setAttribute' : 'removeAttribute']('selected', ''); }); this._bulkSelectionChange = false; // if the taglist is empty, we should return the focus to the button if (!values.length) { this._elements.button.focus(); } // reparents the change event with the select as the target this.trigger('change'); } /** @private */ _addTagToTagList(item) { // we prepare the tag item._tag = item._tag || new Tag(); item._tag.set({ value: item.value, label: { innerHTML: item.innerHTML } }, true); // we add the new tag at the end this._elements.taglist.items.add(item._tag); } /** @private */ _removeTagFromTagList(item, destroy) { if (item._tag) { item._tag.remove(); // we only remove the reference if destroy is passed, this allow us to recycle the tags when possible item._tag = destroy ? undefined : item._tag; } } /** @private */ _onSelectListScrollBottom(event) { // stops propagation cause the event is internal to the component event.stopImmediatePropagation(); if (this._elements.overlay.open) { // Checking if the overlay is open guards against debounced scroll events being handled after an overlay has // already been closed (e.g. clicking the last element in a selectlist always reopened the overlay emediately // after closing) // triggers the corresponding event // since we got the the event from select list we need to trigger the event this._showOptions(); } } /** @private */ _onButtonClick(event) { event.preventDefault(); if (this.disabled || this.readOnly) { return; } // if native is required, we do not need to do anything if (!this._useNativeInput) { // @todo: this was removed cause otherwise the coral-select:showitems event is never triggered. // if this is a multiselect and all items are selected, there should be nothing in the list to focus so do // nothing. // if (this.multiple && this.selectedItems.length === this.items.length) { // return; // } // Toggle openness if (this._elements.overlay.classList.contains('is-open')) { this._hideOptions(); } else { // event should be triggered based on the contents this._showOptions(true); } } } /** @private */ _onNativeSelectClick() { this._showOptions(false); } _onOverlayKeyPress(event) { // Focus on item which text starts with pressed keys this._elements.list._onKeyPress(event); } /** @private */ _onSpaceKey(event) { if (this.disabled || this.readOnly) { return; } event.preventDefault(); if (this._useNativeInput) { // we try to open the native select this._elements.nativeSelect.dispatchEvent(new MouseEvent('mousedown')); } else if (!this._elements.overlay.open || event.keyCode === Keys.keyToCode('space')) { this._elements.button.click(); } } /** Prevents tab key default handling on selectList Items. @private */ // _onTabKey(event) { // event.preventDefault(); // } /** @private */ _onOverlayToggle(event) { // stops propagation cause the event is internal to the component event.stopImmediatePropagation(); // Trigger private event instead const type = event.type.split(':').pop(); this.trigger(`coral-select:_overlay${type}`); this._elements.button.classList.toggle('is-selected', event.target.open); // communicate expanded state to assistive technology this._elements.button.setAttribute('aria-expanded', event.target.open); if (!event.target.open) { this.classList.remove('is-openAbove', 'is-openBelow'); } } /** @private */ _onOverlayPositioned(event) { // stops propagation cause the event is internal to the component event.stopImmediatePropagation(); if (this._elements.overlay.open) { this._elements.overlay.style.width = `${this.offsetWidth}px`; } } // @todo: while the select is multiple, if everything is deselected no change event will be triggered. _onNativeSelectChange(event) { // stops propagation cause the event is internal to the component event.stopImmediatePropagation(); // avoids triggering unnecessary changes in the selectist because selecting items programatically will trigger // a change event if (this._bulkSelectionChange) { return; } this._bulkSelectionChange = true; // extracts the native options for the selected items. We use the selected options, instead of the complete // options to make the diff since it will normally be a smaller set const oldSelectedOptions = this.selectedItems.map((element) => element._nativeOption); // we convert the HTMLCollection to an array const selectedOptions = Array.prototype.slice.call(event.target.querySelectorAll(':checked')); const diff = arrayDiff(oldSelectedOptions, selectedOptions); diff.forEach((item) => { item._selectItem.selected = false; }); // we only sync the items that changed const newSelection = arrayDiff(selectedOptions, oldSelectedOptions); newSelection.forEach((item) => { item._selectItem.selected = true; }); this._bulkSelectionChange = false; // since multiple keeps the select open, we cannot return the focus to the button otherwise the user cannot // continue selecting values if (!this.multiple) { // returns the focus to the button, otherwise the select will keep it this._elements.button.focus(); // since selecting an item closes the native select, we need to trigger an event this.trigger('coral-select:hideitems'); } // if the native change event was triggered, then it means there is some new value this.trigger('change'); } /** This handles content change of coral-select-item and updates its associatives. @private */ _onItemContentChange(event) { // stops propagation cause the event is internal to the component event.stopImmediatePropagation(); const item = event.target; if (item._selectListItem) { const content = new SelectList.Item.Content(); content.innerHTML = item.innerHTML; item._selectListItem.content = content; } if (item._nativeOption) { item._nativeOption.innerHTML = item.innerHTML; } if (item._tag && item._tag.label) { item._tag.label.innerHTML = item.innerHTML; } // since the content changed, we need to sync the placeholder in case it was the selected item this._syncSelectedItemPlaceholder(); } /** @private */ _syncSelectedItemPlaceholder() { this.placeholder = this.getAttribute('placeholder'); // case 3: !p + !m + se = se // case 5: p + !m + se = se if (this.selectedItem && !this.multiple) { this._elements.label.classList.remove('is-placeholder'); this._elements.label.innerHTML = this.selectedItem.innerHTML; } } /** This handles value change of coral-select-item and updates its associatives. @private */ _onItemValueChange(event) { // stops propagation cause the event is internal to the component event.stopImmediatePropagation(); const item = event.target; if (item._selectListItem) { item._selectListItem.value = item.value; } if (item._nativeOption) { item._nativeOption.value = item.value; } if (item._tag) { item._tag.value = item.value; } } /** This handles disabled change of coral-select-item and updates its associatives. @private */ _onItemDisabledChange(event) { // stops propagation cause the event is internal to the component event.stopImmediatePropagation(); const item = event.target; if (item._selectListItem) { item._selectListItem.disabled = item.disabled; } if (item._nativeOption) { item._nativeOption.disabled = item.disabled; } } /** In case an item from the initial selection is removed, we need to remove it from the initial values. @private */ _validateInitialState(nodes) { let item; let index; // we iterate over all the nodes, checking if they matched the initial value for (let i = 0, nodeCount = nodes.length ; i < nodeCount ; i++) { // since we are not sure if the item has been upgraded, we try first the attribute, otherwise we extract the // value from the textContent item = nodes[i]; index = this._initialValues.indexOf(item.value); if (index !== -1) { this._initialValues.splice(index, 1); } } } /** @private */ // eslint-disable-next-line no-unused-vars _onCollectionChange(addedNodes, removedNodes) { // we make sure that items that were part of the initial selection are removed from the internal representation this._validateInitialState(removedNodes); // makes sure that the selection state matches the multiple variable this._setStateFromDOM(); } /** Updates the label to reflect the current state. The label needs to be updated when the placeholder changes and when the selection changes. @private */ _updateLabel() { this._syncSelectedItemPlaceholder(); } /** Handles the selection state. @ignore */ _setStateFromDOM() { // if it is not multiple, we need to be sure only one item is selected if (!this.hasAttribute('multiple')) { // makes sure that only one is selected this.items._deselectAllExceptLast(); // we execute _getFirstSelected instead of _getSelected because it is faster const selectedItem = this.items._getFirstSelected(); // case 1. there is a selected item, so no further change is required // case 2. no selected item and no placeholder. an item will be automatically selected // case 3. no selected item and a placehoder. we just make sure the value is really empty if (!selectedItem) { // we clean the value because there is no selected item this._elements.input.value = ''; // when there is no placeholder, we need to force a selection to behave like the native select if (transform.string(this.getAttribute('placeholder')) === '') { // gets the first candidate for selection const selectable = this.items._getFirstSelectable(); if (selectable) { // selects using the attribute in case the item is not yet initialized selectable.setAttribute('selected', ''); // we set the value explicitely, so we do not need to wait for the MO this._elements.input.value = itemValueFromDOM(selectable); } } } else { // we set the value explicitely, so we do not need to wait for the MO this._elements.input.value = itemValueFromDOM(selectedItem); } } // handles the initial item in the select this._updateLabel(); } /** Handles selecting multiple items. Selection could result a single or multiple selected items. @private */ _onItemSelectedChange(event) { // we stop propagation since it is a private event event.stopImmediatePropagation(); // the item that was selected const item = event.target; // setting this to true will ignore any changes from the selectlist al this._bulkSelectionChange = true; // when the item is selected, we need to enforce the selection mode if (item.selected) { this._onItemSelected(item); if (this.multiple) { this._trackEvent('select', 'coral-select-item', event, item); } // enforces the selection mode if (!this.hasAttribute('multiple')) { this.items._deselectAllExcept(item); } } else { this._onItemDeselected(item); if (this.multiple) { this._trackEvent('deselect', 'coral-select-item', event, item); } } this._bulkSelectionChange = false; // since there is a change in selection, we need to update the placeholder this._updateLabel(); } /** Inherited from {@link BaseFormField#clear}. */ clear() { this.value = ''; } /** Focuses the component. @ignore */ focus() { if (!this.contains(document.activeElement)) { this._elements.button.focus(); } } /** Inherited from {@link BaseFormField#reset}. */ reset() { // reset the values to the initial values this.values = this._initialValues; } /** Returns {@link Select} variants. @return {SelectVariantEnum} */ static get variant() { return variant; } /** @ignore */ static get observedAttributes() { return super.observedAttributes.concat(['variant', 'multiple', 'placeholder', 'loading']); } get observedMessages() { return { 'coral-select-item:_valuechanged': '_onItemValueChange', 'coral-select-item:_contentchanged': '_onItemContentChange', 'coral-select-item:_disabledchanged': '_onItemDisabledChange', 'coral-select-item:_selectedchanged': '_onItemSelectedChange', }; } /** @ignore */ connectedCallback() { super.connectedCallback(); const overlay = this._elements.overlay; // Cannot be open by default when rendered overlay.removeAttribute('open'); // Restore in DOM if (overlay._parent) { overlay._parent.appendChild(overlay); } } /** @ignore */ render() { super.render(); this.classList.add(CLASSNAME); // Default reflected attributes if (!this._variant) { this.variant = variant.DEFAULT; } this.classList.toggle(`${CLASSNAME}--native`, this._useNativeInput); if (!this._useNativeInput && this.contains(this._elements.nativeSelect)) { this.removeChild(this._elements.nativeSelect); } // handles the initial selection this._setStateFromDOM(); // we need to keep a state of the initial items to be able to reset the component. values is not reliable during // initialization since items are not yet initialized this.selectedItems.forEach((item) => { // we use DOM API instead of properties in case the item is not yet initialized this._initialValues.push(itemValueFromDOM(item)); }); // Cleanup template elements (supporting cloneNode) const templateElements = this.querySelectorAll('[handle]'); for (let i = 0 ; i < templateElements.length ; ++i) { const currentElement = templateElements[i]; if (currentElement.parentNode === this) { this.removeChild(currentElement); } } // Render the main template const frag = document.createDocumentFragment(); frag.appendChild(this._elements.button); frag.appendChild(this._elements.input); frag.appendChild(this._elements.nativeSelect); frag.appendChild(this._elements.taglist); frag.appendChild(this._elements.overlay); // avoid popper initialisation if popper neither exist nor overlay opened. this._elements.overlay._avoidPopperInit = this._elements.overlay.open || this._elements.overlay._popper ? false : true; // Assign the button as the target for the overlay this._elements.overlay.target = this._elements.button; // handles the focus allocation every time the overlay closes this._elements.overlay.returnFocusTo(this._elements.button); this.appendChild(frag); // set this to false after overlay has been connected to avoid connected callback target setting delete this._elements.overlay._avoidPopperInit; } /** @ignore */ disconnectedCallback() { super.disconnectedCallback(); const overlay = this._elements.overlay; // In case it was moved out don't forget to remove it if (!this.contains(overlay)) { overlay._parent = overlay._repositioned ? document.body : this; overlay.remove(); } } /** Triggered when the {@link Select} could accept external data to be loaded by the user. If <code>preventDefault()</code> is called, then a loading indicator will be shown. {@link Select#loading} should be set to false to indicate that the data has been successfully loaded. @typedef {CustomEvent} coral-select:showitems @property {Number} detail.start The count of existing items, which is the index where new items should start. */ /** Triggered when the {@link Select} hides the UI used to select items. This is typically used to cancel a load request because the items will not be shown anymore. @typedef {CustomEvent} coral-select:hideitems */ }); export default Select;