feat(storybook): Storybook with Calendar and Modal components #9869 (#10626)

### What problem does this PR solve?

feat(storybook): Storybook with Calendar and Modal components #9869

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
This commit is contained in:
chanx
2025-10-17 09:58:52 +08:00
committed by GitHub
parent 39ad9490ac
commit 15838a6673
4 changed files with 1061 additions and 1 deletions

View File

@ -27,6 +27,21 @@ const config: StorybookConfig = {
},
],
},
{
test: /\.less$/,
use: [
'style-loader',
'css-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [require('tailwindcss'), require('autoprefixer')],
},
},
},
],
},
],
},
},

View File

@ -14,6 +14,12 @@ const sizeClasses = {
large: 'w-8 h-8',
};
const minSizeClasses = {
small: 'min-w-4 min-h-4',
default: 'min-w-6 min-h-6',
large: 'min-w-8 min-h-8',
};
export const Spin: React.FC<SpinProps> = ({
spinning = true,
size = 'default',
@ -32,7 +38,12 @@ export const Spin: React.FC<SpinProps> = ({
)}
>
{spinning && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-text-primary/30 ">
<div
className={cn(
'absolute inset-0 z-10 flex items-center justify-center bg-text-primary/30',
minSizeClasses[size],
)}
>
<div
className={cn(
'rounded-full border-muted-foreground border-2 border-t-transparent animate-spin',

View File

@ -0,0 +1,296 @@
import { Calendar } from '@/components/originui/calendar';
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { useState } from 'react';
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
title: 'Example/Calendar',
component: Calendar,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'centered',
docs: {
description: {
component: `
## Calendar Component
Calendar is a date picker component based on react-day-picker that allows users to select dates or date ranges. It provides a clean and customizable interface for date selection with support for various customization options.
### Import Path
\`\`\`typescript
import { Calendar } from '@/components/originui/calendar';
\`\`\`
### Basic Usage
\`\`\`tsx
import { Calendar } from '@/components/originui/calendar';
import { useState } from 'react';
function MyComponent() {
const [date, setDate] = useState<Date | undefined>(new Date());
return (
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border"
/>
);
}
\`\`\`
### Features
- Single date selection
- Date range selection
- Customizable styling with className prop
- Navigation between months
- Today highlighting
- Disabled dates support
- Customizable components
- Built with Tailwind CSS
`,
},
},
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {
mode: {
description: 'Selection mode - single date or range',
control: { type: 'radio' },
options: ['single', 'range'],
},
selected: {
description: 'Selected date or date range',
control: false,
},
onSelect: {
description: 'Callback function when date is selected',
control: false,
},
className: {
description: 'Additional CSS classes for styling',
control: { type: 'text' },
},
classNames: {
description: 'Custom class names for internal elements',
control: { type: 'object' },
},
showOutsideDays: {
description: 'Whether to show outside days',
control: { type: 'boolean' },
},
components: {
description: 'Custom components for calendar elements',
control: { type: 'object' },
},
},
} satisfies Meta<typeof Calendar>;
export default meta;
type Story = StoryObj<typeof meta>;
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Default: Story = {
args: {
showOutsideDays: true,
className: 'rounded-md border',
},
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [date, setDate] = useState<Date | undefined>(new Date());
return (
<Calendar
mode="single"
selected={date}
onSelect={setDate}
showOutsideDays={true}
className="rounded-md border"
/>
);
},
parameters: {
docs: {
description: {
story: `
### Default Calendar
Shows the basic calendar with single date selection mode.
\`\`\`tsx
const [date, setDate] = useState<Date | undefined>(new Date());
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border"
showOutsideDays={true}
/>
\`\`\`
`,
},
},
},
};
export const RangeSelection: Story = {
args: {
showOutsideDays: true,
className: 'rounded-md border',
},
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [range, setRange] = useState<{
from: Date | undefined;
to?: Date | undefined;
}>({
from: new Date(),
to: undefined,
});
return (
<Calendar
mode="range"
selected={range}
onSelect={(range) =>
setRange(range as { from: Date | undefined; to?: Date | undefined })
}
showOutsideDays={true}
className="rounded-md border"
/>
);
},
parameters: {
docs: {
description: {
story: `
### Range Selection Calendar
Shows the calendar with date range selection mode.
\`\`\`tsx
const [range, setRange] = useState<{ from: Date | undefined; to?: Date | undefined }>({
from: new Date(),
to: undefined,
});
<Calendar
mode="range"
selected={range}
onSelect={(date) => {
if (!range.from) {
setRange({ from: date });
} else if (!range.to && date && date > range.from) {
setRange({ from: range.from, to: date });
} else {
setRange({ from: date });
}
}}
className="rounded-md border"
showOutsideDays={true}
/>
\`\`\`
`,
},
},
},
};
export const WithoutOutsideDays: Story = {
args: {
showOutsideDays: false,
className: 'rounded-md border',
},
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [date, setDate] = useState<Date | undefined>(new Date());
return (
<Calendar
mode="single"
selected={date}
onSelect={setDate}
showOutsideDays={false}
className="rounded-md border"
/>
);
},
parameters: {
docs: {
description: {
story: `
### Calendar without Outside Days
Shows the calendar without displaying days from previous/next months.
\`\`\`tsx
const [date, setDate] = useState<Date | undefined>(new Date());
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border"
showOutsideDays={false}
/>
\`\`\`
`,
},
},
},
};
export const CustomStyling: Story = {
args: {
showOutsideDays: true,
},
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [date, setDate] = useState<Date | undefined>(new Date());
return (
<Calendar
mode="single"
selected={date}
onSelect={setDate}
showOutsideDays={true}
className="rounded-md border-2 border-primary bg-secondary"
classNames={{
caption_label: 'text-lg font-bold text-primary',
day_button: 'size-10 rounded-full hover:bg-primary/20',
}}
/>
);
},
parameters: {
docs: {
description: {
story: `
### Custom Styled Calendar
Shows the calendar with custom styling using className and classNames props.
\`\`\`tsx
const [date, setDate] = useState<Date | undefined>(new Date());
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border-2 border-primary bg-secondary"
classNames={{
caption_label: 'text-lg font-bold text-primary',
day_button: 'size-10 rounded-full hover:bg-primary/20',
}}
showOutsideDays={true}
/>
\`\`\`
`,
},
},
},
};

