UNPKG

fumadocs-ui

Version:

The Radix UI version of Fumadocs UI

351 lines (350 loc) 13 kB
"use client"; import { cn } from "../../utils/cn.js"; import { buttonVariants } from "../ui/button.js"; import { useRouter } from "fumadocs-core/framework"; import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import { ChevronRight, Hash, SearchIcon } from "lucide-react"; import { cva } from "class-variance-authority"; import { Fragment as Fragment$1, createContext, use, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { T, useTranslations } from "@fuma-translate/react"; import { useOnChange } from "fumadocs-core/utils/use-on-change"; import scrollIntoView from "scroll-into-view-if-needed"; import { Dialog, DialogContent, DialogOverlay, DialogTitle } from "@radix-ui/react-dialog"; import { createMarkdownRenderer } from "fumadocs-core/content/md"; import rehypeRaw from "rehype-raw"; import { visit } from "unist-util-visit"; //#region src/components/dialog/search.tsx const RootContext = createContext(null); const ListContext = createContext(null); const TagsListContext = createContext(null); const PreContext = createContext(false); const mdRenderer = createMarkdownRenderer({ remarkRehypeOptions: { allowDangerousHtml: true }, rehypePlugins: [rehypeRaw, rehypeCustomElements] }); const mdComponents = { mark(props) { return /* @__PURE__ */ jsx("span", { ...props, className: "text-fd-primary underline" }); }, a: "span", p(props) { return /* @__PURE__ */ jsx("p", { ...props, className: "min-w-0" }); }, strong(props) { return /* @__PURE__ */ jsx("strong", { ...props, className: "text-fd-accent-foreground font-medium" }); }, code(props) { if (use(PreContext)) return /* @__PURE__ */ jsx("code", { ...props, className: "mask-[linear-gradient(to_bottom,white,white_30px,transparent_80px)]" }); return /* @__PURE__ */ jsx("code", { ...props, className: "border rounded-md px-px bg-fd-secondary text-fd-secondary-foreground" }); }, custom({ _tagName = "fragment", children, ...rest }) { return /* @__PURE__ */ jsxs("span", { className: "inline-flex max-w-full items-center border p-0.5 rounded-md bg-fd-card text-fd-card-foreground divide-x divide-fd-border", children: [ /* @__PURE__ */ jsx("code", { className: "rounded-sm px-0.5 me-1 bg-fd-primary font-medium text-xs text-fd-primary-foreground border-none", children: _tagName }), Object.entries(rest).map(([k, v]) => { if (typeof v !== "string") return; return /* @__PURE__ */ jsxs("code", { className: "truncate text-xs text-fd-muted-foreground px-1", children: [/* @__PURE__ */ jsxs("span", { className: "text-fd-card-foreground", children: [k, ": "] }), v] }, k); }), children && /* @__PURE__ */ jsx("span", { className: "ps-1", children }) ] }); }, pre(props) { return /* @__PURE__ */ jsx("pre", { ...props, className: cn("flex flex-col border rounded-md my-0.5 p-2 bg-fd-secondary text-fd-secondary-foreground max-h-20 overflow-hidden", props.className), children: /* @__PURE__ */ jsx(PreContext, { value: true, children: props.children }) }); } }; function rehypeCustomElements() { return (tree) => { visit(tree, (node) => { if (node.type === "element" && document.createElement(node.tagName) instanceof HTMLUnknownElement) { node.properties._tagName = node.tagName; node.tagName = "custom"; } }); }; } function SearchDialog({ open, onOpenChange, search, onSearchChange, isLoading = false, onSelect: onSelectProp, children }) { const router = useRouter(); const onOpenChangeCallback = useRef(onOpenChange); onOpenChangeCallback.current = onOpenChange; const onSearchChangeCallback = useRef(onSearchChange); onSearchChangeCallback.current = onSearchChange; const onSelect = (item) => { if (item.type === "action") item.onSelect(); else if (item.external) window.open(item.url, "_blank")?.focus(); else router.push(item.url); onOpenChange(false); onSelectProp?.(item); }; const onSelectCallback = useRef(onSelect); onSelectCallback.current = onSelect; return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsx(RootContext, { value: useMemo(() => ({ open, search, isLoading, onOpenChange: (v) => onOpenChangeCallback.current(v), onSearchChange: (v) => onSearchChangeCallback.current(v), onSelect: (v) => onSelectCallback.current(v) }), [ isLoading, open, search ]), children }) }); } function SearchDialogHeader(props) { return /* @__PURE__ */ jsx("div", { ...props, className: cn("flex flex-row items-center gap-2 p-3", props.className) }); } function SearchDialogInput(props) { const t = useTranslations({ note: "search dialog" }); const { search, onSearchChange } = useSearch(); return /* @__PURE__ */ jsx("input", { ...props, value: search, onChange: (e) => onSearchChange(e.target.value), placeholder: t("Search"), className: "w-0 flex-1 bg-transparent text-lg placeholder:text-fd-muted-foreground focus-visible:outline-none" }); } function SearchDialogClose({ children = "ESC", className, ...props }) { const { onOpenChange } = useSearch(); return /* @__PURE__ */ jsx("button", { type: "button", "aria-label": useTranslations({ note: "search dialog" })("Close Search", { note: "aria-label" }), onClick: () => onOpenChange(false), className: cn(buttonVariants({ color: "outline", size: "sm", className: "font-mono text-fd-muted-foreground" }), className), ...props, children }); } function SearchDialogFooter(props) { return /* @__PURE__ */ jsx("div", { ...props, className: cn("bg-fd-secondary/50 p-3 empty:hidden", props.className) }); } function SearchDialogOverlay(props) { return /* @__PURE__ */ jsx(DialogOverlay, { ...props, className: cn("fixed inset-0 z-50 backdrop-blur-xs bg-fd-overlay data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out", props.className) }); } function SearchDialogContent({ children, ...props }) { const t = useTranslations({ note: "search dialog" }); return /* @__PURE__ */ jsxs(DialogContent, { "aria-describedby": void 0, ...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 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), children: [/* @__PURE__ */ jsx(DialogTitle, { className: "hidden", children: t("Search") }), children] }); } function SearchDialogList({ items = null, Empty = () => /* @__PURE__ */ jsx("div", { className: "py-12 text-center text-sm text-fd-muted-foreground", children: /* @__PURE__ */ jsx(T, { text: "No results found", note: "search dialog" }) }), Item = (props) => /* @__PURE__ */ jsx(SearchDialogListItem, { ...props }), ...props }) { const ref = useRef(null); const { onSelect } = useSearch(); const [active, setActive] = useState(() => items && items.length > 0 ? items[0].id : null); const onKey = useEffectEvent((e) => { if (!items || e.isComposing) return; if (e.key === "ArrowDown" || e.key == "ArrowUp") { 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(); } if (e.key === "Enter") { const selected = items.find((item) => item.id === active); if (selected) onSelect(selected); e.preventDefault(); } }); useEffect(() => { const element = ref.current; if (!element) return; const observer = new ResizeObserver(() => { const viewport = element.firstElementChild; element.style.setProperty("--fd-animated-height", `${viewport.clientHeight}px`); }); const viewport = element.firstElementChild; if (viewport) observer.observe(viewport); window.addEventListener("keydown", onKey); return () => { observer.disconnect(); window.removeEventListener("keydown", onKey); }; }, []); useOnChange(items, () => { if (items && items.length > 0) setActive(items[0].id); }); return /* @__PURE__ */ jsx("div", { ...props, ref, "data-empty": items === null, className: cn("overflow-hidden h-(--fd-animated-height) transition-[height]", props.className), children: /* @__PURE__ */ jsx("div", { className: cn("w-full flex flex-col overflow-y-auto max-h-[460px] p-1", !items && "hidden"), children: /* @__PURE__ */ jsxs(ListContext, { value: useMemo(() => ({ active, setActive }), [active]), children: [items?.length === 0 && Empty(), items?.map((item) => /* @__PURE__ */ jsx(Fragment$1, { children: Item({ item, onClick: () => onSelect(item) }) }, item.id))] }) }) }); } function SearchDialogListItem({ item, className, children, renderMarkdown = (s) => /* @__PURE__ */ jsx(mdRenderer.Markdown, { components: mdComponents, children: s }), renderHighlights: _, ...props }) { const { active: activeId, setActive } = useSearchList(); const active = item.id === activeId; if (item.type === "action") children ??= item.node; else children ??= /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx("div", { className: "inline-flex items-center text-fd-muted-foreground text-xs empty:hidden", children: item.breadcrumbs?.map((item, i) => /* @__PURE__ */ jsxs(Fragment$1, { children: [i > 0 && /* @__PURE__ */ jsx(ChevronRight, { className: "size-4 rtl:rotate-180" }), item] }, i)) }), item.type !== "page" && /* @__PURE__ */ jsx("div", { role: "none", className: "absolute inset-s-3 inset-y-0 w-px bg-fd-border" }), item.type === "heading" && /* @__PURE__ */ jsx(Hash, { className: "absolute inset-s-6 top-2.5 size-4 text-fd-muted-foreground" }), /* @__PURE__ */ jsx("div", { className: cn("min-w-0", item.type === "text" && "ps-4", item.type === "heading" && "ps-8", item.type === "page" || item.type === "heading" ? "font-medium" : "text-fd-popover-foreground/80"), children: typeof item.content === "string" ? renderMarkdown(item.content) : item.content }) ] }); return /* @__PURE__ */ jsx("button", { type: "button", ref: useCallback((element) => { if (active && element) scrollIntoView(element, { scrollMode: "if-needed", block: "nearest", boundary: element.parentElement }); }, [active]), "aria-selected": active, className: cn("relative select-none shrink-0 px-2.5 py-2 text-start text-sm overflow-hidden rounded-lg", active && "bg-fd-accent text-fd-accent-foreground", className), onPointerMove: () => setActive(item.id), ...props, children }); } function SearchDialogIcon(props) { const { isLoading } = useSearch(); return /* @__PURE__ */ jsx(SearchIcon, { ...props, className: cn("size-5 text-fd-muted-foreground", isLoading && "animate-pulse duration-400", props.className) }); } const itemVariants = cva("rounded-md border px-2 py-0.5 text-xs font-medium text-fd-muted-foreground transition-colors", { variants: { active: { true: "bg-fd-accent text-fd-accent-foreground" } } }); function TagsList({ tag, onTagChange, allowClear = false, ...props }) { const onTagChangeCallback = useRef(onTagChange); onTagChangeCallback.current = onTagChange; return /* @__PURE__ */ jsx("div", { ...props, className: cn("flex items-center gap-1 flex-wrap", props.className), children: /* @__PURE__ */ jsx(TagsListContext, { value: useMemo(() => ({ value: tag, onValueChange: (v) => onTagChangeCallback.current(v), allowClear }), [allowClear, tag]), children: props.children }) }); } function TagsListItem({ value, className, ...props }) { const { onValueChange, value: selectedValue, allowClear } = useTagsList(); const selected = value === selectedValue; return /* @__PURE__ */ jsx("button", { type: "button", "data-active": selected, className: cn(itemVariants({ active: selected, className })), onClick: () => onValueChange(selected && allowClear ? void 0 : value), tabIndex: -1, ...props, children: props.children }); } function useSearch() { const ctx = use(RootContext); if (!ctx) throw new Error("Missing <SearchDialog />"); return ctx; } function useTagsList() { const ctx = use(TagsListContext); if (!ctx) throw new Error("Missing <TagsList />"); return ctx; } function useSearchList() { const ctx = use(ListContext); if (!ctx) throw new Error("Missing <SearchDialogList />"); return ctx; } //#endregion export { SearchDialog, SearchDialogClose, SearchDialogContent, SearchDialogFooter, SearchDialogHeader, SearchDialogIcon, SearchDialogInput, SearchDialogList, SearchDialogListItem, SearchDialogOverlay, TagsList, TagsListItem, useSearch, useSearchList, useTagsList };