UI: Support custom action type search item in search dialog

This commit is contained in:
Fuma Nama
2025-09-21 19:35:34 +08:00
parent 6548a59b93
commit 53a0635a4f
6 changed files with 151 additions and 54 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': patch
---
Support custom `action` type search item in search dialog
+8 -1
View File
@@ -5,6 +5,9 @@ import { Body } from '@/app/layout.client';
import { Provider } from './provider';
import type { ReactNode } from 'react';
import { Geist, Geist_Mono } from 'next/font/google';
import { TreeContextProvider } from 'fumadocs-ui/contexts/tree';
import { source } from '@/lib/source';
import { NextProvider } from 'fumadocs-core/framework/next';
export const metadata = createMetadata({
title: {
@@ -40,7 +43,11 @@ export default function RootLayout({ children }: { children: ReactNode }) {
suppressHydrationWarning
>
<Body>
<Provider>{children}</Provider>
<NextProvider>
<TreeContextProvider tree={source.pageTree}>
<Provider>{children}</Provider>
</TreeContextProvider>
</NextProvider>
</Body>
</html>
);
+1 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { RootProvider } from 'fumadocs-ui/provider';
import { RootProvider } from 'fumadocs-ui/provider/base';
import dynamic from 'next/dynamic';
import type { ReactNode } from 'react';
import { TooltipProvider } from '@radix-ui/react-tooltip';
+67 -3
View File
@@ -11,18 +11,22 @@ import {
SearchDialogInput,
SearchDialogList,
SearchDialogOverlay,
type SearchItemType,
type SharedProps,
} from 'fumadocs-ui/components/dialog/search';
import { useDocsSearch } from 'fumadocs-core/search/client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from 'fumadocs-ui/components/ui/popover';
import { ChevronDown } from 'lucide-react';
import { ArrowRight, ChevronDown } from 'lucide-react';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from '@/lib/cn';
import { useTreeContext } from 'fumadocs-ui/contexts/tree';
import type { PageTree } from 'fumadocs-core/server';
import { useRouter } from 'next/navigation';
const client = new OramaClient({
endpoint: 'https://cloud.orama.run/v1/indexes/docs-fk97oe',
@@ -56,6 +60,22 @@ const items = [
},
];
function searchNodes(
matcher: (item: PageTree.Item) => boolean,
nodes: PageTree.Node[],
): PageTree.Item | undefined {
for (const node of nodes) {
if (node.type === 'folder') {
if (node.index && matcher(node.index)) return node.index;
const result = searchNodes(matcher, node.children);
if (result) return result;
} else if (node.type === 'page' && matcher(node)) {
return node;
}
}
}
export default function CustomSearchDialog(props: SharedProps) {
const [open, setOpen] = useState(false);
const [tag, setTag] = useState<string | undefined>();
@@ -64,6 +84,41 @@ export default function CustomSearchDialog(props: SharedProps) {
client,
tag,
});
const { root } = useTreeContext();
const router = useRouter();
const pageTreeActions = useMemo(() => {
const actions: SearchItemType[] = [];
if (search.length === 0) return actions;
const normalized = search.toLowerCase();
const result = searchNodes(
(item) =>
typeof item.name === 'string' &&
item.name.toLowerCase().startsWith(normalized),
root.children,
);
if (result) {
actions.push({
id: 'quick-action',
type: 'action',
node: (
<div className="inline-flex items-center gap-2 text-fd-muted-foreground">
<ArrowRight className="size-4" />
<p>
Jump to{' '}
<span className="font-medium text-fd-foreground">
{result.name}
</span>
</p>
</div>
),
onSelect: () => router.push(result.url),
});
}
return actions;
}, [root.children, router, search]);
return (
<SearchDialog
@@ -79,7 +134,16 @@ export default function CustomSearchDialog(props: SharedProps) {
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
<SearchDialogList items={query.data !== 'empty' ? query.data : null} />
<SearchDialogList
items={
query.data !== 'empty' || pageTreeActions.length > 0
? [
...pageTreeActions,
...(Array.isArray(query.data) ? query.data : []),
]
: null
}
/>
<SearchDialogFooter className="flex flex-row flex-wrap gap-2 items-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
+65 -48
View File
@@ -33,9 +33,16 @@ import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import scrollIntoView from 'scroll-into-view-if-needed';
import { buttonVariants } from '@/components/ui/button';
interface ReactSortedResult extends BaseResultType {
external?: boolean;
}
export type SearchItemType =
| (BaseResultType & {
external?: boolean;
})
| {
id: string;
type: 'action';
node: ReactNode;
onSelect: () => void;
};
// needed for backward compatible since some previous guides referenced it
export type { SharedProps };
@@ -184,7 +191,7 @@ export function SearchDialogContent({
aria-describedby={undefined}
{...props}
className={cn(
'fixed left-1/2 top-4 md:top-[calc(50%-250px)] z-50 w-[calc(100%-1rem)] max-w-screen-sm -translate-x-1/2 rounded-xl border bg-fd-popover/80 backdrop-blur-xl text-fd-popover-foreground shadow-2xl shadow-black/50 overflow-hidden data-[state=closed]:animate-fd-dialog-out data-[state=open]:animate-fd-dialog-in',
'fixed left-1/2 top-4 md:top-[calc(50%-250px)] z-50 w-[calc(100%-1rem)] max-w-screen-sm -translate-x-1/2 rounded-xl border bg-fd-popover text-fd-popover-foreground shadow-2xl shadow-black/50 overflow-hidden data-[state=closed]:animate-fd-dialog-out data-[state=open]:animate-fd-dialog-in',
'*:border-b *:has-[+:last-child[data-empty=true]]:border-b-0 *:data-[empty=true]:border-b-0 *:last:border-b-0',
props.className,
)}
@@ -205,7 +212,7 @@ export function SearchDialogList({
Item = (props) => <SearchDialogListItem {...props} />,
...props
}: Omit<ComponentProps<'div'>, 'children'> & {
items: ReactSortedResult[] | null | undefined;
items: SearchItemType[] | null | undefined;
/**
* Renderer for empty list UI
*/
@@ -213,7 +220,7 @@ export function SearchDialogList({
/**
* Renderer for items
*/
Item?: (props: { item: ReactSortedResult; onClick: () => void }) => ReactNode;
Item?: (props: { item: SearchItemType; onClick: () => void }) => ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<string | null>(() =>
@@ -222,9 +229,15 @@ export function SearchDialogList({
const { onOpenChange } = useSearch();
const router = useRouter();
const onOpen = ({ external, url }: ReactSortedResult) => {
if (external) window.open(url, '_blank')?.focus();
else router.push(url);
const onOpen = (item: SearchItemType) => {
if (item.type === 'action') {
item.onSelect();
} else if (item.external) {
window.open(item.url, '_blank')?.focus();
} else {
router.push(item.url);
}
onOpenChange(false);
};
@@ -316,10 +329,6 @@ export function SearchDialogList({
);
}
const icons: Record<string, ReactNode> = {
heading: <Hash className="inline me-1 size-4 text-fd-muted-foreground" />,
};
export function SearchDialogListItem({
item,
className,
@@ -328,11 +337,51 @@ export function SearchDialogListItem({
...props
}: ComponentProps<'button'> & {
renderHighlights?: typeof renderHighlights;
item: ReactSortedResult;
item: SearchItemType;
}) {
const { active: activeId, setActive } = useSearchList();
const active = item.id === activeId;
if (item.type === 'action') {
children ??= item.node;
} else {
children ??= (
<>
<div className="inline-flex items-center text-fd-muted-foreground text-xs empty:hidden">
{item.breadcrumbs?.map((item, i) => (
<Fragment key={i}>
{i > 0 && <ChevronRight className="size-4" />}
{item}
</Fragment>
))}
</div>
{item.type !== 'page' && (
<div
role="none"
className="absolute start-3 inset-y-0 w-px bg-fd-border"
/>
)}
<p
className={cn(
'min-w-0 truncate',
item.type !== 'page' && 'ps-4',
item.type === 'page' || item.type === 'heading'
? 'font-medium'
: 'text-fd-popover-foreground/80',
)}
>
{item.type === 'heading' && (
<Hash className="inline me-1 size-4 text-fd-muted-foreground" />
)}
{item.contentWithHighlights
? render(item.contentWithHighlights)
: item.content}
</p>
</>
);
}
return (
<button
type="button"
@@ -350,46 +399,14 @@ export function SearchDialogListItem({
)}
aria-selected={active}
className={cn(
'relative select-none px-2.5 py-1.5 text-start text-sm rounded-lg',
'relative select-none px-2.5 py-2 text-start text-sm rounded-lg',
active && 'bg-fd-accent text-fd-accent-foreground',
className,
)}
onPointerMove={() => setActive(item.id)}
{...props}
>
{children ?? (
<>
<div className="inline-flex items-center text-fd-muted-foreground text-xs empty:hidden">
{item.breadcrumbs?.map((item, i) => (
<Fragment key={i}>
{i > 0 && <ChevronRight className="size-4" />}
{item}
</Fragment>
))}
</div>
{item.type !== 'page' && (
<div
role="none"
className="absolute start-3 inset-y-0 w-px bg-fd-border"
/>
)}
<p
className={cn(
'min-w-0 truncate',
item.type !== 'page' && 'ps-4',
item.type === 'page' || item.type === 'heading'
? 'font-medium'
: 'text-fd-popover-foreground/80',
)}
>
{icons[item.type]}
{item.contentWithHighlights
? render(item.contentWithHighlights)
: item.content}
</p>
</>
)}
{children}
</button>
);
}
+5 -1
View File
@@ -8,6 +8,7 @@ type MakeRequired<O, K extends keyof O> = Omit<O, K> & Pick<Required<O>, K>;
interface TreeContextType {
root: MakeRequired<PageTree.Root | PageTree.Folder, '$id'>;
full: PageTree.Root;
}
const TreeContext = createContext<TreeContextType>('TreeContext');
@@ -38,7 +39,10 @@ export function TreeContextProvider(props: {
return (
<TreeContext.Provider
value={useMemo(() => ({ root }) as TreeContextType, [root])}
value={useMemo(
() => ({ root, full: tree }) as TreeContextType,
[root, tree],
)}
>
<PathContext.Provider value={path}>{props.children}</PathContext.Provider>
</TreeContext.Provider>