diff --git a/eslint.config.js b/eslint.config.js index 76dedb6..f403d0e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,7 @@ export default tseslint.config( "**/public/**", "**/views/**", "**/migrations/**", + "**/storybook-static/**", "packages/docs/.vitepress/cache/**", "packages/dashboard/vite.lib.config.ts", "packages/dashboard/vitest.setup.ts", diff --git a/packages/dashboard/.storybook/main.ts b/packages/dashboard/.storybook/main.ts index ddc89f5..8c96dc7 100644 --- a/packages/dashboard/.storybook/main.ts +++ b/packages/dashboard/.storybook/main.ts @@ -1,14 +1,9 @@ import type { StorybookConfig } from "@storybook/react-vite"; -import tailwindcss from "@tailwindcss/vite"; const config: StorybookConfig = { stories: ["../src/ui/**/*.stories.@(ts|tsx)"], framework: { name: "@storybook/react-vite", options: {} }, core: { disableTelemetry: true }, - viteFinal: async (cfg) => { - const { mergeConfig } = await import("vite"); - return mergeConfig(cfg, { plugins: [tailwindcss()] }); - }, }; export default config; diff --git a/packages/dashboard/.storybook/preview.ts b/packages/dashboard/.storybook/preview.ts deleted file mode 100644 index a39165c..0000000 --- a/packages/dashboard/.storybook/preview.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Preview } from "@storybook/react-vite"; -import "../src/ui/styles.css"; - -const preview: Preview = {}; - -export default preview; diff --git a/packages/dashboard/.storybook/preview.tsx b/packages/dashboard/.storybook/preview.tsx new file mode 100644 index 0000000..12456aa --- /dev/null +++ b/packages/dashboard/.storybook/preview.tsx @@ -0,0 +1,32 @@ +import type { Preview } from "@storybook/react-vite"; +import "../src/ui/styles.css"; + +const preview: Preview = { + globalTypes: { + theme: { + description: "Design system theme", + defaultValue: "dark", + toolbar: { + title: "Theme", + icon: "circlehollow", + items: [ + { value: "dark", title: "Dark" }, + { value: "light", title: "Light" }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [ + (Story, context) => { + const theme = context.globals.theme ?? "dark"; + return ( +
+ +
+ ); + }, + ], +}; + +export default preview; diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 57ff6ed..40134b8 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -36,13 +36,14 @@ "./ui": { "types": "./dist/ui/index.d.ts", "import": "./dist/ui/index.js" - } + }, + "./ui/styles.css": "./dist/ui/styles.css" }, "files": [ "dist" ], "scripts": { - "build": "npx rollup -c && npx vite build --config vite.lib.config.ts", + "build": "npx rollup -c && npx vite build --config vite.lib.config.ts && npx @tailwindcss/cli -i ./src/ui/styles.css -o ./dist/ui/styles.css --minify", "dev": "npx rollup -c -w", "test": "yarn vitest run", "storybook": "storybook dev -p 6006", @@ -53,6 +54,7 @@ "@sidequest/backend": "workspace:*", "@sidequest/core": "workspace:*", "@sidequest/engine": "workspace:*", + "clsx": "^2.1.1", "ejs": "^4.0.1", "express": "^5.2.1", "express-basic-auth": "^1.2.1", @@ -62,7 +64,6 @@ "devDependencies": { "@highlightjs/cdn-assets": "^11.11.1", "@storybook/react-vite": "^10.4.6", - "@tailwindcss/vite": "^4.3.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -77,6 +78,7 @@ "feather-icons": "^4.29.2", "htmx.org": "^2.0.8", "jsdom": "^29.1.1", + "lucide-react": "^1.23.0", "react": "^19.2.7", "react-dom": "^19.2.7", "storybook": "^10.4.6", @@ -84,10 +86,14 @@ "vite-plugin-dts": "^5.0.3" }, "peerDependencies": { + "lucide-react": "*", "react": "*", "react-dom": "*" }, "peerDependenciesMeta": { + "lucide-react": { + "optional": true + }, "react": { "optional": true }, diff --git a/packages/dashboard/src/ui/Badge.stories.tsx b/packages/dashboard/src/ui/Badge.stories.tsx new file mode 100644 index 0000000..eb8607f --- /dev/null +++ b/packages/dashboard/src/ui/Badge.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Badge, type JobState, type QueueState } from "./Badge"; + +const meta = { + title: "Data display/Badge", + component: Badge, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Completed: Story = { args: { state: "completed" } }; + +export const Running: Story = { args: { state: "running", dot: true } }; + +export const Failed: Story = { args: { state: "failed" } }; + +const ALL: (JobState | QueueState | "neutral")[] = [ + "completed", + "failed", + "running", + "claimed", + "waiting", + "scheduled", + "canceled", + "active", + "paused", + "disabled", + "neutral", +]; + +export const AllStates: Story = { + render: () => ( +
+ {ALL.map((s) => ( + + ))} +
+ ), +}; diff --git a/packages/dashboard/src/ui/Badge.test.tsx b/packages/dashboard/src/ui/Badge.test.tsx new file mode 100644 index 0000000..b8039dd --- /dev/null +++ b/packages/dashboard/src/ui/Badge.test.tsx @@ -0,0 +1,39 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Badge, type JobState } from "./Badge"; + +describe("Badge", () => { + it("renders a preset state with its default label and colors", () => { + const { container } = render(); + + const el = container.querySelector(".sq-badge"); + expect(el).toHaveTextContent("Completed"); + expect(el).toHaveClass("text-status-completed", "bg-status-completed/15"); + // no dot by default + expect(el?.querySelector("span")).toBeNull(); + }); + + it("uses defaults (neutral) and honors dot, children, bg/fg, className, style overrides", () => { + const { container } = render( + + Hi + , + ); + + const el = container.querySelector(".sq-badge"); + expect(el).toHaveClass("extra"); + expect(el).toHaveTextContent("Hi"); + expect(el).toHaveStyle({ background: "rgb(17, 17, 17)", color: "rgb(238, 238, 238)" }); + // dot rendered + expect(el?.querySelector("span")).not.toBeNull(); + }); + + it("falls back to the neutral preset for an unknown state", () => { + const { container } = render(); + + const el = container.querySelector(".sq-badge"); + // neutral label is empty + expect(el).toHaveTextContent(""); + expect(el).toHaveClass("text-fg-secondary", "bg-fg-secondary/15"); + }); +}); diff --git a/packages/dashboard/src/ui/Badge.tsx b/packages/dashboard/src/ui/Badge.tsx new file mode 100644 index 0000000..b1dc51e --- /dev/null +++ b/packages/dashboard/src/ui/Badge.tsx @@ -0,0 +1,72 @@ +import type { CSSProperties, ReactNode } from "react"; +import { cn } from "./cn"; + +/** Job lifecycle states with a badge preset. */ +export type JobState = "completed" | "failed" | "running" | "claimed" | "waiting" | "scheduled" | "canceled"; +/** Queue states with a badge preset. */ +export type QueueState = "active" | "paused" | "disabled"; + +/** Props for the {@link Badge} status pill. */ +export interface BadgeProps { + /** Job or queue state preset (drives color + default label). @default "neutral" */ + state?: JobState | QueueState | "neutral"; + /** Show a leading status dot. */ + dot?: boolean; + /** Override background (caller escape hatch, applied inline). */ + bg?: string; + /** Override foreground (caller escape hatch, applied inline). */ + fg?: string; + children?: ReactNode; + className?: string; + style?: CSSProperties; +} + +interface StatePreset { + /** Foreground text utility (theme-aware status token). */ + text: string; + /** Soft-tint fill utility (same token at low alpha). */ + fill: string; + label: string; +} + +const STATE: Record = { + completed: { text: "text-status-completed", fill: "bg-status-completed/15", label: "Completed" }, + failed: { text: "text-status-failed", fill: "bg-status-failed/15", label: "Failed" }, + running: { text: "text-status-running", fill: "bg-status-running/15", label: "Running" }, + claimed: { text: "text-status-running", fill: "bg-status-running/15", label: "Claimed" }, + waiting: { text: "text-status-scheduled", fill: "bg-status-scheduled/15", label: "Waiting" }, + scheduled: { text: "text-status-scheduled", fill: "bg-status-scheduled/15", label: "Scheduled" }, + canceled: { text: "text-fg-secondary", fill: "bg-fg-secondary/15", label: "Canceled" }, + active: { text: "text-status-completed", fill: "bg-status-completed/15", label: "Active" }, + paused: { text: "text-status-scheduled", fill: "bg-status-scheduled/15", label: "Paused" }, + disabled: { text: "text-status-failed", fill: "bg-status-failed/15", label: "Disabled" }, + neutral: { text: "text-fg-secondary", fill: "bg-fg-secondary/15", label: "" }, +}; + +/** + * Badge — soft-filled status pill. Presets cover every Sidequest job state + * (completed/failed/running/claimed/waiting/scheduled/canceled) and queue state + * (active/paused/disabled). Colors come from the theme-aware status tokens, so the + * pill adapts to the light and dark themes; pass `bg`/`fg` to override. + */ +export function Badge({ state = "neutral", children, dot = false, className = "", style = {}, bg, fg }: BadgeProps) { + const preset = STATE[state] ?? STATE.neutral; + const override: CSSProperties = {}; + if (bg) override.background = bg; + if (fg) override.color = fg; + + return ( + + {dot && } + {children ?? preset.label} + + ); +} diff --git a/packages/dashboard/src/ui/Button.stories.tsx b/packages/dashboard/src/ui/Button.stories.tsx new file mode 100644 index 0000000..81f3fce --- /dev/null +++ b/packages/dashboard/src/ui/Button.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "./Button"; + +const meta = { + title: "Actions/Button", + component: Button, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { args: { variant: "primary", children: "Enqueue job" } }; + +export const WithIcon: Story = { args: { variant: "default", icon: "Play", children: "Run" } }; + +export const Danger: Story = { args: { variant: "danger", icon: "Trash2", children: "Delete" } }; + +export const Variants: Story = { + render: () => ( +
+ + + + + +
+ ), +}; + +export const Sizes: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/dashboard/src/ui/Button.test.tsx b/packages/dashboard/src/ui/Button.test.tsx new file mode 100644 index 0000000..919a7b5 --- /dev/null +++ b/packages/dashboard/src/ui/Button.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Button } from "./Button"; + +describe("Button", () => { + it("renders a default enabled button with no icons", () => { + render(); + + const btn = screen.getByRole("button", { name: "Go" }); + expect(btn).toHaveClass("sq-btn", "sq-btn--default"); + expect(btn).toHaveAttribute("type", "button"); + expect(btn).not.toBeDisabled(); + expect(btn.querySelectorAll("svg")).toHaveLength(0); + }); + + it("honors variant, size, type, icons, block, active, disabled, className and style", () => { + render( + , + ); + + const btn = screen.getByRole("button", { name: "Run" }); + expect(btn).toHaveClass("sq-btn--primary", "extra"); + expect(btn).toHaveAttribute("type", "submit"); + expect(btn).toBeDisabled(); + // leading + trailing icons + expect(btn.querySelectorAll("svg")).toHaveLength(2); + }); + + it("fills the default and ghost variants with the hover surface when active", () => { + const { rerender } = render( + , + ); + const def = screen.getByRole("button", { name: "D" }); + expect(def).toHaveClass("bg-surface-hover"); + expect(def).not.toHaveClass("bg-surface-raised"); + + rerender( + , + ); + expect(screen.getByRole("button", { name: "G" })).toHaveClass("bg-surface-hover"); + }); +}); diff --git a/packages/dashboard/src/ui/Button.tsx b/packages/dashboard/src/ui/Button.tsx new file mode 100644 index 0000000..f6ff022 --- /dev/null +++ b/packages/dashboard/src/ui/Button.tsx @@ -0,0 +1,88 @@ +import type { ButtonHTMLAttributes } from "react"; +import { cn } from "./cn"; +import { Icon, type IconName } from "./Icon"; + +/** Visual treatments for {@link Button}. */ +export type ButtonVariant = "primary" | "default" | "outline" | "ghost" | "danger"; +/** Sizes for {@link Button}. */ +export type ButtonSize = "sm" | "md" | "lg"; + +/** Props for the {@link Button} action control. Extends the native `button` attributes. */ +export interface ButtonProps extends ButtonHTMLAttributes { + /** Visual treatment. @default "default" */ + variant?: ButtonVariant; + /** @default "md" */ + size?: ButtonSize; + /** Leading Lucide icon name. */ + icon?: IconName; + /** Trailing Lucide icon name. */ + iconRight?: IconName; + /** Full-width. */ + block?: boolean; + /** Pressed/selected look (e.g. active pagination). */ + active?: boolean; +} + +const BASE = + "inline-flex items-center justify-center whitespace-nowrap select-none font-sans font-medium leading-none rounded-sm border border-transparent cursor-pointer transition-[background-color,border-color,transform] duration-[120ms] ease-standard disabled:opacity-50 disabled:cursor-not-allowed enabled:active:translate-y-[0.5px] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-ring"; + +const SIZES: Record = { + sm: { class: "h-control-sm px-[0.7rem] gap-[0.35rem] text-xs", icon: 14 }, + md: { class: "h-control px-4 gap-[0.45rem] text-sm", icon: 16 }, + lg: { class: "h-11 px-[1.35rem] gap-2 text-base", icon: 18 }, +}; + +const VARIANTS: Record = { + primary: "bg-brand text-on-brand border-brand enabled:hover:bg-brand-hover enabled:hover:border-brand-hover", + default: "text-fg border-edge-strong enabled:hover:bg-surface-hover", + outline: "bg-transparent text-fg border-edge-strong enabled:hover:bg-surface-hover", + ghost: "text-fg border-transparent enabled:hover:bg-surface-hover", + danger: + "bg-transparent text-status-failed border-edge-strong enabled:hover:bg-status-failed/10 enabled:hover:border-status-failed", +}; + +/** + * Button — the dashboard's primary action control. Subtle raised default, solid brand + * primary, hairline outline, ghost (nav), and danger treatments, with an optional + * leading/trailing Lucide icon. + */ +export function Button({ + children, + variant = "default", + size = "md", + icon, + iconRight, + disabled = false, + block = false, + active = false, + type = "button", + className = "", + ...rest +}: ButtonProps) { + const s = SIZES[size]; + // `active` fills default/ghost with the hover surface to read as pressed/selected. + const activeFill = active && (variant === "default" || variant === "ghost") ? "bg-surface-hover" : ""; + const defaultRaised = variant === "default" && !active ? "bg-surface-raised" : ""; + + return ( + + ); +} diff --git a/packages/dashboard/src/ui/Card.stories.tsx b/packages/dashboard/src/ui/Card.stories.tsx index 841cee0..5d911ba 100644 --- a/packages/dashboard/src/ui/Card.stories.tsx +++ b/packages/dashboard/src/ui/Card.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { Card } from "./Card"; const meta = { - title: "UI/Card", + title: "Layout/Card", component: Card, parameters: { layout: "padded" }, } satisfies Meta; @@ -11,10 +11,22 @@ export default meta; type Story = StoryObj; -export const Solid: Story = { - args: { children: "Solid card" }, +export const Bare: Story = { + args: { children: "A bare panel with just body content." }, }; -export const Muted: Story = { - args: { variant: "muted", children: "Muted card" }, +export const Titled: Story = { + args: { title: "Recent jobs", children: "Panel body." }, +}; + +export const WithActions: Story = { + args: { + title: "Queues", + actions: ( + + ), + children: "Panel with header actions.", + }, }; diff --git a/packages/dashboard/src/ui/Card.test.tsx b/packages/dashboard/src/ui/Card.test.tsx index 2e31015..a1e1f10 100644 --- a/packages/dashboard/src/ui/Card.test.tsx +++ b/packages/dashboard/src/ui/Card.test.tsx @@ -3,32 +3,48 @@ import { describe, expect, it } from "vitest"; import { Card } from "./Card"; describe("Card", () => { - it("renders children inside a solid surface by default", () => { - render(hello); + it("renders a bare panel (no header) with default padding", () => { + const { container } = render(body); - const el = screen.getByText("hello"); - expect(el).toHaveClass("bg-surface"); - expect(el).toHaveClass("rounded-card"); - expect(el).toHaveClass("border-border"); + const root = container.querySelector(".sq-card"); + expect(root).not.toBeNull(); + expect(screen.getByText("body")).toBeInTheDocument(); + // no header when neither title nor actions are given + expect(screen.queryByRole("heading")).toBeNull(); }); - it("applies the muted variant", () => { - render(muted); + it("renders title, actions, and honors custom padding/className/style/bodyStyle", () => { + const { container } = render( + refresh} + padding="10px" + className="extra" + style={{ color: "rgb(1, 2, 3)" }} + bodyStyle={{ gap: "1px" }} + > + body + , + ); - expect(screen.getByText("muted")).toHaveClass("bg-surface-muted"); + const root = container.querySelector(".sq-card"); + expect(root).toHaveClass("extra"); + expect(root).toHaveStyle({ color: "rgb(1, 2, 3)" }); + expect(screen.getByRole("heading", { name: "Recent jobs" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "refresh" })).toBeInTheDocument(); }); - it("merges a custom className and forwards native div attributes", () => { - render( - - content - , - ); + it("renders a header with only a title", () => { + render(body); + + expect(screen.getByRole("heading", { name: "Only title" })).toBeInTheDocument(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("renders a header with only actions", () => { + render(go}>body); - const el = screen.getByTestId("card"); - expect(el).toHaveClass("extra"); - expect(el).toHaveClass("bg-surface"); - expect(el).toHaveAttribute("role", "group"); - expect(el).toHaveTextContent("content"); + expect(screen.queryByRole("heading")).toBeNull(); + expect(screen.getByRole("button", { name: "go" })).toBeInTheDocument(); }); }); diff --git a/packages/dashboard/src/ui/Card.tsx b/packages/dashboard/src/ui/Card.tsx index 9bfebef..002b4ab 100644 --- a/packages/dashboard/src/ui/Card.tsx +++ b/packages/dashboard/src/ui/Card.tsx @@ -1,32 +1,48 @@ -import type { HTMLAttributes } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import { cn } from "./cn"; -/** Visual variants available for {@link Card}. */ -export type CardVariant = "solid" | "muted"; - -/** Props for the {@link Card} component. Extends the native `div` attributes. */ -export interface CardProps extends HTMLAttributes { - /** Background treatment. Defaults to `"solid"`. */ - variant?: CardVariant; +/** Props for the {@link Card} panel primitive. */ +export interface CardProps { + /** Header title. Omit for a bare panel. */ + title?: ReactNode; + /** Right-aligned header actions (buttons). */ + actions?: ReactNode; + /** Body padding (CSS length). @default "var(--space-6)" */ + padding?: string; + children?: ReactNode; + className?: string; + style?: CSSProperties; + bodyStyle?: CSSProperties; } -const variantClasses: Record = { - solid: "bg-surface", - muted: "bg-surface-muted", -}; - /** - * Surface container used across the Sidequest dashboard. Renders a rounded, - * bordered panel that wraps arbitrary content and forwards any native `div` - * attributes. + * Card — the dashboard's panel primitive. Optional title/actions header, then a + * padded body. Compose stat tiles, tables, and job details inside it. */ -export function Card({ variant = "solid", className, children, ...rest }: CardProps) { - const classes = `rounded-card border border-border p-4 text-content ${variantClasses[variant]}${ - className ? ` ${className}` : "" - }`; - +export function Card({ + title, + actions, + children, + padding = "var(--space-6)", + className = "", + style = {}, + bodyStyle = {}, +}: CardProps) { return ( -
- {children} +
+ {(title != null || actions != null) && ( +
+ {title &&

{title}

} + {actions &&
{actions}
} +
+ )} +
{children}
); } diff --git a/packages/dashboard/src/ui/CodeBlock.stories.tsx b/packages/dashboard/src/ui/CodeBlock.stories.tsx new file mode 100644 index 0000000..77b84b8 --- /dev/null +++ b/packages/dashboard/src/ui/CodeBlock.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CodeBlock } from "./CodeBlock"; + +const meta = { + title: "Data display/CodeBlock", + component: CodeBlock, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Args: Story = { + args: { code: { to: "user@example.com", template: "welcome", attempts: 1 } }, +}; + +export const StackTrace: Story = { + args: { + code: "Error: connection refused\n at SendEmailJob.run (jobs/send-email.ts:12:15)\n at Runner.perform (runner.ts:88:9)", + }, +}; diff --git a/packages/dashboard/src/ui/CodeBlock.test.tsx b/packages/dashboard/src/ui/CodeBlock.test.tsx new file mode 100644 index 0000000..0b425bf --- /dev/null +++ b/packages/dashboard/src/ui/CodeBlock.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { CodeBlock } from "./CodeBlock"; + +describe("CodeBlock", () => { + it("renders a string verbatim with defaults", () => { + const { container } = render(); + + expect(screen.getByText("hello world")).toBeInTheDocument(); + expect(container.querySelector(".sq-codeblock")).not.toBeNull(); + }); + + it("pretty-prints an object as JSON and honors language/maxHeight/className", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".sq-codeblock")).toHaveClass("extra"); + expect(container.querySelector("code")).toHaveAttribute("data-language", "json"); + expect(screen.getByText(/"a": 1/)).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/src/ui/CodeBlock.tsx b/packages/dashboard/src/ui/CodeBlock.tsx new file mode 100644 index 0000000..56f39c6 --- /dev/null +++ b/packages/dashboard/src/ui/CodeBlock.tsx @@ -0,0 +1,32 @@ +import type { CSSProperties } from "react"; +import { cn } from "./cn"; + +/** Props for the {@link CodeBlock} monospace panel. */ +export interface CodeBlockProps { + /** String, or an object that will be pretty-printed as JSON. */ + code: string | object; + language?: string; + /** @default "15rem" */ + maxHeight?: string; + className?: string; + style?: CSSProperties; +} + +/** + * CodeBlock — near-black monospace panel for job arguments, results, and stack traces. + * Pass a string, or an object which is pretty-printed as JSON. + */ +export function CodeBlock({ code, language, maxHeight = "15rem", className = "", style = {} }: CodeBlockProps) { + const text = typeof code === "string" ? code : JSON.stringify(code, null, 2); + return ( +
+      {text}
+    
+ ); +} diff --git a/packages/dashboard/src/ui/FormField.stories.tsx b/packages/dashboard/src/ui/FormField.stories.tsx new file mode 100644 index 0000000..1d5eb3b --- /dev/null +++ b/packages/dashboard/src/ui/FormField.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FormField } from "./FormField"; +import { Input } from "./Input"; + +const meta = { + title: "Forms/FormField", + component: FormField, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const WithInput: Story = { + args: { + label: "Job class", + width: "16rem", + children: , + }, +}; + +export const WithHint: Story = { + args: { + label: "Cron", + hint: "in-memory, per instance", + width: "16rem", + children: , + }, +}; diff --git a/packages/dashboard/src/ui/FormField.test.tsx b/packages/dashboard/src/ui/FormField.test.tsx new file mode 100644 index 0000000..bb41750 --- /dev/null +++ b/packages/dashboard/src/ui/FormField.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FormField } from "./FormField"; + +describe("FormField", () => { + it("renders only the control when no label or hint is given", () => { + render( + + + , + ); + + expect(screen.getByLabelText("bare")).toBeInTheDocument(); + expect(screen.queryByText("Name")).toBeNull(); + }); + + it("renders label, hint, fixed width and className", () => { + const { container } = render( + + + , + ); + + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("required")).toBeInTheDocument(); + expect(container.querySelector(".sq-field")).toHaveClass("extra"); + }); +}); diff --git a/packages/dashboard/src/ui/FormField.tsx b/packages/dashboard/src/ui/FormField.tsx new file mode 100644 index 0000000..d19ce80 --- /dev/null +++ b/packages/dashboard/src/ui/FormField.tsx @@ -0,0 +1,34 @@ +import type { CSSProperties, ReactNode } from "react"; +import { cn } from "./cn"; + +/** Props for the {@link FormField} label + control wrapper. */ +export interface FormFieldProps { + /** Label text above the control. */ + label?: string; + htmlFor?: string; + /** Fixed width, e.g. "12rem" (filter bars). */ + width?: string; + /** Helper text below the control. */ + hint?: string; + children?: ReactNode; + className?: string; + style?: CSSProperties; +} + +/** + * FormField — label + control wrapper for filter bars and forms. Optional label above + * and hint below the control. + */ +export function FormField({ label, htmlFor, width, hint, className = "", style = {}, children }: FormFieldProps) { + return ( +
+ {label != null && ( + + )} + {children} + {hint != null && {hint}} +
+ ); +} diff --git a/packages/dashboard/src/ui/Icon.stories.tsx b/packages/dashboard/src/ui/Icon.stories.tsx new file mode 100644 index 0000000..c35eae8 --- /dev/null +++ b/packages/dashboard/src/ui/Icon.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Icon, type IconName } from "./Icon"; + +const meta = { + title: "Actions/Icon", + component: Icon, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Play: Story = { args: { name: "Play" } }; + +export const Large: Story = { args: { name: "RefreshCcw", size: 32 } }; + +const NAMES: IconName[] = ["Play", "X", "RefreshCcw", "Trash2", "Clock", "Activity", "CircleCheck", "CircleX", "Pause"]; + +export const Gallery: Story = { + render: () => ( +
+ {NAMES.map((n) => ( + + ))} +
+ ), +}; diff --git a/packages/dashboard/src/ui/Icon.test.tsx b/packages/dashboard/src/ui/Icon.test.tsx new file mode 100644 index 0000000..cfb774f --- /dev/null +++ b/packages/dashboard/src/ui/Icon.test.tsx @@ -0,0 +1,29 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Icon, type IconName } from "./Icon"; + +describe("Icon", () => { + it("renders a lucide glyph for a known name with defaults", () => { + const { container } = render(); + + const svg = container.querySelector("svg"); + expect(svg).not.toBeNull(); + expect(svg).toHaveAttribute("width", "16"); + expect(svg).toHaveAttribute("aria-hidden", "true"); + }); + + it("resolves multi-segment names and honors size/strokeWidth/rest props", () => { + const { container } = render(); + + const svg = container.querySelector("svg"); + expect(svg).toHaveAttribute("width", "24"); + expect(svg).toHaveAttribute("stroke-width", "3"); + expect(svg).toHaveClass("x"); + }); + + it("renders nothing for an unknown name", () => { + const { container } = render(); + + expect(container.querySelector("svg")).toBeNull(); + }); +}); diff --git a/packages/dashboard/src/ui/Icon.tsx b/packages/dashboard/src/ui/Icon.tsx new file mode 100644 index 0000000..382c652 --- /dev/null +++ b/packages/dashboard/src/ui/Icon.tsx @@ -0,0 +1,28 @@ +import { icons, type LucideProps } from "lucide-react"; +import type { ComponentType } from "react"; + +/** A Lucide icon key, e.g. "Play", "RefreshCcw", "X". */ +export type IconName = keyof typeof icons; + +/** Props for the {@link Icon} component. Extends the underlying Lucide SVG props. */ +export interface IconProps extends Omit { + /** Lucide icon key (PascalCase, as exported by lucide-react), e.g. "Play", "RefreshCcw", "X". */ + name: IconName; + /** Pixel size. @default 16 */ + size?: number; + /** @default 2 */ + strokeWidth?: number; +} + +/** + * Icon — a Lucide glyph resolved by name (Feather's successor; the design system's + * iconography). Renders `currentColor`, so it inherits the surrounding text color. + * Returns nothing for an unknown name. + */ +export function Icon({ name, size = 16, strokeWidth = 2, ...rest }: IconProps) { + const Glyph = icons[name] as ComponentType | undefined; + if (!Glyph) { + return null; + } + return ; +} diff --git a/packages/dashboard/src/ui/Input.stories.tsx b/packages/dashboard/src/ui/Input.stories.tsx new file mode 100644 index 0000000..ba57f9a --- /dev/null +++ b/packages/dashboard/src/ui/Input.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Input } from "./Input"; + +const meta = { + title: "Forms/Input", + component: Input, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: { placeholder: "Search jobs…" } }; + +export const Small: Story = { args: { size: "sm", placeholder: "Filter" } }; + +export const Invalid: Story = { args: { invalid: true, defaultValue: "bad value" } }; diff --git a/packages/dashboard/src/ui/Input.test.tsx b/packages/dashboard/src/ui/Input.test.tsx new file mode 100644 index 0000000..4ca3531 --- /dev/null +++ b/packages/dashboard/src/ui/Input.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Input } from "./Input"; + +describe("Input", () => { + it("renders a text input with defaults", () => { + render(); + + const input = screen.getByLabelText("name"); + expect(input).toHaveClass("sq-input"); + expect(input).toHaveAttribute("type", "text"); + }); + + it("honors type, small size, invalid, className and style", () => { + render(); + + const input = screen.getByLabelText("age"); + expect(input).toHaveClass("sq-input", "extra"); + expect(input).toHaveAttribute("type", "number"); + }); +}); diff --git a/packages/dashboard/src/ui/Input.tsx b/packages/dashboard/src/ui/Input.tsx new file mode 100644 index 0000000..d39ab3b --- /dev/null +++ b/packages/dashboard/src/ui/Input.tsx @@ -0,0 +1,29 @@ +import type { InputHTMLAttributes } from "react"; +import { cn } from "./cn"; + +/** Props for the {@link Input} field. Extends the native `input` attributes. */ +export interface InputProps extends Omit, "size"> { + /** @default "md" */ + size?: "sm" | "md"; + /** Red border for validation errors. */ + invalid?: boolean; +} + +/** + * Input — text/number/datetime field, matching the dashboard's filter-bar inputs. + * Pair with {@link FormField} for a label. + */ +export function Input({ type = "text", size = "md", invalid = false, className = "", ...rest }: InputProps) { + return ( + + ); +} diff --git a/packages/dashboard/src/ui/NavItem.stories.tsx b/packages/dashboard/src/ui/NavItem.stories.tsx new file mode 100644 index 0000000..1122dfa --- /dev/null +++ b/packages/dashboard/src/ui/NavItem.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { NavItem } from "./NavItem"; + +const meta = { + title: "Navigation/NavItem", + component: NavItem, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Active: Story = { args: { label: "Dashboard", icon: "LayoutDashboard", active: true } }; + +export const Inactive: Story = { args: { label: "Jobs", icon: "List" } }; + +export const List: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/dashboard/src/ui/NavItem.test.tsx b/packages/dashboard/src/ui/NavItem.test.tsx new file mode 100644 index 0000000..c244405 --- /dev/null +++ b/packages/dashboard/src/ui/NavItem.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { NavItem } from "./NavItem"; + +describe("NavItem", () => { + it("renders an inactive link with no icon by default", () => { + const { container } = render(); + + const link = screen.getByRole("link", { name: "Jobs" }); + expect(link).toHaveClass("sq-navitem"); + expect(link).toHaveAttribute("href", "#"); + expect(container.querySelector("svg")).toBeNull(); + }); + + it("renders an inactive link with an icon (muted icon color)", () => { + const { container } = render(); + + expect(screen.getByRole("link", { name: /Jobs/ })).toBeInTheDocument(); + expect(container.querySelector("svg")).not.toBeNull(); + }); + + it("renders an active link with an icon, href, className and style", () => { + const { container } = render( + , + ); + + const link = screen.getByRole("link", { name: /Queues/ }); + expect(link).toHaveClass("extra"); + expect(link).toHaveAttribute("href", "/queues"); + expect(container.querySelector("svg")).not.toBeNull(); + }); +}); diff --git a/packages/dashboard/src/ui/NavItem.tsx b/packages/dashboard/src/ui/NavItem.tsx new file mode 100644 index 0000000..ff0e415 --- /dev/null +++ b/packages/dashboard/src/ui/NavItem.tsx @@ -0,0 +1,39 @@ +import type { CSSProperties, MouseEvent } from "react"; +import { cn } from "./cn"; +import { Icon, type IconName } from "./Icon"; + +/** Props for the {@link NavItem} sidebar link. */ +export interface NavItemProps { + label: string; + /** Lucide icon name. */ + icon?: IconName; + active?: boolean; + href?: string; + onClick?: (e: MouseEvent) => void; + className?: string; + style?: CSSProperties; +} + +/** + * NavItem — a single sidebar link. The active state gets a subtle raised fill and a + * brand left-accent; hover lightens. + */ +export function NavItem({ label, icon, active = false, href = "#", onClick, className = "", style = {} }: NavItemProps) { + return ( + + {icon && } + {label} + + ); +} diff --git a/packages/dashboard/src/ui/Pagination.stories.tsx b/packages/dashboard/src/ui/Pagination.stories.tsx new file mode 100644 index 0000000..c46482e --- /dev/null +++ b/packages/dashboard/src/ui/Pagination.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Pagination } from "./Pagination"; + +const meta = { + title: "Data table/Pagination", + component: Pagination, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Middle: Story = { args: { page: 3, hasNext: true } }; + +export const FirstPage: Story = { args: { page: 1, hasNext: true } }; + +export const LastPage: Story = { args: { page: 9, hasNext: false } }; diff --git a/packages/dashboard/src/ui/Pagination.test.tsx b/packages/dashboard/src/ui/Pagination.test.tsx new file mode 100644 index 0000000..93cea99 --- /dev/null +++ b/packages/dashboard/src/ui/Pagination.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { Pagination } from "./Pagination"; + +describe("Pagination", () => { + it("enables both ends when there is a previous and next page", () => { + render(); + + expect(screen.getByText("Page 2")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "«" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "»" })).toBeEnabled(); + }); + + it("disables the ends on the first page with no next, and honors className/style", () => { + const onPrev = vi.fn(); + const onNext = vi.fn(); + render(); + + expect(screen.getByRole("button", { name: "«" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "»" })).toBeDisabled(); + }); +}); diff --git a/packages/dashboard/src/ui/Pagination.tsx b/packages/dashboard/src/ui/Pagination.tsx new file mode 100644 index 0000000..3c0a553 --- /dev/null +++ b/packages/dashboard/src/ui/Pagination.tsx @@ -0,0 +1,41 @@ +import type { CSSProperties, ReactNode } from "react"; +import { cn } from "./cn"; + +/** Props for the {@link Pagination} control. */ +export interface PaginationProps { + page: number; + hasNext?: boolean; + onPrev?: () => void; + onNext?: () => void; + className?: string; + style?: CSSProperties; +} + +/** + * Pagination — the dashboard's joined prev / page / next control. Buttons abut with + * shared borders; the ends disable when there is no previous/next page. + */ +export function Pagination({ page, hasNext = false, onPrev, onNext, className = "", style = {} }: PaginationProps) { + const btn = (content: ReactNode, enabled: boolean, onClick: (() => void) | undefined, active: boolean) => ( + + ); + + return ( +
+ {btn("«", page > 1, onPrev, false)} + {btn(`Page ${page}`, false, undefined, true)} + {btn("»", hasNext, onNext, false)} +
+ ); +} diff --git a/packages/dashboard/src/ui/Select.stories.tsx b/packages/dashboard/src/ui/Select.stories.tsx new file mode 100644 index 0000000..f2803cb --- /dev/null +++ b/packages/dashboard/src/ui/Select.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Select } from "./Select"; + +const meta = { + title: "Forms/Select", + component: Select, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const States: Story = { + args: { + defaultValue: "running", + options: [ + { value: "waiting", label: "Waiting" }, + { value: "running", label: "Running" }, + { value: "completed", label: "Completed" }, + { value: "failed", label: "Failed" }, + ], + }, +}; + +export const Small: Story = { + args: { + size: "sm", + defaultValue: "default", + options: [ + { value: "default", label: "default" }, + { value: "mailers", label: "mailers" }, + ], + }, +}; diff --git a/packages/dashboard/src/ui/Select.test.tsx b/packages/dashboard/src/ui/Select.test.tsx new file mode 100644 index 0000000..591e839 --- /dev/null +++ b/packages/dashboard/src/ui/Select.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Select } from "./Select"; + +describe("Select", () => { + it("renders options passed as data", () => { + render( + + + , + ); + + expect(screen.getByLabelText("queue")).toHaveClass("sq-select", "extra"); + expect(screen.getByRole("option", { name: "Gamma" })).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/src/ui/Select.tsx b/packages/dashboard/src/ui/Select.tsx new file mode 100644 index 0000000..198df41 --- /dev/null +++ b/packages/dashboard/src/ui/Select.tsx @@ -0,0 +1,50 @@ +import type { SelectHTMLAttributes } from "react"; +import { cn } from "./cn"; +import { Icon } from "./Icon"; + +/** A single option for {@link Select} when passing data instead of children. */ +export interface SelectOption { + value: string; + label: string; +} + +/** Props for the {@link Select} dropdown. Extends the native `select` attributes. */ +export interface SelectProps extends Omit, "size"> { + /** Options as data; alternatively pass `