useListNavigation
Keyboard navigation for a combobox or listbox with virtual focus. Real focus stays in the input while
aria-activedescendant follows the highlighted option.Type to filter. ↑/↓ move the highlight (skipping disabled options, and wrapping unless
loop: false), PageUp/PageDown jump by pageSize (default 10), and Enter picks the highlighted option. You own the highlight: pass activeIndex / onActiveChange, onSelect, getId(i) and listId, then spread the returned inputProps (combobox role, aria-expanded, aria-controls, aria-activedescendant), listProps and getOptionProps(i). react-native-web reports a TextInput's keys through onKeyPress, so the demo moves inputProps.onKeyDown there; you can also call handleKeyDown yourself. For a popup list, pass opened, onOpen (↑/↓ open it) and onClose (Escape closes it), and set homeEndKeys for pickers that have no text caret.Loading demo…
import { useState } from 'react';
import { Pressable, TextInput } from 'react-native';
import { Block, Text, useA11yId, useListNavigation, useTheme, webProps } from '@plocks/ui';
const FRUITS = ['Apple', 'Apricot', 'Banana', 'Blueberry', 'Cherry', 'Grape', 'Mango', 'Peach'];
// A pointer press on an option must not move focus out of the input.
const keepInputFocus = (event: { preventDefault(): void }) => event.preventDefault();
export function Demo() {
const theme = useTheme();
const listId = useA11yId(undefined, 'fruits');
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const search = query.trim().toLowerCase();
const options = FRUITS.filter((fruit) => fruit.toLowerCase().includes(search));
const pick = (index: number) => {
setQuery(options[index]);
setActiveIndex(-1);
};
const nav = useListNavigation({
count: options.length,
activeIndex,
onActiveChange: setActiveIndex,
onSelect: pick,
getId: (index) => `${listId}-option-${index}`,
listId,
});
// react-native-web reports a TextInput's keys through onKeyPress, not onKeyDown.
const { onKeyDown, ...comboboxProps } = nav.inputProps;
return (
<Block fullWidth maw={320} gap="xs">
<TextInput
{...comboboxProps}
aria-label="Fruit"
placeholder="Search fruit"
placeholderTextColor={theme.text.muted}
value={query}
onChangeText={(text) => {
setQuery(text);
setActiveIndex(-1);
}}
onKeyPress={onKeyDown}
autoCapitalize="none"
autoCorrect={false}
style={{
borderWidth: 1,
borderColor: theme.backgrounds.borderStrong,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 14,
fontFamily: theme.fontFamily,
color: theme.text.primary,
}}
/>
<Block {...nav.listProps} gap={0}>
{options.map((fruit, index) => (
<Pressable
key={fruit}
{...nav.getOptionProps(index)}
{...webProps({ tabIndex: -1, onMouseDown: keepInputFocus })}
onPress={() => pick(index)}
onHoverIn={() => setActiveIndex(index)}
>
<Block px="sm" py="xs" radius="sm" bg={index === activeIndex ? 'hover' : undefined}>
<Text selectable={false}>{fruit}</Text>
</Block>
</Pressable>
))}
</Block>
</Block>
);
}React Native design for iOS, Android, and Web.
Quick Links
Resources