Skip to content

fix(core): useAnnounce delivers inside the open Astryx modal that encloses its caller - #8009

Merged
imdreamrunner merged 1 commit into
mainfrom
feat/modal-aware-announce
Oct 26, 2026
Merged

imdreamrunner merged 1 commit into
mainfrom
feat/modal-aware-announce

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

User impact

A screen-reader user inside an open native modal — a Dialog, a modal BottomSheet or Drawer, a Lightbox, CommandPalette — heard nothing when a component or the app announced something from inside it: a selection added or removed, a count, "Saved". Every such message went through the page-body live regions, which a native modal hides from assistive technology. Only busy starts had their own region inside the dialog. After this change, useAnnounce delivers by where its caller is: inside an open Astryx modal, into a persistent live region inside that dialog; outside, to the page body as before. Component and app code inside a modal is heard with no change to it; a message sent just before its modal closes ("Saved", then close) is not lost.

Problem and solution fit

  • Why does it happen? useAnnounce() wrote to one polite and one assertive region on document.body. A showModal() dialog makes the rest of the document inert and hidden from the accessibility tree, those regions included.
  • Why does it hurt? The owner believes it announced; the person inside the modal hears silence, with no visible sign anything went wrong. Busy controls had a private in-dialog region, so a form in a Dialog was half-audible: "Loading", but never "3 results" or "Saved".
  • Why does it matter? spec:AST-086 FR14 (current) decides it: a message its owner would otherwise send through the shared channel follows the nearest open native modal enclosing its owner, into that modal's containers when Astryx renders it, through the public useAnnounce with no separate path.

Expected behavior and authority

spec:AST-086 FR14 and DEC-3 (current). The route follows where the owner is when it announces; the public useAnnounce takes it; a message not yet exposed when its modal closes is delivered through the channel exposed after the close; FR14's containers are direct children of the dialog, created empty (FR3), status and alert (FR5), joining messages written before the previous is exposed and saying an identical same-frame repeat once (FR9); FR6 stays the owner's; a busy owner's FR8 region inside a modal is FR14's polite container, and a busy owner inside a native modal an app renders without Astryx still announces inside it.