View File

@ -0,0 +1,738 @@
import { Button } from '@/components/ui/button';
import { Modal } from '@/components/ui/modal/modal';
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { useState } from 'react';
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
title: 'Example/Modal',
component: Modal,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'centered',
docs: {
description: {
component: `
## Modal Component
The Modal component is a dialog overlay that can be used to present content in a modal window. It provides a flexible way to display information, forms, or any other content on top of the main page content.
### Import Path
\`\`\`typescript
import { Modal } from '@/components/ui/modal/modal';
\`\`\`
### Basic Usage
\`\`\`tsx
import { Modal } from '@/components/ui/modal/modal';
import { useState } from 'react';
function MyComponent() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open Modal</button>
<Modal
open={open}
onOpenChange={setOpen}
title="Modal Title"
>
<p>Modal content goes here</p>
</Modal>
</>
);
}
\`\`\`
### Features
- Multiple sizes: small, default, and large
- Customizable header with title and close button
- Customizable footer with default OK/Cancel buttons
- Support for controlled and uncontrolled usage
- Loading state for confirmation button
- Keyboard navigation support (ESC to close)
- Click outside to close functionality
- Full screen mode option
- Built with Radix UI primitives for accessibility
- Customizable styling with className props
`,
},
},
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {
open: {
description: 'Whether the modal is open or not',
control: { type: 'boolean' },
},
onOpenChange: {
description:
'Callback function that is called when the open state changes',
control: false,
},
title: {
description: 'Title of the modal',
control: { type: 'text' },
},
titleClassName: {
description: 'Additional CSS classes for the title container',
control: { type: 'text' },
},
children: {
description: 'Content to be displayed inside the modal',
control: false,
},
footer: {
description:
'Custom footer content. If not provided, default buttons will be shown',
control: { type: 'text' },
},
footerClassName: {
description: 'Additional CSS classes for the footer container',
control: { type: 'text' },
},
showfooter: {
description: 'Whether to show the footer or not',
control: { type: 'boolean' },
},
className: {
description: 'Additional CSS classes for the modal container',
control: { type: 'text' },
},
size: {
description: 'Size of the modal',
control: { type: 'select' },
options: ['small', 'default', 'large'],
},
closable: {
description: 'Whether to show the close button in the header',
control: { type: 'boolean' },
},
closeIcon: {
description: 'Custom close icon',
control: false,
},
maskClosable: {
description: 'Whether to close the modal when clicking on the mask',
control: { type: 'boolean' },
},
destroyOnClose: {
description: 'Whether to unmount the modal content when closed',
control: { type: 'boolean' },
},
full: {
description: 'Whether the modal should take the full screen',
control: { type: 'boolean' },
},
confirmLoading: {
description: 'Whether the confirm button should show a loading state',
control: { type: 'boolean' },
},
cancelText: {
description: 'Text for the cancel button',
control: { type: 'text' },
},
okText: {
description: 'Text for the OK button',
control: { type: 'text' },
},
onOk: {
description: 'Callback function for the OK button',
control: false,
},
onCancel: {
description: 'Callback function for the Cancel button',
control: false,
},
},
} satisfies Meta<typeof Modal>;
export default meta;
type Story = StoryObj<typeof meta>;
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Default: Story = {
args: {
open: false,
title: 'Default Modal',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Modal Content</h3>
<p className="text-muted-foreground">
This is the default modal with standard size and functionality.
</p>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Default Modal</Button>
<Modal open={open} onOpenChange={setOpen} title={args.title}>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Default Modal
Shows the basic modal with default size and standard header/footer.
\`\`\`tsx
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open Default Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Default Modal"
>
<div>
<h3 className="text-lg font-medium mb-2">Modal Content</h3>
<p className="text-muted-foreground">
This is the default modal with standard size and functionality.
</p>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
export const Small: Story = {
args: {
open: false,
title: 'Small Modal',
size: 'small',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Small Modal</h3>
<p className="text-muted-foreground">
This is a small modal, suitable for simple confirmations or short
messages.
</p>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Small Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
size={args.size}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Small Modal
Shows a small-sized modal, ideal for confirmations or brief messages.
\`\`\`tsx
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open Small Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Small Modal"
size="small"
>
<div>
<h3 className="text-lg font-medium mb-2">Small Modal</h3>
<p className="text-muted-foreground">
This is a small modal, suitable for simple confirmations or short messages.
</p>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
export const Large: Story = {
args: {
open: false,
title: 'Large Modal',
size: 'large',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Large Modal</h3>
<p className="text-muted-foreground mb-4">
This is a large modal with more content. It can accommodate forms,
tables, or other complex content.
</p>
<div className="bg-muted p-4 rounded-md">
<p>Additional content area</p>
<p className="mt-2">You can put any content here</p>
</div>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Large Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
size={args.size}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Large Modal
Shows a large-sized modal, suitable for complex content like forms or data tables.
\`\`\`tsx
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open Large Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Large Modal"
size="large"
>
<div>
<h3 className="text-lg font-medium mb-2">Large Modal</h3>
<p className="text-muted-foreground mb-4">
This is a large modal with more content. It can accommodate forms, tables, or other complex content.
</p>
<div className="bg-muted p-4 rounded-md">
<p>Additional content area</p>
<p className="mt-2">You can put any content here</p>
</div>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
export const WithCustomFooter: Story = {
args: {
open: false,
title: 'Custom Footer',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Modal with Custom Footer</h3>
<p className="text-muted-foreground">
This modal has a custom footer with multiple buttons.
</p>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>
Open Modal with Custom Footer
</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
footer={
<div className="flex justify-between w-full">
<Button variant="outline">Secondary Action</Button>
<div className="space-x-2">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button>Primary Action</Button>
</div>
</div>
}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Custom Footer
Shows a modal with a custom footer. You can provide your own footer content instead of using the default OK/Cancel buttons.
\`\`\`tsx
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open Modal with Custom Footer</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Custom Footer"
footer={
<div className="flex justify-between w-full">
<Button variant="outline">Secondary Action</Button>
<div className="space-x-2">
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button>Primary Action</Button>
</div>
</div>
}
>
<div>
<h3 className="text-lg font-medium mb-2">Modal with Custom Footer</h3>
<p className="text-muted-foreground">
This modal has a custom footer with multiple buttons.
</p>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
export const WithoutFooter: Story = {
args: {
open: false,
title: 'No Footer',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Modal without Footer</h3>
<p className="text-muted-foreground">
This modal has no footer. The content area extends to the bottom of
the modal.
</p>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Modal without Footer</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
showfooter={false}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Without Footer
Shows a modal without a footer. Useful when you want to include action buttons within the content area or don't need any footer actions.
\`\`\`tsx
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open Modal without Footer</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="No Footer"
showfooter={false}
>
<div>
<h3 className="text-lg font-medium mb-2">Modal without Footer</h3>
<p className="text-muted-foreground">
This modal has no footer. The content area extends to the bottom of the modal.
</p>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
export const FullScreen: Story = {
args: {
open: false,
title: 'Full Screen Modal',
children: (
<div className="h-96 flex flex-col">
<h3 className="text-lg font-medium mb-2">Full Screen Modal</h3>
<p className="text-muted-foreground mb-4">
This modal takes up the full screen. Useful for complex workflows or
when you need maximum space.
</p>
<div className="flex-grow bg-muted rounded-md p-4">
<p>Content area that can expand to fill available space</p>
</div>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Full Screen Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
full={true}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Full Screen Modal
Shows a full screen modal that takes up the entire viewport. Useful for complex workflows or when maximum space is needed.
\`\`\`tsx
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open Full Screen Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Full Screen Modal"
full={true}
>
<div className="h-96 flex flex-col">
<h3 className="text-lg font-medium mb-2">Full Screen Modal</h3>
<p className="text-muted-foreground mb-4">
This modal takes up the full screen. Useful for complex workflows or when you need maximum space.
</p>
<div className="flex-grow bg-muted rounded-md p-4">
<p>Content area that can expand to fill available space</p>
</div>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
export const LoadingState: Story = {
args: {
open: false,
title: 'Loading State',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Modal with Loading State</h3>
<p className="text-muted-foreground">
The OK button shows a loading spinner when confirmLoading is true.
</p>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
// eslint-disable-next-line react-hooks/rules-of-hooks
const [loading, setLoading] = useState(false);
const handleOk = () => {
setLoading(true);
setTimeout(() => {
setLoading(false);
setOpen(false);
}, 2000);
};
return (
<div>
<Button onClick={() => setOpen(true)}>Open Loading State Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
confirmLoading={loading}
onOk={handleOk}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Loading State
Shows a modal with the confirm button in a loading state. This is useful when performing async operations after clicking OK.
\`\`\`tsx
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleOk = () => {
setLoading(true);
setTimeout(() => {
setLoading(false);
setOpen(false);
}, 2000);
};
<Button onClick={() => setOpen(true)}>Open Loading State Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Loading State"
confirmLoading={loading}
onOk={handleOk}
>
<div>
<h3 className="text-lg font-medium mb-2">Modal with Loading State</h3>
<p className="text-muted-foreground">
The OK button shows a loading spinner when confirmLoading is true.
</p>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
// Interactive example showing how to use the modal in a real component
export const Interactive: Story = {
args: {
open: false,
title: 'Interactive Modal',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Interactive Modal</h3>
<p className="text-muted-foreground">
Click OK to see the loading state, or click Cancel/Close to close the
modal.
</p>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Interactive Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
onOk={() => {
// Simulate API call
setTimeout(() => {
setOpen(false);
}, 1000);
}}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Interactive Example
This is a fully interactive example showing how to use the modal in a real component with state management.
\`\`\`tsx
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Modal } from '@/components/ui/modal/modal';
function InteractiveModal() {
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Interactive Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Interactive Modal"
onOk={() => {
// Simulate API call
setTimeout(() => {
setOpen(false);
}, 1000);
}}
>
<div>
<h3 className="text-lg font-medium mb-2">Interactive Modal</h3>
<p className="text-muted-foreground">
Click OK to see the loading state, or click Cancel/Close to close the modal.
</p>
</div>
</Modal>
</div>
);
}
\`\`\`
`,
},
},
},
};