Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .nx/version-plans/cds-2505-select-alpha-typeahead.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
cds: minor
---

Feat: add native-style typeahead keyboard selection to the web `Select (Alpha)`, supporting a multi-character search buffer, repeated-key cycling, and matching both when the listbox is open and closed.
63 changes: 14 additions & 49 deletions packages/web/src/alpha/select/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { forwardRef, memo, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { autoUpdate, flip, useFloating, type UseFloatingReturn } from '@floating-ui/react-dom';

import { cx } from '../../cx';
Expand All @@ -30,6 +21,7 @@ import {
type SelectProps,
type SelectType,
} from './types';
import { useTypeahead } from './useTypeahead';

// Re-export all types for backward compatibility
export type {
Expand Down Expand Up @@ -135,9 +127,7 @@ const SelectBase = memo(
testID,
} = mergedProps;
const hasMounted = useHasMounted();
// The dropdown keeps a binary density toggle instead of the t-shirt scale, so Select owns
// the translation: only the smallest control size renders a compact dropdown. The control
// still receives the raw `compact` because it needs it for legacy label placement.
// Dropdown density is binary, so only the smallest size renders compact.
const dropdownCompact = (size ?? (compact ? 's' : defaultSelectSize)) === 's';
const [openInternal, setOpenInternal] = useState(defaultOpen ?? false);
const open = openProp ?? openInternal;
Expand All @@ -163,42 +153,17 @@ const SelectBase = memo(
excludeRefs: [refs.reference as React.MutableRefObject<HTMLElement>],
});

const pendingTypeAheadKeyRef = useRef<string | null>(null);

const handleControlKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (disabled || readOnly || open) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;

const key = event.key;
if (/^[a-z]$/.test(key)) {
pendingTypeAheadKeyRef.current = key;
setOpen(true);
}
},
[disabled, readOnly, open, setOpen],
);

useEffect(() => {
if (!open || !pendingTypeAheadKeyRef.current) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we label what these different effects are for? Maybe there is value in giving them names and pulling them into custom hooks just for oganization's sake (e.g. useFocusInPortaledDropdown etc.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is largely addressed: all the typeahead effects were extracted out of Select.tsx into a dedicated useTypeahead hook (useTypeahead.ts), which is the "pull them into a custom hook for organization" direction you suggested. Inside the hook I also added terse labels — the type-to-open focus effect and the window-listener effect each have a one-line comment describing their purpose. I intentionally kept it as a single cohesive hook rather than splitting into several micro-hooks (e.g. useFocusInPortaledDropdown), since the effects share the buffer/timeout/focus refs and splitting would add indirection without real clarity. Happy to split further if you feel strongly.


const key = pendingTypeAheadKeyRef.current;
pendingTypeAheadKeyRef.current = null;
const optionRole = accessibilityRoles?.option ?? 'option';

const floatingEl = refs.floating.current;
if (!floatingEl) return;

const optionRole = accessibilityRoles?.option ?? 'option';
const options = floatingEl.querySelectorAll(`[role="${optionRole}"]`);
const matchingOption = Array.from(options).find((option) => {
const firstLetterMatch = option.textContent?.match(/[a-z]/i);
return firstLetterMatch?.[0]?.toLowerCase() === key;
});

if (matchingOption) {
(matchingOption as HTMLElement).focus();
}
}, [open, refs.floating, accessibilityRoles?.option]);
const { onControlKeyDown } = useTypeahead({
open,
setOpen,
referenceRef: refs.reference as React.MutableRefObject<HTMLElement | null>,
floatingRef: refs.floating,
optionRole,
disabled,
readOnly,
});

const rootStyles = useMemo(
() => ({
Expand Down Expand Up @@ -344,7 +309,7 @@ const SelectBase = memo(
labelVariant={labelVariant}
maxSelectedOptionsToShow={maxSelectedOptionsToShow}
onChange={onChange}
onKeyDown={handleControlKeyDown}
onKeyDown={onControlKeyDown}
open={open}
options={options}
placeholder={placeholder}
Expand Down
89 changes: 89 additions & 0 deletions packages/web/src/alpha/select/__tests__/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import userEvent from '@testing-library/user-event';
import { ComponentConfigProvider } from '../../../system';
import { DefaultThemeProvider } from '../../../utils/test';
import { Select, type SelectDropdownComponent, type SelectProps } from '../Select';
import { TYPEAHEAD_RESET_MS } from '../useTypeahead';

const mockOptions = [
{ value: 'option1', label: 'Option 1' },
Expand Down Expand Up @@ -715,6 +716,94 @@ describe('Select', () => {
});
});

describe('Typeahead', () => {
const typeAheadOptions = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'blueberry', label: 'Blueberry' },
{ value: 'cherry', label: 'Cherry' },
];

const getOption = (name: string) => {
const option = screen.getAllByRole('option').find((opt) => opt.textContent?.includes(name));
if (!option) throw new Error(`Option "${name}" not found`);
return option;
};

it('matches a multi-character buffer while the dropdown is open', async () => {
const user = userEvent.setup();
render(
<DefaultThemeProvider>
<Select {...defaultProps} defaultOpen options={typeAheadOptions} />
</DefaultThemeProvider>,
);

await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});

screen.getByRole('button').focus();
await user.keyboard('bl');

await waitFor(() => {
expect(getOption('Blueberry')).toHaveFocus();
});
});

it('cycles through options that share a first letter on repeated key presses', async () => {
const user = userEvent.setup();
render(
<DefaultThemeProvider>
<Select {...defaultProps} defaultOpen options={typeAheadOptions} />
</DefaultThemeProvider>,
);

await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});

screen.getByRole('button').focus();

await user.keyboard('b');
await waitFor(() => {
expect(getOption('Banana')).toHaveFocus();
});

await user.keyboard('b');
await waitFor(() => {
expect(getOption('Blueberry')).toHaveFocus();
});

await user.keyboard('b');
await waitFor(() => {
expect(getOption('Banana')).toHaveFocus();
});
});

