Skip to content

Commit f4c916e

Browse files
authored
Table Column Resize via screen readers (#3295)
1 parent 43ef6ae commit f4c916e

File tree

11 files changed

+322
-179
lines changed

11 files changed

+322
-179
lines changed

packages/@adobe/spectrum-css-temp/components/table/index.css

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,24 @@ svg.spectrum-Table-sortedIcon {
8686
transform: rotateZ(180deg);
8787
}
8888
}
89+
&.is-resizable {
90+
padding: 0;
91+
.spectrum-Table-headCellContents {
92+
flex: 1 1 auto;
93+
min-width: 0;
94+
}
95+
.spectrum-Table-headCellButton {
96+
box-sizing: border-box;
97+
padding: var(--spectrum-table-header-padding-y) var(--spectrum-table-header-padding-x);
98+
}
99+
}
89100
}
90101

91102
.spectrum-Table-columnResizer {
92103
display: flex;
104+
flex: 0 0 auto;
93105
justify-content: flex-end;
94106
box-sizing: border-box;
95-
position: absolute;
96-
inset-block-start: 0px;
97-
inset-inline-end: 0px;
98107
inline-size: 10px;
99108
block-size: 100%;
100109
user-select: none;
@@ -108,7 +117,7 @@ svg.spectrum-Table-sortedIcon {
108117
}
109118

110119
&:active,
111-
&:focus {
120+
&.focus-ring {
112121
outline: none;
113122
&::after {
114123
inline-size: 2px;
@@ -223,7 +232,8 @@ svg.spectrum-Table-sortedIcon {
223232
}
224233

225234
.spectrum-Table-cell,
226-
.spectrum-Table-headCell {
235+
.spectrum-Table-headCell,
236+
.spectrum-Table-headCellButton {
227237
position: relative;
228238

229239
&:focus {
@@ -251,7 +261,8 @@ svg.spectrum-Table-sortedIcon {
251261
}
252262
}
253263

254-
.spectrum-Table-headCell {
264+
.spectrum-Table-headCell,
265+
.spectrum-Table-headCellButton {
255266
&:focus-ring,
256267
&.is-focused {
257268
&::before {
@@ -267,14 +278,6 @@ svg.spectrum-Table-sortedIcon {
267278
border-inline-end-width: var(--spectrum-table-divider-border-size);
268279
}
269280

270-
.spectrum-Table-cell--divider {
271-
&.is-resizable {
272-
&:hover {
273-
border-inline-end-width: 3px;
274-
}
275-
}
276-
}
277-
278281
.spectrum-Table-row {
279282
position: relative;
280283
cursor: default;

packages/@adobe/spectrum-css-temp/components/table/skin.css

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,6 @@ governing permissions and limitations under the License.
4949
}
5050
}
5151
}
52-
53-
&.is-resizable {
54-
&.is-hovered {
55-
color: var(--spectrum-table-header-text-color-hover);
56-
}
57-
}
5852
}
5953

6054
/* Helper for shared drop target overlay */
@@ -68,7 +62,8 @@ governing permissions and limitations under the License.
6862
}
6963

7064
.spectrum-Table-cell,
71-
.spectrum-Table-headCell {
65+
.spectrum-Table-headCell,
66+
.spectrum-Table-headCellButton {
7267
&:focus-ring,
7368
&.is-focused {
7469
&::before {
@@ -290,7 +285,7 @@ tbody.spectrum-Table-body {
290285
}
291286

292287
&:active,
293-
&:focus {
288+
&:focus-ring {
294289
&::after {
295290
background-color: var(--spectrum-global-color-blue-400);
296291
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
"descendingSort": "ترتيب حسب العمود {columnName} بترتيب تنازلي",
66
"select": "تحديد",
77
"selectAll": "تحديد الكل",
8-
"sortable": "عمود قابل للترتيب"
8+
"sortable": "عمود قابل للترتيب",
9+
"resizeTextValue": "{value} pixels"
910
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
"ascending": "ascending",
66
"descending": "descending",
77
"ascendingSort": "sorted by column {columnName} in ascending order",
8-
"descendingSort": "sorted by column {columnName} in descending order"
8+
"descendingSort": "sorted by column {columnName} in descending order",
9+
"columnSize": "{value} pixels"
910
}

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export interface AriaTableColumnHeaderProps {
2727
/** An object representing the [column header](https://www.w3.org/TR/wai-aria-1.1/#columnheader). Contains all the relevant information that makes up the column header. */
2828
node: GridNode<unknown>,
2929
/** Whether the [column header](https://www.w3.org/TR/wai-aria-1.1/#columnheader) is contained in a virtual scroller. */
30-
isVirtualized?: boolean
30+
isVirtualized?: boolean,
31+
/** Whether the column has a menu in the header, this changes interactions with the header. */
32+
hasMenu?: boolean
3133
}
3234

3335
export interface TableColumnHeaderAria {
@@ -43,25 +45,27 @@ export interface TableColumnHeaderAria {
4345
*/
4446
export function useTableColumnHeader<T>(props: AriaTableColumnHeaderProps, state: TableState<T>, ref: RefObject<FocusableElement>): TableColumnHeaderAria {
4547
let {node} = props;
46-
let allowsResizing = node.props.allowsResizing;
4748
let allowsSorting = node.props.allowsSorting;
4849
// the selection cell column header needs to focus the checkbox within it but the other columns should focus the cell so that focus doesn't land on the resizer
49-
let {gridCellProps} = useGridCell({...props, focusMode: node.props.isSelectionCell || node.props.allowsResizing || node.props.allowsSorting ? 'child' : 'cell'}, state, ref);
50+
let {gridCellProps} = useGridCell({...props, focusMode: node.props.isSelectionCell || props.hasMenu || node.props.allowsSorting ? 'child' : 'cell'}, state, ref);
5051

5152
let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single';
5253

5354
let {pressProps} = usePress({
54-
// Disabled for allowsResizing because if resizing is allowed, a menu trigger is added to the column header.
55-
isDisabled: (!(allowsSorting || allowsResizing)) || isSelectionCellDisabled,
55+
isDisabled: !allowsSorting || isSelectionCellDisabled,
5656
onPress() {
57-
!allowsResizing && state.sort(node.key);
57+
state.sort(node.key);
5858
},
5959
ref
6060
});
6161

6262
// Needed to pick up the focusable context, enabling things like Tooltips for example
6363
let {focusableProps} = useFocusable({}, ref);
6464

65+
if (props.hasMenu) {
66+
pressProps = {};
67+
}
68+
6569
let ariaSort: DOMAttributes['aria-sort'] = null;
6670
let isSortedColumn = state.sortDescriptor?.column === node.key;
6771
let sortDirection = state.sortDescriptor?.direction;
@@ -84,7 +88,9 @@ export function useTableColumnHeader<T>(props: AriaTableColumnHeaderProps, state
8488

8589
return {
8690
columnHeaderProps: {
87-
...mergeProps(gridCellProps, pressProps, focusableProps, descriptionProps),
91+
...mergeProps(gridCellProps, pressProps, focusableProps, descriptionProps, {
92+
onPointerDown: (e) => console.log(e.target.outerHTML)
93+
}),
8894
role: 'columnheader',
8995
id: getColumnHeaderId(state, node.key),
9096
'aria-colspan': node.colspan && node.colspan > 1 ? node.colspan : null,

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

Lines changed: 93 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -10,49 +10,52 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {ChangeEvent, RefObject, useCallback, useRef} from 'react';
1314
import {DOMAttributes} from '@react-types/shared';
1415
import {focusSafely} from '@react-aria/focus';
16+
import {focusWithoutScrolling, mergeProps, useId} from '@react-aria/utils';
17+
import {getColumnHeaderId} from './utils';
1518
import {GridNode} from '@react-types/grid';
16-
import {mergeProps} from '@react-aria/utils';
17-
import {RefObject, useRef} from 'react';
19+
// @ts-ignore
20+
import intlMessages from '../intl/*.json';
1821
import {TableColumnResizeState, TableState} from '@react-stately/table';
19-
import {useKeyboard, useMove} from '@react-aria/interactions';
20-
import {useLocale} from '@react-aria/i18n';
22+
import {useKeyboard, useMove, usePress} from '@react-aria/interactions';
23+
import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
2124

2225
export interface TableColumnResizeAria {
26+
inputProps: DOMAttributes,
2327
resizerProps: DOMAttributes
2428
}
2529

2630
export interface AriaTableColumnResizeProps<T> {
2731
column: GridNode<T>,
28-
showResizer: boolean,
29-
label: string
32+
label: string,
33+
triggerRef: RefObject<HTMLDivElement>
3034
}
3135

32-
export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, state: TableState<T> & TableColumnResizeState<T>, ref: RefObject<HTMLDivElement>): TableColumnResizeAria {
33-
let {column: item, showResizer} = props;
34-
const stateRef = useRef(null);
36+
export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, state: TableState<T>, columnState: TableColumnResizeState<T>, ref: RefObject<HTMLInputElement>): TableColumnResizeAria {
37+
let {column: item, triggerRef} = props;
38+
const stateRef = useRef<TableColumnResizeState<T>>(null);
3539
// keep track of what the cursor on the body is so it can be restored back to that when done resizing
36-
const cursor = useRef(null);
37-
stateRef.current = state;
40+
const cursor = useRef<string | null>(null);
41+
stateRef.current = columnState;
42+
const stringFormatter = useLocalizedStringFormatter(intlMessages);
43+
let id = useId();
3844

3945
let {direction} = useLocale();
4046
let {keyboardProps} = useKeyboard({
4147
onKeyDown: (e) => {
4248
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') {
4349
e.preventDefault();
4450
// switch focus back to the column header on anything that ends edit mode
45-
focusSafely(ref.current.closest('[role="columnheader"]'));
51+
focusSafely(triggerRef.current);
4652
}
4753
}
4854
});
4955

50-
const columnResizeWidthRef = useRef(null);
56+
const columnResizeWidthRef = useRef<number>(0);
5157
const {moveProps} = useMove({
52-
onMoveStart({pointerType}) {
53-
if (pointerType !== 'keyboard') {
54-
stateRef.current.onColumnResizeStart(item);
55-
}
58+
onMoveStart() {
5659
columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key);
5760
cursor.current = document.body.style.cursor;
5861
},
@@ -76,45 +79,89 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
7679
}
7780
}
7881
},
79-
onMoveEnd({pointerType}) {
80-
if (pointerType !== 'keyboard') {
81-
stateRef.current.onColumnResizeEnd(item);
82-
}
82+
onMoveEnd() {
8383
columnResizeWidthRef.current = 0;
8484
document.body.style.cursor = cursor.current;
8585
}
8686
});
87+
let min = Math.floor(stateRef.current.getColumnMinWidth(item.key));
88+
let max = Math.floor(stateRef.current.getColumnMaxWidth(item.key));
89+
if (max === Infinity) {
90+
max = Number.MAX_SAFE_INTEGER;
91+
}
92+
let value = Math.floor(stateRef.current.getColumnWidth(item.key));
8793

8894
let ariaProps = {
89-
role: 'separator',
9095
'aria-label': props.label,
91-
'aria-orientation': 'vertical',
92-
'aria-labelledby': item.key,
93-
'aria-valuenow': stateRef.current.getColumnWidth(item.key),
94-
'aria-valuemin': stateRef.current.getColumnMinWidth(item.key),
95-
'aria-valuemax': stateRef.current.getColumnMaxWidth(item.key)
96+
'aria-orientation': 'horizontal' as 'horizontal',
97+
'aria-labelledby': `${id} ${getColumnHeaderId(state, item.key)}`,
98+
'aria-valuetext': stringFormatter.format('columnSize', {value}),
99+
min,
100+
max,
101+
value
102+
};
103+
104+
const focusInput = useCallback(() => {
105+
if (ref.current) {
106+
focusWithoutScrolling(ref.current);
107+
}
108+
}, [ref]);
109+
110+
let onChange = (e: ChangeEvent<HTMLInputElement>) => {
111+
let currentWidth = stateRef.current.getColumnWidth(item.key);
112+
let nextValue = parseFloat(e.target.value);
113+
114+
if (nextValue > currentWidth) {
115+
nextValue = currentWidth + 10;
116+
} else {
117+
nextValue = currentWidth - 10;
118+
}
119+
stateRef.current.onColumnResize(item, nextValue);
96120
};
97121

122+
let {pressProps} = usePress({
123+
onPressStart: (e) => {
124+
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') {
125+
return;
126+
}
127+
if (e.pointerType === 'virtual' && columnState.currentlyResizingColumn != null) {
128+
stateRef.current.onColumnResizeEnd(item);
129+
focusSafely(triggerRef.current);
130+
return;
131+
}
132+
focusInput();
133+
},
134+
onPress: (e) => {
135+
if (e.pointerType === 'touch') {
136+
focusInput();
137+
} else if (e.pointerType !== 'virtual') {
138+
focusSafely(triggerRef.current);
139+
}
140+
}
141+
});
142+
98143
return {
99-
resizerProps: {
100-
...mergeProps(
101-
moveProps,
102-
{
103-
onFocus: () => {
104-
// useMove calls onMoveStart for every keypress, but we want resize start to only be called when we start resize mode
105-
// call instead during focus and blur
106-
stateRef.current.onColumnResizeStart(item);
107-
state.setKeyboardNavigationDisabled(true);
108-
},
109-
onBlur: () => {
110-
stateRef.current.onColumnResizeEnd(item);
111-
state.setKeyboardNavigationDisabled(false);
112-
},
113-
tabIndex: showResizer ? 0 : undefined
144+
resizerProps: mergeProps(
145+
keyboardProps,
146+
moveProps,
147+
pressProps
148+
),
149+
inputProps: mergeProps(
150+
{
151+
id,
152+
onFocus: () => {
153+
// useMove calls onMoveStart for every keypress, but we want resize start to only be called when we start resize mode
154+
// call instead during focus and blur
155+
stateRef.current.onColumnResizeStart(item);
156+
state.setKeyboardNavigationDisabled(true);
114157
},
115-
keyboardProps,
116-
ariaProps
117-
)
118-
}
158+
onBlur: () => {
159+
stateRef.current.onColumnResizeEnd(item);
160+
state.setKeyboardNavigationDisabled(false);
161+
},
162+
onChange
163+
},
164+
ariaProps
165+
)
119166
};
120167
}

packages/@react-spectrum/table/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
},
3333
"dependencies": {
3434
"@babel/runtime": "^7.6.2",
35+
"@react-aria/button": "^3.6.0",
3536
"@react-aria/focus": "^3.7.0",
3637
"@react-aria/grid": "^3.4.0",
3738
"@react-aria/i18n": "^3.5.0",

0 commit comments

Comments
 (0)