-
Notifications
You must be signed in to change notification settings - Fork 2
Add search UI for full-text entry search #809
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| /** | ||
| * Search Page | ||
| * | ||
| * Prefetches entry data for /search route with full-text search query. | ||
| * AppRouter handles rendering. | ||
| */ | ||
|
|
||
| import { EntryListPage } from "@/components/entries/EntryListPage"; | ||
|
|
||
| interface SearchPageProps { | ||
| searchParams: Promise<{ [key: string]: string | string[] | undefined }>; | ||
| } | ||
|
|
||
| export default function SearchPage({ searchParams }: SearchPageProps) { | ||
| return <EntryListPage pathname="/search" searchParams={searchParams} />; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,105 @@ | ||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * SearchInput Component | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Search input for the /search page. Debounces input and updates the URL | ||||||||||||||||||||||||||
| * query parameter `q` to drive full-text search via entries.list. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Autofocuses on mount. Keyboard shortcuts are disabled while focused. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| "use client"; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import { useState, useEffect, useRef, useCallback } from "react"; | ||||||||||||||||||||||||||
| import { useSearchParams } from "next/navigation"; | ||||||||||||||||||||||||||
| import { clientReplace } from "@/lib/navigation"; | ||||||||||||||||||||||||||
| import { SearchIcon, CloseIcon } from "@/components/ui/icon-button"; | ||||||||||||||||||||||||||
| import { useKeyboardShortcutsContext } from "@/components/keyboard/KeyboardShortcutsProvider"; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const DEBOUNCE_MS = 300; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function SearchInput() { | ||||||||||||||||||||||||||
| const searchParams = useSearchParams(); | ||||||||||||||||||||||||||
| const urlQuery = searchParams?.get("q") ?? ""; | ||||||||||||||||||||||||||
| const [inputValue, setInputValue] = useState(urlQuery); | ||||||||||||||||||||||||||
| const inputRef = useRef<HTMLInputElement>(null); | ||||||||||||||||||||||||||
| const { setEnabled: setKeyboardShortcutsEnabled } = useKeyboardShortcutsContext(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Track the last value we wrote to the URL to distinguish external changes | ||||||||||||||||||||||||||
| // (e.g., browser back/forward) from our own debounced updates | ||||||||||||||||||||||||||
| const lastWrittenRef = useRef(urlQuery); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Sync input value when URL changes externally (browser back/forward). | ||||||||||||||||||||||||||
| // We detect external changes by comparing the URL value with what we last wrote. | ||||||||||||||||||||||||||
| if (urlQuery !== lastWrittenRef.current) { | ||||||||||||||||||||||||||
| lastWrittenRef.current = urlQuery; | ||||||||||||||||||||||||||
| if (urlQuery !== inputValue) { | ||||||||||||||||||||||||||
| setInputValue(urlQuery); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+33
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mutating a ref during the render phase is a side effect that violates React's rules. This can lead to unpredictable behavior in concurrent rendering. Instead, use the recommended pattern for adjusting state when a prop (or URL parameter) changes by tracking the previous value in state.
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Debounce URL updates | ||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||
| const timer = setTimeout(() => { | ||||||||||||||||||||||||||
| if (inputValue !== urlQuery) { | ||||||||||||||||||||||||||
| const params = new URLSearchParams(searchParams?.toString() ?? ""); | ||||||||||||||||||||||||||
| if (inputValue) { | ||||||||||||||||||||||||||
| params.set("q", inputValue); | ||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||
| params.delete("q"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| const queryString = params.toString(); | ||||||||||||||||||||||||||
| const url = queryString ? `/search?${queryString}` : "/search"; | ||||||||||||||||||||||||||
| lastWrittenRef.current = inputValue; | ||||||||||||||||||||||||||
| clientReplace(url); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| }, DEBOUNCE_MS); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return () => clearTimeout(timer); | ||||||||||||||||||||||||||
| }, [inputValue, urlQuery, searchParams]); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Autofocus on mount | ||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||
| inputRef.current?.focus(); | ||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Disable keyboard shortcuts while input is focused | ||||||||||||||||||||||||||
| const handleFocus = useCallback(() => { | ||||||||||||||||||||||||||
| setKeyboardShortcutsEnabled(false); | ||||||||||||||||||||||||||
| }, [setKeyboardShortcutsEnabled]); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const handleBlur = useCallback(() => { | ||||||||||||||||||||||||||
| setKeyboardShortcutsEnabled(true); | ||||||||||||||||||||||||||
| }, [setKeyboardShortcutsEnabled]); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const handleClear = useCallback(() => { | ||||||||||||||||||||||||||
| setInputValue(""); | ||||||||||||||||||||||||||
| inputRef.current?.focus(); | ||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||
|
Comment on lines
+74
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The clear action should update the URL immediately to provide instant feedback to the user. Currently, it relies on the 300ms debounce timer, which causes the previous search results to linger after the input is cleared. |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||
| <div className="relative flex-1"> | ||||||||||||||||||||||||||
| <SearchIcon className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-zinc-400 dark:text-zinc-500" /> | ||||||||||||||||||||||||||
| <input | ||||||||||||||||||||||||||
| ref={inputRef} | ||||||||||||||||||||||||||
| type="search" | ||||||||||||||||||||||||||
| value={inputValue} | ||||||||||||||||||||||||||
| onChange={(e) => setInputValue(e.target.value)} | ||||||||||||||||||||||||||
| onFocus={handleFocus} | ||||||||||||||||||||||||||
| onBlur={handleBlur} | ||||||||||||||||||||||||||
| placeholder="Search entries..." | ||||||||||||||||||||||||||
| aria-label="Search entries" | ||||||||||||||||||||||||||
| className="ui-text-sm w-full rounded-lg border border-zinc-200 bg-white py-2 pr-8 pl-9 text-zinc-900 placeholder-zinc-400 transition-colors outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder-zinc-500 dark:focus:border-zinc-500 dark:focus:ring-zinc-500" | ||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||
| {inputValue && ( | ||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||
| onClick={handleClear} | ||||||||||||||||||||||||||
| className="absolute top-1/2 right-2 -translate-y-1/2 rounded p-0.5 text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300" | ||||||||||||||||||||||||||
| aria-label="Clear search" | ||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||
| <CloseIcon className="h-3.5 w-3.5" /> | ||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The manual toggling of keyboard shortcuts is redundant. The useKeyboardShortcuts hook in this project already uses enableOnFormTags: false for all its hotkeys, which automatically disables them when an input or search field is focused. Removing this manual state management simplifies the component.