mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-04 12:58:17 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
550c52464e | ||
|
|
87c92ae5e5 |
@@ -17,10 +17,13 @@ const PLAYGROUND_RULES = [
|
||||
"NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
|
||||
"NEVER use page background colors (bg-gray-50) - the container has its own background.",
|
||||
"For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
|
||||
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
|
||||
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts. Keep the total UI compact — avoid sprawling multi-section pages. Prefer a single focused Card over a full page layout.",
|
||||
"Wrap each repeated item in a Card for visual separation and structure.",
|
||||
"Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
|
||||
'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
|
||||
"NEVER use emoji characters. Use the Icon component with Lucide icon names instead. For example, use Icon with name:'MapPin' instead of a pin emoji, Icon with name:'Mail' instead of an envelope emoji, etc.",
|
||||
"For icon+label patterns, use a horizontal Stack with gap:'sm' and align:'center' containing an Icon and a Text.",
|
||||
"For any tabular or list data with consistent columns (items, orders, stats), ALWAYS use the Table component. Never simulate tables with Stacks — the columns won't align.",
|
||||
];
|
||||
|
||||
const MAX_PROMPT_LENGTH = 500;
|
||||
|
||||
@@ -18,65 +18,62 @@ import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
||||
|
||||
const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
|
||||
const SIMULATION_PROMPT = "Show a team performance dashboard";
|
||||
|
||||
interface SimulationStage {
|
||||
tree: Spec;
|
||||
stream: string;
|
||||
}
|
||||
|
||||
// Shared state & element definitions for the progressive simulation stages.
|
||||
const FORM_STATE = { form: { name: "", email: "", message: "" } };
|
||||
const DASH_STATE = {
|
||||
chartData: [
|
||||
{ label: "Mon", value: 12 },
|
||||
{ label: "Tue", value: 28 },
|
||||
{ label: "Wed", value: 19 },
|
||||
{ label: "Thu", value: 34 },
|
||||
{ label: "Fri", value: 45 },
|
||||
{ label: "Sat", value: 38 },
|
||||
{ label: "Sun", value: 52 },
|
||||
],
|
||||
};
|
||||
|
||||
const NAME_INPUT = {
|
||||
type: "Input",
|
||||
const METRIC_REVENUE = {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Name",
|
||||
name: "name",
|
||||
statePath: "/form/name",
|
||||
checks: [{ type: "required", message: "Name is required" }],
|
||||
label: "Weekly Revenue",
|
||||
value: "12,400",
|
||||
prefix: "$",
|
||||
change: "+18%",
|
||||
changeType: "positive",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const EMAIL_INPUT = {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
statePath: "/form/email",
|
||||
checks: [
|
||||
{ type: "required", message: "Email is required" },
|
||||
{ type: "email", message: "Please enter a valid email" },
|
||||
],
|
||||
},
|
||||
const CHART = {
|
||||
type: "LineGraph",
|
||||
props: { data: { $state: "/chartData" } },
|
||||
} as const;
|
||||
|
||||
const MESSAGE_INPUT = {
|
||||
type: "Textarea",
|
||||
props: {
|
||||
label: "Message",
|
||||
name: "message",
|
||||
statePath: "/form/message",
|
||||
checks: [{ type: "required", message: "Message is required" }],
|
||||
},
|
||||
const SEP = { type: "Separator", props: {} } as const;
|
||||
|
||||
const PROGRESS_DEALS = {
|
||||
type: "Progress",
|
||||
props: { value: 72, label: "Deals Closed -- 72%" },
|
||||
} as const;
|
||||
|
||||
const SUBMIT_BUTTON = {
|
||||
type: "Button",
|
||||
props: { label: "Send Message", variant: "primary" },
|
||||
on: { press: { action: "formSubmit" } },
|
||||
const PROGRESS_RETENTION = {
|
||||
type: "Progress",
|
||||
props: { value: 91, label: "Retention -- 91%" },
|
||||
} as const;
|
||||
|
||||
const SIMULATION_STAGES: SimulationStage[] = [
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
state: DASH_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
props: { title: "Team Performance", maxWidth: "sm", centered: true },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
@@ -86,72 +83,74 @@ const SIMULATION_STAGES: SimulationStage[] = [
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
state: DASH_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name"],
|
||||
props: { title: "Team Performance", maxWidth: "sm", centered: true },
|
||||
children: ["m1"],
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
m1: METRIC_REVENUE,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/name","value":{"type":"Input","props":{"label":"Name","name":"name","statePath":"/form/name","checks":[{"type":"required","message":"Name is required"}]}}}',
|
||||
'{"op":"add","path":"/elements/m1","value":{"type":"Metric","props":{"label":"Weekly Revenue","value":"12,400","prefix":"$","change":"+18%","changeType":"positive"}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
state: DASH_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email"],
|
||||
props: { title: "Team Performance", maxWidth: "sm", centered: true },
|
||||
children: ["m1", "chart"],
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
email: EMAIL_INPUT,
|
||||
m1: METRIC_REVENUE,
|
||||
chart: CHART,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email","type":"email","statePath":"/form/email","checks":[{"type":"required","message":"Email is required"},{"type":"email","message":"Please enter a valid email"}]}}}',
|
||||
'{"op":"add","path":"/elements/chart","value":{"type":"LineGraph","props":{"data":{"$state":"/chartData"}}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
state: DASH_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email", "message"],
|
||||
props: { title: "Team Performance", maxWidth: "sm", centered: true },
|
||||
children: ["m1", "chart", "sep", "p1"],
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
email: EMAIL_INPUT,
|
||||
message: MESSAGE_INPUT,
|
||||
m1: METRIC_REVENUE,
|
||||
chart: CHART,
|
||||
sep: SEP,
|
||||
p1: PROGRESS_DEALS,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message","statePath":"/form/message","checks":[{"type":"required","message":"Message is required"}]}}}',
|
||||
'{"op":"add","path":"/elements/p1","value":{"type":"Progress","props":{"value":72,"label":"Deals Closed -- 72%"}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
state: DASH_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email", "message", "submit"],
|
||||
props: { title: "Team Performance", maxWidth: "sm", centered: true },
|
||||
children: ["m1", "chart", "sep", "p1", "p2"],
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
email: EMAIL_INPUT,
|
||||
message: MESSAGE_INPUT,
|
||||
submit: SUBMIT_BUTTON,
|
||||
m1: METRIC_REVENUE,
|
||||
chart: CHART,
|
||||
sep: SEP,
|
||||
p1: PROGRESS_DEALS,
|
||||
p2: PROGRESS_RETENTION,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"},"on":{"press":{"action":"formSubmit"}}}}',
|
||||
'{"op":"add","path":"/elements/p2","value":{"type":"Progress","props":{"value":91,"label":"Retention -- 91%"}}}',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -211,10 +210,10 @@ function specToNested(spec: Spec): Record<string, unknown> {
|
||||
}
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
"Create a login form with email and password",
|
||||
"Build a feedback form with rating stars",
|
||||
"Design a contact card with avatar",
|
||||
"Make a settings panel with toggles",
|
||||
"Recipe card with rating and ingredients",
|
||||
"Order receipt with item list and total",
|
||||
"Team member profile card",
|
||||
"Notification inbox with alerts",
|
||||
];
|
||||
|
||||
export function Demo({
|
||||
@@ -1117,7 +1116,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
|
||||
className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[36rem]"}`}
|
||||
>
|
||||
{activeTab !== "catalog" && (
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
@@ -1357,7 +1356,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
|
||||
className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[36rem]"}`}
|
||||
>
|
||||
{renderView === "static" && (
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
|
||||
@@ -165,7 +165,8 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
.enum(["body", "caption", "muted", "lead", "code"])
|
||||
.nullable(),
|
||||
}),
|
||||
description: "Paragraph text",
|
||||
description:
|
||||
'Paragraph text. In repeat scopes, use { "$template": "${field1} ${field2}" } to interpolate item fields.',
|
||||
example: { text: "Hello, world!" },
|
||||
},
|
||||
|
||||
@@ -178,6 +179,18 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
description: "Placeholder image (displays alt text in a styled box)",
|
||||
},
|
||||
|
||||
Icon: {
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
size: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
color: z
|
||||
.enum(["default", "muted", "primary", "success", "warning", "danger"])
|
||||
.nullable(),
|
||||
}),
|
||||
description:
|
||||
"Lucide icon by name. PascalCase: MapPin, Mail, Globe, Calendar, Star, Heart, Check, X, ArrowRight, Phone, Building, Clock, Shield, Zap, Users, Eye, Download, Upload, Search, Filter, Settings, Bell, ChevronRight, ExternalLink, Info, AlertTriangle, CheckCircle, XCircle. Use in horizontal Stacks with Text for icon+label patterns. Never use emoji — always use Icon.",
|
||||
},
|
||||
|
||||
Avatar: {
|
||||
props: z.object({
|
||||
src: z.string().nullable(),
|
||||
@@ -259,8 +272,32 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
value: z.number(),
|
||||
max: z.number().nullable(),
|
||||
label: z.string().nullable(),
|
||||
interactive: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Star rating display",
|
||||
events: ["change"],
|
||||
description:
|
||||
"Interactive star rating. Use { $bindState } on value for binding. Set interactive: false for read-only display.",
|
||||
example: { value: 4, max: 5, label: "Rating" },
|
||||
},
|
||||
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
change: z.string().nullable(),
|
||||
changeType: z.enum(["positive", "negative", "neutral"]).nullable(),
|
||||
prefix: z.string().nullable(),
|
||||
suffix: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Key metric / stat display. Shows a large value with label and optional change indicator. Use for dashboard KPIs.",
|
||||
example: {
|
||||
label: "Total Revenue",
|
||||
value: "125,000",
|
||||
prefix: "$",
|
||||
change: "+12.5%",
|
||||
changeType: "positive",
|
||||
},
|
||||
},
|
||||
|
||||
// ── Charts ──────────────────────────────────────────────────────────
|
||||
@@ -411,11 +448,14 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z.enum(["primary", "secondary", "danger"]).nullable(),
|
||||
variant: z
|
||||
.enum(["primary", "secondary", "outline", "danger"])
|
||||
.nullable(),
|
||||
disabled: z.boolean().nullable(),
|
||||
}),
|
||||
events: ["press"],
|
||||
description: "Clickable button. Bind on.press for handler.",
|
||||
description:
|
||||
"Clickable button. primary = solid fill, outline = bordered/transparent, secondary = muted fill. Bind on.press for handler.",
|
||||
example: { label: "Submit", variant: "primary" },
|
||||
},
|
||||
|
||||
|
||||
+355
-124
@@ -105,6 +105,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { icons as lucideIcons } from "lucide-react";
|
||||
|
||||
// =============================================================================
|
||||
// Registry — components + actions, types inferred from catalog
|
||||
@@ -127,23 +128,25 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border border-border rounded-lg p-4 bg-card text-card-foreground overflow-hidden ${maxWidthClass} ${centeredClass}`}
|
||||
className={`border border-border rounded-xl p-5 bg-card text-card-foreground shadow-sm overflow-hidden h-full flex flex-col ${maxWidthClass} ${centeredClass}`}
|
||||
>
|
||||
{(props.title || props.description) && (
|
||||
<div className="mb-4">
|
||||
{props.title && (
|
||||
<h3 className="font-semibold text-lg text-left">
|
||||
<h3 className="font-semibold text-lg tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
)}
|
||||
{props.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 text-left">
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">{children}</div>
|
||||
<div className="flex-1 flex flex-col gap-4 [&>:last-child]:mt-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -160,14 +163,31 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
: props.gap === "none"
|
||||
? "gap-0"
|
||||
: "gap-3";
|
||||
const alignClass =
|
||||
props.align === "center"
|
||||
? "items-center"
|
||||
: props.align === "end"
|
||||
? "items-end"
|
||||
: props.align === "stretch"
|
||||
? "items-stretch"
|
||||
: "items-start";
|
||||
|
||||
let alignClass: string;
|
||||
if (isHorizontal) {
|
||||
alignClass =
|
||||
props.align === "center"
|
||||
? "items-center"
|
||||
: props.align === "end"
|
||||
? "items-end"
|
||||
: props.align === "stretch"
|
||||
? "items-stretch"
|
||||
: "items-start";
|
||||
} else {
|
||||
// Vertical: items-center/end lets inline elements (Avatar, Badge, Button)
|
||||
// center/align naturally. Block containers (Grid, Accordion, Table) add
|
||||
// their own w-full to stretch regardless.
|
||||
alignClass =
|
||||
props.align === "center"
|
||||
? "items-center"
|
||||
: props.align === "end"
|
||||
? "items-end"
|
||||
: props.align === "start"
|
||||
? "items-start"
|
||||
: "items-stretch";
|
||||
}
|
||||
|
||||
const justifyClass =
|
||||
props.justify === "center"
|
||||
? "justify-center"
|
||||
@@ -181,7 +201,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col"} ${gapClass} ${alignClass} ${justifyClass}`}
|
||||
className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col w-full"} ${gapClass} ${alignClass} ${justifyClass}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -189,7 +209,12 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
},
|
||||
|
||||
Grid: ({ props, children }) => {
|
||||
const n = props.columns ?? 1;
|
||||
const childCount = Array.isArray(children)
|
||||
? children.length
|
||||
: children
|
||||
? 1
|
||||
: 0;
|
||||
const n = Math.min(props.columns ?? 1, childCount || 1);
|
||||
const cols =
|
||||
n >= 6
|
||||
? "grid-cols-6"
|
||||
@@ -205,13 +230,15 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const gridGap =
|
||||
props.gap === "lg" ? "gap-4" : props.gap === "sm" ? "gap-2" : "gap-3";
|
||||
|
||||
return <div className={`grid ${cols} ${gridGap}`}>{children}</div>;
|
||||
return <div className={`grid w-full ${cols} ${gridGap}`}>{children}</div>;
|
||||
},
|
||||
|
||||
Separator: ({ props }) => (
|
||||
<Separator
|
||||
orientation={props.orientation ?? "horizontal"}
|
||||
className={props.orientation === "vertical" ? "h-full mx-2" : "my-3"}
|
||||
className={
|
||||
props.orientation === "vertical" ? "h-full mx-3" : "my-4 opacity-50"
|
||||
}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -383,7 +410,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="w-full rounded-md border border-border overflow-hidden">
|
||||
<TablePrimitive>
|
||||
{props.caption && <TableCaption>{props.caption}</TableCaption>}
|
||||
<TableHeader>
|
||||
@@ -411,20 +438,17 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const level = props.level ?? "h2";
|
||||
const headingClass =
|
||||
level === "h1"
|
||||
? "text-2xl font-bold"
|
||||
? "text-2xl font-bold tracking-tight"
|
||||
: level === "h3"
|
||||
? "text-base font-semibold"
|
||||
? "text-base font-semibold tracking-tight"
|
||||
: level === "h4"
|
||||
? "text-sm font-semibold"
|
||||
: "text-lg font-semibold";
|
||||
? "text-sm font-medium uppercase tracking-wider text-muted-foreground"
|
||||
: "text-xl font-semibold tracking-tight";
|
||||
|
||||
if (level === "h1")
|
||||
return <h1 className={`${headingClass} text-left`}>{props.text}</h1>;
|
||||
if (level === "h3")
|
||||
return <h3 className={`${headingClass} text-left`}>{props.text}</h3>;
|
||||
if (level === "h4")
|
||||
return <h4 className={`${headingClass} text-left`}>{props.text}</h4>;
|
||||
return <h2 className={`${headingClass} text-left`}>{props.text}</h2>;
|
||||
if (level === "h1") return <h1 className={headingClass}>{props.text}</h1>;
|
||||
if (level === "h3") return <h3 className={headingClass}>{props.text}</h3>;
|
||||
if (level === "h4") return <h4 className={headingClass}>{props.text}</h4>;
|
||||
return <h2 className={headingClass}>{props.text}</h2>;
|
||||
},
|
||||
|
||||
Text: ({ props }) => {
|
||||
@@ -440,20 +464,59 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
: "text-sm";
|
||||
|
||||
if (props.variant === "code") {
|
||||
return <code className={`${textClass} text-left`}>{props.text}</code>;
|
||||
return <code className={textClass}>{props.text}</code>;
|
||||
}
|
||||
return <p className={`${textClass} text-left`}>{props.text}</p>;
|
||||
return <p className={textClass}>{props.text}</p>;
|
||||
},
|
||||
|
||||
Image: ({ props }) => (
|
||||
<div
|
||||
className="bg-muted border border-border rounded flex items-center justify-center text-xs text-muted-foreground aspect-video"
|
||||
style={{ width: props.width ?? 80, height: props.height ?? 60 }}
|
||||
className="w-full bg-muted/50 border border-dashed border-border rounded-lg flex flex-col items-center justify-center gap-2 text-muted-foreground/60 px-4"
|
||||
style={{
|
||||
maxWidth: props.width ?? undefined,
|
||||
height: props.height ?? 120,
|
||||
minHeight: 80,
|
||||
}}
|
||||
>
|
||||
{props.alt || "img"}
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="opacity-50"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
{props.alt && <span className="text-xs">{props.alt}</span>}
|
||||
</div>
|
||||
),
|
||||
|
||||
Icon: ({ props }) => {
|
||||
const IconComponent = lucideIcons[props.name as keyof typeof lucideIcons];
|
||||
if (!IconComponent) return null;
|
||||
const sizeMap = { sm: 16, md: 20, lg: 24 } as const;
|
||||
const px = sizeMap[props.size ?? "md"] ?? 20;
|
||||
const colorClass =
|
||||
props.color === "muted"
|
||||
? "text-muted-foreground"
|
||||
: props.color === "primary"
|
||||
? "text-primary"
|
||||
: props.color === "success"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: props.color === "warning"
|
||||
? "text-yellow-600 dark:text-yellow-400"
|
||||
: props.color === "danger"
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "";
|
||||
return <IconComponent size={px} className={`shrink-0 ${colorClass}`} />;
|
||||
},
|
||||
|
||||
Avatar: ({ props }) => {
|
||||
const name = props.name || "?";
|
||||
const initials = name
|
||||
@@ -462,16 +525,16 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
const avatarSize =
|
||||
const sizeStyles =
|
||||
props.size === "lg"
|
||||
? "w-12 h-12 text-base"
|
||||
? { outer: "w-[72px] h-[72px]", text: "text-xl", ring: "ring-[3px]" }
|
||||
: props.size === "sm"
|
||||
? "w-8 h-8 text-xs"
|
||||
: "w-10 h-10 text-sm";
|
||||
? { outer: "w-8 h-8", text: "text-xs", ring: "ring-2" }
|
||||
: { outer: "w-10 h-10", text: "text-sm", ring: "ring-2" };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium`}
|
||||
className={`${sizeStyles.outer} ${sizeStyles.text} rounded-full bg-gradient-to-br from-muted-foreground/20 to-muted flex items-center justify-center font-semibold tracking-wide ${sizeStyles.ring} ring-background shadow-sm`}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
@@ -491,9 +554,20 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
: props.variant === "warning"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100"
|
||||
: "";
|
||||
const dotColor =
|
||||
props.variant === "success"
|
||||
? "bg-green-500"
|
||||
: props.variant === "warning"
|
||||
? "bg-yellow-500"
|
||||
: props.variant === "danger"
|
||||
? "bg-red-500"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className={customClass}>
|
||||
<Badge variant={variant} className={`${customClass} gap-1.5`}>
|
||||
{dotColor && (
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${dotColor} shrink-0`} />
|
||||
)}
|
||||
{props.text}
|
||||
</Badge>
|
||||
);
|
||||
@@ -510,8 +584,47 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
? "border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100"
|
||||
: "";
|
||||
|
||||
const iconProps = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
className: "shrink-0",
|
||||
};
|
||||
|
||||
const icon =
|
||||
props.type === "success" ? (
|
||||
<svg {...iconProps}>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
) : props.type === "warning" ? (
|
||||
<svg {...iconProps}>
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
) : props.type === "error" ? (
|
||||
<svg {...iconProps}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg {...iconProps}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert variant={variant} className={customClass}>
|
||||
{icon}
|
||||
<AlertTitle>{props.title}</AlertTitle>
|
||||
{props.message && (
|
||||
<AlertDescription>{props.message}</AlertDescription>
|
||||
@@ -523,7 +636,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
Progress: ({ props }) => {
|
||||
const value = Math.min(100, Math.max(0, props.value || 0));
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
{props.label && (
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{props.label}
|
||||
@@ -607,9 +720,19 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
</PopoverPrimitive>
|
||||
),
|
||||
|
||||
Rating: ({ props }) => {
|
||||
const ratingValue = props.value || 0;
|
||||
Rating: ({ props, bindings, emit }) => {
|
||||
const [boundValue, setBoundValue] = useBoundProp<number>(
|
||||
props.value as number | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(props.value || 0);
|
||||
const isBound = !!bindings?.value;
|
||||
const ratingValue = isBound ? (boundValue ?? 0) : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
const maxRating = props.max ?? 5;
|
||||
const interactive = props.interactive !== false;
|
||||
const [hoverIndex, setHoverIndex] = useState(-1);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{props.label && (
|
||||
@@ -617,15 +740,78 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
{props.label}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: maxRating }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-lg ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}
|
||||
>
|
||||
*
|
||||
<div
|
||||
className="flex gap-0.5"
|
||||
onMouseLeave={() => interactive && setHoverIndex(-1)}
|
||||
>
|
||||
{Array.from({ length: maxRating }).map((_, i) => {
|
||||
const filled =
|
||||
hoverIndex >= 0 ? i <= hoverIndex : i < ratingValue;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`p-0.5 transition-colors ${interactive ? "cursor-pointer hover:scale-110" : "cursor-default"}`}
|
||||
onMouseEnter={() => interactive && setHoverIndex(i)}
|
||||
onClick={() => {
|
||||
if (!interactive) return;
|
||||
const newVal = i + 1 === ratingValue ? 0 : i + 1;
|
||||
setValue(newVal);
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill={filled ? "currentColor" : "none"}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={
|
||||
filled ? "text-yellow-400" : "text-muted-foreground/40"
|
||||
}
|
||||
>
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const changeColor =
|
||||
props.changeType === "positive"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: props.changeType === "negative"
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-muted-foreground";
|
||||
const changeIcon =
|
||||
props.changeType === "positive"
|
||||
? "\u2191"
|
||||
: props.changeType === "negative"
|
||||
? "\u2193"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">{props.label}</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||
{props.prefix}
|
||||
{props.value}
|
||||
{props.suffix}
|
||||
</span>
|
||||
{props.change && (
|
||||
<span className={`text-sm font-medium ${changeColor}`}>
|
||||
{changeIcon}
|
||||
{props.change}
|
||||
</span>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -636,26 +822,39 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
BarGraph: ({ props }) => {
|
||||
const data = props.data || [];
|
||||
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
||||
const barColors = [
|
||||
"bg-primary",
|
||||
"bg-primary/80",
|
||||
"bg-primary/60",
|
||||
"bg-primary/70",
|
||||
"bg-primary/90",
|
||||
"bg-primary/50",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{props.title && (
|
||||
<div className="text-sm font-medium text-left">{props.title}</div>
|
||||
<div className="text-sm font-medium">{props.title}</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-end gap-2" style={{ height: 160 }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="text-xs text-muted-foreground">{d.value}</div>
|
||||
<div className="w-full h-24 flex items-end">
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 flex flex-col items-center gap-1.5 h-full justify-end group"
|
||||
>
|
||||
<div className="text-[11px] font-medium text-muted-foreground tabular-nums">
|
||||
{d.value}
|
||||
</div>
|
||||
<div className="w-full flex-1 flex items-end">
|
||||
<div
|
||||
className="w-full bg-primary rounded-t transition-all"
|
||||
className={`w-full ${barColors[i % barColors.length]} rounded-t-md transition-all group-hover:opacity-80`}
|
||||
style={{
|
||||
height: `${(d.value / maxValue) * 100}%`,
|
||||
minHeight: 2,
|
||||
minHeight: 4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate w-full text-center">
|
||||
<div className="text-[11px] text-muted-foreground truncate w-full text-center">
|
||||
{d.label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -672,8 +871,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const range = maxValue - minValue || 1;
|
||||
|
||||
const width = 300;
|
||||
const height = 100;
|
||||
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
|
||||
const height = 140;
|
||||
const padding = { top: 12, right: 12, bottom: 12, left: 12 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
@@ -690,77 +889,107 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
return { x, y, ...d };
|
||||
});
|
||||
|
||||
const pathD =
|
||||
points.length > 0
|
||||
? `M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}`
|
||||
: "";
|
||||
// Build smooth cubic bezier curve through points
|
||||
let smoothPath = "";
|
||||
let areaPath = "";
|
||||
if (points.length > 1) {
|
||||
const first = points[0]!;
|
||||
const last = points[points.length - 1]!;
|
||||
smoothPath = `M ${first.x} ${first.y}`;
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const curr = points[i]!;
|
||||
const next = points[i + 1]!;
|
||||
const cpx = (curr.x + next.x) / 2;
|
||||
smoothPath += ` C ${cpx} ${curr.y}, ${cpx} ${next.y}, ${next.x} ${next.y}`;
|
||||
}
|
||||
const bottomY = height - padding.bottom;
|
||||
areaPath = `${smoothPath} L ${last.x} ${bottomY} L ${first.x} ${bottomY} Z`;
|
||||
} else if (points.length === 1) {
|
||||
const only = points[0]!;
|
||||
smoothPath = `M ${only.x} ${only.y}`;
|
||||
}
|
||||
|
||||
const gradientId = `line-gradient-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{props.title && (
|
||||
<div className="text-sm font-medium text-left">{props.title}</div>
|
||||
<div className="text-sm font-medium">{props.title}</div>
|
||||
)}
|
||||
<div className="relative h-28">
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full">
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={padding.top + chartHeight / 2}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top + chartHeight / 2}
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={padding.top}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top}
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={height - padding.bottom}
|
||||
x2={width - padding.right}
|
||||
y2={height - padding.bottom}
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
{pathD && (
|
||||
<div className="relative" style={{ height: 160 }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full h-full"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="currentColor"
|
||||
stopOpacity="0.15"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="currentColor"
|
||||
stopOpacity="0"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => (
|
||||
<line
|
||||
key={frac}
|
||||
x1={padding.left}
|
||||
y1={padding.top + chartHeight * frac}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top + chartHeight * frac}
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.07"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
{areaPath && (
|
||||
<path
|
||||
d={pathD}
|
||||
d={areaPath}
|
||||
fill={`url(#${gradientId})`}
|
||||
className="text-primary"
|
||||
/>
|
||||
)}
|
||||
{smoothPath && (
|
||||
<path
|
||||
d={smoothPath}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
className="text-primary"
|
||||
/>
|
||||
)}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="4"
|
||||
className="fill-primary"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{points.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-[7px] h-[7px] rounded-full bg-primary -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
left: `${(p.x / width) * 100}%`,
|
||||
top: `${(p.y / height) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{data.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
{data.map((d, i) => (
|
||||
<div
|
||||
{points.length > 0 && (
|
||||
<div className="relative h-4">
|
||||
{points.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-xs text-muted-foreground text-center"
|
||||
style={{ width: `${100 / data.length}%` }}
|
||||
className="absolute text-[11px] text-muted-foreground -translate-x-1/2"
|
||||
style={{ left: `${(p.x / width) * 100}%` }}
|
||||
>
|
||||
{d.label}
|
||||
</div>
|
||||
{data[i]?.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -787,7 +1016,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
{props.label && <Label htmlFor={props.name}>{props.label}</Label>}
|
||||
<Input
|
||||
id={props.name}
|
||||
@@ -829,7 +1058,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
{props.label && <Label htmlFor={props.name}>{props.label}</Label>}
|
||||
<Textarea
|
||||
id={props.name}
|
||||
@@ -872,7 +1101,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
<Label>{props.label}</Label>
|
||||
<Select
|
||||
value={value}
|
||||
@@ -945,7 +1174,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
{props.label && <Label>{props.label}</Label>}
|
||||
<RadioGroup
|
||||
value={value}
|
||||
@@ -987,7 +1216,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const setChecked = isBound ? setBoundChecked : setLocalChecked;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="w-full flex items-center justify-between space-x-2">
|
||||
<Label htmlFor={props.name} className="cursor-pointer">
|
||||
{props.label}
|
||||
</Label>
|
||||
@@ -1014,7 +1243,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
{props.label && (
|
||||
<div className="flex justify-between">
|
||||
<Label className="text-sm">{props.label}</Label>
|
||||
@@ -1041,9 +1270,11 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const variant =
|
||||
props.variant === "danger"
|
||||
? "destructive"
|
||||
: props.variant === "secondary"
|
||||
? "secondary"
|
||||
: "default";
|
||||
: props.variant === "outline"
|
||||
? "outline"
|
||||
: props.variant === "secondary"
|
||||
? "secondary"
|
||||
: "default";
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
_resetWarnedComputedFns,
|
||||
_resetWarnedTemplatePaths,
|
||||
} from "./props";
|
||||
import type { PropResolutionContext } from "./props";
|
||||
|
||||
@@ -635,30 +634,32 @@ describe("$template expressions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when path does not start with /", () => {
|
||||
_resetWarnedTemplatePaths();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
it("resolves bare names against state model when not in repeat", () => {
|
||||
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
|
||||
const result = resolvePropValue({ $template: "Hi ${name}!" }, ctx);
|
||||
expect(result).toBe("Hi Bob!");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('$template path "name"'),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
_resetWarnedTemplatePaths();
|
||||
});
|
||||
|
||||
it("deduplicates warnings for the same $template path", () => {
|
||||
_resetWarnedTemplatePaths();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
|
||||
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
|
||||
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
|
||||
const calls = warnSpy.mock.calls.filter((c) =>
|
||||
String(c[0]).includes('$template path "name"'),
|
||||
it("resolves bare names against repeat item first", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { name: "Global" },
|
||||
repeatItem: { name: "Alice", action: "signed up" },
|
||||
repeatIndex: 0,
|
||||
};
|
||||
const result = resolvePropValue({ $template: "${name} ${action}" }, ctx);
|
||||
expect(result).toBe("Alice signed up");
|
||||
});
|
||||
|
||||
it("falls back to state model for bare names not in repeat item", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { fallback: "ok" },
|
||||
repeatItem: { name: "Alice" },
|
||||
repeatIndex: 0,
|
||||
};
|
||||
const result = resolvePropValue(
|
||||
{ $template: "${name} - ${fallback}" },
|
||||
ctx,
|
||||
);
|
||||
expect(calls).toHaveLength(1);
|
||||
warnSpy.mockRestore();
|
||||
_resetWarnedTemplatePaths();
|
||||
expect(result).toBe("Alice - ok");
|
||||
});
|
||||
});
|
||||
|
||||
+18
-24
@@ -24,8 +24,10 @@ import { evaluateVisibility, type VisibilityContext } from "./visibility";
|
||||
* - `{ $cond, $then, $else }` conditionally picks a value
|
||||
* - `{ $computed: string, args?: Record<string, PropExpression> }` calls a
|
||||
* registered function with resolved args and returns the result
|
||||
* - `{ $template: string }` interpolates `${/path}` references in the
|
||||
* string with values from the state model
|
||||
* - `{ $template: string }` interpolates `${/path}` and `${field}`
|
||||
* references. Absolute paths (`${/path}`) resolve against the state
|
||||
* model. Bare names (`${field}`) resolve against the current repeat
|
||||
* item first, then fall back to the state model at `/<field>`.
|
||||
* - Any other value is a literal (passthrough)
|
||||
*/
|
||||
export type PropExpression<T = unknown> =
|
||||
@@ -156,15 +158,6 @@ export function _resetWarnedComputedFns(): void {
|
||||
warnedComputedFns.clear();
|
||||
}
|
||||
|
||||
// Same deduplication pattern for $template paths that don't start with "/".
|
||||
const WARNED_TEMPLATE_MAX = 100;
|
||||
const warnedTemplatePaths = new Set<string>();
|
||||
|
||||
/** @internal Test-only: clear the deduplication set for $template warnings. */
|
||||
export function _resetWarnedTemplatePaths(): void {
|
||||
warnedTemplatePaths.clear();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Prop Expression Resolution
|
||||
// =============================================================================
|
||||
@@ -269,24 +262,25 @@ export function resolvePropValue(
|
||||
return fn(resolvedArgs);
|
||||
}
|
||||
|
||||
// $template: interpolate ${/path} references with state values
|
||||
// $template: interpolate ${/path} and ${field} references.
|
||||
// Absolute paths (starting with "/") resolve against the state model.
|
||||
// Bare names resolve against the current repeat item first, then fall
|
||||
// back to the state model at "/<name>".
|
||||
if (isTemplateExpression(value)) {
|
||||
return value.$template.replace(
|
||||
/\$\{([^}]+)\}/g,
|
||||
(_match, rawPath: string) => {
|
||||
let path = rawPath;
|
||||
if (!path.startsWith("/")) {
|
||||
if (!warnedTemplatePaths.has(path)) {
|
||||
if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) {
|
||||
warnedTemplatePaths.add(path);
|
||||
}
|
||||
console.warn(
|
||||
`$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`,
|
||||
);
|
||||
}
|
||||
path = "/" + path;
|
||||
if (rawPath.startsWith("/")) {
|
||||
const resolved = getByPath(ctx.stateModel, rawPath);
|
||||
return resolved != null ? String(resolved) : "";
|
||||
}
|
||||
const resolved = getByPath(ctx.stateModel, path);
|
||||
// Bare name: try repeat item first
|
||||
if (ctx.repeatItem !== undefined) {
|
||||
const fromItem = getByPath(ctx.repeatItem, rawPath);
|
||||
if (fromItem != null) return String(fromItem);
|
||||
}
|
||||
// Fall back to state model
|
||||
const resolved = getByPath(ctx.stateModel, "/" + rawPath);
|
||||
return resolved != null ? String(resolved) : "";
|
||||
},
|
||||
);
|
||||
|
||||
@@ -943,10 +943,10 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
'4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.',
|
||||
'4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates references in the string. Absolute paths like `${/path}` resolve against the state model. Bare names like `${field}` resolve against the current repeat item first, then fall back to the state model at `/<field>`.',
|
||||
);
|
||||
lines.push(
|
||||
' Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00.',
|
||||
' Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00. Inside a repeat, `{ "$template": "${name} - ${email}" }` reads name and email from each item.',
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user