feat(template): bake shadcn-svelte + Tailwind v4 + TS into fullstack cookiecutter (#265)

* feat(template): fullstack cookiecutter — Tailwind v4 + TS + shadcn-svelte scaffold

* feat(template): fullstack — shadcn-svelte ui components + dashboard layout

* refactor(template): fullstack — remove counter/Header demo files

* feat(spec): add tailwind/shadcn/ts to STACK_REQUIRED fullstack

* test(template): update fullstack cookiecutter assertions for shadcn-svelte

* fix(ci): ruff format test_cookiecutter_templates

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-08-04 22:08:58 +03:00 committed by GitHub
parent e4f9313641
commit 7c3ded601f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 2206 additions and 211 deletions

View file

@ -81,6 +81,9 @@ STACK_REQUIRED: dict[str, list[str]] = {
"ruff",
"mypy",
"pytest",
"tailwind",
"shadcn",
"typescript",
],
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
"cli": ["typer", "uv", "hatchling", "ruff", "mypy", "pytest"],

View file

@ -61,7 +61,7 @@ init flow.
```
Выбери тип проекта (из spec-status VALID_TYPES):
[1] backend — FastAPI + Tortoise, REST API (cookiecutter template)
[2] fullstack — backend + SvelteKit frontend (cookiecutter template)
[2] fullstack — backend + SvelteKit + Tailwind v4 + shadcn-svelte dashboard (TypeScript, cookiecutter template)
[3] cli — Python CLI tool, Typer (cookiecutter template)
[4] mcp-server — MCP + REST сервер (нет cookiecutter template — ручная инициализация)
[5] bot — Telegram bot, aiogram 3 (нет cookiecutter template — ручная инициализация)

View file

@ -42,7 +42,7 @@ truth для порядка и действий — `spec-status` tool. На в
Общий для всех типов: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI.
- **backend**: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich — legacy), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
- **fullstack**: backend + frontend/ (SvelteKit + Svelte 5 runes (JS) + Biome + Vitest + Knip)
- **fullstack**: backend + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip)
- **mcp-server**: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
- **cli**: Typer (default) / click / argparse, hatchling build
- **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
@ -65,7 +65,7 @@ Prompt template A (см. ниже).
```
Выбери тип проекта:
[1] backend — FastAPI + Tortoise, REST API, без frontend
[2] fullstack — backend + SvelteKit/Svelte 5 dashboard (monorepo)
[2] fullstack — backend + SvelteKit/Svelte 5 + Tailwind v4 + shadcn-svelte dashboard (TypeScript, monorepo)
[3] mcp-server — MCP + REST сервер (Patchright/Playwright over CDP)
[4] cli — Python CLI tool (Typer)
[5] bot — Telegram bot (aiogram 3)
@ -88,7 +88,7 @@ backend:
- Auth: [1] none v1 / [2] JWT / [3] X-API-Key
fullstack:
- frontend: [1] SvelteKit + Svelte 5 (default) / [2] add later
- frontend: [1] SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte (default) / [2] add later
- DB: (same as backend)
- Auth: (same as backend)
@ -236,7 +236,7 @@ Spec complete. Issues: #N1, #N2, ...
Default stack для типа (хардкод, добавить всегда):
- Общий: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI
- backend: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
- fullstack: + frontend/ (SvelteKit + Svelte 5 runes (JS) + Biome + Vitest + Knip)
- fullstack: + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip)
- mcp-server: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
- cli: Typer (default) / click / argparse, hatchling build
- bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)

View file

@ -4,5 +4,11 @@
"description": "Fullstack project description",
"use_auth": ["no", "yes"],
"use_db": ["yes", "no"],
"python_version": "3.13"
"python_version": "3.13",
"_copy_without_render": [
"frontend/src/lib/components",
"frontend/src/routes/+page.svelte",
"frontend/src/routes/+layout.svelte",
"frontend/src/routes/+error.svelte"
]
}

View file

@ -21,5 +21,17 @@
},
"files": {
"ignore": ["build", ".svelte-kit", "playwright-report"]
},
"overrides": [
{
"include": ["src/lib/components/ui/**/*.svelte", "src/lib/components/ui/**/*.ts"],
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off"
}
}
}
}
]
}

View file

@ -0,0 +1,17 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "new-york",
"tailwind": {
"css": "src/app.css",
"baseColor": "neutral"
},
"typescript": true,
"aliases": {
"lib": "$lib",
"utils": "$lib/utils",
"components": "$lib/components",
"hooks": "$lib/hooks",
"ui": "$lib/components/ui"
},
"registry": "https://shadcn-svelte.com/registry"
}

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"checkJs": true,
"allowJs": true,
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"verbatimModuleSyntax": false,
"baseUrl": ".",
"paths": {
"$lib": ["src/lib"],
"$lib/*": ["src/lib/*"],
"$components": ["src/lib/components"],
"$components/*": ["src/lib/components/*"]
}
},
"include": ["src/**/*.js", "src/**/*.svelte", "vite.config.js", "vitest.config.js"]
}

View file

@ -1,6 +1,6 @@
{
"entry": ["src/**/*.{js,svelte}", "!src/**/*.test.js"],
"project": ["src/**/*.{js,svelte}"],
"entry": ["src/**/*.{ts,svelte}", "!src/**/*.test.ts"],
"project": ["src/**/*.{ts,svelte}"],
"ignore": ["build", ".svelte-kit"],
"ignoreBinaries": true,
"ignoreDependencies": false

View file

@ -10,11 +10,22 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"lint": "biome check .",
"format": "biome format --write .",
"knip": "knip"
},
"dependencies": {
"bits-ui": "^2.18.1",
"tailwind-variants": "^3.3.1",
"clsx": "^2.1.1",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"@lucide/svelte": "^1.28.0",
"@tanstack/table-core": "^8.21.3",
"svelte-sonner": "^1.1.1",
"mode-watcher": "^1.1.0"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.0.0",
@ -25,6 +36,11 @@
"svelte-check": "^4.0.0",
"vite": "^5.0.0",
"vitest": "^2.0.0",
"@playwright/test": "^1.48.0"
"@playwright/test": "^1.48.0",
"tailwindcss": "^4.3.3",
"@tailwindcss/vite": "^4.3.3",
"typescript": "^5.9.0",
"@tsconfig/svelte": "^5.0.0",
"tslib": "^2.8.0"
}
}

View file

@ -1,23 +1,123 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--bg: #ffffff;
--fg: #1a1a1a;
--accent: #646cff;
--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);
--destructive-foreground: oklch(0.985 0 0);
--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);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a1a;
--fg: #ffffff;
}
.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);
--destructive-foreground: oklch(0.985 0 0);
--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);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--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-destructive-foreground: var(--destructive-foreground);
--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);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: system-ui, sans-serif;
@apply bg-background text-foreground;
}
a {
color: var(--accent);
}

View file

@ -1,23 +0,0 @@
<script>
/**
* @prop {string} title
* @prop {import('svelte').Snippet} [children]
*/
let { title, children } = $props();
</script>
<header>
<h1>{title}</h1>
{@render children?.()}
</header>
<style>
header {
padding: 1rem;
border-bottom: 1px solid #ccc;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
</style>

View file

@ -0,0 +1,45 @@
<script lang="ts">
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import NavMain from './nav-main.svelte';
import NavUser from './nav-user.svelte';
import type { Snippet } from 'svelte';
let {
variant = 'inset',
collapsible = 'icon',
title = '',
children,
}: {
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
title?: string;
children?: Snippet;
} = $props();
</script>
<Sidebar.Provider {variant} {collapsible}>
<Sidebar.Root>
<Sidebar.Header>
<a
href="/"
class="flex items-center gap-2 px-2 py-1.5 text-sm font-medium"
>
<span class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11a9 9 0 0 1 9 9" />
<path d="M4 4a16 16 0 0 1 16 16" />
<circle cx="5" cy="19" r="1" />
</svg>
</span>
<span class="truncate">{title}</span>
</a>
</Sidebar.Header>
<Sidebar.Content>
<NavMain />
</Sidebar.Content>
<Sidebar.Footer>
<NavUser />
</Sidebar.Footer>
</Sidebar.Root>
{@render children?.()}
</Sidebar.Provider>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
import RadioIcon from '@lucide/svelte/icons/radio';
import UploadIcon from '@lucide/svelte/icons/upload';
import SettingsIcon from '@lucide/svelte/icons/settings';
const items = [
{ title: 'Dashboard', url: '/', icon: LayoutDashboardIcon },
{ title: 'Channels', url: '/channels', icon: RadioIcon },
{ title: 'Uploads', url: '/uploads', icon: UploadIcon },
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
];
</script>
<Sidebar.Group>
<Sidebar.GroupLabel>Platform</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each items as item (item.title)}
<Sidebar.MenuItem>
<a href={item.url} class="flex items-center gap-2">
<item.icon class="size-4" />
<span>{item.title}</span>
</a>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import UserIcon from '@lucide/svelte/icons/user';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import SettingsIcon from '@lucide/svelte/icons/settings';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
const user = {
name: 'Operator',
email: 'operator@example.com',
avatar: '',
};
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<button
{...props}
class="flex w-full items-center gap-2 rounded-md p-2 text-sm outline-none hover:bg-sidebar-accent"
>
<span class="bg-muted flex size-7 items-center justify-center rounded-md">
<UserIcon class="size-4" />
</span>
<div class="flex flex-col text-left">
<span class="text-sm font-medium">{user.name}</span>
<span class="text-xs text-muted-foreground">{user.email}</span>
</div>
<ChevronUpIcon class="ml-auto size-4" />
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="top" class="w-56">
<DropdownMenu.Group>
<DropdownMenu.Item href="/settings">
<SettingsIcon class="size-4" />
Settings
</DropdownMenu.Item>
<DropdownMenu.Item>
<UserIcon class="size-4" />
Profile
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<LogOutIcon class="size-4" />
Sign out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import UsersIcon from '@lucide/svelte/icons/users';
import UploadIcon from '@lucide/svelte/icons/upload';
import ListTodoIcon from '@lucide/svelte/icons/list-todo';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
const cards = [
{
title: 'Total Channels',
value: '12',
delta: '+2 this week',
icon: UsersIcon,
},
{
title: 'Active Uploads',
value: '4',
delta: '+1 today',
icon: UploadIcon,
},
{
title: 'Pending Tasks',
value: '7',
delta: '-3 since yesterday',
icon: ListTodoIcon,
},
{
title: 'Success Rate',
value: '98.4%',
delta: '+0.6% mtd',
icon: TrendingUpIcon,
},
];
</script>
<div class="grid grid-cols-1 gap-4 px-4 py-4 sm:grid-cols-2 lg:grid-cols-4">
{#each cards as card (card.title)}
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<Card.Title class="text-sm font-medium text-muted-foreground">
{card.title}
</Card.Title>
<card.icon class="text-muted-foreground size-4" />
</div>
</Card.Header>
<Card.Content>
<div class="text-2xl font-semibold">{card.value}</div>
<p class="text-muted-foreground mt-1 text-xs">{card.delta}</p>
</Card.Content>
</Card.Root>
{/each}
</div>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { cn } from '$lib/utils/cn.svelte.js';
import type { Snippet } from 'svelte';
let {
title = '',
children,
}: { title?: string; children?: Snippet } = $props();
</script>
<header class="bg-background sticky top-0 z-10 flex h-14 items-center gap-2 border-b px-4">
<Sidebar.Trigger class="-ml-1" />
<div class="flex items-center gap-2">
<h1 class={cn('text-sm font-medium', !title && 'hidden md:block')}>
{title}
</h1>
</div>
<div class="ml-auto flex items-center gap-2">
{@render children?.()}
</div>
</header>

View file

@ -0,0 +1,80 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all select-none disabled:pointer-events-none 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',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
outline:
'border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/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',
},
},
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}

View file

@ -0,0 +1,15 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from './button.svelte';
export {
Root,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
Root as Button,
};

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes, Snippet } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $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}
>
{children}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes, Snippet } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
</script>
<div
bind:this={ref}
data-slot="card-content"
class={cn('px-6', className)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes, Snippet } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
</script>
<div
bind:this={ref}
data-slot="card-description"
class={cn('text-muted-foreground text-sm', className)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes, Snippet } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes, Snippet } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $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-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes, Snippet } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn('font-medium leading-none', className)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes, Snippet } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $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}
>
{children}
</div>

View file

@ -0,0 +1,24 @@
import Root from './card.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
import Description from './card-description.svelte';
import Action from './card-action.svelte';
import Content from './card-content.svelte';
import Footer from './card-footer.svelte';
export {
Root,
Header,
Title,
Description,
Action,
Content,
Footer,
Root as Card,
Header as CardHeader,
Title as CardTitle,
Description as CardDescription,
Action as CardAction,
Content as CardContent,
Footer as CardFooter,
};

View file

@ -0,0 +1,35 @@
<script lang="ts">
import Root from 'bits-ui';
import { cn, type WithoutChildren } from '$lib/utils/cn.svelte.js';
import CheckIcon from '@lucide/svelte/icons/check';
type Props = WithoutChildren<Root.Checkbox.RootProps> & {
class?: string;
};
let {
ref = $bindable(null),
checked = $bindable(false),
class: className,
...restProps
}: Props = $props();
</script>
<Root.Checkbox.Root
bind:ref
bind:checked
data-slot="checkbox"
class={cn(
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...restProps}
>
{#snippet children({ checked })}
<div class="flex items-center justify-center text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</Root.Checkbox.Root>

View file

@ -0,0 +1,2 @@
import Root from './checkbox.svelte';
export { Root, Root as Checkbox };

View file

@ -0,0 +1,19 @@
<script lang="ts" module>
import {
type TableOptions,
type Table,
createTable,
type ColumnDef,
} from '@tanstack/table-core';
export type DataTableProps<TData, TValue> = {
data: TData[];
columns: ColumnDef<TData, TValue>[];
};
export function createTableInstance<TData, TValue>(
options: TableOptions<TData>
): Table<TData> {
return createTable(options);
}
</script>

View file

@ -0,0 +1,26 @@
<script lang="ts" module>
import type { CellContext, HeaderContext, RowData } from '@tanstack/table-core';
import { type Snippet } from 'svelte';
export type FlexRenderProps<TData extends RowData, TValue> = {
ctx: CellContext<TData, TValue> | HeaderContext<TData, TValue>;
render: ((ctx: CellContext<TData, TValue> | HeaderContext<TData, TValue>) => unknown) | undefined;
snippet?: Snippet<[{ props: Record<string, unknown> }]>;
};
</script>
<script lang="ts">
let { ctx, render, snippet }: import('./flex-render.svelte').FlexRenderProps<any, any> = $props();
const cell = $derived(
typeof render === 'function' ? (render as any)(ctx) : render
);
</script>
{#if snippet}
{@render snippet({ props: ctx as any })}
{:else if typeof cell === 'string' || typeof cell === 'number' || typeof cell === 'boolean'}
{cell}
{:else if cell}
{@html (cell as { html?: string }).html ?? cell}
{/if}

View file

@ -0,0 +1,5 @@
import { CreateTable } from './create-table.svelte';
import FlexRender from './flex-render.svelte';
import { renderCells, renderHeader } from './render-helpers.svelte';
export { CreateTable, FlexRender, renderCells, renderHeader, CreateTable as DataTable };

View file

@ -0,0 +1,26 @@
import type {
CellContext,
HeaderContext,
RowData,
} from '@tanstack/table-core';
import type { Snippet } from 'svelte';
export function renderHeader<TData extends RowData, TValue>(
ctx: HeaderContext<TData, TValue>,
render: ((ctx: HeaderContext<TData, TValue>) => unknown) | undefined,
snippet?: Snippet<[{ props: Record<string, unknown> }]>
): unknown {
if (snippet) return snippet;
if (typeof render === 'function') return render(ctx);
return render;
}
export function renderCells<TData extends RowData, TValue>(
ctx: CellContext<TData, TValue>,
render: ((ctx: CellContext<TData, TValue>) => unknown) | undefined,
snippet?: Snippet<[{ props: Record<string, unknown> }]>
): unknown {
if (snippet) return snippet;
if (typeof render === 'function') return render(ctx);
return render;
}

View file

@ -0,0 +1,13 @@
import Root from 'bits-ui';
export {
Root,
Root as Dialog,
Root.Trigger as DialogTrigger,
Root.Portal as DialogPortal,
Root.Overlay as DialogOverlay,
Root.Content as DialogContent,
Root.Title as DialogTitle,
Root.Description as DialogDescription,
Root.Close as DialogClose,
};

View file

@ -0,0 +1,20 @@
import Root from 'bits-ui';
export {
Root,
Root as DropdownMenu,
Root.Trigger as DropdownMenuTrigger,
Root.Portal as DropdownMenuPortal,
Root.Content as DropdownMenuContent,
Root.Arrow as DropdownMenuArrow,
Root.Separator as DropdownMenuSeparator,
Root.Group as DropdownMenuGroup,
Root.GroupHeading as DropdownMenuGroupHeading,
Root.Item as DropdownMenuItem,
Root.CheckboxItem as DropdownMenuCheckboxItem,
Root.RadioGroup as DropdownMenuRadioGroup,
Root.RadioItem as DropdownMenuRadioItem,
Root.Sub as DropdownMenuSub,
Root.SubTrigger as DropdownMenuSubTrigger,
Root.SubContent as DropdownMenuSubContent,
};

View file

@ -0,0 +1,17 @@
<script lang="ts">
import Root from 'bits-ui';
import { cn, type WithoutChildren } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<Root.Field.ControlProps> = $props();
</script>
<Root.Field.Control
bind:ref
data-slot="field-control"
class={cn('flex flex-col gap-2', className)}
{...restProps}
/>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import Root from 'bits-ui';
import { cn, type WithoutChildren } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<Root.Field.DescriptionProps> = $props();
</script>
<Root.Field.Description
bind:ref
data-slot="field-description"
class={cn('text-muted-foreground text-sm', className)}
{...restProps}
/>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import Root from 'bits-ui';
import { cn, type WithoutChildren } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<Root.Field.ErrorsProps> = $props();
</script>
<Root.Field.Errors
bind:ref
data-slot="field-errors"
class={cn('text-destructive text-sm', className)}
{...restProps}
/>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import Root from 'bits-ui';
import { cn, type WithoutChildren } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<Root.Field.LabelProps> = $props();
</script>
<Root.Field.Label
bind:ref
data-slot="field-label"
class={cn('text-sm leading-none font-medium select-none', className)}
{...restProps}
/>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import Root from 'bits-ui';
import { cn, type WithoutChildren } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<Root.Field.LegendProps> = $props();
</script>
<Root.Field.Legend
bind:ref
data-slot="field-legend"
class={cn('text-sm leading-none font-medium select-none', className)}
{...restProps}
/>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import Root from 'bits-ui';
import { cn, type WithoutChild } from '$lib/utils/cn.svelte.js';
import type { Snippet } from 'svelte';
type Props = WithoutChild<Root.Field.RootProps> & {
class?: string;
children?: Snippet;
};
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: Props = $props();
</script>
<Root.Field.Root
bind:ref
data-slot="field-root"
class={cn('flex flex-col gap-2', className)}
{...restProps}
>
{children}
</Root.Field.Root>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import Root from 'bits-ui';
import { cn, type WithoutChildren } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<Root.Field.ValidationProps> = $props();
</script>
<Root.Field.Validation
bind:ref
data-slot="field-validation"
class={cn('text-destructive text-sm', className)}
{...restProps}
/>

View file

@ -0,0 +1,24 @@
import Root from 'bits-ui';
import FieldRoot from './field-root.svelte';
import FieldLabel from './field-label.svelte';
import FieldDescription from './field-description.svelte';
import FieldValidation from './field-validation.svelte';
import FieldControl from './field-control.svelte';
import FieldLegend from './field-legend.svelte';
import FieldErrors from './field-errors.svelte';
export {
Root,
FieldRoot,
FieldLabel,
FieldDescription,
FieldValidation,
FieldControl,
FieldLegend,
FieldErrors,
Root as Field,
FieldRoot as FieldRoot,
Root.Field.Set as FieldSet,
Root.Field.Set as FieldSetRoot,
};

View file

@ -0,0 +1,2 @@
import Root from './input.svelte';
export { Root, Root as Input };

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLInputAttributes } from 'svelte/elements';
type InputProps = WithElementRef<
Omit<HTMLInputAttributes, 'value'> & {
value?: string | number | null;
files?: FileList | null;
}
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
class: className,
...restProps
}: InputProps = $props();
</script>
<input
bind:this={ref}
bind:value
data-slot="input"
{type}
class={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
'file:text-foreground file:inline-flex file:border-0 file:bg-transparent file:text-sm file:font-medium',
className
)}
{...restProps}
/>

