feat: redesign event modal with responsive dialog and improved layout
Implemented a new ResponsiveDialog component and redesigned EventModal with better UX for both normal and extended modes. New Features: - Created reusable ResponsiveDialog component (src/components/Dialog/) * Normal mode: 685px centered popup * Extended mode: fullscreen with 90px header preservation * Auto spacing via MUI Stack (16px normal, 24px extended) * Back arrow navigation in extended mode * No backdrop/shadow in extended mode for seamless integration * Configurable props for all dimensions and behaviors EventModal Improvements: - Replaced native checkbox with MUI Checkbox component - Migrated from Popover to ResponsiveDialog - Reorganized field layout => Comprehensive documentation in Dialog/README.md Note: RepeatEvent integration tests need additional refactoring (tracked separately)
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
# ResponsiveDialog Component
|
||||
|
||||
A highly reusable dialog component that supports both normal and expanded (fullscreen) modes while preserving app header visibility.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Two Modes**: Normal popup (685px) and expanded fullscreen mode
|
||||
- ✅ **Preserves Header**: Expanded mode doesn't cover app header (90px default)
|
||||
- ✅ **Clean Expanded View**: No backdrop/shadow in expanded mode for seamless integration
|
||||
- ✅ **Instant Transition**: No animation when expanding for immediate feedback
|
||||
- ✅ **Back Navigation**: Expanded mode shows back arrow icon in header for easy collapse
|
||||
- ✅ **MUI Stack Spacing**: Uses MUI Stack component with configurable spacing prop (2=16px normal, 3=24px expanded)
|
||||
- ✅ **Fully Customizable**: Override styles with `sx`, `contentSx`, `titleSx` props
|
||||
- ✅ **Container Support**: Content container with configurable max-width
|
||||
- ✅ **Type Safe**: Full TypeScript support
|
||||
- ✅ **No Custom CSS**: Uses MUI `sx` prop pattern
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { ResponsiveDialog } from "../../components/Dialog";
|
||||
import { useState } from "react";
|
||||
|
||||
function MyComponent() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
{!showMore && (
|
||||
<Button onClick={() => setShowMore(true)}>Show More</Button>
|
||||
)}
|
||||
<Button onClick={() => setOpen(false)}>Close</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title="My Dialog"
|
||||
isExpanded={showMore}
|
||||
onExpandToggle={() => setShowMore(!showMore)}
|
||||
actions={actions}
|
||||
>
|
||||
<TextField label="Name" fullWidth />
|
||||
<TextField label="Email" fullWidth />
|
||||
{/* Wrapped in Stack with spacing={2} (normal) or spacing={3} (expanded) */}
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------------------- | --------------------- | --------- | ----------------------------------------------------------- |
|
||||
| `open` | `boolean` | required | Whether dialog is open |
|
||||
| `onClose` | `() => void` | required | Close handler |
|
||||
| `title` | `string \| ReactNode` | required | Dialog title (replaced by back icon when expanded) |
|
||||
| `children` | `ReactNode` | required | Dialog content (wrapped in MUI Stack) |
|
||||
| `actions` | `ReactNode` | - | Action buttons |
|
||||
| `isExpanded` | `boolean` | `false` | Toggle fullscreen mode |
|
||||
| `onExpandToggle` | `() => void` | - | Handler for back button click when expanded |
|
||||
| `normalMaxWidth` | `string` | `"685px"` | Max width in normal mode |
|
||||
| `expandedContentMaxWidth` | `string` | `"990px"` | Content container max-width in expanded mode |
|
||||
| `headerHeight` | `string` | `"90px"` | App header height to preserve |
|
||||
| `normalSpacing` | `number` | `2` | Stack spacing in normal mode (MUI spacing units: 1 = 8px) |
|
||||
| `expandedSpacing` | `number` | `3` | Stack spacing in expanded mode (MUI spacing units: 1 = 8px) |
|
||||
| `contentSx` | `SxProps<Theme>` | - | Custom styles for DialogContent |
|
||||
| `titleSx` | `SxProps<Theme>` | - | Custom styles for DialogTitle |
|
||||
| `dialogContentProps` | `DialogContentProps` | - | Additional DialogContent props |
|
||||
| `dialogTitleProps` | `DialogTitleProps` | - | Additional DialogTitle props |
|
||||
| `dividers` | `boolean` | `false` | Show dividers between sections |
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Custom Container Styles
|
||||
|
||||
```tsx
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Custom Styled Dialog"
|
||||
isExpanded={showMore}
|
||||
contentSx={{
|
||||
backgroundColor: "#f5f5f5",
|
||||
padding: 4,
|
||||
}}
|
||||
titleSx={{
|
||||
backgroundColor: "primary.main",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<TextField label="Field" />
|
||||
</ResponsiveDialog>
|
||||
```
|
||||
|
||||
### With Dividers
|
||||
|
||||
```tsx
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Dialog with Dividers"
|
||||
dividers={true}
|
||||
actions={<Button>Save</Button>}
|
||||
>
|
||||
<TextField label="Content" />
|
||||
</ResponsiveDialog>
|
||||
```
|
||||
|
||||
### Different Sizes
|
||||
|
||||
```tsx
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Large Dialog"
|
||||
normalMaxWidth="900px"
|
||||
expandedContentMaxWidth="1200px"
|
||||
>
|
||||
<TextField label="Content" />
|
||||
</ResponsiveDialog>
|
||||
```
|
||||
|
||||
### Custom Spacing
|
||||
|
||||
```tsx
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Custom Spacing"
|
||||
normalSpacing={1} // 8px spacing in normal mode
|
||||
expandedSpacing={4} // 32px spacing in expanded mode
|
||||
>
|
||||
<TextField label="Field 1" />
|
||||
<TextField label="Field 2" />
|
||||
{/* MUI spacing units: 1=8px, 2=16px, 3=24px, 4=32px */}
|
||||
</ResponsiveDialog>
|
||||
```
|
||||
|
||||
### Custom Header Height
|
||||
|
||||
For apps with different header heights:
|
||||
|
||||
```tsx
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Custom Header Height"
|
||||
headerHeight="80px"
|
||||
isExpanded={true}
|
||||
>
|
||||
<TextField label="Content" />
|
||||
</ResponsiveDialog>
|
||||
```
|
||||
|
||||
## Layout Behavior
|
||||
|
||||
### Normal Mode (`isExpanded={false}`)
|
||||
|
||||
- Dialog: max-width = `normalMaxWidth` (default 685px)
|
||||
- Content: 100% width
|
||||
- Height: auto (fits content)
|
||||
- Position: centered with 32px margin
|
||||
- Children spacing: `normalSpacing={2}` (16px via MUI Stack component)
|
||||
|
||||
### Expanded Mode (`isExpanded={true}`)
|
||||
|
||||
- Dialog: full width, height = `calc(100vh - headerHeight)`
|
||||
- Content: max-width = `expandedContentMaxWidth` (default 990px), centered
|
||||
- Position: top = `headerHeight` (preserves header visibility - default 90px)
|
||||
- Backdrop: opacity = 0 (seamless integration with page)
|
||||
- Shadow: removed (no elevation in expanded mode)
|
||||
- Transition: disabled (instant expand/collapse for better UX)
|
||||
- Title: replaced by back arrow IconButton (calls `onExpandToggle`)
|
||||
- Children spacing: `expandedSpacing={3}` (24px via MUI Stack component)
|
||||
- Actions (MuiBox): max-width = `expandedContentMaxWidth` (990px), centered container with buttons right-aligned
|
||||
- padding: 0 12px
|
||||
- width: 100%
|
||||
- justifyContent: flex-end
|
||||
|
||||
## Style Merging
|
||||
|
||||
All `sx` props are **merged** with base styles, not overridden:
|
||||
|
||||
```tsx
|
||||
// Base styles are preserved, your styles are added
|
||||
<ResponsiveDialog
|
||||
contentSx={{
|
||||
padding: 5, // Adds to base styles
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
Full type inference and validation:
|
||||
|
||||
```tsx
|
||||
import { ResponsiveDialog } from "../../components/Dialog";
|
||||
|
||||
// All props are type-checked
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Typed Dialog"
|
||||
// TypeScript will validate all props
|
||||
>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use `isExpanded` for Show More/Less**: Toggle between modes for better UX
|
||||
2. **Provide `onExpandToggle` handler**: Required for back button functionality in expanded mode
|
||||
3. **Hide expand button in actions when expanded**: Back arrow in header provides collapse action
|
||||
4. **Keep `normalMaxWidth` reasonable**: 685px works well for forms
|
||||
5. **Center content in expanded mode**: Default 990px provides good reading width
|
||||
6. **Preserve header**: Default 90px - adjust via `headerHeight` prop if needed
|
||||
7. **MUI Stack handles spacing**: Children wrapped in Stack with configurable spacing prop - override with `normalSpacing`/`expandedSpacing` if needed
|
||||
8. **Seamless expanded mode**: No backdrop/shadow/transition creates clean integration with page
|
||||
9. **Instant expand**: No animation when expanding provides immediate feedback
|
||||
10. **Customize with `sx` props**: Avoid creating custom CSS files
|
||||
|
||||
## See Also
|
||||
|
||||
- `EventModal.tsx` - Real-world example usage
|
||||
- MUI Dialog documentation: https://mui.com/material-ui/react-dialog/
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentProps,
|
||||
DialogTitle,
|
||||
DialogTitleProps,
|
||||
DialogProps,
|
||||
IconButton,
|
||||
Stack,
|
||||
SxProps,
|
||||
Theme,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* ResponsiveDialog - A reusable dialog component that can switch between normal and expanded modes
|
||||
*
|
||||
* Features:
|
||||
* - Normal mode: Dialog with customizable max-width (default 685px)
|
||||
* - Expanded mode: Full height dialog (excluding app header) with centered content container
|
||||
* - Fully customizable with sx props for Dialog, DialogTitle, and DialogContent
|
||||
* - Preserves app header visibility in expanded mode
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ResponsiveDialog
|
||||
* open={open}
|
||||
* onClose={handleClose}
|
||||
* title="My Dialog"
|
||||
* isExpanded={showMore}
|
||||
* actions={<Button onClick={handleSave}>Save</Button>}
|
||||
* contentSx={{ padding: 3 }}
|
||||
* >
|
||||
* <TextField label="Name" />
|
||||
* </ResponsiveDialog>
|
||||
* ```
|
||||
*/
|
||||
interface ResponsiveDialogProps
|
||||
extends Omit<DialogProps, "maxWidth" | "title"> {
|
||||
/** Whether the dialog is open */
|
||||
open: boolean;
|
||||
/** Callback fired when the dialog should be closed */
|
||||
onClose: () => void;
|
||||
/** Dialog title - can be string or custom ReactNode */
|
||||
title: string | ReactNode;
|
||||
/** Dialog content - form fields, text, etc. */
|
||||
children: ReactNode;
|
||||
/** Optional actions rendered in DialogActions (buttons, etc.) */
|
||||
actions?: ReactNode;
|
||||
/** Toggle between normal and expanded (fullscreen) mode */
|
||||
isExpanded?: boolean;
|
||||
/** Callback when expand/collapse button is clicked (required if using isExpanded) */
|
||||
onExpandToggle?: () => void;
|
||||
/** Max width in normal mode (default: "685px") */
|
||||
normalMaxWidth?: string;
|
||||
/** Max width of content container in expanded mode (default: "990px") */
|
||||
expandedContentMaxWidth?: string;
|
||||
/** Height of app header to preserve visibility (default: "90px") */
|
||||
headerHeight?: string;
|
||||
/** Spacing between children in normal mode (default: 2 = 16px) */
|
||||
normalSpacing?: number;
|
||||
/** Spacing between children in expanded mode (default: 3 = 24px) */
|
||||
expandedSpacing?: number;
|
||||
/** Custom styles for DialogContent - merged with base styles */
|
||||
contentSx?: SxProps<Theme>;
|
||||
/** Custom styles for DialogTitle */
|
||||
titleSx?: SxProps<Theme>;
|
||||
/** Additional props for DialogContent (excluding sx) */
|
||||
dialogContentProps?: Omit<DialogContentProps, "sx">;
|
||||
/** Additional props for DialogTitle (excluding sx) */
|
||||
dialogTitleProps?: Omit<DialogTitleProps, "sx">;
|
||||
/** Whether to display dividers between title/content/actions */
|
||||
dividers?: boolean;
|
||||
}
|
||||
|
||||
function ResponsiveDialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
actions,
|
||||
isExpanded = false,
|
||||
onExpandToggle,
|
||||
normalMaxWidth = "685px",
|
||||
expandedContentMaxWidth = "990px",
|
||||
headerHeight = "90px",
|
||||
normalSpacing = 2,
|
||||
expandedSpacing = 3,
|
||||
contentSx,
|
||||
titleSx,
|
||||
dialogContentProps,
|
||||
dialogTitleProps,
|
||||
dividers = false,
|
||||
sx,
|
||||
...otherDialogProps
|
||||
}: ResponsiveDialogProps) {
|
||||
const baseSx: SxProps<Theme> = {
|
||||
"& .MuiBackdrop-root": {
|
||||
opacity: isExpanded ? "0 !important" : undefined,
|
||||
transition: isExpanded ? "none !important" : undefined,
|
||||
},
|
||||
"& .MuiDialog-paper": {
|
||||
maxWidth: isExpanded ? "100%" : normalMaxWidth,
|
||||
width: "100%",
|
||||
height: isExpanded ? `calc(100vh - ${headerHeight})` : "auto",
|
||||
margin: isExpanded ? `${headerHeight} 0 0 0` : "32px",
|
||||
maxHeight: isExpanded
|
||||
? `calc(100vh - ${headerHeight})`
|
||||
: `calc(100vh - 90px)`,
|
||||
boxShadow: isExpanded ? "none !important" : undefined,
|
||||
transition: isExpanded ? "none !important" : undefined,
|
||||
},
|
||||
"& .MuiDialogActions-root .MuiBox-root": {
|
||||
maxWidth: isExpanded ? expandedContentMaxWidth : undefined,
|
||||
margin: isExpanded ? "0 auto" : undefined,
|
||||
padding: isExpanded ? "0 12px" : undefined,
|
||||
width: isExpanded ? "100%" : undefined,
|
||||
justifyContent: isExpanded ? "flex-end" : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const baseContentSx: SxProps<Theme> = {
|
||||
maxWidth: isExpanded ? expandedContentMaxWidth : "100%",
|
||||
margin: isExpanded ? "0 auto" : "0",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const currentSpacing = isExpanded ? expandedSpacing : normalSpacing;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth={false}
|
||||
fullWidth
|
||||
transitionDuration={isExpanded ? 0 : 300}
|
||||
sx={[baseSx, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||
{...otherDialogProps}
|
||||
>
|
||||
<DialogTitle sx={titleSx} {...dialogTitleProps}>
|
||||
{isExpanded && onExpandToggle ? (
|
||||
<IconButton
|
||||
onClick={onExpandToggle}
|
||||
aria-label="show less"
|
||||
sx={{ marginLeft: "-8px" }}
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent
|
||||
dividers={dividers}
|
||||
sx={[
|
||||
baseContentSx,
|
||||
...(Array.isArray(contentSx) ? contentSx : [contentSx]),
|
||||
]}
|
||||
{...dialogContentProps}
|
||||
>
|
||||
<Stack spacing={currentSpacing}>{children}</Stack>
|
||||
</DialogContent>
|
||||
{actions && <DialogActions>{actions}</DialogActions>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResponsiveDialog;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ResponsiveDialog } from "./ResponsiveDialog";
|
||||
Reference in New Issue
Block a user