fumadocs-ui
Version:
The Radix UI version of Fumadocs UI
351 lines (350 loc) • 13 kB
JavaScript
"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 };