mirror of
https://github.com/zhigang1992/react-native-web.git
synced 2026-01-12 22:51:09 +08:00
[add] Modal component
This adds support for the React Native Modal on web. The app content is hidden from screen readers by setting the aria-modal flag on the modal. This focus is trapped within the modal, both when attempting to focus elsewhere using the mouse as well as when attempting to focus elsewhere using the keyboard. A built-in "Escape to close" mechanism is been implemented that calls 'onRequestClose' for the active modal. Close #1646 Fix #1020
This commit is contained in:
committed by
Nicolas Gallagher
parent
6bd41a622a
commit
d97a1ca567
@@ -118,7 +118,7 @@ React Native v0.60
|
||||
| Image | ✓ | Missing multiple sources ([#515](https://github.com/necolas/react-native-web/issues/515)) and HTTP headers ([#1019](https://github.com/necolas/react-native-web/issues/1019)). |
|
||||
| ImageBackground | ✓ | |
|
||||
| KeyboardAvoidingView | (✓) | Mock. No equivalent web APIs. |
|
||||
| Modal | ✘ | Not started ([#1020](https://github.com/necolas/react-native-web/issues/1020)). |
|
||||
| Modal | ✓ | |
|
||||
| Picker | ✓ | |
|
||||
| Pressable | ✓ | |
|
||||
| RefreshControl | ✘ | Not started ([#1027](https://github.com/necolas/react-native-web/issues/1027)). |
|
||||
|
||||
83
packages/docs/src/components/Modal/Modal.stories.mdx
Normal file
83
packages/docs/src/components/Modal/Modal.stories.mdx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Meta, Story, Preview } from '@storybook/addon-docs/blocks';
|
||||
import * as Stories from './examples';
|
||||
|
||||
<Meta title="Components|Modal" />
|
||||
|
||||
# Modal
|
||||
|
||||
The Modal component is a basic way to present content above an enclosing view.
|
||||
Modals may be nested within other Modals.
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Type | Default |
|
||||
| ------------------------- | -------------- | ------- |
|
||||
| animationType | ?AnimationType | 'none' |
|
||||
| children | ?any | |
|
||||
| onDismiss | ?Function | |
|
||||
| onRequestClose | ?Function | |
|
||||
| onShow | ?Function | |
|
||||
| transparent | ?boolean | false |
|
||||
| visible | ?boolean | true |
|
||||
|
||||
### animationType
|
||||
|
||||
The `animationType` prop can be used to add animation to the modal
|
||||
being opened or dismissed.
|
||||
|
||||
* `none` - the modal appears without any animation.
|
||||
* `slide` - the modal slides up from the bottom of the screen.
|
||||
* `fade` - the modal fades in.
|
||||
|
||||
By default this is `none`.
|
||||
|
||||
<Preview withSource='none'>
|
||||
<Story name="propsExample-animationType">
|
||||
<Stories.animatedModal />
|
||||
</Story>
|
||||
</Preview>
|
||||
|
||||
### onDismiss
|
||||
|
||||
The `onDismiss` callback is called after the modal has been dismissed and is no longer visible.
|
||||
|
||||
### onRequestClose
|
||||
|
||||
The `onRequestClose` callback is called when the user is attempting to close the modal -
|
||||
such as when they hit `Escape`.
|
||||
|
||||
Only the top-most Modal responds to hitting `Escape`.
|
||||
|
||||
<Preview withSource='none'>
|
||||
<Story name="propsExample-onRequestClose">
|
||||
<Stories.modalception />
|
||||
</Story>
|
||||
</Preview>
|
||||
|
||||
### onShow
|
||||
|
||||
The `onShow` callback is called once the modal has been shown and may be visible.
|
||||
|
||||
### transparent
|
||||
|
||||
The `transparent` prop determines if the modal is rendered with a `transparent` backdrop or
|
||||
a `white` backdrop.
|
||||
|
||||
<Preview withSource='none'>
|
||||
<Story name="propsExample-transparent">
|
||||
<Stories.transparentModal />
|
||||
</Story>
|
||||
</Preview>
|
||||
|
||||
### visible
|
||||
|
||||
Whether or not the modal is visible.
|
||||
|
||||
When set to `false` the contents are not rendered & the modal removes itself
|
||||
from the screen.
|
||||
|
||||
<Preview withSource='none'>
|
||||
<Story name="propsExample-visible">
|
||||
<Stories.simpleModal />
|
||||
</Story>
|
||||
</Preview>
|
||||
50
packages/docs/src/components/Modal/examples/Animated.js
Normal file
50
packages/docs/src/components/Modal/examples/Animated.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Text, Button, View, StyleSheet } from 'react-native';
|
||||
|
||||
function Gap() {
|
||||
return <View style={styles.gap} />;
|
||||
}
|
||||
|
||||
function AnimatedModal({ animationType }) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={() => setIsVisible(true)} title={`Open Modal with '${animationType}'`} />
|
||||
<Modal
|
||||
animationType={animationType}
|
||||
onRequestClose={() => setIsVisible(false)}
|
||||
visible={isVisible}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
<Text>Modal with "animationType" of "{animationType}"</Text>
|
||||
<Gap />
|
||||
<Button onPress={() => setIsVisible(false)} title={'Close Modal'} />
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnimatedModalStack() {
|
||||
return (
|
||||
<>
|
||||
<AnimatedModal animationType={'none'} />
|
||||
<Gap />
|
||||
<AnimatedModal animationType={'slide'} />
|
||||
<Gap />
|
||||
<AnimatedModal animationType={'fade'} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center'
|
||||
},
|
||||
gap: {
|
||||
height: 10
|
||||
}
|
||||
});
|
||||
52
packages/docs/src/components/Modal/examples/Modalception.js
Normal file
52
packages/docs/src/components/Modal/examples/Modalception.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Modal, View, Text, Button, StyleSheet } from 'react-native';
|
||||
|
||||
const WIGGLE_ROOM = 128;
|
||||
|
||||
function Gap() {
|
||||
return <View style={styles.gap} />;
|
||||
}
|
||||
|
||||
export default function Modalception({ depth = 1 }) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const offset = useMemo(() => {
|
||||
return {
|
||||
top: Math.random() * WIGGLE_ROOM - WIGGLE_ROOM / 2,
|
||||
left: Math.random() * WIGGLE_ROOM - WIGGLE_ROOM / 2
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={() => setIsVisible(true)} title={'Open Modal'} />
|
||||
<Modal onRequestClose={() => setIsVisible(false)} transparent visible={isVisible}>
|
||||
<View style={[styles.container, offset]}>
|
||||
<Text>This is in Modal {depth}</Text>
|
||||
<Gap />
|
||||
{isVisible ? <Modalception depth={depth + 1} /> : null}
|
||||
<Gap />
|
||||
<Button color="red" onPress={() => setIsVisible(false)} title={'Close Modal'} />
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'white',
|
||||
borderColor: '#eee',
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
justifyContent: 'center',
|
||||
height: 300,
|
||||
margin: 'auto',
|
||||
padding: 30,
|
||||
width: 300
|
||||
},
|
||||
gap: {
|
||||
height: 10
|
||||
}
|
||||
});
|
||||
34
packages/docs/src/components/Modal/examples/Simple.js
Normal file
34
packages/docs/src/components/Modal/examples/Simple.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Text, View, Button, StyleSheet } from 'react-native';
|
||||
|
||||
function Gap() {
|
||||
return <View style={styles.gap} />;
|
||||
}
|
||||
|
||||
export default function SimpleModal() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={() => setIsVisible(true)} title={'Open Modal'} />
|
||||
<Modal onRequestClose={() => setIsVisible(false)} visible={isVisible}>
|
||||
<View style={styles.container}>
|
||||
<Text>Hello, World!</Text>
|
||||
<Gap />
|
||||
<Button onPress={() => setIsVisible(false)} title={'Close Modal'} />
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center'
|
||||
},
|
||||
gap: {
|
||||
height: 10
|
||||
}
|
||||
});
|
||||
41
packages/docs/src/components/Modal/examples/Transparent.js
Normal file
41
packages/docs/src/components/Modal/examples/Transparent.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Text, View, Button, StyleSheet } from 'react-native';
|
||||
|
||||
function Gap() {
|
||||
return <View style={styles.gap} />;
|
||||
}
|
||||
|
||||
export default function TransparentModal() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={() => setIsVisible(true)} title={'Open Modal'} />
|
||||
<Modal onRequestClose={() => setIsVisible(false)} transparent visible={isVisible}>
|
||||
<View style={styles.container}>
|
||||
<Text style={{ textAlign: 'center' }}>Modal with "transparent" value</Text>
|
||||
<Gap />
|
||||
<Button onPress={() => setIsVisible(false)} title={'Close Modal'} />
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'white',
|
||||
borderColor: '#eee',
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
justifyContent: 'center',
|
||||
height: 300,
|
||||
margin: 'auto',
|
||||
padding: 30,
|
||||
width: 300
|
||||
},
|
||||
gap: {
|
||||
height: 10
|
||||
}
|
||||
});
|
||||
4
packages/docs/src/components/Modal/examples/index.js
Normal file
4
packages/docs/src/components/Modal/examples/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as transparentModal } from './Transparent';
|
||||
export { default as simpleModal } from './Simple';
|
||||
export { default as animatedModal } from './Animated';
|
||||
export { default as modalception } from './Modalception';
|
||||
131
packages/react-native-web/src/exports/Modal/ModalAnimation.js
vendored
Normal file
131
packages/react-native-web/src/exports/Modal/ModalAnimation.js
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Copyright (c) Nicolas Gallagher.
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useState, useRef } from 'react';
|
||||
import StyleSheet from '../StyleSheet';
|
||||
import createElement from '../createElement';
|
||||
|
||||
const ANIMATION_DURATION = 300;
|
||||
|
||||
function getAnimationStyle(animationType, visible) {
|
||||
if (animationType === 'slide') {
|
||||
return visible ? animatedSlideInStyles : animatedSlideOutStyles;
|
||||
}
|
||||
if (animationType === 'fade') {
|
||||
return visible ? animatedFadeInStyles : animatedFadeOutStyles;
|
||||
}
|
||||
return visible ? styles.container : styles.hidden;
|
||||
}
|
||||
|
||||
export type ModalAnimationProps = {|
|
||||
animationType?: ?('none' | 'slide' | 'fade'),
|
||||
children?: any,
|
||||
onDismiss?: ?() => void,
|
||||
onShow?: ?() => void,
|
||||
visible?: ?boolean
|
||||
|};
|
||||
|
||||
function ModalAnimation(props: ModalAnimationProps) {
|
||||
const { animationType, children, onDismiss, onShow, visible } = props;
|
||||
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
const wasVisible = useRef(false);
|
||||
|
||||
const isAnimated = animationType && animationType !== 'none';
|
||||
|
||||
const animationEndCallback = useCallback(() => {
|
||||
if (visible) {
|
||||
if (onShow) {
|
||||
onShow();
|
||||
}
|
||||
} else {
|
||||
setIsRendering(false);
|
||||
if (onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
}
|
||||
}, [onDismiss, onShow, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setIsRendering(true);
|
||||
}
|
||||
if (visible !== wasVisible.current && !isAnimated) {
|
||||
// Manually call `animationEndCallback` if no animation is used
|
||||
animationEndCallback();
|
||||
}
|
||||
wasVisible.current = visible;
|
||||
}, [isAnimated, visible, animationEndCallback]);
|
||||
|
||||
return isRendering || visible
|
||||
? createElement('div', {
|
||||
style: isRendering ? getAnimationStyle(animationType, visible) : styles.hidden,
|
||||
onAnimationEnd: animationEndCallback,
|
||||
children
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
},
|
||||
animatedIn: {
|
||||
animationDuration: `${ANIMATION_DURATION}ms`,
|
||||
animationTimingFunction: 'ease-in'
|
||||
},
|
||||
animatedOut: {
|
||||
pointerEvents: 'none',
|
||||
animationDuration: `${ANIMATION_DURATION}ms`,
|
||||
animationTimingFunction: 'ease-out'
|
||||
},
|
||||
fadeIn: {
|
||||
opacity: 1,
|
||||
animationKeyframes: {
|
||||
'0%': { opacity: 0 },
|
||||
'100%': { opacity: 1 }
|
||||
}
|
||||
},
|
||||
fadeOut: {
|
||||
opacity: 0,
|
||||
animationKeyframes: {
|
||||
'0%': { opacity: 1 },
|
||||
'100%': { opacity: 0 }
|
||||
}
|
||||
},
|
||||
slideIn: {
|
||||
transform: [{ translateY: '0%' }],
|
||||
animationKeyframes: {
|
||||
'0%': { transform: [{ translateY: '100%' }] },
|
||||
'100%': { transform: [{ translateY: '0%' }] }
|
||||
}
|
||||
},
|
||||
slideOut: {
|
||||
transform: [{ translateY: '100%' }],
|
||||
animationKeyframes: {
|
||||
'0%': { transform: [{ translateY: '0%' }] },
|
||||
'100%': { transform: [{ translateY: '100%' }] }
|
||||
}
|
||||
},
|
||||
hidden: {
|
||||
display: 'none'
|
||||
}
|
||||
});
|
||||
|
||||
const animatedSlideInStyles = [styles.container, styles.animatedIn, styles.slideIn];
|
||||
const animatedSlideOutStyles = [styles.container, styles.animatedOut, styles.slideOut];
|
||||
const animatedFadeInStyles = [styles.container, styles.animatedIn, styles.fadeIn];
|
||||
const animatedFadeOutStyles = [styles.container, styles.animatedOut, styles.fadeOut];
|
||||
|
||||
export default ModalAnimation;
|
||||
73
packages/react-native-web/src/exports/Modal/ModalContent.js
vendored
Normal file
73
packages/react-native-web/src/exports/Modal/ModalContent.js
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) Nicolas Gallagher.
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useMemo, useEffect } from 'react';
|
||||
import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
|
||||
import View from '../View';
|
||||
import StyleSheet from '../StyleSheet';
|
||||
|
||||
export type ModalContentProps = {|
|
||||
active?: ?(boolean | (() => boolean)),
|
||||
children?: any,
|
||||
onRequestClose?: ?() => void,
|
||||
transparent?: ?boolean
|
||||
|};
|
||||
|
||||
const ModalContent = forwardRef<ModalContentProps, *>((props, forwardedRef) => {
|
||||
const { active, children, onRequestClose, transparent } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (canUseDOM) {
|
||||
const closeOnEscape = (e: KeyboardEvent) => {
|
||||
if (active && e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
if (onRequestClose) {
|
||||
onRequestClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keyup', closeOnEscape, false);
|
||||
return () => document.removeEventListener('keyup', closeOnEscape, false);
|
||||
}
|
||||
}, [active, onRequestClose]);
|
||||
|
||||
const style = useMemo(() => {
|
||||
return [styles.modal, transparent ? styles.modalTransparent : styles.modalOpaque];
|
||||
}, [transparent]);
|
||||
|
||||
return (
|
||||
<View accessibilityRole={active ? 'dialog' : null} aria-modal ref={forwardedRef} style={style}>
|
||||
<View style={styles.container}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modal: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: 9999
|
||||
},
|
||||
modalTransparent: {
|
||||
backgroundColor: 'transparent'
|
||||
},
|
||||
modalOpaque: {
|
||||
backgroundColor: 'white'
|
||||
},
|
||||
container: {
|
||||
top: 0,
|
||||
flex: 1
|
||||
}
|
||||
});
|
||||
|
||||
export default ModalContent;
|
||||
141
packages/react-native-web/src/exports/Modal/ModalFocusTrap.js
vendored
Normal file
141
packages/react-native-web/src/exports/Modal/ModalFocusTrap.js
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Copyright (c) Nicolas Gallagher.
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
|
||||
import View from '../View';
|
||||
import createElement from '../createElement';
|
||||
import StyleSheet from '../StyleSheet';
|
||||
import UIManager from '../UIManager';
|
||||
|
||||
/**
|
||||
* This Component is used to "wrap" the modal we're opening
|
||||
* so that changing focus via tab will never leave the document.
|
||||
*
|
||||
* This allows us to properly trap the focus within a modal
|
||||
* even if the modal is at the start or end of a document.
|
||||
*/
|
||||
|
||||
const FocusBracket = () => {
|
||||
return createElement('div', {
|
||||
accessibilityRole: 'none',
|
||||
tabIndex: 0,
|
||||
style: styles.focusBracket
|
||||
});
|
||||
};
|
||||
|
||||
function attemptFocus(element: any) {
|
||||
if (!canUseDOM) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
element.focus();
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
return document.activeElement === element;
|
||||
}
|
||||
|
||||
function focusFirstDescendant(element: any) {
|
||||
for (let i = 0; i < element.childNodes.length; i++) {
|
||||
const child = element.childNodes[i];
|
||||
if (attemptFocus(child) || focusFirstDescendant(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function focusLastDescendant(element: any) {
|
||||
for (let i = element.childNodes.length - 1; i >= 0; i--) {
|
||||
const child = element.childNodes[i];
|
||||
if (attemptFocus(child) || focusLastDescendant(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export type ModalFocusTrapProps = {|
|
||||
active?: boolean | (() => boolean),
|
||||
children?: any
|
||||
|};
|
||||
|
||||
const ModalFocusTrap = ({ active, children }: ModalFocusTrapProps) => {
|
||||
const trapElementRef = useRef<?HTMLElement>();
|
||||
const focusRef = useRef<{ trapFocusInProgress: boolean, lastFocusedElement: ?HTMLElement }>({
|
||||
trapFocusInProgress: false,
|
||||
lastFocusedElement: null
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (canUseDOM) {
|
||||
const trapFocus = () => {
|
||||
// We should not trap focus if:
|
||||
// - The modal hasn't fully initialized with an HTMLElement ref
|
||||
// - Focus is already in the process of being trapped (e.g., we're refocusing)
|
||||
// - isTrapActive prop being falsey tells us to do nothing
|
||||
if (trapElementRef.current == null || focusRef.current.trapFocusInProgress || !active) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
focusRef.current.trapFocusInProgress = true;
|
||||
if (
|
||||
document.activeElement instanceof Node &&
|
||||
!trapElementRef.current.contains(document.activeElement)
|
||||
) {
|
||||
// To handle keyboard focusing we can make an assumption here.
|
||||
// If you're tabbing through the focusable elements, the previously
|
||||
// active element will either be the first or the last.
|
||||
// If the previously selected element is the "first" descendant
|
||||
// and we're leaving it - this means that we should be looping
|
||||
// around to the other side of the modal.
|
||||
let hasFocused = focusFirstDescendant(trapElementRef.current);
|
||||
if (focusRef.current.lastFocusedElement === document.activeElement) {
|
||||
hasFocused = focusLastDescendant(trapElementRef.current);
|
||||
}
|
||||
// If we couldn't focus a new element then we need to focus onto the trap target
|
||||
if (!hasFocused && trapElementRef.current != null && document.activeElement) {
|
||||
UIManager.focus(trapElementRef.current);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
focusRef.current.trapFocusInProgress = false;
|
||||
}
|
||||
focusRef.current.lastFocusedElement = document.activeElement;
|
||||
};
|
||||
|
||||
// Call the trapFocus callback at least once when this modal has been activated.
|
||||
trapFocus();
|
||||
|
||||
document.addEventListener('focus', trapFocus, true);
|
||||
return () => document.removeEventListener('focus', trapFocus, true);
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FocusBracket />
|
||||
<View ref={trapElementRef}>{children}</View>
|
||||
<FocusBracket />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalFocusTrap;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
focusBracket: {
|
||||
outlineStyle: 'none'
|
||||
}
|
||||
});
|
||||
48
packages/react-native-web/src/exports/Modal/ModalPortal.js
vendored
Normal file
48
packages/react-native-web/src/exports/Modal/ModalPortal.js
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright (c) Nicolas Gallagher.
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
export type ModalPortalProps = {|
|
||||
children: any
|
||||
|};
|
||||
|
||||
function ModalPortal(props: ModalPortalProps) {
|
||||
const { children } = props;
|
||||
const elementRef = useRef(null);
|
||||
|
||||
if (canUseDOM && !elementRef.current) {
|
||||
const element = document.createElement('div');
|
||||
|
||||
if (element && document.body) {
|
||||
document.body.appendChild(element);
|
||||
elementRef.current = element;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (canUseDOM) {
|
||||
return () => {
|
||||
if (document.body && elementRef.current) {
|
||||
document.body.removeChild(elementRef.current);
|
||||
elementRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return elementRef.current && canUseDOM
|
||||
? ReactDOM.createPortal(children, elementRef.current)
|
||||
: null;
|
||||
}
|
||||
|
||||
export default ModalPortal;
|
||||
429
packages/react-native-web/src/exports/Modal/__tests__/index.js
vendored
Normal file
429
packages/react-native-web/src/exports/Modal/__tests__/index.js
vendored
Normal file
@@ -0,0 +1,429 @@
|
||||
/* eslint-env jasmine, jest */
|
||||
|
||||
import Modal from '..';
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
describe('components/Modal', () => {
|
||||
test('visible by default', () => {
|
||||
const { getByTestId } = render(
|
||||
<Modal>
|
||||
<a data-testid={'inside'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
);
|
||||
const insideElement = getByTestId('inside');
|
||||
expect(insideElement).not.toBeNull();
|
||||
expect(insideElement).not.toBe(document.body);
|
||||
});
|
||||
|
||||
test('render children when visible', () => {
|
||||
const { getByTestId } = render(
|
||||
<Modal visible={true}>
|
||||
<a data-testid={'inside'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
);
|
||||
const insideElement = getByTestId('inside');
|
||||
expect(insideElement).not.toBeNull();
|
||||
expect(insideElement).not.toBe(document.body);
|
||||
});
|
||||
|
||||
test('does not render children when not visible', () => {
|
||||
const { container } = render(
|
||||
<Modal visible={false}>
|
||||
<a data-testid={'inside'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
);
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
|
||||
test('invisible modals will not be the active modal', () => {
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<Modal key={'modal-a'} visible={true}>
|
||||
<a data-testid={'inside-a'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
<Modal key={'modal-b'} visible={false}>
|
||||
<a data-testid={'inside-b'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
const insideElement = getByTestId('inside-a');
|
||||
const dialogElements = document.body.querySelectorAll('[role=dialog]');
|
||||
expect(dialogElements.length).toBe(1);
|
||||
expect(dialogElements[0].contains(insideElement)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('multiple modals will only mark one as active', () => {
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<Modal key={'modal-a'} visible={true}>
|
||||
<a data-testid={'inside-a'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
<Modal key={'modal-b'} visible={true}>
|
||||
<a data-testid={'inside-b'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
const insideElement = getByTestId('inside-b');
|
||||
const dialogElements = document.body.querySelectorAll('[role=dialog]');
|
||||
expect(dialogElements.length).toBe(1);
|
||||
expect(dialogElements[0].contains(insideElement)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('modal active state changes propogate', () => {
|
||||
const { rerender, getByTestId } = render(
|
||||
<>
|
||||
<Modal key={'modal-a'} visible={true}>
|
||||
<a data-testid={'inside-a'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
<Modal key={'modal-b'} visible={false}>
|
||||
<a data-testid={'inside-b'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<Modal key={'modal-a'} visible={true}>
|
||||
<a data-testid={'inside-a'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
<Modal key={'modal-b'} visible={true}>
|
||||
<a data-testid={'inside-b'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
const insideElement = getByTestId('inside-b');
|
||||
const dialogElements = document.body.querySelectorAll('[role=dialog]');
|
||||
expect(dialogElements.length).toBe(1);
|
||||
expect(dialogElements[0].contains(insideElement)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('removed modal sets others active state', () => {
|
||||
const { rerender, getByTestId } = render(
|
||||
<>
|
||||
<Modal key={'modal-a'} visible={true}>
|
||||
<a data-testid={'inside-a'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
<Modal key={'modal-b'} visible={true}>
|
||||
<a data-testid={'inside-b'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
rerender(
|
||||
<Modal key={'modal-a'} visible={true}>
|
||||
<a data-testid={'inside-a'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const insideElement = getByTestId('inside-a');
|
||||
const dialogElements = document.body.querySelectorAll('[role=dialog]');
|
||||
expect(dialogElements.length).toBe(1);
|
||||
expect(dialogElements[0].contains(insideElement)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('executes onShow callback when initially showing', () => {
|
||||
const onShowCallback = jest.fn();
|
||||
render(<Modal onShow={onShowCallback} visible={true} />);
|
||||
expect(onShowCallback).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not execute onShow callback when initially hidden', () => {
|
||||
const onShowCallback = jest.fn();
|
||||
render(<Modal onShow={onShowCallback} visible={false} />);
|
||||
expect(onShowCallback).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test('does not execute onDismiss callback when initially hidden', () => {
|
||||
const onDismissCallback = jest.fn();
|
||||
render(<Modal onDismiss={onDismissCallback} visible={false} />);
|
||||
expect(onDismissCallback).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test('does not execute onDismiss callback when initially showing', () => {
|
||||
const onDismissCallback = jest.fn();
|
||||
render(<Modal onDismiss={onDismissCallback} visible={true} />);
|
||||
expect(onDismissCallback).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test('executes onShow callback when visibility changes', () => {
|
||||
const onShowCallback = jest.fn();
|
||||
const { rerender } = render(<Modal onShow={onShowCallback} visible={false} />);
|
||||
expect(onShowCallback).toBeCalledTimes(0);
|
||||
rerender(<Modal onShow={onShowCallback} visible={true} />);
|
||||
expect(onShowCallback).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('executes onDismiss callback when visibility changes', () => {
|
||||
const onDismissCallback = jest.fn();
|
||||
const { rerender } = render(<Modal onDismiss={onDismissCallback} visible={true} />);
|
||||
expect(onDismissCallback).toBeCalledTimes(0);
|
||||
rerender(<Modal onDismiss={onDismissCallback} visible={false} />);
|
||||
expect(onDismissCallback).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('animationTypes none is the same as omitting', () => {
|
||||
const { rerender, baseElement } = render(<Modal animationType={'none'} visible={true} />);
|
||||
const animationNoneElement = baseElement.lastChild.lastChild;
|
||||
const animationNoneStyle = window.getComputedStyle(animationNoneElement, null);
|
||||
rerender(<Modal visible={true} />);
|
||||
const animationMissingElement = baseElement.lastChild.lastChild;
|
||||
const animationMissingStyle = window.getComputedStyle(animationMissingElement, null);
|
||||
const styleProps = new Set();
|
||||
|
||||
for (let i = 0; i < animationNoneStyle.length; i++) {
|
||||
styleProps.add(animationNoneStyle[i]);
|
||||
}
|
||||
|
||||
for (let i = 0; i < animationMissingStyle.length; i++) {
|
||||
styleProps.add(animationMissingStyle[i]);
|
||||
}
|
||||
|
||||
for (const prop of styleProps) {
|
||||
expect(animationNoneStyle[prop]).toEqual(animationMissingStyle[prop]);
|
||||
}
|
||||
});
|
||||
|
||||
test('creates view with role="modal" when active', () => {
|
||||
const { baseElement } = render(
|
||||
<Modal visible={true}>
|
||||
<a href={'#hello'}>Hello</a>
|
||||
</Modal>
|
||||
);
|
||||
const dialogElement = baseElement.lastChild.querySelector('[role="dialog"]');
|
||||
expect(dialogElement).not.toBeNull();
|
||||
expect(dialogElement.getAttribute('role')).toBe('dialog');
|
||||
expect(dialogElement.getAttribute('aria-modal')).toBe('true');
|
||||
});
|
||||
|
||||
test('focus is trapped by default', () => {
|
||||
render(
|
||||
<>
|
||||
<a data-testid={'outside'} href={'#outside'}>
|
||||
Outside
|
||||
</a>
|
||||
<Modal>
|
||||
<a data-testid={'inside'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
const outsideElement = document.querySelector('[data-testid="outside"]');
|
||||
const insideElement = document.querySelector('[data-testid="inside"]');
|
||||
outsideElement.focus();
|
||||
expect(document.activeElement).toBe(insideElement);
|
||||
});
|
||||
|
||||
test('focus is trapped when active flag changes', () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<a data-testid={'outside'} href={'#outside'}>
|
||||
Outside
|
||||
</a>
|
||||
<Modal visible={false}>
|
||||
<a data-testid={'inside'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
const outsideElement = document.querySelector('[data-testid="outside"]');
|
||||
outsideElement.focus();
|
||||
expect(document.activeElement).toBe(outsideElement);
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<a data-testid={'outside'} href={'#outside'}>
|
||||
Outside
|
||||
</a>
|
||||
<Modal visible={true}>
|
||||
<a data-testid={'inside'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
const insideElement = document.querySelector('[data-testid="inside"]');
|
||||
expect(document.activeElement).toBe(insideElement);
|
||||
});
|
||||
|
||||
test('focus is trapped when active', () => {
|
||||
render(
|
||||
<>
|
||||
<a data-testid={'outside'} href={'#outside'}>
|
||||
Outside
|
||||
</a>
|
||||
<Modal visible={true}>
|
||||
<a data-testid={'inside'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
const outsideElement = document.querySelector('[data-testid="outside"]');
|
||||
const insideElement = document.querySelector('[data-testid="inside"]');
|
||||
outsideElement.focus();
|
||||
expect(document.activeElement).toBe(insideElement);
|
||||
});
|
||||
|
||||
test('focus wraps forwards', () => {
|
||||
render(
|
||||
<>
|
||||
<Modal visible={true}>
|
||||
<a data-testid={'inside-a'} href={'#'}>
|
||||
Inside A
|
||||
</a>
|
||||
<a data-testid={'inside-b'} href={'#'}>
|
||||
Inside B
|
||||
</a>
|
||||
<a data-testid={'inside-c'} href={'#'}>
|
||||
Inside C
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
const insideStartElement = document.querySelector('[data-testid="inside-a"]');
|
||||
const insideEndElement = document.querySelector('[data-testid="inside-c"]');
|
||||
// This is ugly - perhaps there's a better way?
|
||||
const focusBracket = insideEndElement.parentNode.parentNode.parentNode.nextSibling;
|
||||
insideEndElement.focus();
|
||||
focusBracket.focus();
|
||||
expect(document.activeElement).toBe(insideStartElement);
|
||||
});
|
||||
|
||||
test('focus wraps backwards', () => {
|
||||
render(
|
||||
<>
|
||||
<Modal visible={true}>
|
||||
<a data-testid={'inside-a'} href={'#'}>
|
||||
Inside A
|
||||
</a>
|
||||
<a data-testid={'inside-b'} href={'#'}>
|
||||
Inside B
|
||||
</a>
|
||||
<a data-testid={'inside-c'} href={'#'}>
|
||||
Inside C
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
const insideStartElement = document.querySelector('[data-testid="inside-a"]');
|
||||
const insideEndElement = document.querySelector('[data-testid="inside-c"]');
|
||||
// This is ugly - perhaps there's a better way?
|
||||
const focusBracket = insideEndElement.parentNode.parentNode.parentNode.previousSibling;
|
||||
insideStartElement.focus();
|
||||
focusBracket.focus();
|
||||
expect(document.activeElement).toBe(insideEndElement);
|
||||
});
|
||||
|
||||
test('focus is trapped without contents', () => {
|
||||
render(
|
||||
<>
|
||||
<a data-testid={'outside'} href={'#outside'}>
|
||||
Outside
|
||||
</a>
|
||||
<Modal visible={true}>
|
||||
<div>There are no focusable contents.</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
const outsideElement = document.querySelector('[data-testid="outside"]');
|
||||
outsideElement.focus();
|
||||
expect(document.activeElement).not.toBe(outsideElement);
|
||||
expect(document.activeElement).not.toBe(document.body);
|
||||
});
|
||||
|
||||
test('focus is not trapped when inactive', () => {
|
||||
render(
|
||||
<>
|
||||
<a data-testid={'outside'} href={'#outside'}>
|
||||
Outside
|
||||
</a>
|
||||
<Modal visible={false}>
|
||||
<a data-testid={'inside'} href={'#hello'}>
|
||||
Hello
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
const outsideElement = document.querySelector('[data-testid="outside"]');
|
||||
outsideElement.focus();
|
||||
expect(document.activeElement).toBe(outsideElement);
|
||||
});
|
||||
|
||||
test('creates portal outside of the react container', () => {
|
||||
const { container, baseElement } = render(
|
||||
<Modal visible={true}>
|
||||
<a data-testid={'hello'} href={'#hello'}>
|
||||
Hello World
|
||||
</a>
|
||||
</Modal>
|
||||
);
|
||||
const helloAnchor = document.querySelector('[data-testid="hello"]');
|
||||
expect(container.children.length).toBe(0);
|
||||
expect(helloAnchor).not.toBeNull();
|
||||
expect(baseElement.firstChild).toBe(container);
|
||||
expect(baseElement.lastChild.firstChild.contains(helloAnchor)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('portal created is a div', () => {
|
||||
const { baseElement } = render(
|
||||
<Modal visible={true}>
|
||||
<a data-testid={'hello'} href={'#hello'}>
|
||||
Hello World
|
||||
</a>
|
||||
</Modal>
|
||||
);
|
||||
expect(baseElement.lastChild.tagName).toBe('DIV');
|
||||
});
|
||||
|
||||
test('ref must be set before `mount` hook', () => {
|
||||
const spy = jest.fn();
|
||||
|
||||
function TestComponent() {
|
||||
React.useEffect(() => spy('mount'), []);
|
||||
return <Modal visible={true}><a ref={(ref) => ref ? spy('ref') : spy('noref')} /></Modal>;
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
|
||||
expect(spy).toHaveBeenNthCalledWith(1, 'ref');
|
||||
expect(spy).toHaveBeenNthCalledWith(2, 'mount');
|
||||
});
|
||||
});
|
||||
132
packages/react-native-web/src/exports/Modal/index.js
vendored
132
packages/react-native-web/src/exports/Modal/index.js
vendored
@@ -1,2 +1,130 @@
|
||||
import UnimplementedView from '../../modules/UnimplementedView';
|
||||
export default UnimplementedView;
|
||||
/**
|
||||
* Copyright (c) Nicolas Gallagher.
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useMemo, useEffect, useState } from 'react';
|
||||
import ModalPortal from './ModalPortal';
|
||||
import ModalAnimation from './ModalAnimation';
|
||||
import ModalContent from './ModalContent';
|
||||
import ModalFocusTrap from './ModalFocusTrap';
|
||||
|
||||
export type ModalProps = {|
|
||||
animationType?: 'none' | 'slide' | 'fade',
|
||||
children: any,
|
||||
hardwareAccelerated?: ?boolean,
|
||||
onDismiss?: ?() => mixed,
|
||||
onOrientationChange?: ?(e: {| orientation: 'portrait' | 'landscape' |}) => void,
|
||||
onRequestClose?: ?() => void,
|
||||
onShow?: ?() => void,
|
||||
presentationStyle?: ?('fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen'),
|
||||
statusBarTranslucent?: ?boolean,
|
||||
supportedOrientations?: ?Array<
|
||||
'portrait' | 'portrait-upside-down' | 'landscape' | 'landscape-left' | 'landscape-right'
|
||||
>,
|
||||
transparent?: ?boolean,
|
||||
visible?: ?boolean
|
||||
|};
|
||||
|
||||
let uniqueModalIdentifier = 0;
|
||||
|
||||
const activeModalStack = [];
|
||||
const activeModalListeners = {};
|
||||
|
||||
function notifyActiveModalListeners() {
|
||||
if (activeModalStack.length === 0) {
|
||||
return;
|
||||
}
|
||||
const activeModalId = activeModalStack[activeModalStack.length - 1];
|
||||
activeModalStack.forEach(modalId => {
|
||||
if (modalId in activeModalListeners) {
|
||||
activeModalListeners[modalId](modalId === activeModalId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeActiveModal(modalId) {
|
||||
if (modalId in activeModalListeners) {
|
||||
// Before removing this listener we should probably tell it
|
||||
// that it's no longer the active modal for sure.
|
||||
activeModalListeners[modalId](false);
|
||||
delete activeModalListeners[modalId];
|
||||
}
|
||||
const index = activeModalStack.indexOf(modalId);
|
||||
if (index !== -1) {
|
||||
activeModalStack.splice(index, 1);
|
||||
notifyActiveModalListeners();
|
||||
}
|
||||
}
|
||||
|
||||
function addActiveModal(modalId, listener) {
|
||||
removeActiveModal(modalId);
|
||||
activeModalStack.push(modalId);
|
||||
activeModalListeners[modalId] = listener;
|
||||
notifyActiveModalListeners();
|
||||
}
|
||||
|
||||
const Modal = forwardRef<ModalProps, *>((props, forwardedRef) => {
|
||||
const {
|
||||
animationType,
|
||||
children,
|
||||
onDismiss,
|
||||
onRequestClose,
|
||||
onShow,
|
||||
transparent,
|
||||
visible = true
|
||||
} = props;
|
||||
|
||||
// Set a unique model identifier so we can correctly route
|
||||
// dismissals and check the layering of modals.
|
||||
const modalId = useMemo(() => uniqueModalIdentifier++, []);
|
||||
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
|
||||
const onDismissCallback = useCallback(() => {
|
||||
removeActiveModal(modalId);
|
||||
if (onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
}, [modalId, onDismiss]);
|
||||
|
||||
const onShowCallback = useCallback(() => {
|
||||
addActiveModal(modalId, setIsActive);
|
||||
if (onShow) {
|
||||
onShow();
|
||||
}
|
||||
}, [modalId, onShow]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => removeActiveModal(modalId);
|
||||
}, [modalId]);
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
<ModalAnimation
|
||||
animationType={animationType}
|
||||
onDismiss={onDismissCallback}
|
||||
onShow={onShowCallback}
|
||||
visible={visible}
|
||||
>
|
||||
<ModalFocusTrap active={isActive}>
|
||||
<ModalContent
|
||||
active={isActive}
|
||||
onRequestClose={onRequestClose}
|
||||
ref={forwardedRef}
|
||||
transparent={transparent}
|
||||
>
|
||||
{children}
|
||||
</ModalContent>
|
||||
</ModalFocusTrap>
|
||||
</ModalAnimation>
|
||||
</ModalPortal>
|
||||
);
|
||||
});
|
||||
|
||||
export default Modal;
|
||||
|
||||
Reference in New Issue
Block a user