From f39b690dd79f6ed38a4f231a859a95011f1c1a39 Mon Sep 17 00:00:00 2001 From: Michael Salzmann Date: Mon, 8 Sep 2025 12:52:57 +0200 Subject: [PATCH 01/35] refactor(dialog): migrate Dialog component to use Modal base - Replaced Dialog components with Modal components in the AppNavBarCreateButton and AppNavBarSearch components for consistency and improved functionality. - Updated Dialog imports to LegacyDialog in the navigation components. - Added new Modal component and its related types, slots, and stories to enhance the modal functionality. - Updated documentation to reflect the changes in the Dialog and Modal components. All tests pass, ensuring no breaking changes introduced. --- .../components/app-nav-bar-create-button.tsx | 40 +- .../app-nav-bar-search/app-nav-bar-search.tsx | 28 +- .../data-table/data-table.stories.tsx | 8 +- .../nimbus/src/components/dialog/dialog.mdx | 345 ++++++++++- .../src/components/dialog/dialog.recipe.ts | 259 +------- .../src/components/dialog/dialog.stories.tsx | 428 +++++++++++++ .../nimbus/src/components/dialog/dialog.tsx | 177 ++++-- .../src/components/dialog/dialog.types.ts | 104 ++++ .../nimbus/src/components/dialog/index.ts | 3 +- .../drawer/components/drawer.backdrop.tsx | 33 + .../drawer/components/drawer.body.tsx | 38 ++ .../components/drawer.close-trigger.tsx | 50 ++ .../drawer/components/drawer.content.tsx | 99 +++ .../drawer/components/drawer.description.tsx | 42 ++ .../drawer/components/drawer.footer.tsx | 37 ++ .../drawer/components/drawer.header.tsx | 33 + .../drawer/components/drawer.root.tsx | 51 ++ .../drawer/components/drawer.title.tsx | 39 ++ .../drawer/components/drawer.trigger.tsx | 34 ++ .../src/components/drawer/components/index.ts | 13 + .../nimbus/src/components/drawer/drawer.mdx | 401 ++++++++++++ .../src/components/drawer/drawer.recipe.ts | 363 +++++++++++ .../src/components/drawer/drawer.slots.tsx | 120 ++++ .../src/components/drawer/drawer.stories.tsx | 571 ++++++++++++++++++ .../nimbus/src/components/drawer/drawer.tsx | 127 ++++ .../src/components/drawer/drawer.types.ts | 236 ++++++++ .../nimbus/src/components/drawer/index.ts | 41 ++ packages/nimbus/src/components/index.ts | 2 + .../src/components/modal/components/index.ts | 10 + .../modal/components/modal.backdrop.tsx | 35 ++ .../modal/components/modal.body.tsx | 34 ++ .../modal/components/modal.close-trigger.tsx | 53 ++ .../modal/components/modal.content.tsx | 62 ++ .../modal/components/modal.description.tsx | 37 ++ .../modal/components/modal.footer.tsx | 35 ++ .../modal/components/modal.header.tsx | 34 ++ .../modal/components/modal.root.tsx | 51 ++ .../modal/components/modal.title.tsx | 36 ++ .../modal/components/modal.trigger.tsx | 31 + packages/nimbus/src/components/modal/index.ts | 18 + .../nimbus/src/components/modal/modal.mdx | 391 ++++++++++++ .../src/components/modal/modal.recipe.ts | 299 +++++++++ .../src/components/modal/modal.slots.tsx | 86 +++ .../src/components/modal/modal.stories.tsx | 481 +++++++++++++++ .../nimbus/src/components/modal/modal.tsx | 79 +++ .../src/components/modal/modal.types.ts | 215 +++++++ 46 files changed, 5352 insertions(+), 357 deletions(-) create mode 100644 packages/nimbus/src/components/dialog/dialog.stories.tsx create mode 100644 packages/nimbus/src/components/dialog/dialog.types.ts create mode 100644 packages/nimbus/src/components/drawer/components/drawer.backdrop.tsx create mode 100644 packages/nimbus/src/components/drawer/components/drawer.body.tsx create mode 100644 packages/nimbus/src/components/drawer/components/drawer.close-trigger.tsx create mode 100644 packages/nimbus/src/components/drawer/components/drawer.content.tsx create mode 100644 packages/nimbus/src/components/drawer/components/drawer.description.tsx create mode 100644 packages/nimbus/src/components/drawer/components/drawer.footer.tsx create mode 100644 packages/nimbus/src/components/drawer/components/drawer.header.tsx create mode 100644 packages/nimbus/src/components/drawer/components/drawer.root.tsx create mode 100644 packages/nimbus/src/components/drawer/components/drawer.title.tsx create mode 100644 packages/nimbus/src/components/drawer/components/drawer.trigger.tsx create mode 100644 packages/nimbus/src/components/drawer/components/index.ts create mode 100644 packages/nimbus/src/components/drawer/drawer.mdx create mode 100644 packages/nimbus/src/components/drawer/drawer.recipe.ts create mode 100644 packages/nimbus/src/components/drawer/drawer.slots.tsx create mode 100644 packages/nimbus/src/components/drawer/drawer.stories.tsx create mode 100644 packages/nimbus/src/components/drawer/drawer.tsx create mode 100644 packages/nimbus/src/components/drawer/drawer.types.ts create mode 100644 packages/nimbus/src/components/drawer/index.ts create mode 100644 packages/nimbus/src/components/modal/components/index.ts create mode 100644 packages/nimbus/src/components/modal/components/modal.backdrop.tsx create mode 100644 packages/nimbus/src/components/modal/components/modal.body.tsx create mode 100644 packages/nimbus/src/components/modal/components/modal.close-trigger.tsx create mode 100644 packages/nimbus/src/components/modal/components/modal.content.tsx create mode 100644 packages/nimbus/src/components/modal/components/modal.description.tsx create mode 100644 packages/nimbus/src/components/modal/components/modal.footer.tsx create mode 100644 packages/nimbus/src/components/modal/components/modal.header.tsx create mode 100644 packages/nimbus/src/components/modal/components/modal.root.tsx create mode 100644 packages/nimbus/src/components/modal/components/modal.title.tsx create mode 100644 packages/nimbus/src/components/modal/components/modal.trigger.tsx create mode 100644 packages/nimbus/src/components/modal/index.ts create mode 100644 packages/nimbus/src/components/modal/modal.mdx create mode 100644 packages/nimbus/src/components/modal/modal.recipe.ts create mode 100644 packages/nimbus/src/components/modal/modal.slots.tsx create mode 100644 packages/nimbus/src/components/modal/modal.stories.tsx create mode 100644 packages/nimbus/src/components/modal/modal.tsx create mode 100644 packages/nimbus/src/components/modal/modal.types.ts diff --git a/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-create-button.tsx b/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-create-button.tsx index 9803e398f..4ce16d494 100644 --- a/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-create-button.tsx +++ b/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-create-button.tsx @@ -1,5 +1,11 @@ import { Info, Add } from "@commercetools/nimbus-icons"; -import { Button, Dialog, TextInput, Stack, Text } from "@commercetools/nimbus"; +import { + Button, + LegacyDialog, + TextInput, + Stack, + Text, +} from "@commercetools/nimbus"; import { useCreateDocument } from "@/hooks/useCreateDocument"; /** @@ -21,9 +27,9 @@ export const AppNavBarCreateButton = () => { } = useCreateDocument(); return ( - setIsOpen(false)}> - - + setIsOpen(false)}> + + - - - - Create New Document - + + + + Create New Document + Fill in the details to create a new document. - - - + + + {!isLoading ? ( @@ -104,9 +110,9 @@ export const AppNavBarCreateButton = () => { Saving in progress... )} - + {!isLoading && ( - + @@ -118,9 +124,9 @@ export const AppNavBarCreateButton = () => { > Create - + )} - - + + ); }; diff --git a/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-search/app-nav-bar-search.tsx b/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-search/app-nav-bar-search.tsx index 0fd33a35c..1de076541 100644 --- a/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-search/app-nav-bar-search.tsx +++ b/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-search/app-nav-bar-search.tsx @@ -2,7 +2,7 @@ import { Flex, Box, useHotkeys, - Dialog, + LegacyDialog, TextInput, Text, Kbd, @@ -45,7 +45,7 @@ export const AppNavBarSearch = () => { return ( - { scrollBehavior="outside" size="xl" > - - + + { ⌘+K - - - - + + + + Search the Documentation - - - + + + { Enter to confirm selection. - - - + + + ); }; diff --git a/packages/nimbus/src/components/data-table/data-table.stories.tsx b/packages/nimbus/src/components/data-table/data-table.stories.tsx index e335c7793..19d35aebc 100644 --- a/packages/nimbus/src/components/data-table/data-table.stories.tsx +++ b/packages/nimbus/src/components/data-table/data-table.stories.tsx @@ -55,8 +55,8 @@ type ModalState = { const InfoModal = ({ isOpen, onClose, title, children }: ModalState) => ( !open && onClose && onClose()} + isOpen={isOpen} + onOpenChange={(open) => !open && onClose && onClose()} > @@ -135,8 +135,8 @@ const ProductDetailsModal = ({ }; return ( - - + + {formData.name as string} diff --git a/packages/nimbus/src/components/dialog/dialog.mdx b/packages/nimbus/src/components/dialog/dialog.mdx index ba45a1ac9..8fcb7077c 100644 --- a/packages/nimbus/src/components/dialog/dialog.mdx +++ b/packages/nimbus/src/components/dialog/dialog.mdx @@ -1,10 +1,10 @@ --- id: Dialog title: Dialog -description: displays a dialog -documentState: InitialDraft -lifecycleState: Experimental -order: 999 +description: A center-focused modal dialog optimized for alerts, confirmations, and forms +documentState: Ready +lifecycleState: Stable +order: 1 menu: - Components - Feedback @@ -12,10 +12,345 @@ menu: tags: - dialog - modal + - overlay + - accessibility + - confirmation + - alert + - form --- # Dialog -Experimental, do not use. +A center-focused dialog component optimized for alerts, confirmations, and modal forms. Built on top of the Modal component with pre-configured defaults for traditional dialog use cases. + +## Key Features + +- **Center-positioned by default** - Perfect for alerts and confirmations +- **Scale animation** - Smooth, attention-grabbing entrance +- **WCAG 2.1 AA compliant** - Full accessibility support via React Aria +- **Focus management** - Automatic focus trapping and restoration +- **Keyboard navigation** - Escape key and click-outside dismissal +- **Backdrop overlay** - Visual separation from page content + +## When to Use + +Use Dialog for: + +- **Confirmation dialogs** - "Are you sure?" type interactions +- **Alert messages** - Important notifications requiring acknowledgment +- **Short forms** - Login, signup, or edit dialogs +- **Simple content** - Brief information or settings + +For larger content, sidebars, or slide-in panels, use [Modal](/components/feedback/modal) instead. + +## Basic Usage + +```jsx-live + + + + + + + + Dialog Title + + + + + + + This is a basic dialog with default center positioning and scale animation. + + + + + + + + + + +``` + +## Confirmation Dialog + +Perfect for destructive actions that need user confirmation: + +```jsx-live + + + + + + + + Confirm Delete + + + + + + + This action cannot be undone. Are you sure you want to delete this item? + + + + + + + + + + +``` + +## Form Dialog + +Ideal for short forms and user input: + +```jsx-live + + + + + + + + Edit Profile + + + + + + +
+ + +
+
+ + +
+
+
+ + + + + + +
+
+``` + +## Alert Dialog + +For important notifications that require acknowledgment: + +```jsx-live + + + + + + + + Important Notice + + + + Your session will expire in 5 minutes. Please save your work before continuing. + + + + + + + + + +``` + +## Size Variants + +Dialog supports different sizes to match your content: + +```jsx-live + + + + + + + + + Small Dialog + + + + + + Perfect for simple confirmations. + + + + + + + + + + + + + + + + + Medium Dialog + + + + + + The default size for most use cases. + + + + + + + + + + + + + + + + + Large Dialog + + + + + + For complex forms or detailed content. + + + + + + + + + +``` + +## Controlled State + +Use controlled state when you need programmatic control: + +```jsx-live +function ControlledDialog() { + const [isOpen, setIsOpen] = useState(false); + + return ( + + Dialog is {isOpen ? 'open' : 'closed'} + + + + + + + + + Controlled Dialog + + + + + + + This dialog's state is controlled by the parent component. + + + + + + + + + + + + + + ); +} +``` + +## Accessibility + +Dialog provides comprehensive accessibility features: + +- **Focus management** - Focus moves to dialog and returns to trigger on close +- **Keyboard navigation** - Tab cycles through interactive elements +- **Escape key** - Closes the dialog +- **Click outside** - Dismisses the dialog (configurable) +- **Screen reader support** - Proper ARIA labels and descriptions +- **Role semantics** - Uses `role="dialog"` with accessible labeling + +### ARIA Labels + +Always provide accessible labels: + +```jsx-live + + + + + + + + Application Settings + + + + + + + Modify your application preferences and account settings. + + + + + + + + + +``` + +## API Reference + + + +## Comparison with Modal + +| Feature | Dialog | Modal | +|---------|--------|-------| +| **Default Position** | Center | Configurable | +| **Default Animation** | Scale | Configurable | +| **Use Cases** | Alerts, confirmations, short forms | Large content, drawers, complex layouts | +| **Size Options** | xs, sm, md, lg, xl | xs, sm, md, lg, xl, cover, full | +| **Placement** | center, top, bottom | center, top, bottom | + +Choose **Dialog** for traditional modal dialogs that need user attention. Choose **Modal** for larger content areas or when you need more layout flexibility. diff --git a/packages/nimbus/src/components/dialog/dialog.recipe.ts b/packages/nimbus/src/components/dialog/dialog.recipe.ts index f26679aa4..7149fabaf 100644 --- a/packages/nimbus/src/components/dialog/dialog.recipe.ts +++ b/packages/nimbus/src/components/dialog/dialog.recipe.ts @@ -1,254 +1,7 @@ -import { defineSlotRecipe } from "@chakra-ui/react/styled-system"; +// Re-export the shared modal recipe with Dialog-specific defaults and alias +export { modalSlotRecipe as dialogSlotRecipe } from "../modal/modal.recipe"; -export const dialogSlotRecipe = defineSlotRecipe({ - slots: [ - "trigger", - "backdrop", - "positioner", - "content", - "title", - "description", - "closeTrigger", - "header", - "body", - "footer", - "backdrop", - ], - className: "nimbus-dialog", - base: { - backdrop: { - bg: { - _dark: "bg/50", - _light: "fg/50", - }, - pos: "fixed", - left: 0, - top: 0, - w: "100vw", - h: "100dvh", - zIndex: "modal", - _open: { - animationName: "fade-in", - animationDuration: "slow", - }, - _closed: { - animationName: "fade-out", - animationDuration: "moderate", - }, - }, - positioner: { - display: "flex", - width: "100vw", - height: "100dvh", - position: "fixed", - left: 0, - top: 0, - "--dialog-z-index": "zIndex.modal", - zIndex: "calc(var(--dialog-z-index) + var(--layer-index, 0))", - justifyContent: "center", - overscrollBehaviorY: "none", - }, - content: { - display: "flex", - flexDirection: "column", - position: "relative", - width: "100%", - outline: 0, - borderRadius: "200", - textStyle: "sm", - my: "var(--dialog-margin, var(--dialog-base-margin))", - "--dialog-z-index": "zIndex.modal", - zIndex: "calc(var(--dialog-z-index) + var(--layer-index, 0))", - bg: "bg", - boxShadow: "lg", - _open: { - animationDuration: "moderate", - }, - _closed: { - animationDuration: "faster", - }, - }, - header: { - flex: 0, - px: "600", - pt: "600", - pb: "400", - }, - body: { - flex: "1", - px: "600", - pt: "200", - pb: "600", - }, - footer: { - display: "flex", - alignItems: "center", - justifyContent: "flex-end", - gap: "300", - px: "600", - pt: "200", - pb: "400", - }, - title: { - textStyle: "lg", - fontWeight: "semibold", - }, - description: { - color: "fg.muted", - }, - }, - variants: { - placement: { - center: { - positioner: { - alignItems: "center", - }, - content: { - "--dialog-base-margin": "auto", - mx: "auto", - }, - }, - top: { - positioner: { - alignItems: "flex-start", - }, - content: { - "--dialog-base-margin": "spacing.1600", - mx: "auto", - }, - }, - bottom: { - positioner: { - alignItems: "flex-end", - }, - content: { - "--dialog-base-margin": "spacing.1600", - mx: "auto", - }, - }, - }, - scrollBehavior: { - inside: { - positioner: { - overflow: "hidden", - }, - content: { - maxH: "calc(100% - 7.5rem)", - }, - body: { - overflow: "auto", - }, - }, - outside: { - positioner: { - overflow: "auto", - pointerEvents: "auto", - }, - }, - }, - size: { - xs: { - content: { - maxW: "sm", - }, - }, - sm: { - content: { - maxW: "md", - }, - }, - md: { - content: { - maxW: "lg", - }, - }, - lg: { - content: { - maxW: "2xl", - }, - }, - xl: { - content: { - maxW: "4xl", - }, - }, - cover: { - positioner: { - padding: "1000", - }, - content: { - width: "100%", - height: "100%", - "--dialog-margin": "0", - }, - }, - full: { - content: { - maxW: "100vw", - minH: "100vh", - "--dialog-margin": "0", - borderRadius: "0", - }, - }, - }, - motionPreset: { - scale: { - content: { - _open: { - animationName: "scale-in, fade-in", - }, - _closed: { - animationName: "scale-out, fade-out", - }, - }, - }, - "slide-in-bottom": { - content: { - _open: { - animationName: "slide-from-bottom, fade-in", - }, - _closed: { - animationName: "slide-to-bottom, fade-out", - }, - }, - }, - "slide-in-top": { - content: { - _open: { - animationName: "slide-from-top, fade-in", - }, - _closed: { - animationName: "slide-to-top, fade-out", - }, - }, - }, - "slide-in-left": { - content: { - _open: { - animationName: "slide-from-left, fade-in", - }, - _closed: { - animationName: "slide-to-left, fade-out", - }, - }, - }, - "slide-in-right": { - content: { - _open: { - animationName: "slide-from-right, fade-in", - }, - _closed: { - animationName: "slide-to-right, fade-out", - }, - }, - }, - none: {}, - }, - }, - defaultVariants: { - size: "md", - scrollBehavior: "outside", - placement: "top", - motionPreset: "scale", - }, -}); +/** + * @deprecated Use modalSlotRecipe from Modal component instead. + * This alias is maintained for backward compatibility. + */ diff --git a/packages/nimbus/src/components/dialog/dialog.stories.tsx b/packages/nimbus/src/components/dialog/dialog.stories.tsx new file mode 100644 index 000000000..6c12fadaa --- /dev/null +++ b/packages/nimbus/src/components/dialog/dialog.stories.tsx @@ -0,0 +1,428 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { within, expect, userEvent } from "storybook/test"; +import { Dialog } from "./dialog"; +import { Button, Stack, Text, Heading } from "@/components"; + +const meta: Meta = { + title: "components/Dialog", + component: Dialog.Content, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + size: { + control: { type: "select" }, + options: ["xs", "sm", "md", "lg", "xl"], + }, + placement: { + control: { type: "select" }, + options: ["center", "top", "bottom"], + }, + motionPreset: { + control: { type: "select" }, + options: ["scale", "slide-in-bottom", "slide-in-top", "slide-in-left", "slide-in-right", "none"], + }, + }, + render: (args) => ( + + + + + + + + Dialog Title + + + + + + + This is a dialog message. Dialogs are perfect for confirmations, alerts, and forms. + + + + + + + + + + + ), +}; + +export default meta; +type Story = StoryObj; + +/** + * The default dialog configuration optimized for center positioning and scale animation. + * Perfect for confirmations and alerts. + */ +export const Default: Story = { + args: { + size: "md", + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement.parentNode as HTMLElement); + + await step("Opens dialog on trigger click", async () => { + const trigger = canvas.getByRole("button", { name: "Open Dialog" }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: "Dialog Title" }); + expect(dialog).toBeInTheDocument(); + }); + + await step("Closes dialog on cancel button", async () => { + const cancelButton = canvas.getByRole("button", { name: "Cancel" }); + await userEvent.click(cancelButton); + + await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }, +}; + +/** + * A confirmation dialog for destructive actions with appropriate styling. + */ +export const ConfirmationDialog: Story = { + args: { + size: "sm", + }, + render: (args) => ( + + + + + + + + Confirm Delete + + + + + + + This action cannot be undone. Are you sure you want to delete this item? + + + + + + + + + + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement.parentNode as HTMLElement); + + await step("Opens confirmation dialog", async () => { + const trigger = canvas.getByRole("button", { name: "Delete Item" }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: "Confirm Delete" }); + expect(dialog).toBeInTheDocument(); + + // Verify destructive action messaging + expect(canvas.getByText("This action cannot be undone")).toBeInTheDocument(); + }); + + await step("Can cancel the action", async () => { + const cancelButton = canvas.getByRole("button", { name: "Cancel" }); + await userEvent.click(cancelButton); + + await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }, +}; + +/** + * A form dialog for user input with validation states. + */ +export const FormDialog: Story = { + args: { + size: "md", + }, + render: (args) => ( + + + + + + + + Edit Profile + + + + + + +
+ + +
+
+ + +
+
+
+ + + + + + +
+
+ ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement.parentNode as HTMLElement); + + await step("Opens form dialog", async () => { + const trigger = canvas.getByRole("button", { name: "Edit Profile" }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: "Edit Profile" }); + expect(dialog).toBeInTheDocument(); + }); + + await step("Can interact with form fields", async () => { + const nameInput = canvas.getByLabelText("Name"); + const emailInput = canvas.getByLabelText("Email"); + + await userEvent.type(nameInput, "John Doe"); + await userEvent.type(emailInput, "john@example.com"); + + expect(nameInput).toHaveValue("John Doe"); + expect(emailInput).toHaveValue("john@example.com"); + }); + + await step("Form maintains focus within dialog", async () => { + // Test focus management + const nameInput = canvas.getByLabelText("Name"); + expect(nameInput).toBeInTheDocument(); + + // Close the dialog + const cancelButton = canvas.getByRole("button", { name: "Cancel" }); + await userEvent.click(cancelButton); + }); + }, +}; + +/** + * An alert dialog for important notifications that require acknowledgment. + */ +export const AlertDialog: Story = { + args: { + size: "sm", + }, + render: (args) => ( + + + + + + + + Important Notice + + + + Your session will expire in 5 minutes. Please save your work before continuing. + + + + + + + + + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement.parentNode as HTMLElement); + + await step("Opens alert dialog", async () => { + const trigger = canvas.getByRole("button", { name: "Show Alert" }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: "Important Notice" }); + expect(dialog).toBeInTheDocument(); + + // Verify alert message + expect(canvas.getByText(/Your session will expire/)).toBeInTheDocument(); + }); + + await step("Can acknowledge alert", async () => { + const acknowledgeButton = canvas.getByRole("button", { name: "Understood" }); + await userEvent.click(acknowledgeButton); + + await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }, +}; + +/** + * Dialog size variants showing different size options. + */ +export const Sizes: Story = { + args: {}, + render: () => ( + + + + + + + + + Small Dialog + + + + + + + This is a small dialog, perfect for simple confirmations. + + + + + + + + + + + + + + + + + + Medium Dialog + + + + + + + This is a medium dialog, the default size for most dialog use cases. + + + + + + + + + + + + + + + + + + Large Dialog + + + + + + + This is a large dialog, suitable for complex forms or detailed content. + + + + + + + + + + + ), +}; + +/** + * Dialog accessibility testing - focuses on keyboard navigation and screen reader support. + */ +export const AccessibilityTest: Story = { + args: { + size: "md", + }, + render: (args) => ( + + + + + + + + Accessibility Test + + + + + + + This dialog tests accessibility features including proper focus management, + keyboard navigation, and screen reader announcements. + + + + + + + + + + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement.parentNode as HTMLElement); + + await step("Opens dialog and focuses correctly", async () => { + const trigger = canvas.getByRole("button", { name: "Accessible Dialog" }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: "Accessibility Test" }); + expect(dialog).toBeInTheDocument(); + }); + + await step("Supports keyboard navigation", async () => { + // Tab through interactive elements + await userEvent.tab(); + expect(canvas.getByRole("button", { name: "Close dialog" })).toHaveFocus(); + + await userEvent.tab(); + expect(canvas.getByRole("button", { name: "Cancel" })).toHaveFocus(); + + await userEvent.tab(); + expect(canvas.getByRole("button", { name: "Confirm" })).toHaveFocus(); + }); + + await step("Closes on Escape key", async () => { + await userEvent.keyboard("{Escape}"); + await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }, +}; \ No newline at end of file diff --git a/packages/nimbus/src/components/dialog/dialog.tsx b/packages/nimbus/src/components/dialog/dialog.tsx index e3464b38d..ca6fce26d 100644 --- a/packages/nimbus/src/components/dialog/dialog.tsx +++ b/packages/nimbus/src/components/dialog/dialog.tsx @@ -1,72 +1,123 @@ -import { Dialog as ChakraDialog } from "@chakra-ui/react/dialog"; -import { Portal } from "@chakra-ui/react/portal"; +import { forwardRef } from "react"; +import { Modal } from "../modal/modal"; +import type { + DialogRootProps, + DialogTriggerProps, + DialogContentProps, + DialogBackdropProps, + DialogHeaderProps, + DialogBodyProps, + DialogFooterProps, + DialogTitleProps, + DialogDescriptionProps, + DialogCloseTriggerProps, +} from "./dialog.types"; -interface DialogContentProps extends ChakraDialog.ContentProps { - portalled?: boolean; - portalRef?: React.RefObject; - backdrop?: boolean; - ref?: React.Ref; -} +// Re-export types +export type * from "./dialog.types"; -const DialogContent = function DialogContent(props: DialogContentProps) { - const { - children, - portalled = true, - portalRef, - backdrop = true, - ref, - ...rest - } = props; +/** + * # Dialog.Content + * + * Dialog-specific content component that wraps Modal.Content with optimized defaults. + * Pre-configured for center positioning and scale animations - perfect for alerts, + * confirmations, and form dialogs. + * + * @example + * ```tsx + * + * + * Confirm Action + * + * + * + * Are you sure you want to continue? + * + * + * + * + * + * + * ``` + */ +const DialogContent = forwardRef( + (props, ref) => { + const { + placement = "center", + motionPreset = "scale", + ...restProps + } = props; - return ( - - {backdrop && } - - - {children} - - - - ); -}; + return ( + + ); + } +); -// Create a type-safe composite object with explicit component definitions -interface DialogComponents { - Root: typeof ChakraDialog.Root; - Trigger: typeof ChakraDialog.Trigger; - Content: typeof DialogContent; - Backdrop: typeof ChakraDialog.Backdrop; - Positioner: typeof ChakraDialog.Positioner; - Title: typeof ChakraDialog.Title; - Description: typeof ChakraDialog.Description; - Body: typeof ChakraDialog.Body; - Footer: typeof ChakraDialog.Footer; - Header: typeof ChakraDialog.Header; - CloseTrigger: typeof ChakraDialog.CloseTrigger; - ActionTrigger: typeof ChakraDialog.ActionTrigger; -} +DialogContent.displayName = "Dialog.Content"; /** - * # Dialog - * - * displays a dialog - * - * @see {@link https://nimbus-documentation.vercel.app/components/feedback/dialog} - * - * @experimental This component is experimental and may change or be removed in future versions. + * Dialog + * ============================================================ + * A center-focused dialog component optimized for alerts, confirmations, and modal forms. + * Built on top of the Modal component with pre-configured defaults for traditional dialog use cases. + * + * Key differences from Modal: + * - Default placement: "center" (vs configurable) + * - Default animation: "scale" (vs configurable) + * - Optimized for quick interactions and focused content + * - Perfect for alerts, confirmations, forms, and focused tasks + * + * Features: + * - All Modal functionality with dialog-optimized defaults + * - WCAG 2.1 AA accessibility compliance via React Aria + * - Focus management and keyboard navigation + * - Click-outside and Escape key dismissal + * - Backdrop overlay with smooth animations + * + * @example + * ```tsx + * + * Delete Item + * + * + * Confirm Delete + * × + * + * + * + * This action cannot be undone. Are you sure? + * + * + * + * + * + * + * + * + * ``` + * + * @see https://react-spectrum.adobe.com/react-aria/Dialog.html */ -// Export the Dialog composite with proper typing -export const Dialog: DialogComponents = { - Root: ChakraDialog.Root, - Trigger: ChakraDialog.Trigger, +export const Dialog = { + Root: Modal.Root as React.ComponentType, + Trigger: Modal.Trigger as React.ComponentType, Content: DialogContent, - Backdrop: ChakraDialog.Backdrop, - Positioner: ChakraDialog.Positioner, - Title: ChakraDialog.Title, - Description: ChakraDialog.Description, - Body: ChakraDialog.Body, - Footer: ChakraDialog.Footer, - Header: ChakraDialog.Header, - CloseTrigger: ChakraDialog.CloseTrigger, - ActionTrigger: ChakraDialog.ActionTrigger, + Backdrop: Modal.Backdrop as React.ComponentType, + Header: Modal.Header as React.ComponentType, + Body: Modal.Body as React.ComponentType, + Footer: Modal.Footer as React.ComponentType, + Title: Modal.Title as React.ComponentType, + Description: Modal.Description as React.ComponentType, + CloseTrigger: Modal.CloseTrigger as React.ComponentType, +}; + +// Internal exports for react-docgen +export { + DialogContent as _DialogContent, }; diff --git a/packages/nimbus/src/components/dialog/dialog.types.ts b/packages/nimbus/src/components/dialog/dialog.types.ts new file mode 100644 index 000000000..0dc297c12 --- /dev/null +++ b/packages/nimbus/src/components/dialog/dialog.types.ts @@ -0,0 +1,104 @@ +import type { + ModalRootProps, + ModalTriggerProps, + ModalContentProps, + ModalBackdropProps, + ModalHeaderProps, + ModalBodyProps, + ModalFooterProps, + ModalTitleProps, + ModalDescriptionProps, + ModalCloseTriggerProps +} from "../modal/modal.types"; + +/** + * Props for the Dialog.Root component + * + * The root component that provides context and state management for the dialog. + * Identical to Modal.Root as it uses the same underlying implementation. + */ +export interface DialogRootProps extends ModalRootProps {} + +/** + * Props for the Dialog.Trigger component + * + * The trigger element that opens the dialog when activated. + * Identical to Modal.Trigger as it uses the same underlying implementation. + */ +export interface DialogTriggerProps extends ModalTriggerProps {} + +/** + * Props for the Dialog.Content component + * + * The main dialog content container optimized for center-positioned modal dialogs. + * Extends Modal.Content with Dialog-specific defaults for placement and motionPreset. + */ +export interface DialogContentProps extends Omit { + /** + * The placement of the dialog content + * @default "center" - Dialogs are optimized for center positioning + */ + placement?: "center" | "top" | "bottom"; + + /** + * The motion preset for dialog animations + * @default "scale" - Dialogs use scale animation for better UX + */ + motionPreset?: "scale" | "slide-in-bottom" | "slide-in-top" | "slide-in-left" | "slide-in-right" | "none"; +} + +/** + * Props for the Dialog.Backdrop component + * + * The backdrop overlay that appears behind the dialog content. + * Identical to Modal.Backdrop as it uses the same underlying implementation. + */ +export interface DialogBackdropProps extends ModalBackdropProps {} + +/** + * Props for the Dialog.Header component + * + * The header section of the dialog content. + * Identical to Modal.Header as it uses the same underlying implementation. + */ +export interface DialogHeaderProps extends ModalHeaderProps {} + +/** + * Props for the Dialog.Body component + * + * The main body content section of the dialog. + * Identical to Modal.Body as it uses the same underlying implementation. + */ +export interface DialogBodyProps extends ModalBodyProps {} + +/** + * Props for the Dialog.Footer component + * + * The footer section of the dialog, typically containing action buttons. + * Identical to Modal.Footer as it uses the same underlying implementation. + */ +export interface DialogFooterProps extends ModalFooterProps {} + +/** + * Props for the Dialog.Title component + * + * The accessible title element for the dialog. + * Identical to Modal.Title as it uses the same underlying implementation. + */ +export interface DialogTitleProps extends ModalTitleProps {} + +/** + * Props for the Dialog.Description component + * + * The accessible description element for the dialog. + * Identical to Modal.Description as it uses the same underlying implementation. + */ +export interface DialogDescriptionProps extends ModalDescriptionProps {} + +/** + * Props for the Dialog.CloseTrigger component + * + * A button that closes the dialog when activated. + * Identical to Modal.CloseTrigger as it uses the same underlying implementation. + */ +export interface DialogCloseTriggerProps extends ModalCloseTriggerProps {} \ No newline at end of file diff --git a/packages/nimbus/src/components/dialog/index.ts b/packages/nimbus/src/components/dialog/index.ts index 8bfec8d0f..c1e954434 100644 --- a/packages/nimbus/src/components/dialog/index.ts +++ b/packages/nimbus/src/components/dialog/index.ts @@ -1 +1,2 @@ -export * from "./dialog.tsx"; +export { Dialog } from "./dialog.tsx"; +export type * from "./dialog.types"; diff --git a/packages/nimbus/src/components/drawer/components/drawer.backdrop.tsx b/packages/nimbus/src/components/drawer/components/drawer.backdrop.tsx new file mode 100644 index 000000000..b71558825 --- /dev/null +++ b/packages/nimbus/src/components/drawer/components/drawer.backdrop.tsx @@ -0,0 +1,33 @@ +import { forwardRef } from "react"; +import { DrawerBackdropSlot } from "../drawer.slots"; +import type { DrawerBackdropProps } from "../drawer.types"; + +/** + * # Drawer.Backdrop + * + * The backdrop overlay that appears behind the drawer content. + * Provides a semi-transparent overlay that can dismiss the drawer when clicked. + * + * @example + * ```tsx + * + * + * Title + * + * ``` + */ +export const DrawerBackdrop = forwardRef( + (props, ref) => { + const { style, ...restProps } = props; + + return ( + + ); + } +); + +DrawerBackdrop.displayName = "Drawer.Backdrop"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/components/drawer.body.tsx b/packages/nimbus/src/components/drawer/components/drawer.body.tsx new file mode 100644 index 000000000..290374ebe --- /dev/null +++ b/packages/nimbus/src/components/drawer/components/drawer.body.tsx @@ -0,0 +1,38 @@ +import { forwardRef } from "react"; +import { DrawerBodySlot } from "../drawer.slots"; +import type { DrawerBodyProps } from "../drawer.types"; + +/** + * # Drawer.Body + * + * The main body content section of the drawer. + * Contains the primary drawer content with scrollable overflow handling. + * + * @example + * ```tsx + * + * + * Navigation + * + * + * + * + * + * ``` + */ +export const DrawerBody = forwardRef( + (props, ref) => { + const { children, ...restProps } = props; + + return ( + + {children} + + ); + } +); + +DrawerBody.displayName = "Drawer.Body"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/components/drawer.close-trigger.tsx b/packages/nimbus/src/components/drawer/components/drawer.close-trigger.tsx new file mode 100644 index 000000000..74df54e1b --- /dev/null +++ b/packages/nimbus/src/components/drawer/components/drawer.close-trigger.tsx @@ -0,0 +1,50 @@ +import { forwardRef } from "react"; +import { Button as RaButton } from "react-aria-components"; +import { DrawerCloseTriggerSlot } from "../drawer.slots"; +import type { DrawerCloseTriggerProps } from "../drawer.types"; + +/** + * # Drawer.CloseTrigger + * + * A button that closes the drawer when activated. + * Uses React Aria's Button with proper accessibility features. + * + * Automatically handles keyboard interaction and provides appropriate + * ARIA labeling for screen readers. + * + * @example + * ```tsx + * + * + * Navigation + * + * × + * + * + * + * ``` + */ +export const DrawerCloseTrigger = forwardRef( + (props, ref) => { + const { + children, + "aria-label": ariaLabel = "Close drawer", + ...restProps + } = props; + + return ( + + + {children} + + + ); + } +); + +DrawerCloseTrigger.displayName = "Drawer.CloseTrigger"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/components/drawer.content.tsx b/packages/nimbus/src/components/drawer/components/drawer.content.tsx new file mode 100644 index 000000000..7fd68f427 --- /dev/null +++ b/packages/nimbus/src/components/drawer/components/drawer.content.tsx @@ -0,0 +1,99 @@ +import { forwardRef, useMemo } from "react"; +import { Modal as RaModal, Dialog as RaDialog } from "react-aria-components"; +import { + DrawerPositionerSlot, + DrawerContentSlot +} from "../drawer.slots"; +import type { + DrawerContentProps, + DrawerSide, + DrawerPlacement, + DrawerMotionPreset +} from "../drawer.types"; + +/** + * # Drawer.Content + * + * The main drawer content container that wraps React Aria's Modal and Dialog. + * Handles portalling, backdrop, edge positioning, and content styling. + * + * The `side` prop automatically determines placement and animation: + * - side="left" → placement="left", motionPreset="slide-in-left" + * - side="right" → placement="right", motionPreset="slide-in-right" + * - side="top" → placement="top", motionPreset="slide-in-top" + * - side="bottom" → placement="bottom", motionPreset="slide-in-bottom" + * + * @example + * ```tsx + * + * Open Drawer + * + * + * + * Navigation + * × + * + * Content + * Actions + * + * + * ``` + */ +export const DrawerContent = forwardRef( + (props, ref) => { + const { + children, + side = "left", + isPortalled = true, + portalContainer, + hasBackdrop = true, + isDismissable = true, + isKeyboardDismissDisabled = false, + isSwipeDisabled = false, + onClose, + size, + scrollBehavior, + motionPreset: explicitMotionPreset, + ...restProps + } = props; + + // Automatically map side to placement and motionPreset + const { placement, motionPreset } = useMemo(() => { + const mappings: Record = { + left: { placement: "left", motionPreset: "slide-in-left" }, + right: { placement: "right", motionPreset: "slide-in-right" }, + top: { placement: "top", motionPreset: "slide-in-top" }, + bottom: { placement: "bottom", motionPreset: "slide-in-bottom" }, + }; + + return { + placement: mappings[side].placement, + motionPreset: explicitMotionPreset || mappings[side].motionPreset, + }; + }, [side, explicitMotionPreset]); + + return ( + + + + + {children} + + + + + ); + } +); + +DrawerContent.displayName = "Drawer.Content"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/components/drawer.description.tsx b/packages/nimbus/src/components/drawer/components/drawer.description.tsx new file mode 100644 index 000000000..8082af776 --- /dev/null +++ b/packages/nimbus/src/components/drawer/components/drawer.description.tsx @@ -0,0 +1,42 @@ +import { forwardRef } from "react"; +import { Text as RaText } from "react-aria-components"; +import { DrawerDescriptionSlot } from "../drawer.slots"; +import type { DrawerDescriptionProps } from "../drawer.types"; + +/** + * # Drawer.Description + * + * The accessible description element for the drawer. + * Uses React Aria's Text with slot="description" for proper accessibility. + * + * This description provides additional context about the drawer content + * and is announced by screen readers along with the title. + * + * @example + * + * + * User Profile + * + * + * + * View and edit your account information and preferences. + * + * profile form + * + * + */ +export const DrawerDescription = forwardRef( + (props, ref) => { + const { children, ...restProps } = props; + + return ( + + + {children} + + + ); + } +); + +DrawerDescription.displayName = "Drawer.Description"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/components/drawer.footer.tsx b/packages/nimbus/src/components/drawer/components/drawer.footer.tsx new file mode 100644 index 000000000..8e5763687 --- /dev/null +++ b/packages/nimbus/src/components/drawer/components/drawer.footer.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; +import { DrawerFooterSlot } from "../drawer.slots"; +import type { DrawerFooterProps } from "../drawer.types"; + +/** + * # Drawer.Footer + * + * The footer section of the drawer, typically containing action buttons. + * Positioned at the bottom of the drawer with consistent spacing. + * + * @example + * ```tsx + * + * + * Settings + * + * Settings content + * + * + * + * + * + * ``` + */ +export const DrawerFooter = forwardRef( + (props, ref) => { + const { children, ...restProps } = props; + + return ( + + {children} + + ); + } +); + +DrawerFooter.displayName = "Drawer.Footer"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/components/drawer.header.tsx b/packages/nimbus/src/components/drawer/components/drawer.header.tsx new file mode 100644 index 000000000..c91ffec5b --- /dev/null +++ b/packages/nimbus/src/components/drawer/components/drawer.header.tsx @@ -0,0 +1,33 @@ +import { forwardRef } from "react"; +import { DrawerHeaderSlot } from "../drawer.slots"; +import type { DrawerHeaderProps } from "../drawer.types"; + +/** + * # Drawer.Header + * + * The header section of the drawer content. + * Typically contains the title and close button. + * + * @example + * ```tsx + * + * + * Navigation + * × + * + * + * ``` + */ +export const DrawerHeader = forwardRef( + (props, ref) => { + const { children, ...restProps } = props; + + return ( + + {children} + + ); + } +); + +DrawerHeader.displayName = "Drawer.Header"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/components/drawer.root.tsx b/packages/nimbus/src/components/drawer/components/drawer.root.tsx new file mode 100644 index 000000000..ff48c18cd --- /dev/null +++ b/packages/nimbus/src/components/drawer/components/drawer.root.tsx @@ -0,0 +1,51 @@ +import { DialogTrigger as RaDialogTrigger } from "react-aria-components"; +import { DrawerRootSlot } from "../drawer.slots"; +import type { DrawerRootProps } from "../drawer.types"; + +/** + * # Drawer.Root + * + * The root component that provides context and state management for the drawer. + * Uses React Aria's DialogTrigger for accessibility and keyboard interaction. + * + * This component must wrap all drawer parts (Trigger, Content, etc.) and provides + * the drawer open/close state and variant styling context. + * + * @example + * ```tsx + * + * Open Drawer + * + * + * Drawer Title + * + * Drawer content + * + * + * ``` + */ +export const DrawerRoot = (props: DrawerRootProps) => { + const { + children, + isOpen, + onOpenChange, + defaultOpen = false, + isDisabled = false, + ...restProps + } = props; + + return ( + + + {children} + + + ); +}; + +DrawerRoot.displayName = "Drawer.Root"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/components/drawer.title.tsx b/packages/nimbus/src/components/drawer/components/drawer.title.tsx new file mode 100644 index 000000000..ae8a31218 --- /dev/null +++ b/packages/nimbus/src/components/drawer/components/drawer.title.tsx @@ -0,0 +1,39 @@ +import { forwardRef } from "react"; +import { Heading as RaHeading } from "react-aria-components"; +import { DrawerTitleSlot } from "../drawer.slots"; +import type { DrawerTitleProps } from "../drawer.types"; + +/** + * # Drawer.Title + * + * The accessible title element for the drawer. + * Uses React Aria's Heading for proper accessibility labeling. + * + * This title automatically labels the drawer for screen readers and + * provides the primary heading for the drawer content. + * + * @example + * ```tsx + * + * + * Navigation Menu + * × + * + * + * ``` + */ +export const DrawerTitle = forwardRef( + (props, ref) => { + const { children, ...restProps } = props; + + return ( + + + {children} + + + ); + } +); + +DrawerTitle.displayName = "Drawer.Title"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/components/drawer.trigger.tsx b/packages/nimbus/src/components/drawer/components/drawer.trigger.tsx new file mode 100644 index 000000000..6cb62535f --- /dev/null +++ b/packages/nimbus/src/components/drawer/components/drawer.trigger.tsx @@ -0,0 +1,34 @@ +import { forwardRef } from "react"; +import { Button as RaButton } from "react-aria-components"; +import { DrawerTriggerSlot } from "../drawer.slots"; +import type { DrawerTriggerProps } from "../drawer.types"; + +/** + * # Drawer.Trigger + * + * The trigger element that opens the drawer when activated. + * Built with React Aria's Button for full accessibility support. + * + * @example + * + * Open Navigation + * + * drawer content + * + * + */ +export const DrawerTrigger = forwardRef( + (props, ref) => { + const { children, ...restProps } = props; + + return ( + + + {children} + + + ); + } +); + +DrawerTrigger.displayName = "Drawer.Trigger"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/components/index.ts b/packages/nimbus/src/components/drawer/components/index.ts new file mode 100644 index 000000000..c6426f2f0 --- /dev/null +++ b/packages/nimbus/src/components/drawer/components/index.ts @@ -0,0 +1,13 @@ +/** + * Internal exports for drawer sub-components + */ +export { DrawerRoot } from "./drawer.root"; +export { DrawerTrigger } from "./drawer.trigger"; +export { DrawerContent } from "./drawer.content"; +export { DrawerBackdrop } from "./drawer.backdrop"; +export { DrawerHeader } from "./drawer.header"; +export { DrawerBody } from "./drawer.body"; +export { DrawerFooter } from "./drawer.footer"; +export { DrawerTitle } from "./drawer.title"; +export { DrawerDescription } from "./drawer.description"; +export { DrawerCloseTrigger } from "./drawer.close-trigger"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/drawer.mdx b/packages/nimbus/src/components/drawer/drawer.mdx new file mode 100644 index 000000000..766268ebe --- /dev/null +++ b/packages/nimbus/src/components/drawer/drawer.mdx @@ -0,0 +1,401 @@ +--- +id: drawer +title: Drawer +description: A drawer component optimized for edge-positioned sliding panels, built on the Modal base component. +menu: Components +tags: + - overlay + - modal + - drawer + - navigation + - panel + - slide +--- + +# Drawer + +A drawer component optimized for edge-positioned sliding panels. Built on the Modal base component with React Aria Components for accessibility and WCAG 2.1 AA compliance. + +Perfect for navigation panels, detail views, filters, and mobile-first interfaces. + +## Features + +- **Edge positioning**: Supports left, right, top, and bottom sides +- **Automatic mapping**: Side prop automatically configures placement and motion presets +- **Drawer-specific sizes**: Includes narrow, wide variants plus standard sizing +- **Full accessibility**: Built with React Aria Components for WCAG 2.1 AA compliance +- **Flexible state**: Controlled and uncontrolled modes supported +- **Portal rendering**: Renders outside normal DOM flow by default +- **Focus management**: Automatic focus handling and keyboard navigation +- **Dismissal options**: Click-outside, Escape key, and programmatic dismissal + +## Side Mapping + +The `side` prop automatically determines the placement and animation: + +- `side="left"` → `placement="left"`, `motionPreset="slide-in-left"` +- `side="right"` → `placement="right"`, `motionPreset="slide-in-right"` +- `side="top"` → `placement="top"`, `motionPreset="slide-in-top"` +- `side="bottom"` → `placement="bottom"`, `motionPreset="slide-in-bottom"` + +## Basic Usage + +```jsx-live + + Open Navigation + + + + Navigation + × + + + + + + +``` + +## Use Cases + +### Navigation Drawer (Left) + +Perfect for primary navigation menus and application structure. + +```jsx-live + + Open Menu + + + + Main Navigation + × + + + + + + +``` + +### Detail Panel (Right) + +Ideal for showing detailed information without leaving the current context. + +```jsx-live + + View Details + + + + Product Details + × + + + + Detailed product information and specifications. + +
+

Features

+
    +
  • High-quality materials
  • +
  • 2-year warranty
  • +
  • Free shipping
  • +
  • 30-day returns
  • +
+
+
+ + + + +
+
+``` + +### Notifications (Top) + +Great for displaying notifications or search results from the top. + +```jsx-live + + Show Notifications + + + + Recent Notifications + × + + +
+
+ New message +

+ You have a new message from Alice +

+
+
+ System update +

+ Security update available +

+
+
+
+
+
+``` + +### Action Sheet (Bottom) + +Perfect for mobile action sheets and contextual menus. + +```jsx-live + + Show Options + + + + Quick Actions + × + + +
+ + + + +
+
+
+
+``` + +## Sizes + +Drawers support both standard sizes and drawer-specific variants: + +### Standard Sizes +- `xs`, `sm`, `md` (default), `lg`, `xl` + +### Drawer-Specific Sizes +- `narrow` - Compact drawer for tool panels or minimal navigation +- `wide` - Extended drawer for detailed content or forms +- `cover` - 80% of screen size +- `full` - Full screen coverage + +```jsx-live +
+ + Narrow + + + + Narrow + × + + +
+

Compact drawer

+
+
+
+
+ + + Wide + + + + Wide Drawer + × + + +

This wide drawer has more space for detailed content.

+
+
+
+
+``` + +## Controlled Usage + +Control the drawer state externally using the `isOpen` and `onOpenChange` props: + +```jsx-live +function ControlledDrawer() { + const [isOpen, setIsOpen] = React.useState(false); + + return ( +
+
+ + +
+

Drawer is {isOpen ? 'open' : 'closed'}

+ + + + + + Controlled Drawer + × + + +

This drawer is controlled by external state.

+ +
+
+
+
+ ); +} +``` + +## Differences from Modal + +While Drawer extends the Modal component, it has several key differences: + +### Positioning +- **Modal**: Centers content on screen with various placements (center, top, bottom) +- **Drawer**: Attaches to screen edges with full height/width positioning + +### Animation +- **Modal**: Primarily scale and fade animations +- **Drawer**: Edge-specific slide animations (slide-in-left, slide-in-right, etc.) + +### Sizing +- **Modal**: Width-based sizing (xs to xl) +- **Drawer**: Side-aware sizing (width for left/right, height for top/bottom) + +### Use Cases +- **Modal**: Focused tasks, confirmations, forms that need full attention +- **Drawer**: Navigation, supplementary information, contextual actions + +### API Differences + +| Feature | Modal | Drawer | +|---------|-------|--------| +| Primary prop | `placement` | `side` | +| Sizing | Width-focused | Side-aware | +| Motion | Scale, slide variants | Edge-specific slides | +| Shape | Rounded corners all sides | Edge-specific rounding | + +## Accessibility + +Drawer inherits all accessibility features from the Modal base: + +- **Focus management**: Automatically traps and restores focus +- **Keyboard navigation**: Escape key dismissal, Tab navigation within drawer +- **Screen reader support**: Proper labeling with title and description +- **WCAG 2.1 AA compliant**: Meets accessibility standards + +### Keyboard Interactions + +| Key | Action | +|-----|--------| +| `Escape` | Closes the drawer | +| `Tab` | Moves focus to the next focusable element within the drawer | +| `Shift + Tab` | Moves focus to the previous focusable element within the drawer | + +### ARIA Attributes + +- The drawer content has `role="dialog"` +- Title is linked via `aria-labelledby` +- Description is linked via `aria-describedby` +- Close button has appropriate `aria-label` + +## API Reference + + + +## Related Components + +- **[Modal](../modal)** - The base component that Drawer extends +- **[Dialog](../dialog)** - For focused modal dialogs +- **[Popover](../popover)** - For lightweight contextual overlays \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/drawer.recipe.ts b/packages/nimbus/src/components/drawer/drawer.recipe.ts new file mode 100644 index 000000000..166a41bba --- /dev/null +++ b/packages/nimbus/src/components/drawer/drawer.recipe.ts @@ -0,0 +1,363 @@ +import { defineSlotRecipe } from "@chakra-ui/react/styled-system"; + +export const drawerSlotRecipe = defineSlotRecipe({ + slots: [ + "trigger", + "backdrop", + "positioner", + "content", + "title", + "description", + "closeTrigger", + "header", + "body", + "footer", + ], + className: "nimbus-drawer", + base: { + trigger: { + // Inherits button styling from base theme + }, + backdrop: { + bg: { + _dark: "bg/50", + _light: "fg/50", + }, + pos: "fixed", + left: 0, + top: 0, + w: "100vw", + h: "100dvh", + zIndex: "modal", + _open: { + animationName: "fade-in", + animationDuration: "slow", + }, + _closed: { + animationName: "fade-out", + animationDuration: "moderate", + }, + }, + positioner: { + display: "flex", + width: "100vw", + height: "100dvh", + position: "fixed", + left: 0, + top: 0, + "--drawer-z-index": "zIndex.modal", + zIndex: "calc(var(--drawer-z-index) + var(--layer-index, 0))", + overscrollBehaviorY: "none", + }, + content: { + display: "flex", + flexDirection: "column", + position: "relative", + outline: 0, + textStyle: "sm", + "--drawer-z-index": "zIndex.modal", + zIndex: "calc(var(--drawer-z-index) + var(--layer-index, 0))", + bg: "bg", + boxShadow: "lg", + _open: { + animationDuration: "moderate", + }, + _closed: { + animationDuration: "faster", + }, + }, + header: { + flex: 0, + px: "600", + pt: "600", + pb: "400", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + }, + body: { + flex: "1", + px: "600", + pt: "200", + pb: "600", + }, + footer: { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: "300", + px: "600", + pt: "200", + pb: "400", + }, + title: { + textStyle: "lg", + fontWeight: "semibold", + flex: 1, + mr: "400", + }, + description: { + color: "fg.muted", + mt: "200", + }, + closeTrigger: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + position: "relative", + outline: 0, + border: 0, + bg: "transparent", + cursor: "pointer", + borderRadius: "100", + color: "fg.muted", + p: "200", + minW: "800", + minH: "800", + _hover: { + bg: "bg.muted", + color: "fg", + }, + _focusVisible: { + boxShadow: "outline", + }, + _disabled: { + opacity: 0.5, + cursor: "not-allowed", + }, + }, + }, + variants: { + side: { + left: { + positioner: { + justifyContent: "flex-start", + alignItems: "stretch", + }, + content: { + height: "100vh", + borderTopRightRadius: "200", + borderBottomRightRadius: "200", + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }, + }, + right: { + positioner: { + justifyContent: "flex-end", + alignItems: "stretch", + }, + content: { + height: "100vh", + borderTopLeftRadius: "200", + borderBottomLeftRadius: "200", + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + }, + }, + top: { + positioner: { + alignItems: "flex-start", + justifyContent: "stretch", + }, + content: { + width: "100vw", + borderBottomLeftRadius: "200", + borderBottomRightRadius: "200", + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + }, + }, + bottom: { + positioner: { + alignItems: "flex-end", + justifyContent: "stretch", + }, + content: { + width: "100vw", + borderTopLeftRadius: "200", + borderTopRightRadius: "200", + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + }, + }, + }, + scrollBehavior: { + inside: { + positioner: { + overflow: "hidden", + }, + content: { + maxH: "100vh", + }, + body: { + overflow: "auto", + }, + }, + outside: { + positioner: { + overflow: "auto", + pointerEvents: "auto", + }, + }, + }, + size: { + xs: { + content: { + "&[data-side=left], &[data-side=right]": { + maxW: "xs", + w: "xs", + }, + "&[data-side=top], &[data-side=bottom]": { + maxH: "xs", + h: "xs", + }, + }, + }, + sm: { + content: { + "&[data-side=left], &[data-side=right]": { + maxW: "sm", + w: "sm", + }, + "&[data-side=top], &[data-side=bottom]": { + maxH: "sm", + h: "sm", + }, + }, + }, + md: { + content: { + "&[data-side=left], &[data-side=right]": { + maxW: "md", + w: "md", + }, + "&[data-side=top], &[data-side=bottom]": { + maxH: "md", + h: "md", + }, + }, + }, + lg: { + content: { + "&[data-side=left], &[data-side=right]": { + maxW: "lg", + w: "lg", + }, + "&[data-side=top], &[data-side=bottom]": { + maxH: "lg", + h: "lg", + }, + }, + }, + xl: { + content: { + "&[data-side=left], &[data-side=right]": { + maxW: "xl", + w: "xl", + }, + "&[data-side=top], &[data-side=bottom]": { + maxH: "xl", + h: "xl", + }, + }, + }, + narrow: { + content: { + "&[data-side=left], &[data-side=right]": { + maxW: "2xs", + w: "2xs", + }, + "&[data-side=top], &[data-side=bottom]": { + maxH: "2xs", + h: "2xs", + }, + }, + }, + wide: { + content: { + "&[data-side=left], &[data-side=right]": { + maxW: "2xl", + w: "2xl", + }, + "&[data-side=top], &[data-side=bottom]": { + maxH: "2xl", + h: "2xl", + }, + }, + }, + cover: { + content: { + "&[data-side=left], &[data-side=right]": { + width: "80vw", + maxW: "80vw", + height: "100vh", + }, + "&[data-side=top], &[data-side=bottom]": { + height: "80vh", + maxH: "80vh", + width: "100vw", + }, + }, + }, + full: { + content: { + maxW: "100vw", + width: "100vw", + minH: "100vh", + height: "100vh", + borderRadius: "0", + }, + }, + }, + motionPreset: { + "slide-in-left": { + content: { + _open: { + animationName: "slide-from-left, fade-in", + }, + _closed: { + animationName: "slide-to-left, fade-out", + }, + }, + }, + "slide-in-right": { + content: { + _open: { + animationName: "slide-from-right, fade-in", + }, + _closed: { + animationName: "slide-to-right, fade-out", + }, + }, + }, + "slide-in-top": { + content: { + _open: { + animationName: "slide-from-top, fade-in", + }, + _closed: { + animationName: "slide-to-top, fade-out", + }, + }, + }, + "slide-in-bottom": { + content: { + _open: { + animationName: "slide-from-bottom, fade-in", + }, + _closed: { + animationName: "slide-to-bottom, fade-out", + }, + }, + }, + none: {}, + }, + }, + defaultVariants: { + side: "left", + size: "md", + scrollBehavior: "outside", + motionPreset: "slide-in-left", + }, +}); \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/drawer.slots.tsx b/packages/nimbus/src/components/drawer/drawer.slots.tsx new file mode 100644 index 000000000..c4c394c31 --- /dev/null +++ b/packages/nimbus/src/components/drawer/drawer.slots.tsx @@ -0,0 +1,120 @@ +import { + createSlotRecipeContext, + type HTMLChakraProps, +} from "@chakra-ui/react/styled-system"; +import { drawerSlotRecipe } from "./drawer.recipe"; + +const { withProvider, withContext } = createSlotRecipeContext({ + recipe: drawerSlotRecipe, +}); + +/** + * DrawerRootSlot - Root slot component that provides styling context + */ +export type DrawerRootSlotProps = HTMLChakraProps<"div">; +export const DrawerRootSlot = withProvider( + "div", + "root", + { forwardAsChild: true } +); +DrawerRootSlot.displayName = "DrawerRootSlot"; + +/** + * DrawerTriggerSlot - Trigger button slot component + */ +export type DrawerTriggerSlotProps = HTMLChakraProps<"button">; +export const DrawerTriggerSlot = withContext( + "button", + "trigger" +); +DrawerTriggerSlot.displayName = "DrawerTriggerSlot"; + +/** + * DrawerBackdropSlot - Backdrop overlay slot component + */ +export type DrawerBackdropSlotProps = HTMLChakraProps<"div">; +export const DrawerBackdropSlot = withContext( + "div", + "backdrop" +); +DrawerBackdropSlot.displayName = "DrawerBackdropSlot"; + +/** + * DrawerPositionerSlot - Positioner slot component + */ +export type DrawerPositionerSlotProps = HTMLChakraProps<"div">; +export const DrawerPositionerSlot = withContext( + "div", + "positioner" +); +DrawerPositionerSlot.displayName = "DrawerPositionerSlot"; + +/** + * DrawerContentSlot - Main content slot component + */ +export type DrawerContentSlotProps = HTMLChakraProps<"div">; +export const DrawerContentSlot = withContext( + "div", + "content" +); +DrawerContentSlot.displayName = "DrawerContentSlot"; + +/** + * DrawerHeaderSlot - Header section slot component + */ +export type DrawerHeaderSlotProps = HTMLChakraProps<"header">; +export const DrawerHeaderSlot = withContext( + "header", + "header" +); +DrawerHeaderSlot.displayName = "DrawerHeaderSlot"; + +/** + * DrawerBodySlot - Body content slot component + */ +export type DrawerBodySlotProps = HTMLChakraProps<"div">; +export const DrawerBodySlot = withContext( + "div", + "body" +); +DrawerBodySlot.displayName = "DrawerBodySlot"; + +/** + * DrawerFooterSlot - Footer section slot component + */ +export type DrawerFooterSlotProps = HTMLChakraProps<"footer">; +export const DrawerFooterSlot = withContext( + "footer", + "footer" +); +DrawerFooterSlot.displayName = "DrawerFooterSlot"; + +/** + * DrawerTitleSlot - Title element slot component + */ +export type DrawerTitleSlotProps = HTMLChakraProps<"h2">; +export const DrawerTitleSlot = withContext( + "h2", + "title" +); +DrawerTitleSlot.displayName = "DrawerTitleSlot"; + +/** + * DrawerDescriptionSlot - Description element slot component + */ +export type DrawerDescriptionSlotProps = HTMLChakraProps<"p">; +export const DrawerDescriptionSlot = withContext( + "p", + "description" +); +DrawerDescriptionSlot.displayName = "DrawerDescriptionSlot"; + +/** + * DrawerCloseTriggerSlot - Close button slot component + */ +export type DrawerCloseTriggerSlotProps = HTMLChakraProps<"button">; +export const DrawerCloseTriggerSlot = withContext( + "button", + "closeTrigger" +); +DrawerCloseTriggerSlot.displayName = "DrawerCloseTriggerSlot"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/drawer.stories.tsx b/packages/nimbus/src/components/drawer/drawer.stories.tsx new file mode 100644 index 000000000..6dc8a0ea6 --- /dev/null +++ b/packages/nimbus/src/components/drawer/drawer.stories.tsx @@ -0,0 +1,571 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { expect } from '@storybook/test'; +import { within, userEvent, waitFor } from '@storybook/test'; +import { Drawer } from './drawer'; + +const meta = { + title: 'Components/Overlay/Drawer', + component: Drawer.Root, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +A drawer component optimized for edge-positioned sliding panels, perfect for navigation, filters, details, and mobile-first interfaces. + +Built on the Modal base component with automatic placement and animation mapping based on the \`side\` prop. + +**Key Features:** +- Edge positioning: left, right, top, bottom +- Automatic placement and motion preset mapping +- Drawer-specific sizes: narrow, wide, plus standard sizes +- Full accessibility with React Aria Components +- Portal rendering and focus management +- Keyboard navigation and dismissal + +**Side Mapping:** +- \`side="left"\` → placement="left", motionPreset="slide-in-left" +- \`side="right"\` → placement="right", motionPreset="slide-in-right" +- \`side="top"\` → placement="top", motionPreset="slide-in-top" +- \`side="bottom"\` → placement="bottom", motionPreset="slide-in-bottom" + `, + }, + }, + }, + argTypes: { + // Root props + isOpen: { + control: 'boolean', + description: 'Whether the drawer is open (controlled mode)', + table: { category: 'Root' }, + }, + defaultOpen: { + control: 'boolean', + description: 'Whether the drawer is open by default (uncontrolled mode)', + table: { category: 'Root' }, + }, + isDisabled: { + control: 'boolean', + description: 'Whether the drawer is disabled', + table: { category: 'Root' }, + }, + // Content props + side: { + control: 'select', + options: ['left', 'right', 'top', 'bottom'], + description: 'Which edge of the screen the drawer slides in from', + table: { category: 'Content' }, + }, + size: { + control: 'select', + options: ['xs', 'sm', 'md', 'lg', 'xl', 'narrow', 'wide', 'cover', 'full'], + description: 'Size of the drawer', + table: { category: 'Content' }, + }, + hasBackdrop: { + control: 'boolean', + description: 'Whether to show the backdrop overlay', + table: { category: 'Content' }, + }, + isDismissable: { + control: 'boolean', + description: 'Whether the drawer should close when clicking outside', + table: { category: 'Content' }, + }, + scrollBehavior: { + control: 'select', + options: ['inside', 'outside'], + description: 'Scroll behavior for drawer content', + table: { category: 'Content' }, + }, + }, + args: { + side: 'left', + size: 'md', + hasBackdrop: true, + isDismissable: true, + scrollBehavior: 'outside', + isDisabled: false, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const NavigationContent = ({ side = 'left' }) => ( + <> + + + Navigation + × + + + + + +); + +const DetailContent = ({ side = 'right' }) => ( + <> + + + Product Details + × + + + + Detailed information about the selected product including specifications, reviews, and availability. + +
+

Specifications

+
    +
  • + Brand: Acme Corp +
  • +
  • + Model: AC-2024 +
  • +
  • + Weight: 2.5 lbs +
  • +
  • + Dimensions: 12" x 8" x 3" +
  • +
+
+
+ + + + + +); + +const NotificationContent = ({ side = 'top' }) => ( + <> + + + Notifications + × + + +
+
+
New message received
+
+ You have a new message from John Doe +
+
+ 2 minutes ago +
+
+
+
System update available
+
+ A new version is ready to install +
+
+ 1 hour ago +
+
+
+
+ +); + +const ActionSheetContent = ({ side = 'bottom' }) => ( + <> + + + Quick Actions + × + + +
+ + + + +
+
+ +); + +/** + * Left-side drawer for navigation panels and menus. + * Perfect for primary navigation in web applications. + */ +export const LeftNavigation: Story = { + args: { + side: 'left', + size: 'md', + }, + render: (args) => ( + + Open Navigation + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Find and click the trigger + const trigger = canvas.getByText('Open Navigation'); + await userEvent.click(trigger); + + // Wait for drawer to open and verify content + await waitFor(async () => { + const title = canvas.getByText('Navigation'); + await expect(title).toBeInTheDocument(); + }); + + // Verify navigation links are present + await expect(canvas.getByText('🏠 Dashboard')).toBeInTheDocument(); + await expect(canvas.getByText('👤 Profile')).toBeInTheDocument(); + + // Test close button + const closeButton = canvas.getByLabelText('Close navigation'); + await userEvent.click(closeButton); + + // Verify drawer closes + await waitFor(async () => { + await expect(canvas.queryByText('Navigation')).not.toBeInTheDocument(); + }); + }, +}; + +/** + * Right-side drawer for detail panels and secondary information. + * Great for product details, user profiles, or contextual information. + */ +export const RightDetails: Story = { + args: { + side: 'right', + size: 'wide', + }, + render: (args) => ( + + View Details + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Open the drawer + const trigger = canvas.getByText('View Details'); + await userEvent.click(trigger); + + // Verify content loads + await waitFor(async () => { + const title = canvas.getByText('Product Details'); + await expect(title).toBeInTheDocument(); + }); + + // Verify description and specifications are present + await expect(canvas.getByText(/Detailed information about the selected product/)).toBeInTheDocument(); + await expect(canvas.getByText('Specifications')).toBeInTheDocument(); + await expect(canvas.getByText('Brand:')).toBeInTheDocument(); + + // Test footer buttons + await expect(canvas.getByText('Add to Cart')).toBeInTheDocument(); + await expect(canvas.getByText('Add to Wishlist')).toBeInTheDocument(); + }, +}; + +/** + * Top drawer for notifications and search results. + * Excellent for displaying temporary information from the top of the screen. + */ +export const TopNotifications: Story = { + args: { + side: 'top', + size: 'lg', + }, + render: (args) => ( + + Show Notifications + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Open the drawer + const trigger = canvas.getByText('Show Notifications'); + await userEvent.click(trigger); + + // Verify notifications content + await waitFor(async () => { + const title = canvas.getByText('Notifications'); + await expect(title).toBeInTheDocument(); + }); + + // Verify notification items + await expect(canvas.getByText('New message received')).toBeInTheDocument(); + await expect(canvas.getByText('System update available')).toBeInTheDocument(); + await expect(canvas.getByText('2 minutes ago')).toBeInTheDocument(); + }, +}; + +/** + * Bottom drawer for action sheets and mobile menus. + * Perfect for mobile-first interfaces and contextual actions. + */ +export const BottomActionSheet: Story = { + args: { + side: 'bottom', + size: 'sm', + }, + render: (args) => ( + + Show Actions + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Open the action sheet + const trigger = canvas.getByText('Show Actions'); + await userEvent.click(trigger); + + // Verify action sheet content + await waitFor(async () => { + const title = canvas.getByText('Quick Actions'); + await expect(title).toBeInTheDocument(); + }); + + // Verify action buttons + await expect(canvas.getByText('Share')).toBeInTheDocument(); + await expect(canvas.getByText('Copy')).toBeInTheDocument(); + await expect(canvas.getByText('Favorite')).toBeInTheDocument(); + await expect(canvas.getByText('Delete')).toBeInTheDocument(); + }, +}; + +/** + * Narrow drawer for compact navigation or tool panels. + */ +export const NarrowDrawer: Story = { + args: { + side: 'left', + size: 'narrow', + }, + render: (args) => ( + + Narrow Menu + + + + Tools + × + + +
+ + + + +
+
+
+
+ ), +}; + +/** + * Full-width drawer that covers the entire screen. + */ +export const FullDrawer: Story = { + args: { + side: 'left', + size: 'full', + }, + render: (args) => ( + + Full Screen + + + + Full Screen View + × + + +
+

Full Screen Content

+

+ This drawer takes up the full screen, perfect for immersive experiences + or when you need maximum space for content. +

+
+
+
+
+ ), +}; + +/** + * Controlled drawer with external state management. + */ +export const ControlledDrawer: Story = { + args: { + side: 'right', + size: 'md', + isOpen: false, + }, + render: (args) => { + const [isOpen, setIsOpen] = React.useState(args.isOpen || false); + + return ( +
+
+ + +

+ Drawer is {isOpen ? 'open' : 'closed'} +

+
+ + + + + + Controlled Drawer + × + + +

+ This drawer's open state is controlled by external state. + You can open/close it from buttons outside the drawer. +

+ +
+
+
+
+ ); + }, +}; + +/** + * Drawer with scrollable content demonstrating inside scroll behavior. + */ +export const ScrollableContent: Story = { + args: { + side: 'right', + size: 'md', + scrollBehavior: 'inside', + }, + render: (args) => ( + + Scrollable Content + + + + Long Content + × + + +
+ {Array.from({ length: 50 }, (_, i) => ( +

+ This is paragraph {i + 1} of long scrollable content. + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +

+ ))} +
+
+ + + +
+
+ ), +}; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/drawer.tsx b/packages/nimbus/src/components/drawer/drawer.tsx new file mode 100644 index 000000000..320b644b4 --- /dev/null +++ b/packages/nimbus/src/components/drawer/drawer.tsx @@ -0,0 +1,127 @@ +import { DrawerRoot } from "./components/drawer.root"; +import { DrawerTrigger } from "./components/drawer.trigger"; +import { DrawerContent } from "./components/drawer.content"; +import { DrawerBackdrop } from "./components/drawer.backdrop"; +import { DrawerHeader } from "./components/drawer.header"; +import { DrawerBody } from "./components/drawer.body"; +import { DrawerFooter } from "./components/drawer.footer"; +import { DrawerTitle } from "./components/drawer.title"; +import { DrawerDescription } from "./components/drawer.description"; +import { DrawerCloseTrigger } from "./components/drawer.close-trigger"; + +// Re-export types +export type * from "./drawer.types"; + +/** + * Drawer + * ============================================================ + * A drawer component optimized for edge-positioned sliding panels. + * Built on the Modal base component with React Aria Components for accessibility and WCAG 2.1 AA compliance. + * + * Perfect for navigation panels, detail views, filters, and mobile-first interfaces. + * + * Key Features: + * - Edge positioning with automatic placement and animation mapping + * - Four sides supported: left, right, top, bottom + * - Controlled and uncontrolled modes + * - Customizable sizes including drawer-specific narrow/wide variants + * - Focus management and keyboard navigation + * - Click-outside and Escape key dismissal + * - Portal rendering support + * - Backdrop overlay with animations + * + * The `side` prop automatically configures placement and motion: + * - side="left" → placement="left", motionPreset="slide-in-left" + * - side="right" → placement="right", motionPreset="slide-in-right" + * - side="top" → placement="top", motionPreset="slide-in-top" + * - side="bottom" → placement="bottom", motionPreset="slide-in-bottom" + * + * @example + * // Left navigation drawer + * + * Open Menu + * + * + * + * Navigation + * × + * + * + * + * + * + * + * + * @example + * // Right detail panel drawer + * + * View Details + * + * + * + * Product Details + * × + * + * + * + * Detailed product information and specifications. + * + * product details + * + * + * + * + * + * + * + * + * @example + * // Bottom mobile action sheet + * + * Show Options + * + * + * + * Actions + * × + * + * + * + * + * + * + * + * + * + * @see https://react-spectrum.adobe.com/react-aria/Dialog.html + * @see Modal component (the base component this extends) + */ +export const Drawer = { + Root: DrawerRoot, // MUST BE FIRST - primary entry point + Trigger: DrawerTrigger, + Content: DrawerContent, + Backdrop: DrawerBackdrop, + Header: DrawerHeader, + Body: DrawerBody, + Footer: DrawerFooter, + Title: DrawerTitle, + Description: DrawerDescription, + CloseTrigger: DrawerCloseTrigger, +}; + +// Internal exports for react-docgen +export { + DrawerRoot as _DrawerRoot, + DrawerTrigger as _DrawerTrigger, + DrawerContent as _DrawerContent, + DrawerBackdrop as _DrawerBackdrop, + DrawerHeader as _DrawerHeader, + DrawerBody as _DrawerBody, + DrawerFooter as _DrawerFooter, + DrawerTitle as _DrawerTitle, + DrawerDescription as _DrawerDescription, + DrawerCloseTrigger as _DrawerCloseTrigger, +}; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/drawer.types.ts b/packages/nimbus/src/components/drawer/drawer.types.ts new file mode 100644 index 000000000..c1da5cbf7 --- /dev/null +++ b/packages/nimbus/src/components/drawer/drawer.types.ts @@ -0,0 +1,236 @@ +import { type ComponentProps } from "react"; +import { type RecipeVariantProps } from "@chakra-ui/react"; +import { drawerSlotRecipe } from "./drawer.recipe"; + +/** + * Side position options for the drawer + */ +export type DrawerSide = "left" | "right" | "top" | "bottom"; + +/** + * Size variants specific to drawer positioning + */ +export type DrawerSize = + | "xs" | "sm" | "md" | "lg" | "xl" // Standard sizes + | "full" | "cover" // Full screen variants + | "narrow" | "wide"; // Drawer-specific sizes + +/** + * Props for the Drawer.Root component + * + * The root component that provides context and state management for the drawer. + * Extends Modal.Root with drawer-specific functionality. + */ +export interface DrawerRootProps { + /** + * The children components (Trigger, Content, etc.) + */ + children: React.ReactNode; + + /** + * Whether the drawer is open (controlled mode) + */ + isOpen?: boolean; + + /** + * Callback fired when the drawer open state changes + * @param isOpen - Whether the drawer is now open + */ + onOpenChange?: (isOpen: boolean) => void; + + /** + * Whether the drawer is open by default (uncontrolled mode) + * @default false + */ + defaultOpen?: boolean; + + /** + * Whether the drawer is disabled + * @default false + */ + isDisabled?: boolean; +} + +/** + * Props for the Drawer.Trigger component + * + * The trigger element that opens the drawer when activated. + */ +export interface DrawerTriggerProps extends ComponentProps<"button"> { + /** + * The trigger content + */ + children: React.ReactNode; +} + +/** + * Props for the Drawer.Content component + * + * The main drawer content container that extends Modal.Content with + * edge-positioning and drawer-specific behavior. + */ +export interface DrawerContentProps + extends ComponentProps<"div">, + RecipeVariantProps { + /** + * The drawer content + */ + children: React.ReactNode; + + /** + * Which edge of the screen the drawer should slide in from + * Automatically maps to appropriate placement and motionPreset + * @default "left" + */ + side?: DrawerSide; + + /** + * Whether to render the drawer in a portal + * @default true + */ + isPortalled?: boolean; + + /** + * The container element for the portal + */ + portalContainer?: HTMLElement | (() => HTMLElement); + + /** + * Whether to show the backdrop overlay + * @default true + */ + hasBackdrop?: boolean; + + /** + * Whether the drawer should close when clicking outside + * @default true + */ + isDismissable?: boolean; + + /** + * Whether the drawer should close when pressing Escape + * @default true + */ + isKeyboardDismissDisabled?: boolean; + + /** + * Callback fired when the drawer requests to be closed + */ + onClose?: () => void; + + /** + * Whether the drawer should close when swiping + * @default false + */ + isSwipeDisabled?: boolean; +} + +/** + * Props for the Drawer.Backdrop component + * + * The backdrop overlay that appears behind the drawer content. + */ +export interface DrawerBackdropProps extends ComponentProps<"div"> { + /** + * Custom styles for the backdrop + */ + style?: React.CSSProperties; +} + +/** + * Props for the Drawer.Header component + * + * The header section of the drawer content. + */ +export interface DrawerHeaderProps extends ComponentProps<"header"> { + /** + * The header content + */ + children: React.ReactNode; +} + +/** + * Props for the Drawer.Body component + * + * The main body content section of the drawer. + */ +export interface DrawerBodyProps extends ComponentProps<"div"> { + /** + * The body content + */ + children: React.ReactNode; +} + +/** + * Props for the Drawer.Footer component + * + * The footer section of the drawer, typically containing action buttons. + */ +export interface DrawerFooterProps extends ComponentProps<"footer"> { + /** + * The footer content (usually buttons) + */ + children: React.ReactNode; +} + +/** + * Props for the Drawer.Title component + * + * The accessible title element for the drawer. + */ +export interface DrawerTitleProps extends ComponentProps<"h2"> { + /** + * The title text + */ + children: React.ReactNode; +} + +/** + * Props for the Drawer.Description component + * + * The accessible description element for the drawer. + */ +export interface DrawerDescriptionProps extends ComponentProps<"p"> { + /** + * The description text + */ + children: React.ReactNode; +} + +/** + * Props for the Drawer.CloseTrigger component + * + * A button that closes the drawer when activated. + */ +export interface DrawerCloseTriggerProps extends ComponentProps<"button"> { + /** + * The close button content (typically an icon) + */ + children: React.ReactNode; + + /** + * Accessible label for the close button + * @default "Close drawer" + */ + "aria-label"?: string; +} + +/** + * Placement variants for the drawer (mapped from side) + */ +export type DrawerPlacement = "left" | "right" | "top" | "bottom"; + +/** + * Scroll behavior variants for the drawer + */ +export type DrawerScrollBehavior = "inside" | "outside"; + +/** + * Motion preset variants for drawer animations (mapped from side) + */ +export type DrawerMotionPreset = + | "slide-in-left" + | "slide-in-right" + | "slide-in-top" + | "slide-in-bottom" + | "none"; \ No newline at end of file diff --git a/packages/nimbus/src/components/drawer/index.ts b/packages/nimbus/src/components/drawer/index.ts new file mode 100644 index 000000000..3eb1a732b --- /dev/null +++ b/packages/nimbus/src/components/drawer/index.ts @@ -0,0 +1,41 @@ +/** + * Drawer Component + * ============================================================ + * + * A drawer component optimized for edge-positioned sliding panels. + * Built on the Modal base component with automatic placement and motion mapping. + * + * Perfect for: + * - Navigation panels (left/right) + * - Detail views (right) + * - Notifications (top) + * - Action sheets (bottom) + * - Mobile-first interfaces + * + * @see {@link https://react-spectrum.adobe.com/react-aria/Dialog.html} React Aria Dialog + */ + +// Main component export +export { Drawer } from './drawer'; + +// Type exports +export type { + DrawerRootProps, + DrawerTriggerProps, + DrawerContentProps, + DrawerBackdropProps, + DrawerHeaderProps, + DrawerBodyProps, + DrawerFooterProps, + DrawerTitleProps, + DrawerDescriptionProps, + DrawerCloseTriggerProps, + DrawerSide, + DrawerSize, + DrawerPlacement, + DrawerScrollBehavior, + DrawerMotionPreset, +} from './drawer.types'; + +// Recipe export for external styling +export { drawerSlotRecipe } from './drawer.recipe'; \ No newline at end of file diff --git a/packages/nimbus/src/components/index.ts b/packages/nimbus/src/components/index.ts index ef0265156..da85a8c68 100644 --- a/packages/nimbus/src/components/index.ts +++ b/packages/nimbus/src/components/index.ts @@ -4,6 +4,7 @@ export * from "./button"; export * from "./code"; export * from "./combobox"; export * from "./dialog"; +export * from "./drawer"; export * from "./flex"; export * from "./group"; export * from "./heading"; @@ -49,6 +50,7 @@ export * from "./date-picker"; export * from "./progress-bar"; export * from "./range-calendar"; export * from "./menu"; +export * from "./modal"; export * from "./date-range-picker"; export * from "./toolbar"; export * from "./rich-text-input"; diff --git a/packages/nimbus/src/components/modal/components/index.ts b/packages/nimbus/src/components/modal/components/index.ts new file mode 100644 index 000000000..1e1ad10e8 --- /dev/null +++ b/packages/nimbus/src/components/modal/components/index.ts @@ -0,0 +1,10 @@ +export { ModalRoot } from "./modal.root"; +export { ModalTrigger } from "./modal.trigger"; +export { ModalContent } from "./modal.content"; +export { ModalBackdrop } from "./modal.backdrop"; +export { ModalHeader } from "./modal.header"; +export { ModalBody } from "./modal.body"; +export { ModalFooter } from "./modal.footer"; +export { ModalTitle } from "./modal.title"; +export { ModalDescription } from "./modal.description"; +export { ModalCloseTrigger } from "./modal.close-trigger"; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/components/modal.backdrop.tsx b/packages/nimbus/src/components/modal/components/modal.backdrop.tsx new file mode 100644 index 000000000..f04902121 --- /dev/null +++ b/packages/nimbus/src/components/modal/components/modal.backdrop.tsx @@ -0,0 +1,35 @@ +import { forwardRef } from "react"; +import { ModalOverlay as RaModalOverlay } from "react-aria-components"; +import { ModalBackdropSlot } from "../modal.slots"; +import type { ModalBackdropProps } from "../modal.types"; + +/** + * # Modal.Backdrop + * + * The backdrop overlay that appears behind the modal content. + * Provides a semi-transparent overlay and handles click-outside-to-close behavior. + * + * @example + * ```tsx + * + * Open Modal + * + * + * ... + * + * + * ``` + */ +export const ModalBackdrop = forwardRef( + (props, ref) => { + const { style, ...restProps } = props; + + return ( + + + + ); + } +); + +ModalBackdrop.displayName = "Modal.Backdrop"; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/components/modal.body.tsx b/packages/nimbus/src/components/modal/components/modal.body.tsx new file mode 100644 index 000000000..f9a47796a --- /dev/null +++ b/packages/nimbus/src/components/modal/components/modal.body.tsx @@ -0,0 +1,34 @@ +import { forwardRef } from "react"; +import { ModalBodySlot } from "../modal.slots"; +import type { ModalBodyProps } from "../modal.types"; + +/** + * # Modal.Body + * + * The main body content section of the modal. + * Contains the primary modal content and handles overflow/scrolling. + * + * @example + * ```tsx + * + * ... + * + *

This is the main content of the modal.

+ *
+ * ... + *
+ * ``` + */ +export const ModalBody = forwardRef( + (props, ref) => { + const { children, ...restProps } = props; + + return ( + + {children} + + ); + } +); + +ModalBody.displayName = "Modal.Body"; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/components/modal.close-trigger.tsx b/packages/nimbus/src/components/modal/components/modal.close-trigger.tsx new file mode 100644 index 000000000..419525a83 --- /dev/null +++ b/packages/nimbus/src/components/modal/components/modal.close-trigger.tsx @@ -0,0 +1,53 @@ +import { forwardRef } from "react"; +import { Button as RaButton } from "react-aria-components"; +import { ModalCloseTriggerSlot } from "../modal.slots"; +import type { ModalCloseTriggerProps } from "../modal.types"; + +/** + * # Modal.CloseTrigger + * + * A button that closes the modal when activated. + * Uses React Aria's Button for accessibility and keyboard support. + * + * The component automatically handles the close behavior through React Aria's + * context, so no additional onPress handler is needed. + * + * @example + * ```tsx + * + * Open Modal + * + * + * Title + * + * + * + * + * Content + * + * + * ``` + */ +export const ModalCloseTrigger = forwardRef( + (props, ref) => { + const { + children, + "aria-label": ariaLabel = "Close modal", + ...restProps + } = props; + + return ( + + + {children} + + + ); + } +); + +ModalCloseTrigger.displayName = "Modal.CloseTrigger"; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/components/modal.content.tsx b/packages/nimbus/src/components/modal/components/modal.content.tsx new file mode 100644 index 000000000..829fd3e18 --- /dev/null +++ b/packages/nimbus/src/components/modal/components/modal.content.tsx @@ -0,0 +1,62 @@ +import { forwardRef } from "react"; +import { Modal as RaModal, Dialog as RaDialog } from "react-aria-components"; +import { + ModalPositionerSlot, + ModalContentSlot +} from "../modal.slots"; +import type { ModalContentProps } from "../modal.types"; + +/** + * # Modal.Content + * + * The main modal content container that wraps React Aria's Modal and Dialog. + * Handles portalling, backdrop, positioning, and content styling. + * + * This component creates the modal overlay, positions the content, and provides + * accessibility features like focus management and keyboard dismissal. + * + * @example + * ```tsx + * + * Open Modal + * + * + * Title + * + * Content + * Actions + * + * + * ``` + */ +export const ModalContent = forwardRef( + (props, ref) => { + const { + children, + isPortalled = true, + portalContainer, + hasBackdrop = true, + isDismissable = true, + isKeyboardDismissDisabled = false, + onClose, + ...restProps + } = props; + + return ( + + + + + {children} + + + + + ); + } +); + +ModalContent.displayName = "Modal.Content"; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/components/modal.description.tsx b/packages/nimbus/src/components/modal/components/modal.description.tsx new file mode 100644 index 000000000..8928a684c --- /dev/null +++ b/packages/nimbus/src/components/modal/components/modal.description.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; +import { Text as RaText } from "react-aria-components"; +import { ModalDescriptionSlot } from "../modal.slots"; +import type { ModalDescriptionProps } from "../modal.types"; + +/** + * # Modal.Description + * + * The accessible description element for the modal. + * Uses React Aria's Text for proper accessibility and screen reader support. + * + * @example + * ```tsx + * + * + * Delete Item + * This action cannot be undone. + * + * ... + * + * ``` + */ +export const ModalDescription = forwardRef( + (props, ref) => { + const { children, ...restProps } = props; + + return ( + + + {children} + + + ); + } +); + +ModalDescription.displayName = "Modal.Description"; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/components/modal.footer.tsx b/packages/nimbus/src/components/modal/components/modal.footer.tsx new file mode 100644 index 000000000..68a17da8b --- /dev/null +++ b/packages/nimbus/src/components/modal/components/modal.footer.tsx @@ -0,0 +1,35 @@ +import { forwardRef } from "react"; +import { ModalFooterSlot } from "../modal.slots"; +import type { ModalFooterProps } from "../modal.types"; + +/** + * # Modal.Footer + * + * The footer section of the modal, typically containing action buttons. + * Provides consistent spacing and alignment for modal actions. + * + * @example + * ```tsx + * + * ... + * ... + * + * + * + * + * + * ``` + */ +export const ModalFooter = forwardRef( + (props, ref) => { + const { children, ...restProps } = props; + + return ( + + {children} + + ); + } +); + +ModalFooter.displayName = "Modal.Footer"; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/components/modal.header.tsx b/packages/nimbus/src/components/modal/components/modal.header.tsx new file mode 100644 index 000000000..8ee8d70ec --- /dev/null +++ b/packages/nimbus/src/components/modal/components/modal.header.tsx @@ -0,0 +1,34 @@ +import { forwardRef } from "react"; +import { ModalHeaderSlot } from "../modal.slots"; +import type { ModalHeaderProps } from "../modal.types"; + +/** + * # Modal.Header + * + * The header section of the modal content. + * Typically contains the title and close button. + * + * @example + * ```tsx + * + * + * Modal Title + * + * + * ... + * + * ``` + */ +export const ModalHeader = forwardRef( + (props, ref) => { + const { children, ...restProps } = props; + + return ( + + {children} + + ); + } +); + +ModalHeader.displayName = "Modal.Header"; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/components/modal.root.tsx b/packages/nimbus/src/components/modal/components/modal.root.tsx new file mode 100644 index 000000000..9e0e15bf8 --- /dev/null +++ b/packages/nimbus/src/components/modal/components/modal.root.tsx @@ -0,0 +1,51 @@ +import { DialogTrigger as RaDialogTrigger } from "react-aria-components"; +import { ModalRootSlot } from "../modal.slots"; +import type { ModalRootProps } from "../modal.types"; + +/** + * # Modal.Root + * + * The root component that provides context and state management for the modal. + * Uses React Aria's DialogTrigger for accessibility and keyboard interaction. + * + * This component must wrap all modal parts (Trigger, Content, etc.) and provides + * the modal open/close state and variant styling context. + * + * @example + * ```tsx + * + * Open Modal + * + * + * Modal Title + * + * Modal content + * + * + * ``` + */ +export const ModalRoot = (props: ModalRootProps) => { + const { + children, + isOpen, + onOpenChange, + defaultOpen = false, + isDisabled = false, + ...restProps + } = props; + + return ( + + + {children} + + + ); +}; + +ModalRoot.displayName = "Modal.Root"; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/components/modal.title.tsx b/packages/nimbus/src/components/modal/components/modal.title.tsx new file mode 100644 index 000000000..556b43cc6 --- /dev/null +++ b/packages/nimbus/src/components/modal/components/modal.title.tsx @@ -0,0 +1,36 @@ +import { forwardRef } from "react"; +import { Heading as RaHeading } from "react-aria-components"; +import { ModalTitleSlot } from "../modal.slots"; +import type { ModalTitleProps } from "../modal.types"; + +/** + * # Modal.Title + * + * The accessible title element for the modal. + * Uses React Aria's Heading for proper accessibility and screen reader support. + * + * @example + * ```tsx + * + * + * Confirm Action + * + * ... + * + * ``` + */ +export const ModalTitle = forwardRef( + (props, ref) => { + const { children, ...restProps } = props; + + return ( + + + {children} + + + ); + } +); + +ModalTitle.displayName = "Modal.Title"; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/components/modal.trigger.tsx b/packages/nimbus/src/components/modal/components/modal.trigger.tsx new file mode 100644 index 000000000..c06dc67b6 --- /dev/null +++ b/packages/nimbus/src/components/modal/components/modal.trigger.tsx @@ -0,0 +1,31 @@ +import { Button as RaButton } from "react-aria-components"; +import { ModalTriggerSlot } from "../modal.slots"; +import type { ModalTriggerProps } from "../modal.types"; + +/** + * # Modal.Trigger + * + * The trigger element that opens the modal when activated. + * Uses React Aria's Button for accessibility and keyboard support. + * + * @example + * ```tsx + * + * Open Modal + * ... + * + * ``` + */ +export const ModalTrigger = (props: ModalTriggerProps) => { + const { children, ...restProps } = props; + + return ( + + + {children} + + + ); +}; + +ModalTrigger.displayName = "Modal.Trigger"; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/index.ts b/packages/nimbus/src/components/modal/index.ts new file mode 100644 index 000000000..6eb1a44de --- /dev/null +++ b/packages/nimbus/src/components/modal/index.ts @@ -0,0 +1,18 @@ +export { Modal } from "./modal"; + +// Re-export types for external usage +export type * from "./modal.types"; + +// Re-export slot types for advanced usage +export type { + ModalRootSlotProps, + ModalTriggerSlotProps, + ModalContentSlotProps, + ModalBackdropSlotProps, + ModalHeaderSlotProps, + ModalBodySlotProps, + ModalFooterSlotProps, + ModalTitleSlotProps, + ModalDescriptionSlotProps, + ModalCloseTriggerSlotProps, +} from "./modal.slots"; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/modal.mdx b/packages/nimbus/src/components/modal/modal.mdx new file mode 100644 index 000000000..96686a1d1 --- /dev/null +++ b/packages/nimbus/src/components/modal/modal.mdx @@ -0,0 +1,391 @@ +--- +id: Components-Modal +title: Modal +description: A foundational modal component that serves as the base for both Dialog and Drawer components. +lifecycleState: Stable +order: 999 +menu: + - Components + - Feedback + - Modal +tags: + - component + - overlay + - modal + - dialog + - interactive +figmaLink: >- + https://www.figma.com/design/gHbAJGfcrCv7f2bgzUQgHq/NIMBUS-Guidelines?node-id=1695-45519&m +--- + +# Modal + +A foundational modal component that serves as the base for both Dialog and Drawer components. Built with React Aria Components for accessibility and WCAG 2.1 AA compliance. + +## Overview + +Modals are overlay windows that appear on top of the main content to display important information, gather user input, or require user decisions. They temporarily disable the main interface until the user completes an action or dismisses the modal. + +### Key Features + +- **Accessibility First**: Built with React Aria Components for WCAG 2.1 AA compliance +- **Flexible Positioning**: Support for center, top, and bottom placements +- **Responsive Sizing**: Multiple size variants from xs to full-screen +- **Smooth Animations**: Customizable motion presets for entrance/exit +- **Focus Management**: Automatic focus trapping and restoration +- **Keyboard Navigation**: Full keyboard support including Escape to close +- **Scroll Handling**: Options for inside or outside scrolling behavior + +### Resources + +Deep dive on details and access design library. + +[React Aria Modal Docs](https://react-spectrum.adobe.com/react-aria/Modal.html) +[ARIA Dialog Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) +[Figma Design Library](https://www.figma.com/design/gHbAJGfcrCv7f2bgzUQgHq/NIMBUS-Guidelines?node-id=1695-45519&m) + +## API + +The Modal component is structured as a compound component with multiple parts that work together: + +### Modal.Root + +The root component that provides context and state management for the modal. Uses React Aria's DialogTrigger for accessibility and keyboard interaction. + +**Props:** +- `isOpen?: boolean` - Controls the open state (controlled mode) +- `onOpenChange?: (isOpen: boolean) => void` - Callback when modal state changes +- `defaultOpen?: boolean` - Initial open state (uncontrolled mode, default: false) +- `isDisabled?: boolean` - Whether the modal trigger is disabled (default: false) + +### Modal.Trigger + +The trigger element that opens the modal when activated. Uses React Aria's Button for accessibility. + +**Props:** +- Standard HTML `button` attributes +- All React Aria `Button` props + +### Modal.Content + +The main modal content container that wraps React Aria's Modal and Dialog. Handles portalling, backdrop, positioning, and content styling. + +**Props:** +- `size?: "xs" | "sm" | "md" | "lg" | "xl" | "cover" | "full"` - Modal size (default: "md") +- `placement?: "center" | "top" | "bottom"` - Modal position (default: "center") +- `scrollBehavior?: "inside" | "outside"` - Scroll behavior (default: "outside") +- `motionPreset?: "scale" | "slide-in-bottom" | "slide-in-top" | "slide-in-left" | "slide-in-right" | "none"` - Animation preset (default: "scale") +- `isPortalled?: boolean` - Whether to render in a portal (default: true) +- `portalContainer?: HTMLElement | (() => HTMLElement)` - Portal container +- `hasBackdrop?: boolean` - Whether to show backdrop overlay (default: true) +- `isDismissable?: boolean` - Whether clicking outside closes modal (default: true) +- `isKeyboardDismissDisabled?: boolean` - Whether Escape key is disabled (default: false) +- `onClose?: () => void` - Callback when modal requests to close +- Plus all standard HTML `div` attributes + +### Modal.Backdrop + +The backdrop overlay that appears behind the modal content. Provides click-outside-to-close behavior. + +**Props:** +- Standard HTML `div` attributes +- `style?: React.CSSProperties` - Custom styles + +### Modal.Header / Modal.Body / Modal.Footer + +Semantic sections for organizing modal content with proper styling and spacing. + +**Props:** +- `children: React.ReactNode` - Section content +- Standard HTML `header`, `div`, or `footer` attributes + +### Modal.Title / Modal.Description + +Accessible text elements that provide semantic meaning to screen readers. + +**Props:** +- `children: React.ReactNode` - Text content +- Standard HTML `h2` or `p` attributes + +### Modal.CloseTrigger + +A button that closes the modal when activated. Can be used multiple times within a modal. + +**Props:** +- `aria-label?: string` - Accessible label (default: "Close modal") +- Standard HTML `button` attributes + +## Usage + +The Modal component follows a compound pattern where all parts work together to create a complete modal experience. + +### Basic Usage + +The simplest modal implementation with all essential parts: + +```jsx-live +const App = () => ( + + + + + + + + Modal Title + + + + + + + This is a basic modal with default settings. It includes a backdrop, + title, description, and close button for a complete experience. + + + + + + + + + + +) +``` + +### Size Variants + +Control the modal width with size variants: + +```jsx-live +const App = () => ( + + {["xs", "sm", "md", "lg", "xl"].map((size) => ( + + + + + + + + Size: {size} + + + + + + + This modal demonstrates the "{size}" size variant. + Each size provides different maximum widths for various use cases. + + + + + ))} + +) +``` + +### Placement Options + +Position the modal at different locations: + +```jsx-live +const App = () => ( + + {["center", "top", "bottom"].map((placement) => ( + + + + + + + + Placement: {placement} + + + + + + + This modal is positioned at "{placement}". Different placements + work better for different types of content and user flows. + + + + + ))} + +) +``` + +### Controlled State + +Use external state to control the modal programmatically: + +```jsx-live +const App = () => { + const [isOpen, setIsOpen] = React.useState(false); + + return ( + + + + Modal is currently: {isOpen ? "open" : "closed"} + + + + + + Controlled Modal + + + + + + + This modal's state is controlled by the parent component. + You can open/close it programmatically or through user interaction. + + + + + + + + + + ); +} +``` + +### Scroll Behavior + +Handle long content with different scrolling approaches: + +```jsx-live +const App = () => ( + + {["inside", "outside"].map((scrollBehavior) => ( + + + + + + + + Scroll: {scrollBehavior} + + + + + + + + This modal tests "{scrollBehavior}" scroll behavior with lots of content. + + {Array.from({ length: 10 }, (_, i) => ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + ))} + + + + + ))} + +) +``` + +## Guidelines + +Use modals strategically to enhance user workflow without disrupting the experience. + +### Best Practices + +- **Use modals sparingly**: Only when you need to interrupt the user's flow for critical actions +- **Keep content focused**: Modals should have a single, clear purpose +- **Provide clear actions**: Always include obvious ways to proceed or dismiss +- **Make dismissal easy**: Support Escape key, close buttons, and click-outside behavior +- **Maintain focus flow**: Focus should move logically through interactive elements +- **Size appropriately**: Choose sizes that fit your content without overwhelming the viewport +- **Consider mobile**: Ensure modals work well on small screens + +### When to Use + +✅ **Good use cases:** +- Confirming destructive actions (delete confirmations) +- Collecting focused input (forms, settings) +- Displaying critical alerts or warnings +- Showing detailed information without navigation +- Authentication flows (login, signup) + +❌ **Avoid for:** +- Complex multi-step workflows (use pages instead) +- Non-critical information (use inline content) +- Navigation (use proper routing) +- Content that needs to be referenced while working + +## Specs + + + +## Accessibility + +The Modal component follows WCAG 2.1 AA guidelines and implements proper modal dialog patterns. + +### Keyboard Navigation + +| Key | Action | +|-----|--------| +| `Tab` | Move focus to next focusable element within modal | +| `Shift + Tab` | Move focus to previous focusable element within modal | +| `Escape` | Close the modal (unless disabled) | +| `Enter/Space` | Activate focused button or trigger | + +### Screen Reader Support + +- **Role identification**: Modal has `dialog` role with proper labeling +- **Focus management**: Focus moves to modal on open, returns to trigger on close +- **Focus containment**: Tab navigation stays within modal boundaries +- **Accessible names**: Title and description properly associate with dialog +- **State announcements**: Screen readers announce when modal opens/closes + +### WCAG Compliance + +#### 1. Perceivable +- **1.3.1 Info and Relationships**: Proper semantic structure with header, body, footer +- **1.4.3 Contrast**: All text meets minimum contrast requirements +- **1.4.11 Non-text Contrast**: Focus indicators and UI components meet contrast standards + +#### 2. Operable +- **2.1.1 Keyboard**: Full keyboard navigation support +- **2.1.2 No Keyboard Trap**: Focus contained but escapable via Escape key +- **2.4.3 Focus Order**: Logical focus progression through modal elements +- **2.4.7 Focus Visible**: Clear focus indicators on all interactive elements + +#### 3. Understandable +- **3.2.1 On Focus**: No unexpected context changes when focusing elements +- **3.2.2 On Input**: Predictable behavior for all user interactions +- **3.3.2 Labels or Instructions**: Clear labeling and instructions where needed + +#### 4. Robust +- **4.1.2 Name, Role, Value**: Proper semantic markup and ARIA attributes +- **4.1.3 Status Messages**: Appropriate announcements for state changes + +The Modal component ensures that all users, regardless of their abilities or assistive technologies, can effectively interact with modal content. \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/modal.recipe.ts b/packages/nimbus/src/components/modal/modal.recipe.ts new file mode 100644 index 000000000..be5dbad34 --- /dev/null +++ b/packages/nimbus/src/components/modal/modal.recipe.ts @@ -0,0 +1,299 @@ +import { defineSlotRecipe } from "@chakra-ui/react/styled-system"; + +/** + * Modal recipe - shared styling for Modal, Dialog, and Drawer components + * Supports center positioning (Dialog), edge positioning (Drawer), and all motion presets + */ +export const modalSlotRecipe = defineSlotRecipe({ + slots: [ + "trigger", + "backdrop", + "positioner", + "content", + "title", + "description", + "closeTrigger", + "header", + "body", + "footer", + ], + className: "nimbus-modal", + base: { + backdrop: { + bg: { + _dark: "bg/50", + _light: "fg/50", + }, + pos: "fixed", + left: 0, + top: 0, + w: "100vw", + h: "100dvh", + zIndex: "modal", + _open: { + animationName: "fade-in", + animationDuration: "slow", + }, + _closed: { + animationName: "fade-out", + animationDuration: "moderate", + }, + }, + positioner: { + display: "flex", + width: "100vw", + height: "100dvh", + position: "fixed", + left: 0, + top: 0, + "--modal-z-index": "zIndex.modal", + zIndex: "calc(var(--modal-z-index) + var(--layer-index, 0))", + justifyContent: "center", + overscrollBehaviorY: "none", + }, + content: { + display: "flex", + flexDirection: "column", + position: "relative", + width: "100%", + outline: 0, + borderRadius: "200", + textStyle: "sm", + my: "var(--modal-margin, var(--modal-base-margin))", + "--modal-z-index": "zIndex.modal", + zIndex: "calc(var(--modal-z-index) + var(--layer-index, 0))", + bg: "bg", + boxShadow: "lg", + _open: { + animationDuration: "moderate", + }, + _closed: { + animationDuration: "faster", + }, + }, + header: { + flex: 0, + px: "600", + pt: "600", + pb: "400", + }, + body: { + flex: "1", + px: "600", + pt: "200", + pb: "600", + }, + footer: { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: "300", + px: "600", + pt: "200", + pb: "400", + }, + title: { + textStyle: "lg", + fontWeight: "semibold", + }, + description: { + color: "fg.muted", + }, + closeTrigger: { + position: "absolute", + top: "400", + right: "400", + zIndex: 1, + }, + }, + variants: { + placement: { + center: { + positioner: { + alignItems: "center", + }, + content: { + "--modal-base-margin": "auto", + mx: "auto", + }, + }, + top: { + positioner: { + alignItems: "flex-start", + }, + content: { + "--modal-base-margin": "spacing.1600", + mx: "auto", + }, + }, + bottom: { + positioner: { + alignItems: "flex-end", + }, + content: { + "--modal-base-margin": "spacing.1600", + mx: "auto", + }, + }, + left: { + positioner: { + alignItems: "stretch", + justifyContent: "flex-start", + }, + content: { + "--modal-base-margin": "0", + mx: "0", + my: "0", + height: "100%", + borderRadius: "0 200 200 0", + }, + }, + right: { + positioner: { + alignItems: "stretch", + justifyContent: "flex-end", + }, + content: { + "--modal-base-margin": "0", + mx: "0", + my: "0", + height: "100%", + borderRadius: "200 0 0 200", + }, + }, + }, + scrollBehavior: { + inside: { + positioner: { + overflow: "hidden", + }, + content: { + maxH: "calc(100% - 7.5rem)", + }, + body: { + overflow: "auto", + }, + }, + outside: { + positioner: { + overflow: "auto", + pointerEvents: "auto", + }, + }, + }, + size: { + xs: { + content: { + maxW: "sm", + }, + }, + sm: { + content: { + maxW: "md", + }, + }, + md: { + content: { + maxW: "lg", + }, + }, + lg: { + content: { + maxW: "2xl", + }, + }, + xl: { + content: { + maxW: "4xl", + }, + }, + narrow: { + content: { + maxW: "xs", + }, + }, + wide: { + content: { + maxW: "6xl", + }, + }, + cover: { + positioner: { + padding: "1000", + }, + content: { + width: "100%", + height: "100%", + "--modal-margin": "0", + }, + }, + full: { + content: { + maxW: "100vw", + minH: "100vh", + "--modal-margin": "0", + borderRadius: "0", + }, + }, + }, + motionPreset: { + scale: { + content: { + _open: { + animationName: "scale-in, fade-in", + }, + _closed: { + animationName: "scale-out, fade-out", + }, + }, + }, + "slide-in-bottom": { + content: { + _open: { + animationName: "slide-from-bottom, fade-in", + }, + _closed: { + animationName: "slide-to-bottom, fade-out", + }, + }, + }, + "slide-in-top": { + content: { + _open: { + animationName: "slide-from-top, fade-in", + }, + _closed: { + animationName: "slide-to-top, fade-out", + }, + }, + }, + "slide-in-left": { + content: { + _open: { + animationName: "slide-from-left, fade-in", + }, + _closed: { + animationName: "slide-to-left, fade-out", + }, + }, + }, + "slide-in-right": { + content: { + _open: { + animationName: "slide-from-right, fade-in", + }, + _closed: { + animationName: "slide-to-right, fade-out", + }, + }, + }, + none: {}, + }, + }, + defaultVariants: { + size: "md", + scrollBehavior: "outside", + placement: "center", + motionPreset: "scale", + }, +}); \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/modal.slots.tsx b/packages/nimbus/src/components/modal/modal.slots.tsx new file mode 100644 index 000000000..03fbfc3db --- /dev/null +++ b/packages/nimbus/src/components/modal/modal.slots.tsx @@ -0,0 +1,86 @@ +import { + createSlotRecipeContext, + type HTMLChakraProps, +} from "@chakra-ui/react/styled-system"; +import { modalSlotRecipe } from "./modal.recipe"; + +const { withProvider, withContext } = createSlotRecipeContext({ + recipe: modalSlotRecipe, +}); + +// Root slot - provides recipe context to all child components +export type ModalRootSlotProps = HTMLChakraProps<"div">; +export const ModalRootSlot = withProvider( + "div", + "root" +); + +// Trigger slot - button that opens the modal +export type ModalTriggerSlotProps = HTMLChakraProps<"button">; +export const ModalTriggerSlot = withContext< + HTMLButtonElement, + ModalTriggerSlotProps +>("button", "trigger"); + +// Backdrop slot - overlay behind the modal +export type ModalBackdropSlotProps = HTMLChakraProps<"div">; +export const ModalBackdropSlot = withContext( + "div", + "backdrop" +); + +// Positioner slot - positions the modal content +export type ModalPositionerSlotProps = HTMLChakraProps<"div">; +export const ModalPositionerSlot = withContext( + "div", + "positioner" +); + +// Content slot - main modal container +export type ModalContentSlotProps = HTMLChakraProps<"div">; +export const ModalContentSlot = withContext( + "div", + "content" +); + +// Header slot - modal header section +export type ModalHeaderSlotProps = HTMLChakraProps<"header">; +export const ModalHeaderSlot = withContext( + "header", + "header" +); + +// Body slot - modal body content +export type ModalBodySlotProps = HTMLChakraProps<"div">; +export const ModalBodySlot = withContext( + "div", + "body" +); + +// Footer slot - modal footer section with actions +export type ModalFooterSlotProps = HTMLChakraProps<"footer">; +export const ModalFooterSlot = withContext( + "footer", + "footer" +); + +// Title slot - accessible modal title +export type ModalTitleSlotProps = HTMLChakraProps<"h2">; +export const ModalTitleSlot = withContext( + "h2", + "title" +); + +// Description slot - accessible modal description +export type ModalDescriptionSlotProps = HTMLChakraProps<"p">; +export const ModalDescriptionSlot = withContext( + "p", + "description" +); + +// Close trigger slot - button to close the modal +export type ModalCloseTriggerSlotProps = HTMLChakraProps<"button">; +export const ModalCloseTriggerSlot = withContext< + HTMLButtonElement, + ModalCloseTriggerSlotProps +>("button", "closeTrigger"); \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/modal.stories.tsx b/packages/nimbus/src/components/modal/modal.stories.tsx new file mode 100644 index 000000000..5b963a51f --- /dev/null +++ b/packages/nimbus/src/components/modal/modal.stories.tsx @@ -0,0 +1,481 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { within, expect, userEvent } from "storybook/test"; +import { Modal } from "./modal"; +import { Button, Stack, Text, Heading } from "@/components"; + +const meta: Meta = { + title: "components/Modal", + component: Modal.Content, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + size: { + control: { type: "select" }, + options: ["xs", "sm", "md", "lg", "xl", "cover", "full"], + }, + placement: { + control: { type: "select" }, + options: ["center", "top", "bottom"], + }, + scrollBehavior: { + control: { type: "select" }, + options: ["inside", "outside"], + }, + motionPreset: { + control: { type: "select" }, + options: ["scale", "slide-in-bottom", "slide-in-top", "slide-in-left", "slide-in-right", "none"], + }, + }, + render: (args) => ( + + + + + + + + Modal Title + + + + + + + This is a modal dialog. You can add any content here. + + + + + + + + + + + ), +}; + +export default meta; +type Story = StoryObj; + +/** + * The default modal configuration with medium size and center placement. + */ +export const Default: Story = { + args: { + size: "md", + placement: "center", + scrollBehavior: "outside", + motionPreset: "scale", + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement.parentNode as HTMLElement); + + await step("Opens modal on trigger click", async () => { + const trigger = canvas.getByRole("button", { name: "Open Modal" }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: "Modal Title" }); + expect(dialog).toBeInTheDocument(); + }); + + await step("Closes modal on close button click", async () => { + const closeButton = canvas.getByRole("button", { name: "Cancel" }); + await userEvent.click(closeButton); + + await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }, +}; + +/** + * Modal with different size variants. + */ +export const Sizes: Story = { + args: {}, + render: () => ( + + {(["xs", "sm", "md", "lg", "xl"] as const).map((size) => ( + + + + + + + + Size: {size.toUpperCase()} + + + + + + This modal demonstrates the "{size}" size variant. + + + + + + + + + ))} + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement.parentNode as HTMLElement); + const sizes = ["XS", "SM", "MD", "LG", "XL"]; + + for (const size of sizes) { + await step(`Opens ${size} modal and verifies accessibility`, async () => { + const trigger = canvas.getByRole("button", { name: size }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: `Size: ${size}` }); + expect(dialog).toBeInTheDocument(); + + const closeButton = canvas.getByRole("button", { name: "Close" }); + await userEvent.click(closeButton); + + await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + } + }, +}; + +/** + * Modal with different placement variants. + */ +export const Placements: Story = { + args: {}, + render: () => ( + + {(["center", "top", "bottom"] as const).map((placement) => ( + + + + + + + + Placement: {placement} + + + + + + This modal is positioned at "{placement}". + + + + + + + + + ))} + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement.parentNode as HTMLElement); + const placements = ["center", "top", "bottom"]; + + for (const placement of placements) { + await step(`Tests ${placement} placement modal`, async () => { + const trigger = canvas.getByRole("button", { name: placement }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: `Placement: ${placement}` }); + expect(dialog).toBeInTheDocument(); + + const closeButton = canvas.getByRole("button", { name: "Close" }); + await userEvent.click(closeButton); + + await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + } + }, +}; + +/** + * Modal with scrollable content to test scroll behavior variants. + */ +export const ScrollBehavior: Story = { + args: {}, + render: () => ( + + {(["inside", "outside"] as const).map((scrollBehavior) => ( + + + + + + + + Scroll: {scrollBehavior} + + + + + + + + This modal tests "{scrollBehavior}" scroll behavior with lots of content. + + {Array.from({ length: 20 }, (_, i) => ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. + + ))} + + + + + + + + + + ))} + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement.parentNode as HTMLElement); + + await step("Tests inside scroll behavior", async () => { + const trigger = canvas.getByRole("button", { name: "Scroll inside" }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: "Scroll: inside" }); + expect(dialog).toBeInTheDocument(); + + const closeButton = canvas.getByRole("button", { name: "Close" }); + await userEvent.click(closeButton); + }); + }, +}; + +/** + * Modal with different motion presets for entrance animations. + */ +export const MotionPresets: Story = { + args: {}, + render: () => ( + + {(["scale", "slide-in-bottom", "slide-in-top", "slide-in-left", "slide-in-right", "none"] as const).map((preset) => ( + + + + + + + + Motion: {preset} + + + + + + This modal uses "{preset}" animation preset. + + + + + + + + + ))} + + ), +}; + +/** + * Modal without backdrop for special use cases. + */ +export const WithoutBackdrop: Story = { + args: {}, + render: () => ( + + + + + + + No Backdrop Modal + + + + + + This modal has no backdrop overlay. + + + + + + + + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement.parentNode as HTMLElement); + + await step("Opens modal without backdrop", async () => { + const trigger = canvas.getByRole("button", { name: "Open Modal (No Backdrop)" }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: "No Backdrop Modal" }); + expect(dialog).toBeInTheDocument(); + + const closeButton = canvas.getByRole("button", { name: "Close" }); + await userEvent.click(closeButton); + }); + }, +}; + +/** + * Modal with controlled state example. + */ +export const ControlledState: Story = { + args: {}, + render: () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + Modal is {isOpen ? "open" : "closed"} + + + + + + Controlled Modal + + + + + + + This modal's open state is controlled by parent component state. + + + + + + + + + + ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement.parentNode as HTMLElement); + + await step("Controls modal state externally", async () => { + const trigger = canvas.getByRole("button", { name: "Open Controlled Modal" }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: "Controlled Modal" }); + expect(dialog).toBeInTheDocument(); + + const saveButton = canvas.getByRole("button", { name: "Save" }); + await userEvent.click(saveButton); + + await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }, +}; + +/** + * Modal with keyboard navigation and accessibility testing. + */ +export const KeyboardNavigation: Story = { + args: {}, + render: () => ( + + + + + + + + Keyboard Navigation Test + + + + + + + + Test keyboard navigation: Tab through focusable elements, + Escape to close, Enter/Space on buttons. + + + + + + + + + + + + + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement.parentNode as HTMLElement); + + await step("Tests keyboard interactions", async () => { + const trigger = canvas.getByRole("button", { name: "Test Keyboard Navigation" }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: "Keyboard Navigation Test" }); + expect(dialog).toBeInTheDocument(); + + // Test Escape key closes modal + await userEvent.keyboard("{Escape}"); + await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + await step("Tests focus management", async () => { + const trigger = canvas.getByRole("button", { name: "Test Keyboard Navigation" }); + await userEvent.click(trigger); + + const dialog = await canvas.findByRole("dialog", { name: "Keyboard Navigation Test" }); + expect(dialog).toBeInTheDocument(); + + // Test Tab navigation + await userEvent.tab(); + const firstButton = canvas.getByRole("button", { name: "First Button" }); + expect(firstButton).toHaveFocus(); + + await userEvent.tab(); + const secondButton = canvas.getByRole("button", { name: "Second Button" }); + expect(secondButton).toHaveFocus(); + + const closeButton = canvas.getByRole("button", { name: "Cancel" }); + await userEvent.click(closeButton); + }); + }, +}; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/modal.tsx b/packages/nimbus/src/components/modal/modal.tsx new file mode 100644 index 000000000..317aa1fee --- /dev/null +++ b/packages/nimbus/src/components/modal/modal.tsx @@ -0,0 +1,79 @@ +import { ModalRoot } from "./components/modal.root"; +import { ModalTrigger } from "./components/modal.trigger"; +import { ModalContent } from "./components/modal.content"; +import { ModalBackdrop } from "./components/modal.backdrop"; +import { ModalHeader } from "./components/modal.header"; +import { ModalBody } from "./components/modal.body"; +import { ModalFooter } from "./components/modal.footer"; +import { ModalTitle } from "./components/modal.title"; +import { ModalDescription } from "./components/modal.description"; +import { ModalCloseTrigger } from "./components/modal.close-trigger"; + +// Re-export types +export type * from "./modal.types"; + +/** + * Modal + * ============================================================ + * A foundational modal component that serves as the base for both Dialog and Drawer components. + * Built with React Aria Components for accessibility and WCAG 2.1 AA compliance. + * + * Features: + * - Controlled and uncontrolled modes + * - Customizable placement, size, and animations + * - Focus management and keyboard navigation + * - Click-outside and Escape key dismissal + * - Portal rendering support + * - Backdrop overlay with animations + * + * @example + * ```tsx + * + * Open Modal + * + * + * + * Modal Title + * × + * + * + * + * Modal content goes here + * + * + * + * + * + * + * + * + * ``` + * + * @see https://react-spectrum.adobe.com/react-aria/Dialog.html + */ +export const Modal = { + Root: ModalRoot, // MUST BE FIRST - primary entry point + Trigger: ModalTrigger, + Content: ModalContent, + Backdrop: ModalBackdrop, + Header: ModalHeader, + Body: ModalBody, + Footer: ModalFooter, + Title: ModalTitle, + Description: ModalDescription, + CloseTrigger: ModalCloseTrigger, +}; + +// Internal exports for react-docgen +export { + ModalRoot as _ModalRoot, + ModalTrigger as _ModalTrigger, + ModalContent as _ModalContent, + ModalBackdrop as _ModalBackdrop, + ModalHeader as _ModalHeader, + ModalBody as _ModalBody, + ModalFooter as _ModalFooter, + ModalTitle as _ModalTitle, + ModalDescription as _ModalDescription, + ModalCloseTrigger as _ModalCloseTrigger, +}; \ No newline at end of file diff --git a/packages/nimbus/src/components/modal/modal.types.ts b/packages/nimbus/src/components/modal/modal.types.ts new file mode 100644 index 000000000..27d6072fe --- /dev/null +++ b/packages/nimbus/src/components/modal/modal.types.ts @@ -0,0 +1,215 @@ +import { type ComponentProps } from "react"; +import { type RecipeVariantProps } from "@chakra-ui/react"; +import { modalSlotRecipe } from "./modal.recipe"; + +/** + * Props for the Modal.Root component + * + * The root component that provides context and state management for the modal. + * Uses React Aria's DialogTrigger for accessibility and state management. + */ +export interface ModalRootProps { + /** + * The children components (Trigger, Content, etc.) + */ + children: React.ReactNode; + + /** + * Whether the modal is open (controlled mode) + */ + isOpen?: boolean; + + /** + * Callback fired when the modal open state changes + * @param isOpen - Whether the modal is now open + */ + onOpenChange?: (isOpen: boolean) => void; + + /** + * Whether the modal is open by default (uncontrolled mode) + * @default false + */ + defaultOpen?: boolean; + + /** + * Whether the modal is disabled + * @default false + */ + isDisabled?: boolean; +} + +/** + * Props for the Modal.Trigger component + * + * The trigger element that opens the modal when activated. + */ +export interface ModalTriggerProps extends ComponentProps<"button"> { + /** + * The trigger content + */ + children: React.ReactNode; +} + +/** + * Props for the Modal.Content component + * + * The main modal content container that wraps the React Aria Modal and Dialog. + */ +export interface ModalContentProps + extends ComponentProps<"div">, + RecipeVariantProps { + /** + * The modal content + */ + children: React.ReactNode; + + /** + * Whether to render the modal in a portal + * @default true + */ + isPortalled?: boolean; + + /** + * The container element for the portal + */ + portalContainer?: HTMLElement | (() => HTMLElement); + + /** + * Whether to show the backdrop overlay + * @default true + */ + hasBackdrop?: boolean; + + /** + * Whether the modal should close when clicking outside + * @default true + */ + isDismissable?: boolean; + + /** + * Whether the modal should close when pressing Escape + * @default true + */ + isKeyboardDismissDisabled?: boolean; + + /** + * Callback fired when the modal requests to be closed + */ + onClose?: () => void; +} + +/** + * Props for the Modal.Backdrop component + * + * The backdrop overlay that appears behind the modal content. + */ +export interface ModalBackdropProps extends ComponentProps<"div"> { + /** + * Custom styles for the backdrop + */ + style?: React.CSSProperties; +} + +/** + * Props for the Modal.Header component + * + * The header section of the modal content. + */ +export interface ModalHeaderProps extends ComponentProps<"header"> { + /** + * The header content + */ + children: React.ReactNode; +} + +/** + * Props for the Modal.Body component + * + * The main body content section of the modal. + */ +export interface ModalBodyProps extends ComponentProps<"div"> { + /** + * The body content + */ + children: React.ReactNode; +} + +/** + * Props for the Modal.Footer component + * + * The footer section of the modal, typically containing action buttons. + */ +export interface ModalFooterProps extends ComponentProps<"footer"> { + /** + * The footer content (usually buttons) + */ + children: React.ReactNode; +} + +/** + * Props for the Modal.Title component + * + * The accessible title element for the modal. + */ +export interface ModalTitleProps extends ComponentProps<"h2"> { + /** + * The title text + */ + children: React.ReactNode; +} + +/** + * Props for the Modal.Description component + * + * The accessible description element for the modal. + */ +export interface ModalDescriptionProps extends ComponentProps<"p"> { + /** + * The description text + */ + children: React.ReactNode; +} + +/** + * Props for the Modal.CloseTrigger component + * + * A button that closes the modal when activated. + */ +export interface ModalCloseTriggerProps extends ComponentProps<"button"> { + /** + * The close button content (typically an icon) + */ + children: React.ReactNode; + + /** + * Accessible label for the close button + * @default "Close modal" + */ + "aria-label"?: string; +} + +/** + * Size variants for the modal + */ +export type ModalSize = "xs" | "sm" | "md" | "lg" | "xl" | "cover" | "full"; + +/** + * Placement variants for the modal + */ +export type ModalPlacement = "center" | "top" | "bottom"; + +/** + * Scroll behavior variants for the modal + */ +export type ModalScrollBehavior = "inside" | "outside"; + +/** + * Motion preset variants for modal animations + */ +export type ModalMotionPreset = + | "scale" + | "slide-in-bottom" + | "slide-in-top" + | "slide-in-left" + | "slide-in-right" + | "none"; \ No newline at end of file From e4ad4cda99985bdb33834bea399d0832ffacd57a Mon Sep 17 00:00:00 2001 From: Michael Salzmann Date: Mon, 8 Sep 2025 13:11:45 +0200 Subject: [PATCH 02/35] refactor(dialog): replace LegacyDialog with Modal in navigation components - Updated AppNavBarCreateButton and AppNavBarSearch components to utilize the new Modal component, enhancing consistency and functionality. - Adjusted imports and component structure to reflect the transition from LegacyDialog to Modal. - Ensured all related functionality remains intact with no breaking changes introduced. All tests pass successfully. --- .../components/app-nav-bar-create-button.tsx | 34 +++++++++---------- .../app-nav-bar-search/app-nav-bar-search.tsx | 28 +++++++-------- .../nimbus/src/components/drawer/drawer.mdx | 5 ++- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-create-button.tsx b/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-create-button.tsx index 4ce16d494..7378ec48d 100644 --- a/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-create-button.tsx +++ b/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-create-button.tsx @@ -1,7 +1,7 @@ import { Info, Add } from "@commercetools/nimbus-icons"; import { Button, - LegacyDialog, + Dialog, TextInput, Stack, Text, @@ -27,9 +27,9 @@ export const AppNavBarCreateButton = () => { } = useCreateDocument(); return ( - setIsOpen(false)}> - - + setIsOpen(false)}> + + - - - - Create New Document - + + + + Create New Document + Fill in the details to create a new document. - - - + +
+ {!isLoading ? ( @@ -110,9 +110,9 @@ export const AppNavBarCreateButton = () => { Saving in progress... )} - + {!isLoading && ( - + @@ -124,9 +124,9 @@ export const AppNavBarCreateButton = () => { > Create - + )} - - +
+
); }; diff --git a/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-search/app-nav-bar-search.tsx b/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-search/app-nav-bar-search.tsx index 1de076541..1e3b97205 100644 --- a/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-search/app-nav-bar-search.tsx +++ b/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-search/app-nav-bar-search.tsx @@ -2,7 +2,7 @@ import { Flex, Box, useHotkeys, - LegacyDialog, + Modal, TextInput, Text, Kbd, @@ -45,7 +45,7 @@ export const AppNavBarSearch = () => { return ( - { scrollBehavior="outside" size="xl" > - - + + { ⌘+K - - - - + + + + Search the Documentation - - - + + + { Enter to confirm selection. - - - + + + ); }; diff --git a/packages/nimbus/src/components/drawer/drawer.mdx b/packages/nimbus/src/components/drawer/drawer.mdx index 766268ebe..1d4bc6347 100644 --- a/packages/nimbus/src/components/drawer/drawer.mdx +++ b/packages/nimbus/src/components/drawer/drawer.mdx @@ -2,7 +2,10 @@ id: drawer title: Drawer description: A drawer component optimized for edge-positioned sliding panels, built on the Modal base component. -menu: Components +menu: + - Components + - Overlay + - Drawer tags: - overlay - modal From 82aa18679d88d0be03a079ca7b336a4106dd61ea Mon Sep 17 00:00:00 2001 From: Michael Salzmann Date: Tue, 9 Sep 2025 08:34:02 +0200 Subject: [PATCH 03/35] refactor(modal): streamline component structure and enhance accessibility - Consolidated imports and improved formatting across various modal components for better readability and consistency. - Updated Modal and Drawer components to utilize new conditions for state management, enhancing animation handling. - Refactored stories and documentation to reflect the latest changes in component structure and accessibility features. - Ensured all components maintain proper accessibility standards and functionality. All tests pass successfully, confirming no breaking changes introduced. --- .../components/app-nav-bar-create-button.tsx | 8 +- .../app-nav-bar-search/app-nav-bar-search.tsx | 4 +- .../src/components/dialog/dialog.stories.tsx | 142 +++-- .../nimbus/src/components/dialog/dialog.tsx | 27 +- .../src/components/dialog/dialog.types.ts | 39 +- .../drawer/components/drawer.backdrop.tsx | 14 +- .../drawer/components/drawer.body.tsx | 6 +- .../components/drawer.close-trigger.tsx | 44 +- .../drawer/components/drawer.content.tsx | 48 +- .../drawer/components/drawer.description.tsx | 31 +- .../drawer/components/drawer.footer.tsx | 6 +- .../drawer/components/drawer.header.tsx | 6 +- .../drawer/components/drawer.root.tsx | 8 +- .../drawer/components/drawer.title.tsx | 12 +- .../drawer/components/drawer.trigger.tsx | 10 +- .../src/components/drawer/components/index.ts | 2 +- .../src/components/drawer/drawer.recipe.ts | 6 +- .../src/components/drawer/drawer.slots.tsx | 58 +- .../src/components/drawer/drawer.stories.tsx | 509 +++++++++++------- .../nimbus/src/components/drawer/drawer.tsx | 20 +- .../src/components/drawer/drawer.types.ts | 72 +-- .../nimbus/src/components/drawer/index.ts | 12 +- .../src/components/modal/components/index.ts | 2 +- .../modal/components/modal.backdrop.tsx | 6 +- .../modal/components/modal.body.tsx | 6 +- .../modal/components/modal.close-trigger.tsx | 56 +- .../modal/components/modal.content.tsx | 38 +- .../modal/components/modal.description.tsx | 29 +- .../modal/components/modal.footer.tsx | 6 +- .../modal/components/modal.header.tsx | 6 +- .../modal/components/modal.root.tsx | 8 +- .../modal/components/modal.title.tsx | 6 +- .../modal/components/modal.trigger.tsx | 10 +- packages/nimbus/src/components/modal/index.ts | 2 +- .../src/components/modal/modal.recipe.ts | 2 +- .../src/components/modal/modal.slots.tsx | 42 +- .../src/components/modal/modal.stories.tsx | 271 +++++----- .../nimbus/src/components/modal/modal.tsx | 10 +- .../src/components/modal/modal.types.ts | 72 +-- packages/nimbus/src/theme/conditions.ts | 19 + packages/nimbus/src/theme/index.ts | 2 + 41 files changed, 925 insertions(+), 752 deletions(-) create mode 100644 packages/nimbus/src/theme/conditions.ts diff --git a/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-create-button.tsx b/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-create-button.tsx index 7378ec48d..9803e398f 100644 --- a/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-create-button.tsx +++ b/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-create-button.tsx @@ -1,11 +1,5 @@ import { Info, Add } from "@commercetools/nimbus-icons"; -import { - Button, - Dialog, - TextInput, - Stack, - Text, -} from "@commercetools/nimbus"; +import { Button, Dialog, TextInput, Stack, Text } from "@commercetools/nimbus"; import { useCreateDocument } from "@/hooks/useCreateDocument"; /** diff --git a/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-search/app-nav-bar-search.tsx b/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-search/app-nav-bar-search.tsx index 1e3b97205..ba20a04ac 100644 --- a/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-search/app-nav-bar-search.tsx +++ b/apps/docs/src/components/navigation/app-nav-bar/components/app-nav-bar-search/app-nav-bar-search.tsx @@ -72,9 +72,7 @@ export const AppNavBarSearch = () => { - - Search the Documentation - + Search the Documentation = { - title: "components/Dialog", + title: "components/Overlay/Dialog", component: Dialog.Content, parameters: { layout: "centered", @@ -21,7 +21,14 @@ const meta: Meta = { }, motionPreset: { control: { type: "select" }, - options: ["scale", "slide-in-bottom", "slide-in-top", "slide-in-left", "slide-in-right", "none"], + options: [ + "scale", + "slide-in-bottom", + "slide-in-top", + "slide-in-left", + "slide-in-right", + "none", + ], }, }, render: (args) => ( @@ -41,7 +48,8 @@ const meta: Meta = { - This is a dialog message. Dialogs are perfect for confirmations, alerts, and forms. + This is a dialog message. Dialogs are perfect for confirmations, + alerts, and forms. @@ -68,19 +76,21 @@ export const Default: Story = { }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement.parentNode as HTMLElement); - + await step("Opens dialog on trigger click", async () => { const trigger = canvas.getByRole("button", { name: "Open Dialog" }); await userEvent.click(trigger); - - const dialog = await canvas.findByRole("dialog", { name: "Dialog Title" }); + + const dialog = await canvas.findByRole("dialog", { + name: "Dialog Title", + }); expect(dialog).toBeInTheDocument(); }); - + await step("Closes dialog on cancel button", async () => { const cancelButton = canvas.getByRole("button", { name: "Cancel" }); await userEvent.click(cancelButton); - + await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); }); }, @@ -96,7 +106,9 @@ export const ConfirmationDialog: Story = { render: (args) => ( - + @@ -110,36 +122,43 @@ export const ConfirmationDialog: Story = { - This action cannot be undone. Are you sure you want to delete this item? + This action cannot be undone. Are you sure you want to delete this + item? - + ), play: async ({ canvasElement, step }) => { const canvas = within(canvasElement.parentNode as HTMLElement); - + await step("Opens confirmation dialog", async () => { const trigger = canvas.getByRole("button", { name: "Delete Item" }); await userEvent.click(trigger); - - const dialog = await canvas.findByRole("dialog", { name: "Confirm Delete" }); + + const dialog = await canvas.findByRole("dialog", { + name: "Confirm Delete", + }); expect(dialog).toBeInTheDocument(); - + // Verify destructive action messaging - expect(canvas.getByText("This action cannot be undone")).toBeInTheDocument(); + expect( + canvas.getByText("This action cannot be undone") + ).toBeInTheDocument(); }); - + await step("Can cancel the action", async () => { const cancelButton = canvas.getByRole("button", { name: "Cancel" }); await userEvent.click(cancelButton); - + await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); }); }, @@ -190,31 +209,33 @@ export const FormDialog: Story = { ), play: async ({ canvasElement, step }) => { const canvas = within(canvasElement.parentNode as HTMLElement); - + await step("Opens form dialog", async () => { const trigger = canvas.getByRole("button", { name: "Edit Profile" }); await userEvent.click(trigger); - - const dialog = await canvas.findByRole("dialog", { name: "Edit Profile" }); + + const dialog = await canvas.findByRole("dialog", { + name: "Edit Profile", + }); expect(dialog).toBeInTheDocument(); }); - + await step("Can interact with form fields", async () => { const nameInput = canvas.getByLabelText("Name"); const emailInput = canvas.getByLabelText("Email"); - + await userEvent.type(nameInput, "John Doe"); await userEvent.type(emailInput, "john@example.com"); - + expect(nameInput).toHaveValue("John Doe"); expect(emailInput).toHaveValue("john@example.com"); }); - + await step("Form maintains focus within dialog", async () => { // Test focus management const nameInput = canvas.getByLabelText("Name"); expect(nameInput).toBeInTheDocument(); - + // Close the dialog const cancelButton = canvas.getByRole("button", { name: "Cancel" }); await userEvent.click(cancelButton); @@ -241,7 +262,8 @@ export const AlertDialog: Story = { - Your session will expire in 5 minutes. Please save your work before continuing. + Your session will expire in 5 minutes. Please save your work before + continuing. @@ -254,22 +276,26 @@ export const AlertDialog: Story = { ), play: async ({ canvasElement, step }) => { const canvas = within(canvasElement.parentNode as HTMLElement); - + await step("Opens alert dialog", async () => { const trigger = canvas.getByRole("button", { name: "Show Alert" }); await userEvent.click(trigger); - - const dialog = await canvas.findByRole("dialog", { name: "Important Notice" }); + + const dialog = await canvas.findByRole("dialog", { + name: "Important Notice", + }); expect(dialog).toBeInTheDocument(); - + // Verify alert message expect(canvas.getByText(/Your session will expire/)).toBeInTheDocument(); }); - + await step("Can acknowledge alert", async () => { - const acknowledgeButton = canvas.getByRole("button", { name: "Understood" }); + const acknowledgeButton = canvas.getByRole("button", { + name: "Understood", + }); await userEvent.click(acknowledgeButton); - + await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); }); }, @@ -291,7 +317,9 @@ export const Sizes: Story = { Small Dialog - + @@ -306,7 +334,7 @@ export const Sizes: Story = { - + @@ -316,12 +344,15 @@ export const Sizes: Story = { Medium Dialog - + - This is a medium dialog, the default size for most dialog use cases. + This is a medium dialog, the default size for most dialog use + cases. @@ -331,7 +362,7 @@ export const Sizes: Story = { - + @@ -341,12 +372,15 @@ export const Sizes: Story = { Large Dialog - + - This is a large dialog, suitable for complex forms or detailed content. + This is a large dialog, suitable for complex forms or detailed + content. @@ -384,8 +418,8 @@ export const AccessibilityTest: Story = { - This dialog tests accessibility features including proper focus management, - keyboard navigation, and screen reader announcements. + This dialog tests accessibility features including proper focus + management, keyboard navigation, and screen reader announcements. @@ -399,30 +433,34 @@ export const AccessibilityTest: Story = { ), play: async ({ canvasElement, step }) => { const canvas = within(canvasElement.parentNode as HTMLElement); - + await step("Opens dialog and focuses correctly", async () => { const trigger = canvas.getByRole("button", { name: "Accessible Dialog" }); await userEvent.click(trigger); - - const dialog = await canvas.findByRole("dialog", { name: "Accessibility Test" }); + + const dialog = await canvas.findByRole("dialog", { + name: "Accessibility Test", + }); expect(dialog).toBeInTheDocument(); }); - + await step("Supports keyboard navigation", async () => { // Tab through interactive elements await userEvent.tab(); - expect(canvas.getByRole("button", { name: "Close dialog" })).toHaveFocus(); - + expect( + canvas.getByRole("button", { name: "Close dialog" }) + ).toHaveFocus(); + await userEvent.tab(); expect(canvas.getByRole("button", { name: "Cancel" })).toHaveFocus(); - + await userEvent.tab(); expect(canvas.getByRole("button", { name: "Confirm" })).toHaveFocus(); }); - + await step("Closes on Escape key", async () => { await userEvent.keyboard("{Escape}"); await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); }); }, -}; \ No newline at end of file +}; diff --git a/packages/nimbus/src/components/dialog/dialog.tsx b/packages/nimbus/src/components/dialog/dialog.tsx index ca6fce26d..fa7a548d4 100644 --- a/packages/nimbus/src/components/dialog/dialog.tsx +++ b/packages/nimbus/src/components/dialog/dialog.tsx @@ -1,6 +1,6 @@ import { forwardRef } from "react"; import { Modal } from "../modal/modal"; -import type { +import type { DialogRootProps, DialogTriggerProps, DialogContentProps, @@ -18,11 +18,11 @@ export type * from "./dialog.types"; /** * # Dialog.Content - * + * * Dialog-specific content component that wraps Modal.Content with optimized defaults. - * Pre-configured for center positioning and scale animations - perfect for alerts, + * Pre-configured for center positioning and scale animations - perfect for alerts, * confirmations, and form dialogs. - * + * * @example * ```tsx * @@ -49,7 +49,7 @@ const DialogContent = forwardRef( } = props; return ( - @@ -101,7 +101,7 @@ DialogContent.displayName = "Dialog.Content"; * * * ``` - * + * * @see https://react-spectrum.adobe.com/react-aria/Dialog.html */ export const Dialog = { @@ -114,10 +114,9 @@ export const Dialog = { Footer: Modal.Footer as React.ComponentType, Title: Modal.Title as React.ComponentType, Description: Modal.Description as React.ComponentType, - CloseTrigger: Modal.CloseTrigger as React.ComponentType, + CloseTrigger: + Modal.CloseTrigger as React.ComponentType, }; // Internal exports for react-docgen -export { - DialogContent as _DialogContent, -}; +export { DialogContent as _DialogContent }; diff --git a/packages/nimbus/src/components/dialog/dialog.types.ts b/packages/nimbus/src/components/dialog/dialog.types.ts index 0dc297c12..0d0cd8eb1 100644 --- a/packages/nimbus/src/components/dialog/dialog.types.ts +++ b/packages/nimbus/src/components/dialog/dialog.types.ts @@ -1,4 +1,4 @@ -import type { +import type { ModalRootProps, ModalTriggerProps, ModalContentProps, @@ -8,12 +8,12 @@ import type { ModalFooterProps, ModalTitleProps, ModalDescriptionProps, - ModalCloseTriggerProps + ModalCloseTriggerProps, } from "../modal/modal.types"; /** * Props for the Dialog.Root component - * + * * The root component that provides context and state management for the dialog. * Identical to Modal.Root as it uses the same underlying implementation. */ @@ -21,7 +21,7 @@ export interface DialogRootProps extends ModalRootProps {} /** * Props for the Dialog.Trigger component - * + * * The trigger element that opens the dialog when activated. * Identical to Modal.Trigger as it uses the same underlying implementation. */ @@ -29,27 +29,34 @@ export interface DialogTriggerProps extends ModalTriggerProps {} /** * Props for the Dialog.Content component - * + * * The main dialog content container optimized for center-positioned modal dialogs. * Extends Modal.Content with Dialog-specific defaults for placement and motionPreset. */ -export interface DialogContentProps extends Omit { +export interface DialogContentProps + extends Omit { /** * The placement of the dialog content * @default "center" - Dialogs are optimized for center positioning */ placement?: "center" | "top" | "bottom"; - + /** * The motion preset for dialog animations * @default "scale" - Dialogs use scale animation for better UX */ - motionPreset?: "scale" | "slide-in-bottom" | "slide-in-top" | "slide-in-left" | "slide-in-right" | "none"; + motionPreset?: + | "scale" + | "slide-in-bottom" + | "slide-in-top" + | "slide-in-left" + | "slide-in-right" + | "none"; } /** * Props for the Dialog.Backdrop component - * + * * The backdrop overlay that appears behind the dialog content. * Identical to Modal.Backdrop as it uses the same underlying implementation. */ @@ -57,7 +64,7 @@ export interface DialogBackdropProps extends ModalBackdropProps {} /** * Props for the Dialog.Header component - * + * * The header section of the dialog content. * Identical to Modal.Header as it uses the same underlying implementation. */ @@ -65,7 +72,7 @@ export interface DialogHeaderProps extends ModalHeaderProps {} /** * Props for the Dialog.Body component - * + * * The main body content section of the dialog. * Identical to Modal.Body as it uses the same underlying implementation. */ @@ -73,7 +80,7 @@ export interface DialogBodyProps extends ModalBodyProps {} /** * Props for the Dialog.Footer component - * + * * The footer section of the dialog, typically containing action buttons. * Identical to Modal.Footer as it uses the same underlying implementation. */ @@ -81,7 +88,7 @@ export interface DialogFooterProps extends ModalFooterProps {} /** * Props for the Dialog.Title component - * + * * The accessible title element for the dialog. * Identical to Modal.Title as it uses the same underlying implementation. */ @@ -89,7 +96,7 @@ export interface DialogTitleProps extends ModalTitleProps {} /** * Props for the Dialog.Description component - * + * * The accessible description element for the dialog. * Identical to Modal.Description as it uses the same underlying implementation. */ @@ -97,8 +104,8 @@ export interface DialogDescriptionProps extends ModalDescriptionProps {} /** * Props for the Dialog.CloseTrigger component - * + * * A button that closes the dialog when activated. * Identical to Modal.CloseTrigger as it uses the same underlying implementation. */ -export interface DialogCloseTriggerProps extends ModalCloseTriggerProps {} \ No newline at end of file +export interface DialogCloseTriggerProps extends ModalCloseTriggerProps {} diff --git a/packages/nimbus/src/components/drawer/components/drawer.backdrop.tsx b/packages/nimbus/src/components/drawer/components/drawer.backdrop.tsx index b71558825..55bfbbdd9 100644 --- a/packages/nimbus/src/components/drawer/components/drawer.backdrop.tsx +++ b/packages/nimbus/src/components/drawer/components/drawer.backdrop.tsx @@ -4,10 +4,10 @@ import type { DrawerBackdropProps } from "../drawer.types"; /** * # Drawer.Backdrop - * + * * The backdrop overlay that appears behind the drawer content. * Provides a semi-transparent overlay that can dismiss the drawer when clicked. - * + * * @example * ```tsx * @@ -20,14 +20,8 @@ export const DrawerBackdrop = forwardRef( (props, ref) => { const { style, ...restProps } = props; - return ( - - ); + return ; } ); -DrawerBackdrop.displayName = "Drawer.Backdrop"; \ No newline at end of file +DrawerBackdrop.displayName = "Drawer.Backdrop"; diff --git a/packages/nimbus/src/components/drawer/components/drawer.body.tsx b/packages/nimbus/src/components/drawer/components/drawer.body.tsx index 290374ebe..b4113fc88 100644 --- a/packages/nimbus/src/components/drawer/components/drawer.body.tsx +++ b/packages/nimbus/src/components/drawer/components/drawer.body.tsx @@ -4,10 +4,10 @@ import type { DrawerBodyProps } from "../drawer.types"; /** * # Drawer.Body - * + * * The main body content section of the drawer. * Contains the primary drawer content with scrollable overflow handling. - * + * * @example * ```tsx * @@ -35,4 +35,4 @@ export const DrawerBody = forwardRef( } ); -DrawerBody.displayName = "Drawer.Body"; \ No newline at end of file +DrawerBody.displayName = "Drawer.Body"; diff --git a/packages/nimbus/src/components/drawer/components/drawer.close-trigger.tsx b/packages/nimbus/src/components/drawer/components/drawer.close-trigger.tsx index 74df54e1b..a3d30f78d 100644 --- a/packages/nimbus/src/components/drawer/components/drawer.close-trigger.tsx +++ b/packages/nimbus/src/components/drawer/components/drawer.close-trigger.tsx @@ -5,13 +5,13 @@ import type { DrawerCloseTriggerProps } from "../drawer.types"; /** * # Drawer.CloseTrigger - * + * * A button that closes the drawer when activated. * Uses React Aria's Button with proper accessibility features. - * + * * Automatically handles keyboard interaction and provides appropriate * ARIA labeling for screen readers. - * + * * @example * ```tsx * @@ -24,27 +24,21 @@ import type { DrawerCloseTriggerProps } from "../drawer.types"; * * ``` */ -export const DrawerCloseTrigger = forwardRef( - (props, ref) => { - const { - children, - "aria-label": ariaLabel = "Close drawer", - ...restProps - } = props; +export const DrawerCloseTrigger = forwardRef< + HTMLButtonElement, + DrawerCloseTriggerProps +>((props, ref) => { + const { + children, + "aria-label": ariaLabel = "Close drawer", + ...restProps + } = props; - return ( - - - {children} - - - ); - } -); + return ( + + {children} + + ); +}); -DrawerCloseTrigger.displayName = "Drawer.CloseTrigger"; \ No newline at end of file +DrawerCloseTrigger.displayName = "Drawer.CloseTrigger"; diff --git a/packages/nimbus/src/components/drawer/components/drawer.content.tsx b/packages/nimbus/src/components/drawer/components/drawer.content.tsx index 7fd68f427..759668be2 100644 --- a/packages/nimbus/src/components/drawer/components/drawer.content.tsx +++ b/packages/nimbus/src/components/drawer/components/drawer.content.tsx @@ -1,28 +1,25 @@ import { forwardRef, useMemo } from "react"; import { Modal as RaModal, Dialog as RaDialog } from "react-aria-components"; -import { - DrawerPositionerSlot, - DrawerContentSlot -} from "../drawer.slots"; -import type { - DrawerContentProps, - DrawerSide, - DrawerPlacement, - DrawerMotionPreset +import { DrawerPositionerSlot, DrawerContentSlot } from "../drawer.slots"; +import type { + DrawerContentProps, + DrawerSide, + DrawerPlacement, + DrawerMotionPreset, } from "../drawer.types"; /** * # Drawer.Content - * + * * The main drawer content container that wraps React Aria's Modal and Dialog. * Handles portalling, backdrop, edge positioning, and content styling. - * + * * The `side` prop automatically determines placement and animation: * - side="left" → placement="left", motionPreset="slide-in-left" - * - side="right" → placement="right", motionPreset="slide-in-right" + * - side="right" → placement="right", motionPreset="slide-in-right" * - side="top" → placement="top", motionPreset="slide-in-top" * - side="bottom" → placement="bottom", motionPreset="slide-in-bottom" - * + * * @example * ```tsx * @@ -59,7 +56,10 @@ export const DrawerContent = forwardRef( // Automatically map side to placement and motionPreset const { placement, motionPreset } = useMemo(() => { - const mappings: Record = { + const mappings: Record< + DrawerSide, + { placement: DrawerPlacement; motionPreset: DrawerMotionPreset } + > = { left: { placement: "left", motionPreset: "slide-in-left" }, right: { placement: "right", motionPreset: "slide-in-right" }, top: { placement: "top", motionPreset: "slide-in-top" }, @@ -77,18 +77,20 @@ export const DrawerContent = forwardRef( isDismissable={isDismissable} isKeyboardDismissDisabled={isKeyboardDismissDisabled} > - - + - - {children} - + {children} @@ -96,4 +98,4 @@ export const DrawerContent = forwardRef( } ); -DrawerContent.displayName = "Drawer.Content"; \ No newline at end of file +DrawerContent.displayName = "Drawer.Content"; diff --git a/packages/nimbus/src/components/drawer/components/drawer.description.tsx b/packages/nimbus/src/components/drawer/components/drawer.description.tsx index 8082af776..a7e616b15 100644 --- a/packages/nimbus/src/components/drawer/components/drawer.description.tsx +++ b/packages/nimbus/src/components/drawer/components/drawer.description.tsx @@ -5,13 +5,13 @@ import type { DrawerDescriptionProps } from "../drawer.types"; /** * # Drawer.Description - * + * * The accessible description element for the drawer. * Uses React Aria's Text with slot="description" for proper accessibility. - * + * * This description provides additional context about the drawer content * and is announced by screen readers along with the title. - * + * * @example * * @@ -25,18 +25,17 @@ import type { DrawerDescriptionProps } from "../drawer.types"; * * */ -export const DrawerDescription = forwardRef( - (props, ref) => { - const { children, ...restProps } = props; +export const DrawerDescription = forwardRef< + HTMLParagraphElement, + DrawerDescriptionProps +>((props, ref) => { + const { children, ...restProps } = props; - return ( - - - {children} - - - ); - } -); + return ( + + {children} + + ); +}); -DrawerDescription.displayName = "Drawer.Description"; \ No newline at end of file +DrawerDescription.displayName = "Drawer.Description"; diff --git a/packages/nimbus/src/components/drawer/components/drawer.footer.tsx b/packages/nimbus/src/components/drawer/components/drawer.footer.tsx index 8e5763687..171398bc2 100644 --- a/packages/nimbus/src/components/drawer/components/drawer.footer.tsx +++ b/packages/nimbus/src/components/drawer/components/drawer.footer.tsx @@ -4,10 +4,10 @@ import type { DrawerFooterProps } from "../drawer.types"; /** * # Drawer.Footer - * + * * The footer section of the drawer, typically containing action buttons. * Positioned at the bottom of the drawer with consistent spacing. - * + * * @example * ```tsx * @@ -34,4 +34,4 @@ export const DrawerFooter = forwardRef( } ); -DrawerFooter.displayName = "Drawer.Footer"; \ No newline at end of file +DrawerFooter.displayName = "Drawer.Footer"; diff --git a/packages/nimbus/src/components/drawer/components/drawer.header.tsx b/packages/nimbus/src/components/drawer/components/drawer.header.tsx index c91ffec5b..9d9e5c4f6 100644 --- a/packages/nimbus/src/components/drawer/components/drawer.header.tsx +++ b/packages/nimbus/src/components/drawer/components/drawer.header.tsx @@ -4,10 +4,10 @@ import type { DrawerHeaderProps } from "../drawer.types"; /** * # Drawer.Header - * + * * The header section of the drawer content. * Typically contains the title and close button. - * + * * @example * ```tsx * @@ -30,4 +30,4 @@ export const DrawerHeader = forwardRef( } ); -DrawerHeader.displayName = "Drawer.Header"; \ No newline at end of file +DrawerHeader.displayName = "Drawer.Header"; diff --git a/packages/nimbus/src/components/drawer/components/drawer.root.tsx b/packages/nimbus/src/components/drawer/components/drawer.root.tsx index ff48c18cd..4b64f05f7 100644 --- a/packages/nimbus/src/components/drawer/components/drawer.root.tsx +++ b/packages/nimbus/src/components/drawer/components/drawer.root.tsx @@ -4,13 +4,13 @@ import type { DrawerRootProps } from "../drawer.types"; /** * # Drawer.Root - * + * * The root component that provides context and state management for the drawer. * Uses React Aria's DialogTrigger for accessibility and keyboard interaction. - * + * * This component must wrap all drawer parts (Trigger, Content, etc.) and provides * the drawer open/close state and variant styling context. - * + * * @example * ```tsx * @@ -48,4 +48,4 @@ export const DrawerRoot = (props: DrawerRootProps) => { ); }; -DrawerRoot.displayName = "Drawer.Root"; \ No newline at end of file +DrawerRoot.displayName = "Drawer.Root"; diff --git a/packages/nimbus/src/components/drawer/components/drawer.title.tsx b/packages/nimbus/src/components/drawer/components/drawer.title.tsx index ae8a31218..07a4f5295 100644 --- a/packages/nimbus/src/components/drawer/components/drawer.title.tsx +++ b/packages/nimbus/src/components/drawer/components/drawer.title.tsx @@ -5,13 +5,13 @@ import type { DrawerTitleProps } from "../drawer.types"; /** * # Drawer.Title - * + * * The accessible title element for the drawer. * Uses React Aria's Heading for proper accessibility labeling. - * + * * This title automatically labels the drawer for screen readers and * provides the primary heading for the drawer content. - * + * * @example * ```tsx * @@ -28,12 +28,10 @@ export const DrawerTitle = forwardRef( return ( - - {children} - + {children} ); } ); -DrawerTitle.displayName = "Drawer.Title"; \ No newline at end of file +DrawerTitle.displayName = "Drawer.Title"; diff --git a/packages/nimbus/src/components/drawer/components/drawer.trigger.tsx b/packages/nimbus/src/components/drawer/components/drawer.trigger.tsx index 6cb62535f..e82819bd4 100644 --- a/packages/nimbus/src/components/drawer/components/drawer.trigger.tsx +++ b/packages/nimbus/src/components/drawer/components/drawer.trigger.tsx @@ -5,10 +5,10 @@ import type { DrawerTriggerProps } from "../drawer.types"; /** * # Drawer.Trigger - * + * * The trigger element that opens the drawer when activated. * Built with React Aria's Button for full accessibility support. - * + * * @example * * Open Navigation @@ -23,12 +23,10 @@ export const DrawerTrigger = forwardRef( return ( - - {children} - + {children} ); } ); -DrawerTrigger.displayName = "Drawer.Trigger"; \ No newline at end of file +DrawerTrigger.displayName = "Drawer.Trigger"; diff --git a/packages/nimbus/src/components/drawer/components/index.ts b/packages/nimbus/src/components/drawer/components/index.ts index c6426f2f0..1c3df27ea 100644 --- a/packages/nimbus/src/components/drawer/components/index.ts +++ b/packages/nimbus/src/components/drawer/components/index.ts @@ -10,4 +10,4 @@ export { DrawerBody } from "./drawer.body"; export { DrawerFooter } from "./drawer.footer"; export { DrawerTitle } from "./drawer.title"; export { DrawerDescription } from "./drawer.description"; -export { DrawerCloseTrigger } from "./drawer.close-trigger"; \ No newline at end of file +export { DrawerCloseTrigger } from "./drawer.close-trigger"; diff --git a/packages/nimbus/src/components/drawer/drawer.recipe.ts b/packages/nimbus/src/components/drawer/drawer.recipe.ts index 166a41bba..ca6f67dd3 100644 --- a/packages/nimbus/src/components/drawer/drawer.recipe.ts +++ b/packages/nimbus/src/components/drawer/drawer.recipe.ts @@ -144,7 +144,7 @@ export const drawerSlotRecipe = defineSlotRecipe({ }, right: { positioner: { - justifyContent: "flex-end", + justifyContent: "flex-end", alignItems: "stretch", }, content: { @@ -281,7 +281,7 @@ export const drawerSlotRecipe = defineSlotRecipe({ w: "2xl", }, "&[data-side=top], &[data-side=bottom]": { - maxH: "2xl", + maxH: "2xl", h: "2xl", }, }, @@ -360,4 +360,4 @@ export const drawerSlotRecipe = defineSlotRecipe({ scrollBehavior: "outside", motionPreset: "slide-in-left", }, -}); \ No newline at end of file +}); diff --git a/packages/nimbus/src/components/drawer/drawer.slots.tsx b/packages/nimbus/src/components/drawer/drawer.slots.tsx index c4c394c31..72c03fcd1 100644 --- a/packages/nimbus/src/components/drawer/drawer.slots.tsx +++ b/packages/nimbus/src/components/drawer/drawer.slots.tsx @@ -23,40 +23,40 @@ DrawerRootSlot.displayName = "DrawerRootSlot"; * DrawerTriggerSlot - Trigger button slot component */ export type DrawerTriggerSlotProps = HTMLChakraProps<"button">; -export const DrawerTriggerSlot = withContext( - "button", - "trigger" -); +export const DrawerTriggerSlot = withContext< + HTMLButtonElement, + DrawerTriggerSlotProps +>("button", "trigger"); DrawerTriggerSlot.displayName = "DrawerTriggerSlot"; /** * DrawerBackdropSlot - Backdrop overlay slot component */ export type DrawerBackdropSlotProps = HTMLChakraProps<"div">; -export const DrawerBackdropSlot = withContext( - "div", - "backdrop" -); +export const DrawerBackdropSlot = withContext< + HTMLDivElement, + DrawerBackdropSlotProps +>("div", "backdrop"); DrawerBackdropSlot.displayName = "DrawerBackdropSlot"; /** * DrawerPositionerSlot - Positioner slot component */ export type DrawerPositionerSlotProps = HTMLChakraProps<"div">; -export const DrawerPositionerSlot = withContext( - "div", - "positioner" -); +export const DrawerPositionerSlot = withContext< + HTMLDivElement, + DrawerPositionerSlotProps +>("div", "positioner"); DrawerPositionerSlot.displayName = "DrawerPositionerSlot"; /** * DrawerContentSlot - Main content slot component */ export type DrawerContentSlotProps = HTMLChakraProps<"div">; -export const DrawerContentSlot = withContext( - "div", - "content" -); +export const DrawerContentSlot = withContext< + HTMLDivElement, + DrawerContentSlotProps +>("div", "content"); DrawerContentSlot.displayName = "DrawerContentSlot"; /** @@ -93,28 +93,28 @@ DrawerFooterSlot.displayName = "DrawerFooterSlot"; * DrawerTitleSlot - Title element slot component */ export type DrawerTitleSlotProps = HTMLChakraProps<"h2">; -export const DrawerTitleSlot = withContext( - "h2", - "title" -); +export const DrawerTitleSlot = withContext< + HTMLHeadingElement, + DrawerTitleSlotProps +>("h2", "title"); DrawerTitleSlot.displayName = "DrawerTitleSlot"; /** * DrawerDescriptionSlot - Description element slot component */ export type DrawerDescriptionSlotProps = HTMLChakraProps<"p">; -export const DrawerDescriptionSlot = withContext( - "p", - "description" -); +export const DrawerDescriptionSlot = withContext< + HTMLParagraphElement, + DrawerDescriptionSlotProps +>("p", "description"); DrawerDescriptionSlot.displayName = "DrawerDescriptionSlot"; /** * DrawerCloseTriggerSlot - Close button slot component */ export type DrawerCloseTriggerSlotProps = HTMLChakraProps<"button">; -export const DrawerCloseTriggerSlot = withContext( - "button", - "closeTrigger" -); -DrawerCloseTriggerSlot.displayName = "DrawerCloseTriggerSlot"; \ No newline at end of file +export const DrawerCloseTriggerSlot = withContext< + HTMLButtonElement, + DrawerCloseTriggerSlotProps +>("button", "closeTrigger"); +DrawerCloseTriggerSlot.displayName = "DrawerCloseTriggerSlot"; diff --git a/packages/nimbus/src/components/drawer/drawer.stories.tsx b/packages/nimbus/src/components/drawer/drawer.stories.tsx index 6dc8a0ea6..5b2d0d5c7 100644 --- a/packages/nimbus/src/components/drawer/drawer.stories.tsx +++ b/packages/nimbus/src/components/drawer/drawer.stories.tsx @@ -1,14 +1,13 @@ -import React from 'react'; -import type { Meta, StoryObj } from '@storybook/react'; -import { expect } from '@storybook/test'; -import { within, userEvent, waitFor } from '@storybook/test'; -import { Drawer } from './drawer'; +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, within, userEvent, waitFor } from "storybook/test"; +import { Drawer } from "./drawer"; const meta = { - title: 'Components/Overlay/Drawer', + title: "Components/Overlay/Drawer", component: Drawer.Root, parameters: { - layout: 'centered', + layout: "centered", docs: { description: { component: ` @@ -36,65 +35,75 @@ Built on the Modal base component with automatic placement and animation mapping argTypes: { // Root props isOpen: { - control: 'boolean', - description: 'Whether the drawer is open (controlled mode)', - table: { category: 'Root' }, + control: "boolean", + description: "Whether the drawer is open (controlled mode)", + table: { category: "Root" }, }, defaultOpen: { - control: 'boolean', - description: 'Whether the drawer is open by default (uncontrolled mode)', - table: { category: 'Root' }, + control: "boolean", + description: "Whether the drawer is open by default (uncontrolled mode)", + table: { category: "Root" }, }, isDisabled: { - control: 'boolean', - description: 'Whether the drawer is disabled', - table: { category: 'Root' }, + control: "boolean", + description: "Whether the drawer is disabled", + table: { category: "Root" }, }, // Content props side: { - control: 'select', - options: ['left', 'right', 'top', 'bottom'], - description: 'Which edge of the screen the drawer slides in from', - table: { category: 'Content' }, + control: "select", + options: ["left", "right", "top", "bottom"], + description: "Which edge of the screen the drawer slides in from", + table: { category: "Content" }, }, size: { - control: 'select', - options: ['xs', 'sm', 'md', 'lg', 'xl', 'narrow', 'wide', 'cover', 'full'], - description: 'Size of the drawer', - table: { category: 'Content' }, + control: "select", + options: [ + "xs", + "sm", + "md", + "lg", + "xl", + "narrow", + "wide", + "cover", + "full", + ], + description: "Size of the drawer", + table: { category: "Content" }, }, hasBackdrop: { - control: 'boolean', - description: 'Whether to show the backdrop overlay', - table: { category: 'Content' }, + control: "boolean", + description: "Whether to show the backdrop overlay", + table: { category: "Content" }, }, isDismissable: { - control: 'boolean', - description: 'Whether the drawer should close when clicking outside', - table: { category: 'Content' }, + control: "boolean", + description: "Whether the drawer should close when clicking outside", + table: { category: "Content" }, }, scrollBehavior: { - control: 'select', - options: ['inside', 'outside'], - description: 'Scroll behavior for drawer content', - table: { category: 'Content' }, + control: "select", + options: ["inside", "outside"], + description: "Scroll behavior for drawer content", + table: { category: "Content" }, }, }, args: { - side: 'left', - size: 'md', + side: "left", + size: "md", hasBackdrop: true, isDismissable: true, - scrollBehavior: 'outside', + scrollBehavior: "outside", isDisabled: false, }, - tags: ['autodocs'], + tags: ["autodocs"], } satisfies Meta; export default meta; type Story = StoryObj; -const NavigationContent = ({ side = 'left' }) => ( +const NavigationContent = ({ side = "left" }) => ( <> @@ -102,20 +111,55 @@ const NavigationContent = ({ side = 'left' }) => ( × -