Skip to content
Merged
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 @@
{
Comment thread
marchbox marked this conversation as resolved.
"type": "prerelease",
"comment": "add keyboard support for printable characters in Dropdown",
"packageName": "@fluentui/web-components",
"email": "machi@microsoft.com",
"dependentChangeType": "patch"
}
1 change: 1 addition & 0 deletions packages/web-components/docs/web-components.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,7 @@ export class BaseDropdown extends FASTElement {
placeholder: string;
reportValidity(): boolean;
required: boolean;
protected searchTimeoutMs: number;
// @internal
get selectedIndex(): number;
get selectedOptions(): DropdownOption[];
Expand Down
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 @@ -836,6 +836,64 @@ export class BaseDropdown extends FASTElement {
this._insertingControl = false;
}

/**
* The duration in milliseconds after the last character search keystroke before the search string is cleared.
*/
protected searchTimeoutMs = 500;

/**
* 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;
}, this.searchTimeoutMs);
}

/**
* Handles the keydown events for the dropdown.
*
Expand All @@ -858,16 +916,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 @@ -890,6 +949,12 @@ export class BaseDropdown extends FASTElement {
}

if (!increment) {
if (!this.isCombobox && e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
Comment thread
radium-v marked this conversation as resolved.
if (!this.open) {
this.listbox.showPopover();
}
this.handleSearchCharacter(e.key);
}
return true;
}

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

if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = undefined;
this.searchString = '';
Comment thread
marchbox marked this conversation as resolved.
}

super.disconnectedCallback();
}

Expand Down
118 changes: 118 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,90 @@ test.describe('Dropdown', () => {

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

test.describe('search options by printable characters', () => {
test.use({
innerHTML: /* html */ `
<${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 fastPage.setTemplate();

await combobox.focus();
await page.keyboard.press('b', { delay: 500 });

await expect(combobox).toHaveAttribute('aria-activedescendant', 'o2');

await page.keyboard.press('a', { delay: 500 });

await expect(combobox).toHaveAttribute('aria-activedescendant', 'o1');

await page.keyboard.press('c', { delay: 500 });

await expect(combobox).toHaveAttribute('aria-activedescendant', 'o5');

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 fastPage.setTemplate();

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 fastPage.setTemplate();

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