From 55a484923d94d7c95890bde53298b7bf43b1ce31 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 12 Mar 2026 16:41:08 +0530 Subject: [PATCH] feat: add AlertDialog component using Base UI primitive Co-Authored-By: Claude Opus 4.6 --- .../playground/alert-dialog-examples.tsx | 62 +++++ apps/www/src/components/playground/index.ts | 1 + .../docs/components/alert-dialog/demo.ts | 175 +++++++++++++ .../docs/components/alert-dialog/index.mdx | 123 +++++++++ .../docs/components/alert-dialog/props.ts | 84 ++++++ .../__tests__/alert-dialog.test.tsx | 244 ++++++++++++++++++ .../alert-dialog/alert-dialog-content.tsx | 68 +++++ .../alert-dialog/alert-dialog-misc.tsx | 101 ++++++++ .../components/alert-dialog/alert-dialog.tsx | 22 ++ .../raystack/components/alert-dialog/index.ts | 1 + packages/raystack/index.tsx | 1 + 11 files changed, 882 insertions(+) create mode 100644 apps/www/src/components/playground/alert-dialog-examples.tsx create mode 100644 apps/www/src/content/docs/components/alert-dialog/demo.ts create mode 100644 apps/www/src/content/docs/components/alert-dialog/index.mdx create mode 100644 apps/www/src/content/docs/components/alert-dialog/props.ts create mode 100644 packages/raystack/components/alert-dialog/__tests__/alert-dialog.test.tsx create mode 100644 packages/raystack/components/alert-dialog/alert-dialog-content.tsx create mode 100644 packages/raystack/components/alert-dialog/alert-dialog-misc.tsx create mode 100644 packages/raystack/components/alert-dialog/alert-dialog.tsx create mode 100644 packages/raystack/components/alert-dialog/index.ts diff --git a/apps/www/src/components/playground/alert-dialog-examples.tsx b/apps/www/src/components/playground/alert-dialog-examples.tsx new file mode 100644 index 000000000..0da146660 --- /dev/null +++ b/apps/www/src/components/playground/alert-dialog-examples.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { AlertDialog, Button, Flex } from '@raystack/apsara'; +import PlaygroundLayout from './playground-layout'; + +export function AlertDialogExamples() { + return ( + + + + }> + Delete Item + + + + Are you sure? + + + + This action cannot be undone. This will permanently delete the + item from our servers. + + + + + Cancel + + } + /> + + + + + + }> + Discard Changes + + + + Unsaved Changes + + You have unsaved changes. Do you want to discard them? + + + + + Continue Editing + + } + /> + + + + + + + ); +} diff --git a/apps/www/src/components/playground/index.ts b/apps/www/src/components/playground/index.ts index 62ec07833..e104b2a41 100644 --- a/apps/www/src/components/playground/index.ts +++ b/apps/www/src/components/playground/index.ts @@ -1,3 +1,4 @@ +export * from './alert-dialog-examples'; export * from './amount-examples'; export * from './announcement-bar-examples'; export * from './avatar-examples'; diff --git a/apps/www/src/content/docs/components/alert-dialog/demo.ts b/apps/www/src/content/docs/components/alert-dialog/demo.ts new file mode 100644 index 000000000..a0168438f --- /dev/null +++ b/apps/www/src/content/docs/components/alert-dialog/demo.ts @@ -0,0 +1,175 @@ +'use client'; + +export const getCode = (props: { title?: string; description?: string }) => { + const { title, description } = props; + return ` + + }> + Discard draft + + + + ${title} + + ${description} + + + + Cancel} /> + Discard} /> + + + `; +}; + +export const playground = { + type: 'playground', + controls: { + title: { type: 'text', initialValue: 'Discard draft?' }, + description: { + type: 'text', + initialValue: "You can't undo this action." + } + }, + getCode +}; + +export const controlledDemo = { + type: 'code', + code: ` + function ControlledAlertDialog() { + const [open, setOpen] = React.useState(false); + + return ( + + }> + Delete Account + + + + Delete Account + + + + Are you sure you want to delete your account? All of your data + will be permanently removed. This action cannot be undone. + + + + Cancel} /> + + + + + ); + }` +}; + +export const menuDemo = { + type: 'code', + code: ` + function MenuWithAlertDialog() { + const [dialogOpen, setDialogOpen] = React.useState(false); + + return ( + + + }> + Actions + + + Edit + Duplicate + + setDialogOpen(true)}> + Delete + + + + + + + + Delete item? + + This will permanently delete the item. You can't undo this action. + + + + Cancel} /> + + + + + + ); + }` +}; + +export const discardDemo = { + type: 'code', + code: ` + + }> + Discard Changes + + + + Unsaved Changes + + + + You have unsaved changes. Do you want to discard them or continue editing? + + + + Continue Editing} /> + Discard} /> + + + ` +}; + +export const nestedDemo = { + type: 'code', + code: ` + function NestedAlertDialogExample() { + return ( + + }> + Delete Workspace + + + + Delete Workspace + + + + This will delete the workspace and all its projects. Are you sure? + + + }> + Confirm Delete + + + + Final Confirmation + + This is your last chance. This action is permanent and cannot be reversed. + + + + Go Back} /> + Delete Everything} /> + + + + + + Cancel} /> + + + + ); + }` +}; diff --git a/apps/www/src/content/docs/components/alert-dialog/index.mdx b/apps/www/src/content/docs/components/alert-dialog/index.mdx new file mode 100644 index 000000000..020854f7e --- /dev/null +++ b/apps/www/src/content/docs/components/alert-dialog/index.mdx @@ -0,0 +1,123 @@ +--- +title: AlertDialog +description: A modal dialog that interrupts the user with important content and expects a response. Unlike Dialog, it does not close on outside click, requiring explicit user action. +source: packages/raystack/components/alert-dialog +tag: new +--- + +import { playground, controlledDemo, menuDemo, discardDemo, nestedDemo } from "./demo.ts"; + + + +## Anatomy + +Import and assemble the component: + +```tsx +import { AlertDialog } from '@raystack/apsara' + + + + + + + + + + + + + + + +``` + +## API Reference + +### Root + +Groups all parts of the alert dialog. + + + +### Content + +Renders the alert dialog panel overlay. + + + +### Header + +Renders the alert dialog header section. + + + +### Title + +Renders the alert dialog title text. + + + +### Body + +Renders the main content area of the alert dialog. + + + +### Description + +Renders supplementary text within the alert dialog body. + + + +### Trigger + +Renders the element that opens the alert dialog. + + + +### CloseButton + +Renders a button that closes the alert dialog. + + + +### Footer + +Renders the alert dialog footer section. + + + +## Examples + +### Controlled + +Example of a controlled alert dialog with custom state management. + + + +### Composing with Menu + +Open an alert dialog from a menu item. Since the trigger is outside the `AlertDialog.Root`, use the controlled `open` / `onOpenChange` props. + + + +### Discard Changes + +A common pattern for confirming destructive navigation. Both actions use `AlertDialog.Close` to dismiss the dialog. + + + +### Nested Alert Dialogs + +You can nest alert dialogs for multi-step confirmation flows. When a nested alert dialog opens, the parent dialog automatically scales down and becomes slightly transparent. + + + +## Accessibility + +- Alert dialog has `role="alertdialog"` and `aria-modal="true"` +- Does not close on outside click (backdrop), requiring explicit user action +- Uses `aria-label` or `aria-labelledby` to identify the dialog +- Uses `aria-describedby` to provide additional context +- Focus is trapped within the alert dialog while open diff --git a/apps/www/src/content/docs/components/alert-dialog/props.ts b/apps/www/src/content/docs/components/alert-dialog/props.ts new file mode 100644 index 000000000..cb014b14a --- /dev/null +++ b/apps/www/src/content/docs/components/alert-dialog/props.ts @@ -0,0 +1,84 @@ +export interface AlertDialogProps { + /** Controls the open state of the alert dialog */ + open?: boolean; + + /** Callback when open state changes */ + onOpenChange?: (open: boolean) => void; +} + +export interface AlertDialogContentProps { + /** Controls alert dialog width */ + width?: string | number; + + /** + * Controls whether to show the close button + * @default true + */ + showCloseButton?: boolean; + + /** + * Toggle nested dialog animation (scaling and translation) + * @default true + */ + showNestedAnimation?: boolean; + + /** Overlay configuration including blur, className, and style */ + overlay?: { + blur?: boolean; + className?: string; + style?: React.CSSProperties; + forceRender?: boolean; + } & React.ComponentPropsWithoutRef<'div'>; + + /** Additional CSS class names */ + className?: string; +} + +export interface AlertDialogHeaderProps { + /** Additional CSS class names */ + className?: string; + + children?: React.ReactNode; +} + +export interface AlertDialogBodyProps { + /** Additional CSS class names */ + className?: string; + + children?: React.ReactNode; +} + +export interface AlertDialogTitleProps { + /** Additional CSS class names */ + className?: string; + + children?: React.ReactNode; +} + +export interface AlertDialogDescriptionProps { + /** Additional CSS class names for description */ + className?: string; +} + +export interface AlertDialogTriggerProps { + /** Boolean to merge props onto child element */ + asChild?: boolean; + + /** Additional CSS class names */ + className?: string; +} + +export interface AlertDialogCloseButtonProps { + /** Boolean to merge props onto child element */ + asChild?: boolean; + + /** Additional CSS class names */ + className?: string; +} + +export interface AlertDialogFooterProps { + /** Additional CSS class names */ + className?: string; + + children?: React.ReactNode; +} diff --git a/packages/raystack/components/alert-dialog/__tests__/alert-dialog.test.tsx b/packages/raystack/components/alert-dialog/__tests__/alert-dialog.test.tsx new file mode 100644 index 000000000..e72d3d1dd --- /dev/null +++ b/packages/raystack/components/alert-dialog/__tests__/alert-dialog.test.tsx @@ -0,0 +1,244 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import styles from '../../dialog/dialog.module.css'; +import { AlertDialog } from '../alert-dialog'; + +const TRIGGER_TEXT = 'Open Alert'; +const ALERT_TITLE = 'Test Alert'; +const ALERT_CONTENT = 'This is test alert content'; +const ALERT_DESCRIPTION = 'This is test alert description'; +const ALERT_CLOSE = 'Cancel'; + +const BasicAlertDialog = ({ + open, + onOpenChange, + ...props +}: { + open?: boolean; + onOpenChange?: (open: boolean) => void; + children?: React.ReactNode; +}) => ( + + {TRIGGER_TEXT} + + + {ALERT_TITLE} + + + {ALERT_DESCRIPTION} + {ALERT_CONTENT} + + + {ALERT_CLOSE} + + + + +); + +function renderAndOpenAlertDialog(Dialog: any) { + fireEvent.click(render(Dialog).getByText(TRIGGER_TEXT)); +} + +describe('AlertDialog', () => { + describe('Basic Rendering', () => { + it('renders trigger button', () => { + render(); + const trigger = screen.getByText(TRIGGER_TEXT); + expect(trigger).toBeInTheDocument(); + }); + + it('does not show alert dialog content initially', () => { + render(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(screen.queryByText(ALERT_TITLE)).not.toBeInTheDocument(); + expect(screen.queryByText(ALERT_DESCRIPTION)).not.toBeInTheDocument(); + }); + + it('shows alert dialog when trigger is clicked', async () => { + renderAndOpenAlertDialog(); + + await waitFor(() => { + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(screen.getByText(ALERT_TITLE)).toBeInTheDocument(); + expect(screen.getByText(ALERT_DESCRIPTION)).toBeInTheDocument(); + }); + }); + + it('renders in portal', async () => { + renderAndOpenAlertDialog(); + + await waitFor(() => { + const dialog = screen.getByRole('alertdialog'); + expect(dialog.closest('body')).toBe(document.body); + }); + }); + }); + + describe('Overlay', () => { + it('renders overlay', async () => { + renderAndOpenAlertDialog(); + + await waitFor(() => { + const overlay = document.querySelector(`.${styles.dialogOverlay}`); + expect(overlay).toBeInTheDocument(); + expect(overlay).toHaveAttribute('aria-hidden', 'true'); + expect(overlay).toHaveAttribute('role', 'presentation'); + }); + }); + }); + + describe('Close Behavior', () => { + it('closes when close button is clicked', async () => { + renderAndOpenAlertDialog(); + + await waitFor(() => { + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Close dialog' })); + + await waitFor(() => { + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + }); + + it('closes when AlertDialog.Close is clicked', async () => { + renderAndOpenAlertDialog(); + + await waitFor(() => { + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText(ALERT_CLOSE)); + + await waitFor(() => { + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + }); + + it('does not close on outside click', async () => { + renderAndOpenAlertDialog(); + + await waitFor(() => { + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); + + const overlay = document.querySelector(`.${styles.dialogOverlay}`); + expect(overlay).toBeInTheDocument(); + fireEvent.click(overlay!); + + await waitFor(() => { + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); + }); + }); + + describe('Custom Components', () => { + it('supports custom header content', async () => { + render( + + Open + + +
Custom Header Content
+
Additional Header Info
+
+
+
+ ); + + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + await waitFor(() => { + expect(screen.getByText('Custom Header Content')).toBeInTheDocument(); + expect(screen.getByText('Additional Header Info')).toBeInTheDocument(); + + const header = document.querySelector('.custom-header'); + expect(header).toHaveClass(styles.header); + }); + }); + + it('supports custom body content', async () => { + render( + + Open + + +
+ +