diff --git a/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx index 003c9301..05cdba05 100644 --- a/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx +++ b/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx @@ -1,5 +1,5 @@ import { Search, X } from 'lucide-react'; -import { type KeyboardEvent, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'; +import { type KeyboardEvent, type ReactNode, startTransition, useCallback, useMemo, useRef, useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'; @@ -65,8 +65,10 @@ export function DetailNavigationSheet({ const buttonRefs = useRef(new Map()); const [focusedId, setFocusedId] = useState(null); + const [localQuery, setLocalQuery] = useState(searchQuery); + const hasEntries = items.length > 0; - const trimmedQuery = searchQuery.trim(); + const trimmedQuery = localQuery.trim(); const hasClearButton = hasSearch && trimmedQuery.length > 0; // Build an `id → index` map once per `items`/`getId` change so both the @@ -249,6 +251,11 @@ export function DetailNavigationSheet({ [getId, hasEntries, items], ); + const clearSearch = useCallback(() => { + setLocalQuery(''); + startTransition(() => clearSearchQuery()); + }, [clearSearchQuery]); + // Intercept Esc at the Radix-Content level so we can clear a non-empty // search before the dialog's built-in "close on Esc" fires. Once the // query is empty, we let Radix close as usual — matches the two-step @@ -257,23 +264,25 @@ export function DetailNavigationSheet({ (event: KeyboardEvent) => { if (hasSearch && trimmedQuery.length > 0) { event.preventDefault(); - clearSearchQuery(); + clearSearch(); } }, - [clearSearchQuery, hasSearch, trimmedQuery.length], + [clearSearch, hasSearch, trimmedQuery.length], ); const handleSearchChange = useCallback( (event: React.ChangeEvent) => { - setSearchQuery(event.target.value); + const { value } = event.target; + setLocalQuery(value); + startTransition(() => setSearchQuery(value)); }, [setSearchQuery], ); const handleSearchClear = useCallback(() => { - clearSearchQuery(); + clearSearch(); searchInputRef.current?.focus(); - }, [clearSearchQuery]); + }, [clearSearch]); // One stable callback ref reused for every button. The previous shape — // `setButtonRef(id) => (node) => …` — manufactured a new closure per id @@ -299,6 +308,45 @@ export function DetailNavigationSheet({ }; }, []); + const listItems = useMemo( + () => + items.map((item) => { + const id = String(getId(item)); + const isCurrent = currentId != null && id === String(currentId); + const isFocused = id === focusedId; + + return ( +
  • + +
  • + ); + }), + [currentId, focusedId, getId, getLabel, handleItemClick, items, renderItem, setButtonRef], + ); + return ( ({ placeholder={searchPlaceholder} ref={searchInputRef} type="text" - value={searchQuery} + value={localQuery} /> {hasClearButton ? ( @@ -368,40 +416,7 @@ export function DetailNavigationSheet({ ref={listRef} role="listbox" > - {items.map((item) => { - const id = String(getId(item)); - const isCurrent = currentId != null && id === String(currentId); - const isFocused = id === focusedId; - - return ( -
  • - -
  • - ); - })} + {listItems} ) : (