diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e4c120..7f8b718 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Install Playwright Browsers run: npx playwright install --with-deps chromium - - run: npm run typecheck - run: npm run lint - run: npm run build + - run: npm run typecheck - run: npm run test diff --git a/.gitignore b/.gitignore index cec8deb..c8817be 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ storybook-static/ packages/registry/registry/ *storybook.log +.claude/scheduled_tasks.lock diff --git a/apps/docs/.storybook/globals.d.ts b/apps/docs/.storybook/globals.d.ts new file mode 100644 index 0000000..ef6d741 --- /dev/null +++ b/apps/docs/.storybook/globals.d.ts @@ -0,0 +1 @@ +declare module "*.css" {} diff --git a/apps/docs/src/stories/Accordion.stories.tsx b/apps/docs/src/stories/Accordion.stories.tsx new file mode 100644 index 0000000..f47db9c --- /dev/null +++ b/apps/docs/src/stories/Accordion.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "@webscit/registry"; + +const meta = { + title: "Components/Accordion", + component: Accordion, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + Is it accessible? + +
Yes. It adheres to the WAI-ARIA design pattern.
+
+
+ + Is it styled? + +
Yes. It comes with default styles using CSS scoping.
+
+
+ + Is it animated? + +
Yes. It uses CSS transitions for smooth expand/collapse.
+
+
+
+ ), +}; + +export const Playground: Story = { + render: () => ( + + + Section A + +
Content for section A.
+
+
+ + Section B + +
Content for section B.
+
+
+
+ ), +}; diff --git a/apps/docs/src/stories/AlertDialog.stories.tsx b/apps/docs/src/stories/AlertDialog.stories.tsx new file mode 100644 index 0000000..89ad0a5 --- /dev/null +++ b/apps/docs/src/stories/AlertDialog.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, + Button, +} from "@webscit/registry"; + +const meta = { + title: "Components/AlertDialog", + component: AlertDialog, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your + account and remove your data from our servers. + + + + + + + + + + + + + ), +}; diff --git a/apps/docs/src/stories/ButtonGroup.stories.tsx b/apps/docs/src/stories/ButtonGroup.stories.tsx new file mode 100644 index 0000000..260cc88 --- /dev/null +++ b/apps/docs/src/stories/ButtonGroup.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ButtonGroup, Button } from "@webscit/registry"; + +const meta = { + title: "Components/ButtonGroup", + component: ButtonGroup, + tags: ["autodocs"], + argTypes: { + orientation: { + control: "select", + options: ["horizontal", "vertical"], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + + ), +}; + +export const Vertical: Story = { + render: () => ( + + + + + + ), +}; + +export const Playground: Story = { + args: { orientation: "horizontal" }, + render: (args) => ( + + + + + + ), +}; diff --git a/apps/docs/src/stories/Collapsible.stories.tsx b/apps/docs/src/stories/Collapsible.stories.tsx new file mode 100644 index 0000000..13b5b9c --- /dev/null +++ b/apps/docs/src/stories/Collapsible.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, + Button, +} from "@webscit/registry"; + +const meta = { + title: "Components/Collapsible", + component: Collapsible, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + +
+ This content can be collapsed and expanded. +
+
+
+ ), +}; + +export const DefaultOpen: Story = { + render: () => ( + + + + + +
+ This starts expanded. +
+
+
+ ), +}; diff --git a/apps/docs/src/stories/Field.stories.tsx b/apps/docs/src/stories/Field.stories.tsx new file mode 100644 index 0000000..8f6f0c7 --- /dev/null +++ b/apps/docs/src/stories/Field.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + Field, + FieldLabel, + FieldDescription, + FieldError, + FieldSet, + FieldLegend, + FieldGroup, + FieldSeparator, + Input, +} from "@webscit/registry"; + +const meta = { + title: "Components/Field", + component: Field, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + Email + + We will never share your email. + + ), +}; + +export const WithError: Story = { + render: () => ( + + Username + + Username is required. + + ), +}; + +export const FieldSetExample: Story = { + render: () => ( +
+ Contact Information + + + First Name + + + + + Last Name + + + + + Email + + + +
+ ), +}; diff --git a/apps/docs/src/stories/InputGroup.stories.tsx b/apps/docs/src/stories/InputGroup.stories.tsx new file mode 100644 index 0000000..dce06d4 --- /dev/null +++ b/apps/docs/src/stories/InputGroup.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InputGroup, InputGroupAddon, Input } from "@webscit/registry"; + +const meta = { + title: "Components/InputGroup", + component: InputGroup, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + $ + + + ), +}; + +export const WithSuffix: Story = { + render: () => ( + + + @example.com + + ), +}; + +export const BothSides: Story = { + render: () => ( + + https:// + + /path + + ), +}; diff --git a/apps/docs/src/stories/Popover.stories.tsx b/apps/docs/src/stories/Popover.stories.tsx new file mode 100644 index 0000000..426ab2e --- /dev/null +++ b/apps/docs/src/stories/Popover.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + Popover, + PopoverTrigger, + PopoverContent, + PopoverClose, + Button, +} from "@webscit/registry"; + +const meta = { + title: "Components/Popover", + component: Popover, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + +

Dimensions

+

+ Set the dimensions for the layer. +

+
+
+ ), +}; + +export const WithClose: Story = { + render: () => ( + + + + + +

Some detailed information here.

+ + + +
+
+ ), +}; diff --git a/apps/docs/src/stories/Resizable.stories.tsx b/apps/docs/src/stories/Resizable.stories.tsx new file mode 100644 index 0000000..70f2354 --- /dev/null +++ b/apps/docs/src/stories/Resizable.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@webscit/registry"; + +const meta = { + title: "Components/Resizable", + component: ResizablePanelGroup, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + +
+ Panel One +
+
+ + +
+ Panel Two +
+
+
+ ), +}; + +export const Vertical: Story = { + render: () => ( + + +
+ Top +
+
+ + +
+ Bottom +
+
+
+ ), +}; + +export const ThreePanels: Story = { + render: () => ( + + +
+ Sidebar +
+
+ + +
+ Main +
+
+ + +
+ Details +
+
+
+ ), +}; diff --git a/apps/docs/src/stories/ScrollArea.stories.tsx b/apps/docs/src/stories/ScrollArea.stories.tsx new file mode 100644 index 0000000..5cefe12 --- /dev/null +++ b/apps/docs/src/stories/ScrollArea.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ScrollArea } from "@webscit/registry"; + +const meta = { + title: "Components/ScrollArea", + component: ScrollArea, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + +
+ {Array.from({ length: 30 }, (_, i) => ( +

Item {i + 1}

+ ))} +
+
+ ), +}; diff --git a/apps/docs/src/stories/Separator.stories.tsx b/apps/docs/src/stories/Separator.stories.tsx new file mode 100644 index 0000000..bed36df --- /dev/null +++ b/apps/docs/src/stories/Separator.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Separator } from "@webscit/registry"; + +const meta = { + title: "Components/Separator", + component: Separator, + tags: ["autodocs"], + argTypes: { + orientation: { + control: "select", + options: ["horizontal", "vertical"], + }, + decorative: { control: "boolean" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Vertical: Story = { + render: () => ( +
+ Left + + Right +
+ ), +}; + +export const Playground: Story = { + args: { orientation: "horizontal", decorative: true }, +}; diff --git a/apps/docs/src/stories/Sheet.stories.tsx b/apps/docs/src/stories/Sheet.stories.tsx new file mode 100644 index 0000000..91c7051 --- /dev/null +++ b/apps/docs/src/stories/Sheet.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + Sheet, + SheetTrigger, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, + SheetClose, + Button, +} from "@webscit/registry"; + +const meta = { + title: "Components/Sheet", + component: Sheet, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + + + Edit Profile + + Make changes to your profile here. + + +
+

Sheet content goes here.

+
+ + + + + + +
+
+ ), +}; + +export const Left: Story = { + render: () => ( + + + + + + + Left Sheet + +

This sheet slides from the left.

+
+
+ ), +}; + +export const Top: Story = { + render: () => ( + + + + + + + Top Sheet + +

This sheet slides from the top.

+
+
+ ), +}; + +export const Bottom: Story = { + render: () => ( + + + + + + + Bottom Sheet + +

This sheet slides from the bottom.

+
+
+ ), +}; diff --git a/apps/docs/src/stories/Sidebar.stories.tsx b/apps/docs/src/stories/Sidebar.stories.tsx new file mode 100644 index 0000000..18f99a0 --- /dev/null +++ b/apps/docs/src/stories/Sidebar.stories.tsx @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + SidebarProvider, + Sidebar, + SidebarHeader, + SidebarContent, + SidebarFooter, + SidebarTrigger, + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarSeparator, + SidebarInset, +} from "@webscit/registry"; + +const meta = { + title: "Components/Sidebar", + component: Sidebar, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + +

My App

+
+ + + Navigation + + + + Home + + + Dashboard + + + Settings + + + + + + + Projects + + + + Project Alpha + + + Project Beta + + + + + + +

v1.0.0

+
+
+ +
+ Toggle +

Main Content

+

This is the main content area next to the sidebar.

+
+
+
+ ), +}; + +export const CollapsedByDefault: Story = { + render: () => ( + + + + + + + + Home + + + + + + + +
+ Open +

Sidebar starts collapsed.

+
+
+
+ ), +}; + +export const RightSide: Story = { + render: () => ( + + +
+ Toggle +

Main Content

+
+
+ + + + Details + + + + Properties + + + History + + + + + + +
+ ), +}; diff --git a/apps/docs/src/stories/Skeleton.stories.tsx b/apps/docs/src/stories/Skeleton.stories.tsx new file mode 100644 index 0000000..6e8c13d --- /dev/null +++ b/apps/docs/src/stories/Skeleton.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Skeleton } from "@webscit/registry"; + +const meta = { + title: "Components/Skeleton", + component: Skeleton, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const Card: Story = { + render: () => ( +
+ +
+ + +
+
+ ), +}; diff --git a/apps/docs/src/stories/Slider.stories.tsx b/apps/docs/src/stories/Slider.stories.tsx new file mode 100644 index 0000000..94980d3 --- /dev/null +++ b/apps/docs/src/stories/Slider.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Slider } from "@webscit/registry"; + +const meta = { + title: "Components/Slider", + component: Slider, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+ +
+ ), +}; + +export const Range: Story = { + render: () => ( +
+ +
+ ), +}; + +export const Playground: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/apps/docs/src/stories/Table.stories.tsx b/apps/docs/src/stories/Table.stories.tsx new file mode 100644 index 0000000..b8436a9 --- /dev/null +++ b/apps/docs/src/stories/Table.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + Table, + TableHeader, + TableBody, + TableFooter, + TableRow, + TableHead, + TableCell, + TableCaption, +} from "@webscit/registry"; + +const meta = { + title: "Components/Table", + component: Table, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const invoices = [ + { invoice: "INV001", status: "Paid", method: "Credit Card", amount: "$250.00" }, + { invoice: "INV002", status: "Pending", method: "PayPal", amount: "$150.00" }, + { invoice: "INV003", status: "Unpaid", method: "Bank Transfer", amount: "$350.00" }, + { invoice: "INV004", status: "Paid", method: "Credit Card", amount: "$450.00" }, +]; + +export const Default: Story = { + render: () => ( + + A list of recent invoices. + + + Invoice + Status + Method + Amount + + + + {invoices.map((inv) => ( + + {inv.invoice} + {inv.status} + {inv.method} + {inv.amount} + + ))} + + + + Total + $1,200.00 + + +
+ ), +}; diff --git a/apps/docs/src/stories/Toast.stories.tsx b/apps/docs/src/stories/Toast.stories.tsx new file mode 100644 index 0000000..cdc21de --- /dev/null +++ b/apps/docs/src/stories/Toast.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + ToastProvider, + ToastViewport, + Toast, + useToastManager, + Button, +} from "@webscit/registry"; + +const meta: Meta = { + title: "Components/Toast", + component: Toast, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function ToastDemo() { + const toastManager = useToastManager(); + return ( +
+ + + + + +
+ ); +} + +export const Default: Story = { + render: () => , +}; diff --git a/docs/superpowers/plans/2026-04-09-new-components.md b/docs/superpowers/plans/2026-04-09-new-components.md new file mode 100644 index 0000000..13d9f98 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-new-components.md @@ -0,0 +1,6274 @@ +# New Components Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add 16 new components (accordion, alert-dialog, button-group, collapsible, field, input-group, popover, resizable, scroll-area, separator, sheet, sidebar, skeleton, slider, toast, table) to @webscit/toolkit with full tests and stories. + +**Architecture:** Components follow the existing shadcn-style pattern: each lives in `packages/registry/src/components//` with `.tsx`, `.css`, `.test.tsx`, and `registry.meta.json`. Base UI primitives are used where available; native HTML for layout-only components. CSS uses `@scope` with `data-*` attributes for variants. New design tokens are added for success/warning/info states and sidebar dimensions. + +**Tech Stack:** React 19, @base-ui/react 1.3.0, react-resizable-panels (new), Vitest browser mode, Storybook 10, Style Dictionary v5, W3C DTCG tokens. + +--- + +## File Structure + +### Tokens (modified) + +- `packages/tokens/src/base.tokens.json` — add green + amber primitive colors +- `packages/tokens/src/semantic.tokens.json` — add success/warning/info + sidebar tokens +- `packages/tokens/src/semantic-dark.tokens.json` — add dark overrides + +### New Components (created) + +Each component creates 4 files in `packages/registry/src/components//`: + +| Component | Files | +|-----------|-------| +| separator | `separator.tsx`, `separator.css`, `separator.test.tsx`, `registry.meta.json` | +| skeleton | `skeleton.tsx`, `skeleton.css`, `skeleton.test.tsx`, `registry.meta.json` | +| scroll-area | `scroll-area.tsx`, `scroll-area.css`, `scroll-area.test.tsx`, `registry.meta.json` | +| table | `table.tsx`, `table.css`, `table.test.tsx`, `registry.meta.json` | +| button-group | `button-group.tsx`, `button-group.css`, `button-group.test.tsx`, `registry.meta.json` | +| input-group | `input-group.tsx`, `input-group.css`, `input-group.test.tsx`, `registry.meta.json` | +| popover | `popover.tsx`, `popover.css`, `popover.test.tsx`, `registry.meta.json` | +| slider | `slider.tsx`, `slider.css`, `slider.test.tsx`, `registry.meta.json` | +| collapsible | `collapsible.tsx`, `collapsible.css`, `collapsible.test.tsx`, `registry.meta.json` | +| accordion | `accordion.tsx`, `accordion.css`, `accordion.test.tsx`, `registry.meta.json` | +| alert-dialog | `alert-dialog.tsx`, `alert-dialog.css`, `alert-dialog.test.tsx`, `registry.meta.json` | +| sheet | `sheet.tsx`, `sheet.css`, `sheet.test.tsx`, `registry.meta.json` | +| resizable | `resizable.tsx`, `resizable.css`, `resizable.test.tsx`, `registry.meta.json` | +| toast | `toast.tsx`, `toast.css`, `toast.test.tsx`, `registry.meta.json` | +| field | `field.tsx`, `field.css`, `field.test.tsx`, `registry.meta.json` | +| sidebar | `sidebar.tsx`, `sidebar.css`, `sidebar.test.tsx`, `registry.meta.json` | + +### Modified Files + +- `packages/registry/src/index.ts` — add exports for all new components +- `packages/registry/package.json` — add `react-resizable-panels` dependency + +### Stories (created) + +Each in `apps/docs/src/stories/`: + +`Separator.stories.tsx`, `Skeleton.stories.tsx`, `ScrollArea.stories.tsx`, `Table.stories.tsx`, `ButtonGroup.stories.tsx`, `InputGroup.stories.tsx`, `Popover.stories.tsx`, `Slider.stories.tsx`, `Collapsible.stories.tsx`, `Accordion.stories.tsx`, `AlertDialog.stories.tsx`, `Sheet.stories.tsx`, `Resizable.stories.tsx`, `Toast.stories.tsx`, `Field.stories.tsx`, `Sidebar.stories.tsx` + +--- + +## Task 1: Token Additions + +**Files:** +- Modify: `packages/tokens/src/base.tokens.json` +- Modify: `packages/tokens/src/semantic.tokens.json` +- Modify: `packages/tokens/src/semantic-dark.tokens.json` + +- [ ] **Step 1: Add green and amber primitives to base tokens** + +Add these entries inside `sct.color` in `packages/tokens/src/base.tokens.json`, after the `"red"` block: + +```json +"green": { + "light": { "$type": "color", "$value": "#4caf50" }, + "base": { "$type": "color", "$value": "#2e7d32" }, + "dark": { "$type": "color", "$value": "#1b5e20" } +}, +"amber": { + "light": { "$type": "color", "$value": "#ffb300" }, + "base": { "$type": "color", "$value": "#f57f17" }, + "dark": { "$type": "color", "$value": "#e65100" } +} +``` + +- [ ] **Step 2: Add semantic tokens for success/warning/info and sidebar** + +Add these entries inside `sct.color` in `packages/tokens/src/semantic.tokens.json`, after the `"secondary-foreground"` entry: + +```json +"success": { "$type": "color", "$value": "{sct.color.green.base}" }, +"success-foreground": { "$type": "color", "$value": "{sct.color.neutral.0}" }, +"warning": { "$type": "color", "$value": "{sct.color.amber.base}" }, +"warning-foreground": { "$type": "color", "$value": "{sct.color.neutral.950}" }, +"info": { "$type": "color", "$value": "{sct.color.blue.base}" }, +"info-foreground": { "$type": "color", "$value": "{sct.color.neutral.0}" } +``` + +Add a new `sct.sidebar` section at the same level as `sct.color` in `packages/tokens/src/semantic.tokens.json`: + +```json +"sidebar": { + "width": { "$type": "dimension", "$value": "256px" }, + "width-collapsed": { "$type": "dimension", "$value": "48px" } +} +``` + +- [ ] **Step 3: Add dark theme overrides** + +Add these entries inside `sct.color` in `packages/tokens/src/semantic-dark.tokens.json`, after the `"secondary-foreground"` entry: + +```json +"success": { "$type": "color", "$value": "{sct.color.green.light}" }, +"warning": { "$type": "color", "$value": "{sct.color.amber.light}" }, +"warning-foreground": { "$type": "color", "$value": "{sct.color.neutral.950}" }, +"info": { "$type": "color", "$value": "{sct.color.blue.light}" } +``` + +Note: `success-foreground` and `info-foreground` remain `neutral.0` in dark mode (same as light), so no override needed. + +- [ ] **Step 4: Build tokens and verify** + +Run: `npm run build -w packages/tokens` +Expected: `✓ tokens built` + +Verify the output includes new tokens: + +Run: `grep -E "sct-color-(success|warning|info)|sct-sidebar" packages/tokens/dist/tokens.css` +Expected: Lines containing `--sct-color-success`, `--sct-color-warning`, `--sct-color-info`, `--sct-sidebar-width`, `--sct-sidebar-width-collapsed` + +- [ ] **Step 5: Commit** + +```bash +git add packages/tokens/src/base.tokens.json packages/tokens/src/semantic.tokens.json packages/tokens/src/semantic-dark.tokens.json +git commit -m "feat(tokens): add success/warning/info colors and sidebar dimension tokens" +``` + +--- + +## Task 2: Install react-resizable-panels + +**Files:** +- Modify: `packages/registry/package.json` + +- [ ] **Step 1: Add dependency** + +Run: `npm install react-resizable-panels -w packages/registry` + +- [ ] **Step 2: Verify installation** + +Run: `node -e "require.resolve('react-resizable-panels')"` +Expected: resolves without error + +- [ ] **Step 3: Commit** + +```bash +git add packages/registry/package.json package-lock.json +git commit -m "chore(registry): add react-resizable-panels dependency" +``` + +--- + +## Task 3: Separator + +**Files:** +- Create: `packages/registry/src/components/separator/separator.tsx` +- Create: `packages/registry/src/components/separator/separator.css` +- Create: `packages/registry/src/components/separator/separator.test.tsx` +- Create: `packages/registry/src/components/separator/registry.meta.json` + +- [ ] **Step 1: Write the test** + +Create `packages/registry/src/components/separator/separator.test.tsx`: + +```tsx +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { Separator } from "./separator"; + +describe("Separator", () => { + it("renders without crashing", async () => { + const screen = await render(); + await expect.element(screen.getByRole("none")).toBeInTheDocument(); + }); + + it("renders as separator role when not decorative", async () => { + const screen = await render(); + await expect.element(screen.getByRole("separator")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render(); + await expect + .element(screen.getByRole("separator")) + .toHaveAttribute("data-slot", "separator"); + }); + + it("defaults to horizontal orientation", async () => { + const screen = await render(); + await expect + .element(screen.getByRole("separator")) + .toHaveAttribute("data-orientation", "horizontal"); + }); + + it("supports vertical orientation", async () => { + const screen = await render( + , + ); + await expect + .element(screen.getByRole("separator")) + .toHaveAttribute("data-orientation", "vertical"); + }); + + it("forwards className after scope anchor", async () => { + const screen = await render( + , + ); + await expect + .element(screen.getByRole("separator")) + .toHaveClass("sct-separator my-class"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/registry && npx vitest run src/components/separator/separator.test.tsx` +Expected: FAIL — module not found + +- [ ] **Step 3: Write the component** + +Create `packages/registry/src/components/separator/separator.tsx`: + +```tsx +import "./separator.css"; + +export interface SeparatorProps extends React.HTMLAttributes { + orientation?: "horizontal" | "vertical"; + decorative?: boolean; +} + +export function Separator({ + orientation = "horizontal", + decorative = true, + className, + ...props +}: SeparatorProps) { + return ( +
+ ); +} +``` + +- [ ] **Step 4: Write the CSS** + +Create `packages/registry/src/components/separator/separator.css`: + +```css +@scope (.sct-separator) { + :scope { + flex-shrink: 0; + background-color: var(--sct-color-border); + } + + :scope[data-orientation="horizontal"] { + height: 1px; + width: 100%; + } + + :scope[data-orientation="vertical"] { + width: 1px; + height: 100%; + } +} +``` + +- [ ] **Step 5: Write registry metadata** + +Create `packages/registry/src/components/separator/registry.meta.json`: + +```json +{ + "title": "Separator", + "description": "A visual divider between content sections.", + "dependencies": [], + "devDependencies": [], + "registryDependencies": [] +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd packages/registry && npx vitest run src/components/separator/separator.test.tsx` +Expected: All 6 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/registry/src/components/separator/ +git commit -m "feat(registry): add separator component" +``` + +--- + +## Task 4: Skeleton + +**Files:** +- Create: `packages/registry/src/components/skeleton/skeleton.tsx` +- Create: `packages/registry/src/components/skeleton/skeleton.css` +- Create: `packages/registry/src/components/skeleton/skeleton.test.tsx` +- Create: `packages/registry/src/components/skeleton/registry.meta.json` + +- [ ] **Step 1: Write the test** + +Create `packages/registry/src/components/skeleton/skeleton.test.tsx`: + +```tsx +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { Skeleton } from "./skeleton"; + +describe("Skeleton", () => { + it("renders without crashing", async () => { + const screen = await render(); + await expect.element(screen.getByTestId("skel")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render(); + await expect + .element(screen.getByTestId("skel")) + .toHaveAttribute("data-slot", "skeleton"); + }); + + it("forwards className after scope anchor", async () => { + const screen = await render( + , + ); + await expect + .element(screen.getByTestId("skel")) + .toHaveClass("sct-skeleton my-class"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/registry && npx vitest run src/components/skeleton/skeleton.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Write the component** + +Create `packages/registry/src/components/skeleton/skeleton.tsx`: + +```tsx +import "./skeleton.css"; + +export type SkeletonProps = React.HTMLAttributes; + +export function Skeleton({ className, ...props }: SkeletonProps) { + return ( +
+ ); +} +``` + +- [ ] **Step 4: Write the CSS** + +Create `packages/registry/src/components/skeleton/skeleton.css`: + +```css +@keyframes sct-skeleton-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +@scope (.sct-skeleton) { + :scope { + background-color: var(--sct-color-muted); + border-radius: var(--sct-radius-md); + animation: sct-skeleton-pulse 2s ease-in-out infinite; + } +} +``` + +- [ ] **Step 5: Write registry metadata** + +Create `packages/registry/src/components/skeleton/registry.meta.json`: + +```json +{ + "title": "Skeleton", + "description": "A placeholder loading animation.", + "dependencies": [], + "devDependencies": [], + "registryDependencies": [] +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd packages/registry && npx vitest run src/components/skeleton/skeleton.test.tsx` +Expected: All 3 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/registry/src/components/skeleton/ +git commit -m "feat(registry): add skeleton component" +``` + +--- + +## Task 5: Scroll Area + +**Files:** +- Create: `packages/registry/src/components/scroll-area/scroll-area.tsx` +- Create: `packages/registry/src/components/scroll-area/scroll-area.css` +- Create: `packages/registry/src/components/scroll-area/scroll-area.test.tsx` +- Create: `packages/registry/src/components/scroll-area/registry.meta.json` + +- [ ] **Step 1: Write the test** + +Create `packages/registry/src/components/scroll-area/scroll-area.test.tsx`: + +```tsx +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { ScrollArea, ScrollBar } from "./scroll-area"; + +describe("ScrollArea", () => { + it("renders without crashing", async () => { + const screen = await render( + +
Content
+
, + ); + await expect.element(screen.getByTestId("scroll")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render( + +
Content
+
, + ); + await expect + .element(screen.getByTestId("scroll")) + .toHaveAttribute("data-slot", "scroll-area"); + }); + + it("forwards className after scope anchor", async () => { + const screen = await render( + +
Content
+
, + ); + await expect + .element(screen.getByTestId("scroll")) + .toHaveClass("sct-scroll-area my-class"); + }); +}); + +describe("ScrollBar", () => { + it("renders without crashing", async () => { + const screen = await render(); + await expect.element(screen.getByTestId("bar")).toBeInTheDocument(); + }); + + it("defaults to vertical orientation", async () => { + const screen = await render(); + await expect + .element(screen.getByTestId("bar")) + .toHaveAttribute("data-orientation", "vertical"); + }); + + it("supports horizontal orientation", async () => { + const screen = await render( + , + ); + await expect + .element(screen.getByTestId("bar")) + .toHaveAttribute("data-orientation", "horizontal"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/registry && npx vitest run src/components/scroll-area/scroll-area.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Write the component** + +Create `packages/registry/src/components/scroll-area/scroll-area.tsx`: + +```tsx +import "./scroll-area.css"; + +export interface ScrollAreaProps extends React.HTMLAttributes {} + +export function ScrollArea({ className, children, ...props }: ScrollAreaProps) { + return ( +
+
+ {children} +
+
+ ); +} + +export interface ScrollBarProps extends React.HTMLAttributes { + orientation?: "vertical" | "horizontal"; +} + +export function ScrollBar({ + orientation = "vertical", + className, + ...props +}: ScrollBarProps) { + return ( +
+ ); +} +``` + +- [ ] **Step 4: Write the CSS** + +Create `packages/registry/src/components/scroll-area/scroll-area.css`: + +```css +@scope (.sct-scroll-area) { + :scope { + position: relative; + overflow: hidden; + } + + .sct-scroll-area-viewport { + height: 100%; + width: 100%; + overflow: auto; + scrollbar-width: thin; + scrollbar-color: var(--sct-color-border) transparent; + } + + .sct-scroll-area-viewport::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .sct-scroll-area-viewport::-webkit-scrollbar-track { + background: transparent; + } + + .sct-scroll-area-viewport::-webkit-scrollbar-thumb { + background-color: var(--sct-color-border); + border-radius: var(--sct-radius-full); + border: 2px solid transparent; + background-clip: content-box; + } + + .sct-scroll-area-viewport::-webkit-scrollbar-thumb:hover { + background-color: var(--sct-color-muted-foreground); + } +} + +@scope (.sct-scroll-bar) { + :scope { + display: flex; + touch-action: none; + user-select: none; + padding: 1px; + } + + :scope[data-orientation="vertical"] { + width: 8px; + border-left: 1px solid transparent; + } + + :scope[data-orientation="horizontal"] { + height: 8px; + flex-direction: column; + border-top: 1px solid transparent; + } +} +``` + +- [ ] **Step 5: Write registry metadata** + +Create `packages/registry/src/components/scroll-area/registry.meta.json`: + +```json +{ + "title": "Scroll Area", + "description": "A scrollable area with custom styled scrollbars.", + "dependencies": [], + "devDependencies": [], + "registryDependencies": [] +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd packages/registry && npx vitest run src/components/scroll-area/scroll-area.test.tsx` +Expected: All 6 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/registry/src/components/scroll-area/ +git commit -m "feat(registry): add scroll-area component" +``` + +--- + +## Task 6: Table + +**Files:** +- Create: `packages/registry/src/components/table/table.tsx` +- Create: `packages/registry/src/components/table/table.css` +- Create: `packages/registry/src/components/table/table.test.tsx` +- Create: `packages/registry/src/components/table/registry.meta.json` + +- [ ] **Step 1: Write the test** + +Create `packages/registry/src/components/table/table.test.tsx`: + +```tsx +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { + Table, + TableHeader, + TableBody, + TableFooter, + TableRow, + TableHead, + TableCell, + TableCaption, +} from "./table"; + +describe("Table", () => { + it("renders a complete table without crashing", async () => { + const screen = await render( + + A test table + + + Name + + + + + Alice + + + + + Total + + +
, + ); + await expect.element(screen.getByRole("table")).toBeInTheDocument(); + }); + + it("sets data-slot on table", async () => { + const screen = await render( + + + + A + + +
, + ); + await expect + .element(screen.getByRole("table")) + .toHaveAttribute("data-slot", "table"); + }); + + it("forwards className on table", async () => { + const screen = await render( + + + + A + + +
, + ); + await expect + .element(screen.getByRole("table")) + .toHaveClass("sct-table my-class"); + }); + + it("renders column headers with correct role", async () => { + const screen = await render( + + + + Col + + + + + Val + + +
, + ); + await expect + .element(screen.getByRole("columnheader")) + .toBeInTheDocument(); + }); + + it("renders caption", async () => { + const screen = await render( + + My caption + + + A + + +
, + ); + await expect.element(screen.getByRole("caption")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/registry && npx vitest run src/components/table/table.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Write the component** + +Create `packages/registry/src/components/table/table.tsx`: + +```tsx +import "./table.css"; + +export type TableProps = React.HTMLAttributes; + +export function Table({ className, ...props }: TableProps) { + return ( +
+ + + ); +} + +export type TableHeaderProps = React.HTMLAttributes; + +export function TableHeader({ className, ...props }: TableHeaderProps) { + return ( + + ); +} + +export type TableBodyProps = React.HTMLAttributes; + +export function TableBody({ className, ...props }: TableBodyProps) { + return ( + + ); +} + +export type TableFooterProps = React.HTMLAttributes; + +export function TableFooter({ className, ...props }: TableFooterProps) { + return ( + + ); +} + +export type TableRowProps = React.HTMLAttributes; + +export function TableRow({ className, ...props }: TableRowProps) { + return ( + + ); +} + +export type TableHeadProps = React.ThHTMLAttributes; + +export function TableHead({ className, ...props }: TableHeadProps) { + return ( +
+ ); +} + +export type TableCellProps = React.TdHTMLAttributes; + +export function TableCell({ className, ...props }: TableCellProps) { + return ( + + ); +} + +export type TableCaptionProps = React.HTMLAttributes; + +export function TableCaption({ className, ...props }: TableCaptionProps) { + return ( +
+ ); +} +``` + +- [ ] **Step 4: Write the CSS** + +Create `packages/registry/src/components/table/table.css`: + +```css +.sct-table-wrapper { + position: relative; + width: 100%; + overflow: auto; +} + +@scope (.sct-table) { + :scope { + width: 100%; + caption-side: bottom; + border-collapse: collapse; + font-size: var(--sct-font-size-sm); + } + + .sct-table-header { + border-bottom: 1px solid var(--sct-color-border); + } + + .sct-table-header .sct-table-row:hover { + background-color: transparent; + } + + .sct-table-body .sct-table-row:last-child { + border-bottom: none; + } + + .sct-table-footer { + border-top: 1px solid var(--sct-color-border); + background-color: var(--sct-color-muted); + font-weight: var(--sct-font-weight-medium); + } + + .sct-table-row { + border-bottom: 1px solid var(--sct-color-border); + transition: background-color 150ms; + } + + .sct-table-row:hover { + background-color: var(--sct-color-muted); + } + + .sct-table-head { + height: 2.5rem; + padding-inline: 1rem; + text-align: left; + vertical-align: middle; + font-weight: var(--sct-font-weight-medium); + color: var(--sct-color-muted-foreground); + } + + .sct-table-cell { + padding: 0.75rem 1rem; + vertical-align: middle; + } + + .sct-table-caption { + margin-top: 1rem; + font-size: var(--sct-font-size-sm); + color: var(--sct-color-muted-foreground); + } +} +``` + +- [ ] **Step 5: Write registry metadata** + +Create `packages/registry/src/components/table/registry.meta.json`: + +```json +{ + "title": "Table", + "description": "A semantic HTML table with styled rows and cells.", + "dependencies": [], + "devDependencies": [], + "registryDependencies": [] +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd packages/registry && npx vitest run src/components/table/table.test.tsx` +Expected: All 5 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/registry/src/components/table/ +git commit -m "feat(registry): add table component" +``` + +--- + +## Task 7: Button Group + +**Files:** +- Create: `packages/registry/src/components/button-group/button-group.tsx` +- Create: `packages/registry/src/components/button-group/button-group.css` +- Create: `packages/registry/src/components/button-group/button-group.test.tsx` +- Create: `packages/registry/src/components/button-group/registry.meta.json` + +- [ ] **Step 1: Write the test** + +Create `packages/registry/src/components/button-group/button-group.test.tsx`: + +```tsx +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { ButtonGroup } from "./button-group"; + +describe("ButtonGroup", () => { + it("renders without crashing", async () => { + const screen = await render( + + + + , + ); + await expect.element(screen.getByRole("group")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render( + + + , + ); + await expect + .element(screen.getByRole("group")) + .toHaveAttribute("data-slot", "button-group"); + }); + + it("defaults to horizontal orientation", async () => { + const screen = await render( + + + , + ); + await expect + .element(screen.getByRole("group")) + .toHaveAttribute("data-orientation", "horizontal"); + }); + + it("supports vertical orientation", async () => { + const screen = await render( + + + , + ); + await expect + .element(screen.getByRole("group")) + .toHaveAttribute("data-orientation", "vertical"); + }); + + it("forwards className after scope anchor", async () => { + const screen = await render( + + + , + ); + await expect + .element(screen.getByRole("group")) + .toHaveClass("sct-button-group my-class"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/registry && npx vitest run src/components/button-group/button-group.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Write the component** + +Create `packages/registry/src/components/button-group/button-group.tsx`: + +```tsx +import "./button-group.css"; + +export interface ButtonGroupProps extends React.HTMLAttributes { + orientation?: "horizontal" | "vertical"; +} + +export function ButtonGroup({ + orientation = "horizontal", + className, + ...props +}: ButtonGroupProps) { + return ( +
+ ); +} +``` + +- [ ] **Step 4: Write the CSS** + +Create `packages/registry/src/components/button-group/button-group.css`: + +```css +@scope (.sct-button-group) { + :scope { + display: inline-flex; + } + + :scope[data-orientation="vertical"] { + flex-direction: column; + } + + /* Flatten inner border-radius for horizontal */ + :scope[data-orientation="horizontal"] > :not(:first-child):not(:last-child) { + border-radius: 0; + } + + :scope[data-orientation="horizontal"] > :first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + :scope[data-orientation="horizontal"] > :last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + /* Flatten inner border-radius for vertical */ + :scope[data-orientation="vertical"] > :not(:first-child):not(:last-child) { + border-radius: 0; + } + + :scope[data-orientation="vertical"] > :first-child { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + :scope[data-orientation="vertical"] > :last-child { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + /* Collapse borders between siblings */ + :scope[data-orientation="horizontal"] > :not(:first-child) { + margin-left: -1px; + } + + :scope[data-orientation="vertical"] > :not(:first-child) { + margin-top: -1px; + } +} +``` + +- [ ] **Step 5: Write registry metadata** + +Create `packages/registry/src/components/button-group/registry.meta.json`: + +```json +{ + "title": "Button Group", + "description": "Groups buttons together with connected styling.", + "dependencies": [], + "devDependencies": [], + "registryDependencies": [] +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd packages/registry && npx vitest run src/components/button-group/button-group.test.tsx` +Expected: All 5 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/registry/src/components/button-group/ +git commit -m "feat(registry): add button-group component" +``` + +--- + +## Task 8: Input Group + +**Files:** +- Create: `packages/registry/src/components/input-group/input-group.tsx` +- Create: `packages/registry/src/components/input-group/input-group.css` +- Create: `packages/registry/src/components/input-group/input-group.test.tsx` +- Create: `packages/registry/src/components/input-group/registry.meta.json` + +- [ ] **Step 1: Write the test** + +Create `packages/registry/src/components/input-group/input-group.test.tsx`: + +```tsx +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { InputGroup, InputGroupAddon } from "./input-group"; + +describe("InputGroup", () => { + it("renders without crashing", async () => { + const screen = await render( + + + , + ); + await expect.element(screen.getByTestId("ig")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render( + + + , + ); + await expect + .element(screen.getByTestId("ig")) + .toHaveAttribute("data-slot", "input-group"); + }); + + it("forwards className after scope anchor", async () => { + const screen = await render( + + + , + ); + await expect + .element(screen.getByTestId("ig")) + .toHaveClass("sct-input-group my-class"); + }); +}); + +describe("InputGroupAddon", () => { + it("renders without crashing", async () => { + const screen = await render( + $, + ); + await expect.element(screen.getByTestId("addon")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render( + $, + ); + await expect + .element(screen.getByTestId("addon")) + .toHaveAttribute("data-slot", "input-group-addon"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/registry && npx vitest run src/components/input-group/input-group.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Write the component** + +Create `packages/registry/src/components/input-group/input-group.tsx`: + +```tsx +import "./input-group.css"; + +export type InputGroupProps = React.HTMLAttributes; + +export function InputGroup({ className, ...props }: InputGroupProps) { + return ( +
+ ); +} + +export type InputGroupAddonProps = React.HTMLAttributes; + +export function InputGroupAddon({ className, ...props }: InputGroupAddonProps) { + return ( +
+ ); +} +``` + +- [ ] **Step 4: Write the CSS** + +Create `packages/registry/src/components/input-group/input-group.css`: + +```css +@scope (.sct-input-group) { + :scope { + display: flex; + align-items: stretch; + } + + /* Flatten inner radii */ + :scope > :not(:first-child):not(:last-child) { + border-radius: 0; + } + + :scope > :first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + :scope > :last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + /* Collapse borders */ + :scope > :not(:first-child) { + margin-left: -1px; + } + + .sct-input-group-addon { + display: flex; + align-items: center; + padding-inline: 0.75rem; + border: 1px solid var(--sct-color-input); + background-color: var(--sct-color-muted); + font-size: var(--sct-font-size-sm); + color: var(--sct-color-muted-foreground); + white-space: nowrap; + } +} +``` + +- [ ] **Step 5: Write registry metadata** + +Create `packages/registry/src/components/input-group/registry.meta.json`: + +```json +{ + "title": "Input Group", + "description": "Groups an input with prefix/suffix addons.", + "dependencies": [], + "devDependencies": [], + "registryDependencies": ["input"] +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd packages/registry && npx vitest run src/components/input-group/input-group.test.tsx` +Expected: All 5 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/registry/src/components/input-group/ +git commit -m "feat(registry): add input-group component" +``` + +--- + +## Task 9: Popover + +**Files:** +- Create: `packages/registry/src/components/popover/popover.tsx` +- Create: `packages/registry/src/components/popover/popover.css` +- Create: `packages/registry/src/components/popover/popover.test.tsx` +- Create: `packages/registry/src/components/popover/registry.meta.json` + +- [ ] **Step 1: Write the test** + +Create `packages/registry/src/components/popover/popover.test.tsx`: + +```tsx +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { userEvent } from "@vitest/browser/context"; +import { + Popover, + PopoverTrigger, + PopoverContent, + PopoverClose, +} from "./popover"; + +describe("Popover", () => { + it("renders trigger without crashing", async () => { + const screen = await render( + + Open + Content + , + ); + await expect + .element(screen.getByRole("button", { name: "Open" })) + .toBeInTheDocument(); + }); + + it("sets data-slot on trigger", async () => { + const screen = await render( + + Open + Content + , + ); + await expect + .element(screen.getByRole("button", { name: "Open" })) + .toHaveAttribute("data-slot", "popover-trigger"); + }); + + it("opens popover on trigger click", async () => { + const screen = await render( + + Open + +

Popover body

+
+
, + ); + await userEvent.click(screen.getByRole("button", { name: "Open" })); + await expect.element(screen.getByText("Popover body")).toBeInTheDocument(); + }); + + it("renders close button inside content", async () => { + const screen = await render( + + Open + + Close + + , + ); + await expect + .element(screen.getByRole("button", { name: "Close" })) + .toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/registry && npx vitest run src/components/popover/popover.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Write the component** + +Create `packages/registry/src/components/popover/popover.tsx`: + +```tsx +import { Popover as BasePopover } from "@base-ui/react/popover"; +import "./popover.css"; + +export const Popover = BasePopover.Root; +export type PopoverProps = React.ComponentProps; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface PopoverTriggerProps + extends React.ButtonHTMLAttributes {} + +export function PopoverTrigger({ ...props }: PopoverTriggerProps) { + return ; +} + +export interface PopoverContentProps + extends React.HTMLAttributes {} + +export function PopoverContent({ + className, + children, + ...props +}: PopoverContentProps) { + return ( + + + + {children} + + + + ); +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface PopoverCloseProps + extends React.ButtonHTMLAttributes {} + +export function PopoverClose({ ...props }: PopoverCloseProps) { + return ; +} +``` + +- [ ] **Step 4: Write the CSS** + +Create `packages/registry/src/components/popover/popover.css`: + +```css +@scope (.sct-popover-content) { + :scope { + z-index: 50; + width: 18rem; + background-color: var(--sct-color-background); + border: 1px solid var(--sct-color-border); + border-radius: var(--sct-radius-lg); + padding: 1rem; + box-shadow: var(--sct-shadow-lg); + outline: none; + transition: + opacity 200ms, + transform 200ms; + } + + @starting-style { + :scope { + opacity: 0; + transform: scale(0.95); + } + } + + :scope[data-ending-style] { + opacity: 0; + transform: scale(0.95); + } +} +``` + +- [ ] **Step 5: Write registry metadata** + +Create `packages/registry/src/components/popover/registry.meta.json`: + +```json +{ + "title": "Popover", + "description": "A floating panel anchored to a trigger element.", + "dependencies": ["@base-ui/react"], + "devDependencies": [], + "registryDependencies": [] +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd packages/registry && npx vitest run src/components/popover/popover.test.tsx` +Expected: All 4 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/registry/src/components/popover/ +git commit -m "feat(registry): add popover component" +``` + +--- + +## Task 10: Slider + +**Files:** +- Create: `packages/registry/src/components/slider/slider.tsx` +- Create: `packages/registry/src/components/slider/slider.css` +- Create: `packages/registry/src/components/slider/slider.test.tsx` +- Create: `packages/registry/src/components/slider/registry.meta.json` + +Base UI Slider parts: `Root`, `Control`, `Track`, `Indicator`, `Thumb`. + +- [ ] **Step 1: Write the test** + +Create `packages/registry/src/components/slider/slider.test.tsx`: + +```tsx +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { Slider } from "./slider"; + +describe("Slider", () => { + it("renders without crashing", async () => { + const screen = await render(); + await expect.element(screen.getByRole("slider")).toBeInTheDocument(); + }); + + it("sets data-slot on root", async () => { + const screen = await render( + , + ); + await expect + .element(screen.getByTestId("slider")) + .toHaveAttribute("data-slot", "slider"); + }); + + it("forwards className after scope anchor", async () => { + const screen = await render( + , + ); + await expect + .element(screen.getByTestId("slider")) + .toHaveClass("sct-slider my-class"); + }); + + it("renders with aria valuemin and valuemax", async () => { + const screen = await render( + , + ); + const slider = screen.getByRole("slider"); + await expect.element(slider).toHaveAttribute("aria-valuemin", "0"); + await expect.element(slider).toHaveAttribute("aria-valuemax", "100"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/registry && npx vitest run src/components/slider/slider.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Write the component** + +Create `packages/registry/src/components/slider/slider.tsx`: + +```tsx +import { Slider as BaseSlider } from "@base-ui/react/slider"; +import "./slider.css"; + +export interface SliderProps + extends React.ComponentProps { + className?: string; +} + +export function Slider({ className, ...props }: SliderProps) { + return ( + + + + + + + + + ); +} +``` + +- [ ] **Step 4: Write the CSS** + +Create `packages/registry/src/components/slider/slider.css`: + +```css +@scope (.sct-slider) { + :scope { + position: relative; + display: flex; + width: 100%; + touch-action: none; + user-select: none; + } + + .sct-slider-control { + display: flex; + align-items: center; + width: 100%; + height: 1.25rem; + } + + .sct-slider-track { + position: relative; + width: 100%; + height: 0.25rem; + border-radius: var(--sct-radius-full); + background-color: var(--sct-color-muted); + overflow: hidden; + } + + .sct-slider-range { + position: absolute; + height: 100%; + background-color: var(--sct-color-primary); + } + + .sct-slider-thumb { + display: block; + width: 1.25rem; + height: 1.25rem; + border-radius: var(--sct-radius-full); + border: 2px solid var(--sct-color-primary); + background-color: var(--sct-color-background); + box-shadow: var(--sct-shadow-xs); + cursor: pointer; + outline: none; + transition: + box-shadow 150ms, + border-color 150ms; + } + + .sct-slider-thumb:hover { + border-color: color-mix( + in srgb, + var(--sct-color-primary) 80%, + transparent + ); + } + + .sct-slider-thumb:focus-visible { + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--sct-color-ring) 50%, transparent); + } + + .sct-slider-thumb[data-disabled] { + pointer-events: none; + opacity: 0.5; + } +} +``` + +- [ ] **Step 5: Write registry metadata** + +Create `packages/registry/src/components/slider/registry.meta.json`: + +```json +{ + "title": "Slider", + "description": "A range input slider built on Base UI.", + "dependencies": ["@base-ui/react"], + "devDependencies": [], + "registryDependencies": [] +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd packages/registry && npx vitest run src/components/slider/slider.test.tsx` +Expected: All 4 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/registry/src/components/slider/ +git commit -m "feat(registry): add slider component" +``` + +--- + +## Task 11: Collapsible + +**Files:** +- Create: `packages/registry/src/components/collapsible/collapsible.tsx` +- Create: `packages/registry/src/components/collapsible/collapsible.css` +- Create: `packages/registry/src/components/collapsible/collapsible.test.tsx` +- Create: `packages/registry/src/components/collapsible/registry.meta.json` + +Base UI Collapsible parts: `Root`, `Trigger`, `Panel`. + +- [ ] **Step 1: Write the test** + +Create `packages/registry/src/components/collapsible/collapsible.test.tsx`: + +```tsx +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { userEvent } from "@vitest/browser/context"; +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from "./collapsible"; + +describe("Collapsible", () => { + it("renders trigger without crashing", async () => { + const screen = await render( + + Toggle + Hidden content + , + ); + await expect + .element(screen.getByRole("button", { name: "Toggle" })) + .toBeInTheDocument(); + }); + + it("sets data-slot on trigger", async () => { + const screen = await render( + + Toggle + Content + , + ); + await expect + .element(screen.getByRole("button", { name: "Toggle" })) + .toHaveAttribute("data-slot", "collapsible-trigger"); + }); + + it("shows content when opened", async () => { + const screen = await render( + + Toggle + Visible content + , + ); + await expect + .element(screen.getByText("Visible content")) + .toBeInTheDocument(); + }); + + it("toggles content on trigger click", async () => { + const screen = await render( + + Toggle + Toggle me + , + ); + await userEvent.click(screen.getByRole("button", { name: "Toggle" })); + await expect + .element(screen.getByText("Toggle me")) + .toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/registry && npx vitest run src/components/collapsible/collapsible.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Write the component** + +Create `packages/registry/src/components/collapsible/collapsible.tsx`: + +```tsx +import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible"; +import "./collapsible.css"; + +export const Collapsible = BaseCollapsible.Root; +export type CollapsibleProps = React.ComponentProps< + typeof BaseCollapsible.Root +>; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CollapsibleTriggerProps + extends React.ButtonHTMLAttributes {} + +export function CollapsibleTrigger({ ...props }: CollapsibleTriggerProps) { + return ( + + ); +} + +export interface CollapsibleContentProps + extends React.HTMLAttributes {} + +export function CollapsibleContent({ + className, + ...props +}: CollapsibleContentProps) { + return ( + + ); +} +``` + +- [ ] **Step 4: Write the CSS** + +Create `packages/registry/src/components/collapsible/collapsible.css`: + +```css +@scope (.sct-collapsible-content) { + :scope { + overflow: hidden; + transition: height 200ms ease; + } + + :scope[data-starting-style], + :scope[data-ending-style] { + height: 0; + } +} +``` + +- [ ] **Step 5: Write registry metadata** + +Create `packages/registry/src/components/collapsible/registry.meta.json`: + +```json +{ + "title": "Collapsible", + "description": "A panel that can be expanded or collapsed.", + "dependencies": ["@base-ui/react"], + "devDependencies": [], + "registryDependencies": [] +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd packages/registry && npx vitest run src/components/collapsible/collapsible.test.tsx` +Expected: All 4 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/registry/src/components/collapsible/ +git commit -m "feat(registry): add collapsible component" +``` + +--- + +## Task 12: Accordion + +**Files:** +- Create: `packages/registry/src/components/accordion/accordion.tsx` +- Create: `packages/registry/src/components/accordion/accordion.css` +- Create: `packages/registry/src/components/accordion/accordion.test.tsx` +- Create: `packages/registry/src/components/accordion/registry.meta.json` + +Base UI Accordion parts: `Root`, `Item`, `Header`, `Trigger`, `Panel`. + +- [ ] **Step 1: Write the test** + +Create `packages/registry/src/components/accordion/accordion.test.tsx`: + +```tsx +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { userEvent } from "@vitest/browser/context"; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "./accordion"; + +describe("Accordion", () => { + it("renders without crashing", async () => { + const screen = await render( + + + Item 1 + Content 1 + + , + ); + await expect.element(screen.getByTestId("accordion")).toBeInTheDocument(); + }); + + it("sets data-slot on root", async () => { + const screen = await render( + + + Item 1 + Content 1 + + , + ); + await expect + .element(screen.getByTestId("accordion")) + .toHaveAttribute("data-slot", "accordion"); + }); + + it("renders trigger as button", async () => { + const screen = await render( + + + Toggle + Content + + , + ); + await expect + .element(screen.getByRole("button", { name: "Toggle" })) + .toBeInTheDocument(); + }); + + it("expands content when trigger is clicked", async () => { + const screen = await render( + + + Toggle + Expanded text + + , + ); + await userEvent.click(screen.getByRole("button", { name: "Toggle" })); + await expect + .element(screen.getByText("Expanded text")) + .toBeInTheDocument(); + }); + + it("forwards className on root", async () => { + const screen = await render( + + + Item + Content + + , + ); + await expect + .element(screen.getByTestId("accordion")) + .toHaveClass("sct-accordion my-class"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/registry && npx vitest run src/components/accordion/accordion.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Write the component** + +Create `packages/registry/src/components/accordion/accordion.tsx`: + +```tsx +import { Accordion as BaseAccordion } from "@base-ui/react/accordion"; +import "./accordion.css"; + +export interface AccordionProps + extends React.ComponentProps { + className?: string; +} + +export function Accordion({ className, ...props }: AccordionProps) { + return ( + + ); +} + +export interface AccordionItemProps + extends React.ComponentProps { + className?: string; +} + +export function AccordionItem({ className, ...props }: AccordionItemProps) { + return ( + + ); +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface AccordionTriggerProps + extends React.ButtonHTMLAttributes {} + +export function AccordionTrigger({ + className, + children, + ...props +}: AccordionTriggerProps) { + return ( + + + {children} + + + + + + ); +} + +export interface AccordionContentProps + extends React.HTMLAttributes {} + +export function AccordionContent({ + className, + ...props +}: AccordionContentProps) { + return ( + + ); +} +``` + +- [ ] **Step 4: Write the CSS** + +Create `packages/registry/src/components/accordion/accordion.css`: + +```css +@scope (.sct-accordion) { + .sct-accordion-item { + border-bottom: 1px solid var(--sct-color-border); + } + + .sct-accordion-header { + display: flex; + } + + .sct-accordion-trigger { + display: flex; + flex: 1; + align-items: center; + justify-content: space-between; + padding-block: 1rem; + font-size: var(--sct-font-size-sm); + font-weight: var(--sct-font-weight-medium); + background: none; + border: none; + cursor: pointer; + outline: none; + text-align: left; + color: inherit; + transition: text-decoration 150ms; + } + + .sct-accordion-trigger:hover { + text-decoration: underline; + } + + .sct-accordion-trigger:focus-visible { + border-color: var(--sct-color-ring); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--sct-color-ring) 50%, transparent); + } + + .sct-accordion-chevron { + flex-shrink: 0; + transition: transform 200ms; + } + + .sct-accordion-trigger[data-panel-open] .sct-accordion-chevron { + transform: rotate(180deg); + } + + .sct-accordion-content { + overflow: hidden; + font-size: var(--sct-font-size-sm); + transition: height 200ms ease; + } + + .sct-accordion-content[data-starting-style], + .sct-accordion-content[data-ending-style] { + height: 0; + } + + .sct-accordion-content > div { + padding-bottom: 1rem; + } +} +``` + +- [ ] **Step 5: Write registry metadata** + +Create `packages/registry/src/components/accordion/registry.meta.json`: + +```json +{ + "title": "Accordion", + "description": "A vertically stacked set of expandable panels.", + "dependencies": ["@base-ui/react"], + "devDependencies": [], + "registryDependencies": [] +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd packages/registry && npx vitest run src/components/accordion/accordion.test.tsx` +Expected: All 5 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/registry/src/components/accordion/ +git commit -m "feat(registry): add accordion component" +``` + +--- + +## Task 13: Alert Dialog + +**Files:** +- Create: `packages/registry/src/components/alert-dialog/alert-dialog.tsx` +- Create: `packages/registry/src/components/alert-dialog/alert-dialog.css` +- Create: `packages/registry/src/components/alert-dialog/alert-dialog.test.tsx` +- Create: `packages/registry/src/components/alert-dialog/registry.meta.json` + +Base UI AlertDialog parts: `Root`, `Trigger`, `Popup`, `Backdrop`, `Portal`, `Title`, `Description`, `Close`. + +- [ ] **Step 1: Write the test** + +Create `packages/registry/src/components/alert-dialog/alert-dialog.test.tsx`: + +```tsx +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { userEvent } from "@vitest/browser/context"; +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} from "./alert-dialog"; + +describe("AlertDialog", () => { + it("renders trigger without crashing", async () => { + const screen = await render( + + Delete + + Are you sure? + + , + ); + await expect + .element(screen.getByRole("button", { name: "Delete" })) + .toBeInTheDocument(); + }); + + it("sets data-slot on trigger", async () => { + const screen = await render( + + Delete + + Sure? + + , + ); + await expect + .element(screen.getByRole("button", { name: "Delete" })) + .toHaveAttribute("data-slot", "alert-dialog-trigger"); + }); + + it("opens dialog on trigger click", async () => { + const screen = await render( + + Delete + + + Confirm deletion + This cannot be undone. + + + Cancel + Confirm + + + , + ); + await userEvent.click(screen.getByRole("button", { name: "Delete" })); + await expect.element(screen.getByRole("alertdialog")).toBeInTheDocument(); + await expect + .element(screen.getByText("Confirm deletion")) + .toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/registry && npx vitest run src/components/alert-dialog/alert-dialog.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Write the component** + +Create `packages/registry/src/components/alert-dialog/alert-dialog.tsx`: + +```tsx +import { AlertDialog as BaseAlertDialog } from "@base-ui/react/alert-dialog"; +import "./alert-dialog.css"; + +export const AlertDialog = BaseAlertDialog.Root; +export type AlertDialogProps = React.ComponentProps< + typeof BaseAlertDialog.Root +>; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface AlertDialogTriggerProps + extends React.ButtonHTMLAttributes {} + +export function AlertDialogTrigger({ ...props }: AlertDialogTriggerProps) { + return ( + + ); +} + +export interface AlertDialogOverlayProps + extends React.HTMLAttributes {} + +export function AlertDialogOverlay({ + className, + ...props +}: AlertDialogOverlayProps) { + return ( + + ); +} + +export interface AlertDialogContentProps + extends React.HTMLAttributes {} + +export function AlertDialogContent({ + className, + children, + ...props +}: AlertDialogContentProps) { + return ( + + + + {children} + + + ); +} + +export type AlertDialogHeaderProps = React.HTMLAttributes; + +export function AlertDialogHeader({ + className, + ...props +}: AlertDialogHeaderProps) { + return ( +
+ ); +} + +export type AlertDialogFooterProps = React.HTMLAttributes; + +export function AlertDialogFooter({ + className, + ...props +}: AlertDialogFooterProps) { + return ( +
+ ); +} + +export interface AlertDialogTitleProps + extends React.HTMLAttributes {} + +export function AlertDialogTitle({ + className, + ...props +}: AlertDialogTitleProps) { + return ( + + ); +} + +export interface AlertDialogDescriptionProps + extends React.HTMLAttributes {} + +export function AlertDialogDescription({ + className, + ...props +}: AlertDialogDescriptionProps) { + return ( + + ); +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface AlertDialogActionProps + extends React.ButtonHTMLAttributes {} + +export function AlertDialogAction({ ...props }: AlertDialogActionProps) { + return + + + + ), +}; + +export const Vertical: Story = { + render: () => ( + + + + + + ), +}; + +export const Playground: Story = { + args: { orientation: "horizontal" }, + render: (args) => ( + + + + + + ), +}; +``` + +- [ ] **Step 6: Write InputGroup story** + +Create `apps/docs/src/stories/InputGroup.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { InputGroup, InputGroupAddon, Input } from "@webscit/registry"; + +const meta = { + title: "Components/InputGroup", + component: InputGroup, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + $ + + + ), +}; + +export const WithSuffix: Story = { + render: () => ( + + + @example.com + + ), +}; + +export const BothSides: Story = { + render: () => ( + + https:// + + /path + + ), +}; +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/docs/src/stories/Separator.stories.tsx apps/docs/src/stories/Skeleton.stories.tsx apps/docs/src/stories/ScrollArea.stories.tsx apps/docs/src/stories/Table.stories.tsx apps/docs/src/stories/ButtonGroup.stories.tsx apps/docs/src/stories/InputGroup.stories.tsx +git commit -m "docs: add stories for separator, skeleton, scroll-area, table, button-group, input-group" +``` + +--- + +## Task 21: Stories — Base UI Components + +**Files:** +- Create: `apps/docs/src/stories/Popover.stories.tsx` +- Create: `apps/docs/src/stories/Slider.stories.tsx` +- Create: `apps/docs/src/stories/Collapsible.stories.tsx` +- Create: `apps/docs/src/stories/Accordion.stories.tsx` +- Create: `apps/docs/src/stories/AlertDialog.stories.tsx` +- Create: `apps/docs/src/stories/Sheet.stories.tsx` +- Create: `apps/docs/src/stories/Resizable.stories.tsx` +- Create: `apps/docs/src/stories/Toast.stories.tsx` + +- [ ] **Step 1: Write Popover story** + +Create `apps/docs/src/stories/Popover.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { + Popover, + PopoverTrigger, + PopoverContent, + PopoverClose, + Button, +} from "@webscit/registry"; + +const meta = { + title: "Components/Popover", + component: Popover, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + +

Dimensions

+

+ Set the dimensions for the layer. +

+
+
+ ), +}; + +export const WithClose: Story = { + render: () => ( + + + + + +

Some detailed information here.

+ + + +
+
+ ), +}; +``` + +- [ ] **Step 2: Write Slider story** + +Create `apps/docs/src/stories/Slider.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { Slider } from "@webscit/registry"; + +const meta = { + title: "Components/Slider", + component: Slider, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+ +
+ ), +}; + +export const Range: Story = { + render: () => ( +
+ +
+ ), +}; + +export const Playground: Story = { + render: () => ( +
+ +
+ ), +}; +``` + +- [ ] **Step 3: Write Collapsible story** + +Create `apps/docs/src/stories/Collapsible.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, + Button, +} from "@webscit/registry"; + +const meta = { + title: "Components/Collapsible", + component: Collapsible, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + +
+ This content can be collapsed and expanded. +
+
+
+ ), +}; + +export const DefaultOpen: Story = { + render: () => ( + + + + + +
+ This starts expanded. +
+
+
+ ), +}; +``` + +- [ ] **Step 4: Write Accordion story** + +Create `apps/docs/src/stories/Accordion.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "@webscit/registry"; + +const meta = { + title: "Components/Accordion", + component: Accordion, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + Is it accessible? + +
Yes. It adheres to the WAI-ARIA design pattern.
+
+
+ + Is it styled? + +
Yes. It comes with default styles using CSS scoping.
+
+
+ + Is it animated? + +
Yes. It uses CSS transitions for smooth expand/collapse.
+
+
+
+ ), +}; + +export const Playground: Story = { + render: () => ( + + + Section A + +
Content for section A.
+
+
+ + Section B + +
Content for section B.
+
+
+
+ ), +}; +``` + +- [ ] **Step 5: Write AlertDialog story** + +Create `apps/docs/src/stories/AlertDialog.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, + Button, +} from "@webscit/registry"; + +const meta = { + title: "Components/AlertDialog", + component: AlertDialog, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your + account and remove your data from our servers. + + + + + + + + + + + + + ), +}; +``` + +- [ ] **Step 6: Write Sheet story** + +Create `apps/docs/src/stories/Sheet.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { + Sheet, + SheetTrigger, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, + SheetClose, + Button, +} from "@webscit/registry"; + +const meta = { + title: "Components/Sheet", + component: Sheet, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + + + Edit Profile + + Make changes to your profile here. + + +
+

Sheet content goes here.

+
+ + + + + + +
+
+ ), +}; + +export const Left: Story = { + render: () => ( + + + + + + + Left Sheet + +

This sheet slides from the left.

+
+
+ ), +}; + +export const Top: Story = { + render: () => ( + + + + + + + Top Sheet + +

This sheet slides from the top.

+
+
+ ), +}; + +export const Bottom: Story = { + render: () => ( + + + + + + + Bottom Sheet + +

This sheet slides from the bottom.

+
+
+ ), +}; +``` + +- [ ] **Step 7: Write Resizable story** + +Create `apps/docs/src/stories/Resizable.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@webscit/registry"; + +const meta = { + title: "Components/Resizable", + component: ResizablePanelGroup, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + +
+ Panel One +
+
+ + +
+ Panel Two +
+
+
+ ), +}; + +export const Vertical: Story = { + render: () => ( + + +
+ Top +
+
+ + +
+ Bottom +
+
+
+ ), +}; + +export const ThreePanels: Story = { + render: () => ( + + +
+ Sidebar +
+
+ + +
+ Main +
+
+ + +
+ Details +
+
+
+ ), +}; +``` + +- [ ] **Step 8: Write Toast story** + +Create `apps/docs/src/stories/Toast.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { + ToastProvider, + ToastViewport, + Toast, + ToastContent, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, + useToastManager, + Button, +} from "@webscit/registry"; + +const meta = { + title: "Components/Toast", + component: Toast, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function ToastDemo() { + const toastManager = useToastManager(); + return ( +
+ + + + + +
+ ); +} + +export const Default: Story = { + render: () => , +}; +``` + +- [ ] **Step 9: Commit** + +```bash +git add apps/docs/src/stories/Popover.stories.tsx apps/docs/src/stories/Slider.stories.tsx apps/docs/src/stories/Collapsible.stories.tsx apps/docs/src/stories/Accordion.stories.tsx apps/docs/src/stories/AlertDialog.stories.tsx apps/docs/src/stories/Sheet.stories.tsx apps/docs/src/stories/Resizable.stories.tsx apps/docs/src/stories/Toast.stories.tsx +git commit -m "docs: add stories for popover, slider, collapsible, accordion, alert-dialog, sheet, resizable, toast" +``` + +--- + +## Task 22: Stories — Field and Sidebar + +**Files:** +- Create: `apps/docs/src/stories/Field.stories.tsx` +- Create: `apps/docs/src/stories/Sidebar.stories.tsx` + +- [ ] **Step 1: Write Field story** + +Create `apps/docs/src/stories/Field.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { + Field, + FieldLabel, + FieldDescription, + FieldError, + FieldSet, + FieldLegend, + FieldGroup, + FieldContent, + FieldSeparator, + Input, +} from "@webscit/registry"; + +const meta = { + title: "Components/Field", + component: Field, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + Email + + We will never share your email. + + ), +}; + +export const WithError: Story = { + render: () => ( + + Username + + Username is required. + + ), +}; + +export const FieldSetExample: Story = { + render: () => ( +
+ Contact Information + + + First Name + + + + + Last Name + + + + + Email + + + +
+ ), +}; +``` + +- [ ] **Step 2: Write Sidebar story** + +Create `apps/docs/src/stories/Sidebar.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { + SidebarProvider, + Sidebar, + SidebarHeader, + SidebarContent, + SidebarFooter, + SidebarTrigger, + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarSeparator, + SidebarInset, +} from "@webscit/registry"; + +const meta = { + title: "Components/Sidebar", + component: Sidebar, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + +

My App

+
+ + + Navigation + + + + Home + + + Dashboard + + + Settings + + + + + + + Projects + + + + Project Alpha + + + Project Beta + + + + + + +

v1.0.0

+
+
+ +
+ Toggle +

Main Content

+

This is the main content area next to the sidebar.

+
+
+
+ ), +}; + +export const CollapsedByDefault: Story = { + render: () => ( + + + + + + + + Home + + + + + + + +
+ Open +

Sidebar starts collapsed.

+
+
+
+ ), +}; + +export const RightSide: Story = { + render: () => ( + + +
+ Toggle +

Main Content

+
+
+ + + + Details + + + + Properties + + + History + + + + + + +
+ ), +}; +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/docs/src/stories/Field.stories.tsx apps/docs/src/stories/Sidebar.stories.tsx +git commit -m "docs: add stories for field and sidebar" +``` + +--- + +## Task 23: Final Verification + +- [ ] **Step 1: Run full lint** + +Run: `npm run lint` +Expected: No errors + +- [ ] **Step 2: Run full typecheck** + +Run: `npm run typecheck` +Expected: No errors across all packages + +- [ ] **Step 3: Run full test suite** + +Run: `npm run test` +Expected: All tests pass + +- [ ] **Step 4: Run full build** + +Run: `npm run build` +Expected: All packages build successfully + +- [ ] **Step 5: Verify registry output** + +Run: `cat packages/registry/registry/registry.json | node -e "const j=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(j.items.length + ' components'); console.log(j.items.map(i=>i.name).sort().join(', '))"` +Expected: `33 components` listing all 17 existing + 16 new component names + +- [ ] **Step 6: Commit any final fixes** + +If lint/typecheck/test revealed issues, fix them and commit: + +```bash +git add -A +git commit -m "fix: address lint/type issues in new components" +``` diff --git a/docs/superpowers/specs/2026-04-09-new-components-design.md b/docs/superpowers/specs/2026-04-09-new-components-design.md new file mode 100644 index 0000000..8bf42d6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-new-components-design.md @@ -0,0 +1,359 @@ +# New Components Design Spec + +**Date:** 2026-04-09 +**Scope:** Add 16 new components to @webscit/toolkit, matching shadcn's general-purpose API. + +## Components + +accordion, alert-dialog, button-group, collapsible, field, input-group, popover, resizable, scroll-area, separator, sheet, sidebar, skeleton, slider, toast (replaces "sonner" from the original list — built on Base UI Toast instead of the sonner library), table + +## Implementation Strategy: Dependency-First + +### Token Additions (first) + +New semantic color tokens: + +| Token | Purpose | +|---|---| +| `sct.color.success` / `sct.color.success-foreground` | Success states (toast, future use) | +| `sct.color.warning` / `sct.color.warning-foreground` | Warning states | +| `sct.color.info` / `sct.color.info-foreground` | Informational states | + +New sidebar dimension tokens: + +| Token | Default | Purpose | +|---|---|---| +| `sct.sidebar.width` | 16rem | Default sidebar width | +| `sct.sidebar.width-collapsed` | 3rem | Collapsed/icon-only width | + +Primitive green/amber values go in `base.tokens.json`. Semantic aliases in `semantic.tokens.json` and `semantic-dark.tokens.json`. + +### Dependency Graph + +``` +Layer 0 — Leaf components (no deps on other new components): +├── separator (native
/
) +├── skeleton (native
+ CSS animation) +├── scroll-area (native HTML + CSS scrollbar styling) +├── table (native elements) +├── popover (Base UI popover) +├── slider (Base UI slider) +├── collapsible (Base UI collapsible) +├── accordion (Base UI accordion) +├── alert-dialog (Base UI alert-dialog) +├── sheet (Base UI dialog, side-sliding variant) +├── button-group (native
) +├── input-group (native
composition) +├── resizable (react-resizable-panels wrapper) +└── toast (Base UI toast) + +Layer 1 — Depends on Layer 0: +└── field (uses separator, label; Base UI field + fieldset) + +Layer 2 — Depends on Layers 0 + 1: +└── sidebar (uses collapsible, sheet, separator, skeleton, + scroll-area, tooltip, button, input) +``` + +**Build order:** Tokens first → all Layer 0 (parallelizable) → field → sidebar. + +## Component API Designs + +All components follow existing conventions: +- `sct-` scope-anchor class on root element +- `@scope (.sct-) { ... }` in CSS +- `data-slot` attributes on every sub-component +- `data-variant` / `data-size` / `data-orientation` for variants +- Base UI `data-*` attributes for states +- No `cn()` / `clsx` — manual class composition: `` `sct-foo${className ? ` ${className}` : ''}` `` + +### Separator + +```tsx +export function Separator({ orientation = "horizontal", decorative = true, className, ...props }) +// Renders
(or role="none" if decorative) +// data-orientation="horizontal" | "vertical" +``` + +### Skeleton + +```tsx +export function Skeleton({ className, ...props }) +// Renders
with pulse/shimmer CSS animation +// CSS-only, no JS state +``` + +### Scroll Area + +```tsx +export function ScrollArea({ className, children, ...props }) +export function ScrollBar({ orientation = "vertical", className, ...props }) +// Native overflow with custom scrollbar styling via CSS +// Uses ::-webkit-scrollbar + scrollbar-width/color for Firefox +``` + +### Table + +```tsx +export function Table({ className, ...props }) //
+export function TableHeader({ className, ...props }) // +export function TableBody({ className, ...props }) // +export function TableFooter({ className, ...props }) // +export function TableRow({ className, ...props }) // +export function TableHead({ className, ...props }) //
+export function TableCell({ className, ...props }) // +export function TableCaption({ className, ...props }) //
+``` + +### Popover + +```tsx +export function Popover(props) // Base UI Popover.Root +export function PopoverTrigger(props) // Base UI Popover.Trigger +export function PopoverContent(props) // Base UI Popover.Popup (inside Portal + Positioner) +export function PopoverClose(props) // Base UI Popover.Close +``` + +### Slider + +```tsx +export function Slider({ className, ...props }) // Base UI Slider.Root +export function SliderTrack(props) // Base UI Slider.Track +export function SliderRange(props) // Base UI Slider.Range (or Indicator) +export function SliderThumb(props) // Base UI Slider.Thumb +``` + +### Collapsible + +```tsx +export function Collapsible(props) // Base UI Collapsible.Root +export function CollapsibleTrigger(props) // Base UI Collapsible.Trigger +export function CollapsibleContent(props) // Base UI Collapsible.Panel (animated) +``` + +### Accordion + +```tsx +export function Accordion(props) // Base UI Accordion.Root +export function AccordionItem(props) // Base UI Accordion.Item +export function AccordionTrigger(props) // Base UI Accordion.Trigger (inside Header) +export function AccordionContent(props) // Base UI Accordion.Panel +``` + +### Alert Dialog + +```tsx +export function AlertDialog(props) // Base UI AlertDialog.Root +export function AlertDialogTrigger(props) // Base UI AlertDialog.Trigger +export function AlertDialogContent(props) // Base UI AlertDialog.Popup (inside Portal) +export function AlertDialogOverlay(props) // Base UI AlertDialog.Backdrop +export function AlertDialogHeader(props) // plain
+export function AlertDialogFooter(props) // plain
+export function AlertDialogTitle(props) // Base UI AlertDialog.Title +export function AlertDialogDescription(props) // Base UI AlertDialog.Description +export function AlertDialogAction(props) // + + , + ); + await expect.element(screen.getByRole("group")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render( + + + , + ); + await expect + .element(screen.getByRole("group")) + .toHaveAttribute("data-slot", "button-group"); + }); + + it("defaults to horizontal orientation", async () => { + const screen = await render( + + + , + ); + await expect + .element(screen.getByRole("group")) + .toHaveAttribute("data-orientation", "horizontal"); + }); + + it("supports vertical orientation", async () => { + const screen = await render( + + + , + ); + await expect + .element(screen.getByRole("group")) + .toHaveAttribute("data-orientation", "vertical"); + }); + + it("forwards className after scope anchor", async () => { + const screen = await render( + + + , + ); + await expect + .element(screen.getByRole("group")) + .toHaveClass("sct-button-group my-class"); + }); +}); diff --git a/packages/registry/src/components/button-group/button-group.tsx b/packages/registry/src/components/button-group/button-group.tsx new file mode 100644 index 0000000..d058efc --- /dev/null +++ b/packages/registry/src/components/button-group/button-group.tsx @@ -0,0 +1,22 @@ +import "./button-group.css"; + +export interface ButtonGroupProps + extends React.HTMLAttributes { + orientation?: "horizontal" | "vertical"; +} + +export function ButtonGroup({ + orientation = "horizontal", + className, + ...props +}: ButtonGroupProps) { + return ( +
+ ); +} diff --git a/packages/registry/src/components/button-group/registry.meta.json b/packages/registry/src/components/button-group/registry.meta.json new file mode 100644 index 0000000..93dbcb0 --- /dev/null +++ b/packages/registry/src/components/button-group/registry.meta.json @@ -0,0 +1,7 @@ +{ + "title": "Button Group", + "description": "Groups buttons together with connected styling.", + "dependencies": [], + "devDependencies": [], + "registryDependencies": [] +} diff --git a/packages/registry/src/components/collapsible/collapsible.css b/packages/registry/src/components/collapsible/collapsible.css new file mode 100644 index 0000000..1339e7d --- /dev/null +++ b/packages/registry/src/components/collapsible/collapsible.css @@ -0,0 +1,11 @@ +@scope (.sct-collapsible-content) { + :scope { + overflow: hidden; + transition: height 200ms ease; + } + + :scope[data-starting-style], + :scope[data-ending-style] { + height: 0; + } +} diff --git a/packages/registry/src/components/collapsible/collapsible.test.tsx b/packages/registry/src/components/collapsible/collapsible.test.tsx new file mode 100644 index 0000000..6d3dfbd --- /dev/null +++ b/packages/registry/src/components/collapsible/collapsible.test.tsx @@ -0,0 +1,59 @@ +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { userEvent } from "@vitest/browser/context"; +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from "./collapsible"; + +describe("Collapsible", () => { + it("renders trigger without crashing", async () => { + const screen = await render( + + Toggle + Hidden content + , + ); + await expect + .element(screen.getByRole("button", { name: "Toggle" })) + .toBeInTheDocument(); + }); + + it("sets data-slot on trigger", async () => { + const screen = await render( + + Toggle + Content + , + ); + await expect + .element(screen.getByRole("button", { name: "Toggle" })) + .toHaveAttribute("data-slot", "collapsible-trigger"); + }); + + it("shows content when opened", async () => { + const screen = await render( + + Toggle + Visible content + , + ); + await expect + .element(screen.getByText("Visible content")) + .toBeInTheDocument(); + }); + + it("toggles content on trigger click", async () => { + const screen = await render( + + Toggle + Toggle me + , + ); + await userEvent.click(screen.getByRole("button", { name: "Toggle" })); + await expect + .element(screen.getByText("Toggle me")) + .toBeInTheDocument(); + }); +}); diff --git a/packages/registry/src/components/collapsible/collapsible.tsx b/packages/registry/src/components/collapsible/collapsible.tsx new file mode 100644 index 0000000..67120ae --- /dev/null +++ b/packages/registry/src/components/collapsible/collapsible.tsx @@ -0,0 +1,31 @@ +import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible"; +import "./collapsible.css"; + +export const Collapsible = BaseCollapsible.Root; +export type CollapsibleProps = React.ComponentProps< + typeof BaseCollapsible.Root +>; + +export type CollapsibleTriggerProps = + React.ButtonHTMLAttributes; + +export function CollapsibleTrigger({ ...props }: CollapsibleTriggerProps) { + return ( + + ); +} + +export type CollapsibleContentProps = React.HTMLAttributes; + +export function CollapsibleContent({ + className, + ...props +}: CollapsibleContentProps) { + return ( + + ); +} diff --git a/packages/registry/src/components/collapsible/registry.meta.json b/packages/registry/src/components/collapsible/registry.meta.json new file mode 100644 index 0000000..08cfa52 --- /dev/null +++ b/packages/registry/src/components/collapsible/registry.meta.json @@ -0,0 +1,7 @@ +{ + "title": "Collapsible", + "description": "A panel that can be expanded or collapsed.", + "dependencies": ["@base-ui/react"], + "devDependencies": [], + "registryDependencies": [] +} diff --git a/packages/registry/src/components/field/field.css b/packages/registry/src/components/field/field.css new file mode 100644 index 0000000..2f7c46a --- /dev/null +++ b/packages/registry/src/components/field/field.css @@ -0,0 +1,58 @@ +@scope (.sct-field) { + :scope { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .sct-field-label { + font-size: var(--sct-font-size-sm); + font-weight: var(--sct-font-weight-medium); + color: var(--sct-color-foreground); + } + + :scope[data-invalid] .sct-field-label { + color: var(--sct-color-destructive); + } + + .sct-field-description { + font-size: var(--sct-font-size-xs); + color: var(--sct-color-muted-foreground); + } + + .sct-field-error { + font-size: var(--sct-font-size-xs); + color: var(--sct-color-destructive); + font-weight: var(--sct-font-weight-medium); + } +} + +@scope (.sct-fieldset) { + :scope { + border: 1px solid var(--sct-color-border); + border-radius: var(--sct-radius-lg); + padding: 1rem; + } + + .sct-field-legend { + font-size: var(--sct-font-size-sm); + font-weight: var(--sct-font-weight-semibold); + padding-inline: 0.25rem; + } +} + +@scope (.sct-field-group) { + :scope { + display: flex; + flex-direction: column; + gap: 1rem; + } +} + +@scope (.sct-field-content) { + :scope { + display: flex; + flex-direction: column; + gap: 0.25rem; + } +} diff --git a/packages/registry/src/components/field/field.test.tsx b/packages/registry/src/components/field/field.test.tsx new file mode 100644 index 0000000..74c86a0 --- /dev/null +++ b/packages/registry/src/components/field/field.test.tsx @@ -0,0 +1,112 @@ +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { + Field, + FieldLabel, + FieldDescription, + FieldError, + FieldSet, + FieldLegend, + FieldGroup, + FieldContent, + FieldSeparator, +} from "./field"; + +describe("Field", () => { + it("renders without crashing", async () => { + const screen = await render( + + Name + + , + ); + await expect.element(screen.getByTestId("field")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render( + + Name + , + ); + await expect + .element(screen.getByTestId("field")) + .toHaveAttribute("data-slot", "field"); + }); + + it("renders label", async () => { + const screen = await render( + + Email + + , + ); + await expect.element(screen.getByText("Email")).toBeInTheDocument(); + }); + + it("renders description", async () => { + const screen = await render( + + Name + Enter your full name + + , + ); + await expect + .element(screen.getByText("Enter your full name")) + .toBeInTheDocument(); + }); + + it("renders error message", async () => { + // match={true} forces FieldError to always render regardless of validity state + const screen = await render( + + Name + + This field is required + , + ); + await expect + .element(screen.getByText("This field is required")) + .toBeInTheDocument(); + }); +}); + +describe("FieldSet", () => { + it("renders fieldset with legend", async () => { + const screen = await render( +
+ Contact Info +
, + ); + await expect.element(screen.getByRole("group")).toBeInTheDocument(); + await expect + .element(screen.getByText("Contact Info")) + .toBeInTheDocument(); + }); +}); + +describe("FieldGroup", () => { + it("renders with data-slot", async () => { + const screen = await render(); + await expect + .element(screen.getByTestId("fg")) + .toHaveAttribute("data-slot", "field-group"); + }); +}); + +describe("FieldContent", () => { + it("renders with data-slot", async () => { + const screen = await render(); + await expect + .element(screen.getByTestId("fc")) + .toHaveAttribute("data-slot", "field-content"); + }); +}); + +describe("FieldSeparator", () => { + it("renders separator", async () => { + const screen = await render(); + await expect.element(screen.getByRole("none")).toBeInTheDocument(); + }); +}); diff --git a/packages/registry/src/components/field/field.tsx b/packages/registry/src/components/field/field.tsx new file mode 100644 index 0000000..b2f3f34 --- /dev/null +++ b/packages/registry/src/components/field/field.tsx @@ -0,0 +1,129 @@ +import { Field as BaseField } from "@base-ui/react/field"; +import { Fieldset as BaseFieldset } from "@base-ui/react/fieldset"; +import { Separator } from "../separator/separator"; +import "./field.css"; + +// --- Field (single field) --- + +export interface FieldProps + extends React.ComponentProps { + className?: string; +} + +export function Field({ className, ...props }: FieldProps) { + return ( + + ); +} + +export interface FieldLabelProps + extends React.ComponentProps { + className?: string; +} + +export function FieldLabel({ className, ...props }: FieldLabelProps) { + return ( + + ); +} + +export type FieldDescriptionProps = React.HTMLAttributes; + +export function FieldDescription({ + className, + ...props +}: FieldDescriptionProps) { + return ( + + ); +} + +export type FieldErrorProps = React.HTMLAttributes; + +export function FieldError({ className, ...props }: FieldErrorProps) { + return ( + + ); +} + +export const FieldValidity = BaseField.Validity; +export type FieldValidityProps = React.ComponentProps; + +// --- Fieldset --- + +export interface FieldSetProps + extends React.ComponentProps { + className?: string; +} + +export function FieldSet({ className, ...props }: FieldSetProps) { + return ( + + ); +} + +export type FieldLegendProps = React.ComponentProps< + typeof BaseFieldset.Legend +>; + +export function FieldLegend({ className, ...props }: FieldLegendProps) { + return ( + + ); +} + +// --- Layout helpers --- + +export type FieldGroupProps = React.HTMLAttributes; + +export function FieldGroup({ className, ...props }: FieldGroupProps) { + return ( +
+ ); +} + +export type FieldContentProps = React.HTMLAttributes; + +export function FieldContent({ className, ...props }: FieldContentProps) { + return ( +
+ ); +} + +export type FieldSeparatorProps = React.ComponentProps; + +export function FieldSeparator(props: FieldSeparatorProps) { + return ; +} diff --git a/packages/registry/src/components/field/registry.meta.json b/packages/registry/src/components/field/registry.meta.json new file mode 100644 index 0000000..e56b925 --- /dev/null +++ b/packages/registry/src/components/field/registry.meta.json @@ -0,0 +1,7 @@ +{ + "title": "Field", + "description": "Accessible form field with label, description, and error messaging.", + "dependencies": ["@base-ui/react"], + "devDependencies": [], + "registryDependencies": ["separator", "label"] +} diff --git a/packages/registry/src/components/input-group/input-group.css b/packages/registry/src/components/input-group/input-group.css new file mode 100644 index 0000000..830eff8 --- /dev/null +++ b/packages/registry/src/components/input-group/input-group.css @@ -0,0 +1,35 @@ +@scope (.sct-input-group) { + :scope { + display: flex; + align-items: stretch; + } + + :scope > :not(:first-child):not(:last-child) { + border-radius: 0; + } + + :scope > :first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + :scope > :last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + :scope > :not(:first-child) { + margin-left: -1px; + } + + .sct-input-group-addon { + display: flex; + align-items: center; + padding-inline: 0.75rem; + border: 1px solid var(--sct-color-input); + background-color: var(--sct-color-muted); + font-size: var(--sct-font-size-sm); + color: var(--sct-color-muted-foreground); + white-space: nowrap; + } +} diff --git a/packages/registry/src/components/input-group/input-group.test.tsx b/packages/registry/src/components/input-group/input-group.test.tsx new file mode 100644 index 0000000..00ae2d6 --- /dev/null +++ b/packages/registry/src/components/input-group/input-group.test.tsx @@ -0,0 +1,54 @@ +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { InputGroup, InputGroupAddon } from "./input-group"; + +describe("InputGroup", () => { + it("renders without crashing", async () => { + const screen = await render( + + + , + ); + await expect.element(screen.getByTestId("ig")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render( + + + , + ); + await expect + .element(screen.getByTestId("ig")) + .toHaveAttribute("data-slot", "input-group"); + }); + + it("forwards className after scope anchor", async () => { + const screen = await render( + + + , + ); + await expect + .element(screen.getByTestId("ig")) + .toHaveClass("sct-input-group my-class"); + }); +}); + +describe("InputGroupAddon", () => { + it("renders without crashing", async () => { + const screen = await render( + $, + ); + await expect.element(screen.getByTestId("addon")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render( + $, + ); + await expect + .element(screen.getByTestId("addon")) + .toHaveAttribute("data-slot", "input-group-addon"); + }); +}); diff --git a/packages/registry/src/components/input-group/input-group.tsx b/packages/registry/src/components/input-group/input-group.tsx new file mode 100644 index 0000000..8b579f5 --- /dev/null +++ b/packages/registry/src/components/input-group/input-group.tsx @@ -0,0 +1,28 @@ +import "./input-group.css"; + +export type InputGroupProps = React.HTMLAttributes; + +export function InputGroup({ className, ...props }: InputGroupProps) { + return ( +
+ ); +} + +export type InputGroupAddonProps = React.HTMLAttributes; + +export function InputGroupAddon({ + className, + ...props +}: InputGroupAddonProps) { + return ( +
+ ); +} diff --git a/packages/registry/src/components/input-group/registry.meta.json b/packages/registry/src/components/input-group/registry.meta.json new file mode 100644 index 0000000..0e2ea89 --- /dev/null +++ b/packages/registry/src/components/input-group/registry.meta.json @@ -0,0 +1,7 @@ +{ + "title": "Input Group", + "description": "Groups an input with prefix/suffix addons.", + "dependencies": [], + "devDependencies": [], + "registryDependencies": ["input"] +} diff --git a/packages/registry/src/components/popover/popover.css b/packages/registry/src/components/popover/popover.css new file mode 100644 index 0000000..214453d --- /dev/null +++ b/packages/registry/src/components/popover/popover.css @@ -0,0 +1,27 @@ +@scope (.sct-popover-content) { + :scope { + z-index: 50; + width: 18rem; + background-color: var(--sct-color-background); + border: 1px solid var(--sct-color-border); + border-radius: var(--sct-radius-lg); + padding: 1rem; + box-shadow: var(--sct-shadow-lg); + outline: none; + transition: + opacity 200ms, + transform 200ms; + } + + @starting-style { + :scope { + opacity: 0; + transform: scale(0.95); + } + } + + :scope[data-ending-style] { + opacity: 0; + transform: scale(0.95); + } +} diff --git a/packages/registry/src/components/popover/popover.test.tsx b/packages/registry/src/components/popover/popover.test.tsx new file mode 100644 index 0000000..929de2c --- /dev/null +++ b/packages/registry/src/components/popover/popover.test.tsx @@ -0,0 +1,62 @@ +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { userEvent } from "@vitest/browser/context"; +import { + Popover, + PopoverTrigger, + PopoverContent, + PopoverClose, +} from "./popover"; + +describe("Popover", () => { + it("renders trigger without crashing", async () => { + const screen = await render( + + Open + Content + , + ); + await expect + .element(screen.getByRole("button", { name: "Open" })) + .toBeInTheDocument(); + }); + + it("sets data-slot on trigger", async () => { + const screen = await render( + + Open + Content + , + ); + await expect + .element(screen.getByRole("button", { name: "Open" })) + .toHaveAttribute("data-slot", "popover-trigger"); + }); + + it("opens popover on trigger click", async () => { + const screen = await render( + + Open + +

Popover body

+
+
, + ); + await userEvent.click(screen.getByRole("button", { name: "Open" })); + await expect.element(screen.getByText("Popover body")).toBeInTheDocument(); + }); + + it("renders close button inside content", async () => { + const screen = await render( + + Open + + Close + + , + ); + await expect + .element(screen.getByRole("button", { name: "Close" })) + .toBeInTheDocument(); + }); +}); diff --git a/packages/registry/src/components/popover/popover.tsx b/packages/registry/src/components/popover/popover.tsx new file mode 100644 index 0000000..0c528d4 --- /dev/null +++ b/packages/registry/src/components/popover/popover.tsx @@ -0,0 +1,39 @@ +import { Popover as BasePopover } from "@base-ui/react/popover"; +import "./popover.css"; + +export const Popover = BasePopover.Root; +export type PopoverProps = React.ComponentProps; + +export type PopoverTriggerProps = React.ButtonHTMLAttributes; + +export function PopoverTrigger({ ...props }: PopoverTriggerProps) { + return ; +} + +export type PopoverContentProps = React.HTMLAttributes; + +export function PopoverContent({ + className, + children, + ...props +}: PopoverContentProps) { + return ( + + + + {children} + + + + ); +} + +export type PopoverCloseProps = React.ButtonHTMLAttributes; + +export function PopoverClose({ ...props }: PopoverCloseProps) { + return ; +} diff --git a/packages/registry/src/components/popover/registry.meta.json b/packages/registry/src/components/popover/registry.meta.json new file mode 100644 index 0000000..f1a9ed6 --- /dev/null +++ b/packages/registry/src/components/popover/registry.meta.json @@ -0,0 +1,7 @@ +{ + "title": "Popover", + "description": "A floating panel anchored to a trigger element.", + "dependencies": ["@base-ui/react"], + "devDependencies": [], + "registryDependencies": [] +} diff --git a/packages/registry/src/components/resizable/registry.meta.json b/packages/registry/src/components/resizable/registry.meta.json new file mode 100644 index 0000000..946c003 --- /dev/null +++ b/packages/registry/src/components/resizable/registry.meta.json @@ -0,0 +1,7 @@ +{ + "title": "Resizable", + "description": "Resizable panel groups with draggable handles.", + "dependencies": ["react-resizable-panels"], + "devDependencies": [], + "registryDependencies": [] +} diff --git a/packages/registry/src/components/resizable/resizable.css b/packages/registry/src/components/resizable/resizable.css new file mode 100644 index 0000000..1c88b15 --- /dev/null +++ b/packages/registry/src/components/resizable/resizable.css @@ -0,0 +1,36 @@ +@scope (.sct-resizable-panel-group) { + :scope { + display: flex; + height: 100%; + width: 100%; + overflow: hidden; + } + + .sct-resizable-handle { + position: relative; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--sct-color-border); + transition: background-color 150ms; + outline: none; + } + + .sct-resizable-handle:hover { + background-color: var(--sct-color-primary); + } + + .sct-resizable-handle:focus-visible { + background-color: var(--sct-color-ring); + } + + :scope[data-panel-group-direction="horizontal"] > .sct-resizable-handle { + width: 3px; + cursor: col-resize; + } + + :scope[data-panel-group-direction="vertical"] > .sct-resizable-handle { + height: 3px; + cursor: row-resize; + } +} diff --git a/packages/registry/src/components/resizable/resizable.test.tsx b/packages/registry/src/components/resizable/resizable.test.tsx new file mode 100644 index 0000000..85eae0f --- /dev/null +++ b/packages/registry/src/components/resizable/resizable.test.tsx @@ -0,0 +1,56 @@ +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "./resizable"; + +describe("ResizablePanelGroup", () => { + it("renders without crashing", async () => { + const screen = await render( + + Panel 1 + + Panel 2 + , + ); + await expect.element(screen.getByTestId("group")).toBeInTheDocument(); + }); + + it("sets data-slot on group", async () => { + const screen = await render( + + Panel 1 + + Panel 2 + , + ); + await expect + .element(screen.getByTestId("group")) + .toHaveAttribute("data-slot", "resizable-panel-group"); + }); + + it("renders panels with content", async () => { + const screen = await render( + + Left + + Right + , + ); + await expect.element(screen.getByText("Left")).toBeInTheDocument(); + await expect.element(screen.getByText("Right")).toBeInTheDocument(); + }); + + it("renders handle with separator role", async () => { + const screen = await render( + + A + + B + , + ); + await expect.element(screen.getByRole("separator")).toBeInTheDocument(); + }); +}); diff --git a/packages/registry/src/components/resizable/resizable.tsx b/packages/registry/src/components/resizable/resizable.tsx new file mode 100644 index 0000000..d191295 --- /dev/null +++ b/packages/registry/src/components/resizable/resizable.tsx @@ -0,0 +1,59 @@ +import { + Group as PanelGroup, + Panel, + Separator as PanelResizeHandle, +} from "react-resizable-panels"; +import type { ComponentProps } from "react"; +import "./resizable.css"; + +export interface ResizablePanelGroupProps + extends ComponentProps { + "data-testid"?: string; + direction?: "horizontal" | "vertical"; +} + +export function ResizablePanelGroup({ + className, + "data-testid": testId, + id, + ...props +}: ResizablePanelGroupProps) { + // react-resizable-panels sets data-testid from the id prop. + // Forward data-testid as id so tests can locate the element. + const resolvedId = id ?? testId; + return ( + + ); +} + +export type ResizablePanelProps = ComponentProps; + +export function ResizablePanel({ className, ...props }: ResizablePanelProps) { + return ( + + ); +} + +export type ResizableHandleProps = ComponentProps; + +export function ResizableHandle({ + className, + ...props +}: ResizableHandleProps) { + return ( + + ); +} diff --git a/packages/registry/src/components/scroll-area/registry.meta.json b/packages/registry/src/components/scroll-area/registry.meta.json new file mode 100644 index 0000000..6c9356c --- /dev/null +++ b/packages/registry/src/components/scroll-area/registry.meta.json @@ -0,0 +1,7 @@ +{ + "title": "Scroll Area", + "description": "A scrollable area with custom styled scrollbars.", + "dependencies": [], + "devDependencies": [], + "registryDependencies": [] +} diff --git a/packages/registry/src/components/scroll-area/scroll-area.css b/packages/registry/src/components/scroll-area/scroll-area.css new file mode 100644 index 0000000..7102ef1 --- /dev/null +++ b/packages/registry/src/components/scroll-area/scroll-area.css @@ -0,0 +1,54 @@ +@scope (.sct-scroll-area) { + :scope { + position: relative; + overflow: hidden; + } + + .sct-scroll-area-viewport { + height: 100%; + width: 100%; + overflow: auto; + scrollbar-width: thin; + scrollbar-color: var(--sct-color-border) transparent; + } + + .sct-scroll-area-viewport::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .sct-scroll-area-viewport::-webkit-scrollbar-track { + background: transparent; + } + + .sct-scroll-area-viewport::-webkit-scrollbar-thumb { + background-color: var(--sct-color-border); + border-radius: var(--sct-radius-full); + border: 2px solid transparent; + background-clip: content-box; + } + + .sct-scroll-area-viewport::-webkit-scrollbar-thumb:hover { + background-color: var(--sct-color-muted-foreground); + } +} + +@scope (.sct-scroll-bar) { + :scope { + display: flex; + touch-action: none; + user-select: none; + padding: 1px; + } + + :scope[data-orientation="vertical"] { + width: 8px; + border-left: 1px solid transparent; + } + + :scope[data-orientation="horizontal"] { + height: 8px; + flex-direction: column; + border-top: 1px solid transparent; + } +} diff --git a/packages/registry/src/components/scroll-area/scroll-area.test.tsx b/packages/registry/src/components/scroll-area/scroll-area.test.tsx new file mode 100644 index 0000000..0671483 --- /dev/null +++ b/packages/registry/src/components/scroll-area/scroll-area.test.tsx @@ -0,0 +1,59 @@ +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { ScrollArea, ScrollBar } from "./scroll-area"; + +describe("ScrollArea", () => { + it("renders without crashing", async () => { + const screen = await render( + +
Content
+
, + ); + await expect.element(screen.getByTestId("scroll")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render( + +
Content
+
, + ); + await expect + .element(screen.getByTestId("scroll")) + .toHaveAttribute("data-slot", "scroll-area"); + }); + + it("forwards className after scope anchor", async () => { + const screen = await render( + +
Content
+
, + ); + await expect + .element(screen.getByTestId("scroll")) + .toHaveClass("sct-scroll-area my-class"); + }); +}); + +describe("ScrollBar", () => { + it("renders without crashing", async () => { + const screen = await render(); + await expect.element(screen.getByTestId("bar")).toBeInTheDocument(); + }); + + it("defaults to vertical orientation", async () => { + const screen = await render(); + await expect + .element(screen.getByTestId("bar")) + .toHaveAttribute("data-orientation", "vertical"); + }); + + it("supports horizontal orientation", async () => { + const screen = await render( + , + ); + await expect + .element(screen.getByTestId("bar")) + .toHaveAttribute("data-orientation", "horizontal"); + }); +}); diff --git a/packages/registry/src/components/scroll-area/scroll-area.tsx b/packages/registry/src/components/scroll-area/scroll-area.tsx new file mode 100644 index 0000000..e039c90 --- /dev/null +++ b/packages/registry/src/components/scroll-area/scroll-area.tsx @@ -0,0 +1,36 @@ +import "./scroll-area.css"; + +export type ScrollAreaProps = React.HTMLAttributes; + +export function ScrollArea({ className, children, ...props }: ScrollAreaProps) { + return ( +
+
+ {children} +
+
+ ); +} + +export interface ScrollBarProps extends React.HTMLAttributes { + orientation?: "vertical" | "horizontal"; +} + +export function ScrollBar({ + orientation = "vertical", + className, + ...props +}: ScrollBarProps) { + return ( +
+ ); +} diff --git a/packages/registry/src/components/separator/registry.meta.json b/packages/registry/src/components/separator/registry.meta.json new file mode 100644 index 0000000..b53434a --- /dev/null +++ b/packages/registry/src/components/separator/registry.meta.json @@ -0,0 +1,7 @@ +{ + "title": "Separator", + "description": "A visual divider between content sections.", + "dependencies": [], + "devDependencies": [], + "registryDependencies": [] +} diff --git a/packages/registry/src/components/separator/separator.css b/packages/registry/src/components/separator/separator.css new file mode 100644 index 0000000..246d433 --- /dev/null +++ b/packages/registry/src/components/separator/separator.css @@ -0,0 +1,16 @@ +@scope (.sct-separator) { + :scope { + flex-shrink: 0; + background-color: var(--sct-color-border); + } + + :scope[data-orientation="horizontal"] { + height: 1px; + width: 100%; + } + + :scope[data-orientation="vertical"] { + width: 1px; + height: 100%; + } +} diff --git a/packages/registry/src/components/separator/separator.test.tsx b/packages/registry/src/components/separator/separator.test.tsx new file mode 100644 index 0000000..c4e345e --- /dev/null +++ b/packages/registry/src/components/separator/separator.test.tsx @@ -0,0 +1,47 @@ +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { Separator } from "./separator"; + +describe("Separator", () => { + it("renders without crashing", async () => { + const screen = await render(); + await expect.element(screen.getByRole("none")).toBeInTheDocument(); + }); + + it("renders as separator role when not decorative", async () => { + const screen = await render(); + await expect.element(screen.getByRole("separator")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render(); + await expect + .element(screen.getByRole("separator")) + .toHaveAttribute("data-slot", "separator"); + }); + + it("defaults to horizontal orientation", async () => { + const screen = await render(); + await expect + .element(screen.getByRole("separator")) + .toHaveAttribute("data-orientation", "horizontal"); + }); + + it("supports vertical orientation", async () => { + const screen = await render( + , + ); + await expect + .element(screen.getByRole("separator")) + .toHaveAttribute("data-orientation", "vertical"); + }); + + it("forwards className after scope anchor", async () => { + const screen = await render( + , + ); + await expect + .element(screen.getByRole("separator")) + .toHaveClass("sct-separator my-class"); + }); +}); diff --git a/packages/registry/src/components/separator/separator.tsx b/packages/registry/src/components/separator/separator.tsx new file mode 100644 index 0000000..e8c6fdb --- /dev/null +++ b/packages/registry/src/components/separator/separator.tsx @@ -0,0 +1,24 @@ +import "./separator.css"; + +export interface SeparatorProps extends React.HTMLAttributes { + orientation?: "horizontal" | "vertical"; + decorative?: boolean; +} + +export function Separator({ + orientation = "horizontal", + decorative = true, + className, + ...props +}: SeparatorProps) { + return ( +
+ ); +} diff --git a/packages/registry/src/components/sheet/registry.meta.json b/packages/registry/src/components/sheet/registry.meta.json new file mode 100644 index 0000000..7dc28cb --- /dev/null +++ b/packages/registry/src/components/sheet/registry.meta.json @@ -0,0 +1,7 @@ +{ + "title": "Sheet", + "description": "A side-sliding panel dialog.", + "dependencies": ["@base-ui/react"], + "devDependencies": [], + "registryDependencies": [] +} diff --git a/packages/registry/src/components/sheet/sheet.css b/packages/registry/src/components/sheet/sheet.css new file mode 100644 index 0000000..a5ebf9f --- /dev/null +++ b/packages/registry/src/components/sheet/sheet.css @@ -0,0 +1,107 @@ +@scope (.sct-sheet-overlay) { + :scope { + position: fixed; + inset: 0; + background-color: color-mix( + in srgb, + var(--sct-color-foreground) 50%, + transparent + ); + z-index: 50; + transition: opacity 200ms; + } + + :scope[data-starting-style], + :scope[data-ending-style] { + opacity: 0; + } +} + +@scope (.sct-sheet-content) { + :scope { + position: fixed; + z-index: 50; + background-color: var(--sct-color-background); + box-shadow: var(--sct-shadow-lg); + padding: 1.5rem; + outline: none; + transition: transform 300ms ease; + } + + :scope[data-side="right"] { + top: 0; + right: 0; + bottom: 0; + width: 75vw; + max-width: 24rem; + border-left: 1px solid var(--sct-color-border); + } + + :scope[data-side="right"][data-starting-style], + :scope[data-side="right"][data-ending-style] { + transform: translateX(100%); + } + + :scope[data-side="left"] { + top: 0; + left: 0; + bottom: 0; + width: 75vw; + max-width: 24rem; + border-right: 1px solid var(--sct-color-border); + } + + :scope[data-side="left"][data-starting-style], + :scope[data-side="left"][data-ending-style] { + transform: translateX(-100%); + } + + :scope[data-side="top"] { + top: 0; + left: 0; + right: 0; + border-bottom: 1px solid var(--sct-color-border); + } + + :scope[data-side="top"][data-starting-style], + :scope[data-side="top"][data-ending-style] { + transform: translateY(-100%); + } + + :scope[data-side="bottom"] { + bottom: 0; + left: 0; + right: 0; + border-top: 1px solid var(--sct-color-border); + } + + :scope[data-side="bottom"][data-starting-style], + :scope[data-side="bottom"][data-ending-style] { + transform: translateY(100%); + } + + .sct-sheet-header { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin-bottom: 1rem; + } + + .sct-sheet-title { + font-size: var(--sct-font-size-lg); + font-weight: var(--sct-font-weight-semibold); + color: var(--sct-color-foreground); + } + + .sct-sheet-description { + font-size: var(--sct-font-size-sm); + color: var(--sct-color-muted-foreground); + } + + .sct-sheet-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; + } +} diff --git a/packages/registry/src/components/sheet/sheet.test.tsx b/packages/registry/src/components/sheet/sheet.test.tsx new file mode 100644 index 0000000..117f823 --- /dev/null +++ b/packages/registry/src/components/sheet/sheet.test.tsx @@ -0,0 +1,88 @@ +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { userEvent } from "@vitest/browser/context"; +import { + Sheet, + SheetTrigger, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from "./sheet"; + +describe("Sheet", () => { + it("renders trigger without crashing", async () => { + const screen = await render( + + Open + + Title + + , + ); + await expect + .element(screen.getByRole("button", { name: "Open" })) + .toBeInTheDocument(); + }); + + it("sets data-slot on trigger", async () => { + const screen = await render( + + Open + + Title + + , + ); + await expect + .element(screen.getByRole("button", { name: "Open" })) + .toHaveAttribute("data-slot", "sheet-trigger"); + }); + + it("opens sheet on trigger click", async () => { + const screen = await render( + + Open + + + Sheet Title + Sheet description + + + , + ); + await userEvent.click(screen.getByRole("button", { name: "Open" })); + await expect.element(screen.getByRole("dialog")).toBeInTheDocument(); + await expect + .element(screen.getByText("Sheet Title")) + .toBeInTheDocument(); + }); + + it("defaults to right side", async () => { + const screen = await render( + + Open + + Title + + , + ); + await expect + .element(screen.getByRole("dialog")) + .toHaveAttribute("data-side", "right"); + }); + + it("supports left side", async () => { + const screen = await render( + + Open + + Title + + , + ); + await expect + .element(screen.getByRole("dialog")) + .toHaveAttribute("data-side", "left"); + }); +}); diff --git a/packages/registry/src/components/sheet/sheet.tsx b/packages/registry/src/components/sheet/sheet.tsx new file mode 100644 index 0000000..39c4da8 --- /dev/null +++ b/packages/registry/src/components/sheet/sheet.tsx @@ -0,0 +1,106 @@ +import { Dialog as BaseDialog } from "@base-ui/react/dialog"; +import "./sheet.css"; + +export const Sheet = BaseDialog.Root; +export type SheetProps = React.ComponentProps; + +export type SheetTriggerProps = React.ButtonHTMLAttributes; + +export function SheetTrigger({ ...props }: SheetTriggerProps) { + return ; +} + +export type SheetOverlayProps = React.HTMLAttributes; + +export function SheetOverlay({ className, ...props }: SheetOverlayProps) { + return ( + + ); +} + +export interface SheetContentProps + extends React.HTMLAttributes { + side?: "top" | "right" | "bottom" | "left"; +} + +export function SheetContent({ + side = "right", + className, + children, + ...props +}: SheetContentProps) { + return ( + + + + {children} + + + ); +} + +export type SheetHeaderProps = React.HTMLAttributes; + +export function SheetHeader({ className, ...props }: SheetHeaderProps) { + return ( +
+ ); +} + +export type SheetFooterProps = React.HTMLAttributes; + +export function SheetFooter({ className, ...props }: SheetFooterProps) { + return ( +
+ ); +} + +export type SheetTitleProps = React.HTMLAttributes; + +export function SheetTitle({ className, ...props }: SheetTitleProps) { + return ( + + ); +} + +export type SheetDescriptionProps = React.HTMLAttributes; + +export function SheetDescription({ + className, + ...props +}: SheetDescriptionProps) { + return ( + + ); +} + +export type SheetCloseProps = React.ButtonHTMLAttributes; + +export function SheetClose({ ...props }: SheetCloseProps) { + return ; +} diff --git a/packages/registry/src/components/sidebar/registry.meta.json b/packages/registry/src/components/sidebar/registry.meta.json new file mode 100644 index 0000000..5ad736a --- /dev/null +++ b/packages/registry/src/components/sidebar/registry.meta.json @@ -0,0 +1,16 @@ +{ + "title": "Sidebar", + "description": "A responsive sidebar navigation with collapsible, mobile, and icon-only modes.", + "dependencies": ["@base-ui/react"], + "devDependencies": [], + "registryDependencies": [ + "button", + "collapsible", + "sheet", + "separator", + "skeleton", + "scroll-area", + "tooltip", + "input" + ] +} diff --git a/packages/registry/src/components/sidebar/sidebar.css b/packages/registry/src/components/sidebar/sidebar.css new file mode 100644 index 0000000..5fb0262 --- /dev/null +++ b/packages/registry/src/components/sidebar/sidebar.css @@ -0,0 +1,281 @@ +@scope (.sct-sidebar-provider) { + :scope { + display: flex; + min-height: 100%; + width: 100%; + } +} + +@scope (.sct-sidebar) { + :scope { + display: flex; + flex-direction: column; + width: var(--sct-sidebar-width); + min-height: 100%; + background-color: var(--sct-color-background); + border-right: 1px solid var(--sct-color-border); + transition: width 200ms ease; + flex-shrink: 0; + overflow: hidden; + } + + :scope[data-side="right"] { + border-right: none; + border-left: 1px solid var(--sct-color-border); + } + + :scope[data-variant="floating"] { + border: 1px solid var(--sct-color-border); + border-radius: var(--sct-radius-lg); + margin: 0.5rem; + box-shadow: var(--sct-shadow-sm); + } + + :scope[data-variant="inset"] { + border: none; + } + + :scope[data-collapsible="icon"][data-state="collapsed"] { + width: var(--sct-sidebar-width-collapsed); + } + + :scope[data-collapsible="offcanvas"][data-state="collapsed"] { + width: 0; + overflow: hidden; + } + + .sct-sidebar-inner { + display: flex; + flex-direction: column; + width: var(--sct-sidebar-width); + min-height: 100%; + overflow: hidden; + } + + .sct-sidebar-section-header { + padding: 0.75rem 1rem; + } + + .sct-sidebar-section-content { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + } + + .sct-sidebar-section-footer { + padding: 0.75rem 1rem; + border-top: 1px solid var(--sct-color-border); + } + + .sct-sidebar-group { + padding: 0.5rem 0; + } + + .sct-sidebar-group-label { + padding: 0.25rem 0.75rem; + font-size: var(--sct-font-size-xs); + font-weight: var(--sct-font-weight-semibold); + color: var(--sct-color-muted-foreground); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .sct-sidebar-group-action { + position: absolute; + top: 0.25rem; + right: 0.25rem; + background: none; + border: none; + cursor: pointer; + color: var(--sct-color-muted-foreground); + padding: 0.25rem; + border-radius: var(--sct-radius-sm); + } + + .sct-sidebar-group-action:hover { + color: var(--sct-color-foreground); + } + + .sct-sidebar-menu { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 1px; + } + + .sct-sidebar-menu-item { + position: relative; + } + + .sct-sidebar-menu-button { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: var(--sct-font-size-sm); + background: none; + border: none; + border-radius: var(--sct-radius-md); + cursor: pointer; + color: var(--sct-color-foreground); + text-align: left; + outline: none; + transition: background-color 150ms; + } + + .sct-sidebar-menu-button:hover { + background-color: var(--sct-color-accent); + } + + .sct-sidebar-menu-button:focus-visible { + box-shadow: 0 0 0 2px var(--sct-color-ring); + } + + .sct-sidebar-menu-button[data-active] { + background-color: var(--sct-color-accent); + font-weight: var(--sct-font-weight-medium); + } + + .sct-sidebar-menu-button[data-variant="outline"] { + border: 1px solid var(--sct-color-border); + } + + .sct-sidebar-menu-button[data-size="sm"] { + padding: 0.25rem 0.5rem; + font-size: var(--sct-font-size-xs); + } + + .sct-sidebar-menu-button[data-size="lg"] { + padding: 0.75rem 1rem; + } + + .sct-sidebar-menu-button svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; + } + + .sct-sidebar-menu-action { + position: absolute; + top: 50%; + right: 0.5rem; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + padding: 0.25rem; + border-radius: var(--sct-radius-sm); + color: var(--sct-color-muted-foreground); + opacity: 0; + } + + .sct-sidebar-menu-item:hover .sct-sidebar-menu-action { + opacity: 1; + } + + .sct-sidebar-menu-badge { + margin-left: auto; + font-size: var(--sct-font-size-xs); + color: var(--sct-color-muted-foreground); + } + + .sct-sidebar-menu-skeleton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + } + + .sct-sidebar-menu-sub { + list-style: none; + padding: 0; + margin: 0; + padding-left: 1.5rem; + border-left: 1px solid var(--sct-color-border); + margin-left: 0.75rem; + } + + .sct-sidebar-menu-sub-button { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.375rem 0.75rem; + font-size: var(--sct-font-size-xs); + background: none; + border: none; + border-radius: var(--sct-radius-md); + cursor: pointer; + color: var(--sct-color-muted-foreground); + text-align: left; + outline: none; + transition: background-color 150ms; + } + + .sct-sidebar-menu-sub-button:hover { + background-color: var(--sct-color-accent); + color: var(--sct-color-foreground); + } + + .sct-sidebar-menu-sub-button[data-active] { + color: var(--sct-color-foreground); + font-weight: var(--sct-font-weight-medium); + } +} + +@scope (.sct-sidebar-trigger) { + :scope { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + background: none; + border: none; + border-radius: var(--sct-radius-md); + cursor: pointer; + color: var(--sct-color-foreground); + outline: none; + } + + :scope:hover { + background-color: var(--sct-color-accent); + } + + :scope:focus-visible { + box-shadow: 0 0 0 2px var(--sct-color-ring); + } +} + +@scope (.sct-sidebar-rail) { + :scope { + position: absolute; + top: 0; + bottom: 0; + width: 4px; + background: transparent; + border: none; + cursor: col-resize; + outline: none; + z-index: 1; + } + + :scope:hover { + background-color: var(--sct-color-primary); + } +} + +@scope (.sct-sidebar-inset) { + :scope { + flex: 1; + min-width: 0; + } +} + +.sct-sidebar-mobile { + width: var(--sct-sidebar-width) !important; + max-width: var(--sct-sidebar-width) !important; +} diff --git a/packages/registry/src/components/sidebar/sidebar.test.tsx b/packages/registry/src/components/sidebar/sidebar.test.tsx new file mode 100644 index 0000000..58e621a --- /dev/null +++ b/packages/registry/src/components/sidebar/sidebar.test.tsx @@ -0,0 +1,161 @@ +import { render } from "vitest-browser-react"; +import { describe, it, expect } from "vitest"; +import { + SidebarProvider, + Sidebar, + SidebarHeader, + SidebarContent, + SidebarFooter, + SidebarTrigger, + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarSeparator, + SidebarInset, +} from "./sidebar"; + +describe("SidebarProvider", () => { + it("renders children", async () => { + const screen = await render( + +
Hello
+
, + ); + await expect.element(screen.getByTestId("child")).toBeInTheDocument(); + }); +}); + +describe("Sidebar", () => { + it("renders without crashing", async () => { + const screen = await render( + + + Nav content + + , + ); + await expect.element(screen.getByTestId("sidebar")).toBeInTheDocument(); + }); + + it("sets data-slot attribute", async () => { + const screen = await render( + + + Content + + , + ); + await expect + .element(screen.getByTestId("sidebar")) + .toHaveAttribute("data-slot", "sidebar"); + }); + + it("defaults to left side", async () => { + const screen = await render( + + + Content + + , + ); + await expect + .element(screen.getByTestId("sidebar")) + .toHaveAttribute("data-side", "left"); + }); +}); + +describe("SidebarTrigger", () => { + it("renders a button", async () => { + const screen = await render( + + Toggle + + Content + + , + ); + await expect + .element(screen.getByRole("button", { name: "Toggle" })) + .toBeInTheDocument(); + }); +}); + +describe("Sidebar sections", () => { + it("renders header, content, footer", async () => { + const screen = await render( + + + Header + Content + Footer + + , + ); + await expect.element(screen.getByTestId("header")).toBeInTheDocument(); + await expect.element(screen.getByTestId("content")).toBeInTheDocument(); + await expect.element(screen.getByTestId("footer")).toBeInTheDocument(); + }); +}); + +describe("SidebarMenu", () => { + it("renders menu items", async () => { + const screen = await render( + + + + + Navigation + + + + Home + + + + + + + , + ); + await expect + .element(screen.getByText("Home")) + .toBeInTheDocument(); + await expect + .element(screen.getByText("Navigation")) + .toBeInTheDocument(); + }); +}); + +describe("SidebarInset", () => { + it("renders with data-slot", async () => { + const screen = await render( + + + Nav + + Main content + , + ); + await expect + .element(screen.getByTestId("inset")) + .toHaveAttribute("data-slot", "sidebar-inset"); + }); +}); + +describe("SidebarSeparator", () => { + it("renders separator", async () => { + const screen = await render( + + + + + + + , + ); + await expect.element(screen.getByRole("none")).toBeInTheDocument(); + }); +}); diff --git a/packages/registry/src/components/sidebar/sidebar.tsx b/packages/registry/src/components/sidebar/sidebar.tsx new file mode 100644 index 0000000..c70781f --- /dev/null +++ b/packages/registry/src/components/sidebar/sidebar.tsx @@ -0,0 +1,519 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { Separator } from "../separator/separator"; +import { Skeleton } from "../skeleton/skeleton"; +import { Sheet, SheetContent } from "../sheet/sheet"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../tooltip/tooltip"; +import "./sidebar.css"; + +// ---- Context ---- + +interface SidebarContextValue { + open: boolean; + setOpen: (open: boolean) => void; + toggleSidebar: () => void; + isMobile: boolean; + state: "expanded" | "collapsed"; +} + +const SidebarContext = createContext(null); + +export function useSidebar() { + const ctx = useContext(SidebarContext); + if (!ctx) { + throw new Error("useSidebar must be used within a SidebarProvider"); + } + return ctx; +} + +// ---- Provider ---- + +export interface SidebarProviderProps + extends React.HTMLAttributes { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function SidebarProvider({ + defaultOpen = true, + open: controlledOpen, + onOpenChange, + className, + children, + ...props +}: SidebarProviderProps) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const [isMobile, setIsMobile] = useState( + () => + typeof window !== "undefined" && + window.matchMedia("(max-width: 768px)").matches, + ); + + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = useCallback( + (value: boolean) => { + onOpenChange?.(value); + if (controlledOpen === undefined) { + setUncontrolledOpen(value); + } + }, + [controlledOpen, onOpenChange], + ); + + const toggleSidebar = useCallback(() => { + setOpen(!open); + }, [open, setOpen]); + + useEffect(() => { + const mql = window.matchMedia("(max-width: 768px)"); + const onChange = () => setIsMobile(mql.matches); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "b" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + toggleSidebar(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + const state: "expanded" | "collapsed" = open ? "expanded" : "collapsed"; + + const value = useMemo( + () => ({ open, setOpen, toggleSidebar, isMobile, state }), + [open, setOpen, toggleSidebar, isMobile, state], + ); + + return ( + +
+ {children} +
+
+ ); +} + +// ---- Main Sidebar ---- + +export interface SidebarProps extends React.HTMLAttributes { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; +} + +export function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: SidebarProps) { + const { open, setOpen, isMobile } = useSidebar(); + + if (collapsible === "none") { + return ( + + ); + } + + if (isMobile) { + return ( + + + {children} + + + ); + } + + return ( + + ); +} + +// ---- Trigger ---- + +export type SidebarTriggerProps = React.ButtonHTMLAttributes; + +export function SidebarTrigger({ + className, + onClick, + ...props +}: SidebarTriggerProps) { + const { toggleSidebar } = useSidebar(); + return ( +