Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,41 @@
|
||||
# @toolctrl/ui
|
||||
|
||||
Source-only UI adapter boundary for future primitive migration.
|
||||
|
||||
This package intentionally does not replace existing app imports yet. Runtime components remain in `apps/web/app` until a category migration explicitly moves them. The adapter contracts live in `@toolctrl/ui/*` so agents can target stable names before runtime imports are migrated.
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | Export | Intended ownership |
|
||||
| --- | --- | --- |
|
||||
| `buttons` | `@toolctrl/ui/buttons` | Button-like actions, icon buttons and grouped controls. |
|
||||
| `inputs` | `@toolctrl/ui/inputs` | Text fields, selects, checkboxes, switches and form-adjacent controls. |
|
||||
| `dialogs` | `@toolctrl/ui/dialogs` | Modals, alert dialogs, drawers, popovers and confirmation surfaces. |
|
||||
| `tables` | `@toolctrl/ui/tables` | Data tables, row actions, pagination and empty states for tabular data. |
|
||||
| `layout` | `@toolctrl/ui/layout` | Page shells, panels, stacks, grids, containers and spacing primitives. |
|
||||
| `navigation` | `@toolctrl/ui/navigation` | Menus, tabs, breadcrumbs, sidebars and command/navigation affordances. |
|
||||
| `feedback` | `@toolctrl/ui/feedback` | Toasts, alerts, progress, skeletons, badges and status indicators. |
|
||||
|
||||
## Current Usage
|
||||
|
||||
The package is runtime-safe and still source-only. `buttons`, `layout`, and `navigation` expose available adapter metadata for the current app-level implementation:
|
||||
|
||||
```ts
|
||||
import { uiAdapterCategories } from "@toolctrl/ui";
|
||||
import { buttonAdapters } from "@toolctrl/ui/buttons";
|
||||
import { layoutAdapters } from "@toolctrl/ui/layout";
|
||||
import { navigationAdapters } from "@toolctrl/ui/navigation";
|
||||
```
|
||||
|
||||
`buttonAdapters` describes `Button` and `buttonVariants`. `layoutAdapters` describes `ShadcnSidebarLayout` and `UiRegistry`. `navigationAdapters` describes `AppSidebar`, `AdminSidebar`, `DocsSidebar`, `NavMain`, `NavSecondary`, `NavQuickLinks`, `NavActions`, and `SidebarPrimitives`.
|
||||
|
||||
These contracts do not render components, import React or alter app runtime behavior yet. Other category exports remain empty readonly registries until their migration phase.
|
||||
|
||||
## Migration Guidance
|
||||
|
||||
1. Keep current `apps/web/app/components` imports in place until a migration phase owns that category.
|
||||
2. Migrate one category at a time, starting with leaf primitives that have low app-specific coupling.
|
||||
3. Add concrete component exports to the matching category subpath, then re-export from `@toolctrl/ui` only when the API is stable.
|
||||
4. Keep category exports backward-compatible once app code starts importing them.
|
||||
|
||||
See [docs/migration.md](./docs/migration.md) for the category migration checklist.
|
||||
@@ -0,0 +1,25 @@
|
||||
# UI Adapter Migration
|
||||
|
||||
`@toolctrl/ui` is the package boundary for future UI primitive ownership. Phase 11 only establishes the boundary; it does not migrate existing app imports.
|
||||
|
||||
## Category Checklist
|
||||
|
||||
For each category:
|
||||
|
||||
1. Inventory current app-level primitives and third-party dependencies.
|
||||
2. Pick a stable export name and props contract before moving implementation.
|
||||
3. Add the implementation under the matching `packages/ui/src/<category>.ts` entrypoint or a category-owned folder.
|
||||
4. Add package-level tests or type assertions when the export becomes non-trivial.
|
||||
5. Migrate app imports in a separate phase after the package API is reviewed.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- `buttons`
|
||||
- `inputs`
|
||||
- `dialogs`
|
||||
- `tables`
|
||||
- `layout`
|
||||
- `navigation`
|
||||
- `feedback`
|
||||
|
||||
Do not mix app route behavior, data loading or feature-specific orchestration into this package. Keep this layer focused on reusable UI primitives and adapter contracts.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@toolctrl/ui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./buttons": "./src/buttons.ts",
|
||||
"./inputs": "./src/inputs.ts",
|
||||
"./dialogs": "./src/dialogs.ts",
|
||||
"./tables": "./src/tables.ts",
|
||||
"./layout": "./src/layout.ts",
|
||||
"./navigation": "./src/navigation.ts",
|
||||
"./feedback": "./src/feedback.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm run typecheck",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { UiAdapterDefinition } from "./types.js";
|
||||
|
||||
export const buttonAdapterNames = ["Button", "buttonVariants"] as const;
|
||||
|
||||
export type ButtonAdapterName = (typeof buttonAdapterNames)[number];
|
||||
|
||||
export type ButtonAdapter = UiAdapterDefinition<"buttons"> & {
|
||||
readonly status: "available";
|
||||
readonly name: ButtonAdapterName;
|
||||
readonly source: "apps/web/app/components/ui/button.tsx";
|
||||
readonly implementation: "shadcn/radix";
|
||||
readonly packageExport: "@toolctrl/ui/buttons";
|
||||
};
|
||||
|
||||
export const buttonAdapters = buttonAdapterNames.map((name) => ({
|
||||
category: "buttons",
|
||||
name,
|
||||
status: "available",
|
||||
source: "apps/web/app/components/ui/button.tsx",
|
||||
implementation: "shadcn/radix",
|
||||
packageExport: "@toolctrl/ui/buttons",
|
||||
description: name === "Button" ? "Primary application button primitive adapter target." : "Button variant class generator adapter target.",
|
||||
})) satisfies readonly ButtonAdapter[];
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createEmptyAdapterRegistry, type UiAdapterPlaceholder } from "./types.js";
|
||||
|
||||
export type DialogAdapter = UiAdapterPlaceholder<"dialogs">;
|
||||
|
||||
export const dialogAdapters = createEmptyAdapterRegistry("dialogs");
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createEmptyAdapterRegistry, type UiAdapterPlaceholder } from "./types.js";
|
||||
|
||||
export type FeedbackAdapter = UiAdapterPlaceholder<"feedback">;
|
||||
|
||||
export const feedbackAdapters = createEmptyAdapterRegistry("feedback");
|
||||
@@ -0,0 +1,17 @@
|
||||
export { createEmptyAdapterRegistry, isUiAdapterCategory, uiAdapterCategories } from "./types.js";
|
||||
export type { UiAdapterCategory, UiAdapterDefinition, UiAdapterLifecycle, UiAdapterPlaceholder, UiAdapterRegistry } from "./types.js";
|
||||
|
||||
export { buttonAdapterNames, buttonAdapters } from "./buttons.js";
|
||||
export type { ButtonAdapter, ButtonAdapterName } from "./buttons.js";
|
||||
export { inputAdapters } from "./inputs.js";
|
||||
export type { InputAdapter } from "./inputs.js";
|
||||
export { dialogAdapters } from "./dialogs.js";
|
||||
export type { DialogAdapter } from "./dialogs.js";
|
||||
export { tableAdapters } from "./tables.js";
|
||||
export type { TableAdapter } from "./tables.js";
|
||||
export { layoutAdapterNames, layoutAdapters } from "./layout.js";
|
||||
export type { LayoutAdapter, LayoutAdapterName } from "./layout.js";
|
||||
export { navigationAdapterNames, navigationAdapters } from "./navigation.js";
|
||||
export type { NavigationAdapter, NavigationAdapterName } from "./navigation.js";
|
||||
export { feedbackAdapters } from "./feedback.js";
|
||||
export type { FeedbackAdapter } from "./feedback.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createEmptyAdapterRegistry, type UiAdapterPlaceholder } from "./types.js";
|
||||
|
||||
export type InputAdapter = UiAdapterPlaceholder<"inputs">;
|
||||
|
||||
export const inputAdapters = createEmptyAdapterRegistry("inputs");
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { UiAdapterDefinition } from "./types.js";
|
||||
|
||||
export const layoutAdapterNames = ["ShadcnSidebarLayout", "UiRegistry"] as const;
|
||||
|
||||
export type LayoutAdapterName = (typeof layoutAdapterNames)[number];
|
||||
|
||||
const layoutAdapterSources = {
|
||||
ShadcnSidebarLayout: "apps/web/app/components/layouts/sidebars/shadcn/ShadcnSidebarLayout.tsx",
|
||||
UiRegistry: "apps/web/app/application/ui/uiRegistry.ts",
|
||||
} as const satisfies Record<LayoutAdapterName, string>;
|
||||
|
||||
export type LayoutAdapter = UiAdapterDefinition<"layout"> & {
|
||||
readonly status: "available";
|
||||
readonly name: LayoutAdapterName;
|
||||
readonly source: (typeof layoutAdapterSources)[LayoutAdapterName];
|
||||
readonly implementation: "shell-layout" | "agent-registry";
|
||||
readonly packageExport: "@toolctrl/ui/layout";
|
||||
};
|
||||
|
||||
export const layoutAdapters = layoutAdapterNames.map((name) => ({
|
||||
category: "layout",
|
||||
name,
|
||||
status: "available",
|
||||
source: layoutAdapterSources[name],
|
||||
implementation: name === "UiRegistry" ? "agent-registry" : "shell-layout",
|
||||
packageExport: "@toolctrl/ui/layout",
|
||||
description:
|
||||
name === "UiRegistry"
|
||||
? "Stable data-ui-id registry consumed by shell, navigation and UI inventory guards."
|
||||
: "Stable authenticated shell layout adapter contract. Runtime implementation currently remains in app/.",
|
||||
})) satisfies readonly LayoutAdapter[];
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { UiAdapterDefinition } from "./types.js";
|
||||
|
||||
export const navigationAdapterNames = [
|
||||
"AppSidebar",
|
||||
"AdminSidebar",
|
||||
"DocsSidebar",
|
||||
"NavMain",
|
||||
"NavSecondary",
|
||||
"NavQuickLinks",
|
||||
"NavActions",
|
||||
"SidebarPrimitives",
|
||||
] as const;
|
||||
|
||||
export type NavigationAdapterName = (typeof navigationAdapterNames)[number];
|
||||
|
||||
const navigationAdapterSources = {
|
||||
AppSidebar: "apps/web/app/application/sidebar/AppSidebar.tsx",
|
||||
AdminSidebar: "apps/web/app/application/sidebar/AdminSidebar.tsx",
|
||||
DocsSidebar: "apps/web/app/application/sidebar/DocsSidebar.ts",
|
||||
NavMain: "apps/web/app/components/layouts/sidebars/shadcn/nav-main.tsx",
|
||||
NavSecondary: "apps/web/app/components/layouts/sidebars/shadcn/nav-secondary.tsx",
|
||||
NavQuickLinks: "apps/web/app/components/layouts/sidebars/shadcn/nav-quick-links.tsx",
|
||||
NavActions: "apps/web/app/components/layouts/sidebars/shadcn/nav-actions.tsx",
|
||||
SidebarPrimitives: "apps/web/app/components/ui/sidebar.tsx",
|
||||
} as const satisfies Record<NavigationAdapterName, string>;
|
||||
|
||||
export type NavigationAdapter = UiAdapterDefinition<"navigation"> & {
|
||||
readonly status: "available";
|
||||
readonly name: NavigationAdapterName;
|
||||
readonly source: (typeof navigationAdapterSources)[NavigationAdapterName];
|
||||
readonly implementation: "shadcn/radix" | "application-registry" | "custom";
|
||||
readonly packageExport: "@toolctrl/ui/navigation";
|
||||
};
|
||||
|
||||
export const navigationAdapters = navigationAdapterNames.map((name) => ({
|
||||
category: "navigation",
|
||||
name,
|
||||
status: "available",
|
||||
source: navigationAdapterSources[name],
|
||||
implementation: name.endsWith("Sidebar") ? "application-registry" : name === "NavActions" ? "custom" : "shadcn/radix",
|
||||
packageExport: "@toolctrl/ui/navigation",
|
||||
description: `Stable navigation adapter contract for ${name}. Runtime implementation currently remains in app/.`,
|
||||
})) satisfies readonly NavigationAdapter[];
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.map((part) => part.charAt(0))
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
const meta: Meta<typeof Avatar> = {
|
||||
title: "UI / Feedback / Avatar",
|
||||
component: Avatar,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Avatar>;
|
||||
|
||||
export const Initials: Story = {
|
||||
render: () => (
|
||||
<Avatar>
|
||||
<AvatarFallback>{initials("Ada Lovelace")}</AvatarFallback>
|
||||
</Avatar>
|
||||
),
|
||||
};
|
||||
|
||||
export const Stack: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{["Ada Lovelace", "Grace Hopper", "Linus Torvalds", "N N"].map((name) => (
|
||||
<Avatar key={name}>
|
||||
<AvatarFallback>{initials(name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const SizeScale: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{[20, 28, 36, 48, 64].map((px) => (
|
||||
<Avatar key={px} style={{ width: px, height: px }} className="size-auto">
|
||||
<AvatarFallback className="text-[0.6rem]">AB</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithImage: Story = {
|
||||
render: () => (
|
||||
<Avatar>
|
||||
<AvatarImage src="https://i.pravatar.cc/64?img=64" alt="" />
|
||||
<AvatarFallback>AL</AvatarFallback>
|
||||
</Avatar>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: "UI / Feedback / Badge",
|
||||
component: Badge,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: "inline-radio" },
|
||||
options: ["default", "secondary", "destructive", "outline"],
|
||||
},
|
||||
},
|
||||
args: { variant: "default", children: "New" },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Badge variant="default">Default</Badge>
|
||||
<Badge variant="secondary">Secondary</Badge>
|
||||
<Badge variant="destructive">Failed</Badge>
|
||||
<Badge variant="outline">Draft</Badge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const SemanticColors: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Badge color="green" withColorDot>
|
||||
Active
|
||||
</Badge>
|
||||
<Badge color="yellow" withColorDot>
|
||||
Pending
|
||||
</Badge>
|
||||
<Badge color="red" withColorDot>
|
||||
Failed
|
||||
</Badge>
|
||||
<Badge color="blue" withColorDot>
|
||||
Info
|
||||
</Badge>
|
||||
<Badge color="gray" withColorDot>
|
||||
Draft
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InText: Story = {
|
||||
render: () => (
|
||||
<p style={{ fontSize: 14 }}>
|
||||
Workspace status: <Badge color="green">healthy</Badge>. Latest deploy passed all gates.
|
||||
</p>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: "UI / Buttons / Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
|
||||
},
|
||||
size: { control: { type: "inline-radio" }, options: ["xs", "sm", "default", "lg", "icon"] },
|
||||
isLoading: { control: "boolean" },
|
||||
disabled: { control: "boolean" },
|
||||
},
|
||||
args: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
isLoading: false,
|
||||
disabled: false,
|
||||
children: "Save changes",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Primary: Story = {};
|
||||
|
||||
export const Destructive: Story = { args: { variant: "destructive", children: "Delete account" } };
|
||||
|
||||
export const Outline: Story = { args: { variant: "outline", children: "Cancel" } };
|
||||
|
||||
export const Secondary: Story = { args: { variant: "secondary", children: "Secondary action" } };
|
||||
|
||||
export const Ghost: Story = { args: { variant: "ghost", children: "Dismiss" } };
|
||||
|
||||
export const LinkLike: Story = { args: { variant: "link", children: "Read the docs" } };
|
||||
|
||||
export const Loading: Story = { args: { isLoading: true, children: "Saving" } };
|
||||
|
||||
export const Disabled: Story = { args: { disabled: true, children: "Unavailable" } };
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: (args) => (
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<Button {...args} size="xs">
|
||||
Extra small
|
||||
</Button>
|
||||
<Button {...args} size="sm">
|
||||
Small
|
||||
</Button>
|
||||
<Button {...args} size="default">
|
||||
Default
|
||||
</Button>
|
||||
<Button {...args} size="lg">
|
||||
Large
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
args: { children: undefined },
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: "UI / Layout / Card",
|
||||
component: Card,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Card>;
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<Card className="max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>Manage profile, security, and billing in one place.</CardContent>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithFooter: Story = {
|
||||
render: () => (
|
||||
<Card className="max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Invite teammates</CardTitle>
|
||||
<CardDescription>You can revoke access at any time.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>Send invitations to start collaborating.</CardContent>
|
||||
<CardFooter className="justify-end gap-2 border-t">
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
<Button>Send invites</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
render: () => (
|
||||
<Card className="max-w-sm">
|
||||
<CardContent>A bare card without a header or footer for compact layouts.</CardContent>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { DemoCheckbox } from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta<typeof DemoCheckbox> = {
|
||||
title: "UI / Inputs / Checkbox",
|
||||
component: DemoCheckbox,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
checked: { control: "boolean" },
|
||||
disabled: { control: "boolean" },
|
||||
},
|
||||
args: { label: "I accept the terms", name: "accept-terms" },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DemoCheckbox>;
|
||||
|
||||
export const Unchecked: Story = {};
|
||||
|
||||
export const Checked: Story = { args: { defaultChecked: true } };
|
||||
|
||||
export const Disabled: Story = { args: { disabled: true, defaultChecked: true } };
|
||||
|
||||
export const Group: Story = {
|
||||
render: () => (
|
||||
<fieldset
|
||||
style={{
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<legend style={{ padding: "0 4px", fontWeight: 600 }}>Notifications</legend>
|
||||
<DemoCheckbox name="prefs" label="Product updates" defaultChecked />
|
||||
<DemoCheckbox name="prefs" label="Weekly newsletter" />
|
||||
<DemoCheckbox name="prefs" label="Security alerts" defaultChecked />
|
||||
</fieldset>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { DemoBadge, DemoButton, DemoInput, DemoTable } from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "UI / Tables / DataTable",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
type Invoice = {
|
||||
id: string;
|
||||
customer: string;
|
||||
amount: number;
|
||||
status: "paid" | "open" | "void";
|
||||
issuedAt: string;
|
||||
};
|
||||
|
||||
const invoices: ReadonlyArray<Invoice> = [
|
||||
{ id: "INV-0001", customer: "Acme", amount: 4200, status: "paid", issuedAt: "2026-04-12" },
|
||||
{ id: "INV-0002", customer: "Globex", amount: 800, status: "open", issuedAt: "2026-04-18" },
|
||||
{ id: "INV-0003", customer: "Initech", amount: 12_500, status: "paid", issuedAt: "2026-04-20" },
|
||||
{ id: "INV-0004", customer: "Hooli", amount: 320, status: "void", issuedAt: "2026-04-21" },
|
||||
{ id: "INV-0005", customer: "Soylent", amount: 1500, status: "open", issuedAt: "2026-04-25" },
|
||||
{ id: "INV-0006", customer: "Pied Piper", amount: 9999, status: "paid", issuedAt: "2026-04-28" },
|
||||
{ id: "INV-0007", customer: "Stark", amount: 50_000, status: "paid", issuedAt: "2026-05-02" },
|
||||
{ id: "INV-0008", customer: "Wayne", amount: 0, status: "void", issuedAt: "2026-05-03" },
|
||||
{ id: "INV-0009", customer: "Wonka", amount: 720, status: "open", issuedAt: "2026-05-05" },
|
||||
{ id: "INV-0010", customer: "Cyberdyne", amount: 2200, status: "paid", issuedAt: "2026-05-07" },
|
||||
];
|
||||
|
||||
const statusTone: Record<Invoice["status"], "success" | "warning" | "muted"> = {
|
||||
paid: "success",
|
||||
open: "warning",
|
||||
void: "muted",
|
||||
};
|
||||
|
||||
function Toolbar({ filter, onFilterChange }: { filter: string; onFilterChange: (next: string) => void }) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 12 }}>
|
||||
<DemoInput placeholder="Filter by customer…" value={filter} onChange={(event) => onFilterChange(event.currentTarget.value)} style={{ maxWidth: 260 }} />
|
||||
<DemoButton variant="outline" onClick={() => onFilterChange("")}>
|
||||
Clear
|
||||
</DemoButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pagination({ page, pageCount, onChange }: { page: number; pageCount: number; onChange: (next: number) => void }) {
|
||||
return (
|
||||
<div role="navigation" aria-label="Pagination" style={{ display: "flex", gap: 8, marginTop: 12, alignItems: "center" }}>
|
||||
<DemoButton size="sm" variant="outline" disabled={page === 0} onClick={() => onChange(page - 1)}>
|
||||
Prev
|
||||
</DemoButton>
|
||||
<span style={{ fontSize: 13 }}>
|
||||
Page {page + 1} / {pageCount}
|
||||
</span>
|
||||
<DemoButton size="sm" variant="outline" disabled={page >= pageCount - 1} onClick={() => onChange(page + 1)}>
|
||||
Next
|
||||
</DemoButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const FilteredAndPaginated: Story = {
|
||||
name: "Filtered + paginated",
|
||||
render: () => {
|
||||
const [filter, setFilter] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 4;
|
||||
const filtered = useMemo(() => {
|
||||
const lc = filter.trim().toLowerCase();
|
||||
return lc.length === 0 ? invoices : invoices.filter((row) => row.customer.toLowerCase().includes(lc));
|
||||
}, [filter]);
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const safePage = Math.min(page, pageCount - 1);
|
||||
const rows = filtered.slice(safePage * pageSize, safePage * pageSize + pageSize);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
<Toolbar
|
||||
filter={filter}
|
||||
onFilterChange={(value) => {
|
||||
setFilter(value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
<DemoTable<Invoice>
|
||||
columns={[
|
||||
{ key: "id", header: "Invoice" },
|
||||
{ key: "customer", header: "Customer" },
|
||||
{
|
||||
key: "amount",
|
||||
header: "Amount",
|
||||
align: "right",
|
||||
render: (row) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(row.amount),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (row) => <DemoBadge tone={statusTone[row.status]}>{row.status}</DemoBadge>,
|
||||
},
|
||||
{ key: "issuedAt", header: "Issued" },
|
||||
]}
|
||||
rows={rows}
|
||||
/>
|
||||
<Pagination page={safePage} pageCount={pageCount} onChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { DemoButton, DemoDialog } from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta<typeof DemoDialog> = {
|
||||
title: "UI / Overlays / Dialog",
|
||||
component: DemoDialog,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DemoDialog>;
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<DemoButton onClick={() => setOpen(true)}>Open dialog</DemoButton>
|
||||
<DemoDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Heads up"
|
||||
description="This is a description of the modal action."
|
||||
footer={
|
||||
<>
|
||||
<DemoButton variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</DemoButton>
|
||||
<DemoButton onClick={() => setOpen(false)}>Got it</DemoButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ConfirmDestructive: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<DemoButton variant="destructive" onClick={() => setOpen(true)}>
|
||||
Delete project
|
||||
</DemoButton>
|
||||
<DemoDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Delete project?"
|
||||
description="This action cannot be undone. All data will be permanently removed."
|
||||
footer={
|
||||
<>
|
||||
<DemoButton variant="outline" onClick={() => setOpen(false)}>
|
||||
Keep project
|
||||
</DemoButton>
|
||||
<DemoButton variant="destructive" onClick={() => setOpen(false)}>
|
||||
Confirm delete
|
||||
</DemoButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AlwaysOpen: Story = {
|
||||
render: () => (
|
||||
<DemoDialog
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
title="Permanent dialog"
|
||||
description="Used for layout / visual review inside Storybook."
|
||||
>
|
||||
The dialog body renders any React children, e.g. forms, lists, or images.
|
||||
</DemoDialog>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { DemoButton } from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "UI / Feedback / EmptyState",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
function EmptyState({
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
illustration,
|
||||
}: {
|
||||
title: string;
|
||||
body: string;
|
||||
action?: React.ReactNode;
|
||||
illustration?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
border: "1px dashed var(--color-border)",
|
||||
borderRadius: 8,
|
||||
padding: 32,
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
maxWidth: 420,
|
||||
color: "var(--color-muted)",
|
||||
}}
|
||||
>
|
||||
{illustration}
|
||||
<h3 style={{ margin: 0, fontSize: 18, color: "var(--color-fg)" }}>{title}</h3>
|
||||
<p style={{ margin: 0, fontSize: 14 }}>{body}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Inbox = () => (
|
||||
<svg width="56" height="56" viewBox="0 0 56 56" aria-hidden focusable="false">
|
||||
<rect x="6" y="14" width="44" height="32" rx="4" fill="var(--color-accent)" />
|
||||
<path d="M6 28 H20 L24 34 H32 L36 28 H50" stroke="var(--color-fg)" strokeWidth="2" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const NoData: Story = {
|
||||
name: "No data",
|
||||
render: () => (
|
||||
<EmptyState
|
||||
illustration={<Inbox />}
|
||||
title="No projects yet"
|
||||
body="Create your first project to start collaborating with your team."
|
||||
action={<DemoButton>Create project</DemoButton>}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const NoResults: Story = {
|
||||
name: "No search results",
|
||||
render: () => (
|
||||
<EmptyState
|
||||
title="No matches for your search"
|
||||
body="Try a different keyword or clear the filters to see all items."
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ErrorState: Story = {
|
||||
name: "Error state",
|
||||
render: () => (
|
||||
<EmptyState
|
||||
title="Something went wrong"
|
||||
body="We could not load the dashboard. Please try again in a moment."
|
||||
action={<DemoButton variant="outline">Retry</DemoButton>}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DemoButton,
|
||||
DemoCheckbox,
|
||||
DemoField,
|
||||
DemoInput,
|
||||
DemoSelect,
|
||||
DemoTextarea,
|
||||
} from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "UI / Forms / Form",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
function ContactForm() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
return (
|
||||
<form
|
||||
style={{ display: "flex", flexDirection: "column", gap: 14, maxWidth: 420 }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setSubmitted(true);
|
||||
}}
|
||||
>
|
||||
<DemoField label="Full name" htmlFor="contact-name">
|
||||
<DemoInput id="contact-name" name="name" required />
|
||||
</DemoField>
|
||||
<DemoField label="Email" htmlFor="contact-email">
|
||||
<DemoInput id="contact-email" name="email" type="email" required />
|
||||
</DemoField>
|
||||
<DemoField label="Topic" htmlFor="contact-topic">
|
||||
<DemoSelect
|
||||
id="contact-topic"
|
||||
name="topic"
|
||||
options={[
|
||||
{ value: "sales", label: "Sales" },
|
||||
{ value: "support", label: "Support" },
|
||||
{ value: "partnership", label: "Partnership" },
|
||||
]}
|
||||
/>
|
||||
</DemoField>
|
||||
<DemoField label="Message" htmlFor="contact-message" hint="A few sentences are enough.">
|
||||
<DemoTextarea id="contact-message" name="message" rows={4} required />
|
||||
</DemoField>
|
||||
<DemoCheckbox name="newsletter" label="Subscribe me to product updates" />
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<DemoButton variant="ghost" type="reset">
|
||||
Reset
|
||||
</DemoButton>
|
||||
<DemoButton type="submit">Send message</DemoButton>
|
||||
</div>
|
||||
{submitted ? (
|
||||
<p role="status" style={{ color: "var(--color-success)", fontSize: 13 }}>
|
||||
Form submitted — thanks!
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export const ContactFormStory: Story = {
|
||||
name: "Contact form",
|
||||
render: () => <ContactForm />,
|
||||
};
|
||||
|
||||
export const FieldList: Story = {
|
||||
name: "Field list (read-only)",
|
||||
render: () => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12, maxWidth: 360 }}>
|
||||
<DemoField label="Plan" htmlFor="ro-plan">
|
||||
<DemoInput id="ro-plan" value="Team" readOnly />
|
||||
</DemoField>
|
||||
<DemoField label="Seats used" htmlFor="ro-seats">
|
||||
<DemoInput id="ro-seats" value="14 / 25" readOnly />
|
||||
</DemoField>
|
||||
<DemoField label="Renews on" htmlFor="ro-renew">
|
||||
<DemoInput id="ro-renew" value="2026-09-12" readOnly />
|
||||
</DemoField>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
htmlFor,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
htmlFor: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex max-w-sm flex-col gap-1.5 text-sm" htmlFor={htmlFor}>
|
||||
<span className="font-medium">{label}</span>
|
||||
{children}
|
||||
{hint && !error ? <span className="text-muted-foreground text-xs">{hint}</span> : null}
|
||||
{error ? (
|
||||
<span className="text-destructive text-xs" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: "UI / Inputs / Input",
|
||||
component: Input,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
type: { control: { type: "select" }, options: ["text", "email", "password", "search", "tel"] },
|
||||
disabled: { control: "boolean" },
|
||||
},
|
||||
args: { type: "text", placeholder: "you@example.com" },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Input>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => (
|
||||
<Field label="Email" htmlFor="email-default">
|
||||
<Input id="email-default" {...args} />
|
||||
</Field>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithHint: Story = {
|
||||
render: (args) => (
|
||||
<Field label="Email" hint="We use this to send sign-in links." htmlFor="email-hint">
|
||||
<Input id="email-hint" {...args} />
|
||||
</Field>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
render: (args) => (
|
||||
<Field label="Email" error="Enter a valid email address." htmlFor="email-error">
|
||||
<Input id="email-error" {...args} aria-invalid="true" />
|
||||
</Field>
|
||||
),
|
||||
};
|
||||
|
||||
export const PasswordType: Story = {
|
||||
args: { type: "password", placeholder: "" },
|
||||
render: (args) => (
|
||||
<Field label="Password" htmlFor="password-default">
|
||||
<Input id="password-default" {...args} />
|
||||
</Field>
|
||||
),
|
||||
};
|
||||
|
||||
export const DisabledInput: Story = {
|
||||
args: { disabled: true, value: "locked@example.com" },
|
||||
render: (args) => (
|
||||
<Field label="Email" htmlFor="email-disabled">
|
||||
<Input id="email-disabled" {...args} readOnly />
|
||||
</Field>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "UI / Feedback / LoadingState",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
function SkeletonLine({ width }: { width: string | number }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
display: "block",
|
||||
height: 12,
|
||||
width,
|
||||
borderRadius: 6,
|
||||
background: "var(--color-accent)",
|
||||
animation: "demo-pulse 1.2s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function injectKeyframesOnce() {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.getElementById("demo-pulse-keyframes")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "demo-pulse-keyframes";
|
||||
style.textContent = "@keyframes demo-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
export const SkeletonCard: Story = {
|
||||
name: "Skeleton card",
|
||||
render: () => {
|
||||
injectKeyframesOnce();
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
style={{
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
width: 360,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<SkeletonLine width="40%" />
|
||||
<SkeletonLine width="100%" />
|
||||
<SkeletonLine width="100%" />
|
||||
<SkeletonLine width="60%" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const InlineSpinner: Story = {
|
||||
name: "Inline spinner",
|
||||
render: () => (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: "50%",
|
||||
border: "2px solid var(--color-muted)",
|
||||
borderRightColor: "transparent",
|
||||
display: "inline-block",
|
||||
animation: "demo-spin 600ms linear infinite",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 13 }}>Loading workspace…</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const TableLoading: Story = {
|
||||
name: "Table loading",
|
||||
render: () => {
|
||||
injectKeyframesOnce();
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading rows"
|
||||
style={{
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
width: 480,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr 60px",
|
||||
gap: 12,
|
||||
padding: "10px 14px",
|
||||
borderTop: index === 0 ? "none" : "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<SkeletonLine width="80%" />
|
||||
<SkeletonLine width="60%" />
|
||||
<SkeletonLine width="40%" />
|
||||
<SkeletonLine width="100%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { DemoButton } from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "UI / Overlays / Popover",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
const menuButtonStyle = {
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
padding: 0,
|
||||
textAlign: "left",
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
function Popover({
|
||||
trigger,
|
||||
children,
|
||||
}: {
|
||||
trigger: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
return (
|
||||
<div ref={ref} style={{ position: "relative", display: "inline-block" }}>
|
||||
{/* trigger is always a focusable DemoButton; its keyboard activation bubbles a
|
||||
click to this span, so the span needs no separate role/key handler. */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
|
||||
<span onClick={() => setOpen((prev) => !prev)}>{trigger}</span>
|
||||
{open ? (
|
||||
<div
|
||||
role="dialog"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 6px)",
|
||||
left: 0,
|
||||
minWidth: 220,
|
||||
background: "var(--color-card)",
|
||||
color: "var(--color-card-foreground)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
|
||||
fontSize: 14,
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SimplePopover: Story = {
|
||||
name: "Simple popover",
|
||||
render: () => (
|
||||
<Popover
|
||||
trigger={<DemoButton variant="outline">Open menu</DemoButton>}
|
||||
>
|
||||
<ul style={{ margin: 0, padding: 0, listStyle: "none", display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<li><button type="button" style={menuButtonStyle}>Profile</button></li>
|
||||
<li><button type="button" style={menuButtonStyle}>Settings</button></li>
|
||||
<li><button type="button" style={{ ...menuButtonStyle, color: "var(--color-destructive)" }}>Sign out</button></li>
|
||||
</ul>
|
||||
</Popover>
|
||||
),
|
||||
};
|
||||
|
||||
export const RichContent: Story = {
|
||||
name: "Rich content",
|
||||
render: () => (
|
||||
<Popover trigger={<DemoButton>Filters</DemoButton>}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<strong>Status</strong>
|
||||
<label style={{ display: "flex", gap: 6 }}>
|
||||
<input type="checkbox" defaultChecked /> Active
|
||||
</label>
|
||||
<label style={{ display: "flex", gap: 6 }}>
|
||||
<input type="checkbox" /> Pending
|
||||
</label>
|
||||
<label style={{ display: "flex", gap: 6 }}>
|
||||
<input type="checkbox" /> Archived
|
||||
</label>
|
||||
</div>
|
||||
</Popover>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { DemoRadioGroup } from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta<typeof DemoRadioGroup> = {
|
||||
title: "UI / Inputs / Radio",
|
||||
component: DemoRadioGroup,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DemoRadioGroup>;
|
||||
|
||||
const planOptions = [
|
||||
{ value: "free", label: "Free — 1 project" },
|
||||
{ value: "team", label: "Team — 25 projects" },
|
||||
{ value: "enterprise", label: "Enterprise — custom" },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: { name: "plan", options: planOptions, defaultValue: "team" },
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { name: "plan-empty", options: planOptions },
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { DemoField, DemoSelect } from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta<typeof DemoSelect> = {
|
||||
title: "UI / Inputs / Select",
|
||||
component: DemoSelect,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DemoSelect>;
|
||||
|
||||
const roleOptions = [
|
||||
{ value: "viewer", label: "Viewer" },
|
||||
{ value: "editor", label: "Editor" },
|
||||
{ value: "admin", label: "Admin" },
|
||||
];
|
||||
|
||||
export const Single: Story = {
|
||||
args: { options: roleOptions, defaultValue: "viewer" },
|
||||
render: (args) => (
|
||||
<DemoField label="Role" htmlFor="role-single">
|
||||
<DemoSelect id="role-single" {...args} />
|
||||
</DemoField>
|
||||
),
|
||||
};
|
||||
|
||||
export const Multi: Story = {
|
||||
args: { options: roleOptions, multiple: true, size: 3 },
|
||||
render: (args) => (
|
||||
<DemoField label="Roles" htmlFor="role-multi" hint="Hold ⌘/Ctrl to select multiple.">
|
||||
<DemoSelect id="role-multi" {...args} />
|
||||
</DemoField>
|
||||
),
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { options: roleOptions, disabled: true, defaultValue: "admin" },
|
||||
render: (args) => (
|
||||
<DemoField label="Role" htmlFor="role-disabled">
|
||||
<DemoSelect id="role-disabled" {...args} />
|
||||
</DemoField>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { DemoBadge } from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "UI / Navigation / Sidebar",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
type NavItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
badge?: { tone: "default" | "warning" | "success"; text: string };
|
||||
};
|
||||
|
||||
const items: ReadonlyArray<NavItem> = [
|
||||
{ id: "dashboard", label: "Dashboard", href: "#dashboard" },
|
||||
{ id: "projects", label: "Projects", href: "#projects", badge: { tone: "default", text: "12" } },
|
||||
{ id: "members", label: "Members", href: "#members" },
|
||||
{ id: "billing", label: "Billing", href: "#billing", badge: { tone: "warning", text: "due" } },
|
||||
{ id: "settings", label: "Settings", href: "#settings" },
|
||||
];
|
||||
|
||||
function Sidebar({ collapsed }: { collapsed: boolean }) {
|
||||
const [active, setActive] = useState("projects");
|
||||
return (
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
style={{
|
||||
width: collapsed ? 56 : 220,
|
||||
background: "var(--color-card)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
padding: collapsed ? "12px 6px" : "16px 12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
transition: "width 160ms",
|
||||
minHeight: 320,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: collapsed ? 4 : "4px 8px 12px",
|
||||
fontSize: 12,
|
||||
letterSpacing: 1,
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-muted)",
|
||||
}}
|
||||
>
|
||||
{collapsed ? "T" : "Toolctrl"}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setActive(item.id);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "space-between",
|
||||
padding: collapsed ? 8 : "8px 10px",
|
||||
borderRadius: 6,
|
||||
background: active === item.id ? "var(--color-accent)" : "transparent",
|
||||
color: active === item.id ? "var(--color-fg)" : "var(--color-muted)",
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
fontWeight: active === item.id ? 600 : 500,
|
||||
}}
|
||||
>
|
||||
<span>{collapsed ? item.label.charAt(0) : item.label}</span>
|
||||
{!collapsed && item.badge ? (
|
||||
<DemoBadge tone={item.badge.tone}>{item.badge.text}</DemoBadge>
|
||||
) : null}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export const Expanded: Story = {
|
||||
render: () => <Sidebar collapsed={false} />,
|
||||
};
|
||||
|
||||
export const Collapsed: Story = {
|
||||
render: () => <Sidebar collapsed={true} />,
|
||||
};
|
||||
|
||||
export const SideBySide: Story = {
|
||||
name: "Side by side",
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 24 }}>
|
||||
<Sidebar collapsed={true} />
|
||||
<Sidebar collapsed={false} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { DemoSwitch } from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta<typeof DemoSwitch> = {
|
||||
title: "UI / Inputs / Switch",
|
||||
component: DemoSwitch,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
checked: { control: "boolean" },
|
||||
disabled: { control: "boolean" },
|
||||
},
|
||||
args: { label: "Enable beta features" },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DemoSwitch>;
|
||||
|
||||
export const Off: Story = {};
|
||||
|
||||
export const On: Story = { args: { defaultChecked: true } };
|
||||
|
||||
export const Disabled: Story = { args: { disabled: true, defaultChecked: true } };
|
||||
|
||||
export const Stack: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<DemoSwitch label="Email notifications" defaultChecked />
|
||||
<DemoSwitch label="Push notifications" />
|
||||
<DemoSwitch label="Marketing emails" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { DemoBadge, DemoTable } from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "UI / Tables / Table",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Member = {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: "active" | "pending" | "disabled";
|
||||
};
|
||||
|
||||
const members: ReadonlyArray<Member> = [
|
||||
{ name: "Ada Lovelace", email: "ada@example.com", role: "Admin", status: "active" },
|
||||
{ name: "Grace Hopper", email: "grace@example.com", role: "Editor", status: "active" },
|
||||
{ name: "Linus Torvalds", email: "linus@example.com", role: "Viewer", status: "pending" },
|
||||
{ name: "Dennis Ritchie", email: "dmr@example.com", role: "Viewer", status: "disabled" },
|
||||
];
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
const statusTone: Record<Member["status"], "success" | "warning" | "muted"> = {
|
||||
active: "success",
|
||||
pending: "warning",
|
||||
disabled: "muted",
|
||||
};
|
||||
|
||||
export const BasicTable: Story = {
|
||||
name: "Basic table",
|
||||
render: () => (
|
||||
<DemoTable<Member>
|
||||
caption="Workspace members"
|
||||
columns={[
|
||||
{ key: "name", header: "Name" },
|
||||
{ key: "email", header: "Email" },
|
||||
{ key: "role", header: "Role" },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (row) => <DemoBadge tone={statusTone[row.status]}>{row.status}</DemoBadge>,
|
||||
},
|
||||
]}
|
||||
rows={members}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const SortableHeader: Story = {
|
||||
name: "Sortable header",
|
||||
render: () => (
|
||||
<DemoTable<Member>
|
||||
columns={[
|
||||
{ key: "name", header: "▲ Name" },
|
||||
{ key: "email", header: "Email" },
|
||||
{ key: "role", header: "Role" },
|
||||
]}
|
||||
rows={[...members].sort((a, b) => a.name.localeCompare(b.name))}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
name: "Empty state",
|
||||
render: () => (
|
||||
<DemoTable<Member>
|
||||
columns={[
|
||||
{ key: "name", header: "Name" },
|
||||
{ key: "email", header: "Email" },
|
||||
{ key: "role", header: "Role" },
|
||||
]}
|
||||
rows={[]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "UI / Navigation / Tabs",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
type Tab = { id: string; label: string; content: React.ReactNode };
|
||||
|
||||
function Tabs({ tabs, initial }: { tabs: ReadonlyArray<Tab>; initial?: string }) {
|
||||
const [active, setActive] = useState(initial ?? tabs[0]?.id);
|
||||
return (
|
||||
<div style={{ maxWidth: 480 }}>
|
||||
<div
|
||||
role="tablist"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const selected = tab.id === active;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={selected}
|
||||
id={`tab-${tab.id}`}
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
onClick={() => setActive(tab.id)}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
padding: "8px 12px",
|
||||
fontSize: 14,
|
||||
fontFamily: "inherit",
|
||||
color: selected ? "var(--color-fg)" : "var(--color-muted)",
|
||||
borderBottom: selected ? "2px solid var(--color-primary)" : "2px solid transparent",
|
||||
marginBottom: -1,
|
||||
cursor: "pointer",
|
||||
fontWeight: selected ? 600 : 500,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tabpanel"
|
||||
id={`panel-${tab.id}`}
|
||||
aria-labelledby={`tab-${tab.id}`}
|
||||
hidden={tab.id !== active}
|
||||
style={{ padding: "16px 4px", fontSize: 14 }}
|
||||
>
|
||||
{tab.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Three: Story = {
|
||||
name: "Three tabs",
|
||||
render: () => (
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: "overview", label: "Overview", content: <p>Project health, recent activity, KPIs.</p> },
|
||||
{ id: "members", label: "Members", content: <p>Add, remove, and manage team members.</p> },
|
||||
{ id: "settings", label: "Settings", content: <p>Project name, integrations, danger zone.</p> },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const Many: Story = {
|
||||
name: "Many tabs",
|
||||
render: () => (
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: "a", label: "General", content: <p>General settings.</p> },
|
||||
{ id: "b", label: "Security", content: <p>Two-factor, sessions.</p> },
|
||||
{ id: "c", label: "Integrations", content: <p>Third-party apps.</p> },
|
||||
{ id: "d", label: "Billing", content: <p>Invoices and plan.</p> },
|
||||
{ id: "e", label: "Audit log", content: <p>Recent admin events.</p> },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
htmlFor,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
htmlFor: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex max-w-sm flex-col gap-1.5 text-sm" htmlFor={htmlFor}>
|
||||
<span className="font-medium">{label}</span>
|
||||
{children}
|
||||
{hint && !error ? <span className="text-muted-foreground text-xs">{hint}</span> : null}
|
||||
{error ? (
|
||||
<span className="text-destructive text-xs" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof Textarea> = {
|
||||
title: "UI / Inputs / Textarea",
|
||||
component: Textarea,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
disabled: { control: "boolean" },
|
||||
},
|
||||
args: { placeholder: "Tell us what happened…", rows: 4 },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Textarea>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => (
|
||||
<Field label="Message" htmlFor="msg-default">
|
||||
<Textarea id="msg-default" {...args} />
|
||||
</Field>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithCharacterHint: Story = {
|
||||
render: (args) => (
|
||||
<Field label="Bio" hint="Max 280 characters." htmlFor="bio-hint">
|
||||
<Textarea id="bio-hint" {...args} maxLength={280} />
|
||||
</Field>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
render: (args) => (
|
||||
<Field label="Message" error="Message can not be empty." htmlFor="msg-error">
|
||||
<Textarea id="msg-error" {...args} aria-invalid="true" />
|
||||
</Field>
|
||||
),
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true, defaultValue: "Submission already locked — read-only." },
|
||||
render: (args) => (
|
||||
<Field label="Message" htmlFor="msg-disabled">
|
||||
<Textarea id="msg-disabled" {...args} />
|
||||
</Field>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { DemoButton } from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "UI / Feedback / Toast",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
type ToastTone = "success" | "error" | "info";
|
||||
|
||||
function ToastView({ tone, message }: { tone: ToastTone; message: string }) {
|
||||
const palette: Record<ToastTone, { bg: string; fg: string }> = {
|
||||
success: { bg: "var(--color-success)", fg: "#fff" },
|
||||
error: { bg: "var(--color-destructive)", fg: "var(--color-destructive-foreground)" },
|
||||
info: { bg: "var(--color-primary)", fg: "var(--color-primary-foreground)" },
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
padding: "10px 14px",
|
||||
borderRadius: 6,
|
||||
background: palette[tone].bg,
|
||||
color: palette[tone].fg,
|
||||
fontSize: 14,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.18)",
|
||||
minWidth: 240,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Success: Story = {
|
||||
render: () => <ToastView tone="success" message="Project saved." />,
|
||||
};
|
||||
|
||||
export const ErrorToast: Story = {
|
||||
name: "Error",
|
||||
render: () => <ToastView tone="error" message="Failed to save — please retry." />,
|
||||
};
|
||||
|
||||
export const Info: Story = {
|
||||
render: () => <ToastView tone="info" message="A new version is available." />,
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: () => {
|
||||
const [toast, setToast] = useState<{ tone: ToastTone; message: string } | null>(null);
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<DemoButton onClick={() => setToast({ tone: "success", message: "Saved!" })}>
|
||||
Trigger success
|
||||
</DemoButton>
|
||||
<DemoButton
|
||||
variant="destructive"
|
||||
onClick={() => setToast({ tone: "error", message: "Could not delete." })}
|
||||
>
|
||||
Trigger error
|
||||
</DemoButton>
|
||||
<DemoButton variant="outline" onClick={() => setToast(null)}>
|
||||
Dismiss
|
||||
</DemoButton>
|
||||
<div style={{ flexBasis: "100%" }} />
|
||||
{toast ? <ToastView {...toast} /> : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { DemoButton } from "./_demo-primitives.js";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "UI / Overlays / Tooltip",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
function Tooltip({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<span
|
||||
style={{ position: "relative", display: "inline-block" }}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
{children}
|
||||
{open ? (
|
||||
<span
|
||||
role="tooltip"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 6px)",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "var(--color-fg)",
|
||||
color: "var(--color-bg)",
|
||||
fontSize: 12,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 4,
|
||||
whiteSpace: "nowrap",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const OnButton: Story = {
|
||||
name: "On a button",
|
||||
render: () => (
|
||||
<Tooltip label="Saves the form and closes the dialog">
|
||||
<DemoButton>Save and close</DemoButton>
|
||||
</Tooltip>
|
||||
),
|
||||
};
|
||||
|
||||
export const OnIcon: Story = {
|
||||
name: "On an icon button",
|
||||
render: () => (
|
||||
<Tooltip label="Copy to clipboard">
|
||||
<DemoButton variant="ghost" aria-label="Copy">
|
||||
⧉
|
||||
</DemoButton>
|
||||
</Tooltip>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,626 @@
|
||||
/*
|
||||
* Style-only demo primitives for Storybook.
|
||||
*
|
||||
* The `@toolctrl/ui` package itself contains only adapter *metadata* (it
|
||||
* registers which components live in apps/web). To give Storybook standalone
|
||||
* visual stories without dragging the entire web app into the build, we
|
||||
* re-implement minimal shadcn-style primitives here. They share the same
|
||||
* surface (variant/size/disabled) so the stories communicate the design
|
||||
* intent without coupling to the real apps/web bundle.
|
||||
*
|
||||
* If you need to story the *real* apps/web components instead, import them
|
||||
* directly from `apps/web/app/components/ui/*` in a story file — vite will
|
||||
* resolve the path. We avoid that here so the Storybook build stays
|
||||
* dependency-light.
|
||||
*/
|
||||
import React, {
|
||||
forwardRef,
|
||||
type ButtonHTMLAttributes,
|
||||
type HTMLAttributes,
|
||||
type InputHTMLAttributes,
|
||||
type LabelHTMLAttributes,
|
||||
type ReactNode,
|
||||
type SelectHTMLAttributes,
|
||||
type TextareaHTMLAttributes,
|
||||
} from "react";
|
||||
|
||||
export type ButtonVariant = "default" | "destructive" | "outline" | "ghost" | "link";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
const buttonBase: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
borderRadius: 6,
|
||||
fontWeight: 600,
|
||||
fontFamily: "inherit",
|
||||
cursor: "pointer",
|
||||
border: "1px solid transparent",
|
||||
transition: "background 120ms, color 120ms, border-color 120ms",
|
||||
};
|
||||
|
||||
const buttonVariants: Record<ButtonVariant, React.CSSProperties> = {
|
||||
default: {
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
},
|
||||
destructive: {
|
||||
background: "var(--color-destructive)",
|
||||
color: "var(--color-destructive-foreground)",
|
||||
},
|
||||
outline: {
|
||||
background: "transparent",
|
||||
color: "var(--color-fg)",
|
||||
borderColor: "var(--color-border)",
|
||||
},
|
||||
ghost: {
|
||||
background: "transparent",
|
||||
color: "var(--color-fg)",
|
||||
},
|
||||
link: {
|
||||
background: "transparent",
|
||||
color: "var(--color-primary)",
|
||||
textDecoration: "underline",
|
||||
padding: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const buttonSizes: Record<ButtonSize, React.CSSProperties> = {
|
||||
sm: { padding: "4px 10px", fontSize: 12 },
|
||||
md: { padding: "8px 14px", fontSize: 14 },
|
||||
lg: { padding: "10px 18px", fontSize: 16 },
|
||||
};
|
||||
|
||||
export type DemoButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const DemoButton = forwardRef<HTMLButtonElement, DemoButtonProps>(function DemoButton(
|
||||
{ variant = "default", size = "md", loading, disabled, children, style, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const merged: React.CSSProperties = {
|
||||
...buttonBase,
|
||||
...buttonVariants[variant],
|
||||
...(variant === "link" ? {} : buttonSizes[size]),
|
||||
opacity: disabled || loading ? 0.6 : 1,
|
||||
cursor: disabled || loading ? "not-allowed" : buttonBase.cursor,
|
||||
...style,
|
||||
};
|
||||
return (
|
||||
<button ref={ref} disabled={disabled || loading} style={merged} {...rest}>
|
||||
{loading ? <Spinner /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
border: "2px solid currentColor",
|
||||
borderRightColor: "transparent",
|
||||
display: "inline-block",
|
||||
animation: "demo-spin 600ms linear infinite",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldShellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
fontFamily: "inherit",
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const fieldLabelStyle: React.CSSProperties = {
|
||||
fontWeight: 600,
|
||||
color: "var(--color-fg)",
|
||||
};
|
||||
|
||||
const fieldHintStyle: React.CSSProperties = {
|
||||
color: "var(--color-muted)",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const fieldErrorStyle: React.CSSProperties = {
|
||||
color: "var(--color-destructive)",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const fieldControlBase: React.CSSProperties = {
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 6,
|
||||
padding: "8px 10px",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 14,
|
||||
background: "var(--color-bg)",
|
||||
color: "var(--color-fg)",
|
||||
};
|
||||
|
||||
export type DemoFieldProps = HTMLAttributes<HTMLLabelElement> & {
|
||||
label: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
htmlFor?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function DemoField({ label, hint, error, htmlFor, children, style, ...rest }: DemoFieldProps) {
|
||||
return (
|
||||
<label style={{ ...fieldShellStyle, ...style }} htmlFor={htmlFor} {...rest}>
|
||||
<span style={fieldLabelStyle}>{label}</span>
|
||||
{children}
|
||||
{hint && !error ? <span style={fieldHintStyle}>{hint}</span> : null}
|
||||
{error ? (
|
||||
<span style={fieldErrorStyle} role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export type DemoInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
invalid?: boolean;
|
||||
};
|
||||
|
||||
export const DemoInput = forwardRef<HTMLInputElement, DemoInputProps>(function DemoInput(
|
||||
{ invalid, style, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const merged: React.CSSProperties = {
|
||||
...fieldControlBase,
|
||||
borderColor: invalid ? "var(--color-destructive)" : fieldControlBase.border,
|
||||
...style,
|
||||
};
|
||||
return <input ref={ref} style={merged} {...rest} />;
|
||||
});
|
||||
|
||||
export type DemoTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
invalid?: boolean;
|
||||
};
|
||||
|
||||
export const DemoTextarea = forwardRef<HTMLTextAreaElement, DemoTextareaProps>(function DemoTextarea(
|
||||
{ invalid, style, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const merged: React.CSSProperties = {
|
||||
...fieldControlBase,
|
||||
minHeight: 80,
|
||||
resize: "vertical",
|
||||
borderColor: invalid ? "var(--color-destructive)" : fieldControlBase.border,
|
||||
...style,
|
||||
};
|
||||
return <textarea ref={ref} style={merged} {...rest} />;
|
||||
});
|
||||
|
||||
export type DemoSelectProps = SelectHTMLAttributes<HTMLSelectElement> & {
|
||||
options: ReadonlyArray<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
export const DemoSelect = forwardRef<HTMLSelectElement, DemoSelectProps>(function DemoSelect(
|
||||
{ options, style, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<select ref={ref} style={{ ...fieldControlBase, ...style }} {...rest}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
});
|
||||
|
||||
export type DemoCheckboxProps = LabelHTMLAttributes<HTMLLabelElement> & {
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
name?: string;
|
||||
onCheckedChange?: (next: boolean) => void;
|
||||
};
|
||||
|
||||
export function DemoCheckbox({
|
||||
checked,
|
||||
defaultChecked,
|
||||
disabled,
|
||||
label,
|
||||
name,
|
||||
onCheckedChange,
|
||||
style,
|
||||
...rest
|
||||
}: DemoCheckboxProps) {
|
||||
return (
|
||||
<label
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 14,
|
||||
color: "var(--color-fg)",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
onChange={(event) => onCheckedChange?.(event.currentTarget.checked)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export type DemoRadioGroupProps = {
|
||||
name: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
options: ReadonlyArray<{ value: string; label: string }>;
|
||||
onValueChange?: (next: string) => void;
|
||||
};
|
||||
|
||||
export function DemoRadioGroup({ name, value, defaultValue, options, onValueChange }: DemoRadioGroupProps) {
|
||||
return (
|
||||
<div role="radiogroup" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 8, fontSize: 14 }}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={value === undefined ? undefined : value === option.value}
|
||||
defaultChecked={defaultValue === option.value}
|
||||
onChange={() => onValueChange?.(option.value)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type DemoSwitchProps = {
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onCheckedChange?: (next: boolean) => void;
|
||||
};
|
||||
|
||||
export function DemoSwitch({ checked, defaultChecked, disabled, label, onCheckedChange }: DemoSwitchProps) {
|
||||
const [internal, setInternal] = React.useState<boolean>(defaultChecked ?? false);
|
||||
const isChecked = checked ?? internal;
|
||||
return (
|
||||
<label
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
fontSize: 14,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
role="switch"
|
||||
aria-checked={isChecked}
|
||||
aria-disabled={disabled}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
if (checked === undefined) setInternal((prev) => !prev);
|
||||
onCheckedChange?.(!isChecked);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== " " && event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
if (checked === undefined) setInternal((prev) => !prev);
|
||||
onCheckedChange?.(!isChecked);
|
||||
}}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 20,
|
||||
borderRadius: 999,
|
||||
background: isChecked ? "var(--color-primary)" : "var(--color-border)",
|
||||
position: "relative",
|
||||
transition: "background 120ms",
|
||||
display: "inline-block",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 2,
|
||||
left: isChecked ? 18 : 2,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: "50%",
|
||||
background: "#ffffff",
|
||||
transition: "left 120ms",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export type DemoCardProps = HTMLAttributes<HTMLDivElement> & {
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
export function DemoCard({ header, footer, children, style, ...rest }: DemoCardProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
background: "var(--color-card)",
|
||||
color: "var(--color-card-foreground)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxWidth: 380,
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{header ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ padding: 16, fontSize: 14 }}>{children}</div>
|
||||
{footer ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type DemoBadgeProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
tone?: "default" | "success" | "warning" | "destructive" | "muted";
|
||||
};
|
||||
|
||||
export function DemoBadge({ tone = "default", style, ...rest }: DemoBadgeProps) {
|
||||
const palette: Record<NonNullable<DemoBadgeProps["tone"]>, React.CSSProperties> = {
|
||||
default: { background: "var(--color-primary)", color: "var(--color-primary-foreground)" },
|
||||
success: { background: "var(--color-success)", color: "#ffffff" },
|
||||
warning: { background: "var(--color-warning)", color: "#ffffff" },
|
||||
destructive: { background: "var(--color-destructive)", color: "var(--color-destructive-foreground)" },
|
||||
muted: { background: "var(--color-accent)", color: "var(--color-accent-foreground)" },
|
||||
};
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
...palette[tone],
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type DemoAvatarProps = {
|
||||
src?: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function DemoAvatar({ src, name, size = 36 }: DemoAvatarProps) {
|
||||
const initials = name
|
||||
.split(/\s+/)
|
||||
.map((part) => part.charAt(0))
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
return (
|
||||
<span
|
||||
aria-label={name}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
background: "var(--color-accent)",
|
||||
color: "var(--color-accent-foreground)",
|
||||
fontWeight: 700,
|
||||
fontSize: size * 0.4,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
) : (
|
||||
<span>{initials}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export type DemoTableColumn<T> = {
|
||||
key: keyof T & string;
|
||||
header: string;
|
||||
align?: "left" | "right" | "center";
|
||||
render?: (row: T) => ReactNode;
|
||||
};
|
||||
|
||||
export type DemoTableProps<T> = {
|
||||
columns: ReadonlyArray<DemoTableColumn<T>>;
|
||||
rows: ReadonlyArray<T>;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export function DemoTable<T extends Record<string, unknown>>({ columns, rows, caption }: DemoTableProps<T>) {
|
||||
return (
|
||||
<table
|
||||
style={{
|
||||
borderCollapse: "collapse",
|
||||
width: "100%",
|
||||
fontSize: 13,
|
||||
background: "var(--color-card)",
|
||||
color: "var(--color-card-foreground)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{caption ? <caption style={{ textAlign: "left", padding: 8 }}>{caption}</caption> : null}
|
||||
<thead style={{ background: "var(--color-accent)", color: "var(--color-accent-foreground)" }}>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
scope="col"
|
||||
style={{
|
||||
textAlign: col.align ?? "left",
|
||||
padding: "8px 12px",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={index} style={{ borderTop: "1px solid var(--color-border)" }}>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{ padding: "8px 12px", textAlign: col.align ?? "left" }}
|
||||
>
|
||||
{col.render ? col.render(row) : String(row[col.key] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export type DemoDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
export function DemoDialog({ open, onOpenChange, title, description, children, footer }: DemoDialogProps) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
// Plain backdrop element (no ARIA role): the dialog role belongs on the
|
||||
// panel below. Backdrop dismiss closes only on a direct backdrop click, and
|
||||
// Escape provides the keyboard-equivalent close.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
zIndex: 50,
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onOpenChange(false);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="demo-dialog-title"
|
||||
aria-describedby={description ? "demo-dialog-desc" : undefined}
|
||||
style={{
|
||||
background: "var(--color-card)",
|
||||
color: "var(--color-card-foreground)",
|
||||
borderRadius: 8,
|
||||
padding: 20,
|
||||
minWidth: 320,
|
||||
maxWidth: 480,
|
||||
boxShadow: "0 12px 32px rgba(0,0,0,0.35)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<h2 id="demo-dialog-title" style={{ margin: 0, fontSize: 18 }}>
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p id="demo-dialog-desc" style={{ margin: 0, color: "var(--color-muted)", fontSize: 14 }}>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
{children ? <div style={{ fontSize: 14 }}>{children}</div> : null}
|
||||
{footer ? <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>{footer}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* keyframes injected once for spinner */
|
||||
if (typeof document !== "undefined" && !document.getElementById("demo-spin-keyframes")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "demo-spin-keyframes";
|
||||
style.textContent = "@keyframes demo-spin { to { transform: rotate(360deg); } }";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createEmptyAdapterRegistry, type UiAdapterPlaceholder } from "./types.js";
|
||||
|
||||
export type TableAdapter = UiAdapterPlaceholder<"tables">;
|
||||
|
||||
export const tableAdapters = createEmptyAdapterRegistry("tables");
|
||||
@@ -0,0 +1,38 @@
|
||||
export const uiAdapterCategories = [
|
||||
"buttons",
|
||||
"inputs",
|
||||
"dialogs",
|
||||
"tables",
|
||||
"layout",
|
||||
"navigation",
|
||||
"feedback",
|
||||
] as const;
|
||||
|
||||
export type UiAdapterCategory = (typeof uiAdapterCategories)[number];
|
||||
|
||||
export type UiAdapterLifecycle = "placeholder" | "available" | "deprecated";
|
||||
|
||||
export type UiAdapterDefinition<Category extends UiAdapterCategory = UiAdapterCategory> = {
|
||||
readonly category: Category;
|
||||
readonly name: string;
|
||||
readonly status: UiAdapterLifecycle;
|
||||
readonly description?: string;
|
||||
readonly source?: string;
|
||||
readonly plannedSource?: string;
|
||||
readonly packageExport?: string;
|
||||
};
|
||||
|
||||
export type UiAdapterPlaceholder<Category extends UiAdapterCategory = UiAdapterCategory> = UiAdapterDefinition<Category> & {
|
||||
readonly status: "placeholder";
|
||||
};
|
||||
|
||||
export type UiAdapterRegistry<Category extends UiAdapterCategory = UiAdapterCategory> = readonly UiAdapterDefinition<Category>[];
|
||||
|
||||
export function createEmptyAdapterRegistry<Category extends UiAdapterCategory>(_category: Category): UiAdapterRegistry<Category> {
|
||||
void _category;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function isUiAdapterCategory(value: string): value is UiAdapterCategory {
|
||||
return (uiAdapterCategories as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user