Compare commits

..
Author SHA1 Message Date
Simon H 512f7fe5c5 feat: Svelte-renderer (#168)
* create svelte-renderer

* helpers

* reactive API

* turn into provider components

* fixes

* examples

* docs

* correct component props for catalog, harmonize defineRegistry API with other APis

* shuffle things around

* createRenderer

* on

* boundary/warning

* functions

* fix

* fix types
2026-03-06 17:31:06 -06:00
199 changed files with 10915 additions and 467 deletions
+1
View File
@@ -27,6 +27,7 @@ out/
build
dist
*.tsbuildinfo
.svelte-kit/
# Debug
+2 -1
View File
@@ -462,8 +462,9 @@ pnpm dev
- http://react-email-demo.json-render.localhost:1355 - React Email Example
- http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
- Chat Example: run `pnpm dev` in `examples/chat`
- Svelte Example: run `pnpm dev` in `examples/svelte` or `examples/svelte-chat`
- Vue Example: run `pnpm dev` in `examples/vue`
- Vite Renderers (React + Vue): run `pnpm dev` in `examples/vite-renderers`
- Vite Renderers (React + Vue + Svelte): run `pnpm dev` in `examples/vite-renderers`
- React Native example: run `npx expo start` in `examples/react-native`
## How It Works
@@ -0,0 +1,126 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/svelte")
# @json-render/svelte
Svelte 5 components, providers, and helpers for rendering json-render specs.
## Installation
<PackageInstall packages="@json-render/core @json-render/svelte" />
Peer dependencies: `svelte ^5.0.0` and `zod ^4.0.0`.
<PackageInstall packages="svelte zod" />
## Components
### Renderer
```svelte
<Renderer
spec={spec} // Spec | null
registry={registry}
loading={false}
/>
```
Renders a spec with your component registry. If `spec` is `null`, it renders nothing.
### JsonUIProvider
Convenience wrapper around `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`.
```svelte
<JsonUIProvider
initialState={{}}
handlers={handlers}
validationFunctions={validationFunctions}
>
<Renderer {spec} {registry} />
</JsonUIProvider>
```
## defineRegistry
Create a typed component registry and action handlers from a catalog.
```typescript
import { defineRegistry } from "@json-render/svelte";
const { registry, handlers, executeAction } = defineRegistry(catalog, {
components: {
Card,
Button,
},
actions: {
submit: async (params, setState, state) => {
// custom action logic
},
},
});
```
`handlers` is designed for `JsonUIProvider`/`ActionProvider`. `executeAction` is an imperative helper.
## Component Props
Registry components receive `BaseComponentProps<TProps>`:
```typescript
interface BaseComponentProps<TProps> {
props: TProps;
children?: Snippet;
emit: (event: string) => void;
bindings?: Record<string, string>;
loading?: boolean;
}
```
Use `emit("eventName")` to trigger handlers declared in the spec `on` bindings.
## Context Helpers
Use these helpers inside Svelte components:
- `getStateValue(path)` - read/write state via `.current`
- `getBoundProp(() => value, () => bindingPath)` - write back resolved `$bindState` / `$bindItem` values
- `isVisible(condition)` - evaluate visibility via `.current`
- `getAction(name)` - read a registered action handler via `.current`
- `getFieldValidation(ctx, path, config)` - get field validation state + actions
For advanced usage, access full contexts:
- `getStateContext()`
- `getActionContext()`
- `getVisibilityContext()`
- `getValidationContext()`
- `getOptionalValidationContext()`
## Streaming
### createUIStream
```typescript
const stream = createUIStream({
api: "/api/generate-ui",
onComplete: (spec) => console.log(spec),
});
await stream.send("Create a login form");
console.log(stream.spec);
console.log(stream.isStreaming);
```
### createChatUI
```typescript
const chat = createChatUI({ api: "/api/chat-ui" });
await chat.send("Build a settings panel");
console.log(chat.messages, chat.isStreaming);
```
## Schema Export
Use `schema` from `@json-render/svelte` when defining catalogs for Svelte specs.
@@ -21,6 +21,14 @@ Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
<PackageInstall packages="vue zod" />
## For Svelte
<PackageInstall packages="@json-render/core @json-render/svelte" />
Peer dependencies: `svelte ^5.0.0` and `zod ^4.0.0`.
<PackageInstall packages="svelte zod" />
## For React UI with shadcn/ui
Pre-built components for fast prototyping and production use:
+7 -1
View File
@@ -80,13 +80,18 @@ export const docsNavigation: NavSection[] = [
href: "https://github.com/vercel-labs/json-render/tree/main/examples/image",
external: true,
},
{
title: "Svelte",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/svelte",
external: true,
},
{
title: "Vue",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
external: true,
},
{
title: "Renders with Vite (Vue / React)",
title: "Renders with Vite (Vue / React / Svelte)",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
external: true,
},
@@ -121,6 +126,7 @@ export const docsNavigation: NavSection[] = [
{ title: "@json-render/image", href: "/docs/api/image" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/vue", href: "/docs/api/vue" },
{ title: "@json-render/svelte", href: "/docs/api/svelte" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
],
},
+1
View File
@@ -45,6 +45,7 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/react-pdf": "@json-render/react-pdf API",
"docs/api/react-email": "@json-render/react-email API",
"docs/api/react-native": "@json-render/react-native API",
"docs/api/svelte": "@json-render/svelte API",
"docs/api/codegen": "@json-render/codegen API",
"docs/api/image": "@json-render/image API",
"docs/api/remotion": "@json-render/remotion API",
+1 -1
View File
@@ -25,7 +25,7 @@
"react-resizable-panels": "^4.4.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
"zod": "4.3.5"
"zod": "4.3.6"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv create --template minimal --types ts --no-install svelte-chat
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "svelte-chat",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check-types": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.0.0",
"bits-ui": "^2.14.4",
"svelte": "^5.49.2",
"svelte-check": "^4.3.6",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@ai-sdk/gateway": "^3.0.46",
"@ai-sdk/svelte": "^4.0.96",
"@json-render/core": "workspace:*",
"@json-render/svelte": "workspace:*",
"ai": "^6.0.86",
"clsx": "^2.1.1",
"lucide-svelte": "^0.500.0",
"tailwind-merge": "^3.2.0",
"zod": "4.3.6"
}
}
+125
View File
@@ -0,0 +1,125 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
button {
cursor: pointer;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.animate-shimmer {
background: linear-gradient(
90deg,
currentColor 25%,
hsl(0 0% 64%) 50%,
currentColor 75%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 2s ease-in-out infinite;
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
import { ToolLoopAgent, stepCountIs } from "ai";
import { createGateway } from "@ai-sdk/gateway";
import { explorerCatalog } from "./render/catalog";
import { getWeather } from "./tools/weather";
import { getGitHubRepo, getGitHubPullRequests } from "./tools/github";
import { getCryptoPrice, getCryptoPriceHistory } from "./tools/crypto";
import { getHackerNewsTop } from "./tools/hackernews";
import { webSearch } from "./tools/search";
import { env } from "$env/dynamic/private";
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
const AGENT_INSTRUCTIONS = `You are a knowledgeable assistant that helps users explore data and learn about any topic. You look up real-time information, build visual dashboards, and create rich educational content.
WORKFLOW:
1. Call the appropriate tools to gather relevant data. Use webSearch for general topics not covered by specialized tools.
2. Respond with a brief, conversational summary of what you found.
3. Then output the JSONL UI spec wrapped in a \`\`\`spec fence to render a rich visual experience.
RULES:
- Always call tools FIRST to get real data. Never make up data.
- Embed the fetched data directly in /state paths so components can reference it.
- Use Card components to group related information.
- NEVER nest a Card inside another Card. If you need sub-sections inside a Card, use Stack, Separator, Heading, or Accordion instead.
- Use Grid for multi-column layouts.
- Use Metric for key numeric values (temperature, stars, price, etc.).
- Use Table for lists of items (stories, forecasts, languages, etc.).
- Use BarChart or LineChart for numeric trends and time-series data.
- Use PieChart for compositional/proportional data (market share, breakdowns, distributions).
- Use Tabs when showing multiple categories of data side by side.
- Use Badge for status indicators.
- Use Callout for key facts, tips, warnings, or important takeaways.
- Use Accordion to organize detailed sections the user can expand for deeper reading.
- Use Timeline for historical events, processes, step-by-step explanations, or milestones.
- When teaching about a topic, combine multiple component types to create a rich, engaging experience.
DATA BINDING:
- The state model is the single source of truth. Put fetched data in /state, then reference it with { "$state": "/json/pointer" } in any prop.
- $state works on ANY prop at ANY nesting level. The renderer resolves expressions before components receive props.
- Scalar binding: "title": { "$state": "/quiz/title" }
- Array binding: "items": { "$state": "/quiz/questions" } (for Accordion, Timeline, etc.)
- For Table, BarChart, LineChart, and PieChart, use { "$state": "/path" } on the data prop to bind read-only data from state.
- Always emit /state patches BEFORE the elements that reference them, so data is available when the UI renders.
- Always use the { "$state": "/foo" } object syntax for data binding.
INTERACTIVITY:
- You can use visible, repeat, on.press, and $cond/$then/$else freely.
- visible: Conditionally show/hide elements based on state. e.g. "visible": { "$state": "/q1/answer", "eq": "a" }
- repeat: Iterate over state arrays. e.g. "repeat": { "statePath": "/items" }
- on.press: Trigger actions on button clicks. e.g. "on": { "press": { "action": "setState", "params": { "statePath": "/submitted", "value": true } } }
- $cond/$then/$else: Conditional prop values. e.g. { "$cond": { "$state": "/correct" }, "$then": "Correct!", "$else": "Try again" }
BUILT-IN ACTIONS (use with on.press):
- setState: Set a value at a state path. params: { statePath: "/foo", value: "bar" }
- pushState: Append to an array. params: { statePath: "/items", value: { ... } }
- removeState: Remove by index. params: { statePath: "/items", index: 0 }
INPUT COMPONENTS:
- RadioGroup: Renders radio buttons. Writes selected value to statePath automatically.
- SelectInput: Dropdown select. Writes selected value to statePath automatically.
- TextInput: Text input field. Writes entered value to statePath automatically.
- Button: Clickable button. Use on.press to trigger actions.
${explorerCatalog.prompt({
mode: "chat",
customRules: [
"NEVER use viewport height classes (min-h-screen, h-screen) — the UI renders inside a fixed-size container.",
"Prefer Grid with columns='2' or columns='3' for side-by-side layouts.",
"Use Metric components for key numbers instead of plain Text.",
"Put chart data arrays in /state and reference them with { $state: '/path' } on the data prop.",
"Keep the UI clean and information-dense — no excessive padding or empty space.",
"For educational prompts ('teach me about', 'explain', 'what is'), use a mix of Callout, Accordion, Timeline, and charts to make the content visually rich.",
],
})}`;
export const gateway = createGateway({ apiKey: env.AI_GATEWAY_API_KEY });
export const agent = new ToolLoopAgent({
model: gateway(env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
instructions: AGENT_INSTRUCTIONS,
tools: {
getWeather,
getGitHubRepo,
getGitHubPullRequests,
getCryptoPrice,
getCryptoPriceHistory,
getHackerNewsTop,
webSearch,
},
stopWhen: stepCountIs(5),
temperature: 0.7,
});
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,22 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
</script>
<AccordionPrimitive.Content
bind:ref
data-slot="accordion-content"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...restProps}
>
<div class={cn("pt-0 pb-4", className)}>
{@render children?.()}
</div>
</AccordionPrimitive.Content>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AccordionPrimitive.ItemProps = $props();
</script>
<AccordionPrimitive.Item
bind:ref
data-slot="accordion-item"
class={cn("border-b last:border-b-0", className)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
level = 3,
children,
...restProps
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
level?: AccordionPrimitive.HeaderProps["level"];
} = $props();
</script>
<AccordionPrimitive.Header {level} class="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
bind:ref
class={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
@@ -0,0 +1,16 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: AccordionPrimitive.RootProps = $props();
</script>
<AccordionPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="accordion"
{...restProps}
/>
@@ -0,0 +1,16 @@
import Root from "./accordion.svelte";
import Content from "./accordion-content.svelte";
import Item from "./accordion-item.svelte";
import Trigger from "./accordion-trigger.svelte";
export {
Root,
Content,
Item,
Trigger,
//
Root as Accordion,
Content as AccordionContent,
Item as AccordionItem,
Trigger as AccordionTrigger,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>
@@ -0,0 +1,14 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};
@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>
@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("leading-none font-semibold", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};
@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};
@@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}
@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...restProps}
/>
@@ -0,0 +1,7 @@
import Root from "./progress.svelte";
export {
Root,
//
Root as Progress,
};
@@ -0,0 +1,27 @@
<script lang="ts">
import { Progress as ProgressPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
max = 100,
value,
...restProps
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
</script>
<ProgressPrimitive.Root
bind:ref
data-slot="progress"
class={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
{value}
{max}
{...restProps}
>
<div
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
></div>
</ProgressPrimitive.Root>
@@ -0,0 +1,10 @@
import Root from "./radio-group.svelte";
import Item from "./radio-group-item.svelte";
export {
Root,
Item,
//
Root as RadioGroup,
Item as RadioGroupItem,
};
@@ -0,0 +1,31 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
</script>
<RadioGroupPrimitive.Item
bind:ref
data-slot="radio-group-item"
class={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<div data-slot="radio-group-indicator" class="relative flex items-center justify-center">
{#if checked}
<CircleIcon
class="fill-primary absolute start-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2"
/>
{/if}
</div>
{/snippet}
</RadioGroupPrimitive.Item>
@@ -0,0 +1,19 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: RadioGroupPrimitive.RootProps = $props();
</script>
<RadioGroupPrimitive.Root
bind:ref
bind:value
data-slot="radio-group"
class={cn("grid gap-3", className)}
{...restProps}
/>
@@ -0,0 +1,37 @@
import Root from "./select.svelte";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
import Portal from "./select-portal.svelte";
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
};
@@ -0,0 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />
@@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>
@@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />
@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};
@@ -0,0 +1,21 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:min-h-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>
@@ -0,0 +1,28 @@
import Root from "./table.svelte";
import Body from "./table-body.svelte";
import Caption from "./table-caption.svelte";
import Cell from "./table-cell.svelte";
import Footer from "./table-footer.svelte";
import Head from "./table-head.svelte";
import Header from "./table-header.svelte";
import Row from "./table-row.svelte";
export {
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tbody
bind:this={ref}
data-slot="table-body"
class={cn("[&_tr:last-child]:border-0", className)}
{...restProps}
>
{@render children?.()}
</tbody>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<caption
bind:this={ref}
data-slot="table-caption"
class={cn("text-muted-foreground mt-4 text-sm", className)}
{...restProps}
>
{@render children?.()}
</caption>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLTdAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTdAttributes> = $props();
</script>
<td
bind:this={ref}
data-slot="table-cell"
class={cn(
"bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0",
className
)}
{...restProps}
>
{@render children?.()}
</td>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tfoot
bind:this={ref}
data-slot="table-footer"
class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...restProps}
>
{@render children?.()}
</tfoot>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLThAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLThAttributes> = $props();
</script>
<th
bind:this={ref}
data-slot="table-head"
class={cn(
"text-foreground h-10 bg-clip-padding px-2 text-start align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pe-0",
className
)}
{...restProps}
>
{@render children?.()}
</th>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<thead
bind:this={ref}
data-slot="table-header"
class={cn("[&_tr]:border-b", className)}
{...restProps}
>
{@render children?.()}
</thead>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
</script>
<tr
bind:this={ref}
data-slot="table-row"
class={cn(
"hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...restProps}
>
{@render children?.()}
</tr>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLTableAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTableAttributes> = $props();
</script>
<div data-slot="table-container" class="relative w-full overflow-x-auto">
<table
bind:this={ref}
data-slot="table"
class={cn("w-full caption-bottom text-sm", className)}
{...restProps}
>
{@render children?.()}
</table>
</div>
@@ -0,0 +1,16 @@
import Root from "./tabs.svelte";
import Content from "./tabs-content.svelte";
import List from "./tabs-list.svelte";
import Trigger from "./tabs-trigger.svelte";
export {
Root,
Content,
List,
Trigger,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
};
@@ -0,0 +1,17 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ContentProps = $props();
</script>
<TabsPrimitive.Content
bind:ref
data-slot="tabs-content"
class={cn("flex-1 outline-none", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ListProps = $props();
</script>
<TabsPrimitive.List
bind:ref
data-slot="tabs-list"
class={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.TriggerProps = $props();
</script>
<TabsPrimitive.Trigger
bind:ref
data-slot="tabs-trigger"
class={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
</script>
<TabsPrimitive.Root
bind:ref
bind:value
data-slot="tabs"
class={cn("flex flex-col gap-2", className)}
{...restProps}
/>
+4
View File
@@ -0,0 +1,4 @@
// place files you want to import through the `$lib` alias in this folder.
export { explorerCatalog } from "./render/catalog";
export { registry } from "./render/registry";
export { agent } from "./agent";
@@ -0,0 +1,16 @@
<script lang="ts">
import { Renderer, JsonUIProvider } from "@json-render/svelte";
import type { Spec } from "@json-render/svelte";
import { registry } from "./registry";
interface Props {
spec: Spec;
loading?: boolean;
}
let { spec, loading = false }: Props = $props();
</script>
<JsonUIProvider initialState={spec.state}>
<Renderer {spec} {registry} {loading} />
</JsonUIProvider>
@@ -0,0 +1,363 @@
import { schema } from "@json-render/svelte/schema";
import { z } from "zod";
/**
* json-render + AI SDK Example Catalog (Svelte)
*
* Components for rendering data dashboards generated by the ToolLoopAgent.
* Data flows in through tools (weather, GitHub, crypto, HN), not user actions.
*/
export const explorerCatalog = schema.createCatalog({
components: {
// Layout
Stack: {
props: z.object({
direction: z.enum(["horizontal", "vertical"]).nullable(),
gap: z.enum(["sm", "md", "lg"]).nullable(),
wrap: z.boolean().nullable(),
}),
slots: ["default"],
description: "Flex layout container",
example: { direction: "vertical", gap: "md", wrap: null },
},
Card: {
props: z.object({
title: z.string().nullable(),
description: z.string().nullable(),
}),
slots: ["default"],
description: "Card container with optional title and description",
example: { title: "Weather", description: "Current conditions" },
},
Grid: {
props: z.object({
columns: z.enum(["1", "2", "3", "4"]).nullable(),
gap: z.enum(["sm", "md", "lg"]).nullable(),
}),
slots: ["default"],
description: "Responsive grid layout container",
example: { columns: "3", gap: "md" },
},
// Typography
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
}),
description: "Section heading",
example: { text: "Data Explorer", level: "h1" },
},
Text: {
props: z.object({
content: z.string(),
muted: z.boolean().nullable(),
}),
description: "Text content",
example: { content: "Here is your data overview." },
},
// Data display
Badge: {
props: z.object({
text: z.string(),
variant: z
.enum(["default", "secondary", "destructive", "outline"])
.nullable(),
}),
description: "Status badge",
example: { text: "Live", variant: "default" },
},
Alert: {
props: z.object({
variant: z.enum(["default", "destructive"]).nullable(),
title: z.string(),
description: z.string().nullable(),
}),
description: "Alert or info message",
},
Separator: {
props: z.object({}),
description: "Visual divider",
},
Metric: {
props: z.object({
label: z.string(),
value: z.string(),
detail: z.string().nullable(),
trend: z.enum(["up", "down", "neutral"]).nullable(),
}),
description:
"Single metric display with label, value, and optional trend indicator",
example: {
label: "Temperature",
value: "72F",
detail: "Feels like 68F",
trend: "up",
},
},
Table: {
props: z.object({
data: z.array(z.record(z.string(), z.unknown())),
columns: z.array(
z.object({
key: z.string(),
label: z.string(),
}),
),
emptyMessage: z.string().nullable(),
}),
description:
'Data table. Use { "$state": "/path" } to bind read-only data from state.',
example: {
data: { $state: "/stories" },
columns: [
{ key: "title", label: "Title" },
{ key: "score", label: "Score" },
],
},
},
Link: {
props: z.object({
text: z.string(),
href: z.string(),
}),
description: "External link that opens in a new tab",
example: { text: "View on GitHub", href: "https://github.com" },
},
// Charts
BarChart: {
props: z.object({
title: z.string().nullable(),
data: z.array(z.record(z.string(), z.unknown())),
xKey: z.string(),
yKey: z.string(),
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
color: z.string().nullable(),
height: z.number().nullable(),
}),
description:
'Bar chart visualization. Use { "$state": "/path" } to bind read-only data. xKey is the category field, yKey is the numeric value field.',
},
LineChart: {
props: z.object({
title: z.string().nullable(),
data: z.array(z.record(z.string(), z.unknown())),
xKey: z.string(),
yKey: z.string(),
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
color: z.string().nullable(),
height: z.number().nullable(),
}),
description:
'Line chart visualization. Use { "$state": "/path" } to bind read-only data. xKey is the x-axis field, yKey is the numeric value field.',
},
// Interactive
Tabs: {
props: z.object({
defaultValue: z.string().nullable(),
tabs: z.array(
z.object({
value: z.string(),
label: z.string(),
}),
),
}),
slots: ["default"],
description: "Tabbed content container",
},
TabContent: {
props: z.object({
value: z.string(),
}),
slots: ["default"],
description: "Content for a specific tab",
},
Progress: {
props: z.object({
value: z.number(),
max: z.number().nullable(),
}),
description: "Progress bar",
},
Skeleton: {
props: z.object({
width: z.string().nullable(),
height: z.string().nullable(),
}),
description: "Loading placeholder",
},
// Educational / Rich content
Callout: {
props: z.object({
type: z.enum(["info", "tip", "warning", "important"]).nullable(),
title: z.string().nullable(),
content: z.string(),
}),
description:
"Highlighted callout box for tips, warnings, notes, or key information",
example: {
type: "tip",
title: "Did you know?",
content: "The sun is about 93 million miles from Earth.",
},
},
Accordion: {
props: z.object({
items: z.array(
z.object({
title: z.string(),
content: z.string(),
}),
),
type: z.enum(["single", "multiple"]).nullable(),
}),
description:
"Collapsible accordion sections for organizing detailed content",
example: {
items: [{ title: "Overview", content: "A brief introduction." }],
type: "multiple",
},
},
Timeline: {
props: z.object({
items: z.array(
z.object({
title: z.string(),
description: z.string().nullable(),
date: z.string().nullable(),
status: z.enum(["completed", "current", "upcoming"]).nullable(),
}),
),
}),
description:
"Vertical timeline showing ordered events, steps, or historical milestones",
example: {
items: [
{
title: "Discovery",
description: "Initial breakthrough",
date: "1905",
status: "completed",
},
],
},
},
PieChart: {
props: z.object({
title: z.string().nullable(),
data: z.array(z.record(z.string(), z.unknown())),
nameKey: z.string(),
valueKey: z.string(),
height: z.number().nullable(),
}),
description:
'Pie/donut chart for proportional data. Use { "$state": "/path" } to bind read-only data. nameKey is the label field, valueKey is the numeric value field.',
},
// Interactive / Input
RadioGroup: {
props: z.object({
label: z.string().nullable(),
value: z.string().nullable(),
options: z.array(
z.object({
value: z.string(),
label: z.string(),
}),
),
}),
description:
'Radio button group for single selection. Use { "$bindState": "/path" } for two-way binding.',
example: {
label: "Choose one",
value: { $bindState: "/answer" },
options: [
{ value: "a", label: "Option A" },
{ value: "b", label: "Option B" },
],
},
},
SelectInput: {
props: z.object({
label: z.string().nullable(),
value: z.string().nullable(),
placeholder: z.string().nullable(),
options: z.array(
z.object({
value: z.string(),
label: z.string(),
}),
),
}),
description:
'Dropdown select input. Use { "$bindState": "/path" } for two-way binding.',
example: {
label: "Country",
value: { $bindState: "/selectedCountry" },
placeholder: "Select a country",
options: [
{ value: "us", label: "United States" },
{ value: "uk", label: "United Kingdom" },
],
},
},
TextInput: {
props: z.object({
label: z.string().nullable(),
value: z.string().nullable(),
placeholder: z.string().nullable(),
type: z.enum(["text", "email", "number", "password", "url"]).nullable(),
}),
description:
'Text input field. Use { "$bindState": "/path" } for two-way binding.',
example: {
label: "Your name",
value: { $bindState: "/userName" },
placeholder: "Enter your name",
type: "text",
},
},
Button: {
props: z.object({
label: z.string(),
variant: z
.enum(["default", "secondary", "destructive", "outline", "ghost"])
.nullable(),
size: z.enum(["default", "sm", "lg"]).nullable(),
disabled: z.boolean().nullable(),
}),
description:
"Clickable button. Use with on.press to trigger actions like setState.",
example: {
label: "Submit",
variant: "default",
size: "default",
disabled: null,
},
},
},
actions: {},
});
@@ -0,0 +1,22 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
import * as Accordion from "$lib/components/ui/accordion";
interface Props extends BaseComponentProps<{
items: Array<{ title: string; content: string }>;
type?: "single" | "multiple" | null;
}> {}
let { props }: Props = $props();
</script>
<Accordion.Root type={props.type === "single" ? "single" : "multiple"} class="w-full">
{#each props.items ?? [] as item, i}
<Accordion.Item value="item-{i}">
<Accordion.Trigger>{item.title}</Accordion.Trigger>
<Accordion.Content>
<p class="text-muted-foreground">{item.content}</p>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion.Root>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
import * as Alert from "$lib/components/ui/alert";
interface Props extends BaseComponentProps<{
variant?: "default" | "destructive" | null;
title: string;
description?: string | null;
}> {}
let { props }: Props = $props();
</script>
<Alert.Root variant={props.variant ?? "default"}>
<Alert.Title>{props.title}</Alert.Title>
{#if props.description}
<Alert.Description>{props.description}</Alert.Description>
{/if}
</Alert.Root>
@@ -0,0 +1,13 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
import { Badge } from "$lib/components/ui/badge";
interface Props extends BaseComponentProps<{
text: string;
variant?: "default" | "secondary" | "destructive" | "outline" | null;
}> {}
let { props }: Props = $props();
</script>
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
@@ -0,0 +1,99 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
title?: string | null;
data: Array<Record<string, unknown>>;
xKey: string;
yKey: string;
aggregate?: "sum" | "count" | "avg" | null;
color?: string | null;
height?: number | null;
}> {}
let { props }: Props = $props();
const rawData = $derived(props.data);
const rawItems = $derived<Array<Record<string, unknown>>>(
Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<Record<string, unknown>>)
: []
);
const processedData = $derived(() => {
if (rawItems.length === 0) return { items: [], valueKey: props.yKey };
const { xKey, yKey, aggregate } = props;
if (!aggregate) {
const formatted = rawItems.map((item) => ({
label: String(item[xKey] ?? ""),
value: typeof item[yKey] === "number" ? item[yKey] : parseFloat(String(item[yKey])) || 0,
}));
return { items: formatted, valueKey: yKey };
}
const groups = new Map<string, Array<Record<string, unknown>>>();
for (const item of rawItems) {
const groupKey = String(item[xKey] ?? "unknown");
const group = groups.get(groupKey) ?? [];
group.push(item);
groups.set(groupKey, group);
}
const valueKey = aggregate === "count" ? "count" : yKey;
const aggregated: Array<{ label: string; value: number }> = [];
for (const [key, group] of Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
let value: number;
if (aggregate === "count") {
value = group.length;
} else if (aggregate === "sum") {
value = group.reduce((sum, item) => {
const v = item[yKey];
return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
} else {
const sum = group.reduce((s, item) => {
const v = item[yKey];
return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
value = group.length > 0 ? sum / group.length : 0;
}
aggregated.push({ label: key, value });
}
return { items: aggregated, valueKey };
});
const chartData = $derived(processedData());
const maxValue = $derived(Math.max(...chartData.items.map(d => d.value), 1));
const chartColor = $derived(props.color ?? "var(--chart-1)");
</script>
<div class="w-full">
{#if props.title}
<p class="text-sm font-medium mb-2">{props.title}</p>
{/if}
{#if chartData.items.length === 0}
<div class="text-center py-4 text-muted-foreground">No data available</div>
{:else}
<div class="space-y-2" style="height: {props.height ?? 200}px">
{#each chartData.items as item}
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground w-16 truncate">{item.label}</span>
<div class="flex-1 h-6 bg-muted rounded overflow-hidden">
<div
class="h-full rounded transition-all"
style="width: {(item.value / maxValue) * 100}%; background-color: {chartColor}"
></div>
</div>
<span class="text-xs font-medium w-12 text-right">{item.value.toLocaleString()}</span>
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
import { Button } from "$lib/components/ui/button";
interface Props extends BaseComponentProps<{
label: string;
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | null;
size?: "default" | "sm" | "lg" | null;
disabled?: boolean | null;
}> {}
let { props, emit }: Props = $props();
</script>
<Button
variant={props.variant ?? "default"}
size={props.size ?? "default"}
disabled={props.disabled ?? false}
onclick={() => emit("press")}
>
{props.label}
</Button>
@@ -0,0 +1,57 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
import { Info, Lightbulb, AlertTriangle, Star } from "lucide-svelte";
interface Props extends BaseComponentProps<{
type?: "info" | "tip" | "warning" | "important" | null;
title?: string | null;
content: string;
}> {}
let { props }: Props = $props();
const configs = {
info: {
border: "border-l-blue-500",
bg: "bg-blue-500/5",
iconColor: "text-blue-500",
},
tip: {
border: "border-l-emerald-500",
bg: "bg-emerald-500/5",
iconColor: "text-emerald-500",
},
warning: {
border: "border-l-amber-500",
bg: "bg-amber-500/5",
iconColor: "text-amber-500",
},
important: {
border: "border-l-purple-500",
bg: "bg-purple-500/5",
iconColor: "text-purple-500",
},
};
const config = $derived(configs[props.type ?? "info"] ?? configs.info);
</script>
<div class="border-l-4 {config.border} {config.bg} rounded-r-lg p-4">
<div class="flex items-start gap-3">
{#if props.type === "tip"}
<Lightbulb class="h-5 w-5 mt-0.5 shrink-0 {config.iconColor}" />
{:else if props.type === "warning"}
<AlertTriangle class="h-5 w-5 mt-0.5 shrink-0 {config.iconColor}" />
{:else if props.type === "important"}
<Star class="h-5 w-5 mt-0.5 shrink-0 {config.iconColor}" />
{:else}
<Info class="h-5 w-5 mt-0.5 shrink-0 {config.iconColor}" />
{/if}
<div class="flex-1 min-w-0">
{#if props.title}
<p class="font-semibold text-sm mb-1">{props.title}</p>
{/if}
<p class="text-sm text-muted-foreground">{props.content}</p>
</div>
</div>
</div>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { BaseComponentProps } from "@json-render/svelte";
import * as Card from "$lib/components/ui/card";
interface Props extends BaseComponentProps<{
title?: string | null;
description?: string | null;
}> {
children?: Snippet;
}
let { props, children }: Props = $props();
</script>
<Card.Root>
{#if props.title || props.description}
<Card.Header>
{#if props.title}
<Card.Title>{props.title}</Card.Title>
{/if}
{#if props.description}
<Card.Description>{props.description}</Card.Description>
{/if}
</Card.Header>
{/if}
<Card.Content class="flex flex-col gap-4">
{#if children}
{@render children()}
{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
columns?: "1" | "2" | "3" | "4" | null;
gap?: "sm" | "md" | "lg" | null;
}> {
children?: Snippet;
}
let { props, children }: Props = $props();
const colsClass = $derived(
{
"1": "grid-cols-1",
"2": "grid-cols-1 md:grid-cols-2",
"3": "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
"4": "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
}[props.columns ?? "3"] ?? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
);
const gapClass = $derived(
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4"
);
</script>
<div class="grid {colsClass} {gapClass}">
{#if children}
{@render children()}
{/if}
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
text: string;
level?: "h1" | "h2" | "h3" | "h4" | null;
}> {}
let { props }: Props = $props();
const level = $derived(props.level ?? "h2");
</script>
{#if level === "h1"}
<h1 class="text-3xl font-bold tracking-tight">{props.text}</h1>
{:else if level === "h2"}
<h2 class="text-2xl font-semibold tracking-tight">{props.text}</h2>
{:else if level === "h3"}
<h3 class="text-xl font-semibold">{props.text}</h3>
{:else}
<h4 class="text-lg font-medium">{props.text}</h4>
{/if}
@@ -0,0 +1,109 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
title?: string | null;
data: Array<Record<string, unknown>>;
xKey: string;
yKey: string;
aggregate?: "sum" | "count" | "avg" | null;
color?: string | null;
height?: number | null;
}> {}
let { props }: Props = $props();
const rawData = $derived(props.data);
const rawItems = $derived<Array<Record<string, unknown>>>(
Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<Record<string, unknown>>)
: []
);
const processedData = $derived(() => {
if (rawItems.length === 0) return [];
const { xKey, yKey, aggregate } = props;
if (!aggregate) {
return rawItems.map((item) => ({
label: String(item[xKey] ?? ""),
value: typeof item[yKey] === "number" ? item[yKey] : parseFloat(String(item[yKey])) || 0,
}));
}
const groups = new Map<string, Array<Record<string, unknown>>>();
for (const item of rawItems) {
const groupKey = String(item[xKey] ?? "unknown");
const group = groups.get(groupKey) ?? [];
group.push(item);
groups.set(groupKey, group);
}
const aggregated: Array<{ label: string; value: number }> = [];
for (const [key, group] of Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
let value: number;
if (aggregate === "count") {
value = group.length;
} else if (aggregate === "sum") {
value = group.reduce((sum, item) => {
const v = item[yKey];
return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
} else {
const sum = group.reduce((s, item) => {
const v = item[yKey];
return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
value = group.length > 0 ? sum / group.length : 0;
}
aggregated.push({ label: key, value });
}
return aggregated;
});
const chartData = $derived(processedData());
const maxValue = $derived(Math.max(...chartData.map(d => d.value), 1));
const minValue = $derived(Math.min(...chartData.map(d => d.value), 0));
const chartColor = $derived(props.color ?? "var(--chart-1)");
const height = $derived(props.height ?? 200);
const points = $derived(() => {
if (chartData.length === 0) return "";
const range = maxValue - minValue || 1;
return chartData.map((d, i) => {
const x = (i / (chartData.length - 1 || 1)) * 100;
const y = 100 - ((d.value - minValue) / range) * 100;
return `${x},${y}`;
}).join(" ");
});
</script>
<div class="w-full">
{#if props.title}
<p class="text-sm font-medium mb-2">{props.title}</p>
{/if}
{#if chartData.length === 0}
<div class="text-center py-4 text-muted-foreground">No data available</div>
{:else}
<div class="relative" style="height: {height}px">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" class="w-full h-full">
<polyline
points={points()}
fill="none"
stroke={chartColor}
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
</svg>
<div class="flex justify-between text-xs text-muted-foreground mt-1">
{#each chartData.filter((_, i) => i === 0 || i === chartData.length - 1 || i === Math.floor(chartData.length / 2)) as item}
<span>{item.label}</span>
{/each}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
text: string;
href: string;
}> {}
let { props }: Props = $props();
</script>
<a
href={props.href}
target="_blank"
rel="noopener noreferrer"
class="text-primary underline underline-offset-4 hover:text-primary/80"
>
{props.text}
</a>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
import { TrendingUp, TrendingDown, Minus } from "lucide-svelte";
interface Props extends BaseComponentProps<{
label: string;
value: string;
detail?: string | null;
trend?: "up" | "down" | "neutral" | null;
}> {}
let { props }: Props = $props();
const trendColor = $derived(
props.trend === "up"
? "text-green-500"
: props.trend === "down"
? "text-red-500"
: "text-muted-foreground"
);
</script>
<div class="flex flex-col gap-1">
<p class="text-sm text-muted-foreground">{props.label}</p>
<div class="flex items-center gap-2">
<span class="text-2xl font-bold">{props.value}</span>
{#if props.trend}
{#if props.trend === "up"}
<TrendingUp class="h-4 w-4 {trendColor}" />
{:else if props.trend === "down"}
<TrendingDown class="h-4 w-4 {trendColor}" />
{:else}
<Minus class="h-4 w-4 {trendColor}" />
{/if}
{/if}
</div>
{#if props.detail}
<p class="text-xs text-muted-foreground">{props.detail}</p>
{/if}
</div>
@@ -0,0 +1,106 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
title?: string | null;
data: Array<Record<string, unknown>>;
nameKey: string;
valueKey: string;
height?: number | null;
}> {}
let { props }: Props = $props();
const PIE_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
];
const rawData = $derived(props.data);
const items = $derived<Array<Record<string, unknown>>>(
Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<Record<string, unknown>>)
: []
);
const chartData = $derived(
items.map((item, i) => ({
name: String(item[props.nameKey] ?? `Segment ${i + 1}`),
value: typeof item[props.valueKey] === "number"
? item[props.valueKey] as number
: parseFloat(String(item[props.valueKey])) || 0,
color: PIE_COLORS[i % PIE_COLORS.length],
}))
);
const total = $derived(chartData.reduce((sum, d) => sum + d.value, 0));
const segments = $derived(() => {
let startAngle = 0;
return chartData.map((d) => {
const angle = (d.value / (total || 1)) * 360;
const segment = {
...d,
startAngle,
endAngle: startAngle + angle,
percentage: Math.round((d.value / (total || 1)) * 100),
};
startAngle += angle;
return segment;
});
});
function polarToCartesian(cx: number, cy: number, r: number, angle: number) {
const rad = ((angle - 90) * Math.PI) / 180;
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad),
};
}
function describeArc(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
const start = polarToCartesian(cx, cy, r, endAngle);
const end = polarToCartesian(cx, cy, r, startAngle);
const largeArc = endAngle - startAngle <= 180 ? 0 : 1;
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 0 ${end.x} ${end.y}`;
}
</script>
<div class="w-full">
{#if props.title}
<p class="text-sm font-medium mb-2">{props.title}</p>
{/if}
{#if items.length === 0}
<div class="text-center py-4 text-muted-foreground">No data available</div>
{:else}
<div class="flex items-center gap-4" style="height: {props.height ?? 200}px">
<svg viewBox="0 0 100 100" class="h-full aspect-square">
{#each segments() as seg}
{#if seg.endAngle - seg.startAngle >= 1}
<path
d={describeArc(50, 50, 35, seg.startAngle, seg.endAngle)}
fill="none"
stroke={seg.color}
stroke-width="15"
/>
{/if}
{/each}
</svg>
<div class="flex flex-col gap-1 text-sm">
{#each segments() as seg}
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full" style="background-color: {seg.color}"></span>
<span class="text-muted-foreground">{seg.name}</span>
<span class="font-medium">{seg.percentage}%</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
import { Progress } from "$lib/components/ui/progress";
interface Props extends BaseComponentProps<{
value: number;
max?: number | null;
}> {}
let { props }: Props = $props();
</script>
<Progress value={props.value} max={props.max ?? 100} />
@@ -0,0 +1,45 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
import { getBoundProp } from "@json-render/svelte";
import * as RadioGroup from "$lib/components/ui/radio-group";
import { Label } from "$lib/components/ui/label";
interface Props extends BaseComponentProps<{
label?: string | null;
value?: string | null;
options: Array<{ value: string; label: string }>;
}> {}
let { props, bindings }: Props = $props();
function valueBinding() {
return getBoundProp<string>(
() => (props.value ?? undefined) as string | undefined,
() => bindings?.value,
);
}
let value = $derived(
valueBinding().current ?? ""
);
function handleChange(newValue: string) {
valueBinding().current = newValue;
}
</script>
<div class="flex flex-col gap-2">
{#if props.label}
<Label class="text-sm font-medium">{props.label}</Label>
{/if}
<RadioGroup.Root value={value} onValueChange={handleChange}>
{#each props.options ?? [] as opt}
<div class="flex items-center gap-2">
<RadioGroup.Item value={opt.value} id="rg-{opt.value}" />
<Label for="rg-{opt.value}" class="font-normal cursor-pointer">
{opt.label}
</Label>
</div>
{/each}
</RadioGroup.Root>
</div>
@@ -0,0 +1,56 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
import { getBoundProp } from "@json-render/svelte";
import * as Select from "$lib/components/ui/select";
import { Label } from "$lib/components/ui/label";
interface Props extends BaseComponentProps<{
label?: string | null;
value?: string | null;
placeholder?: string | null;
options: Array<{ value: string; label: string }>;
}> {}
let { props, bindings }: Props = $props();
function valueBinding() {
return getBoundProp<string>(
() => (props.value ?? undefined) as string | undefined,
() => bindings?.value,
);
}
let value = $derived(
valueBinding().current ?? ""
);
const selectedOption = $derived(
props.options?.find(o => o.value === value)
);
function handleChange(newValue: string | undefined) {
if (newValue) {
valueBinding().current = newValue;
}
}
</script>
<div class="flex flex-col gap-2">
{#if props.label}
<Label class="text-sm font-medium">{props.label}</Label>
{/if}
<Select.Root type="single" value={value} onValueChange={handleChange}>
<Select.Trigger>
{#if selectedOption}
{selectedOption.label}
{:else}
<span class="text-muted-foreground">{props.placeholder ?? "Select..."}</span>
{/if}
</Select.Trigger>
<Select.Content>
{#each props.options ?? [] as opt}
<Select.Item value={opt.value}>{opt.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
@@ -0,0 +1,10 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
import { Separator } from "$lib/components/ui/separator";
interface Props extends BaseComponentProps<Record<string, never>> {}
let {}: Props = $props();
</script>
<Separator />
@@ -0,0 +1,14 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
width?: string | null;
height?: string | null;
}> {}
let { props }: Props = $props();
</script>
<div
class="animate-pulse rounded-md bg-muted {props.width ?? 'w-full'} {props.height ?? 'h-4'}"
></div>
@@ -0,0 +1,28 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
direction?: "horizontal" | "vertical" | null;
gap?: "sm" | "md" | "lg" | null;
wrap?: boolean | null;
}> {
children?: Snippet;
}
let { props, children }: Props = $props();
const gapClass = $derived(
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4"
);
const dirClass = $derived(
props.direction === "horizontal" ? "flex-row" : "flex-col"
);
const wrapClass = $derived(props.wrap ? "flex-wrap" : "");
</script>
<div class="flex {dirClass} {wrapClass} {gapClass}">
{#if children}
{@render children()}
{/if}
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { BaseComponentProps } from "@json-render/svelte";
import * as Tabs from "$lib/components/ui/tabs";
interface Props extends BaseComponentProps<{
value: string;
}> {
children?: Snippet;
}
let { props, children }: Props = $props();
</script>
<Tabs.Content value={props.value}>
{#if children}
{@render children()}
{/if}
</Tabs.Content>
@@ -0,0 +1,91 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
import * as Table from "$lib/components/ui/table";
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-svelte";
interface Props extends BaseComponentProps<{
data: Array<Record<string, unknown>>;
columns: Array<{ key: string; label: string }>;
emptyMessage?: string | null;
}> {}
let { props }: Props = $props();
let sortKey = $state<string | null>(null);
let sortDir = $state<"asc" | "desc">("asc");
const rawData = $derived(props.data);
const items = $derived<Array<Record<string, unknown>>>(
Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<Record<string, unknown>>)
: []
);
const sorted = $derived(
sortKey
? [...items].sort((a, b) => {
const av = a[sortKey!];
const bv = b[sortKey!];
if (typeof av === "number" && typeof bv === "number") {
return sortDir === "asc" ? av - bv : bv - av;
}
const as = String(av ?? "");
const bs = String(bv ?? "");
return sortDir === "asc" ? as.localeCompare(bs) : bs.localeCompare(as);
})
: items
);
function handleSort(key: string) {
if (sortKey === key) {
sortDir = sortDir === "asc" ? "desc" : "asc";
} else {
sortKey = key;
sortDir = "asc";
}
}
</script>
{#if items.length === 0}
<div class="text-center py-4 text-muted-foreground">
{props.emptyMessage ?? "No data"}
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
{#each props.columns as col}
<Table.Head>
<button
type="button"
class="inline-flex items-center gap-1 hover:text-foreground transition-colors"
onclick={() => handleSort(col.key)}
>
{col.label}
{#if sortKey === col.key}
{#if sortDir === "asc"}
<ArrowUp class="h-3 w-3 text-muted-foreground" />
{:else}
<ArrowDown class="h-3 w-3 text-muted-foreground" />
{/if}
{:else}
<ArrowUpDown class="h-3 w-3 text-muted-foreground" />
{/if}
</button>
</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each sorted as item, i}
<Table.Row>
{#each props.columns as col}
<Table.Cell>{String(item[col.key] ?? "")}</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { BaseComponentProps } from "@json-render/svelte";
import * as Tabs from "$lib/components/ui/tabs";
interface Props extends BaseComponentProps<{
defaultValue?: string | null;
tabs: Array<{ value: string; label: string }>;
}> {
children?: Snippet;
}
let { props, children }: Props = $props();
const defaultVal = $derived(
props.defaultValue ?? (props.tabs ?? [])[0]?.value ?? ""
);
</script>
<Tabs.Root value={defaultVal}>
<Tabs.List>
{#each props.tabs ?? [] as tab}
<Tabs.Trigger value={tab.value}>{tab.label}</Tabs.Trigger>
{/each}
</Tabs.List>
{#if children}
{@render children()}
{/if}
</Tabs.Root>

Some files were not shown because too many files have changed in this diff Show More