Skip to content

Commit f14249d

Browse files
authored
feat(web-components): add keyboard support for printable characters in Dropdown (#36232)
1 parent 0a59056 commit f14249d

5 files changed

Lines changed: 208 additions & 8 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "prerelease",
3+
"comment": "add keyboard support for printable characters in Dropdown",
4+
"packageName": "@fluentui/web-components",
5+
"email": "machi@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/web-components/docs/web-components.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,7 @@ export class BaseDropdown extends FASTElement {
677677
placeholder: string;
678678
reportValidity(): boolean;
679679
required: boolean;
680+
protected searchTimeoutMs: number;
680681
// @internal
681682
get selectedIndex(): number;
682683
get selectedOptions(): DropdownOption[];

packages/web-components/src/dropdown/dropdown.base.ts

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,64 @@ export class BaseDropdown extends FASTElement {
836836
this._insertingControl = false;
837837
}
838838

839+
/**
840+
* The duration in milliseconds after the last character search keystroke before the search string is cleared.
841+
*/
842+
protected searchTimeoutMs = 500;
843+
844+
/**
845+
* The accumulated search string used to match option labels by prefix when printable characters are typed.
846+
*
847+
* @internal
848+
*/
849+
private searchString: string = '';
850+
851+
/**
852+
* The timeout id used to reset the search string.
853+
*
854+
* @internal
855+
*/
856+
private searchTimeout?: ReturnType<typeof setTimeout>;
857+
858+
/**
859+
* Handles printable character input by moving {@link activeIndex} to the next option whose label matches the
860+
* accumulated search string. When the string is a single character (or the same character repeated), matching
861+
* options are cycled through; otherwise the string is treated as a prefix match.
862+
*
863+
* @param char - the printable character that was pressed
864+
* @internal
865+
*/
866+
private handleSearchCharacter(char: string): void {
867+
const isRepeating = this.searchString === char.repeat(this.searchString.length);
868+
this.searchString += char;
869+
870+
let candidates = this.searchString.length > 1 ? this.filterOptions(this.searchString) : [];
871+
let isCycling = false;
872+
873+
if (!candidates.length && isRepeating) {
874+
candidates = this.filterOptions(char);
875+
isCycling = true;
876+
}
877+
878+
if (candidates.length) {
879+
const activeOption = this.enabledOptions[this.activeIndex];
880+
const currentPos = candidates.indexOf(activeOption);
881+
const nextMatch = isCycling
882+
? candidates[this.getEnabledIndexInBounds(currentPos + 1, candidates.length)]
883+
: currentPos >= 0
884+
? activeOption
885+
: candidates[0];
886+
887+
this.activeIndex = this.enabledOptions.indexOf(nextMatch);
888+
}
889+
890+
clearTimeout(this.searchTimeout);
891+
this.searchTimeout = setTimeout(() => {
892+
this.searchString = '';
893+
this.searchTimeout = undefined;
894+
}, this.searchTimeoutMs);
895+
}
896+
839897
/**
840898
* Handles the keydown events for the dropdown.
841899
*
@@ -858,16 +916,17 @@ export class BaseDropdown extends FASTElement {
858916
break;
859917
}
860918

861-
case ' ': {
862-
if (this.isCombobox) {
863-
break;
864-
}
865-
866-
e.preventDefault();
867-
}
868-
919+
case ' ':
869920
case 'Enter':
870921
case 'Tab': {
922+
if (e.key === ' ') {
923+
if (this.isCombobox) {
924+
break;
925+
}
926+
927+
e.preventDefault();
928+
}
929+
871930
if (this.open) {
872931
this.selectOption(this.activeIndex, true);
873932
if (this.multiple) {
@@ -890,6 +949,12 @@ export class BaseDropdown extends FASTElement {
890949
}
891950

892951
if (!increment) {
952+
if (!this.isCombobox && e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
953+
if (!this.open) {
954+
this.listbox.showPopover();
955+
}
956+
this.handleSearchCharacter(e.key);
957+
}
893958
return true;
894959
}
895960

@@ -1046,6 +1111,12 @@ export class BaseDropdown extends FASTElement {
10461111
BaseDropdown.AnchorPositionFallbackObserver?.disconnect();
10471112
this.debounceController?.abort();
10481113

1114+
if (this.searchTimeout) {
1115+
clearTimeout(this.searchTimeout);
1116+
this.searchTimeout = undefined;
1117+
this.searchString = '';
1118+
}
1119+
10491120
super.disconnectedCallback();
10501121
}
10511122

packages/web-components/src/dropdown/dropdown.spec.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,38 @@ test.describe('Dropdown', () => {
114114
await expect(listbox).toBeVisible();
115115
});
116116

117+
test('should open the dropdown when a character is pressed', async ({ fastPage }) => {
118+
const { element } = fastPage;
119+
const listbox = element.locator(ListboxTagName);
120+
const button = element.locator('[role=combobox]');
121+
122+
await fastPage.setTemplate();
123+
124+
await button.press('a');
125+
126+
await expect(listbox).toBeVisible();
127+
});
128+
129+
test('should not open the dropdown when a character is pressed with Meta, Alt, or Ctrl', async ({ fastPage }) => {
130+
const { element } = fastPage;
131+
const listbox = element.locator(ListboxTagName);
132+
const button = element.locator('[role=combobox]');
133+
134+
await fastPage.setTemplate();
135+
136+
await button.press('Meta+a');
137+
138+
await expect(listbox).toBeHidden();
139+
140+
await button.press('Alt+a');
141+
142+
await expect(listbox).toBeHidden();
143+
144+
await button.press('Control+a');
145+
146+
await expect(listbox).toBeHidden();
147+
});
148+
117149
test("should set the `name` property on options when it's set on the dropdown", async ({ fastPage }) => {
118150
const { element } = fastPage;
119151
const options = element.locator(OptionTagName);
@@ -550,4 +582,90 @@ test.describe('Dropdown', () => {
550582

551583
await expect(listbox).toBeHidden();
552584
});
585+
586+
test.describe('search options by printable characters', () => {
587+
test.use({
588+
innerHTML: /* html */ `
589+
<${ListboxTagName}>
590+
<${OptionTagName} id="o1">Afoo</${OptionTagName}>
591+
<${OptionTagName} id="o2">Bfoo</${OptionTagName}>
592+
<${OptionTagName} id="o3">Bbfoo</${OptionTagName}>
593+
<${OptionTagName} id="o4">Bcfoo</${OptionTagName}>
594+
<${OptionTagName} id="o5">Cfoo</${OptionTagName}>
595+
</${ListboxTagName}>
596+
`,
597+
});
598+
599+
test('should set active descendant based on user typing', async ({ fastPage }) => {
600+
const { element, page } = fastPage;
601+
const combobox = element.getByRole('combobox');
602+
603+
await fastPage.setTemplate();
604+
605+
await combobox.focus();
606+
await page.keyboard.press('b', { delay: 500 });
607+
608+
await expect(combobox).toHaveAttribute('aria-activedescendant', 'o2');
609+
610+
await page.keyboard.press('a', { delay: 500 });
611+
612+
await expect(combobox).toHaveAttribute('aria-activedescendant', 'o1');
613+
614+
await page.keyboard.press('c', { delay: 500 });
615+
616+
await expect(combobox).toHaveAttribute('aria-activedescendant', 'o5');
617+
618+
await page.keyboard.press('d');
619+
620+
await expect(combobox).toHaveAttribute('aria-activedescendant', 'o5');
621+
});
622+
623+
test('should cycle through matching options as active descendant based on user typing', async ({ fastPage }) => {
624+
const { element, page } = fastPage;
625+
const combobox = element.getByRole('combobox');
626+
627+
await fastPage.setTemplate();
628+
629+
await combobox.focus();
630+
await page.keyboard.press('b');
631+
632+
await expect(combobox).toHaveAttribute('aria-activedescendant', 'o2');
633+
634+
await page.keyboard.press('b');
635+
636+
await expect(combobox).toHaveAttribute('aria-activedescendant', 'o3');
637+
638+
await page.keyboard.press('b');
639+
640+
await expect(combobox).toHaveAttribute('aria-activedescendant', 'o4');
641+
642+
await page.keyboard.press('b');
643+
644+
await expect(combobox).toHaveAttribute('aria-activedescendant', 'o2');
645+
});
646+
647+
test('should set active descendant if its label has repeated character', async ({ fastPage }) => {
648+
const { element, page } = fastPage;
649+
const combobox = element.getByRole('combobox');
650+
651+
await fastPage.setTemplate();
652+
653+
await combobox.focus();
654+
await page.keyboard.type('bb', { delay: 100 });
655+
656+
await expect(combobox).toHaveAttribute('aria-activedescendant', 'o3');
657+
658+
await page.waitForTimeout(500);
659+
660+
await page.keyboard.type('bb', { delay: 100 });
661+
662+
await expect(combobox).toHaveAttribute('aria-activedescendant', 'o3');
663+
664+
await page.waitForTimeout(500);
665+
666+
await page.keyboard.type('bb', { delay: 600 });
667+
668+
await expect(combobox).toHaveAttribute('aria-activedescendant', 'o2');
669+
});
670+
});
553671
});

packages/web-components/src/dropdown/dropdown.stories.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,15 @@ export const Default: Story = {
105105
slottedContent: () => [
106106
{ value: 'apple', slottedContent: () => 'Apple' },
107107
{ value: 'banana', slottedContent: () => 'Banana' },
108+
{ value: 'blueberry', slottedContent: () => 'Blueberry' },
108109
{ value: 'orange', slottedContent: () => 'Orange' },
109110
{ value: 'mango', slottedContent: () => 'Mango' },
110111
{ value: 'kiwi', slottedContent: () => 'Kiwi' },
111112
{ value: 'cherry', slottedContent: () => 'Cherry' },
112113
{ value: 'grapefruit', slottedContent: () => 'Grapefruit' },
113114
{ value: 'papaya', slottedContent: () => 'Papaya' },
115+
{ value: 'pear', slottedContent: () => 'Pear' },
116+
{ value: 'peach', slottedContent: () => 'Peach' },
114117
{ value: 'lychee', slottedContent: () => 'Lychee' },
115118
],
116119
},

0 commit comments

Comments
 (0)