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 + + } + /> + Delete + + + + + }> + Discard Changes + + + + Unsaved Changes + + You have unsaved changes. Do you want to discard them? + + + + + Continue Editing + + } + /> + Discard + + + + + + ); +} 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} /> + setOpen(false)}>Yes, delete account + + + + ); + }` +}; + +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} /> + setDialogOpen(false)}>Delete + + + + + ); + }` +}; + +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} + Confirm + + + +); + +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 + + + + + + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Name')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Description')).toBeInTheDocument(); + + const body = document.querySelector('.custom-body'); + expect(body).toHaveClass(styles.body); + }); + }); + + it('supports custom footer content', async () => { + render( + + Open + + + Action 1 + Action 2 + Primary Action + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'Action 1' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Action 2' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Primary Action' }) + ).toBeInTheDocument(); + + const footer = document.querySelector('.custom-footer'); + expect(footer).toHaveClass(styles.footer); + }); + }); + + it('works without header, body, or footer', async () => { + render( + + Open Minimal + + Minimal alert dialog content + Close + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open Minimal' })); + + await waitFor(() => { + expect( + screen.getByText('Minimal alert dialog content') + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Close' }) + ).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/packages/raystack/components/alert-dialog/alert-dialog-content.tsx b/packages/raystack/components/alert-dialog/alert-dialog-content.tsx new file mode 100644 index 000000000..e60acfdda --- /dev/null +++ b/packages/raystack/components/alert-dialog/alert-dialog-content.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { type ElementRef, forwardRef } from 'react'; +import styles from '../dialog/dialog.module.css'; +import { CloseButton } from './alert-dialog-misc'; + +export interface AlertDialogContentProps + extends AlertDialogPrimitive.Popup.Props { + showCloseButton?: boolean; + overlay?: AlertDialogPrimitive.Backdrop.Props & { blur?: boolean }; + width?: string | number; + /** + * Toggles nested dialog animation (scaling and translation) + * `@default` true + */ + showNestedAnimation?: boolean; +} + +export const AlertDialogContent = forwardRef< + ElementRef, + AlertDialogContentProps +>( + ( + { + className, + children, + showCloseButton = true, + overlay, + width, + style, + showNestedAnimation = true, + ...props + }, + ref + ) => { + return ( + + + + + {children} + {showCloseButton && } + + + + ); + } +); + +AlertDialogContent.displayName = 'AlertDialog.Content'; diff --git a/packages/raystack/components/alert-dialog/alert-dialog-misc.tsx b/packages/raystack/components/alert-dialog/alert-dialog-misc.tsx new file mode 100644 index 000000000..f0fe92e4b --- /dev/null +++ b/packages/raystack/components/alert-dialog/alert-dialog-misc.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react'; +import { Cross1Icon } from '@radix-ui/react-icons'; +import { cx } from 'class-variance-authority'; +import { ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react'; +import styles from '../dialog/dialog.module.css'; +import { Flex } from '../flex'; + +export const AlertDialogHeader = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +AlertDialogHeader.displayName = 'AlertDialog.Header'; + +export const AlertDialogFooter = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +AlertDialogFooter.displayName = 'AlertDialog.Footer'; + +export const AlertDialogBody = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +AlertDialogBody.displayName = 'AlertDialog.Body'; + +export const CloseButton = forwardRef< + ElementRef, + AlertDialogPrimitive.Close.Props +>(({ className, ...props }, ref) => { + return ( + + + + ); +}); + +CloseButton.displayName = 'AlertDialog.CloseButton'; + +export const AlertDialogTitle = forwardRef< + ElementRef, + AlertDialogPrimitive.Title.Props +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +AlertDialogTitle.displayName = 'AlertDialog.Title'; + +export const AlertDialogDescription = forwardRef< + ElementRef, + AlertDialogPrimitive.Description.Props +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +AlertDialogDescription.displayName = 'AlertDialog.Description'; diff --git a/packages/raystack/components/alert-dialog/alert-dialog.tsx b/packages/raystack/components/alert-dialog/alert-dialog.tsx new file mode 100644 index 000000000..5c8a22122 --- /dev/null +++ b/packages/raystack/components/alert-dialog/alert-dialog.tsx @@ -0,0 +1,22 @@ +import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react'; +import { AlertDialogContent } from './alert-dialog-content'; +import { + AlertDialogBody, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + CloseButton +} from './alert-dialog-misc'; + +export const AlertDialog = Object.assign(AlertDialogPrimitive.Root, { + Header: AlertDialogHeader, + Footer: AlertDialogFooter, + Body: AlertDialogBody, + Trigger: AlertDialogPrimitive.Trigger, + Content: AlertDialogContent, + Close: AlertDialogPrimitive.Close, + CloseButton: CloseButton, + Title: AlertDialogTitle, + Description: AlertDialogDescription +}); diff --git a/packages/raystack/components/alert-dialog/index.ts b/packages/raystack/components/alert-dialog/index.ts new file mode 100644 index 000000000..5e1520cb3 --- /dev/null +++ b/packages/raystack/components/alert-dialog/index.ts @@ -0,0 +1 @@ +export { AlertDialog } from './alert-dialog'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 37885b603..3a78bc230 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -2,6 +2,7 @@ import './styles/index.css'; import './normalize.css'; export { Accordion } from './components/accordion'; +export { AlertDialog } from './components/alert-dialog'; export { Amount } from './components/amount'; export { AnnouncementBar } from './components/announcement-bar'; export { Avatar, AvatarGroup, getAvatarColor } from './components/avatar';