diff --git a/contributor-docs/adrs/adr-024-modular-component-architecture.md b/contributor-docs/adrs/adr-024-modular-component-architecture.md new file mode 100644 index 00000000000..ca348e851d8 --- /dev/null +++ b/contributor-docs/adrs/adr-024-modular-component-architecture.md @@ -0,0 +1,348 @@ +# Modular Component Architecture + +📆 Date: 2026-04-27 +📝 Updated: 2026-04-30 + +## Status + +| Stage | State | +| -------------- | -------------- | +| Status | Proposed 🟡 | +| Implementation | In progress ⚠️ | + +## Context + +Primer React components are monolithic — each ships as a single unit mixing behavior, accessibility, styling, and composition into one API surface. This makes it difficult for consumers to: + +- Use Primer accessibility and behavior logic without Primer's visual opinions +- Compose components from smaller, reusable parts +- Incrementally adopt Primer in codebases with existing design systems +- Override specific layers (e.g., styling) without forking the entire component + +We need a layered architecture where each layer has a clear responsibility, a stable API contract, and can be used independently. + +**Source issues:** + +- [primer#6546](https://github.com/github/primer/issues/6546) — Layer definitions +- [core-ux#2270](https://github.com/github/core-ux/issues/2270) — Layer 2 composition pattern +- [core-ux#2272](https://github.com/github/core-ux/issues/2272) — Layer 3/4 prop-getters vs context +- [core-ux#2269](https://github.com/github/core-ux/issues/2269) — Export & package structure +- [core-ux#2271](https://github.com/github/core-ux/issues/2271) — Contracts between layers + +## Decision + +### Four layers + +Every modular component is decomposed into four layers. Each layer builds on the one below. + +| Layer | Name | Responsibility | Styled? | +| ----- | ----------- | ---------------------------------------------- | ---------------------------- | +| 4 | Hooks | Individual, single-purpose behavior | ❌ No markup or styles | +| 3 | Foundations | Unstyled accessible components + compound hook | ❌ Unstyled (CSS reset only) | +| 2 | Parts | Primer-styled JSX composition | ✅ Full Primer styles | +| 1 | Ready-made | Props-based convenience wrapper | ✅ Full Primer styles | + +Ready-made (L1) uses Parts (L2), Parts use Foundations (L3), Foundations use Hooks (L4). + +> **Open question — layer naming:** "Foundations" and "Parts" may not be the most intuitive names. Hooks (L4) and Ready-made (L1) are clear. Layer 3 candidates: primitives (conflicts with `primer/primitives` token package), base, headless, core. Layer 2 candidates: blocks, components, kit. To be resolved + +### Layer 4 — Hooks + +**Individual, single-purpose behavior hooks.** Not component-specific. Reusable across any component that needs the behavior. + +Examples: `useFocusTrap`, `useFocusZone`, `useOnEscapePress`, `useScrollLock` + +**API pattern:** Each hook takes options and returns refs, callbacks, or prop objects. + +```tsx +const {containerRef} = useFocusZone({bindKeys: FocusKeys.ArrowVertical}) +``` + +**Rules:** + +- One behavior per hook — no compound hooks at this layer +- No knowledge of which component is consuming them +- No styling or markup opinions + +### Layer 3 — Foundations + +Layer 3 provides two complementary APIs: + +1. **Unstyled components** — React components with no visual styling that enforce structural accessibility constraints. Similar to [Base UI](https://base-ui.com/) or [Radix Primitives](https://www.radix-ui.com/primitives). These handle ARIA wiring, focus management, and keyboard interaction whilst letting consumers bring their own styles. + +2. **Compound hook with prop-getters** — For consumers who need full markup control beyond what unstyled components offer. The hook returns prop-getter functions that consumers spread onto their own elements. + +#### Unstyled components (primary Layer 3 API) + +```tsx +// Foundation consumer — unstyled, bring your own CSS + + + Title + Subtitle + Content + + + +``` + +Unstyled components enforce structural constraints that prop-getters cannot: + +- Title must be a descendant of the dialog +- Close button is present and accessible +- ARIA relationships are wired automatically via context + +**Foundation CSS:** Each foundation ships a minimal CSS reset that removes browser defaults without adding visual opinion. This can be implemented via CSS cascade layers (preferred — clearer intent) or `:where()` selectors (zero specificity fallback). Consumer styles always win regardless of approach. + +#### Compound hook (escape hatch) + +For consumers who need full control over every rendered element — no component tree imposed. + +```tsx +// Hook consumer — owns all markup +const dialog = useDialog({open, onClose}) + + +

Title

+

Subtitle

+
Content
+ +
+``` + +**Why both approaches:** + +- Unstyled components cover the common case: "I want Primer's accessibility, but my own styles." They enforce a11y constraints and are self-documenting in JSX. +- The compound hook covers the advanced case: "I need full markup control." Useful for integrating with other component systems (MUI, custom libraries) or building non-standard layouts. +- This matches the industry standard: Base UI and React Aria ship unstyled components, with hooks as the lower-level escape hatch. + +**Context** is used internally within unstyled components for ARIA cross-wiring (e.g., `aria-labelledby` pointing title ID to dialog) but is never exposed to consumers. + +### Layer 2 — Parts (Composition) + +**Styled JSX components for Primer-opinionated composition.** + +**Composition via slots (`useSlots`):** + +- Use slots (children-based) for all composition +- Render props exist only in legacy code — do not add new ones +- Context (e.g., `useDialogContext()`) replaces render-prop-injected IDs for ARIA wiring +- Never use `React.Children` + `React.cloneElement` + +```tsx +// Parts consumer — Primer-styled, compositional + + + + Title + + + Content + + + + + +``` + +**Rules:** + +- Parts wrap Layer 3 unstyled components and add Primer design tokens, CSS modules, and layout opinions +- Parts are the building blocks for Ready-made (Layer 1) +- All Parts must include `data-component` attributes per [ADR-023](./adr-023-stable-selectors-api.md) + +### Stable selectors (ADR-023) + +All Layer 2 Parts and Layer 1 Ready-made components must include `data-component` attributes as defined in [ADR-023](./adr-023-stable-selectors-api.md). + +**Rules:** + +- Root component: `data-component="ComponentName"` (e.g., `data-component="Dialog"`) +- Sub-components match the React API: `data-component="ComponentName.PartName"` (e.g., `data-component="Dialog.Header"`) +- State and modifier attributes (`data-width`, `data-size`, `data-variant`) remain separate — they describe state, not identity +- Layer 3 (Foundations) does NOT add `data-component` — the consumer owns styling and may choose their own selectors +- Internal CSS may target `data-component` selectors using `:where()` for zero specificity + +> **Open question:** With compositional parts available, is `data-component` still necessary at Layer 2, or is `className` sufficient? `data-component` serves testing and agent selectors (stable across refactors), which is a different concern from styling. To be resolved. + +```html + + +
+
+

Title

+ +
+
Content
+ +
+
+``` + +### Layer 1 — Ready-made + +**Props-based convenience API.** The simplest way to use a component — pass data, get a fully composed component. + +```tsx +// Ready-made consumer — just props + + Are you sure you want to save? + +``` + +**Rules:** + +- Ready-made is a thin wrapper over Parts — it composes ``, ``, etc. +- Props map directly to Parts children — no new behavior at this layer +- This is the default recommendation for most consumers +- **Not every component needs a Ready-made layer.** Config-based APIs can lead to unwieldy types (e.g., SelectPanel). The Ready-made layer should capture the 80% use case. If a component's common usage is inherently compositional, Layer 2 may be the right default and Layer 1 adds complexity without benefit. Decide per component. + +## Accessibility contract by layer + +Each layer shifts accessibility responsibility to the consumer differently. This table defines what each layer handles automatically and what the consumer must provide. + +| Requirement | L4 (Hooks) | L3 (Foundations) | L2 (Parts) | L1 (Ready-made) | +| -------------------------------------- | -------------------- | ---------------------------------- | ----------------- | ----------------------- | +| `role="dialog"` / `role="alertdialog"` | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| `aria-modal="true"` | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| `aria-labelledby` → title | Consumer wires | ✅ Auto-wired via context | ✅ Inherited | ✅ From `title` prop | +| `aria-describedby` → description | Consumer wires | ✅ Auto-wired if Description used | ✅ Inherited | ✅ From `subtitle` prop | +| Focus trapping | Consumer implements | ✅ Native `showModal()` | ✅ Inherited | ✅ Inherited | +| Escape closes dialog | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Focus moves into dialog | Consumer manages | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Focus returns on close | Consumer manages | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Visible close button | Consumer provides | ✅ Enforced by component structure | ✅ Built-in | ✅ Built-in | +| Background inert | Consumer manages | ✅ Native `showModal()` | ✅ Inherited | ✅ Inherited | +| Scroll lock | `useScrollLock` hook | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Visible backdrop | Consumer provides | ⚠️ Consumer must style | ✅ Primer token | ✅ Primer token | +| Appropriate heading level | Consumer chooses | ⚠️ Consumer must choose | ✅ `

` default | ✅ `

