Skip to content

Commit

Permalink
fix: make the menu keyboard-accessible in browser (callstack#1046)
Browse files Browse the repository at this point in the history
This PR makes sure that
- The correct item is focused when showing/hiding the menu
- The menu can be dismissed by the keyboard
- The menu is not rendered when not visible
  • Loading branch information
satya164 authored and Trancever committed Jun 27, 2019
1 parent 72da47e commit ad3eb62
Showing 1 changed file with 92 additions and 43 deletions.
135 changes: 92 additions & 43 deletions src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
TouchableWithoutFeedback,
View,
ViewStyle,
findNodeHandle,
} from 'react-native';

import { withTheme } from '../../core/theming';
Expand Down Expand Up @@ -56,6 +57,7 @@ type Props = {
type Layout = Omit<Omit<LayoutRectangle, 'x'>, 'y'>;

type State = {
rendered: boolean;
top: number;
left: number;
windowLayout: Layout;
Expand Down Expand Up @@ -131,7 +133,16 @@ class Menu extends React.Component<Props, State> {
statusBarHeight: APPROX_STATUSBAR_HEIGHT,
};

static getDerivedStateFromProps(nextProps: Props, prevState: State) {
if (nextProps.visible && !prevState.rendered) {
return { rendered: true };
}

return null;
}

state = {
rendered: this.props.visible,
top: 0,
left: 0,
windowLayout: { width: 0, height: 0 },
Expand All @@ -148,12 +159,11 @@ class Menu extends React.Component<Props, State> {
}

componentWillUnmount() {
BackHandler.removeEventListener('hardwareBackPress', this._handleDismiss);
Dimensions.removeEventListener('change', this._handleDismiss);
this._removeListeners();
}

_anchor?: View | null;
_menu?: View | null;
_anchor?: View | null = null;
_menu?: View | null = null;

_measureMenuLayout = () =>
new Promise<LayoutRectangle>(resolve => {
Expand All @@ -173,25 +183,65 @@ class Menu extends React.Component<Props, State> {
}
});

_updateVisibility = () => {
_updateVisibility = async () => {
// Menu is rendered in Portal, which updates items asynchronously
// We need to do the same here so that the ref is up-to-date
await Promise.resolve();

if (this.props.visible) {
this._show();
} else {
this._hide();
}
};

_isBrowser = () => 'document' in global;

_focusFirstDOMNode = (el: View | null | undefined) => {
if (el && this._isBrowser()) {
// When in the browser, we want to focus the first focusable item on toggle
// For example, when menu is shown, focus the first item in the menu
// And when menu is dismissed, send focus back to the button to resume tabbing
const node: any = findNodeHandle(el);
const focusableNode = node.querySelector(
// This is a rough list of selectors that can be focused
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);

focusableNode && focusableNode.focus();
}
};

_handleDismiss = () => {
if (this.props.visible) {
this.props.onDismiss();
}
return true;
};

_show = async () => {
_handleKeypress = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
this.props.onDismiss();
}
};

_attachListeners = () => {
BackHandler.addEventListener('hardwareBackPress', this._handleDismiss);
Dimensions.addEventListener('change', this._handleDismiss);

this._isBrowser() &&
document.addEventListener('keyup', this._handleKeypress);
};

_removeListeners = () => {
BackHandler.removeEventListener('hardwareBackPress', this._handleDismiss);
Dimensions.removeEventListener('change', this._handleDismiss);

this._isBrowser() &&
document.removeEventListener('keyup', this._handleKeypress);
};

_show = async () => {
const windowLayout = Dimensions.get('window');
const [menuLayout, anchorLayout] = await Promise.all([
this._measureMenuLayout(),
Expand All @@ -212,7 +262,6 @@ class Menu extends React.Component<Props, State> {
!anchorLayout.width ||
!anchorLayout.height
) {
BackHandler.removeEventListener('hardwareBackPress', this._handleDismiss);
setTimeout(this._show, ANIMATION_DURATION);
return;
}
Expand All @@ -235,6 +284,8 @@ class Menu extends React.Component<Props, State> {
},
}),
() => {
this._attachListeners();

Animated.parallel([
Animated.timing(this.state.scaleAnimation, {
toValue: { x: menuLayout.width, y: menuLayout.height },
Expand All @@ -248,14 +299,17 @@ class Menu extends React.Component<Props, State> {
easing: EASING,
useNativeDriver: true,
}),
]).start();
]).start(({ finished }) => {
if (finished) {
this._focusFirstDOMNode(this._menu);
}
});
}
);
};

_hide = () => {
BackHandler.removeEventListener('hardwareBackPress', this._handleDismiss);
Dimensions.removeEventListener('change', this._handleDismiss);
this._removeListeners();

Animated.timing(this.state.opacityAnimation, {
toValue: 0,
Expand All @@ -264,7 +318,10 @@ class Menu extends React.Component<Props, State> {
useNativeDriver: true,
}).start(finished => {
if (finished) {
this._focusFirstDOMNode(this._anchor);

this.state.scaleAnimation.setValue({ x: 0, y: 0 });
this.setState({ rendered: false });
}
});
};
Expand All @@ -281,6 +338,7 @@ class Menu extends React.Component<Props, State> {
} = this.props;

const {
rendered,
windowLayout,
menuLayout,
anchorLayout,
Expand Down Expand Up @@ -403,43 +461,34 @@ class Menu extends React.Component<Props, State> {
collapsable={false}
>
{anchor}
<Portal>
{visible ? (
{rendered ? (
<Portal>
<TouchableWithoutFeedback onPress={onDismiss}>
<View style={StyleSheet.absoluteFill} />
</TouchableWithoutFeedback>
) : null}
<View
ref={ref => {
// This hack is needed to properly show menu
// when visible is `true` initially
// because in componentDidMount _menu ref is undefined
// because it's rendered in portal
if (!this._menu) {
<View
ref={ref => {
this._menu = ref;
if (visible) {
this._show();
}
}
}}
collapsable={false}
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.wrapper, positionStyle, style]}
>
<Animated.View style={{ transform: positionTransforms }}>
<Surface
style={
[
styles.shadowMenuContainer,
shadowMenuContainerStyle,
] as StyleProp<ViewStyle>
}
>
{children}
</Surface>
</Animated.View>
</View>
</Portal>
}}
collapsable={false}
accessibilityViewIsModal={visible}
style={[styles.wrapper, positionStyle, style]}
>
<Animated.View style={{ transform: positionTransforms }}>
<Surface
style={
[
styles.shadowMenuContainer,
shadowMenuContainerStyle,
] as StyleProp<ViewStyle>
}
>
{children}
</Surface>
</Animated.View>
</View>
</Portal>
) : null}
</View>
);
}
Expand Down

0 comments on commit ad3eb62

Please sign in to comment.