Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-info-dialog-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@commercetools/nimbus": minor
---

feat(info-dialog): add InfoDialog pattern (FEC-437)
4 changes: 2 additions & 2 deletions bundle-sizes.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"@commercetools/nimbus": {
"esm": 1547139,
"cjs": 60942
"esm": 1588197,
"cjs": 64416
},
"@commercetools/nimbus-icons": {
"esm": 1555358,
Expand Down
110 changes: 110 additions & 0 deletions openspec/changes/info-dialog-pattern/design.md
Original file line number Diff line number Diff line change
@@ -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.
61 changes: 61 additions & 0 deletions openspec/changes/info-dialog-pattern/proposal.md
Original file line number Diff line number Diff line change
@@ -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
207 changes: 207 additions & 0 deletions openspec/changes/info-dialog-pattern/specs/nimbus-info-dialog/spec.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading