feat: add Storybook for UI components (#137) (#955)

This commit is contained in:
Dream 2026-01-21 02:30:35 -05:00 committed by GitHub
parent 24b785ef81
commit 3593caf6f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1112 additions and 15 deletions

3
.gitignore vendored
View file

@ -59,3 +59,6 @@ __pycache__/
resources/prebuilt/bin/
resources/prebuilt/venv/
resources/prebuilt/cache/
*storybook.log
storybook-static

13
.storybook/main.ts Normal file
View file

@ -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

47
.storybook/preview.tsx Normal file
View file

@ -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) => (
<div className="root" data-theme="light" style={{ padding: '1rem' }}>
<Story />
<Toaster style={{ zIndex: '999999 !important', position: 'fixed' }} />
</div>
),
],
}
export default preview

1
.storybook/storybook.css Normal file
View file

@ -0,0 +1 @@
/* Storybook-specific CSS overrides - currently empty as component handles styling */

View file

@ -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"

View file

@ -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<typeof Button> = {
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<typeof Button>
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) => (
<Button {...args}>
<Plus /> Add Item
</Button>
),
args: {
variant: 'primary',
},
}
export const IconOnly: Story = {
render: (args) => (
<Button {...args}>
<Download />
</Button>
),
args: {
variant: 'ghost',
size: 'icon',
},
}
export const AllVariants: Story = {
render: () => (
<div className="flex flex-wrap gap-4">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="success">Success</Button>
<Button variant="warning">Warning</Button>
</div>
),
}
export const AllSizes: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-4">
<Button variant="primary" size="xxs">
XXS
</Button>
<Button variant="primary" size="xs">
XS
</Button>
<Button variant="primary" size="sm">
SM
</Button>
<Button variant="primary" size="md">
MD
</Button>
<Button variant="primary" size="lg">
LG
</Button>
<Button variant="primary" size="icon">
<Trash2 />
</Button>
</div>
),
}
// 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)
},
}

View file

