diff --git a/web/package-lock.json b/web/package-lock.json index 6f1d947e1..97f1f0523 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -21,6 +21,7 @@ "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-context-menu": "^2.2.6", + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-select": "^2.1.6", @@ -2428,7 +2429,6 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -5800,6 +5800,42 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz", + "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index b97607c90..039656e0e 100644 --- a/web/package.json +++ b/web/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-context-menu": "^2.2.6", + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-select": "^2.1.6", diff --git a/web/src/components/ui/modal/Modal.stories.tsx b/web/src/components/ui/modal/Modal.stories.tsx new file mode 100644 index 000000000..8d1443044 --- /dev/null +++ b/web/src/components/ui/modal/Modal.stories.tsx @@ -0,0 +1,208 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { AppModalNew } from './Modal'; +import { Button } from '../buttons/Button'; +import React from 'react'; +import { ModalProps } from './Modal'; +const meta: Meta = { + title: 'UI/Modal/AppModal', + component: AppModalNew, + argTypes: { + open: { + control: 'boolean', + description: 'Controls whether the modal is open or closed' + }, + onClose: { + action: 'closed', + description: 'Function called when the modal is closed' + }, + width: { + control: { type: 'number', min: 300, max: 1200, step: 50 }, + description: 'Width of the modal in pixels' + }, + header: { + description: 'Header configuration with title and optional description' + }, + footer: { + description: 'Footer configuration with primary and optional secondary buttons' + } + }, + parameters: { + layout: 'centered' + } +}; + +export default meta; +type Story = StoryObj; + +// Helper component to control modal state in Storybook +const ModalContainer = (args: ModalProps) => { + const [isOpen, setIsOpen] = React.useState(true); + + const handleOpen = () => setIsOpen(true); + const handleClose = () => setIsOpen(false); + + const modalProps = { + ...args, + open: isOpen, + onClose: handleClose, + footer: { + ...args.footer, + primaryButton: { + ...args.footer.primaryButton, + onClick: () => { + args.footer.primaryButton.onClick?.(); + handleClose(); + } + }, + secondaryButton: args.footer.secondaryButton + ? { + ...args.footer.secondaryButton, + onClick: () => { + args.footer.secondaryButton?.onClick?.(); + handleClose(); + } + } + : undefined + } + }; + + return ( +
+ + +
+ ); +}; + +export const Default: Story = { + render: (args) => , + args: { + header: { + title: 'Modal Title', + description: 'This is a description of the modal' + }, + footer: { + primaryButton: { + text: 'Confirm', + onClick: () => console.log('Primary button clicked') + }, + secondaryButton: { + text: 'Cancel', + onClick: () => console.log('Secondary button clicked') + } + }, + width: 600, + children: ( +
+

This is the content of the modal.

+

You can put any React components here.

+
+ ) + } +}; + +export const WithoutDescription: Story = { + render: (args) => , + args: { + header: { + title: 'Simple Modal' + }, + footer: { + primaryButton: { + text: 'OK', + onClick: () => console.log('OK clicked') + } + }, + width: 500, + children: ( +
+

A modal without a description and secondary button.

+
+ ) + } +}; + +export const LoadingState: Story = { + render: (args) => , + args: { + header: { + title: 'Processing Data', + description: 'Please wait while we process your request' + }, + footer: { + primaryButton: { + text: 'Submit', + onClick: () => console.log('Submit clicked'), + loading: true + }, + secondaryButton: { + text: 'Cancel', + onClick: () => console.log('Cancel clicked'), + disabled: true + } + }, + width: 550, + children: ( +
+

This modal shows loading and disabled states for buttons.

+
+ ) + } +}; + +export const CustomWidth: Story = { + render: (args) => , + args: { + header: { + title: 'Wide Modal', + description: 'This modal has a custom width' + }, + footer: { + primaryButton: { + text: 'Save', + onClick: () => console.log('Save clicked') + }, + secondaryButton: { + text: 'Discard', + onClick: () => console.log('Discard clicked') + } + }, + width: 300, + children: ( +
+

This is a wider modal that can be used for displaying more content.

+
+
Column 1 content
+
Column 2 content
+
+
+ ) + } +}; + +export const WithCustomFooterLeft: Story = { + render: (args) => , + args: { + header: { + title: 'Custom Footer', + description: 'This modal has custom content in the left side of the footer' + }, + footer: { + left: Additional footer information, + primaryButton: { + text: 'Continue', + onClick: () => console.log('Continue clicked') + }, + secondaryButton: { + text: 'Back', + onClick: () => console.log('Back clicked') + } + }, + width: 600, + children: ( +
+

Notice the additional text on the left side of the footer.

+
+ ) + } +}; diff --git a/web/src/components/ui/modal/Modal.tsx b/web/src/components/ui/modal/Modal.tsx new file mode 100644 index 000000000..398f563eb --- /dev/null +++ b/web/src/components/ui/modal/Modal.tsx @@ -0,0 +1,105 @@ +import React, { useMemo } from 'react'; +import { type ButtonProps, Button } from '../buttons/Button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from './ModalBase'; +import { useMemoizedFn } from 'ahooks'; +import { cn } from '@/lib/classMerge'; + +export interface ModalProps { + className?: string; + style?: React.CSSProperties; + open: boolean; + onClose: () => void; + footer: { + left?: React.ReactNode; + primaryButton: { + text: string; + onClick: () => void; + variant?: ButtonProps['variant']; + loading?: boolean; + disabled?: boolean; + }; + secondaryButton?: { + text: string; + onClick: () => void; + variant?: ButtonProps['variant']; + loading?: boolean; + disabled?: boolean; + }; + }; + header: { + title: string; + description?: string; + }; + width?: number; + children: React.ReactNode; +} + +export const AppModalNew: React.FC = React.memo( + ({ open, onClose, footer, header, width = 600, className, style, children }) => { + const onOpenChange = useMemoizedFn((open: boolean) => { + if (!open) { + onClose(); + } + }); + + const memoizedStyle = useMemo( + () => ({ + minWidth: width ?? 600, + maxWidth: width ?? 600, + ...style + }), + [width, style] + ); + + return ( + + +
+ {header && ( + + {header.title && {header.title}} + {header.description && {header.description}} + + )} + + {children} +
+ + {footer && ( + + {footer.left && footer.left} +
+ {footer.secondaryButton && ( + + )} + +
+
+ )} +
+
+ ); + } +); + +AppModalNew.displayName = 'AppModal'; diff --git a/web/src/components/ui/modal/ModalBase.tsx b/web/src/components/ui/modal/ModalBase.tsx new file mode 100644 index 000000000..635dc4183 --- /dev/null +++ b/web/src/components/ui/modal/ModalBase.tsx @@ -0,0 +1,108 @@ +'use client'; + +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { Xmark } from '@/components/ui/icons'; + +import { cn } from '@/lib/utils'; +import { Button } from '../buttons'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.memo( + React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef + >(({ className, children, ...props }, ref) => ( + + + + {children} + +