Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/6.x/docs/guides/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,22 @@ The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`,

Previously, the Android back button dismissed the modal when `dismissable` was `true`, even if `dismissableBackButton` was `false`. The `dismissableBackButton` prop can now prevent the modal from being dismissed via the back button independently of the `dismissable` prop.

The Escape key on web now dismisses the modal too, and `dismissableBackButton` controls it together with the back button. When several modals are open, back and Escape dismiss only the topmost one. When `dismissableBackButton` is `false`, they are absorbed and nothing is dismissed, rather than passed on to the content behind the modal.

Opening a modal now moves focus into it, and closing it returns focus to the element that had it before. You can choose the elements with the new `initialFocusRef` and `restoreFocusRef` props:

```jsx
<Modal
visible={visible}
onDismiss={hideModal}
aria-label="Rename file"
initialFocusRef={inputRef}
restoreFocusRef={buttonRef}
>
<TextInput ref={inputRef} label="Name" />
</Modal>
```

### Dialog

`Dialog` now uses a `Portal` internally and doesn't require an explicit `Portal` wrapper. So you need to remove any existing `Portal` wrappers around `Dialog`:
Expand All @@ -261,6 +277,8 @@ The dialog now has the `dialog` role. On web, the dialog's accessible name is se
</Dialog>
```

`Dialog` follows the same back button, Escape key and focus behavior as `Modal`, including the `initialFocusRef` and `restoreFocusRef` props.

When the dialog is `dismissable`, screen reader users can dismiss it with a visually hidden button inside the dialog. You can change the button's accessibility label with the new `dismissAccessibilityLabel` prop.

- The default elevation changed from level `1` to level `3`.
Expand Down Expand Up @@ -300,6 +318,8 @@ The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`:
</Menu>
```

On web, a menu now closes on the Escape key when it is pressed down, instead of when it is released. Back and Escape dismiss only the topmost overlay, so a menu opened from a `Modal` or `Dialog` closes first and leaves the modal open.

### Searchbar

The misspelled `traileringIcon` props have been renamed:
Expand Down
3 changes: 3 additions & 0 deletions jest/reanimatedSnapshotSerializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ const reanimatedTestProps = new Set([
'jestAnimatedProps',
'jestAnimatedStyle',
'jestInlineStyle',
// Reanimated passes the ref it was given down as a prop of its own. Printing
// it walks the whole host instance, which never ends.
'forwardedRef',
]);

module.exports = {
Expand Down
2 changes: 2 additions & 0 deletions jest/testSetup.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ jest.mock('react-native', () => {
RN.Animated.loop = loop;
RN.Animated.parallel = parallel;

RN.AccessibilityInfo.sendAccessibilityEvent = jest.fn();

return RN;
});

Expand Down
14 changes: 13 additions & 1 deletion src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Platform, StyleSheet } from 'react-native';
import type { StyleProp } from 'react-native';
import type { StyleProp, View } from 'react-native';

import { useSafeAreaInsets } from 'react-native-safe-area-context';

Expand Down Expand Up @@ -40,6 +40,14 @@ export type Props = {
* Determines Whether the dialog is visible.
*/
visible: boolean;
/**
* An accessibility element to focus when the dialog opens.
*/
initialFocusRef?: React.RefObject<React.ComponentRef<typeof View> | null>;
/**
* An accessibility element to focus when the dialog closes.
*/
restoreFocusRef?: React.RefObject<React.ComponentRef<typeof View> | null>;
/**
* Content of the `Dialog`.
*/
Expand Down Expand Up @@ -106,6 +114,8 @@ const Dialog = ({
dismissAccessibilityLabel,
'aria-label': ariaLabel,
visible = false,
initialFocusRef,
restoreFocusRef,
style,
theme: themeOverrides,
testID,
Expand All @@ -129,6 +139,8 @@ const Dialog = ({
onDismiss={onDismiss}
dismissAccessibilityLabel={dismissAccessibilityLabel}
visible={visible}
initialFocusRef={initialFocusRef}
restoreFocusRef={restoreFocusRef}
contentBackgroundColor={backgroundColor}
contentBorderRadius={borderRadius}
contentElevation={DIALOG_ELEVATION}
Expand Down
36 changes: 11 additions & 25 deletions src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { useLocale } from '../../core/locale';
import { useInternalTheme } from '../../core/theming';
import type { Elevation, ThemeProp } from '../../theme/types';
import { addEventListener } from '../../utils/addEventListener';
import { BackHandler } from '../../utils/BackHandler/BackHandler';
import { useOverlayDismiss } from '../../utils/useOverlayDismiss';
import Portal from '../Portal/Portal';
import Surface from '../Surface';
import type { SurfaceStyle } from '../Surface';
Expand Down Expand Up @@ -251,9 +251,6 @@ const Menu = ({
EmitterSubscription | undefined
> = React.useRef(undefined);

const backHandlerSubscriptionRef: React.RefObject<
NativeEventSubscription | undefined
> = React.useRef(undefined);
const dimensionsSubscriptionRef: React.RefObject<
NativeEventSubscription | undefined
> = React.useRef(undefined);
Expand All @@ -264,37 +261,26 @@ const Menu = ({
}
}, [onDismiss, visible]);

const handleKeypress = React.useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
onDismiss?.();
}
},
[onDismiss]
);
// Back and Escape go through the overlay stack, so that a menu opened from a
// modal closes first and the modal stays. Without `onDismiss` there is
// nothing to close, and the press is left to whatever is underneath.
useOverlayDismiss({
enabled: visible && onDismiss !== undefined,
dismissable: true,
onDismiss,
});

const removeListeners = React.useCallback(() => {
backHandlerSubscriptionRef.current?.remove();
dimensionsSubscriptionRef.current?.remove();

if (Platform.OS === 'web' && 'document' in global) {
document.removeEventListener('keyup', handleKeypress);
}
}, [handleKeypress]);
}, []);

const attachListeners = React.useCallback(() => {
backHandlerSubscriptionRef.current = addEventListener(
BackHandler,
'hardwareBackPress',
handleDismiss
);
dimensionsSubscriptionRef.current = addEventListener(
Dimensions,
'change',
handleDismiss
);
Platform.OS === 'web' && document.addEventListener('keyup', handleKeypress);
}, [handleDismiss, handleKeypress]);
}, [handleDismiss]);

const measureMenuLayout = () =>
new Promise<LayoutRectangle>((resolve) => {
Expand Down
74 changes: 54 additions & 20 deletions src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,28 @@ import type { Props as SurfaceProps, SurfaceStyle } from './Surface';
import { useInternalTheme } from '../core/theming';
import { tokens } from '../theme/tokens';
import type { Elevation, ThemeProp } from '../theme/types';
import { addEventListener } from '../utils/addEventListener';
import { BackHandler } from '../utils/BackHandler/BackHandler';
import { useOverlayDismiss } from '../utils/useOverlayDismiss';
import { useOverlayFocus } from '../utils/useOverlayFocus';

const scrimAlpha = tokens.md.sys.scrim.alpha;

export type Props = {
/**
* Determines whether clicking outside the modal dismisses it.
* Determines whether clicking outside the modal dismisses it. It also decides
* whether the screen reader escape gesture and the visually hidden dismiss
* button dismiss it, and is the default for `dismissableBackButton`.
*/
dismissable?: boolean;
/**
* Determines whether clicking Android hardware back button dismisses the dialog.
* Determines whether the Android hardware back button and the Escape key on the web dismiss the modal.
* While a modal is visible, only the topmost one handles these. When this is `false`,
* they are absorbed and nothing is dismissed, rather than passed on to what is behind the modal.
*/
dismissableBackButton?: boolean;
/**
* Callback that is called when the user dismisses the modal.
* A visible modal always takes the back button and Escape key, so without
* this callback they do nothing, and on Android the user cannot go back.
*/
onDismiss?: () => void;
/**
Expand All @@ -54,6 +60,14 @@ export type Props = {
* Determines Whether the modal is visible.
*/
visible: boolean;
/**
* Element to focus when the modal opens.
*/
initialFocusRef?: React.RefObject<React.ComponentRef<typeof View> | null>;
/**
* Element to focus when the modal closes.
*/
restoreFocusRef?: React.RefObject<React.ComponentRef<typeof View> | null>;
/**
* Content of the `Modal`.
*/
Expand Down Expand Up @@ -94,6 +108,10 @@ export type Props = {
};

const DEFAULT_DURATION = 220;

// Having the handler at all keeps the screen reader escape gesture on this
// modal, instead of passing it on to the modal behind it.
const absorbAccessibilityEscape = () => {};
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);

/**
Expand Down Expand Up @@ -138,6 +156,8 @@ function Modal({
dismissable = true,
dismissableBackButton = dismissable,
visible = false,
initialFocusRef,
restoreFocusRef,
dismissAccessibilityLabel = 'Close modal',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
Expand Down Expand Up @@ -167,6 +187,15 @@ function Modal({

const { scale } = theme.animation;

const contentRef = React.useRef<React.ComponentRef<typeof View>>(null);

const { focusInitialTarget, restoreFocus } = useOverlayFocus({
visible,
containerRef: contentRef,
initialFocusRef,
restoreFocusRef,
});

React.useEffect(() => {
const timeout = setTimeout(() => setAnimatedVisible(visible), 0);

Expand All @@ -187,26 +216,28 @@ function Modal({
}, [scale, visible, visibleInternal]);

React.useEffect(() => {
if (!visible) {
return undefined;
if (!visibleInternal) {
restoreFocus();
}
}, [restoreFocus, visibleInternal]);

const onHardwareBackPress = () => {
if (dismissableBackButton) {
onDismissCallback();
}
useOverlayDismiss({
enabled: visible,
dismissable: dismissableBackButton,
onDismiss: onDismissCallback,
});

return true;
};
React.useEffect(() => {
if (!animatedVisible) {
return undefined;
}

const subscription = addEventListener(
BackHandler,
'hardwareBackPress',
onHardwareBackPress
);
// Transparent views are not in the accessibility tree, so focus has to
// wait for the fade to finish.
const timeout = setTimeout(focusInitialTarget, scale * DEFAULT_DURATION);

return () => subscription.remove();
}, [dismissableBackButton, onDismissCallback, visible]);
return () => clearTimeout(timeout);
}, [animatedVisible, focusInitialTarget, scale]);

const transitionTimingFunction = cubicBezier(1 / 3, 1, 2 / 3, 1);

Expand Down Expand Up @@ -240,7 +271,9 @@ function Modal({
pointerEvents={visible ? 'auto' : 'none'}
aria-live="polite"
style={StyleSheet.absoluteFill}
onAccessibilityEscape={dismissable ? onDismissCallback : undefined}
onAccessibilityEscape={
dismissable ? onDismissCallback : absorbAccessibilityEscape
}
testID={testID}
>
<AnimatedPressable
Expand All @@ -261,6 +294,7 @@ function Modal({
pointerEvents="box-none"
>
<Surface
ref={contentRef}
role="dialog"
aria-modal
aria-label={ariaLabel}
Expand Down
Loading
Loading