diff --git a/packages/vitnode/src/components/form/auto-form.test.tsx b/packages/vitnode/src/components/form/auto-form.test.tsx index 3b899cf4a..1cd2e233f 100644 --- a/packages/vitnode/src/components/form/auto-form.test.tsx +++ b/packages/vitnode/src/components/form/auto-form.test.tsx @@ -7,7 +7,7 @@ import { z } from "zod"; import messages from "@/locales/en.json"; import type { FormMode } from "../ui/form"; -import type { AutoFormOnSubmit } from "./auto-form"; +import type { AutoFormOnSubmit, ItemAutoFormComponentProps } from "./auto-form"; import { setFormFieldError } from "../ui/form"; import { AutoForm } from "./auto-form"; @@ -305,3 +305,33 @@ describe("AutoFormArray", () => { expect(amounts().map(input => input.value)).toEqual(["1", "3"]); }); }); + +describe("field props", () => { + it("omits children entirely when a field has no nested fields", async () => { + const received: ItemAutoFormComponentProps[] = []; + + await settled(() => { + render( + + { + received.push(fieldProps); + + return ; + }, + }, + ]} + formSchema={nameSchema} + onSubmit={() => {}} + /> + , + ); + }); + + expect(received.length).toBeGreaterThan(0); + expect(received.every(props => !("children" in props))).toBe(true); + }); +}); diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index 550bada8b..b4155096d 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -285,9 +285,9 @@ export function AutoForm>({ > {component({ field, - children: nestedFields.length - ? nestedFields.map(renderField) - : undefined, + ...(nestedFields.length + ? { children: nestedFields.map(renderField) } + : {}), description: typeof params.description === "string" ? params.description diff --git a/packages/vitnode/src/components/ui/dynamic-icon.test.tsx b/packages/vitnode/src/components/ui/dynamic-icon.test.tsx new file mode 100644 index 000000000..284d8ee5e --- /dev/null +++ b/packages/vitnode/src/components/ui/dynamic-icon.test.tsx @@ -0,0 +1,33 @@ +// @vitest-environment node +import React from "react"; +import { renderToReadableStream } from "react-dom/server"; +import { expect, test } from "vitest"; + +import { DynamicIcon } from "./dynamic-icon"; + +const renderToHtml = async (node: React.ReactElement) => { + const stream = await renderToReadableStream(node); + await stream.allReady; + + return new Response(stream).text(); +}; + +test("renders the icon into the server-rendered shell", async () => { + const html = await renderToHtml( + } + name="house" + />, + ); + + expect(html).toContain("lucide-house"); + expect(html).toContain("size-4"); + expect(html).not.toContain('id="fallback"'); +}); + +test("renders nothing for an unknown icon", async () => { + const html = await renderToHtml(); + + expect(html).not.toContain(" { - const Icon = React.use(loadLucideIcons()).get(name); + const icon = React.use(loadLucideIcon(name)); - return Icon ? React.createElement(Icon, props) : null; + return icon ? : null; }; export const DynamicIcon = ({ diff --git a/packages/vitnode/src/components/ui/emoji-icon-picker-panel.tsx b/packages/vitnode/src/components/ui/emoji-icon-picker-panel.tsx index 22e2bd0d6..8a90434bf 100644 --- a/packages/vitnode/src/components/ui/emoji-icon-picker-panel.tsx +++ b/packages/vitnode/src/components/ui/emoji-icon-picker-panel.tsx @@ -7,7 +7,7 @@ import type { EmojiIconValue } from "@/lib/emoji-icon"; import { Button } from "./button"; import { EmojiPicker } from "./emoji-picker"; import { IconPicker } from "./icon-picker"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; +import { Tabs, TabsContent, TabsList, TabsPanels, TabsTrigger } from "./tabs"; const PANEL_HEIGHT = 288; @@ -54,22 +54,24 @@ export const EmojiIconPickerPanel = ({ )} - - onChange({ type: "emoji", value: emoji })} - /> - + + + onChange({ type: "emoji", value: emoji })} + /> + - - onChange({ type: "icon", value: name })} - value={value?.type === "icon" ? value.value : undefined} - /> - + + onChange({ type: "icon", value: name })} + value={value?.type === "icon" ? value.value : undefined} + /> + + ); }; diff --git a/packages/vitnode/src/components/ui/icon-registry.test.ts b/packages/vitnode/src/components/ui/icon-registry.test.ts new file mode 100644 index 000000000..5876094aa --- /dev/null +++ b/packages/vitnode/src/components/ui/icon-registry.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; + +import { loadLucideIcon, loadLucideIcons } from "./icon-registry"; + +describe("loadLucideIcon", () => { + test("resolves icon data for a canonical name", async () => { + const icon = await loadLucideIcon("house"); + + expect(icon?.name).toBe("house"); + expect(icon?.node.length).toBeGreaterThan(0); + }); + + test("resolves an alias name", async () => { + const [alias, canonical] = await Promise.all([ + loadLucideIcon("home"), + loadLucideIcon("house"), + ]); + + expect(alias?.node).toStrictEqual(canonical?.node); + }); + + test("resolves every name the picker offers", async () => { + const { names } = await loadLucideIcons(); + const resolved = await Promise.all(names.map(loadLucideIcon)); + + expect(resolved.filter(icon => !icon)).toStrictEqual([]); + }); + + test("returns undefined for an unknown name", async () => { + await expect(loadLucideIcon("not-a-real-icon")).resolves.toBeUndefined(); + }); + + test("returns a stable promise so React.use does not re-suspend", () => { + expect(loadLucideIcon("camera")).toBe(loadLucideIcon("camera")); + }); +}); diff --git a/packages/vitnode/src/components/ui/icon-registry.ts b/packages/vitnode/src/components/ui/icon-registry.ts index da0a469de..e5edff7a1 100644 --- a/packages/vitnode/src/components/ui/icon-registry.ts +++ b/packages/vitnode/src/components/ui/icon-registry.ts @@ -1,3 +1,4 @@ +import type { LucideIconData } from "lucide-react"; import type React from "react"; import { @@ -17,6 +18,53 @@ export interface LucideIconRegistry { names: string[]; } +type LucideIconLoader = () => Promise<{ __iconData?: LucideIconData }>; + +let loaders: Promise> | undefined; + +// eslint-disable-next-line @typescript-eslint/promise-function-async +const loadIconLoaders = (): Promise> => { + loaders ??= import("lucide-react/dynamicIconImports.mjs").then( + ({ default: imports }) => { + const entries = Object.entries( + imports as unknown as Record, + ); + const resolved = new Map(entries); + + for (const [name, loader] of entries) { + const componentName = componentNameToIconName( + iconNameToComponentName(name), + ); + + if (!resolved.has(componentName)) resolved.set(componentName, loader); + } + + return resolved; + }, + ); + + return loaders; +}; + +const icons = new Map>(); + +// eslint-disable-next-line @typescript-eslint/promise-function-async +export const loadLucideIcon = (name: string) => { + const cached = icons.get(name); + + if (cached) return cached; + + const pending = loadIconLoaders().then(async resolved => { + const loader = resolved.get(name); + + return loader ? (await loader()).__iconData : undefined; + }); + + icons.set(name, pending); + + return pending; +}; + let registry: Promise | undefined; // eslint-disable-next-line @typescript-eslint/promise-function-async diff --git a/packages/vitnode/src/components/ui/navigation-menu.tsx b/packages/vitnode/src/components/ui/navigation-menu.tsx index e11382a5f..6d6ee2b45 100644 --- a/packages/vitnode/src/components/ui/navigation-menu.tsx +++ b/packages/vitnode/src/components/ui/navigation-menu.tsx @@ -26,19 +26,141 @@ function NavigationMenu({ ); } +const HIGHLIGHT_SELECTOR = + '[data-slot="navigation-menu-trigger"], [data-slot="navigation-menu-link"]'; +const OPEN_TRIGGER_SELECTOR = + '[data-slot="navigation-menu-trigger"][data-popup-open]'; +const ACTIVE_LINK_SELECTOR = + '[data-slot="navigation-menu-link"][data-active]:not([data-slot="navigation-menu-content"] *)'; + function NavigationMenuList({ className, + children, + onFocus, + onPointerLeave, + onPointerOver, + ref, ...props }: React.ComponentProps) { + const listRef = React.useRef(null); + const highlightRef = React.useRef(null); + const pointerInsideRef = React.useRef(false); + + const moveHighlightTo = React.useCallback((item: HTMLElement) => { + const list = listRef.current; + const highlight = highlightRef.current; + if (!list || !highlight) return; + + const listBox = list.getBoundingClientRect(); + const itemBox = item.getBoundingClientRect(); + const wasVisible = highlight.dataset.visible !== undefined; + + if (!wasVisible) highlight.dataset.instant = ""; + + highlight.style.height = `${itemBox.height.toString()}px`; + highlight.style.top = `${(itemBox.top - listBox.top).toString()}px`; + highlight.style.translate = `${(itemBox.left - listBox.left).toString()}px`; + highlight.style.width = `${itemBox.width.toString()}px`; + + if (!wasVisible) { + highlight.getBoundingClientRect(); + delete highlight.dataset.instant; + } + + highlight.dataset.visible = ""; + }, []); + + const hideHighlight = React.useCallback(() => { + const highlight = highlightRef.current; + if (highlight) delete highlight.dataset.visible; + }, []); + + const itemUnder = (target: EventTarget | null) => { + const list = listRef.current; + if (!list || !(target instanceof Element)) return null; + + const item = target.closest(HIGHLIGHT_SELECTOR); + + return item && list.contains(item) ? item : null; + }; + + const settleHighlight = React.useCallback(() => { + const list = listRef.current; + const resting = + list?.querySelector(OPEN_TRIGGER_SELECTOR) ?? + list?.querySelector(ACTIVE_LINK_SELECTOR); + + if (resting) { + moveHighlightTo(resting); + + return; + } + + hideHighlight(); + }, [hideHighlight, moveHighlightTo]); + + React.useEffect(() => { + const list = listRef.current; + if (!list) return; + + const settleWhenIdle = () => { + if (!pointerInsideRef.current) settleHighlight(); + }; + + const attributes = new MutationObserver(settleWhenIdle); + attributes.observe(list, { + attributeFilter: ["data-active", "data-popup-open"], + attributes: true, + subtree: true, + }); + + const resize = new ResizeObserver(settleWhenIdle); + resize.observe(list); + + return () => { + attributes.disconnect(); + resize.disconnect(); + }; + }, [settleHighlight]); + return ( { + const item = itemUnder(event.target); + if (item) moveHighlightTo(item); + onFocus?.(event); + }} + onPointerLeave={event => { + pointerInsideRef.current = false; + settleHighlight(); + onPointerLeave?.(event); + }} + onPointerOver={event => { + pointerInsideRef.current = true; + const item = itemUnder(event.target); + if (item) moveHighlightTo(item); + onPointerOver?.(event); + }} + ref={node => { + listRef.current = node; + if (typeof ref === "function") return ref(node); + if (ref) ref.current = node; + }} {...props} - /> + > + + {children} + ); } @@ -56,7 +178,7 @@ function NavigationMenuItem({ } const navigationMenuTriggerStyle = cva( - "group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted", + "group/navigation-menu-trigger relative inline-flex h-9 w-max items-center justify-center rounded-md px-3 py-2 text-sm font-medium transition-all outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:after:absolute data-popup-open:after:inset-y-0 data-popup-open:after:-inset-x-(--navigation-menu-gap) data-popup-open:after:content-['']", ); function NavigationMenuTrigger({ @@ -73,7 +195,7 @@ function NavigationMenuTrigger({ {children}{" "}