From 15838a667376a94cd6c71e2eb5b0ab69ff6358b6 Mon Sep 17 00:00:00 2001 From: chanx <1243304602@qq.com> Date: Fri, 17 Oct 2025 09:58:52 +0800 Subject: [PATCH] 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) --- web/.storybook/main.ts | 15 + web/src/components/ui/spin.tsx | 13 +- web/src/stories/calendar.stories.tsx | 296 +++++++++++ web/src/stories/modal.stories.tsx | 738 +++++++++++++++++++++++++++ 4 files changed, 1061 insertions(+), 1 deletion(-) create mode 100644 web/src/stories/calendar.stories.tsx create mode 100644 web/src/stories/modal.stories.tsx diff --git a/web/.storybook/main.ts b/web/.storybook/main.ts index dcba9b6c0..8a9408aaf 100644 --- a/web/.storybook/main.ts +++ b/web/.storybook/main.ts @@ -27,6 +27,21 @@ const config: StorybookConfig = { }, ], }, + { + test: /\.less$/, + use: [ + 'style-loader', + 'css-loader', + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [require('tailwindcss'), require('autoprefixer')], + }, + }, + }, + ], + }, ], }, }, diff --git a/web/src/components/ui/spin.tsx b/web/src/components/ui/spin.tsx index ad024748b..45e2a95d3 100644 --- a/web/src/components/ui/spin.tsx +++ b/web/src/components/ui/spin.tsx @@ -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 = ({ spinning = true, size = 'default', @@ -32,7 +38,12 @@ export const Spin: React.FC = ({ )} > {spinning && ( -
+
(new Date()); + + return ( + + ); +} +\`\`\` + +### 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; + +export default meta; +type Story = StoryObj; + +// 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(new Date()); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +### Default Calendar + +Shows the basic calendar with single date selection mode. + +\`\`\`tsx +const [date, setDate] = useState(new Date()); + + +\`\`\` + `, + }, + }, + }, +}; + +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 ( + + 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, +}); + + { + 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(new Date()); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +### Calendar without Outside Days + +Shows the calendar without displaying days from previous/next months. + +\`\`\`tsx +const [date, setDate] = useState(new Date()); + + +\`\`\` + `, + }, + }, + }, +}; + +export const CustomStyling: Story = { + args: { + showOutsideDays: true, + }, + render: () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [date, setDate] = useState(new Date()); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +### Custom Styled Calendar + +Shows the calendar with custom styling using className and classNames props. + +\`\`\`tsx +const [date, setDate] = useState(new Date()); + + +\`\`\` + `, + }, + }, + }, +}; diff --git a/web/src/stories/modal.stories.tsx b/web/src/stories/modal.stories.tsx new file mode 100644 index 000000000..003bfcecf --- /dev/null +++ b/web/src/stories/modal.stories.tsx @@ -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 ( + <> + + +

Modal content goes here

+
+ + ); +} +\`\`\` + +### 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; + +export default meta; +type Story = StoryObj; + +// 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: ( +
+

Modal Content

+

+ This is the default modal with standard size and functionality. +

+
+ ), + }, + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [open, setOpen] = useState(false); + + return ( +
+ + + {args.children} + +
+ ); + }, + parameters: { + docs: { + description: { + story: ` +### Default Modal + +Shows the basic modal with default size and standard header/footer. + +\`\`\`tsx +const [open, setOpen] = useState(false); + + + +
+

Modal Content

+

+ This is the default modal with standard size and functionality. +

+
+
+\`\`\` + `, + }, + }, + }, +}; + +export const Small: Story = { + args: { + open: false, + title: 'Small Modal', + size: 'small', + children: ( +
+

Small Modal

+

+ This is a small modal, suitable for simple confirmations or short + messages. +

+
+ ), + }, + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [open, setOpen] = useState(false); + + return ( +
+ + + {args.children} + +
+ ); + }, + parameters: { + docs: { + description: { + story: ` +### Small Modal + +Shows a small-sized modal, ideal for confirmations or brief messages. + +\`\`\`tsx +const [open, setOpen] = useState(false); + + + +
+

Small Modal

+

+ This is a small modal, suitable for simple confirmations or short messages. +

+
+
+\`\`\` + `, + }, + }, + }, +}; + +export const Large: Story = { + args: { + open: false, + title: 'Large Modal', + size: 'large', + children: ( +
+

Large Modal

+

+ This is a large modal with more content. It can accommodate forms, + tables, or other complex content. +

+
+

Additional content area

+

You can put any content here

+
+
+ ), + }, + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [open, setOpen] = useState(false); + + return ( +
+ + + {args.children} + +
+ ); + }, + 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); + + + +
+

Large Modal

+

+ This is a large modal with more content. It can accommodate forms, tables, or other complex content. +

+
+

Additional content area

+

You can put any content here

+
+
+
+\`\`\` + `, + }, + }, + }, +}; + +export const WithCustomFooter: Story = { + args: { + open: false, + title: 'Custom Footer', + children: ( +
+

Modal with Custom Footer

+

+ This modal has a custom footer with multiple buttons. +

+
+ ), + }, + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [open, setOpen] = useState(false); + + return ( +
+ + + +
+ + +
+
+ } + > + {args.children} + +
+ ); + }, + 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); + + + + +
+ + +
+
+ } +> +
+

Modal with Custom Footer

+

+ This modal has a custom footer with multiple buttons. +

+
+ +\`\`\` + `, + }, + }, + }, +}; + +export const WithoutFooter: Story = { + args: { + open: false, + title: 'No Footer', + children: ( +
+

Modal without Footer

+

+ This modal has no footer. The content area extends to the bottom of + the modal. +

+
+ ), + }, + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [open, setOpen] = useState(false); + + return ( +
+ + + {args.children} + +
+ ); + }, + 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); + + + +
+

Modal without Footer

+

+ This modal has no footer. The content area extends to the bottom of the modal. +

+
+
+\`\`\` + `, + }, + }, + }, +}; + +export const FullScreen: Story = { + args: { + open: false, + title: 'Full Screen Modal', + children: ( +
+

Full Screen Modal

+

+ This modal takes up the full screen. Useful for complex workflows or + when you need maximum space. +

+
+

Content area that can expand to fill available space

+
+
+ ), + }, + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [open, setOpen] = useState(false); + + return ( +
+ + + {args.children} + +
+ ); + }, + 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); + + + +
+

Full Screen Modal

+

+ This modal takes up the full screen. Useful for complex workflows or when you need maximum space. +

+
+

Content area that can expand to fill available space

+
+
+
+\`\`\` + `, + }, + }, + }, +}; + +export const LoadingState: Story = { + args: { + open: false, + title: 'Loading State', + children: ( +
+

Modal with Loading State

+

+ The OK button shows a loading spinner when confirmLoading is true. +

+
+ ), + }, + 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 ( +
+ + + {args.children} + +
+ ); + }, + 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); +}; + + + +
+

Modal with Loading State

+

+ The OK button shows a loading spinner when confirmLoading is true. +

+
+
+\`\`\` + `, + }, + }, + }, +}; + +// Interactive example showing how to use the modal in a real component + +export const Interactive: Story = { + args: { + open: false, + title: 'Interactive Modal', + children: ( +
+

Interactive Modal

+

+ Click OK to see the loading state, or click Cancel/Close to close the + modal. +

+
+ ), + }, + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [open, setOpen] = useState(false); + + return ( +
+ + { + // Simulate API call + setTimeout(() => { + setOpen(false); + }, 1000); + }} + > + {args.children} + +
+ ); + }, + 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 ( +
+ + { + // Simulate API call + setTimeout(() => { + setOpen(false); + }, 1000); + }} + > +
+

Interactive Modal

+

+ Click OK to see the loading state, or click Cancel/Close to close the modal. +

+
+
+
+ ); +} +\`\`\` + `, + }, + }, + }, +};