View file

@ -0,0 +1,2 @@
import Root from './label.svelte';
export { Root, Root as Label };

View file

@ -0,0 +1,20 @@
<script lang="ts">
import Root from 'bits-ui';
import { cn, type WithoutChildren } from '$lib/utils/cn.svelte.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<Root.Label.RootProps> = $props();
</script>
<Root.Label.Root
bind:ref
class={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50 group-data-[invalid]:text-destructive',
className
)}
{...restProps}
/>

View file

@ -0,0 +1,2 @@
import Root from './separator.svelte';
export { Root, Root as Separator };

View file

@ -0,0 +1,28 @@
<script lang="ts">
import Root from 'bits-ui';
import { cn, type WithoutChildren } from '$lib/utils/cn.svelte.js';
type Props = WithoutChildren<Root.Separator.RootProps> & {
class?: string;
};
let {
ref = $bindable(null),
class: className,
orientation = 'horizontal',
decorative = true,
...restProps
}: Props = $props();
</script>
<Root.Separator.Root
bind:ref
data-slot="separator"
{orientation}
{decorative}
class={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...restProps}
/>

View file

@ -0,0 +1,13 @@
import Root from 'bits-ui';
export {
Root,
Root as Sheet,
Root.Trigger as SheetTrigger,
Root.Portal as SheetPortal,
Root.Overlay as SheetOverlay,
Root.Content as SheetContent,
Root.Title as SheetTitle,
Root.Description as SheetDescription,
Root.Close as SheetClose,
};

