Skip to content

Commit 3593caf

Browse files
authored
feat: add Storybook for UI components (#137) (#955)
1 parent 24b785e commit 3593caf

10 files changed

Lines changed: 1112 additions & 15 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,6 @@ __pycache__/
5959
resources/prebuilt/bin/
6060
resources/prebuilt/venv/
6161
resources/prebuilt/cache/
62+
63+
*storybook.log
64+
storybook-static

.storybook/main.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { StorybookConfig } from '@storybook/react-vite'
2+
3+
const config: StorybookConfig = {
4+
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
5+
addons: ['@storybook/addon-docs', '@storybook/addon-a11y'],
6+
framework: '@storybook/react-vite',
7+
viteFinal: async (config) => {
8+
// Reuse project's vite config for path aliases
9+
return config
10+
},
11+
}
12+
13+
export default config

.storybook/preview.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { Preview } from '@storybook/react-vite'
2+
import React from 'react'
3+
import '@fontsource/inter/400.css'
4+
import '@fontsource/inter/500.css'
5+
import '@fontsource/inter/600.css'
6+
import '@fontsource/inter/700.css'
7+
import '@fontsource/inter/800.css'
8+
import '../src/style/index.css'
9+
import './storybook.css' // Storybook-specific overrides
10+
import { Toaster } from 'sonner'
11+
12+
// Apply theme immediately via script
13+
if (typeof document !== 'undefined') {
14+
document.documentElement.setAttribute('data-theme', 'light')
15+
document.documentElement.classList.add('root')
16+
}
17+
18+
const preview: Preview = {
19+
tags: ['autodocs'],
20+
parameters: {
21+
layout: 'centered',
22+
controls: {
23+
expanded: true,
24+
matchers: {
25+
color: /(background|color)$/i,
26+
date: /Date$/i,
27+
},
28+
},
29+
backgrounds: {
30+
default: 'light',
31+
values: [
32+
{ name: 'light', value: '#f5f5f5' },
33+
{ name: 'dark', value: '#1d1c1b' },
34+
],
35+
},
36+
},
37+
decorators: [
38+
(Story) => (
39+
<div className="root" data-theme="light" style={{ padding: '1rem' }}>
40+
<Story />
41+
<Toaster style={{ zIndex: '999999 !important', position: 'fixed' }} />
42+
</div>
43+
),
44+
],
45+
}
46+
47+
export default preview

.storybook/storybook.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/* Storybook-specific CSS overrides - currently empty as component handles styling */

package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
"test:watch": "vitest",
3232
"test:e2e": "vitest run --config vitest.config.ts",
3333
"test:coverage": "vitest run --coverage",
34-
"type-check": "tsc --noEmit"
34+
"type-check": "tsc --noEmit",
35+
"storybook": "storybook dev -p 6006",
36+
"build-storybook": "storybook build -o storybook-static"
3537
},
3638
"dependencies": {
3739
"@electron/notarize": "^2.5.0",
@@ -131,7 +133,11 @@
131133
"vite": "^5.4.11",
132134
"vite-plugin-electron": "^0.29.0",
133135
"vite-plugin-electron-renderer": "^0.14.6",
134-
"vitest": "^2.1.5"
136+
"vitest": "^2.1.5",
137+
"storybook": "^10.1.11",
138+
"@storybook/react-vite": "^10.1.11",
139+
"@storybook/addon-a11y": "^10.1.11",
140+
"@storybook/addon-docs": "^10.1.11"
135141
},
136142
"overrides": {
137143
"glob": "^10.4.5"
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite'
2+
import { Button } from './button'
3+
import { Plus, Download, Trash2 } from 'lucide-react'
4+
import { expect, fn, userEvent, within } from 'storybook/test'
5+
6+
const meta: Meta<typeof Button> = {
7+
title: 'UI/Button',
8+
component: Button,
9+
argTypes: {
10+
variant: {
11+
control: 'select',
12+
options: [
13+
'primary',
14+
'secondary',
15+
'outline',
16+
'ghost',
17+
'success',
18+
'cuation',
19+
'information',
20+
'warning',
21+
],
22+
},
23+
size: {
24+
control: 'select',
25+
options: ['xxs', 'xs', 'sm', 'md', 'lg', 'icon'],
26+
},
27+
disabled: {
28+
control: 'boolean',
29+
},
30+
asChild: {
31+
control: 'boolean',
32+
},
33+
children: {
34+
control: 'text',
35+
},
36+
},
37+
args: {
38+
children: 'Button',
39+
variant: 'primary',
40+
size: 'md',
41+
},
42+
}
43+
44+
export default meta
45+
46+
type Story = StoryObj<typeof Button>
47+
48+
export const Primary: Story = {
49+
args: {
50+
variant: 'primary',
51+
children: 'Primary Button',
52+
},
53+
}
54+
55+
export const Secondary: Story = {
56+
args: {
57+
variant: 'secondary',
58+
children: 'Secondary Button',
59+
},
60+
}
61+
62+
export const Outline: Story = {
63+
args: {
64+
variant: 'outline',
65+
children: 'Outline Button',
66+
},
67+
}
68+
69+
export const Ghost: Story = {
70+
args: {
71+
variant: 'ghost',
72+
children: 'Ghost Button',
73+
},
74+
}
75+
76+
export const Success: Story = {
77+
args: {
78+
variant: 'success',
79+
children: 'Success Button',
80+
},
81+
}
82+
83+
export const Warning: Story = {
84+
args: {
85+
variant: 'warning',
86+
children: 'Warning Button',
87+
},
88+
}
89+
90+
export const Disabled: Story = {
91+
args: {
92+
variant: 'primary',
93+
children: 'Disabled Button',
94+
disabled: true,
95+
},
96+
}
97+
98+
export const WithIcon: Story = {
99+
render: (args) => (
100+
<Button {...args}>
101+
<Plus /> Add Item
102+
</Button>
103+
),
104+
args: {
105+
variant: 'primary',
106+
},
107+
}
108+
109+
export const IconOnly: Story = {
110+
render: (args) => (
111+
<Button {...args}>
112+
<Download />
113+
</Button>
114+
),
115+
args: {
116+
variant: 'ghost',
117+
size: 'icon',
118+
},
119+
}
120+
121+
export const AllVariants: Story = {
122+
render: () => (
123+
<div className="flex flex-wrap gap-4">
124+
<Button variant="primary">Primary</Button>
125+
<Button variant="secondary">Secondary</Button>
126+
<Button variant="outline">Outline</Button>
127+
<Button variant="ghost">Ghost</Button>
128+
<Button variant="success">Success</Button>
129+
<Button variant="warning">Warning</Button>
130+
</div>
131+
),
132+
}
133+
134+
export const AllSizes: Story = {
135+
render: () => (
136+
<div className="flex flex-wrap items-center gap-4">
137+
<Button variant="primary" size="xxs">
138+
XXS
139+
</Button>
140+
<Button variant="primary" size="xs">
141+
XS
142+
</Button>
143+
<Button variant="primary" size="sm">
144+
SM
145+
</Button>
146+
<Button variant="primary" size="md">
147+
MD
148+
</Button>
149+
<Button variant="primary" size="lg">
150+
LG
151+
</Button>
152+
<Button variant="primary" size="icon">
153+
<Trash2 />
154+
</Button>
155+
</div>
156+
),
157+
}
158+
159+
// Interaction test stories
160+
export const ClickInteraction: Story = {
161+
args: {
162+
variant: 'primary',
163+
children: 'Click Me',
164+
onClick: fn(),
165+
},
166+
play: async ({ args, canvasElement }) => {
167+
const canvas = within(canvasElement)
168+
const button = canvas.getByRole('button', { name: /click me/i })
169+
170+
// Test that button is visible and enabled
171+
await expect(button).toBeVisible()
172+
await expect(button).toBeEnabled()
173+
174+
// Click the button
175+
await userEvent.click(button)
176+
177+
// Verify the onClick handler was called
178+
await expect(args.onClick).toHaveBeenCalledTimes(1)
179+
},
180+
}
181+
182+
export const DisabledInteraction: Story = {
183+
args: {
184+
variant: 'primary',
185+
children: 'Disabled Button',
186+
disabled: true,
187+
onClick: fn(),
188+
},
189+
play: async ({ args, canvasElement }) => {
190+
const canvas = within(canvasElement)
191+
const button = canvas.getByRole('button', { name: /disabled button/i })
192+
193+
// Test that button is visible but disabled
194+
await expect(button).toBeVisible()
195+
await expect(button).toBeDisabled()
196+
197+
// Verify the onClick handler was NOT called (disabled buttons block pointer events)
198+
await expect(args.onClick).not.toHaveBeenCalled()
199+
},
200+
}
201+
202+
export const HoverInteraction: Story = {
203+
args: {
204+
variant: 'outline',
205+
children: 'Hover Over Me',
206+
},
207+
play: async ({ canvasElement }) => {
208+
const canvas = within(canvasElement)
209+
const button = canvas.getByRole('button', { name: /hover over me/i })
210+
211+
// Test initial state
212+
await expect(button).toBeVisible()
213+
214+
// Hover over the button
215+
await userEvent.hover(button)
216+
217+
// The button should still be visible after hover
218+
await expect(button).toBeVisible()
219+
220+
// Unhover
221+
await userEvent.unhover(button)
222+
},
223+
}

0 commit comments

Comments
 (0)