Repository navigation
feat(Popover): isModal decides whether the popover holds Tab (AST-116 FR12–FR16) - #11156
nynexman4464 wants to merge 1 commit into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
Approval waits on an engineering owner's spec decision: current component:Popover DEC-9 and AR3 make every Popover hold Tab, modal or not, and this pull request changes isModal={false} on the strength of draft spec:AST-113 DEC-5 and FR12–FR16, including a non-modal sheet in place of DEC-8's modal sheet. The owner also needs to settle whether this change to released behavior ships as a patch [feat] (an accessibility fix under spec:AST-017 FR53) or as [breaking] in a declared minor (FR7, FR25). An engineering owner records the decision by commenting /approve-spec <full head SHA>; as an engineering owner, the author may do so.
Whatever the decision, these need fixing:
- With the default
hasLightDismiss, pressing Escape on another page control still closes a non-modal popover in Chromium and Firefox. When the shared stack skips the layer, the browser's ownpopover="auto"close request fires (usePopover.tsx:634,:665), although the docs, the changeset andfamily:overlay-dismissalFR3 say the popover stays open. - The docs and the changeset say Tab moves "from the anchor (else the trigger) into the popover and on to what follows the anchor". Without a separate
anchor, Tab from the trigger skips the open popover in Chromium and Firefox, and in Chromium Tab from its last control returns to the trigger (Popover.doc.mjs:135,Popover.tsx:354-357,usePopover.tsx:309-311). - The
presentationdocs this pull request rewrites still say the sheet always closes on a tap outside. A non-modal sheet (hasScrim={false},Popover.tsx:1147) has no backdrop and ignores that tap (Popover.doc.mjs:176,Popover.tsx:421-422,Popover.tsx:1133-1137). component:PopoverVG1 asks for real-browser evidence when focus or light-dismiss behavior changes, and the jsdom tests cannot see either failure above. Please add non-modal cases topackages/core/src/Popover/__tests__/PopoverKeyboard.interaction.browser.spec.ts: Tab from the trigger and from a separate anchor, and Escape on another control under the defaulthasLightDismiss.
| anchorOptionRef.current == null ? null : boundAnchorRef.current, | ||
| layerId: layer.id, | ||
| isContaining: isModal, | ||
| isEscapeInScope: isModal ? undefined : isFocusInNonModalScope, |
There was a problem hiding this comment.
With the default hasLightDismiss, this popover is still a native popover="auto" (lightDismiss at line 634). When isEscapeInScope returns false, the shared stack skips the layer and does not preventDefault the key, so the browser's own Escape close request hides the popover anyway. In Chromium and Firefox, Escape pressed on another page control closes a non-modal popover unless the caller also passes hasLightDismiss={false}. One fix: keep a non-modal popover off native light dismiss (lightDismiss: hasLightDismiss && !watchesOutsidePress && isModal) and send its outside presses through the useOutsidePress watch, as onOutsidePress already does.
| type: 'boolean', | ||
| description: | ||
| 'Whether a dialog-style popover sets aria-modal. Only applies when role is dialog.', | ||
| 'Whether the popover is modal. true holds Tab inside the open popover and marks a dialog popover aria-modal. false holds nothing: Tab moves from the anchor (else the trigger) into the popover and on to what follows the anchor, and Escape closes it only while focus is inside it or on its anchor or trigger. Use false for a coachmark or a tour step. As a sheet, false presents a sheet with no scrim that leaves the page interactive.', |
There was a problem hiding this comment.
"Tab moves from the anchor (else the trigger) into the popover" holds only with a separate anchor. With no anchor, Tab from the trigger skips the open popover in Chromium and Firefox, and in Chromium Tab from the popover's last control goes back to the trigger. Make the trigger case match, or narrow this sentence (and the JSDoc and the changeset) to what ships.
| type: "'popover' | 'bottom-sheet' | 'adaptive'", | ||
| description: | ||
| "The surface that holds the content. 'popover' anchors it to the trigger, 'bottom-sheet' presents it in a modal BottomSheet labeled by label, and 'adaptive' uses the BottomSheet on a compact touch screen (a coarse pointer on a screen the theme counts as compact: narrower than its md width point or shorter than 500 px, so a phone in either orientation) and the anchored popover elsewhere. In a sheet, placement, alignment, width, role, isModal and hasAutoFocus do not apply, and xstyle, className and style style the sheet's panel; the sheet always light-dismisses (a tap outside it or a swipe down closes it, whatever hasLightDismiss says), Escape follows hasEscapeDismiss, and the fallback close stays hidden. The surface is chosen as it opens and kept until it closes.", | ||
| "The surface that holds the content. 'popover' anchors it to the trigger, 'bottom-sheet' presents it in a modal BottomSheet labeled by label, and 'adaptive' uses the BottomSheet on a compact touch screen (a coarse pointer on a screen the theme counts as compact: narrower than its md width point or shorter than 500 px, so a phone in either orientation) and the anchored popover elsewhere. In a sheet, placement, alignment, width, role and hasAutoFocus do not apply, and xstyle, className and style style the sheet's panel; the sheet always light-dismisses (a tap outside it or a swipe down closes it, whatever hasLightDismiss says), Escape follows hasEscapeDismiss, and the fallback close stays hidden. The surface is chosen as it opens and kept until it closes.", |
There was a problem hiding this comment.
For isModal={false} this is no longer true: the sheet is rendered with hasScrim={false} (Popover.tsx:1147), and BottomSheet dismisses on a tap only through its backdrop when it has a scrim, so a tap outside a non-modal sheet does nothing. Please say which exits a non-modal sheet has (swipe down, the close control, Escape).
| // Tab and Shift+Tab wrap inside the container while the trap is active and | ||
| // containing. |
There was a problem hiding this comment.
Non-blocking (INV29): this comment restates isActive && isContaining on the next line. Consider dropping it.
244aa61 to
18cc3b1
Compare
fd92d14 to
cc68bb8
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner's spec decision: current component:Popover DEC-9 and AR3 make every Popover hold Tab, modal or not, and DEC-8 makes its sheet modal, while this pull request follows draft spec:AST-113 DEC-5 and FR12–FR16 for isModal={false}. The same decision needs to settle the release tier under spec:AST-017: an accessibility fix shipped as a patch [fix] (FR53), or a [breaking] change in a declared minor (FR7, FR25). An engineering owner records it by commenting /approve-spec <full head SHA> on this pull request; as an engineering owner, the author may do so.
Whatever the decision, these need fixing:
- With the default
hasLightDismiss, pressing Escape on another page control still closes a non-modal popover in Chromium and Firefox, because the browser's ownpopover="auto"close request fires when the shared stack skips the layer (usePopover.tsx:634,:665), although the docs and the changeset say it stays open. - The docs and the changeset say Tab moves "from the anchor (else the trigger) into the popover and on to what follows the anchor", but with no separate
anchor, Tab from the trigger skips the open popover in Chromium and Firefox, and in Chromium Tab from its last control returns to the trigger (Popover.doc.mjs:135,Popover.tsx:355-357,usePopover.tsx:309-311). - The
presentationdocs still say every sheet is modal and closes on a tap outside, yet a non-modal sheet has no scrim and stays open when tapped above (Popover.doc.mjs:176,Popover.tsx:417-422,Popover.tsx:1133-1136). - Miss: a non-modal sheet closes on an Escape pressed on a page control behind it, because BottomSheet's own dismissal registration has no focus scope (
Popover.tsx:1147,BottomSheet.tsx:892-895), contrary to "Escape closes it only while focus is inside it or on its anchor or trigger". - Miss: the changeset tags this change to released behavior
[feat], whichspec:AST-017FR7 keeps for compatible capabilities, so it needs[fix]with its accessibility target (FR53) or[breaking]with a migration (FR7), whichever tier is decided. component:PopoverVG1 asks for real-browser evidence when focus or light-dismiss behavior changes, and the jsdom test atPopover.test.tsx:1960passes the Escape case that fails in real engines, so please add a real-browser spec underpackages/core/src/Popover/__tests__/for Escape elsewhere under the defaulthasLightDismiss, and for Tab from a trigger and from a separate anchor.
| onOpenChange={setSheetOpen} | ||
| // A non-modal popover stays non-modal as a sheet: no scrim, the page | ||
| // stays interactive, and focus is not held. | ||
| hasScrim={isModal !== false} |
There was a problem hiding this comment.
Miss (not raised in the earlier review of this same patch): this makes the sheet non-modal, but its Escape is not scoped to focus. BottomSheet joins the dismissal stack with its own presence check (BottomSheet.tsx:892-895), which ignores where focus is, so an Escape pressed on a page control behind an open non-modal sheet closes it (Chromium, 390 × 844). The isModal docs ("Escape closes it only while focus is inside it or on its anchor or trigger") and draft FR16 ("Escape while it holds focus") say otherwise. Scope the sheet's Escape the same way as the anchored popover's, or document the sheet's own rule.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] Popover `isModal` now decides whether the popover holds focus. `isModal` (the default) is unchanged: Tab and Shift+Tab cycle inside the open popover, and a dialog popover carries `aria-modal`. `isModal={false}` holds nothing. Tab moves from the anchor or trigger into the popover and on to what follows it, and Escape closes the popover only while focus is inside it or on its anchor or trigger. Use it for a coachmark or a tour step that lets the person reach the control it describes. On a compact touch screen, a non-modal popover presents a sheet with no scrim. **Behavior change:** before, `isModal={false}` removed only `aria-modal` and still kept Tab inside the popover. If a popover needs Tab held, leave `isModal` at its default. |
There was a problem hiding this comment.
Miss (not raised in the earlier review of this same patch): spec:AST-017 FR7 keeps [feat] for compatible capabilities and makes a behavior-correcting incompatibility [breaking] (a minor under 0.x); FR53 ships an accessibility fix as a patch [fix] that names its accessibility target. This entry changes released isModal={false} behavior, so whichever tier the owner rules, the tag needs to be [fix] with the target or [breaking] with a migration, not [feat].
18cc3b1 to
923cd1e
Compare
cc68bb8 to
abef85d
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner's spec decision: current component:Popover DEC-9 and AR3 make every Popover hold Tab, modal or not, and DEC-8 makes its phone sheet modal, while this pull request intentionally changes that for isModal={false} on the strength of spec:AST-113 DEC-5 and FR12–FR16, which are still draft. An engineering owner needs to approve or reject that direction and rule whether the change to released isModal={false} behavior ships as a patch accessibility fix (spec:AST-017 FR53) or as [breaking] in a declared minor (FR7, FR25), by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Whatever the ruling, these need changes before approval:
- With the default
hasLightDismiss, Escape pressed on another page control still closes a non-modal popover in Chromium and Firefox, because the nativepopover="auto"close request fires when the shared stack declines the press (usePopover.tsx:634,:665); keep a non-modal popover off native light dismiss and route its outside presses through the existing outside-press watch. - The docs, JSDoc and changeset say Tab moves "from the anchor (else the trigger) into the popover", but with no separate
anchorTab from the trigger skips the open popover in Chromium and Firefox (Popover.doc.mjs:135); make the trigger case match or narrow the sentence. - The
presentationdocs and the sheet comment still say every sheet is modal and closes on a tap outside, which a non-modal sheet with no scrim does not do (Popover.doc.mjs:176,Popover.tsx:417-422,:1133-1137); list the non-modal sheet's exits. - Miss: a non-modal sheet closes on an Escape pressed anywhere on the page, because BottomSheet's presence check has no focus scope (
Popover.tsx:1147,BottomSheet.tsx:892-895); scope it like the anchored popover or document the sheet's rule. - Miss: the changeset tags a changed released behavior
[feat], whichspec:AST-017FR7 reserves for compatible capabilities; retag it[fix]with its accessibility target or[breaking]with a migration, to match the owner's ruling. component:PopoverVG1 asks for real-browser evidence when focus or light-dismiss behavior changes, and the new jsdom test atPopover.test.tsx:1960passes a case that fails in real browsers; add a real-browser spec underpackages/core/src/Popover/__tests__/for Escape elsewhere and Tab from the trigger and from a separate anchor.
…al follows page order (spec:AST-113 FR12–FR16)
abef85d to
8872b22
Compare
923cd1e to
2ffbdf5
Compare
User need
A coachmark or tour step points at a control the person should be able to reach and use. Today every Popover holds Tab while open.
isModal={false}only removesaria-modal, so a non-modal popover still traps the keyboard inside itself, and the person cannot Tab to the control it describes.Problem and solution fit
isModalnow decides modality (AST-116 FR12–FR16, DEC-5).true(default, unchanged): Tab and Shift+Tab cycle inside the open popover, and a dialog popover carriesaria-modal.false:hasLightDismissandonOutsidePressstay separate.hasScrim={false}), so the adaptive sheet never turns it modal again.Current specification
spec:AST-116FR12–FR16, FR22 and DEC-5 (draft, #11112). DEC-5 supersedescomponent:PopoverDEC-9 forisModal: false.Public delta and caller burden
isModalkeeps its name, type and default.isModal={false}stops holding Tab. The changeset carries the release note.isModal: false:useFocusTrapInternalgainsisContainingandisEscapeInScope; the latter reaches the shared dismissal stack'sisPresent.Design and alternatives
Rejected (DEC-5): a separate focus-region input with a value that cycles the popover and its anchor. It would be a second name for modality, and a trap the platform does not have.
Evidence
aria-modal;showModal().aria-modal.modal-drive.mjsis in the bundle for a macOS run.uiproject: 824 files, 21,896 tests passed.Scope
Components: Popover, usePopover, useFocusTrap (internal)
Testing
pnpm exec vitest run --project ui packages/core/src/Popoverpnpm exec vitest run --project ui --maxWorkers=6 packages/core: 824 files, 21,896 tests passedtsc --noEmit(core), eslint: clean