Skip to content

Commit 77fbf10

Browse files
authored
Table column resizing support for RAC (#4785)
1 parent 1c32cd5 commit 77fbf10

File tree

19 files changed

+1080
-197
lines changed

19 files changed

+1080
-197
lines changed

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -551,14 +551,11 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
551551
// eslint-disable-next-line react-hooks/exhaustive-deps
552552
}, [scopeRef, contain]);
553553

554-
// useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously.
555554
useLayoutEffect(() => {
556555
if (!restoreFocus) {
557556
return;
558557
}
559558

560-
focusScopeTree.getTreeNode(scopeRef).nodeToRestore = nodeToRestoreRef.current;
561-
562559
// Handle the Tab key so that tabbing out of the scope goes to the next element
563560
// after the node that had focus when the scope mounted. This is important when
564561
// using portals for overlays, so that focus goes to the expected element when
@@ -621,6 +618,18 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
621618
if (!contain) {
622619
document.removeEventListener('keydown', onKeyDown, true);
623620
}
621+
};
622+
}, [scopeRef, restoreFocus, contain]);
623+
624+
// useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously.
625+
useLayoutEffect(() => {
626+
if (!restoreFocus) {
627+
return;
628+
}
629+
630+
focusScopeTree.getTreeNode(scopeRef).nodeToRestore = nodeToRestoreRef.current;
631+
632+
return () => {
624633
let nodeToRestore = focusScopeTree.getTreeNode(scopeRef).nodeToRestore;
625634

626635
// if we already lost focus to the body and this was the active scope, then we should attempt to restore
@@ -662,7 +671,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
662671
});
663672
}
664673
};
665-
}, [scopeRef, restoreFocus, contain]);
674+
}, [scopeRef, restoreFocus]);
666675
}
667676

668677
/**

packages/@react-aria/menu/src/useMenuTrigger.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ export function useMenuTrigger<T>(props: AriaMenuTriggerProps, state: MenuTrigge
8686
e.preventDefault();
8787
state.toggle('last');
8888
break;
89+
default:
90+
// Allow other keys.
91+
if ('continuePropagation' in e) {
92+
e.continuePropagation();
93+
}
8994
}
9095
}
9196
};

packages/@react-aria/overlays/src/Overlay.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ export interface OverlayProps {
2929
* This option should be used very carefully. When focus management is disabled, you must
3030
* implement focus containment and restoration to ensure the overlay is keyboard accessible.
3131
*/
32-
disableFocusManagement?: boolean
32+
disableFocusManagement?: boolean,
33+
/**
34+
* Whether the overlay is currently performing an exit animation. When true,
35+
* focus is allowed to move outside.
36+
*/
37+
isExiting?: boolean
3338
}
3439

