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
44 changes: 44 additions & 0 deletions src/components/AutocompleteInput/AutocompleteInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<AutocompleteInput
debounceMs={0}
label="Assignee"
onChange={onChange}
searchSource={createStaticSearchSource(items)}
value={null}
/>,
);

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();
Expand Down
71 changes: 43 additions & 28 deletions src/components/AutocompleteInput/BaseAutocompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -482,47 +482,62 @@ export function BaseAutocompleteInput<T extends SearchableItem>({
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}
Expand Down
38 changes: 38 additions & 0 deletions src/components/MultiSelect/MultiSelect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MultiSelect
label="Columns"
onChange={() => {}}
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();
Expand Down
45 changes: 45 additions & 0 deletions src/components/Select/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Select
hasSearch
label="Fruit"
onChange={() => {}}
options={['Apple', 'Apricot', 'Grape', 'Cherry']}
value={null}
/>,
);

await user.click(screen.getByRole('combobox', {name: 'Fruit'}));

const search = screen.getByLabelText('Search Fruit');
await user.type(search, 'ap');
await user.keyboard('{ArrowDown}');

const highlightedId = search.getAttribute('aria-activedescendant');
expect(
screen.getByRole('option', {hidden: true, name: 'Apple'}),
).toHaveAttribute('id', highlightedId);

// Home and End stay with the text caret while the search field has focus.
await user.keyboard('{Home}');
expect(search).toHaveProperty('selectionStart', 0);
expect(search).toHaveAttribute('aria-activedescendant', highlightedId);

await user.keyboard('{End}');
expect(search).toHaveProperty('selectionStart', 'ap'.length);
expect(search).toHaveAttribute('aria-activedescendant', highlightedId);

// PageUp/PageDown are the jumps the listbox owns everywhere.
await user.keyboard('{PageDown}');
expect(
screen.getByRole('option', {hidden: true, name: 'Grape'}),
).toHaveAttribute('id', search.getAttribute('aria-activedescendant'));

await user.keyboard('{PageUp}');
expect(
screen.getByRole('option', {hidden: true, name: 'Apple'}),
).toHaveAttribute('id', search.getAttribute('aria-activedescendant'));
});

it('does not open when disabled', async () => {
const user = userEvent.setup();

Expand Down
74 changes: 74 additions & 0 deletions src/internal/listboxKeyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type {KeyboardEvent} from 'react';
import {isComposingEvent} from 'internal/isComposingEvent';

/**
* The combobox keyboard vocabulary shared by every listbox in the library.
*
* - `open` -- the listbox is closed and an arrow key should reveal it, seeded
* from the given edge.
* - `move` -- step the highlight one option in `step`'s direction.
* - `jump` -- send the highlight straight to an edge of the list.
* - `commit` -- accept whatever is highlighted.
*/
export type ListboxKeyAction =
| {edge: 'first' | 'last'; type: 'jump'}
| {edge: 'first' | 'last'; type: 'open'}
| {step: -1 | 1; type: 'move'}
| {type: 'commit'};

/**
* Whether the key event landed on something with a text caret, which claims
* Home/End for caret movement. Comboboxes with an in-popover search field bind
* the same handler to both the field and the trigger, so the target decides.
*/
function hasTextCaret(event: KeyboardEvent<HTMLElement>): boolean {
const target = event.target;
return (
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLInputElement &&
// `selectionStart` is null on input types without a caret (checkbox,
// color, range) and a number on the text-entry ones.
target.selectionStart != null)
);
}

/**
* Maps a key event onto the listbox action it should perform, or null when the
* listbox has no interest in the key. Callers own `preventDefault()` so they
* can decline an action they cannot fulfil -- Enter with nothing highlighted,
* for instance, must stay with the form.
*
* Home/End only reach the listbox when the caret is not in a text field;
* PageUp/PageDown are the jumps that always belong to the listbox.
*/
export function resolveListboxKeyAction(
event: KeyboardEvent<HTMLElement>,
{isOpen}: {isOpen: boolean},
): ListboxKeyAction | null {
if (isComposingEvent(event)) {
return null;
}

switch (event.key) {
case 'ArrowDown':
return isOpen ? {step: 1, type: 'move'} : {edge: 'first', type: 'open'};
case 'ArrowUp':
return isOpen ? {step: -1, type: 'move'} : {edge: 'last', type: 'open'};
case 'Home':
return isOpen && !hasTextCaret(event)
? {edge: 'first', type: 'jump'}
: null;
case 'End':
return isOpen && !hasTextCaret(event)
? {edge: 'last', type: 'jump'}
: null;
case 'PageUp':
return isOpen ? {edge: 'first', type: 'jump'} : null;
case 'PageDown':
return isOpen ? {edge: 'last', type: 'jump'} : null;
case 'Enter':
return isOpen ? {type: 'commit'} : null;
default:
return null;
}
}
Loading