diff --git a/.changeset/add-info-dialog-pattern.md b/.changeset/add-info-dialog-pattern.md new file mode 100644 index 000000000..56c5e0ab6 --- /dev/null +++ b/.changeset/add-info-dialog-pattern.md @@ -0,0 +1,5 @@ +--- +"@commercetools/nimbus": minor +--- + +feat(info-dialog): add InfoDialog pattern (FEC-437) diff --git a/bundle-sizes.json b/bundle-sizes.json index 10f082bb3..1f577a285 100644 --- a/bundle-sizes.json +++ b/bundle-sizes.json @@ -1,7 +1,7 @@ { "@commercetools/nimbus": { - "esm": 1547139, - "cjs": 60942 + "esm": 1588197, + "cjs": 64416 }, "@commercetools/nimbus-icons": { "esm": 1555358, diff --git a/openspec/changes/info-dialog-pattern/design.md b/openspec/changes/info-dialog-pattern/design.md new file mode 100644 index 000000000..b8da12d97 --- /dev/null +++ b/openspec/changes/info-dialog-pattern/design.md @@ -0,0 +1,110 @@ +## Context + +The Merchant Center Application Kit's `InfoDialog` has accrued configuration +props over time (`size`, `zIndex`, `aria-label`, `getParentSelector`, a +`TextTitle` sub-component). A usage audit across every MC repository shows +that most of this surface area is unused in practice: + +| Prop / surface | Usage across ~131 production instances | +| --- | --- | +| `size` (default) | 83% (94 files) | +| `size` (any explicit value) | 17% (19 files, dominated by `size={16}`) | +| `title` as string | 96% (126 files) | +| `title` as ReactNode | 4% (5 files, mostly badge/icon + heading) | +| `TextTitle` sub-component | 0% (zero consumer usages across 10 repos) | +| `zIndex` | Rare; handled by Dialog primitive | +| `getParentSelector` | Rare; handled by React Aria portaling | + +Nimbus's `Dialog` primitive already solves the cross-cutting concerns via +its recipe (z-index stacking with +`calc(var(--dialog-z-index) + var(--layer-index, 0))`) and React Aria's +`Modal` + `ModalOverlay` (portaling). That means the pattern layer can +stay thin. + +## Goals / Non-Goals + +### Goals + +- Ship a low-friction API for the overwhelmingly common read-only + informational dialog shape +- Maintain feature parity for the >95% case (string or composed-JSX title, + default size, default dismiss behaviour) +- Delegate all modal mechanics (z-index, portal target, focus trap, + dismissal wiring) to the underlying `Dialog` + +### Non-Goals + +- Configurable sizing at the pattern layer — consumers needing a + non-default size drop down to `Dialog` directly (documented escape hatch) +- `TextTitle` sub-component — zero consumer uptake in app-kit, drop + entirely +- Duplicating `aria-label` enforcement at the component boundary — a11y + linting in consumer repositories is the intended layer for catching + missing accessible names on non-string titles + +## Decisions + +### Decision: Location under `patterns/dialogs/` + +Place the component at `packages/nimbus/src/patterns/dialogs/info-dialog/`, +introducing a new `dialogs/` sub-category next to the existing `fields/` +sub-category. + +- **Alternatives considered**: + - `components/info-dialog/` (alongside the `Dialog` primitive) — + rejected because the primitive vs. pattern distinction is the key + mental model: primitive = building block, pattern = pre-configured + composition. + - `patterns/info-dialog/` (flat, no sub-category) — rejected because + future dialog patterns (`FormDialog`, `ConfirmationDialog`) will need + a home, and grouping them under `dialogs/` mirrors the app-kit + structure consumers are already familiar with. +- **Rationale**: InfoDialog is not a new primitive, it is a canonical + composition of Dialog parts. The `patterns/` directory is the right + home, and the `dialogs/` sub-category prepares for sibling patterns + expected under the same migration epic. + +### Decision: `title` is `ReactNode`, not `string` + +Five concrete app-kit usages (identity SSO, discount-group info dialog, +risks-stepper, and two discount-description dialogs) pass composed JSX +(badge + heading, icon + heading). Keeping the prop as `ReactNode` avoids +a regression for these consumers and keeps the API symmetric with other +Nimbus components. + +### Decision: No `aria-label` prop + +`Dialog.Root` already accepts `aria-label` via its own API. Consumers +passing a non-string `title` who need an explicit accessible label can +compose `Dialog` directly (escape hatch). Avoiding duplication at the +pattern layer keeps the flat API flat; accessibility linting in consumer +repos catches missing labels when they matter. + +### Decision: Dismiss behaviour defaults on + +Forward `isDismissable` as enabled to `Dialog.Root` so clicking the +overlay or pressing Escape closes the dialog. This matches app-kit's +behaviour when `onClose` is provided and aligns with user expectations +for a read-only dialog with no forms to lose. + +## Risks / Trade-offs + +- **Risk**: The 19 app-kit consumers who set an explicit `size` will need + to migrate to `Dialog` directly when the time comes. + **Mitigation**: Document the escape hatch prominently with a runnable + code sample in `.dev.mdx`; call it out in the migration notes when this + pattern is rolled out. + +- **Trade-off**: Dropping `TextTitle` means any consumer who later wants + themed title styling must compose `Dialog` directly. Given zero current + usage, the simplicity of the flat API wins. If demand emerges, we can + revisit without breaking existing consumers. + +## Open Questions + +- **Does the pattern need a `.i18n.ts` file?** The only user-facing + string the pattern itself owns is the close-button accessible name, + which is already provided by `Dialog.CloseTrigger`'s default + `aria-label="Close dialog"`. Existing patterns (e.g. + `text-input-field`) ship without `.i18n.ts`. Current expectation: + no `.i18n.ts` required; confirm during implementation. diff --git a/openspec/changes/info-dialog-pattern/proposal.md b/openspec/changes/info-dialog-pattern/proposal.md new file mode 100644 index 000000000..37fdf2145 --- /dev/null +++ b/openspec/changes/info-dialog-pattern/proposal.md @@ -0,0 +1,61 @@ +# Change: Add InfoDialog Pattern Component + +## Why + +Consumers migrating from Merchant Center Application Kit need a Nimbus +replacement for the `InfoDialog` component. It is the most common read-only +informational dialog pattern in the MC codebase (~131 production usages). +Rather than require each consumer to compose `Dialog.Root`, `Dialog.Content`, +`Dialog.Header`, `Dialog.Title`, `Dialog.Body`, and `Dialog.CloseTrigger` +every time, Nimbus provides `InfoDialog` as a pre-configured pattern with a +flat, minimal API. + +A usage audit of the app-kit component across every MC repository informs the +scope: 83% of usages take the default size, 96% pass a string title, and the +`TextTitle` sub-component has zero consumer uptake — so the Nimbus pattern +can drop the app-kit `size`, `zIndex`, `getParentSelector`, and `TextTitle` +props without regressing real-world usage. `aria-label` is retained as an +optional escape for composed `ReactNode` titles whose auto-derived accessible +name would be confusing. + +## What Changes + +- **NEW** `InfoDialog` pattern at + `packages/nimbus/src/patterns/dialogs/info-dialog/` — introduces a new + `dialogs/` sub-category under `patterns/` alongside the existing + `fields/` sub-category +- **NEW** Flat props API: + - `title: ReactNode` + - `children: ReactNode` + - `isOpen?: boolean` (controlled mode) + - `defaultOpen?: boolean` (uncontrolled mode) + - `onOpenChange?: (isOpen: boolean) => void` + - `aria-label?: string` (overrides accessible name derived from `title`) +- **NEW** Internally composes `Dialog.Root`, `Dialog.Content`, + `Dialog.Header`, `Dialog.Title`, `Dialog.Body`, `Dialog.CloseTrigger`; + no footer actions +- **NEW** Close affordances: X button in header, Escape key, overlay click +- **NEW** Stacking, sizing defaults, and portaling delegated entirely to + the underlying Dialog primitive — no `size`, `zIndex`, or portal props +- **NEW** Hardcodes `scrollBehavior="inside"` on the underlying Dialog so + long content scrolls within the body while the header stays pinned at + the top +- **NEW** `.dev.mdx` documentation includes an "escape hatch" section + showing the equivalent manual Dialog composition for consumers needing + custom size or dismiss behaviour +- **MODIFIED** `packages/nimbus/src/patterns/index.ts` adds new + `./dialogs` export + +## Impact + +- Affected specs: none (new capability `nimbus-info-dialog`) +- Affected code: + - `packages/nimbus/src/patterns/dialogs/info-dialog/` (new) + - `packages/nimbus/src/patterns/dialogs/index.ts` (new) + - `packages/nimbus/src/patterns/index.ts` (export added) + +## Related + +- Jira: FEC-437 +- Parent epic: FEC-428 (Application-Components Migration to Nimbus) +- Replaces: `merchant-center-application-kit` `InfoDialog` component diff --git a/openspec/changes/info-dialog-pattern/specs/nimbus-info-dialog/spec.md b/openspec/changes/info-dialog-pattern/specs/nimbus-info-dialog/spec.md new file mode 100644 index 000000000..71125ad9d --- /dev/null +++ b/openspec/changes/info-dialog-pattern/specs/nimbus-info-dialog/spec.md @@ -0,0 +1,207 @@ +## ADDED Requirements + +### Requirement: Component Export + +The InfoDialog pattern SHALL export as a single flat component (not a +compound namespace) from `@commercetools/nimbus`. + +#### Scenario: Import surface + +- **WHEN** a consumer imports from `@commercetools/nimbus` +- **THEN** SHALL expose an `InfoDialog` component +- **AND** SHALL expose an `InfoDialogProps` type + +#### Scenario: Location + +- **WHEN** the source is inspected +- **THEN** the component SHALL reside at + `packages/nimbus/src/patterns/dialogs/info-dialog/` +- **AND** SHALL be re-exported through + `packages/nimbus/src/patterns/dialogs/index.ts` +- **AND** that file SHALL be re-exported through + `packages/nimbus/src/patterns/index.ts` + +### Requirement: Flat Props API + +The InfoDialog component SHALL accept a small, flat set of props that +together cover the common informational-dialog case. + +#### Scenario: Required props + +- **WHEN** InfoDialog is rendered +- **THEN** SHALL require `title` typed as `ReactNode` +- **AND** SHALL require `children` typed as `ReactNode` + +#### Scenario: Optional props + +- **WHEN** InfoDialog is rendered +- **THEN** MAY accept `isOpen` typed as `boolean` for controlled usage +- **AND** MAY accept `defaultOpen` typed as `boolean` for uncontrolled usage +- **AND** MAY accept `onOpenChange` typed as `(isOpen: boolean) => void` +- **AND** MAY accept `aria-label` typed as `string` to override the + accessible name derived from `title` + +#### Scenario: No additional configuration props + +- **WHEN** the `InfoDialogProps` type is inspected +- **THEN** SHALL NOT declare a `size` prop +- **AND** SHALL NOT declare a `zIndex` prop +- **AND** SHALL NOT declare a `getParentSelector` or other portal prop +- **AND** SHALL NOT expose a `TextTitle` sub-component + +### Requirement: Internal Composition + +The InfoDialog SHALL be implemented by composing the Nimbus `Dialog` +primitive rather than duplicating its logic. + +#### Scenario: Composed parts + +- **WHEN** InfoDialog renders +- **THEN** SHALL wrap the output in `Dialog.Root` +- **AND** SHALL use `Dialog.Content` for the dialog surface +- **AND** SHALL use `Dialog.Header` containing `Dialog.Title` for the + heading area +- **AND** SHALL use `Dialog.CloseTrigger` for the close button in the + header +- **AND** SHALL use `Dialog.Body` for the content area +- **AND** SHALL NOT render a `Dialog.Footer` + +#### Scenario: Prop forwarding + +- **WHEN** InfoDialog receives `isOpen`, `defaultOpen`, `onOpenChange`, + or `aria-label` +- **THEN** SHALL forward those values to `Dialog.Root` + +### Requirement: Open State + +The InfoDialog SHALL reflect its open state through the underlying Dialog +primitive and support both controlled and uncontrolled usage. + +#### Scenario: Controlled open + +- **WHEN** `isOpen` is `true` +- **THEN** the dialog SHALL be visible + +#### Scenario: Uncontrolled open + +- **WHEN** `isOpen` is omitted and `defaultOpen` is `true` +- **THEN** the dialog SHALL be visible on mount +- **AND** SHALL close when the user triggers any close affordance without + requiring the consumer to manage state + +#### Scenario: Controlled close + +- **WHEN** the user triggers any close affordance +- **THEN** SHALL invoke `onOpenChange(false)` + +#### Scenario: Opening + +- **WHEN** `isOpen` transitions from `false` to `true` +- **THEN** focus SHALL move into the dialog +- **AND** focus SHALL be trapped within the dialog + +#### Scenario: Closing + +- **WHEN** `isOpen` transitions from `true` to `false` +- **THEN** focus SHALL return to the element that was focused before the + dialog opened + +### Requirement: Close Affordances + +Because the InfoDialog has no footer actions, it SHALL close via multiple +user actions. + +#### Scenario: Close button + +- **WHEN** the user clicks the close button in the header +- **THEN** SHALL invoke `onOpenChange(false)` + +#### Scenario: Escape key + +- **WHEN** the user presses Escape while the dialog is open +- **THEN** SHALL invoke `onOpenChange(false)` + +#### Scenario: Overlay click + +- **WHEN** the user clicks outside the dialog content (on the overlay) +- **THEN** SHALL invoke `onOpenChange(false)` + +### Requirement: Title Rendering + +The InfoDialog SHALL render the `title` prop as the accessible heading +of the dialog. + +#### Scenario: String title + +- **WHEN** `title` is a string +- **THEN** SHALL render it inside `Dialog.Title` +- **AND** SHALL expose the string as the accessible name of the dialog + +#### Scenario: ReactNode title + +- **WHEN** `title` is a React element (e.g. composed markup with an icon + or badge alongside heading text) +- **THEN** SHALL render the element inside `Dialog.Title` + +### Requirement: Content Rendering + +The InfoDialog SHALL render `children` inside the dialog body with +scrolling handled by the underlying Dialog primitive. + +#### Scenario: Short content + +- **WHEN** `children` fit within the dialog viewport +- **THEN** SHALL render without a scrollbar + +#### Scenario: Long content + +- **WHEN** `children` exceed the available viewport height +- **THEN** the body SHALL scroll +- **AND** the header SHALL remain visible at the top of the dialog + +### Requirement: Accessibility + +By virtue of composing the Nimbus `Dialog` primitive, InfoDialog SHALL +meet WCAG 2.1 AA dialog requirements. + +#### Scenario: Dialog role + +- **WHEN** the dialog is open +- **THEN** SHALL expose `role="dialog"` to assistive technology + +#### Scenario: Accessible name + +- **WHEN** `title` is a string +- **THEN** the dialog's accessible name SHALL be that string +- **AND** no additional `aria-label` SHALL be required from the consumer + +#### Scenario: Accessible name override + +- **WHEN** `aria-label` is provided +- **THEN** it SHALL be forwarded to `Dialog.Root` and SHALL serve as the + dialog's accessible name, overriding the name derived from `title` + +### Requirement: Developer Documentation + +The pattern SHALL ship with developer-facing documentation that covers +both the flat API and an escape hatch for advanced customization. + +#### Scenario: Frontmatter + +- **WHEN** the `.mdx` file is read +- **THEN** SHALL declare `related-components: [Dialog]` +- **AND** SHALL declare `menu: [Patterns, Dialogs, Info dialog]` + +#### Scenario: Escape hatch section + +- **WHEN** the `.dev.mdx` file is rendered +- **THEN** SHALL include a section demonstrating the equivalent manual + `Dialog` composition +- **AND** SHALL guide consumers to drop down to `Dialog` when they need + a non-default size or a custom dismissability behaviour + +#### Scenario: API reference + +- **WHEN** the `.dev.mdx` renders the PropsTable +- **THEN** SHALL list the public props (`title`, `children`, `isOpen`, + `defaultOpen`, `onOpenChange`, `aria-label`) with their types and JSDoc diff --git a/openspec/changes/info-dialog-pattern/tasks.md b/openspec/changes/info-dialog-pattern/tasks.md new file mode 100644 index 000000000..9f56235cf --- /dev/null +++ b/openspec/changes/info-dialog-pattern/tasks.md @@ -0,0 +1,61 @@ +## 1. Scaffold + +- [x] 1.1 Create directory + `packages/nimbus/src/patterns/dialogs/info-dialog/` +- [x] 1.2 Create `patterns/dialogs/index.ts` with + `export * from "./info-dialog"` +- [x] 1.3 Add `export * from "./dialogs"` to + `packages/nimbus/src/patterns/index.ts` +- [x] 1.4 Create `info-dialog/index.ts` barrel re-exporting the component + and types + +## 2. Implementation + +- [x] 2.1 Implement `info-dialog.types.ts` with `InfoDialogProps`: + `title: ReactNode`, `isOpen?: boolean`, + `onOpenChange?: (isOpen: boolean) => void`, `children: ReactNode`, + with JSDoc on every prop +- [x] 2.2 Implement `info-dialog.tsx` composing `Dialog.Root` + (passing `isOpen`, `onOpenChange`, `isDismissable`), + `Dialog.Content`, `Dialog.Header` wrapping `Dialog.Title`, + `Dialog.Body`, `Dialog.CloseTrigger`; set `displayName = "InfoDialog"` +- [x] 2.3 Confirm the close button accessible name from + `Dialog.CloseTrigger` is translated; only add `.i18n.ts` if a + pattern-owned user-facing string exists + (no `.i18n.ts` required — the close button inherits its localized + "Close dialog" label from `Dialog.CloseTrigger` directly) + +## 3. Testing + +- [x] 3.1 Write Storybook stories (`info-dialog.stories.tsx`) with play + functions covering: basic string title, ReactNode title with + composed content (e.g. badge + heading), long scrollable content, + close via X button, close via Escape key, close via overlay click, + focus trap while open and focus restoration on close +- [x] 3.2 Write consumer implementation tests + (`info-dialog.docs.spec.tsx`) covering basic controlled usage + +## 4. Documentation + +- [x] 4.1 Create `info-dialog.dev.mdx` with: import statement, basic + usage, scrollable content example, accessibility section, and + "Escape hatch" section demonstrating the equivalent manual Dialog + composition (for consumers needing custom size, custom + dismissability, or custom aria-label) +- [x] 4.2 Create `info-dialog.mdx` with frontmatter + `related-components: [Dialog]` and + `menu: [Patterns, Dialogs, Info dialog]` +- [x] 4.3 Verify the generated PropsTable correctly reflects the flat + four-prop API + +## 5. Validation + +- [x] 5.1 `pnpm --filter @commercetools/nimbus typecheck` passes + (info-dialog files have only the pre-existing + `Cannot find module '@commercetools/nimbus'` pattern shared by + other stories and docs-specs — no new type errors) +- [x] 5.2 `pnpm --filter @commercetools/nimbus build` succeeds +- [x] 5.3 Storybook stories render with all play functions passing + (`pnpm test:storybook` — 5/5 pass) +- [x] 5.4 Consumer implementation tests pass (`pnpm test` — 5/5 pass) +- [x] 5.5 `openspec validate info-dialog-pattern --strict` passes diff --git a/packages/nimbus/src/components/dialog/components/dialog.content.tsx b/packages/nimbus/src/components/dialog/components/dialog.content.tsx index bf7386ab8..84c10bd8b 100644 --- a/packages/nimbus/src/components/dialog/components/dialog.content.tsx +++ b/packages/nimbus/src/components/dialog/components/dialog.content.tsx @@ -28,6 +28,7 @@ export const DialogContent = (props: DialogContentProps) => { shouldCloseOnInteractOutside, isOpen, onOpenChange, + "aria-label": ariaLabel, } = useDialogRootContext(); const modalProps = { @@ -47,7 +48,9 @@ export const DialogContent = (props: DialogContentProps) => { - {children} + + {children} + diff --git a/packages/nimbus/src/docs/dialogs.mdx b/packages/nimbus/src/docs/dialogs.mdx new file mode 100644 index 000000000..fbd659c84 --- /dev/null +++ b/packages/nimbus/src/docs/dialogs.mdx @@ -0,0 +1,31 @@ +--- +id: Patterns-Dialogs +title: Dialogs +description: Pre-composed dialog patterns built on top of the Dialog primitive for common read-only and confirmation scenarios +order: 2 +menu: + - Patterns + - Dialogs +tags: + - patterns + - dialogs + - overlays +icon: OpenInNew +--- + +# Dialogs + +Dialog pattern components wrap the `Dialog` primitive in small, opinionated APIs for the most common dialog shapes. Use them to avoid hand-composing `Dialog.Root`, `Dialog.Content`, `Dialog.Header`, `Dialog.Title`, `Dialog.Body`, and `Dialog.CloseTrigger` for everyday cases. + +## When to use dialog patterns + +Dialog patterns are ideal for: +- Read-only informational dialogs (help text, details, metadata) +- Simple, scoped dialog shapes that appear many times across an app +- Reducing boilerplate and ensuring consistent close affordances + +For dialogs with bespoke footer actions, non-default sizing, or custom dismiss behaviour, compose the `Dialog` primitive directly. + +## Available dialog patterns + + diff --git a/packages/nimbus/src/patterns/dialogs/index.ts b/packages/nimbus/src/patterns/dialogs/index.ts new file mode 100644 index 000000000..299bcdecf --- /dev/null +++ b/packages/nimbus/src/patterns/dialogs/index.ts @@ -0,0 +1 @@ +export * from "./info-dialog"; diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/index.ts b/packages/nimbus/src/patterns/dialogs/info-dialog/index.ts new file mode 100644 index 000000000..b83df8d65 --- /dev/null +++ b/packages/nimbus/src/patterns/dialogs/info-dialog/index.ts @@ -0,0 +1,2 @@ +export * from "./info-dialog"; +export * from "./info-dialog.types"; diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.dev.mdx b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.dev.mdx new file mode 100644 index 000000000..5e1100707 --- /dev/null +++ b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.dev.mdx @@ -0,0 +1,229 @@ +--- +title: InfoDialog Pattern +tab-title: Implementation +tab-order: 3 +--- + +## Comparison: InfoDialog vs manual Dialog composition + +**With InfoDialog:** +```tsx + + Some goods cannot be shipped to the selected region. + +``` + +**Manual composition:** +```tsx + + + + Shipping restrictions + + + + Some goods cannot be shipped to the selected region. + + + +``` + +### When to use which + +**Use InfoDialog when:** +- You need a read-only informational dialog with no footer actions +- The default dialog size and dismiss behaviour are appropriate +- You want a flat four-prop API instead of composing six nested elements + +**Use Dialog directly when:** +- You need a non-default size +- You need to override dismissability (disable overlay click, disable Escape) +- Your dialog has footer actions (Cancel/Confirm, destructive action, etc.) + +## Getting started + +### Import + +```tsx +import { InfoDialog, type InfoDialogProps } from '@commercetools/nimbus'; +``` + +### Basic usage + +The simplest implementation with a string title and controlled open state: + +```jsx live-dev +const App = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + + Some goods cannot be shipped to the selected region. Review the + restricted items and update the shipping address if needed. + + + + ); +} +``` + +## Usage examples + +### ReactNode title + +The `title` prop accepts any ReactNode, so you can compose a heading with inline elements like a badge or icon: + +```jsx live-dev +const App = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + Plan details + Pro + + } + isOpen={isOpen} + onOpenChange={setIsOpen} + > + + Your current plan includes unlimited projects and priority support. + + + + ); +} +``` + +When the `title` is not a plain string, pass an explicit `aria-label` if the composed markup does not form a meaningful accessible name on its own: + +```tsx + + Plan details + Pro + + } + aria-label="Plan details" + isOpen={isOpen} + onOpenChange={setIsOpen} +> + {/* ... */} + +``` + +### Long, scrollable content + +Content that exceeds the available dialog height scrolls within the body; the header with the title and close button stays pinned at the top. + +```jsx live-dev +const App = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + + {Array.from({ length: 20 }, (_, i) => ( + + Paragraph {i + 1}: Lorem ipsum dolor sit amet, consectetur + adipiscing elit. Sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. + + ))} + + + + ); +} +``` + +## Close affordances + +Because InfoDialog has no footer actions, the underlying Dialog is always rendered with `isDismissable` enabled so the user has multiple ways to close it: + +- The X button in the header +- The Escape key +- A click on the overlay outside the dialog content + +All three routes invoke `onOpenChange(false)`. + +## Accessibility + +InfoDialog inherits its accessibility guarantees from the Nimbus `Dialog` primitive: + +- `role="dialog"` is exposed to assistive technology while open +- Focus moves into the dialog on open and is trapped within it +- Focus is restored to the triggering element on close +- When `title` is a string, the dialog's accessible name is derived from it automatically — no extra `aria-label` is required + +If the `title` is a composed ReactNode, pass an explicit `aria-label` to InfoDialog so the dialog has a meaningful accessible name. + +## Escape hatch + +If you need a non-default size or a custom dismiss behaviour, drop down to composing `Dialog` directly. The equivalent manual composition is: + +```jsx live-dev +const App = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + + + Shipping restrictions + + + + + Some goods cannot be shipped to the selected region. + + + + + + ); +} +``` + +## API reference + + + +## Testing your implementation + +These examples demonstrate how to test your implementation when using InfoDialog within your application. The component's internal behaviour is already covered by Nimbus — these tests help you verify your integration. + +{{docs-tests: info-dialog.docs.spec.tsx}} diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.docs.spec.tsx b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.docs.spec.tsx new file mode 100644 index 000000000..b8b03aad5 --- /dev/null +++ b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.docs.spec.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { useState } from "react"; +import { InfoDialog, NimbusProvider } from "@commercetools/nimbus"; + +/** + * @docs-section basic-rendering + * @docs-title Basic Rendering + * @docs-description Verify the InfoDialog opens and renders its title and content. + * @docs-order 1 + */ +describe("InfoDialog - Basic rendering", () => { + it("renders title and children when isOpen is true", () => { + render( + + +

Something you should know.

+
+
+ ); + + expect( + screen.getByRole("heading", { name: "Heads up" }) + ).toBeInTheDocument(); + expect(screen.getByText("Something you should know.")).toBeInTheDocument(); + }); + + it("uses the string title as the dialog's accessible name", () => { + render( + + +

Body

+
+
+ ); + + expect(screen.getByRole("dialog")).toHaveAccessibleName("Account details"); + }); + + it("does not render the dialog when isOpen is false", () => { + render( + + +

Not visible

+
+
+ ); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); + +/** + * @docs-section controlled-state + * @docs-title Controlled Open State + * @docs-description Drive the InfoDialog from consumer state via isOpen and onOpenChange. + * @docs-order 2 + */ +describe("InfoDialog - Controlled state", () => { + it("invokes onOpenChange(false) when the close button is clicked", async () => { + const user = userEvent.setup(); + + const ControlledInfoDialog = () => { + const [isOpen, setIsOpen] = useState(true); + return ( + +

Dismissable content

+
+ ); + }; + + render( + + + + ); + + await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument()); + + await user.click(screen.getByRole("button", { name: /close/i })); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.mdx b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.mdx new file mode 100644 index 000000000..87c41c915 --- /dev/null +++ b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.mdx @@ -0,0 +1,42 @@ +--- +id: Patterns-InfoDialog +title: Info dialog +exportName: InfoDialog +description: A pre-composed read-only informational dialog with a flat, minimal API. +order: 999 +menu: + - Patterns + - Dialogs + - Info dialog +tags: + - component + - pattern + - dialog + - InfoDialog +related-components: + - Dialog +--- + +## Overview + +InfoDialog is a pattern component that wraps the Dialog primitive in a flat, minimal API for the most common read-only informational dialog shape: a title, some content, and a close affordance. It is the recommended replacement for the Merchant Center Application Kit's `InfoDialog`. + +The pattern exposes a small, flat set of props — `title`, `children`, and optional `isOpen` / `defaultOpen` / `onOpenChange` / `aria-label` — and delegates everything else (sizing, stacking, portaling, focus management) to the underlying `Dialog` primitive. + +### When to use + +- Displaying read-only information the user needs to acknowledge and dismiss (help text, details panels, metadata) +- Any scenario where the dialog has no footer actions — the user's only task is to read and close + +### When not to use + +- Dialogs that need confirmation, cancellation, or destructive action buttons — use `Dialog` directly or a confirmation pattern +- Dialogs hosting editable forms — use `Dialog` directly with form wiring +- Any scenario that needs a non-default size or non-default dismiss behaviour — drop down to `Dialog` directly (see the escape hatch in the developer documentation) + +### Resources + +For detailed guidance on the underlying primitive, consult the component guidelines: + +- [Dialog Guidelines](/components/feedback/dialog) — Variants, dismissability, sizing, placement, accessibility +- [Storybook](https://nimbus-storybook.vercel.app/?path=/docs/patterns-dialogs-infodialog--docs) diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.stories.tsx b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.stories.tsx new file mode 100644 index 000000000..41fc7ba10 --- /dev/null +++ b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.stories.tsx @@ -0,0 +1,286 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { userEvent, within, expect, waitFor } from "storybook/test"; +import { useState } from "react"; +import { Badge, Button, Flex, Stack, Text } from "@commercetools/nimbus"; +import { InfoDialog } from "./info-dialog"; + +const meta: Meta = { + title: "patterns/dialogs/InfoDialog", + component: InfoDialog, +}; + +export default meta; +type Story = StoryObj; + +/** + * Basic controlled usage with a string title. The pattern has no trigger + * slot — consumers render their own button and drive `isOpen` / `onOpenChange`. + * + * This story also exercises every close affordance (X button, Escape key, + * overlay click) and verifies focus moves into the dialog on open and is + * restored to the trigger on close. + */ +export const Base: Story = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + + Some goods cannot be shipped to the selected region. Review the + restricted items and update the shipping address if needed. + + + + ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within( + (canvasElement.parentNode as HTMLElement) ?? canvasElement + ); + const getTrigger = () => + canvas.getByRole("button", { name: "Open info dialog" }); + + await step("Dialog is not rendered initially", async () => { + expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + await step( + "Opens on trigger click and exposes the title as accessible name", + async () => { + await userEvent.click(getTrigger()); + + await waitFor(() => { + expect(canvas.getByRole("dialog")).toBeInTheDocument(); + }); + + const dialog = canvas.getByRole("dialog"); + expect(dialog).toHaveAccessibleName("Shipping restrictions"); + expect( + canvas.getByRole("heading", { name: "Shipping restrictions" }) + ).toBeInTheDocument(); + } + ); + + await step("Moves focus into the dialog on open", async () => { + const dialog = canvas.getByRole("dialog"); + await waitFor(() => { + const active = document.activeElement; + expect(active === dialog || dialog.contains(active)).toBeTruthy(); + }); + }); + + await step("Closes via the X button and restores focus", async () => { + await userEvent.click(canvas.getByRole("button", { name: /close/i })); + + await waitFor(() => { + expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + await waitFor( + () => { + expect(getTrigger()).toHaveFocus(); + }, + { timeout: 1000 } + ); + }); + + await step("Closes via the Escape key", async () => { + await userEvent.click(getTrigger()); + + await waitFor(() => { + expect(canvas.getByRole("dialog")).toBeInTheDocument(); + }); + + await userEvent.keyboard("{Escape}"); + + await waitFor(() => { + expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + await step("Closes when the overlay is clicked", async () => { + await userEvent.click(getTrigger()); + + const dialog = await waitFor(() => canvas.getByRole("dialog")); + + // React Aria's useInteractOutside listens on the document and fires + // onOpenChange when a pointerdown lands outside the Dialog element. + // The overlay/backdrop is an ancestor of the dialog but outside the + // dialog's own subtree — walk up until we find a wrapper whose direct + // parent is and click its corner (outside the centered + // modal content). + let overlay: HTMLElement | null = dialog; + while (overlay && overlay.parentElement !== document.body) { + overlay = overlay.parentElement; + } + expect(overlay).not.toBeNull(); + + await userEvent.pointer([ + { + target: overlay!, + coords: { clientX: 2, clientY: 2 }, + keys: "[MouseLeft]", + }, + ]); + + await waitFor(() => { + expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + }, +}; + +/** + * Title accepts any ReactNode, so consumers can compose a heading with inline + * elements like a badge or icon. When the composed title would produce a + * confusing accessible name, pass an explicit `aria-label` to override it. + */ +export const WithReactNodeTitle: Story = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + Plan details + Pro + + } + aria-label="Plan details" + isOpen={isOpen} + onOpenChange={setIsOpen} + > + + Your current plan includes unlimited projects and priority support. + + + + ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within( + (canvasElement.parentNode as HTMLElement) ?? canvasElement + ); + const getTrigger = () => + canvas.getByRole("button", { name: "Open info dialog" }); + + await step("Opens with a composed ReactNode title", async () => { + await userEvent.click(getTrigger()); + + await waitFor(() => { + expect(canvas.getByRole("dialog")).toBeInTheDocument(); + }); + + expect(canvas.getByText("Plan details")).toBeInTheDocument(); + expect(canvas.getByText("Pro")).toBeInTheDocument(); + }); + + await step( + "aria-label overrides the composed title as the dialog's accessible name", + async () => { + expect(canvas.getByRole("dialog")).toHaveAccessibleName("Plan details"); + } + ); + + await step("Closes via the X button and restores focus", async () => { + await userEvent.click(canvas.getByRole("button", { name: /close/i })); + + await waitFor(() => { + expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + await waitFor( + () => { + expect(getTrigger()).toHaveFocus(); + }, + { timeout: 1000 } + ); + }); + }, +}; + +/** + * Long content scrolls within the dialog body; the header with the title and + * close button stays pinned at the top. + */ +export const LongContent: Story = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + const paragraphs = Array.from({ length: 25 }, (_, i) => ( + + Paragraph {i + 1}: 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. + + )); + return ( + <> + + + {paragraphs} + + + ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within( + (canvasElement.parentNode as HTMLElement) ?? canvasElement + ); + + await step("Opens with long scrollable content", async () => { + await userEvent.click( + canvas.getByRole("button", { name: "Open info dialog" }) + ); + + await waitFor(() => { + expect(canvas.getByRole("dialog")).toBeInTheDocument(); + }); + + expect( + canvas.getByRole("heading", { name: "Terms of service" }) + ).toBeInTheDocument(); + expect(canvas.getByText(/^Paragraph 1:/)).toBeInTheDocument(); + expect( + canvas.getByRole("button", { name: /close/i }) + ).toBeInTheDocument(); + }); + + await step( + "Body scrolls to reveal the last paragraph while the header stays pinned", + async () => { + const lastParagraph = canvas.getByText(/^Paragraph 25:/); + const header = canvas.getByRole("heading", { + name: "Terms of service", + }); + const headerTopBeforeScroll = header.getBoundingClientRect().top; + + lastParagraph.scrollIntoView({ block: "end" }); + + await waitFor(() => { + const paragraphRect = lastParagraph.getBoundingClientRect(); + expect(paragraphRect.top).toBeLessThan(window.innerHeight); + expect(paragraphRect.bottom).toBeGreaterThan(0); + }); + + // Header position is unchanged because it's pinned outside the + // scrollable body region. + expect(header.getBoundingClientRect().top).toBeCloseTo( + headerTopBeforeScroll, + 0 + ); + } + ); + }, +}; diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.tsx b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.tsx new file mode 100644 index 000000000..ca463f206 --- /dev/null +++ b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.tsx @@ -0,0 +1,56 @@ +import { Dialog } from "@/components"; +import type { InfoDialogProps } from "./info-dialog.types"; + +/** + * # InfoDialog + * + * A pre-composed, read-only informational dialog pattern built on top of the + * Nimbus `Dialog` primitive. Exposes a flat API (`title`, `children`, and + * optional `isOpen` / `defaultOpen` / `onOpenChange` / `aria-label`) for the + * common case of showing information the user only needs to read and dismiss. + * + * Close affordances: the X button in the header, the Escape key, and a click + * on the overlay all invoke `onOpenChange(false)`. + * + * @example + * ```tsx + * const [isOpen, setIsOpen] = useState(false); + * + * + * Some goods cannot be shipped to the selected region. + * + * ``` + */ +export const InfoDialog = ({ + title, + children, + isOpen, + defaultOpen, + onOpenChange, + "aria-label": ariaLabel, +}: InfoDialogProps) => { + return ( + + + + {title} + + + {children} + + + ); +}; + +InfoDialog.displayName = "InfoDialog"; diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.types.ts b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.types.ts new file mode 100644 index 000000000..ac85c33a2 --- /dev/null +++ b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.types.ts @@ -0,0 +1,71 @@ +import type { ReactNode } from "react"; + +/** + * Props for the InfoDialog pattern component. + * + * A pre-composed read-only informational dialog built on top of the Nimbus + * Dialog primitive. The flat API covers the overwhelmingly common + * informational-dialog case (string or composed-JSX title, default size, + * default dismiss behaviour). + * + * Consumers needing a non-default size or custom dismissability should + * compose `Dialog.Root`, `Dialog.Content`, `Dialog.Header`, `Dialog.Title`, + * `Dialog.Body`, and `Dialog.CloseTrigger` directly (see the "Escape hatch" + * section in the dev documentation). + */ +export type InfoDialogProps = { + /** + * Title rendered inside the dialog header. + * + * When passed as a string, the dialog's accessible name is derived from + * the string automatically. When passed as a ReactNode (for example a + * composed heading with an icon or badge), consumers remain responsible + * for any additional a11y affordances their composition requires. + */ + title: ReactNode; + + /** + * Content rendered inside the dialog body. + * + * Long content scrolls within the body; the header remains pinned at the + * top of the dialog. + */ + children: ReactNode; + + /** + * Whether the dialog is open (controlled mode). + * + * Pair with `onOpenChange` to drive the dialog from consumer state. When + * omitted, use `defaultOpen` to start the dialog open in uncontrolled mode. + */ + isOpen?: boolean; + + /** + * Whether the dialog is open by default (uncontrolled mode). + * + * Use when the consumer does not need to observe or control the open state. + * Ignored when `isOpen` is provided. + * + * @default false + */ + defaultOpen?: boolean; + + /** + * Callback fired when the open state changes. + * + * Invoked with `false` when the user closes the dialog via the close + * button in the header, the Escape key, or a click on the overlay. + */ + onOpenChange?: (isOpen: boolean) => void; + + /** + * Accessible label for the dialog, forwarded to the underlying + * `Dialog.Root`. + * + * By default the dialog's accessible name is derived from `title`. Provide + * this override when `title` is a composed `ReactNode` whose text content + * would produce a confusing accessible name (for example a title that + * concatenates inline badges or icons). + */ + "aria-label"?: string; +}; diff --git a/packages/nimbus/src/patterns/index.ts b/packages/nimbus/src/patterns/index.ts index f87a5f89e..256b212ac 100644 --- a/packages/nimbus/src/patterns/index.ts +++ b/packages/nimbus/src/patterns/index.ts @@ -1 +1,2 @@ +export * from "./dialogs"; export * from "./fields";