make tailwind modla

This commit is contained in:
Nate Kelley 2025-03-01 14:45:38 -07:00
parent f4c0054301
commit ad3709f924
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
5 changed files with 459 additions and 1 deletions

38
web/package-lock.json generated
View File

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

View File

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

View File

@ -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<typeof AppModalNew> = {
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<typeof AppModalNew>;
// 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 (
<div>
<Button onClick={handleOpen}>Open Modal</Button>
<AppModalNew {...modalProps} />
</div>
);
};
export const Default: Story = {
render: (args) => <ModalContainer {...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: (
<div className="">
<p>This is the content of the modal.</p>
<p className="mt-2">You can put any React components here.</p>
</div>
)
}
};
export const WithoutDescription: Story = {
render: (args) => <ModalContainer {...args} />,
args: {
header: {
title: 'Simple Modal'
},
footer: {
primaryButton: {
text: 'OK',
onClick: () => console.log('OK clicked')
}
},
width: 500,
children: (
<div className="">
<p>A modal without a description and secondary button.</p>
</div>
)
}
};
export const LoadingState: Story = {
render: (args) => <ModalContainer {...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: (
<div className="">
<p>This modal shows loading and disabled states for buttons.</p>
</div>
)
}
};
export const CustomWidth: Story = {
render: (args) => <ModalContainer {...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: (
<div className="">
<p>This is a wider modal that can be used for displaying more content.</p>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="rounded border p-4">Column 1 content</div>
<div className="rounded border p-4">Column 2 content</div>
</div>
</div>
)
}
};
export const WithCustomFooterLeft: Story = {
render: (args) => <ModalContainer {...args} />,
args: {
header: {
title: 'Custom Footer',
description: 'This modal has custom content in the left side of the footer'
},
footer: {
left: <span className="text-sm text-gray-500">Additional footer information</span>,
primaryButton: {
text: 'Continue',
onClick: () => console.log('Continue clicked')
},
secondaryButton: {
text: 'Back',
onClick: () => console.log('Back clicked')
}
},
width: 600,
children: (
<div className="">
<p>Notice the additional text on the left side of the footer.</p>
</div>
)
}
};

View File

@ -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<ModalProps> = 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={className} style={memoizedStyle}>
<div className="flex flex-col gap-4 p-6">
{header && (
<DialogHeader>
{header.title && <DialogTitle>{header.title}</DialogTitle>}
{header.description && <DialogDescription>{header.description}</DialogDescription>}
</DialogHeader>
)}
{children}
</div>
{footer && (
<DialogFooter
className={cn('flex items-center', footer.left ? 'justify-between' : 'justify-end')}>
{footer.left && footer.left}
<div className={cn('flex items-center space-x-2')}>
{footer.secondaryButton && (
<Button
onClick={footer.secondaryButton.onClick}
variant={footer.secondaryButton.variant ?? 'ghost'}
loading={footer.secondaryButton.loading}
disabled={footer.secondaryButton.disabled}>
{footer.secondaryButton.text}
</Button>
)}
<Button
onClick={footer.primaryButton.onClick}
variant={footer.primaryButton.variant ?? 'black'}
loading={footer.primaryButton.loading}
disabled={footer.primaryButton.disabled}>
{footer.primaryButton.text}
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
);
AppModalNew.displayName = 'AppModal';

View File

@ -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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.memo(
React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-[20%] data-[state=open]:slide-in-from-top-[20%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
'bg-background rounded',
'border shadow-lg duration-200',
className
)}
{...props}>
{children}
<DialogPrimitive.Close
className={cn(
'absolute top-4 right-4 opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none'
)}>
<Button prefix={<Xmark />} variant="ghost" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('space-x-2 border-t px-6 py-2.5', className)} {...props} />
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-text-default text-xl leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-text-secondary text-md', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
};