From 3593caf6f64c65af7824a39a52b40ca484970bc8 Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka928@users.noreply.github.com> Date: Wed, 21 Jan 2026 02:30:35 -0500 Subject: [PATCH] feat: add Storybook for UI components (#137) (#955) --- .gitignore | 3 + .storybook/main.ts | 13 + .storybook/preview.tsx | 47 +++ .storybook/storybook.css | 1 + package.json | 10 +- src/components/ui/button.stories.tsx | 223 ++++++++++++ src/components/ui/dialog.stories.tsx | 512 +++++++++++++++++++++++++++ src/components/ui/input.stories.tsx | 291 +++++++++++++++ src/components/ui/input.tsx | 25 +- tailwind.config.js | 2 +- 10 files changed, 1112 insertions(+), 15 deletions(-) create mode 100644 .storybook/main.ts create mode 100644 .storybook/preview.tsx create mode 100644 .storybook/storybook.css create mode 100644 src/components/ui/button.stories.tsx create mode 100644 src/components/ui/dialog.stories.tsx create mode 100644 src/components/ui/input.stories.tsx diff --git a/.gitignore b/.gitignore index 6b6bff22..719583e8 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ __pycache__/ resources/prebuilt/bin/ resources/prebuilt/venv/ resources/prebuilt/cache/ + +*storybook.log +storybook-static diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 00000000..301640e5 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,13 @@ +import type { StorybookConfig } from '@storybook/react-vite' + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-docs', '@storybook/addon-a11y'], + framework: '@storybook/react-vite', + viteFinal: async (config) => { + // Reuse project's vite config for path aliases + return config + }, +} + +export default config diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 00000000..4142d6db --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,47 @@ +import type { Preview } from '@storybook/react-vite' +import React from 'react' +import '@fontsource/inter/400.css' +import '@fontsource/inter/500.css' +import '@fontsource/inter/600.css' +import '@fontsource/inter/700.css' +import '@fontsource/inter/800.css' +import '../src/style/index.css' +import './storybook.css' // Storybook-specific overrides +import { Toaster } from 'sonner' + +// Apply theme immediately via script +if (typeof document !== 'undefined') { + document.documentElement.setAttribute('data-theme', 'light') + document.documentElement.classList.add('root') +} + +const preview: Preview = { + tags: ['autodocs'], + parameters: { + layout: 'centered', + controls: { + expanded: true, + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + backgrounds: { + default: 'light', + values: [ + { name: 'light', value: '#f5f5f5' }, + { name: 'dark', value: '#1d1c1b' }, + ], + }, + }, + decorators: [ + (Story) => ( +
+ + +
+ ), + ], +} + +export default preview diff --git a/.storybook/storybook.css b/.storybook/storybook.css new file mode 100644 index 00000000..6e0e8f28 --- /dev/null +++ b/.storybook/storybook.css @@ -0,0 +1 @@ +/* Storybook-specific CSS overrides - currently empty as component handles styling */ diff --git a/package.json b/package.json index 9cdd6453..dd7170c4 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "test:watch": "vitest", "test:e2e": "vitest run --config vitest.config.ts", "test:coverage": "vitest run --coverage", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build -o storybook-static" }, "dependencies": { "@electron/notarize": "^2.5.0", @@ -131,7 +133,11 @@ "vite": "^5.4.11", "vite-plugin-electron": "^0.29.0", "vite-plugin-electron-renderer": "^0.14.6", - "vitest": "^2.1.5" + "vitest": "^2.1.5", + "storybook": "^10.1.11", + "@storybook/react-vite": "^10.1.11", + "@storybook/addon-a11y": "^10.1.11", + "@storybook/addon-docs": "^10.1.11" }, "overrides": { "glob": "^10.4.5" diff --git a/src/components/ui/button.stories.tsx b/src/components/ui/button.stories.tsx new file mode 100644 index 00000000..0ae56ba1 --- /dev/null +++ b/src/components/ui/button.stories.tsx @@ -0,0 +1,223 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Button } from './button' +import { Plus, Download, Trash2 } from 'lucide-react' +import { expect, fn, userEvent, within } from 'storybook/test' + +const meta: Meta = { + title: 'UI/Button', + component: Button, + argTypes: { + variant: { + control: 'select', + options: [ + 'primary', + 'secondary', + 'outline', + 'ghost', + 'success', + 'cuation', + 'information', + 'warning', + ], + }, + size: { + control: 'select', + options: ['xxs', 'xs', 'sm', 'md', 'lg', 'icon'], + }, + disabled: { + control: 'boolean', + }, + asChild: { + control: 'boolean', + }, + children: { + control: 'text', + }, + }, + args: { + children: 'Button', + variant: 'primary', + size: 'md', + }, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = { + args: { + variant: 'primary', + children: 'Primary Button', + }, +} + +export const Secondary: Story = { + args: { + variant: 'secondary', + children: 'Secondary Button', + }, +} + +export const Outline: Story = { + args: { + variant: 'outline', + children: 'Outline Button', + }, +} + +export const Ghost: Story = { + args: { + variant: 'ghost', + children: 'Ghost Button', + }, +} + +export const Success: Story = { + args: { + variant: 'success', + children: 'Success Button', + }, +} + +export const Warning: Story = { + args: { + variant: 'warning', + children: 'Warning Button', + }, +} + +export const Disabled: Story = { + args: { + variant: 'primary', + children: 'Disabled Button', + disabled: true, + }, +} + +export const WithIcon: Story = { + render: (args) => ( + + ), + args: { + variant: 'primary', + }, +} + +export const IconOnly: Story = { + render: (args) => ( + + ), + args: { + variant: 'ghost', + size: 'icon', + }, +} + +export const AllVariants: Story = { + render: () => ( +
+ + + + + + +
+ ), +} + +export const AllSizes: Story = { + render: () => ( +
+ + + + + + +
+ ), +} + +// Interaction test stories +export const ClickInteraction: Story = { + args: { + variant: 'primary', + children: 'Click Me', + onClick: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement) + const button = canvas.getByRole('button', { name: /click me/i }) + + // Test that button is visible and enabled + await expect(button).toBeVisible() + await expect(button).toBeEnabled() + + // Click the button + await userEvent.click(button) + + // Verify the onClick handler was called + await expect(args.onClick).toHaveBeenCalledTimes(1) + }, +} + +export const DisabledInteraction: Story = { + args: { + variant: 'primary', + children: 'Disabled Button', + disabled: true, + onClick: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement) + const button = canvas.getByRole('button', { name: /disabled button/i }) + + // Test that button is visible but disabled + await expect(button).toBeVisible() + await expect(button).toBeDisabled() + + // Verify the onClick handler was NOT called (disabled buttons block pointer events) + await expect(args.onClick).not.toHaveBeenCalled() + }, +} + +export const HoverInteraction: Story = { + args: { + variant: 'outline', + children: 'Hover Over Me', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const button = canvas.getByRole('button', { name: /hover over me/i }) + + // Test initial state + await expect(button).toBeVisible() + + // Hover over the button + await userEvent.hover(button) + + // The button should still be visible after hover + await expect(button).toBeVisible() + + // Unhover + await userEvent.unhover(button) + }, +} diff --git a/src/components/ui/dialog.stories.tsx b/src/components/ui/dialog.stories.tsx new file mode 100644 index 00000000..0a151d4e --- /dev/null +++ b/src/components/ui/dialog.stories.tsx @@ -0,0 +1,512 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { useState } from 'react' +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogContentSection, + DialogFooter, +} from './dialog' +import { Button } from './button' +import { Input } from './input' +import { expect, userEvent, within } from 'storybook/test' + +const meta: Meta = { + title: 'UI/Dialog', + component: Dialog, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: function DefaultDialog() { + const [open, setOpen] = useState(false) + return ( + + + + + + + +

