Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion packages/vitnode/src/components/form/auto-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
<IntlProvider locale="en" messages={messages}>
<AutoForm
fields={[
{
id: "name",
component: fieldProps => {
received.push(fieldProps);

return <AutoFormInput label="Name" {...fieldProps} />;
},
},
]}
formSchema={nameSchema}
onSubmit={() => {}}
/>
</IntlProvider>,
);
});

expect(received.length).toBeGreaterThan(0);
expect(received.every(props => !("children" in props))).toBe(true);
});
});
6 changes: 3 additions & 3 deletions packages/vitnode/src/components/form/auto-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,9 @@ export function AutoForm<T extends z.ZodObject<z.ZodRawShape>>({
>
{component({
field,
children: nestedFields.length
? nestedFields.map(renderField)
: undefined,
...(nestedFields.length
? { children: nestedFields.map(renderField) }
: {}),
description:
typeof params.description === "string"
? params.description
Expand Down
33 changes: 33 additions & 0 deletions packages/vitnode/src/components/ui/dynamic-icon.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<DynamicIcon
className="size-4"
fallback={<span id="fallback" />}
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(<DynamicIcon name="not-a-real-icon" />);

expect(html).not.toContain("<svg");
});
7 changes: 4 additions & 3 deletions packages/vitnode/src/components/ui/dynamic-icon.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { cn } from "cn";
import { Icon } from "lucide-react";
import React from "react";

import { loadLucideIcons } from "./icon-registry";
import { loadLucideIcon } from "./icon-registry";

export interface DynamicIconProps {
absoluteStrokeWidth?: boolean;
Expand All @@ -12,9 +13,9 @@ export interface DynamicIconProps {
}

const ResolvedIcon = ({ name, ...props }: DynamicIconProps) => {
const Icon = React.use(loadLucideIcons()).get(name);
const icon = React.use(loadLucideIcon(name));

return Icon ? React.createElement(Icon, props) : null;
return icon ? <Icon icon={icon} {...props} /> : null;
};

export const DynamicIcon = ({
Expand Down
34 changes: 18 additions & 16 deletions packages/vitnode/src/components/ui/emoji-icon-picker-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -54,22 +54,24 @@ export const EmojiIconPickerPanel = ({
)}
</div>

<TabsContent value="emoji">
<EmojiPicker
autoFocus
height={PANEL_HEIGHT}
onSelect={emoji => onChange({ type: "emoji", value: emoji })}
/>
</TabsContent>
<TabsPanels>
<TabsContent value="emoji">
<EmojiPicker
autoFocus
height={PANEL_HEIGHT}
onSelect={emoji => onChange({ type: "emoji", value: emoji })}
/>
</TabsContent>

<TabsContent value="icon">
<IconPicker
autoFocus
height={PANEL_HEIGHT}
onSelect={name => onChange({ type: "icon", value: name })}
value={value?.type === "icon" ? value.value : undefined}
/>
</TabsContent>
<TabsContent value="icon">
<IconPicker
autoFocus
height={PANEL_HEIGHT}
onSelect={name => onChange({ type: "icon", value: name })}
value={value?.type === "icon" ? value.value : undefined}
/>
</TabsContent>
</TabsPanels>
</Tabs>
);
};
36 changes: 36 additions & 0 deletions packages/vitnode/src/components/ui/icon-registry.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
});
48 changes: 48 additions & 0 deletions packages/vitnode/src/components/ui/icon-registry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { LucideIconData } from "lucide-react";
import type React from "react";

import {
Expand All @@ -17,6 +18,53 @@ export interface LucideIconRegistry {
names: string[];
}

type LucideIconLoader = () => Promise<{ __iconData?: LucideIconData }>;

let loaders: Promise<Map<string, LucideIconLoader>> | undefined;

// eslint-disable-next-line @typescript-eslint/promise-function-async
const loadIconLoaders = (): Promise<Map<string, LucideIconLoader>> => {
loaders ??= import("lucide-react/dynamicIconImports.mjs").then(
({ default: imports }) => {
const entries = Object.entries(
imports as unknown as Record<string, LucideIconLoader>,
);
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<string, Promise<LucideIconData | undefined>>();

// 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<LucideIconRegistry> | undefined;

// eslint-disable-next-line @typescript-eslint/promise-function-async
Expand Down
Loading
Loading