From 5da67e3b758b4f3c0fb172eb645e2c4953134519 Mon Sep 17 00:00:00 2001 From: Andrey Goder Date: Wed, 5 Aug 2026 16:04:03 -0700 Subject: [PATCH] Share one keyboard map across Select, MultiSelect, and AutocompleteInput (#380) The listbox key handling lived in two places with different bugs. Pull the decision layer into `internal/listboxKeyboard`, which maps a key event onto an open/move/jump/commit action and leaves `preventDefault()` to the caller. `useListboxNavigation` and `BaseAutocompleteInput` both drive off it now, so Select, MultiSelect, TagsInput, and AutocompleteInput answer to the same keys. That resolves three defects in the listbox map: - Home/End no longer fire while the caret is in a text field, so the search input inside the Select/MultiSelect popover gets them back for caret movement. The target decides, since the same handler is bound to both the search field and the trigger button. - Home/End go to the actual first/last option instead of the selection. `getInitialHighlight` still prefers the selection, but only for opening the listbox with an arrow key -- an explicit jump now uses the new `getEdgeHighlight`. - PageUp/PageDown jump the highlight to the first/last option. They previously fell through to the browser, which page-scrolled the popover away from `aria-activedescendant`. AutocompleteInput picks up the jumps as part of the alignment, plus two smaller consistency fixes it inherits from the shared map: ArrowUp on a closed menu opens it rather than silently moving a hidden highlight, and opening with an arrow key seeds the highlight at the matching edge. --- .../AutocompleteInput.test.tsx | 44 ++++++++ .../BaseAutocompleteInput.tsx | 71 +++++++----- .../MultiSelect/MultiSelect.test.tsx | 38 +++++++ src/components/Select/Select.test.tsx | 45 ++++++++ src/internal/listboxKeyboard.ts | 74 +++++++++++++ src/internal/useListboxNavigation.ts | 101 +++++++++--------- 6 files changed, 293 insertions(+), 80 deletions(-) create mode 100644 src/internal/listboxKeyboard.ts diff --git a/src/components/AutocompleteInput/AutocompleteInput.test.tsx b/src/components/AutocompleteInput/AutocompleteInput.test.tsx index b4137d79..785257bd 100644 --- a/src/components/AutocompleteInput/AutocompleteInput.test.tsx +++ b/src/components/AutocompleteInput/AutocompleteInput.test.tsx @@ -292,6 +292,50 @@ describe('AutocompleteInput', () => { expect(onOpenChange).toHaveBeenCalledWith(false); }); + it('jumps results with PageUp and PageDown while Home and End keep the caret', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + const input = screen.getByRole('combobox', {name: 'Assignee'}); + await user.type(input, 'a'); + + const highlightedId = input.getAttribute('aria-activedescendant'); + expect( + screen.getByRole('option', {hidden: true, name: /Ada Lovelace/}), + ).toHaveAttribute('id', highlightedId); + + await user.keyboard('{Home}'); + expect(input).toHaveProperty('selectionStart', 0); + expect(input).toHaveAttribute('aria-activedescendant', highlightedId); + + await user.keyboard('{End}'); + expect(input).toHaveProperty('selectionStart', 1); + expect(input).toHaveAttribute('aria-activedescendant', highlightedId); + + await user.keyboard('{PageDown}'); + expect( + screen.getByRole('option', {hidden: true, name: /Katherine Johnson/}), + ).toHaveAttribute('id', input.getAttribute('aria-activedescendant')); + + await user.keyboard('{PageUp}'); + expect( + screen.getByRole('option', {hidden: true, name: /Ada Lovelace/}), + ).toHaveAttribute('id', input.getAttribute('aria-activedescendant')); + + await user.keyboard('{Enter}'); + expect(onChange).toHaveBeenCalledWith(items[0]); + }); + it('does not select or close results while composing', async () => { const user = userEvent.setup(); const onChange = vi.fn(); diff --git a/src/components/AutocompleteInput/BaseAutocompleteInput.tsx b/src/components/AutocompleteInput/BaseAutocompleteInput.tsx index 789d56a0..67ef401a 100644 --- a/src/components/AutocompleteInput/BaseAutocompleteInput.tsx +++ b/src/components/AutocompleteInput/BaseAutocompleteInput.tsx @@ -28,7 +28,7 @@ import {Icon} from 'components/Icon'; import {Popover} from 'components/Popover'; import {Spinner} from 'components/Spinner'; import {Text} from 'components/Text'; -import {isComposingEvent} from 'internal/isComposingEvent'; +import {resolveListboxKeyAction} from 'internal/listboxKeyboard'; import {mergeRefs} from 'internal/mergeRefs'; import {scrollOptionIntoView} from 'internal/scrollOptionIntoView'; import {css} from 'styled-system/css'; @@ -482,47 +482,62 @@ export function BaseAutocompleteInput({ return; } - if (isComposingEvent(event)) { + const action = resolveListboxKeyAction(event, {isOpen}); + if (action == null) { return; } - if (event.key === 'ArrowDown') { - event.preventDefault(); - if (!isOpen) { + // Keep the highlighted result visible as the user moves through an + // overflowing list. + const highlightIndex = (index: number): void => { + setHighlightedIndex(index); + scrollOptionIntoView(`${listboxId}-option-${index}`); + }; + const edgeIndex = (edge: 'first' | 'last'): number => + edge === 'last' ? results.length - 1 : 0; + + switch (action.type) { + case 'open': { + event.preventDefault(); if (results.length > 0) { showMenu(); + highlightIndex(edgeIndex(action.edge)); } else if (hasEntriesOnFocus) { void runSearch('', 'bootstrap'); } return; } - if (results.length === 0) { - setHighlightedIndex(-1); + case 'move': { + event.preventDefault(); + if (results.length === 0) { + setHighlightedIndex(-1); + return; + } + highlightIndex( + highlightedIndex < 0 + ? edgeIndex(action.step === 1 ? 'first' : 'last') + : (highlightedIndex + action.step + results.length) % + results.length, + ); return; } - const nextIndex = (highlightedIndex + 1) % results.length; - setHighlightedIndex(nextIndex); - // Keep the highlighted result visible as the user arrows through - // an overflowing list. - scrollOptionIntoView(`${listboxId}-option-${nextIndex}`); - } else if (event.key === 'ArrowUp') { - event.preventDefault(); - if (results.length === 0) { - setHighlightedIndex(-1); + case 'jump': { + event.preventDefault(); + if (results.length === 0) { + setHighlightedIndex(-1); + return; + } + highlightIndex(edgeIndex(action.edge)); + return; + } + case 'commit': { + if (highlightedIndex < 0 || highlightedIndex >= results.length) { + return; + } + event.preventDefault(); + selectItem(results[highlightedIndex]); return; } - const nextIndex = - (highlightedIndex - 1 + results.length) % results.length; - setHighlightedIndex(nextIndex); - scrollOptionIntoView(`${listboxId}-option-${nextIndex}`); - } else if ( - event.key === 'Enter' && - isOpen && - highlightedIndex >= 0 && - highlightedIndex < results.length - ) { - event.preventDefault(); - selectItem(results[highlightedIndex]); } }} placeholder={placeholder} diff --git a/src/components/MultiSelect/MultiSelect.test.tsx b/src/components/MultiSelect/MultiSelect.test.tsx index 665b693f..5f834918 100644 --- a/src/components/MultiSelect/MultiSelect.test.tsx +++ b/src/components/MultiSelect/MultiSelect.test.tsx @@ -264,6 +264,44 @@ describe('MultiSelect', () => { expect(trigger).toHaveAttribute('aria-expanded', 'false'); }); + it('jumps to an edge with Home, End, and PageUp/PageDown past the selection', async () => { + const user = userEvent.setup(); + + render( + {}} + options={['Name', 'Email', 'Role']} + value={['Email']} + />, + ); + + const trigger = screen.getByRole('combobox', {name: 'Columns'}); + trigger.focus(); + + // Opening lands on the selection so the user resumes where they left off. + await user.keyboard('{ArrowDown}'); + expect( + screen.getByRole('option', {hidden: true, name: 'Email'}), + ).toHaveAttribute('id', trigger.getAttribute('aria-activedescendant')); + + // An explicit jump means the edge of the list, not the selection. + await user.keyboard('{Home}'); + expect( + screen.getByRole('option', {hidden: true, name: 'Name'}), + ).toHaveAttribute('id', trigger.getAttribute('aria-activedescendant')); + + await user.keyboard('{PageDown}'); + expect( + screen.getByRole('option', {hidden: true, name: 'Role'}), + ).toHaveAttribute('id', trigger.getAttribute('aria-activedescendant')); + + await user.keyboard('{PageUp}'); + expect( + screen.getByRole('option', {hidden: true, name: 'Name'}), + ).toHaveAttribute('id', trigger.getAttribute('aria-activedescendant')); + }); + it('ignores navigation and commit keys while composing', async () => { const user = userEvent.setup(); const onChange = vi.fn(); diff --git a/src/components/Select/Select.test.tsx b/src/components/Select/Select.test.tsx index 436bce4f..b875055b 100644 --- a/src/components/Select/Select.test.tsx +++ b/src/components/Select/Select.test.tsx @@ -367,6 +367,51 @@ describe('Select', () => { ).toHaveAttribute('id', search.getAttribute('aria-activedescendant')); }); + it('leaves Home and End to the search caret and jumps with PageUp and PageDown', async () => { + const user = userEvent.setup(); + + render( +