Skip to content

Commit 3732448

Browse files
authored
Resizer keyboard (#3258)
1 parent 0df3071 commit 3732448

File tree

19 files changed

+838
-145
lines changed

19 files changed

+838
-145
lines changed

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

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ svg.spectrum-Table-sortedIcon {
6565
text-transform: uppercase;
6666
padding: var(--spectrum-table-header-padding-y) var(--spectrum-table-header-padding-x);
6767
transition: color var(--spectrum-global-animation-duration-100) ease-in-out;
68-
cursor: default;
6968
outline: 0;
7069
border-radius: var(--spectrum-table-header-border-radius);
7170

@@ -89,13 +88,6 @@ svg.spectrum-Table-sortedIcon {
8988
}
9089
}
9190

92-
.spectrum-Table--resizingColumn {
93-
.spectrum-Table-row,
94-
.spectrum-Table-headCell {
95-
cursor: col-resize;
96-
}
97-
}
98-
9991
.spectrum-Table-columnResizer {
10092
display: flex;
10193
justify-content: flex-end;
@@ -105,24 +97,21 @@ svg.spectrum-Table-sortedIcon {
10597
inset-inline-end: 0px;
10698
inline-size: 10px;
10799
block-size: 100%;
108-
cursor: col-resize;
109100
user-select: none;
110101

111102
&::after {
112103
content: "";
113-
position: absolute;
114104
display: block;
115105
box-sizing: border-box;
116106
inline-size: 1px;
117107
block-size: 100%;
118-
background-color: var(--spectrum-table-divider-border-color);
119108
}
120109

121110
&:active,
122111
&:focus {
112+
outline: none;
123113
&::after {
124114
inline-size: 2px;
125-
background-color: var(--spectrum-global-color-blue-400);
126115
}
127116
}
128117
}

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,11 @@ tbody.spectrum-Table-body {
149149
}
150150

151151
/* Alternative to border on rows. Using box shadow since they don't take room unlike border which would cause wiggles
152-
* in the hightlight case and displace the sticky indicator. Also allows for a nicer bottom curved border to match the container,
152+
* in the highlight case and displace the sticky indicator. Also allows for a nicer bottom curved border to match the container,
153153
* the bottom border curved corners were cut off when using borders.
154154
*/
155155

156-
/* Box shadow for bottom border for non-selected rows that aren't immediatly above a selected row. Can't omit the bottom border for last row unlike listview
156+
/* Box shadow for bottom border for non-selected rows that aren't immediately above a selected row. Can't omit the bottom border for last row unlike listview
157157
* due to how table rows always reserve 1px for the bottom border (results in a white gap on hover otherwise).
158158
*/
159159
&:after {
@@ -169,7 +169,7 @@ tbody.spectrum-Table-body {
169169
pointer-events: none;
170170
}
171171

172-
/* Box shadow for bottom border for non-selected row that is immediatly above a selected row. */
172+
/* Box shadow for bottom border for non-selected row that is immediately above a selected row. */
173173
&.is-next-selected {
174174
&:after {
175175
box-shadow: inset 0 -1px 0 0 var(--spectrum-global-color-blue-500);
@@ -283,3 +283,16 @@ tbody.spectrum-Table-body {
283283
}
284284
}
285285
}
286+
287+
.spectrum-Table-columnResizer {
288+
&::after {
289+
background-color: var(--spectrum-table-divider-border-color);
290+
}
291+
292+
&:active,
293+
&:focus {
294+
&::after {
295+
background-color: var(--spectrum-global-color-blue-400);
296+
}
297+
}
298+
}

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
236236
useLayoutEffect(() => {
237237
let scope = scopeRef.current;
238238
if (!contain) {
239+
// if contain was changed, then we should cancel any ongoing waits to pull focus back into containment
240+
if (raf.current) {
241+
cancelAnimationFrame(raf.current);
242+
raf.current = null;
243+
}
239244
return;
240245
}
241246

@@ -310,7 +315,11 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
310315

311316
// eslint-disable-next-line arrow-body-style
312317
useEffect(() => {
313-
return () => cancelAnimationFrame(raf.current);
318+
return () => {
319+
if (raf.current) {
320+
cancelAnimationFrame(raf.current);
321+
}
322+
};
314323
}, [raf]);
315324
}
316325

@@ -462,7 +471,8 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
462471

463472
if (restoreFocus && nodeToRestore && isElementInScope(document.activeElement, scopeRef.current)) {
464473
requestAnimationFrame(() => {
465-
if (document.body.contains(nodeToRestore)) {
474+
// Only restore focus if we've lost focus to the body, the alternative is that focus has been purposefully moved elsewhere
475+
if (document.body.contains(nodeToRestore) && document.activeElement === document.body) {
466476
focusElement(nodeToRestore);
467477
}
468478
});

packages/@react-aria/focus/test/FocusScope.test.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,7 @@ import userEvent from '@testing-library/user-event';
1818

1919
describe('FocusScope', function () {
2020
beforeEach(() => {
21-
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb());
22-
});
23-
24-
afterEach(() => {
25-
window.requestAnimationFrame.mockRestore();
21+
jest.useFakeTimers();
2622
});
2723

2824
describe('focus containment', function () {
@@ -234,9 +230,11 @@ describe('FocusScope', function () {
234230

235231
userEvent.tab();
236232
fireEvent.focusIn(input2);
233+
act(() => {jest.runAllTimers();});
237234
expect(document.activeElement).toBe(input2);
238235

239236
act(() => {input2.blur();});
237+
act(() => {jest.runAllTimers();});
240238
expect(document.activeElement).toBe(input2);
241239

242240
act(() => {outside.focus();});
@@ -263,9 +261,11 @@ describe('FocusScope', function () {
263261

264262
userEvent.tab();
265263
fireEvent.focusIn(input2);
264+
act(() => {jest.runAllTimers();});
266265
expect(document.activeElement).toBe(input2);
267266

268267
act(() => {input2.blur();});
268+
act(() => {jest.runAllTimers();});
269269
expect(document.activeElement).toBe(input2);
270270
fireEvent.focusOut(input2);
271271
expect(document.activeElement).toBe(input2);
@@ -327,6 +327,7 @@ describe('FocusScope', function () {
327327
expect(document.activeElement).toBe(input1);
328328

329329
rerender(<Test />);
330+
act(() => {jest.runAllTimers();});
330331

331332
expect(document.activeElement).toBe(outside);
332333
});
@@ -358,6 +359,7 @@ describe('FocusScope', function () {
358359
expect(document.activeElement).toBe(input2);
359360

360361
rerender(<Test />);
362+
act(() => {jest.runAllTimers();});
361363

362364
expect(document.activeElement).toBe(outside);
363365
});
@@ -454,6 +456,7 @@ describe('FocusScope', function () {
454456
expect(document.activeElement).toBe(dynamic);
455457

456458
rerender(<Test />);
459+
act(() => {jest.runAllTimers();});
457460

458461
expect(document.activeElement).toBe(outside);
459462
});
@@ -1068,14 +1071,19 @@ describe('FocusScope', function () {
10681071
let child2 = getByTestId('child2');
10691072
let child3 = getByTestId('child3');
10701073

1074+
act(() => {jest.runAllTimers();});
10711075
expect(document.activeElement).toBe(child1);
10721076
userEvent.tab();
1077+
act(() => {jest.runAllTimers();});
10731078
expect(document.activeElement).toBe(child2);
10741079
userEvent.tab();
1080+
act(() => {jest.runAllTimers();});
10751081
expect(document.activeElement).toBe(child3);
10761082
userEvent.tab();
1083+
act(() => {jest.runAllTimers();});
10771084
expect(document.activeElement).toBe(child1);
10781085
userEvent.tab({shift: true});
1086+
act(() => {jest.runAllTimers();});
10791087
expect(document.activeElement).toBe(child3);
10801088
});
10811089

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
114114
id,
115115
'aria-multiselectable': state.selectionManager.selectionMode === 'multiple' ? 'true' : undefined
116116
},
117-
collectionProps,
117+
state.isKeyboardNavigationDisabled ? {} : collectionProps,
118118
descriptionProps
119119
);
120120

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ export function useGridCell<T, C extends GridCollection<T>>(props: GridCellProps
9696
onAction: onCellAction ? () => onCellAction(node.key) : onAction
9797
});
9898

99-
let onKeyDown = (e: ReactKeyboardEvent) => {
100-
if (!e.currentTarget.contains(e.target as HTMLElement)) {
99+
let onKeyDownCapture = (e: ReactKeyboardEvent) => {
100+
if (!e.currentTarget.contains(e.target as HTMLElement) || state.isKeyboardNavigationDisabled) {
101101
return;
102102
}
103103

@@ -225,7 +225,7 @@ export function useGridCell<T, C extends GridCollection<T>>(props: GridCellProps
225225

226226
let gridCellProps: HTMLAttributes<HTMLElement> = mergeProps(itemProps, {
227227
role: 'gridcell',
228-
onKeyDownCapture: onKeyDown,
228+
onKeyDownCapture,
229229
onFocus
230230
});
231231

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,18 @@ export function useTableColumnHeader<T>(props: ColumnHeaderProps, state: TableSt
4444
let {node} = props;
4545
let allowsResizing = node.props.allowsResizing;
4646
let allowsSorting = node.props.allowsSorting;
47-
let {gridCellProps} = useGridCell(props, state, ref);
47+
// 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
48+
let {gridCellProps} = useGridCell({...props, focusMode: node.props.isSelectionCell || node.props.allowsResizing || node.props.allowsSorting ? 'child' : 'cell'}, state, ref);
4849

4950
let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single';
51+
5052
let {pressProps} = usePress({
5153
// Disabled for allowsResizing because if resizing is allowed, a menu trigger is added to the column header.
52-
isDisabled: !allowsSorting || isSelectionCellDisabled || allowsResizing,
54+
isDisabled: (!(allowsSorting || allowsResizing)) || isSelectionCellDisabled,
5355
onPress() {
54-
state.sort(node.key);
55-
}
56+
!allowsResizing && state.sort(node.key);
57+
},
58+
ref
5659
});
5760

5861
// Needed to pick up the focusable context, enabling things like Tooltips for example

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

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

13-
import {ColumnResizeState} from '@react-stately/table';
14-
import {focusSafely, useFocusable} from '@react-aria/focus';
13+
import {ColumnResizeState, TableState} from '@react-stately/table';
14+
import {focusSafely} from '@react-aria/focus';
1515
import {GridNode} from '@react-types/grid';
1616
import {HTMLAttributes, RefObject, useRef} from 'react';
1717
import {mergeProps} from '@react-aria/utils';
@@ -23,41 +23,37 @@ interface ResizerAria {
2323
}
2424

2525
interface ResizerProps<T> {
26-
column: GridNode<T>
26+
column: GridNode<T>,
27+
showResizer: boolean,
28+
label: string
2729
}
2830

29-
export function useTableColumnResize<T>(props: ResizerProps<T>, state: ColumnResizeState<T>, ref: RefObject<HTMLDivElement>): ResizerAria {
30-
let {column: item} = props;
31+
export function useTableColumnResize<T>(props: ResizerProps<T>, state: TableState<T> & ColumnResizeState<T>, ref: RefObject<HTMLDivElement>): ResizerAria {
32+
let {column: item, showResizer} = props;
3133
const stateRef = useRef(null);
3234
// keep track of what the cursor on the body is so it can be restored back to that when done resizing
3335
const cursor = useRef(null);
3436
stateRef.current = state;
3537

3638
let {direction} = useLocale();
37-
let {focusableProps} = useFocusable({excludeFromTabOrder: true}, ref);
3839
let {keyboardProps} = useKeyboard({
3940
onKeyDown: (e) => {
40-
if (e.key === 'Tab') {
41-
// useKeyboard stops propagation by default. We want to continue propagation for tab so focus leaves the table
42-
e.continuePropagation();
43-
}
44-
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
45-
// switch focus back to the column header on escape
46-
const columnHeader = ref.current.previousSibling as HTMLElement;
47-
if (columnHeader) {
48-
focusSafely(columnHeader);
49-
}
41+
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') {
42+
e.preventDefault();
43+
// switch focus back to the column header on anything that ends edit mode
44+
focusSafely(ref.current.closest('[role="columnheader"]'));
5045
}
5146
}
5247
});
5348

5449
const columnResizeWidthRef = useRef(null);
5550
const {moveProps} = useMove({
56-
onMoveStart() {
57-
stateRef.current.onColumnResizeStart();
51+
onMoveStart({pointerType}) {
52+
if (pointerType !== 'keyboard') {
53+
stateRef.current.onColumnResizeStart(item);
54+
}
5855
columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key);
5956
cursor.current = document.body.style.cursor;
60-
document.body.style.setProperty('cursor', 'col-resize');
6157
},
6258
onMove({deltaX, pointerType}) {
6359
if (direction === 'rtl') {
@@ -70,18 +66,54 @@ export function useTableColumnResize<T>(props: ResizerProps<T>, state: ColumnRes
7066
}
7167
columnResizeWidthRef.current += deltaX;
7268
stateRef.current.onColumnResize(item, columnResizeWidthRef.current);
69+
if (stateRef.current.getColumnMinWidth(item.key) >= stateRef.current.getColumnWidth(item.key)) {
70+
document.body.style.setProperty('cursor', direction === 'rtl' ? 'w-resize' : 'e-resize');
71+
} else if (stateRef.current.getColumnMaxWidth(item.key) <= stateRef.current.getColumnWidth(item.key)) {
72+
document.body.style.setProperty('cursor', direction === 'rtl' ? 'e-resize' : 'w-resize');
73+
} else {
74+
document.body.style.setProperty('cursor', 'col-resize');
75+
}
7376
}
7477
},
75-
onMoveEnd() {
76-
stateRef.current.onColumnResizeEnd();
78+
onMoveEnd({pointerType}) {
79+
if (pointerType !== 'keyboard') {
80+
stateRef.current.onColumnResizeEnd(item);
81+
}
7782
columnResizeWidthRef.current = 0;
7883
document.body.style.cursor = cursor.current;
7984
}
8085
});
8186

87+
let ariaProps = {
88+
role: 'separator',
89+
'aria-label': props.label,
90+
'aria-orientation': 'vertical',
91+
'aria-labelledby': item.key,
92+
'aria-valuenow': stateRef.current.getColumnWidth(item.key),
93+
'aria-valuemin': stateRef.current.getColumnMinWidth(item.key),
94+
'aria-valuemax': stateRef.current.getColumnMaxWidth(item.key)
95+
};
96+
8297
return {
8398
resizerProps: {
84-
...mergeProps(moveProps, focusableProps, keyboardProps)
99+
...mergeProps(
100+
moveProps,
101+
{
102+
onFocus: () => {
103+
// useMove calls onMoveStart for every keypress, but we want resize start to only be called when we start resize mode
104+
// call instead during focus and blur
105+
stateRef.current.onColumnResizeStart(item);
106+
state.setKeyboardNavigationDisabled(true);
107+
},
108+
onBlur: () => {
109+
stateRef.current.onColumnResizeEnd(item);
110+
state.setKeyboardNavigationDisabled(false);
111+
},
112+
tabIndex: showResizer ? 0 : undefined
113+
},
114+
keyboardProps,
115+
ariaProps
116+
)
85117
}
86118
};
87119
}

packages/@react-spectrum/menu/src/MenuTrigger.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ function MenuTrigger(props: SpectrumMenuTriggerProps, ref: DOMRef<HTMLElement>)
8282
UNSAFE_className: classNames(styles, {'spectrum-Menu-popover': !isMobile})
8383
};
8484

85+
// Only contain focus while the menu is open. There is a fade out transition during which we may try to move focus.
86+
// If we contain, then focus will be pulled back into the menu.
8587
let contents = (
86-
<FocusScope restoreFocus contain={isMobile}>
88+
<FocusScope restoreFocus contain={isMobile && state.isOpen}>
8789
<DismissButton onDismiss={state.close} />
8890
{menu}
8991
<DismissButton onDismiss={state.close} />

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
"loadingMore": "جارٍ تحميل المزيد...",
44
"sortAscending": "Sort Ascending",
55
"sortDescending": "Sort Descending",
6-
"resizeColumn": "Resize column"
6+
"resizeColumn": "Resize column",
7+
"columnResizer": "Column resizer"
78
}

0 commit comments

Comments
 (0)