feat(ui): type-safe provider props

This commit is contained in:
Fuma Nama
2026-06-15 13:21:11 +08:00
parent cfe022641e
commit 5499f59ea7
7 changed files with 75 additions and 45 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-ui': patch
'@fumadocs/base-ui': patch
---
type-safe provider props
@@ -1,8 +1,8 @@
'use client';
import { useDocsSearch } from 'fumadocs-core/search/client';
import { SearchClient, useDocsSearch } from 'fumadocs-core/search/client';
import { fetchClient } from 'fumadocs-core/search/client/fetch';
import { type ReactNode, useMemo, useState } from 'react';
import { type ReactNode, use, useMemo, useState } from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { useI18n } from '@/contexts/i18n';
import {
@@ -23,6 +23,9 @@ import type { SortedResult } from 'fumadocs-core/search';
import type { SearchLink, TagItem } from '@/contexts/search';
export interface DefaultSearchDialogProps extends SharedProps {
/** @deprecated re-create the dialog instead for other clients, see https://fumadocs.dev/docs/search/orama */
type?: 'static';
links?: SearchLink[];
defaultTag?: string;
@@ -48,7 +51,10 @@ export interface DefaultSearchDialogProps extends SharedProps {
allowClear?: boolean;
}
let STATIC: Promise<typeof import('fumadocs-core/search/client/orama-static')> | undefined;
export default function DefaultSearchDialog({
type,
defaultTag,
tags = [],
api,
@@ -60,14 +66,27 @@ export default function DefaultSearchDialog({
}: DefaultSearchDialogProps) {
const { locale } = useI18n();
const [tag, setTag] = useState(defaultTag);
const { search, setSearch, query } = useDocsSearch({
client: fetchClient({
let client: SearchClient;
if (type === 'static') {
// TODO: must remove it on next major, currently, this will bundle the Orama client unnecessarily
client = use((STATIC ??= import('fumadocs-core/search/client/orama-static'))).oramaStaticClient(
{
from: api,
locale,
tag,
},
);
} else {
client = fetchClient({
api,
locale,
tag,
}),
delayMs,
});
});
}
const { search, setSearch, query } = useDocsSearch({ client, delayMs });
const defaultItems = useMemo<SortedResult[] | null>(() => {
if (links.length === 0) return null;
return links.map(([name, link]) => ({
+8 -12
View File
@@ -1,4 +1,5 @@
'use client';
import type { DefaultSearchDialogProps } from '@/components/dialog/search-default';
import {
type ComponentType,
createContext,
@@ -33,7 +34,7 @@ export interface TagItem {
value: string;
}
export interface SearchProviderProps {
export interface SearchProviderProps<DialogProps extends SharedProps = DefaultSearchDialogProps> {
/**
* Preload search dialog before opening it
*
@@ -58,12 +59,12 @@ export interface SearchProviderProps {
*
* It receives the `open` and `onOpenChange` prop, can be lazy loaded with `React.lazy()`
*/
SearchDialog?: ComponentType<SharedProps>;
SearchDialog?: ComponentType<DialogProps>;
/**
* Additional props to the dialog
*/
options?: Partial<SharedProps & Record<string, unknown>>;
options?: Partial<DialogProps>;
children?: ReactNode;
}
@@ -109,14 +110,14 @@ const DEFAULT_HOT_KEYS: HotKey[] = [
const DefaultSearchDialog = lazy(() => import('@/components/dialog/search-default'));
export function SearchProvider({
export function SearchProvider<DialogProps extends SharedProps = DefaultSearchDialogProps>({
SearchDialog = DefaultSearchDialog,
children,
preload = true,
options,
hotKey = DEFAULT_HOT_KEYS,
links,
}: SearchProviderProps) {
}: SearchProviderProps<DialogProps>) {
const [isOpen, setIsOpen] = useState(preload ? false : undefined);
const onKeyDown = useEffectEvent((e: KeyboardEvent) => {
if (hotKey.every((v) => (typeof v.key === 'string' ? e.key === v.key : v.key(e)))) {
@@ -146,13 +147,8 @@ export function SearchProvider({
>
<Suspense fallback={null}>
{isOpen !== undefined && (
<SearchDialog
open={isOpen}
onOpenChange={setIsOpen}
// @ts-expect-error -- insert prop for official UIs
links={links}
{...options}
/>
// @ts-expect-error -- assume all required props are filled
<SearchDialog open={isOpen} onOpenChange={setIsOpen} links={links} {...options} />
)}
</Suspense>
-3
View File
@@ -2,14 +2,11 @@
import type { ReactNode } from 'react';
import { DirectionProvider } from '@base-ui/react/direction-provider';
import type { DefaultSearchDialogProps } from '@/components/dialog/search-default';
import { ThemeProvider, type ThemeProviderProps } from 'next-themes';
import { I18nProvider, type I18nProviderProps } from '@/contexts/i18n';
import { SearchProvider, type SearchProviderProps } from '@/contexts/search';
interface SearchOptions extends Omit<SearchProviderProps, 'children'> {
options?: Partial<DefaultSearchDialogProps>;
/**
* Enable search functionality
*
@@ -1,8 +1,8 @@
'use client';
import { useDocsSearch } from 'fumadocs-core/search/client';
import { SearchClient, useDocsSearch } from 'fumadocs-core/search/client';
import { fetchClient } from 'fumadocs-core/search/client/fetch';
import { type ReactNode, useMemo, useState } from 'react';
import { type ReactNode, use, useMemo, useState } from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { useI18n } from '@/contexts/i18n';
import {
@@ -23,6 +23,9 @@ import type { SortedResult } from 'fumadocs-core/search';
import type { SearchLink, TagItem } from '@/contexts/search';
export interface DefaultSearchDialogProps extends SharedProps {
/** @deprecated re-create the dialog instead for other clients, see https://fumadocs.dev/docs/search/orama */
type?: 'static';
links?: SearchLink[];
defaultTag?: string;
tags?: TagItem[];
@@ -47,7 +50,10 @@ export interface DefaultSearchDialogProps extends SharedProps {
allowClear?: boolean;
}
let STATIC: Promise<typeof import('fumadocs-core/search/client/orama-static')> | undefined;
export default function DefaultSearchDialog({
type,
defaultTag,
tags = [],
api,
@@ -59,14 +65,27 @@ export default function DefaultSearchDialog({
}: DefaultSearchDialogProps) {
const { locale } = useI18n();
const [tag, setTag] = useState(defaultTag);
const { search, setSearch, query } = useDocsSearch({
client: fetchClient({
let client: SearchClient;
if (type === 'static') {
// TODO: must remove it on next major, currently, this will bundle the Orama client unnecessarily
client = use((STATIC ??= import('fumadocs-core/search/client/orama-static'))).oramaStaticClient(
{
from: api,
locale,
tag,
},
);
} else {
client = fetchClient({
api,
locale,
tag,
}),
delayMs,
});
});
}
const { search, setSearch, query } = useDocsSearch({ client, delayMs });
const defaultItems = useMemo<SortedResult[] | null>(() => {
if (links.length === 0) return null;
return links.map(([name, link]) => ({
+8 -12
View File
@@ -1,4 +1,5 @@
'use client';
import type { DefaultSearchDialogProps } from '@/components/dialog/search-default';
import {
type ComponentType,
createContext,
@@ -33,7 +34,7 @@ export interface TagItem {
value: string;
}
export interface SearchProviderProps {
export interface SearchProviderProps<DialogProps extends SharedProps = DefaultSearchDialogProps> {
/**
* Preload search dialog before opening it
*
@@ -58,12 +59,12 @@ export interface SearchProviderProps {
*
* It receives the `open` and `onOpenChange` prop, can be lazy loaded with `React.lazy()`
*/
SearchDialog?: ComponentType<SharedProps>;
SearchDialog?: ComponentType<DialogProps>;
/**
* Additional props to the dialog
*/
options?: Partial<SharedProps & Record<string, unknown>>;
options?: Partial<DialogProps>;
children?: ReactNode;
}
@@ -109,14 +110,14 @@ const DEFAULT_HOT_KEYS: HotKey[] = [
const DefaultSearchDialog = lazy(() => import('@/components/dialog/search-default'));
export function SearchProvider({
export function SearchProvider<DialogProps extends SharedProps = DefaultSearchDialogProps>({
SearchDialog = DefaultSearchDialog,
children,
preload = true,
options,
hotKey = DEFAULT_HOT_KEYS,
links,
}: SearchProviderProps) {
}: SearchProviderProps<DialogProps>) {
const [isOpen, setIsOpen] = useState(preload ? false : undefined);
const onKeyDown = useEffectEvent((e: KeyboardEvent) => {
if (hotKey.every((v) => (typeof v.key === 'string' ? e.key === v.key : v.key(e)))) {
@@ -146,13 +147,8 @@ export function SearchProvider({
>
<Suspense fallback={null}>
{isOpen !== undefined && (
<SearchDialog
open={isOpen}
onOpenChange={setIsOpen}
// @ts-expect-error -- insert prop for official UIs
links={links}
{...options}
/>
// @ts-expect-error -- assume all required props are filled
<SearchDialog open={isOpen} onOpenChange={setIsOpen} links={links} {...options} />
)}
</Suspense>
+1 -4
View File
@@ -2,14 +2,11 @@
import type { ReactNode } from 'react';
import { DirectionProvider } from '@radix-ui/react-direction';
import type { DefaultSearchDialogProps } from '@/components/dialog/search-default';
import { ThemeProvider, type ThemeProviderProps } from 'next-themes';
import { I18nProvider, type I18nProviderProps } from '@/contexts/i18n';
import { SearchProvider, type SearchProviderProps } from '@/contexts/search';
interface SearchOptions extends Omit<SearchProviderProps, 'options' | 'children'> {
options?: Partial<DefaultSearchDialogProps>;
interface SearchOptions extends Omit<SearchProviderProps, 'children'> {
/**
* Enable search functionality
*