Smallest restoration

  • A closed dialog at rest holds no observer. The provider's presentation watch (a MutationObserver on open, inert, aria-hidden) is a session: it starts when the owner intends the dialog open (or finds it already open), follows the exit, and ends when the dialog has really closed or the provider unmounts. A preloaded sheet that has never opened — Popover's presentation="bottom-sheet" mounts one at rest — and a Drawer never shown hold none (architecture:react-component-runtime INV5; main's Popover test "a closed sheet holds no observers or viewport listeners until it opens" passes). Test: no presentation watch for a closed BottomSheet; one from the open intent; disconnected after the real close.
  • Every native-modal presenter on main renders the provider, by composition: Dialog (AlertDialog, CommandPalette), BottomSheet (standalone, switcher steps, DropdownMenu's MenuBottomSheet, Popover's lazy sheet presentation), Drawer, Lightbox, MobileNav — the five showModal() call sites in Core. Tests: a caller inside Popover's presentation="bottom-sheet" content and inside a DropdownMenu presentation="bottom-sheet" footer is announced inside that sheet.
  • Three modules. useAnnounce.ts (a modification of main's file, not a rename) keeps the shared channel, the modal context, and the in-dialog region writer the busy-announcement hook needs for a native modal an app renders without Astryx; it exposes one hook, installModalRoute. modalAnnounce.ts holds the FR14 route — exposure, top-layer order, hand-off and hold, the covered rule, deferral — and installs itself into useAnnounce as it loads. ModalAnnounceProvider.tsx renders the context and drives the route's presentation bookkeeping. Only the modals import the last two, so a component that calls useAnnounce and renders no modal carries neither: a context value is non-null only where a provider rendered, which loaded the route first. Measured: useAnnounce 1,762 → 3,947 eager bytes for a non-modal caller (the writer and context), modalAnnounce + provider 3,138 B only in bundles that contain a modal.
  • useAnnounce routes by context. Each Astryx component that owns a native <dialog> — Dialog, BottomSheet (both render paths), Drawer, Lightbox; CommandPalette through Dialog — renders an internal ModalAnnounceProvider dialogRef={…} around its content. useAnnounce() reads the nearest provider chain and, per call, walks it to the first dialog that is open and matches :modal; it writes there, else to the shared channel. A scrimless sheet or drawer (open, not modal) is passed over, so a caller inside one nested in a modal reaches the modal. The public signature is unchanged.
  • One container pair per dialog. data-astryx-modal-live-region="polite" (role="status") and "assertive" (role="alert"), direct children, inserted empty, filled a frame later, cleared after two seconds; messages written before the previous is exposed are joined in order (a message counts as exposed one frame after its words were written), an identical message already waiting is not repeated, an empty message withdraws pending and shown text.
  • Delivery follows exposure. A dialog is exposed when it is open as a modal and neither inert nor aria-hidden="true" — a modal BottomSheet, standalone or a switcher step, stays open and modal but inert + aria-hidden through its exit motion, and words written there reach nobody. The provider watches the dialog's open, inert and aria-hidden attributes; whenever the dialog stops being exposed, the words its containers hold that assistive technology has not had yet are handed, joined, to the channel exposed then: the modal on top when Astryx renders it and it is exposed — a switcher's entering step, the Dialog beneath — else the shared channel once no native modal is open. While the leaving dialog is still an open modal through its exit and no other modal is exposed, nothing is exposed (Chromium ignores the page-body region behind an active modal dialog), so the words are held and written to the page body at the dialog's real close. The same hand-off runs from the provider's cleanup, so a Dialog unmounted while open ({isOpen && <Dialog isOpen …>}, no close event) loses nothing, and a message that arrives at a dialog already not exposed is passed on at once. Words count as exposed only after the frame that writes them ran while the dialog was exposed. A dialog closed and shown again in one task is exposed again by the time the change is seen, so its words stay (a hand-over, not a close). "On top" is found among every provider's dialog, not only ancestors: each provider notes when its dialog's open attribute is set (showModal() sets it as it is called, so the record order is the top-layer order), with the document's exposed modals as the fallback for a dialog presented in the same task.
  • Top-layer order is kept by open alone. A dialog turning inert/aria-hidden for its exit keeps its place in the order, so after Back in a switcher the step the person is on is the top exposed modal and its messages and busy starts are heard; the leaving step never moves up the order. "Top" means the last-presented dialog that is open, modal and exposed.
  • An exiting modal above the next candidate covers it. When the words' own dialog, or any dialog above the candidate beneath it, is still an open modal through its exit, nothing is exposed yet: the words are held and delivered at that modal's real close to whatever is exposed then — a sheet closing over a Dialog delivers "Saved" to the Dialog (which the earlier version wrote while the sheet still covered it, so the Dialog cleared it as covered).
  • MobileNav renders the provider too (every showModal() caller in core: Dialog, BottomSheet standalone and switcher paths, Drawer, Lightbox, MobileNav; AlertDialog composes Dialog).
  • An empty message withdraws held words too. announce('') from inside a dialog clears its region for that urgency and drops the words of that urgency waiting for the dialog's real close, so a status taken back while the sheet exits ("3 results", then the query is cleared) is not delivered to the page body after the close.
  • A message announced in the commit that opens its modal is routed a frame later. A Dialog presents from its own passive Effect, which runs after its content's; a field error announced from a mount Effect inside {isOpen && <Dialog isOpen>} would otherwise be routed before showModal() and land on the page body behind the modal. Each provider receives its owner's intent to present as a modal (Dialog, Lightbox, MobileNav pass isOpen; Drawer and the standalone BottomSheet pass isOpen && hasScrim, a switcher step isPresented && hasScrim — a scrimless Drawer is shown with showPopover() and never becomes open, and a scrimless sheet is not an FR14 modal, so callers inside either route as the page does) and keeps it on the stable scope object through a layout effect, so the context value and useAnnounce's function keep their identity; only when a provider nearer than the first open modal in the chain is meant to be open and its dialog is not open yet does useAnnounce defer the message to the next frame and resolve the route then — so a Dialog or modal BottomSheet opening inside an already-open Dialog receives its content's mount-time message itself, rather than the outer Dialog it is about to cover. A caller inside a modal meant to be closed (its content stays mounted) is not deferred: it takes the shared channel at once, joined with the frame's other messages per FR9 — a page message announced beside it is never dropped; an empty message in the same commit withdraws the deferred messages of its urgency and clears the resolved channel (FR9), so a status taken back before the Dialog presents is never spoken.
  • The shared channel is exactly main's (fix(useAnnounce): messages announced in the same frame are joined, an identical repeat once #8244): messages announced in the same frame are joined in order through one frame per region, an identical repeat is kept once, each announcer's share can be withdrawn (takeJoinedAnnouncement / withdrawPendingAnnouncement, which useBusyAnnouncement uses to drop a busy start overtaken by its outcome). FR14 adds only the in-modal route; main's useAnnounce.test.tsx, useBusyAnnouncement.test.tsx, useBusyAnnouncement.adopters.test.tsx and BusyOwnerAnnouncement.test.tsx pass unchanged except for the modal region's attribute name.
  • A busy start inside a modal is withdrawn by its outcome too. announceInModal keeps a holder count per pending text; useBusyAnnouncement's settle function calls withdrawFromModalPending(dialog, message), which removes the busy start's own share while it is still unexposed (or held for the dialog's close) and leaves the words when another owner wrote the same ones — the in-modal mirror of the shared channel's withdrawPendingAnnouncement, so "Loading" never precedes "3 results" inside a modal (FR8).
  • Consumer doc (useAnnounce.doc.mjs, en and dense): the hook delivers where its caller is — the page body, or a region inside the open Astryx modal the calling component is rendered in; a message sent as the modal is dismissed reaches what is exposed afterwards; a call from outside the modal, even by the component that renders it, uses the page body. Worded without "close", so astryx search close keeps matching the components about closing and the experimental-isolation CLI goldens hold unchanged (34 tests pass).
  • Covered modals clear. A message a modal receives while a later Astryx modal is on top is not written — the owner is not operable until the upper one closes, and FR14 says it is not heard — and its region is cleared, so the modal holds nothing from that time when it is on top again.
  • Busy starts on the same containers. useBusyAnnouncement writes through the same announceInModal; it keeps locating the modal from the control's own element (closest('dialog') walked to the nearest open :modal), so a busy start inside a native modal an app renders without Astryx is heard inside it (FR14's last sentence). Its old data-astryx-modal-busy-region marker is gone; two tests follow the attribute. useAnnounceRenderedText needs no change: it announces through useAnnounce.
  • The earlier opt-in hook (useModalAwareAnnounce) does not exist; there is no second route.

Evidence

  • Before (main): inside an open Dialog, useAnnounce('Saved') wrote to body > [data-astryx-live-region="polite"]; Chromium's accessibility tree omits that node while the modal is open. A busy control inside the Dialog announced "Loading" through its own region.
  • After: useAnnounce.modal.test.tsx (39 tests): a caller outside any modal uses the shared channel; a caller inside an open Dialog, modal BottomSheet, modal Drawer, and Lightbox reaches that dialog's status container (inserted empty, filled next frame) with the shared channel empty; a caller in a scrimless BottomSheet with no modal above keeps the shared channel, and one in a scrimless sheet nested in a Dialog reaches the Dialog; a modal over a modal routes each caller to its own dialog; app code (a form's submit) inside a Dialog is heard; two different messages join ("Added Design Removed Eng"), the same handler twice adds nothing, a message in the write frame joins, one a frame later starts fresh, assertive has its own alert container, an empty message withdraws; "Saved" followed by closing the Dialog lands on the shared channel; "3 results" followed by closing an upper Dialog lands in the lower Dialog's container; a waiting message stays in a dialog closed and shown again in one task, and in one re-presented from a later layout effect (both failed before: the words went to the page body); a leaving step's words, unexposed when it closes in the update that opens a sibling step, land in the entering step's container; "Saved" sent as a modal BottomSheet closes (its dialog still open, inert, aria-hidden) is held — the sheet's region and the shared channel both empty — and reaches the shared channel at the dialog's real close; "Overview saved" sent as a BottomSheetSwitcher step hands over lands in the entering step's container (the leaving step open and inert); "Saved" sent just before an open Dialog is unmounted reaches the shared channel; words a covered modal receives are not written, and it holds nothing when the upper modal closes within the clear delay while a message after that is heard; a busy start inside a native modal an app renders without Astryx, opened inside a Dialog, lands in the app modal's region and not the Dialog's; a caller inside an open MobileNav reaches its dialog; an assertive field error announced from a mount Effect inside a conditionally mounted Dialog lands in the Dialog's alert container, not the page body (failed before); the same error inside a Dialog, and inside a modal BottomSheet, that opens within an already-open Dialog lands in the inner modal's container and not the outer's (both failed before: routed to the outer Dialog, which the inner one then covered); inside a scrimless Drawer, with a showPopover() stub that leaves open unset, a mount-time message joins a page message from the same task ("Page loaded Inside drawer", failed before: "Inside drawer") and a busy start settled by its outcome leaves "3 results" alone (failed before: "Loading 3 results"); "Page loaded" announced on mount beside a closed Dialog whose mounted content announces "Inside closed" leaves the shared channel reading "Page loaded Inside closed" with no modal region created (failed before: "Page loaded" was dropped); the same error followed by an empty message in that commit reaches no channel (failed before); a message and an empty message in one handler inside an already-open Dialog leave the region clear; a busy start written inside a Dialog and settled by its outcome before it was exposed leaves "3 results" alone (failed before: "Loading 3 results"); one of two owners settling leaves "Loading" for the other; words held for a sheet's close and then withdrawn by an empty message while the sheet exits reach no channel after the close (failed before); after Back in a BottomSheetSwitcher, a message from the step the person is on lands in that step while the leaving step is still open and inert (failed before: dropped as covered); "Saved" sent as a modal BottomSheet over a Dialog closes is held while the sheet still covers the Dialog and lands in the Dialog's region at the real close (failed before: cleared as covered); a component that renders a Dialog but announces from above it takes the shared channel (the route of where the call is), and routing its own messages in means rendering the announcing part inside the provider. BusyOwnerAnnouncement.test.tsx (every busy owner): the busy start lands in the dialog's polite container (data-astryx-modal-live-region="polite"), inserted empty and filled a frame later, when the owner is inside an open modal, and when it is already busy as its dialog opens. Real Chromium (Playwright, iPhone profile, this head's Storybook, the Tokenizer sheet story — a modal BottomSheet whose content calls the public useAnnounce): the sheet dialog matches :modal; a row tap writes "Added Tag 03" to dialog > [data-astryx-modal-live-region="polite"] and nothing to the page body; Chromium's accessibility tree lists that container as role: status, live: polite, not ignored, inside the dialog; a typed query writes "111 results" and a miss "0 results" there; opening with a synchronous source creates no container and announces nothing (no busy start). A row toggled in the same task as Done: the words are written to the dialog's container on the next frame and are exposed there for the ~300 ms the sheet takes to animate out before dialog.close(), so nothing is unexposed at the close and nothing needs handing off; the synchronous-close hand-off (Dialog) is proven by the unit tests above. Switcher flow (the BottomSheetSwitcher "Hug content" story, whose first step has a Continue control that calls useAnnounce('Overview saved') from inside the step as it hands over): the leaving step turns inert + aria-hidden in the commit that presents the entering step, so the words move to the entering step's container, which Chromium's accessibility tree lists as role: status, not ignored; nothing is left in the leaving step and nothing reaches the page body. Switcher Back ("Hug content": Continue, Back, then "Save draft" from step 1 while step 2 fades out open+inert): "Draft saved" is in step 1's container, AX status/not ignored, nothing on the body. Sheet over a Dialog (new BottomSheet story "Sheet over a Dialog": Done announces "Saved" and closes): while the sheet fades, nothing in the sheet, the Dialog or the body; after the real close "Saved" is in the Dialog's container, AX status/not ignored, body empty. Modal BottomSheet close (Tokenizer sheet story): a row toggled with Done in the same task leaves the sheet's own container clear and the page body empty while the sheet is still an open, inert modal (Chromium's accessibility tree ignores the page-body region then: activeModalDialog); at the real close the words are on the page-body channel, which the tree then lists as status, not ignored.
  • Unchanged path: useAnnounce.test.tsx (the shared channel), BusyOwnerAnnouncement.test.tsx, DateInputTouch.test.tsx, Dialog, BottomSheet, Drawer, Lightbox, CommandPalette, Selector, MultiSelector, Toast, Switch and Chat suites pass; outside a modal every message goes exactly where it did.

Size (architecture:component-style-authoring INV6)

Each changed component's subpath import, bundled alone from packages/core/dist with esbuild (minified ESM, code splitting; react, react-dom, @stylexjs/stylex external), gzip level 9, base (main 216ad5788) against this head. CSS: no StyleX rule changes in this pull request, so each component's CSS delta is 0. Nothing here runs only on a coarse pointer.

Component (import) main, eager gz this PR, eager gz delta
Dialog 46.5 KB 48.6 KB +2.1 KB
BottomSheet 23.5 KB 25.6 KB +2.1 KB
Drawer 46.8 KB 49.2 KB +2.4 KB
Lightbox 42.1 KB 43.7 KB +1.6 KB
MobileNav 41.3 KB 43.4 KB +2.1 KB
AlertDialog (composes Dialog) 46.8 KB 48.9 KB +2.1 KB
Tokenizer (a useAnnounce caller, no modal) 56.2 KB 56.7 KB +0.5 KB
Selector (a useAnnounce caller; its sheet is a modal) 70.6 KB 71.0 KB +0.4 KB
TextInput (a useAnnounce caller, no modal) 45.5 KB 45.9 KB +0.4 KB

A caller that renders no modal carries only useAnnounce's writer core and context (≈0.4–0.5 KB gz); a modal carries the route and the provider as well (≈1.6–2.4 KB). Lazy chunks are unchanged. Base: main 216ad5788.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split: the Tokenizer sheet that is the first component to rely on this inside its own modal is its own pull request.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label: useAnnounce's signature is unchanged; ModalAnnounceProvider (its own module) and the delivery helpers are internal, imported by path and absent from the hooks barrel and the package entry.
  • Public text and artifacts contain no internal Meta context.

Not in this change

spec:AST-086 Current-state impact names owners that announce about their own modal from above it — CommandPalette's result counts and empty results, and Lightbox's position and zoom messages — as "a change in each component beside useAnnounce": their announcing code sits in the component that renders the Dialog, above the provider, so routing by where the call is does not reach the modal (the last two tests above show both the behavior and the fix pattern). Selector and MultiSelector in their presentation="bottom-sheet" are the same shape: both call useAnnounce in the component that renders their SelectorBottomSheet (Selector.tsx, MultiSelector.tsx), above the sheet's provider, so their selection and result-count messages ("1 of 4 selected") still reach only the page-body region while the sheet is open. Moving each component's announcing code inside its modal is each component's own follow-up; the changeset lists all four.

Testing

  • pnpm exec vitest run packages/core/src/hooks packages/core/src/__tests__ packages/core/src/Dialog packages/core/src/BottomSheet packages/core/src/Drawer packages/core/src/Lightbox packages/core/src/CommandPalette packages/core/src/Selector packages/core/src/MultiSelector packages/core/src/DateInput packages/core/src/TimeInput packages/core/src/Toast packages/core/src/Switch packages/core/src/Chat — 110 files, 2786 tests passed.
  • pnpm -F @astryxdesign/core typecheck — clean; strict lint on the touched files — 0 errors; Prettier — clean; pnpm check:repo — exit 0; pnpm sync:exports:check — up to date; pnpm check:changesets — valid.
  • git diff --check — clean. Based on current main.
  • Real-browser evidence as under Evidence (Tokenizer sheet 7/7, switcher forward 4/4, switcher Back 3/3, sheet over Dialog 3/3). Based on current main (c98456e21); useBusyAnnouncement keeps main's new returned "drop a pending busy start" function.

@vercel

vercel Bot commented Oct 23, 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 26, 2026 6:25pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 26, 2026, 7:06 PM UTC

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

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Previews ready: Storybook and Sandbox were built with this PR's docsite. CI evidence will be added when available.

View Storybook for this PR · View Sandbox for this PR

@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.

This needs an engineering owner's decision before it can be approved: spec:AST-086 settles delivery inside a native modal only for busy starts ("Other delivery inside native modals is outside this record"), and FR10 covers owners that spec:AST-003 rehosts above modals, so routing every other message from a control inside a modal through the dialog's own polite and assertive regions has no governing spec yet. Any engineering owner, you included, can record that decision by naming or updating the record that states this route, including whether FR3, FR5, FR6 and FR9's same-frame joining apply to those regions, and commenting /approve-spec <full head SHA> on this pull request. Two fixes are needed either way:

  • Inside a modal, announce('') skips the dialog's region, so a withdrawn message still lands or lingers there; clear the dialog's pending write and text too, and add a test.
  • The [fix] Changeset says announcements from inside a modal now reach assistive technology, but no component uses the new route yet; describe the shared internal route and the data-astryx-modal-busy-region rename instead.

[Automated review]

return useCallback(
(message: string, politeness: AnnouncePoliteness = 'polite') => {
const dialog = enclosingModalDialog(elementRef.current);
if (dialog != null && message) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

An empty message inside a modal skips this branch, so announce('') only clears the page-body region: the dialog region's pending write still lands and its text stays until the two-second clear. useAnnounce('') cancels its pending write, and callers rely on that to withdraw stale status (CommandPalette does on close). Suggest clearing the dialog's region here too (cancel its pending frame and timer, then empty it) and adding a test for announce('') inside a modal.

Comment thread .changeset/modal-aware-announce.md Outdated
'@astryxdesign/core': patch
---

[fix] Status announcements made from inside an open native modal dialog now reach assistive technology. The shared page-body announcement channel is not exposed while a native modal is open, so components that announce from inside one — a control's busy start, a selection change — can route through a persistent polite or assertive live region inside that dialog instead. Busy controls already did this for their busy start; the route is now one internal hook they share, and its in-dialog region carries a new internal attribute name. No public API changes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No component calls useModalAwareAnnounce yet, so after this change announcements from inside a modal reach assistive technology exactly as before: busy starts already did, and others, such as CommandPalette's result count, still go to the page-body channel. Suggest describing what ships (the shared internal route, and the busy region's rename from data-astryx-modal-busy-region to data-astryx-modal-live-region="polite") and leaving the delivery claim to the pull request that adopts the route.

@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.

This needs an engineering owner's decision before it can be approved: spec:AST-086 settles delivery inside a native modal only for busy starts ("Other delivery inside native modals is outside this record") and FR10 covers owners spec:AST-003 rehosts above modals, so the in-dialog route this hook gives every other message has no governing spec yet, and the component:Tokenizer DEC-3 and AR6 the description relies on are still proposed in #7976. An engineering owner (you may do this yourself, as one) can record the decision by naming or updating the record that states this route, including whether FR3, FR5, FR6 and FR9's same-frame joining apply to the per-dialog regions, and commenting /approve-spec <full head SHA> on this pull request; the hook's FR10 citations should then point at that record. The earlier empty-message and Changeset points are resolved, and the busy-start refactor keeps the same in-dialog region in a real modal.

[Automated review]

Comment on lines +16 to +19
* While a native modal dialog is open, the shared channel on the page body is
* not exposed to assistive technology (`spec:AST-086` Platform support), so an
* owner rendered inside the modal announces through its own persistent
* containers in that dialog (FR10). The regions are inserted empty and filled

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-086 FR10 covers owners spec:AST-003 rehosts above native modals, not a control rendered inside one, and Platform support leaves other in-modal delivery "outside this record". Once an engineering owner names or approves the record for this route, cite it here, at line 150, and in the test name at useModalAwareAnnounce.test.tsx:100.

@imdreamrunner
imdreamrunner force-pushed the feat/modal-aware-announce branch from 14cc6d3 to 0ec909f Compare October 23, 2026 23:33
@imdreamrunner
imdreamrunner changed the base branch from main to spec/ast-086-in-modal-owners October 23, 2026 23:33

@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.

This needs an engineering owner's decision before it can be approved: spec:AST-086 on main still settles in-modal delivery only for busy starts ("Other delivery inside native modals is outside this record"), and the FR14 this hook implements is proposed in #8020 without an engineering owner's approval. An engineering owner (you may do this yourself, as one) can record the decision by approving and landing #8020 or by commenting /approve-spec <full head SHA> on this pull request, including whether FR14's joining of messages written before the previous one is exposed applies inside the dialog. Either way, two fixes are needed:

  • announceInModal cancels the pending write instead of joining it (useModalAwareAnnounce.ts:87-91), so the first of two messages from one handler, or a selection message followed by a busy start, is never exposed, although the header says the regions join as FR9 says; join them and add a same-frame test, or drop the claim if joining is ruled out.
  • enclosingModalDialog checks only the nearest <dialog>, so an owner in a non-modal sheet or drawer nested inside an open modal announces to the page-body channel that the modal hides; walk up to the nearest open :modal ancestor.

[Automated review]

Comment on lines +87 to +91
const previous = pendingByRegion.get(target);
if (previous != null) {
cancelAnimationFrame(previous.frame);
clearTimeout(previous.timer);
}

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 cancels the write still pending in the container, so the earlier of two messages written before the first is exposed is never heard: two announce calls in one handler expose only the second, and a busy start a frame after a selection message (they share this polite container) empties it in the same frame the selection message was written. The header (lines 19-21) and FR14 say these messages are joined in order, as FR9 says of the shared channel. Suggest keeping the container's messages that are not yet exposed, counting text written earlier in the same frame, and writing them joined, with a hook test for both cases.

Comment on lines +142 to +143
const dialog = element?.closest('dialog') ?? null;
return dialog?.open && dialog.matches(':modal') ? dialog : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only the nearest <dialog> is checked, so an owner inside a non-modal dialog (a BottomSheet or Drawer without a scrim, which render their <dialog> in place) that sits inside an open modal Dialog is routed to the page-body channel the modal hides. Suggest walking up to the nearest enclosing dialog that is open and matches :modal, and testing the nested case.

@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.

This needs an engineering owner's decision before it can be approved: spec:AST-086 on main still gives in-modal delivery to busy starts only ("Other delivery inside native modals is outside this record"), and the FR14 this hook implements is proposed in #8020 without an engineering owner's approval. An engineering owner (you may do this yourself, as one) can record that decision by approving and landing #8020 or by commenting /approve-spec <full head SHA> on this pull request. The earlier joining and nested-dialog points are fixed. One change is needed either way: the [chore] Changeset says no announced text changes, but busy starts from a control in a non-modal sheet or drawer inside an open modal are now heard and busy starts written together in one modal are joined, so make it a [fix] that names both, as spec:AST-017 FR7 asks of corrections, and update the description's "exactly as before".

[Automated review]

Comment thread .changeset/modal-aware-announce.md Outdated
'@astryxdesign/core': patch
---

[chore] Busy controls inside an open native modal dialog announce their busy start through a live region the hook that owns it now shares with other in-dialog announcements. The region's internal marker changes from `data-astryx-modal-busy-region` to `data-astryx-modal-live-region="polite"`; no public API and no announced text change.

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 head changes what people hear: a busy control in a non-modal sheet or drawer nested inside an open modal now announces its busy start in that modal (on the base it went to the page-body region, which the modal hides), and busy starts written to one modal's region before the first is exposed are joined instead of the last one replacing the others. spec:AST-017 FR7 declares a correction as [fix], and the tag and body are the whole declaration, so suggest making this a [fix] entry that names both changes instead of "no announced text change".

@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.

This needs an engineering owner's decision before it can be approved: spec:AST-086 on main still gives in-modal delivery to busy starts only ("Other delivery inside native modals is outside this record"), and the FR14 this hook implements (one polite and one assertive container per open modal dialog, shared with busy starts, joining words not yet exposed and emptied by an empty message) is proposed in #8020 without an engineering owner's approval. An engineering owner (you may do this yourself, as one) can record that decision by approving and landing #8020 or by commenting /approve-spec <full head SHA> on this pull request. The [fix] Changeset now matches what ships and the busy-start changes hold in a real browser, so no code change is needed once FR14 is decided. Separately, the description's User impact line still says busy starts are unchanged, which the Changeset now contradicts.

[Automated review]

@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.

This needs an engineering owner's decision before it can be approved: spec:AST-086 on main still gives in-modal delivery to busy starts only ("Other delivery inside native modals is outside this record"), and the FR14 that this hook and useAnnounceRenderedText's new route implement (one polite and one assertive container per open modal dialog, shared with busy starts, joining words not yet exposed and emptied by an empty message) is proposed in #8020 without an engineering owner's approval. Any engineering owner (you may do this yourself, as one) can record that decision by approving and landing #8020 or by commenting /approve-spec <full head SHA> on this pull request. The busy-start changes and the new route behave as described in a real browser, and the tests fail when the nested-dialog walk, the joining, the route argument or the empty-message clearing is removed, so no code change is needed once FR14 is decided. Separately, the description's User impact line still says busy starts are unchanged, which the Changeset contradicts.

[Automated review]

@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.

useAnnounce now delivers messages sent from inside an open Astryx modal through live regions inside that dialog, as spec:AST-086 FR14 describes, and I found nothing that needs to change before an engineering owner reviews it. The new hook tests pass, and in Chromium the in-dialog region is exposed to the accessibility tree while the page-body region the same message used to reach is ignored behind the modal.

  • Optional: useAnnounce.tsx no longer contains JSX now that the provider lives in ModalAnnounceProvider.tsx, so keeping the file name useAnnounce.ts would show the hook as a 634-line modification instead of a full delete and re-add.

[Automated review]

@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.

useAnnounce now delivers messages sent from inside an open Astryx modal through live regions inside that dialog, as spec:AST-086 FR14 describes, and I found nothing that needs to change before an engineering owner reviews it. In Chromium the in-dialog region is exposed to the accessibility tree while the page-body region the same message used to reach is ignored behind the modal, and the hook tests cover the close, sheet-exit and covered-modal hand-offs. CommandPalette's, Lightbox's, and bottom-sheet Selector's and MultiSelector's own messages still need the follow-ups the changeset lists.

[Automated review]

…loses its caller

Implements spec:AST-086 FR14. The Astryx components that own a native
<dialog> — Dialog, BottomSheet, Drawer, Lightbox; CommandPalette through
Dialog — render an internal ModalAnnounceProvider around their content, and
useAnnounce resolves each call against the nearest provider whose dialog is
open as a modal: the message is written to a persistent polite or assertive
container inside that dialog, which assistive technology hears while the
page-body channel is hidden behind the modal; a caller outside any modal,
or inside a non-modal sheet with no modal above it, keeps the shared
channel. App code and components inside a modal are heard without change.

The containers are direct children of the dialog, created empty on first
use (FR3), carry status and alert (FR5), join messages written before the
previous is exposed and say an identical same-frame repeat once (FR9), and
clear shortly after each announcement. When a modal closes with a message
not yet exposed, the provider hands it to the channel exposed after the
close: the modal then on top, or the shared channel.

useBusyAnnouncement writes busy starts to the same containers; it keeps
locating the modal from the control's own element, so a busy start inside
a native modal an app renders without Astryx is heard inside it too (FR14,
last sentence). Its former data-astryx-modal-busy-region marker becomes
data-astryx-modal-live-region="polite". useAnnounceRenderedText needs no
change: it announces through useAnnounce.

@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.

useAnnounce now delivers messages sent from inside an open Astryx modal through live regions inside that dialog, as spec:AST-086 FR14 describes, and nothing needs to change before an engineering owner reviews it. In Chromium the in-dialog regions are exposed in the accessibility tree where the page-body regions the same messages used to reach are ignored behind the modal, and the hook tests cover the close, sheet-exit and covered-modal hand-offs. CommandPalette's, Lightbox's, and bottom-sheet Selector's and MultiSelector's own messages still need the follow-ups the changeset lists.

[Automated review]

@imdreamrunner
imdreamrunner enabled auto-merge (squash) October 26, 2026 20:45
@imdreamrunner
imdreamrunner merged commit c22cbe2 into main Oct 26, 2026
69 checks passed
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
Main's meta#8009 wraps MobileNav's drawer content in ModalAnnounceProvider; the
one conflict is the drawer panel's opening tag, resolved as main's wrapper
with this branch's `ref={drawerRef}` on the panel. Nothing else conflicts.
cixzhang added a commit that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…e) into uikit/c24-pager

Lightbox.tsx: main's meta#8009 (c22cbe2) wraps the content in
ModalAnnounceProvider and re-indents it; this branch changed lines inside
that block. Resolved as this branch's file plus meta#8009's import and wrapper,
formatted by prettier (which only re-wraps): apart from that formatting, the
result is this branch's file with meta#8009's three lines. Files this branch
never changed that conflicted only through a second merge base take the
incoming side.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…nto uikit/c24-dismiss

Lightbox.tsx: main's meta#8009 (c22cbe2) wraps the content in
ModalAnnounceProvider and re-indents it; this branch changed lines inside
that block. Resolved as this branch's file plus meta#8009's import and wrapper,
formatted by prettier (which only re-wraps): apart from that formatting, the
result is this branch's file with meta#8009's three lines. Files this branch
never changed that conflicted only through a second merge base take the
incoming side.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…nto uikit/c24-chrome-toggle

Lightbox.tsx: main's meta#8009 (c22cbe2) wraps the content in
ModalAnnounceProvider and re-indents it; this branch changed lines inside
that block. Resolved as this branch's file plus meta#8009's import and wrapper,
formatted by prettier (which only re-wraps): apart from that formatting, the
result is this branch's file with meta#8009's three lines. Files this branch
never changed that conflicted only through a second merge base take the
incoming side.
@github-actions
github-actions Bot deleted the feat/modal-aware-announce branch October 27, 2026 06:22

This branch was successfully deployed

1 active deployment
Preview — a4cf1b7b Deployed Oct 26, 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. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant