Repository navigation
Conversation
Re-provide `ReduceMotionContext` in `Portal`, alongside the settings, locale and theme contexts already forwarded across the portal boundary, so portal content stops falling back to the context default of `false`.
Compare the key when looking up the queued `mount` to replace, so an update that arrives before the `PortalManager` ref is attached no longer overwrites an unrelated queued portal.
Add an opt-in `overlay` prop to `Portal` that hides every layer below it -- the app content and any portal mounted earlier -- from assistive technology and from the web focus order, while portals mounted on top stay reachable.
|
Found potential problems with the pull request:
|
Address review feedback on callstack#5126: - rename the `overlay` prop to `modal` - rename `PortalManager`'s `pageContent` prop to `children` and make it required, since a portal host doesn't render a page - move the `collapsable` comment onto the prop it explains - rewrite the `modal` prop documentation
A `Modal` is an overlay, so it always needs a `Portal` with `modal` set to hide the content behind it. Render one itself instead of asking every call site to wrap the modal and pass the prop. BREAKING CHANGE: `Modal` and `Dialog` no longer need to be wrapped in a `Portal`.
Every dialog now hides the content behind it, so the dedicated "Inert background" example no longer has anything of its own to show.
`Dialog` renders itself in a `Portal`, so the examples no longer need to wrap it in one.
26f1f8e to
4ce304d
Compare
# Conflicts: # src/components/Modal.tsx # src/components/Portal/PortalHost.tsx # src/components/Portal/PortalManager.tsx # src/components/__tests__/Portal.test.tsx # src/components/__tests__/__snapshots__/Modal.test.tsx.snap
# Conflicts: # src/components/Modal.tsx # src/components/__tests__/Modal.test.tsx
| import { addEventListener } from './addEventListener'; | ||
| import { BackHandler } from './BackHandler/BackHandler'; | ||
|
|
||
| const visibleOverlays: Array<number> = []; |
There was a problem hiding this comment.
I'm not sure about this approach. The order of modals is already in the portal manager, so duplicating it here means 2 sources of truth and possible mismatches.
It may make sense to expose this information from portal via context, or move this logic to portal manager.
There was a problem hiding this comment.
Logic moved to Portal Manager
| event.stopImmediatePropagation(); | ||
| }; | ||
|
|
||
| document.addEventListener('keydown', handleKeyDown, true); |
There was a problem hiding this comment.
Claude says (please verify):
useOverlayDismiss.tsx:113 listens for keydown on document in the capture phase, then calls stopImmediatePropagation() at line 110.
A capture listener on document runs before any handler inside the modal. So the defaultPrevented check at line 101 never sees what closer handlers did, and the comment above it is wrong.
Stopping the event in the capture phase also means it never reaches its target. React's root listeners never get it either. So while a modal is open, no onKeyDown or onKeyPress inside the app receives Escape.
The test "stays out of the way once something nearer the key press handled it" hides this because it builds the event with defaultPrevented: true already set.
Fix: drop the true so the listener runs in the bubble phase.
| /** | ||
| * Closes only the overlay on top when the user presses back or Escape. | ||
| */ | ||
| export function useOverlayDismiss({ |
There was a problem hiding this comment.
Menu uses portal too, but since this logic is only used in Modal, if both menu and modal are open, both will close. Probably an argument to centralize this in portal manager.
There was a problem hiding this comment.
That's expected, actually. Menu is out of scope and would have to adapt useOverlayDismiss in a separate task.
There was a problem hiding this comment.
That's expected, actually. Menu is out of scope
not quite. the problem i'm highlighting is that this is not a general solution. what about users who build their own components using Portal? how would they integrate with an internal hook?
There was a problem hiding this comment.
I agree. It makes sense to create a general solution that Paper's component can adopt, but also users can adopt in their own components.
Have not updated Menu to keep this PR small enough. If you are fine with the current solution, I will add another PR that updates Menu as well. Unless you would like to add Menu's changes in this PR instead
There was a problem hiding this comment.
This now also controls escape key, so the description is wrong. The name would also be misleading. Though maybe only dismissable should control escape key. Not sure.
There was a problem hiding this comment.
dismissable now controls back button, escape, and screen reader gestures. That renders dismissableBackButton useless, because escape key and screen reader gestures on Android go through back button press handles. That's why I removed it, but added dismissableOverlay to control overlay/backdrop tap
| describe('DialogActions', () => { | ||
| it('renders passed children', async () => { | ||
| await render( | ||
| <Dialog.Actions> | ||
| <Button testID="button-cancel">Cancel</Button> | ||
| <Button testID="button-ok">Ok</Button> | ||
| </Dialog.Actions> | ||
| <Portal.Host> | ||
| <Dialog.Actions> | ||
| <Button testID="button-cancel">Cancel</Button> | ||
| <Button testID="button-ok">Ok</Button> | ||
| </Dialog.Actions> | ||
| </Portal.Host> | ||
| ); | ||
|
|
||
| expect(screen.getByTestId('button-cancel')).toBeOnTheScreen(); | ||
| expect(screen.getByTestId('button-ok')).toBeOnTheScreen(); | ||
| }); | ||
|
|
||
| it('applies default styles', async () => { | ||
| await render( | ||
| <Dialog.Actions testID="dialog-actions"> | ||
| <Button>Cancel</Button> | ||
| <Button>Ok</Button> | ||
| </Dialog.Actions> | ||
| <Portal.Host> | ||
| <Dialog.Actions testID="dialog-actions"> | ||
| <Button>Cancel</Button> | ||
| <Button>Ok</Button> | ||
| </Dialog.Actions> | ||
| </Portal.Host> | ||
| ); | ||
|
|
||
| const dialogActionsContainer = screen.getByTestId('dialog-actions'); | ||
| const dialogActionButtons = dialogActionsContainer.children; | ||
|
|
||
| expect(dialogActionsContainer).toHaveStyle({ | ||
| paddingBottom: 24, | ||
| paddingHorizontal: 24, | ||
| }); | ||
| expect(dialogActionButtons[0]).toHaveStyle({ marginRight: 8 }); | ||
| expect(dialogActionButtons[1]).toHaveStyle({ marginRight: 0 }); | ||
| }); | ||
|
|
||
| it('applies custom styles', async () => { | ||
| await render( | ||
| <Dialog.Actions testID="dialog-actions"> | ||
| <Button style={styles.spacing}>Cancel</Button> | ||
| <Button style={styles.noSpacing}>Ok</Button> | ||
| </Dialog.Actions> | ||
| <Portal.Host> | ||
| <Dialog.Actions testID="dialog-actions"> | ||
| <Button style={styles.spacing}>Cancel</Button> | ||
| <Button style={styles.noSpacing}>Ok</Button> | ||
| </Dialog.Actions> | ||
| </Portal.Host> | ||
| ); |
There was a problem hiding this comment.
Portal.Host wrappers are unnecessary around dialog actions.
| beforeEach(() => { | ||
| BackHandler.exitApp.mockClear(); | ||
| }); |
There was a problem hiding this comment.
better to restore/clear all mocks after each test so we don't need to keep track of individual mocks
| beforeEach(() => { | |
| BackHandler.exitApp.mockClear(); | |
| }); | |
| afterEach(() => { | |
| jest.restoreAllMocks(); | |
| }); |
| expect(onDismiss).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('absorbs the Android back button for a non-dismissible modal', async () => { |
There was a problem hiding this comment.
"absorbs" is strange wording
| it('absorbs the Android back button for a non-dismissible modal', async () => { | |
| it("doesn't handle the Android back button for a non-dismissible modal", async () => { |
Each Portal.Host now closes only its topmost modal portal on the Android back button, the Escape key on web, and the screen reader's escape gesture, replacing the global useOverlayDismiss stack. BREAKING CHANGE: Modal and Dialog drop `dismissableBackButton`. `dismissable` now controls the back button, the Escape key, the screen reader's escape gesture and the dismiss button, and the new `dismissableOverlay` prop controls closing on an outside tap.
Motivation
Some of our overlay components have their own dismiss logic (back on native, Escape on web), and none of them route it to the topmost overlay. This PR moves dismissal logic into
Portal, so any overlay built on amodalportal gets it.Portal.Hostasks only its topmostmodalportal to close.ModalandDialogare the first to use it.Menuwill adopt it separately, in another PR.Modalstops being the topmost modal as soon as it starts closing.Breaking changes
dismissablenow controls the back button, Escape, the screen reader's escape gesture and the hidden dismiss button.dismissable={false}blocks every way of closing.dismissableBackButtonprop is removed.dismissableOverlayprop controls an outside tap, and applies only when the modal isdismissable.The migration guide is updated.
Related issue
Notion
Screenshots / Videos
No visual change.
Test plan
yarn testcovers the hook (ranking, one press per overlay, absorbed presses, Escape) and a back-press test onModal.