@ -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<typeof Dialog> = {
title: 'UI/Dialog',
component: Dialog,
}
export default meta
type Story = StoryObj<typeof Dialog>
export const Default: Story = {
render: function DefaultDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="primary">Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader title="Dialog Title" subtitle="Optional subtitle text" />
<DialogContentSection>
<p className="text-text-body">
This is the main content area of the dialog. You can add any content
here including forms, text, images, or other components.
</p>
</DialogContentSection>
<DialogFooter
showCancelButton
showConfirmButton
onCancel={() => setOpen(false)}
onConfirm={() => {
console.log('Confirmed!')
setOpen(false)
}}
/>
</DialogContent>
</Dialog>
)
},
}
export const SmallSize: Story = {
render: function SmallDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="primary">Small Dialog</Button>
</DialogTrigger>
<DialogContent size="sm">
<DialogHeader title="Small Dialog" />
<DialogContentSection>
<p className="text-text-body">A compact dialog for simple actions.</p>
</DialogContentSection>
<DialogFooter
showConfirmButton
confirmButtonText="OK"
onConfirm={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
)
},
}
export const LargeSize: Story = {
render: function LargeDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="primary">Large Dialog</Button>
</DialogTrigger>
<DialogContent size="lg">
<DialogHeader
title="Large Dialog"
subtitle="This dialog is wider for more complex content"
/>
<DialogContentSection>
<p className="text-text-body">
Large dialogs are useful when you need to display more content, such
as detailed forms, tables, or multi-step processes.
</p>
</DialogContentSection>
<DialogFooter
showCancelButton
showConfirmButton
onCancel={() => setOpen(false)}
onConfirm={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
)
},
}
export const WithForm: Story = {
render: function FormDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="primary">Create Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader
title="Create Account"
subtitle="Enter your details to create a new account"
/>
<DialogContentSection>
<div className="flex flex-col gap-4">
<Input title="Full Name" placeholder="Enter your full name" required />
<Input
title="Email"
type="email"
placeholder="name@example.com"
required
/>
<Input
title="Password"
type="password"
placeholder="Create a password"
required
note="Must be at least 8 characters"
/>
</div>
</DialogContentSection>
<DialogFooter
showCancelButton
showConfirmButton
confirmButtonText="Create Account"
onCancel={() => setOpen(false)}
onConfirm={() => {
console.log('Account created!')
setOpen(false)
}}
/>
</DialogContent>
</Dialog>
)
},
}
export const WithTooltip: Story = {
render: function TooltipDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="primary">With Tooltip</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader
title="Settings"
tooltip="Configure your application settings"
showTooltip
/>
<DialogContentSection>
<p className="text-text-body">
Hover over the icon next to the title to see the tooltip.
</p>
</DialogContentSection>
<DialogFooter
showConfirmButton
confirmButtonText="Save"
onConfirm={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
)
},
}
export const WithBackButton: Story = {
render: function BackButtonDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="primary">Multi-step Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader
title="Step 2 of 3"
subtitle="Configure your preferences"
showBackButton
onBackClick={() => console.log('Back clicked')}
/>
<DialogContentSection>
<p className="text-text-body">
Click the back button to return to the previous step.
</p>
</DialogContentSection>
<DialogFooter
showCancelButton
showConfirmButton
cancelButtonText="Back"
confirmButtonText="Next"
onCancel={() => setOpen(false)}
onConfirm={() => {
console.log('Next step')
setOpen(false)
}}
/>
</DialogContent>
</Dialog>
)
},
}
export const DestructiveAction: Story = {
render: function DestructiveDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="warning">Delete Item</Button>
</DialogTrigger>
<DialogContent size="sm">
<DialogHeader
title="Delete Item"
subtitle="This action cannot be undone"
/>
<DialogContentSection>
<p className="text-text-body">
Are you sure you want to delete this item? All associated data will
be permanently removed.
</p>
</DialogContentSection>
<DialogFooter
showCancelButton
showConfirmButton
confirmButtonText="Delete"
confirmButtonVariant="warning"
onCancel={() => setOpen(false)}
onConfirm={() => {
console.log('Item deleted!')
setOpen(false)
}}
/>
</DialogContent>
</Dialog>
)
},
}
export const NoCloseButton: Story = {
render: function NoCloseDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="primary">No Close Button</Button>
</DialogTrigger>
<DialogContent showCloseButton={false}>
<DialogHeader title="Required Action" />
<DialogContentSection>
<p className="text-text-body">
This dialog does not have a close button. User must interact with
the footer buttons.
</p>
</DialogContentSection>
<DialogFooter
showConfirmButton
confirmButtonText="Acknowledge"
onConfirm={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
)
},
}
export const AllSizes: Story = {
render: function AllSizesDialog() {
const [openSm, setOpenSm] = useState(false)
const [openMd, setOpenMd] = useState(false)
const [openLg, setOpenLg] = useState(false)
return (
<div className="flex gap-4">
<Dialog open={openSm} onOpenChange={setOpenSm}>
<DialogTrigger asChild>
<Button variant="outline">Small (400px)</Button>
</DialogTrigger>
<DialogContent size="sm">
<DialogHeader title="Small Dialog" />
<DialogContentSection>
<p className="text-text-body">Max width: 400px</p>
</DialogContentSection>
<DialogFooter
showConfirmButton
confirmButtonText="Close"
onConfirm={() => setOpenSm(false)}
/>
</DialogContent>
</Dialog>
<Dialog open={openMd} onOpenChange={setOpenMd}>
<DialogTrigger asChild>
<Button variant="outline">Medium (600px)</Button>
</DialogTrigger>
<DialogContent size="md">
<DialogHeader title="Medium Dialog" />
<DialogContentSection>
<p className="text-text-body">Max width: 600px (default)</p>
</DialogContentSection>
<DialogFooter
showConfirmButton
confirmButtonText="Close"
onConfirm={() => setOpenMd(false)}
/>
</DialogContent>
</Dialog>
<Dialog open={openLg} onOpenChange={setOpenLg}>
<DialogTrigger asChild>
<Button variant="outline">Large (900px)</Button>
</DialogTrigger>
<DialogContent size="lg">
<DialogHeader title="Large Dialog" />
<DialogContentSection>
<p className="text-text-body">Max width: 900px</p>
</DialogContentSection>
<DialogFooter
showConfirmButton
confirmButtonText="Close"
onConfirm={() => setOpenLg(false)}
/>
</DialogContent>
</Dialog>
</div>
)
},
}
// Interaction test stories
export const OpenCloseInteraction: Story = {
render: function OpenCloseDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="primary">Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader title="Interactive Dialog" subtitle="Test opening and closing" />
<DialogContentSection>
<p className="text-text-body">
This dialog tests the open/close interaction.
</p>
</DialogContentSection>
<DialogFooter
showCancelButton
showConfirmButton
onCancel={() => setOpen(false)}
onConfirm={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
)
},
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 (
<div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="primary">Open Form</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader title="Sign Up" subtitle="Create your account" />
<DialogContentSection>
<div className="flex flex-col gap-4">
<Input title="Name" placeholder="Enter your name" required />
<Input
title="Email"
type="email"
placeholder="Enter your email"
required
/>
</div>
</DialogContentSection>
<DialogFooter
showCancelButton
showConfirmButton
confirmButtonText="Sign Up"
onCancel={() => setOpen(false)}
onConfirm={() => {
setSubmitted(true)
setOpen(false)
}}
/>
</DialogContent>
</Dialog>
{submitted && <p data-testid="success-message">Form submitted successfully!</p>}
</div>
)
},
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 (
<div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="warning">Delete Item</Button>
</DialogTrigger>
<DialogContent size="sm">
<DialogHeader title="Confirm Delete" subtitle="This action cannot be undone" />
<DialogContentSection>
<p className="text-text-body">
Are you sure you want to delete this item?
</p>
</DialogContentSection>
<DialogFooter
showCancelButton
showConfirmButton
confirmButtonText="Delete"
confirmButtonVariant="warning"
onCancel={() => setOpen(false)}
onConfirm={() => {
setConfirmed(true)
setOpen(false)
}}
/>
</DialogContent>
</Dialog>
{confirmed && <p data-testid="deleted-message">Item deleted!</p>}
</div>
)
},
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!')
},
}

View file

@ -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<typeof Input> = {
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) => (
<div className="w-80">
<Story />
</div>
),
],
}
export default meta
type Story = StoryObj<typeof Input>
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: <Search size={16} />,
},
}
export const WithBackIcon: Story = {
render: function PasswordInput() {
const [showPassword, setShowPassword] = useState(false)
return (
<Input
title="Password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter password"
backIcon={showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
onBackIconClick={() => setShowPassword(!showPassword)}
/>
)
},
}
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-4">
<Input title="Default" state="default" placeholder="Default state" />
<Input title="Hover" state="hover" placeholder="Hover state" />
<Input title="Input" state="input" placeholder="Input state" />
<Input
title="Error"
state="error"
placeholder="Error state"
note="Error message"
/>
<Input
title="Success"
state="success"
placeholder="Success state"
note="Success message"
/>
<Input title="Disabled" disabled placeholder="Disabled state" />
</div>
),
}
export const FormExample: Story = {
render: () => (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-heading">Contact Form</h3>
<Input title="Full Name" placeholder="John Doe" required />
<Input
title="Email"
type="email"
placeholder="john@example.com"
required
/>
<Input
title="Phone"
type="tel"
placeholder="+1 (555) 123-4567"
tooltip="We'll only use this for urgent matters"
/>
<Input
title="Message"
placeholder="How can we help you?"
note="Maximum 500 characters"
/>
</div>
),
decorators: [
(Story) => (
<div className="w-96">
<Story />
</div>
),
],
}
// 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 (
<Input
title="Password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter password"
defaultValue="secret123"
backIcon={showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
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')
},
}

View file

@ -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<HTMLInputElement, BaseInputProps>(
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",

View file

@ -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: {