3540
export const OverlayContext = React.createContext(null);
@@ -40,7 +45,7 @@ export const OverlayContext = React.createContext(null);
4045
*/
4146
export function Overlay(props: OverlayProps) {
4247
let isSSR = useIsSSR();
43-
let {portalContainer = isSSR ? null : document.body} = props;
48+
let {portalContainer = isSSR ? null : document.body, isExiting} = props;
4449
let [contain, setContain] = useState(false);
4550
let contextValue = useMemo(() => ({contain, setContain}), [contain, setContain]);
4651

@@ -52,7 +57,7 @@ export function Overlay(props: OverlayProps) {
5257
if (!props.disableFocusManagement) {
5358
contents = (
5459
<OverlayContext.Provider value={contextValue}>
55-
<FocusScope restoreFocus contain={contain}>
60+
<FocusScope restoreFocus contain={contain && !isExiting}>
5661
{props.children}
5762
</FocusScope>
5863
</OverlayContext.Provider>

packages/@react-aria/selection/src/useSelectableCollection.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
319319
let element = scrollRef.current.querySelector(`[data-key="${manager.focusedKey}"]`) as HTMLElement;
320320
if (element) {
321321
// This prevents a flash of focus on the first/last element in the collection, or the collection itself.
322-
focusWithoutScrolling(element);
322+
if (!element.contains(document.activeElement)) {
323+
focusWithoutScrolling(element);
324+
}
323325

324326
let modality = getInteractionModality();
325327
if (modality === 'keyboard') {

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

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

13-
import {ChangeEvent, Key, RefObject, useCallback, useRef} from 'react';
13+
import {ChangeEvent, Key, RefObject, useCallback, useEffect, useRef} from 'react';
14+
import {ColumnSize} from '@react-types/table';
1415
import {DOMAttributes, FocusableElement} from '@react-types/shared';
1516
import {focusSafely} from '@react-aria/focus';
16-
import {focusWithoutScrolling, mergeProps, useDescription, useId} from '@react-aria/utils';
1717
import {getColumnHeaderId} from './utils';
1818
import {GridNode} from '@react-types/grid';
1919
// @ts-ignore
2020
import intlMessages from '../intl/*.json';
21+
import {mergeProps, useDescription, useEffectEvent, useId} from '@react-aria/utils';
2122
import {TableColumnResizeState} from '@react-stately/table';
2223
import {useInteractionModality, useKeyboard, useMove, usePress} from '@react-aria/interactions';
2324
import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
@@ -46,84 +47,82 @@ export interface AriaTableColumnResizeProps<T> {
4647
/** If resizing is disabled. */
4748
isDisabled?: boolean,
4849
/** Called when resizing starts. */
49-
onResizeStart?: (widths: Map<Key, number | string>) => void,
50+
onResizeStart?: (widths: Map<Key, ColumnSize>) => void,
5051
/** Called for every resize event that results in new column sizes. */
51-
onResize?: (widths: Map<Key, number | string>) => void,
52+
onResize?: (widths: Map<Key, ColumnSize>) => void,
5253
/** Called when resizing ends. */
53-
onResizeEnd?: (widths: Map<Key, number | string>) => void
54+
onResizeEnd?: (widths: Map<Key, ColumnSize>) => void
5455
}
5556

56-
export interface AriaTableColumnResizeState<T> extends Omit<TableColumnResizeState<T>, 'widths'> {}
57-
5857
/**
5958
* Provides the behavior and accessibility implementation for a table column resizer element.
6059
* @param props - Props for the resizer.
6160
* @param state - State for the table's resizable columns, as returned by `useTableColumnResizeState`.
6261
* @param ref - The ref attached to the resizer's visually hidden input element.
6362
*/
64-
export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, state: AriaTableColumnResizeState<T>, ref: RefObject<HTMLInputElement>): TableColumnResizeAria {
63+
export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, state: TableColumnResizeState<T>, ref: RefObject<HTMLInputElement>): TableColumnResizeAria {
6564
let {column: item, triggerRef, isDisabled, onResizeStart, onResize, onResizeEnd, 'aria-label': ariaLabel} = props;
6665
const stringFormatter = useLocalizedStringFormatter(intlMessages);
6766
let id = useId();
6867
let isResizing = state.resizingColumn === item.key;
6968
let isResizingRef = useRef(isResizing);
7069
let lastSize = useRef(null);
70+
let wasFocusedOnResizeStart = useRef(false);
7171
let editModeEnabled = state.tableState.isKeyboardNavigationDisabled;
7272

7373
let {direction} = useLocale();
7474
let {keyboardProps} = useKeyboard({
7575
onKeyDown: (e) => {
76-
let resizeOnFocus = !!triggerRef?.current;
7776
if (editModeEnabled) {
7877
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') {
7978
e.preventDefault();
80-
if (resizeOnFocus) {
81-
// switch focus back to the column header on anything that ends edit mode
82-
focusSafely(triggerRef.current);
83-
} else {
84-
endResize(item);
85-
state.tableState.setKeyboardNavigationDisabled(false);
86-
}
79+
endResize(item);
8780
}
88-
} else if (!resizeOnFocus) {
81+
} else {
8982
// Continue propagation on keydown events so they still bubbles to useSelectableCollection and are handled there
9083
e.continuePropagation();
9184

9285
if (e.key === 'Enter') {
9386
startResize(item);
94-
state.tableState.setKeyboardNavigationDisabled(true);
9587
}
9688
}
9789
}
9890
});
9991

100-
let startResize = useCallback((item) => {
92+
let startResize = useEffectEvent((item) => {
10193
if (!isResizingRef.current) {
10294
lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key));
10395
state.startResize(item.key);
96+
state.tableState.setKeyboardNavigationDisabled(true);
10497
onResizeStart?.(lastSize.current);
10598
}
10699
isResizingRef.current = true;
107-
}, [isResizingRef, onResizeStart, state]);
100+
});
108101

109-
let resize = useCallback((item, newWidth) => {
102+
let resize = useEffectEvent((item, newWidth) => {
110103
let sizes = state.updateResizedColumns(item.key, newWidth);
111104
onResize?.(sizes);
112105
lastSize.current = sizes;
113-
}, [onResize, state]);
106+
});
114107

115-
let endResize = useCallback((item) => {
108+
let endResize = useEffectEvent((item) => {
116109
if (isResizingRef.current) {
117110
if (lastSize.current == null) {
118111
lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key));
119112
}
120113

121114
state.endResize();
115+
state.tableState.setKeyboardNavigationDisabled(false);
122116
onResizeEnd?.(lastSize.current);
117+
isResizingRef.current = false;
118+
119+
if (triggerRef?.current && !wasFocusedOnResizeStart.current) {
120+
// switch focus back to the column header unless the resizer was already focused when resizing started.
121+
focusSafely(triggerRef.current);
122+
}
123123
}
124-
isResizingRef.current = false;
125124
lastSize.current = null;
126-
}, [isResizingRef, onResizeEnd, state]);
125+
});
127126

128127
const columnResizeWidthRef = useRef<number>(0);
129128
const {moveProps} = useMove({
@@ -149,10 +148,9 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
149148
}
150149
},
151150
onMoveEnd(e) {
152-
let resizeOnFocus = !!triggerRef?.current;
153151
let {pointerType} = e;
154152
columnResizeWidthRef.current = 0;
155-
if (pointerType === 'mouse' || (pointerType === 'touch' && !resizeOnFocus)) {
153+
if (pointerType === 'mouse' || (pointerType === 'touch' && wasFocusedOnResizeStart.current)) {
156154
endResize(item);
157155
}
158156
}
@@ -191,10 +189,24 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
191189

192190
const focusInput = useCallback(() => {
193191
if (ref.current) {
194-
focusWithoutScrolling(ref.current);
192+
focusSafely(ref.current);
195193
}
196194
}, [ref]);
197195

196+
let resizingColumn = state.resizingColumn;
197+
let prevResizingColumn = useRef(null);
198+
useEffect(() => {
199+
if (prevResizingColumn.current !== resizingColumn && resizingColumn != null && resizingColumn === item.key) {
200+
wasFocusedOnResizeStart.current = document.activeElement === ref.current;
201+
startResize(item);
202+
focusInput();
203+
// VoiceOver on iOS has problems focusing the input from a menu.
204+
let timeout = setTimeout(focusInput, 400);
205+
return () => clearTimeout(timeout);
206+
}
207+
prevResizingColumn.current = resizingColumn;
208+
}, [resizingColumn, item, focusInput, ref, startResize]);
209+
198210
let onChange = (e: ChangeEvent<HTMLInputElement>) => {
199211
let currentWidth = state.getColumnWidth(item.key);
200212
let nextValue = parseFloat(e.target.value);
@@ -213,11 +225,7 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
213225
return;
214226
}
215227
if (e.pointerType === 'virtual' && state.resizingColumn != null) {
216-
let resizeOnFocus = !!triggerRef?.current;
217228
endResize(item);
218-
if (resizeOnFocus) {
219-
focusSafely(triggerRef.current);
220-
}
221229
return;
222230
}
223231

@@ -232,8 +240,7 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
232240
}
233241
},
234242
onPress: (e) => {
235-
let resizeOnFocus = !!triggerRef?.current;
236-
if (((e.pointerType === 'touch' && !resizeOnFocus) || e.pointerType === 'mouse') && state.resizingColumn != null) {
243+
if (((e.pointerType === 'touch' && wasFocusedOnResizeStart.current) || e.pointerType === 'mouse') && state.resizingColumn != null) {
237244
endResize(item);
238245
}
239246
}
@@ -244,24 +251,15 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
244251
resizerProps: mergeProps(
245252
keyboardProps,
246253
{...moveProps, onKeyDown},
247-
pressProps
254+
pressProps,
255+
{style: {touchAction: 'none'}}
248256
),
249257
inputProps: mergeProps(
250258
visuallyHiddenProps,
251259
{
252260
id,
253-
onFocus: () => {
254-
let resizeOnFocus = !!triggerRef?.current;
255-
if (resizeOnFocus) {
256-
// useMove calls onMoveStart for every keypress, but we want resize start to only be called when we start resize mode
257-
// call instead during focus and blur
258-
startResize(item);
259-
state.tableState.setKeyboardNavigationDisabled(true);
260-
}
261-
},
262261
onBlur: () => {
263262
endResize(item);
264-
state.tableState.setKeyboardNavigationDisabled(false);
265263
},
266264
onChange,
267265
disabled: isDisabled

packages/@react-spectrum/overlays/src/Overlay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function Overlay(props: OverlayProps, ref: DOMRef<HTMLDivElement>) {
5555
}
5656

5757
return (
58-
<ReactAriaOverlay portalContainer={container} disableFocusManagement={disableFocusManagement}>
58+
<ReactAriaOverlay portalContainer={container} disableFocusManagement={disableFocusManagement} isExiting={!isOpen}>
5959
<Provider ref={ref} UNSAFE_style={{background: 'transparent', isolation: 'isolate'}} isDisabled={false}>
6060
<OpenTransition
6161
in={isOpen}

packages/@react-spectrum/table/src/Resizer.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const CURSORS = {
4747

4848
function Resizer<T>(props: ResizerProps<T>, ref: RefObject<HTMLInputElement>) {
4949
let {column, showResizer} = props;
50-
let {isEmpty, layout} = useTableContext();
50+
let {isEmpty, layout, onFocusedResizer} = useTableContext();
5151
// Virtualizer re-renders, but these components are all cached
5252
// in order to get around that and cause a rerender here, we use context
5353
// but we don't actually need any value, they are available on the layout object
@@ -78,8 +78,7 @@ function Resizer<T>(props: ResizerProps<T>, ref: RefObject<HTMLInputElement>) {
7878
let {inputProps, resizerProps} = useTableColumnResize<unknown>(
7979
mergeProps(props, {
8080
'aria-label': stringFormatter.format('columnResizer'),
81-
isDisabled: isEmpty,
82-
shouldResizeOnFocus: true
81+
isDisabled: isEmpty
8382
}), layout, ref);
8483

8584
let isEResizable = layout.getColumnMinWidth(column.key) >= layout.getColumnWidth(column.key);
@@ -95,23 +94,23 @@ function Resizer<T>(props: ResizerProps<T>, ref: RefObject<HTMLInputElement>) {
9594
}
9695

9796
let style = {
97+
...resizerProps.style,
9898
height: '100%',
9999
display: showResizer ? undefined : 'none',
100-
touchAction: 'none',
101100
cursor
102101
};
103102

104103
return (
105104
<>
106105
<FocusRing within focusRingClass={classNames(styles, 'focus-ring')}>
107106
<div
107+
{...resizerProps}
108108
role="presentation"
109109
style={style}
110-
className={classNames(styles, 'spectrum-Table-columnResizer')}
111-
{...resizerProps}>
110+
className={classNames(styles, 'spectrum-Table-columnResizer')}>
112111
<input
113112
ref={ref}
114-
{...inputProps} />
113+
{...mergeProps(inputProps, {onFocus: onFocusedResizer})} />
115114
</div>
116115
</FocusRing>
117116
{/* Placeholder so that the title doesn't intersect with space reserved by the resizer when it appears. */}

0 commit comments

Comments
 (0)