Skip to content

Commit 5e55d7a

Browse files
iOS VO selection improvements (#2521)
* iOS VO selection improvements * remove dead code * remove more dead code * fix imports * fix ts * Only announce if NOT after keyboard navigation * Better comment * Add keyboard information, add some new strings * don't break the tests * review comments and tests * fixed tests * Update from code review * fix lint and tests * Have our announcer handle everything * handle select all cases as well * cleanup and add a test that tries row based onAction * get test working with no onAction to useTable * fix lint * for real fix lint * now fix ts * add an odd story that shows some difficulties with onAction * uses onrow/cellaction on grid state * remove dead comment * move long press announcement to useGrid and test * move out to selection, simplify if expressions * remove unused strings * cleanup * push listbox example for consideration * fix lint * Backwards compat with onAction * cleanup and doc comments * add deprecated notices * Move new actions to weak map * Cleanup * review followup, move description back to grid for now * handle if a VO user hasn't interacted with the application Co-authored-by: Devon Govett <[email protected]>
1 parent 8626c5a commit 5e55d7a

20 files changed

+867
-82
lines changed

packages/@react-aria/grid/intl/ar-AE.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
"select": "تحديد",
44
"selectedAll": "جميع العناصر المحددة.",
55
"selectedCount": "{count, plural, =0 {لم يتم تحديد عناصر} one {# عنصر محدد} other {# عنصر محدد}}.",
6-
"selectedItem": "{item} المحدد"
6+
"selectedItem": "{item} المحدد",
7+
"longPressToSelect": "Long press to enter selection mode."
78
}

packages/@react-aria/grid/intl/en-US.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
"select": "Select",
44
"selectedCount": "{count, plural, =0 {No items selected} one {# item selected} other {# items selected}}.",
55
"selectedAll": "All items selected.",
6-
"selectedItem": "{item} selected."
6+
"selectedItem": "{item} selected.",
7+
"longPressToSelect": "Long press to enter selection mode."
78
}

packages/@react-aria/grid/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@react-aria/selection": "^3.7.0",
2626
"@react-aria/utils": "^3.10.0",
2727
"@react-stately/grid": "^3.1.0",
28+
"@react-stately/selection": "^3.8.0",
2829
"@react-stately/virtualizer": "^3.1.6",
2930
"@react-types/checkbox": "^3.2.3",
3031
"@react-types/grid": "^3.0.0",

packages/@react-aria/grid/src/useGrid.ts

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ import {AriaLabelingProps, DOMProps, KeyboardDelegate, Selection} from '@react-t
1515
import {filterDOMProps, mergeProps, useId, useUpdateEffect} from '@react-aria/utils';
1616
import {GridCollection} from '@react-types/grid';
1717
import {GridKeyboardDelegate} from './GridKeyboardDelegate';
18-
import {gridKeyboardDelegates} from './utils';
18+
import {gridMap} from './utils';
1919
import {GridState} from '@react-stately/grid';
2020
import {HTMLAttributes, Key, RefObject, useMemo, useRef} from 'react';
2121
// @ts-ignore
2222
import intlMessages from '../intl/*.json';
2323
import {useCollator, useLocale, useMessageFormatter} from '@react-aria/i18n';
24+
import {useHighlightSelectionDescription} from './useHighlightSelectionDescription';
2425
import {useSelectableCollection} from '@react-aria/selection';
2526

2627
export interface GridProps extends DOMProps, AriaLabelingProps {
@@ -44,7 +45,11 @@ export interface GridProps extends DOMProps, AriaLabelingProps {
4445
/**
4546
* The ref attached to the scrollable body. Used to provided automatic scrolling on item focus for non-virtualized grids.
4647
*/
47-
scrollRef?: RefObject<HTMLElement>
48+
scrollRef?: RefObject<HTMLElement>,
49+
/** Handler that is called when a user performs an action on the row. */
50+
onRowAction?: (key: Key) => void,
51+
/** Handler that is called when a user performs an action on the cell. */
52+
onCellAction?: (key: Key) => void
4853
}
4954

5055
export interface GridAria {
@@ -65,7 +70,9 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
6570
keyboardDelegate,
6671
focusMode,
6772
getRowText = (key) => state.collection.getItem(key)?.textValue,
68-
scrollRef
73+
scrollRef,
74+
onRowAction,
75+
onCellAction
6976
} = props;
7077
let formatMessage = useMessageFormatter(intlMessages);
7178

@@ -85,6 +92,7 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
8592
collator,
8693
focusMode
8794
}), [keyboardDelegate, state.collection, state.disabledKeys, ref, direction, collator, focusMode]);
95+
8896
let {collectionProps} = useSelectableCollection({
8997
ref,
9098
selectionManager: state.selectionManager,
@@ -94,16 +102,25 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
94102
});
95103

96104
let id = useId();
97-
gridKeyboardDelegates.set(state, delegate);
105+
gridMap.set(state, {keyboardDelegate: delegate, actions: {onRowAction, onCellAction}});
98106

99-
let domProps = filterDOMProps(props, {labelable: true});
100-
let gridProps: HTMLAttributes<HTMLElement> = mergeProps(domProps, {
101-
role: 'grid',
102-
id,
103-
'aria-multiselectable': state.selectionManager.selectionMode === 'multiple' ? 'true' : undefined,
104-
...collectionProps
107+
let descriptionProps = useHighlightSelectionDescription({
108+
selectionManager: state.selectionManager,
109+
hasItemActions: !!(onRowAction || onCellAction)
105110
});
106111

112+
let domProps = filterDOMProps(props, {labelable: true});
113+
let gridProps: HTMLAttributes<HTMLElement> = mergeProps(
114+
domProps,
115+
{
116+
role: 'grid',
117+
id,
118+
'aria-multiselectable': state.selectionManager.selectionMode === 'multiple' ? 'true' : undefined
119+
},
120+
collectionProps,
121+
descriptionProps
122+
);
123+
107124
if (isVirtualized) {
108125
gridProps['aria-rowcount'] = state.collection.size;
109126
gridProps['aria-colcount'] = state.collection.columnCount;
@@ -114,9 +131,7 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
114131
let selection = state.selectionManager.rawSelection;
115132
let lastSelection = useRef(selection);
116133
useUpdateEffect(() => {
117-
// Do not do this when using selectionBehavior = 'replace' to avoid selection announcements
118-
// every time the user presses the arrow keys.
119-
if (!state.selectionManager.isFocused || state.selectionManager.selectionBehavior === 'replace') {
134+
if (!state.selectionManager.isFocused) {
120135
lastSelection.current = selection;
121136

122137
return;
@@ -126,8 +141,17 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
126141
let removedKeys = diffSelection(lastSelection.current, selection);
127142

128143
// If adding or removing a single row from the selection, announce the name of that item.
144+
let isReplace = state.selectionManager.selectionBehavior === 'replace';
129145
let messages = [];
130-
if (addedKeys.size === 1 && removedKeys.size === 0) {
146+
147+
if ((state.selectionManager.selectedKeys.size === 1 && isReplace)) {
148+
if (state.collection.getItem(state.selectionManager.selectedKeys.keys().next().value)) {
149+
let currentSelectionText = getRowText(state.selectionManager.selectedKeys.keys().next().value);
150+
if (currentSelectionText) {
151+
messages.push(formatMessage('selectedItem', {item: currentSelectionText}));
152+
}
153+
}
154+
} else if (addedKeys.size === 1 && removedKeys.size === 0) {
131155
let addedText = getRowText(addedKeys.keys().next().value);
132156
if (addedText) {
133157
messages.push(formatMessage('selectedItem', {item: addedText}));
@@ -143,7 +167,7 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
143167

144168
// Announce how many items are selected, except when selecting the first item.
145169
if (state.selectionManager.selectionMode === 'multiple') {
146-
if (messages.length === 0 || selection === 'all' || selection.size > 1 || lastSelection.current === 'all' || lastSelection.current.size > 1) {
170+
if (messages.length === 0 || selection === 'all' || selection.size > 1 || lastSelection.current === 'all' || lastSelection.current?.size > 1) {
147171
messages.push(selection === 'all'
148172
? formatMessage('selectedAll')
149173
: formatMessage('selectedCount', {count: selection.size})

packages/@react-aria/grid/src/useGridCell.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
1414
import {GridCollection} from '@react-types/grid';
15-
import {gridKeyboardDelegates} from './utils';
15+
import {gridMap} from './utils';
1616
import {GridState} from '@react-stately/grid';
1717
import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, RefObject} from 'react';
1818
import {isFocusVisible} from '@react-aria/interactions';
@@ -30,7 +30,11 @@ interface GridCellProps {
3030
focusMode?: 'child' | 'cell',
3131
/** Whether selection should occur on press up instead of press down. */
3232
shouldSelectOnPressUp?: boolean,
33-
/** Handler that is called when a user performs an action on the cell. */
33+
/**
34+
* Handler that is called when a user performs an action on the cell.
35+
* Please use onCellAction at the collection level instead.
36+
* @deprecated
37+
**/
3438
onAction?: () => void
3539
}
3640

@@ -56,7 +60,7 @@ export function useGridCell<T, C extends GridCollection<T>>(props: GridCellProps
5660
} = props;
5761

5862
let {direction} = useLocale();
59-
let keyboardDelegate = gridKeyboardDelegates.get(state);
63+
let {keyboardDelegate, actions: {onCellAction}} = gridMap.get(state);
6064

6165
// Handles focusing the cell. If there is a focusable child,
6266
// it is focused, otherwise the cell itself is focused.
@@ -84,7 +88,7 @@ export function useGridCell<T, C extends GridCollection<T>>(props: GridCellProps
8488
isVirtualized,
8589
focus,
8690
shouldSelectOnPressUp,
87-
onAction
91+
onAction: onCellAction ? () => onCellAction(node.key) : onAction
8892
});
8993

9094
let onKeyDown = (e: ReactKeyboardEvent) => {

packages/@react-aria/grid/src/useGridRow.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import {GridCollection} from '@react-types/grid';
14+
import {gridMap} from './utils';
1415
import {GridState} from '@react-stately/grid';
1516
import {HTMLAttributes, RefObject} from 'react';
1617
import {Node} from '@react-types/shared';
@@ -23,7 +24,11 @@ export interface GridRowProps<T> {
2324
isVirtualized?: boolean,
2425
/** Whether selection should occur on press up instead of press down. */
2526
shouldSelectOnPressUp?: boolean,
26-
/** Handler that is called when a user performs an action on the row. */
27+
/**
28+
* Handler that is called when a user performs an action on the row.
29+
* Please use onCellAction at the collection level instead.
30+
* @deprecated
31+
**/
2732
onAction?: () => void
2833
}
2934

@@ -47,13 +52,14 @@ export function useGridRow<T, C extends GridCollection<T>, S extends GridState<T
4752
onAction
4853
} = props;
4954

55+
let {actions: {onRowAction}} = gridMap.get(state);
5056
let {itemProps, isPressed} = useSelectableItem({
5157
selectionManager: state.selectionManager,
5258
key: node.key,
5359
ref,
5460
isVirtualized,
5561
shouldSelectOnPressUp,
56-
onAction
62+
onAction: onRowAction ? () => onRowAction(node.key) : onAction
5763
});
5864

5965
let isSelected = state.selectionManager.isSelected(node.key);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright 2021 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {AriaLabelingProps} from '@react-types/shared';
14+
// @ts-ignore
15+
import intlMessages from '../intl/*.json';
16+
import {MultipleSelectionManager} from '@react-stately/selection';
17+
import {useDescription} from '@react-aria/utils';
18+
import {useInteractionModality} from '@react-aria/interactions';
19+
import {useMemo} from 'react';
20+
import {useMessageFormatter} from '@react-aria/i18n';
21+
22+
interface UseHighlightSelectionDescriptionProps {
23+
selectionManager: MultipleSelectionManager,
24+
hasItemActions?: boolean
25+
}
26+
27+
/**
28+
* Computes the description for a grid selectable collection.
29+
* @param props
30+
*/
31+
export function useHighlightSelectionDescription(props: UseHighlightSelectionDescriptionProps): AriaLabelingProps {
32+
let formatMessage = useMessageFormatter(intlMessages);
33+
let modality = useInteractionModality();
34+
// null is the default if the user hasn't interacted with the table at all yet or the rest of the page
35+
let shouldLongPress = (modality === 'pointer' || modality === 'virtual' || modality == null) && 'ontouchstart' in window;
36+
37+
let interactionDescription = useMemo(() => {
38+
let selectionMode = props.selectionManager.selectionMode;
39+
let selectionBehavior = props.selectionManager.selectionBehavior;
40+
41+
let message = undefined;
42+
if (shouldLongPress) {
43+
message = formatMessage('longPressToSelect');
44+
}
45+
46+
return selectionBehavior === 'replace' && selectionMode !== 'none' && props.hasItemActions ? message : undefined;
47+
}, [props.selectionManager.selectionMode, props.selectionManager.selectionBehavior, props.hasItemActions, formatMessage, shouldLongPress]);
48+
49+
let descriptionProps = useDescription(interactionDescription);
50+
return descriptionProps;
51+
}

packages/@react-aria/grid/src/utils.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,18 @@
1212

1313
import type {GridCollection} from '@react-types/grid';
1414
import type {GridState} from '@react-stately/grid';
15+
import {Key} from 'react';
1516
import type {KeyboardDelegate} from '@react-types/shared';
1617

17-
// Used to share keyboard delegate between useGrid and useGridCell
18-
export const gridKeyboardDelegates = new WeakMap<GridState<unknown, GridCollection<unknown>>, KeyboardDelegate>();
18+
interface GridMapShared {
19+
keyboardDelegate: KeyboardDelegate,
20+
actions: {
21+
onRowAction: (key: Key) => void,
22+
onCellAction: (key: Key) => void
23+
}
24+
}
25+
26+
// Used to share:
27+
// keyboard delegate between useGrid and useGridCell
28+
// onRowAction/onCellAction across hooks
29+
export const gridMap = new WeakMap<GridState<unknown, GridCollection<unknown>>, GridMapShared>();

packages/@react-aria/grid/test/useGrid.test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ function renderGrid(props = {}) {
3838
}
3939

4040
describe('useGrid', () => {
41+
beforeAll(() => {
42+
jest.useFakeTimers('modern');
43+
});
44+
afterEach(() => {
45+
// run out notifications
46+
act(() => {jest.runAllTimers();});
47+
});
4148
it('gridFocusMode = row, cellFocusMode = cell', () => {
4249
let tree = renderGrid({gridFocusMode: 'row', cellFocusMode: 'cell'});
4350

packages/@react-aria/table/src/useTable.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ export function useTable<T>(props: TableProps<T>, state: TableState<T>, ref: Ref
117117
}, [sortDescription]);
118118

119119
return {
120-
gridProps: mergeProps(gridProps, descriptionProps)
120+
gridProps: mergeProps(
121+
gridProps,
122+
descriptionProps,
123+
{
124+
// merge sort description with long press information
125+
'aria-describedby': [descriptionProps['aria-describedby'], gridProps['aria-describedby']].filter(Boolean).join(' ')
126+
}
127+
)
121128
};
122129
}

0 commit comments

Comments
 (0)