` default | +| Colour contrast | Consumer responsible | ⚠️ Consumer must ensure | ✅ Primer tokens | ✅ Primer tokens | + +> **Important:** At Layer 3, the foundation ships a transparent backdrop by default. Per ARIA APG, `aria-modal="true"` should only be set when background content is **both** non-interactive and visually obscured. Consumers using Layer 3 foundations **must** provide visible backdrop styling to meet this requirement. Layer 2 Parts handle this automatically. + +**`aria-describedby` guidance:** Per ARIA APG, omit `aria-describedby` when dialog content has complex semantic structure (lists, tables, multiple paragraphs) — screen readers announce it as a flat string. At Layer 3+, don't render the Description component if content is complex. At Layer 4, don't call `getDescriptionProps()`. + +**Initial focus guidance:** For dialogs with complex semantic content, set `initialFocusRef` to a static element at the top with `tabIndex={-1}` so assistive technology users can navigate the structure. For destructive actions, focus the least destructive button. See the [ARIA APG dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) for full guidance. + +## Export & package structure + +### Entry points + +> **Open question — entry point strategy:** An alternative to separate entry points (`/foundations`, `/hooks`) is using an `unstable_` prefix convention and importing from the same package entry point. This is simpler for consumers — fewer paths to remember. To be resolved with Primer Engineering. + +| Layer | Stable import | Experimental import | +| --------------- | --------------------------- | ---------------------------------------- | +| 1 — Ready-made | `@primer/react` | `@primer/react/experimental` | +| 2 — Parts | `@primer/react` | `@primer/react/experimental` | +| 3 — Foundations | `@primer/react/foundations` | `@primer/react/foundations/experimental` | +| 4 — Hooks | `@primer/react/hooks` | `@primer/react/hooks/experimental` | + +### Naming conventions + +> **Open question — hook naming:** Layer 3 hooks should be named by their role, not their layer. `useDialog` rather than `useDialogFoundation`. The "Foundation" suffix is an internal architectural concept, not a consumer-facing concern. + +| Layer | Convention | Example | +| ----- | ------------------- | ------------------------------- | +| 4 | `use` | `useScrollLock`, `useFocusTrap` | +| 3 | `use` | `useDialog` | +| 2 | `` | `DialogRoot`, `DialogHeader` | +| 1 | `` | `Dialog` | + +**Sub-component naming: flat exports.** All Layer 2 and Layer 3 sub-components use flat named exports (`DialogRoot`, `DialogHeader`, `DialogTitle`, etc.) rather than dot-notation (`Dialog.Root`, `Dialog.Header`). This is required for RSC compatibility — the `Object.assign` pattern creates dot-notation sub-components that break in React Server Components (property access on a client reference returns `undefined`). Flat imports are already the pattern Tabs uses in Primer. + +Layer 2 and Layer 3 share the same component names. The entry point determines which you get: + +- `import { DialogRoot } from '@primer/react'` → Primer-styled (Layer 2) +- `import { DialogRoot } from '@primer/react/foundations'` → unstyled (Layer 3) + +### Rules + +- `@primer/react` does NOT re-export Foundations or Hooks — each layer is opt-in via its own entry point +- All layers ship in one package version +- Stability is per-component — `useDialog` can graduate while others remain experimental +- Graduation = one-time import path change (`/experimental` → stable) + +### Source folder structure + +``` +packages/react/src/ +├── hooks/ # Layer 4 (existing + new) +│ ├── useFocusTrap.ts +│ ├── useOnEscapePress.ts +│ └── useScrollLock.ts +├── foundations/ # Layer 3 +│ └── experimental/ +│ └── / +│ ├── .tsx # Unstyled components +│ ├── use.ts # Compound hook (prop-getters) +│ ├── Foundation.css # Minimal CSS reset +│ └── index.ts +├── experimental/ # Layer 2 + Layer 1 (while experimental) +│ └── / +│ ├── .tsx # Parts (Layer 2) +│ ├── .tsx # Ready-made (Layer 1) +│ ├── .module.css +│ ├── .spec.md # Component specification +│ └── index.ts +└── / # Layer 1 + 2 (after graduation) + └── .tsx +``` + +### package.json exports (additions for new entry points) + +```json +{ + "./foundations/experimental": { + "types": "./dist/foundations/experimental/index.d.ts", + "default": "./dist/foundations/experimental/index.js" + }, + "./hooks/experimental": { + "types": "./dist/hooks/experimental/index.d.ts", + "default": "./dist/hooks/experimental/index.js" + } +} +``` + +## Alternatives considered + +### Prop-getters only for Layer 3 (no unstyled components) + +Initially considered shipping only compound hooks with prop-getters at Layer 3 (inspired by [Downshift](https://www.downshift-js.com/)). This was revised because: + +- It creates too large a gap between Layer 2 (fully styled components) and Layer 3 (raw hook, build all JSX from scratch) +- Prop-getters cannot enforce structural accessibility constraints (e.g., title must be a descendant of the dialog) +- The industry standard for this layer (Base UI, Radix Primitives, React Aria Components) ships unstyled components, with hooks as a lower-level escape hatch +- The compound hook is retained alongside unstyled components for consumers who need full markup control + +### Context as public API + +We considered exposing React Context for ARIA wiring (e.g., `useDialogContext()` to get title IDs). This was rejected because: + +- It leaks implementation details and couples consumers to our component tree +- Prop-getters achieve the same wiring without requiring a specific provider hierarchy +- Context is still used internally within Layer 3 unstyled components — just not exposed to consumers + +### Render props for Layer 2 composition + +Render props were considered for Layer 2 but rejected: + +- They already exist in legacy code — we don't want to add more +- Slots via `useSlots` are more declarative and composable +- `React.Children` + `React.cloneElement` are fragile and discouraged by React team + +## Consequences + +- Every new component should be built using this 4-layer decomposition +- Existing components can be incrementally migrated by extracting hooks and foundations +- Consumers get predictable, documented layers to adopt at their comfort level +- Breaking changes can be scoped to individual layers rather than entire components +- The first component through this architecture is Dialog — it serves as the reference implementation +- Each layer requires an accessibility checklist documenting what the consumer is responsible for diff --git a/packages/react/package.json b/packages/react/package.json index bf354b115d6..ef28ae5428d 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -26,6 +26,14 @@ "types": "./dist/utils/test-helpers.d.ts", "default": "./dist/test-helpers.js" }, + "./foundations/experimental": { + "types": "./dist/foundations/experimental/index.d.ts", + "default": "./dist/foundations/experimental/index.js" + }, + "./hooks/experimental": { + "types": "./dist/hooks/experimental/index.d.ts", + "default": "./dist/hooks/experimental/index.js" + }, "./generated/components.json": "./generated/components.json", "./generated/hooks.json": "./generated/hooks.json" }, diff --git a/packages/react/src/experimental/Dialog/Dialog.module.css b/packages/react/src/experimental/Dialog/Dialog.module.css new file mode 100644 index 00000000000..d5532ed73f4 --- /dev/null +++ b/packages/react/src/experimental/Dialog/Dialog.module.css @@ -0,0 +1,231 @@ +/* Layer 2: Parts — Primer-styled Dialog */ + +@keyframes dialog-backdrop-appear { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes dialog-content-scaleFade { + 0% { + opacity: 0; + transform: scale(0.5); + } + + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes dialog-content-slideUp { + from { + transform: translateY(100%); + } +} + +@keyframes dialog-content-slideInRight { + from { + transform: translateX(-100%); + } +} + +@keyframes dialog-content-slideInLeft { + from { + transform: translateX(100%); + } +} + +/* --- Root (native ) --- */ + +.Root { + border: none; + padding: 0; + background: transparent; + max-width: unset; + max-height: unset; + overflow: visible; + color: inherit; + + /* Sheet positions: override native centering (margin: auto) */ + &:has(> [data-component='Dialog.Content'][data-position-regular='right']) { + margin-right: 0; + } + + &:has(> [data-component='Dialog.Content'][data-position-regular='left']) { + margin-left: 0; + } + + &::backdrop { + background-color: var(--overlay-backdrop-bgColor); + animation: dialog-backdrop-appear 200ms cubic-bezier(0.33, 1, 0.68, 1); + } +} + +/* --- Content --- */ + +.Content { + display: flex; + /* stylelint-disable-next-line primer/responsive-widths */ + width: 640px; + min-width: 296px; + max-width: calc(100dvw - 64px); + height: auto; + max-height: calc(100dvh - 64px); + flex-direction: column; + background-color: var(--overlay-bgColor); + border-radius: var(--borderRadius-large); + box-shadow: var(--shadow-floating-small); + opacity: 1; + + &:where([data-width='small']) { + width: 296px; + } + + &:where([data-width='medium']) { + width: 320px; + } + + &:where([data-width='large']) { + /* stylelint-disable-next-line primer/responsive-widths */ + width: 480px; + } + + &:where([data-height='small']) { + height: 480px; + } + + &:where([data-height='large']) { + height: 640px; + } + + @media screen and (prefers-reduced-motion: no-preference) { + animation: dialog-content-scaleFade 0.2s cubic-bezier(0.33, 1, 0.68, 1) 1ms 1 normal none running; + } + + &[data-position-regular='left'] { + height: 100dvh; + max-height: unset; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + @media screen and (prefers-reduced-motion: no-preference) { + animation: dialog-content-slideInRight 0.25s cubic-bezier(0.33, 1, 0.68, 1) 1ms 1 normal none running; + } + } + + &[data-position-regular='right'] { + height: 100dvh; + max-height: unset; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + @media screen and (prefers-reduced-motion: no-preference) { + animation: dialog-content-slideInLeft 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + } + } + + &[data-position-regular='center'] { + &[data-align='top'] { + margin-top: var(--base-size-64); + } + + &[data-align='bottom'] { + margin-bottom: var(--base-size-64); + } + } + + @media (max-width: 767px) { + &[data-position-narrow='bottom'] { + width: 100dvw; + max-width: 100dvw; + height: auto; + max-height: calc(100dvh - 64px); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + + @media screen and (prefers-reduced-motion: no-preference) { + animation: dialog-content-slideUp 0.25s cubic-bezier(0.33, 1, 0.68, 1) 1ms 1 normal none running; + } + } + + &[data-position-narrow='fullscreen'] { + width: 100%; + max-width: 100dvw; + height: 100%; + max-height: 100dvh; + border-radius: unset !important; + flex-grow: 1; + + @media screen and (prefers-reduced-motion: no-preference) { + animation: dialog-content-scaleFade 0.2s cubic-bezier(0.33, 1, 0.68, 1) 1ms 1 normal none running; + } + } + } +} + +/* --- Header --- */ + +.Header { + z-index: 1; + display: flex; + max-height: 35vh; + padding: var(--base-size-8); + overflow-y: auto; + /* stylelint-disable-next-line primer/box-shadow */ + box-shadow: 0 1px 0 var(--borderColor-default); + flex-shrink: 0; +} + +/* --- Title --- */ + +.Title { + margin: 0; + padding-inline: var(--base-size-8); + padding-block: var(--base-size-6); + font-size: var(--text-body-size-medium); + font-weight: var(--text-title-weight-large); + flex-grow: 1; +} + +/* --- Subtitle --- */ + +.Subtitle { + margin: 0; + margin-top: var(--base-size-4); + padding-inline: var(--base-size-8); + font-size: var(--text-body-size-small); + font-weight: var(--base-text-weight-normal); + color: var(--fgColor-muted); +} + +/* --- Body --- */ + +.Body { + padding: var(--base-size-16); + overflow: auto; + flex-grow: 1; +} + +/* --- Footer --- */ + +.Footer { + z-index: 1; + display: flex; + flex-flow: wrap; + justify-content: flex-end; + padding: var(--base-size-16); + gap: var(--base-size-8); + flex-shrink: 0; + + @media (max-height: 325px) { + flex-wrap: nowrap; + overflow-x: scroll; + flex-direction: row; + justify-content: unset; + } +} diff --git a/packages/react/src/experimental/Dialog/Dialog.spec.md b/packages/react/src/experimental/Dialog/Dialog.spec.md new file mode 100644 index 00000000000..39f233ce986 --- /dev/null +++ b/packages/react/src/experimental/Dialog/Dialog.spec.md @@ -0,0 +1,687 @@ +# Dialog — 4-Layer Component Spec + +> **Status:** Draft +> **Issue:** [core-ux#2267](https://github.com/github/core-ux/issues/2267) > **Authors:** Lukas Oppermann +> **Last updated:** 2026-04-27 + +## Overview + +This document defines the Dialog component across all four layers of the [modular component architecture](https://github.com/github/primer/issues/6546): + +| Layer | Name | What it provides | +| ----- | --------------- | --------------------------------------------------------------------------------- | +| 4 | **Hooks** | Behavioral primitives — state, keyboard, focus, ARIA attributes | +| 3 | **Foundations** | Compound hook with prop-getters — consumer controls markup, foundation wires a11y | +| 2 | **Parts** | Primer-styled compositional components | +| 1 | **Ready-made** | Props-based API — drop in and go | + +Each layer builds on the one below. Most consumers use Layer 1. Teams needing custom layouts use Layer 2. Teams needing custom visuals use Layer 3. Teams needing full control over markup use Layer 4. + +Dialog is the first component to go through this process, so the patterns established here will inform all subsequent components. + +--- + +## Web Standards Baseline + +The spec is grounded in two web standards. Where we follow them, we don't need to justify it. Where we deviate, we document why. + +### HTML `` element + +The native `` element ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog)) provides significant built-in behavior when used with `showModal()`: + +| Capability | How it works | +| ------------------------------- | ---------------------------------------------------------------------------- | +| **Modal behavior** | Background becomes inert — no interaction possible outside the dialog | +| **Focus trapping** | Tab/Shift+Tab cycle within the dialog automatically | +| **Escape to close** | Fires a `cancel` event, closes the dialog | +| **Top layer rendering** | Rendered above all other content, no z-index management needed | +| **`::backdrop` pseudo-element** | Styleable backdrop behind the modal | +| **`autofocus` attribute** | Focuses the marked element when the dialog opens | +| **Focus restoration** | Returns focus to the previously-focused element on close | +| **`closedby` attribute** | Controls which gestures can close the dialog (`any`, `closerequest`, `none`) | +| **Form integration** | `
` closes the dialog on submit, sets `returnValue` | +| **`returnValue`** | String value set when the dialog is closed via form submission | + +**Browser support:** `` and `showModal()` are supported in all evergreen browsers. The `closedby` attribute is newer (Chrome 134+, Firefox 137+) — may need a polyfill or fallback for older browsers. + +### ARIA APG Dialog (Modal) Pattern + +The [APG dialog-modal pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) defines the accessibility contract: + +#### Roles, States, and Properties + +| Requirement | Details | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Container has `role="dialog"` | Native `` provides this implicitly | +| `aria-modal="true"` | Set on the dialog container. Native `showModal()` sets this implicitly | +| `aria-labelledby` or `aria-label` | References a visible title element, or provides a direct label | +| `aria-describedby` (optional) | References content describing the dialog's purpose. Omit when content has complex semantic structure (lists, tables) — screen readers announce it as a flat string | + +#### Keyboard Interaction + +| Key | Behavior | +| ------------- | ------------------------------------------------------------------------------------- | +| **Tab** | Moves focus to the next tabbable element inside the dialog. Wraps from last to first. | +| **Shift+Tab** | Moves focus to the previous tabbable element. Wraps from first to last. | +| **Escape** | Closes the dialog. | + +#### Focus Management + +1. **On open:** Focus moves to an element inside the dialog. The best target depends on content: + - First focusable element (default) + - A static element at the top (`tabindex="-1"`) if content is complex/semantic + - The least destructive action button for irreversible operations + - The most likely-used element (e.g., OK button) for simple confirmations +2. **On close:** Focus returns to the element that invoked the dialog, unless: + - That element no longer exists → focus a logical alternative + - Workflow design makes a different target more appropriate +3. **Close button:** Strongly recommended — a visible button with `role="button"` that closes the dialog + +### What native `` gives us for free + +When we use `` with `showModal()`, we get most ARIA APG requirements automatically: + +- ✅ `role="dialog"` (implicit) +- ✅ `aria-modal="true"` (implicit) +- ✅ Focus trapping (Tab/Shift+Tab cycle) +- ✅ Escape to close (`cancel` event) +- ✅ Top layer rendering (no Portal needed) +- ✅ Background inert (no manual `aria-hidden` on siblings) +- ✅ `::backdrop` styling +- ✅ Focus restoration on close +- ✅ `autofocus` support + +**We still need to provide:** + +- `aria-labelledby` / `aria-label` (connect to title element) +- `aria-describedby` (optional, connect to description element) +- Close button (visible, keyboard accessible) +- Scroll lock on body (native `inert` prevents interaction but doesn't prevent scroll in all browsers) +- Animation (open/close transitions) +- Responsive positioning (center, bottom sheet, fullscreen on narrow) + +--- + +## Layer 4: Hooks + +Hooks provide behavioral building blocks with zero markup or styling. They return state, event handlers, and ARIA attributes that consumers wire into their own elements. + +**Import:** `@primer/react/hooks` + +### `useDialog` + +Manages the core dialog lifecycle: open/close state, scroll lock, and focus restoration. + +```ts +interface UseDialogOptions { + /** + * Whether the dialog is open. + * When using native , this controls showModal()/close() calls. + */ + open: boolean + + /** + * Called when the dialog requests to close. + * Receives the gesture that triggered the close. + * The dialog does NOT close until `open` is set to `false` — this is a request, not a command. + */ + onClose: (gesture: 'escape' | 'close-button' | 'backdrop') => void + + /** + * Element to focus when the dialog opens. + * Falls back to the first focusable element, then the dialog itself. + * @default undefined (auto-detect) + */ + initialFocusRef?: React.RefObject + + /** + * Element to return focus to when the dialog closes. + * Falls back to the element that was focused before the dialog opened. + * @default undefined (auto-restore) + */ + returnFocusRef?: React.RefObject + + /** + * Whether clicking the backdrop closes the dialog. + * @default false + */ + closeOnBackdropClick?: boolean +} + +interface UseDialogReturn { + /** Ref to attach to the element */ + dialogRef: React.RefObject + + /** Props to spread onto the element */ + dialogProps: { + role: 'dialog' | 'alertdialog' + 'aria-modal': true + 'aria-label'?: string + } + + /** Call to programmatically close the dialog */ + close: (gesture: 'escape' | 'close-button' | 'backdrop') => void + + /** Whether the dialog is currently open */ + isOpen: boolean +} + +function useDialog(options: UseDialogOptions): UseDialogReturn +``` + +**Behavior:** + +- Calls `dialogRef.current.showModal()` when `open` transitions to `true` +- Calls `dialogRef.current.close()` when `open` transitions to `false` +- Intercepts the native `cancel` event (Escape key): calls `preventDefault()` to prevent the browser from closing the dialog, then calls `onClose('escape')`. The dialog only closes when the consumer sets `open` to `false`. This is the **controlled close contract** — React state is the single source of truth. +- Manages scroll lock on `document.body` while open +- Handles initial focus placement (respects `initialFocusRef` if provided, then looks for an element with `autofocus`, then first focusable element) +- Restores focus on close (respects `returnFocusRef`, falls back to previously-focused element) +- Handles backdrop click detection when `closeOnBackdropClick` is `true` + +**Controlled close contract:** + +The dialog is fully controlled by the `open` prop. Native close paths are intercepted: + +- **`cancel` event (Escape):** Intercepted with `preventDefault()`, routed to `onClose('escape')` +- **`close` event:** Should only fire as a result of our `dialogRef.close()` call when `open` becomes `false` +- **``:** Not supported in this API. Forms inside the dialog should use standard submit handlers and call `onClose` explicitly. This avoids the dialog closing outside React's control. +- **`requestClose()`:** Not used. We implement close-request semantics via `onClose` callback. +- **`returnValue`:** Not surfaced. Consumers track form/button state in React state, not via the native `returnValue` string. + +**Why not just use native `` directly?** + +Native `` handles most of this, but the hook adds: + +- Controlled open/close state (React-managed, not imperative) with cancel event interception +- `initialFocusRef` / `returnFocusRef` for precise focus control beyond what `autofocus` offers +- Scroll lock on body (native makes background inert but doesn't prevent scroll) +- Consistent close gesture reporting (`'escape' | 'close-button' | 'backdrop'`) +- Backdrop click detection (native `` fires `click` on the dialog element itself when backdrop is clicked — needs coordinate-based detection) + +> **Layer 4 is native-dialog-specific.** This hook is designed for use with `` + `showModal()`. It is not a generic modal hook. Consumers who need full control over markup (no `` element) should use the individual behavioral hooks (`useFocusTrap`, `useScrollLock`, `useOnEscapePress`) directly. + +### `useFocusTrap` + +Traps focus within a container. Wraps Tab/Shift+Tab to cycle through focusable elements. + +```ts +interface UseFocusTrapOptions { + /** Ref to the container element */ + containerRef: React.RefObject + + /** Element to focus initially */ + initialFocusRef?: React.RefObject + + /** Whether the trap is active */ + disabled?: boolean + + /** Restore focus to the previously-focused element on cleanup */ + restoreFocusOnCleanUp?: boolean + + /** Element to return focus to on cleanup (overrides restoreFocusOnCleanUp) */ + returnFocusRef?: React.RefObject +} + +function useFocusTrap(options: UseFocusTrapOptions): void +``` + +**When used with native ``:** This hook is unnecessary when the dialog is opened via `showModal()`, which provides native focus trapping. It exists for cases where consumers build dialog-like UI without the native element (e.g., non-modal dialogs, custom overlays). + +> **Deviation from native:** We retain this hook because `showModal()` focus trapping doesn't support `initialFocusRef` — it uses the `autofocus` attribute or falls back to the dialog element itself. Our hook enables ref-based focus targeting, which is more flexible in React. + +### `useScrollLock` + +Prevents background scrolling while the dialog is open. + +```ts +interface UseScrollLockOptions { + /** Whether the scroll lock is active */ + enabled: boolean +} + +function useScrollLock(options: UseScrollLockOptions): void +``` + +**Behavior:** + +- Sets `overflow: hidden` on `document.body` +- Compensates for scrollbar removal to prevent layout shift (sets `padding-right` equal to scrollbar width) +- Cleans up when disabled or unmounted +- Handles nested dialogs — only removes scroll lock when the last dialog closes + +> **Deviation from native:** Native `showModal()` makes background content inert (no interaction), but does not prevent scroll on all browsers. We add explicit scroll lock for consistent behavior. + +--- + +## Layer 3: Foundations + +A compound hook returning prop-getters. The consumer controls all markup — the foundation wires up ARIA relationships, focus management, and keyboard behavior. + +Per [core-ux#2272](https://github.com/github/core-ux/issues/2272): prop-getters are the public API; context is an internal implementation detail only. + +**Import:** `@primer/react/foundations/experimental` + +### `useDialogFoundation` + +```ts +interface UseDialogFoundationOptions { + /** Whether the dialog is open */ + open: boolean + + /** Called when the dialog requests to close (controlled — dialog stays open until `open` becomes false) */ + onClose: (gesture: 'escape' | 'close-button' | 'backdrop') => void + + /** ARIA role */ + role?: 'dialog' | 'alertdialog' + + /** Accessible label when no visible title is used */ + 'aria-label'?: string + + /** Element to focus when the dialog opens */ + initialFocusRef?: React.RefObject + + /** Element to return focus to on close */ + returnFocusRef?: React.RefObject + + /** Whether clicking the backdrop closes the dialog. @default false */ + closeOnBackdropClick?: boolean +} + +interface UseDialogFoundationReturn { + /** Props for the element */ + getDialogProps: () => { + ref: React.RefCallback + role: 'dialog' | 'alertdialog' + 'aria-modal': true + 'aria-labelledby'?: string + 'aria-label'?: string + 'aria-describedby'?: string + onClick: (e: React.MouseEvent) => void + } + + /** Props for the title element (auto-wires aria-labelledby) */ + getTitleProps: () => { + id: string + } + + /** Props for the description element (auto-wires aria-describedby). Only call if description is present. */ + getDescriptionProps: () => { + id: string + } + + /** Props for the close button */ + getCloseProps: () => { + type: 'button' + onClick: () => void + } + + /** Props for a scrollable body region */ + getBodyProps: () => { + 'aria-labelledby': string + tabIndex: 0 + role: 'region' + } + + /** Whether the dialog is currently open (reflects DOM state) */ + isOpen: boolean + + /** Programmatically request close */ + close: (gesture: 'escape' | 'close-button' | 'backdrop') => void +} + +function useDialogFoundation(options: UseDialogFoundationOptions): UseDialogFoundationReturn +``` + +### Usage + +```tsx +import {useDialogFoundation} from '@primer/react/foundations/experimental' + +function MyCustomDialog({open, onClose}) { + const dialog = useDialogFoundation({open, onClose}) + + return ( + +
+

Confirm changes

+

This action cannot be undone.

+ +
+
+

Are you sure you want to proceed?

+
+
+ + +
+
+ ) +} +``` + +### Behavior + +- Internally uses Layer 4 hooks: `useScrollLock` for scroll lock, native `` for focus trapping + Escape +- Intercepts the native `cancel` event (`preventDefault()`) to maintain controlled close contract +- Auto-generates stable IDs for `aria-labelledby` and `aria-describedby` wiring +- `getDialogProps()` returns a ref callback that manages `showModal()`/`close()` based on `open` prop +- `getBodyProps()` returns `tabIndex: 0` and `role: "region"` so the scrollable body is keyboard-accessible and announced +- Backdrop click detection via `onClick` on the `` element (comparing click coordinates to dialog bounds) + +### Accessible name contract + +Every dialog MUST have an accessible name: + +- If `getTitleProps()` is spread onto an element → `aria-labelledby` is auto-wired (preferred) +- If no title → `aria-label` option is required +- A dev-mode warning fires if neither is provided + +> **Deviation from current implementation:** The current Dialog uses `
` rendered inside a Portal. Foundations switch to native `` because: +> +> 1. Native `` with `showModal()` renders in the top layer — no Portal or z-index needed +> 2. Background is automatically inert — no manual `aria-hidden` management +> 3. Focus trapping is built in — less JS, fewer edge cases +> 4. `::backdrop` pseudo-element is natively styleable +> +> The Portal approach was necessary before native `` had broad support. That's no longer the case. + +### Minimal CSS reset + +Foundations ship with a minimal CSS reset — only what's needed to remove browser default styling: + +```css +/* Foundation reset — no visual opinion */ +dialog[data-dialog-foundation] { + border: none; + padding: 0; + background: transparent; + max-width: unset; + max-height: unset; +} + +dialog[data-dialog-foundation]::backdrop { + background: transparent; +} +``` + +> **Important:** A Foundation-level dialog with a transparent backdrop is semantically modal (background is inert) but visually non-modal. Per ARIA APG, `aria-modal="true"` should only be set when background content is **both** non-interactive and visually obscured. Consumers using Foundations directly **must** provide visible backdrop styling to meet this requirement. Layer 2 Parts handle this automatically with Primer's `--overlay-backdrop-bgColor` token. + +--- + +## Layer 2: Parts + +Primer-styled compositional components. These are styled wrappers around Layer 3 Foundations, using Primer design tokens and CSS modules. + +**Import:** `@primer/react` + +### Component tree + +Same structure as Foundations, but with Primer visual styling applied: + +``` +Dialog.Root ← Styled DialogRoot +├── Dialog.Content ← Styled DialogContent (width, height, border-radius, shadow, animation) +│ ├── Dialog.Header ← Styled DialogHeader (padding, border-bottom) +│ │ ├── Dialog.Title ← Styled DialogTitle (font-size, font-weight) +│ │ ├── Dialog.Subtitle ← Styled DialogDescription (smaller, muted) +│ │ └── Dialog.CloseButton ← Styled DialogClose (IconButton with XIcon) +│ ├── Dialog.Body ← Styled DialogBody (padding, scroll, overflow border) +│ └── Dialog.Footer ← Styled DialogFooter (padding, flex layout, gap) +``` + +### API + +Parts use the same props as their Foundation counterparts, plus styling props: + +```tsx +// Dialog.Root — extends DialogRoot +interface DialogRootPartProps extends DialogRootProps { + // No additional props — styling is handled via CSS modules +} + +// Dialog.Content — extends DialogContent +interface DialogContentPartProps extends DialogContentProps { + /** Width preset */ + width?: 'small' | 'medium' | 'large' | 'xlarge' + /** Height preset */ + height?: 'small' | 'large' | 'auto' + /** Position */ + position?: 'center' | 'left' | 'right' | ResponsiveValue<'left' | 'right' | 'bottom' | 'fullscreen' | 'center'> + /** Vertical alignment (only when position is 'center') */ + align?: 'top' | 'center' | 'bottom' +} +``` + +### Usage + +```tsx +import {Dialog} from '@primer/react' + +function MyDialog({open, onClose}) { + return ( + + + + Confirm changes + This action cannot be undone. + + + +

