diff --git a/.github/agents/modular-component.agent.md b/.github/agents/modular-component.agent.md new file mode 100644 index 00000000000..b053ed6078e --- /dev/null +++ b/.github/agents/modular-component.agent.md @@ -0,0 +1,299 @@ +--- +name: Modular Component Builder +description: Builds new Primer React components using the 4-layer modular architecture (hooks → foundations → parts → ready-made), or decomposes existing monolithic components into this structure. Environment agnostic — works in VS Code, Copilot CLI, and the Copilot coding agent. +tools: ['edit', 'execute', 'read', 'search'] +--- + +# Modular Component Builder + +You build and decompose Primer React components using a layered modular architecture. Every component is decomposed into four layers, each with a clear responsibility and stable API contract. + +## Before you start + +Read the resource files in `.github/agents/resources/modular-architecture/` to load the full architecture reference: + +1. **`layer-patterns.md`** — concrete code templates for each layer, naming conventions, export rules +2. **`accessibility-contract.md`** — what each layer handles automatically vs what the consumer must provide + +These files are the source of truth. Read them in full before generating any code. + +Also read the repo instruction files for coding standards: + +- `.github/instructions/general-coding.instructions.md` +- `.github/instructions/typescript-react.instructions.md` +- `.github/instructions/css.instructions.md` + +## Architecture overview + +Every modular component is decomposed into four layers. Each layer builds on the one below. + +| Layer | Name | Responsibility | Styled? | +| ----- | ----------- | ---------------------------------------------- | ---------------------------- | +| 4 | Hooks | Individual, single-purpose behaviour | ❌ 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 | + +**Layer dependency:** Ready-made (L1) uses Parts (L2), Parts use Foundations (L3), Foundations use Hooks (L4). Never skip a layer — L2 must not directly use L4 hooks that should be composed through L3. + +## Two workflows + +This agent supports two modes: + +### Mode 1: Build a new component + +Start from scratch. The user provides a component name and description. You build all four layers. + +### Mode 2: Decompose an existing component + +Take an existing monolithic Primer component and extract it into the 4-layer structure. The user points you at the existing component. + +--- + +## Mode 1: Build a new component + +### Step 0: Understand the requirement + +Ask the user: + +- What component are you building? (name, purpose) +- What are the key interactive behaviours? (e.g., focus trapping, keyboard navigation, open/close) +- Are there ARIA patterns to follow? (e.g., dialog, tabs, menu, listbox) +- Does this component need a Ready-made (L1) layer, or is L2 (Parts) the right default entry point? + +**On the L1 question:** Not every component benefits from a Ready-made layer. Config-based APIs can lead to unwieldy types (SelectPanel is a cautionary example). L1 should capture the 80% use case. If the component's common usage is inherently compositional, L2 Parts may be the better default and L1 adds complexity without value. Surface this decision to the user — don't silently include or exclude L1. + +### Step 1: Identify Layer 4 hooks + +Identify the individual, single-purpose behaviours this component needs. Each behaviour is a separate hook. + +**Rules:** + +- One behaviour per hook — no compound hooks at this layer +- No knowledge of which component consumes them — hooks are reusable +- No styling or markup opinions +- Check if hooks already exist in `packages/react/src/hooks/` before creating new ones + +**Naming:** `use` — e.g., `useScrollLock`, `useFocusTrap`, `useFocusZone`, `useOnEscapePress` + +**File location:** `packages/react/src/hooks/` (stable) or `packages/react/src/hooks/experimental/` (new) + +### Step 2: Build Layer 3 — Foundations + +Layer 3 provides two complementary APIs: + +#### 2a: Compound hook with prop-getters + +A single hook that composes all the Layer 4 hooks and returns prop-getter functions. This is the escape hatch for consumers who need full markup control. + +**Naming:** `use` — e.g., `useDialog`, `useTabs` + +**Important:** Before naming the compound hook, search the repo for existing hooks with the same name. primer/react has legacy hooks (e.g., `src/hooks/useDialog.ts`) that may conflict. The foundation hook lives at a different path (`foundations/experimental/`) and uses different imports, but name collisions can cause confusion. If a conflict exists, the foundation hook takes the `use` name and the legacy hook should be documented as deprecated. + +The hook: + +- Takes an options object with the component's behavioural configuration +- Returns prop-getter functions that consumers spread onto their own elements (`getDialogProps()`, `getTitleProps()`, etc.) +- Handles all ARIA wiring internally (generating IDs, cross-referencing `aria-labelledby`/`aria-describedby`) +- Manages lifecycle (open/close, focus management, scroll lock) +- Fires a dev-mode warning if required accessibility attributes are missing +- Passes through `aria-label` when provided (for dialogs without a visible title) + +**Important options to include:** + +- `open` / `onClose` — controlled component contract +- `role` — e.g., `'dialog' | 'alertdialog'` +- `aria-label` — accessible name when no visible title is used +- `initialFocusRef` / `returnFocusRef` — focus management +- `closeOnBackdropClick` — opt-in backdrop dismiss (default `false`). Always require explicit opt-in — accidental dismissal of complex forms is a poor UX. + +#### 2b: Unstyled components + +React components with no visual styling that enforce structural accessibility constraints. These wrap the compound hook and provide a component tree with context-based ARIA wiring. Similar to [Base UI](https://base-ui.com/) or [Radix Primitives](https://www.radix-ui.com/primitives). + +**Layer 3 always ships both APIs.** The unstyled components and the compound hook serve different consumers: + +- **Unstyled components** cover the common case: "I want Primer's accessibility, but my own styles." They enforce structural constraints (e.g., title must be a descendant of dialog) 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 or building non-standard layouts. + +**Foundation CSS:** Each foundation ships a minimal CSS reset that removes browser defaults without adding visual opinion. Use `:where()` selectors for zero specificity so consumer styles always win. + +**File location:** + +``` +packages/react/src/foundations/experimental// +├── use.ts # Compound hook (prop-getters) +├── .tsx # Unstyled components (wrap the hook) +├── Foundation.css # Minimal CSS reset +├── index.ts # Re-exports +└── __tests__/ + ├── use.test.tsx # Tests for the compound hook + └── .test.tsx # Tests for unstyled components +``` + +### Step 3: Build Layer 2 — Parts + +Styled JSX components for Primer-opinionated composition. Parts wrap Layer 3 foundations and add Primer design tokens, CSS modules, and layout opinions. + +**Rules:** + +- Composition via children (slots), never render props or `React.Children` + `React.cloneElement` +- Context (`useContext()`) for ARIA wiring between sub-components — never expose context to consumers +- All Parts must include `data-component` attributes for stable selectors (testing, agents) + - Root: `data-component="ComponentName"` + - Sub-components: `data-component="ComponentName.PartName"` +- Use CSS Modules (`.module.css`) with Primer design tokens for all styling +- Use `clsx` for className merging +- Use existing Primer components where appropriate (e.g., `Button` from `../../Button`, `IconButton`, Octicons) — don't re-implement HTML buttons with custom styling when Primer already has the component +- Keep sub-components composable — don't bake one sub-component into another. For example, `Header` should accept `Title` and `CloseButton` as children, not render `CloseButton` internally. This lets consumers control placement and omission. +- **Subtitle placement:** Subtitle is rendered outside the Header, not inside it. The Header contains Title + CloseButton; Subtitle sits between Header and Body (below the header's bottom border). This gives it distinct visual separation from the header group. + +**Sub-component naming:** Flat exports — `DialogRoot`, `DialogHeader`, `DialogTitle` — are the goal for React Server Components compatibility. The `Object.assign` pattern for dot-notation breaks in RSC (property access on a client reference returns `undefined`). However, the current convention in the repo is a composed export using `Object.assign`: + +```ts +export const DialogParts = Object.assign(Root, { Content, Header, Title, ... }) +``` + +Follow whichever convention existing components in the repo use. If starting fresh, prefer flat named exports alongside the composed object for forward compatibility: + +```ts +// Flat exports (RSC-safe) +export {Root as DialogRoot, Content as DialogContent, Header as DialogHeader, ...} + +// Composed export (convenience for non-RSC) +export const DialogParts = Object.assign(Root, { Content, Header, Title, ... }) +``` + +**File location:** + +``` +packages/react/src/experimental// +├── .tsx # Parts (Layer 2) +├── .module.css # Primer-styled CSS +├── .spec.md # Component specification +├── index.ts # Re-exports +└── +``` + +### Step 4: Build Layer 1 — Ready-made (if appropriate) + +A props-based convenience wrapper. The simplest way to use the component — pass data, get a fully composed component. + +**Rules:** + +- Ready-made is a thin wrapper over Parts — it composes the Part sub-components internally +- Props map directly to Parts children — no new behaviour at this layer +- Ready-made must not implement behaviour itself, but it **may translate convenience props into existing lower-layer behavioural options**. Example: Dialog maps `footerButtons[].autoFocus` to the foundation's `initialFocusRef` via a ref forwarded to the rendered button. +- The `children` prop maps to the Body/Content slot +- Config props (like `footerButtons`) render as Parts children internally +- Forward all behavioural props from the foundation (e.g., `returnFocusRef`, `closeOnBackdropClick`) — don't silently drop options that the lower layers support +- Use existing Primer components (e.g., `Button`) for rendering footer buttons — match the `variant` prop to Primer's button API +- Define a dedicated button props type (e.g., `DialogButtonProps`) that extends Primer's `ButtonProps` with convenience fields like `buttonType` (mapped to `variant`) and `content` (the label). This gives consumers a clean API without needing to know Primer's internal Button prop names. + +**File location:** Same directory as Parts: + +``` +packages/react/src/experimental// +├── ReadyMade.tsx # Ready-made (Layer 1) +``` + +### Step 5: Wire up exports + +#### Entry points + +| Layer | Experimental import | Stable import | +| --------------- | ---------------------------------------- | --------------------------- | +| 1 — Ready-made | `@primer/react/experimental` | `@primer/react` | +| 2 — Parts | `@primer/react/experimental` | `@primer/react` | +| 3 — Foundations | `@primer/react/foundations/experimental` | `@primer/react/foundations` | +| 4 — Hooks | `@primer/react/hooks/experimental` | `@primer/react/hooks` | + +- `@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 — a hook can graduate while foundations remain experimental + +#### Index files + +Create `index.ts` files that re-export the public API for each layer. Update the experimental barrel files to include the new component. + +Check `packages/react/package.json` `exports` field for the required subpaths (`./foundations/experimental`, `./hooks/experimental`). Add or update package exports only if the subpath does not already exist. + +### Step 6: Create stories + +Create Storybook stories for each layer that has a consumer-facing API: + +- **Foundation hook stories** — demonstrate the compound hook with consumer-owned markup and inline styles. Show that the hook provides behaviour and ARIA without imposing UI. +- **Foundation component stories** — demonstrate the unstyled components with consumer-owned CSS classes. Show that they enforce structural constraints whilst remaining visually unopinionated. +- **Parts stories** — demonstrate the compound component API with Primer styling. Cover sizes, positions, nested usage. +- **Ready-made stories** (if L1 exists) — demonstrate the props-based API. Cover common configurations. + +### Step 7: Create tests + +- **Layer 4 hooks** — unit tests for each hook in isolation +- **Layer 3 foundation hook** — test the compound hook via a minimal test harness. Cover: ARIA attributes, focus management, keyboard interaction, lifecycle (open/close/reopen), dev-mode warnings +- **Layer 3 foundation components** — test the unstyled components render correct structure, wire ARIA via context, and enforce constraints +- **Layer 2 Parts** — test compound component rendering, context wiring, `data-component` selectors +- **Layer 1 Ready-made** — test that props correctly compose into Parts children + +Use Vitest and `@testing-library/react`. Follow existing test patterns in the repo. + +### Step 8: Validate + +Run validation in this order: + +1. `npx prettier --write ` +2. `npx eslint --fix ` +3. `npx stylelint -q --rd --fix ` +4. `npm run type-check` +5. `npm test -- --reporter=verbose ` + +Fix any failures before reporting completion. + +--- + +## Mode 2: Decompose an existing component + +### Step 0: Audit the existing component + +Read the existing component and identify: + +- What behaviours does it contain? (→ L4 hooks) +- What accessibility/ARIA patterns does it implement? (→ L3 foundation) +- What styled sub-components exist? (→ L2 parts) +- What is the current public API surface? (→ L1 ready-made compatibility) + +### Step 1: Extract Layer 4 hooks + +Identify reusable behaviours and extract them as standalone hooks. Check whether equivalent hooks already exist — don't duplicate. + +### Step 2: Build Layer 3 foundation + +Create the compound hook and (optionally) unstyled components. Wire up all ARIA, focus management, and lifecycle using the extracted L4 hooks. + +### Step 3: Refactor Layer 2 Parts + +Rewrite the existing styled components to use the L3 foundation instead of implementing behaviour directly. This is where most of the surgical work happens. + +### Step 4: Create or preserve Layer 1 + +If the existing component has a props-based API, preserve it as the L1 Ready-made wrapper that now composes L2 Parts internally. The public API should remain identical — this is a non-breaking refactor. + +### Step 5: Validate backwards compatibility + +- Existing tests should still pass (the public API hasn't changed) +- Existing stories should still render correctly +- Run the full validation suite (Step 8 from Mode 1) + +--- + +## Open decisions to surface + +When building a component, explicitly surface these decisions to the user rather than assuming an answer: + +1. **Does this component need L1 (Ready-made)?** Default: probably yes for simple components, probably no for complex compositional ones. + +2. **Layer naming in code.** The compound hook is named `use` (not `useFoundation`). The "Foundation" suffix is an internal architectural concept, not a consumer-facing concern. + +3. **Entry point strategy.** The default is separate entry points (`/foundations`, `/hooks`). An alternative is `unstable_` prefix convention with a single entry point. Follow whatever convention the repo has adopted at the time. + +4. **`data-component` at Layer 2.** Currently included for testing and agent selectors. With compositional parts available, `className` may be sufficient for styling. Keep `data-component` unless explicitly told otherwise — it serves a different concern (stable identity across refactors) than styling. diff --git a/.github/agents/resources/modular-architecture/accessibility-contract.md b/.github/agents/resources/modular-architecture/accessibility-contract.md new file mode 100644 index 00000000000..81dc89bb51e --- /dev/null +++ b/.github/agents/resources/modular-architecture/accessibility-contract.md @@ -0,0 +1,90 @@ +# Accessibility Contract — Modular Component Architecture + +Each layer shifts accessibility responsibility to the consumer differently. This table defines what each layer handles automatically and what the consumer must provide. + +## Responsibility matrix + +Use Dialog as the reference example. Apply the same pattern to other components — identify the ARIA pattern (dialog, tabs, menu, listbox, etc.) and map each requirement to the appropriate layer. + +### Dialog example + +| 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 | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Focus moves into component | Consumer manages | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Focus returns on close | Consumer manages | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Visible close button | Consumer provides | ✅ Enforced by 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 | + +## Key principles + +### Layer 3 consumer responsibilities + +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 with Primer tokens. + +### `aria-describedby` guidance + +Per ARIA APG, omit `aria-describedby` when 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 components 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. + +### Dev-mode warnings + +The compound hook (Layer 3) should fire a dev-mode warning when: + +- No accessible name is provided (neither `getTitleProps()` called nor `aria-label` passed) +- Required structural elements are missing + +Use `queueMicrotask` to check after render so prop-getters have been called: + +```tsx +useEffect(() => { + if (process.env.NODE_ENV !== 'production' && open) { + queueMicrotask(() => { + if (!titleUsed.current && !ariaLabel) { + console.warn( + ': No accessible name provided. Use getTitleProps() on a title element, or pass aria-label.', + ) + } + }) + } +}, [open, ariaLabel]) +``` + +## Applying to other ARIA patterns + +When building a component with a different ARIA pattern (tabs, menu, listbox, etc.), create a similar responsibility matrix: + +1. **Identify the ARIA APG pattern** — read the W3C ARIA Authoring Practices Guide for the relevant pattern +2. **List all requirements** — roles, states, properties, keyboard interaction, focus management +3. **Assign to layers** following the same principle: + - L4: Consumer does everything (hook provides raw behaviour only) + - L3: Automatic ARIA wiring, focus management, keyboard interaction + - L2: Inherits L3 + adds visual styling with Primer tokens + - L1: Inherits L2 + maps props to Parts children +4. **Mark consumer responsibilities** — anything L3 does NOT handle automatically (⚠️) must be documented clearly + +### Tabs example (skeleton) + +| Requirement | L4 (Hooks) | L3 (Foundations) | L2 (Parts) | L1 (Ready-made) | +| ------------------------------- | ------------------- | ------------------------- | ---------------- | ---------------- | +| `role="tablist"` | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| `role="tab"` on each tab | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| `role="tabpanel"` on each panel | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| `aria-selected` on active tab | Consumer manages | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| `aria-controls` tab → panel | Consumer wires | ✅ Auto-wired via context | ✅ Inherited | ✅ Inherited | +| Arrow key navigation | `useFocusZone` hook | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Home/End to first/last tab | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Hidden panels (`hidden` attr) | Consumer manages | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Focus indicator styling | Consumer styles | ⚠️ Consumer must style | ✅ Primer tokens | ✅ Primer tokens | diff --git a/.github/agents/resources/modular-architecture/layer-patterns.md b/.github/agents/resources/modular-architecture/layer-patterns.md new file mode 100644 index 00000000000..0c2b343e503 --- /dev/null +++ b/.github/agents/resources/modular-architecture/layer-patterns.md @@ -0,0 +1,799 @@ +# Layer Patterns — Modular Component Architecture + +Concrete code patterns for each layer. Use these as templates when building new components. + +--- + +## Layer 4 — Hooks + +Individual, single-purpose behaviour hooks. Not component-specific. Reusable across any component that needs the behaviour. + +### Pattern + +```tsx +import {useEffect, useRef} from 'react' + +/** + * + * + */ +export function use(enabled: boolean): void { + // Implementation +} +``` + +### Example: useScrollLock + +```tsx +import {useEffect, useRef} from 'react' + +let activeScrollLocks = 0 + +/** + * Prevents background scrolling while active. + * Compensates for scrollbar removal to prevent layout shift. + * Handles nested usage 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]) +} +``` + +### Rules + +- One behaviour per hook +- No knowledge of which component consumes them +- No styling or markup opinions +- API: takes options, returns refs/callbacks/prop objects +- File: `packages/react/src/hooks/use.ts` +- Test: `packages/react/src/hooks/__tests__/use.test.ts` + +### Nested/ref-counted hooks + +When a hook manages a global side-effect (like scroll lock), use a module-level ref counter so nested usage works correctly. Only apply the side-effect when the count goes from 0→1, and only remove it when the count goes from 1→0. + +--- + +## Layer 3 — Foundations + +### 3a: Compound hook with prop-getters + +A single hook that composes L4 hooks and returns prop-getter functions. The consumer owns all markup. + +#### Pattern + +```tsx +import {useCallback, useEffect, useId, useRef} from 'react' + +// --- Types --- + +type CloseGesture = 'escape' | 'close-button' | 'backdrop' + +export interface UseOptions { + /** Core behavioural props */ + open: boolean + onClose: (gesture: CloseGesture) => void + role?: 'dialog' | 'alertdialog' + 'aria-label'?: string + initialFocusRef?: React.RefObject + returnFocusRef?: React.RefObject + /** Whether clicking the backdrop closes the component. @default false */ + closeOnBackdropClick?: boolean +} + +export interface UseReturn { + /** Prop-getter for the root element */ + getProps: () => Props + /** Prop-getter for the title element — auto-wires aria-labelledby */ + getTitleProps: () => TitleProps + /** Prop-getter for the description element — auto-wires aria-describedby */ + getDescriptionProps: () => DescriptionProps + /** Prop-getter for the close control */ + getCloseProps: () => CloseProps + /** Prop-getter for the scrollable body region */ + getBodyProps: () => BodyProps + /** Whether the component is currently active */ + isOpen: boolean + /** Programmatically request close */ + close: (gesture: CloseGesture) => void +} + +// --- Hook --- + +export function use(options: UseOptions): UseReturn { + // 1. Compose L4 hooks (useScrollLock, useFocusTrap, etc.) + // 2. Generate IDs for ARIA cross-referencing + // 3. Manage lifecycle (open/close, focus save/restore) + // 4. Return prop-getters that wire everything together + + const titleId = useId() + const descriptionId = useId() + + // ... implementation ... + + return { + getProps, + getTitleProps, + getDescriptionProps, + getCloseProps, + getBodyProps, + isOpen: options.open, + close, + } +} +``` + +#### Prop-getter pattern + +Each prop-getter returns a plain object of HTML/ARIA attributes that the consumer spreads onto their element: + +```tsx +const getTitleProps = useCallback((): TitleProps => { + return {id: titleId} +}, [titleId]) + +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]) +``` + +**Body region:** The body/content area should have `role="region"` and `aria-labelledby` pointing to the title ID. This makes the scrollable content area navigable as a landmark for assistive technology users. Always include `tabIndex: 0` so keyboard users can scroll the body. + +**Root prop-getter:** Must include `aria-label` passthrough when provided (for components without a visible title): + +```tsx +const getRootProps = useCallback(() => { + const props = { + ref: refCallback, + role, + 'aria-modal': true, + 'aria-labelledby': titleId, + 'aria-describedby': descriptionId, + onClick: handleClick, + } + if (ariaLabel) { + props['aria-label'] = ariaLabel + } + return props +}, [refCallback, role, titleId, descriptionId, handleClick, ariaLabel]) +``` + +#### Consumer usage + +```tsx +const dialog = useDialog({open, onClose}) + + +

