From bd072222fd813160789b04fe27efeb0f33f00480 Mon Sep 17 00:00:00 2001 From: Zacky Ma Date: Tue, 19 May 2026 14:51:58 -0700 Subject: [PATCH 1/6] support type ahead in dropdown --- .../src/dropdown/dropdown.base.ts | 70 +++++++++++++++++++ .../src/dropdown/dropdown.stories.ts | 4 ++ 2 files changed, 74 insertions(+) diff --git a/packages/web-components/src/dropdown/dropdown.base.ts b/packages/web-components/src/dropdown/dropdown.base.ts index 8f14dbd1ae7243..b346ba6f94c870 100644 --- a/packages/web-components/src/dropdown/dropdown.base.ts +++ b/packages/web-components/src/dropdown/dropdown.base.ts @@ -12,6 +12,11 @@ import { uniqueId } from '../utils/unique-id.js'; import { DropdownType } from './dropdown.options.js'; import { dropdownButtonTemplate, dropdownInputTemplate } from './dropdown.template.js'; +/** + * The duration in milliseconds after the last typeahead keystroke before the buffer is cleared. + */ +const TYPEAHEAD_TIMEOUT = 500; + /** * A Dropdown Custom HTML Element. * Implements the {@link https://w3c.github.io/aria/#combobox | ARIA combobox } role. @@ -835,6 +840,59 @@ export class BaseDropdown extends FASTElement { this._insertingControl = false; } + /** + * The accumulated typeahead buffer used to match option labels by prefix. + * + * @internal + */ + private typeaheadBuffer: string = ''; + + /** + * The timeout id used to reset the typeahead buffer. + * + * @internal + */ + private typeaheadTimeout?: ReturnType; + + /** + * Handles typeahead character input by moving {@link activeIndex} to the next option whose label matches the + * accumulated buffer. When the buffer is a single character (or the same character repeated), matching options are + * cycled through; otherwise the buffer is treated as a prefix match. + * + * @param char - the printable character that was pressed + * @internal + */ + private handleTypeahead(char: string): void { + const isRepeating = this.typeaheadBuffer === char.repeat(this.typeaheadBuffer.length); + this.typeaheadBuffer += char; + + let candidates = this.typeaheadBuffer.length > 1 ? this.filterOptions(this.typeaheadBuffer) : []; + let isCycling = false; + + if (!candidates.length && isRepeating) { + candidates = this.filterOptions(char); + isCycling = true; + } + + if (candidates.length) { + const activeOption = this.enabledOptions[this.activeIndex]; + const currentPos = candidates.indexOf(activeOption); + const nextMatch = isCycling + ? candidates[this.getEnabledIndexInBounds(currentPos + 1, candidates.length)] + : currentPos >= 0 + ? activeOption + : candidates[0]; + + this.activeIndex = this.enabledOptions.indexOf(nextMatch); + } + + clearTimeout(this.typeaheadTimeout); + this.typeaheadTimeout = setTimeout(() => { + this.typeaheadBuffer = ''; + this.typeaheadTimeout = undefined; + }, TYPEAHEAD_TIMEOUT); + } + /** * Handles the keydown events for the dropdown. * @@ -889,6 +947,12 @@ export class BaseDropdown extends FASTElement { } if (!increment) { + if (!this.isCombobox && e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) { + if (!this.open) { + this.listbox.showPopover(); + } + this.handleTypeahead(e.key); + } return true; } @@ -1045,6 +1109,12 @@ export class BaseDropdown extends FASTElement { BaseDropdown.AnchorPositionFallbackObserver?.disconnect(); this.debounceController?.abort(); + if (this.typeaheadTimeout) { + clearTimeout(this.typeaheadTimeout); + this.typeaheadTimeout = undefined; + this.typeaheadBuffer = ''; + } + super.disconnectedCallback(); } diff --git a/packages/web-components/src/dropdown/dropdown.stories.ts b/packages/web-components/src/dropdown/dropdown.stories.ts index 3bb215087a2b11..bf18a06ec3e3d9 100644 --- a/packages/web-components/src/dropdown/dropdown.stories.ts +++ b/packages/web-components/src/dropdown/dropdown.stories.ts @@ -105,12 +105,16 @@ export const Default: Story = { slottedContent: () => [ { value: 'apple', slottedContent: () => 'Apple' }, { value: 'banana', slottedContent: () => 'Banana' }, + { value: 'banana', slottedContent: () => 'Bbanana' }, + { value: 'blueberry', slottedContent: () => 'Blueberry' }, { value: 'orange', slottedContent: () => 'Orange' }, { value: 'mango', slottedContent: () => 'Mango' }, { value: 'kiwi', slottedContent: () => 'Kiwi' }, { value: 'cherry', slottedContent: () => 'Cherry' }, { value: 'grapefruit', slottedContent: () => 'Grapefruit' }, { value: 'papaya', slottedContent: () => 'Papaya' }, + { value: 'pear', slottedContent: () => 'Pear' }, + { value: 'peach', slottedContent: () => 'Peach' }, { value: 'lychee', slottedContent: () => 'Lychee' }, ], }, From 1c52a5940dc4416ec5bd760ddaad7fe891941a80 Mon Sep 17 00:00:00 2001 From: Zacky Ma Date: Tue, 19 May 2026 14:56:30 -0700 Subject: [PATCH 2/6] move space case to avoid fall through case --- .../src/dropdown/dropdown.base.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/web-components/src/dropdown/dropdown.base.ts b/packages/web-components/src/dropdown/dropdown.base.ts index b346ba6f94c870..11a97c0c9898bc 100644 --- a/packages/web-components/src/dropdown/dropdown.base.ts +++ b/packages/web-components/src/dropdown/dropdown.base.ts @@ -915,16 +915,17 @@ export class BaseDropdown extends FASTElement { break; } - case ' ': { - if (this.isCombobox) { - break; - } - - e.preventDefault(); - } - + case ' ': case 'Enter': case 'Tab': { + if (e.key === ' ') { + if (this.isCombobox) { + break; + } + + e.preventDefault(); + } + if (this.open) { this.selectOption(this.activeIndex, true); if (this.multiple) { From 4c17c50e3f474d24d96ee133ee26bbf4861fda57 Mon Sep 17 00:00:00 2001 From: Zacky Ma Date: Tue, 19 May 2026 15:00:01 -0700 Subject: [PATCH 3/6] change file --- ...eb-components-5a70d41d-e498-461d-96aa-4ada7ddcac9b.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-web-components-5a70d41d-e498-461d-96aa-4ada7ddcac9b.json diff --git a/change/@fluentui-web-components-5a70d41d-e498-461d-96aa-4ada7ddcac9b.json b/change/@fluentui-web-components-5a70d41d-e498-461d-96aa-4ada7ddcac9b.json new file mode 100644 index 00000000000000..0f41c48d9f525e --- /dev/null +++ b/change/@fluentui-web-components-5a70d41d-e498-461d-96aa-4ada7ddcac9b.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "support type ahead in Dropdown", + "packageName": "@fluentui/web-components", + "email": "machi@microsoft.com", + "dependentChangeType": "patch" +} From 853d810a6e7da5a717044a717b6b17507840416d Mon Sep 17 00:00:00 2001 From: Zacky Ma Date: Tue, 19 May 2026 16:54:23 -0700 Subject: [PATCH 4/6] add tests --- .../src/dropdown/dropdown.spec.ts | 120 ++++++++++++++++++ .../src/dropdown/dropdown.stories.ts | 1 - 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/packages/web-components/src/dropdown/dropdown.spec.ts b/packages/web-components/src/dropdown/dropdown.spec.ts index cfc2ecec632fb6..b32a0f59369616 100644 --- a/packages/web-components/src/dropdown/dropdown.spec.ts +++ b/packages/web-components/src/dropdown/dropdown.spec.ts @@ -114,6 +114,38 @@ test.describe('Dropdown', () => { await expect(listbox).toBeVisible(); }); + test('should open the dropdown when a character is pressed', async ({ fastPage }) => { + const { element } = fastPage; + const listbox = element.locator(ListboxTagName); + const button = element.locator('[role=combobox]'); + + await fastPage.setTemplate(); + + await button.press('a'); + + await expect(listbox).toBeVisible(); + }); + + test('should not open the dropdown when a character is pressed with Meta, Alt, or Ctrl', async ({ fastPage }) => { + const { element } = fastPage; + const listbox = element.locator(ListboxTagName); + const button = element.locator('[role=combobox]'); + + await fastPage.setTemplate(); + + await button.press('Meta+a'); + + await expect(listbox).toBeHidden(); + + await button.press('Alt+a'); + + await expect(listbox).toBeHidden(); + + await button.press('Control+a'); + + await expect(listbox).toBeHidden(); + }); + test("should set the `name` property on options when it's set on the dropdown", async ({ fastPage }) => { const { element } = fastPage; const options = element.locator(OptionTagName); @@ -550,4 +582,92 @@ test.describe('Dropdown', () => { await expect(listbox).toBeHidden(); }); + + test.describe('type ahead', () => { + test.beforeEach(async ({ fastPage }) => { + await fastPage.setTemplate({ + innerHTML: ` + <${ListboxTagName}> + <${OptionTagName} id="o1">Afoo + <${OptionTagName} id="o2">Bfoo + <${OptionTagName} id="o3">Bbfoo + <${OptionTagName} id="o4">Bcfoo + <${OptionTagName} id="o5">Cfoo + + `, + }); + }); + + test('should set active descendant based on user typing', async ({ fastPage }) => { + const { element, page } = fastPage; + const combobox = element.getByRole('combobox'); + + await combobox.focus(); + await page.keyboard.press('b'); + + await expect(combobox).toHaveAttribute('aria-activedescendant', 'o2'); + + await page.waitForTimeout(500); + + await page.keyboard.press('a'); + + await expect(combobox).toHaveAttribute('aria-activedescendant', 'o1'); + + await page.waitForTimeout(500); + + await page.keyboard.press('c'); + + await expect(combobox).toHaveAttribute('aria-activedescendant', 'o5'); + + await page.waitForTimeout(500); + + await page.keyboard.press('d'); + + await expect(combobox).toHaveAttribute('aria-activedescendant', 'o5'); + }); + + test('should cycle through matching options as active descendant based on user typing', async ({ fastPage }) => { + const { element, page } = fastPage; + const combobox = element.getByRole('combobox'); + + await combobox.focus(); + await page.keyboard.press('b'); + + await expect(combobox).toHaveAttribute('aria-activedescendant', 'o2'); + + await page.keyboard.press('b'); + + await expect(combobox).toHaveAttribute('aria-activedescendant', 'o3'); + + await page.keyboard.press('b'); + + await expect(combobox).toHaveAttribute('aria-activedescendant', 'o4'); + + await page.keyboard.press('b'); + + await expect(combobox).toHaveAttribute('aria-activedescendant', 'o2'); + }); + + test('should set active descendant if its label has repeated character', async ({ fastPage }) => { + const { element, page } = fastPage; + const combobox = element.getByRole('combobox'); + + await combobox.focus(); + await page.keyboard.type('bb', { delay: 100 }); + + await expect(combobox).toHaveAttribute('aria-activedescendant', 'o3'); + + await page.waitForTimeout(500); + + await page.keyboard.type('bb', { delay: 100 }); + + await expect(combobox).toHaveAttribute('aria-activedescendant', 'o3'); + + await page.waitForTimeout(500); + + await page.keyboard.type('bb', { delay: 600 }); + + await expect(combobox).toHaveAttribute('aria-activedescendant', 'o2'); + }); + }); }); diff --git a/packages/web-components/src/dropdown/dropdown.stories.ts b/packages/web-components/src/dropdown/dropdown.stories.ts index bf18a06ec3e3d9..21d30b712c3c7a 100644 --- a/packages/web-components/src/dropdown/dropdown.stories.ts +++ b/packages/web-components/src/dropdown/dropdown.stories.ts @@ -105,7 +105,6 @@ export const Default: Story = { slottedContent: () => [ { value: 'apple', slottedContent: () => 'Apple' }, { value: 'banana', slottedContent: () => 'Banana' }, - { value: 'banana', slottedContent: () => 'Bbanana' }, { value: 'blueberry', slottedContent: () => 'Blueberry' }, { value: 'orange', slottedContent: () => 'Orange' }, { value: 'mango', slottedContent: () => 'Mango' }, From 075733275dec22c8bf04247500208291ea725c29 Mon Sep 17 00:00:00 2001 From: Zacky Ma Date: Wed, 20 May 2026 10:39:07 -0700 Subject: [PATCH 5/6] Update comment for Dropdown keyboard support --- ...tui-web-components-5a70d41d-e498-461d-96aa-4ada7ddcac9b.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/change/@fluentui-web-components-5a70d41d-e498-461d-96aa-4ada7ddcac9b.json b/change/@fluentui-web-components-5a70d41d-e498-461d-96aa-4ada7ddcac9b.json index 0f41c48d9f525e..f789c27168308a 100644 --- a/change/@fluentui-web-components-5a70d41d-e498-461d-96aa-4ada7ddcac9b.json +++ b/change/@fluentui-web-components-5a70d41d-e498-461d-96aa-4ada7ddcac9b.json @@ -1,6 +1,6 @@ { "type": "prerelease", - "comment": "support type ahead in Dropdown", + "comment": "add keyboard support for printable characters in Dropdown", "packageName": "@fluentui/web-components", "email": "machi@microsoft.com", "dependentChangeType": "patch" From ed5047a0d123fd4a774f18182ccb321e8d5597dc Mon Sep 17 00:00:00 2001 From: Zacky Ma Date: Wed, 20 May 2026 10:48:39 -0700 Subject: [PATCH 6/6] change wording --- .../src/dropdown/dropdown.base.ts | 46 +++++++++---------- .../src/dropdown/dropdown.spec.ts | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/web-components/src/dropdown/dropdown.base.ts b/packages/web-components/src/dropdown/dropdown.base.ts index 11a97c0c9898bc..b58b08e6f64cd1 100644 --- a/packages/web-components/src/dropdown/dropdown.base.ts +++ b/packages/web-components/src/dropdown/dropdown.base.ts @@ -13,9 +13,9 @@ import { DropdownType } from './dropdown.options.js'; import { dropdownButtonTemplate, dropdownInputTemplate } from './dropdown.template.js'; /** - * The duration in milliseconds after the last typeahead keystroke before the buffer is cleared. + * The duration in milliseconds after the last character search keystroke before the search string is cleared. */ -const TYPEAHEAD_TIMEOUT = 500; +const SEARCH_TIMEOUT = 500; /** * A Dropdown Custom HTML Element. @@ -841,32 +841,32 @@ export class BaseDropdown extends FASTElement { } /** - * The accumulated typeahead buffer used to match option labels by prefix. + * The accumulated search string used to match option labels by prefix when printable characters are typed. * * @internal */ - private typeaheadBuffer: string = ''; + private searchString: string = ''; /** - * The timeout id used to reset the typeahead buffer. + * The timeout id used to reset the search string. * * @internal */ - private typeaheadTimeout?: ReturnType; + private searchTimeout?: ReturnType; /** - * Handles typeahead character input by moving {@link activeIndex} to the next option whose label matches the - * accumulated buffer. When the buffer is a single character (or the same character repeated), matching options are - * cycled through; otherwise the buffer is treated as a prefix match. + * Handles printable character input by moving {@link activeIndex} to the next option whose label matches the + * accumulated search string. When the string is a single character (or the same character repeated), matching + * options are cycled through; otherwise the string is treated as a prefix match. * * @param char - the printable character that was pressed * @internal */ - private handleTypeahead(char: string): void { - const isRepeating = this.typeaheadBuffer === char.repeat(this.typeaheadBuffer.length); - this.typeaheadBuffer += char; + private handleSearchCharacter(char: string): void { + const isRepeating = this.searchString === char.repeat(this.searchString.length); + this.searchString += char; - let candidates = this.typeaheadBuffer.length > 1 ? this.filterOptions(this.typeaheadBuffer) : []; + let candidates = this.searchString.length > 1 ? this.filterOptions(this.searchString) : []; let isCycling = false; if (!candidates.length && isRepeating) { @@ -886,11 +886,11 @@ export class BaseDropdown extends FASTElement { this.activeIndex = this.enabledOptions.indexOf(nextMatch); } - clearTimeout(this.typeaheadTimeout); - this.typeaheadTimeout = setTimeout(() => { - this.typeaheadBuffer = ''; - this.typeaheadTimeout = undefined; - }, TYPEAHEAD_TIMEOUT); + clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(() => { + this.searchString = ''; + this.searchTimeout = undefined; + }, SEARCH_TIMEOUT); } /** @@ -952,7 +952,7 @@ export class BaseDropdown extends FASTElement { if (!this.open) { this.listbox.showPopover(); } - this.handleTypeahead(e.key); + this.handleSearchCharacter(e.key); } return true; } @@ -1110,10 +1110,10 @@ export class BaseDropdown extends FASTElement { BaseDropdown.AnchorPositionFallbackObserver?.disconnect(); this.debounceController?.abort(); - if (this.typeaheadTimeout) { - clearTimeout(this.typeaheadTimeout); - this.typeaheadTimeout = undefined; - this.typeaheadBuffer = ''; + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + this.searchTimeout = undefined; + this.searchString = ''; } super.disconnectedCallback(); diff --git a/packages/web-components/src/dropdown/dropdown.spec.ts b/packages/web-components/src/dropdown/dropdown.spec.ts index b32a0f59369616..cc4ec6a389fb1d 100644 --- a/packages/web-components/src/dropdown/dropdown.spec.ts +++ b/packages/web-components/src/dropdown/dropdown.spec.ts @@ -583,7 +583,7 @@ test.describe('Dropdown', () => { await expect(listbox).toBeHidden(); }); - test.describe('type ahead', () => { + test.describe('search options by printable characters', () => { test.beforeEach(async ({ fastPage }) => { await fastPage.setTemplate({ innerHTML: `