aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/dashboard
diff options
context:
space:
mode:
authorlexafaxine <40200356+lexafaxine@users.noreply.github.com>2025-07-14 09:00:36 +0900
committerGitHub <noreply@github.com>2025-07-14 01:00:36 +0100
commit39fcda015b467be6c08d134fd45ec94204b08a09 (patch)
treece78ec11e2cbbb349ec7d02ba69fad8fe16e19a1 /apps/web/components/dashboard
parentecb13cec5d5c646308b34c714401a716f3cdf199 (diff)
downloadkarakeep-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.tsx2
-rw-r--r--apps/web/components/dashboard/search/SearchInput.tsx197
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>
);
});