feat: add keyboard navigation to combobox and select fields on web

This commit is contained in:
Mohamed Boudra
2026-01-30 22:52:07 +07:00
parent 025a68c3a4
commit ea33d1980f
8 changed files with 320 additions and 22 deletions
@@ -134,11 +134,44 @@ export function SelectField({
valueEllipsizeMode,
testID,
}: SelectFieldProps): ReactElement {
const getWebKey = useCallback((event: unknown): string | null => {
if (!event || typeof event !== "object") return null;
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
if (typeof eventWithNative.key === "string") return eventWithNative.key;
const nativeEvent = eventWithNative.nativeEvent as { key?: unknown } | undefined;
return typeof nativeEvent?.key === "string" ? nativeEvent.key : null;
}, []);
const preventWebDefault = useCallback((event: unknown) => {
if (!event || typeof event !== "object") return;
const candidate = event as { preventDefault?: unknown };
if (typeof candidate.preventDefault === "function") {
candidate.preventDefault();
}
}, []);
const handleKeyDown = useCallback(
(event: unknown) => {
if (Platform.OS !== "web") return;
const key = getWebKey(event);
if (key === "Enter" || key === " ") {
preventWebDefault(event);
onPress();
}
},
[getWebKey, onPress, preventWebDefault]
);
return (
<View style={styles.selectFieldContainer}>
<Pressable
ref={controlRef}
onPress={onPress}
// @ts-ignore - tabIndex is web-only
tabIndex={0}
accessibilityRole="button"
// @ts-ignore - onKeyDown is web-only
onKeyDown={handleKeyDown}
disabled={disabled}
testID={testID}
style={[styles.selectFieldControl, disabled && styles.selectFieldControlDisabled]}
@@ -332,10 +365,43 @@ function CompactSelectField({
isLoading,
controlRef,
}: CompactSelectFieldProps): ReactElement {
const getWebKey = useCallback((event: unknown): string | null => {
if (!event || typeof event !== "object") return null;
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
if (typeof eventWithNative.key === "string") return eventWithNative.key;
const nativeEvent = eventWithNative.nativeEvent as { key?: unknown } | undefined;
return typeof nativeEvent?.key === "string" ? nativeEvent.key : null;
}, []);
const preventWebDefault = useCallback((event: unknown) => {
if (!event || typeof event !== "object") return;
const candidate = event as { preventDefault?: unknown };
if (typeof candidate.preventDefault === "function") {
candidate.preventDefault();
}
}, []);
const handleKeyDown = useCallback(
(event: unknown) => {
if (Platform.OS !== "web") return;
const key = getWebKey(event);
if (key === "Enter" || key === " ") {
preventWebDefault(event);
onPress();
}
},
[getWebKey, onPress, preventWebDefault]
);
return (
<Pressable
ref={controlRef}
onPress={onPress}
// @ts-ignore - tabIndex is web-only
tabIndex={0}
accessibilityRole="button"
// @ts-ignore - onKeyDown is web-only
onKeyDown={handleKeyDown}
disabled={disabled}
style={[styles.compactSelectControl, disabled && styles.compactSelectControlDisabled]}
>
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { getNextActiveIndex } from "./combobox-keyboard";
describe("getNextActiveIndex", () => {
it("returns -1 when itemCount is 0", () => {
expect(
getNextActiveIndex({ currentIndex: 0, itemCount: 0, key: "ArrowDown" })
).toBe(-1);
});
it("starts at 0 on ArrowDown when no active item", () => {
expect(
getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowDown" })
).toBe(0);
});
it("starts at last on ArrowUp when no active item", () => {
expect(
getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowUp" })
).toBe(2);
});
it("wraps around on ArrowDown and ArrowUp", () => {
expect(
getNextActiveIndex({ currentIndex: 2, itemCount: 3, key: "ArrowDown" })
).toBe(0);
expect(
getNextActiveIndex({ currentIndex: 0, itemCount: 3, key: "ArrowUp" })
).toBe(2);
});
});
@@ -0,0 +1,19 @@
export function getNextActiveIndex(args: {
currentIndex: number;
itemCount: number;
key: "ArrowDown" | "ArrowUp";
}): number {
const { currentIndex, itemCount, key } = args;
if (itemCount <= 0) return -1;
if (currentIndex < 0) {
return key === "ArrowDown" ? 0 : itemCount - 1;
}
const normalizedCurrent = currentIndex % itemCount;
return key === "ArrowDown"
? (normalizedCurrent + 1) % itemCount
: (normalizedCurrent - 1 + itemCount) % itemCount;
}
+116 -12
View File
@@ -27,6 +27,7 @@ import {
size as floatingSize,
useFloating,
} from "@floating-ui/react-native";
import { getNextActiveIndex } from "./combobox-keyboard";
const IS_WEB = Platform.OS === "web";
@@ -113,6 +114,7 @@ export interface ComboboxItemProps {
label: string;
description?: string;
selected?: boolean;
active?: boolean;
onPress: () => void;
testID?: string;
}
@@ -121,6 +123,7 @@ export function ComboboxItem({
label,
description,
selected,
active,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
@@ -132,6 +135,7 @@ export function ComboboxItem({
style={({ pressed }) => [
styles.comboboxItem,
pressed && styles.comboboxItemPressed,
active && styles.comboboxItemActive,
]}
>
<View style={styles.comboboxItemCheckSlot}>
@@ -174,6 +178,7 @@ export function Combobox({
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-1);
const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false);
@@ -294,7 +299,6 @@ export function Combobox({
);
}, [options, normalizedSearch]);
const hasMatches = filteredOptions.length > 0;
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption =
allowCustomValue &&
@@ -305,6 +309,56 @@ export function Combobox({
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
);
const visibleOptions = useMemo(() => {
const next: Array<{
id: string;
label: string;
description?: string;
}> = [];
if (showCustomOption) {
next.push({
id: sanitizedSearchValue,
label: `${customValuePrefix} "${sanitizedSearchValue}"`,
description: customValueDescription,
});
}
for (const opt of filteredOptions) {
next.push({
id: opt.id,
label: opt.label,
description: opt.description,
});
}
return next;
}, [
customValueDescription,
customValuePrefix,
filteredOptions,
sanitizedSearchValue,
showCustomOption,
]);
useEffect(() => {
if (!isOpen) return;
if (!IS_WEB && isMobile) return;
if (visibleOptions.length === 0) {
setActiveIndex(-1);
return;
}
if (normalizedSearch) {
setActiveIndex(0);
return;
}
const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
}, [isMobile, isOpen, normalizedSearch, value, visibleOptions]);
const handleSelect = useCallback(
(id: string) => {
onSelect(id);
@@ -319,6 +373,58 @@ export function Combobox({
}
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
const handleDesktopKey = useCallback(
(key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => {
if (!isOpen) return;
if (!IS_WEB && isMobile) return;
if (key === "ArrowDown" || key === "ArrowUp") {
event?.preventDefault();
setActiveIndex((currentIndex) =>
getNextActiveIndex({
currentIndex,
itemCount: visibleOptions.length,
key,
})
);
return;
}
if (key === "Enter") {
if (visibleOptions.length === 0) return;
event?.preventDefault();
const index =
activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0;
handleSelect(visibleOptions[index]!.id);
return;
}
if (key === "Escape") {
event?.preventDefault();
handleClose();
}
},
[activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions]
);
useEffect(() => {
if (!IS_WEB || !isOpen) return;
const handler = (event: KeyboardEvent) => {
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
return;
}
handleDesktopKey(key, event);
};
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
return () => {
window.removeEventListener("keydown", handler, true);
};
}, [handleDesktopKey, isOpen]);
const searchInput = (
<SearchInput
placeholder={searchPlaceholder ?? placeholder}
@@ -331,26 +437,20 @@ export function Combobox({
const optionsList = (
<>
{showCustomOption ? (
<ComboboxItem
label={`${customValuePrefix} "${sanitizedSearchValue}"`}
description={customValueDescription}
onPress={() => handleSelect(sanitizedSearchValue)}
/>
) : null}
{hasMatches ? (
filteredOptions.map((opt) => (
{visibleOptions.length > 0 ? (
visibleOptions.map((opt, index) => (
<ComboboxItem
key={opt.id}
label={opt.label}
description={opt.description}
selected={opt.id === value}
active={index === activeIndex}
onPress={() => handleSelect(opt.id)}
/>
))
) : !showCustomOption ? (
) : (
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
) : null}
)}
</>
);
@@ -412,6 +512,7 @@ export function Combobox({
width: referenceWidth ?? undefined,
},
floatingStyles,
referenceWidth === null ? { opacity: 0 } : null,
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
typeof availableSize?.width === "number" ? { maxWidth: availableSize.width } : null,
]}
@@ -475,6 +576,9 @@ const styles = StyleSheet.create((theme) => ({
comboboxItemPressed: {
backgroundColor: theme.colors.surface2,
},
comboboxItemActive: {
backgroundColor: theme.colors.surface2,
},
comboboxItemCheckSlot: {
width: 16,
alignItems: "center",