From f8edb587e4828efae4cca10bfcd7d612d00443e1 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 18 Apr 2026 14:48:30 +0100 Subject: [PATCH] feat(base): add useListKeyboardNav hook for dropdown keyboard nav --- .../base/hooks/use-list-keyboard-nav.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 apps/client/src/features/base/hooks/use-list-keyboard-nav.ts diff --git a/apps/client/src/features/base/hooks/use-list-keyboard-nav.ts b/apps/client/src/features/base/hooks/use-list-keyboard-nav.ts new file mode 100644 index 00000000..12c55ebd --- /dev/null +++ b/apps/client/src/features/base/hooks/use-list-keyboard-nav.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +type UseListKeyboardNavResult = { + activeIndex: number; + setActiveIndex: (idx: number) => void; + handleNavKey: (e: React.KeyboardEvent) => boolean; + setOptionRef: (idx: number) => (el: HTMLElement | null) => void; +}; + +export function useListKeyboardNav( + itemCount: number, + resetDeps: ReadonlyArray, +): UseListKeyboardNavResult { + const [activeIndex, setActiveIndex] = useState(-1); + const optionRefs = useRef>([]); + + // Reset highlight when filter/open-state changes. resetDeps is intentional. + useEffect(() => { + setActiveIndex(-1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, resetDeps); + + useEffect(() => { + if (activeIndex < 0) return; + const el = optionRefs.current[activeIndex]; + if (el) el.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + const setOptionRef = useCallback( + (idx: number) => (el: HTMLElement | null) => { + optionRefs.current[idx] = el; + }, + [], + ); + + const handleNavKey = useCallback( + (e: React.KeyboardEvent): boolean => { + if (itemCount === 0) return false; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((idx) => (idx < itemCount - 1 ? idx + 1 : 0)); + return true; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((idx) => (idx <= 0 ? itemCount - 1 : idx - 1)); + return true; + } + if (e.key === "Home") { + e.preventDefault(); + setActiveIndex(0); + return true; + } + if (e.key === "End") { + e.preventDefault(); + setActiveIndex(itemCount - 1); + return true; + } + return false; + }, + [itemCount], + ); + + return { activeIndex, setActiveIndex, handleNavKey, setOptionRef }; +}