+ This is the main content area of the dialog. You can add any content + here including forms, text, images, or other components. +

+
+ setOpen(false)} + onConfirm={() => { + console.log('Confirmed!') + setOpen(false) + }} + /> +
+
+ ) + }, +} + +export const SmallSize: Story = { + render: function SmallDialog() { + const [open, setOpen] = useState(false) + return ( + + + + + + + +

A compact dialog for simple actions.

+
+ setOpen(false)} + /> +
+
+ ) + }, +} + +export const LargeSize: Story = { + render: function LargeDialog() { + const [open, setOpen] = useState(false) + return ( + + + + + + + +

+ Large dialogs are useful when you need to display more content, such + as detailed forms, tables, or multi-step processes. +

+
+ setOpen(false)} + onConfirm={() => setOpen(false)} + /> +
+
+ ) + }, +} + +export const WithForm: Story = { + render: function FormDialog() { + const [open, setOpen] = useState(false) + return ( + + + + + + + +
+ + + +
+
+ setOpen(false)} + onConfirm={() => { + console.log('Account created!') + setOpen(false) + }} + /> +
+
+ ) + }, +} + +export const WithTooltip: Story = { + render: function TooltipDialog() { + const [open, setOpen] = useState(false) + return ( + + + + + + + +

+ Hover over the icon next to the title to see the tooltip. +

+
+ setOpen(false)} + /> +
+
+ ) + }, +} + +export const WithBackButton: Story = { + render: function BackButtonDialog() { + const [open, setOpen] = useState(false) + return ( + + + + + + console.log('Back clicked')} + /> + +

+ Click the back button to return to the previous step. +

+
+ setOpen(false)} + onConfirm={() => { + console.log('Next step') + setOpen(false) + }} + /> +
+
+ ) + }, +} + +export const DestructiveAction: Story = { + render: function DestructiveDialog() { + const [open, setOpen] = useState(false) + return ( + + + + + + + +

+ Are you sure you want to delete this item? All associated data will + be permanently removed. +

+
+ setOpen(false)} + onConfirm={() => { + console.log('Item deleted!') + setOpen(false) + }} + /> +
+
+ ) + }, +} + +export const NoCloseButton: Story = { + render: function NoCloseDialog() { + const [open, setOpen] = useState(false) + return ( + + + + + + + +

+ This dialog does not have a close button. User must interact with + the footer buttons. +

+
+ setOpen(false)} + /> +
+
+ ) + }, +} + +export const AllSizes: Story = { + render: function AllSizesDialog() { + const [openSm, setOpenSm] = useState(false) + const [openMd, setOpenMd] = useState(false) + const [openLg, setOpenLg] = useState(false) + return ( +
+ + + + + + + +

Max width: 400px

+
+ setOpenSm(false)} + /> +
+
+ + + + + + + + +

Max width: 600px (default)

+
+ setOpenMd(false)} + /> +
+
+ + + + + + + + +

Max width: 900px

+
+ setOpenLg(false)} + /> +
+
+
+ ) + }, +} + +// Interaction test stories +export const OpenCloseInteraction: Story = { + render: function OpenCloseDialog() { + const [open, setOpen] = useState(false) + return ( + + + + + + + +

