1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-07-16 00:14:34 +02:00

Mobile: Accessibility: Improve focus handling in the note actions menu and modal dialogs (#11929)

This commit is contained in:
Henry Heino
2025-03-08 03:53:06 -08:00
committed by GitHub
parent 0430ccb3e7
commit 1aa0f11670
20 changed files with 573 additions and 126 deletions

View File

@ -685,7 +685,14 @@ packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/TextInput.js packages/app-mobile/components/TextInput.js
packages/app-mobile/components/ToggleSpaceButton.js packages/app-mobile/components/ToggleSpaceButton.js
packages/app-mobile/components/accessibility/AccessibleModalMenu.js packages/app-mobile/components/accessibility/AccessibleModalMenu.js
packages/app-mobile/components/accessibility/AccessibleView.test.js
packages/app-mobile/components/accessibility/AccessibleView.js packages/app-mobile/components/accessibility/AccessibleView.js
packages/app-mobile/components/accessibility/FocusControl/AutoFocusProvider.js
packages/app-mobile/components/accessibility/FocusControl/FocusControl.js
packages/app-mobile/components/accessibility/FocusControl/FocusControlProvider.js
packages/app-mobile/components/accessibility/FocusControl/MainAppContent.js
packages/app-mobile/components/accessibility/FocusControl/ModalWrapper.js
packages/app-mobile/components/accessibility/FocusControl/types.js
packages/app-mobile/components/app-nav.js packages/app-mobile/components/app-nav.js
packages/app-mobile/components/base-screen.js packages/app-mobile/components/base-screen.js
packages/app-mobile/components/biometrics/BiometricPopup.js packages/app-mobile/components/biometrics/BiometricPopup.js

7
.gitignore vendored
View File

@ -660,7 +660,14 @@ packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/TextInput.js packages/app-mobile/components/TextInput.js
packages/app-mobile/components/ToggleSpaceButton.js packages/app-mobile/components/ToggleSpaceButton.js
packages/app-mobile/components/accessibility/AccessibleModalMenu.js packages/app-mobile/components/accessibility/AccessibleModalMenu.js
packages/app-mobile/components/accessibility/AccessibleView.test.js
packages/app-mobile/components/accessibility/AccessibleView.js packages/app-mobile/components/accessibility/AccessibleView.js
packages/app-mobile/components/accessibility/FocusControl/AutoFocusProvider.js
packages/app-mobile/components/accessibility/FocusControl/FocusControl.js
packages/app-mobile/components/accessibility/FocusControl/FocusControlProvider.js
packages/app-mobile/components/accessibility/FocusControl/MainAppContent.js
packages/app-mobile/components/accessibility/FocusControl/ModalWrapper.js
packages/app-mobile/components/accessibility/FocusControl/types.js
packages/app-mobile/components/app-nav.js packages/app-mobile/components/app-nav.js
packages/app-mobile/components/base-screen.js packages/app-mobile/components/base-screen.js
packages/app-mobile/components/biometrics/BiometricPopup.js packages/app-mobile/components/biometrics/BiometricPopup.js

View File

@ -1,7 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import { RefObject, useCallback, useMemo, useRef } from 'react'; import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import { GestureResponderEvent, Modal, ModalProps, ScrollView, StyleSheet, View, ViewStyle, useWindowDimensions } from 'react-native'; import { GestureResponderEvent, Modal, ModalProps, Platform, ScrollView, StyleSheet, View, ViewStyle, useWindowDimensions } from 'react-native';
import { hasNotch } from 'react-native-device-info'; import { hasNotch } from 'react-native-device-info';
import FocusControl from './accessibility/FocusControl/FocusControl';
import { msleep, Second } from '@joplin/utils/time';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { ModalState } from './accessibility/FocusControl/types';
interface ModalElementProps extends ModalProps { interface ModalElementProps extends ModalProps {
children: React.ReactNode; children: React.ReactNode;
@ -67,6 +71,36 @@ const useBackgroundTouchListeners = (onRequestClose: (event: GestureResponderEve
return { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished }; return { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished };
}; };
const useModalStatus = (containerComponent: View|null, visible: boolean) => {
const contentMounted = !!containerComponent;
const [controlsFocus, setControlsFocus] = useState(false);
useAsyncEffect(async (event) => {
if (contentMounted) {
setControlsFocus(true);
} else {
// Accessibility: Work around Android's default focus-setting behavior.
// By default, React Native's Modal on Android sets focus about 0.8 seconds
// after the modal is dismissed. As a result, the Modal controls focus until
// roughly one second after the modal is dismissed.
if (Platform.OS === 'android') {
await msleep(Second);
}
if (!event.cancelled) {
setControlsFocus(false);
}
}
}, [contentMounted]);
let modalStatus = ModalState.Closed;
if (controlsFocus) {
modalStatus = visible ? ModalState.Open : ModalState.Closing;
} else if (visible) {
modalStatus = ModalState.Open;
}
return modalStatus;
};
const ModalElement: React.FC<ModalElementProps> = ({ const ModalElement: React.FC<ModalElementProps> = ({
children, children,
containerStyle, containerStyle,
@ -84,29 +118,36 @@ const ModalElement: React.FC<ModalElementProps> = ({
</View> </View>
); );
const backgroundRef = useRef<View>();
const { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished } = useBackgroundTouchListeners(modalProps.onRequestClose, backgroundRef); const [containerComponent, setContainerComponent] = useState<View|null>(null);
const modalStatus = useModalStatus(containerComponent, modalProps.visible);
const containerRef = useRef<View|null>(null);
containerRef.current = containerComponent;
const { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished } = useBackgroundTouchListeners(modalProps.onRequestClose, containerRef);
const contentAndBackdrop = <View const contentAndBackdrop = <View
ref={backgroundRef} ref={setContainerComponent}
style={styles.modalBackground} style={styles.modalBackground}
onStartShouldSetResponder={onShouldBackgroundCaptureTouch} onStartShouldSetResponder={onShouldBackgroundCaptureTouch}
onResponderRelease={onBackgroundTouchFinished} onResponderRelease={onBackgroundTouchFinished}
>{content}</View>; >{content}</View>;
// supportedOrientations: On iOS, this allows the dialog to be shown in non-portrait orientations.
return ( return (
<Modal <FocusControl.ModalWrapper state={modalStatus}>
supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']} <Modal
{...modalProps} // supportedOrientations: On iOS, this allows the dialog to be shown in non-portrait orientations.
> supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']}
{scrollOverflow ? ( {...modalProps}
<ScrollView >
style={styles.modalScrollView} {scrollOverflow ? (
contentContainerStyle={styles.modalScrollViewContent} <ScrollView
>{contentAndBackdrop}</ScrollView> style={styles.modalScrollView}
) : contentAndBackdrop} contentContainerStyle={styles.modalScrollViewContent}
</Modal> >{contentAndBackdrop}</ScrollView>
) : contentAndBackdrop}
</Modal>
</FocusControl.ModalWrapper>
); );
}; };

View File

@ -5,6 +5,8 @@ import { themeStyle } from '../global-style';
import { Menu, MenuOption as MenuOptionComponent, MenuOptions, MenuTrigger } from 'react-native-popup-menu'; import { Menu, MenuOption as MenuOptionComponent, MenuOptions, MenuTrigger } from 'react-native-popup-menu';
import AccessibleView from '../accessibility/AccessibleView'; import AccessibleView from '../accessibility/AccessibleView';
import debounce from '../../utils/debounce'; import debounce from '../../utils/debounce';
import FocusControl from '../accessibility/FocusControl/FocusControl';
import { ModalState } from '../accessibility/FocusControl/types';
interface MenuOptionDivider { interface MenuOptionDivider {
isDivider: true; isDivider: true;
@ -81,18 +83,19 @@ const MenuComponent: React.FC<Props> = props => {
// When undefined/null: Don't auto-focus anything. // When undefined/null: Don't auto-focus anything.
const [refocusCounter, setRefocusCounter] = useState<number|undefined>(undefined); const [refocusCounter, setRefocusCounter] = useState<number|undefined>(undefined);
let key = 0; let keyCounter = 0;
let isFirst = true; let isFirst = true;
for (const option of props.options) { for (const option of props.options) {
if (option.isDivider === true) { if (option.isDivider === true) {
menuOptionComponents.push( menuOptionComponents.push(
<View key={`menuOption_divider_${key++}`} style={styles.divider} />, <View key={`menuOption_divider_${keyCounter++}`} style={styles.divider} />,
); );
} else { } else {
const canAutoFocus = isFirst; const canAutoFocus = isFirst;
const key = `menuOption_${option.key ?? keyCounter++}`;
menuOptionComponents.push( menuOptionComponents.push(
<MenuOptionComponent value={option.onPress} key={`menuOption_${option.key ?? key++}`} style={styles.contextMenuItem} disabled={!!option.disabled}> <MenuOptionComponent value={option.onPress} key={key} style={styles.contextMenuItem} disabled={!!option.disabled}>
<AccessibleView refocusCounter={canAutoFocus ? refocusCounter : undefined}> <AccessibleView refocusCounter={canAutoFocus ? refocusCounter : undefined} testID={key}>
<Text <Text
style={option.disabled ? styles.contextMenuItemTextDisabled : styles.contextMenuItemText} style={option.disabled ? styles.contextMenuItemTextDisabled : styles.contextMenuItemText}
disabled={!!option.disabled} disabled={!!option.disabled}
@ -105,42 +108,47 @@ const MenuComponent: React.FC<Props> = props => {
} }
} }
const [open, setOpen] = useState(false);
const onMenuItemSelect = useCallback((value: unknown) => { const onMenuItemSelect = useCallback((value: unknown) => {
if (typeof value === 'function') { if (typeof value === 'function') {
value(); value();
} }
setRefocusCounter(undefined); setRefocusCounter(undefined);
setOpen(false);
}, []); }, []);
// debounce: If the menu is focused during its transition animation, it briefly // debounce: If the menu is focused during its transition animation, it briefly
// appears to be in the wrong place. As such, add a brief delay before focusing. // appears to be in the wrong place. As such, add a brief delay before focusing.
const onMenuOpen = useMemo(() => debounce(() => { const onMenuOpened = useMemo(() => debounce(() => {
setRefocusCounter(counter => (counter ?? 0) + 1); setRefocusCounter(counter => (counter ?? 0) + 1);
setOpen(true);
}, 200), []); }, 200), []);
// Resetting the refocus counter to undefined causes the menu to not be focused immediately // Resetting the refocus counter to undefined causes the menu to not be focused immediately
// after opening. // after opening.
const onMenuClose = useCallback(() => { const onMenuClosed = useCallback(() => {
setRefocusCounter(undefined); setRefocusCounter(undefined);
setOpen(false);
}, []); }, []);
return ( return (
<Menu <Menu
onSelect={onMenuItemSelect} onSelect={onMenuItemSelect}
onClose={onMenuClose} onClose={onMenuClosed}
onOpen={onMenuOpen} onOpen={onMenuOpened}
style={styles.contextMenu} style={styles.contextMenu}
> >
<MenuTrigger style={styles.contextMenuButton} testID='screen-header-menu-trigger'> <MenuTrigger style={styles.contextMenuButton} testID='screen-header-menu-trigger'>
{props.children} {props.children}
</MenuTrigger> </MenuTrigger>
<MenuOptions> <MenuOptions>
<ScrollView <FocusControl.ModalWrapper state={open ? ModalState.Open : ModalState.Closed}>
style={styles.menuContentScroller} <ScrollView
aria-modal={true} style={styles.menuContentScroller}
accessibilityViewIsModal={true} testID={`menu-content-${refocusCounter ? 'refocusing' : ''}`}
testID={`menu-content-${refocusCounter ? 'refocusing' : ''}`} >{menuOptionComponents}</ScrollView>
>{menuOptionComponents}</ScrollView> </FocusControl.ModalWrapper>
</MenuOptions> </MenuOptions>
</Menu> </Menu>
); );

View File

@ -271,12 +271,14 @@ const SideMenuComponent: React.FC<Props> = props => {
<AccessibleView <AccessibleView
inert={!open} inert={!open}
style={styles.menuWrapper} style={styles.menuWrapper}
testID='menu-wrapper'
> >
<AccessibleView <AccessibleView
// Auto-focuses an empty view at the beginning of the sidemenu -- if we instead // 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 // focus the container view, VoiceOver fails to focus to any components within
// the sidebar. // the sidebar.
refocusCounter={!open ? 1 : undefined} refocusCounter={!open ? 1 : undefined}
testID='sidemenu-menu-focus-region'
/> />
{props.menu} {props.menu}
@ -287,8 +289,9 @@ const SideMenuComponent: React.FC<Props> = props => {
<AccessibleView <AccessibleView
inert={open} inert={open}
style={styles.contentWrapper} style={styles.contentWrapper}
testID='content-wrapper'
> >
<AccessibleView refocusCounter={open ? 1 : undefined} /> <AccessibleView refocusCounter={open ? 1 : undefined} testID='sidemenu-content-focus-region' />
{props.children} {props.children}
</AccessibleView> </AccessibleView>
); );

View File

@ -0,0 +1,75 @@
import * as React from 'react';
import FocusControl from './FocusControl/FocusControl';
import { render } from '@testing-library/react-native';
import AccessibleView from './AccessibleView';
import { AccessibilityInfo } from 'react-native';
import ModalWrapper from './FocusControl/ModalWrapper';
import { ModalState } from './FocusControl/types';
interface TestContentWrapperProps {
mainContent: React.ReactNode;
dialogs: React.ReactNode;
}
const TestContentWrapper: React.FC<TestContentWrapperProps> = props => {
return <FocusControl.Provider>
{props.dialogs}
<FocusControl.MainAppContent>
{props.mainContent}
</FocusControl.MainAppContent>
</FocusControl.Provider>;
};
jest.mock('react-native', () => {
const ReactNative = jest.requireActual('react-native');
ReactNative.AccessibilityInfo.setAccessibilityFocus = jest.fn();
return ReactNative;
});
describe('AccessibleView', () => {
test('should wait for the currently-open dialog to dismiss before applying focus requests', () => {
const setFocusMock = AccessibilityInfo.setAccessibilityFocus as jest.Mock;
setFocusMock.mockClear();
interface TestContentOptions {
modalState: ModalState;
refocusCounter: undefined|number;
}
const renderTestContent = ({ modalState, refocusCounter }: TestContentOptions) => {
const mainContent = <AccessibleView refocusCounter={refocusCounter}/>;
const visibleDialog = <ModalWrapper state={modalState}>{null}</ModalWrapper>;
return <TestContentWrapper
mainContent={mainContent}
dialogs={visibleDialog}
/>;
};
render(renderTestContent({
refocusCounter: undefined,
modalState: ModalState.Open,
}));
// Increasing the refocusCounter for a background view while a dialog is visible
// should not try to focus the background view.
render(renderTestContent({
refocusCounter: 1,
modalState: ModalState.Open,
}));
expect(setFocusMock).not.toHaveBeenCalled();
// Focus should not be set until done closing
render(renderTestContent({
refocusCounter: 1,
modalState: ModalState.Closing,
}));
expect(setFocusMock).not.toHaveBeenCalled();
// Keeping the same refocus counter, but dismissing the dialog should focus
// the test view.
render(renderTestContent({
refocusCounter: 1,
modalState: ModalState.Closed,
}));
expect(setFocusMock).toHaveBeenCalled();
});
});

View File

@ -1,8 +1,9 @@
import { focus } from '@joplin/lib/utils/focusHandler'; import { focus } from '@joplin/lib/utils/focusHandler';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
import * as React from 'react'; import * as React from 'react';
import { useEffect, useState } from 'react'; import { useContext, useEffect, useRef, useState } from 'react';
import { AccessibilityInfo, findNodeHandle, Platform, UIManager, View, ViewProps } from 'react-native'; import { AccessibilityInfo, findNodeHandle, Platform, UIManager, View, ViewProps } from 'react-native';
import { AutoFocusContext } from './FocusControl/AutoFocusProvider';
const logger = Logger.create('AccessibleView'); const logger = Logger.create('AccessibleView');
@ -16,9 +17,68 @@ interface Props extends ViewProps {
refocusCounter?: number; refocusCounter?: number;
} }
const useAutoFocus = (refocusCounter: number|null, containerNode: View|HTMLElement|null, debugLabel: string) => {
const autoFocusControl = useContext(AutoFocusContext);
const autoFocusControlRef = useRef(autoFocusControl);
autoFocusControlRef.current = autoFocusControl;
const debugLabelRef = useRef(debugLabel);
debugLabelRef.current = debugLabel;
useEffect(() => {
if ((refocusCounter ?? null) === null) return () => {};
if (!containerNode) return () => {};
const focusContainer = () => {
const doFocus = () => {
if (Platform.OS === 'web') {
// react-native-web defines UIManager.focus for setting the keyboard focus. However,
// this property is not available in standard react-native. As such, access it using type
// narrowing:
// eslint-disable-next-line no-restricted-properties
if (!('focus' in UIManager) || typeof UIManager.focus !== 'function') {
throw new Error('Failed to focus sidebar. UIManager.focus is not a function.');
}
// Disable the "use focusHandler for all focus calls" rule -- UIManager.focus requires
// an argument, which is not supported by focusHandler.
// eslint-disable-next-line no-restricted-properties
UIManager.focus(containerNode);
} else {
const handle = findNodeHandle(containerNode as View);
if (handle !== null) {
AccessibilityInfo.setAccessibilityFocus(handle);
} else {
logger.warn('Couldn\'t find a view to focus.');
}
}
};
focus(`AccessibleView::${debugLabelRef.current}`, {
focus: doFocus,
});
};
const canFocusNow = !autoFocusControlRef.current || autoFocusControlRef.current.canAutoFocus();
if (canFocusNow) {
focusContainer();
return () => {};
} else { // Delay autofocus
logger.debug(`Delaying autofocus for ${debugLabelRef.current}`);
// Allows the view to be refocused when, for example, a dialog is dismissed
autoFocusControlRef.current?.setAutofocusCallback(focusContainer);
return () => {
autoFocusControlRef.current?.removeAutofocusCallback(focusContainer);
};
}
}, [containerNode, refocusCounter]);
};
const AccessibleView: React.FC<Props> = ({ inert, refocusCounter, children, ...viewProps }) => { const AccessibleView: React.FC<Props> = ({ inert, refocusCounter, children, ...viewProps }) => {
const [containerRef, setContainerRef] = useState<View|HTMLElement|null>(null); const [containerRef, setContainerRef] = useState<View|HTMLElement|null>(null);
const debugLabel = viewProps.testID ?? 'AccessibleView';
useAutoFocus(refocusCounter, containerRef, debugLabel);
// On web, there's no clear way to disable keyboard focus for an element **and its descendants** // On web, there's no clear way to disable keyboard focus for an element **and its descendants**
// without accessing the underlying HTML. // without accessing the underlying HTML.
useEffect(() => { useEffect(() => {
@ -32,39 +92,6 @@ const AccessibleView: React.FC<Props> = ({ inert, refocusCounter, children, ...v
} }
}, [containerRef, inert]); }, [containerRef, inert]);
useEffect(() => {
if ((refocusCounter ?? null) === null) return;
if (!containerRef) return;
const autoFocus = () => {
if (Platform.OS === 'web') {
// react-native-web defines UIManager.focus for setting the keyboard focus. However,
// this property is not available in standard react-native. As such, access it using type
// narrowing:
// eslint-disable-next-line no-restricted-properties
if (!('focus' in UIManager) || typeof UIManager.focus !== 'function') {
throw new Error('Failed to focus sidebar. UIManager.focus is not a function.');
}
// Disable the "use focusHandler for all focus calls" rule -- UIManager.focus requires
// an argument, which is not supported by focusHandler.
// eslint-disable-next-line no-restricted-properties
UIManager.focus(containerRef);
} else {
const handle = findNodeHandle(containerRef as View);
if (handle !== null) {
AccessibilityInfo.setAccessibilityFocus(handle);
} else {
logger.warn('Couldn\'t find a view to focus.');
}
}
};
focus('AccessibleView', {
focus: autoFocus,
});
}, [containerRef, refocusCounter]);
const canFocus = (refocusCounter ?? null) !== null; const canFocus = (refocusCounter ?? null) !== null;
return <View return <View

View File

@ -0,0 +1,62 @@
import * as React from 'react';
import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
type AutoFocusCallback = ()=> void;
interface AutoFocusControl {
// It isn't always possible to autofocus (e.g. due to a dialog obscuring focus).
canAutoFocus(): boolean;
// Sets the callback to be triggered when it becomes possible to autofocus
setAutofocusCallback(callback: AutoFocusCallback): void;
removeAutofocusCallback(callback: AutoFocusCallback): void;
}
export const AutoFocusContext = createContext<AutoFocusControl|null>(null);
interface Props {
children: React.ReactNode;
allowAutoFocus: boolean;
}
const AutoFocusProvider: React.FC<Props> = ({ allowAutoFocus, children }) => {
const [autoFocusCallback, setAutofocusCallback] = useState<AutoFocusCallback|null>(null);
const allowAutoFocusRef = useRef(allowAutoFocus);
allowAutoFocusRef.current = allowAutoFocus;
useEffect(() => {
if (allowAutoFocus && autoFocusCallback) {
autoFocusCallback();
setAutofocusCallback(null);
}
}, [autoFocusCallback, allowAutoFocus]);
const removeAutofocusCallback = useCallback((toRemove: AutoFocusCallback) => {
setAutofocusCallback(callback => {
// Update the callback only if it's different
if (callback === toRemove) {
return null;
} else {
return callback;
}
});
}, []);
const autoFocusControl = useMemo((): AutoFocusControl => {
return {
canAutoFocus: () => {
return allowAutoFocusRef.current;
},
setAutofocusCallback: (callback) => {
setAutofocusCallback(() => callback);
},
removeAutofocusCallback,
};
}, [removeAutofocusCallback, setAutofocusCallback]);
return <AutoFocusContext.Provider value={autoFocusControl}>
{children}
</AutoFocusContext.Provider>;
};
export default AutoFocusProvider;

View File

@ -0,0 +1,11 @@
import FocusControlProvider from './FocusControlProvider';
import MainAppContent from './MainAppContent';
import ModalWrapper from './ModalWrapper';
const FocusControl = {
Provider: FocusControlProvider,
ModalWrapper,
MainAppContent,
};
export default FocusControl;

View File

@ -0,0 +1,51 @@
import * as React from 'react';
import { createContext, useCallback, useMemo, useState } from 'react';
import { ModalState } from './types';
export interface FocusControl {
setModalState(dialogId: string, state: ModalState): void;
hasOpenModal: boolean;
hasClosingModal: boolean;
}
export const FocusControlContext = createContext<FocusControl|null>(null);
interface Props {
children: React.ReactNode;
}
const FocusControlProvider: React.FC<Props> = props => {
type ModalStates = Record<string, ModalState>;
const [modalStates, setModalStates] = useState<ModalStates>({});
const setModalOpen = useCallback((dialogId: string, state: ModalState) => {
setModalStates(modalStates => {
modalStates = { ...modalStates };
if (state === ModalState.Closed) {
delete modalStates[dialogId];
} else {
modalStates[dialogId] = state;
}
return modalStates;
});
}, []);
const modalStateValues = Object.values(modalStates);
const hasOpenModal = modalStateValues.includes(ModalState.Open);
const hasClosingModal = modalStateValues.includes(ModalState.Closing);
const focusControl = useMemo((): FocusControl => {
return {
hasOpenModal: hasOpenModal,
hasClosingModal: hasClosingModal,
setModalState: setModalOpen,
};
}, [hasOpenModal, hasClosingModal, setModalOpen]);
return <FocusControlContext.Provider value={focusControl}>
{props.children}
</FocusControlContext.Provider>;
};
export default FocusControlProvider;

View File

@ -0,0 +1,27 @@
import * as React from 'react';
import AccessibleView from '../AccessibleView';
import { FocusControlContext } from './FocusControlProvider';
import { useContext } from 'react';
import { StyleProp, ViewStyle } from 'react-native';
import AutoFocusProvider from './AutoFocusProvider';
interface Props {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}
// A region that should not be accessibility focusable while a dialog
// is open.
const MainAppContent: React.FC<Props> = props => {
const { hasOpenModal, hasClosingModal } = useContext(FocusControlContext);
const blockFocus = hasOpenModal;
const allowAutoFocus = !hasClosingModal && !blockFocus;
return <AccessibleView inert={blockFocus} style={props.style}>
<AutoFocusProvider allowAutoFocus={allowAutoFocus}>
{props.children}
</AutoFocusProvider>
</AccessibleView>;
};
export default MainAppContent;

View File

@ -0,0 +1,34 @@
import * as React from 'react';
import { useContext, useEffect, useId } from 'react';
import { FocusControlContext } from './FocusControlProvider';
import { ModalState } from './types';
interface Props {
children: React.ReactNode;
state: ModalState;
}
// A wrapper component that notifies the focus handler that a modal-like component
// is visible. Modals that capture focus should wrap their content in this component.
const ModalWrapper: React.FC<Props> = props => {
const { setModalState: setDialogState } = useContext(FocusControlContext);
const id = useId();
useEffect(() => {
if (!setDialogState) {
throw new Error('ModalContent components must have a FocusControlProvider as an ancestor. Is FocusControlProvider part of the provider stack?');
}
setDialogState(id, props.state);
}, [id, props.state, setDialogState]);
useEffect(() => {
return () => {
setDialogState?.(id, ModalState.Closed);
};
}, [id, setDialogState]);
return <>
{props.children}
</>;
};
export default ModalWrapper;

View File

@ -0,0 +1,13 @@
// eslint-disable-next-line import/prefer-default-export -- FocusControl currently only has one shared type for external use
export enum ModalState {
// When `Open`, a modal blocks focus for the main app content.
Open,
// When `Closing`, a modal doesn't block main app content focus, but focus
// shouldn't be moved to the main app content yet.
// This is useful for native Modals, which have their own focus handling logic.
// If Joplin moves accessibility focus before the native Modal focus handling
// has completed, the Joplin-specified accessibility focus may be ignored.
Closing,
Closed,
}

View File

@ -7,6 +7,8 @@ import PluginService from '@joplin/lib/services/plugins/PluginService';
import WebviewController, { ContainerType } from '@joplin/lib/services/plugins/WebviewController'; import WebviewController, { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
import useViewInfos from './hooks/useViewInfos'; import useViewInfos from './hooks/useViewInfos';
import PluginPanelViewer from './PluginPanelViewer'; import PluginPanelViewer from './PluginPanelViewer';
import FocusControl from '../../accessibility/FocusControl/FocusControl';
import { ModalState } from '../../accessibility/FocusControl/types';
interface Props { interface Props {
themeId: number; themeId: number;
@ -40,12 +42,14 @@ const PluginDialogManager: React.FC<Props> = props => {
visible={true} visible={true}
onDismiss={() => dismissDialog(viewInfo)} onDismiss={() => dismissDialog(viewInfo)}
> >
<PluginDialogWebView <FocusControl.ModalWrapper state={ModalState.Open}>
viewInfo={viewInfo} <PluginDialogWebView
themeId={props.themeId} viewInfo={viewInfo}
pluginStates={props.pluginStates} themeId={props.themeId}
pluginHtmlContents={props.pluginHtmlContents} pluginStates={props.pluginStates}
/> pluginHtmlContents={props.pluginHtmlContents}
/>
</FocusControl.ModalWrapper>
</Modal> </Modal>
</Portal>, </Portal>,
); );

View File

@ -2,13 +2,11 @@ import * as React from 'react';
import { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; import { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
import configScreenStyles from '../../configScreenStyles'; import configScreenStyles from '../../configScreenStyles';
import Setting from '@joplin/lib/models/Setting'; import Setting from '@joplin/lib/models/Setting';
import { Provider } from 'react-redux';
import { Store } from 'redux'; import { Store } from 'redux';
import { PaperProvider } from 'react-native-paper';
import PluginStates from '../PluginStates'; import PluginStates from '../PluginStates';
import { AppState } from '../../../../../utils/types'; import { AppState } from '../../../../../utils/types';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { MenuProvider } from 'react-native-popup-menu'; import TestProviderStack from '../../../../testing/TestProviderStack';
interface WrapperProps { interface WrapperProps {
initialPluginSettings: PluginSettings; initialPluginSettings: PluginSettings;
@ -29,19 +27,15 @@ const PluginStatesWrapper = (props: WrapperProps) => {
}, []); }, []);
return ( return (
<Provider store={props.store}> <TestProviderStack store={props.store}>
<MenuProvider> <PluginStates
<PaperProvider> styles={styles}
<PluginStates themeId={Setting.THEME_LIGHT}
styles={styles} updatePluginStates={updatePluginStates}
themeId={Setting.THEME_LIGHT} pluginSettings={pluginSettings}
updatePluginStates={updatePluginStates} shouldShowBasedOnSearchQuery={shouldShowBasedOnSettingSearchQuery}
pluginSettings={pluginSettings} />
shouldShowBasedOnSearchQuery={shouldShowBasedOnSettingSearchQuery} </TestProviderStack>
/>
</PaperProvider>
</MenuProvider>
</Provider>
); );
}; };

View File

@ -4,6 +4,7 @@ import { MenuProvider } from 'react-native-popup-menu';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { Store } from 'redux'; import { Store } from 'redux';
import { AppState } from '../../utils/types'; import { AppState } from '../../utils/types';
import FocusControl from '../accessibility/FocusControl/FocusControl';
interface Props { interface Props {
store: Store<AppState>; store: Store<AppState>;
@ -12,11 +13,13 @@ interface Props {
const TestProviderStack: React.FC<Props> = props => { const TestProviderStack: React.FC<Props> = props => {
return <Provider store={props.store}> return <Provider store={props.store}>
<MenuProvider> <FocusControl.Provider>
<PaperProvider> <MenuProvider>
{props.children} <PaperProvider>
</PaperProvider> {props.children}
</MenuProvider> </PaperProvider>
</MenuProvider>
</FocusControl.Provider>
</Provider>; </Provider>;
}; };

View File

@ -67,6 +67,7 @@ const RecordingControls: React.FC<Props> = props => {
refocusCounter={1} refocusCounter={1}
aria-live='polite' aria-live='polite'
role='heading' role='heading'
testID='recording-controls-heading'
> >
<Text variant='bodyMedium'> <Text variant='bodyMedium'>
{props.heading} {props.heading}

View File

@ -140,6 +140,7 @@ import lockToSingleInstance from './utils/lockToSingleInstance';
import { AppState } from './utils/types'; import { AppState } from './utils/types';
import { getDisplayParentId } from '@joplin/lib/services/trash'; import { getDisplayParentId } from '@joplin/lib/services/trash';
import PluginNotification from './components/plugins/PluginNotification'; import PluginNotification from './components/plugins/PluginNotification';
import FocusControl from './components/accessibility/FocusControl/FocusControl';
const logger = Logger.create('root'); const logger = Logger.create('root');
@ -1312,7 +1313,7 @@ class AppComponent extends React.Component {
disableGestures={disableSideMenuGestures} disableGestures={disableSideMenuGestures}
> >
<StatusBar barStyle={statusBarStyle} /> <StatusBar barStyle={statusBarStyle} />
<MenuProvider style={{ flex: 1 }}> <View style={{ flexGrow: 1, flexShrink: 1, flexBasis: '100%' }}>
<SafeAreaView style={{ flex: 0, backgroundColor: theme.backgroundColor2 }}/> <SafeAreaView style={{ flex: 0, backgroundColor: theme.backgroundColor2 }}/>
<SafeAreaView style={{ flex: 1 }}> <SafeAreaView style={{ flex: 1 }}>
<View style={{ flex: 1, backgroundColor: theme.backgroundColor }}> <View style={{ flex: 1, backgroundColor: theme.backgroundColor }}>
@ -1326,7 +1327,7 @@ class AppComponent extends React.Component {
sensorInfo={this.state.sensorInfo} sensorInfo={this.state.sensorInfo}
/> } /> }
</SafeAreaView> </SafeAreaView>
</MenuProvider> </View>
</SideMenu> </SideMenu>
<PluginRunnerWebView /> <PluginRunnerWebView />
<PluginNotification/> <PluginNotification/>
@ -1338,44 +1339,50 @@ class AppComponent extends React.Component {
// Wrap everything in a PaperProvider -- this allows using components from react-native-paper // Wrap everything in a PaperProvider -- this allows using components from react-native-paper
return ( return (
<PaperProvider theme={{ <FocusControl.Provider>
...paperTheme, <PaperProvider theme={{
version: 3, ...paperTheme,
colors: { version: 3,
...paperTheme.colors, colors: {
onPrimaryContainer: theme.color5, ...paperTheme.colors,
primaryContainer: theme.backgroundColor5, onPrimaryContainer: theme.color5,
primaryContainer: theme.backgroundColor5,
outline: theme.codeBorderColor, outline: theme.codeBorderColor,
primary: theme.color4, primary: theme.color4,
onPrimary: theme.backgroundColor4, onPrimary: theme.backgroundColor4,
background: theme.backgroundColor, background: theme.backgroundColor,
surface: theme.backgroundColor, surface: theme.backgroundColor,
onSurface: theme.color, onSurface: theme.color,
secondaryContainer: theme.raisedBackgroundColor, secondaryContainer: theme.raisedBackgroundColor,
onSecondaryContainer: theme.raisedColor, onSecondaryContainer: theme.raisedColor,
surfaceVariant: theme.backgroundColor3, surfaceVariant: theme.backgroundColor3,
onSurfaceVariant: theme.color3, onSurfaceVariant: theme.color3,
elevation: { elevation: {
level0: 'transparent', level0: 'transparent',
level1: theme.oddBackgroundColor, level1: theme.oddBackgroundColor,
level2: theme.raisedBackgroundColor, level2: theme.raisedBackgroundColor,
level3: theme.raisedBackgroundColor, level3: theme.raisedBackgroundColor,
level4: theme.raisedBackgroundColor, level4: theme.raisedBackgroundColor,
level5: theme.raisedBackgroundColor, level5: theme.raisedBackgroundColor,
},
}, },
}, }}>
}}> <DialogManager themeId={this.props.themeId}>
<DialogManager themeId={this.props.themeId}> <MenuProvider style={{ flex: 1 }}>
{mainContent} <FocusControl.MainAppContent style={{ flex: 1 }}>
</DialogManager> {mainContent}
</PaperProvider> </FocusControl.MainAppContent>
</MenuProvider>
</DialogManager>
</PaperProvider>
</FocusControl.Provider>
); );
} }
} }

View File

@ -172,4 +172,5 @@ sideloading
ggml ggml
Minidump Minidump
collapseall collapseall
newfolder newfolder
unfocusable

View File

@ -0,0 +1,71 @@
# Modal focus management
Most Joplin dialogs should follow the [modal dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/). On desktop, this is usually done with the native [`<dialog>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog) element. On mobile, it's a bit more complicated.
## Mobile
### Managing focus
On mobile, the `<AccessibleView>` component allows moving focus to a component or preventing a component from being accessibility focused. For example,
```jsx
<AccessibleView inert={true}>{children}</AccessibleView>
```
prevents `children` from being focused using accessibility tools in a cross-platform way. The `inert` prop is named after the [HTML `inert` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/inert).
Similarly, the following logic auto-focuses `children` when the view first renders:
```jsx
// Danger: This implicitly sets `accessible={true}`, which prevents
// VoiceOver from focusing individual children in `children`.
<AccessibleView refocusCounter={1}>{children}</AccessibleView>
```
Changing the `refocusCounter` prop causes the `AccessibleView` to be focused again.
### Native `Modal`s
React Native has a built-in `Modal` component.
The `components/Modal` component wraps this built-in `Modal` component. Among other things, this wrapper tracks whether `Modal`s are open, closing, or closed. This allows greater customization over where focus moves after modals are dismissed.
When a `Modal` is visible, it prevents content behind it from being focused. With the React Native built-in `Modal`, setting focus to items behind a visible `Modal` does nothing. On Android, this is also the case briefly after the `Modal` is dismissed.
The custom `Modal` works with `AccessibleView` to improve focus behavior. The `Modal` keeps track of the last `AccessibleView` that was focused while the `Modal` was open. When the `Modal` is dismissed, it auto-focuses this `AccessibleView`. This is useful, for example, if an button in a `Modal` shows UI that needs to be auto-focused when the `Modal` is dismissed. The custom `Modal` determines when the native `Modal` is dismissed, and could then move focus to the just-shown UI.
### Inaccessible 3rd-party modals
Sometimes a library includes a component that should handle focus in a modal-like way, but doesn't. Examples include [`react-native-paper`'s Modal](https://github.com/callstack/react-native-paper/issues/3912) and [`react-native-popup-menu`'s Menu](https://github.com/instea/react-native-popup-menu/issues/138). The components in the `FocusControl` object can often improve focus management for these libraries.
`FocusControl` provides three components:
- A `FocusControl.Provider` that sets up shared focus-related state.
- A `FocusControl.MainAppContent` that should wrap the main application content (everything that isn't part of a modal).
- A `FocusControl.ModalWrapper` that should be used to wrap content within modals. This allows `FocusControl` to determine whether a modal is visible.
When a modal is visible, the `MainAppContent` is wrapped with an `inert` `AccessibleView`, preventing it from receiving accessibility focus. This traps focus within the visible modal components.
In general, prefer Joplin's `components/Modal` component to react-native-paper `Modal`s. As an example, however, a [`react-native-paper` `Modal`](https://callstack.github.io/react-native-paper/docs/components/Modal/) might be rendered with:
```tsx
<Portal>
<Modal
visible={visible}
onDismiss={onDismiss}
>
<FocusControl.ModalWrapper
state={visible ? ModalState.Open : ModalState.Closed}
>
{...content here...}
</FocusControl.ModalWrapper>
</Modal>
</Portal>
```
Above, the `FocusControl.ModalWrapper` communicates whether the dialog is visible to the global `<FocusControl.Provider>`. This allows the `MainAppContent` (not shown above) to be marked as focusable or unfocusable depending on whether the `Modal` is visible or not.
:::danger
The [`<Portal>`](https://callstack.github.io/react-native-paper/docs/components/Portal/) is important part of the example. A `<Portal>` is a react-native-paper component that renders its children outside of the main app content (near the global `PaperProvider`).
If the `<Portal>` is omitted, then the modal, and the `FocusControl.ModalWrapper`'s children, will be rendered within the main app content. This will cause them to be marked as unfocusable when the modal is visible, preventing screen readers from accessing the modal's content.
When adding a `FocusControl.ModalWrapper`, it's important to verify that the modal can still be used by a screen reader.
:::