import { css, html } from 'lit'; import { SlotMixin, uuid } from '@lion/ui/core.js'; import { dedupeMixin } from '@open-wc/dedupe-mixin'; import { ChoiceGroupMixin, FormControlMixin, FormRegistrarMixin } from '@lion/ui/form-core.js'; import { ScopedElementsMixin } from '../../core/src/ScopedElementsMixin.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('../../form-core/types/FormControlMixinTypes.js').HTMLElementWithValue} HTMLElementWithValue * @typedef {import('../../form-core/types/FormControlMixinTypes.js').FormControlHost} FormControlHost * @typedef {import('./LionOption.js').LionOption} LionOption * @typedef {import('../types/ListboxMixinTypes.js').ListboxMixin} ListboxMixin * @typedef {import('../types/ListboxMixinTypes.js').ListboxHost} ListboxHost * @typedef {import('../../form-core/types/registration/FormRegistrarPortalMixinTypes.js').FormRegistrarPortalHost} FormRegistrarPortalHost * @typedef {import('../../form-core/types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails */ // TODO: consider adding methods below to @lion/helpers /** * Sometimes, we want to provide best DX (direct slottables) and be accessible * at the same time. * In the first example below, we need to wrap our options in light dom in an element with * [role=listbox]. We could achieve this via the second example, but it would affect our * public api negatively. not allowing us to be forward compatible with the AOM spec: * https://wicg.github.io/aom/explainer.html * With this method, it's possible to watch elements in the default slot and move them * to the desired target (the element with [role=listbox]) in light dom. * * @example * # desired api * * * * # desired end state * *
* *
*
* @param {HTMLElement} source host of ShadowRoot with default * @param {HTMLElement} target the desired target in light dom */ function moveDefaultSlottablesToTarget(source, target) { Array.from(source.childNodes).forEach((/** @type {* & Element} */ c) => { const isNamedSlottable = c.hasAttribute && c.hasAttribute('slot'); if (!isNamedSlottable) { target.appendChild(c); } }); } /** * @type {ListboxMixin} * @param {import('@open-wc/dedupe-mixin').Constructor} superclass */ const ListboxMixinImplementation = superclass => // @ts-ignore https://github.com/microsoft/TypeScript/issues/36821#issuecomment-588375051 class ListboxMixin extends FormControlMixin( ScopedElementsMixin(ChoiceGroupMixin(SlotMixin(FormRegistrarMixin(superclass)))), ) { /** @type {any} */ 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', }, _noTypeAhead: { type: Boolean, }, }; } 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 { // @ts-expect-error [external] fix types scopedElements ...super.scopedElements, 'lion-options': LionOptions, }; } get slots() { return { ...super.slots, input: () => { const lionOptions = /** @type {import('./LionOptions.js').LionOptions} */ ( this.createScopedElement('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; } 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