Title

+

Subtitle

+
Content
+ +
+``` + +### 3b: Unstyled components + +React components that wrap the compound hook and enforce structural accessibility via a component tree. No visual styling — consumers bring their own CSS. These mirror the sub-component names from Layer 2 Parts but ship from the `/foundations` entry point. + +#### Pattern + +```tsx +import React, {createContext, useContext, useMemo} from 'react' +import {use, type UseOptions, type UseReturn} from './use' + +// --- Context (internal only) --- + +interface FoundationContextValue { + foundation: UseReturn +} + +const FoundationContext = createContext<FoundationContextValue | null>(null) + +function useFoundationContext(): FoundationContextValue { + const ctx = useContext(FoundationContext) + if (!ctx) { + throw new Error(' foundation components must be used within <.Root>') + } + return ctx +} + +// --- Root --- + +interface RootProps extends UseOptions { + children: React.ReactNode + className?: string +} + +function Root({children, className, ...options}: RootProps) { + const foundation = use(options) + const rootProps = foundation.getProps() + + const ctx = useMemo(() => ({foundation}), [foundation]) + + return ( + <FoundationContext.Provider value={ctx}> + < {...rootProps} className={className}> + {children} + > + FoundationContext.Provider> + ) +} + +// --- Sub-components (Title, Description, Body, Close, etc.) --- + +function Title({children, className, ...props}: React.ComponentProps<'h2'>) { + const {foundation} = useFoundationContext() + const titleProps = foundation.getTitleProps() + return

{children}

+} + +function Description({children, className, ...props}: React.ComponentProps<'p'>) { + const {foundation} = useFoundationContext() + const descriptionProps = foundation.getDescriptionProps() + return

{children}

+} + +function Body({children, className, ...props}: React.ComponentProps<'div'>) { + const {foundation} = useFoundationContext() + const bodyProps = foundation.getBodyProps() + return
{children}
+} + +function Close({children, className, ...props}: React.ComponentProps<'button'>) { + const {foundation} = useFoundationContext() + const closeProps = foundation.getCloseProps() + return +} +``` + +#### Consumer usage + +```tsx +// Foundation consumer — unstyled, bring your own CSS +import {Dialog} from '@primer/react/foundations/experimental' +; + Title + Subtitle + Content + + +``` + +#### Rules + +- Unstyled components **do not add any visual styles** — no Primer tokens, no CSS Modules. Only the foundation CSS reset applies. +- They enforce structural constraints that prop-getters alone cannot (e.g., title must be a descendant of the component root) +- Context is internal — never exposed to consumers +- Sub-component names mirror the Layer 2 Parts names for consistency +- Accept `className` for consumer styling — pass it through, don't merge with anything +- File: `packages/react/src/foundations/experimental//.tsx` + +### 3c: Foundation CSS + +Minimal CSS reset. Zero visual opinion. Uses `:where()` for zero specificity. + +```css +/* Foundation reset — no visual opinion. + * Removes browser defaults that interfere with correct behaviour. + * Uses :where() for zero specificity so consumer styles always win. */ + +:where(dialog[data--foundation]) { + border: none; + padding: 0; + background: transparent; + max-width: unset; + max-height: unset; + overflow: visible; + color: inherit; +} + +:where(dialog[data--foundation])::backdrop { + background: transparent; +} +``` + +The foundation hook adds the `data--foundation` attribute via its root prop-getter so the reset CSS applies automatically. Note `overflow: visible` and `color: inherit` — these prevent the browser from clipping content and overriding text colour. + +### Rules + +- Compose L4 hooks internally — don't re-implement behaviours +- Generate stable IDs via `useId()` for ARIA cross-referencing +- Always return `aria-labelledby` and `aria-describedby` referencing generated IDs — if the element doesn't exist, the attribute is silently ignored +- Dev-mode warning (via `queueMicrotask`) if no accessible name is provided +- Intercept native events (e.g., `cancel` on ``) to maintain controlled component contract +- Context is internal only — never exposed to consumers +- File: `packages/react/src/foundations/experimental//use.ts` + +### Controlled component contract (critical) + +For components that wrap native elements with built-in behaviour (like ``), the compound hook must maintain a fully controlled contract: + +1. **Opening**: Call `showModal()` only when `open === true` and `dialog.open === false` +2. **Closing**: Call `dialog.close()` when `open === false` and `dialog.open === true` +3. **Escape handling**: Intercept the native `cancel` event, call `preventDefault()`, and fire `onClose('escape')` instead of allowing the native close +4. **Close guard**: Listen for the native `close` event. If `open` is still `true` but the dialog was closed externally (e.g., `dialog.close()` called directly), re-open it to maintain the controlled contract +5. **Backdrop click detection**: For ``, the backdrop is the dialog element's own area outside its content box. Detect backdrop clicks by checking `e.target === dialog` (click on the dialog itself, not a child), then verify the click coordinates fall outside the content box rect +6. **Focus lifecycle**: Save `document.activeElement` before opening; restore focus to `returnFocusRef` or the previously-focused element on close + +These invariants ensure the component cannot be closed or modified except through the controlled `open` prop and `onClose` callback. + +### `aria-describedby` wiring pattern + +Always assign the generated description ID to `aria-describedby`, regardless of whether `getDescriptionProps()` has been called: + +```tsx +props['aria-describedby'] = descriptionId +``` + +If no element uses the description ID, the attribute is silently ignored by assistive technology. This avoids render-order dependencies between prop-getters. + +--- + +## Layer 2 — Parts + +Styled JSX components that wrap L3 foundations and add Primer design tokens. + +### Pattern + +```tsx +import React, {createContext, useCallback, useContext, useMemo} from 'react' +import {clsx} from 'clsx' +import {use, type UseOptions, type UseReturn} from '../../foundations/experimental/' +import classes from './.module.css' + +// --- Context (internal only) --- + +interface ContextValue { + foundation: UseReturn +} + +const Context = createContext<ContextValue | null>(null) + +function useContext(): ContextValue { + const ctx = useContext(Context) + if (!ctx) { + throw new Error(' compound components must be used within <Root>') + } + return ctx +} + +// --- Root --- + +interface RootProps extends UseOptions { + children: React.ReactNode + className?: string +} + +const Root = React.forwardRefElement, RootProps>( + function Root({children, className, ...options}, forwardedRef) { + const foundation = use(options) + const rootProps = foundation.getProps() + + // Merge foundation ref with forwarded ref + const foundationRef = rootProps.ref + const mergedRef = useCallback( + (node: HTMLElement | null) => { + foundationRef(node) + if (typeof forwardedRef === 'function') { + forwardedRef(node) + } else if (forwardedRef) { + forwardedRef.current = node + } + }, + [foundationRef, forwardedRef], + ) + + const ctx = useMemo(() => ({foundation}), [foundation]) + + return ( + <Context.Provider value={ctx}> + < {...rootProps} ref={mergedRef} + className={clsx(className, classes.Root)} + data-component=""> + {children} + > + Context.Provider> + ) + }, +) + +// --- Sub-components --- + +function Title({className, ...props}: React.ComponentProps<'h2'>) { + const {foundation} = useContext() + const titleProps = foundation.getTitleProps() + + return ( +

