Skip to content

Commit

Permalink
[change] Add Modal implementation
Browse files Browse the repository at this point in the history
A Modal implementation using CSS animations and ARIA.

The app is hidden from screen readers via `aria-modal`. Focus is contained
within the modal. When the `Escape` key is pressed, the `onRequestClose`
function is called on the top-most modal.

Close #1646
Fix #1020
  • Loading branch information
imnotjames authored and necolas committed Aug 26, 2020
1 parent 98d74da commit d6e83d8
Show file tree
Hide file tree
Showing 13 changed files with 1,141 additions and 3 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)). |
Expand Down
86 changes: 86 additions & 0 deletions packages/docs/src/components/Modal/Modal.stories.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Meta, Props, 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

## Props

| Name | Type | Default |
| ------------------------- | --------- | ------- |
| animationType | ?string | 'none' |
| disabled | ?boolean | false |
| onDismiss | ?Function | |
| onRequestClose | ?Function | |
| onShow | ?Function | |
| transparent | ?boolean | false |
| visible | ?boolean | false |


### 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="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="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="transparent">
<Stories.transparentModal />
</Story>
</Preview>

### visible

Whether or not the modal is visible.

When set to `false` the contents are not rendered and the modal removes itself
from the screen.

<Preview withSource='none'>
<Story name="visible">
<Stories.simpleModal />
</Story>
</Preview>
32 changes: 32 additions & 0 deletions packages/docs/src/components/Modal/examples/Animated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React, { useState } from 'react';
import { Modal, Text, Button } from 'react-native';

function Animated({ animationType }) {
const [isVisible, setIsVisible] = useState(false);

return (
<>
<Button onPress={() => setIsVisible(true)} title={`Open Modal with '${animationType}'`} />
<Modal
animationType={animationType}
onRequestClose={() => setIsVisible(false)}
visible={isVisible}
>
<Text>This is in the {animationType} Modal. Hello!</Text>
<Button onPress={() => setIsVisible(false)} title={'Close Modal'} />
</Modal>
</>
);
}

export default function() {
return (
<>
<Animated animationType={'none'} />
<Text>&nbsp;</Text>
<Animated animationType={'slide'} />
<Text>&nbsp;</Text>
<Animated animationType={'fade'} />
</>
);
}
55 changes: 55 additions & 0 deletions packages/docs/src/components/Modal/examples/Modalception.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, { useState, useMemo } from 'react';
import { Modal, View, Text, Button } from 'react-native';

const WIGGLE_ROOM = 128;

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={[style.backdrop]} />
<View style={[style.modal, offset]}>
<View style={[style.container]}>
<Text>This is in Modal {depth}. Hello!</Text>
<Modalception depth={depth + 1} />
<Button onPress={() => setIsVisible(false)} title={'Close Modal'} />
</View>
</View>
</Modal>
</>
);
}

const style = {
backdrop: {
zIndex: -1,
position: 'fixed',
background: 'rgba(255, 255, 255, 0.6)',
top: 0,
left: 0,
right: 0,
bottom: 0
},
modal: {
justifyContent: 'center',
alignItems: 'center',
position: 'relative'
},
container: {
width: 600,
background: 'white',
border: '1px solid black',
margin: '40px',
padding: '20px'
}
};
16 changes: 16 additions & 0 deletions packages/docs/src/components/Modal/examples/Simple.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React, { useState } from 'react';
import { Modal, Text, Button } from 'react-native';

export default function() {
const [isVisible, setIsVisible] = useState(false);

return (
<>
<Button onPress={() => setIsVisible(true)} title={'Open Modal'} />
<Modal onRequestClose={() => setIsVisible(false)} visible={isVisible}>
<Text>Hello, World!</Text>
<Button onPress={() => setIsVisible(false)} title={'Close Modal'} />
</Modal>
</>
);
}
16 changes: 16 additions & 0 deletions packages/docs/src/components/Modal/examples/Transparent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React, { useState } from 'react';
import { Modal, Text, Button } from 'react-native';

export default function() {
const [isVisible, setIsVisible] = useState(false);

return (
<>
<Button onPress={() => setIsVisible(true)} title={'Open Modal'} />
<Modal transparent visible={isVisible}>
<Text>This Modal has a Transparent Background.</Text>
<Button onPress={() => setIsVisible(false)} title={'Close Modal'} />
</Modal>
</>
);
}
4 changes: 4 additions & 0 deletions packages/docs/src/components/Modal/examples/index.js
Original file line number Diff line number Diff line change
@@ -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';
130 changes: 130 additions & 0 deletions packages/react-native-web/src/exports/Modal/ModalAnimation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* 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, useState, useCallback } 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 = {|
children?: any,
animationType: ?('none' | 'slide' | 'fade'),
visible?: ?boolean,
onShow?: ?() => void,
onDismiss?: ?() => void
|};

function ModalAnimation(props: ModalAnimationProps) {
const { children, animationType, visible, onShow, onDismiss } = props;

const [isRendered, setIsRendered] = useState(visible);

const isAnimated = animationType !== 'none';

const animationEndCallback = useCallback(() => {
if (visible) {
if (onShow != null) {
onShow();
}
} else {
setIsRendered(false);
if (onDismiss != null) {
onDismiss();
}
}
}, [onDismiss, onShow, visible]);

useEffect(() => {
if (visible) {
setIsRendered(true);
}
if (!isAnimated) {
// Manually call `animationEndCallback` if no animation
animationEndCallback();
}
}, [isAnimated, visible, animationEndCallback]);

return isRendered
? createElement('div', {
children,
onAnimationEnd: animationEndCallback,
style: getAnimationStyle(animationType, visible)
})
: 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;
Loading

0 comments on commit d6e83d8

Please sign in to comment.