feat(frontend): add dialog component (#10254)

## Changes 🏗️

### Overview

Introduces a new responsive `<Dialog />` component that automatically
adapts to screen size, providing optimal UX across devices.

<img width="800" alt="Screenshot 2025-06-27 at 16 00 01"
src="https://github.com/user-attachments/assets/d0c53b30-488f-4102-8100-c9318168d65b"
/>

<img width="300" alt="Screenshot 2025-06-27 at 16 00 12"
src="https://github.com/user-attachments/assets/f2105708-97d9-4a94-8e26-3c2d582ea8cd"
/>

### Key Features

#### 📱 **Responsive Behavior**
- **Desktop**: Modal dialog with overlay
- **Mobile**: Bottom drawer [Vaul](https://vaul.emilkowal.ski/) with
**swipe-to-dismiss** functionality

#### 🎯 **Multiple Interaction Methods**
- `ESC` key to close (both desktop & mobile)
- Click outside to dismiss
- Swipe down to dismiss (mobile drawer)
- Close button (X)

####  Why I did not use `shadcn/dialog` in this case as a base

While we already use the raw `shadcn/dialog` on the platform, it's
designed as a desktop-only solution and is not really
responsive-friendly. It lacks 📱 mobile-optimisation patterns like
_bottom drawers_, _swipe-to-dismiss gestures_ ( the new implementation
has it via [Vaul](https://vaul.emilkowal.ski/) ), and automatic
breakpoint adaptation according to screen size.

#### 🧩 **Compound Component Pattern**
```tsx
<Dialog title="Example">
  <Dialog.Trigger>
    <Button>Open Dialog</Button>
  </Dialog.Trigger>
  <Dialog.Content>
    Content goes here
  </Dialog.Content>
</Dialog>
```

#### ⚙️ **Flexible Control**
- **Uncontrolled**: Self-managed state via triggers
- **Controlled**: External state management
- **Force open**: rare but might be needed
- **Custom styling**: if needed

## Checklist 📋

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] **Desktop Modal**: Opens/closes via trigger, ESC key, click
outside, close button
- [x] **Mobile Drawer**: Automatically switches at `lg` breakpoint,
swipe-to-dismiss works
- [x] **Controlled Mode**: External state management functions correctly
  - [x] **Force Open**: Dialog stays open for preview purposes
  - [x] **Custom Styling**: CSS-in-JS overrides work as expected
- [x] **Footer Component**: Action buttons render and function properly
  - [x] **No Title Mode**: Dialog works without title prop
- [x] **Accessibility**: Tab navigation, screen reader announcements,
ARIA compliance
- [x] **Responsive Breakpoints**: Component switches modes at correct
screen sizes
  - [x] **Storybook**: All stories render and function correctly

---------

Co-authored-by: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com>
This commit is contained in:
Ubbe
2025-06-27 21:08:03 +04:00
committed by GitHub
parent 4d0db27d5e
commit 2dd366172e
14 changed files with 643 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ const config: StorybookConfig = {
"../src/components/overview.stories.@(js|jsx|mjs|ts|tsx)",
"../src/components/tokens/**/*.stories.@(js|jsx|mjs|ts|tsx)",
"../src/components/atoms/**/*.stories.@(js|jsx|mjs|ts|tsx)",
"../src/components/molecules/**/*.stories.@(js|jsx|mjs|ts|tsx)",
"../src/components/agptui/**/*.stories.@(js|jsx|mjs|ts|tsx)",
],
addons: [

View File

@@ -89,6 +89,7 @@
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7",
"uuid": "11.1.0",
"vaul": "1.1.2",
"zod": "3.25.56"
},
"devDependencies": {

View File

@@ -200,6 +200,9 @@ importers:
uuid:
specifier: 11.1.0
version: 11.1.0
vaul:
specifier: 1.1.2
version: 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
zod:
specifier: 3.25.56
version: 3.25.56
@@ -6866,6 +6869,12 @@ packages:
resolution: {integrity: sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==}
engines: {node: '>= 0.10'}
vaul@1.1.2:
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
vfile-message@4.0.2:
resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==}
@@ -14701,6 +14710,15 @@ snapshots:
validator@13.15.15: {}
vaul@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@radix-ui/react-dialog': 1.1.14(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
vfile-message@4.0.2:
dependencies:
'@types/unist': 3.0.3

View File

@@ -0,0 +1,197 @@
import { Button } from "@/components/atoms/Button/Button";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import { Dialog } from "./Dialog";
const meta: Meta<typeof Dialog> = {
title: "Molecules/Dialog",
component: Dialog,
parameters: {
layout: "centered",
docs: {
description: {
component:
"A responsive dialog component that automatically switches between modal dialog (desktop) and drawer (mobile). Built on top of Radix UI Dialog and Vaul drawer with custom styling. Supports compound components: Dialog.Trigger, Dialog.Content, and Dialog.Footer.",
},
},
},
argTypes: {
title: {
control: "text",
description: "Dialog title - can be string or React node",
},
forceOpen: {
control: "boolean",
description: "Force the dialog to stay open (useful for previewing)",
},
styling: {
control: "object",
description: "Custom CSS styles object",
},
onClose: {
action: "closed",
description: "Callback fired when dialog closes",
},
},
args: {
title: "Dialog Title",
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
render: renderBasicDialog,
};
export const WithoutTitle: Story = {
render: renderDialogWithoutTitle,
};
export const ForceOpen: Story = {
args: {
forceOpen: true,
title: "Preview Dialog",
},
render: renderForceOpenDialog,
};
export const WithFooter: Story = {
render: renderDialogWithFooter,
};
export const Controlled: Story = {
render: renderControlledDialog,
};
export const CustomStyling: Story = {
render: renderCustomStyledDialog,
};
function renderBasicDialog() {
return (
<Dialog title="Basic Dialog">
<Dialog.Trigger>
<Button variant="primary">Open Dialog</Button>
</Dialog.Trigger>
<Dialog.Content>
<p>This is a basic dialog with some content.</p>
<p>
It automatically adapts to screen size - modal on desktop, drawer on
mobile.
</p>
</Dialog.Content>
</Dialog>
);
}
function renderDialogWithoutTitle() {
return (
<Dialog>
<Dialog.Trigger>
<Button variant="primary">Open Dialog (no title)</Button>
</Dialog.Trigger>
<Dialog.Content>
<p className="flex min-h-[100px] flex-row items-center justify-center">
This dialog doesn&apos;t use the title prop, allowing for no header or
a custom header.
</p>
</Dialog.Content>
</Dialog>
);
}
function renderForceOpenDialog(args: any) {
return (
<Dialog {...args}>
<Dialog.Content>
<p>This dialog is forced open for preview purposes.</p>
<p>
In real usage, you&apos;d typically use a trigger or controlled state.
</p>
</Dialog.Content>
</Dialog>
);
}
function renderDialogWithFooter() {
return (
<Dialog title="Dialog with Footer">
<Dialog.Trigger>
<Button variant="primary">Open Dialog with Footer</Button>
</Dialog.Trigger>
<Dialog.Content>
<p>This dialog includes a footer with action buttons.</p>
<p>Use the footer for primary and secondary actions.</p>
<Dialog.Footer>
<Button variant="ghost" size="small">
Cancel
</Button>
<Button variant="primary" size="small">
Confirm
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}
function renderControlledDialog() {
const [isOpen, setIsOpen] = useState(false);
const handleToggle = async () => {
setIsOpen(!isOpen);
};
return (
<div className="space-y-4">
<Button variant="primary" onClick={handleToggle}>
{isOpen ? "Close" : "Open"} Controlled Dialog
</Button>
<Dialog
title="Controlled Dialog"
controlled={{
isOpen,
set: setIsOpen,
}}
onClose={() => console.log("Dialog closed")}
>
<Dialog.Content>
<div className="flex flex-col gap-4">
<p>This dialog is controlled by external state.</p>
<p>
Open state:{" "}
<span className="font-bold">{isOpen ? "Open" : "Closed"}</span>
</p>
</div>
<Button onClick={handleToggle} className="mt-8" size="small">
Close this modal
</Button>
</Dialog.Content>
</Dialog>
</div>
);
}
function renderCustomStyledDialog() {
return (
<Dialog
title="Custom Styled Dialog"
styling={{
maxWidth: "800px",
backgroundColor: "rgb(248 250 252)",
border: "2px solid rgb(59 130 246)",
}}
>
<Dialog.Trigger>
<Button variant="primary">Open Custom Styled Dialog</Button>
</Dialog.Trigger>
<Dialog.Content>
<p>This dialog has custom styling applied.</p>
<p>You can customize dimensions, colors, and other CSS properties.</p>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import * as RXDialog from "@radix-ui/react-dialog";
import { CSSProperties, PropsWithChildren } from "react";
import { Drawer } from "vaul";
import { BaseContent } from "./components/BaseContent";
import { BaseFooter } from "./components/BaseFooter";
import { BaseTrigger } from "./components/BaseTrigger";
import { DialogCtx, useDialogCtx } from "./useDialogCtx";
import { useDialogInternal } from "./useDialogInternal";
interface Props extends PropsWithChildren {
title?: React.ReactNode;
styling?: CSSProperties;
forceOpen?: boolean;
onClose?: (() => void) | undefined;
controlled?: {
isOpen: boolean;
set: (open: boolean) => Promise<void> | void;
};
}
Dialog.Trigger = BaseTrigger;
Dialog.Content = BaseContent;
Dialog.Footer = BaseFooter;
function Dialog({
children,
title,
styling,
forceOpen = false,
onClose,
controlled,
}: Props) {
const config = useDialogInternal({ controlled });
const isOpen = forceOpen || config.isOpen;
return (
<DialogCtx.Provider
value={{
title: title || "",
styling,
isOpen,
isForceOpen: forceOpen,
isLargeScreen: config.isLgScreenUp,
handleOpen: config.handleOpen,
handleClose: async () => {
await config.handleClose();
onClose?.();
},
}}
>
{config.isLgScreenUp ? (
<RXDialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
config.handleClose();
onClose?.();
}
}}
>
{children}
</RXDialog.Root>
) : (
<Drawer.Root
shouldScaleBackground
open={isOpen}
onOpenChange={(open) => {
if (!open) {
config.handleClose();
onClose?.();
}
}}
>
{children}
</Drawer.Root>
)}
</DialogCtx.Provider>
);
}
export { Dialog, useDialogCtx };

View File

@@ -0,0 +1,17 @@
import { PropsWithChildren } from "react";
import { useDialogCtx } from "../useDialogCtx";
import { DialogWrap } from "./DialogWrap";
import { DrawerWrap } from "./DrawerWrap";
type Props = PropsWithChildren;
export function BaseContent({ children }: Props) {
const ctx = useDialogCtx();
return ctx.isLargeScreen ? (
<DialogWrap {...ctx}>{children}</DialogWrap>
) : (
<DrawerWrap {...ctx}>{children}</DrawerWrap>
);
}

View File

@@ -0,0 +1,31 @@
import { useDialogCtx } from "../useDialogCtx";
interface Props {
children: React.ReactNode;
testId?: string;
className?: string;
}
export function BaseFooter({
children,
testId = "modal-footer",
className = "",
}: Props) {
const ctx = useDialogCtx();
return ctx.isLargeScreen ? (
<div
className={`flex justify-end gap-4 pt-6 ${className}`}
data-testid={testId}
>
{children}
</div>
) : (
<div
className={`flex w-full items-end justify-between gap-4 pt-6 ${className}`}
data-testid={testId}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import React, { PropsWithChildren } from "react";
import { useDialogCtx } from "../useDialogCtx";
export function BaseTrigger({ children }: PropsWithChildren) {
const ctx = useDialogCtx();
return React.cloneElement(children as React.ReactElement, {
onClick: ctx.handleOpen,
className: "cursor-pointer",
});
}

View File

@@ -0,0 +1,66 @@
import { cn } from "@/lib/utils";
import { X } from "@phosphor-icons/react";
import * as RXDialog from "@radix-ui/react-dialog";
import { CSSProperties, PropsWithChildren } from "react";
import { DialogCtx } from "../useDialogCtx";
import { modalStyles } from "./styles";
type BaseProps = DialogCtx & PropsWithChildren;
interface Props extends BaseProps {
title: React.ReactNode;
styling: CSSProperties | undefined;
withGradient?: boolean;
}
export function DialogWrap({
children,
title,
styling = {},
isForceOpen,
handleClose,
}: Props) {
return (
<RXDialog.Portal>
<RXDialog.Overlay className={modalStyles.overlay} />
<RXDialog.Content
onInteractOutside={handleClose}
onEscapeKeyDown={handleClose}
aria-describedby={undefined}
className={cn(modalStyles.content)}
style={{
...styling,
}}
>
<div
className={`flex items-center justify-between ${
title ? "pb-6" : "pb-0"
}`}
>
{title ? (
<RXDialog.Title className={modalStyles.title}>
{title}
</RXDialog.Title>
) : (
<span className="sr-only">
{/* Title is required for a11y compliance even if not displayed so screen readers can announce it */}
<RXDialog.Title>{title}</RXDialog.Title>
</span>
)}
{isForceOpen && !handleClose ? null : (
<button
type="button"
onClick={handleClose}
aria-label="Close"
className={`${modalStyles.iconWrap} transition-colors duration-200 hover:bg-gray-200 dark:hover:bg-stone-900`}
>
<X className={modalStyles.icon} />
</button>
)}
</div>
<div className="overflow-y-auto">{children}</div>
</RXDialog.Content>
</RXDialog.Portal>
);
}

View File

@@ -0,0 +1,63 @@
import { Button } from "@/components/ui/button";
import { X } from "@phosphor-icons/react";
import { PropsWithChildren } from "react";
import { Drawer } from "vaul";
import { DialogCtx } from "../useDialogCtx";
import { drawerStyles, modalStyles } from "./styles";
type BaseProps = DialogCtx & PropsWithChildren;
interface Props extends BaseProps {
testId?: string;
title: React.ReactNode;
handleClose: () => void;
}
export function DrawerWrap({
children,
title,
testId,
handleClose,
isForceOpen,
}: Props) {
const closeBtn = (
<Button variant="link" aria-label="Close" onClick={handleClose}>
<X width="1.5rem" />
</Button>
);
return (
<Drawer.Portal>
<Drawer.Overlay className={drawerStyles.overlay} />
<Drawer.Content
aria-describedby={undefined}
className={drawerStyles.content}
data-testid={testId}
onInteractOutside={handleClose}
>
<div
className={`flex w-full items-center justify-between ${
title ? "pb-6" : "pb-0"
}`}
>
{title ? (
<Drawer.Title className={drawerStyles.title}>{title}</Drawer.Title>
) : null}
{!isForceOpen ? (
title ? (
closeBtn
) : (
<div
className={`${modalStyles.iconWrap} transition-colors duration-200 hover:bg-gray-200 dark:hover:bg-gray-700`}
>
{closeBtn}
</div>
)
) : null}
</div>
<div className="overflow-auto">{children}</div>
</Drawer.Content>
</Drawer.Portal>
);
}

View File

@@ -0,0 +1,23 @@
// Common styles as Tailwind class strings
const commonStyles = {
title: "font-poppins text-md md:text-lg leading-none",
overlay:
"fixed inset-0 z-50 bg-stone-500/20 dark:bg-black/50 backdrop-blur-md animate-fade-in",
content:
"bg-stone-100 dark:bg-stone-800 p-6 fixed rounded-xl flex flex-col z-50 w-full overflow-hidden",
};
// Modal specific styles
export const modalStyles = {
...commonStyles,
content: `${commonStyles.content} p-6 border border-stone-200 dark:border-stone-700 overflow-y-auto min-w-[40vw] max-w-[60vw] max-h-[95vh] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-fadein`,
iconWrap:
"absolute top-2 right-3 bg-transparent p-2 rounded-full transition-colors duration-300 ease-in-out outline-none border-none",
icon: "w-4 h-4 text-stone-800 dark:text-stone-300",
};
// Drawer specific styles
export const drawerStyles = {
...commonStyles,
content: `${commonStyles.content} max-h-[90vh] w-full bottom-0 rounded-br-none rounded-bl-none`,
};

View File

@@ -0,0 +1,27 @@
import { CSSProperties, createContext, useContext } from "react";
export function useDialogCtx() {
const modalContext = useContext(DialogCtx);
return modalContext;
}
export interface DialogCtx {
title: React.ReactNode;
handleOpen: () => void;
handleClose: () => void;
isOpen: boolean;
isForceOpen: boolean;
isLargeScreen: boolean;
styling: CSSProperties | undefined;
}
export const DialogCtx = createContext<DialogCtx>({
title: "",
isOpen: false,
isForceOpen: false,
isLargeScreen: true,
handleOpen: () => undefined,
handleClose: () => undefined,
styling: {},
});

View File

@@ -0,0 +1,51 @@
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { useEffect, useState } from "react";
type Args = {
controlled:
| {
isOpen: boolean;
set: (open: boolean) => Promise<void> | void;
}
| undefined;
};
export function useDialogInternal({ controlled }: Args) {
const [isOpen, setIsOpen] = useState(false);
const breakpoint = useBreakpoint();
const [isLgScreenUp, setIsLgScreenUp] = useState(isLargeScreen(breakpoint));
useEffect(() => {
setIsLgScreenUp(isLargeScreen(breakpoint));
}, [breakpoint]);
// if first opened as modal, or drawer - we need to keep it this way
// because, given the current implementation, we can't switch between modal and drawer without a full remount
async function handleOpen() {
setIsLgScreenUp(isLargeScreen(breakpoint));
if (controlled) {
await controlled.set(true);
} else {
setIsOpen(true);
}
}
async function handleClose() {
if (controlled) {
await controlled.set(false);
} else {
setIsOpen(false);
}
}
return {
isOpen: controlled ? controlled.isOpen : isOpen,
handleOpen,
handleClose,
isLgScreenUp,
};
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
export type Breakpoint = "base" | "sm" | "md" | "lg" | "xl" | "2xl";
// Explicitly maps to tailwind breakpoints
const breakpoints: Record<Breakpoint, number> = {
base: 0,
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
"2xl": 1536,
};
export function useBreakpoint(): Breakpoint {
const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg");
useEffect(() => {
const getBreakpoint = () => {
const width = window.innerWidth;
if (width < breakpoints.sm) return "base";
if (width < breakpoints.md) return "sm";
if (width < breakpoints.lg) return "md";
if (width < breakpoints.xl) return "lg";
if (width < breakpoints["2xl"]) return "xl";
return "2xl";
};
const handleResize = () => {
const current = getBreakpoint();
setBreakpoint(current);
};
window.addEventListener("resize", handleResize);
handleResize(); // initial call
return () => window.removeEventListener("resize", handleResize);
}, []);
return breakpoint;
}
export function isLargeScreen(bp: Breakpoint) {
if (bp === "sm") return false;
if (bp === "md") return false;
if (bp === "lg") return true;
if (bp === "xl") return true;
if (bp === "2xl") return true;
return false;
}