+ ) +} +Title.displayName = '.Title' + +// ... more sub-components following the same pattern ... + +// --- Composed export --- + +export const Parts = Object.assign(Root, { + Content, + Header, + Title, + Subtitle, + Body, + Footer, + CloseButton, +}) +``` + +### CSS Module pattern + +```css +/* Layer 2: Parts — Primer-styled */ + +.Root { + /* Reset + Primer tokens */ + border: none; + padding: 0; + background: transparent; + max-width: unset; + max-height: unset; + overflow: visible; + color: inherit; + + &::backdrop { + background-color: var(--overlay-backdrop-bgColor); + animation: -backdrop-appear 200ms cubic-bezier(0.33, 1, 0.68, 1); + } +} + +.Content { + display: flex; + flex-direction: column; + /* stylelint-disable-next-line primer/responsive-widths */ + width: 640px; /* default = xlarge */ + min-width: 296px; + max-width: calc(100dvw - 64px); + height: auto; + max-height: calc(100dvh - 64px); + background-color: var(--overlay-bgColor); + border-radius: var(--borderRadius-large); + box-shadow: var(--shadow-floating-small); + opacity: 1; + + /* Width variants via data attributes — use :where() for zero specificity */ + &: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; + } + /* xlarge is the default (640px) — no override needed */ + + /* Height variants */ + &:where([data-height='small']) { + height: 480px; + } + &:where([data-height='large']) { + height: 640px; + } +} + +.Header { + z-index: 1; /* Stay above scrolling body */ + display: flex; + max-height: 35vh; /* Prevent oversized headers */ + padding: var(--base-size-8); + overflow-y: auto; + box-shadow: 0 1px 0 var(--borderColor-default); + flex-shrink: 0; +} + +.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 { + 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 { + padding: var(--base-size-16); + overflow: auto; + flex-grow: 1; +} + +.Footer { + z-index: 1; /* Stay above scrolling body */ + display: flex; + flex-flow: wrap; + justify-content: flex-end; + padding: var(--base-size-16); + gap: var(--base-size-8); + flex-shrink: 0; +} +``` + +**Key CSS details:** + +- Root needs `overflow: visible` and `color: inherit` to prevent browser defaults from clipping content or overriding text colour +- Default width is `640px` (xlarge) — this is the most common dialog size +- Header and Footer get `z-index: 1` to stay above the scrolling body +- Header has `max-height: 35vh` to prevent oversized headers from pushing body off-screen +- **Subtitle is rendered outside the Header** (below the header border), not inside it. This is a structural decision: the header contains Title + CloseButton, while Subtitle sits between header and body + +### Rules + +- Every sub-component reads from context, never receives foundation via props +- `data-component` on every rendered element (root + sub-components) +- Use `clsx` for className merging — always accept `className` prop +- Use CSS Modules with Primer design tokens (`var(--*)`) +- Width/height/position variants via `data-*` attributes, styled with `:where([data-*])` for zero specificity +- Use `:where()` for variant selectors to avoid specificity wars +- `displayName` on every sub-component for DevTools +- Flat named exports for RSC compatibility (but composed via `Object.assign` for convenience) +- Animation: respect `prefers-reduced-motion` with `@media screen and (prefers-reduced-motion: no-preference)` + +### CSS specificity rules + +Distinguish three types of selectors: + +- **`data-component`** = stable identity for testing and agents (not for styling) +- **`data-*` state/variant attributes** (`data-width`, `data-position-*`) = variant selectors, always wrap in `:where()` for zero specificity +- **CSS Module classes** (`.Root`, `.Header`) = primary styling hook, normal specificity + +When targeting `data-component` or state attributes in CSS, use `:where()`: + +```css +/* Good — zero specificity */ +&:where([data-width='small']) { + width: 296px; +} + +/* Avoid — unnecessarily high specificity */ +&[data-width='small'] { + width: 296px; +} +``` + +### Sub-component composability + +Sub-components must be independently composable — never bake one sub-component into another. For example: + +```tsx +// ✅ Good — Header accepts children, consumer controls layout +; + Title + + + +// ❌ Bad — Header renders CloseButton internally +function Header({children}) { + return ( +
+ {children} + {/* Don't do this */} +
+ ) +} +``` + +This lets consumers control placement, omission, and ordering of sub-components. + +### Use existing Primer components + +Layer 2 Parts and Layer 1 Ready-made should use existing Primer components wherever appropriate: + +- **Buttons:** Use `Button` from `../../Button` (not plain ` +

