ui/Dialog, ui/AlertDialog, ui/Drawer: support sticky header and footer#77559
Conversation
There was a problem hiding this comment.
Some of the changes in this file are not directly related to sticky header/footer, but are part of a refactor aimed at tidying up spacing (especially vertical), which was important when testing and comparing changes in this branch vs trunk
|
Size Change: -1 B (0%) Total Size: 7.77 MB 📦 View Changed
ℹ️ View Unchanged
|
|
Flaky tests detected in 4633f0a. 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/25016178673
|
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
2984f9a to
470550a
Compare
60d00a7 to
854138c
Compare
|
Looks good. Did you consider making only the content area scrollable rather than the entire dialog? I ask only because the appearance might be a bit neater if the scrollbar is constrained to the middle portion of the layout. If there's a technical reason to handle it this way that's fine. |
|
Just noticed that the PR title and description don't mention changes to |
See also #77605 where @ntsekouras has been utilizing Drawer with a fixed header/footer. Would love to avoid all those overrides and support it all out of the box! |
|
@tyxla sounds good, I'll bump the priority of this PR in my queue |
854138c to
a4b30d8
Compare
It can be done, but the cleanest way that I can think of will introduce new (mandatory) I will try to implement it and we can decide whether we prefer it to the current implementation. |
|
@jameskoster done — pushed in f6579af. The scroll region now lives on a dedicated element ( API-wise:
I updated stories and docs accordingly, together with a few more fixes/improvements. |
Extracts a new `overlay-chrome.module.css` that centralizes the sticky chrome primitives — `.header-chrome`, `.footer-chrome`, `.header-sticky`, `.footer-sticky`, `.header`/`.footer` flex layouts, `.title` — and all scroll-container behavior (yield-and-reclaim padding, data-attribute driven separator coloring, focus-aware scroll-padding). All rules key off `[data-wp-ui-overlay-scroll-container]`, which `useOverlayScrollStateAttributes` now toggles on attach, so the shared CSS applies uniformly regardless of which element actually scrolls. Dialog and AlertDialog consume the shared primitives directly and lose their duplicated rules. AlertDialog's heading also gains the shared `.title` class so its color and GCD defenses match Dialog/Drawer. For Drawer, scroll ownership moves from `_Drawer.Popup` to `_Drawer.Content` (which already carries the inner padding and safe-area insets). Base UI's swipe-dismiss-on-scroll-edge logic discovers the scrollable element dynamically, so the move is transparent to it. The content element gets `block-size: 100%` plus a viewport-minus-inset `max-block-size` cap on up/down drawers so `is-auto` sizing still yields a scrollable box. Drawer's default spacing now matches Dialog/AlertDialog (symmetric `gap-lg` around chrome) — the explicit `padding-top` and `margin-block` overrides on `.header`/`.footer` are no longer needed. Class names migrated to kebab-case (`.header-sticky`, `.footer-sticky`) as part of the same pass. Made-with: Cursor
Extends the `Scrollable` stories with inline size knobs (and a swipe direction knob on Drawer) so reviewers can exercise sticky chrome against every popup variant without re-mounting the story. AlertDialog and Popover are intentionally skipped: neither exposes a `size` prop. Drawer's story switches from `args.children` to a `render` function so state can drive both `Drawer.Popup size` and `Drawer.Root swipeDirection`. Made-with: Cursor
- Scrollable story now passes `<ScrollableContent />` via `args.children` instead of hardcoding it inside a custom `render`, matching the other AlertDialog stories. - Trim the `stickyHeader` / `stickyFooter` JSDocs to drop the separator border detail — that's a stylistic implementation detail, not part of the API contract. Made-with: Cursor
Move `overscroll-behavior: contain` out of the per-component CSS and into `overlay-chrome.module.css`, guarded by a new `data-wp-ui-overlay-modal` attribute. Dialog and Drawer mirror their `modal` prop onto the popup; AlertDialog sets it unconditionally since it's always modal. Non-modal Dialog / Drawer no longer trap scroll, which matches the intuition that a non-modal overlay lets the page underneath remain scrollable. The shared rule uses two selectors to cover both scroll-container topologies in the codebase: one for Dialog/AlertDialog where the popup is itself the scroll container, and one for Drawer where the scroll container sits inside the popup (`_Drawer.Content`). Made-with: Cursor
Introduces `Dialog.Content` and `Drawer.Content` as the scroll-owning region of each overlay, confining the scrollbar to the body area between the pinned header and footer (Jay's request on #77559). The pinned-vs- scrolls-with-body toggle for `Header` / `Footer` moves from a `sticky` prop to pure DOM placement: render the chrome as a sibling of `Content` to pin it, nest it inside `Content` to scroll with the body. `AlertDialog` keeps `stickyHeader` / `stickyFooter` props because its chrome is internal and not composable — the props now control sibling- vs-nested placement around an internal scroll container. Shared `overlay-chrome.module.css` centralizes the chrome + scroll- container layout. The separator line lives on the pinned chrome (not on `Content`) and is reserved via a `1px solid transparent` border on the inner block side only when pinned; scroll state toggles the color. The pinned chrome subtracts 1px from its adjacent padding so the border-box height is identical whether pinned or not. Nested chrome inside `Content` collapses its block-outer and inline padding to avoid stacking on top of `Content`'s padding. Storybook `Scrollable` stories for Dialog and Drawer expose a "Sticky header / Sticky footer" toggle that flips the DOM placement at runtime. PR description, CHANGELOG, and JSDoc updated to reflect the new API. Made-with: Cursor
Switches the shared overlay-chrome sibling rules from adjacent (`+`) to general-sibling (`~`) combinators, so extra elements between `.header` / `.content` / `.footer` (custom wrappers, an a11y live region, etc.) don't silently break the pinned layout. Expected DOM shape is still `header → content → footer`; intermediate siblings are now tolerated. Also splits the nested-chrome padding rules so `padding-inline: 0` applies to any `.content > .header` / `.footer` regardless of its position inside `.content`, while `padding-block-*: 0` stays guarded by `:first-child` / `:last-child`. A nested header that isn't the first child of `.content` (e.g. preceded by a `VisuallyHidden` shim) still collapses inline padding correctly; block padding only collapses at the scroll-edge position where it's semantically right. Adds a forced-colors note explaining why the transparent separator border still works in FC mode without extra rules: `transparent` border-color is preserved per spec, and the toggled-in token color is substituted with `CanvasText` by the UA. Made-with: Cursor
Dropping `overflow: auto` from `Dialog.Popup` / `Drawer.Popup` means existing consumers that don't wrap body content in `Dialog.Content` / `Drawer.Content` now silently clip instead of scrolling. That is a behavior change and belongs under Breaking Changes, not Enhancements. Keeps a short Enhancements line for the actual feature (pinned chrome + separator colorization) and points to the Breaking Changes entry for the API details. Made-with: Cursor
WCAG 2.1.1 requires scrollable regions to be reachable by keyboard. `useOverlayScrollStateAttributes` now toggles `tabindex="0"` on its target only while the element actually overflows; a non-scrolling `<div tabindex="0">` is a tab-stop anti-pattern, so we remove it as soon as the overflow disappears. A `data-wp-ui-overlay-scroll-tabbable` flag marks hook-installed tabindex so a consumer-supplied `tabindex` is never overwritten. To keep the newly tabbable scroll container from stealing initial focus when a real interactive control is available, widen `useDeprioritizedInitialFocus` to accept a list of deprioritized attributes, and include `data-wp-ui-overlay-scroll-container` in that list from `Dialog.Popup` / `Drawer.Popup`. Popover keeps its single close-icon attribute since it has no managed scroll container. The `SCROLL_CONTAINER_ATTR` constant is exported from `use-overlay-scroll-state-attributes` so the popups can reference it without stringly-coupling to the library-internal data attribute. Made-with: Cursor
Covers the JSDOM-reachable behavior of the new scroll-container architecture: ref forwarding on the new `Content` subcomponent, sibling-vs-nested chrome placement, `data-wp-ui-overlay-modal` mirroring (including the non-modal and `trap-focus` cases where the attribute must be absent), composed `onScroll`, `data-wp-ui-overlay-scroll-container` target, and the overflow-driven `tabindex` toggle (exercised by stubbing `scrollHeight` / `clientHeight` / `scrollTop` on the element). AlertDialog also verifies that `stickyHeader` / `stickyFooter` reparent the internal chrome into or out of the scroller. Purely style-dependent behavior — separator visibility, sticky positioning, `overscroll-behavior`, the ResizeObserver-driven update loop — isn't testable in JSDOM and stays out of scope. Made-with: Cursor
The previous `childList`-only MutationObserver at the scroll container
missed descendant mutations that don't propagate a resize up to a
direct child — e.g. rows appended to a nested list, a lazy component
mounting into a fixed-size wrapper. With the scroll container at
\`flex: 1 1 auto; min-block-size: 0\`, its own box doesn't resize as
content grows beyond the viewport, so the ResizeObserver alone
wouldn't refresh attributes either, and the newly overflowing region
could stay without a \`tabindex\` until the user happened to scroll.
Widen the MutationObserver to \`{ childList: true, subtree: true }\` so
any descendant addition / removal triggers an attribute refresh. The
callback still only re-observes ResizeObserver targets for direct
children — subtree observation is purely for discovery, keeping the
per-mutation cost O(1) regardless of tree depth.
Made-with: Cursor
…lling Overlay popups don't support horizontal scroll: the chrome layout is block-axis only, and `useOverlayScrollStateAttributes` tracks overflow on the block axis alone. Make that contract explicit in CSS by adding `overflow-inline: hidden` to the shared `.content` scroll container, and call it out in the hook's JSDoc so consumers know to constrain content width rather than expect a horizontal scrollbar. No visual change in practice — the popup's own `overflow: hidden` already clipped wide content; this just moves the clip to the scroll container where the intent is explicit and the behavior doesn't accidentally rely on the popup's clipping. Made-with: Cursor
Bundled small follow-ups from self-review: - \`useOverlayScrollStateAttributes\`: document the consumer-pre-set-tabindex edge case inline so the trade-off around the \`data-wp-ui-overlay-scroll-tabbable\` flag is obvious, and comment the cleanup-path guard that uses it. - \`useDeprioritizedInitialFocus\`: drop the \`readonly\` on \`deprioritizedAttributes\` (all call sites pass plain arrays) and update the inline comment to call out that Base UI wraps \`initialFocus\` in \`useValueAsRef\`, so reference identity doesn't matter and skipping \`useMemo\` is intentional. - \`AlertDialog\` test \`findScroller\`: distinguish "popup ref not attached" from "scroller not inside popup" for faster debugging when a test setup is wrong. - \`CHANGELOG\`: make the sticky-chrome entries concise — one line for the breaking change, one line for the enhancement. Made-with: Cursor
- Add a scroll-state attribute test per component (Dialog / Drawer / AlertDialog) that stubs \`scrollHeight\` / \`clientHeight\` / \`scrollTop\` and dispatches scroll events, asserting \`data-wp-ui-overlay-scrolled-from-top\` and \`data-wp-ui-overlay-scrolled-from-bottom\` toggle correctly as the simulated scroll position moves from top → middle → bottom. - Tighten the pinned-chrome tests for Dialog and Drawer by also asserting the header comes *before* the scroll container in document order via \`compareDocumentPosition\` — the CSS sibling-based separator rules depend on that. No JSDOM workarounds we can avoid here: the harness doesn't compute layout, so initial-mount overflow detection can't be covered without stubbing. Attribute-toggle + DOM-order coverage is the best fidelity available without switching to a real-browser runner. Made-with: Cursor
\`Dialog.Content\` and \`Drawer.Content\` used to inherit \`tabIndex\`
implicitly from \`ComponentProps<'div'>\`, which meant the
auto-tabbable-when-overflowing behavior and the consumer-override
edge case were only documented in the hook itself. Re-declare
\`tabIndex\` explicitly on both \`ContentProps\` types with JSDoc
that spells out:
- the automatic \`tabindex="0"\` that the component installs on
overflow (WCAG 2.1.1);
- that an explicit \`tabIndex\` is never overwritten, including
\`tabIndex={ -1 }\` as a deliberate opt-out;
- the single edge case: removing an explicit \`tabIndex\` at
runtime lets the component take management back over, because
a prior explicit value can't be distinguished from "unset".
No runtime change — this is purely documentation surfaced in
IDE tooltips and generated docs.
Made-with: Cursor
When the body overflows, the scroll container becomes a keyboard tab stop so users can arrow-scroll it — but without a focus ring, there was no visible indication that focus had landed there. Compose the shared \`outset-ring--focus-visible\` utility onto \`Dialog.Content\`, \`Drawer.Content\`, and the AlertDialog internal scroll container so the ring matches the color, width, and transition used by every other focusable surface in the design system. Made-with: Cursor
The shared \`outset-ring--focus-visible\` utility offsets the ring \`+1px\` outside the element. For the overlay scroll container, that puts the ring at the popup's rounded corners, where \`overflow: hidden\` on the popup clips it. Override \`outline-offset\` to a negative value (keyed off \`--wpds-border-width-focus\` so it tracks the token) in the shared \`.content\` rule so the ring sits flush inside the content box and stays fully visible within the popup's clipping region. All other visual properties (color, width, transition) still come from the shared utility. Made-with: Cursor
When `AlertDialog` body content overflows, the internal scroll container becomes `tabindex="0"` so keyboard users can arrow-scroll it (WCAG 2.1.1). Without `useDeprioritizedInitialFocus` wired up, that container would be the first tabbable element in the popup at open time, stealing initial focus from the Cancel/OK actions. Mirror the wiring already present on Dialog and Drawer: route `_AlertDialog.Popup` through `useDeprioritizedInitialFocus` with `[SCROLL_CONTAINER_ATTR]` (no close icon to skip on AlertDialog) and forward the resolved callback as `initialFocus`. Add a regression test that forces the scroller into the overflowing state via `Element.prototype` getter stubs and asserts focus settles on the Cancel button. Made-with: Cursor
Round 4's switch to `subtree: true` was meant to catch async content
added deep inside the scroll region (rows in a nested list, a lazy
component mounting into a wrapper that doesn't itself resize, …).
In practice, anything whose growth actually changes the scroll size
already propagates a resize up the layout tree and is caught by the
existing `ResizeObserver` on direct children — and `subtree: true`
fans the callback out over every text-node insertion in
content-heavy overlays (rich-text editors, virtualized lists), which
isn't worth the cost of the rare deep-mutation-without-resize case.
Revert to `{ childList: true }` only and document the trade-off in
the hook's JSDoc, calling out rAF-coalescing as the future
mitigation if a real consumer ever needs the broader observation.
Made-with: Cursor
Two small cleanups that don't change behavior: - Loop over a `HOOK_OWNED_ATTRS` constant in `cleanupScrollAttributes` instead of three open-coded `removeAttribute` calls, so future attributes added to the hook don't have to remember to extend the cleanup path. - Annotate `SCROLL_TABBABLE_FLAG_ATTR` with a note that its literal string is named in the public JSDoc as a debugging breadcrumb, so anyone tempted to rename it remembers to update the JSDoc too. Made-with: Cursor
Rephrase the existing comment block on the overlay scroll container's `:focus-visible` rule so it reads as cause → consequence: the shared utility's `+1px` outline-offset would collide with the popup's rounded clipping region, so we override to a negative offset that sits inside the content box. Also call out that the shared utility's `transition: outline …` shorthand only animates outline-color and outline-width, not outline-offset — so the negative offset applied here is static by design, with the visible fade-in/out coming from color change only. Future maintainers seeing "non-transitioned offset on top of a transitioned outline" won't need to re-derive the trade-off. Made-with: Cursor
Extend the JSDoc on `Dialog.ContentProps` and `Drawer.ContentProps` `tabIndex` to call out that the scroll region intentionally renders without `role` / `aria-label`. The auto-managed `tabindex="0"` makes the region keyboard-reachable when it overflows (WCAG 2.1.1), but a generic landmark role announcement would land on top of the dialog's own heading + body context and be redundant for screen-reader users. Surfaces the trade-off in the same place a consumer would see when deciding whether to override `tabIndex` themselves. Made-with: Cursor
If the hook installed `tabindex="0"` on an overflowing scroll container and the consumer later passed an explicit `tabIndex` prop, the internal "this is ours" flag stayed in place. A subsequent non-overflow cleanup tick would then strip the consumer's value, contradicting the contract documented on `Dialog.Content` / `Drawer.Content`. Reconcile at the start of every update and cleanup: when the flag is set but the current `tabindex` is no longer `"0"`, drop just the flag and leave the consumer's value alone. Add a regression test that flips overflow on, mutates the attribute to simulate takeover, then flips overflow off and asserts the consumer's value survives. Made-with: Cursor
9f10dc6 to
e03e9a4
Compare
Tighten the JSDoc on `useOverlayScrollStateAttributes` so the tabindex
contract reads as a single labelled list rather than scattered prose:
- Pre-install opt-out (consumer sets `tabindex` before first overflow).
- Takeover after install (consumer overrides the hook's `"0"` later).
- Indistinguishable case (`tabIndex={ 0 }` collides with the managed
value, so the hook can't tell them apart and may strip it on the
next non-overflow tick).
Also reword the inline comments in `updateScrollAttributes` so each
comment block sits next to the code it describes (the takeover note
moves up next to the `reconcileTabbableFlag` call; the pre-install
opt-out note stays with the install branch). And expand the
`reconcileTabbableFlag` JSDoc to cross-reference the contract.
Comment-only change.
Made-with: Cursor
Document why the takeover regression test exercises only `updateScrollAttributes` and not `cleanupScrollAttributes`: both paths share `reconcileTabbableFlag`, so a single test guards both. Flag the maintenance condition (split / inline the helper → add an explicit unmount-after-takeover test) so future readers don't have to re-derive the coverage rationale. Comment-only change. Made-with: Cursor
Reflect the takeover-after-install support and the
`tabIndex={ 0 }` indistinguishable case in the public JSDoc on
`Dialog.Content` and `Drawer.Content`. The wins-and-is-never-
overwritten guarantee now explicitly covers overrides applied after
the component had already managed the value, and a short bullet on
the `0`-collision corner case nudges consumers toward picking a
different value when they need the tabindex to stick across
overflow flips.
Doc-only change.
Made-with: Cursor
#77559) * Dialog: Make Dialog.Header and Dialog.Footer sticky Keep the title and primary actions visible while long dialog content scrolls. Separator borders fade in when content is hidden above or below the scroll viewport. Opt out per sub-component with sticky={ false }. Scroll state is exposed to CSS via data-wp-ui-dialog-scrolled-from-top / -from-bottom attributes toggled imperatively in the popup's onScroll handler, so the border color flips inside the same scroll frame with no React re-render. Layout is driven by CSS only: the popup yields its block padding to a flush-sticky header/footer via :has(), and the chrome extends across the popup's horizontal padding so the separator border runs edge-to-edge. will-change: transform pre-promotes the sticky chrome to its own compositor layer to avoid a subpixel snap on the first stick. overscroll-behavior: contain keeps scroll momentum inside the popup. Closes #77180 Made-with: Cursor * Dialog: Update CHANGELOG entry with PR number Made-with: Cursor * Dialog: Add sticky toggles to the Scrollable story Add independent "Sticky header" and "Sticky footer" checkboxes to the Scrollable story, placed both outside the trigger and inside the dialog body (state is shared, matching the AllSizes pattern) so reviewers can flip either prop without reopening the dialog. Made-with: Cursor * ui: Align AlertDialog sticky chrome with Dialog, add overlay scroll hook - Add useOverlayScrollStateAttributes in utils; use data-wp-ui-overlay-scrolled-* attributes for reuse (e.g. future Drawer). - Dialog: extract scroll wiring to the hook; split header/footer chrome classes for shared styling with AlertDialog. - AlertDialog: sticky title/actions stacks via stickyHeader/stickyFooter on Popup; footer-actions row; Scrollable story with toggles. - Update CHANGELOG Unreleased entry. Made-with: Cursor * ui: Compose Popup onScroll with overlay scroll-state handler - useOverlayScrollStateAttributes: optional consumer onScroll runs after updating data-wp-ui-overlay-scrolled-* attributes. - Dialog.Popup and AlertDialog.Popup: destructure onScroll from props and pass into the hook so callers are not overridden. - Guard ResizeObserver behind typeof check (aligns with modal and avoids runtime throw when unavailable). Made-with: Cursor * ui/AlertDialog: preserve non-sticky vertical spacing Restore trunk-equivalent spacing when sticky header/footer are toggled off. Use shared dialog chrome only for sticky mode so sticky behavior remains while non-sticky mode keeps the original AlertDialog vertical rhythm. Made-with: Cursor * ui/Dialog: Use inset box-shadow for sticky chrome separator Switch the sticky header/footer chrome separator from a 1px transparent border to an inset box-shadow so it never contributes to layout size. This keeps vertical rhythm identical to trunk's non-sticky spacing and lets AlertDialog drop its conditional sticky markup: the chrome classes are now applied uniformly regardless of the sticky toggles. Made-with: Cursor * ui/AlertDialog: Use Stack for footer actions row Made-with: Cursor * ui/Dialog: Fix initial scroll-state attributes on open `useOverlayScrollStateAttributes` now returns a callback ref instead of reading a `RefObject` inside a `useLayoutEffect`. Base UI popups mount their DOM lazily when the overlay opens, so the old effect ran before the node existed and never re-ran; the scroll-state attributes were only set on the first actual scroll. The callback-ref version fires the moment the popup element is attached (and again if it's unmounted/remounted), so the sticky separators reflect the correct state as soon as the dialog opens. Made-with: Cursor * ui/Dialog: Address self-review feedback on sticky chrome CSS - Restore a 1px `border` separator (with matching `-1px` padding compensation) in place of the inset `box-shadow`, so the sticky chrome stays visible in `forced-colors` / Windows High Contrast mode where box-shadow is suppressed. Layout rhythm still matches trunk. - Move the sticky chrome's extra block padding into `:has()`-gated rules, so a sticky header/footer that isn't the first/last popup child doesn't double the popup's own padding. - Add `scroll-padding-block-*` on the popup when a sticky chrome is present, so Tab / `scrollIntoView` keeps focused descendants clear of the pinned chrome (WCAG 2.4.11 focus-not-obscured). - Drop the always-on `will-change: transform`; it forced a permanent compositor layer for every open dialog. - Bump the sticky `z-index` from 1 to 2 and note the popup's stacking context, so a single layer of consumer content can sit below the chrome without covering the separator. - Note the cross-package coupling with `alert-dialog/popup.tsx` above the shared chrome classes. Made-with: Cursor * ui: Address self-review feedback on overlay scroll-state hook - Generic over the scrollable element (`<T extends HTMLElement>`) so consumers get a precisely typed `ref` and `onScroll`. Both call sites pass `<HTMLDivElement>` explicitly for clarity. - Pair the `ResizeObserver` with a `MutationObserver` on the container's `childList`, so direct children added after mount start being observed and `updateScrollAttributes` re-runs on each mutation. Deep descendant reflow still propagates up through the existing child observers. - Fold the file-header block comment into the hook's JSDoc, document the callback-ref rationale, the Strict Mode / repeated-attachment behavior, the scroll-state contract exposed to the consumer `onScroll`, and the forward path via `@container scroll-state()`. Made-with: Cursor * Address round 2 self-review feedback - Hoist popup scroll-padding to `:has(> .headerSticky)` / `:has(> .footerSticky)` so focused descendants stay visible even when the sticky chrome is not the first/last direct child of the popup. - Bump scroll-padding from 5rem to 6rem for a safer upper bound at larger font sizes / multi-line titles, with a comment explaining a measured (ResizeObserver-driven) refinement can replace this if ever needed. - Broaden the MutationObserver's addedNodes check from `instanceof HTMLElement` to `instanceof Element` so SVG/MathML direct children are tracked too. - Unobserve removedNodes to avoid leaking entries into ResizeObserver's set across long-lived overlays. - Update story copy: separator borders "appear" (instant toggle) rather than "fade in" — no CSS transition is in play. Made-with: Cursor * Drop popup scroll-padding while focus is inside sticky chrome When a dialog or alert dialog opens with initial focus landing on a footer action button (or when Tab moves focus to a close icon inside the sticky header), the `scroll-padding-block-*` reserved on the popup would cause the browser's focus scroll to move the already-pinned chrome out of the way, scrolling the content unnecessarily. Guard the padding with `:has(> .headerSticky:focus-within)` / `:has(> .footerSticky:focus-within)` exceptions that reset it to 0 while focus is inside the sticky chrome itself. When focus leaves into non-sticky content, the padding reapplies and continues to keep focused descendants clear of the pinned chrome. Made-with: Cursor * Drawer: Add sticky header and footer support Extend the sticky chrome pattern already used by Dialog and AlertDialog to `Drawer.Header` and `Drawer.Footer`. Both gain an optional `sticky` prop (default `true`) that pins the chrome to the drawer's top/bottom edge while the body scrolls. Separator borders only appear when there is off-screen content in that direction, via the same data-attribute-driven approach (`data-wp-ui-overlay-scrolled-*`) powered by the shared `useOverlayScrollStateAttributes` hook wired into `Drawer.Popup`. Drawer has a different internal structure from Dialog — `Drawer.Popup` is the scroll container and `_Drawer.Content` carries the padding (including per-swipe-direction safe-area overrides) — so the CSS mirrors the Dialog approach but: - Yields block padding on `.content` (not `.popup`) when sticky chrome is first/last child, and reclaims it on the chrome's own block padding. - Preserves safe-area insets on up/down swipe-direction drawers by scoping the reclaim to `max(var(--wpds-dimension-padding-2xl), env(safe-area-*))`. - Uses the shared 6rem `scroll-padding` + `:focus-within` exception so the browser's focus scroll doesn't fight pinned chrome. Spacing for the non-sticky case is unchanged: the base `.header` / `.footer` rules keep trunk's original `margin-bottom: gap-lg` and `margin-top: gap-lg; padding-top: padding-lg`. The sticky overrides shave 1px off the matching direction to compensate for the transparent separator border, so the visible rhythm matches trunk whether the separator is colored or not. A Scrollable story mirrors Dialog's, driving `sticky` on both subcomponents from toggles kept in sync inside/outside the drawer. Made-with: Cursor * ui/Dialog,Drawer: Anchor close icon to end when title is visually hidden Wrapping Title in VisuallyHidden applies position: absolute, which removes the title from flex flow and neutralises `.title { margin-inline-end: auto }`. Also anchor the close icon via `margin-inline-start: auto` (selected through the existing data-wp-ui-*-close-icon attribute) so it remains pushed to the inline end regardless of whether the title participates in the flex layout. Made-with: Cursor * Uniform alertdialog spacing, aligning it with dialog * ui: Share overlay chrome across Dialog/AlertDialog/Drawer Extracts a new `overlay-chrome.module.css` that centralizes the sticky chrome primitives — `.header-chrome`, `.footer-chrome`, `.header-sticky`, `.footer-sticky`, `.header`/`.footer` flex layouts, `.title` — and all scroll-container behavior (yield-and-reclaim padding, data-attribute driven separator coloring, focus-aware scroll-padding). All rules key off `[data-wp-ui-overlay-scroll-container]`, which `useOverlayScrollStateAttributes` now toggles on attach, so the shared CSS applies uniformly regardless of which element actually scrolls. Dialog and AlertDialog consume the shared primitives directly and lose their duplicated rules. AlertDialog's heading also gains the shared `.title` class so its color and GCD defenses match Dialog/Drawer. For Drawer, scroll ownership moves from `_Drawer.Popup` to `_Drawer.Content` (which already carries the inner padding and safe-area insets). Base UI's swipe-dismiss-on-scroll-edge logic discovers the scrollable element dynamically, so the move is transparent to it. The content element gets `block-size: 100%` plus a viewport-minus-inset `max-block-size` cap on up/down drawers so `is-auto` sizing still yields a scrollable box. Drawer's default spacing now matches Dialog/AlertDialog (symmetric `gap-lg` around chrome) — the explicit `padding-top` and `margin-block` overrides on `.header`/`.footer` are no longer needed. Class names migrated to kebab-case (`.header-sticky`, `.footer-sticky`) as part of the same pass. Made-with: Cursor * ui/Dialog,Drawer: Add size controls to Scrollable stories Extends the `Scrollable` stories with inline size knobs (and a swipe direction knob on Drawer) so reviewers can exercise sticky chrome against every popup variant without re-mounting the story. AlertDialog and Popover are intentionally skipped: neither exposes a `size` prop. Drawer's story switches from `args.children` to a `render` function so state can drive both `Drawer.Popup size` and `Drawer.Root swipeDirection`. Made-with: Cursor * ui/AlertDialog: Address mirka's story + JSDoc feedback - Scrollable story now passes `<ScrollableContent />` via `args.children` instead of hardcoding it inside a custom `render`, matching the other AlertDialog stories. - Trim the `stickyHeader` / `stickyFooter` JSDocs to drop the separator border detail — that's a stylistic implementation detail, not part of the API contract. Made-with: Cursor * ui: Scope overscroll contain to modal overlays Move `overscroll-behavior: contain` out of the per-component CSS and into `overlay-chrome.module.css`, guarded by a new `data-wp-ui-overlay-modal` attribute. Dialog and Drawer mirror their `modal` prop onto the popup; AlertDialog sets it unconditionally since it's always modal. Non-modal Dialog / Drawer no longer trap scroll, which matches the intuition that a non-modal overlay lets the page underneath remain scrollable. The shared rule uses two selectors to cover both scroll-container topologies in the codebase: one for Dialog/AlertDialog where the popup is itself the scroll container, and one for Drawer where the scroll container sits inside the popup (`_Drawer.Content`). Made-with: Cursor * ui: Move overlay scrolling onto dedicated Content subcomponent Introduces `Dialog.Content` and `Drawer.Content` as the scroll-owning region of each overlay, confining the scrollbar to the body area between the pinned header and footer (Jay's request on #77559). The pinned-vs- scrolls-with-body toggle for `Header` / `Footer` moves from a `sticky` prop to pure DOM placement: render the chrome as a sibling of `Content` to pin it, nest it inside `Content` to scroll with the body. `AlertDialog` keeps `stickyHeader` / `stickyFooter` props because its chrome is internal and not composable — the props now control sibling- vs-nested placement around an internal scroll container. Shared `overlay-chrome.module.css` centralizes the chrome + scroll- container layout. The separator line lives on the pinned chrome (not on `Content`) and is reserved via a `1px solid transparent` border on the inner block side only when pinned; scroll state toggles the color. The pinned chrome subtracts 1px from its adjacent padding so the border-box height is identical whether pinned or not. Nested chrome inside `Content` collapses its block-outer and inline padding to avoid stacking on top of `Content`'s padding. Storybook `Scrollable` stories for Dialog and Drawer expose a "Sticky header / Sticky footer" toggle that flips the DOM placement at runtime. PR description, CHANGELOG, and JSDoc updated to reflect the new API. Made-with: Cursor * ui: Loosen overlay chrome sibling selectors Switches the shared overlay-chrome sibling rules from adjacent (`+`) to general-sibling (`~`) combinators, so extra elements between `.header` / `.content` / `.footer` (custom wrappers, an a11y live region, etc.) don't silently break the pinned layout. Expected DOM shape is still `header → content → footer`; intermediate siblings are now tolerated. Also splits the nested-chrome padding rules so `padding-inline: 0` applies to any `.content > .header` / `.footer` regardless of its position inside `.content`, while `padding-block-*: 0` stays guarded by `:first-child` / `:last-child`. A nested header that isn't the first child of `.content` (e.g. preceded by a `VisuallyHidden` shim) still collapses inline padding correctly; block padding only collapses at the scroll-edge position where it's semantically right. Adds a forced-colors note explaining why the transparent separator border still works in FC mode without extra rules: `transparent` border-color is preserved per spec, and the toggled-in token color is substituted with `CanvasText` by the UA. Made-with: Cursor * ui: Move Dialog/Drawer Content requirement to Breaking Changes Dropping `overflow: auto` from `Dialog.Popup` / `Drawer.Popup` means existing consumers that don't wrap body content in `Dialog.Content` / `Drawer.Content` now silently clip instead of scrolling. That is a behavior change and belongs under Breaking Changes, not Enhancements. Keeps a short Enhancements line for the actual feature (pinned chrome + separator colorization) and points to the Breaking Changes entry for the API details. Made-with: Cursor * ui: Make overflowing overlay scroll container keyboard-reachable WCAG 2.1.1 requires scrollable regions to be reachable by keyboard. `useOverlayScrollStateAttributes` now toggles `tabindex="0"` on its target only while the element actually overflows; a non-scrolling `<div tabindex="0">` is a tab-stop anti-pattern, so we remove it as soon as the overflow disappears. A `data-wp-ui-overlay-scroll-tabbable` flag marks hook-installed tabindex so a consumer-supplied `tabindex` is never overwritten. To keep the newly tabbable scroll container from stealing initial focus when a real interactive control is available, widen `useDeprioritizedInitialFocus` to accept a list of deprioritized attributes, and include `data-wp-ui-overlay-scroll-container` in that list from `Dialog.Popup` / `Drawer.Popup`. Popover keeps its single close-icon attribute since it has no managed scroll container. The `SCROLL_CONTAINER_ATTR` constant is exported from `use-overlay-scroll-state-attributes` so the popups can reference it without stringly-coupling to the library-internal data attribute. Made-with: Cursor * ui: Add tests for Dialog/Drawer/AlertDialog scroll container Covers the JSDOM-reachable behavior of the new scroll-container architecture: ref forwarding on the new `Content` subcomponent, sibling-vs-nested chrome placement, `data-wp-ui-overlay-modal` mirroring (including the non-modal and `trap-focus` cases where the attribute must be absent), composed `onScroll`, `data-wp-ui-overlay-scroll-container` target, and the overflow-driven `tabindex` toggle (exercised by stubbing `scrollHeight` / `clientHeight` / `scrollTop` on the element). AlertDialog also verifies that `stickyHeader` / `stickyFooter` reparent the internal chrome into or out of the scroller. Purely style-dependent behavior — separator visibility, sticky positioning, `overscroll-behavior`, the ResizeObserver-driven update loop — isn't testable in JSDOM and stays out of scope. Made-with: Cursor * ui: Extend overlay scroll MutationObserver to the subtree The previous `childList`-only MutationObserver at the scroll container missed descendant mutations that don't propagate a resize up to a direct child — e.g. rows appended to a nested list, a lazy component mounting into a fixed-size wrapper. With the scroll container at \`flex: 1 1 auto; min-block-size: 0\`, its own box doesn't resize as content grows beyond the viewport, so the ResizeObserver alone wouldn't refresh attributes either, and the newly overflowing region could stay without a \`tabindex\` until the user happened to scroll. Widen the MutationObserver to \`{ childList: true, subtree: true }\` so any descendant addition / removal triggers an attribute refresh. The callback still only re-observes ResizeObserver targets for direct children — subtree observation is purely for discovery, keeping the per-mutation cost O(1) regardless of tree depth. Made-with: Cursor * ui: Clip inline overflow on overlay Content; document block-only scrolling Overlay popups don't support horizontal scroll: the chrome layout is block-axis only, and `useOverlayScrollStateAttributes` tracks overflow on the block axis alone. Make that contract explicit in CSS by adding `overflow-inline: hidden` to the shared `.content` scroll container, and call it out in the hook's JSDoc so consumers know to constrain content width rather than expect a horizontal scrollbar. No visual change in practice — the popup's own `overflow: hidden` already clipped wide content; this just moves the clip to the scroll container where the intent is explicit and the behavior doesn't accidentally rely on the popup's clipping. Made-with: Cursor * ui: Tighten overlay scroll utility docs, types, and helpers Bundled small follow-ups from self-review: - \`useOverlayScrollStateAttributes\`: document the consumer-pre-set-tabindex edge case inline so the trade-off around the \`data-wp-ui-overlay-scroll-tabbable\` flag is obvious, and comment the cleanup-path guard that uses it. - \`useDeprioritizedInitialFocus\`: drop the \`readonly\` on \`deprioritizedAttributes\` (all call sites pass plain arrays) and update the inline comment to call out that Base UI wraps \`initialFocus\` in \`useValueAsRef\`, so reference identity doesn't matter and skipping \`useMemo\` is intentional. - \`AlertDialog\` test \`findScroller\`: distinguish "popup ref not attached" from "scroller not inside popup" for faster debugging when a test setup is wrong. - \`CHANGELOG\`: make the sticky-chrome entries concise — one line for the breaking change, one line for the enhancement. Made-with: Cursor * ui: Add overlay scroll-state attribute + DOM-order tests - Add a scroll-state attribute test per component (Dialog / Drawer / AlertDialog) that stubs \`scrollHeight\` / \`clientHeight\` / \`scrollTop\` and dispatches scroll events, asserting \`data-wp-ui-overlay-scrolled-from-top\` and \`data-wp-ui-overlay-scrolled-from-bottom\` toggle correctly as the simulated scroll position moves from top → middle → bottom. - Tighten the pinned-chrome tests for Dialog and Drawer by also asserting the header comes *before* the scroll container in document order via \`compareDocumentPosition\` — the CSS sibling-based separator rules depend on that. No JSDOM workarounds we can avoid here: the harness doesn't compute layout, so initial-mount overflow detection can't be covered without stubbing. Attribute-toggle + DOM-order coverage is the best fidelity available without switching to a real-browser runner. Made-with: Cursor * ui: Surface tabIndex as an explicit Content prop with JSDoc \`Dialog.Content\` and \`Drawer.Content\` used to inherit \`tabIndex\` implicitly from \`ComponentProps<'div'>\`, which meant the auto-tabbable-when-overflowing behavior and the consumer-override edge case were only documented in the hook itself. Re-declare \`tabIndex\` explicitly on both \`ContentProps\` types with JSDoc that spells out: - the automatic \`tabindex="0"\` that the component installs on overflow (WCAG 2.1.1); - that an explicit \`tabIndex\` is never overwritten, including \`tabIndex={ -1 }\` as a deliberate opt-out; - the single edge case: removing an explicit \`tabIndex\` at runtime lets the component take management back over, because a prior explicit value can't be distinguished from "unset". No runtime change — this is purely documentation surfaced in IDE tooltips and generated docs. Made-with: Cursor * ui: Apply shared focus-visible ring to overlay scroll containers When the body overflows, the scroll container becomes a keyboard tab stop so users can arrow-scroll it — but without a focus ring, there was no visible indication that focus had landed there. Compose the shared \`outset-ring--focus-visible\` utility onto \`Dialog.Content\`, \`Drawer.Content\`, and the AlertDialog internal scroll container so the ring matches the color, width, and transition used by every other focusable surface in the design system. Made-with: Cursor * ui: Inset the overlay scroll container focus ring The shared \`outset-ring--focus-visible\` utility offsets the ring \`+1px\` outside the element. For the overlay scroll container, that puts the ring at the popup's rounded corners, where \`overflow: hidden\` on the popup clips it. Override \`outline-offset\` to a negative value (keyed off \`--wpds-border-width-focus\` so it tracks the token) in the shared \`.content\` rule so the ring sits flush inside the content box and stays fully visible within the popup's clipping region. All other visual properties (color, width, transition) still come from the shared utility. Made-with: Cursor * ui/AlertDialog: Deprioritize scroll container in initial focus When `AlertDialog` body content overflows, the internal scroll container becomes `tabindex="0"` so keyboard users can arrow-scroll it (WCAG 2.1.1). Without `useDeprioritizedInitialFocus` wired up, that container would be the first tabbable element in the popup at open time, stealing initial focus from the Cancel/OK actions. Mirror the wiring already present on Dialog and Drawer: route `_AlertDialog.Popup` through `useDeprioritizedInitialFocus` with `[SCROLL_CONTAINER_ATTR]` (no close icon to skip on AlertDialog) and forward the resolved callback as `initialFocus`. Add a regression test that forces the scroller into the overflowing state via `Element.prototype` getter stubs and asserts focus settles on the Cancel button. Made-with: Cursor * ui: Drop subtree observation on overlay MutationObserver Round 4's switch to `subtree: true` was meant to catch async content added deep inside the scroll region (rows in a nested list, a lazy component mounting into a wrapper that doesn't itself resize, …). In practice, anything whose growth actually changes the scroll size already propagates a resize up the layout tree and is caught by the existing `ResizeObserver` on direct children — and `subtree: true` fans the callback out over every text-node insertion in content-heavy overlays (rich-text editors, virtualized lists), which isn't worth the cost of the rare deep-mutation-without-resize case. Revert to `{ childList: true }` only and document the trade-off in the hook's JSDoc, calling out rAF-coalescing as the future mitigation if a real consumer ever needs the broader observation. Made-with: Cursor * ui: Polish overlay scroll utility internals Two small cleanups that don't change behavior: - Loop over a `HOOK_OWNED_ATTRS` constant in `cleanupScrollAttributes` instead of three open-coded `removeAttribute` calls, so future attributes added to the hook don't have to remember to extend the cleanup path. - Annotate `SCROLL_TABBABLE_FLAG_ATTR` with a note that its literal string is named in the public JSDoc as a debugging breadcrumb, so anyone tempted to rename it remembers to update the JSDoc too. Made-with: Cursor * ui: Clarify why the inset focus-ring offset is static Rephrase the existing comment block on the overlay scroll container's `:focus-visible` rule so it reads as cause → consequence: the shared utility's `+1px` outline-offset would collide with the popup's rounded clipping region, so we override to a negative offset that sits inside the content box. Also call out that the shared utility's `transition: outline …` shorthand only animates outline-color and outline-width, not outline-offset — so the negative offset applied here is static by design, with the visible fade-in/out coming from color change only. Future maintainers seeing "non-transitioned offset on top of a transitioned outline" won't need to re-derive the trade-off. Made-with: Cursor * ui: Document the role-less scroll region on Content tabIndex JSDoc Extend the JSDoc on `Dialog.ContentProps` and `Drawer.ContentProps` `tabIndex` to call out that the scroll region intentionally renders without `role` / `aria-label`. The auto-managed `tabindex="0"` makes the region keyboard-reachable when it overflows (WCAG 2.1.1), but a generic landmark role announcement would land on top of the dialog's own heading + body context and be redundant for screen-reader users. Surfaces the trade-off in the same place a consumer would see when deciding whether to override `tabIndex` themselves. Made-with: Cursor * ui: Detect consumer takeover of hook-managed scroll-container tabindex If the hook installed `tabindex="0"` on an overflowing scroll container and the consumer later passed an explicit `tabIndex` prop, the internal "this is ours" flag stayed in place. A subsequent non-overflow cleanup tick would then strip the consumer's value, contradicting the contract documented on `Dialog.Content` / `Drawer.Content`. Reconcile at the start of every update and cleanup: when the flag is set but the current `tabindex` is no longer `"0"`, drop just the flag and leave the consumer's value alone. Add a regression test that flips overflow on, mutates the attribute to simulate takeover, then flips overflow off and asserts the consumer's value survives. Made-with: Cursor * ui: Document the overlay scroll-state tabindex contract explicitly Tighten the JSDoc on `useOverlayScrollStateAttributes` so the tabindex contract reads as a single labelled list rather than scattered prose: - Pre-install opt-out (consumer sets `tabindex` before first overflow). - Takeover after install (consumer overrides the hook's `"0"` later). - Indistinguishable case (`tabIndex={ 0 }` collides with the managed value, so the hook can't tell them apart and may strip it on the next non-overflow tick). Also reword the inline comments in `updateScrollAttributes` so each comment block sits next to the code it describes (the takeover note moves up next to the `reconcileTabbableFlag` call; the pre-install opt-out note stays with the install branch). And expand the `reconcileTabbableFlag` JSDoc to cross-reference the contract. Comment-only change. Made-with: Cursor * ui: Note the cleanup-path coverage on the tabindex takeover test Document why the takeover regression test exercises only `updateScrollAttributes` and not `cleanupScrollAttributes`: both paths share `reconcileTabbableFlag`, so a single test guards both. Flag the maintenance condition (split / inline the helper → add an explicit unmount-after-takeover test) so future readers don't have to re-derive the coverage rationale. Comment-only change. Made-with: Cursor * ui: Update Dialog/Drawer Content tabIndex JSDoc with full contract Reflect the takeover-after-install support and the `tabIndex={ 0 }` indistinguishable case in the public JSDoc on `Dialog.Content` and `Drawer.Content`. The wins-and-is-never- overwritten guarantee now explicitly covers overrides applied after the component had already managed the value, and a short bullet on the `0`-collision corner case nudges consumers toward picking a different value when they need the tabindex to stick across overflow flips. Doc-only change. Made-with: Cursor --- Co-authored-by: ciampo <mciampini@git.wordpress.org> Co-authored-by: mirka <0mirka00@git.wordpress.org> Co-authored-by: jameskoster <jameskoster@git.wordpress.org> Co-authored-by: tyxla <tyxla@git.wordpress.org>

Closes #77180 · Supersedes #77182
Partially extracted to:
What?
Pin the header and footer of
Dialog,AlertDialog, andDrawerto the popup edges when the body overflows, with a separator that appears only while there's off-screen content in that direction.Dialog/Drawer: a new requiredContentsubcomponent owns the scroll region. RenderHeader/Footeras siblings ofContentto pin them; nest them insideContentto scroll with the body.AlertDialog.Popup: gainsstickyHeader/stickyFooterprops (defaulttrue) for the same choice on its internal chrome.Why?
When an overlay overflows, the title and primary actions scroll out of view. Pinning them keeps users oriented; conditional separators make overflow discoverable without permanent visual noise. Confining scroll to the body region also tucks the scrollbar between the chrome edges (cleaner, and unambiguous about what scrolls).
How?
A new shared
utils/css/overlay-chrome.module.csscentralizes the chrome layout and scroll-state separator styling; Dialog, AlertDialog, and Drawer all compose from it. The Popup is a flex column, and theContentelement (Dialog / Drawer) or an internal scroll container (AlertDialog) owns the overflow — pinning comes for free from flex positioning, noposition: stickyinvolved. Scrolling is block-axis only (overflow-inline: hidden); consumers should constrain content width rather than rely on horizontal scroll.A companion hook
useOverlayScrollStateAttributesmarks the scroll container and togglesdata-wp-ui-overlay-scrolled-from-{top,bottom}on it as the user scrolls, so CSS can draw the separators without React re-renders. The same hook conditionally togglestabindex="0"on the scroll container only while it actually overflows, so keyboard users can focus and arrow-scroll the body (WCAG 2.1.1) without introducing a stray tab stop on non-scrolling popups.Dialog.Content/Drawer.ContentexposetabIndexas an explicit prop with JSDoc covering the managed behavior and consumer-override edge case.useDeprioritizedInitialFocuskeeps the newly tabbable container from stealing initial focus when real controls are available.The pinned-chrome separator uses a
transparent→ token-color toggle, which forced-colors mode substitutes withCanvasTextautomatically.Testing Instructions
npm installcd packages/ui && npm run storybookHeader/Footerbetween sibling-of-Content(pinned) and nested-inside-Content(scrolls with body). On AlertDialog, togglestickyHeader/stickyFooter.swipeDirections; swipe-to-dismiss should still engage once scroll reaches the edge.Keyboard
Follow-ups
Screenshots or screencast
Kapture.2026-04-27.at.18.13.07.mp4
Use of AI Tools
Cursor + Claude Opus 4.7.