Skip to content

fix(Dialog): an explicit autofocus request wins over a header's title, and Lab can read the Dialog context through an internal entry - #8798

Open
jiunshinn wants to merge 1 commit into
meta:mainfrom
jiunshinn:feat/4182-dialog-core-focus-order
Open

jiunshinn wants to merge 1 commit into
meta:mainfrom
jiunshinn:feat/4182-dialog-core-focus-order

Conversation

@jiunshinn

Copy link
Copy Markdown
Contributor

Part of #4182. Split out of #5105.

The review app asked to split #5105 because it has 2,146 authored lines, and the limit is 2,000. This PR holds only the Core portion. #5105 keeps the Lab DialogHeroHeader. When this PR merges, I will merge main into #5105, and its diff will drop below the limit.

What changes

1. Initial focus follows component:Dialog FR2. (packages/core/src/Dialog/Dialog.tsx:595)

FR2 is settled: "A request is eligible only when its rendered target is inside the active Dialog, is neither hidden nor inert, and can receive programmatic focus when selection runs. DialogHeader supplies its title as the default when no non-default eligible request is rendered."

main This PR
First [data-autofocus] is in a hidden or inert subtree Dialog tries that one only Dialog skips it and tries the next request
First request cannot take focus (for example, disabled) Dialog tries that one only, and the later requests are ignored Dialog tries the next request
A header title request comes before an explicit request in DOM order The title wins The explicit request wins. data-autofocus="dialog-title" comes after every explicit request
No request can take focus Native initial focus Native initial focus, no change (FR3)

2. syncDefaultName() on the Dialog context. (Dialog.tsx:528)

Dialog looks for its title in its callback ref, when it mounts. A composed header whose title element renders later, changes, or leaves calls syncDefaultName(), and Dialog runs the same check again. The default aria-labelledby then follows the title. A caller's aria-label or aria-labelledby still wins.

3. @astryxdesign/core/internal/dialog-context. (packages/core/src/internal/dialog-context.ts)

