diff --git a/src/components/AutocompleteInput/AutocompleteInput.test.tsx b/src/components/AutocompleteInput/AutocompleteInput.test.tsx
index b4137d7..785257b 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 789d56a..67ef401 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 665b693..5f83491 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 436bce4..b875055 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(
+