import { html, css, LitElement, SlotMixin } from '@lion/core'; import { LocalOverlayController, overlays } from '@lion/overlays'; import { FormControlMixin, InteractionStateMixin, FormRegistrarMixin } from '@lion/field'; import { ValidateMixin } from '@lion/validate'; import './differentKeyNamesShimIE.js'; import '../lion-select-invoker.js'; function uuid() { return Math.random() .toString(36) .substr(2, 10); } function detectInteractionMode() { if (navigator.appVersion.indexOf('Mac') !== -1) { return 'mac'; } return 'windows/linux'; } /** * LionSelectRich: wraps the element * * @customElement * @extends LionField */ export class LionSelectRich extends FormRegistrarMixin( InteractionStateMixin(ValidateMixin(FormControlMixin(SlotMixin(LitElement)))), ) { static get properties() { return { checkedValue: { type: Object, }, disabled: { type: Boolean, reflect: true, }, opened: { type: Boolean, reflect: true, }, interactionMode: { type: String, attribute: 'interaction-mode', }, modelValue: { type: Array, }, name: { type: String, }, }; } static get styles() { return [ css` :host { display: block; } :host([disabled]) { color: #adadad; } `, ]; } static _isPrefilled(modelValue) { if (!modelValue) { return false; } const checkedModelValue = modelValue.find(subModelValue => subModelValue.checked === true); if (!checkedModelValue) { return false; } const { value } = checkedModelValue; return super._isPrefilled(value); } get slots() { return { ...super.slots, invoker: () => { return document.createElement('lion-select-invoker'); }, }; } get _invokerNode() { return this.querySelector('[slot=invoker]'); } get _listboxNode() { return this.querySelector('[slot=input]'); } get _listboxActiveDescendantNode() { return this._listboxNode.querySelector(`#${this._listboxActiveDescendant}`); } get checkedIndex() { if (this.modelValue) { return this.modelValue.findIndex(el => el.value === this.checkedValue); } return -1; } set checkedIndex(index) { if (this.formElements[index]) { this.formElements[index].checked = true; } } get activeIndex() { return this.formElements.findIndex(el => el.active === true); } set activeIndex(index) { if (this.formElements[index]) { this.formElements[index].active = true; } } constructor() { super(); this.interactionMode = 'auto'; this.disabled = false; this.opened = false; // for interaction states // we use a different event as 'model-value-changed' would bubble up from all options this._valueChangedEvent = 'select-model-value-changed'; this._listboxActiveDescendant = null; this.__hasInitialSelectedFormElement = false; this.__setupEventListeners(); } connectedCallback() { if (super.connectedCallback) { super.connectedCallback(); } this.__setupOverlay(); this.__setupInvokerNode(); this.__setupListboxNode(); } disconnectedCallback() { if (super.disconnectedCallback) { super.disconnectedCallback(); } this.__teardownEventListeners(); this.__teardownOverlay(); this.__teardownInvokerNode(); this.__teardownListboxNode(); } _requestUpdate(name, oldValue) { super._requestUpdate(name, oldValue); if ( name === 'checkedValue' && !this.__isSyncingCheckedAndModelValue && this.modelValue && this.modelValue.length > 0 ) { if (this.checkedIndex) { this.checkedIndex = this.checkedIndex; } } if (name === 'modelValue') { this.dispatchEvent(new CustomEvent('select-model-value-changed')); this.__onModelValueChanged(); } if (name === 'interactionMode') { if (this.interactionMode === 'auto') { this.interactionMode = detectInteractionMode(); } } } updated(changedProps) { super.updated(changedProps); if (changedProps.has('opened')) { if (this.opened) { this.__overlay.show(); } else { this.__overlay.hide(); } } if (changedProps.has('disabled')) { if (this.disabled) { this._invokerNode.makeRequestToBeDisabled(); this.__requestOptionsToBeDisabled(); } else { this._invokerNode.retractRequestToBeDisabled(); this.__retractRequestOptionsToBeDisabled(); } } } toggle() { this.opened = !this.opened; } /** * @override */ // eslint-disable-next-line inputGroupInputTemplate() { return html`
`; } /** * Overrides FormRegistrar adding to make sure children have specific default states when added * * @override * @param {*} child */ addFormElement(child) { super.addFormElement(child); // we need to adjust the elements being registered /* eslint-disable no-param-reassign */ child.id = child.id || `${this.localName}-option-${uuid()}`; if (this.disabled) { child.makeRequestToBeDisabled(); } // the first elements checked by default if (!this.__hasInitialSelectedFormElement && (!child.disabled || this.disabled)) { child.active = true; child.checked = true; this.__hasInitialSelectedFormElement = true; } this.__setAttributeForAllFormElements('aria-setsize', this.formElements.length); child.setAttribute('aria-posinset', this.formElements.length); this.__onChildModelValueChanged({ target: child }); this.resetInteractionState(); /* eslint-enable no-param-reassign */ } _getFromAllFormElements(property) { return this.formElements.map(e => e[property]); } /** * add same aria-label to invokerNode as inputElement * @override */ _onAriaLabelledbyChanged({ _ariaLabelledby }) { if (this.inputElement) { this.inputElement.setAttribute('aria-labelledby', _ariaLabelledby); } if (this._invokerNode) { this._invokerNode.setAttribute( 'aria-labelledby', `${_ariaLabelledby} ${this._invokerNode.id}`, ); } } /** * add same aria-label to invokerNode as inputElement * @override */ _onAriaDescribedbyChanged({ _ariaDescribedby }) { if (this.inputElement) { this.inputElement.setAttribute('aria-describedby', _ariaDescribedby); } if (this._invokerNode) { this._invokerNode.setAttribute('aria-describedby', _ariaDescribedby); } } __setupEventListeners() { this.__onChildActiveChanged = this.__onChildActiveChanged.bind(this); this.__onChildModelValueChanged = this.__onChildModelValueChanged.bind(this); this.__onKeyUp = this.__onKeyUp.bind(this); this.addEventListener('active-changed', this.__onChildActiveChanged); this.addEventListener('model-value-changed', this.__onChildModelValueChanged); this.addEventListener('keyup', this.__onKeyUp); } __teardownEventListeners() { this.removeEventListener('active-changed', this.__onChildActiveChanged); this.removeEventListener('model-value-changed', this.__onChildModelValueChanged); this.removeEventListener('keyup', this.__onKeyUp); } __onChildActiveChanged({ target }) { if (target.active === true) { this.formElements.forEach(formElement => { if (formElement !== target) { // eslint-disable-next-line no-param-reassign formElement.active = false; } }); this._listboxNode.setAttribute('aria-activedescendant', target.id); } } __setAttributeForAllFormElements(attribute, value) { this.formElements.forEach(formElement => { formElement.setAttribute(attribute, value); }); } __onChildModelValueChanged({ target }) { if (target.checked) { this.formElements.forEach(formElement => { if (formElement !== target) { // eslint-disable-next-line no-param-reassign formElement.checked = false; } }); } this.modelValue = this._getFromAllFormElements('modelValue'); } __onModelValueChanged() { this.__isSyncingCheckedAndModelValue = true; const foundChecked = this.modelValue.find(subModelValue => subModelValue.checked); if (foundChecked && foundChecked.value !== this.checkedValue) { this.checkedValue = foundChecked.value; // sync to invoker this._invokerNode.selectedElement = this.formElements[this.checkedIndex]; } this.__isSyncingCheckedAndModelValue = false; } __getNextEnabledOption(currentIndex, offset = 1) { for (let i = currentIndex + offset; i < this.formElements.length; i += 1) { if (this.formElements[i] && !this.formElements[i].disabled) { return i; } } return currentIndex; } __getPreviousEnabledOption(currentIndex, offset = -1) { for (let i = currentIndex + offset; i >= 0; i -= 1) { if (this.formElements[i] && !this.formElements[i].disabled) { return i; } } return currentIndex; } /** * @desc * Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects * an item. * * @param ev - the keydown event object */ __listboxOnKeyUp(ev) { if (this.disabled) { return; } const { key } = ev; switch (key) { case 'Escape': ev.preventDefault(); this.opened = false; break; case 'Enter': case ' ': ev.preventDefault(); if (this.interactionMode === 'mac') { this.checkedIndex = this.activeIndex; } this.opened = false; break; case 'ArrowUp': ev.preventDefault(); this.activeIndex = this.__getPreviousEnabledOption(this.activeIndex); break; case 'ArrowDown': ev.preventDefault(); this.activeIndex = this.__getNextEnabledOption(this.activeIndex); break; case 'Home': ev.preventDefault(); this.activeIndex = this.__getNextEnabledOption(0, 0); break; case 'End': ev.preventDefault(); this.activeIndex = this.__getPreviousEnabledOption(this.formElements.length - 1, 0); break; /* no default */ } const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End']; if (keys.includes(key) && this.interactionMode === 'windows/linux') { this.checkedIndex = this.activeIndex; } } __listboxOnKeyDown(ev) { if (this.disabled) { return; } const { key } = ev; switch (key) { case 'Tab': // Tab can only be caught in keydown ev.preventDefault(); this.opened = false; break; /* no default */ } } __onKeyUp(ev) { if (this.disabled) { return; } if (this.opened) { return; } const { key } = ev; switch (key) { case 'ArrowUp': ev.preventDefault(); if (this.interactionMode === 'mac') { this.opened = true; } else { this.checkedIndex = this.__getPreviousEnabledOption(this.checkedIndex); } break; case 'ArrowDown': ev.preventDefault(); if (this.interactionMode === 'mac') { this.opened = true; } else { this.checkedIndex = this.__getNextEnabledOption(this.checkedIndex); } break; /* no default */ } } __requestOptionsToBeDisabled() { this.formElements.forEach(el => { if (el.makeRequestToBeDisabled) { el.makeRequestToBeDisabled(); } }); } __retractRequestOptionsToBeDisabled() { this.formElements.forEach(el => { if (el.retractRequestToBeDisabled) { el.retractRequestToBeDisabled(); } }); } __setupInvokerNode() { this._invokerNode.id = `invoker-${this._inputId}`; this._invokerNode.setAttribute('aria-haspopup', 'listbox'); this.__setupInvokerNodeEventListener(); } __setupInvokerNodeEventListener() { this.__invokerOnClick = () => { if (!this.disabled) { this.toggle(); } }; this._invokerNode.addEventListener('click', this.__invokerOnClick); this.__invokerOnBlur = () => { this.dispatchEvent(new Event('blur')); }; this._invokerNode.addEventListener('blur', this.__invokerOnBlur); } __teardownInvokerNode() { this._invokerNode.removeEventListener('click', this.__invokerOnClick); this._invokerNode.removeEventListener('blur', this.__invokerOnBlur); } /** * For ShadyDom the listboxNode is available right from the start so we can add those events * immediately. * For native ShadowDom the select gets render before the listboxNode is available so we * will add an event to the slotchange and add the events once available. */ __setupListboxNode() { if (this._listboxNode) { this.__setupListboxNodeEventListener(); } else { const inputSlot = this.shadowRoot.querySelector('slot[name=input]'); if (inputSlot) { inputSlot.addEventListener('slotchange', () => { this.__setupListboxNodeEventListener(); }); } } } __setupListboxNodeEventListener() { this.__listboxOnClick = () => { this.opened = false; }; this._listboxNode.addEventListener('click', this.__listboxOnClick); this.__listboxOnKeyUp = this.__listboxOnKeyUp.bind(this); this._listboxNode.addEventListener('keyup', this.__listboxOnKeyUp); this.__listboxOnKeyDown = this.__listboxOnKeyDown.bind(this); this._listboxNode.addEventListener('keydown', this.__listboxOnKeyDown); } __teardownListboxNode() { if (this._listboxNode) { this._listboxNode.removeEventListener('click', this.__listboxOnClick); this._listboxNode.removeEventListener('keyup', this.__listboxOnKeyUp); this._listboxNode.removeEventListener('keydown', this.__listboxOnKeyDown); } } __setupOverlay() { this.__overlay = overlays.add( new LocalOverlayController({ contentNode: this._listboxNode, invokerNode: this._invokerNode, hidesOnEsc: false, hidesOnOutsideClick: true, inheritsReferenceObjectWidth: true, popperConfig: { placement: 'bottom-start', modifiers: { offset: { enabled: false, }, }, }, }), ); this.__overlayOnShow = () => { this.opened = true; if (this.checkedIndex) { this.activeIndex = this.checkedIndex; } this._listboxNode.focus(); }; this.__overlay.addEventListener('show', this.__overlayOnShow); this.__overlayOnHide = () => { this.opened = false; this._invokerNode.focus(); }; this.__overlay.addEventListener('hide', this.__overlayOnHide); } __teardownOverlay() { this.__overlay.removeEventListener('show', this.__overlayOnShow); this.__overlay.removeEventListener('hide', this.__overlayOnHide); } // eslint-disable-next-line class-methods-use-this __isRequired(modelValue) { const checkedModelValue = modelValue.find(subModelValue => subModelValue.checked === true); if (!checkedModelValue) { return { required: false }; } const { value } = checkedModelValue; return { required: (typeof value === 'string' && value !== '') || (typeof value !== 'string' && value !== undefined && value !== null), }; } }