it('resets the search buffer after the reset timeout elapses', async () => {
const user = userEvent.setup();
render(
<DefaultThemeProvider>
<Select {...defaultProps} defaultOpen options={typeAheadOptions} />
</DefaultThemeProvider>,
);

screen.getByRole('button').focus();

await user.keyboard('b');
await waitFor(() => {
expect(getOption('Banana')).toHaveFocus();
});

// Let the buffer reset. Typing "l" afterwards is a fresh, unmatched buffer ("l"), so focus
// must stay on Banana. Without the reset the accumulated "bl" would jump to Blueberry.
await new Promise((resolve) => setTimeout(resolve, TYPEAHEAD_RESET_MS + 100));

await user.keyboard('l');
expect(getOption('Banana')).toHaveFocus();
});
});

describe('readOnly', () => {
it('does not apply disabled opacity styling', () => {
render(
Expand Down
114 changes: 114 additions & 0 deletions packages/web/src/alpha/select/__tests__/useTypeahead.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
getTypeaheadMatchIndex,
isPrintableTypeaheadKey,
isTypeaheadKeyEvent,
normalizeOptionText,
TYPEAHEAD_RESET_MS,
} from '../useTypeahead';

describe('typeahead helpers', () => {
describe('isPrintableTypeaheadKey', () => {
it.each(['a', 'Z', '5'])('treats single alphanumeric "%s" as printable', (key) => {
expect(isPrintableTypeaheadKey(key)).toBe(true);
});

it.each(['Enter', 'ArrowDown', 'Escape', ' ', '-', 'ab'])(
'treats "%s" as non-printable',
(key) => {
expect(isPrintableTypeaheadKey(key)).toBe(false);
},
);
});

describe('isTypeaheadKeyEvent', () => {
const keyEvent = (
event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey'>,
): KeyboardEvent => event as KeyboardEvent;

it('accepts a bare printable key', () => {
expect(
isTypeaheadKeyEvent(keyEvent({ key: 'a', ctrlKey: false, metaKey: false, altKey: false })),
).toBe(true);
});

it('rejects printable keys pressed with a modifier', () => {
expect(
isTypeaheadKeyEvent(keyEvent({ key: 'a', ctrlKey: true, metaKey: false, altKey: false })),
).toBe(false);
expect(
isTypeaheadKeyEvent(keyEvent({ key: 'a', ctrlKey: false, metaKey: true, altKey: false })),
).toBe(false);
expect(
isTypeaheadKeyEvent(keyEvent({ key: 'a', ctrlKey: false, metaKey: false, altKey: true })),
).toBe(false);
});

it('rejects non-printable keys', () => {
expect(
isTypeaheadKeyEvent(
keyEvent({ key: 'Enter', ctrlKey: false, metaKey: false, altKey: false }),
),
).toBe(false);
});
});

describe('normalizeOptionText', () => {
it('lowercases the text', () => {
expect(normalizeOptionText('Banana')).toBe('banana');
});

it('strips leading non-alphanumeric characters', () => {
expect(normalizeOptionText(' ✓ Banana')).toBe('banana');
});

it('handles nullish input', () => {
expect(normalizeOptionText(null)).toBe('');
expect(normalizeOptionText(undefined)).toBe('');
});
});

describe('getTypeaheadMatchIndex', () => {
const labels = ['apple', 'banana', 'blueberry', 'cherry'];

it('returns -1 when there is no search or no options', () => {
expect(getTypeaheadMatchIndex(labels, '', -1)).toBe(-1);
expect(getTypeaheadMatchIndex([], 'a', -1)).toBe(-1);
});

it('finds the first prefix match when nothing is focused', () => {
expect(getTypeaheadMatchIndex(labels, 'b', -1)).toBe(1);
});

it('cycles to the next match on a repeated single character', () => {
// Focused on "banana" (index 1), pressing "b" again should move to "blueberry".
expect(getTypeaheadMatchIndex(labels, 'b', 1)).toBe(2);
});

it('wraps around when cycling past the last match', () => {
// Focused on "blueberry" (index 2), pressing "b" wraps back to "banana".
expect(getTypeaheadMatchIndex(labels, 'b', 2)).toBe(1);
});

it('treats a repeated same character buffer as cycling', () => {
expect(getTypeaheadMatchIndex(labels, 'bb', 1)).toBe(2);
});

it('performs a multi-character prefix match and keeps the current option when it still matches', () => {
// Focused on "banana" (index 1) after typing "b"; refining to "ba" keeps "banana".
expect(getTypeaheadMatchIndex(labels, 'ba', 1)).toBe(1);
});

it('moves to a different option when the refined buffer no longer matches the current one', () => {
// Focused on "banana" (index 1); refining to "bl" should jump to "blueberry".
expect(getTypeaheadMatchIndex(labels, 'bl', 1)).toBe(2);
});

it('returns -1 when nothing matches', () => {
expect(getTypeaheadMatchIndex(labels, 'z', -1)).toBe(-1);
});
});

it('exposes a reset timeout constant', () => {
expect(TYPEAHEAD_RESET_MS).toBe(500);
});
});
Loading
Loading