import { ChoiceGroupMixin, FormControlMixin, FormRegistrarMixin } from '@lion/form-core'; import { css, html, dedupeMixin, ScopedElementsMixin, SlotMixin } from '@lion/core'; import '@lion/core/src/differentKeyEventNamesShimIE.js'; 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('./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 */ 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 [ css` :host { display: block; } :host([hidden]) { display: none; } :host([disabled]) { color: #adadad; } :host([orientation='horizontal']) ::slotted([role='listbox']) { display: flex; } `, ]; } static get scopedElements() { return { ...super.scopedElements, 'lion-options': LionOptions, }; } get slots() { return { ...super.slots, input: () => { const lionOptions = /** @type {HTMLElement & FormRegistrarPortalHost} */ (document.createElement( ListboxMixin.getScopedTagName('lion-options'), )); lionOptions.registrationTarget = this; return lionOptions; }, }; } get _listboxNode() { return /** @type {LionOptions} */ (this._inputNode); } get _listboxActiveDescendantNode() { return this._listboxNode.querySelector(`#${this._listboxActiveDescendant}`); } 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. set serializedValue(value) { super.serializedValue = value; } /** * @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} index */ set checkedIndex(index) { this.setCheckedIndex(index); } /** * When `multipleChoice` is false, will toggle, else will check provided index * @param {Number} index */ setCheckedIndex(index) { if (this.formElements[index]) { if (!this.multipleChoice) { this.formElements[index].checked = true; } else { this.formElements[index].checked = !this.formElements[index].checked; // __onChildCheckedChanged, which also responds to programmatic (model)value changes // of children, will do the rest } } } get activeIndex() { return this.formElements.findIndex(el => el.active === true); } get _scrollTargetNode() { return this._listboxNode; } set activeIndex(index) { if (this.formElements[index]) { const el = this.formElements[index]; el.active = true; if (!isInView(this._scrollTargetNode, el)) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } } constructor() { super(); // this.disabled = false; /** * When setting this to true, on initial render, no option will be selected. * It it 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