UI: require TagsListItem to be used with TagsList

This commit is contained in:
Fuma Nama
2025-05-16 21:14:34 +08:00
parent 39bf0885f4
commit 05b3bd99cc
5 changed files with 139 additions and 123 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': patch
---
[Internal] require `TagsListItem` to be used with `TagsList`
@@ -1,16 +1,17 @@
'use client';
import {
useDocsSearch,
type AlgoliaOptions,
useDocsSearch,
} from 'fumadocs-core/search/client';
import { type ReactNode, useState } from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import {
SearchDialog,
type SharedProps,
TagsList,
type TagItem,
TagsList,
TagsListItem,
} from './search';
export interface AlgoliaSearchDialogProps extends SharedProps {
@@ -68,27 +69,27 @@ export default function AlgoliaSearchDialog({
isLoading={query.isLoading}
{...props}
footer={
tags ? (
<>
<TagsList
tag={tag}
onTagChange={setTag}
items={tags}
allowClear={allowClear}
>
{showAlgolia ? <AlgoliaTitle /> : null}
<>
{tags ? (
<TagsList tag={tag} onTagChange={setTag} allowClear={allowClear}>
{tags.map((tag) => (
<TagsListItem key={tag.value} value={tag.value}>
{tag.name}
</TagsListItem>
))}
{showAlgolia && <AlgoliaTitle />}
</TagsList>
{props.footer}
</>
) : (
props.footer
)
) : (
showAlgolia && <AlgoliaTitle />
)}
{props.footer}
</>
}
/>
);
}
function AlgoliaTitle(): ReactNode {
function AlgoliaTitle() {
return (
<a
href="https://algolia.com"
@@ -9,6 +9,7 @@ import {
type SharedProps,
type TagItem,
TagsList,
TagsListItem,
} from './search';
export interface DefaultSearchDialogProps extends SharedProps {
@@ -78,19 +79,18 @@ export default function DefaultSearchDialog({
results={query.data ?? []}
{...props}
footer={
tags ? (
<>
<TagsList
tag={tag}
onTagChange={setTag}
items={tags}
allowClear={allowClear}
/>
{props.footer}
</>
) : (
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}
</>
}
/>
);
@@ -1,8 +1,8 @@
'use client';
import {
useDocsSearch,
type OramaCloudOptions,
useDocsSearch,
} from 'fumadocs-core/search/client';
import { type ReactNode, useState } from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
@@ -11,6 +11,7 @@ import {
type SharedProps,
type TagItem,
TagsList,
TagsListItem,
} from './search';
export interface OramaSearchDialogProps extends SharedProps {
@@ -75,27 +76,27 @@ export default function OramaSearchDialog({
isLoading={query.isLoading}
{...props}
footer={
tags ? (
<>
<TagsList
tag={tag}
onTagChange={setTag}
items={tags}
allowClear={allowClear}
>
{showOrama ? <Label /> : null}
<>
{tags ? (
<TagsList tag={tag} onTagChange={setTag} allowClear={allowClear}>
{tags.map((tag) => (
<TagsListItem key={tag.value} value={tag.value}>
{tag.name}
</TagsListItem>
))}
{showOrama && <Label />}
</TagsList>
{props.footer}
</>
) : (
props.footer
)
) : (
showOrama && <Label />
)}
{props.footer}
</>
}
/>
);
}
function Label(): ReactNode {
function Label() {
return (
<a
href="https://orama.com"
+86 -77
View File
@@ -8,8 +8,7 @@ import {
Text,
} from 'lucide-react';
import {
type ButtonHTMLAttributes,
type HTMLAttributes,
type ComponentProps,
type ReactNode,
useCallback,
useEffect,
@@ -29,7 +28,7 @@ import {
import type { SortedResult } from 'fumadocs-core/server';
import { cva } from 'class-variance-authority';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
import { useRouter } from 'fumadocs-core/framework';
import { createContext, useRouter } from 'fumadocs-core/framework';
export type SearchLink = [name: string, href: string];
@@ -38,6 +37,11 @@ type ReactSortedResult = Omit<SortedResult, 'content'> & {
content: ReactNode;
};
export interface TagItem {
name: string;
value: string;
}
export interface SharedProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -69,6 +73,7 @@ export function SearchDialog({
...props
}: SearchDialogProps) {
const { text } = useI18n();
const [active, setActive] = useState<string>();
const defaultItems = useMemo<ReactSortedResult[]>(
() =>
links.map(([name, link]) => ({
@@ -94,6 +99,7 @@ export function SearchDialog({
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"
@@ -114,13 +120,15 @@ export function SearchDialog({
</div>
{props.results !== 'empty' || defaultItems.length > 0 ? (
<SearchResults
active={active}
onActiveChange={setActive}
items={props.results === 'empty' ? defaultItems : props.results}
onSelect={() => onOpenChange(false)}
/>
) : null}
{footer ? (
<div className="mt-auto flex flex-col border-t p-3">{footer}</div>
) : null}
<div className="mt-auto flex flex-col border-t p-3 empty:hidden">
{footer}
</div>
</DialogContent>
</Dialog>
);
@@ -134,24 +142,21 @@ const icons = {
function SearchResults({
items,
active = items.at(0)?.id,
onActiveChange,
onSelect,
...props
}: HTMLAttributes<HTMLDivElement> & {
}: ComponentProps<'div'> & {
active?: string;
onActiveChange: (active: string | undefined) => void;
items: ReactSortedResult[];
onSelect?: (value: string) => void;
}) {
const [active, setActive] = useState<string>();
const { text } = useI18n();
const router = useRouter();
const sidebar = useSidebar();
if (
items.length > 0 &&
(!active || items.every((item) => item.id !== active))
) {
setActive(items[0].id);
}
const onOpen = ({ external, url }: ReactSortedResult) => {
if (external) window.open(url, '_blank')?.focus();
else router.push(url);
@@ -161,14 +166,15 @@ function SearchResults({
const onKey = useEffectEvent((e: KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key == 'ArrowUp') {
setActive((cur) => {
const idx = items.findIndex((item) => item.id === cur);
if (idx === -1) return items.at(0)?.id;
return items.at(
(e.key === 'ArrowDown' ? idx + 1 : idx - 1) % items.length,
)?.id;
});
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,
);
}
e.preventDefault();
}
@@ -203,9 +209,8 @@ function SearchResults({
{items.map((item) => (
<CommandItem
key={item.id}
value={item.id}
active={active}
onActiveChange={setActive}
active={active === item.id}
onPointerMove={() => onActiveChange(item.id)}
onClick={() => {
onOpen(item);
}}
@@ -244,34 +249,29 @@ function LoadingIndicator({ isLoading }: { isLoading: boolean }) {
}
function CommandItem({
active,
onActiveChange,
value,
active = false,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
value: string;
active?: string;
onActiveChange: (value: string) => void;
}: ComponentProps<'button'> & {
active?: boolean;
}) {
return (
<button
ref={useCallback(
(element: HTMLButtonElement | null) => {
if (active === value && element) {
if (active && element) {
element.scrollIntoView({
block: 'nearest',
});
}
},
[active, value],
[active],
)}
type="button"
aria-selected={active === value}
onPointerMove={() => onActiveChange(value)}
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 === value && 'bg-fd-accent text-fd-accent-foreground',
active && 'bg-fd-accent text-fd-accent-foreground',
props.className,
)}
>
@@ -280,19 +280,10 @@ function CommandItem({
);
}
export interface TagItem {
name: string;
value: string | undefined;
props?: HTMLAttributes<HTMLButtonElement>;
}
export interface TagsListProps extends HTMLAttributes<HTMLDivElement> {
export interface TagsListProps extends ComponentProps<'div'> {
tag?: string;
onTagChange: (tag: string | undefined) => void;
allowClear?: boolean;
items: TagItem[];
}
const itemVariants = cva(
@@ -306,44 +297,62 @@ const itemVariants = cva(
},
);
const TagsListContext = createContext<{
value?: string;
onValueChange: (value: string | undefined) => void;
allowClear: boolean;
}>('TagsList');
export function TagsList({
tag,
onTagChange,
items,
allowClear,
allowClear = false,
...props
}: TagsListProps) {
return (
<div
{...props}
className={cn(
'flex flex-row items-center gap-1 flex-wrap',
props.className,
)}
className={cn('flex items-center gap-1 flex-wrap', props.className)}
>
{items.map((item) => (
<button
key={item.value}
type="button"
data-active={tag === item.value}
className={cn(
itemVariants({ active: tag === item.value }),
item.props?.className,
)}
onClick={() => {
if (tag === item.value && allowClear) {
onTagChange(undefined);
} else {
onTagChange(item.value);
}
}}
tabIndex={-1}
{...item.props}
>
{item.name}
</button>
))}
{props.children}
<TagsListContext.Provider
value={useMemo(
() => ({
value: tag,
onValueChange: onTagChange,
allowClear,
}),
[allowClear, onTagChange, tag],
)}
>
{props.children}
</TagsListContext.Provider>
</div>
);
}
export function TagsListItem({
value,
className,
...props
}: ComponentProps<'button'> & {
value: string;
}) {
const ctx = TagsListContext.use();
return (
<button
type="button"
data-active={value === ctx.value}
className={cn(itemVariants({ active: value === ctx.value, className }))}
onClick={() => {
ctx.onValueChange(
ctx.value === value && ctx.allowClear ? undefined : value,
);
}}
tabIndex={-1}
{...props}
>
{props.children}
</button>
);
}