Skip to content

Instantly share code, notes, and snippets.

@albingroen
Last active October 31, 2025 11:27
Show Gist options
  • Select an option

  • Save albingroen/17297bab5e539634359e7b1ad2bb6c6c to your computer and use it in GitHub Desktop.

Select an option

Save albingroen/17297bab5e539634359e7b1ad2bb6c6c to your computer and use it in GitHub Desktop.
Global Search
import Mark from "mark.js";
import { useEffect, useRef, useState } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
} from "@radix-ui/react-dialog";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "./ui/input-group";
import { ArrowDownIcon, ArrowUpIcon, SearchIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { Kbd, KbdGroup } from "./ui/kbd";
import { platform } from "@tauri-apps/plugin-os";
function isMetaKey(event: React.KeyboardEvent | KeyboardEvent) {
const platformName = platform();
if (platformName === "macos") {
return event.metaKey;
}
return event.ctrlKey;
}
export default function GlobalSearch() {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [searchTerm, setSearchTerm] = useState("");
const [currentIndex, setCurrentIndex] = useState(0);
const [totalMatches, setTotalMatches] = useState(0);
const markInstance = useRef<Mark | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
markInstance.current = new Mark(document.body);
function handleKeyDown(event: KeyboardEvent) {
if (isMetaKey(event) && event.key === "f") {
if (isOpen) {
inputRef.current?.focus();
} else {
setIsOpen(true);
}
}
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
useEffect(() => {
if (isOpen && searchTerm) {
performSearch(searchTerm);
} else if (!isOpen) {
if (markInstance.current) {
markInstance.current.unmark();
}
}
}, [isOpen]);
const performSearch = (term: string) => {
if (!markInstance.current) return;
markInstance.current.unmark();
if (!term) {
setTotalMatches(0);
setCurrentIndex(0);
return;
}
markInstance.current.mark(term, {
separateWordSearch: false,
done: (totalMarks) => {
setTotalMatches(totalMarks);
if (totalMarks > 0) {
setCurrentIndex(0);
scrollToMatch(0);
}
},
className: "search-highlight",
each: (element) => {
element.setAttribute("data-mark-index", "");
},
});
};
const scrollToMatch = (index: number) => {
const marks = document.querySelectorAll("mark.search-highlight");
if (marks[index]) {
marks.forEach((m) => m.classList.remove("current-match"));
marks[index].classList.add("current-match");
marks[index].scrollIntoView({
behavior: "smooth",
block: "center",
});
}
};
const goToNext = () => {
if (totalMatches === 0) return;
const nextIndex = (currentIndex + 1) % totalMatches;
setCurrentIndex(nextIndex);
scrollToMatch(nextIndex);
};
const goToPrevious = () => {
if (totalMatches === 0) return;
const prevIndex = (currentIndex - 1 + totalMatches) % totalMatches;
setCurrentIndex(prevIndex);
scrollToMatch(prevIndex);
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen} modal={false}>
<DialogContent
onInteractOutside={(e) => {
e.preventDefault();
}}
className="bg-background shadow-xl flex items-center z-50 fixed top-2 right-2 gap-2 rounded-md"
>
<DialogTitle className="sr-only">Search</DialogTitle>
<DialogDescription className="sr-only">
Search the page for any word or character
</DialogDescription>
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
ref={inputRef}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
performSearch(e.target.value);
}}
placeholder="Find..."
onKeyDown={(e) => {
if (
(e.shiftKey && e.key === "Enter") ||
(isMetaKey(e) && e.shiftKey && e.key === "g")
) {
goToPrevious();
} else if (e.key === "Enter" || (isMetaKey(e) && e.key === "g")) {
goToNext();
}
}}
/>
<InputGroupAddon align="inline-end" className="w-20 justify-end">
<InputGroupText
className={cn(searchTerm ? "opacity-100" : "opacity-0")}
>
{totalMatches > 0
? `${currentIndex + 1} of ${totalMatches}`
: "0 of 0"}
</InputGroupText>
</InputGroupAddon>
<InputGroupAddon align="inline-end" className="gap-0">
<Tooltip>
<TooltipTrigger asChild>
<InputGroupButton size="icon-xs" onClick={goToNext}>
<ArrowDownIcon />
</InputGroupButton>
</TooltipTrigger>
<TooltipContent className="pr-1">
<span>Next result</span>
<KbdGroup className="ml-1.5">
<Kbd>⏎</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<InputGroupButton size="icon-xs" onClick={goToPrevious}>
<ArrowUpIcon />
</InputGroupButton>
</TooltipTrigger>
<TooltipContent className="pr-1">
<span>Previous result</span>
<KbdGroup className="ml-1.5">
<Kbd>⇧</Kbd>
<Kbd>⏎</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<Tooltip>
<TooltipTrigger asChild>
<DialogClose asChild>
<InputGroupButton size="icon-xs">
<XIcon />
</InputGroupButton>
</DialogClose>
</TooltipTrigger>
<TooltipContent className="pr-1">
<span>Close find</span>
<KbdGroup className="ml-1.5">
<Kbd>␛</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
</DialogContent>
</Dialog>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment