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}>
+
+
+
+ ${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: [],
};