From 1b2327cd6492a7636679d577d927c66d4e3c46bd Mon Sep 17 00:00:00 2001 From: Giorgi Gordiashvili Date: Mon, 13 Apr 2026 12:07:05 +0400 Subject: [PATCH] feat: support Arrow Up/Down keyboard navigation in InputTagsWithSuggestions Add keyboard navigation to the tags input suggestions dropdown: - Arrow Down/Up to cycle through filtered suggestions - Enter to select the highlighted suggestion - Escape to close the dropdown - Mouse hover syncs with keyboard highlight state - Scroll highlighted items into view for long lists - Add ARIA combobox attributes for accessibility Closes #77 --- full-kit/src/components/ui/input-tags.tsx | 87 ++++++++++++++++++++--- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/full-kit/src/components/ui/input-tags.tsx b/full-kit/src/components/ui/input-tags.tsx index d4307906..0d4296d8 100644 --- a/full-kit/src/components/ui/input-tags.tsx +++ b/full-kit/src/components/ui/input-tags.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useImperativeHandle, useRef, useState } from "react" +import { useEffect, useId, useImperativeHandle, useRef, useState } from "react" import { X } from "lucide-react" import type { ComponentProps, KeyboardEvent } from "react" @@ -121,9 +121,12 @@ export function InputTagsWithSuggestions({ className, ...props }: InputTagsWithSuggestionsProps) { + const listboxId = useId() const [inputValue, setInputValue] = useState("") const [open, setOpen] = useState(false) + const [highlightedIndex, setHighlightedIndex] = useState(-1) const containerRef = useRef(null) + const listRef = useRef(null) const [popoverWidth, setPopoverWidth] = useState( undefined ) @@ -140,6 +143,19 @@ export function InputTagsWithSuggestions({ !tags.includes(suggestion) ) + // Reset highlighted index when input value changes + useEffect(() => { + setHighlightedIndex(-1) + }, [inputValue]) + + // Scroll highlighted item into view + useEffect(() => { + if (highlightedIndex >= 0 && listRef.current) { + const items = listRef.current.querySelectorAll("[cmdk-item]") + items[highlightedIndex]?.scrollIntoView({ block: "nearest" }) + } + }, [highlightedIndex]) + const addTag = (tag: string) => { const trimmedTag = tag.trim() if (trimmedTag && !tags.includes(trimmedTag)) { @@ -153,16 +169,58 @@ export function InputTagsWithSuggestions({ } const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && !open) { + if (e.key === "ArrowDown") { e.preventDefault() - addTag(inputValue) + if (!open && filteredSuggestions.length > 0) { + setOpen(true) + } + if (filteredSuggestions.length > 0) { + setHighlightedIndex((prev) => + prev < filteredSuggestions.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (!open && filteredSuggestions.length > 0) { + setOpen(true) + } + if (filteredSuggestions.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredSuggestions.length - 1 + ) + } + } else if (e.key === "Enter") { + e.preventDefault() + if ( + highlightedIndex >= 0 && + highlightedIndex < filteredSuggestions.length + ) { + addTag(filteredSuggestions[highlightedIndex]) + setHighlightedIndex(-1) + } else if (!open) { + addTag(inputValue) + } + } else if (e.key === "Escape" && open) { + setOpen(false) + setHighlightedIndex(-1) } else if (e.key === "Backspace" && !inputValue && tags.length > 0) { removeTag(tags.length - 1) } } + // Derive the highlighted value for cmdk's controlled selection + const highlightedValue = + highlightedIndex >= 0 ? (filteredSuggestions[highlightedIndex] ?? "") : "" + return ( - + { + setOpen(isOpen) + if (!isOpen) setHighlightedIndex(-1) + }} + >
setInputValue(e.target.value)} onKeyDown={handleKeyDown} @@ -208,13 +270,22 @@ export function InputTagsWithSuggestions({ onOpenAutoFocus={(e) => e.preventDefault()} > - - + {}} + > + No results found. - {filteredSuggestions.map((suggestion) => ( + {filteredSuggestions.map((suggestion, index) => ( addTag(suggestion)} + value={suggestion} + onMouseMove={() => setHighlightedIndex(index)} + onSelect={() => { + addTag(suggestion) + setHighlightedIndex(-1) + }} > {suggestion}