mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: Redesign fumadocs-ui/components/dialog/search
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-ui': patch
|
||||
---
|
||||
|
||||
Redesign `fumadocs-ui/components/dialog/search` usage to make it composable, and mark it as stable API.
|
||||
@@ -4,18 +4,27 @@ import {
|
||||
type AlgoliaOptions,
|
||||
useDocsSearch,
|
||||
} from 'fumadocs-core/search/client';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
import {
|
||||
SearchDialog,
|
||||
type SharedProps,
|
||||
type TagItem,
|
||||
SearchDialogClose,
|
||||
SearchDialogContent,
|
||||
SearchDialogFooter,
|
||||
SearchDialogHeader,
|
||||
SearchDialogInput,
|
||||
SearchDialogInputIcon,
|
||||
SearchDialogList,
|
||||
SearchDialogOverlay,
|
||||
TagsList,
|
||||
TagsListItem,
|
||||
} from './search';
|
||||
import type { SortedResult } from 'fumadocs-core/server';
|
||||
import type { SearchLink, SharedProps, TagItem } from '@/contexts/search';
|
||||
|
||||
export interface AlgoliaSearchDialogProps extends SharedProps {
|
||||
searchOptions: AlgoliaOptions;
|
||||
links?: SearchLink[];
|
||||
|
||||
footer?: ReactNode;
|
||||
|
||||
@@ -39,10 +48,12 @@ export interface AlgoliaSearchDialogProps extends SharedProps {
|
||||
|
||||
export default function AlgoliaSearchDialog({
|
||||
searchOptions,
|
||||
tags,
|
||||
tags = [],
|
||||
defaultTag,
|
||||
showAlgolia = false,
|
||||
allowClear = false,
|
||||
links = [],
|
||||
footer,
|
||||
...props
|
||||
}: AlgoliaSearchDialogProps): ReactNode {
|
||||
const [tag, setTag] = useState(defaultTag);
|
||||
@@ -54,36 +65,60 @@ export default function AlgoliaSearchDialog({
|
||||
undefined,
|
||||
tag,
|
||||
);
|
||||
const defaultItems = useMemo<SortedResult[]>(
|
||||
() =>
|
||||
links.map(([name, link]) => ({
|
||||
type: 'page',
|
||||
id: name,
|
||||
content: name,
|
||||
url: link,
|
||||
})),
|
||||
[links],
|
||||
);
|
||||
|
||||
useOnChange(defaultTag, (v) => {
|
||||
setTag(v);
|
||||
});
|
||||
|
||||
const label = showAlgolia && <AlgoliaTitle />;
|
||||
|
||||
return (
|
||||
<SearchDialog
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
results={query.data ?? []}
|
||||
isLoading={query.isLoading}
|
||||
{...props}
|
||||
footer={
|
||||
<>
|
||||
{tags ? (
|
||||
<TagsList tag={tag} onTagChange={setTag} allowClear={allowClear}>
|
||||
{tags.map((tag) => (
|
||||
<TagsListItem key={tag.value} value={tag.value}>
|
||||
{tag.name}
|
||||
</TagsListItem>
|
||||
))}
|
||||
{showAlgolia && <AlgoliaTitle />}
|
||||
</TagsList>
|
||||
) : (
|
||||
showAlgolia && <AlgoliaTitle />
|
||||
)}
|
||||
{props.footer}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<SearchDialogOverlay />
|
||||
<SearchDialogContent>
|
||||
<SearchDialogHeader>
|
||||
<SearchDialogInputIcon />
|
||||
<SearchDialogInput />
|
||||
<SearchDialogClose />
|
||||
</SearchDialogHeader>
|
||||
{query.data !== 'empty' && query.data && (
|
||||
<SearchDialogList items={query.data} />
|
||||
)}
|
||||
{query.data === 'empty' && defaultItems.length > 0 && (
|
||||
<SearchDialogList items={defaultItems} />
|
||||
)}
|
||||
</SearchDialogContent>
|
||||
<SearchDialogFooter>
|
||||
{tags.length > 0 ? (
|
||||
<TagsList tag={tag} onTagChange={setTag} allowClear={allowClear}>
|
||||
{tags.map((tag) => (
|
||||
<TagsListItem key={tag.value} value={tag.value}>
|
||||
{tag.name}
|
||||
</TagsListItem>
|
||||
))}
|
||||
{label}
|
||||
</TagsList>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
{footer}
|
||||
</SearchDialogFooter>
|
||||
</SearchDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useDocsSearch } from 'fumadocs-core/search/client';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
import {
|
||||
SearchDialog,
|
||||
type SharedProps,
|
||||
type TagItem,
|
||||
SearchDialogClose,
|
||||
SearchDialogContent,
|
||||
SearchDialogFooter,
|
||||
SearchDialogHeader,
|
||||
SearchDialogInput,
|
||||
SearchDialogInputIcon,
|
||||
SearchDialogList,
|
||||
SearchDialogOverlay,
|
||||
TagsList,
|
||||
TagsListItem,
|
||||
} from './search';
|
||||
import type { SortedResult } from 'fumadocs-core/server';
|
||||
import type { SearchLink, SharedProps, TagItem } from '@/contexts/search';
|
||||
|
||||
export interface DefaultSearchDialogProps extends SharedProps {
|
||||
links?: SearchLink[];
|
||||
|
||||
/**
|
||||
* @defaultValue 'fetch'
|
||||
*/
|
||||
@@ -43,11 +53,13 @@ export interface DefaultSearchDialogProps extends SharedProps {
|
||||
|
||||
export default function DefaultSearchDialog({
|
||||
defaultTag,
|
||||
tags,
|
||||
tags = [],
|
||||
api,
|
||||
delayMs,
|
||||
type = 'fetch',
|
||||
allowClear = false,
|
||||
links = [],
|
||||
footer,
|
||||
...props
|
||||
}: DefaultSearchDialogProps): ReactNode {
|
||||
const { locale } = useI18n();
|
||||
@@ -66,6 +78,16 @@ export default function DefaultSearchDialog({
|
||||
tag,
|
||||
delayMs,
|
||||
);
|
||||
const defaultItems = useMemo<SortedResult[]>(
|
||||
() =>
|
||||
links.map(([name, link]) => ({
|
||||
type: 'page',
|
||||
id: name,
|
||||
content: name,
|
||||
url: link,
|
||||
})),
|
||||
[links],
|
||||
);
|
||||
|
||||
useOnChange(defaultTag, (v) => {
|
||||
setTag(v);
|
||||
@@ -76,22 +98,34 @@ export default function DefaultSearchDialog({
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
isLoading={query.isLoading}
|
||||
results={query.data ?? []}
|
||||
{...props}
|
||||
footer={
|
||||
<>
|
||||
{tags && (
|
||||
<TagsList tag={tag} onTagChange={setTag} allowClear={allowClear}>
|
||||
{tags.map((tag) => (
|
||||
<TagsListItem key={tag.value} value={tag.value}>
|
||||
{tag.name}
|
||||
</TagsListItem>
|
||||
))}
|
||||
</TagsList>
|
||||
)}
|
||||
{props.footer}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<SearchDialogOverlay />
|
||||
<SearchDialogContent>
|
||||
<SearchDialogHeader>
|
||||
<SearchDialogInputIcon />
|
||||
<SearchDialogInput />
|
||||
<SearchDialogClose />
|
||||
</SearchDialogHeader>
|
||||
{query.data !== 'empty' && query.data && (
|
||||
<SearchDialogList items={query.data} />
|
||||
)}
|
||||
{query.data === 'empty' && defaultItems.length > 0 && (
|
||||
<SearchDialogList items={defaultItems} />
|
||||
)}
|
||||
</SearchDialogContent>
|
||||
<SearchDialogFooter>
|
||||
{tags.length > 0 && (
|
||||
<TagsList tag={tag} onTagChange={setTag} allowClear={allowClear}>
|
||||
{tags.map((tag) => (
|
||||
<TagsListItem key={tag.value} value={tag.value}>
|
||||
{tag.name}
|
||||
</TagsListItem>
|
||||
))}
|
||||
</TagsList>
|
||||
)}
|
||||
{footer}
|
||||
</SearchDialogFooter>
|
||||
</SearchDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,17 +4,26 @@ import {
|
||||
type OramaCloudOptions,
|
||||
useDocsSearch,
|
||||
} from 'fumadocs-core/search/client';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
import {
|
||||
SearchDialog,
|
||||
type SharedProps,
|
||||
type TagItem,
|
||||
SearchDialogClose,
|
||||
SearchDialogContent,
|
||||
SearchDialogFooter,
|
||||
SearchDialogHeader,
|
||||
SearchDialogInput,
|
||||
SearchDialogInputIcon,
|
||||
SearchDialogList,
|
||||
SearchDialogOverlay,
|
||||
TagsList,
|
||||
TagsListItem,
|
||||
} from './search';
|
||||
import type { SortedResult } from 'fumadocs-core/server';
|
||||
import type { SearchLink, SharedProps, TagItem } from '@/contexts/search';
|
||||
|
||||
export interface OramaSearchDialogProps extends SharedProps {
|
||||
links: SearchLink[];
|
||||
client: OramaCloudOptions['client'];
|
||||
searchOptions?: OramaCloudOptions['params'];
|
||||
index?: OramaCloudOptions['index'];
|
||||
@@ -45,11 +54,13 @@ export interface OramaSearchDialogProps extends SharedProps {
|
||||
export default function OramaSearchDialog({
|
||||
client,
|
||||
searchOptions,
|
||||
tags,
|
||||
tags = [],
|
||||
defaultTag,
|
||||
showOrama = false,
|
||||
allowClear = false,
|
||||
index,
|
||||
footer,
|
||||
links = [],
|
||||
...props
|
||||
}: OramaSearchDialogProps): ReactNode {
|
||||
const [tag, setTag] = useState(defaultTag);
|
||||
@@ -64,35 +75,60 @@ export default function OramaSearchDialog({
|
||||
tag,
|
||||
);
|
||||
|
||||
const defaultItems = useMemo<SortedResult[]>(
|
||||
() =>
|
||||
links.map(([name, link]) => ({
|
||||
type: 'page',
|
||||
id: name,
|
||||
content: name,
|
||||
url: link,
|
||||
})),
|
||||
[links],
|
||||
);
|
||||
|
||||
useOnChange(defaultTag, (v) => {
|
||||
setTag(v);
|
||||
});
|
||||
|
||||
const label = showOrama && <Label />;
|
||||
|
||||
return (
|
||||
<SearchDialog
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
results={query.data ?? []}
|
||||
isLoading={query.isLoading}
|
||||
{...props}
|
||||
footer={
|
||||
<>
|
||||
{tags ? (
|
||||
>
|
||||
<SearchDialogOverlay />
|
||||
<SearchDialogContent>
|
||||
<SearchDialogHeader>
|
||||
<SearchDialogInputIcon />
|
||||
<SearchDialogInput />
|
||||
<SearchDialogClose />
|
||||
</SearchDialogHeader>
|
||||
{query.data !== 'empty' && query.data && (
|
||||
<SearchDialogList items={query.data} />
|
||||
)}
|
||||
{query.data === 'empty' && defaultItems.length > 0 && (
|
||||
<SearchDialogList items={defaultItems} />
|
||||
)}
|
||||
<SearchDialogFooter>
|
||||
{tags.length > 0 ? (
|
||||
<TagsList tag={tag} onTagChange={setTag} allowClear={allowClear}>
|
||||
{tags.map((tag) => (
|
||||
<TagsListItem key={tag.value} value={tag.value}>
|
||||
{tag.name}
|
||||
</TagsListItem>
|
||||
))}
|
||||
{showOrama && <Label />}
|
||||
{label}
|
||||
</TagsList>
|
||||
) : (
|
||||
showOrama && <Label />
|
||||
label
|
||||
)}
|
||||
{props.footer}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{footer}
|
||||
</SearchDialogFooter>
|
||||
</SearchDialogContent>
|
||||
</SearchDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,16 @@ import {
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
Fragment,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
import { I18nLabel, useI18n } from '@/contexts/i18n';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -27,152 +30,215 @@ import {
|
||||
import type { SortedResult } from 'fumadocs-core/server';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
|
||||
import { createContext, useRouter } from 'fumadocs-core/framework';
|
||||
|
||||
export type SearchLink = [name: string, href: string];
|
||||
import { useRouter } from 'fumadocs-core/framework';
|
||||
import type { SharedProps } from '@/contexts/search';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
|
||||
type ReactSortedResult = Omit<SortedResult, 'content'> & {
|
||||
external?: boolean;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export interface TagItem {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SharedProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
||||
/**
|
||||
* Custom links to be displayed if search is empty
|
||||
*/
|
||||
links?: SearchLink[];
|
||||
}
|
||||
|
||||
interface SearchDialogProps extends SharedProps {
|
||||
export interface SearchDialogProps extends SharedProps {
|
||||
search: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
isLoading?: boolean;
|
||||
hideResults?: boolean;
|
||||
results: ReactSortedResult[] | 'empty';
|
||||
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Context = createContext<{
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
search: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
|
||||
isLoading: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const ListContext = createContext<{
|
||||
active: string | null;
|
||||
setActive: (v: string | null) => void;
|
||||
} | null>(null);
|
||||
|
||||
const TagsListContext = createContext<{
|
||||
value?: string;
|
||||
onValueChange: (value: string | undefined) => void;
|
||||
allowClear: boolean;
|
||||
} | null>(null);
|
||||
|
||||
export function SearchDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
footer,
|
||||
links = [],
|
||||
search,
|
||||
onSearchChange,
|
||||
isLoading,
|
||||
...props
|
||||
isLoading = false,
|
||||
children,
|
||||
}: SearchDialogProps) {
|
||||
const { text } = useI18n();
|
||||
const [active, setActive] = useState<string>();
|
||||
const defaultItems = useMemo<ReactSortedResult[]>(
|
||||
() =>
|
||||
links.map(([name, link]) => ({
|
||||
type: 'page',
|
||||
id: name,
|
||||
content: name,
|
||||
url: link,
|
||||
})),
|
||||
[links],
|
||||
);
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogOverlay className="fixed inset-0 z-50 bg-black/30 backdrop-blur-sm data-[state=closed]:animate-fd-fade-out data-[state=open]:animate-fd-fade-in" />
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className="fixed left-1/2 top-[10vh] z-50 w-[98vw] max-w-screen-sm -translate-x-1/2 rounded-lg border bg-fd-popover text-fd-popover-foreground shadow-lg data-[state=closed]:animate-fd-dialog-out data-[state=open]:animate-fd-dialog-in"
|
||||
<Context.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
open,
|
||||
onOpenChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
active,
|
||||
setActive,
|
||||
isLoading,
|
||||
}),
|
||||
[active, isLoading, onOpenChange, onSearchChange, open, search],
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="hidden">{text.search}</DialogTitle>
|
||||
<div className="flex flex-row items-center gap-2 px-3">
|
||||
<LoadingIndicator isLoading={isLoading ?? false} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value);
|
||||
setActive(undefined);
|
||||
}}
|
||||
placeholder={text.search}
|
||||
className="w-0 flex-1 bg-transparent py-3 text-base placeholder:text-fd-muted-foreground focus-visible:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close Search"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'outline',
|
||||
className: 'text-xs p-1.5',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
Esc
|
||||
</button>
|
||||
</div>
|
||||
{props.results !== 'empty' || defaultItems.length > 0 ? (
|
||||
<SearchResults
|
||||
active={active}
|
||||
onActiveChange={setActive}
|
||||
items={props.results === 'empty' ? defaultItems : props.results}
|
||||
onSelect={() => onOpenChange(false)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-auto flex flex-col border-t p-3 empty:hidden">
|
||||
{footer}
|
||||
</div>
|
||||
</DialogContent>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchDialogHeader(props: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn('flex flex-row items-center gap-2 px-3', props.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchDialogInput(props: ComponentProps<'input'>) {
|
||||
const { text } = useI18n();
|
||||
const { search, onSearchChange } = useSearch();
|
||||
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={text.search}
|
||||
className="w-0 flex-1 bg-transparent py-3 text-base placeholder:text-fd-muted-foreground focus-visible:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchDialogClose({
|
||||
children = 'Esc',
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'button'>) {
|
||||
const { onOpenChange } = useSearch();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'outline',
|
||||
className: 'text-xs p-1.5',
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchDialogFooter(props: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'mt-auto flex flex-col border-t p-3 empty:hidden',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchDialogOverlay(
|
||||
props: ComponentProps<typeof DialogOverlay>,
|
||||
) {
|
||||
return (
|
||||
<DialogOverlay
|
||||
{...props}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/30 backdrop-blur-sm data-[state=closed]:animate-fd-fade-out data-[state=open]:animate-fd-fade-in',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchDialogContent({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof DialogContent>) {
|
||||
const { text } = useI18n();
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
{...props}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-[10vh] z-50 w-[98vw] max-w-screen-sm -translate-x-1/2 rounded-lg border bg-fd-popover text-fd-popover-foreground shadow-lg data-[state=closed]:animate-fd-dialog-out data-[state=open]:animate-fd-dialog-in',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="hidden">{text.search}</DialogTitle>
|
||||
{children}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
const icons = {
|
||||
text: <Text className="size-4 text-fd-muted-foreground" />,
|
||||
heading: <Hash className="size-4 text-fd-muted-foreground" />,
|
||||
page: <FileText className="size-4 text-fd-muted-foreground" />,
|
||||
};
|
||||
|
||||
function SearchResults({
|
||||
export function SearchDialogList({
|
||||
items,
|
||||
active = items.at(0)?.id,
|
||||
onActiveChange,
|
||||
onSelect,
|
||||
Empty = () => (
|
||||
<div className="py-12 text-center text-sm">
|
||||
<I18nLabel label="searchNoResult" />
|
||||
</div>
|
||||
),
|
||||
Item = (props) => <SearchDialogListItem {...props} />,
|
||||
...props
|
||||
}: ComponentProps<'div'> & {
|
||||
active?: string;
|
||||
onActiveChange: (active: string | undefined) => void;
|
||||
|
||||
}: Omit<ComponentProps<'div'>, 'children'> & {
|
||||
items: ReactSortedResult[];
|
||||
onSelect?: (value: string) => void;
|
||||
/**
|
||||
* Renderer for empty list UI
|
||||
*/
|
||||
Empty?: () => ReactNode;
|
||||
/**
|
||||
* Renderer for items
|
||||
*/
|
||||
Item?: (props: { item: ReactSortedResult; onClick: () => void }) => ReactNode;
|
||||
}) {
|
||||
const { text } = useI18n();
|
||||
const [active, setActive] = useState<string | null>(items.at(0)?.id ?? null);
|
||||
const { onOpenChange } = useSearch();
|
||||
const router = useRouter();
|
||||
|
||||
const onOpen = ({ external, url }: ReactSortedResult) => {
|
||||
if (external) window.open(url, '_blank')?.focus();
|
||||
else router.push(url);
|
||||
onSelect?.(url);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const onKey = useEffectEvent((e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown' || e.key == 'ArrowUp') {
|
||||
const idx = items.findIndex((item) => item.id === active);
|
||||
if (idx === -1) {
|
||||
onActiveChange(items.at(0)?.id);
|
||||
} else {
|
||||
onActiveChange(
|
||||
items.at((e.key === 'ArrowDown' ? idx + 1 : idx - 1) % items.length)
|
||||
?.id,
|
||||
);
|
||||
}
|
||||
let idx = items.findIndex((item) => item.id === active);
|
||||
if (idx === -1) idx = 0;
|
||||
else if (e.key === 'ArrowDown') idx++;
|
||||
else idx--;
|
||||
|
||||
setActive(items.at(idx % items.length)?.id ?? null);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
@@ -191,6 +257,10 @@ function SearchResults({
|
||||
};
|
||||
}, [onKey]);
|
||||
|
||||
useOnChange(items, () => {
|
||||
if (items.length > 0) setActive(items[0].id);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
@@ -199,36 +269,74 @@ function SearchResults({
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm">{text.searchNoResult}</div>
|
||||
) : null}
|
||||
<ListContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
active,
|
||||
setActive,
|
||||
}),
|
||||
[active],
|
||||
)}
|
||||
>
|
||||
{items.length === 0 && Empty()}
|
||||
|
||||
{items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
active={active === item.id}
|
||||
onPointerMove={() => onActiveChange(item.id)}
|
||||
onClick={() => {
|
||||
onOpen(item);
|
||||
}}
|
||||
>
|
||||
{item.type !== 'page' ? (
|
||||
<div
|
||||
role="none"
|
||||
className="ms-2 h-full min-h-10 w-px bg-fd-border"
|
||||
/>
|
||||
) : null}
|
||||
{icons[item.type]}
|
||||
<p className="w-0 flex-1 truncate">{item.content}</p>
|
||||
</CommandItem>
|
||||
))}
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
{Item({ item, onClick: () => onOpen(item) })}
|
||||
</Fragment>
|
||||
))}
|
||||
</ListContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingIndicator({ isLoading }: { isLoading: boolean }) {
|
||||
export function SearchDialogListItem({
|
||||
item,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<'button'> & {
|
||||
item: ReactSortedResult;
|
||||
}) {
|
||||
const { active: activeId, setActive } = useSearchList();
|
||||
const active = item.id === activeId;
|
||||
|
||||
return (
|
||||
<div className="relative size-4">
|
||||
<button
|
||||
type="button"
|
||||
ref={useCallback(
|
||||
(element: HTMLButtonElement | null) => {
|
||||
if (active && element) {
|
||||
element.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
},
|
||||
[active],
|
||||
)}
|
||||
aria-selected={active}
|
||||
className={cn(
|
||||
'flex min-h-10 select-none flex-row items-center gap-2.5 rounded-lg px-2 text-start text-sm',
|
||||
active && 'bg-fd-accent text-fd-accent-foreground',
|
||||
className,
|
||||
)}
|
||||
onPointerMove={() => setActive(item.id)}
|
||||
{...props}
|
||||
>
|
||||
{item.type !== 'page' && (
|
||||
<div role="none" className="ms-2 h-full min-h-10 w-px bg-fd-border" />
|
||||
)}
|
||||
{icons[item.type]}
|
||||
<p className="w-0 flex-1 truncate">{children ?? item.content}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchDialogInputIcon(props: ComponentProps<'div'>) {
|
||||
const { isLoading } = useSearch();
|
||||
|
||||
return (
|
||||
<div {...props} className={cn('relative size-4', props.className)}>
|
||||
<LoaderCircle
|
||||
className={cn(
|
||||
'absolute size-full animate-spin text-fd-primary transition-opacity',
|
||||
@@ -245,38 +353,6 @@ function LoadingIndicator({ isLoading }: { isLoading: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
active = false,
|
||||
...props
|
||||
}: ComponentProps<'button'> & {
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
ref={useCallback(
|
||||
(element: HTMLButtonElement | null) => {
|
||||
if (active && element) {
|
||||
element.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
},
|
||||
[active],
|
||||
)}
|
||||
type="button"
|
||||
aria-selected={active}
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex min-h-10 select-none flex-row items-center gap-2.5 rounded-lg px-2 text-start text-sm',
|
||||
active && 'bg-fd-accent text-fd-accent-foreground',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TagsListProps extends ComponentProps<'div'> {
|
||||
tag?: string;
|
||||
onTagChange: (tag: string | undefined) => void;
|
||||
@@ -294,12 +370,6 @@ const itemVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
const TagsListContext = createContext<{
|
||||
value?: string;
|
||||
onValueChange: (value: string | undefined) => void;
|
||||
allowClear: boolean;
|
||||
}>('TagsList');
|
||||
|
||||
export function TagsList({
|
||||
tag,
|
||||
onTagChange,
|
||||
@@ -334,17 +404,16 @@ export function TagsListItem({
|
||||
}: ComponentProps<'button'> & {
|
||||
value: string;
|
||||
}) {
|
||||
const ctx = TagsListContext.use();
|
||||
const { onValueChange, value: selectedValue, allowClear } = useTagsList();
|
||||
const selected = value === selectedValue;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-active={value === ctx.value}
|
||||
className={cn(itemVariants({ active: value === ctx.value, className }))}
|
||||
data-active={selected}
|
||||
className={cn(itemVariants({ active: selected, className }))}
|
||||
onClick={() => {
|
||||
ctx.onValueChange(
|
||||
ctx.value === value && ctx.allowClear ? undefined : value,
|
||||
);
|
||||
onValueChange(selected && allowClear ? undefined : value);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
{...props}
|
||||
@@ -353,3 +422,21 @@ export function TagsListItem({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSearch() {
|
||||
const ctx = useContext(Context);
|
||||
if (!ctx) throw new Error('Missing <SearchDialog />');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useTagsList() {
|
||||
const ctx = useContext(TagsListContext);
|
||||
if (!ctx) throw new Error('Missing <TagsList />');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useSearchList() {
|
||||
const ctx = useContext(ListContext);
|
||||
if (!ctx) throw new Error('Missing <SearchDialogList />');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { SearchLink, SharedProps } from '@/components/dialog/search';
|
||||
import { createContext } from 'fumadocs-core/framework';
|
||||
|
||||
interface HotKey {
|
||||
@@ -18,6 +17,18 @@ interface HotKey {
|
||||
key: string | ((e: KeyboardEvent) => boolean);
|
||||
}
|
||||
|
||||
export interface SharedProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export type SearchLink = [name: string, href: string];
|
||||
|
||||
export interface TagItem {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SearchProviderProps {
|
||||
/**
|
||||
* Preload search dialog before opening it
|
||||
@@ -48,7 +59,7 @@ export interface SearchProviderProps {
|
||||
/**
|
||||
* Additional props to the dialog
|
||||
*/
|
||||
options?: Partial<SharedProps>;
|
||||
options?: Partial<SharedProps & Record<string, unknown>>;
|
||||
|
||||
children?: ReactNode;
|
||||
}
|
||||
@@ -129,6 +140,7 @@ export function SearchProvider({
|
||||
<SearchDialog
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
// @ts-expect-error -- insert prop for official UIs
|
||||
links={links}
|
||||
{...options}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,8 @@ import { usePathname, useRouter } from 'fumadocs-core/framework';
|
||||
|
||||
interface SearchOptions
|
||||
extends Omit<SearchProviderProps, 'options' | 'children'> {
|
||||
options?: Partial<DefaultSearchDialogProps> | SearchProviderProps['options'];
|
||||
options?: Partial<DefaultSearchDialogProps>;
|
||||
|
||||
/**
|
||||
* Enable search functionality
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user