View file

@ -0,0 +1,3 @@
export const SIDEBAR_WIDTH = '16rem';
export const SIDEBAR_WIDTH_ICON = '3rem';
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-content"
data-sidebar="content"
class={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,37 @@
import { getContext, setContext } from 'svelte';
import { browser } from '$app/environment';
import { SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from './constants.js';
export const SIDEBAR_ID = Symbol('sidebar');
export type SidebarVariant = 'sidebar' | 'floating' | 'inset';
export type SidebarCollapsible = 'offcanvas' | 'icon' | 'none';
export type SidebarProps = {
variant?: SidebarVariant;
collapsible?: SidebarCollapsible;
side?: 'left' | 'right';
};
export type SidebarContextValue = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
variant: SidebarVariant;
collapsible: SidebarCollapsible;
side: 'left' | 'right';
toggleSidebar: () => void;
};
export function useSidebar() {
return getContext<SidebarContextValue>(SIDEBAR_ID);
}
export function setSidebarContext(value: SidebarContextValue) {
setContext(SIDEBAR_ID, value);
}
export { SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON };

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-footer"
data-sidebar="footer"
class={cn('flex flex-col gap-2 p-2', className)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>> & { children?: Snippet } = $props();
</script>
<button
bind:this={ref}
data-slot="sidebar-group-action"
data-sidebar="group-action"
class={cn(
'text-sidebar-foreground/70 ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:hidden',
className
)}
{...restProps}
>
{children}
</button>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group-content"
data-sidebar="group-content"
class={cn('w-full text-sm', className)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group-label"
data-sidebar="group-label"
class={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-none transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:text-sidebar-accent-foreground',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className
)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group"
data-sidebar="group"
class={cn(
'group/sidebar-group relative flex w-full min-w-0 flex-col p-2',
className
)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-header"
data-sidebar="header"
class={cn('flex flex-col gap-2 p-2', className)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,82 @@
import { SIDEBAR_ID, setSidebarContext, useSidebar, type SidebarContextValue, type SidebarVariant, type SidebarCollapsible, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from './context.svelte.js';
import Provider from './provider.svelte';
import Root from './root.svelte';
import Header from './header.svelte';
import Footer from './footer.svelte';
import Content from './content.svelte';
import Group from './group.svelte';
import GroupLabel from './group-label.svelte';
import GroupAction from './group-action.svelte';
import GroupContent from './group-content.svelte';
import Menu from './menu.svelte';
import MenuItem from './menu-item.svelte';
import MenuButton from './menu-button.svelte';
import MenuAction from './menu-action.svelte';
import MenuBadge from './menu-badge.svelte';
import MenuSkeleton from './menu-skeleton.svelte';
import MenuSub from './menu-sub.svelte';
import MenuSubItem from './menu-sub-item.svelte';
import MenuSubButton from './menu-sub-button.svelte';
import MenuSubTrigger from './menu-sub-trigger.svelte';
import Rail from './rail.svelte';
import Separator from './separator.svelte';
import Input from './input.svelte';
import Trigger from './trigger.svelte';
import Inset from './inset.svelte';
export {
Provider,
Root,
Header,
Footer,
Content,
Group,
GroupLabel,
GroupAction,
GroupContent,
Menu,
MenuItem,
MenuButton,
MenuAction,
MenuBadge,
MenuSkeleton,
MenuSub,
MenuSubItem,
MenuSubButton,
MenuSubTrigger,
Rail,
Separator,
Input,
Trigger,
Inset,
useSidebar,
setSidebarContext,
SIDEBAR_ID,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
Root as Sidebar,
Provider as SidebarProvider,
Header as SidebarHeader,
Footer as SidebarFooter,
Content as SidebarContent,
Group as SidebarGroup,
GroupLabel as SidebarGroupLabel,
GroupAction as SidebarGroupAction,
GroupContent as SidebarGroupContent,
Menu as SidebarMenu,
MenuItem as SidebarMenuItem,
MenuButton as SidebarMenuButton,
MenuAction as SidebarMenuAction,
MenuBadge as SidebarMenuBadge,
MenuSkeleton as SidebarMenuSkeleton,
MenuSub as SidebarMenuSub,
MenuSubItem as SidebarMenuSubItem,
MenuSubButton as SidebarMenuSubButton,
MenuSubTrigger as SidebarMenuSubTrigger,
Rail as SidebarRail,
Separator as SidebarSeparator,
Input as SidebarInput,
Trigger as SidebarTrigger,
Inset as SidebarInset,
};

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLInputElement>> = $props();
</script>
<input
bind:this={ref}
data-slot="sidebar-input"
data-sidebar="input"
class={cn(
'bg-background border-input ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex h-8 w-full min-w-0 rounded-md border px-2 py-1 text-sm shadow-xs outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50',
className
)}
{...restProps}
/>

View file

@ -0,0 +1,28 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import { useSidebar } from './context.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
const sidebar = useSidebar();
</script>
<div
bind:this={ref}
data-slot="sidebar-inset"
class={cn(
'bg-background relative flex min-h-svh flex-1 flex-col',
'peer-data-[variant=inset]:shadow-xs peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 peer-data-[variant=inset]:ml-0 peer-data-[variant=inset]:peer-data-[side=right]:ml-2 peer-data-[variant=inset]:peer-data-[side=right]:mr-2 peer-data-[variant=inset]:peer-data-[side=right]:peer-data-[state=collapsed]:mr-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:peer-data-[side=right]:peer-data-[state=collapsed]:mr-2 md:peer-data-[variant=inset]:peer-data-[side=right]:ml-2 md:peer-data-[variant=inset]:peer-data-[side=right]:peer-data-[state=collapsed]:mr-2 md:peer-data-[variant=inset]:peer-data-[side=right]:mr-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
sidebar?.variant === 'inset' && 'ml-0',
className
)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>> & { children?: Snippet } = $props();
</script>
<button
bind:this={ref}
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
class={cn(
'text-sidebar-foreground/70 ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...restProps}
>
{children}
</button>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> & { children?: Snippet } = $props();
</script>
<span
bind:this={ref}
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
class={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=sm]/menu-button:top-1 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...restProps}
>
{children}
</span>

View file

@ -0,0 +1,28 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
import { useSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>> & { children?: Snippet } = $props();
const sidebar = useSidebar();
</script>
<button
bind:this={ref}
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
class={cn(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-invalid:border-destructive aria-invalid:text-destructive group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
className
)}
onclick={() => sidebar?.setOpenMobile(false)}
{...restProps}
>
{children}
</button>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>> & { children?: Snippet } = $props();
</script>
<li
bind:this={ref}
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
class={cn(
'group/menu-item relative',
className
)}
{...restProps}
>
{children}
</li>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
class={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
import { useSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>> & { children?: Snippet } = $props();
const sidebar = useSidebar();
</script>
<button
bind:this={ref}
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
class={cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground/70 outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:text-destructive [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:hidden',
className
)}
onclick={() => sidebar?.setOpenMobile(false)}
{...restProps}
>
{children}
</button>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>> & { children?: Snippet } = $props();
</script>
<li
bind:this={ref}
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
class={cn('group/menu-sub-item relative', className)}
{...restProps}
>
{children}
</li>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>> & { children?: Snippet } = $props();
</script>
<button
bind:this={ref}
data-slot="sidebar-menu-sub-trigger"
data-sidebar="menu-sub-trigger"
class={cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground/70 outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:text-destructive [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:hidden',
className
)}
{...restProps}
>
{children}
</button>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>> & { children?: Snippet } = $props();
</script>
<li
bind:this={ref}
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
class={cn('border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5', 'group-data-[collapsible=icon]:hidden', className)}
{...restProps}
>
{children}
</li>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLUListElement>> & { children?: Snippet } = $props();
</script>
<ul
bind:this={ref}
data-slot="sidebar-menu"
data-sidebar="menu"
class={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...restProps}
>
{children}
</ul>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { browser } from '$app/environment';
import { setSidebarContext, type SidebarContextValue, type SidebarProps } from './context.svelte.js';
let {
variant = 'sidebar',
collapsible = 'offcanvas',
side = 'left',
open = $bindable(false),
openMobile = $bindable(false),
children,
}: SidebarProps & {
open?: boolean;
openMobile?: boolean;
children?: import('svelte').Snippet;
} = $props();
let isMobile = $state(false);
if (browser) {
const mq = window.matchMedia('(max-width: 767px)');
isMobile = mq.matches;
const handler = (e: MediaQueryListEvent) => (isMobile = e.matches);
mq.addEventListener('change', handler);
}
const setOpen = (v: boolean) => (open = v);
const setOpenMobile = (v: boolean) => (openMobile = v);
const toggleSidebar = () => {
if (isMobile) {
setOpenMobile(!openMobile);
} else {
setOpen(!open);
}
};
const ctx: SidebarContextValue = {
get state() {
return open ? 'expanded' : 'collapsed';
},
get open() {
return open;
},
setOpen,
get openMobile() {
return openMobile;
},
setOpenMobile,
get isMobile() {
return isMobile;
},
variant,
collapsible,
side,
toggleSidebar,
};
setSidebarContext(ctx);
</script>
{@render children?.()}

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-rail"
data-sidebar="rail"
class={cn(
'bg-sidebar-border hover:bg-sidebar-border/50 absolute inset-y-0 z-20 hidden w-px transition-[opacity]',
'group-data-[side=left]:-right-4 group-data[side=right]:-left-4',
'group-data-[collapsible=icon]:hidden',
className
)}
{...restProps}
>
{children}
</div>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import { useSidebar } from './context.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
style,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children?: Snippet } = $props();
const sidebar = useSidebar();
</script>
{#if sidebar?.isMobile}
<div
bind:this={ref}
data-slot="sidebar"
data-sidebar="sidebar"
data-mobile="true"
class={cn(
'bg-background text-foreground fixed inset-y-0 z-50 flex w-(--sidebar-width) flex-col transition-transform duration-300',
sidebar.side === 'right' ? 'right-0' : 'left-0',
sidebar.openMobile
? 'translate-x-0'
: sidebar.side === 'right'
? 'translate-x-full'
: '-translate-x-full',
className
)}
style={style}
{...restProps}
>
{children}
</div>
{:else}
<div
bind:this={ref}
data-slot="sidebar"
data-sidebar="sidebar"
class={cn(
'bg-sidebar text-sidebar-foreground group/sidebar flex h-svh w-(--sidebar-width) flex-col transition-[width,transform] duration-200',
sidebar?.collapsible === 'icon' && !sidebar?.open && 'w-(--sidebar-width-icon)',
sidebar?.collapsible === 'offcanvas' && !sidebar?.open && '-translate-x-full',
sidebar?.side === 'right' ? 'ml-auto' : '',
className
)}
style={style}
{...restProps}
>
{children}
</div>
{/if}

View file

@ -0,0 +1,19 @@
<script lang="ts">
import SeparatorPrimitive from './separator.svelte';
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-separator"
data-sidebar="separator"
class={cn('bg-sidebar-border mx-2 w-auto', className)}
{...restProps}
></div>

View file

@ -0,0 +1,31 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import { useSidebar } from './context.svelte.js';
import type { HTMLAttributes, Snippet } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>> & { children?: Snippet } = $props();
const sidebar = useSidebar();
</script>
<button
bind:this={ref}
data-slot="sidebar-trigger"
data-sidebar="trigger"
class={cn('size-7 inline-flex items-center justify-center rounded-md p-0 text-sidebar-foreground/70 outline-none transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring', className)}
onclick={sidebar?.toggleSidebar}
{...restProps}
>
{children ?? (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
)}
</button>

View file

@ -0,0 +1,2 @@
import Root from './skeleton.svelte';
export { Root, Root as Skeleton };

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="skeleton"
class={cn('bg-muted animate-pulse rounded-md', className)}
{...restProps}
></div>

View file

@ -0,0 +1,3 @@
import { Sonner } from './sonner.svelte';
export { Sonner, Sonner as Toaster };

View file

@ -0,0 +1,21 @@
<script lang="ts" module>
export { default as Sonner } from './sonner.svelte';
</script>
<script lang="ts">
import { Toaster as SonnerPrimitive } from 'svelte-sonner';
import { cn } from '$lib/utils/cn.svelte.js';
import { mode } from 'mode-watcher';
let {
class: className,
...restProps
}: import('svelte-sonner').ToasterProps & { class?: string } = $props();
</script>
<SonnerPrimitive
data-slot="sonner"
class={cn('toaster group', className)}
theme={$mode}
{...restProps}
/>

View file

@ -0,0 +1,2 @@
import Root from './switch.svelte';
export { Root, Root as Switch };

View file

@ -0,0 +1,36 @@
<script lang="ts">
import Root from 'bits-ui';
import { cn, type WithoutChildren } from '$lib/utils/cn.svelte.js';
import type { Snippet } from 'svelte';
type Props = WithoutChildren<Root.Switch.RootProps> & {
children?: Snippet;
class?: string;
};
let {
ref = $bindable(null),
checked = $bindable(false),
class: className,
children,
...restProps
}: Props = $props();
</script>
<Root.Switch.Root
bind:ref
bind:checked
data-slot="switch"
class={cn(
'peer focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] data-[state=unchecked]:bg-input data-[state=checked]:bg-primary disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...restProps}
>
<Root.Switch.Thumb
data-slot="switch-thumb"
class="bg-background pointer-events-none block size-4 rounded-full ring-0 shadow-lg transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
>
{@render children?.()}
</Root.Switch.Thumb>
</Root.Switch.Root>

View file

@ -0,0 +1,2 @@
import Root from './textarea.svelte';
export { Root, Root as Textarea };

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/cn.svelte.js';
import type { HTMLTextareaAttributes } from 'svelte/elements';
type TextareaProps = WithElementRef<HTMLTextareaAttributes>;
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...restProps
}: TextareaProps = $props();
</script>
<textarea
bind:this={ref}
bind:value
data-slot="textarea"
class={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex field-sizing-content min-h-16 w-full rounded-md border px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...restProps}
></textarea>

View file

@ -0,0 +1,9 @@
import Root from 'bits-ui';
export {
Root,
Root as Tooltip,
Root.Provider as TooltipProvider,
Root.Trigger as TooltipTrigger,
Root.Content as TooltipContent,
};

View file

@ -0,0 +1,26 @@
import { browser } from '$app/environment';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const isMobile = $state(
browser ? window.innerWidth < MOBILE_BREAKPOINT : false
);
if (browser) {
const media = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const handler = (event: MediaQueryListEvent) => {
isMobile = event.matches;
};
media.addEventListener('change', handler);
$effect(() => {
return () => media.removeEventListener('change', handler);
});
}
return {
get current() {
return isMobile;
},
};
}

View file

@ -1,22 +0,0 @@
/**
* Counter store Svelte 5 runes ($state) in a *.svelte.js module.
* Import the store and use `.count` and `.increment()`.
*/
export function createCounter(initial = 0) {
let count = $state(initial);
return {
get count() {
return count;
},
increment() {
count += 1;
},
decrement() {
count -= 1;
},
reset() {
count = initial;
},
};
}

View file

@ -1,29 +0,0 @@
import { describe, it, expect } from 'vitest';
import { createCounter } from './counter.svelte.js';
describe('createCounter', () => {
it('starts at the initial value', () => {
const c = createCounter(5);
expect(c.count).toBe(5);
});
it('increments', () => {
const c = createCounter(0);
c.increment();
expect(c.count).toBe(1);
});
it('decrements', () => {
const c = createCounter(3);
c.decrement();
expect(c.count).toBe(2);
});
it('resets to initial', () => {
const c = createCounter(7);
c.increment();
c.increment();
c.reset();
expect(c.count).toBe(7);
});
});

View file

@ -0,0 +1,22 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
};
export type WithoutChildren<T> = T extends any
? 'children' extends keyof T
? Omit<T, 'children'>
: T
: T;
export type WithoutChild<T> = T extends any
? 'child' extends keyof T
? Omit<T, 'child'>
: T
: T;

View file

@ -1,11 +0,0 @@
/**
* Misc utility helpers.
*/
export function formatDate(date) {
return new Date(date).toLocaleDateString();
}
export function truncate(str, n) {
return str.length > n ? `${str.slice(0, n)}` : str;
}

View file

@ -1,20 +0,0 @@
import { describe, it, expect } from 'vitest';
import { formatDate, truncate } from './format.js';
describe('formatDate', () => {
it('formats a date string', () => {
const out = formatDate('2024-01-15');
expect(typeof out).toBe('string');
expect(out.length).toBeGreaterThan(0);
});
});
describe('truncate', () => {
it('returns the string when shorter than n', () => {
expect(truncate('abc', 10)).toBe('abc');
});
it('truncates with ellipsis when longer', () => {
expect(truncate('abcdef', 3)).toBe('abc…');
});
});

View file

@ -1,9 +1,9 @@
<script>
<script lang="ts">
import { page } from '$app/state';
</script>
<svelte:head>
<title>{page.status}{{ cookiecutter.project_name }}</title>
<title>{page.status}</title>
</svelte:head>
<h1>{page.status}</h1>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { ModeWatcher } from 'mode-watcher';
import AppSidebar from '$lib/components/app-sidebar.svelte';
import SiteHeader from '$lib/components/site-header.svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import type { Snippet } from 'svelte';
let { data, children }: { data: { title: string }; children?: Snippet } = $props();
</script>
<ModeWatcher />
<AppSidebar variant="inset" collapsible="icon" title={data.title}>
<Sidebar.Inset>
<SiteHeader title={data.title} />
<main class="flex flex-1 flex-col">
{@render children?.()}
</main>
</Sidebar.Inset>
</AppSidebar>

View file

@ -0,0 +1,5 @@
import type { LayoutLoad } from './$types';
export function load(): { title: string } {
return { title: '{{ cookiecutter.project_name }}' };
}

View file

@ -1,4 +0,0 @@
/** @type {import('./$types').PageLoad} */
export function load() {
return { title: '{{ cookiecutter.project_name }}' };
}

View file

@ -1,31 +1,62 @@
<script>
import Header from '$components/Header.svelte';
import { createCounter } from '$lib/stores/counter.svelte.js';
<script lang="ts">
import type { ColumnDef } from '@tanstack/table-core';
import SectionCards from '$lib/components/section-cards.svelte';
import * as Card from '$lib/components/ui/card/index.js';
import * as DataTable from '$lib/components/ui/data-table/index.js';
let { data } = $props();
let counter = $state(createCounter(0));
type Channel = {
id: string;
name: string;
status: 'active' | 'paused' | 'archived';
uploaded: number;
};
const columns: ColumnDef<Channel>[] = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'status', header: 'Status' },
{ accessorKey: 'uploaded', header: 'Uploads' },
];
const rows: Channel[] = [
{ id: 'CH-001', name: 'Main feed', status: 'active', uploaded: 1248 },
{ id: 'CH-002', name: 'Archive', status: 'paused', uploaded: 412 },
{ id: 'CH-003', name: 'Outreach', status: 'active', uploaded: 89 },
];
</script>
<svelte:head>
<title>{data.title}</title>
</svelte:head>
<Header title={data.title}>
<p>{{ cookiecutter.description }}</p>
</Header>
<SectionCards />
<main>
<p>Counter: {counter.count}</p>
<button onclick={() => counter.increment()}>+1</button>
<button onclick={() => counter.decrement()}>-1</button>
<button onclick={() => counter.reset()}>reset</button>
</main>
<style>
main {
padding: 1rem;
}
button {
margin: 0 0.25rem;
}
</style>
<Card.Root class="mx-4 mb-4">
<Card.Header>
<Card.Title>Channels</Card.Title>
<Card.Description>Recent channel activity.</Card.Description>
</Card.Header>
<Card.Content>
<table class="w-full text-sm">
<thead>
<tr class="text-muted-foreground border-b text-left">
{#each columns as col}
<th class="py-2 pr-4 font-medium">{col.header as string}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row (row.id)}
<tr class="border-b last:border-0">
<td class="py-2 pr-4">{row.id}</td>
<td class="py-2 pr-4">{row.name}</td>
<td class="py-2 pr-4">{row.status}</td>
<td class="py-2 pr-4">{row.uploaded}</td>
</tr>
{/each}
</tbody>
</table>
</Card.Content>
</Card.Root>

View file

@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
export function load(): { title: string } {
return { title: '{{ cookiecutter.project_name }}' };
}

View file

@ -8,7 +8,15 @@ const config = {
adapter: adapter(),
alias: {
$lib: 'src/lib',
$components: 'src/lib/components',
'$lib/*': 'src/lib/*',
'$lib/utils': 'src/lib/utils',
'$lib/utils/*': 'src/lib/utils/*',
'$lib/hooks': 'src/lib/hooks',
'$lib/hooks/*': 'src/lib/hooks/*',
'$lib/components': 'src/lib/components',
'$lib/components/*': 'src/lib/components/*',
'$lib/components/ui': 'src/lib/components/ui',
'$lib/components/ui/*': 'src/lib/components/ui/*',
},
},
};

View file

@ -1,12 +0,0 @@
import { expect, test } from '@playwright/test';
test('homepage shows the project title', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('{{ cookiecutter.project_name }}');
});
test('counter increments on click', async ({ page }) => {
await page.goto('/');
const buttons = page.getByRole('button');
await expect(buttons.first()).toBeVisible();
});

View file

@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('sidebar visible on dashboard load', async ({ page }) => {
await page.goto('/');
await expect(page.locator('[data-sidebar="sidebar"]')).toBeVisible();
});

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