Repository navigation
feat(Popover): isModal decides whether the popover holds Tab (AST-116 FR12–FR16) #11156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: nynexman4464/feat/popover-return-focus
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@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. | ||
|
|
||
| @nynexman4464 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -132,7 +132,7 @@ export const docs = { | |
| name: 'isModal', | ||
| 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.', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Tab moves from the anchor (else the trigger) into the popover" holds only with a separate |
||
| default: 'true', | ||
| }, | ||
| { | ||
|
|
@@ -173,7 +173,7 @@ export const docs = { | |
| name: 'presentation', | ||
| 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.", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For |
||
| default: "'popover'", | ||
| }, | ||
| { | ||
|
|
@@ -419,7 +419,7 @@ export const docsZh = { | |
| name: 'isModal', | ||
| type: 'boolean', | ||
| description: | ||
| '对话框式弹出框是否设置 aria-modal。仅在 role 为 dialog 时适用。', | ||
| '弹出框是否为模态。true 时 Tab 在打开的弹出框内循环,对话框式弹出框带有 aria-modal。false 时不限制焦点:Tab 从锚点(否则为触发器)进入弹出框,再移到锚点之后的元素;仅当焦点在弹出框内或其锚点、触发器上时,Escape 才会关闭它。教学提示或导览步骤请使用 false。作为底部面板时,false 呈现无遮罩、页面仍可交互的面板。', | ||
| default: 'true', | ||
| }, | ||
| { | ||
|
|
@@ -459,7 +459,7 @@ export const docsZh = { | |
| name: 'presentation', | ||
| type: "'popover' | 'bottom-sheet' | 'adaptive'", | ||
| description: | ||
| "承载内容的界面。'popover' 锚定在触发器上,'bottom-sheet' 以模态 BottomSheet 呈现(由 label 命名),'adaptive' 在紧凑触屏(粗指针,且屏幕按主题定义为紧凑:宽度小于其 md 断点或高度小于 500 px,即任意方向的手机)上使用 BottomSheet,在其他设备上使用锚定的弹出框。在底部面板中,placement、alignment、width、role、isModal 和 hasAutoFocus 不生效,xstyle、className 和 style 作用于面板;底部面板始终支持轻触关闭(点按面板外部或向下轻扫即可关闭,不受 hasLightDismiss 影响),Escape 遵循 hasEscapeDismiss,后备关闭按钮保持隐藏。界面在打开时选定,并保持到关闭。", | ||
| "承载内容的界面。'popover' 锚定在触发器上,'bottom-sheet' 以模态 BottomSheet 呈现(由 label 命名),'adaptive' 在紧凑触屏(粗指针,且屏幕按主题定义为紧凑:宽度小于其 md 断点或高度小于 500 px,即任意方向的手机)上使用 BottomSheet,在其他设备上使用锚定的弹出框。在底部面板中,placement、alignment、width、role 和 hasAutoFocus 不生效,xstyle、className 和 style 作用于面板;底部面板始终支持轻触关闭(点按面板外部或向下轻扫即可关闭,不受 hasLightDismiss 影响),Escape 遵循 hasEscapeDismiss,后备关闭按钮保持隐藏。界面在打开时选定,并保持到关闭。", | ||
| default: "'popover'", | ||
| }, | ||
| ], | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -351,8 +351,13 @@ export interface PopoverProps extends Pick< | |
| role?: 'dialog' | 'none'; | ||
|
|
||
| /** | ||
| * Whether a dialog-style popover is modal (`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 (`presentation`), `false` presents a sheet with no scrim that | ||
| * leaves the page interactive. | ||
| * | ||
| * @default true | ||
| */ | ||
|
|
@@ -409,7 +414,7 @@ export interface PopoverProps extends Pick< | |
| * either orientation) and the anchored popover elsewhere, which is what iOS | ||
| * does with a popover by default. | ||
| * | ||
| * In a sheet, `placement`, `alignment`, `width`, `role`, `isModal` and | ||
| * In a sheet, `placement`, `alignment`, `width`, `role` and | ||
| * `hasAutoFocus` do not apply (the sheet is a modal dialog with its own | ||
| * focus handling); `xstyle`, `className` and `style` style the sheet's | ||
| * panel, as they style the anchored surface; `padding` insets the | ||
|
|
@@ -1137,6 +1142,9 @@ export function Popover({ | |
| id={sheetId} | ||
| isOpen={isSheetOpen} | ||
| 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} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 ( |
||
| label={label ?? triggerText} | ||
| height="tall" | ||
| padding={padding} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -302,9 +302,17 @@ export interface UsePopoverOptions { | |
| role?: 'dialog' | 'none'; | ||
|
|
||
| /** | ||
| * Whether the dialog is modal (`aria-modal`). Only applies when `role` is | ||
| * `'dialog'`. Set to `false` for non-modal dialogs that do not inert the rest | ||
| * of the page. | ||
| * Whether the popover is modal. | ||
| * | ||
| * - `true`: Tab and Shift+Tab cycle inside the open popover, and a dialog | ||
| * popover carries `aria-modal`. | ||
| * - `false`: the popover holds nothing. Tab moves in the browser's order: | ||
| * from the anchor (else the trigger) into the popover, and from its last | ||
| * control to what follows the anchor. Escape closes it only while focus | ||
| * is inside it or on its anchor or trigger. Use it for a coachmark or a | ||
| * tour step, whose reader must reach the control it describes. | ||
| * | ||
| * A Tab pressed outside the popover is never pulled into it. | ||
| * | ||
| * @default true | ||
| */ | ||
|
|
@@ -589,6 +597,26 @@ function usePopoverImplementation( | |
| // A caller that may cancel an outside dismissal gets the outside-press | ||
| // watch in place of native light dismiss, which no script can cancel. | ||
| const watchesOutsidePress = hasLightDismiss && onOutsidePress != null; | ||
| // A non-modal popover takes Escape only while it holds focus: inside it, or | ||
| // on the anchor or trigger it belongs to. Read at press time. | ||
| const surfaceHolderRef = | ||
| useRef<React.RefObject<HTMLDivElement | null> | null>(null); | ||
| const isFocusInNonModalScope = useCallback((): boolean => { | ||
| const surface = surfaceHolderRef.current?.current ?? null; | ||
| const doc = surface?.ownerDocument ?? document; | ||
| const active = doc.activeElement; | ||
| if (active == null) { | ||
| return false; | ||
| } | ||
| const anchor = boundAnchorRef.current; | ||
| const trigger = triggerElementRef.current; | ||
| return ( | ||
| surface?.contains(active) === true || | ||
| anchor?.contains(active) === true || | ||
| trigger?.contains(active) === true | ||
| ); | ||
| }, []); | ||
|
|
||
| // Focus returns as the popover starts to close, while it still holds focus: | ||
| // the browser restores focus only for an auto popover whose opener is still | ||
| // there, and then with a focus ring whatever the last input was. The trap | ||
|
|
@@ -633,7 +661,10 @@ function usePopoverImplementation( | |
| getAnchor: () => | ||
| anchorOptionRef.current == null ? null : boundAnchorRef.current, | ||
| layerId: layer.id, | ||
| isContaining: isModal, | ||
| isEscapeInScope: isModal ? undefined : isFocusInNonModalScope, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With the default |
||
| }); | ||
| surfaceHolderRef.current = contentRef; | ||
| returnFocusRef.current = returnFocus; | ||
|
|
||
| const focusInitialTarget = useCallback(() => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -583,6 +583,18 @@ interface InternalFocusTrapOptions extends UseFocusTrapOptions { | |
| getAnchor?: () => Element | null; | ||
| /** The id the trigger's `aria-controls` names. */ | ||
| layerId?: string; | ||
| /** | ||
| * Whether Tab and Shift+Tab cycle inside the container while it is active. | ||
| * A non-modal overlay leaves Tab to the page's own order. | ||
| * @default true | ||
| */ | ||
| isContaining?: boolean; | ||
| /** | ||
| * Whether the overlay takes an Escape press now, asked at press time. A | ||
| * non-modal overlay takes Escape only while it holds focus; otherwise the | ||
| * press passes to the layer or page beneath. | ||
| */ | ||
| isEscapeInScope?: () => boolean; | ||
| } | ||
|
|
||
| export interface InternalFocusTrapReturn< | ||
|
|
@@ -607,6 +619,8 @@ export function useFocusTrapInternal<T extends HTMLElement = HTMLElement>( | |
| getTrigger, | ||
| getAnchor, | ||
| layerId, | ||
| isContaining = true, | ||
| isEscapeInScope, | ||
| } = options; | ||
| const getRestoreTargetRef = useRef(getRestoreTarget); | ||
| getRestoreTargetRef.current = getRestoreTarget; | ||
|
|
@@ -623,8 +637,9 @@ export function useFocusTrapInternal<T extends HTMLElement = HTMLElement>( | |
|
|
||
| const containerRef = useRef<T | null>(null); | ||
|
|
||
| // Tab and Shift+Tab wrap inside the container while the trap is active. | ||
| useFocusRegionContainment(containerRef, isActive); | ||
| // Tab and Shift+Tab wrap inside the container while the trap is active and | ||
| // containing. | ||
|
Comment on lines
+640
to
+641
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking (INV29): this comment restates |
||
| useFocusRegionContainment(containerRef, isActive && isContaining); | ||
|
|
||
| // Join the shared layer dismissal stack. The trap no longer listens for | ||
| // Escape itself: the stack owns one listener and routes each press to the | ||
|
|
@@ -646,6 +661,7 @@ export function useFocusTrapInternal<T extends HTMLElement = HTMLElement>( | |
| // content; hand the stack the container instead so two DOM-nested traps | ||
| // still resolve in the right order. | ||
| getContainer: () => containerRef.current, | ||
| isPresent: isEscapeInScope, | ||
| }); | ||
|
|
||
| useFocusTrapEscapeCompatibilitySignal(isEscapeTrap); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Miss (not raised in the earlier review of this same patch):
spec:AST-017FR7 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 releasedisModal={false}behavior, so whichever tier the owner rules, the tag needs to be[fix]with the target or[breaking]with a migration, not[feat].