Skip to content

Commit 3e5f99b

Browse files
authoredDec 13, 2022
Refactor column resizing for controlled state resizing (adobe#3672)
Controlled state column resizing. Use resizing state hook + TableLayout implementation. Fix aria values on resizer. Aria example.
1 parent 13941cf commit 3e5f99b

27 files changed

+2802
-1095
lines changed
 

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

+6-5
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ svg.spectrum-Table-sortedIcon {
4141
color var(--spectrum-global-animation-duration-100) ease-in-out;
4242
}
4343

44-
.spectrum-Table-menuChevron {
44+
.spectrum-Table-menuChevron.spectrum-Table-menuChevron {
4545
display: none;
4646
flex: 0 0 auto;
4747
margin-inline-start: var(--spectrum-table-header-sort-icon-gap);
@@ -51,10 +51,10 @@ svg.spectrum-Table-sortedIcon {
5151
}
5252

5353
.spectrum-Table-headWrapper {
54-
border-left-width: 1px;
55-
border-left-style: solid;
56-
border-right-width: 1px;
57-
border-right-style: solid;
54+
border-inline-start-width: 1px;
55+
border-inline-start-style: solid;
56+
border-inline-end-width: 1px;
57+
border-inline-end-style: solid;
5858
flex: 0 0 auto;
5959
padding-bottom: 1px;
6060
margin-bottom: -1px;
@@ -472,6 +472,7 @@ svg.spectrum-Table-sortedIcon {
472472
}
473473
.spectrum-Table-colResizeNubbin {
474474
display: none;
475+
pointer-events: none;
475476
position: absolute;
476477
/* svg top pixel is anti-aliased, this lets through the blue bar in the background, so we move the bar
477478
down one pixel and the nubbin circle up one pixel to cover it completely */

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ governing permissions and limitations under the License.
1515
}
1616

1717
.spectrum-Table-headWrapper {
18-
border-left-color: transparent;
19-
border-right-color: transparent;
18+
border-inline-start-color: transparent;
19+
border-inline-end-color: transparent;
2020
}
2121

2222
.spectrum-Table-headCell {

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

+6-14
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,11 @@ import {useGridCell} from '@react-aria/grid';
2323
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2424
import {usePress} from '@react-aria/interactions';
2525

26-
export interface AriaTableColumnHeaderProps {
26+
export interface AriaTableColumnHeaderProps<T> {
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. */
28-
node: GridNode<unknown>,
28+
node: GridNode<T>,
2929
/** Whether the [column header](https://www.w3.org/TR/wai-aria-1.1/#columnheader) is contained in a virtual scroller. */
30-
isVirtualized?: boolean,
31-
/** Whether the column has a menu in the header, this changes interactions with the header.
32-
* @private
33-
*/
34-
hasMenu?: boolean
30+
isVirtualized?: boolean
3531
}
3632

3733
export interface TableColumnHeaderAria {
@@ -45,11 +41,11 @@ export interface TableColumnHeaderAria {
4541
* @param state - State of the table, as returned by `useTableState`.
4642
* @param ref - The ref attached to the column header element.
4743
*/
48-
export function useTableColumnHeader<T>(props: AriaTableColumnHeaderProps, state: TableState<T>, ref: RefObject<FocusableElement>): TableColumnHeaderAria {
44+
export function useTableColumnHeader<T>(props: AriaTableColumnHeaderProps<T>, state: TableState<T>, ref: RefObject<FocusableElement>): TableColumnHeaderAria {
4945
let {node} = props;
5046
let allowsSorting = node.props.allowsSorting;
51-
// 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
52-
let {gridCellProps} = useGridCell({...props, focusMode: node.props.isSelectionCell || props.hasMenu || node.props.allowsSorting ? 'child' : 'cell'}, state, ref);
47+
// if there are no focusable children, the column header will focus the cell
48+
let {gridCellProps} = useGridCell({...props, focusMode: 'child'}, state, ref);
5349

5450
let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single';
5551

@@ -64,10 +60,6 @@ export function useTableColumnHeader<T>(props: AriaTableColumnHeaderProps, state
6460
// Needed to pick up the focusable context, enabling things like Tooltips for example
6561
let {focusableProps} = useFocusable({}, ref);
6662

67-
if (props.hasMenu) {
68-
pressProps = {};
69-
}
70-
7163
let ariaSort: DOMAttributes['aria-sort'] = null;
7264
let isSortedColumn = state.sortDescriptor?.column === node.key;
7365
let sortDirection = state.sortDescriptor?.direction;

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

+100-28
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {ChangeEvent, RefObject, useCallback, useRef} from 'react';
13+
import {ChangeEvent, Key, RefObject, useCallback, useRef} from 'react';
14+
import {ColumnSize} from '@react-types/table';
1415
import {DOMAttributes, MoveEndEvent, MoveMoveEvent} from '@react-types/shared';
1516
import {focusSafely} from '@react-aria/focus';
1617
import {focusWithoutScrolling, mergeProps, useId} from '@react-aria/utils';
1718
import {getColumnHeaderId} from './utils';
1819
import {GridNode} from '@react-types/grid';
1920
// @ts-ignore
2021
import intlMessages from '../intl/*.json';
21-
import {TableColumnResizeState, TableState} from '@react-stately/table';
22+
import {TableState} from '@react-stately/table';
2223
import {useKeyboard, useMove, usePress} from '@react-aria/interactions';
2324
import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
2425

@@ -28,37 +29,102 @@ export interface TableColumnResizeAria {
2829
}
2930

3031
export interface AriaTableColumnResizeProps<T> {
32+
/** 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. */
3133
column: GridNode<T>,
34+
/** Aria label for the hidden input. Gets read when resizing. */
3235
label: string,
33-
triggerRef: RefObject<HTMLDivElement>,
36+
/**
37+
* Ref to the trigger if resizing was started from a column header menu. If it's provided,
38+
* focus will be returned there when resizing is done.
39+
* */
40+
triggerRef?: RefObject<HTMLDivElement>,
41+
/** If resizing is disabled. */
3442
isDisabled?: boolean,
35-
onMove: (e: MoveMoveEvent) => void,
36-
onMoveEnd: (e: MoveEndEvent) => void
43+
/** If the resizer was moved. Different from onResize because it is always called. */
44+
onMove?: (e: MoveMoveEvent) => void,
45+
/**
46+
* If the resizer was moved. Different from onResizeEnd because it is always called.
47+
* It also carries the interaction details in the object.
48+
* */
49+
onMoveEnd?: (e: MoveEndEvent) => void,
50+
/** Called when resizing starts. */
51+
onResizeStart: (key: Key) => void,
52+
/** Called for every resize event that results in new column sizes. */
53+
onResize: (widths: Map<Key, number | string>) => void,
54+
/** Called when resizing ends. */
55+
onResizeEnd: (key: Key) => void
3756
}
3857

39-
export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, state: TableState<T>, columnState: TableColumnResizeState<T>, ref: RefObject<HTMLInputElement>): TableColumnResizeAria {
40-
let {column: item, triggerRef, isDisabled} = props;
41-
const stateRef = useRef<TableColumnResizeState<T>>(null);
42-
stateRef.current = columnState;
58+
59+
export interface TableLayoutState {
60+
/** Get the current width of the specified column. */
61+
getColumnWidth: (key: Key) => number,
62+
/** Get the current min width of the specified column. */
63+
getColumnMinWidth: (key: Key) => number,
64+
/** Get the current max width of the specified column. */
65+
getColumnMaxWidth: (key: Key) => number,
66+
/** Get the currently resizing column. */
67+
resizingColumn: Key,
68+
/** Called to update the state that resizing has started. */
69+
onColumnResizeStart: (key: Key) => void,
70+
/**
71+
* Called to update the state that a resize event has occurred.
72+
* Returns the new widths for all columns based on the resized column.
73+
**/
74+
onColumnResize: (column: Key, width: number) => Map<Key, ColumnSize>,
75+
/** Called to update the state that resizing has ended. */
76+
onColumnResizeEnd: (key: Key) => void
77+
}
78+
79+
export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, state: TableState<T>, layoutState: TableLayoutState, ref: RefObject<HTMLInputElement>): TableColumnResizeAria {
80+
let {column: item, triggerRef, isDisabled, onResizeStart, onResize, onResizeEnd} = props;
4381
const stringFormatter = useLocalizedStringFormatter(intlMessages);
4482
let id = useId();
83+
let isResizing = useRef(false);
84+
let lastSize = useRef(null);
4585

4686
let {direction} = useLocale();
4787
let {keyboardProps} = useKeyboard({
4888
onKeyDown: (e) => {
49-
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') {
89+
if (triggerRef?.current && (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab')) {
5090
e.preventDefault();
5191
// switch focus back to the column header on anything that ends edit mode
5292
focusSafely(triggerRef.current);
5393
}
5494
}
5595
});
5696

97+
let startResize = useCallback((item) => {
98+
if (!isResizing.current) {
99+
layoutState.onColumnResizeStart(item.key);
100+
onResizeStart?.(item.key);
101+
}
102+
isResizing.current = true;
103+
}, [isResizing, onResizeStart, layoutState]);
104+
105+
let resize = useCallback((item, newWidth) => {
106+
let sizes = layoutState.onColumnResize(item.key, newWidth);
107+
onResize?.(sizes);
108+
lastSize.current = sizes;
109+
}, [onResize, layoutState]);
110+
111+
let endResize = useCallback((item) => {
112+
if (lastSize.current == null) {
113+
lastSize.current = layoutState.onColumnResize(item.key, layoutState.getColumnWidth(item.key));
114+
}
115+
if (isResizing.current) {
116+
layoutState.onColumnResizeEnd(item.key);
117+
onResizeEnd?.(lastSize.current);
118+
}
119+
isResizing.current = false;
120+
lastSize.current = null;
121+
}, [isResizing, onResizeEnd, layoutState]);
122+
57123
const columnResizeWidthRef = useRef<number>(0);
58124
const {moveProps} = useMove({
59125
onMoveStart() {
60-
columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key);
61-
stateRef.current.onColumnResizeStart(item);
126+
columnResizeWidthRef.current = layoutState.getColumnWidth(item.key);
127+
startResize(item);
62128
},
63129
onMove(e) {
64130
let {deltaX, deltaY, pointerType} = e;
@@ -71,29 +137,29 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
71137
}
72138
deltaX *= 10;
73139
}
140+
props.onMove?.(e);
74141
// if moving up/down only, no need to resize
75142
if (deltaX !== 0) {
76143
columnResizeWidthRef.current += deltaX;
77-
stateRef.current.onColumnResize(item, columnResizeWidthRef.current);
78-
props.onMove(e);
144+
resize(item, columnResizeWidthRef.current);
79145
}
80146
},
81147
onMoveEnd(e) {
82148
let {pointerType} = e;
83149
columnResizeWidthRef.current = 0;
84-
props.onMoveEnd(e);
150+
props.onMoveEnd?.(e);
85151
if (pointerType === 'mouse') {
86-
stateRef.current.onColumnResizeEnd(item);
152+
endResize(item);
87153
}
88154
}
89155
});
90-
let min = Math.floor(stateRef.current.getColumnMinWidth(item.key));
91-
let max = Math.floor(stateRef.current.getColumnMaxWidth(item.key));
156+
157+
let min = Math.floor(layoutState.getColumnMinWidth(item.key));
158+
let max = Math.floor(layoutState.getColumnMaxWidth(item.key));
92159
if (max === Infinity) {
93160
max = Number.MAX_SAFE_INTEGER;
94161
}
95-
let value = Math.floor(stateRef.current.getColumnWidth(item.key));
96-
162+
let value = Math.floor(layoutState.getColumnWidth(item.key));
97163
let ariaProps = {
98164
'aria-label': props.label,
99165
'aria-orientation': 'horizontal' as 'horizontal',
@@ -111,25 +177,29 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
111177
}, [ref]);
112178

113179
let onChange = (e: ChangeEvent<HTMLInputElement>) => {
114-
let currentWidth = stateRef.current.getColumnWidth(item.key);
180+
let currentWidth = layoutState.getColumnWidth(item.key);
115181
let nextValue = parseFloat(e.target.value);
116182

117183
if (nextValue > currentWidth) {
118184
nextValue = currentWidth + 10;
119185
} else {
120186
nextValue = currentWidth - 10;
121187
}
122-
stateRef.current.onColumnResize(item, nextValue);
188+
props.onMove({pointerType: 'virtual'} as MoveMoveEvent);
189+
props.onMoveEnd({pointerType: 'virtual'} as MoveEndEvent);
190+
resize(item, nextValue);
123191
};
124192

125193
let {pressProps} = usePress({
126194
onPressStart: (e) => {
127195
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') {
128196
return;
129197
}
130-
if (e.pointerType === 'virtual' && columnState.currentlyResizingColumn != null) {
131-
stateRef.current.onColumnResizeEnd(item);
132-
focusSafely(triggerRef.current);
198+
if (e.pointerType === 'virtual' && layoutState.resizingColumn != null) {
199+
endResize(item);
200+
if (triggerRef?.current) {
201+
focusSafely(triggerRef.current);
202+
}
133203
return;
134204
}
135205
focusInput();
@@ -138,7 +208,9 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
138208
if (e.pointerType === 'touch') {
139209
focusInput();
140210
} else if (e.pointerType !== 'virtual') {
141-
focusSafely(triggerRef.current);
211+
if (triggerRef?.current) {
212+
focusSafely(triggerRef.current);
213+
}
142214
}
143215
}
144216
});
@@ -155,11 +227,11 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
155227
onFocus: () => {
156228
// useMove calls onMoveStart for every keypress, but we want resize start to only be called when we start resize mode
157229
// call instead during focus and blur
158-
stateRef.current.onColumnResizeStart(item);
230+
startResize(item);
159231
state.setKeyboardNavigationDisabled(true);
160232
},
161233
onBlur: () => {
162-
stateRef.current.onColumnResizeEnd(item);
234+
endResize(item);
163235
state.setKeyboardNavigationDisabled(false);
164236
},
165237
onChange,

0 commit comments

Comments
 (0)
Failed to load comments.