Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mobile: Fixes #11028: Accessibility: Fix sidebar broken in right-to-left mode, improve screen reader accessibility #11056

Merged
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
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,7 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,7 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
Expand Down
23 changes: 0 additions & 23 deletions packages/app-mobile/components/SideMenu.ts

This file was deleted.

326 changes: 326 additions & 0 deletions packages/app-mobile/components/SideMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { AccessibilityInfo, Animated, Dimensions, Easing, I18nManager, LayoutChangeEvent, PanResponder, Pressable, StyleSheet, useWindowDimensions, View } from 'react-native';
import { State } from '@joplin/lib/reducer';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import AccessibleView from './accessibility/AccessibleView';
import { _ } from '@joplin/lib/locale';
import useReduceMotionEnabled from '../utils/hooks/useReduceMotionEnabled';
import { themeStyle } from './global-style';

export enum SideMenuPosition {
Left = 'left',
Right = 'right',
}

export type OnChangeCallback = (isOpen: boolean)=> void;

interface Props {
themeId: number;
isOpen: boolean;

menu: React.ReactNode;
children: React.ReactNode|React.ReactNode[];
edgeHitWidth: number;
toleranceX: number;
toleranceY: number;
openMenuOffset: number;
menuPosition: SideMenuPosition;

onChange: OnChangeCallback;
disableGestures: boolean;
}

interface UseStylesProps {
themeId: number;
isLeftMenu: boolean;
menuWidth: number;
menuOpenFraction: Animated.AnimatedInterpolation<number>;
}

const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStylesProps) => {
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
mainContainer: {
display: 'flex',
alignContent: 'stretch',
height: windowHeight,
},
contentOuterWrapper: {
width: windowWidth,
height: windowHeight,
transform: [{
translateX: menuOpenFraction.interpolate({
inputRange: [0, 1],
outputRange: [0, isLeftMenu ? menuWidth : -menuWidth],
}),
// The RN Animation docs suggests setting "perspective" while setting other transform styles:
// https://reactnative.dev/docs/animations#bear-in-mind
}, { perspective: 1000 }],
},
contentWrapper: {
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
},
menuWrapper: {
position: 'absolute',
height: windowHeight,
width: menuWidth,

// In React Native, RTL replaces `left` with `right` and `right` with `left`.
// As such, we need to reverse the normal direction in RTL mode.
...(isLeftMenu === !I18nManager.isRTL ? {
left: 0,
} : {
right: 0,
}),
},
closeButtonOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,

zIndex: 1,
width: windowWidth,
height: windowHeight,

opacity: menuOpenFraction.interpolate({
inputRange: [0, 1],
outputRange: [0, 0.1],
extrapolate: 'clamp',
}),
backgroundColor: theme.colorFaded,
display: 'flex',
alignContent: 'stretch',
},
overlayContent: {
height: windowHeight,
width: windowWidth,
},
});
}, [themeId, isLeftMenu, windowWidth, windowHeight, menuWidth, menuOpenFraction]);
};

interface UseAnimationsProps {
menuWidth: number;
isLeftMenu: boolean;
open: boolean;
}

const useAnimations = ({ menuWidth, isLeftMenu, open }: UseAnimationsProps) => {
const [animating, setIsAnimating] = useState(false);
const menuDragOffset = useMemo(() => new Animated.Value(0), []);
const basePositioningFraction = useMemo(() => new Animated.Value(0), []);
const maximumDragOffsetValue = useMemo(() => new Animated.Value(1), []);

// Update the value in a useEffect to prevent delays in applying the animation caused by
// re-renders.
useEffect(() => {
// In a right-side menu, the drag offset increases while the menu is closing.
// It needs to be inverted in that case:
// || 1: Prevents division by zero
maximumDragOffsetValue.setValue((menuWidth || 1) * (isLeftMenu ? 1 : -1));
}, [menuWidth, isLeftMenu, maximumDragOffsetValue]);

const menuOpenFraction = useMemo(() => {
const animatedDragFraction = Animated.divide(menuDragOffset, maximumDragOffsetValue);

return Animated.add(basePositioningFraction, animatedDragFraction);
}, [menuDragOffset, basePositioningFraction, maximumDragOffsetValue]);

const reduceMotionEnabled = useReduceMotionEnabled();
const reduceMotionEnabledRef = useRef(false);
reduceMotionEnabledRef.current = reduceMotionEnabled;

const updateMenuPosition = useCallback(() => {
const baseAnimationProps = {
easing: Easing.elastic(0.5),
duration: reduceMotionEnabledRef.current ? 0 : 200,
useNativeDriver: true,
};
setIsAnimating(true);

const animation = Animated.parallel([
Animated.timing(basePositioningFraction, { toValue: open ? 1 : 0, ...baseAnimationProps }),
Animated.timing(menuDragOffset, { toValue: 0, ...baseAnimationProps }),
]);
animation.start((result) => {
if (result.finished) {
setIsAnimating(false);
}
});
}, [open, menuDragOffset, basePositioningFraction]);
useEffect(() => {
updateMenuPosition();
}, [updateMenuPosition]);

return { setIsAnimating, animating, updateMenuPosition, menuOpenFraction, menuDragOffset };
};

