Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
Copy link
Copy Markdown

@github-actions github-actions Bot May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-web-components/TextInput 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-web-components/TextInput. - Dark Mode.normal.chromium_1.png 288 Changed

"type": "prerelease",
"comment": "add keyboard support for printable characters in Dropdown",
"packageName": "@fluentui/web-components",
"email": "machi@microsoft.com",
"dependentChangeType": "patch"
}
87 changes: 79 additions & 8 deletions packages/web-components/src/dropdown/dropdown.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 character search keystroke before the search string is cleared.
*/
const SEARCH_TIMEOUT = 500;

/**
* A Dropdown Custom HTML Element.
* Implements the {@link https://w3c.github.io/aria/#combobox | ARIA combobox } role.
Expand Down Expand Up @@ -835,6 +840,59 @@ export class BaseDropdown extends FASTElement {
this._insertingControl = false;
}

/**
* The accumulated search string used to match option labels by prefix when printable characters are typed.
*
* @internal
*/
private searchString: string = '';

/**
* The timeout id used to reset the search string.
*
* @internal
*/
private searchTimeout?: ReturnType<typeof setTimeout>;

/**
* 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 handleSearchCharacter(char: string): void {
const isRepeating = this.searchString === char.repeat(this.searchString.length);
this.searchString += char;

let candidates = this.searchString.length > 1 ? this.filterOptions(this.searchString) : [];
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.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.searchString = '';
this.searchTimeout = undefined;
}, SEARCH_TIMEOUT);
}

/**
* Handles the keydown events for the dropdown.
*
Expand All @@ -857,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) {
Expand All @@ -889,6 +948,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.handleSearchCharacter(e.key);
}
return true;
}

Expand Down Expand Up @@ -1045,6 +1110,12 @@ export class BaseDropdown extends FASTElement {
BaseDropdown.AnchorPositionFallbackObserver?.disconnect();
this.debounceController?.abort();

if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = undefined;
this.searchString = '';
}

super.disconnectedCallback();
}

Expand Down
120 changes: 120 additions & 0 deletions packages/web-components/src/dropdown/dropdown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -550,4 +582,92 @@ test.describe('Dropdown', () => {

await expect(listbox).toBeHidden();
});

test.describe('search options by printable characters', () => {
test.beforeEach(async ({ fastPage }) => {
await fastPage.setTemplate({
innerHTML: `
<${ListboxTagName}>
<${OptionTagName} id="o1">Afoo</${OptionTagName}>
<${OptionTagName} id="o2">Bfoo</${OptionTagName}>
<${OptionTagName} id="o3">Bbfoo</${OptionTagName}>
<${OptionTagName} id="o4">Bcfoo</${OptionTagName}>
<${OptionTagName} id="o5">Cfoo</${OptionTagName}>
</${ListboxTagName}>
`,
});
});

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');
});
});
});
3 changes: 3 additions & 0 deletions packages/web-components/src/dropdown/dropdown.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,15 @@ export const Default: Story = {
slottedContent: () => [
{ value: 'apple', slottedContent: () => 'Apple' },
{ value: 'banana', slottedContent: () => 'Banana' },
{ 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' },
],
},
Expand Down
Loading