+ This dialog tests the open/close interaction. +

+
+ setOpen(false)} + onConfirm={() => setOpen(false)} + /> +
+
+ ) + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + // Open the dialog + await userEvent.click(canvas.getByRole('button', { name: /open dialog/i })) + + // Verify dialog is visible (renders in portal) + await expect(await body.findByText('Interactive Dialog')).toBeVisible() + + // Close the dialog + await userEvent.click(body.getByRole('button', { name: /cancel/i })) + + // Verify dialog is closed + await expect(body.queryByText('Interactive Dialog')).not.toBeInTheDocument() + }, +} + +export const FormInteraction: Story = { + render: function FormInteractionDialog() { + const [open, setOpen] = useState(false) + const [submitted, setSubmitted] = useState(false) + return ( +
+ + + + + + + +
+ + +
+
+ setOpen(false)} + onConfirm={() => { + setSubmitted(true) + setOpen(false) + }} + /> +
+
+ {submitted &&

Form submitted successfully!

} +
+ ) + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + // Open the dialog + await userEvent.click(canvas.getByRole('button', { name: /open form/i })) + + // Wait for dialog to appear (renders in portal) + await expect(await body.findByText('Sign Up')).toBeVisible() + + // Fill in the form + const nameInput = body.getByPlaceholderText('Enter your name') + const emailInput = body.getByPlaceholderText('Enter your email') + + await userEvent.type(nameInput, 'John Doe') + await userEvent.type(emailInput, 'john@example.com') + + // Verify inputs have values + await expect(nameInput).toHaveValue('John Doe') + await expect(emailInput).toHaveValue('john@example.com') + + // Submit the form + await userEvent.click(body.getByRole('button', { name: /sign up/i })) + + // Verify success message appears + await expect(await canvas.findByTestId('success-message')).toBeVisible() + }, +} + +export const ConfirmDialogInteraction: Story = { + render: function ConfirmInteractionDialog() { + const [open, setOpen] = useState(false) + const [confirmed, setConfirmed] = useState(false) + return ( +
+ + + + + + + +

