Skip to content

feat(AlertDialog): short actions share a row on compact touch screens - #8095

Draft
vjeux wants to merge 2 commits into
meta:mainfrom
vjeux:uikit/c18-alerts-pr3
Draft

vjeux wants to merge 2 commits into
meta:mainfrom
vjeux:uikit/c18-alerts-pr3

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Stack: #8084 → #8095

#8084 lands first. Review only this pull request's change: vjeux/astryx@62c9b4a...8226b5e

User need

On a phone, AlertDialog stacks its two buttons at full width, so "Cancel" and "Delete" take two rows. Under a finger on a compact screen, two short actions now share one row in equal halves, Cancel leading. A label too long for its half stacks them, the destructive action above Cancel: as before on a portrait phone, and new on a landscape phone, where the actions used to sit in a trailing row. With a mouse nothing changes.

Problem and solution fit

  1. Row (FR3): under a finger on a compact screen, two actions whose labels each fit half of the row share it in equal halves, Cancel leading. Visual, DOM and tab order agree, and Cancel keeps initial focus.
  2. Stack: a label too long for its half stacks them, the destructive action above Cancel. On a portrait phone that is today's layout; on a landscape phone it replaces the trailing row.
  3. Fresh decisions: a rotation, a zoom, a web font that finishes loading or a label change decides again while the alert is shown, and an alert mounted closed decides when it opens.
  4. A mouse keeps today's layout: stacked at 640 px and below.

Current specification

Specs decision, 2026-11-04: compact touch screens get the shared row (component:AlertDialog DEC-5). The rule is iOS's: per label, never by the two widths together (alertController.actions.rowShare). The row itself is Astryx's: Astryx Buttons, the footer's Stack gap token, and the theme's type and colors. Nothing of iOS's styling is copied. spec:AST-012 FR9 defines a compact window. The record gains FR3's compact touch layout, PR1's title observation and DEC-5.

Public delta and caller burden

No API change, and no decision for callers: a label too long for its half stacks the actions on its own. Under a finger on a compact screen, two short actions share a row; with a mouse nothing changes.

Design and alternatives

  1. CSS flex-wrap. No script, but it wraps on the sum of both widths and keeps the DOM order when it wraps, so Cancel would land on top, and visual and tab order would disagree.
  2. A container query on the row. No script either, but it can't see each label's length.
  3. Measure each label against half of the row, in script, only when something can change the answer: the alert's first display, a rotation, a zoom, a web font that finishes loading, or a label change.

Only option 3 expresses the per-label rule and keeps visual, DOM and tab order together. It watches the alert's title, whose size the actions never change, so a decision never resizes what it watches and starts no resize observer loop. It also listens for web fonts, which change a label's width without changing the title's size. It commits before the first paint.

Evidence

On a phone, on this head's preview:

  1. Delete story: tap "Delete item". Cancel and Delete share one row, Cancel on the left. On feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084 they stack.
  2. Async story: tap "Revoke access", then "Revoke". The spinner stays inside its half, and the row holds.
  3. Mobile Touch story: "Keep this workspace" and "Permanently delete workspace" are too long for a half, so they stack, the destructive action above Cancel.
  4. Open the Delete alert, then rotate to landscape and back: it decides again (a landscape phone is compact by its height).
  5. With a mouse at 640 px and below, nothing changes: the actions stack.

The spec owner's real-phone check of the action layout, edge cases and long labels included, passed (2026-11-05).

