Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
6adffe8
Refactor column resizing to make a single source of truth
snowystinger Oct 20, 2022
dd8512f
Merge branch 'main' into refactor-column-resizing
snowystinger Oct 20, 2022
f370934
fix merge
snowystinger Oct 20, 2022
7fd45f9
fix remove column math
snowystinger Oct 20, 2022
fdb68a3
remove unused dep
snowystinger Oct 20, 2022
9fe9238
Merge branch 'main' into refactor-column-resizing
snowystinger Nov 22, 2022
9c39b1a
Merge branch 'main' into refactor-column-resizing
snowystinger Nov 28, 2022
3fb4677
Move aria implementation to stately hook instead of layout
snowystinger Nov 28, 2022
69f481d
fix keyboard resizing and default widths
snowystinger Nov 28, 2022
a99ca8e
use aria tests for spectrum as well
snowystinger Nov 29, 2022
83c2bd1
rename some props, refactor resizer so it sets on top of everything
snowystinger Nov 29, 2022
36804cb
restore logic to fix tests
snowystinger Nov 30, 2022
0bed81d
fix min/max calculations
snowystinger Nov 30, 2022
f39e26b
fix all types and lint
snowystinger Nov 30, 2022
01ada31
Merge branch 'main' into refactor-column-resizing
snowystinger Nov 30, 2022
a8ab122
fix import
snowystinger Nov 30, 2022
bae3c9e
Merge branch 'refactor-column-resizing' of github.com:adobe/react-spe…
snowystinger Nov 30, 2022
af599ac
onResizeEnd called once with sizes
snowystinger Dec 5, 2022
d0c5f73
fix aria story missing resize handles
snowystinger Dec 5, 2022
16873d9
Add story descriptions
snowystinger Dec 5, 2022
a50b0ec
Fix resizer gets stuck visible
snowystinger Dec 5, 2022
9586a1d
Merge branch 'main' into refactor-column-resizing
snowystinger Dec 5, 2022
2137c2f
simplify column header focus mode
snowystinger Dec 5, 2022
ceca13e
clean up and code sharing
snowystinger Dec 5, 2022
e9c21b7
fix lint
snowystinger Dec 5, 2022
1f57a19
add test from other PR
snowystinger Dec 5, 2022
acbc4c7
add max width as well
snowystinger Dec 5, 2022
35bc3a0
Makes types clearer
snowystinger Dec 5, 2022
eb36ff6
clarify min/max width types
snowystinger Dec 5, 2022
a04a7a2
fix exports
snowystinger Dec 5, 2022
d0acbf7
fix css specificity
snowystinger Dec 5, 2022
0be2489
address resizers getting stuck in hovered state
snowystinger Dec 5, 2022
c68d962
Make sure input "value" updates
snowystinger Dec 5, 2022
ca8f423
fix lint
snowystinger Dec 5, 2022
cf36abc
remove unused api
snowystinger Dec 6, 2022
6ff2ac2
Code reviews
snowystinger Dec 7, 2022
f5a26b4
code reviews
snowystinger Dec 7, 2022
cd7281a
Merge branch 'main' into refactor-column-resizing
snowystinger Dec 7, 2022
918ddd9
more reviews
snowystinger Dec 7, 2022
ad4dadb
Merge branch 'main' into refactor-column-resizing
snowystinger Dec 8, 2022
e7d8783
add missed type
snowystinger Dec 12, 2022
62dfe3d
fix types and remove read/write render ref
snowystinger Dec 12, 2022
c1b2a75
re-type getDefaultWidth and add strict nulls
snowystinger Dec 13, 2022
67560cb
remove some memos, use existing value for resizer
snowystinger Dec 13, 2022
6f9b0d4
Merge branch 'main' into refactor-column-resizing
snowystinger Dec 13, 2022
2cc7c68
rename confusing variable
snowystinger Dec 13, 2022
afb1591
move column layout to table stately
snowystinger Dec 13, 2022
a7372f8
add resizing hide header story
snowystinger Dec 13, 2022
dff4b4d
move types
snowystinger Dec 13, 2022
9b94c17
Fix onResizeEnd call
snowystinger Dec 13, 2022
f330245
Merge branch 'main' into refactor-column-resizing
snowystinger Dec 13, 2022
e905d3d
move types
snowystinger Dec 13, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions packages/@adobe/spectrum-css-temp/components/table/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ svg.spectrum-Table-sortedIcon {
color var(--spectrum-global-animation-duration-100) ease-in-out;
}

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

.spectrum-Table-headWrapper {
border-left-width: 1px;
border-left-style: solid;
border-right-width: 1px;
border-right-style: solid;
border-inline-start-width: 1px;
border-inline-start-style: solid;
border-inline-end-width: 1px;
border-inline-end-style: solid;
flex: 0 0 auto;
padding-bottom: 1px;
margin-bottom: -1px;
Expand Down Expand Up @@ -472,6 +472,7 @@ svg.spectrum-Table-sortedIcon {
}
.spectrum-Table-colResizeNubbin {
display: none;
pointer-events: none;
position: absolute;
/* svg top pixel is anti-aliased, this lets through the blue bar in the background, so we move the bar
down one pixel and the nubbin circle up one pixel to cover it completely */
Expand Down
4 changes: 2 additions & 2 deletions packages/@adobe/spectrum-css-temp/components/table/skin.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ governing permissions and limitations under the License.
}

.spectrum-Table-headWrapper {
border-left-color: transparent;
border-right-color: transparent;
border-inline-start-color: transparent;
border-inline-end-color: transparent;
}

.spectrum-Table-headCell {
Expand Down
20 changes: 6 additions & 14 deletions packages/@react-aria/table/src/useTableColumnHeader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,11 @@ import {useGridCell} from '@react-aria/grid';
import {useLocalizedStringFormatter} from '@react-aria/i18n';
import {usePress} from '@react-aria/interactions';

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

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

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

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

if (props.hasMenu) {
pressProps = {};
}

let ariaSort: DOMAttributes['aria-sort'] = null;
let isSortedColumn = state.sortDescriptor?.column === node.key;
let sortDirection = state.sortDescriptor?.direction;
Expand Down
128 changes: 100 additions & 28 deletions packages/@react-aria/table/src/useTableColumnResize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@
* governing permissions and limitations under the License.
*/

import {ChangeEvent, RefObject, useCallback, useRef} from 'react';
import {ChangeEvent, Key, RefObject, useCallback, useRef} from 'react';
import {ColumnSize} from '@react-types/table';
import {DOMAttributes, MoveEndEvent, MoveMoveEvent} from '@react-types/shared';
import {focusSafely} from '@react-aria/focus';
import {focusWithoutScrolling, mergeProps, useId} from '@react-aria/utils';
import {getColumnHeaderId} from './utils';
import {GridNode} from '@react-types/grid';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {TableColumnResizeState, TableState} from '@react-stately/table';
import {TableState} from '@react-stately/table';
import {useKeyboard, useMove, usePress} from '@react-aria/interactions';
import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';

Expand All @@ -28,37 +29,102 @@ export interface TableColumnResizeAria {
}

export interface AriaTableColumnResizeProps<T> {
/** 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. */
column: GridNode<T>,
/** Aria label for the hidden input. Gets read when resizing. */
label: string,
triggerRef: RefObject<HTMLDivElement>,
/**
* Ref to the trigger if resizing was started from a column header menu. If it's provided,
* focus will be returned there when resizing is done.
* */
triggerRef?: RefObject<HTMLDivElement>,
/** If resizing is disabled. */
isDisabled?: boolean,
onMove: (e: MoveMoveEvent) => void,
onMoveEnd: (e: MoveEndEvent) => void
/** If the resizer was moved. Different from onResize because it is always called. */
onMove?: (e: MoveMoveEvent) => void,
/**
* If the resizer was moved. Different from onResizeEnd because it is always called.
* It also carries the interaction details in the object.
* */
onMoveEnd?: (e: MoveEndEvent) => void,
/** Called when resizing starts. */
onResizeStart: (key: Key) => void,
/** Called for every resize event that results in new column sizes. */
onResize: (widths: Map<Key, number | string>) => void,
/** Called when resizing ends. */
onResizeEnd: (key: Key) => void
Comment on lines +50 to +55
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume you weren't able to use onMove and onMoveEnd only, hence the addition of the following handlers? Just trying to figure out if the following wouldn't work with onMove/onMoveEnd

  let onResizeStart = useCallback(() => {
    setIsResizing(true);
  }, [setIsResizing]);
  let onResizeEnd = useCallback((widths) => {
    setIsInResizeMode(false);
    setIsResizing(false);
    propsOnResizeEnd?.(widths);
  }, [propsOnResizeEnd, setIsInResizeMode, setIsResizing]);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you look at the description for onMove/onMoveEnd it describes their differences. Specifically that they are called at different times and carry the interaction method, allowing the rendering to change in response and do things like change the mouse cursor when using with a mouse, but not during keyboard resizing.

so they are different than onResize. maybe they have a bad name?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I was more wondering if onMoveStart (which doesn't exist here), onMove, and onMoveEnd could handle the logic from onResizeStart, onResize, and onResizeEnd as well. Now that I type this up, I now realize that the keyboard experience would always fire onMove -> onMoveEnd per keypress instead of how onResizeEnd only happens when finalizing a resize operation, so I think we are fine here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to combine them, but I haven't figured out a good way to. Nor have I really figured out a better name for the difference.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: add to followup

}

export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, state: TableState<T>, columnState: TableColumnResizeState<T>, ref: RefObject<HTMLInputElement>): TableColumnResizeAria {
let {column: item, triggerRef, isDisabled} = props;
const stateRef = useRef<TableColumnResizeState<T>>(null);
stateRef.current = columnState;

export interface TableLayoutState {
/** Get the current width of the specified column. */
getColumnWidth: (key: Key) => number,
/** Get the current min width of the specified column. */
getColumnMinWidth: (key: Key) => number,
/** Get the current max width of the specified column. */
getColumnMaxWidth: (key: Key) => number,
/** Get the currently resizing column. */
resizingColumn: Key,
/** Called to update the state that resizing has started. */
onColumnResizeStart: (key: Key) => void,
/**
* Called to update the state that a resize event has occurred.
* Returns the new widths for all columns based on the resized column.
**/
onColumnResize: (column: Key, width: number) => Map<Key, ColumnSize>,
/** Called to update the state that resizing has ended. */
onColumnResizeEnd: (key: Key) => void
}

export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, state: TableState<T>, layoutState: TableLayoutState, ref: RefObject<HTMLInputElement>): TableColumnResizeAria {
let {column: item, triggerRef, isDisabled, onResizeStart, onResize, onResizeEnd} = props;
const stringFormatter = useLocalizedStringFormatter(intlMessages);
let id = useId();
let isResizing = useRef(false);
let lastSize = useRef(null);

let {direction} = useLocale();
let {keyboardProps} = useKeyboard({
onKeyDown: (e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') {
if (triggerRef?.current && (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab')) {
e.preventDefault();
// switch focus back to the column header on anything that ends edit mode
focusSafely(triggerRef.current);
}
}
});

let startResize = useCallback((item) => {
if (!isResizing.current) {
layoutState.onColumnResizeStart(item.key);
onResizeStart?.(item.key);
}
isResizing.current = true;
}, [isResizing, onResizeStart, layoutState]);

let resize = useCallback((item, newWidth) => {
let sizes = layoutState.onColumnResize(item.key, newWidth);
onResize?.(sizes);
lastSize.current = sizes;
}, [onResize, layoutState]);

let endResize = useCallback((item) => {
if (lastSize.current == null) {
lastSize.current = layoutState.onColumnResize(item.key, layoutState.getColumnWidth(item.key));
}
if (isResizing.current) {
layoutState.onColumnResizeEnd(item.key);
onResizeEnd?.(lastSize.current);
Comment thread
snowystinger marked this conversation as resolved.
}
isResizing.current = false;
lastSize.current = null;
}, [isResizing, onResizeEnd, layoutState]);

const columnResizeWidthRef = useRef<number>(0);
const {moveProps} = useMove({
onMoveStart() {
columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key);
stateRef.current.onColumnResizeStart(item);
columnResizeWidthRef.current = layoutState.getColumnWidth(item.key);
startResize(item);
},
onMove(e) {
let {deltaX, deltaY, pointerType} = e;
Expand All @@ -71,29 +137,29 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
}
deltaX *= 10;
}
props.onMove?.(e);
// if moving up/down only, no need to resize
if (deltaX !== 0) {
columnResizeWidthRef.current += deltaX;
stateRef.current.onColumnResize(item, columnResizeWidthRef.current);
props.onMove(e);
resize(item, columnResizeWidthRef.current);
}
},
onMoveEnd(e) {
let {pointerType} = e;
columnResizeWidthRef.current = 0;
props.onMoveEnd(e);
props.onMoveEnd?.(e);
if (pointerType === 'mouse') {
stateRef.current.onColumnResizeEnd(item);
endResize(item);
}
}
});
let min = Math.floor(stateRef.current.getColumnMinWidth(item.key));
let max = Math.floor(stateRef.current.getColumnMaxWidth(item.key));

let min = Math.floor(layoutState.getColumnMinWidth(item.key));
let max = Math.floor(layoutState.getColumnMaxWidth(item.key));
if (max === Infinity) {
max = Number.MAX_SAFE_INTEGER;
}
let value = Math.floor(stateRef.current.getColumnWidth(item.key));

let value = Math.floor(layoutState.getColumnWidth(item.key));
let ariaProps = {
'aria-label': props.label,
'aria-orientation': 'horizontal' as 'horizontal',
Expand All @@ -111,25 +177,29 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
}, [ref]);