+ Are you sure you want to delete this item? +

+
+ setOpen(false)} + onConfirm={() => { + setConfirmed(true) + setOpen(false) + }} + /> +
+
+ {confirmed &&

Item deleted!

} +
+ ) + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + // Open the confirmation dialog + await userEvent.click(canvas.getByRole('button', { name: /delete item/i })) + + // Verify dialog is visible (renders in portal) + await expect(await body.findByText('Confirm Delete')).toBeVisible() + + // Click the confirm/delete button + await userEvent.click(body.getByRole('button', { name: /^delete$/i })) + + // Verify the deleted message appears + const deletedMessage = await canvas.findByTestId('deleted-message') + await expect(deletedMessage).toHaveTextContent('Item deleted!') + }, +} diff --git a/src/components/ui/input.stories.tsx b/src/components/ui/input.stories.tsx new file mode 100644 index 00000000..047affa4 --- /dev/null +++ b/src/components/ui/input.stories.tsx @@ -0,0 +1,291 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Input } from './input' +import { Search, Eye, EyeOff } from 'lucide-react' +import { useState } from 'react' +import { expect, fn, userEvent, within } from 'storybook/test' + +const meta: Meta = { + title: 'UI/Input', + component: Input, + argTypes: { + size: { + control: 'select', + options: ['default', 'sm'], + }, + state: { + control: 'select', + options: ['default', 'hover', 'input', 'error', 'success', 'disabled'], + }, + disabled: { + control: 'boolean', + }, + required: { + control: 'boolean', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + placeholder: 'Enter text...', + }, +} + +export const WithTitle: Story = { + args: { + title: 'Email Address', + placeholder: 'name@example.com', + type: 'email', + }, +} + +export const Required: Story = { + args: { + title: 'Username', + placeholder: 'Enter username', + required: true, + }, +} + +export const WithTooltip: Story = { + args: { + title: 'API Key', + placeholder: 'Enter your API key', + tooltip: 'Your API key can be found in your account settings', + }, +} + +export const WithNote: Story = { + args: { + title: 'Password', + type: 'password', + placeholder: 'Enter password', + note: 'Must be at least 8 characters', + }, +} + +export const ErrorState: Story = { + args: { + title: 'Email', + placeholder: 'name@example.com', + state: 'error', + note: 'Please enter a valid email address', + defaultValue: 'invalid-email', + }, +} + +export const SuccessState: Story = { + args: { + title: 'Username', + placeholder: 'Enter username', + state: 'success', + note: 'Username is available', + defaultValue: 'johndoe', + }, +} + +export const Disabled: Story = { + args: { + title: 'Locked Field', + placeholder: 'This field is disabled', + disabled: true, + defaultValue: 'Cannot edit', + }, +} + +export const SmallSize: Story = { + args: { + size: 'sm', + placeholder: 'Small input', + }, +} + +export const WithLeadingIcon: Story = { + args: { + placeholder: 'Search...', + leadingIcon: , + }, +} + +export const WithBackIcon: Story = { + render: function PasswordInput() { + const [showPassword, setShowPassword] = useState(false) + return ( + : } + onBackIconClick={() => setShowPassword(!showPassword)} + /> + ) + }, +} + +export const AllStates: Story = { + render: () => ( +
+ + + + + + +
+ ), +} + +export const FormExample: Story = { + render: () => ( +
+

Contact Form

+ + + + +
+ ), + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +// Interaction test stories +export const TypeInteraction: Story = { + args: { + placeholder: 'Type something...', + onChange: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement) + const input = canvas.getByPlaceholderText('Type something...') + + // Test that input is visible and enabled + await expect(input).toBeVisible() + await expect(input).toBeEnabled() + + // Clear any existing value and type new text + await userEvent.clear(input) + await userEvent.type(input, 'Hello World') + + // Verify the input value + await expect(input).toHaveValue('Hello World') + + // Verify onChange was called + await expect(args.onChange).toHaveBeenCalled() + }, +} + +export const FocusInteraction: Story = { + args: { + title: 'Focus Test', + placeholder: 'Click to focus...', + onFocus: fn(), + onBlur: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement) + const input = canvas.getByPlaceholderText('Click to focus...') + + // Click to focus the input + await userEvent.click(input) + await expect(args.onFocus).toHaveBeenCalled() + + // Tab away to blur + await userEvent.tab() + await expect(args.onBlur).toHaveBeenCalled() + }, +} + +export const ClearAndTypeInteraction: Story = { + args: { + title: 'Edit Field', + placeholder: 'Edit this text', + defaultValue: 'Initial value', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const input = canvas.getByPlaceholderText('Edit this text') + + // Verify initial value + await expect(input).toHaveValue('Initial value') + + // Select all and replace + await userEvent.tripleClick(input) + await userEvent.type(input, 'Replaced text') + + // Verify the new value + await expect(input).toHaveValue('Replaced text') + }, +} + +export const PasswordToggleInteraction: Story = { + render: function PasswordToggle() { + const [showPassword, setShowPassword] = useState(false) + return ( + : } + onBackIconClick={() => setShowPassword(!showPassword)} + /> + ) + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const input = canvas.getByPlaceholderText('Enter password') + const toggleButton = canvas.getByRole('button') + + // Initially password should be hidden (type="password") + await expect(input).toHaveAttribute('type', 'password') + + // Click toggle to show password + await userEvent.click(toggleButton) + await expect(input).toHaveAttribute('type', 'text') + + // Click toggle again to hide password + await userEvent.click(toggleButton) + await expect(input).toHaveAttribute('type', 'password') + }, +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index a8534db9..7efc37c9 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -31,47 +31,47 @@ function resolveStateClasses(state: InputState | undefined) { if (state === "disabled") { return { container: "opacity-50 cursor-not-allowed", - field: - "border-input-border-default bg-input-bg-default text-input-text-default", + field: "border-input-border-default bg-input-bg-default", + input: "text-text-heading", placeholder: "placeholder-input-label-default", } } if (state === "hover") { return { container: "", - field: - "border-input-border-hover bg-input-bg-default text-input-text-default", + field: "border-input-border-hover bg-input-bg-default", + input: "text-text-heading", placeholder: "placeholder-input-label-default", } } if (state === "input") { return { container: "", - field: - "border-input-border-focus bg-input-bg-input text-input-text-focus", + field: "border-input-border-focus bg-input-bg-input", + input: "text-text-heading", placeholder: "placeholder-input-label-default", } } if (state === "error") { return { container: "", - field: - "border-input-border-cuation bg-input-bg-default text-text-body", + field: "border-input-border-cuation bg-input-bg-default", + input: "text-text-heading", placeholder: "placeholder-input-label-default", } } if (state === "success") { return { container: "", - field: - "border-input-border-success bg-input-bg-confirm text-text-body", + field: "border-input-border-success bg-input-bg-confirm", + input: "text-text-heading", placeholder: "placeholder-input-label-default", } } return { container: "", - field: - "border-input-border-default bg-input-bg-default text-input-text-default", + field: "border-input-border-default bg-input-bg-default", + input: "text-text-heading", placeholder: "placeholder-input-label-default/10", } } @@ -140,6 +140,7 @@ const Input = React.forwardRef( placeholder={placeholder} className={cn( "peer w-full bg-transparent outline-none placeholder:transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium", + stateCls.input, stateCls.placeholder, hasLeft ? "pl-9" : "pl-3", hasRight ? "pr-9" : "pr-3", diff --git a/tailwind.config.js b/tailwind.config.js index cb0e2014..29f90933 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,7 +1,7 @@ /** @type {import('tailwindcss').Config} */ module.exports = { darkMode: ["class"], - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "./.storybook/**/*.{js,ts,jsx,tsx}"], theme: { extend: { colors: {