Real Chromium renders of the inline alert story under a coarse pointer, 320 px on the left, 390 px on the right. They were taken at 1f61aea; at this head the same cases render the same pixels, apart from the loading spinner's angle.

  • Two short labels, before (feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084's head) and after:
    Before: stacked
    After: one row, Cancel leading
  • One long label stacks them, whatever the other: One long label
  • Two long labels: Two long labels
  • A label that fits its half only on the wider screen ("Delete all items" needs 133 px; a half is 124 px at 320 and 140 px at 390): Stack at 320, row at 390
  • German: short labels share the row, a long one stacks them: Abbrechen and Löschen Abbrechen and Endgültig löschen
  • Right to left: the row mirrors, Cancel on the right: Right to left
  • Twice the text size: the actions stack. The Button keeps its token height, so its label fills the pill; that is Button's existing sizing, unchanged here: Large text
  • 200% zoom (a 320 and a 390 px phone at 160 and 195 CSS px): the actions stack: 200% zoom
  • Another action variant (primary): the same row: Primary action
  • A loading action keeps its half: it is measured by its label, not by its spinner's text: Loading
  • A label change while the alert is shown: "Delete" becomes "Delete every message", and the actions stack: Before the change After the change
  • A mouse keeps today's layout: stacked at 640 px and below: Mouse

Review follow-ups

The earlier review's items, on this rebuild:

  • A mouse keeps the native button: only a finger or a pen is tracked (pointerTypes: ['touch', 'pen']), as on main. The usage doc now says so; it still read "every pointer".
  • No resize observer loop on the first compact touch open: the hook watches the title, not the row. A browser case collects the page's errors and finds none; it fails with the old hook, which watched the row.
  • No flushSync during the observer's registration call inside the layout effect. A unit case renders under StrictMode and logs no React error; it fails with the old hook.
  • The docs, stories and changeset say what ships: the theme's compact definition (by default narrower than 768 px or shorter than 500 px), a coarse pointer, and a mouse that keeps the 640 px stack. The changeset is a [feat].
  • component:AlertDialog FR3, PR1 and DEC-5 record the specs decision (2026-11-04).
  • One commit on feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084, in place of the old stack.

This round's review, at 1f61aea:

  • An alert mounted closed now shares the row once it opens: the title comes through a callback ref, so its mount starts the observation. A unit case and a browser case (the inline story mounted with isOpen false, then opened) each fail without that.
  • PR1 names the web-font listener beside the title's resize observation.
  • The case for a label that fits only on a wider screen now runs at 300 px (a half is about 114 px) and 390 px (140 px) with "Delete forever" (about 125 px), so it has room on both sides.
  • feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084 dropped the unused isInert tracker option.

And at 93ae42e:

  • The changeset and the record's compatibility note say that long labels stacking is new on a landscape phone, where main shows a trailing row.
  • The even split's style is named equalHalfAction, without a comment restating it.

One test change outside the layout: #8084's busy-main-thread proof now counts a frame as "the alert changed" by the mean absolute change of the region's pixels, not by the change of their mean. With the row's shorter alert, a frame came where the alert changed but the region's mean luminance moved by only 0.044: the fade's gain was cancelled by text crossing the region's edge as the alert scaled. That missed frame failed 6 of 9 runs on two cores; with the new measure 6 of 6 pass. A still alert during the block still reads as missed on every frame of the control (58 of 58).

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

Local gates at 8226b5e, on #8084's head 62c9b4a (main 252b45b):

  • The new layout spec, both AlertDialog interaction specs, Dialog's touch entrance and the input hold's browser spec: Chromium twice and pinned to 2 cores three times, 60 of 60 each; WebKit in CI's container five times, 280 passed (the 20 skipped cases are Chromium-only). AlertDialog's accessibility contract and the bottom sheet's toast outlet contract (7) and the hooks, AlertDialog, Dialog, Layer, Toast and motion units (2,538) pass.
  • Red first: 10 of the layout spec's 16 cases fail on feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084's Storybook, which has no row, and the mounted-closed case fails on the previous head. The hook's unit cases for watching only the title, for the registration under StrictMode, for a loading label, for a font load and for an alert mounted closed each fail without that part.
  • CI=true pnpm lint (no errors), the build, pnpm typecheck, Storybook, pnpm test:ui (20,121), check:knowledge and check:repo pass. In pnpm test:node 16,453 passed, and only feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084's two ci-interaction-webkit.test.mjs cases failed, both on modalOutlet.ts; ci: the WebKit interaction lane covers the modal outlet #10018 clears them. api:report --check reports drift already on main (DropdownMenu, the CLI), none of it from this change.

Writing rubric results

component:AlertDialog: FR3 states the compact touch layout, with visual, DOM and tab order together and Cancel's initial focus in every layout; PR1 adds the title observation and the web-font listener; DEC-5 records the decision and the rejected alternative; the Behavior row and the FR1–FR5 verification row name the layout and its tests; the Compatibility bullet adds FR3.

  • R1 narration, R2 research, R4 rejected alternatives, R5 change references: none in the record.
  • R3 evidence: the per-label rule's source is alertController.actions.rowShare (measured, iOS 26.2); the decision's is DEC-5.
  • R6 open questions: none.

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants