import { LionListbox } from '@lion/listbox'; import { html, ScopedElementsMixin, SlotMixin, browserDetection } from '@lion/core'; import { OverlayMixin, withDropdownConfig } from '@lion/overlays'; import '@lion/core/differentKeyEventNamesShimIE'; import { LionSelectInvoker } from './LionSelectInvoker.js'; /** * @typedef {import('@lion/listbox').LionOptions} LionOptions * @typedef {import('@lion/listbox').LionOption} LionOption * @typedef {import('@open-wc/scoped-elements/src/types').ScopedElementsHost} ScopedElementsHost * @typedef {import('@lion/form-core/types/registration/FormRegisteringMixinTypes').FormRegisteringHost} FormRegisteringHost * @typedef {import('@lion/form-core/types/FormControlMixinTypes').FormControlHost} FormControlHost * @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap */ function detectInteractionMode() { if (browserDetection.isMac) { return 'mac'; } return 'windows/linux'; } /** * LionSelectRich: wraps the element */ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(LionListbox))) { static get scopedElements() { return { ...super.scopedElements, 'lion-select-invoker': LionSelectInvoker, }; } /** @type {any} */ static get properties() { return { navigateWithinInvoker: { type: Boolean, attribute: 'navigate-within-invoker', }, interactionMode: { type: String, attribute: 'interaction-mode', }, singleOption: { type: Boolean, reflect: true, attribute: 'single-option', }, }; } /** * @enhance FormControlMixin * @protected */ // eslint-disable-next-line class-methods-use-this _inputGroupInputTemplate() { return html`
`; } get slots() { return { ...super.slots, invoker: () => { // @ts-ignore we load a polyfill to support createElement on shadowRoot const invokerEl = this.shadowRoot.createElement('lion-select-invoker'); invokerEl.setAttribute('data-tag-name', 'lion-select-invoker'); return invokerEl; }, }; } /** * @protected * @type {LionSelectInvoker} */ get _invokerNode() { return /** @type {LionSelectInvoker} */ ( Array.from(this.children).find(child => child.slot === 'invoker') ); } /** * @configure ListboxMixin * @protected */ get _scrollTargetNode() { // TODO: should this be defined here or in extension layer? return /** @type {HTMLElement} */ ( /** @type {HTMLElement & {_scrollTargetNode?: HTMLElement}} */ (this._overlayContentNode) ._scrollTargetNode || this._overlayContentNode ); } constructor() { super(); /** * When invoker has focus, up and down arrow keys changes active state of listbox, * without opening overlay. * @type {Boolean} */ this.navigateWithinInvoker = false; /** * Aligns behavior for 'selectionFollowFocus' and 'navigateWithinInvoker' with * platform. When 'auto' (default), platform is automatically detected * @type {'windows/linux'|'mac'|'auto'} */ this.interactionMode = 'auto'; this.singleOption = false; /** @protected */ this._arrowWidth = 28; /** @private */ this.__onKeyUp = this.__onKeyUp.bind(this); /** @private */ this.__invokerOnBlur = this.__invokerOnBlur.bind(this); /** @private */ this.__overlayOnHide = this.__overlayOnHide.bind(this); /** @private */ this.__overlayOnShow = this.__overlayOnShow.bind(this); /** @private */ this.__invokerOnClick = this.__invokerOnClick.bind(this); /** @private */ this.__overlayBeforeShow = this.__overlayBeforeShow.bind(this); /** @protected */ this._listboxOnClick = this._listboxOnClick.bind(this); } connectedCallback() { super.connectedCallback(); this._invokerNode.selectedElement = this.formElements[/** @type {number} */ (this.checkedIndex)]; this.__setupInvokerNode(); this.__toggleInvokerDisabled(); this.addEventListener('keyup', this.__onKeyUp); } disconnectedCallback() { super.disconnectedCallback(); this.__teardownInvokerNode(); this.removeEventListener('keyup', this.__onKeyUp); } /** * @param {string} name * @param {unknown} oldValue */ requestUpdate(name, oldValue) { super.requestUpdate(name, oldValue); if (name === 'interactionMode') { if (this.interactionMode === 'auto') { this.interactionMode = detectInteractionMode(); } else { this.selectionFollowsFocus = Boolean(this.interactionMode === 'windows/linux'); this.navigateWithinInvoker = Boolean(this.interactionMode === 'windows/linux'); } } if (name === 'disabled' || name === 'readOnly') { this.__toggleInvokerDisabled(); } } /** * @param {import('@lion/core').PropertyValues } changedProperties */ updated(changedProperties) { super.updated(changedProperties); if (changedProperties.has('disabled')) { if (this.disabled) { this._invokerNode.makeRequestToBeDisabled(); } else { this._invokerNode.retractRequestToBeDisabled(); } } if (this._inputNode && this._invokerNode) { if (changedProperties.has('_ariaLabelledNodes')) { this._invokerNode.setAttribute( 'aria-labelledby', `${this._inputNode.getAttribute('aria-labelledby')} ${this._invokerNode.id}`, ); } if (changedProperties.has('_ariaDescribedNodes')) { this._invokerNode.setAttribute( 'aria-describedby', /** @type {string} */ (this._inputNode.getAttribute('aria-describedby')), ); } if (changedProperties.has('showsFeedbackFor')) { // The ValidateMixin sets aria-invalid on the inputNode, but in this component we also need it on the invoker this._invokerNode.setAttribute('aria-invalid', `${this._hasFeedbackVisibleFor('error')}`); } } if (changedProperties.has('modelValue')) { this.__syncInvokerElement(); } } /** * @enhance FprmRegistrarMixin make sure children have specific default states when added * @param {LionOption & FormControlHost} child * @param {Number} indexToInsertAt */ addFormElement(child, indexToInsertAt) { super.addFormElement(child, indexToInsertAt); // the first elements checked by default if ( !this.hasNoDefaultSelected && !this.__hasInitialSelectedFormElement && (!child.disabled || this.disabled) ) { /* eslint-disable no-param-reassign */ child.active = true; child.checked = true; /* eslint-enable no-param-reassign */ this.__hasInitialSelectedFormElement = true; } this._alignInvokerWidth(); this._onFormElementsChanged(); } /** * @enhance FprmRegistrarMixin * @param {FormRegisteringHost} child the child element (field) */ removeFormElement(child) { super.removeFormElement(child); this._alignInvokerWidth(); this._onFormElementsChanged(); } // TODO: move to overlayMixin and offer open and close toggle() { this.opened = !this.opened; } /** * In the select disabled options are still going to a possible value for example * when prefilling or programmatically setting it. * @override ChoiceGroupMixin * @protected */ _getCheckedElements() { return this.formElements.filter(el => el.checked); } /** @protected */ _onFormElementsChanged() { this.singleOption = this.formElements.length === 1; this._invokerNode.singleOption = this.singleOption; } /** @private */ __initInteractionStates() { this.initInteractionState(); } /** @private */ __toggleInvokerDisabled() { if (this._invokerNode) { this._invokerNode.disabled = this.disabled; this._invokerNode.readOnly = this.readOnly; } } /** @private */ __syncInvokerElement() { // sync to invoker if (this._invokerNode) { this._invokerNode.selectedElement = this.formElements[/** @type {number} */ (this.checkedIndex)]; /** * Manually update this, as the node reference may be the same, but the modelValue might not. * This would mean that it won't pass the LitElement dirty check. * hasChanged in selectedElement won't work, since the oldValue and the newValue's modelValues will be the same, * as they are referenced through the same node reference. */ this._invokerNode.requestUpdate('selectedElement'); } } /** @private */ __setupInvokerNode() { this._invokerNode.id = `invoker-${this._inputId}`; this._invokerNode.setAttribute('aria-haspopup', 'listbox'); this.__setupInvokerNodeEventListener(); } /** @private */ __invokerOnClick() { if (!this.disabled && !this.readOnly && !this.singleOption && !this.__blockListShow) { this._overlayCtrl.toggle(); } } /** @private */ __invokerOnBlur() { this.dispatchEvent(new Event('blur')); } /** @private */ __setupInvokerNodeEventListener() { this._invokerNode.addEventListener('click', this.__invokerOnClick); this._invokerNode.addEventListener('blur', this.__invokerOnBlur); } /** @private */ __teardownInvokerNode() { this._invokerNode.removeEventListener('click', this.__invokerOnClick); this._invokerNode.removeEventListener('blur', this.__invokerOnBlur); } /** * @configure OverlayMixin * @protected */ // eslint-disable-next-line class-methods-use-this _defineOverlayConfig() { return { ...withDropdownConfig(), }; } /** * With no selected element, we should override the inheritsReferenceWidth in most cases. * By default, we will set it to 'min', and then set it back to what it was initially when * something is selected. * As a subclasser you can override this behavior. * @protected */ _noDefaultSelectedInheritsWidth() { if (this.checkedIndex === -1) { this._overlayCtrl.updateConfig({ inheritsReferenceWidth: 'min' }); } else { this._overlayCtrl.updateConfig({ inheritsReferenceWidth: this._initialInheritsReferenceWidth, }); } } /** @private */ __overlayBeforeShow() { if (this.hasNoDefaultSelected) { this._noDefaultSelectedInheritsWidth(); } } /** @private */ __overlayOnShow() { if (this.checkedIndex != null) { this.activeIndex = /** @type {number} */ (this.checkedIndex); } this._listboxNode.focus(); } /** @private */ __overlayOnHide() { this._invokerNode.focus(); } /** * @enhance OverlayMixin * @protected */ _setupOverlayCtrl() { super._setupOverlayCtrl(); this._initialInheritsReferenceWidth = this._overlayCtrl.inheritsReferenceWidth; this._alignInvokerWidth(); this._overlayCtrl.addEventListener('before-show', this.__overlayBeforeShow); this._overlayCtrl.addEventListener('show', this.__overlayOnShow); this._overlayCtrl.addEventListener('hide', this.__overlayOnHide); } /** * @enhance OverlayMixin * @protected */ _teardownOverlayCtrl() { super._teardownOverlayCtrl(); this._overlayCtrl.removeEventListener('show', this.__overlayOnShow); this._overlayCtrl.removeEventListener('before-show', this.__overlayBeforeShow); this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide); } /** * Align invoker width with content width * Make sure display is not set to "none" while calculating the content width * @protected */ async _alignInvokerWidth() { if (this._overlayCtrl && this._overlayCtrl.content) { await this.updateComplete; const initContentDisplay = this._overlayCtrl.content.style.display; const initContentMinWidth = this._overlayCtrl.content.style.minWidth; const initContentWidth = this._overlayCtrl.content.style.width; this._overlayCtrl.content.style.display = ''; this._overlayCtrl.content.style.minWidth = 'auto'; this._overlayCtrl.content.style.width = 'auto'; const contentWidth = this._overlayCtrl.content.getBoundingClientRect().width; /** * TODO when inside an overlay the current solution doesn't work. * Since that dialog is still hidden, open and close the select-rich * doesn't have any effect so the contentWidth returns 0 */ if (contentWidth > 0) { this._invokerNode.style.width = `${contentWidth + this._arrowWidth}px`; } this._overlayCtrl.content.style.display = initContentDisplay; this._overlayCtrl.content.style.minWidth = initContentMinWidth; this._overlayCtrl.content.style.width = initContentWidth; } } /** * @configure FormControlMixin * @protected */ _onLabelClick() { this._invokerNode.focus(); } /** * @configure OverlayMixin * @protected */ get _overlayInvokerNode() { return this._invokerNode; } /** * @configure OverlayMixin * @protected */ get _overlayContentNode() { return this._listboxNode; } /** * @param {KeyboardEvent} ev * @protected */ // TODO: rename to _onKeyUp in v1 __onKeyUp(ev) { if (this.disabled) { return; } if (this.opened) { return; } const { key } = ev; switch (key) { case 'ArrowUp': ev.preventDefault(); if (this.navigateWithinInvoker) { this.setCheckedIndex( this._getPreviousEnabledOption(/** @type {number} */ (this.checkedIndex)), ); } else { this.opened = true; } break; case 'ArrowDown': ev.preventDefault(); if (this.navigateWithinInvoker) { this.setCheckedIndex( this._getNextEnabledOption(/** @type {number} */ (this.checkedIndex)), ); } else { this.opened = true; } break; /* no default */ } } /** * @desc * Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects * an item. * * @param {KeyboardEvent} ev - the keydown event object * @protected */ _listboxOnKeyDown(ev) { super._listboxOnKeyDown(ev); if (this.disabled) { return; } const { key } = ev; switch (key) { case 'Tab': // Tab can only be caught in keydown this.opened = false; break; /* no default */ case 'Escape': this.opened = false; this.__blockListShowDuringTransition(); break; case 'Enter': case ' ': this.opened = false; this.__blockListShowDuringTransition(); break; /* no default */ } } /** @protected */ _listboxOnClick() { this.opened = false; } /** @protected */ _setupListboxNode() { super._setupListboxNode(); this._listboxNode.addEventListener('click', this._listboxOnClick); } /** @protected */ _teardownListboxNode() { super._teardownListboxNode(); if (this._listboxNode) { this._listboxNode.removeEventListener('click', this._listboxOnClick); } } /** * Normally, when textbox gets focus or a char is typed, it opens listbox. * In transition phases (like clicking option) we prevent this. * @private */ __blockListShowDuringTransition() { this.__blockListShow = true; // We need this timeout to make sure click handler triggered by keyup (space/enter) of // button has been executed. // TODO: alternative would be to let the 'checking' party 'release' this boolean // Or: call 'stopPropagation' on keyup of keys that have been handled in keydown setTimeout(() => { this.__blockListShow = false; }, 200); } }