[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:
James Ward
2020-09-08 11:45:37 -07:00
committed by Nicolas Gallagher
parent 6bd41a622a
commit d97a1ca567
13 changed files with 1217 additions and 3 deletions

View File

@@ -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)). |

View 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>

View 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
}
});

View 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
}
});

View 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
}
});

View 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
}
});

View 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';

View 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;

View 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;

View 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'
}
});

View 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;

View 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');
});
});

View File

@@ -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;