+``` + +**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..8bad6491682 --- /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` + +### `useDialog` + +```ts +interface UseDialogOptions { + /** 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 UseDialogReturn { + /** 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 useDialog(options: UseDialogOptions): UseDialogReturn +``` + +### Usage + +```tsx +import {useDialog} from '@primer/react/foundations/experimental' + +function MyCustomDialog({open, onClose}) { + const dialog = useDialog({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 useDialog 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 = useDialog({ + 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 = useDialog({ + 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..9a7f9d47c67 --- /dev/null +++ b/packages/react/src/experimental/Dialog/DialogHookInspector.stories.tsx @@ -0,0 +1,332 @@ +import {useState, useRef, useCallback, type PropsWithChildren} from 'react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import {useDialog, type UseDialogOptions, type UseDialogReturn} from '../../foundations/experimental/Dialog' + +/** + * Hook Inspector — useDialog + * + * 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: UseDialogReturn}) { + 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 = useDialog({ + 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 = useDialog({ + 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 = useDialog({ + 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 = useDialog({ + 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 = useDialog({ + 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__/useDialog.test.tsx b/packages/react/src/foundations/experimental/Dialog/__tests__/useDialog.test.tsx new file mode 100644 index 00000000000..e79d64c2ce7 --- /dev/null +++ b/packages/react/src/foundations/experimental/Dialog/__tests__/useDialog.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 {useDialog, type UseDialogOptions} from '..' + +// Test harness that renders a dialog using the foundation hook +function TestDialog(props: UseDialogOptions & {children?: React.ReactNode}) { + const {children, ...options} = props + const foundation = useDialog(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('useDialog', () => { + 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 = useDialog({ + 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 = useDialog({ + 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 = useDialog({ + 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..2927ee135e0 --- /dev/null +++ b/packages/react/src/foundations/experimental/Dialog/index.ts @@ -0,0 +1,2 @@ +export {useDialog} from './useDialog' +export type {UseDialogOptions, UseDialogReturn} from './useDialog' diff --git a/packages/react/src/foundations/experimental/Dialog/useDialog.ts b/packages/react/src/foundations/experimental/Dialog/useDialog.ts new file mode 100644 index 00000000000..829d3cca7ee --- /dev/null +++ b/packages/react/src/foundations/experimental/Dialog/useDialog.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 UseDialogOptions { + /** 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 UseDialogReturn { + /** 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 useDialog(options: UseDialogOptions): UseDialogReturn { + 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 useDialog().', + ) + } + }) + } + }, [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..a0b096c4ea6 --- /dev/null +++ b/packages/react/src/foundations/experimental/index.ts @@ -0,0 +1,2 @@ +export {useDialog} from './Dialog' +export type {UseDialogOptions, UseDialogReturn} 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]) +}