From 3384f21fce74989618fb6c95f22258d6be73d0c3 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Sat, 21 Dec 2024 15:23:37 -0800 Subject: [PATCH 1/7] Mobile: Fix folder toggle and right-click accessibility --- .../components/side-menu-content.tsx | 239 +++++++++++------- 1 file changed, 142 insertions(+), 97 deletions(-) diff --git a/packages/app-mobile/components/side-menu-content.tsx b/packages/app-mobile/components/side-menu-content.tsx index 4c68991d76c..7b0a0c62b9c 100644 --- a/packages/app-mobile/components/side-menu-content.tsx +++ b/packages/app-mobile/components/side-menu-content.tsx @@ -23,7 +23,8 @@ import { ModelType } from '@joplin/lib/BaseModel'; import { DialogContext } from './DialogManager'; import { TextStyle, ViewStyle } from 'react-native'; import { StateDecryptionWorker, StateResourceFetcher } from '@joplin/lib/reducer'; -const { TouchableRipple } = require('react-native-paper'); +import useOnLongPressProps from '../utils/hooks/useOnLongPressProps'; +import { TouchableRipple } from 'react-native-paper'; const { substrWithEllipsis } = require('@joplin/lib/string-utils'); interface Props { @@ -56,11 +57,9 @@ const folderIconRightMargin = 10; let syncIconAnimation: Animated.CompositeAnimation|null = null; -const SideMenuContentComponent = (props: Props) => { - const alwaysShowFolderIcons = useMemo(() => Folder.shouldShowFolderIcons(props.folders), [props.folders]); - - const styles_ = useMemo(() => { - const theme = themeStyle(props.themeId); +const useStyles = (themeId: number) => { + return useMemo(() => { + const theme = themeStyle(themeId); const buttonStyle: ViewStyle = { flex: 1, @@ -150,7 +149,128 @@ const SideMenuContentComponent = (props: Props) => { }); return styles; - }, [props.themeId]); + }, [themeId]); +}; + +type Styles = ReturnType; + +interface FolderItemProps { + themeId: number; + hasChildren: boolean; + collapsed: boolean; + folder: FolderEntity; + selected: boolean; + depth: number; + styles: Styles; + alwaysShowFolderIcons: boolean; + + onPress(folder: FolderEntity): void; + onTogglePress(folder: FolderEntity): void; + onLongPress(folder: FolderEntity): void; +} + +const FolderItem: React.FC = props => { + const theme = themeStyle(props.themeId); + const styles_ = props.styles; + + const folderButtonStyle: ViewStyle = { + flex: 1, + flexDirection: 'row', + flexBasis: 'auto', + height: 36, + alignItems: 'center', + paddingRight: theme.marginRight, + paddingLeft: 10, + }; + const selected = props.selected; + if (selected) folderButtonStyle.backgroundColor = theme.selectedColor; + folderButtonStyle.paddingLeft = props.depth * 10 + theme.marginLeft; + + const iconWrapperStyle: ViewStyle = { paddingLeft: 10, paddingRight: 10 }; + if (selected) iconWrapperStyle.backgroundColor = theme.selectedColor; + + let iconWrapper = null; + + const collapsed = props.collapsed;// props.collapsedFolderIds.indexOf(folder.id) >= 0; + const iconName = collapsed ? 'chevron-down' : 'chevron-up'; + const iconComp = ; + + iconWrapper = !props.hasChildren ? null : ( + { + props.onTogglePress(props.folder); + }} + + accessibilityLabel={collapsed ? _('Expand') : _('Collapse')} + accessibilityState={{ expanded: !collapsed }} + accessibilityRole="togglebutton" + > + {iconComp} + + ); + + const folderIcon = Folder.unserializeIcon(props.folder.icon); + + const renderFolderIcon = (folderId: string, folderIcon: FolderIcon) => { + if (!folderIcon) { + if (folderId === getTrashFolderId()) { + folderIcon = getTrashFolderIcon(FolderIconType.FontAwesome); + } else if (props.alwaysShowFolderIcons) { + return ; + } else { + return null; + } + } + + if (folderIcon.type === FolderIconType.Emoji) { + return {folderIcon.emoji}; + } else if (folderIcon.type === FolderIconType.DataUrl) { + return ; + } else if (folderIcon.type === FolderIconType.FontAwesome) { + return ; + } else { + throw new Error(`Unsupported folder icon type: ${folderIcon.type}`); + } + }; + + const onPress = useCallback(() => { + props.onPress(props.folder); + }, [props.folder, props.onPress]); + + const onLongPress = useCallback(() => { + props.onLongPress(props.folder); + }, [props.folder, props.onLongPress]); + + const longPressProps = useOnLongPressProps({ + onLongPress, + actionDescription: _('Notebook options'), + }); + + return ( + + + + {renderFolderIcon(props.folder.id, folderIcon)} + + {Folder.displayTitle(props.folder)} + + + + {iconWrapper} + + ); +}; + +const SideMenuContentComponent = (props: Props) => { + const alwaysShowFolderIcons = useMemo(() => Folder.shouldShowFolderIcons(props.folders), [props.folders]); + const styles_ = useStyles(props.themeId); useEffect(() => { if (props.syncStarted) { @@ -170,6 +290,8 @@ const SideMenuContentComponent = (props: Props) => { } }, [props.syncStarted]); + const dialogs = useContext(DialogContext); + const folder_press = (folder: FolderEntity) => { props.dispatch({ type: 'SIDE_MENU_CLOSE' }); @@ -180,8 +302,6 @@ const SideMenuContentComponent = (props: Props) => { }); }; - const dialogs = useContext(DialogContext); - const folder_longPress = async (folderOrAll: FolderEntity | string) => { if (folderOrAll === 'all') return; @@ -408,96 +528,21 @@ const SideMenuContentComponent = (props: Props) => { if (actionDone === 'auth') props.dispatch({ type: 'SIDE_MENU_CLOSE' }); }, [performSync, props.dispatch]); - const renderFolderIcon = (folderId: string, folderIcon: FolderIcon) => { - if (!folderIcon) { - if (folderId === getTrashFolderId()) { - folderIcon = getTrashFolderIcon(FolderIconType.FontAwesome); - } else if (alwaysShowFolderIcons) { - return ; - } else { - return null; - } - } - - if (folderIcon.type === FolderIconType.Emoji) { - return {folderIcon.emoji}; - } else if (folderIcon.type === FolderIconType.DataUrl) { - return ; - } else if (folderIcon.type === FolderIconType.FontAwesome) { - return ; - } else { - throw new Error(`Unsupported folder icon type: ${folderIcon.type}`); - } - }; const renderFolderItem = (folder: FolderEntity, hasChildren: boolean, depth: number) => { - const theme = themeStyle(props.themeId); - - const folderButtonStyle: ViewStyle = { - flex: 1, - flexDirection: 'row', - flexBasis: 'auto', - height: 36, - alignItems: 'center', - paddingRight: theme.marginRight, - paddingLeft: 10, - }; - const selected = isFolderSelected(folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType }); - if (selected) folderButtonStyle.backgroundColor = theme.selectedColor; - folderButtonStyle.paddingLeft = depth * 10 + theme.marginLeft; - - const iconWrapperStyle: ViewStyle = { paddingLeft: 10, paddingRight: 10 }; - if (selected) iconWrapperStyle.backgroundColor = theme.selectedColor; - - let iconWrapper = null; - - const collapsed = props.collapsedFolderIds.indexOf(folder.id) >= 0; - const iconName = collapsed ? 'chevron-down' : 'chevron-up'; - const iconComp = ; - - iconWrapper = !hasChildren ? null : ( - { - if (hasChildren) folder_togglePress(folder); - }} - - accessibilityLabel={collapsed ? _('Expand') : _('Collapse')} - accessibilityRole="togglebutton" - > - {iconComp} - - ); - - const folderIcon = Folder.unserializeIcon(folder.icon); - - return ( - - { - folder_press(folder); - }} - onLongPress={() => { - void folder_longPress(folder); - }} - onContextMenu={(event: Event) => { // web only - event.preventDefault(); - void folder_longPress(folder); - }} - accessibilityHint={_('Opens notebook')} - role='button' - > - - {renderFolderIcon(folder.id, folderIcon)} - - {Folder.displayTitle(folder)} - - - - {iconWrapper} - - ); + return ; }; const renderSidebarButton = (key: string, title: string, iconName: string, onPressHandler: ()=> void = null, selected = false) => { From 86b918b2611793339d1cdea5b1a63c24a55fe033 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Mon, 23 Dec 2024 07:54:11 -0800 Subject: [PATCH 2/7] Fix missing key warning, remove commented out code --- packages/app-mobile/components/side-menu-content.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app-mobile/components/side-menu-content.tsx b/packages/app-mobile/components/side-menu-content.tsx index 7b0a0c62b9c..a0beb4a41a7 100644 --- a/packages/app-mobile/components/side-menu-content.tsx +++ b/packages/app-mobile/components/side-menu-content.tsx @@ -191,7 +191,7 @@ const FolderItem: React.FC = props => { let iconWrapper = null; - const collapsed = props.collapsed;// props.collapsedFolderIds.indexOf(folder.id) >= 0; + const collapsed = props.collapsed; const iconName = collapsed ? 'chevron-down' : 'chevron-up'; const iconComp = ; @@ -531,6 +531,7 @@ const SideMenuContentComponent = (props: Props) => { const renderFolderItem = (folder: FolderEntity, hasChildren: boolean, depth: number) => { return Date: Mon, 23 Dec 2024 10:22:48 -0800 Subject: [PATCH 3/7] Refactoring, add additional accessibility information --- .../components/side-menu-content.tsx | 144 +++++++++++------- 1 file changed, 92 insertions(+), 52 deletions(-) diff --git a/packages/app-mobile/components/side-menu-content.tsx b/packages/app-mobile/components/side-menu-content.tsx index a0beb4a41a7..7c1a5159b08 100644 --- a/packages/app-mobile/components/side-menu-content.tsx +++ b/packages/app-mobile/components/side-menu-content.tsx @@ -25,6 +25,7 @@ import { TextStyle, ViewStyle } from 'react-native'; import { StateDecryptionWorker, StateResourceFetcher } from '@joplin/lib/reducer'; import useOnLongPressProps from '../utils/hooks/useOnLongPressProps'; import { TouchableRipple } from 'react-native-paper'; +import shim from '@joplin/lib/shim'; const { substrWithEllipsis } = require('@joplin/lib/string-utils'); interface Props { @@ -154,6 +155,7 @@ const useStyles = (themeId: number) => { type Styles = ReturnType; +type FolderEventHandler = (folder: FolderEntity)=> void; interface FolderItemProps { themeId: number; hasChildren: boolean; @@ -164,47 +166,56 @@ interface FolderItemProps { styles: Styles; alwaysShowFolderIcons: boolean; - onPress(folder: FolderEntity): void; - onTogglePress(folder: FolderEntity): void; - onLongPress(folder: FolderEntity): void; + onPress: FolderEventHandler; + onTogglePress: FolderEventHandler; + onLongPress: FolderEventHandler; } const FolderItem: React.FC = props => { - const theme = themeStyle(props.themeId); - const styles_ = props.styles; - - const folderButtonStyle: ViewStyle = { - flex: 1, - flexDirection: 'row', - flexBasis: 'auto', - height: 36, - alignItems: 'center', - paddingRight: theme.marginRight, - paddingLeft: 10, - }; - const selected = props.selected; - if (selected) folderButtonStyle.backgroundColor = theme.selectedColor; - folderButtonStyle.paddingLeft = props.depth * 10 + theme.marginLeft; + const styles = useMemo(() => { + const theme = themeStyle(props.themeId); - const iconWrapperStyle: ViewStyle = { paddingLeft: 10, paddingRight: 10 }; - if (selected) iconWrapperStyle.backgroundColor = theme.selectedColor; + return StyleSheet.create({ + buttonWrapper: { flex: 1, flexDirection: 'row' }, + folderButton: { + flex: 1, + flexDirection: 'row', + flexBasis: 'auto', + height: 36, + alignItems: 'center', + paddingRight: theme.marginRight, - let iconWrapper = null; + backgroundColor: props.selected ? theme.selectedColor : undefined, + paddingLeft: props.depth * 10 + theme.marginLeft, + }, + iconWrapper: { + paddingLeft: 10, + paddingRight: 10, + backgroundColor: props.selected ? theme.selectedColor : undefined, + }, + }); + }, [props.selected, props.depth, props.themeId]); + const baseStyles = props.styles; const collapsed = props.collapsed; const iconName = collapsed ? 'chevron-down' : 'chevron-up'; - const iconComp = ; + const iconComp = ; - iconWrapper = !props.hasChildren ? null : ( + const onTogglePress = useCallback(() => { + props.onTogglePress(props.folder); + }, [props.folder, props.onTogglePress]); + + const iconWrapper = !props.hasChildren ? null : ( { - props.onTogglePress(props.folder); - }} - - accessibilityLabel={collapsed ? _('Expand') : _('Collapse')} - accessibilityState={{ expanded: !collapsed }} - accessibilityRole="togglebutton" + style={styles.iconWrapper} + onPress={onTogglePress} + accessibilityLabel={_('Expand %s', props.folder.title)} + + aria-pressed={!collapsed} + accessibilityState={{ checked: !collapsed }} + // The togglebutton role is only supported on Android and iOS. + // On web, the button role with aria-pressed creates a togglebutton. + accessibilityRole={shim.mobilePlatform() === 'web' ? 'button' : 'togglebutton'} > {iconComp} @@ -217,18 +228,18 @@ const FolderItem: React.FC = props => { if (folderId === getTrashFolderId()) { folderIcon = getTrashFolderIcon(FolderIconType.FontAwesome); } else if (props.alwaysShowFolderIcons) { - return ; + return ; } else { return null; } } if (folderIcon.type === FolderIconType.Emoji) { - return {folderIcon.emoji}; + return {folderIcon.emoji}; } else if (folderIcon.type === FolderIconType.DataUrl) { - return ; + return ; } else if (folderIcon.type === FolderIconType.FontAwesome) { - return ; + return ; } else { throw new Error(`Unsupported folder icon type: ${folderIcon.type}`); } @@ -244,11 +255,17 @@ const FolderItem: React.FC = props => { const longPressProps = useOnLongPressProps({ onLongPress, - actionDescription: _('Notebook options'), + actionDescription: _('Show notebook options'), }); + const folderTitle = Folder.displayTitle(props.folder); + // React Native doesn't seem to include an equivalent to web's aria-level. + // To allow screen reader users to determine whether a notebook is a subnotebook or not, + // depth is specified with an accessibilityLabel: + const folderDepthDescription = props.depth > 0 ? _('(level %d)', props.depth) : ''; + const accessibilityLabel = `${folderTitle} ${folderDepthDescription}`.trim(); return ( - + = props => { accessibilityHint={_('Opens notebook')} role='button' > - + {renderFolderIcon(props.folder.id, folderIcon)} - - {Folder.displayTitle(props.folder)} + + {folderTitle} @@ -546,7 +567,17 @@ const SideMenuContentComponent = (props: Props) => { />; }; - const renderSidebarButton = (key: string, title: string, iconName: string, onPressHandler: ()=> void = null, selected = false) => { + type SidebarButtonOptions = { + onPress?: ()=> void; + selected?: boolean; + isHeader?: boolean; + }; + const renderSidebarButton = ( + key: string, + title: string, + iconName: string, + { onPress = null, selected = false, isHeader = false }: SidebarButtonOptions = {}, + ) => { let icon = ; if (key === 'synchronize_button') { @@ -554,16 +585,20 @@ const SideMenuContentComponent = (props: Props) => { } const content = ( - + {icon} {title} ); - if (!onPressHandler) return content; + if (!onPress) return content; return ( - + {content} ); @@ -571,7 +606,7 @@ const SideMenuContentComponent = (props: Props) => { const makeDivider = (key: string) => { const theme = themeStyle(props.themeId); - return ; + return ; }; const renderBottomPanel = () => { @@ -581,15 +616,15 @@ const SideMenuContentComponent = (props: Props) => { items.push(makeDivider('divider_1')); - items.push(renderSidebarButton('newFolder_button', _('New Notebook'), 'folder-open', newFolderButton_press)); + items.push(renderSidebarButton('newFolder_button', _('New Notebook'), 'folder-open', { onPress: newFolderButton_press })); - items.push(renderSidebarButton('tag_button', _('Tags'), 'pricetag', tagButton_press)); + items.push(renderSidebarButton('tag_button', _('Tags'), 'pricetag', { onPress: tagButton_press })); if (props.profileConfig && props.profileConfig.profiles.length > 1) { - items.push(renderSidebarButton('switchProfile_button', _('Switch profile'), 'people-circle-outline', switchProfileButton_press)); + items.push(renderSidebarButton('switchProfile_button', _('Switch profile'), 'people-circle-outline', { onPress: switchProfileButton_press })); } - items.push(renderSidebarButton('config_button', _('Configuration'), 'settings', configButton_press)); + items.push(renderSidebarButton('config_button', _('Configuration'), 'settings', { onPress: configButton_press })); items.push(makeDivider('divider_2')); @@ -611,7 +646,7 @@ const SideMenuContentComponent = (props: Props) => { if (resourceFetcherText) fullReport.push(resourceFetcherText); if (decryptionReportText) fullReport.push(decryptionReportText); - items.push(renderSidebarButton('synchronize_button', !props.syncStarted ? _('Synchronise') : _('Cancel'), 'sync', synchronize_press)); + items.push(renderSidebarButton('synchronize_button', !props.syncStarted ? _('Synchronise') : _('Cancel'), 'sync', { onPress: synchronize_press })); if (fullReport.length) { items.push( @@ -640,11 +675,16 @@ const SideMenuContentComponent = (props: Props) => { // using padding. So instead creating blank elements for padding bottom and top. items.push(); - items.push(renderSidebarButton('all_notes', _('All notes'), 'document', allNotesButton_press, props.notesParentType === 'SmartFilter')); + items.push(renderSidebarButton('all_notes', _('All notes'), 'document', { + onPress: allNotesButton_press, + selected: props.notesParentType === 'SmartFilter', + })); items.push(makeDivider('divider_all')); - items.push(renderSidebarButton('folder_header', _('Notebooks'), 'folder')); + items.push(renderSidebarButton('folder_header', _('Notebooks'), 'folder', { + isHeader: true, + })); const folderTree = useMemo(() => { return buildFolderTree(props.folders); From 21fde76452452cfe15d936297c4c0dc6132607a5 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Mon, 23 Dec 2024 10:40:35 -0800 Subject: [PATCH 4/7] Mark the selected notebook as selected --- packages/app-mobile/components/side-menu-content.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/app-mobile/components/side-menu-content.tsx b/packages/app-mobile/components/side-menu-content.tsx index 7c1a5159b08..6361f686c41 100644 --- a/packages/app-mobile/components/side-menu-content.tsx +++ b/packages/app-mobile/components/side-menu-content.tsx @@ -271,6 +271,8 @@ const FolderItem: React.FC = props => { onPress={onPress} {...longPressProps} accessibilityHint={_('Opens notebook')} + accessibilityState={{ selected: props.selected }} + aria-selected={props.selected} role='button' > From 603f905877bda6b49e9930bc5b6b89b358214050 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Mon, 23 Dec 2024 10:55:15 -0800 Subject: [PATCH 5/7] Remove aria-selected (which doesn't seem to work on web) --- packages/app-mobile/components/side-menu-content.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-mobile/components/side-menu-content.tsx b/packages/app-mobile/components/side-menu-content.tsx index 6361f686c41..57ed721c083 100644 --- a/packages/app-mobile/components/side-menu-content.tsx +++ b/packages/app-mobile/components/side-menu-content.tsx @@ -272,7 +272,7 @@ const FolderItem: React.FC = props => { {...longPressProps} accessibilityHint={_('Opens notebook')} accessibilityState={{ selected: props.selected }} - aria-selected={props.selected} + aria-current={props.selected} role='button' > From 3b2daf3842a80e4ea1f77be6d0785ba62c537f1a Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Mon, 23 Dec 2024 11:43:11 -0800 Subject: [PATCH 6/7] iOS: Fix icon before "notebooks" header is focusable --- packages/app-mobile/components/Icon.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/app-mobile/components/Icon.tsx b/packages/app-mobile/components/Icon.tsx index 1f9ca927b7d..ee8e78464bb 100644 --- a/packages/app-mobile/components/Icon.tsx +++ b/packages/app-mobile/components/Icon.tsx @@ -34,8 +34,9 @@ const Icon: React.FC = props => { const importantForAccessibility = accessibilityHidden ? 'no-hide-descendants' : 'yes'; const sharedProps = { - importantForAccessibility, - 'aria-hidden': accessibilityHidden, + importantForAccessibility, // Android + accessibilityElementsHidden: accessibilityHidden, // iOS + 'aria-hidden': accessibilityHidden, // Web accessibilityLabel: props.accessibilityLabel, style: props.style, allowFontScaling: props.allowFontScaling, @@ -62,6 +63,7 @@ const Icon: React.FC = props => { style={props.style} aria-hidden={accessibilityHidden} importantForAccessibility={importantForAccessibility} + accessibilityElementsHidden={accessibilityHidden} > {nameSuffix} From 1a5c125489f4363708eb120dd486b87b63a2d0f9 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Mon, 23 Dec 2024 11:47:06 -0800 Subject: [PATCH 7/7] iOS: Fix notebooks heading not recognised as a heading --- packages/app-mobile/components/side-menu-content.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/app-mobile/components/side-menu-content.tsx b/packages/app-mobile/components/side-menu-content.tsx index 57ed721c083..6452e8b211a 100644 --- a/packages/app-mobile/components/side-menu-content.tsx +++ b/packages/app-mobile/components/side-menu-content.tsx @@ -587,13 +587,12 @@ const SideMenuContentComponent = (props: Props) => { } const content = ( - + {icon} - {title} + {title} );