diff options
| author | lexafaxine <40200356+lexafaxine@users.noreply.github.com> | 2025-07-14 09:00:36 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-07-14 01:00:36 +0100 |
| commit | 39fcda015b467be6c08d134fd45ec94204b08a09 (patch) | |
| tree | ce78ec11e2cbbb349ec7d02ba69fad8fe16e19a1 /apps/web/components/dashboard | |
| parent | ecb13cec5d5c646308b34c714401a716f3cdf199 (diff) | |
| download | karakeep-39fcda015b467be6c08d134fd45ec94204b08a09.tar.zst | |
feat: adding search history #1541 (#1627)
* feat: adding search history
* fix popover should close when no matched history
* remove unnecessary react import
* replace current Input component with CommandInput for better UX
* add i18n for recent searches label
* fix bug
* refactor local storage logic to make code reusable
* using zod schema to validate search history and revert debounce change
* Consolidate some of the files
---------
Co-authored-by: Mohamed Bassem <me@mbassem.com>
Diffstat (limited to 'apps/web/components/dashboard')
| -rw-r--r-- | apps/web/components/dashboard/header/Header.tsx | 2 | ||||
| -rw-r--r-- | apps/web/components/dashboard/search/SearchInput.tsx | 197 |
2 files changed, 168 insertions, 31 deletions
diff --git a/apps/web/components/dashboard/header/Header.tsx b/apps/web/components/dashboard/header/Header.tsx index e882ebfc..f830beb6 100644 --- a/apps/web/components/dashboard/header/Header.tsx +++ b/apps/web/components/dashboard/header/Header.tsx @@ -20,7 +20,7 @@ export default async function Header() { </Link> </div> <div className="flex flex-1 gap-2"> - <SearchInput className="min-w-40 bg-muted" /> + <SearchInput className="min-w-40 rounded-md bg-muted" /> <GlobalActions /> </div> <div className="flex items-center"> diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx index fad45672..0de7694a 100644 --- a/apps/web/components/dashboard/search/SearchInput.tsx +++ b/apps/web/components/dashboard/search/SearchInput.tsx @@ -1,20 +1,44 @@ "use client"; -import React, { useEffect, useImperativeHandle, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; -import { SearchIcon } from "lucide-react"; +import { History } from "lucide-react"; + +import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history"; import { EditListModal } from "../lists/EditListModal"; import QueryExplainerTooltip from "./QueryExplainerTooltip"; +const MAX_DISPLAY_SUGGESTIONS = 5; + function useFocusSearchOnKeyPress( inputRef: React.RefObject<HTMLInputElement>, - onChange: (e: React.ChangeEvent<HTMLInputElement>) => void, + value: string, + setValue: (value: string) => void, + setPopoverOpen: React.Dispatch<React.SetStateAction<boolean>>, ) { useEffect(() => { function handleKeyPress(e: KeyboardEvent) { @@ -27,18 +51,12 @@ function useFocusSearchOnKeyPress( // Move the cursor to the end of the input field, so you can continue typing const length = inputRef.current.value.length; inputRef.current.setSelectionRange(length, length); + setPopoverOpen(true); } - if ( - e.code === "Escape" && - e.target == inputRef.current && - inputRef.current.value !== "" - ) { + if (e.code === "Escape" && e.target == inputRef.current && value !== "") { e.preventDefault(); inputRef.current.blur(); - inputRef.current.value = ""; - onChange({ - target: inputRef.current, - } as React.ChangeEvent<HTMLInputElement>); + setValue(""); } } @@ -46,7 +64,7 @@ function useFocusSearchOnKeyPress( return () => { document.removeEventListener("keydown", handleKeyPress); }; - }, [inputRef, onChange]); + }, [inputRef, value, setValue, setPopoverOpen]); } const SearchInput = React.forwardRef< @@ -54,20 +72,81 @@ const SearchInput = React.forwardRef< React.HTMLAttributes<HTMLInputElement> & { loading?: boolean } >(({ className, ...props }, ref) => { const { t } = useTranslation(); - const { debounceSearch, searchQuery, parsedSearchQuery, isInSearchPage } = - useDoBookmarkSearch(); + const { + debounceSearch, + searchQuery, + doSearch, + parsedSearchQuery, + isInSearchPage, + } = useDoBookmarkSearch(); + const { addTerm, history } = useSearchHistory({ + getItem: (k: string) => localStorage.getItem(k), + setItem: (k: string, v: string) => localStorage.setItem(k, v), + removeItem: (k: string) => localStorage.removeItem(k), + }); const [value, setValue] = React.useState(searchQuery); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false); const inputRef = useRef<HTMLInputElement>(null); - const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { - setValue(e.target.value); - debounceSearch(e.target.value); - }; + const isHistorySelected = useRef(false); - useFocusSearchOnKeyPress(inputRef, onChange); + const handleValueChange = useCallback( + (newValue: string) => { + setValue(newValue); + debounceSearch(newValue); + isHistorySelected.current = false; // Reset flag when user types + }, + [debounceSearch], + ); + + const suggestions = useMemo(() => { + if (value.trim() === "") { + // Show recent items when not typing + return history.slice(0, MAX_DISPLAY_SUGGESTIONS); + } else { + // Show filtered items when typing + return history + .filter((item) => item.toLowerCase().includes(value.toLowerCase())) + .slice(0, MAX_DISPLAY_SUGGESTIONS); + } + }, [history, value]); + + const isPopoverVisible = isPopoverOpen && suggestions.length > 0; + const handleHistorySelect = useCallback( + (term: string) => { + isHistorySelected.current = true; + setValue(term); + doSearch(term); + addTerm(term); + setIsPopoverOpen(false); + inputRef.current?.blur(); + }, + [doSearch], + ); + + const handleCommandKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter") { + const selectedItem = document.querySelector( + '[cmdk-item][data-selected="true"]', + ); + const isPlaceholderSelected = + selectedItem?.getAttribute("data-value") === "-"; + if (!selectedItem || isPlaceholderSelected) { + e.preventDefault(); + setIsPopoverOpen(false); + inputRef.current?.blur(); + } + } else if (e.key === "Escape") { + e.preventDefault(); + setIsPopoverOpen(false); + inputRef.current?.blur(); + } + }, []); + + useFocusSearchOnKeyPress(inputRef, value, setValue, setIsPopoverOpen); useImperativeHandle(ref, () => inputRef.current!); - const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false); useEffect(() => { if (!isInSearchPage) { @@ -75,6 +154,21 @@ const SearchInput = React.forwardRef< } }, [isInSearchPage]); + const handleFocus = useCallback(() => { + setIsPopoverOpen(true); + }, []); + + const handleBlur = useCallback(() => { + // Only add to history if it wasn't a history selection + if (value && !isHistorySelected.current) { + addTerm(value); + } + + // Reset the flag + isHistorySelected.current = false; + setIsPopoverOpen(false); + }, [value, addTerm]); + return ( <div className={cn("relative flex-1", className)}> <EditListModal @@ -103,14 +197,57 @@ const SearchInput = React.forwardRef< {t("actions.save")} </Button> )} - <Input - startIcon={<SearchIcon size={18} className="text-muted-foreground" />} - ref={inputRef} - value={value} - onChange={onChange} - placeholder={t("common.search")} - {...props} - /> + <Command + shouldFilter={false} + className="relative rounded-md bg-transparent" + onKeyDown={handleCommandKeyDown} + > + <Popover open={isPopoverVisible}> + <PopoverTrigger asChild> + <div className="relative"> + <CommandInput + ref={inputRef} + placeholder={t("common.search")} + value={value} + onValueChange={handleValueChange} + onFocus={handleFocus} + onBlur={handleBlur} + className={cn("h-10", className)} + {...props} + /> + </div> + </PopoverTrigger> + <PopoverContent + className="w-[--radix-popover-trigger-width] p-0" + onOpenAutoFocus={(e) => e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + <CommandList> + <CommandGroup + heading={t("search.history")} + className="max-h-60 overflow-y-auto" + > + {/* prevent cmdk auto select the first suggestion -> https://github.com/pacocoursey/cmdk/issues/171*/} + <CommandItem value="-" className="hidden" /> + {suggestions.map((term) => ( + <CommandItem + key={term} + value={term} + onSelect={() => handleHistorySelect(term)} + onMouseDown={() => { + isHistorySelected.current = true; + }} + className="cursor-pointer" + > + <History className="mr-2 h-4 w-4" /> + <span>{term}</span> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </PopoverContent> + </Popover> + </Command> </div> ); }); |
