From d793c9bfbbc01f8a1af52104cb9a47a6394287bb Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Fri, 21 Nov 2025 17:28:51 +0100 Subject: [PATCH] feat: implement searchbar based on doc content --- package.json | 5 +- pnpm-lock.yaml | 24 +- .../components/content/card-group/card.astro | 2 +- .../elements/DocNavigationWrapper.astro | 8 +- src/lib/components/elements/navbar.tsx | 341 +++++++++--------- .../components/elements/search-command.tsx | 120 ++++++ src/lib/components/ui/command.tsx | 184 ++++++++++ src/lib/components/ui/dialog.tsx | 140 +++++++ src/lib/components/ui/input-group.tsx | 168 +++++++++ src/lib/components/ui/input.tsx | 21 ++ src/lib/components/ui/kbd.tsx | 28 ++ src/lib/components/ui/textarea.tsx | 18 + src/lib/layouts/BaseLayout.astro | 16 + 13 files changed, 900 insertions(+), 175 deletions(-) create mode 100644 src/lib/components/elements/search-command.tsx create mode 100644 src/lib/components/ui/command.tsx create mode 100644 src/lib/components/ui/dialog.tsx create mode 100644 src/lib/components/ui/input-group.tsx create mode 100644 src/lib/components/ui/input.tsx create mode 100644 src/lib/components/ui/kbd.tsx create mode 100644 src/lib/components/ui/textarea.tsx diff --git a/package.json b/package.json index 9d2e6f9..93c04b1 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,10 @@ "@iconify/react": "^6.0.0", "@lucide/astro": "^0.488.0", "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.13", + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-navigation-menu": "^1.2.5", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-slot": "^1.2.3", "@scalar/api-reference-react": "^0.6.19", "@tailwindcss/vite": "^4.0.17", "@types/hast": "^3.0.4", @@ -34,6 +34,7 @@ "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "hast": "^1.0.0", "hast-util-to-html": "^9.0.5", "hastscript": "^9.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca8ad3a..0ef770b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,7 +30,7 @@ dependencies: specifier: ^1.1.12 version: 1.1.12(@types/react-dom@19.1.6)(@types/react@19.1.8)(react-dom@19.1.0)(react@19.1.0) '@radix-ui/react-dialog': - specifier: ^1.1.13 + specifier: ^1.1.14 version: 1.1.14(@types/react-dom@19.1.6)(@types/react@19.1.8)(react-dom@19.1.0)(react@19.1.0) '@radix-ui/react-dropdown-menu': specifier: ^2.1.6 @@ -39,7 +39,7 @@ dependencies: specifier: ^1.2.5 version: 1.2.13(@types/react-dom@19.1.6)(@types/react@19.1.8)(react-dom@19.1.0)(react@19.1.0) '@radix-ui/react-slot': - specifier: ^1.1.2 + specifier: ^1.2.3 version: 1.2.3(@types/react@19.1.8)(react@19.1.0) '@scalar/api-reference-react': specifier: ^0.6.19 @@ -80,6 +80,9 @@ dependencies: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.1.6)(@types/react@19.1.8)(react-dom@19.1.0)(react@19.1.0) hast: specifier: ^1.0.0 version: 1.0.0 @@ -3785,6 +3788,23 @@ packages: engines: {node: '>=6'} dev: false + /cmdk@1.1.1(@types/react-dom@19.1.6)(@types/react@19.1.8)(react-dom@19.1.0)(react@19.1.0): + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.1.6)(@types/react@19.1.8)(react-dom@19.1.0)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6)(@types/react@19.1.8)(react-dom@19.1.0)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + dev: false + /codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} dependencies: diff --git a/src/lib/components/content/card-group/card.astro b/src/lib/components/content/card-group/card.astro index 2f453d2..cf0808b 100644 --- a/src/lib/components/content/card-group/card.astro +++ b/src/lib/components/content/card-group/card.astro @@ -22,7 +22,7 @@ const props = Astro.props; />
-

{props.label}

+

{props.label}

diff --git a/src/lib/components/elements/DocNavigationWrapper.astro b/src/lib/components/elements/DocNavigationWrapper.astro index 9ba230c..e43dffb 100644 --- a/src/lib/components/elements/DocNavigationWrapper.astro +++ b/src/lib/components/elements/DocNavigationWrapper.astro @@ -41,7 +41,9 @@ const container = "flex flex-col justify-between border rounded-xl p-6 w-1/2";
-

{previousPage.data.title}

+

+ {previousPage.data.title} +

{previousPage.data.description}

@@ -67,7 +69,9 @@ const container = "flex flex-col justify-between border rounded-xl p-6 w-1/2";
-

{nextPage.data.title}

+

+ {nextPage.data.title} +

{nextPage.data.description}