let onChange = (e: ChangeEvent<HTMLInputElement>) => {
let currentWidth = stateRef.current.getColumnWidth(item.key);
let currentWidth = layoutState.getColumnWidth(item.key);
let nextValue = parseFloat(e.target.value);

if (nextValue > currentWidth) {
nextValue = currentWidth + 10;
} else {
nextValue = currentWidth - 10;
}
stateRef.current.onColumnResize(item, nextValue);
props.onMove({pointerType: 'virtual'} as MoveMoveEvent);
props.onMoveEnd({pointerType: 'virtual'} as MoveEndEvent);
resize(item, nextValue);
};

let {pressProps} = usePress({
onPressStart: (e) => {
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') {
return;
}
if (e.pointerType === 'virtual' && columnState.currentlyResizingColumn != null) {
stateRef.current.onColumnResizeEnd(item);
focusSafely(triggerRef.current);
if (e.pointerType === 'virtual' && layoutState.resizingColumn != null) {
endResize(item);
if (triggerRef?.current) {
focusSafely(triggerRef.current);
}
return;
}
focusInput();
Expand All @@ -138,7 +208,9 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
if (e.pointerType === 'touch') {
focusInput();
} else if (e.pointerType !== 'virtual') {
focusSafely(triggerRef.current);
if (triggerRef?.current) {
focusSafely(triggerRef.current);
}
}
}
});
Expand All @@ -155,11 +227,11 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st
onFocus: () => {
// useMove calls onMoveStart for every keypress, but we want resize start to only be called when we start resize mode
// call instead during focus and blur
stateRef.current.onColumnResizeStart(item);
startResize(item);
state.setKeyboardNavigationDisabled(true);
},
onBlur: () => {
stateRef.current.onColumnResizeEnd(item);
endResize(item);
state.setKeyboardNavigationDisabled(false);
},
onChange,
Expand Down
Loading