From 39fcda015b467be6c08d134fd45ec94204b08a09 Mon Sep 17 00:00:00 2001
From: lexafaxine <40200356+lexafaxine@users.noreply.github.com>
Date: Mon, 14 Jul 2025 09:00:36 +0900
Subject: 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>
---
 .../components/dashboard/search/SearchInput.tsx    | 197 +++++++++++++++++----
 1 file changed, 167 insertions(+), 30 deletions(-)

(limited to 'apps/web/components/dashboard/search/SearchInput.tsx')

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>
   );
 });
-- 
cgit v1.2.3-70-g09d2

