@codecademy/gamut
72.3.072.3.1-alpha.f273fd.0
agent-tools/skills/gamut-modal/SKILL.md+
agent-tools/skills/gamut-modal/SKILL.mdNew file+186
Index: package/agent-tools/skills/gamut-modal/SKILL.md
===================================================================
--- package/agent-tools/skills/gamut-modal/SKILL.md
+++ package/agent-tools/skills/gamut-modal/SKILL.md
@@ -0,0 +1,186 @@
+---
+name: gamut-modal
+description: Use this skill when implementing or auditing `Modal` or `Dialog` — single vs. multi-view composition, size/layout, close button wiring, and how dismiss (Escape/outside-click) and focus-trap/autofocus actually work under the hood via `Overlay`/`FocusTrap`. Not for building a custom overlay from scratch (see gamut-accessibility Focus management) or floating popovers/menus (see gamut-z-index).
+---
+
+# Gamut Modal
+
+`Modal` and `Dialog` are the two dialog surfaces in Gamut. Both are thin wrappers around the same `Overlay` → `FocusTrap` → `ModalContainer` stack — `Dialog` is `Modal` pre-configured for a single confirm/cancel action.
+
+Source: `packages/gamut/src/Modals/Modal.tsx` · `Dialog.tsx` · `elements.tsx` · `types.ts` · `packages/gamut/src/Overlay/index.tsx` · `packages/gamut/src/FocusTrap/index.tsx`
+
+See also: [`gamut-accessibility`](../gamut-accessibility/SKILL.md) — universal ARIA/focus rules and the shorter Dialog/Modal summary. [`gamut-z-index`](../gamut-z-index/SKILL.md) — stacking order when something floats above or inside an open Modal.
+
+Storybook: [Molecules / Modals / Modal](https://gamut.codecademy.com/?path=/docs-molecules-modals-modal--docs) · [Molecules / Modals / Dialog](https://gamut.codecademy.com/?path=/docs-molecules-modals-dialog--docs)
+
+---
+
+## Modal vs Dialog — when to use which
+
+| Use | Component |
+| ------------------------------------------------------------------------------------- | --------- |
+| Single yes/no or confirm/cancel action (delete, discard, leave page) | `Dialog` |
+| Free-form content, a multi-step wizard (`views`), or no default action buttons at all | `Modal` |
+
+`Dialog` always renders `layout="dialog"` (title + content + cancel/confirm row) and always requires `confirmCta`. `Modal` defaults to `layout="standard"` (title + content, no buttons) unless you pass `views`, which switches it to `layout="dialog"` automatically.
+
+---
+
+## Props — `Modal`
+
+Two variants of `ModalProps`, distinguished by whether `views` is passed:
+
+| Prop | Type | Default | Notes |
+| -------------------- | --------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
+| `isOpen` | `boolean` | — | Required in practice — `Overlay` renders `null` when falsy. Controls mount/unmount; there is no exit animation. |
+| `onRequestClose` | `() => void` | — | Required. Called by Escape, outside-click, and the built-in close button. Must actually flip `isOpen` — the component never closes itself. |
+| `title` | `ReactNode` | — | Renders as `headingLevel` inside the dialog and feeds the accessible name (see caveat below). |
+| `aria-label` | `string` | — | Pass this when `title` is not a plain string, or there is no visible title. |
+| `headingLevel` | `'h1' \| 'h2'` | `'h2'` | Use `h1` only when the Modal effectively takes over the page. |
+| `size` | `'small' \| 'medium' \| 'large' \| 'fluid'` | `'fluid'` | `fluid` sizes to content; the others are fixed widths (400/540/680px). |
+| `scrollable` | `boolean` | `false` | Enables `overflow-y: auto` on the content area instead of letting the dialog grow. |
+| `closeButtonProps` | `{ hidden?, ref?, tip?, tipAlignment?, disabled? }` | — | `tip` defaults to `"Close modal"`. Set `hidden: true` only if you supply your own close control in `children`. |
+| `containerFocusRef` | `Ref<HTMLDivElement>` | — | Ref to the dialog's outer container — see Focus management below. Does **not** change what receives initial focus. |
+| `image` | `ReactNode` | — | Rendered above content via `ImageContainer`, sized to match `size`. |
+| `views` | `ModalViewProps[]` | — | Switches to multi-view wizard mode — see below. Mutually exclusive with `children`/`image` at the top level. |
+| `clickOutsideCloses` | `boolean` | `true` | Passed through to `Overlay`. |
+| `escapeCloses` | `boolean` | `true` | Passed through to `Overlay`. |
+| `shroud` | `boolean` | `true` | `Modal` always passes `shroud` to `Overlay`; darkens the backdrop. |
+| `zIndex` | `number` | `3` | Passed through to `Overlay` → `BodyPortal`. See [`gamut-z-index`](../gamut-z-index/SKILL.md) before overriding. |
+
+## Props — `Dialog`
+
+Same overlay/focus mechanics as `Modal`, with a fixed single-action layout:
+
+| Prop | Type | Notes |
+| ------------ | ------------------------------------------------------- | -------------------------------------------------------------------------------- |
+| `title` | `ReactNode` | Required. |
+| `confirmCta` | `{ children, href?, onClick? }` | Required. Rendered as `FillButton`; `onRequestClose` fires before its `onClick`. |
+| `cancelCta` | `{ children, href?, onClick? }` | Optional `TextButton`; same close-then-callback order as `confirmCta`. |
+| `variant` | `Extract<FillButton['variant'], 'primary' \| 'danger'>` | Styles `confirmCta`; use `'danger'` for destructive confirmations. |
+| `size` | `'small' \| 'medium' \| 'large'` | Default `'small'`. No `'fluid'` — `Dialog` is always a fixed size. |
+
+```tsx
+<Dialog
+ title="Delete this project?"
+ variant="danger"
+ confirmCta={{ children: 'Delete', onClick: handleDelete }}
+ cancelCta={{ children: 'Cancel' }}
+ isOpen={isOpen}
+ onRequestClose={() => setIsOpen(false)}
+/>
+```
+
+---
+
+## Multi-view `Modal` (wizard)
+
+Pass `views: ModalViewProps[]` instead of `children`. Each view gets its own `title`/`children`/`image`, plus a `primaryCta`/`secondaryCta` pair keyed by `actionType`:
+
+| `actionType` (primary) | Behavior |
+| ---------------------- | ----------------------------------------------------------------- |
+| `'next'` | Advances `currentView`; auto-disables on the last view. |
+| `'confirm'` | Terminal action; `variant` restricted to `'primary' \| 'danger'`. |
+
+| `actionType` (secondary) | Behavior |
+| ------------------------ | -------------------------------------------------------- |
+| `'back'` | Retreats `currentView`; auto-disables on the first view. |
+| `'cancel'` | Calls `onRequestClose`. |
+
+```tsx
+<Modal
+ isOpen={isOpen}
+ onRequestClose={() => setIsOpen(false)}
+ views={[
+ {
+ title: 'Step 1',
+ children: <StepOne />,
+ primaryCta: { actionType: 'next', children: 'Next' },
+ secondaryCta: { actionType: 'cancel', children: 'Cancel' },
+ },
+ {
+ title: 'Step 2',
+ children: <StepTwo />,
+ primaryCta: {
+ actionType: 'confirm',
+ children: 'Finish',
+ onClick: handleFinish,
+ },
+ secondaryCta: { actionType: 'back', children: 'Back' },
+ },
+ ]}
+/>
+```
+
+View-level `headingLevel` and `onRequestClose` are not supported per-view — they apply to the whole `Modal`.
+
+---
+
+## Dismiss and focus-trap — how it actually works
+
+`Modal`/`Dialog` render `<Overlay shroud onRequestClose={...}>` around `ModalContainer`. Nothing here is bespoke to Modal — it's the same stack every Gamut overlay uses:
+
+1. **Mount gating** — `Overlay` returns `null` when `isOpen` is falsy. There's no unmount transition; if you need an exit animation, animate your own trigger, not the Modal.
+2. **Portal** — content renders into `BodyPortal` (a `ReactDOM.createPortal` to `document.body`), not inline where `<Modal>` is declared. This is why a Modal reliably renders above in-page content regardless of surrounding `overflow`/`position` — but it also means a _second_ portal (a non-inline `PopoverContainer`, `SelectDropdown` menu, or `DatePicker` calendar) opened from inside the Modal is a **separate** sibling portal, not a descendant of the Modal's — see [`gamut-z-index`](../gamut-z-index/SKILL.md) for why that can render behind the Modal.
+3. **Focus trap** — `FocusTrap` (`react-focus-on` → `focus-lock`) wraps `ModalContainer` and constrains Tab/Shift+Tab to elements inside it while `active` (mirrors `Overlay`'s `inline` — always active for Modal/Dialog since they're never `inline`).
+4. **Initial focus** — `ModalContainer` carries `data-autofocus` and `tabIndex={-1}`. `focus-lock`'s autofocus engine reads that attribute and focuses the container element itself (not the first focusable child) as soon as the trap activates — this is why the dialog's role and label get announced immediately instead of jumping straight to, say, the close button. `containerFocusRef` gives you a ref to that same node for later imperative `.focus()` calls (e.g. re-focusing after an in-modal validation error) — passing it does **not** change what receives focus on open.
+5. **Dismiss paths** — Escape (`escapeCloses`, default `true`), click on the shroud outside `ModalContainer` (`clickOutsideCloses`, default `true`), and the built-in close `IconButton` (suppress with `closeButtonProps.hidden`) all call `onRequestClose`. `Dialog`'s `confirmCta`/`cancelCta` call `onRequestClose` first, then their own `onClick`.
+6. **Return focus** — `react-focus-on` returns focus to whatever triggered the Modal when it unmounts (default `returnFocus` behavior) — you don't need to store/restore the trigger ref yourself.
+
+```tsx
+// wrong — Modal can't close itself; nothing flips isOpen
+<Modal isOpen={isOpen} onRequestClose={() => {}} title="Confirm" />;
+
+// correct
+const [isOpen, setIsOpen] = useState(false);
+<Modal isOpen={isOpen} onRequestClose={() => setIsOpen(false)} title="Confirm">
+ ...
+</Modal>;
+```
+
+### Caveat — `aria-labelledby` uses the title text directly
+
+`Modal`/`Dialog` set `aria-labelledby={String(title)}` rather than pointing at an element `id`. This works fine for a plain string `title`, but stringifying a non-text `ReactNode` title (icons, nested markup) will not produce a useful accessible name. Pass an explicit `aria-label` on `Modal` whenever `title` isn't plain text (`Dialog`'s `title` has no such escape hatch — keep it plain text).
+
+---
+
+## Stacking order — floating content inside a Modal
+
+`Overlay`'s portal (what `Modal`/`Dialog` render into) defaults to `zIndex={3}`. That default is only safe for what's _outside_ the Modal (it reliably clears a sticky global header, for example) — it says nothing about floating content you put _inside_ the Modal's own `children`/`views`.
+
+Concretely: if content inside a `Modal` opens a **second, non-`inline` portal** — a portalling `SelectDropdown` menu, a non-`inline` `PopoverContainer`/`Menu`, or a `DatePicker` calendar that isn't `inline` — that creates a brand-new sibling `BodyPortal` (default `zIndex={1}`) next to the Modal's own portal (`zIndex={3}`). Because `1 < 3`, the thing you just opened _from inside_ the Modal can render **behind** it.
+
+```tsx
+// risky — if SelectDropdown's menu portals, it can render behind this Modal
+<Modal isOpen={isOpen} onRequestClose={onClose} title="Edit">
+ <SelectDropdown options={options} />
+</Modal>
+
+// safer — keep nested floating UI inline, inside the Modal's own stacking context
+<Modal isOpen={isOpen} onRequestClose={onClose} title="Actions">
+ <PopoverContainer inline isOpen={menuOpen} targetRef={triggerRef}>
+ <Menu role="menu">
+ <MenuItem onClick={handleAction}>Action</MenuItem>
+ </Menu>
+ </PopoverContainer>
+</Modal>
+```
+
+Full component-by-component z-index reference, the `AppWrapper`/`BodyPortal` stacking-tier explanation, and when it's actually safe to add a new value: [`gamut-z-index`](../gamut-z-index/SKILL.md).
+
+---
+
+## Basic usage — single-view `Modal`
+
+```tsx
+<Modal
+ isOpen={isOpen}
+ onRequestClose={() => setIsOpen(false)}
+ title="Notification settings"
+ size="medium"
+>
+ <FormGroup htmlFor="email-opt-in" label="Email me updates">
+ <Toggle id="email-opt-in" />
+ </FormGroup>
+</Modal>
+```