mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-07-09 17:28:35 +00:00
parent
24b785ef81
commit
3593caf6f6
10 changed files with 1112 additions and 15 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -59,3 +59,6 @@ __pycache__/
|
|||
resources/prebuilt/bin/
|
||||
resources/prebuilt/venv/
|
||||
resources/prebuilt/cache/
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
|
|
|||
13
.storybook/main.ts
Normal file
13
.storybook/main.ts
Normal 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
47
.storybook/preview.tsx
Normal 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
1
.storybook/storybook.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* Storybook-specific CSS overrides - currently empty as component handles styling */
|
||||
10
package.json
10
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"
|
||||
|
|
|
|||
223
src/components/ui/button.stories.tsx
Normal file
223
src/components/ui/button.stories.tsx
Normal 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)
|
||||
},
|
||||
}
|
||||
512
src/components/ui/dialog.stories.tsx
Normal file
512
src/components/ui/dialog.stories.tsx
Normal 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!')
|
||||
},
|
||||
}
|
||||
291
src/components/ui/input.stories.tsx
Normal file
291
src/components/ui/input.stories.tsx
Normal 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')
|
||||
},
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue