From ea61bfc498ed7fce973afbc88e926d27a475392b Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Thu, 12 Sep 2024 01:04:23 -0700 Subject: [PATCH] Mobile: Fixes #10253: Move accessibility focus to the first note action menu item on open (#11031) --- .eslintignore | 1 + .gitignore | 1 + .../components/ScreenHeader/Menu.tsx | 148 ++++++++++++++++++ .../components/ScreenHeader/index.tsx | 111 +++---------- .../accessibility/AccessibleView.tsx | 10 +- 5 files changed, 181 insertions(+), 90 deletions(-) create mode 100644 packages/app-mobile/components/ScreenHeader/Menu.tsx diff --git a/.eslintignore b/.eslintignore index dad96e30e..f996d668f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -612,6 +612,7 @@ packages/app-mobile/components/NoteList.js packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.js packages/app-mobile/components/ProfileSwitcher/useProfileConfig.js +packages/app-mobile/components/ScreenHeader/Menu.js packages/app-mobile/components/ScreenHeader/WarningBanner.test.js packages/app-mobile/components/ScreenHeader/WarningBanner.js packages/app-mobile/components/ScreenHeader/WarningBox.js diff --git a/.gitignore b/.gitignore index 58e0340b8..43f790bba 100644 --- a/.gitignore +++ b/.gitignore @@ -589,6 +589,7 @@ packages/app-mobile/components/NoteList.js packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.js packages/app-mobile/components/ProfileSwitcher/useProfileConfig.js +packages/app-mobile/components/ScreenHeader/Menu.js packages/app-mobile/components/ScreenHeader/WarningBanner.test.js packages/app-mobile/components/ScreenHeader/WarningBanner.js packages/app-mobile/components/ScreenHeader/WarningBox.js diff --git a/packages/app-mobile/components/ScreenHeader/Menu.tsx b/packages/app-mobile/components/ScreenHeader/Menu.tsx new file mode 100644 index 000000000..c5f41ed0b --- /dev/null +++ b/packages/app-mobile/components/ScreenHeader/Menu.tsx @@ -0,0 +1,148 @@ +import * as React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { StyleSheet, TextStyle, View, Text, ScrollView, useWindowDimensions } from 'react-native'; +import { themeStyle } from '../global-style'; +import { Menu, MenuOption as MenuOptionComponent, MenuOptions, MenuTrigger } from 'react-native-popup-menu'; +import AccessibleView from '../accessibility/AccessibleView'; +import debounce from '../../utils/debounce'; + +interface MenuOptionDivider { + isDivider: true; +} + +interface MenuOptionButton { + key?: string; + isDivider?: false|undefined; + disabled?: boolean; + onPress: ()=> void; + title: string; +} + +export type MenuOptionType = MenuOptionDivider|MenuOptionButton; + +interface Props { + themeId: number; + options: MenuOptionType[]; + children: React.ReactNode; +} + +const useStyles = (themeId: number) => { + const { height: windowHeight } = useWindowDimensions(); + + return useMemo(() => { + const theme = themeStyle(themeId); + + const contextMenuItemTextBase: TextStyle = { + flex: 1, + textAlignVertical: 'center', + paddingLeft: theme.marginLeft, + paddingRight: theme.marginRight, + paddingTop: theme.itemMarginTop, + paddingBottom: theme.itemMarginBottom, + color: theme.color, + backgroundColor: theme.backgroundColor, + fontSize: theme.fontSize, + }; + + return StyleSheet.create({ + divider: { + borderBottomWidth: 1, + borderColor: theme.dividerColor, + backgroundColor: '#0000ff', + }, + contextMenu: { + backgroundColor: theme.backgroundColor2, + }, + contextMenuItem: { + backgroundColor: theme.backgroundColor, + }, + contextMenuItemText: { + ...contextMenuItemTextBase, + }, + contextMenuItemTextDisabled: { + ...contextMenuItemTextBase, + opacity: 0.5, + }, + menuContentScroller: { + maxHeight: windowHeight - 50, + }, + contextMenuButton: { + padding: 0, + }, + }); + }, [themeId, windowHeight]); +}; + +const MenuComponent: React.FC = props => { + const styles = useStyles(props.themeId); + + const menuOptionComponents: React.ReactNode[] = []; + + // When undefined/null: Don't auto-focus anything. + const [refocusCounter, setRefocusCounter] = useState(undefined); + + let key = 0; + let isFirst = true; + for (const option of props.options) { + if (option.isDivider === true) { + menuOptionComponents.push( + , + ); + } else { + const canAutoFocus = isFirst; + menuOptionComponents.push( + + + {option.title} + + , + ); + + isFirst = false; + } + } + + const onMenuItemSelect = useCallback((value: unknown) => { + if (typeof value === 'function') { + value(); + } + setRefocusCounter(undefined); + }, []); + + // 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. + const onMenuOpen = useMemo(() => debounce(() => { + setRefocusCounter(counter => (counter ?? 0) + 1); + }, 200), []); + + // Resetting the refocus counter to undefined causes the menu to not be focused immediately + // after opening. + const onMenuClose = useCallback(() => { + setRefocusCounter(undefined); + }, []); + + return ( + + + {props.children} + + + {menuOptionComponents} + + + ); +}; + +export default MenuComponent; diff --git a/packages/app-mobile/components/ScreenHeader/index.tsx b/packages/app-mobile/components/ScreenHeader/index.tsx index 2c037e682..bd1e63c74 100644 --- a/packages/app-mobile/components/ScreenHeader/index.tsx +++ b/packages/app-mobile/components/ScreenHeader/index.tsx @@ -1,11 +1,10 @@ import * as React from 'react'; import { PureComponent, ReactElement } from 'react'; import { connect } from 'react-redux'; -import { View, Text, StyleSheet, TouchableOpacity, Image, ScrollView, Dimensions, ViewStyle, Platform } from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, Image, ViewStyle, Platform } from 'react-native'; const Icon = require('react-native-vector-icons/Ionicons').default; const { BackButtonService } = require('../../services/back-button.js'); import NavService from '@joplin/lib/services/NavService'; -import { Menu, MenuOptions, MenuOption, MenuTrigger } from 'react-native-popup-menu'; import { _, _n } from '@joplin/lib/locale'; import Note from '@joplin/lib/models/Note'; import Folder from '@joplin/lib/models/Folder'; @@ -26,27 +25,17 @@ import { Dispatch } from 'redux'; import WarningBanner from './WarningBanner'; import WebBetaButton from './WebBetaButton'; +import Menu, { MenuOptionType } from './Menu'; +export { MenuOptionType }; + // Rather than applying a padding to the whole bar, it is applied to each // individual component (button, picker, etc.) so that the touchable areas // are widder and to give more room to the picker component which has a larger // default height. const PADDING_V = 10; -type OnSelectCallbackType=()=> void; type OnPressCallback=()=> void; -export type MenuOptionType = { - onPress: OnPressCallback; - isDivider?: boolean; - title: string; - disabled?: boolean; -}|{ - isDivider: true; - title?: undefined; - onPress?: undefined; - disabled?: false; -}; - interface ScreenHeaderProps { selectedNoteIds: string[]; selectedFolderId: string; @@ -116,11 +105,6 @@ class ScreenHeaderComponent extends PureComponent); - } else { - menuOptionComponents.push( - - {o.title} - , - ); - } - } - - if (menuOptionComponents.length) { - menuOptionComponents.push(); + if (menuOptions.length) { + menuOptions.push({ isDivider: true }); } } else { - menuOptionComponents.push( - this.deleteButton_press()} key={'menuOption_delete'} style={this.styles().contextMenuItem}> - {_('Delete')} - , - ); + menuOptions.push({ + key: 'delete', + title: _('Delete'), + onPress: this.deleteButton_press, + }); - menuOptionComponents.push( - this.duplicateButton_press()} key={'menuOption_duplicate'} style={this.styles().contextMenuItem}> - {_('Duplicate')} - , - ); + menuOptions.push({ + key: 'duplicate', + title: _('Duplicate'), + onPress: this.duplicateButton_press, + }); } const createTitleComponent = (disabled: boolean, hideableAfterTitleComponents: ReactElement) => { @@ -661,7 +600,6 @@ class ScreenHeaderComponent extends PureComponent; const titleComp = createTitleComponent(headerItemDisabled, hideableRightComponents); - const windowHeight = Dimensions.get('window').height - 50; const contextMenuStyle: ViewStyle = { paddingTop: PADDING_V, @@ -672,16 +610,11 @@ class ScreenHeaderComponent extends PureComponent this.menu_select(value)} style={this.styles().contextMenu}> - - - - - - - {menuOptionComponents} - + !menuOptions.length || !showContextMenuButton ? null : ( + + + + ); diff --git a/packages/app-mobile/components/accessibility/AccessibleView.tsx b/packages/app-mobile/components/accessibility/AccessibleView.tsx index e731801f6..519fa9bae 100644 --- a/packages/app-mobile/components/accessibility/AccessibleView.tsx +++ b/packages/app-mobile/components/accessibility/AccessibleView.tsx @@ -1,8 +1,11 @@ import { focus } from '@joplin/lib/utils/focusHandler'; +import Logger from '@joplin/utils/Logger'; import * as React from 'react'; import { useEffect, useState } from 'react'; import { AccessibilityInfo, findNodeHandle, Platform, UIManager, View, ViewProps } from 'react-native'; +const logger = Logger.create('AccessibleView'); + interface Props extends ViewProps { // Prevents a view from being interacted with by accessibility tools, the mouse, or the keyboard focus. // See https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert. @@ -31,6 +34,7 @@ const AccessibleView: React.FC = ({ inert, refocusCounter, children, ...v useEffect(() => { if ((refocusCounter ?? null) === null) return; + if (!containerRef) return; const autoFocus = () => { if (Platform.OS === 'web') { @@ -48,7 +52,11 @@ const AccessibleView: React.FC = ({ inert, refocusCounter, children, ...v UIManager.focus(containerRef); } else { const handle = findNodeHandle(containerRef as View); - AccessibilityInfo.setAccessibilityFocus(handle); + if (handle !== null) { + AccessibilityInfo.setAccessibilityFocus(handle); + } else { + logger.warn('Couldn\'t find a view to focus.'); + } } };