const SideMenuComponent: React.FC<Props> = props => {
const [open, setIsOpen] = useState(false);

useEffect(() => {
setIsOpen(props.isOpen);
}, [props.isOpen]);

const [menuWidth, setMenuWidth] = useState(0);
const [contentWidth, setContentWidth] = useState(0);

// In right-to-left layout, swap left and right to be consistent with other parts of
// the app's layout.
const isLeftMenu = props.menuPosition === (I18nManager.isRTL ? SideMenuPosition.Right : SideMenuPosition.Left);

const onLayoutChange = useCallback((e: LayoutChangeEvent) => {
const { width } = e.nativeEvent.layout;
const openMenuOffsetPercentage = props.openMenuOffset / Dimensions.get('window').width;
const menuWidth = Math.floor(width * openMenuOffsetPercentage);

setContentWidth(width);
setMenuWidth(menuWidth);
}, [props.openMenuOffset]);

const { animating, setIsAnimating, menuDragOffset, updateMenuPosition, menuOpenFraction } = useAnimations({
isLeftMenu, menuWidth, open,
});

const panResponder = useMemo(() => {
return PanResponder.create({
onMoveShouldSetPanResponderCapture: (_event, gestureState) => {
if (props.disableGestures) {
return false;
}

let startX;
let dx;
const dy = gestureState.dy;

// Untransformed start position of the gesture -- moveX is the current position of
// the pointer. Subtracting dx gives us the original start position.
const gestureStartScreenX = gestureState.moveX - gestureState.dx;

// Transform x, dx such that they are relative to the target screen edge -- this simplifies later
// math.
if (isLeftMenu) {
startX = gestureStartScreenX;
dx = gestureState.dx;
} else {
startX = contentWidth - gestureStartScreenX;
dx = -gestureState.dx;
}

const motionWithinToleranceY = Math.abs(dy) <= props.toleranceY;
let startWithinTolerance, motionWithinToleranceX;
if (open) {
startWithinTolerance = startX >= menuWidth - props.edgeHitWidth;
motionWithinToleranceX = dx <= -props.toleranceX;
} else {
startWithinTolerance = startX <= props.edgeHitWidth;
motionWithinToleranceX = dx >= props.toleranceX;
}

return startWithinTolerance && motionWithinToleranceX && motionWithinToleranceY;
},
onPanResponderGrant: () => {
setIsAnimating(true);
},
onPanResponderMove: Animated.event([
null,
// Updates menuDragOffset with the .dx property of the second argument:
{ dx: menuDragOffset },
], { useNativeDriver: false }),
onPanResponderEnd: (_event, gestureState) => {
const newOpen = (gestureState.dx > 0) === isLeftMenu;
if (newOpen === open) {
updateMenuPosition();
} else {
setIsOpen(newOpen);
}
},
});
}, [isLeftMenu, menuDragOffset, menuWidth, props.toleranceX, props.toleranceY, contentWidth, open, props.disableGestures, props.edgeHitWidth, updateMenuPosition, setIsAnimating]);

const onChangeRef = useRef(props.onChange);
onChangeRef.current = props.onChange;
useEffect(() => {
onChangeRef.current(open);

AccessibilityInfo.announceForAccessibility(
open ? _('Side menu opened') : _('Side menu closed'),
);
}, [open]);

const onCloseButtonPress = useCallback(() => {
setIsOpen(false);
// Set isAnimating as soon as possible to avoid components disappearing, then reappearing.
setIsAnimating(true);
}, [setIsAnimating]);

const styles = useStyles({ themeId: props.themeId, menuOpenFraction, menuWidth, isLeftMenu });

const menuComponent = (
<AccessibleView
inert={!open}
style={styles.menuWrapper}
>
<AccessibleView
// Auto-focuses an empty view at the beginning of the sidemenu -- if we instead
// focus the container view, VoiceOver fails to focus to any components within
// the sidebar.
refocusCounter={!open ? 1 : undefined}
/>

{props.menu}
</AccessibleView>
);

const contentComponent = (
<AccessibleView
inert={open}
style={styles.contentWrapper}
>
<AccessibleView refocusCounter={open ? 1 : undefined} />
{props.children}
</AccessibleView>
);
const closeButtonOverlay = (open || animating) ? (
<Animated.View
style={styles.closeButtonOverlay}
>
<Pressable
aria-label={_('Close side menu')}
role='button'
onPress={onCloseButtonPress}
style={styles.overlayContent}
></Pressable>
</Animated.View>
) : null;

return (
<View
onLayout={onLayoutChange}
style={styles.mainContainer}
{...panResponder.panHandlers}
>
{menuComponent}
<Animated.View style={styles.contentOuterWrapper}>
{contentComponent}
{closeButtonOverlay}
</Animated.View>
</View>
);
};

const SideMenu = connect((state: State) => {
return {
themeId: state.settings.theme,
isOpen: state.showSideMenu,
};
})(SideMenuComponent);

export default SideMenu;
Loading
Loading