Are you sure you want to proceed?

+
+ + + + +
+
+ ) +} +``` + +### Styling + +Parts use Primer design tokens via CSS modules: + +| Token area | Applied to | +| ---------------------------- | ---------------------------------------------- | +| `--overlay-bgColor` | Dialog.Content background | +| `--overlay-backdrop-bgColor` | `::backdrop` background | +| `--shadow-floating-small` | Dialog.Content box-shadow | +| `--borderRadius-large` | Dialog.Content border-radius | +| `--borderColor-default` | Header/body divider, body/footer scroll border | +| `--text-body-size-medium` | Dialog.Title font-size | +| `--text-title-weight-large` | Dialog.Title font-weight | +| `--text-body-size-small` | Dialog.Subtitle font-size | +| `--fgColor-muted` | Dialog.Subtitle color | +| `--base-size-*` | Padding, gaps | + +### Animations + +Parts include open/close animations using the same keyframes as the current Dialog: + +- **Center:** Scale fade (`scale(0.5)` → `scale(1)` + opacity) +- **Left/Right:** Slide in from edge +- **Bottom (narrow):** Slide up +- Respects `prefers-reduced-motion: reduce` + +--- + +## Layer 1: Ready-made + +The props-based API that most consumers use. Implemented as a thin wrapper around Layer 2 Parts. + +**Import:** `@primer/react` + +### API + +```tsx +interface DialogProps { + /** Dialog title. Also serves as aria-label. */ + title?: React.ReactNode + /** Subtitle rendered below the title. Also serves as aria-describedby. */ + subtitle?: React.ReactNode + /** Called when the dialog is closed via any gesture */ + onClose: (gesture: 'close-button' | 'escape') => void + /** ARIA role */ + role?: 'dialog' | 'alertdialog' + /** Width preset */ + width?: 'small' | 'medium' | 'large' | 'xlarge' + /** Height preset */ + height?: 'small' | 'large' | 'auto' + /** Position */ + position?: 'center' | 'left' | 'right' | ResponsiveValue<...> + /** Vertical alignment */ + align?: 'top' | 'center' | 'bottom' + /** Buttons to render in the footer */ + footerButtons?: DialogButtonProps[] + /** Element to focus on open */ + initialFocusRef?: React.RefObject + /** Element to return focus to on close */ + returnFocusRef?: React.RefObject + /** Custom header renderer */ + renderHeader?: React.FunctionComponent + /** Custom body renderer */ + renderBody?: React.FunctionComponent + /** Custom footer renderer */ + renderFooter?: React.FunctionComponent + /** Content */ + children: React.ReactNode +} +``` + +### How it maps to Parts + +The Ready-made `Dialog` is implemented entirely using Layer 2 Parts: + +```tsx +function Dialog({ title, subtitle, onClose, children, footerButtons, width, height, position, align, ...rest }) { + return ( + + + + {title} + {subtitle && {subtitle}} + + + {children} + {footerButtons?.length > 0 && ( + + {footerButtons.map(btn => + + +
+

+ Foundation Dialog +

+ +
+

+ This dialog is built entirely with consumer-owned markup. +

+
+

+ The useDialogFoundation hook provides prop-getters that wire up ARIA attributes, focus + management, scroll lock, and controlled close — but zero UI. +

+
+
+ + ) + }, +} + +// --- AlertDialog --- + +export const AlertDialog: StoryObj = { + render: () => { + const [open, setOpen] = useState(false) + const buttonRef = useRef(null) + + const onClose = useCallback(() => setOpen(false), []) + const foundation = useDialogFoundation({ + open, + onClose, + role: 'alertdialog', + returnFocusRef: buttonRef, + }) + + const dialogProps = foundation.getDialogProps() + const titleProps = foundation.getTitleProps() + const descriptionProps = foundation.getDescriptionProps() + + return ( + <> + + + +
+

+ Are you sure? +

+

+ This action cannot be undone. This will permanently delete this item. +

+
+ + +
+
+
+ + ) + }, +} + +// --- With Backdrop Click --- + +export const WithBackdropClick: StoryObj = { + render: () => { + const [open, setOpen] = useState(false) + const [lastGesture, setLastGesture] = useState('') + const buttonRef = useRef(null) + + const onClose = useCallback((gesture: string) => { + setLastGesture(gesture) + setOpen(false) + }, []) + + const foundation = useDialogFoundation({ + open, + onClose, + closeOnBackdropClick: true, + returnFocusRef: buttonRef, + }) + + const dialogProps = foundation.getDialogProps() + const titleProps = foundation.getTitleProps() + const bodyProps = foundation.getBodyProps() + const closeProps = foundation.getCloseProps() + + return ( + <> +
+ + {lastGesture && ( +

+ Last close gesture: {lastGesture} +

+ )} +
+ + +
+

+ Backdrop Click Demo +

+ +
+
+

Click the backdrop (outside this dialog) to close. The gesture will be reported.

+
+
+ + ) + }, +} diff --git a/packages/react/src/experimental/Dialog/DialogHookInspector.stories.tsx b/packages/react/src/experimental/Dialog/DialogHookInspector.stories.tsx new file mode 100644 index 00000000000..1020d2f7f96 --- /dev/null +++ b/packages/react/src/experimental/Dialog/DialogHookInspector.stories.tsx @@ -0,0 +1,336 @@ +import {useState, useRef, useCallback, type PropsWithChildren} from 'react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import { + useDialogFoundation, + type UseDialogFoundationOptions, + type UseDialogFoundationReturn, +} from '../../foundations/experimental/Dialog' + +/** + * Hook Inspector — useDialogFoundation + * + * These stories document the hook's contract: what goes in, what comes out, + * and how the return values change as you interact. The dialog itself is + * rendered with minimal inline styles — the point is the hook, not the UI. + */ +const meta: Meta = { + title: 'Experimental/Dialog/Hook Inspector', + parameters: { + controls: {expanded: true}, + }, +} + +export default meta + +// --- Rendering controls (remount/rerender) --- + +function RenderingControls({children}: PropsWithChildren) { + const [key, setKey] = useState(1) + const [, setRerender] = useState(1) + + return ( +
+ {children} +
+
+ + +
+
+ ) +} + +// --- Prop getter inspector --- + +function PropGetterInspector({foundation}: {foundation: UseDialogFoundationReturn}) { + const dialogProps = foundation.getDialogProps() + const titleProps = foundation.getTitleProps() + const descriptionProps = foundation.getDescriptionProps() + const closeProps = foundation.getCloseProps() + const bodyProps = foundation.getBodyProps() + + // Strip the ref and onClick (not serialisable) for display + const {ref: _ref, onClick: _onClick, ...displayDialogProps} = dialogProps + + return ( +
+

Hook return value

+
+        
+          {JSON.stringify(
+            {
+              isOpen: foundation.isOpen,
+              'getDialogProps()': {...displayDialogProps, ref: '[RefCallback]', onClick: '[Function]'},
+              'getTitleProps()': titleProps,
+              'getDescriptionProps()': descriptionProps,
+              'getCloseProps()': {...closeProps, onClick: '[Function]'},
+              'getBodyProps()': bodyProps,
+            },
+            null,
+            2,
+          )}
+        
+      
+
+ ) +} + +// --- Minimal dialog rendering --- + +const overlayStyle: React.CSSProperties = { + border: 'none', + borderRadius: 12, + padding: 0, + boxShadow: '0 8px 24px rgba(0,0,0,0.2)', + maxWidth: 480, + width: '100%', +} + +const headerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 16px', + borderBottom: '1px solid #d1d9e0', +} + +// --- Story: Default (inspect all prop-getters) --- + +export const Default: StoryObj = { + render: () => { + const [open, setOpen] = useState(false) + const [lastGesture, setLastGesture] = useState('(none)') + const buttonRef = useRef(null) + + const onClose = useCallback((gesture: string) => { + setLastGesture(gesture) + setOpen(false) + }, []) + + const foundation = useDialogFoundation({ + open, + onClose, + returnFocusRef: buttonRef, + }) + + const dialogProps = foundation.getDialogProps() + const titleProps = foundation.getTitleProps() + const descriptionProps = foundation.getDescriptionProps() + const closeProps = foundation.getCloseProps() + const bodyProps = foundation.getBodyProps() + + return ( + +
+
+ +

+ Last close gesture: {lastGesture} +

+
+ + +
+ + +
+

+ Dialog Title +

+ +
+

+ This is the description, wired to aria-describedby. +

+
+

Body content. Check the inspector panel to see what each prop-getter returns.

+
+
+
+ ) + }, +} + +// --- Story: ARIA wiring (with vs without title) --- + +export const AriaLabelFallback: StoryObj = { + name: 'aria-label fallback (no visible title)', + render: () => { + const [open, setOpen] = useState(false) + + const foundation = useDialogFoundation({ + open, + onClose: () => setOpen(false), + 'aria-label': 'Confirm deletion', + }) + + const dialogProps = foundation.getDialogProps() + const closeProps = foundation.getCloseProps() + + return ( + + + + + +
+

+ This dialog has no visible title — it uses aria-label instead. +

+

+ Check the inspector: aria-label is set, aria-labelledby still points to a + generated ID but no element uses it. +

+ +
+
+
+ ) + }, +} + +// --- Story: Backdrop click --- + +export const BackdropClick: StoryObj = { + name: 'closeOnBackdropClick behaviour', + render: () => { + const [open, setOpen] = useState(false) + const [gestures, setGestures] = useState([]) + + const onClose = useCallback((gesture: string) => { + setGestures(prev => [...prev, gesture]) + setOpen(false) + }, []) + + const foundation = useDialogFoundation({ + open, + onClose, + closeOnBackdropClick: true, + }) + + const dialogProps = foundation.getDialogProps() + const titleProps = foundation.getTitleProps() + const closeProps = foundation.getCloseProps() + + return ( + + + +
+

+ Close gesture log: [{gestures.map(g => `"${g}"`).join(', ')}] +

+
+ + + + +
+

+ Backdrop Click Demo +

+ +
+
+

Try closing via: Escape, close button, or clicking the backdrop.

+

Each gesture is logged below the trigger button.

+
+
+
+ ) + }, +} + +// --- Story: alertdialog role --- + +export const AlertDialogRole: StoryObj = { + name: 'role="alertdialog"', + render: () => { + const [open, setOpen] = useState(false) + + const foundation = useDialogFoundation({ + open, + onClose: () => setOpen(false), + role: 'alertdialog', + }) + + const dialogProps = foundation.getDialogProps() + const titleProps = foundation.getTitleProps() + const descriptionProps = foundation.getDescriptionProps() + + return ( + + + + + +
+

+ Are you sure? +

+

+ This action cannot be undone. +

+

+ Check the inspector: role is now "alertdialog". +

+
+ + +
+
+
+
+ ) + }, +} + +// --- Story: Initial focus ref --- + +export const InitialFocusRef: StoryObj = { + name: 'initialFocusRef', + render: () => { + const [open, setOpen] = useState(false) + const cancelRef = useRef(null) + + const foundation = useDialogFoundation({ + open, + onClose: () => setOpen(false), + initialFocusRef: cancelRef, + }) + + const dialogProps = foundation.getDialogProps() + const titleProps = foundation.getTitleProps() + + return ( + + + + + +
+

+ Focus Test +

+

The Cancel button should receive focus on open.

+
+ + +
+
+
+
+ ) + }, +} diff --git a/packages/react/src/experimental/Dialog/DialogParts.stories.tsx b/packages/react/src/experimental/Dialog/DialogParts.stories.tsx new file mode 100644 index 00000000000..57138f0199f --- /dev/null +++ b/packages/react/src/experimental/Dialog/DialogParts.stories.tsx @@ -0,0 +1,240 @@ +import {useState, useRef, useCallback} from 'react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import {DialogParts} from './Dialog' +import {Button} from '../../Button' +import Text from '../../Text' + +/** + * Layer 2 — Parts stories. + * + * These demonstrate the compound component API: `DialogParts.Root`, `DialogParts.Content`, + * `DialogParts.Header`, `DialogParts.Title`, `DialogParts.Subtitle`, `DialogParts.Body`, + * `DialogParts.Footer`, and `DialogParts.CloseButton`. + */ +const meta: Meta = { + title: 'Experimental/Dialog/Parts', + parameters: { + controls: {expanded: true}, + }, +} + +export default meta + +const lipsum = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque sollicitudin mauris maximus elit sagittis, nec lobortis ligula elementum. Nam iaculis, urna nec lobortis posuere, eros urna venenatis eros, vel accumsan turpis nunc vitae enim.' + +// --- Default --- + +export const Default: StoryObj = { + render: () => { + const [open, setOpen] = useState(false) + const buttonRef = useRef(null) + const onClose = useCallback(() => setOpen(false), []) + + return ( + <> + + + + + + Parts Dialog + + + Built with compound components + + {lipsum} + + + + + + + + + ) + }, +} + +// --- Sizes --- + +export const Small: StoryObj = { + render: () => { + const [open, setOpen] = useState(false) + const onClose = useCallback(() => setOpen(false), []) + + return ( + <> + + + + + Small + + + + A compact 296px dialog. + + + + + ) + }, +} + +export const Large: StoryObj = { + render: () => { + const [open, setOpen] = useState(false) + const onClose = useCallback(() => setOpen(false), []) + + return ( + <> + + + + + Large + + + + A 480×640 dialog with fixed height. + {lipsum} + {lipsum} + + + + + + + + ) + }, +} + +// --- Positions --- + +export const PositionRight: StoryObj = { + name: 'Position: Right (side sheet)', + render: () => { + const [open, setOpen] = useState(false) + const onClose = useCallback(() => setOpen(false), []) + + return ( + <> + + + + + Side Sheet + + + + This dialog slides in from the right edge, full height. + + + + + ) + }, +} + +export const PositionLeft: StoryObj = { + name: 'Position: Left (side sheet)', + render: () => { + const [open, setOpen] = useState(false) + const onClose = useCallback(() => setOpen(false), []) + + return ( + <> + + + + + Left Sheet + + + + Slides in from the left edge. + + + + + ) + }, +} + +export const ResponsivePosition: StoryObj = { + name: 'Responsive: center → fullscreen on narrow', + render: () => { + const [open, setOpen] = useState(false) + const onClose = useCallback(() => setOpen(false), []) + + return ( + <> + + + + + Responsive + + + + Center on desktop, fullscreen on narrow viewports. Resize to see. + + + + + + + + ) + }, +} + +// --- Nested Dialogs --- + +export const Nested: StoryObj = { + render: () => { + const [firstOpen, setFirstOpen] = useState(false) + const [secondOpen, setSecondOpen] = useState(false) + + return ( + <> + + + setFirstOpen(false)}> + + + First Dialog + + + + This is the first dialog. You can open another one on top. + + + + + + setSecondOpen(false)}> + + + Second Dialog + + + + Nested dialog with independent scroll lock. + + + + + ) + }, +} diff --git a/packages/react/src/experimental/Dialog/ReadyMadeDialog.stories.tsx b/packages/react/src/experimental/Dialog/ReadyMadeDialog.stories.tsx new file mode 100644 index 00000000000..dd1b4747845 --- /dev/null +++ b/packages/react/src/experimental/Dialog/ReadyMadeDialog.stories.tsx @@ -0,0 +1,167 @@ +import {useState, useRef, useCallback} from 'react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import {Dialog} from './ReadyMadeDialog' +import {Button} from '../../Button' +import Text from '../../Text' + +/** + * Layer 1 — Ready-made Dialog stories. + * + * The simplest API: a single `` component with props for + * title, subtitle, footer buttons, and children as body content. + */ +const meta: Meta = { + title: 'Experimental/Dialog/ReadyMade', + component: Dialog, + parameters: { + controls: {expanded: true}, + }, +} + +export default meta + +// --- Default --- + +export const Default: StoryObj = { + render: () => { + const [open, setOpen] = useState(false) + const buttonRef = useRef(null) + const onClose = useCallback(() => setOpen(false), []) + + return ( + <> + + + + + This dialog is built with a single component. Title, subtitle, and footer buttons are all props. + + + + ) + }, +} + +// --- Alert Dialog --- + +export const Alert: StoryObj = { + name: 'Alert Dialog (destructive action)', + render: () => { + const [open, setOpen] = useState(false) + const onClose = useCallback(() => setOpen(false), []) + + return ( + <> + + + + + Once deleted, all data including issues, pull requests, and actions will be permanently removed. + + + + ) + }, +} + +// --- No Footer --- + +export const NoFooter: StoryObj = { + name: 'Without footer buttons', + render: () => { + const [open, setOpen] = useState(false) + const onClose = useCallback(() => setOpen(false), []) + + return ( + <> + + + + This dialog has no footer buttons. Close it with the X button or press Escape. + + + ) + }, +} + +// --- Side Sheet --- + +export const SideSheet: StoryObj = { + name: 'Position: Right (side sheet)', + render: () => { + const [open, setOpen] = useState(false) + const onClose = useCallback(() => setOpen(false), []) + + return ( + <> + + + + A side sheet that slides in from the right. Click the backdrop to dismiss. + + + ) + }, +} + +// --- Auto Focus Button --- + +export const AutoFocusButton: StoryObj = { + name: 'Auto-focus on footer button', + render: () => { + const [open, setOpen] = useState(false) + const onClose = useCallback(() => setOpen(false), []) + + return ( + <> + + + + The “Confirm” button receives focus automatically. + + + ) + }, +} diff --git a/packages/react/src/experimental/Dialog/ReadyMadeDialog.tsx b/packages/react/src/experimental/Dialog/ReadyMadeDialog.tsx new file mode 100644 index 00000000000..7bae84cbff0 --- /dev/null +++ b/packages/react/src/experimental/Dialog/ReadyMadeDialog.tsx @@ -0,0 +1,143 @@ +import React, {useCallback, useRef} from 'react' +import type {ButtonProps} from '../../Button' +import {Button} from '../../Button' +import type {ResponsiveValue} from '../../hooks/useResponsiveValue' +import {DialogParts} from './Dialog' + +// --- Types --- + +export type DialogButtonProps = Omit & { + /** The variant of button to render */ + buttonType?: 'default' | 'primary' | 'danger' + /** The button label */ + content: React.ReactNode + /** + * If true, focus this button when the dialog opens. + * Only the first button with autoFocus will receive focus. + */ + autoFocus?: boolean +} + +export interface DialogProps { + /** Whether the dialog is open */ + open: boolean + + /** Title displayed in the dialog header */ + title: React.ReactNode + + /** Subtitle displayed below the title */ + subtitle?: React.ReactNode + + /** + * Called when the dialog requests to close. + * The dialog does NOT close until `open` is set to `false`. + */ + onClose: (gesture: 'escape' | 'close-button' | 'backdrop') => void + + /** @default 'dialog' */ + role?: 'dialog' | 'alertdialog' + + /** The width of the dialog content area */ + width?: 'small' | 'medium' | 'large' | 'xlarge' + + /** The height of the dialog content area */ + height?: 'small' | 'large' | 'auto' + + /** The position of the dialog */ + position?: 'center' | 'left' | 'right' | ResponsiveValue<'left' | 'right' | 'bottom' | 'fullscreen' | 'center'> + + /** Vertical alignment when position is center */ + align?: 'top' | 'center' | 'bottom' + + /** Buttons rendered in the dialog footer */ + footerButtons?: DialogButtonProps[] + + /** Dialog body content */ + children: React.ReactNode + + /** Additional class name for the root dialog element */ + className?: string + + /** Element to return focus to on close */ + returnFocusRef?: React.RefObject + + /** Whether clicking the backdrop closes the dialog. @default false */ + closeOnBackdropClick?: boolean +} + +// --- Component --- + +const buttonTypeToVariant: Record = { + default: 'default', + primary: 'primary', + danger: 'danger', +} + +export const Dialog = React.forwardRef(function Dialog( + { + open, + title, + subtitle, + onClose, + role, + width, + height, + position, + align, + footerButtons, + children, + className, + returnFocusRef, + closeOnBackdropClick, + }, + ref, +) { + // Find the first button with autoFocus to use as initialFocusRef + const autoFocusButtonRef = useRef(null) + const autoFocusIndex = footerButtons?.findIndex(b => b.autoFocus) ?? -1 + + const onCloseHandler = useCallback( + (gesture: 'escape' | 'close-button' | 'backdrop') => { + onClose(gesture) + }, + [onClose], + ) + + return ( + = 0 ? autoFocusButtonRef : undefined} + > + + + {title} + + + {subtitle && {subtitle}} + {children} + {footerButtons && footerButtons.length > 0 && ( + + {footerButtons.map(({buttonType = 'default', content, autoFocus: _autoFocus, ...buttonProps}, index) => ( + + ))} + + )} + + + ) +}) + +Dialog.displayName = 'Dialog' diff --git a/packages/react/src/experimental/Dialog/index.ts b/packages/react/src/experimental/Dialog/index.ts new file mode 100644 index 00000000000..4ce4a5a5645 --- /dev/null +++ b/packages/react/src/experimental/Dialog/index.ts @@ -0,0 +1,4 @@ +export {DialogParts} from './Dialog' +export type {DialogRootProps, DialogContentProps} from './Dialog' +export {Dialog} from './ReadyMadeDialog' +export type {DialogProps, DialogButtonProps} from './ReadyMadeDialog' diff --git a/packages/react/src/foundations/experimental/Dialog/DialogFoundation.css b/packages/react/src/foundations/experimental/Dialog/DialogFoundation.css new file mode 100644 index 00000000000..eb8a8afbc13 --- /dev/null +++ b/packages/react/src/foundations/experimental/Dialog/DialogFoundation.css @@ -0,0 +1,16 @@ +/* Foundation reset — no visual opinion. + * Removes browser defaults that interfere with correct behavior. + * Uses :where() for zero specificity so Layer 2 styles always win. */ + +:where(dialog[data-dialog-foundation]) { + border: none; + padding: 0; + background: transparent; + max-width: unset; + max-height: unset; + color: inherit; +} + +:where(dialog[data-dialog-foundation])::backdrop { + background: transparent; +} diff --git a/packages/react/src/foundations/experimental/Dialog/__tests__/useDialogFoundation.test.tsx b/packages/react/src/foundations/experimental/Dialog/__tests__/useDialogFoundation.test.tsx new file mode 100644 index 00000000000..f1f6230952e --- /dev/null +++ b/packages/react/src/foundations/experimental/Dialog/__tests__/useDialogFoundation.test.tsx @@ -0,0 +1,252 @@ +import React, {useRef} from 'react' +import {render, fireEvent, screen} from '@testing-library/react' +import {describe, expect, it, vi} from 'vitest' +import {useDialogFoundation, type UseDialogFoundationOptions} from '..' + +// Test harness that renders a dialog using the foundation hook +function TestDialog(props: UseDialogFoundationOptions & {children?: React.ReactNode}) { + const {children, ...options} = props + const foundation = useDialogFoundation(options) + const dialogProps = foundation.getDialogProps() + const titleProps = foundation.getTitleProps() + const descriptionProps = foundation.getDescriptionProps() + const closeProps = foundation.getCloseProps() + const bodyProps = foundation.getBodyProps() + + return ( + +

Test Title

+

Test Description

+
{children ?? 'Body content'}
+ +
+ ) +} + +describe('useDialogFoundation', () => { + it('renders a dialog with correct ARIA attributes', () => { + render( {}} />) + + const dialog = screen.getByRole('dialog') + expect(dialog).toBeInTheDocument() + expect(dialog).toHaveAttribute('aria-modal', 'true') + expect(dialog).toHaveAttribute('aria-labelledby') + expect(dialog).toHaveAttribute('aria-describedby') + expect(dialog).toHaveAttribute('data-dialog-foundation', '') + }) + + it('calls showModal when open is true', () => { + render( {}} />) + const dialog = screen.getByRole('dialog') + // Dialog should be open (showModal was called by the hook) + expect(dialog).toHaveAttribute('open') + }) + + it('wires title id to aria-labelledby', () => { + render( {}} />) + const dialog = screen.getByRole('dialog') + const title = screen.getByText('Test Title') + + expect(dialog.getAttribute('aria-labelledby')).toBe(title.id) + }) + + it('wires description id to aria-describedby', () => { + render( {}} />) + const dialog = screen.getByRole('dialog') + const description = screen.getByText('Test Description') + + expect(dialog.getAttribute('aria-describedby')).toBe(description.id) + }) + + it('calls onClose with "close-button" when close button is clicked', () => { + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByText('Close')) + expect(onClose).toHaveBeenCalledWith('close-button') + }) + + it('calls onClose with "escape" when cancel event fires', () => { + const onClose = vi.fn() + render() + + const dialog = screen.getByRole('dialog') + const cancelEvent = new Event('cancel', {cancelable: true}) + dialog.dispatchEvent(cancelEvent) + + expect(onClose).toHaveBeenCalledWith('escape') + expect(cancelEvent.defaultPrevented).toBe(true) + }) + + it('supports role="alertdialog"', () => { + render( {}} role="alertdialog" />) + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + }) + + it('sets aria-label when provided (no visible title)', () => { + function AriaLabelDialog() { + const foundation = useDialogFoundation({ + open: true, + onClose: () => {}, + 'aria-label': 'Confirm deletion', + }) + const dialogProps = foundation.getDialogProps() + return Are you sure? + } + + render() + const dialog = screen.getByRole('dialog') + expect(dialog).toHaveAttribute('aria-label', 'Confirm deletion') + }) + + it('body region has role="region" and aria-labelledby', () => { + render( {}} />) + const body = screen.getByRole('region') + expect(body).toHaveAttribute('aria-labelledby') + expect(body).toHaveAttribute('tabindex', '0') + }) + + it('supports initialFocusRef', () => { + function DialogWithInitialFocus() { + const inputRef = useRef(null) + const foundation = useDialogFoundation({ + open: true, + onClose: () => {}, + initialFocusRef: inputRef, + }) + const dialogProps = foundation.getDialogProps() + const titleProps = foundation.getTitleProps() + + return ( + +

Title

+ +
+ ) + } + + render() + expect(screen.getByTestId('focus-target')).toHaveFocus() + }) + + it('restores focus to returnFocusRef on close', () => { + function DialogWithReturnFocus() { + const [open, setOpen] = React.useState(false) + const buttonRef = useRef(null) + + return ( + <> + + setOpen(false)} returnFocusRef={buttonRef} /> + + ) + } + + render() + fireEvent.click(screen.getByTestId('trigger')) + expect(screen.getByRole('dialog')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Close')) + expect(screen.getByTestId('trigger')).toHaveFocus() + }) + + it('restores focus to previously-focused element when no returnFocusRef', () => { + function DialogWithAutoRestore() { + const [open, setOpen] = React.useState(false) + + return ( + <> + + setOpen(false)} /> + + ) + } + + render() + const trigger = screen.getByTestId('trigger') + trigger.focus() + fireEvent.click(trigger) + expect(screen.getByRole('dialog')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Close')) + expect(trigger).toHaveFocus() + }) + + it('applies scroll lock when open', () => { + const {unmount} = render( {}} />) + expect(document.body.style.overflow).toBe('hidden') + + unmount() + expect(document.body.style.overflow).toBe('') + }) + + it('handles nested scroll locks correctly', () => { + function NestedDialogs() { + return ( + <> + {}} /> + {}} /> + + ) + } + + const {unmount} = render() + expect(document.body.style.overflow).toBe('hidden') + + // Unmounting removes both — scroll lock should be released + unmount() + expect(document.body.style.overflow).toBe('') + }) + + it('closes and reopens correctly', () => { + function ReopenDialog() { + const [open, setOpen] = React.useState(true) + const buttonRef = useRef(null) + + return ( + <> + + setOpen(false)} returnFocusRef={buttonRef} /> + + ) + } + + const {rerender} = render() + expect(screen.getByRole('dialog')).toHaveAttribute('open') + + // Close + fireEvent.click(screen.getByText('Close')) + + // Reopen + fireEvent.click(screen.getByTestId('trigger')) + expect(screen.getByRole('dialog')).toHaveAttribute('open') + }) + + it('warns in dev mode when no accessible name is provided', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + function NoNameDialog() { + const foundation = useDialogFoundation({ + open: true, + onClose: () => {}, + }) + const dialogProps = foundation.getDialogProps() + return Content + } + + render() + + // Wait for the queueMicrotask to flush + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No accessible name provided')) + + warnSpy.mockRestore() + }) +}) diff --git a/packages/react/src/foundations/experimental/Dialog/index.ts b/packages/react/src/foundations/experimental/Dialog/index.ts new file mode 100644 index 00000000000..70bcf37cef7 --- /dev/null +++ b/packages/react/src/foundations/experimental/Dialog/index.ts @@ -0,0 +1,2 @@ +export {useDialogFoundation} from './useDialogFoundation' +export type {UseDialogFoundationOptions, UseDialogFoundationReturn} from './useDialogFoundation' diff --git a/packages/react/src/foundations/experimental/Dialog/useDialogFoundation.ts b/packages/react/src/foundations/experimental/Dialog/useDialogFoundation.ts new file mode 100644 index 00000000000..c3ddc7dbda4 --- /dev/null +++ b/packages/react/src/foundations/experimental/Dialog/useDialogFoundation.ts @@ -0,0 +1,276 @@ +import {useCallback, useEffect, useId, useRef} from 'react' +import {useScrollLock} from '../../../hooks/useScrollLock' +import './DialogFoundation.css' + +// --- Types --- + +type CloseGesture = 'escape' | 'close-button' | 'backdrop' + +export interface UseDialogFoundationOptions { + /** Whether the dialog is open */ + open: boolean + + /** + * Called when the dialog requests to close. + * The dialog does NOT close until `open` is set to `false` — this is a request, not a command. + */ + onClose: (gesture: CloseGesture) => void + + /** @default 'dialog' */ + role?: 'dialog' | 'alertdialog' + + /** Accessible label when no visible title is used */ + 'aria-label'?: string + + /** Element to focus when the dialog opens */ + initialFocusRef?: React.RefObject + + /** Element to return focus to on close */ + returnFocusRef?: React.RefObject + + /** Whether clicking the backdrop closes the dialog. @default false */ + closeOnBackdropClick?: boolean +} + +export interface UseDialogFoundationReturn { + /** Props for the element */ + getDialogProps: () => DialogProps + /** Props for the title element (auto-wires aria-labelledby) */ + getTitleProps: () => TitleProps + /** Props for the description element (auto-wires aria-describedby) */ + getDescriptionProps: () => DescriptionProps + /** Props for the close button */ + getCloseProps: () => CloseProps + /** Props for a scrollable body region */ + getBodyProps: () => BodyProps + /** Whether the dialog is currently open */ + isOpen: boolean + /** Programmatically request close */ + close: (gesture: CloseGesture) => void +} + +interface DialogProps { + ref: React.RefCallback + role: 'dialog' | 'alertdialog' + 'aria-modal': true + 'aria-labelledby'?: string + 'aria-label'?: string + 'aria-describedby'?: string + 'data-dialog-foundation': '' + onClick: (e: React.MouseEvent) => void +} + +interface TitleProps { + id: string +} + +interface DescriptionProps { + id: string +} + +interface CloseProps { + type: 'button' + onClick: () => void +} + +interface BodyProps { + 'aria-labelledby': string + tabIndex: 0 + role: 'region' +} + +// --- Hook --- + +export function useDialogFoundation(options: UseDialogFoundationOptions): UseDialogFoundationReturn { + const { + open, + onClose, + role = 'dialog', + 'aria-label': ariaLabel, + initialFocusRef, + returnFocusRef, + closeOnBackdropClick = false, + } = options + + const dialogRef = useRef(null) + const previousFocusRef = useRef(null) + const titleId = useId() + const descriptionId = useId() + // Track whether getTitleProps/getDescriptionProps are called + const titleUsed = useRef(false) + const descriptionUsed = useRef(false) + + // Reset usage tracking each render + titleUsed.current = false + descriptionUsed.current = false + + // Scroll lock + useScrollLock(open) + + // Open/close lifecycle + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + + if (open) { + if (!dialog.open) { + // Store the element that had focus before opening + previousFocusRef.current = document.activeElement + dialog.showModal() + } + + // Handle initial focus + if (initialFocusRef?.current) { + initialFocusRef.current.focus() + } + // Otherwise, showModal() handles focus (autofocus attribute or first focusable) + } else { + if (dialog.open) { + dialog.close() + } + + // Restore focus + const returnTarget = returnFocusRef?.current ?? previousFocusRef.current + if (returnTarget instanceof HTMLElement) { + returnTarget.focus() + } + previousFocusRef.current = null + } + }, [open, initialFocusRef, returnFocusRef]) + + // Intercept native cancel event (Escape key) — controlled close contract + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + + const handleCancel = (e: Event) => { + e.preventDefault() + onClose('escape') + } + + // Guard: if native close happens without going through onClose + // (e.g. dialog.close() called directly), re-sync state. + const handleClose = () => { + if (open && !dialog.open) { + // Native dialog was closed externally — re-open to maintain controlled contract + dialog.showModal() + } + } + + dialog.addEventListener('cancel', handleCancel) + dialog.addEventListener('close', handleClose) + return () => { + dialog.removeEventListener('cancel', handleCancel) + dialog.removeEventListener('close', handleClose) + } + }, [onClose, open]) + + // Backdrop click detection + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!closeOnBackdropClick) return + + // Native fires click on the dialog element when backdrop is clicked. + // We detect backdrop clicks by checking if the click was on the dialog itself + // (not a child) — the backdrop area is the padding/border area of the . + const dialog = dialogRef.current + if (!dialog || e.target !== dialog) return + + // Check if click was outside the dialog's content box + const rect = dialog.getBoundingClientRect() + const clickedInside = + e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom + + if (!clickedInside) { + onClose('backdrop') + } + }, + [closeOnBackdropClick, onClose], + ) + + const close = useCallback( + (gesture: CloseGesture) => { + onClose(gesture) + }, + [onClose], + ) + + // Dev-mode accessible name check + useEffect(() => { + if (process.env.NODE_ENV !== 'production' && open) { + // Check after a microtask so getTitleProps has been called + queueMicrotask(() => { + if (!titleUsed.current && !ariaLabel) { + console.warn( + 'Dialog: No accessible name provided. Use getTitleProps() on a title element, or pass aria-label to useDialogFoundation().', + ) + } + }) + } + }, [open, ariaLabel]) + + // Ref callback for the dialog element + const refCallback = useCallback((node: HTMLDialogElement | null) => { + dialogRef.current = node + }, []) + + // --- Prop getters --- + + const getDialogProps = useCallback((): DialogProps => { + const props: DialogProps = { + ref: refCallback, + role, + 'aria-modal': true, + 'data-dialog-foundation': '', + onClick: handleClick, + } + + // Accessible name: prefer aria-labelledby (set if getTitleProps is used) + // Fall back to aria-label + if (ariaLabel) { + props['aria-label'] = ariaLabel + } + // aria-labelledby and aria-describedby are always set — + // they reference IDs that may or may not exist in the DOM. + // If the element with that ID doesn't exist, the attribute is silently ignored. + props['aria-labelledby'] = titleId + props['aria-describedby'] = descriptionId + + return props + }, [refCallback, role, ariaLabel, titleId, descriptionId, handleClick]) + + const getTitleProps = useCallback((): TitleProps => { + titleUsed.current = true + return {id: titleId} + }, [titleId]) + + const getDescriptionProps = useCallback((): DescriptionProps => { + descriptionUsed.current = true + return {id: descriptionId} + }, [descriptionId]) + + const getCloseProps = useCallback((): CloseProps => { + return { + type: 'button', + onClick: () => onClose('close-button'), + } + }, [onClose]) + + const getBodyProps = useCallback((): BodyProps => { + return { + 'aria-labelledby': titleId, + tabIndex: 0, + role: 'region', + } + }, [titleId]) + + return { + getDialogProps, + getTitleProps, + getDescriptionProps, + getCloseProps, + getBodyProps, + isOpen: open, + close, + } +} diff --git a/packages/react/src/foundations/experimental/index.ts b/packages/react/src/foundations/experimental/index.ts new file mode 100644 index 00000000000..7debadc8cda --- /dev/null +++ b/packages/react/src/foundations/experimental/index.ts @@ -0,0 +1,2 @@ +export {useDialogFoundation} from './Dialog' +export type {UseDialogFoundationOptions, UseDialogFoundationReturn} from './Dialog' diff --git a/packages/react/src/hooks/__tests__/useScrollLock.test.ts b/packages/react/src/hooks/__tests__/useScrollLock.test.ts new file mode 100644 index 00000000000..8ce4bc159f2 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useScrollLock.test.ts @@ -0,0 +1,61 @@ +import {renderHook} from '@testing-library/react' +import {describe, expect, it, vi, beforeEach} from 'vitest' +import {useScrollLock} from '../useScrollLock' + +describe('useScrollLock', () => { + beforeEach(() => { + document.body.style.overflow = '' + document.body.style.paddingRight = '' + }) + + it('sets overflow hidden on body when enabled', () => { + renderHook(() => useScrollLock(true)) + expect(document.body.style.overflow).toBe('hidden') + }) + + it('removes overflow hidden when disabled', () => { + const {rerender} = renderHook(({enabled}) => useScrollLock(enabled), { + initialProps: {enabled: true}, + }) + + expect(document.body.style.overflow).toBe('hidden') + + rerender({enabled: false}) + expect(document.body.style.overflow).toBe('') + }) + + it('restores body styles on unmount', () => { + const {unmount} = renderHook(() => useScrollLock(true)) + expect(document.body.style.overflow).toBe('hidden') + + unmount() + expect(document.body.style.overflow).toBe('') + }) + + it('handles nested locks — only removes on last unlock', () => { + const hook1 = renderHook(() => useScrollLock(true)) + const hook2 = renderHook(() => useScrollLock(true)) + + expect(document.body.style.overflow).toBe('hidden') + + hook1.unmount() + // Still locked because hook2 is active + expect(document.body.style.overflow).toBe('hidden') + + hook2.unmount() + // Now unlocked + expect(document.body.style.overflow).toBe('') + }) + + it('does not lock when initially disabled', () => { + renderHook(() => useScrollLock(false)) + expect(document.body.style.overflow).toBe('') + }) + + it('compensates for scrollbar width', () => { + // In browser test env scrollbar width is 0, so paddingRight is '0px' + renderHook(() => useScrollLock(true)) + const px = parseInt(document.body.style.paddingRight, 10) + expect(px).toBe(0) + }) +}) diff --git a/packages/react/src/hooks/experimental/index.ts b/packages/react/src/hooks/experimental/index.ts new file mode 100644 index 00000000000..558a466b765 --- /dev/null +++ b/packages/react/src/hooks/experimental/index.ts @@ -0,0 +1 @@ +export {useScrollLock} from '../useScrollLock' diff --git a/packages/react/src/hooks/useScrollLock.ts b/packages/react/src/hooks/useScrollLock.ts new file mode 100644 index 00000000000..12bfa3df417 --- /dev/null +++ b/packages/react/src/hooks/useScrollLock.ts @@ -0,0 +1,39 @@ +import {useEffect, useRef} from 'react' + +// Global ref count for nested dialogs — only remove scroll lock when the last dialog closes +let activeScrollLocks = 0 + +/** + * Prevents background scrolling while active. + * Compensates for scrollbar removal to prevent layout shift. + * Handles nested dialogs via ref counting — scroll lock is only + * removed when the last active lock is released. + */ +export function useScrollLock(enabled: boolean): void { + const isLocked = useRef(false) + + useEffect(() => { + if (enabled && !isLocked.current) { + isLocked.current = true + activeScrollLocks++ + + if (activeScrollLocks === 1) { + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth + document.body.style.setProperty('--dialog-scrollbar-gutter', `${scrollbarWidth}px`) + document.body.style.paddingRight = `${scrollbarWidth}px` + document.body.style.overflow = 'hidden' + } + + return () => { + isLocked.current = false + activeScrollLocks-- + + if (activeScrollLocks === 0) { + document.body.style.removeProperty('--dialog-scrollbar-gutter') + document.body.style.removeProperty('padding-right') + document.body.style.removeProperty('overflow') + } + } + } + }, [enabled]) +}