Merge pull request #6 from DocuBook/dev-4eb3d42

chore: Sync package version v1.14.2

The original Search component was monolithic, handling state management, UI triggers, and modal content logic in a single file. This made it difficult to maintain and extend.

This commit refactors the component into three distinct, more focused components:
- `Search.tsx`: The new parent component that manages the dialog's open/close state and will serve as the entry point for selecting different search types (e.g., default, Algolia).
- `SearchTrigger.tsx`: A simple component responsible only for rendering the search button/input UI that opens the modal.
- `SearchModal.tsx`: Encapsulates all logic and UI for the search dialog itself, including the input field, results display, and keyboard navigation.

This separation of concerns improves readability, maintainability, and prepares the codebase for future scalability.
This commit is contained in:
Wildan Nursahidan
2025-08-05 18:00:38 +07:00
committed by GitHub
6 changed files with 274 additions and 224 deletions

View File

@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
import AnimatedShinyText from "@/components/ui/animated-shiny-text"; import AnimatedShinyText from "@/components/ui/animated-shiny-text";
import { getMetadata } from "@/app/layout"; import { getMetadata } from "@/app/layout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// v1.14.1
export const metadata = getMetadata({ export const metadata = getMetadata({
title: "Home", title: "Home",
}); });
@@ -25,7 +25,7 @@ export default function Home() {
)} )}
> >
<AnimatedShinyText className="inline-flex items-center justify-center px-4 py-1 transition ease-out hover:text-neutral-100 hover:duration-300 hover:dark:text-neutral-200"> <AnimatedShinyText className="inline-flex items-center justify-center px-4 py-1 transition ease-out hover:text-neutral-100 hover:duration-300 hover:dark:text-neutral-200">
<span>🚀 New Version - Release v1.14.1</span> <span>🚀 New Version - Release v1.14.2</span>
<ArrowRightIcon className="ml-1 size-3 transition-transform duration-300 ease-in-out group-hover:translate-x-0.5" /> <ArrowRightIcon className="ml-1 size-3 transition-transform duration-300 ease-in-out group-hover:translate-x-0.5" />
</AnimatedShinyText> </AnimatedShinyText>
</div> </div>

198
components/SearchModal.tsx Normal file
View File

@@ -0,0 +1,198 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useRef } from "react";
import { ArrowUpIcon, ArrowDownIcon, CornerDownLeftIcon, FileTextIcon } from "lucide-react";
import Anchor from "./anchor";
import { advanceSearch, cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { page_routes } from "@/lib/routes-config";
import {
DialogContent,
DialogHeader,
DialogFooter,
DialogClose,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
type ContextInfo = {
icon: string;
description: string;
title?: string;
};
type SearchResult = {
title: string;
href: string;
noLink?: boolean;
items?: undefined;
score?: number;
context?: ContextInfo;
};
const paddingMap = {
1: "pl-2",
2: "pl-4",
3: "pl-10",
} as const;
interface SearchModalProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
export function SearchModal({ isOpen, setIsOpen }: SearchModalProps) {
const router = useRouter();
const [searchedInput, setSearchedInput] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
if (!isOpen) {
setSearchedInput("");
}
}, [isOpen]);
const filteredResults = useMemo<SearchResult[]>(() => {
const trimmedInput = searchedInput.trim();
if (trimmedInput.length < 3) {
return page_routes
.filter((route) => !route.href.endsWith('/'))
.slice(0, 6)
.map((route: { title: string; href: string; noLink?: boolean; context?: ContextInfo }) => ({
title: route.title,
href: route.href,
noLink: route.noLink,
context: route.context,
}));
}
return advanceSearch(trimmedInput) as unknown as SearchResult[];
}, [searchedInput]);
useEffect(() => {
setSelectedIndex(0);
}, [filteredResults]);
useEffect(() => {
const handleNavigation = (event: KeyboardEvent) => {
if (!isOpen || filteredResults.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((prev) => (prev + 1) % filteredResults.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((prev) => (prev - 1 + filteredResults.length) % filteredResults.length);
} else if (event.key === "Enter") {
event.preventDefault();
const selectedItem = filteredResults[selectedIndex];
if (selectedItem) {
router.push(`/docs${selectedItem.href}`);
setIsOpen(false);
}
}
};
window.addEventListener("keydown", handleNavigation);
return () => window.removeEventListener("keydown", handleNavigation);
}, [isOpen, filteredResults, selectedIndex, router, setIsOpen]);
useEffect(() => {
if (itemRefs.current[selectedIndex]) {
itemRefs.current[selectedIndex]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [selectedIndex]);
return (
<DialogContent className="p-0 max-w-[650px] sm:top-[38%] top-[45%] !rounded-md">
<DialogHeader>
<DialogTitle className="sr-only">Search Documentation</DialogTitle>
<DialogDescription className="sr-only">Search through the documentation</DialogDescription>
</DialogHeader>
<input
value={searchedInput}
onChange={(e) => setSearchedInput(e.target.value)}
placeholder="Type something to search..."
autoFocus
className="h-14 px-6 bg-transparent border-b text-[14px] outline-none w-full"
aria-label="Search documentation"
/>
{filteredResults.length == 0 && searchedInput && (
<p className="text-muted-foreground mx-auto mt-2 text-sm">
No results found for{" "}
<span className="text-primary">{`"${searchedInput}"`}</span>
</p>
)}
<ScrollArea className="max-h-[400px] overflow-y-auto">
<div className="flex flex-col items-start overflow-y-auto sm:px-2 px-1 pb-4">
{filteredResults.map((item, index) => {
const level = (item.href.split("/").slice(1).length - 1) as keyof typeof paddingMap;
const paddingClass = paddingMap[level] || 'pl-2';
const isActive = index === selectedIndex;
return (
<DialogClose key={item.href} asChild>
<Anchor
ref={(el) => {
itemRefs.current[index] = el as HTMLDivElement | null;
}}
className={cn(
"dark:hover:bg-accent/15 hover:bg-accent/10 w-full px-3 rounded-sm text-sm flex items-center gap-2.5",
isActive && "bg-primary/20 dark:bg-primary/30",
paddingClass
)}
href={`/docs${item.href}`}
tabIndex={-1}
>
<div
className={cn(
"flex items-center w-full h-full py-3 gap-1.5 px-2 justify-between",
level > 1 && "border-l pl-4"
)}
>
<div className="flex items-center">
<FileTextIcon className="h-[1.1rem] w-[1.1rem] mr-1" />
<span>{item.title}</span>
</div>
{isActive && (
<div className="hidden md:flex items-center text-xs text-muted-foreground">
<span>Return</span>
<CornerDownLeftIcon className="h-3 w-3 ml-1" />
</div>
)}
</div>
</Anchor>
</DialogClose>
);
})}
</div>
</ScrollArea>
<DialogFooter className="md:flex md:justify-start hidden h-14 px-6 bg-transparent border-t text-[14px] outline-none">
<div className="flex items-center gap-2">
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
<ArrowUpIcon className="w-3 h-3"/>
</span>
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
<ArrowDownIcon className="w-3 h-3"/>
</span>
<p className="text-muted-foreground">to navigate</p>
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
<CornerDownLeftIcon className="w-3 h-3"/>
</span>
<p className="text-muted-foreground">to select</p>
<span className="dark:bg-accent/15 bg-slate-200 border rounded px-2 py-1">
esc
</span>
<p className="text-muted-foreground">to close</p>
</div>
</DialogFooter>
</DialogContent>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { CommandIcon, SearchIcon } from "lucide-react";
import { DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
export function SearchTrigger() {
return (
<DialogTrigger asChild>
<div className="relative flex-1 cursor-pointer max-w-[140px]">
<div className="flex items-center">
<div className="md:hidden p-2 -ml-2">
<SearchIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="hidden md:block w-full">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="w-full rounded-full dark:bg-background/95 bg-background border h-9 pl-10 pr-0 sm:pr-4 text-sm shadow-sm overflow-ellipsis"
placeholder="Search"
readOnly // This input is for display only
/>
<div className="flex absolute top-1/2 -translate-y-1/2 right-2 text-xs font-medium font-mono items-center gap-0.5 dark:bg-accent bg-accent text-white px-2 py-0.5 rounded-full">
<CommandIcon className="w-3 h-3" />
<span>K</span>
</div>
</div>
</div>
</div>
</DialogTrigger>
);
}

View File

@@ -1,52 +1,28 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useState, useEffect } from "react";
import { useEffect, useMemo, useState, useRef } from "react"; import { Dialog } from "@/components/ui/dialog";
import { ArrowUpIcon, ArrowDownIcon, CommandIcon, FileTextIcon, SearchIcon, CornerDownLeftIcon } from "lucide-react"; import { SearchTrigger } from "@/components/SearchTrigger";
import { Input } from "@/components/ui/input"; import { SearchModal } from "@/components/SearchModal";
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTrigger,
DialogClose,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import Anchor from "./anchor";
import { advanceSearch, cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { page_routes } from "@/lib/routes-config";
// Define the ContextInfo type to match the one in routes-config // Define props for the Search component
type ContextInfo = { interface SearchProps {
icon: string; /**
description: string; * Specify the type of search engine to use.
title?: string; * @default 'default'
}; */
type?: "default" | "algolia";
}
type SearchResult = { export default function Search({ type = "default" }: SearchProps) {
title: string;
href: string;
noLink?: boolean;
items?: undefined;
score?: number;
context?: ContextInfo;
};
export default function Search() {
const router = useRouter();
const [searchedInput, setSearchedInput] = useState("");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
// Effect to handle keyboard shortcut (Cmd/Ctrl + K)
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "k") { if ((event.ctrlKey || event.metaKey) && event.key === "k") {
event.preventDefault(); event.preventDefault();
setIsOpen(true); setIsOpen((open) => !open);
} }
}; };
@@ -56,191 +32,20 @@ export default function Search() {
}; };
}, []); }, []);
const filteredResults = useMemo<SearchResult[]>(() => { // Here you can add logic for different search types if needed in the future
const trimmedInput = searchedInput.trim(); if (type === "algolia") {
// return <AlgoliaSearchComponent />; // Example for future implementation
// If search input is empty or less than 3 characters, show initial suggestions console.warn("Tipe pencarian 'algolia' belum diimplementasikan.");
if (trimmedInput.length < 3) { // For now, we will fall back to the default search implementation
return page_routes }
.filter((route: { href: string }) => !route.href.endsWith('/')) // Filter out directory routes
.slice(0, 6) // Limit to 6 posts
.map((route: { title: string; href: string; noLink?: boolean; context?: ContextInfo }) => ({
title: route.title,
href: route.href,
noLink: route.noLink,
context: route.context
}));
}
// For search with 3 or more characters, use the advance search
return advanceSearch(trimmedInput) as unknown as SearchResult[];
}, [searchedInput]);
useEffect(() => {
setSelectedIndex(0);
}, [filteredResults]);
useEffect(() => {
const handleNavigation = (event: KeyboardEvent) => {
if (!isOpen || filteredResults.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((prev) => (prev + 1) % filteredResults.length);
}
if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((prev) => (prev - 1 + filteredResults.length) % filteredResults.length);
}
if (event.key === "Enter") {
event.preventDefault();
const selectedItem = filteredResults[selectedIndex];
if (selectedItem) {
router.push(`/docs${selectedItem.href}`);
setIsOpen(false);
}
}
};
window.addEventListener("keydown", handleNavigation);
return () => {
window.removeEventListener("keydown", handleNavigation);
};
}, [isOpen, filteredResults, selectedIndex, router]);
useEffect(() => {
if (itemRefs.current[selectedIndex]) {
itemRefs.current[selectedIndex]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [selectedIndex]);
// Render the default search components
return ( return (
<div> <div>
<Dialog <Dialog open={isOpen} onOpenChange={setIsOpen}>
open={isOpen} <SearchTrigger />
onOpenChange={(open) => { <SearchModal isOpen={isOpen} setIsOpen={setIsOpen} />
if (!open) setSearchedInput("");
setIsOpen(open);
}}
>
<DialogTrigger asChild>
<div className="relative flex-1 cursor-pointer max-w-[140px]">
<div className="flex items-center">
<div className="md:hidden p-2 -ml-2">
<SearchIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="hidden md:block w-full">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="w-full rounded-full dark:bg-background/95 bg-background border h-9 pl-10 pr-0 sm:pr-4 text-sm shadow-sm overflow-ellipsis"
placeholder="Search"
type="search"
/>
<div className="flex absolute top-1/2 -translate-y-1/2 right-2 text-xs font-medium font-mono items-center gap-0.5 dark:bg-accent bg-accent text-white px-2 py-0.5 rounded-full">
<CommandIcon className="w-3 h-3" />
<span>K</span>
</div>
</div>
</div>
</div>
</DialogTrigger>
<DialogContent className="p-0 max-w-[650px] sm:top-[38%] top-[45%] !rounded-md">
<DialogHeader>
<DialogTitle className="sr-only">Search Documentation</DialogTitle>
</DialogHeader>
<DialogDescription className="sr-only">
Search through the documentation
</DialogDescription>
<input
value={searchedInput}
onChange={(e) => setSearchedInput(e.target.value)}
placeholder="Type something to search..."
autoFocus
className="h-14 px-6 bg-transparent border-b text-[14px] outline-none w-full"
aria-label="Search documentation"
/>
{filteredResults.length == 0 && searchedInput && (
<p className="text-muted-foreground mx-auto mt-2 text-sm">
No results found for{" "}
<span className="text-primary">{`"${searchedInput}"`}</span>
</p>
)}
<ScrollArea className="max-h-[400px] overflow-y-auto">
<div className="flex flex-col items-start overflow-y-auto sm:px-2 px-1 pb-4">
{filteredResults.map((item, index) => {
const level = (item.href.split("/").slice(1).length - 1) as keyof typeof paddingMap;
const paddingClass = paddingMap[level];
const isActive = index === selectedIndex;
return (
<DialogClose key={item.href} asChild>
<Anchor
ref={(el) => {
itemRefs.current[index] = el as HTMLDivElement | null;
}}
className={cn(
"dark:hover:bg-accent/15 hover:bg-accent/10 w-full px-3 rounded-sm text-sm flex items-center gap-2.5",
isActive && "bg-primary/20 dark:bg-primary/30",
paddingClass
)}
href={`/docs${item.href}`}
tabIndex={0}
>
<div
className={cn(
"flex items-center w-full h-full py-3 gap-1.5 px-2 justify-between",
level > 1 && "border-l pl-4"
)}
>
<div className="flex items-center">
<FileTextIcon className="h-[1.1rem] w-[1.1rem] mr-1" />
<span>{item.title}</span>
</div>
{isActive && (
<div className="hidden md:flex items-center text-xs text-muted-foreground">
<span>Return</span>
<CornerDownLeftIcon className="h-3 w-3 ml-1" />
</div>
)}
</div>
</Anchor>
</DialogClose>
);
})}
</div>
</ScrollArea>
<DialogFooter className="md:flex md:justify-start hidden h-14 px-6 bg-transparent border-t text-[14px] outline-none">
<div className="flex items-center gap-2">
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
<ArrowUpIcon className="w-3 h-3"/>
</span>
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
<ArrowDownIcon className="w-3 h-3"/>
</span>
<p className="text-muted-foreground">to navigate</p>
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
<CornerDownLeftIcon className="w-3 h-3"/>
</span>
<p className="text-muted-foreground">to select</p>
<span className="dark:bg-accent/15 bg-slate-200 border rounded px-2 py-1">
esc
</span>
<p className="text-muted-foreground">to close</p>
</div>
</DialogFooter>
</DialogContent>
</Dialog> </Dialog>
</div> </div>
); );
} }
const paddingMap = {
1: "pl-2",
2: "pl-4",
3: "pl-10",
} as const;

View File

@@ -8,6 +8,22 @@ date: 02-08-2025
This changelog contains a list of all the changes made to the DocuBook template. It will be updated with each new release and will include information about new features, bug fixes, and other improvements. This changelog contains a list of all the changes made to the DocuBook template. It will be updated with each new release and will include information about new features, bug fixes, and other improvements.
</Note> </Note>
<div className="sr-only">
### v 1.14.2
</div>
<Release version="1.14.2" date="2025-08-05" title="Refactor & Fix: Decouple Search Component and Resolve Type Errors">
<Changes type="added">
- Refactor Search component into three distinct components: Search, SearchTrigger, and SearchModal for better maintainability and scalability.
- New SearchTrigger components
- New SearchModal components
</Changes>
<Changes type="fixed">
- Resolve TypeScript error for missing 'noLink' property on the 'Page' type after refactoring.
- Fix TypeScript error for missing 'context' property by providing a complete inline type annotation in the SearchModal component.
</Changes>
</Release>
<div className="sr-only"> <div className="sr-only">
### v 1.14.0 ### v 1.14.0
</div> </div>

View File

@@ -1,6 +1,6 @@
{ {
"name": "docubook", "name": "docubook",
"version": "1.14.1", "version": "1.14.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",