diff --git a/libs/components/src/lib/option/option.ts b/libs/components/src/lib/option/option.ts index 547bb64ca1..04c2a11d25 100644 --- a/libs/components/src/lib/option/option.ts +++ b/libs/components/src/lib/option/option.ts @@ -1,8 +1,22 @@ import { applyMixins, FoundationElement } from '@microsoft/fast-foundation'; import { attr, observable, Observable } from '@microsoft/fast-element'; +import { isHTMLElement } from '@microsoft/fast-web-utilities'; import { AffixIconWithTrailing } from '../../shared/patterns/affix'; import { ARIAGlobalStatesAndProperties } from '../../shared/foundation/patterns/aria-global'; +/** + * Determines if the element is a {@link (ListboxOption:class)} + * + * @param element - the element to test. + * @public + */ +export function isListboxOption(el: Element): el is ListboxOption { + return ( + isHTMLElement(el) && + ((el.getAttribute('role') as string) === 'option' || + el instanceof HTMLOptionElement) + ); +} /** * An Option Custom HTML Element. * Implements {@link https://www.w3.org/TR/wai-aria-1.1/#option | ARIA option }. diff --git a/libs/components/src/lib/select/select.form-associated.ts b/libs/components/src/lib/select/select.form-associated.ts new file mode 100644 index 0000000000..2e882b7b6b --- /dev/null +++ b/libs/components/src/lib/select/select.form-associated.ts @@ -0,0 +1,10 @@ +import { FormAssociated } from '@microsoft/fast-foundation'; +import { Listbox } from '../../shared/foundation/listbox/listbox'; + +class _Select extends Listbox {} +// eslint-disable-next-line @typescript-eslint/naming-convention +interface _Select extends FormAssociated {} + +export class FormAssociatedSelect extends FormAssociated(_Select) { + proxy = document.createElement('select'); +} diff --git a/libs/components/src/lib/select/select.spec.ts b/libs/components/src/lib/select/select.spec.ts index 0b89245969..637f655bc9 100644 --- a/libs/components/src/lib/select/select.spec.ts +++ b/libs/components/src/lib/select/select.spec.ts @@ -6,7 +6,16 @@ import { fixture, getControlElement, } from '@vivid-nx/shared'; +import { + keyArrowDown, + keyArrowUp, + keyEnd, + keyEscape, + keyHome, + keyTab, +} from '@microsoft/fast-web-utilities'; import { Size } from '../enums'; +import { ListboxOption } from '../option/option.ts'; import { Select } from './select'; import '.'; @@ -16,6 +25,32 @@ const ICON_SELECTOR = 'vwc-icon'; describe('vwc-select', () => { let originalScrollIntoView: any; + /** + * Get the "checked options", which are visually highlighted options in multi-select mode + */ + const getCheckedOptions = () => + Array.from( + element.querySelectorAll('vwc-option[aria-checked="true"]') + ) as ListboxOption[]; + + /** + * Active option is the last checked option in multi-select mode + */ + const getActiveOption = () => { + const checkedOptions = getCheckedOptions(); + if (checkedOptions.length === 0) { + return null; + } else if (checkedOptions.length === 1) { + return checkedOptions[0]; + } else { + // We do not know which one was last checked + throw new Error('Unable to determine active option'); + } + }; + + const getOption = (value: string) => + element.querySelector(`vwc-option[value="${value}"]`) as ListboxOption; + let element: Select; beforeAll(() => { @@ -122,9 +157,9 @@ describe('vwc-select', () => { describe('selectedIndex', () => { beforeEach(async () => { element.innerHTML = ` - - - + + + `; await elementUpdated(element); }); @@ -137,9 +172,33 @@ describe('vwc-select', () => { it('should change selection when changed', async () => { element.selectedIndex = 2; await elementUpdated(element); - expect(element.selectedOptions).toEqual([ - element.querySelector('option:nth-child(3)'), - ]); + expect(element.selectedOptions).toEqual([getOption('3')]); + }); + + it('should update value when selectedIndex is set', async () => { + element.selectedIndex = 2; + await elementUpdated(element); + expect(element.value).toEqual('3'); + }); + + it('should choose the next selectable option when selecting a disabled option', async () => { + getOption('2').disabled = true; + element.selectedIndex = 1; + expect(element.selectedIndex).toEqual(2); + }); + + it('should search for the next selectable option in reverse order when the new index ist smaller than the current one', async () => { + getOption('2').disabled = true; + element.selectedIndex = 2; + element.selectedIndex = 1; + expect(element.selectedIndex).toEqual(0); + }); + + it('should revert to the previous value if no selectable option can be found', async () => { + getOption('3').disabled = true; + element.selectedIndex = 1; + element.selectedIndex = 2; + expect(element.selectedIndex).toEqual(1); }); }); @@ -239,17 +298,6 @@ describe('vwc-select', () => { }); describe('multiple', () => { - it('should set multiple attribute on the element', async () => { - const multipleAttributeExistsWithMultipleFalse = - element.hasAttribute('multiple'); - - element.multiple = true; - await elementUpdated(element); - - expect(multipleAttributeExistsWithMultipleFalse).toBeFalsy(); - expect(element.hasAttribute('multiple')).toBeTruthy(); - }); - it('should leave popup open when set', async function () { const popup = element.shadowRoot?.querySelector('.popup'); @@ -268,7 +316,7 @@ describe('vwc-select', () => { }); describe('open', function () { - it('should set open when clicked', async () => { + it('should toggle open when clicked', async () => { const openStateBeforeClick = element.open; element.click(); @@ -277,6 +325,26 @@ describe('vwc-select', () => { expect(openStateBeforeClick).toEqual(false); expect(element.open).toEqual(true); }); + + it('should not toggle open when clicked while disabled', async () => { + element.disabled = true; + + element.click(); + + expect(element.open).toBe(false); + }); + + it('should not toggle open when clicking on a disabled option', async () => { + element.innerHTML = ` + + `; + element.open = true; + await elementUpdated(element); + + (element.querySelector('vwc-option') as HTMLElement).click(); + + expect(element.open).toBe(true); + }); }); describe('appearance', function () { @@ -727,6 +795,415 @@ describe('vwc-select', () => { }); }); + describe('value', function () { + it("should change when the selected option's value changes", async () => { + element.innerHTML = ` + + `; + await elementUpdated(element); + + getOption('1').value = 'changed'; + + expect(element.value).toBe('changed'); + }); + + it('should select an option when selected is set on it', async () => { + element.innerHTML = ` + + + `; + await elementUpdated(element); + + getOption('2').selected = true; + + expect(element.value).toBe('2'); + }); + }); + + describe('single select mode', () => { + beforeEach(() => { + element.innerHTML = ` + + + + `; + }); + + it('should ignore key presses when disabled', async () => { + element.disabled = true; + + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyEnd })); + + expect(element.selectedIndex).toBe(0); + }); + + it('should select the first option when pressing home', async () => { + element.selectedIndex = 1; + + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyHome })); + + expect(element.selectedIndex).toBe(0); + }); + + it('should select the last option when pressing end', async () => { + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyEnd })); + + expect(element.selectedIndex).toBe(2); + }); + + it('should select the next option when pressing ArrowDown', async () => { + element.focus(); + element.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowDown }) + ); + + expect(element.selectedIndex).toBe(1); + }); + + it('should select the previous option when pressing ArrowUp', async () => { + element.selectedIndex = 1; + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyArrowUp })); + + expect(element.selectedIndex).toBe(0); + }); + + describe('when closed', () => { + it.each(['input', 'change'])( + `should emit %s event when selecting an option with the keyboard`, + async (eventName) => { + const eventSpy = jest.fn(); + element.addEventListener(eventName, eventSpy); + + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyEnd })); + + expect(eventSpy).toHaveBeenCalledTimes(1); + } + ); + }); + + describe('when open', () => { + beforeEach(async () => { + element.open = true; + await elementUpdated(element); + }); + + it.each(['input', 'change'])( + `should emit %s event only once the select closes`, + async (eventName) => { + const eventSpy = jest.fn(); + element.addEventListener(eventName, eventSpy); + + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyEnd })); + + expect(eventSpy).toHaveBeenCalledTimes(0); + + element.open = false; + + expect(eventSpy).toHaveBeenCalledTimes(1); + } + ); + + it(`should select an option by clicking on it`, async () => { + getOption('3').click(); + + expect(element.value).toBe('3'); + }); + + it(`should close after selecting an option by clicking on it`, async () => { + getOption('3').click(); + + expect(element.open).toBe(false); + }); + + it.each(['input', 'change'])( + `should emit %s event when selecting an option by clicking on it`, + async (eventName) => { + const eventSpy = jest.fn(); + element.addEventListener(eventName, eventSpy); + + getOption('3').click(); + + expect(eventSpy).toHaveBeenCalledTimes(1); + } + ); + }); + }); + + describe('multiple select mode', () => { + beforeEach(async () => { + element.multiple = true; + element.innerHTML = ` + + + + `; + await elementUpdated(element); + }); + + it('should initially not have an active option', () => { + expect(getActiveOption()).toBeNull(); + }); + + it('should make an option active when clicking on it', async () => { + getOption('1').click(); + await elementUpdated(element); + + expect(getActiveOption()).toBe(getOption('1')); + }); + + it('should make first option active when pressing Home', async () => { + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyHome })); + await elementUpdated(element); + + expect(getActiveOption()).toBe(getOption('1')); + }); + + it('should check all options from the active to first when pressing Shift + Home', async () => { + getOption('3').click(); + + element.focus(); + element.dispatchEvent( + new KeyboardEvent('keydown', { key: keyHome, shiftKey: true }) + ); + await elementUpdated(element); + + expect(getCheckedOptions()).toEqual([ + getOption('1'), + getOption('2'), + getOption('3'), + ]); + }); + + it('should make last option active when pressing End', async () => { + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyEnd })); + await elementUpdated(element); + + expect(getActiveOption()).toBe(getOption('3')); + }); + + it('should check all options from active to last when pressing Shift + End', async () => { + getOption('1').click(); + + element.focus(); + element.dispatchEvent( + new KeyboardEvent('keydown', { key: keyEnd, shiftKey: true }) + ); + await elementUpdated(element); + + expect(getCheckedOptions()).toEqual([ + getOption('1'), + getOption('2'), + getOption('3'), + ]); + }); + + it('should make the next option active when pressing ArrowDown', async () => { + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyHome })); + element.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowDown }) + ); + await elementUpdated(element); + + expect(getActiveOption()).toBe(getOption('2')); + }); + + it('should stay on the last option when pressing ArrowDown', async () => { + getOption('3').click(); + + element.focus(); + element.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowDown }) + ); + await elementUpdated(element); + + expect(getActiveOption()).toBe(getOption('3')); + }); + + it('should check the next option when pressing Shift + ArrowDown', async () => { + getOption('1').click(); + + element.focus(); + element.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowDown, shiftKey: true }) + ); + await elementUpdated(element); + + expect(getCheckedOptions()).toEqual([getOption('1'), getOption('2')]); + }); + + it('should make the previous option active when pressing ArrowUp', async () => { + getOption('3').click(); + + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyArrowUp })); + await elementUpdated(element); + + expect(getActiveOption()).toBe(getOption('2')); + }); + + it('should stay on the first option when pressing ArrowUp', async () => { + getOption('1').click(); + + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyArrowUp })); + await elementUpdated(element); + + expect(getActiveOption()).toBe(getOption('1')); + }); + + it('should check the previous option when pressing Shift + ArrowUp', async () => { + getOption('3').click(); + + element.focus(); + element.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowUp, shiftKey: true }) + ); + await elementUpdated(element); + + expect(getCheckedOptions()).toEqual([getOption('2'), getOption('3')]); + }); + + it('should toggle the selected state of the active option when pressing space', async () => { + getOption('1').click(); + await elementUpdated(element); + + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + + expect(element.selectedOptions).toEqual([]); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + + expect(element.selectedOptions).toEqual([getOption('1')]); + }); + + it.each(['input', 'change'])( + 'should emit %s event when toggling an option with the keyboard', + async (eventName) => { + const eventSpy = jest.fn(); + element.addEventListener(eventName, eventSpy); + + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyHome })); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + await elementUpdated(element); + + expect(eventSpy).toHaveBeenCalledTimes(1); + } + ); + + it('should toggle the selected state of an option when clicking on it', () => { + getOption('1').click(); + expect(element.selectedOptions).toEqual([getOption('1')]); + + getOption('1').click(); + expect(element.selectedOptions).toEqual([]); + }); + + it.each(['input', 'change'])( + 'should emit %s event when toggling an option by clicking', + (eventName) => { + const eventSpy = jest.fn(); + element.addEventListener(eventName, eventSpy); + + getOption('1').click(); + + expect(eventSpy).toHaveBeenCalledTimes(1); + } + ); + + it('should uncheck all options on blur', async () => { + getOption('1').click(); + element.focus(); + element.dispatchEvent( + new KeyboardEvent('keydown', { key: keyEnd, shiftKey: true }) + ); + await elementUpdated(element); + + element.blur(); + await elementUpdated(element); + + expect(getCheckedOptions()).toEqual([]); + }); + + it('should uncheck all options when multiple is changed to false', async () => { + getOption('1').click(); + element.focus(); + element.dispatchEvent( + new KeyboardEvent('keydown', { key: keyEnd, shiftKey: true }) + ); + await elementUpdated(element); + + element.multiple = false; + await elementUpdated(element); + + expect(getCheckedOptions()).toEqual([]); + }); + + it('should uncheck all options except the active one when pressing Escape', async () => { + getOption('1').click(); + element.focus(); + element.dispatchEvent( + new KeyboardEvent('keydown', { key: keyEnd, shiftKey: true }) + ); + await elementUpdated(element); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyEscape })); + await elementUpdated(element); + + expect(getActiveOption()).toBe(getOption('3')); + }); + + it('should ignore key presses when disabled', async () => { + element.disabled = true; + await elementUpdated(element); + + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyHome })); + await elementUpdated(element); + + expect(getActiveOption()).toBe(null); + }); + + it('should scroll the active option into view when pressing Tab', async () => { + getOption('1').click(); + element.focus(); + await elementUpdated(element); + getOption('1').scrollIntoView = jest.fn(); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyTab })); + await elementUpdated(element); + + expect(getOption('1').scrollIntoView).toHaveBeenCalledTimes(1); + }); + + it('should prevent focus when clicking on the scrollbar', () => { + Object.defineProperty( + element.shadowRoot!.querySelector('.listbox'), + 'scrollWidth', + { + get: () => 100, + } + ); + const mousedown = new MouseEvent('mousedown', { cancelable: true }); + Object.defineProperty(mousedown, 'offsetX', { value: 101 }); + + element.dispatchEvent(mousedown); + + expect(mousedown.defaultPrevented).toBe(true); + }); + }); + describe('feedback messages', () => { it('should ignore events when triggered on feedback messages', async () => { element.helperText = 'helper text'; @@ -740,6 +1217,72 @@ describe('vwc-select', () => { }); }); + describe('typeahead', () => { + beforeEach(async () => { + element = (await fixture(`<${COMPONENT_TAG}> + + + + `)) as Select; + }); + + it('should select the first option that starts with the typed character', async () => { + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'n' })); + + expect(element.selectedIndex).toBe(2); + }); + + it('should select the first option that starts with multiple typed characters', async () => { + element.focus(); + await elementUpdated(element); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'n' })); + await elementUpdated(element); + + expect(element.selectedIndex).toBe(1); + }); + + it('should reset the typeahead after 5 seconds of no key presses', async () => { + element.focus(); + await elementUpdated(element); + + jest.useFakeTimers(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + + jest.advanceTimersByTime(5000); + jest.useRealTimers(); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'n' })); + + expect(element.selectedIndex).toBe(2); + }); + + it('should make options active instead of selecting them when multiple is true', async () => { + element.multiple = true; + await elementUpdated(element); + + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'n' })); + await elementUpdated(element); + + expect(element.selectedIndex).toBe(0); + expect(getActiveOption()?.value).toBe('3'); + }); + }); + + it('should not add non-option children to the proxy options', async () => { + element.innerHTML = ` + + +
Div option
+ `; + await elementUpdated(element); + + expect(element.proxy.options.length).toBe(2); + }); + describe('a11y', () => { it('should pass html a11y test', async () => { element.innerHTML = ` diff --git a/libs/components/src/lib/select/select.ts b/libs/components/src/lib/select/select.ts index c83580dc4e..fc1c772510 100644 --- a/libs/components/src/lib/select/select.ts +++ b/libs/components/src/lib/select/select.ts @@ -1,5 +1,22 @@ -import { Select as FoundationSelect } from '@microsoft/fast-foundation'; -import { attr, observable, Observable } from '@microsoft/fast-element'; +import { + attr, + DOM, + observable, + Observable, + volatile, +} from '@microsoft/fast-element'; +import { + inRange, + keyArrowDown, + keyArrowUp, + keyEnd, + keyEnter, + keyEscape, + keyHome, + keySpace, + keyTab, + uniqueId, +} from '@microsoft/fast-web-utilities'; import { AffixIconWithTrailing, errorText, @@ -13,6 +30,7 @@ import type { Appearance, Shape, Size } from '../enums'; import type { ListboxOption } from '../option/option'; import { Listbox } from '../listbox/listbox'; import { applyMixinsWithObservables } from '../../shared/utils/applyMixinsWithObservables'; +import { FormAssociatedSelect } from './select.form-associated'; export type SelectAppearance = Extract< Appearance, @@ -34,7 +52,804 @@ export type SelectSize = Extract; */ @errorText @formElements -export class Select extends FoundationSelect { +export class Select extends FormAssociatedSelect { + /** + * The index of the most recently checked option. + * + * @internal + * @remarks + * Multiple-selection mode only. + */ + @observable + protected activeIndex = -1; + + /** + * Returns the last checked option. + * + * @internal + */ + get activeOption(): ListboxOption | null { + return this.options[this.activeIndex]; + } + + /** + * Returns the list of checked options. + * + * @internal + */ + protected get checkedOptions(): ListboxOption[] { + return this.options.filter((o) => o.checked); + } + + /** + * Returns the index of the first selected option. + * + * @internal + */ + get firstSelectedOptionIndex(): number { + return this.options.indexOf(this.firstSelectedOption); + } + + /** + * Indicates if the listbox is in multi-selection mode. + * + * @remarks + * HTML Attribute: `multiple` + * + * @public + */ + @attr({ mode: 'boolean' }) + // @ts-expect-error Type is incorrectly non-optional + multiple: boolean; + + /** + * The start index when checking a range of options. + * + * @internal + */ + protected rangeStartIndex = -1; + + /** + * Updates the `ariaActiveDescendant` property when the active index changes. + * + * @internal + */ + protected activeIndexChanged(_: number | undefined, next: number): void { + this.ariaActiveDescendant = this.options[next]?.id ?? ''; + this.focusAndScrollOptionIntoView(); + } + + /** + * Toggles the checked state for the currently active option. + * + * @remarks + * Multiple-selection mode only. + * + * @internal + */ + protected checkActiveIndex() { + const activeItem = this.activeOption; + if (activeItem) { + activeItem.checked = true; + } + } + + /** + * Sets the active index to the first option and marks it as checked. + * + * @remarks + * Multi-selection mode only. + * + * @param preserveChecked - mark all options unchecked before changing the active index + * + * @internal + */ + protected checkFirstOption(preserveChecked: boolean) { + if (preserveChecked) { + if (this.rangeStartIndex === -1) { + this.rangeStartIndex = this.activeIndex + 1; + } + + this.options.forEach((o, i) => { + o.checked = inRange(i, this.rangeStartIndex); + }); + } else { + this.uncheckAllOptions(); + } + + this.activeIndex = 0; + this.checkActiveIndex(); + } + + /** + * Decrements the active index and sets the matching option as checked. + * + * @remarks + * Multi-selection mode only. + * + * @param preserveChecked - mark all options unchecked before changing the active index + * + * @internal + */ + protected checkLastOption(preserveChecked: boolean) { + if (preserveChecked) { + if (this.rangeStartIndex === -1) { + this.rangeStartIndex = this.activeIndex; + } + + this.options.forEach((o, i) => { + o.checked = inRange(i, this.rangeStartIndex, this.options.length); + }); + } else { + this.uncheckAllOptions(); + } + + this.activeIndex = this.options.length - 1; + this.checkActiveIndex(); + } + + /** + * Increments the active index and marks the matching option as checked. + * + * @remarks + * Multiple-selection mode only. + * + * @param preserveChecked - mark all options unchecked before changing the active index + * + * @internal + */ + protected checkNextOption(preserveChecked: boolean) { + if (preserveChecked) { + if (this.rangeStartIndex === -1) { + this.rangeStartIndex = this.activeIndex; + } + + this.options.forEach((o, i) => { + o.checked = inRange(i, this.rangeStartIndex, this.activeIndex + 1); + }); + } else { + this.uncheckAllOptions(); + } + + this.activeIndex += this.activeIndex < this.options.length - 1 ? 1 : 0; + this.checkActiveIndex(); + } + + /** + * Decrements the active index and marks the matching option as checked. + * + * @remarks + * Multiple-selection mode only. + * + * @param preserveChecked - mark all options unchecked before changing the active index + * + * @internal + */ + protected checkPreviousOption(preserveChecked: boolean) { + if (preserveChecked) { + if (this.rangeStartIndex === -1) { + this.rangeStartIndex = this.activeIndex; + } + + if (this.checkedOptions.length === 1) { + this.rangeStartIndex += 1; + } + + this.options.forEach((o, i) => { + o.checked = inRange(i, this.activeIndex, this.rangeStartIndex); + }); + } else { + this.uncheckAllOptions(); + } + + this.activeIndex -= this.activeIndex > 0 ? 1 : 0; + this.checkActiveIndex(); + } + + /** + * @internal + */ + protected override focusAndScrollOptionIntoView() { + super.focusAndScrollOptionIntoView(this.activeOption); + } + + /** + * In multiple-selection mode: + * If any options are selected, the first selected option is checked when + * the listbox receives focus. If no options are selected, the first + * selectable option is checked. + * + * @internal + */ + override focusinHandler(e: FocusEvent): boolean | void { + if (!this.multiple) { + return super.focusinHandler(e); + } + + if (!this.shouldSkipFocus && e.target === e.currentTarget) { + this.uncheckAllOptions(); + + if (this.activeIndex === -1) { + this.activeIndex = + this.firstSelectedOptionIndex !== -1 + ? this.firstSelectedOptionIndex + : 0; + } + + this.checkActiveIndex(); + this.setSelectedOptions(); + this.focusAndScrollOptionIntoView(); + } + + this.shouldSkipFocus = false; + } + + /** + * Sets an option as selected and gives it focus. + * + * @public + */ + protected override setSelectedOptions() { + if (!this.multiple) { + super.setSelectedOptions(); + return; + } + + if (this.$fastController.isConnected && this.options) { + this.selectedOptions = this.options.filter((o) => o.selected); + this.focusAndScrollOptionIntoView(); + } + } + + /** + * Toggles the selected state of the provided options. If any provided items + * are in an unselected state, all items are set to selected. If every + * provided item is selected, they are all unselected. + * + * @internal + */ + toggleSelectedForAllCheckedOptions() { + const enabledCheckedOptions = this.checkedOptions.filter( + (o) => !o.disabled + ); + const force = !enabledCheckedOptions.every((o) => o.selected); + enabledCheckedOptions.forEach((o) => (o.selected = force)); + this.selectedIndex = this.options.indexOf( + enabledCheckedOptions[enabledCheckedOptions.length - 1] + ); + + this.setSelectedOptions(); + this.updateValue(true); + } + + /** + * @internal + */ + override typeaheadBufferChanged(prev: string, next: string): void { + if (!this.multiple) { + super.typeaheadBufferChanged(prev, next); + return; + } + + if (this.$fastController.isConnected) { + const typeaheadMatches = this.getTypeaheadMatches(); + const activeIndex = this.options.indexOf(typeaheadMatches[0]); + if (activeIndex > -1) { + this.activeIndex = activeIndex; + this.uncheckAllOptions(); + this.checkActiveIndex(); + } + + this.typeaheadExpired = false; + } + } + + /** + * Unchecks all options. + * + * @remarks + * Multiple-selection mode only. + * + * @param preserveChecked - reset the rangeStartIndex + * + * @internal + */ + protected uncheckAllOptions(preserveChecked = false): void { + this.options.forEach((o) => (o.checked = false)); + if (!preserveChecked) { + this.rangeStartIndex = -1; + } + } + + /** + * The open attribute. + * + * @public + * @remarks + * HTML Attribute: open + */ + @attr({ attribute: 'open', mode: 'boolean' }) + open = false; + + /** + * Sets focus and synchronizes ARIA attributes when the open property changes. + * + * @internal + */ + openChanged(prev: boolean, next: boolean) { + if (!this.collapsible) { + return; + } + + if (this.open) { + this.ariaControls = this.listboxId; + this.ariaExpanded = 'true'; + + this.focusAndScrollOptionIntoView(); + this.indexWhenOpened = this.selectedIndex; + + // focus is directed to the element when `open` is changed programmatically + DOM.queueUpdate(() => this.focus()); + + return; + } + + const didClose = prev === true && next === false; + const selectionChangedWhileOpen = + this.indexWhenOpened !== this.selectedIndex; + if (didClose && selectionChangedWhileOpen) { + this.updateValue(true); + } + + this.ariaControls = ''; + this.ariaExpanded = 'false'; + } + + /** + * The selectedIndex when the open property is true. + * + * @internal + */ + private indexWhenOpened!: number; + + /** + * The internal value property. + * + * @internal + */ + private _value!: string; + + /** + * The component is collapsible when in single-selection mode. + * + * @internal + */ + @volatile + get collapsible(): boolean { + return !this.multiple; + } + + /** + * The ref to the internal `.control` element. + * + * @internal + */ + @observable + control!: HTMLElement; + + /** + * The value property. + * + * @public + */ + override get value() { + Observable.track(this, 'value'); + return this._value; + } + + override set value(next: string) { + const prev = `${this._value}`; + + if (this._options.length) { + const selectedIndex = this._options.findIndex((el) => el.value === next); + const prevSelectedValue = + this._options[this.selectedIndex]?.value ?? null; + const nextSelectedValue = this._options[selectedIndex]?.value ?? null; + + if (selectedIndex === -1 || prevSelectedValue !== nextSelectedValue) { + next = ''; + this.selectedIndex = selectedIndex; + } + + next = this.firstSelectedOption?.value ?? next; + } + + if (prev !== next) { + this._value = next; + super.valueChanged(prev, next); + Observable.notify(this, 'value'); + this.updateDisplayValue(); + } + } + + /** + * Sets the value and display value to match the first selected option. + * + * @param shouldEmit - if true, the input and change events will be emitted + * + * @internal + */ + private updateValue(shouldEmit?: boolean) { + if (this.$fastController.isConnected) { + this.value = this.firstSelectedOption?.value ?? ''; + } + + if (shouldEmit) { + this.$emit('input'); + this.$emit('change', this, { + bubbles: true, + composed: undefined, + }); + } + } + + /** + * Updates the proxy value when the selected index changes. + * + * @param prev - the previous selected index + * @param next - the next selected index + * + * @internal + */ + override selectedIndexChanged(prev: number | undefined, next: number) { + super.selectedIndexChanged(prev, next); + this.updateValue(); + } + + /** + * Reference to the internal listbox element. + * + * @internal + */ + listbox!: HTMLDivElement; + + /** + * The unique id for the internal listbox element. + * + * @internal + */ + listboxId = uniqueId('listbox-'); + + /** + * The max height for the listbox when opened. + * + * @internal + */ + @observable + maxHeight = 0; + + /** + * Synchronize the `aria-disabled` property when the `disabled` property changes. + * + * @param prev - The previous disabled value + * @param next - The next disabled value + * + * @internal + */ + override disabledChanged(prev: boolean, next: boolean) { + if (super.disabledChanged) { + super.disabledChanged(prev, next); + } + this.ariaDisabled = this.disabled ? 'true' : 'false'; + } + + /** + * Handle opening and closing the listbox when the select is clicked. + * + * @param e - the mouse event + * @internal + */ + override clickHandler(e: MouseEvent): boolean | void { + // do nothing if the select is disabled + if (this.disabled) { + return; + } + + const clickedOption = (e.target as HTMLElement).closest( + `option,[role=option]` + ) as ListboxOption; + + if (clickedOption && clickedOption.disabled) { + return; + } + + if (this.multiple) { + this.uncheckAllOptions(); + this.activeIndex = this.options.indexOf(clickedOption); + this.checkActiveIndex(); + this.toggleSelectedForAllCheckedOptions(); + } else { + super.clickHandler(e); + } + + if (this.collapsible) { + this.open = !this.open; + } + + return true; + } + + /** + * Handles focus state when the element or its children lose focus. + * + * @param e - The focus event + * @internal + */ + focusoutHandler(e: FocusEvent): boolean | void { + if (this.multiple) { + this.uncheckAllOptions(); + } + + if (!this.open) { + return true; + } + + const focusTarget = e.relatedTarget as HTMLElement; + if (this.isSameNode(focusTarget)) { + this.focus(); + return; + } + + if (!this.options.includes(focusTarget as ListboxOption)) { + this.open = false; + if (this.indexWhenOpened !== this.selectedIndex) { + this.updateValue(true); + } + } + } + + /** + * Updates the value when an option's value changes. + * + * @param source - the source object + * @param propertyName - the property to evaluate + * + * @internal + */ + override handleChange(source: any, propertyName: string) { + super.handleChange(source, propertyName); + if (propertyName === 'value') { + this.updateValue(); + } + } + + /** + * Prevents focus when a scrollbar is clicked. + * + * @param e - the mouse event object + * + * @internal + */ + override mousedownHandler(e: MouseEvent): boolean | void { + if (e.offsetX >= 0 && e.offsetX <= this.listbox.scrollWidth) { + return super.mousedownHandler(e); + } + + return this.collapsible; + } + + override multipleChanged(prev: boolean | undefined, next: boolean) { + super.multipleChanged(prev, next); + this.options.forEach((o) => { + o.checked = next ? false : undefined; + }); + + this.setSelectedOptions(); + + if (this.proxy) { + this.proxy.multiple = next; + } + } + + /** + * Updates the selectedness of each option when the list of selected options changes. + * + * @param prev - the previous list of selected options + * @param next - the current list of selected options + * + * @internal + */ + protected override selectedOptionsChanged( + prev: ListboxOption[] | undefined, + next: ListboxOption[] + ) { + super.selectedOptionsChanged(prev, next); + this.options.forEach((o, i) => { + const proxyOption = this.proxy.options.item(i); + if (proxyOption) { + proxyOption.selected = o.selected; + } + }); + } + + /** + * Resets and fills the proxy to match the component's options. + * + * @internal + */ + private setProxyOptions(): void { + if (this.proxy instanceof HTMLSelectElement && this.options) { + this.proxy.options.length = 0; + this.options.forEach((option) => { + const proxyOption = + option.proxy || + (option instanceof HTMLOptionElement ? option.cloneNode() : null); + + if (proxyOption) { + this.proxy.options.add(proxyOption); + } + }); + } + } + + /** + * Handles keydown actions when the select is in multiple selection mode. + * + * @internal + */ + multipleKeydownHandler(e: KeyboardEvent) { + if (this.disabled) { + return; + } + + const { key, shiftKey } = e; + + this.shouldSkipFocus = false; + + switch (key) { + case keyHome: { + this.checkFirstOption(shiftKey); + return; + } + + case keyArrowDown: { + this.checkNextOption(shiftKey); + return; + } + + case keyArrowUp: { + this.checkPreviousOption(shiftKey); + return; + } + + case keyEnd: { + this.checkLastOption(shiftKey); + return; + } + + case keyTab: { + this.focusAndScrollOptionIntoView(); + return; + } + + case keyEscape: { + this.uncheckAllOptions(); + this.checkActiveIndex(); + return; + } + + // @ts-expect-error fallthrough case + case keySpace: { + e.preventDefault(); + if (this.typeaheadExpired) { + this.toggleSelectedForAllCheckedOptions(); + return; + } + } + + // fallthrough: + default: { + if (key.length === 1) { + // Send key to Typeahead handler + this.handleTypeAhead(`${key}`); + } + return; + } + } + } + + /** + * Handle keyboard interaction for the select. + * + * @param e - the keyboard event + * @internal + */ + override keydownHandler(e: KeyboardEvent): boolean | void { + const selectedIndexBefore = this.selectedIndex; + + if (this.multiple) { + this.multipleKeydownHandler(e); + } else { + super.keydownHandler(e); + } + + const key = e.key; + + switch (key) { + case keySpace: { + e.preventDefault(); + if (this.collapsible && this.typeaheadExpired) { + this.open = !this.open; + } + break; + } + + case keyHome: + case keyEnd: { + e.preventDefault(); + break; + } + + case keyEnter: { + e.preventDefault(); + this.open = !this.open; + break; + } + + case keyEscape: { + if (this.collapsible && this.open) { + e.preventDefault(); + this.open = false; + } + break; + } + + case keyTab: { + if (this.collapsible && this.open) { + e.preventDefault(); + this.open = false; + } + + return true; + } + } + + if ( + this.collapsible && + !this.open && + this.selectedIndex !== selectedIndexBefore + ) { + // Selecting an option when closed should update the value immediately + this.updateValue(true); + } + + return !(e.key === keyArrowDown || e.key === keyArrowUp); + } + + override connectedCallback() { + super.connectedCallback(); + + this.addEventListener('focusout', this.focusoutHandler); + this.addEventListener('contentchange', this.updateDisplayValue); + } + + override disconnectedCallback() { + this.removeEventListener('focusout', this.focusoutHandler); + this.removeEventListener('contentchange', this.updateDisplayValue); + + super.disconnectedCallback(); + } + + /** + * + * @internal + */ + private updateDisplayValue(): void { + if (this.collapsible) { + Observable.notify(this, 'displayValue'); + } + } + /** * @internal */ @@ -107,11 +922,7 @@ export class Select extends FoundationSelect { } } - override connectedCallback() { - super.connectedCallback(); - } - - override get displayValue(): string { + get displayValue(): string { Observable.track(this, 'displayValue'); return ( @@ -149,8 +960,20 @@ export class Select extends FoundationSelect { * @internal */ override slottedOptionsChanged(prev: Element[] | undefined, next: Element[]) { + this.options.forEach((o) => { + const notifier = Observable.getNotifier(o); + notifier.unsubscribe(this, 'value'); + }); + super.slottedOptionsChanged(prev, next); + this.options.forEach((o) => { + const notifier = Observable.getNotifier(o); + notifier.subscribe(this, 'value'); + }); + this.setProxyOptions(); + this.updateValue(); + const scale = this.getAttribute('scale') || this.scale; next.forEach((element) => { if (scale) { @@ -168,22 +991,48 @@ export class Select extends FoundationSelect { } override formResetCallback() { - super.formResetCallback(); + this.setProxyOptions(); + // Call the base class's implementation setDefaultSelectedOption instead of the select's + // override, in order to reset the selectedIndex without using the value property. + super.setDefaultSelectedOption(); + if (this.selectedIndex === -1) { + this.selectedIndex = 0; + } + if (this.placeholder) { this.selectedIndex = -1; } } } +/** + * Includes ARIA states and properties relating to the ARIA select role. + * + * @public + */ +export class DelegatesARIASelect { + /** + * See {@link https://www.w3.org/TR/wai-aria-1.2/#combobox} for more information + * @public + * @remarks + * HTML Attribute: `aria-controls` + */ + @observable + // @ts-expect-error Type is incorrectly non-optional + ariaControls: string | null; +} + export interface Select extends AffixIconWithTrailing, FormElement, FormElementHelperText, ErrorText, - FormElementSuccessText {} + FormElementSuccessText, + DelegatesARIASelect {} applyMixinsWithObservables( Select, AffixIconWithTrailing, FormElementHelperText, - FormElementSuccessText + FormElementSuccessText, + DelegatesARIASelect ); diff --git a/libs/components/src/shared/foundation/listbox/listbox.ts b/libs/components/src/shared/foundation/listbox/listbox.ts new file mode 100644 index 0000000000..d88dc6d6b0 --- /dev/null +++ b/libs/components/src/shared/foundation/listbox/listbox.ts @@ -0,0 +1,619 @@ +import { applyMixins, FoundationElement } from '@microsoft/fast-foundation'; +import { attr, observable, Observable } from '@microsoft/fast-element'; +import { + findLastIndex, + keyArrowDown, + keyArrowUp, + keyEnd, + keyEnter, + keyEscape, + keyHome, + keySpace, + keyTab, + uniqueId, +} from '@microsoft/fast-web-utilities'; +import { + isListboxOption, + type ListboxOption, +} from '../../../lib/option/option'; +import { ARIAGlobalStatesAndProperties } from '../patterns'; + +export abstract class Listbox extends FoundationElement { + /** + * The internal unfiltered list of selectable options. + * + * @internal + */ + protected _options: ListboxOption[] = []; + + /** + * The first selected option. + * + * @internal + */ + get firstSelectedOption(): ListboxOption { + return this.selectedOptions[0] ?? null; + } + + /** + * Returns true if there is one or more selectable option. + * + * @internal + */ + protected get hasSelectableOptions(): boolean { + return this.options.length > 0 && !this.options.every((o) => o.disabled); + } + + /** + * The number of options. + * + * @public + */ + get length(): number { + return this.options.length; + } + + /** + * The list of options. + * + * @public + */ + get options(): ListboxOption[] { + Observable.track(this, 'options'); + return this._options; + } + + set options(value: ListboxOption[]) { + this._options = value; + Observable.notify(this, 'options'); + } + + /** + * The disabled state of the listbox. + * + * @public + * @remarks + * HTML Attribute: `disabled` + */ + @attr({ mode: 'boolean' }) + // @ts-expect-error Type is incorrectly non-optional + disabled: boolean; + + /** + * The index of the selected option. + * + * @public + */ + @observable + selectedIndex = -1; + + /** + * A collection of the selected options. + * + * @public + */ + @observable + selectedOptions: ListboxOption[] = []; + + /** + * A standard `click` event creates a `focus` event before firing, so a + * `mousedown` event is used to skip that initial focus. + * + * @internal + */ + protected shouldSkipFocus = false; + + /** + * A static filter to include only selectable options. + * + * @param n - element to filter + * @public + */ + static slottedOptionFilter = (n: HTMLElement) => + isListboxOption(n) && !n.hidden; + + /** + * The default slotted elements. + * + * @internal + */ + @observable + slottedOptions!: Element[]; + + /** + * Typeahead timeout in milliseconds. + * + * @internal + */ + protected static readonly TYPE_AHEAD_TIMEOUT_MS = 1000; + + /** + * The current typeahead buffer string. + * + * @internal + */ + @observable + protected typeaheadBuffer = ''; + + /** + * Flag for the typeahead timeout expiration. + * + * @internal + */ + protected typeaheadExpired = true; + + /** + * The timeout ID for the typeahead handler. + * + * @internal + */ + protected typeaheadTimeout = -1; + + /** + * Handle click events for listbox options. + * + * @internal + */ + clickHandler(e: MouseEvent): boolean | void { + const captured = (e.target as HTMLElement).closest( + `option,[role=option]` + ) as ListboxOption; + + if (captured && !captured.disabled) { + this.selectedIndex = this.options.indexOf(captured); + return true; + } + } + + /** + * Ensures that the provided option is focused and scrolled into view. + * + * @param optionToFocus - The option to focus + * @internal + */ + protected focusAndScrollOptionIntoView( + optionToFocus: ListboxOption | null = this.firstSelectedOption + ): void { + // To ensure that the browser handles both `focus()` and `scrollIntoView()`, the + // timing here needs to guarantee that they happen on different frames. Since this + // function is typically called from the `openChanged` observer, `DOM.queueUpdate` + // causes the calls to be grouped into the same frame. To prevent this, + // `requestAnimationFrame` is used instead of `DOM.queueUpdate`. + if (this.contains(document.activeElement) && optionToFocus !== null) { + optionToFocus.focus(); + requestAnimationFrame(() => { + optionToFocus.scrollIntoView({ block: 'nearest' }); + }); + } + } + + /** + * Handles `focusin` actions for the component. When the component receives focus, + * the list of selected options is refreshed and the first selected option is scrolled + * into view. + * + * @internal + */ + focusinHandler(e: FocusEvent): void { + if (!this.shouldSkipFocus && e.target === e.currentTarget) { + this.setSelectedOptions(); + this.focusAndScrollOptionIntoView(); + } + + this.shouldSkipFocus = false; + } + + /** + * Returns the options which match the current typeahead buffer. + * + * @internal + */ + protected getTypeaheadMatches(): ListboxOption[] { + const pattern = this.typeaheadBuffer.replace( + /[.*+\-?^${}()|[\]\\]/g, + '\\$&' + ); + const re = new RegExp(`^${pattern}`, 'gi'); + return this.options.filter((o: ListboxOption) => o.text.trim().match(re)); + } + + /** + * Determines the index of the next option which is selectable, if any. + * + * @param prev - the previous selected index + * @param next - the next index to select + * + * @internal + */ + protected getSelectableIndex(prev: number, next: number) { + const direction = prev > next ? -1 : 1; + const potentialDirection = prev + direction; + + let nextSelectableOption: ListboxOption | null = null; + + switch (direction) { + case -1: { + nextSelectableOption = this.options.reduceRight( + (nextSelectableOption, thisOption, index) => + !nextSelectableOption && + !thisOption.disabled && + index < potentialDirection + ? thisOption + : nextSelectableOption, + nextSelectableOption + ); + break; + } + + case 1: { + nextSelectableOption = this.options.reduce( + (nextSelectableOption, thisOption, index) => + !nextSelectableOption && + !thisOption.disabled && + index > potentialDirection + ? thisOption + : nextSelectableOption, + nextSelectableOption + ); + break; + } + } + + return this.options.indexOf(nextSelectableOption as any); + } + + /** + * Handles external changes to child options. + * + * @param source - the source object + * @param propertyName - the property + * + * @internal + */ + handleChange(source: any, propertyName: string) { + switch (propertyName) { + case 'selected': { + if (Listbox.slottedOptionFilter(source)) { + this.selectedIndex = this.options.indexOf(source); + } + this.setSelectedOptions(); + break; + } + } + } + + /** + * Moves focus to an option whose label matches characters typed by the user. + * Consecutive keystrokes are batched into a buffer of search text used + * to match against the set of options. If `TYPE_AHEAD_TIMEOUT_MS` passes + * between consecutive keystrokes, the search restarts. + * + * @param key - the key to be evaluated + * + * @internal + */ + handleTypeAhead(key: string): void { + if (this.typeaheadTimeout) { + window.clearTimeout(this.typeaheadTimeout); + } + + this.typeaheadTimeout = window.setTimeout( + () => (this.typeaheadExpired = true), + Listbox.TYPE_AHEAD_TIMEOUT_MS + ); + + this.typeaheadBuffer = `${ + this.typeaheadExpired ? '' : this.typeaheadBuffer + }${key}`; + } + + /** + * Handles `keydown` actions for listbox navigation and typeahead. + * + * @internal + */ + keydownHandler(e: KeyboardEvent): boolean | void { + if (this.disabled) { + return true; + } + + this.shouldSkipFocus = false; + + const key = e.key; + + switch (key) { + // Select the first available option + case keyHome: { + if (!e.shiftKey) { + e.preventDefault(); + this.selectFirstOption(); + } + break; + } + + // Select the next selectable option + case keyArrowDown: { + if (!e.shiftKey) { + e.preventDefault(); + this.selectNextOption(); + } + break; + } + + // Select the previous selectable option + case keyArrowUp: { + if (!e.shiftKey) { + e.preventDefault(); + this.selectPreviousOption(); + } + break; + } + + // Select the last available option + case keyEnd: { + e.preventDefault(); + this.selectLastOption(); + break; + } + + case keyTab: { + this.focusAndScrollOptionIntoView(); + return true; + } + + case keyEnter: + case keyEscape: + return true; + + // @ts-expect-error - fallthrough case + case keySpace: + if (this.typeaheadExpired) { + return true; + } + + // fallthrough: + default: { + if (key.length === 1) { + // Send key to Typeahead handler + this.handleTypeAhead(`${key}`); + } + return true; + } + } + } + + /** + * Prevents `focusin` events from firing before `click` events when the + * element is unfocused. + * + * @internal + */ + mousedownHandler(_: MouseEvent): boolean | void { + this.shouldSkipFocus = !this.contains(document.activeElement); + return true; + } + + /** + * Switches between single-selection and multi-selection mode. + * + * @internal + */ + multipleChanged(_: boolean | undefined, next: boolean): void { + this.ariaMultiSelectable = next ? 'true' : null; + } + + /** + * Updates the list of selected options when the `selectedIndex` changes. + * + * @param prev - the previous selected index value + * @param next - the current selected index value + * + * @internal + */ + selectedIndexChanged(prev: number | undefined, next: number): void { + if (!this.hasSelectableOptions) { + this.selectedIndex = -1; + return; + } + + if ( + this.options[this.selectedIndex]?.disabled && + typeof prev === 'number' + ) { + const selectableIndex = this.getSelectableIndex(prev, next); + const newNext = selectableIndex > -1 ? selectableIndex : prev; + this.selectedIndex = newNext; + return; + } + + this.setSelectedOptions(); + } + + /** + * Updates the selectedness of each option when the list of selected options changes. + * + * @internal + */ + protected selectedOptionsChanged( + _: ListboxOption[] | undefined, + next: ListboxOption[] + ): void { + const filteredNext = next.filter(Listbox.slottedOptionFilter); + this.options.forEach((o) => { + const notifier = Observable.getNotifier(o); + notifier.unsubscribe(this, 'selected'); + o.selected = filteredNext.includes(o); + notifier.subscribe(this, 'selected'); + }); + } + + /** + * Moves focus to the first selectable option. + * + * @public + */ + selectFirstOption(): void { + if (!this.disabled) { + this.selectedIndex = this.options.findIndex((o) => !o.disabled); + } + } + + /** + * Moves focus to the last selectable option. + * + * @internal + */ + selectLastOption(): void { + if (!this.disabled) { + this.selectedIndex = findLastIndex(this.options, (o) => !o.disabled); + } + } + + /** + * Moves focus to the next selectable option. + * + * @internal + */ + selectNextOption(): void { + if (!this.disabled && this.selectedIndex < this.options.length - 1) { + this.selectedIndex += 1; + } + } + + /** + * Moves focus to the previous selectable option. + * + * @internal + */ + selectPreviousOption(): void { + if (!this.disabled && this.selectedIndex > 0) { + this.selectedIndex = this.selectedIndex - 1; + } + } + + /** + * Updates the selected index to match the first selected option. + * + * @internal + */ + protected setDefaultSelectedOption() { + this.selectedIndex = this.options.findIndex((el) => el.defaultSelected); + } + + /** + * Sets an option as selected and gives it focus. + * + * @public + */ + protected setSelectedOptions() { + if (this.options.length) { + this.selectedOptions = [this.options[this.selectedIndex]]; + this.ariaActiveDescendant = this.firstSelectedOption?.id ?? ''; + this.focusAndScrollOptionIntoView(); + } + } + + /** + * Updates the list of options and resets the selected option when the slotted option content changes. + * + * @internal + */ + slottedOptionsChanged(_: Element[] | undefined, next: Element[]) { + this.options = next.reduce((options, item) => { + if (isListboxOption(item)) { + options.push(item); + } + return options; + }, []); + + const setSize = `${this.options.length}`; + this.options.forEach((option, index) => { + if (!option.id) { + option.id = uniqueId('option-'); + } + option.ariaPosInSet = `${index + 1}`; + option.ariaSetSize = setSize; + }); + + if (this.$fastController.isConnected) { + this.setSelectedOptions(); + this.setDefaultSelectedOption(); + } + } + + /** + * Updates the filtered list of options when the typeahead buffer changes. + * + * @internal + */ + typeaheadBufferChanged(_prev: string, _next: string): void { + if (this.$fastController.isConnected) { + const typeaheadMatches = this.getTypeaheadMatches(); + + if (typeaheadMatches.length) { + const selectedIndex = this.options.indexOf(typeaheadMatches[0]); + if (selectedIndex > -1) { + this.selectedIndex = selectedIndex; + } + } + + this.typeaheadExpired = false; + } + } +} + +/** + * Includes ARIA states and properties relating to the ARIA listbox role + * + * @public + */ +export class DelegatesARIAListbox { + /** + * See {@link https://www.w3.org/TR/wai-aria-1.2/#listbox} for more information + * @public + * @remarks + * HTML Attribute: `aria-activedescendant` + */ + @observable + // @ts-expect-error Type is incorrectly non-optional + ariaActiveDescendant: string | null; + + /** + * See {@link https://www.w3.org/TR/wai-aria-1.2/#listbox} for more information + * @public + * @remarks + * HTML Attribute: `aria-disabled` + */ + @observable + // @ts-expect-error Type is incorrectly non-optional + ariaDisabled: 'true' | 'false' | string | null; + + /** + * See {@link https://www.w3.org/TR/wai-aria-1.2/#listbox} for more information + * @public + * @remarks + * HTML Attribute: `aria-expanded` + */ + @observable + // @ts-expect-error Type is incorrectly non-optional + ariaExpanded: 'true' | 'false' | string | null; + + /** + * See {@link https://w3c.github.io/aria/#listbox} for more information + * @public + * @remarks + * HTML Attribute: `aria-multiselectable` + */ + @observable + // @ts-expect-error Type is incorrectly non-optional + ariaMultiSelectable: 'true' | 'false' | string | null; +} + +export interface DelegatesARIAListbox extends ARIAGlobalStatesAndProperties {} +applyMixins(DelegatesARIAListbox, ARIAGlobalStatesAndProperties); + +/** + * @internal + */ +export interface Listbox extends DelegatesARIAListbox {} +applyMixins(Listbox, DelegatesARIAListbox); diff --git a/libs/wrapper-gen/src/generator/customElementDeclarations.ts b/libs/wrapper-gen/src/generator/customElementDeclarations.ts index c12249b64e..08fd6c0ada 100644 --- a/libs/wrapper-gen/src/generator/customElementDeclarations.ts +++ b/libs/wrapper-gen/src/generator/customElementDeclarations.ts @@ -453,6 +453,15 @@ const VividMixins: Record = { ], TrappedFocus: [], DelegatesARIATextbox: [], + DelegatesARIASelect: [ + { + name: 'aria-controls', + description: + 'See https://www.w3.org/TR/wai-aria-1.2/#combobox for more information.', + type: { text: 'string' }, + fieldName: 'ariaControls', + }, + ], ARIAGlobalStatesAndProperties: [], };