This entry re-exports useDialogContext by name and exports nothing else. spec:AST-017 FR21 keeps a module without a public contract internal, and exports it only through Core's ./internal/* subpaths. Those subpaths are reserved for Astryx's own packages, and lint limits them to Core and Lab (architecture:react-component-runtime INV13). The entry has the same shape as internal/tokenizer-presentation (#8735), and it is registered in FILE_MODULE_SUBPATH_EXPORTS. The public @astryxdesign/core/Dialog barrel is unchanged, and a test asserts that it exports no context.

Who uses this

No Core component uses the dialog-title marker or syncDefaultName() yet. Lab's DialogHeroHeader in #5105 uses both. Core's DialogHeader does not change: it still focuses its title from its own mount effect.

Tests

Test main This PR
Dialog.test.tsx:211 skips a hidden or inert request for the next eligible one fails passes
Dialog.test.tsx:233 falls through a request that cannot take focus fails passes
Dialog.test.tsx:248 honors an explicit request before a header's title request fails passes
Dialog.test.tsx:263 focuses the title request when no explicit request is eligible passes (guard) passes
Dialog.test.tsx:1291 names itself by a header title that renders after mount fails passes
internal/dialog-context.test.ts re-exports the same hook by name; the public barrel exports no context — passes

To get the main column, I put main's Dialog.tsx in place and ran the new tests.

Local validation:

  • 832 tests pass in the Dialog, AlertDialog, Drawer, BottomSheet, CommandPalette and internal suites, and in scripts/sync-exports.test.mjs.
  • pnpm -F @astryxdesign/core typecheck, eslint and prettier pass on the changed files.
  • check:changesets, check:sync, check:package-boundaries and the full pre-commit check:repo pass.
  • changeset-coverage.mjs pr passes against v0.7.4.

Changesets

  • [fix] Dialog: a hidden, inert or unfocusable first request falls through to the next one, and a header's default title request comes after every explicit request.
  • [chore] Internal: the new internal entry. Nothing that a consumer sees changes.

…, and Lab can read the Dialog context through an internal entry

Dialog now honors only eligible initial-focus requests (component:Dialog FR2):
a request inside a hidden or inert subtree is skipped, and a request that
cannot take focus falls through to the next one. A header's default title
request (data-autofocus="dialog-title") comes after every explicit request.

The Dialog context gains syncDefaultName(), so a composed header whose title
renders after mount can ask Dialog to check its default name again.
@astryxdesign/core/internal/dialog-context re-exports useDialogContext for
Astryx's own packages only (spec:AST-017 FR21), the same shape as
internal/tokenizer-presentation.

Part of meta#4182. Split out of meta#5105.
@vercel

vercel Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 28, 2026 10:05am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 28, 2026
@astracat-bot

astracat-bot Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Oct 28, 2026, 10:34 AM UTC

@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge labels Oct 28, 2026
@jiunshinn
jiunshinn marked this pull request as ready for review October 28, 2026 10:01
@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 57.3KB 57.4KB +96B 12.5KB 12.5KB 0B
core/CommandPalette 51.5KB 51.6KB +104B 12.0KB 12.0KB 0B
core/CommandPaletteInput 32.9KB 32.9KB 0B 8.3KB 8.3KB 0B
core/CommandPaletteItem 8.4KB 8.4KB 0B 3.5KB 3.5KB 0B
core/Dialog 16.2KB 16.3KB +115B 7.1KB 7.1KB 0B
core/DialogHeader 49.5KB 49.5KB 0B 10.0KB 10.0KB 0B
core/PowerSearch 166.6KB 166.6KB 0B 20.1KB 20.1KB 0B
core/Tokenizer 69.3KB 69.3KB +1B 16.7KB 16.7KB 0B
lab/CommentThread N/A 216.7KB N/A N/A 2.4KB N/A
lab/Comments N/A 221.3KB N/A N/A 2.5KB N/A
lab/MobilePowerSearch N/A 147.3KB N/A N/A 1.4KB N/A
lab/MobileTokenizer N/A 92.9KB N/A N/A 809B N/A
richtext/RichTextEditorToolbar N/A 213.1KB N/A N/A 454B N/A

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 35.1KB 35.1KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

57 of 504 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 37 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: the new fall-through and title precedence match component:Dialog FR2 in an ordinary page, but two lines in the focus loop look outside the dialog. Please fix both and add a regression test for each case:

  • Dialog.tsx:604: closest('[hidden], [inert], [aria-hidden="true"]') walks past the <dialog>, so a Dialog rendered inside an inert region skips its own focusable request; only count a match the dialog contains.
  • Dialog.tsx:611: document.activeElement belongs to the page's document, so in an iframe or Picture-in-Picture window the loop never stops and the last request (the dialog-title request when present) wins; compare against target.ownerDocument.activeElement.

The changesets could also mention the aria-hidden skip and syncDefaultName.

[Automated review]

),
];
for (const target of autofocusTargets) {
if (target.closest('[hidden], [inert], [aria-hidden="true"]')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This walks every ancestor, past the <dialog>. A modal dialog escapes an ancestor's inertness: Chromium focuses a button inside a modal <dialog> under <div inert>. A Dialog rendered in an inert region therefore skips its own eligible request and falls back to native focus, against component:Dialog FR2. Count a match only when it is inside the dialog:

const blocker = target.closest('[hidden], [inert], [aria-hidden="true"]');
if (blocker != null && dialog.contains(blocker)) {
  continue;
}

// browser. If a request cannot land, try the next eligible request
// and finally the title; otherwise preserve native initial focus.
focusForLastInput(target);
if (document.activeElement === target) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

document is the page's document. For a Dialog rendered into an iframe or a Picture-in-Picture window this is never true, so the loop focuses every eligible request in turn and ends on the last one, which is the dialog-title request when there is one. Compare against the target's own document:

if (target.ownerDocument.activeElement === target) {
  break;
}

@duytbp

duytbp commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

A case for syncDefaultName() with Core's own DialogHeader: today the default name is read once, in the <dialog> callback ref (Dialog.tsx:513-526), so a DialogHeader that mounts after the dialog has attached leaves the open dialog with no accessible name. That happens with content that loads after open, or a multi-step dialog that swaps its header per step.

function Repro() {
  const [isReady, setIsReady] = useState(false);
  useEffect(() => {
    const id = setTimeout(() => setIsReady(true), 10);
    return () => clearTimeout(id);
  }, []);
  return (
    <Dialog isOpen onOpenChange={() => {}}>
      {isReady ? <DialogHeader title="Edit profile" /> : <p>Loading</p>}
    </Dialog>
  );
}

In jsdom on 0.7.4 (unchanged on main at 14fc587), the <dialog> ends with no aria-labelledby although the title is rendered; with the header present at mount it gets aria-labelledby pointing at the title.

Since this PR adds syncDefaultName(), could Core's DialogHeader call it on mount and unmount (and when its title node changes)? That would fix this case for every consumer, not only Lab's header.

This branch was successfully deployed

1 active deployment
Preview — 2b4795f3 Deployed Oct 28, 2026 by vercel[bot]
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. community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants