mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: Support custom action type search item in search dialog
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-ui': patch
|
||||
---
|
||||
|
||||
Support custom `action` type search item in search dialog
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user