coral-spectrum/coral-component-select/src/scripts/Select.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 {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;