import { css, dedupeMixin, html, ScopedElementsMixin, SlotMixin } from '@lion/core'; import '@lion/core/closestPolyfill'; import '@lion/core/differentKeyEventNamesShimIE'; import { ChoiceGroupMixin, FormControlMixin, FormRegistrarMixin } from '@lion/form-core'; import { LionOptions } from './LionOptions.js'; // TODO: extract ListNavigationWithActiveDescendantMixin that can be reused in [role="menu"] // having children with [role="menuitem|menuitemcheckbox|menuitemradio|option"] and // list items that can be found via MutationObserver or registration (.formElements) /** * @typedef {import('@lion/form-core/types/FormControlMixinTypes').HTMLElementWithValue} HTMLElementWithValue * @typedef {import('./LionOption').LionOption} LionOption * @typedef {import('../types/ListboxMixinTypes').ListboxMixin} ListboxMixin * @typedef {import('../types/ListboxMixinTypes').ListboxHost} ListboxHost * @typedef {import('@lion/form-core/types/registration/FormRegistrarPortalMixinTypes').FormRegistrarPortalHost} FormRegistrarPortalHost * @typedef {import('@lion/form-core/types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails */ function uuid() { return Math.random().toString(36).substr(2, 10); } /** * @param {HTMLElement} container * @param {HTMLElement} element * @param {Boolean} [partial] */ function isInView(container, element, partial = false) { const cTop = container.scrollTop; const cBottom = cTop + container.clientHeight; const eTop = element.offsetTop; const eBottom = eTop + element.clientHeight; const isTotal = eTop >= cTop && eBottom <= cBottom; let isPartial; if (partial === true) { isPartial = (eTop < cTop && eBottom > cTop) || (eBottom > cBottom && eTop < cBottom); } else if (typeof partial === 'number') { if (eTop < cTop && eBottom > cTop) { isPartial = ((eBottom - cTop) * 100) / element.clientHeight > partial; } else if (eBottom > cBottom && eTop < cBottom) { isPartial = ((cBottom - eTop) * 100) / element.clientHeight > partial; } } return isTotal || isPartial; } /** * @type {ListboxMixin} * @param {import('@open-wc/dedupe-mixin').Constructor} superclass */ const ListboxMixinImplementation = superclass => class ListboxMixin extends FormControlMixin( ScopedElementsMixin(ChoiceGroupMixin(SlotMixin(FormRegistrarMixin(superclass)))), ) { static get properties() { return { orientation: String, selectionFollowsFocus: { type: Boolean, attribute: 'selection-follows-focus', }, rotateKeyboardNavigation: { type: Boolean, attribute: 'rotate-keyboard-navigation', }, hasNoDefaultSelected: { type: Boolean, reflect: true, attribute: 'has-no-default-selected', }, }; } static get styles() { return [ ...(super.styles || []), css` :host { display: block; } :host([hidden]) { display: none; } :host([disabled]) { color: #adadad; } :host([orientation='horizontal']) ::slotted([role='listbox']) { display: flex; } `, ]; } /** * @override FormControlMixin * @protected */ // eslint-disable-next-line _inputGroupInputTemplate() { return html`
`; } static get scopedElements() { return { ...super.scopedElements, 'lion-options': LionOptions, }; } // @ts-ignore get slots() { return { ...super.slots, input: () => { const lionOptions = /** @type {HTMLElement & FormRegistrarPortalHost} */ (document.createElement( ListboxMixin.getScopedTagName('lion-options'), )); lionOptions.setAttribute('data-tag-name', 'lion-options'); lionOptions.registrationTarget = this; return lionOptions; }, }; } /** * @configure FormControlMixin */ get _inputNode() { return /** @type {HTMLElementWithValue} */ (this.querySelector('[slot="input"]')); } /** * @overridable */ get _listboxNode() { // Cast to unknown first, since HTMLElementWithValue is not compatible with LionOptions return /** @type {LionOptions} */ (/** @type {unknown} */ (this._inputNode)); } /** * @overridable * @type {HTMLElement} */ get _listboxActiveDescendantNode() { return /** @type {HTMLElement} */ (this._listboxNode.querySelector( `#${this._listboxActiveDescendant}`, )); } /** * @overridable * @type {HTMLElement} */ get _listboxSlot() { return /** @type {HTMLElement} */ ( /** @type {ShadowRoot} */ (this.shadowRoot).querySelector('slot[name=input]') ); } /** * @overridable * @type {HTMLElement} */ get _scrollTargetNode() { return this._listboxNode; } /** * @overridable * @type {HTMLElement} */ get _activeDescendantOwnerNode() { return this._listboxNode; } /** * @override ChoiceGroupMixin */ get serializedValue() { return this.modelValue; } // Duplicating from FormGroupMixin, because you cannot independently inherit/override getter + setter. // If you override one, gotta override the other, they go in pairs. /** * @override ChoiceGroupMixin */ set serializedValue(value) { super.serializedValue = value; } get activeIndex() { return this.formElements.findIndex(el => el.active === true); } set activeIndex(index) { if (this.formElements[index]) { const el = this.formElements[index]; this.__setChildActive(el); } else { this.__setChildActive(null); } } /** * @type {number | number[]} */ get checkedIndex() { const options = this.formElements; if (!this.multipleChoice) { return options.indexOf(options.find(o => o.checked)); } return options.filter(o => o.checked).map(o => options.indexOf(o)); } /** * @deprecated * This setter exists for backwards compatibility of single choice groups. * A setter api would be confusing for a multipleChoice group. Use `setCheckedIndex` instead. * @param {number|number[]} index */ set checkedIndex(index) { this.setCheckedIndex(index); } constructor() { super(); /** * When setting this to true, on initial render, no option will be selected. * It is advisable to override `_noSelectionTemplate` method in the select-invoker * to render some kind of placeholder initially */ this.hasNoDefaultSelected = false; /** * Informs screen reader and affects keyboard navigation. * By default 'vertical' */ this.orientation = 'vertical'; /** * Will give first option active state when navigated to the next option from * last option. */ this.rotateKeyboardNavigation = false; /** * When true, will synchronize activedescendant and selected element on * arrow key navigation. * This behavior can usually be seen on