diff --git a/src/lib/components/elements/navbar.tsx b/src/lib/components/elements/navbar.tsx index 60dc4ac..816ded9 100644 --- a/src/lib/components/elements/navbar.tsx +++ b/src/lib/components/elements/navbar.tsx @@ -18,7 +18,7 @@ import { cn } from "@/utils"; import { Icon } from "@iconify/react"; import config from "explainer.config"; import { LaptopIcon, MenuIcon, MoonIcon, SunIcon } from "lucide-react"; -import * as React from "react"; +import { forwardRef, Fragment, useState } from "react"; import { Sheet, SheetContent, @@ -28,8 +28,9 @@ import { SheetTitle, SheetTrigger, } from "../ui/sheet"; +import { SearchCommand } from "./search-command"; -const ListItem = React.forwardRef< +const ListItem = forwardRef< React.ComponentRef<"a">, React.ComponentPropsWithoutRef<"a"> & { icon?: string } >(({ className, title, icon, children, ...props }, ref) => { @@ -60,196 +61,200 @@ ListItem.displayName = "ListItem"; type NavbarProps = { docs: any[]; + searchableDoc: any[]; }; export default function Navbar(props: NavbarProps) { return ( -
-
- - - 💧 {config.projectName} - - - -
- + +
+
+ 💧 {config.projectName} -
- - - {props.docs.map((element, index) => ( - - - - {element.data.label} - - -
    - {element.children - // .filter((item: any) => item.visible) - .map((item: any) => ( - - {item.data.description} - - ))} -
-
-
- ))} -
-
- - - {config.navbar.map((element) => ( - - - {element.label} - - - ))} - - -
-
- +
+ + + 💧 {config.projectName} + + + +
+ + + {props.docs.map((element, index) => ( + + + + {element.data.label} + + +
    + {element.children + // .filter((item: any) => item.visible) + .map((item: any) => ( + + {item.data.description} + + ))} +
+
+
+ ))} +
+
+ + + {config.navbar.map((element) => ( + + + {element.label} + + + ))} + + +
- {config.socials.media.github && ( - - + + + {config.socials.media.github && ( + - - - {config.seo.title} on GitHub - - )} + + + + {config.seo.title} on GitHub + + )} +
-
-
- - - - - - -
- - {config.seo.title} - - -
- - Home - - {config.navbar.map((element) => { - if (element.href) { - return ( - - {element.label} - - ); - } - })} +
+ + + + + + +
+ + {config.seo.title} + + +
+ + Home + + {config.navbar.map((element) => { + if (element.href) { + return ( + + {element.label} + + ); + } + })} -
- {props.docs.map((element, index) => ( -
-
- - {element.label} -
-
-
    - {element.children - // .filter((item: any) => item.visible) - .map((item: any) => ( - - {item.data.title} - - ))} -
+
+ {props.docs.map((element, index) => ( +
+
+ + {element.label} +
+
+
    + {element.children + // .filter((item: any) => item.visible) + .map((item: any) => ( + + {item.data.title} + + ))} +
+
-
- ))} + ))} +
-
- - {config.socials.media.github && ( - - )} - - - + + + + GitHub + + + )} + + + +
-
+ ); } function ThemeToggle() { - const [open, setOpen] = React.useState(false); + const [open, setOpen] = useState(false); const { theme, setTheme } = useTheme(); return ( diff --git a/src/lib/components/elements/search-command.tsx b/src/lib/components/elements/search-command.tsx new file mode 100644 index 0000000..97876fd --- /dev/null +++ b/src/lib/components/elements/search-command.tsx @@ -0,0 +1,120 @@ +import { Fragment, useEffect, useState } from "react"; + +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { useTheme } from "@/hooks/use-theme"; +import { Icon } from "@iconify/react"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "../ui/input-group"; +import { Kbd, KbdGroup } from "../ui/kbd"; + +type Props = { + items: any[]; +}; + +export function SearchCommand(props: Props) { + const { setTheme } = useTheme(); + const [open, setOpen] = useState(false); + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen((open) => !open); + } + }; + + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, []); + + return ( + + setOpen(true)} + > + + + + + + + ⌘ + + + k + + + + + + + No results found. + + {props.items.map((doc) => { + return ( + + {doc.children.map((page: any) => ( + + +
+ + + {page.data.title} + +
+

+ {page.content.remarkPluginFrontmatter.description} +

+
+
+ ))} +
+ ); + })} + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/src/lib/components/ui/command.tsx b/src/lib/components/ui/command.tsx new file mode 100644 index 0000000..f1dc467 --- /dev/null +++ b/src/lib/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { Command as CommandPrimitive } from "cmdk"; +import { SearchIcon } from "lucide-react"; +import * as React from "react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/utils"; + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ); +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ); +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +}; diff --git a/src/lib/components/ui/dialog.tsx b/src/lib/components/ui/dialog.tsx new file mode 100644 index 0000000..0779adb --- /dev/null +++ b/src/lib/components/ui/dialog.tsx @@ -0,0 +1,140 @@ +import { cn } from "@/utils"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; +import * as React from "react"; + +function Dialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/src/lib/components/ui/input-group.tsx b/src/lib/components/ui/input-group.tsx new file mode 100644 index 0000000..89f451e --- /dev/null +++ b/src/lib/components/ui/input-group.tsx @@ -0,0 +1,168 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/utils"; + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
textarea]:h-auto", + + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", + + // Error state. + "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + + className, + )} + {...props} + /> + ); +} + +const inputGroupAddonVariants = cva( + "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", + "block-end": + "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", + }, + }, + defaultVariants: { + align: "inline-start", + }, + }, +); + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return; + } + e.currentTarget.parentElement?.querySelector("input")?.focus(); + }} + {...props} + /> + ); +} + +const inputGroupButtonVariants = cva( + "text-sm shadow-none flex gap-2 items-center", + { + variants: { + size: { + xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", + sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + }, +); + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +