From bd946182d7d57560b94167b279263f943d6066f2 Mon Sep 17 00:00:00 2001 From: Lukas Oppermann Date: Mon, 27 Apr 2026 12:19:16 +0200 Subject: [PATCH 01/23] Add Dialog 4-layer component spec Defines the Dialog component across all four layers of the modular component architecture: Hooks, Foundations, Parts, and Ready-made. Grounded in web standards (HTML + ARIA APG dialog-modal pattern). Documents deviations from native behavior with rationale. Issue: github/core-ux#2267 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- contributor-docs/specs/Dialog.md | 758 +++++++++++++++++++++++++++++++ 1 file changed, 758 insertions(+) create mode 100644 contributor-docs/specs/Dialog.md diff --git a/contributor-docs/specs/Dialog.md b/contributor-docs/specs/Dialog.md new file mode 100644 index 00000000000..bb4cfc89440 --- /dev/null +++ b/contributor-docs/specs/Dialog.md @@ -0,0 +1,758 @@ +# 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** | Unstyled, accessible components — semantic markup with zero visual opinion | +| 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 + +Unstyled, accessible components with semantic markup. These provide the correct DOM structure, ARIA attributes, and keyboard behavior with zero visual opinion (CSS reset only). + +**Import:** `@primer/react/foundations` + +### Component tree + +``` +DialogRoot ← element, manages open/close lifecycle +├── DialogBackdrop ← ::backdrop pseudo-element (CSS only, not a component) +├── DialogContent ← Container for all dialog content, manages layout +│ ├── DialogHeader ← Landmark for title + close button +│ │ ├── DialogTitle ← Connected to aria-labelledby +│ │ ├── DialogDescription ← Connected to aria-describedby (optional) +│ │ └── DialogClose ← Close button +│ ├── DialogBody ← Scrollable content area +│ └── DialogFooter ← Action buttons area +``` + +### `DialogRoot` + +The root component. Renders a native `` element. + +```tsx +interface DialogRootProps { + /** Whether the dialog is open */ + open: boolean + + /** Called when the dialog requests to close */ + onClose: (gesture: 'escape' | 'close-button' | 'backdrop') => void + + /** ARIA role — 'dialog' (default) or 'alertdialog' */ + role?: 'dialog' | 'alertdialog' + + /** + * Accessible label for the dialog. Required if no DialogTitle is provided. + * When DialogTitle is present, aria-labelledby is auto-wired and this is not needed. + */ + '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 + + children: React.ReactNode +} +``` + +**Renders:** +```html + + aria-label="{from prop}" + aria-describedby="{auto-generated-id}" +> + {children} + +``` + +**Accessible name contract:** Every dialog MUST have an accessible name. `DialogRoot` enforces this: +- If a `DialogTitle` child is present → `aria-labelledby` is auto-wired (preferred) +- If no `DialogTitle` → `aria-label` prop is required +- A dev-mode warning fires if neither is provided + +**Uses:** `useDialog` (Layer 4) internally. + +**Why native ``:** This is the foundation layer — we want to build on web standards, not around them. Using `` gives us focus trapping, Escape handling, top layer rendering, `::backdrop`, and inert background for free. We only add what the platform doesn't provide. + +> **Deviation from current implementation:** The current Dialog uses `
` rendered inside a Portal. The foundation layer switches 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. + +### `DialogContent` + +Wraps all dialog content. Provides the layout container inside the ``. + +```tsx +interface DialogContentProps extends React.ComponentProps<'div'> { + children: React.ReactNode +} +``` + +**Renders:** `
{children}
` + +**Purpose:** Separates the content container from the `` element itself. This is needed because: +- `` dimensions include the `::backdrop` in some layout scenarios +- Animations should target the content, not the `` element +- Consumers may need to style the content container independently + +### `DialogHeader` + +Container for the dialog title, description, and close button. + +```tsx +interface DialogHeaderProps extends React.ComponentProps<'header'> { + children: React.ReactNode +} +``` + +**Renders:** `
{children}
` + +### `DialogTitle` + +The dialog's title. Automatically connected to `aria-labelledby` on `DialogRoot`. + +```tsx +interface DialogTitleProps extends React.ComponentProps<'h2'> { + children: React.ReactNode +} +``` + +**Renders:** `

{children}

` + +> **Deviation from current implementation:** Current uses `

`. We use `

` because dialogs are overlays on a page that already has an `

`. The heading level should reflect the dialog's position in the document outline, not assert top-level importance. This follows ARIA authoring best practices. + +### `DialogDescription` + +Optional description. Automatically connected to `aria-describedby` on `DialogRoot`. + +```tsx +interface DialogDescriptionProps extends React.ComponentProps<'p'> { + children: React.ReactNode +} +``` + +**Renders:** `

{children}

` + +**When to omit:** Per ARIA APG, omit `aria-describedby` when dialog content has complex semantic structure (lists, tables, multiple paragraphs). The description is announced as a flat string, which can be confusing for complex content. + +### `DialogBody` + +Scrollable content area. + +```tsx +interface DialogBodyProps extends React.ComponentProps<'div'> { + children: React.ReactNode +} +``` + +**Renders:** `
{children}
` + +**Accessibility:** When content overflows, the body becomes a scrollable region. It must be: +- Labeled via `aria-labelledby` pointing to `DialogTitle` +- Focusable (`tabindex="0"`) so keyboard users can scroll without a mouse +- Announced as a scrollable region (`role="region"` when scrollable) + +This mirrors the behavior of the existing `ScrollableRegion` component in primer/react. + +### `DialogFooter` + +Container for action buttons. + +```tsx +interface DialogFooterProps extends React.ComponentProps<'footer'> { + children: React.ReactNode +} +``` + +**Renders:** `
{children}
` + +### `DialogClose` + +A button that closes the dialog. Calls `onClose('close-button')` from the nearest `DialogRoot`. + +```tsx +interface DialogCloseProps extends React.ComponentProps<'button'> { + /** Content for the close button. Required at Foundation level — Foundations have no visual opinion, so consumers must provide visible content. */ + children: React.ReactNode +} +``` + +**Renders:** `` + +**Behavior:** Accesses `onClose` from `DialogRoot` via React context. Consumers must provide visible content (text, icon, etc.) and an accessible label (`aria-label` or visible text). Layer 2 Parts provide a default icon-only close button. + +### Context + +Foundations use React context to wire ARIA relationships: + +```tsx +interface DialogContextValue { + /** ID for aria-labelledby */ + titleId: string + /** ID for aria-describedby (undefined if no DialogDescription) */ + descriptionId: string | undefined + /** Close handler from DialogRoot */ + onClose: (gesture: 'escape' | 'close-button' | 'backdrop') => void + /** Whether the dialog is open */ + isOpen: boolean +} +``` + +`DialogRoot` provides this context. `DialogTitle`, `DialogDescription`, and `DialogClose` consume it to auto-wire IDs and handlers. + +### Minimal CSS reset + +Foundation components ship with a minimal CSS reset — only what's needed to remove browser default styling that would interfere with correct behavior: + +```css +/* Foundation reset — no visual opinion */ +dialog.DialogRoot { + /* Remove default border and padding */ + border: none; + padding: 0; + /* Remove default background */ + background: transparent; + /* Ensure the dialog doesn't have a max-width by default */ + max-width: unset; + max-height: unset; +} + +dialog.DialogRoot::backdrop { + /* Reset backdrop to transparent — consumers must style it */ + 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 Foundation components 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 => + +
+

Are you sure you want to proceed?

+
+
+ + +
+
+ ) } ``` -**Renders:** `` - -**Behavior:** Accesses `onClose` from `DialogRoot` via React context. Consumers must provide visible content (text, icon, etc.) and an accessible label (`aria-label` or visible text). Layer 2 Parts provide a default icon-only close button. +### Behavior -### Context +- 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) -Foundations use React context to wire ARIA relationships: +### Accessible name contract -```tsx -interface DialogContextValue { - /** ID for aria-labelledby */ - titleId: string - /** ID for aria-describedby (undefined if no DialogDescription) */ - descriptionId: string | undefined - /** Close handler from DialogRoot */ - onClose: (gesture: 'escape' | 'close-button' | 'backdrop') => void - /** Whether the dialog is open */ - isOpen: boolean -} -``` +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 -`DialogRoot` provides this context. `DialogTitle`, `DialogDescription`, and `DialogClose` consume it to auto-wire IDs and handlers. +> **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 -Foundation components ship with a minimal CSS reset — only what's needed to remove browser default styling that would interfere with correct behavior: +Foundations ship with a minimal CSS reset — only what's needed to remove browser default styling: ```css /* Foundation reset — no visual opinion */ -dialog.DialogRoot { - /* Remove default border and padding */ +dialog[data-dialog-foundation] { border: none; padding: 0; - /* Remove default background */ background: transparent; - /* Ensure the dialog doesn't have a max-width by default */ max-width: unset; max-height: unset; } -dialog.DialogRoot::backdrop { - /* Reset backdrop to transparent — consumers must style it */ +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 Foundation components directly **must** provide visible backdrop styling to meet this requirement. Layer 2 Parts handle this automatically with Primer's `--overlay-backdrop-bgColor` token. +> **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. --- diff --git a/packages/react/src/experimental/Dialog/architecture.md b/packages/react/src/experimental/Dialog/architecture.md new file mode 100644 index 00000000000..5e2c6f71127 --- /dev/null +++ b/packages/react/src/experimental/Dialog/architecture.md @@ -0,0 +1,154 @@ +# Modular Component Architecture — Decisions + +> **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 + +## Layer Definitions + +| Layer | Name | Import | API shape | Styled? | +|-------|------|--------|-----------|---------| +| 1 | Ready-made | `@primer/react` | Props-based — pass data, get a component | ✅ Full Primer styles | +| 2 | Parts | `@primer/react` | JSX composition — `` | ✅ Full Primer styles | +| 3 | Foundations | `@primer/react/foundations` | Prop-getters — consumer controls markup | ❌ Unstyled (CSS reset only) | +| 4 | Hooks | `@primer/react/hooks` | Individual behavior hooks | ❌ No markup or styles | + +Each layer builds on the one below. Ready-made uses Parts, Parts use Foundations, Foundations use Hooks. + +## Layer 4 — Hooks + +**Individual, single-purpose behavior hooks.** Not component-specific. + +Examples: `useFocusTrap`, `useFocusZone`, `useOnEscapePress`, `useScrollLock` + +These already exist in `packages/react/src/hooks/`. New hooks go there too. + +**API pattern:** Each hook takes options and returns refs, callbacks, or prop objects. +```tsx +const {containerRef} = useFocusZone({bindKeys: FocusKeys.ArrowVertical}) +``` + +## Layer 3 — Foundations + +**Compound hooks returning prop-getters.** Component-specific, wires up ARIA relationships. + +Source: [core-ux#2272](https://github.com/github/core-ux/issues/2272) + +**Key rule:** Prop-getters are the public API. Context is an implementation detail only — consumers never call `useContext()` directly. + +```tsx +// Layer 3 public API +const dialog = useDialogFoundation({open, onClose}) + + +

Title

+

Subtitle

+
Content
+ +
+``` + +**Why prop-getters over components:** +- Full markup ownership — consumer chooses every element +- Composable with any component system (MUI, Radix, custom) +- Multi-element wiring is natural (`getTitleProps()`, `getBodyProps()`) +- TypeScript return types are explicit and statically known +- No imposed component tree + +**Context is allowed internally** for ARIA cross-wiring (e.g., `aria-labelledby` pointing title ID to dialog) but is never exposed to consumers. + +## Layer 2 — Parts (Composition) + +**Slots via `useSlots` — the preferred pattern.** + +Source: [core-ux#2270](https://github.com/github/core-ux/issues/2270) + +- 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 +// Layer 2 consumer API + + + + Title + + + Content + + + + + +``` + +## Export & Package Structure + +Source: [core-ux#2269](https://github.com/github/core-ux/issues/2269) + +### Entry points + +| Layer | Import path | Experimental path | +|-------|------------|-------------------| +| 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 + +| Layer | Convention | Example | +|-------|-----------|---------| +| 4 | `use` | `useScrollLock`, `useFocusTrap` | +| 3 | `useFoundation` | `useDialogFoundation` | +| 2 | `.` | `Dialog.Header`, `Dialog.Body` | +| 1 | `` | `Dialog` | + +### Rules + +- `@primer/react` does NOT re-export Foundations or Hooks — each layer is opt-in +- All layers ship in one package version +- Stability is per-component — `DialogFoundation` 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 # existing +│ ├── useOnEscapePress.ts # existing +│ └── useScrollLock.ts # new +├── foundations/ # Layer 3 (new) +│ └── experimental/ +│ └── Dialog/ +│ ├── useDialogFoundation.ts +│ └── index.ts +├── experimental/ +│ └── Dialog/ # Layer 2 (new parts) + spec +│ ├── Dialog.spec.md +│ ├── Dialog.tsx # Parts (Layer 2) +│ └── index.ts +└── Dialog/ # Layer 1 (existing, will wrap Layer 2) + └── Dialog.tsx +``` + +### package.json exports (proposed additions) + +```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" + } +} +``` From b658628d25cb51f47edc51d64e87858ed592582f Mon Sep 17 00:00:00 2001 From: Lukas Oppermann Date: Mon, 27 Apr 2026 12:52:51 +0200 Subject: [PATCH 04/23] Implement 4-layer Dialog: hooks, foundation, parts, exports Layer 4: useScrollLock hook with nested dialog support Layer 3: useDialogFoundation compound hook (prop-getters pattern) Layer 3: DialogFoundation.css reset for native Layer 2: Dialog Parts (Root, Content, Header, Title, Subtitle, Body, Footer, CloseButton) Export paths: @primer/react/foundations/experimental, @primer/react/hooks/experimental Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/react/package.json | 8 + .../src/experimental/Dialog/Dialog.module.css | 221 +++++++++++++++ .../react/src/experimental/Dialog/Dialog.tsx | 158 +++++++++++ .../react/src/experimental/Dialog/index.ts | 2 + .../experimental/Dialog/DialogFoundation.css | 16 ++ .../foundations/experimental/Dialog/index.ts | 2 + .../Dialog/useDialogFoundation.ts | 264 ++++++++++++++++++ .../src/foundations/experimental/index.ts | 2 + .../react/src/hooks/experimental/index.ts | 1 + packages/react/src/hooks/useScrollLock.ts | 39 +++ 10 files changed, 713 insertions(+) create mode 100644 packages/react/src/experimental/Dialog/Dialog.module.css create mode 100644 packages/react/src/experimental/Dialog/Dialog.tsx create mode 100644 packages/react/src/experimental/Dialog/index.ts create mode 100644 packages/react/src/foundations/experimental/Dialog/DialogFoundation.css create mode 100644 packages/react/src/foundations/experimental/Dialog/index.ts create mode 100644 packages/react/src/foundations/experimental/Dialog/useDialogFoundation.ts create mode 100644 packages/react/src/foundations/experimental/index.ts create mode 100644 packages/react/src/hooks/experimental/index.ts create mode 100644 packages/react/src/hooks/useScrollLock.ts 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..499b5b96fc6 --- /dev/null +++ b/packages/react/src/experimental/Dialog/Dialog.module.css @@ -0,0 +1,221 @@ +/* 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; + color: inherit; + + &::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.tsx b/packages/react/src/experimental/Dialog/Dialog.tsx new file mode 100644 index 00000000000..c9d442259a0 --- /dev/null +++ b/packages/react/src/experimental/Dialog/Dialog.tsx @@ -0,0 +1,158 @@ +import React, {createContext, useContext, useMemo} from 'react' +import {clsx} from 'clsx' +import { + useDialogFoundation, + type UseDialogFoundationOptions, + type UseDialogFoundationReturn, +} from '../../foundations/experimental/Dialog' +import {IconButton} from '../../Button' +import {XIcon} from '@primer/octicons-react' +import type {ResponsiveValue} from '../../hooks/useResponsiveValue' + +import classes from './Dialog.module.css' + +// --- Context --- + +interface DialogContextValue { + foundation: UseDialogFoundationReturn +} + +const DialogContext = createContext(null) + +function useDialogContext(): DialogContextValue { + const ctx = useContext(DialogContext) + if (!ctx) { + throw new Error('Dialog compound components must be used within ') + } + return ctx +} + +// --- Dialog.Root --- + +interface DialogRootProps extends UseDialogFoundationOptions { + children: React.ReactNode + className?: string +} + +const Root = React.forwardRef(function DialogRoot( + {children, className, ...options}, + _forwardedRef, +) { + const foundation = useDialogFoundation(options) + const dialogProps = foundation.getDialogProps() + + const ctx = useMemo(() => ({foundation}), [foundation]) + + return ( + + + {children} + + + ) +}) + +// --- Dialog.Content --- + +interface DialogContentProps extends React.ComponentProps<'div'> { + width?: 'small' | 'medium' | 'large' | 'xlarge' + height?: 'small' | 'large' | 'auto' + position?: 'center' | 'left' | 'right' | ResponsiveValue<'left' | 'right' | 'bottom' | 'fullscreen' | 'center'> + align?: 'top' | 'center' | 'bottom' +} + +function Content({width = 'xlarge', height = 'auto', position = 'center', align, className, ...props}: DialogContentProps) { + const positionDataAttributes = + typeof position === 'string' + ? {'data-position-regular': position} + : Object.fromEntries(Object.entries(position).map(([key, value]) => [`data-position-${key}`, value])) + + return ( +
+ ) +} +Content.displayName = 'Dialog.Content' + +// --- Dialog.Header --- + +function Header({className, ...props}: React.ComponentProps<'header'>) { + return
+} +Header.displayName = 'Dialog.Header' + +// --- Dialog.Title --- + +function Title({className, ...props}: React.ComponentProps<'h2'>) { + const {foundation} = useDialogContext() + const titleProps = foundation.getTitleProps() + + return

+} +Title.displayName = 'Dialog.Title' + +// --- Dialog.Subtitle --- + +function Subtitle({className, ...props}: React.ComponentProps<'p'>) { + const {foundation} = useDialogContext() + const descriptionProps = foundation.getDescriptionProps() + + return

+} +Subtitle.displayName = 'Dialog.Subtitle' + +// --- Dialog.Body --- + +function Body({className, ...props}: React.ComponentProps<'div'>) { + const {foundation} = useDialogContext() + const bodyProps = foundation.getBodyProps() + + return

+} +Body.displayName = 'Dialog.Body' + +// --- Dialog.Footer --- + +function Footer({className, ...props}: React.ComponentProps<'footer'>) { + return