You've already forked joplin
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:
@ -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
7
.gitignore
vendored
@ -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
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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
|
||||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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,
|
||||||
|
}
|
@ -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>,
|
||||||
);
|
);
|
||||||
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -172,4 +172,5 @@ sideloading
|
|||||||
ggml
|
ggml
|
||||||
Minidump
|
Minidump
|
||||||
collapseall
|
collapseall
|
||||||
newfolder
|
newfolder
|
||||||
|
unfocusable
|
||||||
|
71
readme/dev/spec/modal_focus_management.md
Normal file
71
readme/dev/spec/modal_focus_management.md
Normal 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.
|
||||||
|
|
||||||
|
:::
|
Reference in New Issue
Block a user