From 6adffe86c7908c1770b4224ae163a96fc7014d2d Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 20 Oct 2022 08:45:30 -0700 Subject: [PATCH 01/42] Refactor column resizing to make a single source of truth --- .eslintignore | 2 +- packages/@react-aria/table/src/index.ts | 2 +- .../table/src/useTableColumnResize.ts | 45 ++-- .../@react-spectrum/table/src/Resizer.tsx | 32 +-- .../@react-spectrum/table/src/TableView.tsx | 158 +++++++------- .../@react-spectrum/table/test/Table.test.js | 112 +++++----- .../table/test/TableSizing.test.js | 141 +++--------- packages/@react-stately/layout/package.json | 1 + .../@react-stately/layout/src/TableLayout.ts | 101 ++++++++- .../@react-stately/layout/src/TableUtils.ts | 157 ++++++++++++++ .../table/src/useTableColumnResizeState.ts | 200 +---------------- packages/@react-stately/table/src/utils.ts | 111 ---------- .../test/useTableColumnResizeState.test.ts | 202 ------------------ packages/@react-types/table/src/index.d.ts | 4 +- .../storybook-builder-parcel/.eslintignore | 0 rfcs/2022-v3-resizable-columns.md | 10 +- 16 files changed, 502 insertions(+), 776 deletions(-) create mode 100644 packages/@react-stately/layout/src/TableUtils.ts delete mode 100644 packages/@react-stately/table/src/utils.ts delete mode 100644 packages/@react-stately/table/test/useTableColumnResizeState.test.ts create mode 100644 packages/dev/storybook-builder-parcel/.eslintignore diff --git a/.eslintignore b/.eslintignore index b190fdd8cf3..167182479c0 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,4 +6,4 @@ node_modules packages/*/*/dist packages/react-aria/dist packages/react-stately/dist -packages/dev/storybook-builder-preview/preview.js +packages/dev/storybook-builder-parcel/preview.js diff --git a/packages/@react-aria/table/src/index.ts b/packages/@react-aria/table/src/index.ts index 2898c3b9a44..ee36ea7c905 100644 --- a/packages/@react-aria/table/src/index.ts +++ b/packages/@react-aria/table/src/index.ts @@ -31,4 +31,4 @@ export type {AriaTableColumnHeaderProps, TableColumnHeaderAria} from './useTable export type {AriaTableCellProps, TableCellAria} from './useTableCell'; export type {TableHeaderRowAria} from './useTableHeaderRow'; export type {AriaTableSelectionCheckboxProps, TableSelectionCheckboxAria, TableSelectAllCheckboxAria} from './useTableSelectionCheckbox'; -export type {AriaTableColumnResizeProps, TableColumnResizeAria} from './useTableColumnResize'; +export type {AriaTableColumnResizeProps, TableColumnResizeAria, TableLayoutState} from './useTableColumnResize'; diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index bba0c8bf28e..7243f0d10c6 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {ChangeEvent, RefObject, useCallback, useRef} from 'react'; +import {ChangeEvent, Key, RefObject, useCallback, useRef} from 'react'; import {DOMAttributes, MoveEndEvent, MoveMoveEvent} from '@react-types/shared'; import {focusSafely} from '@react-aria/focus'; import {focusWithoutScrolling, mergeProps, useId} from '@react-aria/utils'; @@ -18,7 +18,7 @@ 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'; @@ -32,14 +32,26 @@ export interface AriaTableColumnResizeProps { label: string, triggerRef: RefObject, isDisabled?: boolean, - onMove: (e: MoveMoveEvent) => void, + onMove: (e: MoveMoveEvent, width: number) => void, onMoveEnd: (e: MoveEndEvent) => void } -export function useTableColumnResize(props: AriaTableColumnResizeProps, state: TableState, columnState: TableColumnResizeState, ref: RefObject): TableColumnResizeAria { +export interface TableLayoutState { + layout: { + getColumnWidth: (key: Key) => number, + getColumnMinWidth: (key: Key) => number, + getColumnMaxWidth: (key: Key) => number, + resizingColumn: Key | null + }, + onColumnResizeStart: (column: GridNode) => void, + onColumnResize: (column: GridNode, width: number) => void, + onColumnResizeEnd: (column: GridNode) => void +} + +export function useTableColumnResize(props: AriaTableColumnResizeProps, state: TableState, layoutState: TableLayoutState, ref: RefObject): TableColumnResizeAria { let {column: item, triggerRef, isDisabled} = props; - const stateRef = useRef>(null); - stateRef.current = columnState; + const stateRef = useRef>(null); + stateRef.current = layoutState; const stringFormatter = useLocalizedStringFormatter(intlMessages); let id = useId(); @@ -57,7 +69,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st const columnResizeWidthRef = useRef(0); const {moveProps} = useMove({ onMoveStart() { - columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key); + columnResizeWidthRef.current = stateRef.current.layout.getColumnWidth(item.key); + layoutState.layout.resizingColumn = item.key; stateRef.current.onColumnResizeStart(item); }, onMove(e) { @@ -75,7 +88,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st if (deltaX !== 0) { columnResizeWidthRef.current += deltaX; stateRef.current.onColumnResize(item, columnResizeWidthRef.current); - props.onMove(e); + props.onMove(e, columnResizeWidthRef.current); } }, onMoveEnd(e) { @@ -83,16 +96,17 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st columnResizeWidthRef.current = 0; props.onMoveEnd(e); if (pointerType === 'mouse') { + layoutState.layout.resizingColumn = null; stateRef.current.onColumnResizeEnd(item); } } }); - let min = Math.floor(stateRef.current.getColumnMinWidth(item.key)); - let max = Math.floor(stateRef.current.getColumnMaxWidth(item.key)); + let min = Math.floor(stateRef.current.layout.getColumnMinWidth(item.key)); + let max = Math.floor(stateRef.current.layout.getColumnMaxWidth(item.key)); if (max === Infinity) { max = Number.MAX_SAFE_INTEGER; } - let value = Math.floor(stateRef.current.getColumnWidth(item.key)); + let value = Math.floor(stateRef.current.layout.getColumnWidth(item.key)); let ariaProps = { 'aria-label': props.label, @@ -111,7 +125,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st }, [ref]); let onChange = (e: ChangeEvent) => { - let currentWidth = stateRef.current.getColumnWidth(item.key); + let currentWidth = stateRef.current.layout.getColumnWidth(item.key); let nextValue = parseFloat(e.target.value); if (nextValue > currentWidth) { @@ -120,6 +134,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st nextValue = currentWidth - 10; } stateRef.current.onColumnResize(item, nextValue); + props.onMove({pointerType: 'virtual'} as MoveMoveEvent, nextValue); + props.onMoveEnd({pointerType: 'virtual'} as MoveEndEvent); }; let {pressProps} = usePress({ @@ -127,7 +143,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') { return; } - if (e.pointerType === 'virtual' && columnState.currentlyResizingColumn != null) { + if (e.pointerType === 'virtual' && layoutState.layout.resizingColumn != null) { + layoutState.layout.resizingColumn = item.key; stateRef.current.onColumnResizeEnd(item); focusSafely(triggerRef.current); return; @@ -155,10 +172,12 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, 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 + layoutState.layout.resizingColumn = item.key; stateRef.current.onColumnResizeStart(item); state.setKeyboardNavigationDisabled(true); }, onBlur: () => { + layoutState.layout.resizingColumn = null; stateRef.current.onColumnResizeEnd(item); state.setKeyboardNavigationDisabled(false); }, diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index f3c08e6794c..4c80dd8a2ff 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -7,13 +7,14 @@ import intlMessages from '../intl/*.json'; import {MoveMoveEvent} from '@react-types/shared'; import React, {RefObject, useRef} from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; -import {TableColumnResizeState} from '@react-stately/table'; +import {TableLayout} from '@react-stately/layout'; +import {TableLayoutState, useTableColumnResize} from '@react-aria/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; -import {useTableColumnResize} from '@react-aria/table'; -import {useTableContext} from './TableView'; +import {useTableContext, useTableVirtualizerContext} from './TableView'; import {VisuallyHidden} from '@react-aria/visually-hidden'; interface ResizerProps { + layout: TableLayout, column: GridNode, showResizer: boolean, triggerRef: RefObject, @@ -21,36 +22,43 @@ interface ResizerProps { } function Resizer(props: ResizerProps, ref: RefObject) { - let {column, showResizer} = props; + let {column, showResizer, layout} = props; let {state, columnState, isEmpty} = useTableContext(); + let {state: virtualizerState} = useTableVirtualizerContext(); let stringFormatter = useLocalizedStringFormatter(intlMessages); let {direction} = useLocale(); - const stateRef = useRef>(null); - stateRef.current = columnState; + const stateRef = useRef>(null); + stateRef.current = { + ...columnState, + layout + }; let {inputProps, resizerProps} = useTableColumnResize({ ...props, label: stringFormatter.format('columnResizer'), isDisabled: isEmpty, - onMove: (e) => { + onMove: (e, width) => { document.body.classList.remove(classNames(styles, 'resize-ew')); document.body.classList.remove(classNames(styles, 'resize-e')); document.body.classList.remove(classNames(styles, 'resize-w')); - if (stateRef.current.getColumnMinWidth(column.key) >= stateRef.current.getColumnWidth(column.key)) { + if (stateRef.current.layout.getColumnMinWidth(column.key) >= stateRef.current.layout.getColumnWidth(column.key)) { document.body.classList.add(direction === 'rtl' ? classNames(styles, 'resize-w') : classNames(styles, 'resize-e')); - } else if (stateRef.current.getColumnMaxWidth(column.key) <= stateRef.current.getColumnWidth(column.key)) { + } else if (stateRef.current.layout.getColumnMaxWidth(column.key) <= stateRef.current.layout.getColumnWidth(column.key)) { document.body.classList.add(direction === 'rtl' ? classNames(styles, 'resize-e') : classNames(styles, 'resize-w')); } else { document.body.classList.add(classNames(styles, 'resize-ew')); } props.onMoveResizer(e); + // setting the resize column width in a state object leads to it being a render cycle behind + layout.setResizeColumnWidth(width); + virtualizerState.virtualizer.relayoutNow({sizeChanged: true}); }, onMoveEnd: () => { document.body.classList.remove(classNames(styles, 'resize-ew')); document.body.classList.remove(classNames(styles, 'resize-e')); document.body.classList.remove(classNames(styles, 'resize-w')); } - }, state, columnState, ref); + }, state, stateRef.current, ref); let style = { cursor: undefined, @@ -58,8 +66,8 @@ function Resizer(props: ResizerProps, ref: RefObject) { display: showResizer ? undefined : 'none', touchAction: 'none' }; - let isEResizable = columnState.getColumnMinWidth(column.key) >= columnState.getColumnWidth(column.key); - let isWResizable = columnState.getColumnMaxWidth(column.key) <= columnState.getColumnWidth(column.key); + let isEResizable = layout.getColumnMinWidth(column.key) >= layout.getColumnWidth(column.key); + let isWResizable = layout.getColumnMaxWidth(column.key) <= layout.getColumnWidth(column.key); return ( <> diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 1f579685463..2db2d70115c 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -26,7 +26,7 @@ import {layoutInfoToStyle, ScrollView, setScrollLeft, useVirtualizer, Virtualize import {Nubbin} from './Nubbin'; import {ProgressCircle} from '@react-spectrum/progress'; import React, {ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import {Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; +import {Rect, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer'; import {Resizer} from './Resizer'; import {SpectrumColumnProps, SpectrumTableProps} from '@react-types/table'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; @@ -91,12 +91,21 @@ interface TableContextValue { onMoveResizer: (e: MoveMoveEvent) => void } +interface TableVirtualizerContextValue { + state: VirtualizerState +} + const TableContext = React.createContext>(null); export function useTableContext() { return useContext(TableContext); } +const TableVirtualizerContext = React.createContext(null); +export function useTableVirtualizerContext() { + return useContext(TableVirtualizerContext); +} + function TableView(props: SpectrumTableProps, ref: DOMRef) { props = useProviderProps(props); let {isQuiet, onAction} = props; @@ -115,6 +124,15 @@ function TableView(props: SpectrumTableProps, ref: DOMRef { + if (hideHeader) { + let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; + return showDivider ? width + 1 : width; + } else if (isSelectionCell) { + return SELECTION_CELL_DEFAULT_WIDTH[scale]; + } + }, [scale]); + let [isInResizeMode, setIsInResizeMode] = useState(false); let state = useTableState({ ...props, @@ -124,7 +142,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef { setIsInResizeMode(false); - }}), getDefaultWidth}, state.collection); + }})}); // If the selection behavior changes in state, we need to update showSelectionCheckboxes here due to the circular dependency... let shouldShowCheckboxes = state.selectionManager.selectionBehavior !== 'replace'; @@ -151,12 +169,13 @@ function TableView(props: SpectrumTableProps, ref: DOMRef(props: SpectrumTableProps, ref: DOMRef + isFocusVisible={isFocusVisible} /> ); } // This is a custom Virtualizer that also has a header that syncs its scroll position with the body. -function TableVirtualizer({layout, collection, lastResizeInteractionModality, focusedKey, renderView, renderWrapper, domRef, bodyRef, headerRef, setTableWidth, getColumnWidth, onVisibleRectChange: onVisibleRectChangeProp, isFocusVisible, ...otherProps}) { +function TableVirtualizer({layout, collection, lastResizeInteractionModality, focusedKey, renderView, renderWrapper, domRef, bodyRef, headerRef, onVisibleRectChange: onVisibleRectChangeProp, isFocusVisible, ...otherProps}) { let {direction} = useLocale(); - let {state: tableState, columnState} = useTableContext(); + let {state: tableState} = useTableContext(); let loadingState = collection.body.props.loadingState; let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; let onLoadMore = collection.body.props.onLoadMore; @@ -410,11 +427,6 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo } }, state, domRef); - // If columnwidths change, need to relayout. - useLayoutEffect(() => { - state.virtualizer.relayoutNow({sizeChanged: true}); - }, [getColumnWidth, state.virtualizer]); - useEffect(() => { if (lastResizeInteractionModality.current === 'keyboard' && headerRef.current.contains(document.activeElement)) { document.activeElement?.scrollIntoView?.(false); @@ -431,8 +443,6 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo }, [bodyRef, headerRef]); let onVisibleRectChange = useCallback((rect: Rect) => { - setTableWidth(rect.width); - state.setVisibleRect(rect); if (!isLoading && onLoadMore) { @@ -453,12 +463,12 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo }, [state.contentSize, state.virtualizer, state.isAnimating, onLoadMore, isLoading]); let keysBefore = []; - let key = columnState.currentlyResizingColumn; + let key = layout.resizingColumn; do { keysBefore.push(key); key = tableState.collection.getKeyBefore(key); } while (key != null); - let resizerPosition = keysBefore.reduce((acc, key) => acc + columnState.getColumnWidth(key), 0) - 2; + let resizerPosition = keysBefore.reduce((acc, key) => acc + layout.getColumnWidth(key), 0) - 2; let resizerAtEdge = resizerPosition > Math.max(state.virtualizer.contentSize.width, state.virtualizer.visibleRect.width) - 3; // this should be fine, every movement of the resizer causes a rerender // scrolling can cause it to lag for a moment, but it's always updated @@ -466,53 +476,55 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo let shouldHardCornerResizeCorner = resizerAtEdge && resizerInVisibleRegion; return ( - -
+ +
- {state.visibleViews[0]} -
- - {state.visibleViews[1]} + // Override virtualizer provided tabindex if TableView is empty, so it is tabbable. + {...mergeProps(otherProps, virtualizerProps, collection.size === 0 && {tabIndex: 0})} + ref={domRef}>
- -
-
+ role="presentation" + className={classNames(styles, 'spectrum-Table-headWrapper')} + style={{ + width: visibleRect.width, + height: headerHeight, + overflow: 'hidden', + position: 'relative', + willChange: state.isScrolling ? 'scroll-position' : '', + transition: state.isAnimating ? `none ${state.virtualizer.transitionDuration}ms` : undefined + }} + ref={headerRef}> + {state.visibleViews[0]} +
+ + {state.visibleViews[1]} +
+ +
+
+ ); } @@ -601,7 +613,7 @@ function ResizableTableColumnHeader(props) { let ref = useRef(null); let triggerRef = useRef(null); let resizingRef = useRef(null); - let {state, columnState, headerRowHovered, setIsInResizeMode, isInResizeMode, isEmpty, onFocusedResizer, onMoveResizer} = useTableContext(); + let {state, columnState, layout, headerRowHovered, setIsInResizeMode, isInResizeMode, isEmpty, onFocusedResizer, onMoveResizer} = useTableContext(); let stringFormatter = useLocalizedStringFormatter(intlMessages); let {pressProps, isPressed} = usePress({isDisabled: isEmpty}); let {columnHeaderProps} = useTableColumnHeader({ @@ -630,6 +642,7 @@ function ResizableTableColumnHeader(props) { state.sort(column.key, 'descending'); break; case 'resize': + layout.resizingColumn = column.key; columnState.onColumnResizeStart(column); setIsInResizeMode(true); break; @@ -656,7 +669,7 @@ function ResizableTableColumnHeader(props) { }, [allowsSorting]); useEffect(() => { - if (columnState.currentlyResizingColumn === column.key) { + if (layout.resizingColumn === column.key) { // focusSafely won't actually focus because the focus moves from the menuitem to the body during the after transition wait // without the immediate timeout, Android Chrome doesn't move focus to the resizer setTimeout(() => { @@ -665,9 +678,9 @@ function ResizableTableColumnHeader(props) { }, 0); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [columnState.currentlyResizingColumn, column.key]); + }, [layout.resizingColumn, column.key]); - let showResizer = !isEmpty && ((headerRowHovered && getInteractionModality() !== 'keyboard') || columnState.currentlyResizingColumn != null); + let showResizer = !isEmpty && ((headerRowHovered && getInteractionModality() !== 'keyboard') || layout.resizingColumn != null); return ( @@ -708,7 +721,7 @@ function ResizableTableColumnHeader(props) {
{column.rendered}
} { - columnProps.allowsResizing && columnState.currentlyResizingColumn === null && + columnProps.allowsResizing && layout.resizingColumn === null && } @@ -722,6 +735,7 @@ function ResizableTableColumnHeader(props) { @@ -731,8 +745,8 @@ function ResizableTableColumnHeader(props) { styles, 'spectrum-Table-colResizeIndicator', { - 'spectrum-Table-colResizeIndicator--visible': columnState.currentlyResizingColumn != null, - 'spectrum-Table-colResizeIndicator--resizing': columnState.currentlyResizingColumn === column.key + 'spectrum-Table-colResizeIndicator--visible': layout.resizingColumn != null, + 'spectrum-Table-colResizeIndicator--resizing': layout.resizingColumn === column.key } )}>
diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index f962b4c8be1..0f0fe97d121 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -144,18 +144,26 @@ describe('TableView', function () { act(() => {jest.runAllTimers();}); }); - let render = (children, scale = 'medium') => renderComponent( - - {children} - - ); - - let rerender = (tree, children, scale = 'medium') => tree.rerender( - - {children} - - ); + let render = (children, scale = 'medium') => { + let tree = renderComponent( + + {children} + + ); + // account for table column resizing to do initial pass due to relayout from useTableColumnResizeState render + act(() => {jest.runAllTimers();}); + return tree; + }; + let rerender = (tree, children, scale = 'medium') => { + let newTree = tree.rerender( + + {children} + + ); + act(() => {jest.runAllTimers();}); + return newTree; + }; // I'd use tree.getByRole(role, {name: text}) here, but it's unbearably slow. let getCell = (tree, text) => { // Find by text, then go up to the element with the cell role. @@ -786,42 +794,50 @@ describe('TableView', function () { describe('keyboard focus', function () { // locale is being set here, since we can't nest them, use original render function - let renderTable = (locale = 'en-US', props = {}) => renderComponent( - - - - {column => {column.name}} - - - {item => - ( - {key => {item[key]}} - ) - } - - - - ); + let renderTable = (locale = 'en-US', props = {}) => { + let tree = renderComponent( + + + + {column => {column.name}} + + + {item => + ( + {key => {item[key]}} + ) + } + + + + ); + act(() => {jest.runAllTimers();}); + return tree; + }; // locale is being set here, since we can't nest them, use original render function - let renderNested = (locale = 'en-US') => renderComponent( - - - - {column => - {column.name} - } - - - {item => - ( - {key => {item[key]}} - ) - } - - - - ); + let renderNested = (locale = 'en-US') => { + let tree = renderComponent( + + + + {column => + {column.name} + } + + + {item => + ( + {key => {item[key]}} + ) + } + + + + ); + act(() => {jest.runAllTimers();}); + return tree; + }; let renderMany = () => render( @@ -3891,10 +3907,8 @@ describe('TableView', function () { render(); act(() => jest.runAllTimers()); - // first loadMore triggered by onVisibleRectChange, other 3 by useLayoutEffect - // we can't get better results than that without mocking every single clientHeight/Width - // this is a good candidate for storybook interactions test - expect(onLoadMoreSpy).toHaveBeenCalledTimes(4); + // first loadMore triggered by onVisibleRectChange, other 2 by useLayoutEffect + expect(onLoadMoreSpy).toHaveBeenCalledTimes(3); }); }); diff --git a/packages/@react-spectrum/table/test/TableSizing.test.js b/packages/@react-spectrum/table/test/TableSizing.test.js index e58296d9a17..a612a36d296 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.js +++ b/packages/@react-spectrum/table/test/TableSizing.test.js @@ -73,17 +73,26 @@ describe('TableViewSizing', function () { act(() => {jest.runAllTimers();}); }); - let render = (children, scale = 'medium') => renderComponent( - - {children} - - ); - - let rerender = (tree, children, scale = 'medium') => tree.rerender( - - {children} - - ); + let render = (children, scale = 'medium') => { + let tree = renderComponent( + + {children} + + ); + // account for table column resizing to do initial pass due to relayout from useTableColumnResizeState render + act(() => {jest.runAllTimers();}); + return tree; + }; + + let rerender = (tree, children, scale = 'medium') => { + let newTree = tree.rerender( + + {children} + + ); + act(() => {jest.runAllTimers();}); + return newTree; + }; describe('layout', function () { describe('row heights', function () { @@ -686,18 +695,7 @@ describe('TableViewSizing', function () { expect(row.childNodes[2].style.width).toBe('200px'); } expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 595 - }, { - key: 'bar', - width: 200 - }, { - key: 'baz', - width: 200 - } - ]); + expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); // actual locations do not matter, the delta matters between events for the calculation of useMove fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 30}); @@ -711,18 +709,7 @@ describe('TableViewSizing', function () { expect(row.childNodes[2].style.width).toBe('190px'); } expect(onColumnResizeEnd).toHaveBeenCalledTimes(2); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 620 - }, { - key: 'bar', - width: 190 - }, { - key: 'baz', - width: 190 - } - ]); + expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); fireEvent.pointerLeave(resizer, {pointerType: 'mouse', pointerId: 1}); fireEvent.pointerLeave(resizableHeader, {pointerType: 'mouse', pointerId: 1}); @@ -782,18 +769,7 @@ describe('TableViewSizing', function () { expect(row.childNodes[2].style.width).toBe('200px'); } expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 595 - }, { - key: 'bar', - width: 200 - }, { - key: 'baz', - width: 200 - } - ]); + expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); // actual locations do not matter, the delta matters between events for the calculation of useMove fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 30}); @@ -807,18 +783,7 @@ describe('TableViewSizing', function () { expect(row.childNodes[2].style.width).toBe('190px'); } expect(onColumnResizeEnd).toHaveBeenCalledTimes(2); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 620 - }, { - key: 'bar', - width: 190 - }, { - key: 'baz', - width: 190 - } - ]); + expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); fireEvent.pointerLeave(resizer, {pointerType: 'mouse', pointerId: 1}); fireEvent.pointerLeave(resizableHeader, {pointerType: 'mouse', pointerId: 1}); @@ -908,18 +873,7 @@ describe('TableViewSizing', function () { act(() => {jest.runAllTimers();}); expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 620 - }, { - key: 'bar', - width: 190 - }, { - key: 'baz', - width: 190 - } - ]); + expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); expect(tree.queryByRole('slider')).toBeNull(); }); @@ -1004,18 +958,7 @@ describe('TableViewSizing', function () { act(() => {jest.runAllTimers();}); expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 620 - }, { - key: 'bar', - width: 190 - }, { - key: 'baz', - width: 190 - } - ]); + expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); expect(tree.queryByRole('slider')).toBeNull(); }); @@ -1122,18 +1065,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Escape'}); fireEvent.keyUp(document.activeElement, {key: 'Escape'}); expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 600 - }, { - key: 'bar', - width: 200 - }, { - key: 'baz', - width: 200 - } - ]); + expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); expect(document.activeElement).toBe(resizableHeader); @@ -1214,18 +1146,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Escape'}); fireEvent.keyUp(document.activeElement, {key: 'Escape'}); expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 600 - }, { - key: 'bar', - width: 200 - }, { - key: 'baz', - width: 200 - } - ]); + expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); expect(document.activeElement).toBe(resizableHeader); @@ -1276,7 +1197,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Enter'}); fireEvent.keyUp(document.activeElement, {key: 'Enter'}); expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([]); + expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); expect(document.activeElement).toBe(resizableHeader); @@ -1326,7 +1247,7 @@ describe('TableViewSizing', function () { userEvent.tab(); expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([]); + expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); expect(document.activeElement).toBe(resizableHeader); @@ -1376,7 +1297,7 @@ describe('TableViewSizing', function () { userEvent.tab({shift: true}); expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([]); + expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); expect(document.activeElement).toBe(resizableHeader); diff --git a/packages/@react-stately/layout/package.json b/packages/@react-stately/layout/package.json index fc9933c587d..ccf929dccc8 100644 --- a/packages/@react-stately/layout/package.json +++ b/packages/@react-stately/layout/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@babel/runtime": "^7.6.2", + "@react-stately/table": "^3.5.0", "@react-stately/virtualizer": "^3.3.1", "@react-types/grid": "^3.1.4", "@react-types/shared": "^3.15.0", diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 0481e9935b0..0465252bf0f 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -10,30 +10,58 @@ * governing permissions and limitations under the License. */ +import {calculateColumnSizes, getMaxWidth, getMinWidth} from './TableUtils'; import {GridNode} from '@react-types/grid'; import {Key} from 'react'; import {LayoutInfo, Point, Rect, Size} from '@react-stately/virtualizer'; import {LayoutNode, ListLayout, ListLayoutOptions} from './ListLayout'; import {TableCollection} from '@react-types/table'; +type TableLayoutOptions = ListLayoutOptions & { + getDefaultWidth: (props) => string | number, + getDefaultMinWidth: (props) => string | number +} export class TableLayout extends ListLayout { collection: TableCollection; + resizingColumn: Key | null; lastCollection: TableCollection; - getColumnWidth: (key: Key) => number; + columnWidths: Map = new Map(); + changedColumns: Map = new Map(); stickyColumnIndices: number[]; + getDefaultWidth: (props) => string | number; + getDefaultMinWidth: (props) => string | number; wasLoading = false; isLoading = false; lastPersistedKeys: Set = null; persistedIndices: Map = new Map(); private disableSticky: boolean; - constructor(options: ListLayoutOptions) { + constructor(options: TableLayoutOptions) { super(options); + this.getDefaultWidth = options.getDefaultWidth; + this.getDefaultMinWidth = options.getDefaultMinWidth; this.stickyColumnIndices = []; this.disableSticky = this.checkChrome105(); } + setResizeColumnWidth(width): void { + this.changedColumns.set(this.resizingColumn, width); + } + + getColumnWidth(key: Key): number { + return this.columnWidths.get(key); + } + + getColumnMinWidth(key: Key): number { + let column = this.collection.columns.find(col => col.key === key); + return getMinWidth(column.props.minWidth, this.virtualizer.visibleRect.width); + } + + getColumnMaxWidth(key: Key): number { + let column = this.collection.columns.find(col => col.key === key); + return getMaxWidth(column.props.maxWidth, this.virtualizer.visibleRect.width); + } buildCollection(): LayoutNode[] { // If columns changed, clear layout cache. @@ -51,9 +79,9 @@ export class TableLayout extends ListLayout { this.wasLoading = this.isLoading; this.isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; + this.buildColumnWidths(); let header = this.buildHeader(); let body = this.buildBody(0); - this.stickyColumnIndices = this.collection.columns.filter(c => c.props.isSelectionCell || this.collection.rowHeaderColumnKeys.has(c.key)).map(c => c.index); this.lastPersistedKeys = null; body.layoutInfo.rect.width = Math.max(header.layoutInfo.rect.width, body.layoutInfo.rect.width); @@ -64,6 +92,65 @@ export class TableLayout extends ListLayout { ]; } + buildColumnWidths() { + let prevColumnWidths = this.columnWidths; + this.columnWidths = new Map(); + this.stickyColumnIndices = []; + + for (let column of this.collection.columns) { + // The selection cell and any other sticky columns always need to be visible. + // In addition, row headers need to be in the DOM for accessibility labeling. + if (column.props.isSelectionCell || this.collection.rowHeaderColumnKeys.has(column.key)) { + this.stickyColumnIndices.push(column.index); + } + } + if (this.resizingColumn == null) { + // initial layout or table/window resizing + let columnWidths = calculateColumnSizes( + this.virtualizer.visibleRect.width, + this.collection.columns.map(col => ({...col.column.props, key: col.key})), + this.changedColumns, + (i) => this.getDefaultWidth(this.collection.columns[i].props), + (i) => this.getDefaultMinWidth(this.collection.columns[i].props) + ); + + // columns going in will be the same order as the columns coming out + columnWidths.forEach((width, index) => { + this.columnWidths.set(this.collection.columns[index].key, width); + }); + } else { + // resizing a column + // TODO do we want to recalculate sizes of columns after the one we're resizing? + // I personally feel like that's weird because it changes once all columns become static or resized + let resizeIndex = Infinity; + let resizingChanged = new Map(this.changedColumns); + this.collection.columns.forEach((column, i) => { + if (resizeIndex < i) { + return; + } + if (column.key === this.resizingColumn) { + resizeIndex = i; + } + if (!resizingChanged.has(column.key)) { + resizingChanged.set(column.key, prevColumnWidths.get(column.key)); + } + }); + + let columnWidths = calculateColumnSizes( + this.virtualizer.visibleRect.width, + this.collection.columns.map(col => ({...col.column.props, key: col.key})), + resizingChanged, + (i) => this.getDefaultWidth(this.collection.columns[i].props), + (i) => this.getDefaultMinWidth(this.collection.columns[i].props) + ); + + // columns going in will be the same order as the columns coming out + columnWidths.forEach((width, index) => { + this.columnWidths.set(this.collection.columns[index].key, width); + }); + } + } + buildHeader(): LayoutNode { let rect = new Rect(0, 0, 0, 0); let layoutInfo = new LayoutInfo('header', 'header', rect); @@ -131,13 +218,13 @@ export class TableLayout extends ListLayout { } // used to get the column widths when rendering to the DOM - getColumnWidth_(node: GridNode) { + getRenderedColumnWidth(node: GridNode) { let colspan = node.colspan ?? 1; let colIndex = node.colIndex ?? node.index; let width = 0; for (let i = colIndex; i < colIndex + colspan; i++) { let column = this.collection.columns[i]; - width += this.getColumnWidth(column.key); + width += this.columnWidths.get(column.key); } return width; @@ -167,7 +254,7 @@ export class TableLayout extends ListLayout { } buildColumn(node: GridNode, x: number, y: number): LayoutNode { - let width = this.getColumnWidth_(node); + let width = this.getRenderedColumnWidth(node); let {height, isEstimated} = this.getEstimatedHeight(node, width, this.headingHeight, this.estimatedHeadingHeight); let rect = new Rect(x, y, width, height); let layoutInfo = new LayoutInfo(node.type, node.key, rect); @@ -268,7 +355,7 @@ export class TableLayout extends ListLayout { } buildCell(node: GridNode, x: number, y: number): LayoutNode { - let width = this.getColumnWidth_(node); + let width = this.getRenderedColumnWidth(node); let {height, isEstimated} = this.getEstimatedHeight(node, width, this.rowHeight, this.estimatedRowHeight); let rect = new Rect(x, y, width, height); let layoutInfo = new LayoutInfo(node.type, node.key, rect); diff --git a/packages/@react-stately/layout/src/TableUtils.ts b/packages/@react-stately/layout/src/TableUtils.ts new file mode 100644 index 00000000000..1c82dc38c2d --- /dev/null +++ b/packages/@react-stately/layout/src/TableUtils.ts @@ -0,0 +1,157 @@ +import {Key} from 'react'; + +// numbers and percents are considered static. *fr units or a lack of units are considered dynamic. +export function isStatic(width: number | string): boolean { + return width != null && (!isNaN(width as number) || (String(width)).match(/^(\d+)(?=%$)/) !== null); +} + +function parseFractionalUnit(width: string): number { + if (!width) { + return 1; + } + let match = width.match(/^(\d+)(?=fr$)/); + // if width is the incorrect format, just deafult it to a 1fr + if (!match) { + console.warn(`width: ${width} is not a supported format, width should be a number (ex. 150), percentage (ex. '50%') or fr unit (ex. '2fr')`, + 'defaulting to \'1fr\''); + return 1; + } + return parseInt(match[0], 10); +} + +export function parseStaticWidth(width: number | string, tableWidth: number): number { + if (typeof width === 'string') { + let match = width.match(/^(\d+)(?=%$)/); + if (!match) { + throw new Error('Only percentages or numbers are supported for static column widths'); + } + return tableWidth * (parseInt(match[0], 10) / 100); + } + return width; +} + + +export function getMaxWidth(maxWidth: number | string, tableWidth: number): number { + return maxWidth != null + ? parseStaticWidth(maxWidth, tableWidth) + : Number.MAX_SAFE_INTEGER; +} + +export function getMinWidth(minWidth: number | string, tableWidth: number, defaultMinWidth = 75): number { + return minWidth != null + ? parseStaticWidth(minWidth, tableWidth) + : defaultMinWidth; +} + +// tell us the delta between min width and target width vs max width and target width +function mapDynamicColumns(dynamicColumns: IndexedColumn[], availableSpace: number, tableWidth: number): (IndexedColumn & {delta: number})[] { + let fractions = dynamicColumns.reduce( + (sum, column) => column ? sum + parseFractionalUnit(column.column.defaultWidth as string) : sum, + 0 + ); + + let columns = dynamicColumns.map((column) => { + if (!column) { + return null; + } + const targetWidth = + (parseFractionalUnit(column.column.defaultWidth as string) * availableSpace) / fractions; + const delta = Math.max( + getMinWidth(column.column.minWidth, tableWidth) - targetWidth, + targetWidth - getMaxWidth(column.column.maxWidth, tableWidth) + ); + + return { + ...column, + delta + }; + }); + + return columns; +} + +// mutates columns to set their width +function findDynamicColumnWidths(dynamicColumns: IndexedColumn[], availableSpace: number, tableWidth: number): void { + let fractions = dynamicColumns.reduce( + (sum, col) => col ? sum + parseFractionalUnit(col.column.defaultWidth as string) : sum, + 0 + ); + + dynamicColumns.forEach((column) => { + if (!column) { + return null; + } + const targetWidth = + (parseFractionalUnit(column.column.defaultWidth as string) * availableSpace) / fractions; + let width = Math.max( + getMinWidth(column.column.minWidth, tableWidth), + Math.min(Math.floor(targetWidth), getMaxWidth(column.column.maxWidth, tableWidth)) + ); + column.width = width; + availableSpace -= width; + fractions -= parseFractionalUnit(column.column.defaultWidth as string); + }); +} + +export function getDynamicColumnWidths(dynamicColumns: IndexedColumn[], availableSpace: number, tableWidth: number) { + let columns = mapDynamicColumns(dynamicColumns, availableSpace, tableWidth); + + // sort is nlogn and copying is n, so copying and sorting is faster than sorting twice + // sort by delta's to prioritize assigning width + let sorted = [...columns].sort((a, b) => { + if (a && b) { + return b.delta - a.delta; + } + return a ? -1 : 1; + }); + // this function mutates the column entries, so no need to have it return anything + // plus we don't need to undo the sort since we already have the correct order + findDynamicColumnWidths(sorted, availableSpace, tableWidth); + + return columns; +} + + +export interface IColumn { + minWidth?: number | string, + maxWidth?: number | string, + width?: number | string, + defaultWidth?: number | string, + key?: Key +} +export interface IndexedColumn { + column: IColumn, + index: number, + width: number, + isDynamic?: boolean, + delta?: number +} + +export function calculateColumnSizes(availableWidth: number, columns: IColumn[], changedColumns: Map, getDefaultWidth, getDefaultMinWidth) { + let remainingSpace = availableWidth; + let {staticColumns, dynamicColumns} = columns.reduce((acc, column, index) => { + let width = changedColumns.get(column.key) != null ? changedColumns.get(column.key) : column.width ?? column.defaultWidth ?? getDefaultWidth?.(index) ?? '1fr'; + let defaultMinWidth = getDefaultMinWidth?.(index); + if (isStatic(width)) { + let w = parseStaticWidth(width, availableWidth); + w = Math.max( + getMinWidth(column.minWidth, availableWidth, defaultMinWidth), + Math.min(Math.floor(w), getMaxWidth(column.maxWidth, availableWidth))); + acc.staticColumns.push({index, column, width: w} as IndexedColumn); + acc.dynamicColumns.push(null); + remainingSpace -= w; + } else { + acc.staticColumns.push(null); + acc.dynamicColumns.push({index, column, width: null} as IndexedColumn); + } + return acc; + }, {staticColumns: [] as IndexedColumn[], dynamicColumns: [] as IndexedColumn[]}); + let newColWidths = getDynamicColumnWidths(dynamicColumns, remainingSpace, availableWidth); + + return staticColumns.map((col, i) => { + if (col) { + return col.width; + } + return newColWidths[i].width; + }); +} diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index aadaf2ee07f..44767744a18 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -1,226 +1,44 @@ -import {ColumnProps} from '@react-types/table'; -import {getContentWidth, getDynamicColumnWidths, getMaxWidth, getMinWidth, isStatic, parseStaticWidth} from './utils'; import {GridNode} from '@react-types/grid'; -import {Key, MutableRefObject, useCallback, useRef, useState} from 'react'; - -interface AffectedColumnWidth { - /** The column key. */ - key: Key, - /** The column width. */ - width: number -} -interface AffectedColumnWidths extends Array {} +import {Key, useRef} from 'react'; export interface TableColumnResizeState { - /** A ref whose current value is the state of all the column widths. */ - columnWidths: MutableRefObject>, - /** Setter for the table width. */ - setTableWidth: (width: number) => void, /** Trigger a resize and recalculation. */ onColumnResize: (column: GridNode, width: number) => void, /** Callback for when onColumnResize has started. */ onColumnResizeStart: (column: GridNode) => void, /** Callback for when onColumnResize has ended. */ - onColumnResizeEnd: (column: GridNode) => void, - /** Getter for column width. */ - getColumnWidth: (key: Key) => number, - /** Getter for column min width. */ - getColumnMinWidth: (key: Key) => number, - /** Getter for column max widths. */ - getColumnMaxWidth: (key: Key) => number, - /** Key of column currently being resized. */ - currentlyResizingColumn: Key | null + onColumnResizeEnd: (column: GridNode) => void } export interface TableColumnResizeStateProps { - /** Callback to determine what the default width of a column should be. */ - getDefaultWidth?: (props) => string | number, /** Callback that is invoked during the entirety of the resize event. */ - onColumnResize?: (affectedColumnWidths: AffectedColumnWidths) => void, + onColumnResize?: (key: Key, width: number) => void, /** Callback that is invoked when the resize event is ended. */ - onColumnResizeEnd?: (affectedColumnWidths: AffectedColumnWidths) => void, - /** The default table width. */ - tableWidth?: number + onColumnResizeEnd?: (key: Key) => void } -interface ColumnState { - columns: GridNode[] -} - -export function useTableColumnResizeState(props: TableColumnResizeStateProps, state: ColumnState): TableColumnResizeState { - const {getDefaultWidth, tableWidth: defaultTableWidth = null} = props; - const {columns} = state; - const columnsRef = useRef[]>([]); - const tableWidth = useRef(defaultTableWidth); +export function useTableColumnResizeState(props: TableColumnResizeStateProps): TableColumnResizeState { const isResizing = useRef(null); - const startResizeContentWidth = useRef(); - - const [columnWidths, setColumnWidths] = useState>(new Map(columns.map(col => [col.key, 0]))); - const columnWidthsRef = useRef>(columnWidths); - const affectedColumnWidthsRef = useRef([]); - const [resizedColumns, setResizedColumns] = useState>(new Set()); - const resizedColumnsRef = useRef>(resizedColumns); - - const [currentlyResizingColumn, setCurrentlyResizingColumn] = useState(null); - - function setColumnWidthsForRef(newWidths: Map) { - columnWidthsRef.current = newWidths; - // new map so that change detection is triggered - setColumnWidths(newWidths); - } - /* - returns the resolved column width in this order: - previously calculated width -> controlled width prop -> uncontrolled defaultWidth prop -> dev assigned width -> default dynamic width - */ - let getResolvedColumnWidth = useCallback((column: GridNode): (number | string) => { - let columnProps = column.props as ColumnProps; - return resizedColumns?.has(column.key) ? columnWidthsRef.current.get(column.key) : columnProps.width ?? columnProps.defaultWidth ?? getDefaultWidth?.(column.props) ?? '1fr'; - }, [getDefaultWidth, resizedColumns]); - - let getStaticAndDynamicColumns = useCallback((columns: GridNode[]) : { staticColumns: GridNode[], dynamicColumns: GridNode[] } => columns.reduce((acc, column) => { - let width = getResolvedColumnWidth(column); - return isStatic(width) ? {...acc, staticColumns: [...acc.staticColumns, column]} : {...acc, dynamicColumns: [...acc.dynamicColumns, column]}; - }, {staticColumns: [], dynamicColumns: []}), [getResolvedColumnWidth]); - - let buildColumnWidths = useCallback((affectedColumns: GridNode[], availableSpace: number): Map => { - const widths = new Map(); - let remainingSpace = availableSpace; - - const {staticColumns, dynamicColumns} = getStaticAndDynamicColumns(affectedColumns); - - staticColumns.forEach(column => { - let width = getResolvedColumnWidth(column); - let w = parseStaticWidth(width, tableWidth.current); - widths.set(column.key, w); - remainingSpace -= w; - }); - - // dynamic columns - if (dynamicColumns.length > 0) { - const newColumnWidths = getDynamicColumnWidths(dynamicColumns, remainingSpace, tableWidth.current); - for (let column of newColumnWidths) { - widths.set(column.key, column.calculatedWidth); - } - } - - return widths; - }, [getStaticAndDynamicColumns, getResolvedColumnWidth]); - - - const prevColKeys = columnsRef.current.map(col => col.key); - const colKeys = columns.map(col => col.key); - // if the columns change, need to rebuild widths. - if (prevColKeys.length !== colKeys.length || !colKeys.every((col, i) => col === prevColKeys[i])) { - columnsRef.current = columns; - const widths = buildColumnWidths(columns, tableWidth.current); - setColumnWidthsForRef(widths); - } - - function setTableWidth(width: number) { - if (width && width !== tableWidth.current) { - tableWidth.current = width; - if (!isResizing.current) { - const widths = buildColumnWidths(columns, width); - setColumnWidthsForRef(widths); - } - } - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars function onColumnResizeStart(column: GridNode) { - setCurrentlyResizingColumn(column.key); isResizing.current = true; - startResizeContentWidth.current = getContentWidth(columnWidthsRef.current); } function onColumnResize(column: GridNode, width: number) { - let widthsObj = resizeColumn(column, width); - affectedColumnWidthsRef.current = widthsObj; - props.onColumnResize && props.onColumnResize(affectedColumnWidthsRef.current); + props.onColumnResize && props.onColumnResize(column.key, width); } // eslint-disable-next-line @typescript-eslint/no-unused-vars function onColumnResizeEnd(column: GridNode) { - props.onColumnResizeEnd && isResizing.current && props.onColumnResizeEnd(affectedColumnWidthsRef.current); - setCurrentlyResizingColumn(null); + props.onColumnResizeEnd && isResizing.current && props.onColumnResizeEnd(column.key); isResizing.current = false; - affectedColumnWidthsRef.current = []; - - let widths = new Map(columnWidthsRef.current); - setColumnWidthsForRef(widths); } - function resizeColumn(column: GridNode, newWidth: number) : AffectedColumnWidths { - let boundedWidth = Math.max( - getMinWidth(column.props.minWidth, tableWidth.current), - Math.min(Math.floor(newWidth), getMaxWidth(column.props.maxWidth, tableWidth.current))); - - // copy the columnWidths map and set the new width for the column being resized - let widths = new Map(columnWidthsRef.current); - widths.set(column.key, boundedWidth); - - // keep track of all columns that have been sized - resizedColumnsRef.current.add(column.key); - setResizedColumns(resizedColumnsRef.current); - - // get the columns affected by resize and remaining space - const resizeIndex = columnsRef.current.findIndex(col => col.key === column.key); - let affectedColumns = columnsRef.current.slice(resizeIndex + 1); - - // we only care about the columns that CAN be resized, we ignore static columns. - let {dynamicColumns} = getStaticAndDynamicColumns(affectedColumns); - - // available space for affected columns - let availableSpace = columnsRef.current.reduce((acc, column, index) => { - if (index <= resizeIndex || isStatic(getResolvedColumnWidth(column))) { - return acc - widths.get(column.key); - } - return acc; - }, tableWidth.current); - - // merge the unaffected column widths and the recalculated column widths - let recalculatedColumnWidths = buildColumnWidths(dynamicColumns, availableSpace); - widths = new Map([...widths, ...recalculatedColumnWidths]); - - setColumnWidthsForRef(widths); - - /* - when getting recalculated columns above, the column being resized is not considered "recalculated" - so we need to add it to the list of affected columns - */ - let allAffectedColumns = ([[column.key, boundedWidth], ...recalculatedColumnWidths] as [Key, number][]).map(([key, width]) => ({key, width})); - return allAffectedColumns; - } - - // This function is regenerated whenever columnWidthsRef.current changes in order to get the new correct ref value. - // eslint-disable-next-line react-hooks/exhaustive-deps - let getColumnWidth = useCallback((key: Key): number => columnWidthsRef.current.get(key) ?? 0, [columnWidthsRef.current]); - - let getColumnMinWidth = useCallback((key: Key) => { - const columnIndex = columns.findIndex(col => col.key === key); - if (columnIndex === -1) { - return; - } - return getMinWidth(columns[columnIndex].props.minWidth, tableWidth.current); - }, [columns]); - - let getColumnMaxWidth = useCallback((key: Key) => { - const columnIndex = columns.findIndex(col => col.key === key); - if (columnIndex === -1) { - return; - } - return getMaxWidth(columns[columnIndex].props.maxWidth, tableWidth.current); - }, [columns]); - return { - columnWidths: columnWidthsRef, - setTableWidth, onColumnResize, onColumnResizeStart, - onColumnResizeEnd, - getColumnWidth, - getColumnMinWidth, - getColumnMaxWidth, - currentlyResizingColumn + onColumnResizeEnd }; } diff --git a/packages/@react-stately/table/src/utils.ts b/packages/@react-stately/table/src/utils.ts deleted file mode 100644 index dfe00f76283..00000000000 --- a/packages/@react-stately/table/src/utils.ts +++ /dev/null @@ -1,111 +0,0 @@ -import {GridNode} from '@react-types/grid'; -import {Key} from 'react'; - -type mappedColumn = GridNode & { - index: number, - delta: number, - calculatedWidth?: number -}; - -export function getContentWidth(widths: Map): number { - return Array.from(widths).map(e => e[1]).reduce((acc, cur) => acc + cur, 0); -} - -// numbers and percents are considered static. *fr units or a lack of units are considered dynamic. -export function isStatic(width: number | string): boolean { - return width != null && (!isNaN(width as number) || (String(width)).match(/^(\d+)(?=%$)/) !== null); -} - -function parseFractionalUnit(width: string): number { - if (!width) { - return 1; - } - let match = width.match(/^(\d+)(?=fr$)/); - // if width is the incorrect format, just deafult it to a 1fr - if (!match) { - console.warn(`width: ${width} is not a supported format, width should be a number (ex. 150), percentage (ex. '50%') or fr unit (ex. '2fr')`, - 'defaulting to \'1fr\''); - return 1; - } - return parseInt(match[0], 10); -} - -export function parseStaticWidth(width: number | string, tableWidth: number): number { - if (typeof width === 'string') { - let match = width.match(/^(\d+)(?=%$)/); - if (!match) { - throw new Error('Only percentages or numbers are supported for static column widths'); - } - return tableWidth * (parseInt(match[0], 10) / 100); - } - return width; -} - - -export function getMaxWidth(maxWidth: number | string, tableWidth: number): number { - return maxWidth != null - ? parseStaticWidth(maxWidth, tableWidth) - : Number.MAX_SAFE_INTEGER; -} - -export function getMinWidth(minWidth: number | string, tableWidth: number): number { - return minWidth != null - ? parseStaticWidth(minWidth, tableWidth) - : 75; -} - -function mapDynamicColumns(dynamicColumns: GridNode[], availableSpace: number, tableWidth: number): mappedColumn[] { - let fractions = dynamicColumns.reduce( - (sum, column) => sum + parseFractionalUnit(column.props.defaultWidth), - 0 - ); - - let columns = dynamicColumns.map((column, index) => { - const targetWidth = - (parseFractionalUnit(column.props.defaultWidth) * availableSpace) / fractions; - const delta = Math.max( - getMinWidth(column.props.minWidth, tableWidth) - targetWidth, - targetWidth - getMaxWidth(column.props.maxWidth, tableWidth) - ); - - return { - ...column, - index, - delta - }; - }); - - return columns; -} - -function findDynamicColumnWidths(dynamicColumns: mappedColumn[], availableSpace: number, tableWidth: number): mappedColumn[] { - let fractions = dynamicColumns.reduce( - (sum, col) => sum + parseFractionalUnit(col.props.defaultWidth), - 0 - ); - - const columns = dynamicColumns.map((column) => { - const targetWidth = - (parseFractionalUnit(column.props.defaultWidth) * availableSpace) / fractions; - let width = Math.max( - getMinWidth(column.props.minWidth, tableWidth), - Math.min(Math.floor(targetWidth), getMaxWidth(column.props.maxWidth, tableWidth)) - ); - column.calculatedWidth = width; - availableSpace -= width; - fractions -= parseFractionalUnit(column.props.defaultWidth); - return column; - }); - - return columns; -} - -export function getDynamicColumnWidths(dynamicColumns: GridNode[], availableSpace: number, tableWidth: number) { - let columns = mapDynamicColumns(dynamicColumns, availableSpace, tableWidth); - - columns.sort((a, b) => b.delta - a.delta); - columns = findDynamicColumnWidths(columns, availableSpace, tableWidth); - columns.sort((a, b) => a.index - b.index); - - return columns; -} diff --git a/packages/@react-stately/table/test/useTableColumnResizeState.test.ts b/packages/@react-stately/table/test/useTableColumnResizeState.test.ts deleted file mode 100644 index db3859c16db..00000000000 --- a/packages/@react-stately/table/test/useTableColumnResizeState.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright 2020 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import {getContentWidth} from '../src/utils'; -import {GridNode} from '@react-types/grid'; -import {renderHook} from '@react-spectrum/test-utils'; -import {useTableColumnResizeState} from '../'; - -const createColumn = (key, columnProps) => ({ - type: 'column', - props: columnProps, - key, - value: null, - level: 0, - hasChildNodes: null, - childNodes: [], - rendered: key, - textValue: key -}); - -describe('useTableColumnResizeState', () => { - describe('static defaultWidth', () => { - it('should handle pixel widths', () => { - const columns = [ - createColumn('Name', {allowsResizing: true, defaultWidth: 300}), - createColumn('Age', {allowsResizing: true, defaultWidth: 100}), - createColumn('Weight', {allowsResizing: true, defaultWidth: 200}) - ]; - - const {result} = renderHook(() => useTableColumnResizeState>({}, {columns})); - expect(result.current.columnWidths.current).toEqual(new Map([['Name', 300], ['Age', 100], ['Weight', 200]])); - }); - - it('should handle percentage widths', () => { - const columns = [ - createColumn('Name', {allowsResizing: true, defaultWidth: '50%'}), - createColumn('Age', {allowsResizing: true, defaultWidth: '16%'}), - createColumn('Weight', {allowsResizing: true, defaultWidth: '33%'}) - ]; - - const {result} = renderHook(() => useTableColumnResizeState({tableWidth: 600}, {columns})); - expect(result.current.columnWidths.current).toEqual(new Map([['Name', 300], ['Age', 96], ['Weight', 198]])); - }); - }); - - describe('dynamic defaultWidth', () => { - it('should proportionately allocate space when no defaultWidth is given', () => { - const columns = [ - createColumn('Name', {allowsResizing: true}), - createColumn('Age', {allowsResizing: true}), - createColumn('Weight', {allowsResizing: true}) - ]; - - const {result} = renderHook(() => useTableColumnResizeState({tableWidth: 333}, {columns})); - expect(result.current.columnWidths.current).toEqual(new Map([['Name', 111], ['Age', 111], ['Weight', 111]])); - }); - - it('should proportionately allocate space when defaultWidth is *fr units', () => { - const columns = [ - createColumn('Name', {allowsResizing: true, defaultWidth: '1fr'}), - createColumn('Age', {allowsResizing: true, defaultWidth: '2fr'}), - createColumn('Weight', {allowsResizing: true, defaultWidth: '1fr'}) - ]; - - const {result} = renderHook(() => useTableColumnResizeState({tableWidth: 1000}, {columns})); - expect(result.current.columnWidths.current).toEqual(new Map([['Name', 250], ['Age', 500], ['Weight', 250]])); - }); - }); - - describe('bounded widths', () => { - it('should fulfill the maxWidth constraint and give remaining space to other dynamic columns', () => { - const columns = [ - createColumn('Name', {allowsResizing: true, maxWidth: 85}), - createColumn('Age', {allowsResizing: true, defaultWidth: '2fr'}), - createColumn('Weight', {allowsResizing: true, defaultWidth: '1fr'}) - ]; - - const {result} = renderHook(() => useTableColumnResizeState({tableWidth: 1000}, {columns})); - expect(result.current.columnWidths.current).toEqual(new Map([['Name', 85], ['Age', 610], ['Weight', 305]])); - }); - - it('should fulfill the minWidth constraint and give remaining space to other dynamic columns', () => { - const columns = [ - createColumn('Name', {allowsResizing: true, minWidth: 400}), - createColumn('Age', {allowsResizing: true, defaultWidth: '2fr'}), - createColumn('Weight', {allowsResizing: true, defaultWidth: '1fr'}) - ]; - - const {result} = renderHook(() => useTableColumnResizeState({tableWidth: 1000}, {columns})); - expect(result.current.columnWidths.current).toEqual(new Map([['Name', 400], ['Age', 400], ['Weight', 200]])); - }); - - it('should fulfill the bounded constraints when the total column widths is greater than the allowed table width', () => { - const columns = [ - createColumn('Name', {allowsResizing: true, minWidth: 1000}), - createColumn('Age', {allowsResizing: true, minWidth: 1000}), - createColumn('Weight', {allowsResizing: true, defaultWidth: '1fr'}) - ]; - - const tableWidth = 1000; - - const {result} = renderHook(() => useTableColumnResizeState({tableWidth}, {columns})); - - const actualColumnWidths = getContentWidth(result.current.columnWidths.current); - - expect(result.current.columnWidths.current).toEqual(new Map([['Name', 1000], ['Age', 1000], ['Weight', 75]])); - expect(actualColumnWidths > tableWidth); - }); - - it('should fulfill the bounded constraints when the total column widths is less than the allowed table width', () => { - const columns = [ - createColumn('Name', {allowsResizing: true, maxWidth: 100}), - createColumn('Age', {allowsResizing: true, maxWidth: 100}), - createColumn('Weight', {allowsResizing: true, maxWidth: 250, defaultWidth: '1fr'}) - ]; - - const tableWidth = 1000; - - const {result} = renderHook(() => useTableColumnResizeState({tableWidth}, {columns})); - - const actualColumnWidths = getContentWidth(result.current.columnWidths.current); - - expect(result.current.columnWidths.current).toEqual(new Map([['Name', 100], ['Age', 100], ['Weight', 250]])); - expect(actualColumnWidths < tableWidth); - }); - - it('should allocate extra space to previous dynamic columns if later columns are bounded.', () => { - const columns = [ - createColumn('Name', {allowsResizing: true, defaultWidth: '2fr'}), - createColumn('Age', {allowsResizing: true, maxWidth: 100}), - createColumn('Weight', {allowsResizing: true, maxWidth: 250, defaultWidth: '1fr'}) - ]; - - const tableWidth = 1000; - - const {result} = renderHook(() => useTableColumnResizeState({tableWidth}, {columns})); - - expect(result.current.columnWidths.current).toEqual(new Map([['Name', 650], ['Age', 100], ['Weight', 250]])); - }); - - /* - This test actually fails if the minWidth for 'Name' is large (like 600). Even though minWidth 600 is less than 650 (and therefore not bounded) - its delta ends up being larger than the other columns and it gets evaluated first - which is incorrect. - - We tried to come up with a simple way to resolve this problem without causing regressions but couldn't. - However this is an extreme edge-case and these cases being tested were already failing in the previous width calculation so we are not - introducing any new breaking behavior and actually have introduced new behavior which fixes 4/5 cases that were previously broken. - - Making this comment as acknowledgement of the broken case and maybe in the future we might enhance this with an algorithm that covers all cases. - */ - it('should allocate extra space to previous "less bounded" minWidth dynamic columns if later columns are "more bounded".', () => { - const columns = [ - createColumn('Name', {allowsResizing: true, minWidth: 300}), - createColumn('Age', {allowsResizing: true, maxWidth: 100}), - createColumn('Weight', {allowsResizing: true, maxWidth: 250}) - ]; - - const tableWidth = 1000; - - const {result} = renderHook(() => useTableColumnResizeState({tableWidth}, {columns})); - - expect(result.current.columnWidths.current).toEqual(new Map([['Name', 650], ['Age', 100], ['Weight', 250]])); - }); - - it('should allocate extra space to previous "less bounded" maxWidth dynamic columns if later columns are "more bounded".', () => { - const columns = [ - createColumn('Name', {allowsResizing: true, maxWidth: 1000}), - createColumn('Age', {allowsResizing: true, minWidth: 500}), - createColumn('Weight', {allowsResizing: true, maxWidth: 400}) - ]; - - const tableWidth = 1000; - - const {result} = renderHook(() => useTableColumnResizeState({tableWidth}, {columns})); - - expect(result.current.columnWidths.current).toEqual(new Map([['Name', 250], ['Age', 500], ['Weight', 250]])); - }); - - it('should distribute extra space evenly amongst "less bounded" dynamic columns.', () => { - const columns = [ - createColumn('Name', {allowsResizing: true, maxWidth: 330}), - createColumn('Age', {allowsResizing: true, minWidth: 500}), - createColumn('Weight', {allowsResizing: true, maxWidth: 330}) - ]; - - const tableWidth = 1000; - - const {result} = renderHook(() => useTableColumnResizeState({tableWidth}, {columns})); - - expect(result.current.columnWidths.current).toEqual(new Map([['Name', 250], ['Age', 500], ['Weight', 250]])); - }); - }); -}); diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 9d65de0d44f..e6dc46fedf4 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -42,12 +42,12 @@ export interface SpectrumTableProps extends TableProps, SpectrumSelectionP * Handler that is called when a user performs a column resize. * @private */ - onColumnResize?: (affectedColumns: {key: Key, width: number}[]) => void, + onColumnResize?: (key: Key, width: number) => void, /** * Handler that is called when a column resize ends. * @private */ - onColumnResizeEnd?: (affectedColumns: {key: Key, width: number}[]) => void + onColumnResizeEnd?: (key: Key) => void } export interface TableHeaderProps { diff --git a/packages/dev/storybook-builder-parcel/.eslintignore b/packages/dev/storybook-builder-parcel/.eslintignore new file mode 100644 index 00000000000..e69de29bb2d diff --git a/rfcs/2022-v3-resizable-columns.md b/rfcs/2022-v3-resizable-columns.md index caba6301e07..6126a7dedb6 100644 --- a/rfcs/2022-v3-resizable-columns.md +++ b/rfcs/2022-v3-resizable-columns.md @@ -73,11 +73,11 @@ Resizing a column will only affect the dynamic columns that come after that colu Calculating column widths follows this flow: 1. Static columns are calculated first. Pixel values are straightforward, these are simply checked to see if they should be clamped by a min or max. Percent values are set as a percent of the visible table width (not the total width of all table contents). -2. Dynamic colmns are calculated next. With dynamic columns, the amount of space remaining is divided up amongst the remaining columns. If no `defaultWidth` is provided, the column defaults to `1fr`. +2. Dynamic columns are calculated next. With dynamic columns, the amount of space remaining is divided up amongst the remaining columns. If no `defaultWidth` is provided, the column defaults to `1fr`. ### Accessibilty Functionality * Using arrow keys, users can navigate to the column header -* While the column header is focussed, pressing `return/enter` or `space` will activate a dropdown +* While the column header is focused, pressing `return/enter` or `space` will activate a dropdown * One of the options in the dropdown will be to resize the column (if column is resizable) * User can navigate through the dropdown using arrow keys * Pressing `return/enter` or `space` on the dropdown item will activate the resize mode, closing the dropdown and focussing the resizer @@ -94,7 +94,7 @@ Stories will be added to the storybook for column resizing. The react spectrum d There may be minor performance hits from adding resizing. This adds to the amount of calculation that happens each time the table renders. -The a11y proposal involves changing the default behavior for clicking on a table oclumn header if it is sortable and resizable. Currently clicking on a column header that is sortable will toggle the sort. If it is resizable and sortable, clicking on the column header will activate a dropdown where the user can select to sort or resize. This is an extra click for the end user. +The a11y proposal involves changing the default behavior for clicking on a table column header if it is sortable and resizable. Currently clicking on a column header that is sortable will toggle the sort. If it is resizable and sortable, clicking on the column header will activate a dropdown where the user can select to sort or resize. This is an extra click for the end user. ## Backwards Compatibility Analysis @@ -104,7 +104,7 @@ The only change in existing behavior is the change to table header click behavio ## Alternatives -We researched many commonly used tables components to help define the desired behavior for this column resizing feature. Three main examples that were researched were Excel, Marketo Engage and AG Grid. +We researched many commonly used tables components to help define the desired behavior for this column resizing feature. Three main examples that we researched were Excel, Marketo Engage and AG Grid. ## Open Questions @@ -136,4 +136,4 @@ Still need to define how a controlled model will work for column resizing. If there is an issue, pull request, or other URL that provides useful context for this proposal, please include those links here. ---> \ No newline at end of file +--> From f37093485553fb59b3e52de624d81f3667504c2a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 20 Oct 2022 10:16:24 -0700 Subject: [PATCH 02/42] fix merge --- .../@react-spectrum/table/src/TableView.tsx | 81 +++++++++---------- 1 file changed, 38 insertions(+), 43 deletions(-) diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 49962f0411f..9c55cb45fcc 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -482,50 +482,45 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo {...mergeProps(otherProps, virtualizerProps)} ref={domRef}>
-
- {state.visibleViews[0]} -
- - {state.visibleViews[1]} -
- + role="presentation" + className={classNames(styles, 'spectrum-Table-headWrapper')} + style={{ + width: visibleRect.width, + height: headerHeight, + overflow: 'hidden', + position: 'relative', + willChange: state.isScrolling ? 'scroll-position' : '', + transition: state.isAnimating ? `none ${state.virtualizer.transitionDuration}ms` : undefined + }} + ref={headerRef}> + {state.visibleViews[0]}
+ + {state.visibleViews[1]} +
+
From 7fd45f9a07144b123637079836f1ddbe2df42759 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 20 Oct 2022 13:05:22 -0700 Subject: [PATCH 03/42] fix remove column math --- packages/@react-stately/layout/src/TableLayout.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 0465252bf0f..c0c38215c6f 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -50,16 +50,22 @@ export class TableLayout extends ListLayout { } getColumnWidth(key: Key): number { - return this.columnWidths.get(key); + return this.columnWidths.get(key) ?? 0; } getColumnMinWidth(key: Key): number { let column = this.collection.columns.find(col => col.key === key); + if (!column) { + return 0; + } return getMinWidth(column.props.minWidth, this.virtualizer.visibleRect.width); } getColumnMaxWidth(key: Key): number { let column = this.collection.columns.find(col => col.key === key); + if (!column) { + return 0; + } return getMaxWidth(column.props.maxWidth, this.virtualizer.visibleRect.width); } From fdb68a3a59e54cff58923f4faf2b98393bc9ce38 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 20 Oct 2022 13:17:11 -0700 Subject: [PATCH 04/42] remove unused dep --- packages/@react-stately/layout/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@react-stately/layout/package.json b/packages/@react-stately/layout/package.json index ccf929dccc8..fc9933c587d 100644 --- a/packages/@react-stately/layout/package.json +++ b/packages/@react-stately/layout/package.json @@ -18,7 +18,6 @@ }, "dependencies": { "@babel/runtime": "^7.6.2", - "@react-stately/table": "^3.5.0", "@react-stately/virtualizer": "^3.3.1", "@react-types/grid": "^3.1.4", "@react-types/shared": "^3.15.0", From 3fb46770a455541c8caf36e4855dec67231508e4 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 28 Nov 2022 13:24:29 +1100 Subject: [PATCH 05/42] Move aria implementation to stately hook instead of layout --- packages/@react-aria/table/package.json | 1 + .../table/src/useTableColumnResize.ts | 59 +- .../table/stories/example-resizing.tsx | 311 +++++++++ .../@react-aria/table/stories/resizing.css | 30 + .../table/stories/useTable.stories.tsx | 158 ++++- .../table/test/ariaTableResizing.test.tsx | 612 ++++++++++++++++++ .../@react-spectrum/table/src/Resizer.tsx | 31 +- .../@react-spectrum/table/src/TableView.tsx | 159 +++-- .../table/test/TableSizing.test.js | 16 +- .../@react-stately/layout/src/TableLayout.ts | 302 ++++++--- .../@react-stately/layout/src/TableUtils.ts | 38 +- .../layout/test/TableUtils.test.js | 162 +++++ packages/@react-stately/table/package.json | 3 + .../table/src/useTableColumnResizeState.ts | 141 +++- packages/@react-types/table/src/index.d.ts | 2 +- 15 files changed, 1763 insertions(+), 262 deletions(-) create mode 100644 packages/@react-aria/table/stories/example-resizing.tsx create mode 100644 packages/@react-aria/table/stories/resizing.css create mode 100644 packages/@react-aria/table/test/ariaTableResizing.test.tsx create mode 100644 packages/@react-stately/layout/test/TableUtils.test.js diff --git a/packages/@react-aria/table/package.json b/packages/@react-aria/table/package.json index 99bb20097bb..35d727c117e 100644 --- a/packages/@react-aria/table/package.json +++ b/packages/@react-aria/table/package.json @@ -30,6 +30,7 @@ "@react-aria/live-announcer": "^3.1.1", "@react-aria/selection": "^3.12.0", "@react-aria/utils": "^3.14.1", + "@react-stately/layout": "^3.9.0", "@react-stately/table": "^3.6.0", "@react-stately/virtualizer": "^3.4.0", "@react-types/checkbox": "^3.4.1", diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 7243f0d10c6..3a20cd1c383 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -21,6 +21,7 @@ import intlMessages from '../intl/*.json'; import {TableState} from '@react-stately/table'; import {useKeyboard, useMove, usePress} from '@react-aria/interactions'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; +import {TableLayout} from '@react-stately/layout'; export interface TableColumnResizeAria { inputProps: DOMAttributes, @@ -32,20 +33,22 @@ export interface AriaTableColumnResizeProps { label: string, triggerRef: RefObject, isDisabled?: boolean, - onMove: (e: MoveMoveEvent, width: number) => void, - onMoveEnd: (e: MoveEndEvent) => void + onMove: (e: MoveMoveEvent) => void, + onMoveEnd: (e: MoveEndEvent) => void, + onResizeStart: (key: Key) => void, + onResize: (widths: Map) => void, + onResizeEnd: (key: Key) => void } export interface TableLayoutState { - layout: { - getColumnWidth: (key: Key) => number, - getColumnMinWidth: (key: Key) => number, - getColumnMaxWidth: (key: Key) => number, - resizingColumn: Key | null - }, - onColumnResizeStart: (column: GridNode) => void, + getColumnWidth: (key: Key) => number, + getColumnMinWidth: (key: Key) => number, + getColumnMaxWidth: (key: Key) => number, + setResizingColumn: (key: Key | null) => void, + resizingColumn: Key, + onColumnResizeStart: (key: Key) => void, onColumnResize: (column: GridNode, width: number) => void, - onColumnResizeEnd: (column: GridNode) => void + onColumnResizeEnd: (key: Key) => void } export function useTableColumnResize(props: AriaTableColumnResizeProps, state: TableState, layoutState: TableLayoutState, ref: RefObject): TableColumnResizeAria { @@ -69,9 +72,9 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st const columnResizeWidthRef = useRef(0); const {moveProps} = useMove({ onMoveStart() { - columnResizeWidthRef.current = stateRef.current.layout.getColumnWidth(item.key); - layoutState.layout.resizingColumn = item.key; + columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key); stateRef.current.onColumnResizeStart(item); + props.onResizeStart?.(item.key); }, onMove(e) { let {deltaX, deltaY, pointerType} = e; @@ -87,26 +90,27 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st // if moving up/down only, no need to resize if (deltaX !== 0) { columnResizeWidthRef.current += deltaX; - stateRef.current.onColumnResize(item, columnResizeWidthRef.current); - props.onMove(e, columnResizeWidthRef.current); + let sizes = stateRef.current.onColumnResize(item, columnResizeWidthRef.current); + props.onMove?.(e, columnResizeWidthRef.current); + props.onResize?.(sizes); } }, onMoveEnd(e) { let {pointerType} = e; columnResizeWidthRef.current = 0; - props.onMoveEnd(e); + props.onMoveEnd?.(e); if (pointerType === 'mouse') { - layoutState.layout.resizingColumn = null; stateRef.current.onColumnResizeEnd(item); + props.onResizeEnd?.(item.key); } } }); - let min = Math.floor(stateRef.current.layout.getColumnMinWidth(item.key)); - let max = Math.floor(stateRef.current.layout.getColumnMaxWidth(item.key)); + let min = Math.floor(stateRef.current.getColumnMinWidth(item.key)); + let max = Math.floor(stateRef.current.getColumnMaxWidth(item.key)); if (max === Infinity) { max = Number.MAX_SAFE_INTEGER; } - let value = Math.floor(stateRef.current.layout.getColumnWidth(item.key)); + let value = Math.floor(stateRef.current.getColumnWidth(item.key)); let ariaProps = { 'aria-label': props.label, @@ -125,7 +129,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st }, [ref]); let onChange = (e: ChangeEvent) => { - let currentWidth = stateRef.current.layout.getColumnWidth(item.key); + let currentWidth = stateRef.current.getColumnWidth(item.key); let nextValue = parseFloat(e.target.value); if (nextValue > currentWidth) { @@ -134,7 +138,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st nextValue = currentWidth - 10; } stateRef.current.onColumnResize(item, nextValue); - props.onMove({pointerType: 'virtual'} as MoveMoveEvent, nextValue); + props.onMove({pointerType: 'virtual'} as MoveMoveEvent); props.onMoveEnd({pointerType: 'virtual'} as MoveEndEvent); }; @@ -143,8 +147,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') { return; } - if (e.pointerType === 'virtual' && layoutState.layout.resizingColumn != null) { - layoutState.layout.resizingColumn = item.key; + if (e.pointerType === 'virtual' && layoutState.resizingColumn != null) { + layoutState.resizingColumn = item.key; stateRef.current.onColumnResizeEnd(item); focusSafely(triggerRef.current); return; @@ -155,7 +159,9 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st if (e.pointerType === 'touch') { focusInput(); } else if (e.pointerType !== 'virtual') { - focusSafely(triggerRef.current); + if (triggerRef?.current) { + focusSafely(triggerRef.current); + } } } }); @@ -172,13 +178,14 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, 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 - layoutState.layout.resizingColumn = item.key; stateRef.current.onColumnResizeStart(item); + props.onResizeStart?.(item.key); state.setKeyboardNavigationDisabled(true); }, onBlur: () => { - layoutState.layout.resizingColumn = null; + layoutState.resizingColumn = null; stateRef.current.onColumnResizeEnd(item); + props.onResizeEnd?.(item.key); state.setKeyboardNavigationDisabled(false); }, onChange, diff --git a/packages/@react-aria/table/stories/example-resizing.tsx b/packages/@react-aria/table/stories/example-resizing.tsx new file mode 100644 index 00000000000..7ee99f82d3c --- /dev/null +++ b/packages/@react-aria/table/stories/example-resizing.tsx @@ -0,0 +1,311 @@ +/* + * Copyright 2021 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {mergeProps, useResizeObserver} from '@react-aria/utils'; +import React, {useCallback, useLayoutEffect, useState} from 'react'; +import {useCheckbox} from '@react-aria/checkbox'; +import {FocusRing, useFocusRing} from '@react-aria/focus'; +import {useRef} from 'react'; +import { + AriaTableColumnResizeProps, + useTable, + useTableCell, + useTableColumnHeader, + useTableColumnResize, + useTableHeaderRow, + useTableRow, + useTableRowGroup, + useTableSelectAllCheckbox, + useTableSelectionCheckbox +} from '@react-aria/table'; +import {useTableColumnResizeState, useTableState} from '@react-stately/table'; +import {useToggleState} from '@react-stately/toggle'; +import {VisuallyHidden} from '@react-aria/visually-hidden'; +import {classNames} from '@react-spectrum/utils'; +import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; +import ariaStyles from './resizing.css'; + +export function Table(props) { + let [showSelectionCheckboxes, setShowSelectionCheckboxes] = useState(props.selectionStyle !== 'highlight'); + let state = useTableState({ + ...props, + showSelectionCheckboxes, + selectionBehavior: props.selectionStyle === 'highlight' ? 'replace' : 'toggle' + }); + + let [tableWidth, setTableWidth] = useState(0); + let getDefaultWidth = useCallback(() => 150, []); + let getDefaultMinWidth = useCallback(() => 50, []); + let layoutState = useTableColumnResizeState({ + getDefaultWidth, + getDefaultMinWidth, + tableWidth + }, state); + let {widths} = layoutState; + // If the selection behavior changes in state, we need to update showSelectionCheckboxes here due to the circular dependency... + let shouldShowCheckboxes = state.selectionManager.selectionBehavior !== 'replace'; + if (shouldShowCheckboxes !== showSelectionCheckboxes) { + setShowSelectionCheckboxes(shouldShowCheckboxes); + } + let ref = useRef(null); + let bodyRef = useRef(null); + let {collection} = state; + let {gridProps} = useTable( + { + ...props, + onRowAction: props.onAction, + scrollRef: bodyRef, + 'aria-label': 'example table' + }, + state, + ref + ); + + useLayoutEffect(() => { + if (bodyRef && bodyRef.current) { + setTableWidth(bodyRef.current.clientWidth); + } + }, []); + useResizeObserver({ref, onResize: () => setTableWidth(bodyRef.current.clientWidth)}); + + return ( + + + {collection.headerRows.map(headerRow => ( + + {[...headerRow.childNodes].map(column => + column.props.isSelectionCell + ? + : + )} + + ))} + + + {[...collection.body.childNodes].map(row => ( + + {[...row.childNodes].map(cell => + cell.props.isSelectionCell + ? + : + )} + + ))} + +
+ ); +} + +export const TableRowGroup = React.forwardRef((props: any, ref) => { + let {type: Element, style, children, className} = props; + let {rowGroupProps} = useTableRowGroup(); + return ( + + {children} + + ); +}); + +export function TableHeaderRow({item, state, children, className}) { + let ref = useRef(); + let {rowProps} = useTableHeaderRow({node: item}, state, ref); + + return ( + + {children} + + ); +} +function Resizer({column, state, layoutState, onResize}) { + let ref = useRef(null); + let {resizerProps, inputProps} = useTableColumnResize({ + column, + label: 'Resizer', + onResize: onResize + } as AriaTableColumnResizeProps, state, layoutState, ref); + + return ( + <> + +
+ + + +
+
+ + ); +} +export function TableColumnHeader({column, state, widths, layoutState, onResize}) { + let ref = useRef(); + let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); + let {isFocusVisible, focusProps} = useFocusRing(); + let arrowIcon = state.sortDescriptor?.direction === 'ascending' ? 'â–²' : 'â–¼'; + + return ( + 1 ? 'center' : 'left', + padding: '5px 10px', + outline: isFocusVisible ? '2px solid orange' : 'none', + cursor: 'default', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + display: 'block', + flex: '0 0 auto' + }} + ref={ref}> +
+
+ {column.rendered} + {column.props.allowsSorting && + + } +
+ { + column.props.allowsResizing && + + } +
+ + ); +} + +export function TableRow({item, children, state, className}) { + let ref = useRef(); + let isSelected = state.selectionManager.isSelected(item.key); + let {rowProps} = useTableRow({node: item}, state, ref); + let {isFocusVisible, focusProps} = useFocusRing(); + + return ( + + {children} + + ); +} + +export function TableCell({cell, state, widths}) { + let ref = useRef(); + let {gridCellProps} = useTableCell({node: cell}, state, ref); + let {isFocusVisible, focusProps} = useFocusRing(); + let column = cell.column; + let isSelected = state.selectionManager.isSelected(cell.parentKey); + + return ( + + {cell.rendered} + + ); +} + +export function TableCheckboxCell({cell, state, widths}) { + let ref = useRef(); + let {gridCellProps} = useTableCell({node: cell}, state, ref); + let {checkboxProps} = useTableSelectionCheckbox({key: cell.parentKey}, state); + + let inputRef = useRef(null); + let {inputProps} = useCheckbox(checkboxProps, useToggleState(checkboxProps), inputRef); + let column = cell.column; + + return ( + + + + ); +} + +export function TableSelectAllCell({column, state, widths}) { + let ref = useRef(); + let isSingleSelectionMode = state.selectionManager.selectionMode === 'single'; + let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); + + let {checkboxProps} = useTableSelectAllCheckbox(state); + let inputRef = useRef(null); + let {inputProps} = useCheckbox(checkboxProps, useToggleState(checkboxProps), inputRef); + + return ( + + { + /* + In single selection mode, the checkbox will be hidden. + So to avoid leaving a column header with no accessible content, + use a VisuallyHidden component to include the aria-label from the checkbox, + which for single selection will be "Select." + */ + isSingleSelectionMode && + {inputProps['aria-label']} + } + + + ); +} diff --git a/packages/@react-aria/table/stories/resizing.css b/packages/@react-aria/table/stories/resizing.css new file mode 100644 index 00000000000..3fc4e15b7ee --- /dev/null +++ b/packages/@react-aria/table/stories/resizing.css @@ -0,0 +1,30 @@ + +.aria-table, +.aria-table * { + box-sizing: border-box; +} + +.aria-table { + width: 800px; + height: 300px; + display: block; + position: relative; + overflow: auto; + + .aria-table-rowGroup { + display: block; + } + .aria-table-rowGroupHeader { + position: sticky; + top: 0; + background: var(--spectrum-gray-100); + } + .aria-table-row { + display: flex; + } + .aria-table-headerRow { + [role="columnheader"] { + border-bottom: 2px solid gray; + } + } +} diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index 94869c90112..2273ba425bf 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -14,7 +14,8 @@ import {action} from '@storybook/addon-actions'; import {Table as BackwardCompatTable} from './example-backwards-compat'; import {Cell, Column, Row, TableBody, TableHeader} from '@react-stately/table'; import {Meta, Story} from '@storybook/react'; -import React from 'react'; +import React, {Key, useCallback, useMemo, useState} from 'react'; +import {Table as ResizingTable} from './example-resizing'; import {SpectrumTableProps} from '@react-types/table'; import {Table} from './example'; @@ -30,19 +31,19 @@ let columns = [ {name: 'Level', uid: 'level'} ]; -let rows = [ - {id: 1, name: 'Charizard', type: 'Fire, Flying', level: '67'}, - {id: 2, name: 'Blastoise', type: 'Water', level: '56'}, - {id: 3, name: 'Venusaur', type: 'Grass, Poison', level: '83'}, - {id: 4, name: 'Pikachu', type: 'Electric', level: '100'}, - {id: 5, name: 'Charizard', type: 'Fire, Flying', level: '67'}, - {id: 6, name: 'Blastoise', type: 'Water', level: '56'}, - {id: 7, name: 'Venusaur', type: 'Grass, Poison', level: '83'}, - {id: 8, name: 'Pikachu', type: 'Electric', level: '100'}, - {id: 9, name: 'Charizard', type: 'Fire, Flying', level: '67'}, - {id: 10, name: 'Blastoise', type: 'Water', level: '56'}, - {id: 11, name: 'Venusaur', type: 'Grass, Poison', level: '83'}, - {id: 12, name: 'Pikachu', type: 'Electric', level: '100'} +let defaultRows = [ + {id: 1, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, + {id: 2, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, + {id: 3, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, + {id: 4, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'}, + {id: 5, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, + {id: 6, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, + {id: 7, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, + {id: 8, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'}, + {id: 9, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, + {id: 10, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, + {id: 11, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, + {id: 12, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'} ]; const Template: Story> = (args) => ( @@ -57,7 +58,7 @@ const Template: Story> = (args) => ( )} - + {item => ( {columnKey => {item[columnKey]}} @@ -82,7 +83,7 @@ const TemplateBackwardsCompat: Story> = (args) => ( )} - + {item => ( {columnKey => {item[columnKey]}} @@ -105,3 +106,128 @@ ActionTesting.args = {selectionBehavior: 'replace', selectionStyle: 'highlight', export const BackwardCompatActionTesting = TemplateBackwardsCompat.bind({}); BackwardCompatActionTesting.args = {selectionBehavior: 'replace', selectionStyle: 'highlight', onAction: action('onAction')}; + +export const TableWithResizingNoProps = { + args: {}, + render: (args) => ( + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + + + ) +}; + +interface ColumnData { + name: string, + uid: string, + defaultWidth?: number | string, + width?: number | string +} +let columnsDefaultFR: ColumnData[] = [ + {name: 'Name', uid: 'name', defaultWidth: '1fr'}, + {name: 'Type', uid: 'type', defaultWidth: '1fr'}, + {name: 'Level', uid: 'level', defaultWidth: '4fr'} +]; + +export const TableWithResizingFRs = { + args: {}, + render: () => ( + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + + + ) +}; + +function ControlledTableResizing(props: {columns: Array<{name: string, uid: string, width: string}>, rows, onResize}) { + let {columns, rows = defaultRows, onResize, ...otherProps} = props; + let [widths, _setWidths] = useState>(() => new Map(columns.filter(col => col.width).map((col) => [col.uid as Key, col.width]))); + + let setWidths = useCallback((vals: Map) => { + let controlledKeys = new Set(columns.filter(col => col.width).map((col) => col.uid as Key)); + let newVals = new Map(Array.from(vals).filter(([key]) => controlledKeys.has(key))); + _setWidths(newVals); + onResize?.(vals); + }, [columns]); + let [savedCols, setSavedCols] = useState(widths); + let [renderKey, setRenderKey] = useState(Math.random()); + let cols = useMemo(() => columns.map(col => ({...col})), [columns, widths]); + + return ( +
+ + +
Current saved column state: {'{'}{Array.from(savedCols).map(([key, entry]) => `${key} => ${entry}`).join(',')}{'}'}
+
+ + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + + +
+
+ ); +} + +let columnsFR: ColumnData[] = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Level', uid: 'level', width: '4fr'} +]; + +export const TableWithResizingFRsControlled = { + args: {columns: columnsFR}, + render: (args) => +}; + +let columnsSomeFR: ColumnData[] = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} +]; + +export const TableWithSomeResizingFRsControlled = { + args: {columns: columnsSomeFR}, + render: (args) => +}; diff --git a/packages/@react-aria/table/test/ariaTableResizing.test.tsx b/packages/@react-aria/table/test/ariaTableResizing.test.tsx new file mode 100644 index 00000000000..8738e1f88db --- /dev/null +++ b/packages/@react-aria/table/test/ariaTableResizing.test.tsx @@ -0,0 +1,612 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, fireEvent, installPointerEvent} from '@react-spectrum/test-utils'; +import {Cell, Column, Row, TableBody, TableHeader} from '@react-stately/table'; +import {composeStories} from '@storybook/testing-react'; +import React, {Key} from 'react'; +import {render} from '@testing-library/react'; +import {Table as ResizingTable} from '../stories/example-resizing'; +import * as stories from '../stories/useTable.stories'; +import {within} from '@testing-library/dom'; + +let {TableWithSomeResizingFRsControlled} = composeStories(stories); + +let rows = [ + {id: 1, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, + {id: 2, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, + {id: 3, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, + {id: 4, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'}, + {id: 5, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, + {id: 6, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, + {id: 7, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, + {id: 8, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'}, + {id: 9, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, + {id: 10, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, + {id: 11, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, + {id: 12, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'} +]; + +function getColumnWidths(tree) { + let rows = tree.getAllByRole('row') as HTMLElement[]; + return Array.from(rows[0].childNodes).map((cell: HTMLElement) => Number(cell.style.width.replace('px', ''))); +} + +// I'd use tree.getByRole(role, {name: text}) here, but it's unbearably slow. +function getColumn(tree, name) { + // Find by text, then go up to the element with the cell role. + let el = tree.getByText(name); + while (el && !/columnheader/.test(el.getAttribute('role'))) { + el = el.parentElement; + } + + return el; +} + +function resizeCol(tree, col, delta) { + let column = getColumn(tree, col); + let resizer = within(column).getByRole('slider'); + + fireEvent.pointerEnter(resizer); + + // actual locations do not matter, the delta matters between events for the calculation of useMove + fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 0, pageY: 30}); + fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: delta, pageY: 25}); + fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); +} + +// assumption with all these tests, the controlling values we pass in aren't actually controlling +// the sizes, they are instead more like the default values that the controlling logic uses +describe('Aria Table Resizing', () => { + installPointerEvent(); + let offsetWidth, offsetHeight; + let onResize; + + beforeEach(function () { + offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 900); + offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + jest.useFakeTimers(); + onResize = jest.fn(); + }); + + afterEach(function () { + act(() => {jest.runAllTimers();}); + offsetWidth.mockReset(); + offsetHeight.mockReset(); + onResize = null; + }); + + describe.each` + allowsResizing + ${undefined} + ${true} + `('initial column sizes allowsResizing=$allowsResizing', ({allowsResizing}) => { + it('should handle no value if table was written with default widths', () => { + let columns = [ + {name: 'Name', id: 'name', allowsResizing}, + {name: 'Type', id: 'type', allowsResizing}, + {name: 'Level', id: 'level', allowsResizing} + ]; + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 150]); + }); + it('should handle default pixel widths', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: 100, allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: 100, allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: 400, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 400]); + }); + it('should handle default percent widths', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: '16%', allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: '33%', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 144, 297]); + }); + it('should handle default fr widths', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: '4fr', allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: '3fr', allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: '2fr', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([400, 300, 200]); + }); + it('should handle a mix of default widths', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: '2fr', allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 200, 100, 150]); + }); + it('any single remaining column with an FR will take the remaining space, regardless of how many FRs it is "worth"', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 200, 100, 150]); + }); + it('cannot size less than the minWidth', () => { + let columns = [ + {name: 'Name', id: 'name', minWidth: 500, defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', minWidth: 100, defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', minWidth: 150, defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', minWidth: 200, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([500, 100, 150, 200]); + }); + it('cannot size more than the maxWidth', () => { + let columns = [ + {name: 'Name', id: 'name', maxWidth: 400, defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', maxWidth: 50, defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', maxWidth: 50, defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([400, 50, 50, 100]); + }); + it('minWidth can be a percent', () => { + let columns = [ + {name: 'Name', id: 'name', minWidth: '50%', defaultWidth: '30%', allowsResizing}, + {name: 'Type', id: 'type', maxWidth: 50, defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', maxWidth: 50, defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 50, 50, 100]); + }); + it('maxWidth can be a percent', () => { + let columns = [ + {name: 'Name', id: 'name', maxWidth: '50%', defaultWidth: '70%', allowsResizing}, + {name: 'Type', id: 'type', maxWidth: 50, defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', maxWidth: 50, defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 50, 50, 100]); + }); + }); + + describe('resizing', () => { + function mapFromWidths(columnNames, widths) { + return new Map(widths.map((width, i) => [columnNames[i].toLowerCase(), width])); + } + it.each` + col | delta | expected | expectedOnResize + ${'Name'} | ${-50} | ${[50, 110, 150, 150, 440]} | ${[50, '1fr', 150, 150, '4fr']} + ${'Name'} | ${50} | ${[150, 90, 150, 150, 360]} | ${[150, '1fr', 150, 150, '4fr']} + ${'Type'} | ${-50} | ${[100, 50, 150, 150, 450]} | ${[100, 50, 150, 150, '4fr']} + ${'Type'} | ${50} | ${[100, 150, 150, 150, 350]} | ${[100, 150, 150, 150, '4fr']} + ${'Height'} | ${-50} | ${[100, 100, 100, 150, 450]} | ${[100, 100, 100, 150, '4fr']} + ${'Height'} | ${50} | ${[100, 100, 200, 150, 350]} | ${[100, 100, 200, 150, '4fr']} + ${'Weight'} | ${-50} | ${[100, 100, 150, 100, 450]} | ${[100, 100, 150, 100, '4fr']} + ${'Weight'} | ${50} | ${[100, 100, 150, 200, 350]} | ${[100, 100, 150, 200, '4fr']} + ${'Level'} | ${-50} | ${[100, 100, 150, 150, 350]} | ${[100, 100, 150, 150, 350]} + ${'Level'} | ${50} | ${[100, 100, 150, 150, 450]} | ${[100, 100, 150, 150, 450]} + `('can resize $col to be $delta px different', + function ({col, delta, expected, expectedOnResize}) { + let columnNames = ['Name', 'Type', 'Height', 'Weight', 'Level']; + let tree = render(); + resizeCol(tree, col, delta); + expect(getColumnWidths(tree)).toStrictEqual(expected); + expect(onResize).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, expectedOnResize)); + }); + + it('cannot resize to be less than a minWidth, from start to end', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; + let columnNames = ['Name', 'Type', 'Height', 'Weight', 'Level']; + + let tree = render(); + resizeCol(tree, 'Name', -50); // first column + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + expect(onResize).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, '1fr', 150, 150, '4fr'])); + resizeCol(tree, 'Type', -50); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + expect(onResize).toHaveBeenCalledTimes(2); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 150, 150, '4fr'])); + resizeCol(tree, 'Height', -100); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 150, 450]); + expect(onResize).toHaveBeenCalledTimes(3); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 150, '4fr'])); + resizeCol(tree, 'Weight', -100); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 500]); + expect(onResize).toHaveBeenCalledTimes(4); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 100, '4fr'])); + resizeCol(tree, 'Level', -500); // last column + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); + expect(onResize).toHaveBeenCalledTimes(5); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 100, 100])); + }); + + it('cannot resize to be less than a minWidth, from end to start', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; + + let tree = render(); + resizeCol(tree, 'Level', -500); // last column + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 100]); + resizeCol(tree, 'Weight', -100); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 100, 100]); + resizeCol(tree, 'Height', -100); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); + resizeCol(tree, 'Type', -100); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); + resizeCol(tree, 'Name', -500); // first column + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); + }); + + it('cannot resize to be more than a maxWidth, from start to end', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', maxWidth: 150}, + {name: 'Type', uid: 'type', width: '1fr', maxWidth: 150}, + {name: 'Height', uid: 'height', maxWidth: 200}, + {name: 'Weight', uid: 'weight', maxWidth: 200}, + {name: 'Level', uid: 'level', width: '4fr', maxWidth: 500} + ]; + + let tree = render(); + resizeCol(tree, 'Name', 150); + expect(getColumnWidths(tree)).toStrictEqual([150, 90, 150, 150, 360]); + resizeCol(tree, 'Type', 150); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 150, 150, 300]); + resizeCol(tree, 'Height', 100); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 150, 250]); + resizeCol(tree, 'Weight', 100); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 200]); + resizeCol(tree, 'Level', 400); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 500]); + }); + + it('cannot resize to be more than a maxWidth, from end to start', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', maxWidth: 150}, + {name: 'Type', uid: 'type', width: '1fr', maxWidth: 150}, + {name: 'Height', uid: 'height', maxWidth: 200}, + {name: 'Weight', uid: 'weight', maxWidth: 200}, + {name: 'Level', uid: 'level', width: '4fr', maxWidth: 500} + ]; + + let tree = render(); + resizeCol(tree, 'Level', 150); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 500]); + resizeCol(tree, 'Weight', 150); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 200, 500]); + resizeCol(tree, 'Height', 100); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 200, 200, 500]); + resizeCol(tree, 'Type', 100); + expect(getColumnWidths(tree)).toStrictEqual([100, 150, 200, 200, 500]); + resizeCol(tree, 'Name', 400); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 500]); + }); + + it('resizing the starter column will preserve fr column ratios to the right', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Name', -50); + expect(getColumnWidths(tree)).toStrictEqual([50, 110, 150, 150, 440]); + resizeCol(tree, 'Name', 50); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + }); + + it('resizing the last column will lock columns to pixels to the left', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Level', -50); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 350]); + resizeCol(tree, 'Level', 50); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + }); + + it('can handle removing a column', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + tree.rerender(); + expect(getColumnWidths(tree)).toStrictEqual([125, 125, 150, 500]); + }); + + it('can handle adding a column', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([125, 125, 150, 500]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + tree.rerender(); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + }); + + it('can handle resizing, then removing an uncontrolled column, then adding the column again', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Name', -50); + expect(getColumnWidths(tree)).toStrictEqual([50, 110, 150, 150, 440]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + tree.rerender(); + expect(getColumnWidths(tree)).toStrictEqual([50, 140, 150, 560]); + tree.rerender(); + expect(getColumnWidths(tree)).toStrictEqual([50, 110, 150, 150, 440]); + }); + + it('can handle resizing, then removing an controlled column, then adding the column again', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Name', -50); + expect(getColumnWidths(tree)).toStrictEqual([50, 110, 150, 150, 440]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'} + ]; + tree.rerender(); + expect(getColumnWidths(tree)).toStrictEqual([50, 550, 150, 150]); + tree.rerender(); + expect(getColumnWidths(tree)).toStrictEqual([50, 110, 150, 150, 440]); + }); + + it('can add new columns after resizing', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'} + ]; + + let tree = render(); + resizeCol(tree, 'Name', -50); + expect(getColumnWidths(tree)).toStrictEqual([325, 425, 150]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + tree.rerender(); + expect(getColumnWidths(tree)).toStrictEqual([325, 125, 150, 150, 150]); + }); + + it('can remove and re-add the resized column', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Type', -50); + expect(getColumnWidths(tree)).toStrictEqual([100, 50, 150, 150, 450]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + tree.rerender(); + expect(getColumnWidths(tree)).toStrictEqual([100, 150, 150, 500]); + tree.rerender(); + expect(getColumnWidths(tree)).toStrictEqual([100, 50, 150, 150, 450]); + }); + + it('can resize smaller if the minWidth gets smaller', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + resizeCol(tree, 'Type', -100); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 50}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; + tree.rerender(); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + resizeCol(tree, 'Type', -100); + expect(getColumnWidths(tree)).toStrictEqual([100, 50, 150, 150, 450]); + }); + }); + + describe('resizing table', () => { + it('will not affect pixel widths', () => { + let columns = [ + {name: 'Name', uid: 'name', width: 100}, + {name: 'Type', uid: 'type', width: 100}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: 400} + ]; + + let tree = render(); + offsetWidth.mockImplementation(() => 1000); + fireEvent(window, new Event('resize')); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + }); + + it('will resize all percent columns', () => { + let columns = [ + {name: 'Name', uid: 'name', width: '20%'}, + {name: 'Type', uid: 'type', width: '20%'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '40%'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([180, 180, 150, 150, 360]); + offsetWidth.mockImplementation(() => 1000); + fireEvent(window, new Event('resize')); + expect(getColumnWidths(tree)).toStrictEqual([200, 200, 150, 150, 400]); + }); + + it('will resize all fr columns', () => { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + offsetWidth.mockImplementation(() => 1000); + fireEvent(window, new Event('resize')); + expect(getColumnWidths(tree)).toStrictEqual([117, 117, 150, 150, 466]); + }); + + it('will resize all fr columns only after percent columns', () => { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '20%'}, + {name: 'Height', uid: 'height', width: '20%'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([78, 180, 180, 150, 312]); + offsetWidth.mockImplementation(() => 1000); + fireEvent(window, new Event('resize')); + expect(getColumnWidths(tree)).toStrictEqual([90, 200, 200, 150, 360]); + }); + + it('will resize all fr columns only after percent columns', () => { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '20%'}, + {name: 'Height', uid: 'height', width: '20%'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([78, 180, 180, 150, 312]); + offsetWidth.mockImplementation(() => 1000); + fireEvent(window, new Event('resize')); + expect(getColumnWidths(tree)).toStrictEqual([90, 200, 200, 150, 360]); + }); + }); +}); + +function Table(props: {columns: {id: Key, name: string}[], rows}) { + let {columns, rows, ...args} = props; + return ( + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + + + ); +} diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index 4c80dd8a2ff..f8eddc9410a 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -5,60 +5,55 @@ import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; import {MoveMoveEvent} from '@react-types/shared'; -import React, {RefObject, useRef} from 'react'; +import React, {Key, RefObject, useRef} from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; -import {TableLayout} from '@react-stately/layout'; import {TableLayoutState, useTableColumnResize} from '@react-aria/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; -import {useTableContext, useTableVirtualizerContext} from './TableView'; +import {useTableContext} from './TableView'; import {VisuallyHidden} from '@react-aria/visually-hidden'; interface ResizerProps { - layout: TableLayout, + layout: TableLayoutState, column: GridNode, showResizer: boolean, triggerRef: RefObject, + onResizeStart: () => void, + onResize: (widths: Map) => void, + onResizeEnd: () => void, onMoveResizer: (e: MoveMoveEvent) => void } function Resizer(props: ResizerProps, ref: RefObject) { let {column, showResizer, layout} = props; - let {state, columnState, isEmpty} = useTableContext(); - let {state: virtualizerState} = useTableVirtualizerContext(); + let {state, isEmpty} = useTableContext(); let stringFormatter = useLocalizedStringFormatter(intlMessages); let {direction} = useLocale(); const stateRef = useRef>(null); - stateRef.current = { - ...columnState, - layout - }; + stateRef.current = layout; - let {inputProps, resizerProps} = useTableColumnResize({ + let {inputProps, resizerProps} = useTableColumnResize({ ...props, label: stringFormatter.format('columnResizer'), isDisabled: isEmpty, - onMove: (e, width) => { + onMove: (e) => { document.body.classList.remove(classNames(styles, 'resize-ew')); document.body.classList.remove(classNames(styles, 'resize-e')); document.body.classList.remove(classNames(styles, 'resize-w')); - if (stateRef.current.layout.getColumnMinWidth(column.key) >= stateRef.current.layout.getColumnWidth(column.key)) { + if (stateRef.current.getColumnMinWidth(column.key) >= stateRef.current.getColumnWidth(column.key)) { document.body.classList.add(direction === 'rtl' ? classNames(styles, 'resize-w') : classNames(styles, 'resize-e')); - } else if (stateRef.current.layout.getColumnMaxWidth(column.key) <= stateRef.current.layout.getColumnWidth(column.key)) { + } else if (stateRef.current.getColumnMaxWidth(column.key) <= stateRef.current.getColumnWidth(column.key)) { document.body.classList.add(direction === 'rtl' ? classNames(styles, 'resize-e') : classNames(styles, 'resize-w')); } else { document.body.classList.add(classNames(styles, 'resize-ew')); } props.onMoveResizer(e); - // setting the resize column width in a state object leads to it being a render cycle behind - layout.setResizeColumnWidth(width); - virtualizerState.virtualizer.relayoutNow({sizeChanged: true}); }, onMoveEnd: () => { document.body.classList.remove(classNames(styles, 'resize-ew')); document.body.classList.remove(classNames(styles, 'resize-e')); document.body.classList.remove(classNames(styles, 'resize-w')); } - }, state, stateRef.current, ref); + }, state, layout, ref); let style = { cursor: undefined, diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index d2646d60f23..45ec1f0ea8f 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -32,7 +32,7 @@ import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; import {layoutInfoToStyle, ScrollView, setScrollLeft, useVirtualizer, VirtualizerItem} from '@react-aria/virtualizer'; import {Nubbin} from './Nubbin'; import {ProgressCircle} from '@react-spectrum/progress'; -import React, {ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {Key, ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {Rect, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer'; import {Resizer} from './Resizer'; import {SpectrumColumnProps, SpectrumTableProps} from '@react-types/table'; @@ -45,6 +45,7 @@ import {useButton} from '@react-aria/button'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useProvider, useProviderProps} from '@react-spectrum/provider'; import { + TableLayoutState, useTable, useTableCell, useTableColumnHeader, @@ -55,6 +56,7 @@ import { useTableSelectionCheckbox } from '@react-aria/table'; import {VisuallyHidden} from '@react-aria/visually-hidden'; +import {TableColumnLayout} from '@react-stately/layout/src/TableLayout'; const DEFAULT_HEADER_HEIGHT = { medium: 34, @@ -88,31 +90,24 @@ const SELECTION_CELL_DEFAULT_WIDTH = { interface TableContextValue { state: TableState, + columnState: TableLayoutState layout: TableLayout, - columnState: TableColumnResizeState, headerRowHovered: boolean, isInResizeMode: boolean, setIsInResizeMode: (val: boolean) => void, isEmpty: boolean, onFocusedResizer: () => void, + onColumnResizeStart: () => void, + onColumnResize: (widths: Map) => void, + onColumnResizeEnd: () => void, onMoveResizer: (e: MoveMoveEvent) => void } -interface TableVirtualizerContextValue { - state: VirtualizerState -} - - const TableContext = React.createContext>(null); export function useTableContext() { return useContext(TableContext); } -const TableVirtualizerContext = React.createContext(null); -export function useTableVirtualizerContext() { - return useContext(TableVirtualizerContext); -} - function TableView(props: SpectrumTableProps, ref: DOMRef) { props = useProviderProps(props); let {isQuiet, onAction} = props; @@ -147,10 +142,6 @@ function TableView(props: SpectrumTableProps, ref: DOMRef { - setIsInResizeMode(false); - }})}); - // If the selection behavior changes in state, we need to update showSelectionCheckboxes here due to the circular dependency... let shouldShowCheckboxes = state.selectionManager.selectionBehavior !== 'replace'; if (shouldShowCheckboxes !== showSelectionCheckboxes) { @@ -163,6 +154,13 @@ function TableView(props: SpectrumTableProps, ref: DOMRef new TableColumnLayout({ + getDefaultWidth, + getDefaultMinWidth + }), + [getDefaultWidth, getDefaultMinWidth] + ); let layout = useMemo(() => new TableLayout({ // If props.rowHeight is auto, then use estimated heights based on scale, otherwise use fixed heights. rowHeight: props.overflowMode === 'wrap' @@ -177,12 +175,11 @@ function TableView(props: SpectrumTableProps, ref: DOMRef(props: SpectrumTableProps, ref: DOMRef, unknown>; let renderWrapper = (parent: View, reusableView: View, children: View[], renderChildren: (views: View[]) => ReactElement[]) => { let style = layoutInfoToStyle(reusableView.layoutInfo, direction, parent && parent.layoutInfo); @@ -360,7 +357,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef + - + +
-
- {state.visibleViews[0]} -
- - {state.visibleViews[1]} -
- + role="presentation" + className={classNames(styles, 'spectrum-Table-headWrapper')} + style={{ + width: visibleRect.width, + height: headerHeight, + overflow: 'hidden', + position: 'relative', + willChange: state.isScrolling ? 'scroll-position' : '', + transition: state.isAnimating ? `none ${state.virtualizer.transitionDuration}ms` : undefined + }} + ref={headerRef}> + {state.visibleViews[0]}
- - + + {state.visibleViews[1]} +
+ +
+ ); } @@ -620,13 +615,12 @@ function ResizableTableColumnHeader(props) { let ref = useRef(null); let triggerRef = useRef(null); let resizingRef = useRef(null); - let {state, columnState, layout, headerRowHovered, setIsInResizeMode, isInResizeMode, isEmpty, onFocusedResizer, onMoveResizer} = useTableContext(); + let {state, columnState, layout, onColumnResizeStart, onColumnResize, onColumnResizeEnd, headerRowHovered, setIsInResizeMode, isInResizeMode, isEmpty, onFocusedResizer, onMoveResizer} = useTableContext(); let stringFormatter = useLocalizedStringFormatter(intlMessages); let {pressProps, isPressed} = usePress({isDisabled: isEmpty}); let {columnHeaderProps} = useTableColumnHeader({ node: column, - isVirtualized: true, - hasMenu: true + isVirtualized: true }, state, ref); let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty}); @@ -649,8 +643,7 @@ function ResizableTableColumnHeader(props) { state.sort(column.key, 'descending'); break; case 'resize': - layout.resizingColumn = column.key; - columnState.onColumnResizeStart(column); + layout.onColumnResizeStart(column); setIsInResizeMode(true); break; } @@ -677,7 +670,7 @@ function ResizableTableColumnHeader(props) { let isMobile = useIsMobileDevice(); useEffect(() => { - if (layout.resizingColumn === column.key) { + if (layout.columnLayout.resizingColumn === column.key) { // focusSafely won't actually focus because the focus moves from the menuitem to the body during the after transition wait // without the immediate timeout, Android Chrome doesn't move focus to the resizer if (isMobile) { @@ -693,9 +686,10 @@ function ResizableTableColumnHeader(props) { }, 0); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layout.resizingColumn, column.key, isMobile]); + }, [layout.columnLayout.resizingColumn, column.key]); - let showResizer = !isEmpty && ((headerRowHovered && getInteractionModality() !== 'keyboard') || layout.resizingColumn != null); + let resizingColumn = layout.columnLayout.resizingColumn; + let showResizer = !isEmpty && ((headerRowHovered && getInteractionModality() !== 'keyboard') || resizingColumn != null); return ( @@ -736,7 +730,7 @@ function ResizableTableColumnHeader(props) {
{column.rendered}
} { - columnProps.allowsResizing && layout.resizingColumn === null && + columnProps.allowsResizing && resizingColumn == null && } @@ -752,6 +746,9 @@ function ResizableTableColumnHeader(props) { column={column} layout={layout} showResizer={showResizer} + onResizeStart={onColumnResizeStart} + onResize={onColumnResize} + onResizeEnd={onColumnResizeEnd} triggerRef={useUnwrapDOMRef(triggerRef)} onMoveResizer={onMoveResizer} />
diff --git a/packages/@react-spectrum/table/test/TableSizing.test.js b/packages/@react-spectrum/table/test/TableSizing.test.js index a612a36d296..e2fe182b85a 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.js +++ b/packages/@react-spectrum/table/test/TableSizing.test.js @@ -374,9 +374,9 @@ describe('TableViewSizing', function () { for (let row of rows) { expect(row.childNodes[0].style.width).toBe('38px'); - expect(row.childNodes[1].style.width).toBe('320px'); + expect(row.childNodes[1].style.width).toBe('321px'); expect(row.childNodes[2].style.width).toBe('321px'); - expect(row.childNodes[3].style.width).toBe('321px'); + expect(row.childNodes[3].style.width).toBe('320px'); } }); @@ -401,8 +401,8 @@ describe('TableViewSizing', function () { for (let row of rows) { expect(row.childNodes[0].style.width).toBe('48px'); expect(row.childNodes[1].style.width).toBe('317px'); - expect(row.childNodes[2].style.width).toBe('317px'); - expect(row.childNodes[3].style.width).toBe('318px'); + expect(row.childNodes[2].style.width).toBe('318px'); + expect(row.childNodes[3].style.width).toBe('317px'); } }); @@ -623,17 +623,17 @@ describe('TableViewSizing', function () { expect(rows[0].childNodes[1].style.width).toBe('770px'); expect(rows[1].childNodes[0].style.width).toBe('230px'); - expect(rows[1].childNodes[1].style.width).toBe('384px'); + expect(rows[1].childNodes[1].style.width).toBe('385px'); expect(rows[1].childNodes[2].style.width).toBe('193px'); - expect(rows[1].childNodes[3].style.width).toBe('193px'); + expect(rows[1].childNodes[3].style.width).toBe('192px'); for (let row of rows.slice(2)) { expect(row.childNodes[0].style.width).toBe('38px'); expect(row.childNodes[1].style.width).toBe('192px'); - expect(row.childNodes[2].style.width).toBe('192px'); + expect(row.childNodes[2].style.width).toBe('193px'); expect(row.childNodes[3].style.width).toBe('192px'); expect(row.childNodes[4].style.width).toBe('193px'); - expect(row.childNodes[5].style.width).toBe('193px'); + expect(row.childNodes[5].style.width).toBe('192px'); } }); }); diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 6bd96ff3a2a..6e287759efd 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -10,7 +10,14 @@ * governing permissions and limitations under the License. */ -import {calculateColumnSizes, getMaxWidth, getMinWidth} from './TableUtils'; +import { + calculateColumnSizes, + getMaxWidth, + getMinWidth, + isStatic, + parseFractionalUnit, + parseStaticWidth +} from './TableUtils'; import {GridNode} from '@react-types/grid'; import {Key} from 'react'; import {LayoutInfo, Point, Rect, Size} from '@react-stately/virtualizer'; @@ -18,39 +25,177 @@ import {LayoutNode, ListLayout, ListLayoutOptions} from './ListLayout'; import {TableCollection} from '@react-types/table'; type TableLayoutOptions = ListLayoutOptions & { + columnLayout: TableColumnLayout +} + +interface TableColumnLayoutOptions { getDefaultWidth: (props) => string | number, getDefaultMinWidth: (props) => string | number } +export class TableColumnLayout { + resizingColumn: Key | null; + getDefaultWidth: (props) => string | number; + getDefaultMinWidth: (props) => string | number; + columnWidths: Map = new Map(); + changedColumns: Map = new Map(); + uncontrolledColumnWidths: Map = new Map(); + + constructor(options: TableColumnLayoutOptions) { + this.getDefaultWidth = options.getDefaultWidth; + this.getDefaultMinWidth = options.getDefaultMinWidth; + } + + // can probably delete this + setResizeColumnWidth(width: number): void { + this.changedColumns.set(this.resizingColumn, width); + } + + // can probably delete this + setResizingColumn(key: Key | null): void { + this.resizingColumn = key; + } + + getColumnWidth(key: Key): number { + return this.columnWidths.get(key) ?? 0; + } + + getColumnMinWidth(minWidth: number | string, tableWidth: number): number { + return getMinWidth(minWidth, tableWidth); + } + + getColumnMaxWidth(maxWidth: number | string, tableWidth: number): number { + return getMaxWidth(maxWidth, tableWidth); + } + + resizeColumnWidth(tableWidth: number, collection: TableCollection, controlledWidths, uncontrolledWidths, col = null, width) { + let prevColumnWidths = this.columnWidths; + // resizing a column + let resizeIndex = Infinity; + let resizingChanged = new Map(Array.from(controlledWidths).concat(Array.from(uncontrolledWidths))); + let frKeys = new Map(); + let percentKeys = new Map(); + let frKeysToTheRight = new Map(); + let minWidths = new Map(); + // freeze columns to the left to their previous pixel value + // at the same time count how many total FR's are in play and which of those FRs are + // to the right or left of the resizing column + collection.columns.forEach((column, i) => { + let frKey; + minWidths.set(column.key, this.getDefaultMinWidth(collection.columns[i].props)); + if (col !== column.key && !column.column.props.width && !isStatic(uncontrolledWidths.get(column.key))) { + // uncontrolled don't have props.width for us, so instead get from our state + frKey = column.key; + frKeys.set(column.key, parseFractionalUnit(uncontrolledWidths.get(column.key))); + } else if (col !== column.key && !isStatic(column.column.props.width) && !uncontrolledWidths.get(column.key)) { + // controlledWidths will be the same in the collection + frKey = column.key; + frKeys.set(column.key, parseFractionalUnit(column.column.props.width)); + } else if (col !== column.key && column.column.props.width?.endsWith?.('%')) { + percentKeys.set(column.key, column.column.props.width); + } + // don't freeze columns to the right of the resizing one + if (resizeIndex < i) { + if (frKey) { + frKeysToTheRight.set(frKey, frKeys.get(frKey)); + } + return; + } + // we already know the new size of the resizing column + if (column.key === col) { + resizeIndex = i; + return; + } + // freeze column to previous value + resizingChanged.set(column.key, prevColumnWidths.get(column.key)); + }); + resizingChanged.set(col, Math.floor(width)); + + // predict pixels sizes for all columns based on resize + let columnWidths = calculateColumnSizes( + tableWidth, + collection.columns.map(col => ({...col.column.props, key: col.key})), + resizingChanged, + (i) => this.getDefaultWidth(collection.columns[i].props), + (i) => this.getDefaultMinWidth(collection.columns[i].props) + ); + + // set all new column widths for onResize event + // columns going in will be the same order as the columns coming out + let newWidths = new Map(); + // set all column widths based on calculateColumnSize + columnWidths.forEach((width, index) => { + let key = collection.columns[index].key; + newWidths.set(key, width); + }); + + // add FR's back as they were to columns to the right + Array.from(frKeys).forEach(([key]) => { + if (frKeysToTheRight.has(key)) { + newWidths.set(key, `${frKeysToTheRight.get(key)}fr`); + } + }); + + // put back in percents + Array.from(percentKeys).forEach(([key, width]) => { + // resizing locks a column to a px width + if (key === col) { + return; + } + newWidths.set(key, width); + }); + return newWidths; + } + + buildColumnWidths(tableWidth: number, collection: TableCollection, controlledWidths) { + this.columnWidths = new Map(); + + // initial layout or table/window resizing + let columnWidths = calculateColumnSizes( + tableWidth, + collection.columns.map(col => ({...col.column.props, key: col.key})), + controlledWidths, + (i) => this.getDefaultWidth(collection.columns[i].props), + (i) => this.getDefaultMinWidth(collection.columns[i].props) + ); + + // columns going in will be the same order as the columns coming out + columnWidths.forEach((width, index) => { + this.columnWidths.set(collection.columns[index].key, width); + }); + return this.columnWidths; + } +} + export class TableLayout extends ListLayout { collection: TableCollection; - resizingColumn: Key | null; lastCollection: TableCollection; columnWidths: Map = new Map(); - changedColumns: Map = new Map(); stickyColumnIndices: number[]; - getDefaultWidth: (props) => string | number; - getDefaultMinWidth: (props) => string | number; wasLoading = false; isLoading = false; lastPersistedKeys: Set = null; persistedIndices: Map = new Map(); private disableSticky: boolean; + columnLayout: TableColumnLayout; + controlledWidths: Map>; + uncontrolledWidths: Map>; + widths: Map; constructor(options: TableLayoutOptions) { super(options); - this.getDefaultWidth = options.getDefaultWidth; - this.getDefaultMinWidth = options.getDefaultMinWidth; + this.collection = options.initialCollection; this.stickyColumnIndices = []; this.disableSticky = this.checkChrome105(); - } - - setResizeColumnWidth(width): void { - this.changedColumns.set(this.resizingColumn, width); + this.columnLayout = options.columnLayout; + this.getSplitColumns(); + this.widths = new Map(Array.from(this.uncontrolledWidths).map(([key, col]) => + [key, col.props.defaultWidth ?? this.columnLayout.getDefaultWidth?.(col.props)] + )); } getColumnWidth(key: Key): number { - return this.columnWidths.get(key) ?? 0; + return this.columnLayout.getColumnWidth(key) ?? 0; } getColumnMinWidth(key: Key): number { @@ -58,7 +203,7 @@ export class TableLayout extends ListLayout { if (!column) { return 0; } - return getMinWidth(column.props.minWidth, this.virtualizer.visibleRect.width); + return this.columnLayout.getColumnMinWidth(column.props.minWidth, this.virtualizer.visibleRect.width); } getColumnMaxWidth(key: Key): number { @@ -66,15 +211,73 @@ export class TableLayout extends ListLayout { if (!column) { return 0; } - return getMaxWidth(column.props.maxWidth, this.virtualizer.visibleRect.width); + return this.columnLayout.getColumnMaxWidth(column.props.minWidth, this.virtualizer.visibleRect.width); + } + + // outside, where this is called, should call props.onColumnResizeStart... + onColumnResizeStart(column: GridNode): void { + this.columnLayout.setResizingColumn(column.key); + } + + // only way to call props.onColumnResize with the new size outside of Layout is to send the result back + onColumnResize(column: GridNode, width: number): Map { + let newControlled = new Map(Array.from(this.controlledWidths).map(([key, entry]) => [key, entry.props.width])); + let newSizes = this.columnLayout.resizeColumnWidth(this.virtualizer.visibleRect.width, this.collection, newControlled, this.widths, column.key, width); + + if (!column.props.width) { + let map = new Map(Array.from(this.uncontrolledWidths).map(([key]) => [key, newSizes.get(key)])); + map.set(column.key, width); + this.widths = map; + } + // relayoutNow still uses setState, should happen at the same time the parent + // component's state is processed as a result of props.onColumnResize + this.virtualizer.relayoutNow({sizeChanged: true}); + return newSizes; + } + + onColumnResizeEnd(column: GridNode): void { + this.columnLayout.setResizingColumn(null); + } + + getSplitColumns() { + let [controlledWidths, uncontrolledWidths] = this.collection.columns.reduce((acc, col) => { + if (col.props.width !== undefined) { + acc[0].set(col.key, col); + } else { + acc[1].set(col.key, col); + } + return acc; + }, [new Map(), new Map()]); + this.controlledWidths = controlledWidths; + this.uncontrolledWidths = uncontrolledWidths; + } + + recombineColumns() { + return new Map(this.collection.columns.map(col => { + if (this.uncontrolledWidths.has(col.key)) { + return [col.key, this.widths.get(col.key)]; + } else { + return [col.key, this.controlledWidths.get(col.key).props.width]; + } + })); } buildCollection(): LayoutNode[] { + this.getSplitColumns(); + let cWidths = this.recombineColumns(); + // I think this runs every render cycle? + // Which would mean that we'd be behind by one render since invalidate + // will take a render to resolve. // If columns changed, clear layout cache. if ( !this.lastCollection || this.collection.columns.length !== this.lastCollection.columns.length || - this.collection.columns.some((c, i) => c.key !== this.lastCollection.columns[i].key) + this.collection.columns.some((c, i) => + c.key !== this.lastCollection.columns[i].key || + c.props.width !== this.lastCollection.columns[i].props.width || + c.props.minWidth !== this.lastCollection.columns[i].props.minWidth || + c.props.maxWidth !== this.lastCollection.columns[i].props.maxWidth + ) ) { // Invalidate everything in this layout pass. Will be reset in ListLayout on the next pass. this.invalidateEverything = true; @@ -84,8 +287,16 @@ export class TableLayout extends ListLayout { let loadingState = this.collection.body.props.loadingState; this.wasLoading = this.isLoading; this.isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; + this.stickyColumnIndices = []; - this.buildColumnWidths(); + for (let column of this.collection.columns) { + // The selection cell and any other sticky columns always need to be visible. + // In addition, row headers need to be in the DOM for accessibility labeling. + if (column.props.isSelectionCell || this.collection.rowHeaderColumnKeys.has(column.key)) { + this.stickyColumnIndices.push(column.index); + } + } + this.columnWidths = this.columnLayout.buildColumnWidths(this.virtualizer.visibleRect.width, this.collection, cWidths); let header = this.buildHeader(); let body = this.buildBody(0); this.lastPersistedKeys = null; @@ -98,65 +309,6 @@ export class TableLayout extends ListLayout { ]; } - buildColumnWidths() { - let prevColumnWidths = this.columnWidths; - this.columnWidths = new Map(); - this.stickyColumnIndices = []; - - for (let column of this.collection.columns) { - // The selection cell and any other sticky columns always need to be visible. - // In addition, row headers need to be in the DOM for accessibility labeling. - if (column.props.isSelectionCell || this.collection.rowHeaderColumnKeys.has(column.key)) { - this.stickyColumnIndices.push(column.index); - } - } - if (this.resizingColumn == null) { - // initial layout or table/window resizing - let columnWidths = calculateColumnSizes( - this.virtualizer.visibleRect.width, - this.collection.columns.map(col => ({...col.column.props, key: col.key})), - this.changedColumns, - (i) => this.getDefaultWidth(this.collection.columns[i].props), - (i) => this.getDefaultMinWidth(this.collection.columns[i].props) - ); - - // columns going in will be the same order as the columns coming out - columnWidths.forEach((width, index) => { - this.columnWidths.set(this.collection.columns[index].key, width); - }); - } else { - // resizing a column - // TODO do we want to recalculate sizes of columns after the one we're resizing? - // I personally feel like that's weird because it changes once all columns become static or resized - let resizeIndex = Infinity; - let resizingChanged = new Map(this.changedColumns); - this.collection.columns.forEach((column, i) => { - if (resizeIndex < i) { - return; - } - if (column.key === this.resizingColumn) { - resizeIndex = i; - } - if (!resizingChanged.has(column.key)) { - resizingChanged.set(column.key, prevColumnWidths.get(column.key)); - } - }); - - let columnWidths = calculateColumnSizes( - this.virtualizer.visibleRect.width, - this.collection.columns.map(col => ({...col.column.props, key: col.key})), - resizingChanged, - (i) => this.getDefaultWidth(this.collection.columns[i].props), - (i) => this.getDefaultMinWidth(this.collection.columns[i].props) - ); - - // columns going in will be the same order as the columns coming out - columnWidths.forEach((width, index) => { - this.columnWidths.set(this.collection.columns[index].key, width); - }); - } - } - buildHeader(): LayoutNode { let rect = new Rect(0, 0, 0, 0); let layoutInfo = new LayoutInfo('header', 'header', rect); diff --git a/packages/@react-stately/layout/src/TableUtils.ts b/packages/@react-stately/layout/src/TableUtils.ts index 1c82dc38c2d..6f90a21637e 100644 --- a/packages/@react-stately/layout/src/TableUtils.ts +++ b/packages/@react-stately/layout/src/TableUtils.ts @@ -5,18 +5,18 @@ export function isStatic(width: number | string): boolean { return width != null && (!isNaN(width as number) || (String(width)).match(/^(\d+)(?=%$)/) !== null); } -function parseFractionalUnit(width: string): number { +export function parseFractionalUnit(width: string): number { if (!width) { return 1; } - let match = width.match(/^(\d+)(?=fr$)/); - // if width is the incorrect format, just deafult it to a 1fr + let match = width.match(/^(.+)(?=fr$)/); + // if width is the incorrect format, just default it to a 1fr if (!match) { console.warn(`width: ${width} is not a supported format, width should be a number (ex. 150), percentage (ex. '50%') or fr unit (ex. '2fr')`, 'defaulting to \'1fr\''); return 1; } - return parseInt(match[0], 10); + return parseFloat(match[0]); } export function parseStaticWidth(width: number | string, tableWidth: number): number { @@ -25,7 +25,7 @@ export function parseStaticWidth(width: number | string, tableWidth: number): nu if (!match) { throw new Error('Only percentages or numbers are supported for static column widths'); } - return tableWidth * (parseInt(match[0], 10) / 100); + return tableWidth * (parseFloat(match[0]) / 100); } return width; } @@ -37,16 +37,17 @@ export function getMaxWidth(maxWidth: number | string, tableWidth: number): numb : Number.MAX_SAFE_INTEGER; } -export function getMinWidth(minWidth: number | string, tableWidth: number, defaultMinWidth = 75): number { +// cannot support FR units, we'd need to know everything else in the table to do that +export function getMinWidth(minWidth: number | string, tableWidth: number): number { return minWidth != null ? parseStaticWidth(minWidth, tableWidth) - : defaultMinWidth; + : 0; } // tell us the delta between min width and target width vs max width and target width function mapDynamicColumns(dynamicColumns: IndexedColumn[], availableSpace: number, tableWidth: number): (IndexedColumn & {delta: number})[] { let fractions = dynamicColumns.reduce( - (sum, column) => column ? sum + parseFractionalUnit(column.column.defaultWidth as string) : sum, + (sum, column) => column ? sum + parseFractionalUnit((column.column.width || column.column.defaultWidth) as string) : sum, 0 ); @@ -55,7 +56,7 @@ function mapDynamicColumns(dynamicColumns: IndexedColumn[], availableSpace: numb return null; } const targetWidth = - (parseFractionalUnit(column.column.defaultWidth as string) * availableSpace) / fractions; + (parseFractionalUnit((column.column.width || column.column.defaultWidth) as string) * availableSpace) / fractions; const delta = Math.max( getMinWidth(column.column.minWidth, tableWidth) - targetWidth, targetWidth - getMaxWidth(column.column.maxWidth, tableWidth) @@ -73,7 +74,7 @@ function mapDynamicColumns(dynamicColumns: IndexedColumn[], availableSpace: numb // mutates columns to set their width function findDynamicColumnWidths(dynamicColumns: IndexedColumn[], availableSpace: number, tableWidth: number): void { let fractions = dynamicColumns.reduce( - (sum, col) => col ? sum + parseFractionalUnit(col.column.defaultWidth as string) : sum, + (sum, col) => col ? sum + col.width : sum, 0 ); @@ -82,14 +83,14 @@ function findDynamicColumnWidths(dynamicColumns: IndexedColumn[], availableSpace return null; } const targetWidth = - (parseFractionalUnit(column.column.defaultWidth as string) * availableSpace) / fractions; + (column.width * availableSpace) / fractions; let width = Math.max( getMinWidth(column.column.minWidth, tableWidth), - Math.min(Math.floor(targetWidth), getMaxWidth(column.column.maxWidth, tableWidth)) + Math.min(Math.round(targetWidth), getMaxWidth(column.column.maxWidth, tableWidth)) ); - column.width = width; availableSpace -= width; - fractions -= parseFractionalUnit(column.column.defaultWidth as string); + fractions -= column.width; + column.width = width; }); } @@ -131,18 +132,21 @@ export function calculateColumnSizes(availableWidth: number, columns: IColumn[], let remainingSpace = availableWidth; let {staticColumns, dynamicColumns} = columns.reduce((acc, column, index) => { let width = changedColumns.get(column.key) != null ? changedColumns.get(column.key) : column.width ?? column.defaultWidth ?? getDefaultWidth?.(index) ?? '1fr'; - let defaultMinWidth = getDefaultMinWidth?.(index); + let minWidth = column.minWidth ?? getDefaultMinWidth?.(index); + column.minWidth = minWidth; + if (isStatic(width)) { let w = parseStaticWidth(width, availableWidth); w = Math.max( - getMinWidth(column.minWidth, availableWidth, defaultMinWidth), + getMinWidth(column.minWidth, availableWidth), Math.min(Math.floor(w), getMaxWidth(column.maxWidth, availableWidth))); acc.staticColumns.push({index, column, width: w} as IndexedColumn); acc.dynamicColumns.push(null); remainingSpace -= w; } else { + let w = parseFractionalUnit(width); acc.staticColumns.push(null); - acc.dynamicColumns.push({index, column, width: null} as IndexedColumn); + acc.dynamicColumns.push({index, column, width: w} as IndexedColumn); } return acc; }, {staticColumns: [] as IndexedColumn[], dynamicColumns: [] as IndexedColumn[]}); diff --git a/packages/@react-stately/layout/test/TableUtils.test.js b/packages/@react-stately/layout/test/TableUtils.test.js new file mode 100644 index 00000000000..745efe2c1aa --- /dev/null +++ b/packages/@react-stately/layout/test/TableUtils.test.js @@ -0,0 +1,162 @@ +import {calculateColumnSizes} from '../src/TableUtils'; +import {TableColumnLayout} from '../src/TableLayout'; + +describe('TableUtils', () => { + describe('column building', () => { + it('real life case 1', () => { + let controlledWidths = new Map([['name', '0.9982425307557117fr'], ['type', 286], ['level', '4fr']]); + let tableWidth = [284, 284, 1140].reduce((acc, width) => acc + width, 0); + let widths = calculateColumnSizes( + tableWidth, + [{key: 'name', width: '0.9982425307557117fr'}, {key: 'type', width: '286'}, {key: 'level', width: '4fr'}], + controlledWidths, + () => 150, + () => 50 + ); + expect(widths).toStrictEqual([284, 286, 1138]); + }); + + it('real life case 2', () => { + let controlledWidths = new Map([['name', 235], ['type', 235], ['level', '4fr'], ['height', 150]]); + let tableWidth = [284, 284, 1140].reduce((acc, width) => acc + width, 0); + let widths = calculateColumnSizes( + tableWidth, + [{key: 'name', width: '1fr'}, {key: 'type', width: '1fr'}, {key: 'height'}, {key: 'weight'}, {key: 'level', width: '4fr'}], + controlledWidths, + () => 150, + () => 50 + ); + expect(widths).toStrictEqual([235, 235, 150, 150, 938]); + }); + + it('defaultWidths', () => { + let tableWidth = 800; + let widths = calculateColumnSizes( + tableWidth, + [{key: 'name', defaultWidth: '1fr'}, {key: 'type', defaultWidth: '1fr'}, {key: 'level', width: '4fr'}], + new Map(), + () => 150, + () => 50 + ); + expect(widths).toStrictEqual([133, 133, 534]); + }); + }); + + describe('resizing', () => { + it('can resize both controlled and uncontrolled columns', () => { + let layout = new TableColumnLayout({ + getDefaultWidth: () => 150, + getDefaultMinWidth: () => 50 + }); + let collection = {columns: [{key: 'name', column: {props: {width: '1fr'}}}, {key: 'type', column: {props: {width: '1fr'}}}, {key: 'height', column: {props: {}}}, {key: 'weight', column: {props: {}}}, {key: 'level', column: {props: {width: '5fr'}}}]}; + let columns = layout.buildColumnWidths( + 1000, + collection, + new Map([['name', '1fr'], ['type', '1fr'], ['height', 150], ['weight', 150], ['level', '5fr']]) + ); + expect(columns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 150], ['weight', 150], ['level', 500]])); + + let resizedColumns = layout.resizeColumnWidth( + 1000, + collection, + new Map([['name', '1fr'], ['type', '1fr'], ['level', '5fr']]), + new Map([['height', 150], ['weight', 150]]), + 'height', + 200 + ); + expect(resizedColumns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 200], ['weight', 150], ['level', '5fr']])); + + collection = {columns: [{key: 'name', column: {props: {width: 100}}}, {key: 'type', column: {props: {width: 100}}}, {key: 'height', column: {props: {}}}, {key: 'weight', column: {props: {}}}, {key: 'level', column: {props: {width: '5fr'}}}]}; + columns = layout.buildColumnWidths( + 1000, + collection, + new Map([['name', 100], ['type', 100], ['height', 200], ['weight', 150], ['level', '5fr']]) + ); + expect(columns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 200], ['weight', 150], ['level', 450]])); + + resizedColumns = layout.resizeColumnWidth( + 1000, + collection, + new Map([['name', 100], ['type', 100], ['level', '5fr']]), + new Map([['height', 200], ['weight', 150]]), + 'type', + 50 + ); + expect(resizedColumns).toStrictEqual(new Map([['name', 100], ['type', 50], ['height', 200], ['weight', 150], ['level', '5fr']])); + + collection = {columns: [{key: 'name', column: {props: {width: 100}}}, {key: 'type', column: {props: {width: 50}}}, {key: 'height', column: {props: {}}}, {key: 'weight', column: {props: {}}}, {key: 'level', column: {props: {width: '5fr'}}}]}; + columns = layout.buildColumnWidths( + 1000, + collection, + new Map([['name', 100], ['type', 50], ['height', 200], ['weight', 150], ['level', '5fr']]) + ); + expect(columns).toStrictEqual(new Map([['name', 100], ['type', 50], ['height', 200], ['weight', 150], ['level', 500]])); + }); + + it('can resize to bigger than the table', () => { + let layout = new TableColumnLayout({ + getDefaultWidth: () => 150, + getDefaultMinWidth: () => 50 + }); + let collection = {columns: [{key: 'name', column: {props: {width: '1fr'}}}, {key: 'type', column: {props: {width: '1fr'}}}, {key: 'height', column: {props: {}}}, {key: 'weight', column: {props: {}}}, {key: 'level', column: {props: {width: '5fr'}}}]}; + let columns = layout.buildColumnWidths( + 1000, + collection, + new Map([['name', '1fr'], ['type', '1fr'], ['height', 150], ['weight', 150], ['level', '5fr']]) + ); + expect(columns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 150], ['weight', 150], ['level', 500]])); + + let resizedColumns = layout.resizeColumnWidth( + 1000, + collection, + new Map([['name', '1fr'], ['type', '1fr'], ['level', '5fr']]), + new Map([['height', 150], ['weight', 150]]), + 'height', + 1000 + ); + expect(resizedColumns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 1000], ['weight', 150], ['level', '5fr']])); + + collection = {columns: [{key: 'name', column: {props: {width: 100}}}, {key: 'type', column: {props: {width: 100}}}, {key: 'height', column: {props: {}}}, {key: 'weight', column: {props: {}}}, {key: 'level', column: {props: {width: '5fr'}}}]}; + columns = layout.buildColumnWidths( + 1000, + collection, + new Map([['name', 100], ['type', 100], ['height', 1000], ['weight', 150], ['level', '5fr']]) + ); + expect(columns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 1000], ['weight', 150], ['level', 50]])); + + }); + + it('can resize a later column smaller', () => { + let layout = new TableColumnLayout({ + getDefaultWidth: () => 150, + getDefaultMinWidth: () => 50 + }); + let collection = {columns: [{key: 'name', column: {props: {width: '1fr'}}}, {key: 'type', column: {props: {width: '1fr'}}}, {key: 'height', column: {props: {}}}, {key: 'weight', column: {props: {}}}, {key: 'level', column: {props: {width: '5fr'}}}]}; + let columns = layout.buildColumnWidths( + 1000, + collection, + new Map([['name', '1fr'], ['type', '1fr'], ['height', 150], ['weight', 150], ['level', '5fr']]) + ); + expect(columns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 150], ['weight', 150], ['level', 500]])); + + let resizedColumns = layout.resizeColumnWidth( + 1000, + collection, + new Map([['name', '1fr'], ['type', '1fr'], ['level', '5fr']]), + new Map([['height', 150], ['weight', 150]]), + 'level', + 400 + ); + expect(resizedColumns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 150], ['weight', 150], ['level', 400]])); + + collection = {columns: [{key: 'name', column: {props: {width: 100}}}, {key: 'type', column: {props: {width: 100}}}, {key: 'height', column: {props: {}}}, {key: 'weight', column: {props: {}}}, {key: 'level', column: {props: {width: 400}}}]}; + columns = layout.buildColumnWidths( + 1000, + collection, + new Map([['name', 100], ['type', 100], ['height', 150], ['weight', 150], ['level', 400]]) + ); + expect(columns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 150], ['weight', 150], ['level', 400]])); + + }); + }); +}); diff --git a/packages/@react-stately/table/package.json b/packages/@react-stately/table/package.json index 398bcd20ca8..343f5762c7d 100644 --- a/packages/@react-stately/table/package.json +++ b/packages/@react-stately/table/package.json @@ -23,9 +23,12 @@ }, "dependencies": { "@babel/runtime": "^7.6.2", + "@react-aria/utils": "^3.14.1", "@react-stately/collections": "^3.5.0", "@react-stately/grid": "^3.4.1", + "@react-stately/layout": "^3.9.0", "@react-stately/selection": "^3.11.1", + "@react-stately/utils": "^3.5.1", "@react-types/grid": "^3.1.5", "@react-types/shared": "^3.16.0", "@react-types/table": "^3.3.3" diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 44767744a18..d2cd54f70ce 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -1,6 +1,7 @@ import {GridNode} from '@react-types/grid'; -import {Key, useRef} from 'react'; +import {Key, useCallback, useMemo, useState} from 'react'; +import {TableColumnLayout} from '@react-stately/layout/src/TableLayout'; export interface TableColumnResizeState { /** Trigger a resize and recalculation. */ @@ -8,37 +9,137 @@ export interface TableColumnResizeState { /** Callback for when onColumnResize has started. */ onColumnResizeStart: (column: GridNode) => void, /** Callback for when onColumnResize has ended. */ - onColumnResizeEnd: (column: GridNode) => void + onColumnResizeEnd: (column: GridNode) => void, + getColumnWidth: (key: Key) => number, + getColumnMinWidth: (key: Key) => number, + getColumnMaxWidth: (key: Key) => number, + widths: Map } export interface TableColumnResizeStateProps { + tableWidth: number, + getDefaultWidth, + getDefaultMinWidth, /** Callback that is invoked during the entirety of the resize event. */ - onColumnResize?: (key: Key, width: number) => void, + onColumnResize?: (widths: Map) => void, + /** Callback that is invoked when the resize event is started. */ + onColumnResizeStart?: (key: Key) => void, /** Callback that is invoked when the resize event is ended. */ onColumnResizeEnd?: (key: Key) => void } -export function useTableColumnResizeState(props: TableColumnResizeStateProps): TableColumnResizeState { - const isResizing = useRef(null); +export function useTableColumnResizeState(props: TableColumnResizeStateProps, state): TableColumnResizeState { + let { + getDefaultWidth, + getDefaultMinWidth + } = props; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - function onColumnResizeStart(column: GridNode) { - isResizing.current = true; - } + let columnLayout = useMemo( + () => new TableColumnLayout({ + getDefaultWidth, + getDefaultMinWidth + }), + [getDefaultWidth, getDefaultMinWidth] + ); - function onColumnResize(column: GridNode, width: number) { - props.onColumnResize && props.onColumnResize(column.key, width); - } + let tableWidth = props.tableWidth ?? 0; + let [controlledWidths, uncontrolledWidths]: [Map>, Map>] = useMemo(() => + state.collection.columns.reduce((acc, col) => { + if (col.props.width !== undefined) { + acc[0].set(col.key, col); + } else { + acc[1].set(col.key, col); + } + return acc; + }, [new Map(), new Map()]) + , [state.collection.columns]); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - function onColumnResizeEnd(column: GridNode) { - props.onColumnResizeEnd && isResizing.current && props.onColumnResizeEnd(column.key); - isResizing.current = false; - } + // uncontrolled column widths + let [widths, setWidths] = useState>(() => new Map( + Array.from(uncontrolledWidths).map(([key, col]) => + [key, col.props.defaultWidth ?? getDefaultWidth?.(col.props)] + )) + ); + // combine columns back into one map that maintains same order as the columns + let cWidths = useMemo(() => new Map(state.collection.columns.map(col => { + if (uncontrolledWidths.has(col.key)) { + return [col.key, widths.get(col.key)]; + } else { + return [col.key, controlledWidths.get(col.key).props.width]; + } + })), [state.collection.columns, uncontrolledWidths, controlledWidths]); - return { + + let onColumnResizeStart = useCallback((column: GridNode) => { + columnLayout.setResizingColumn(column.key); + props.onColumnResizeStart && props.onColumnResizeStart(column.key); + }, [columnLayout, props.onColumnResizeStart]); + + let onColumnResize = useCallback((column: GridNode, width: number): Map => { + let newControlled = new Map(Array.from(controlledWidths).map(([key, entry]) => [key, entry.props.width])); + let newSizes = columnLayout.resizeColumnWidth(tableWidth, state.collection, newControlled, widths, column.key, width); + + if (!column.props.width) { + let map = new Map(Array.from(uncontrolledWidths).map(([key]) => [key, newSizes.get(key)])); + map.set(column.key, width); + setWidths(map); + } + return newSizes; + }, [controlledWidths, uncontrolledWidths, props.onColumnResize, setWidths, tableWidth, columnLayout, state.collection, widths]); + + let onColumnResizeEnd = useCallback((column: GridNode) => { + columnLayout.setResizingColumn(null); + props.onColumnResizeEnd && props.onColumnResizeEnd(column.key); + }, [columnLayout, props.onColumnResizeEnd]); + + // done + let getColumnWidth = useCallback((key: Key) => { + return columnLayout.getColumnWidth(key); + }, [columnLayout]); + + // done + let getColumnMinWidth = useCallback((key: Key) => { + let columnMinWidth = state.collection.columns.find(col => col.key === key).props.minWidth; + return columnLayout.getColumnMinWidth(columnMinWidth, tableWidth); + }, [columnLayout, state.collection, tableWidth]); + + // done + let getColumnMaxWidth = useCallback((key: Key) => { + let columnMaxWidth = state.collection.columns.find(col => col.key === key).props.maxWidth; + return columnLayout.getColumnMaxWidth(columnMaxWidth, tableWidth); + }, [columnLayout, state.collection, tableWidth]); + + let setResizingColumn = useCallback((key: Key) => { + columnLayout.setResizingColumn(key); + }, [columnLayout]); + + let setResizeColumnWidth = useCallback((width) => { + columnLayout.setResizeColumnWidth(width); + }, [columnLayout]); + + let columnWidths = useMemo(() => + columnLayout.buildColumnWidths(tableWidth, state.collection, cWidths) + , [tableWidth, state.collection, cWidths]); + + return useMemo(() => ({ + onColumnResize, + onColumnResizeStart, + onColumnResizeEnd, + getColumnWidth, + getColumnMinWidth, + getColumnMaxWidth, + setResizingColumn, + setResizeColumnWidth, + widths: columnWidths + }), [ onColumnResize, onColumnResizeStart, - onColumnResizeEnd - }; + onColumnResizeEnd, + getColumnWidth, + getColumnMinWidth, + getColumnMaxWidth, + setResizingColumn, + setResizeColumnWidth, + columnWidths + ]); } diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index e6dc46fedf4..ad9e99d0af9 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -42,7 +42,7 @@ export interface SpectrumTableProps extends TableProps, SpectrumSelectionP * Handler that is called when a user performs a column resize. * @private */ - onColumnResize?: (key: Key, width: number) => void, + onColumnResize?: (widths: Map) => void, /** * Handler that is called when a column resize ends. * @private From 69f481d83e60dfb5608e2ac568b92aa2e2ad32c9 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 28 Nov 2022 17:04:57 +1100 Subject: [PATCH 06/42] fix keyboard resizing and default widths --- packages/@react-aria/table/src/useTableColumnHeader.ts | 1 + packages/@react-aria/table/src/useTableColumnResize.ts | 4 +--- packages/@react-spectrum/table/src/TableView.tsx | 6 ++++-- packages/@react-spectrum/table/test/Table.test.js | 2 +- packages/@react-stately/layout/src/TableLayout.ts | 1 + 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnHeader.ts b/packages/@react-aria/table/src/useTableColumnHeader.ts index efbae0ed932..428f1710212 100644 --- a/packages/@react-aria/table/src/useTableColumnHeader.ts +++ b/packages/@react-aria/table/src/useTableColumnHeader.ts @@ -64,6 +64,7 @@ export function useTableColumnHeader(props: AriaTableColumnHeaderProps, state // Needed to pick up the focusable context, enabling things like Tooltips for example let {focusableProps} = useFocusable({}, ref); + // try to just delete this, but figure out why it causes an extra focus target if (props.hasMenu) { pressProps = {}; } diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 3a20cd1c383..2a14dbf9355 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -147,8 +147,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') { return; } - if (e.pointerType === 'virtual' && layoutState.resizingColumn != null) { - layoutState.resizingColumn = item.key; + if (e.pointerType === 'virtual' && layoutState.columnLayout.resizingColumn != null) { stateRef.current.onColumnResizeEnd(item); focusSafely(triggerRef.current); return; @@ -183,7 +182,6 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st state.setKeyboardNavigationDisabled(true); }, onBlur: () => { - layoutState.resizingColumn = null; stateRef.current.onColumnResizeEnd(item); props.onResizeEnd?.(item.key); state.setKeyboardNavigationDisabled(false); diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 45ec1f0ea8f..e3d85a079ac 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -133,6 +133,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef + style={{[direction === 'ltr' ? 'left' : 'right']: `${resizerPosition}px`, height: `${Math.max(state.virtualizer.contentSize.height, state.virtualizer.visibleRect.height)}px`, display: layout.columnLayout.resizingColumn ? 'block' : 'none'}} />
@@ -620,7 +621,8 @@ function ResizableTableColumnHeader(props) { let {pressProps, isPressed} = usePress({isDisabled: isEmpty}); let {columnHeaderProps} = useTableColumnHeader({ node: column, - isVirtualized: true + isVirtualized: true, + hasMenu: true }, state, ref); let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty}); diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index 8a4e072a799..22321f6fd24 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -1660,7 +1660,7 @@ describe('TableView', function () { expect(document.activeElement).toBe(cell); expect(body.scrollTop).toBe(0); - // When scrolling the focused item out of view, focus should remaind on the item, + // When scrolling the focused item out of view, focus should remain on the item, // virtualizer keeps focused items from being reused body.scrollTop = 1000; body.scrollLeft = 1000; diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 6e287759efd..285283458e9 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -296,6 +296,7 @@ export class TableLayout extends ListLayout { this.stickyColumnIndices.push(column.index); } } + this.columnWidths = this.columnLayout.buildColumnWidths(this.virtualizer.visibleRect.width, this.collection, cWidths); let header = this.buildHeader(); let body = this.buildBody(0); From a99ca8e22ecceeb4456f0653edc723a36b957b96 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 29 Nov 2022 14:27:49 +1100 Subject: [PATCH 07/42] use aria tests for spectrum as well --- .../table/stories/example-resizing.tsx | 4 +- .../table/stories/useTable.stories.tsx | 4 +- .../table/test/ariaTableResizing.test.tsx | 989 +++++++++--------- .../virtualizer/src/ScrollView.tsx | 1 - .../@react-spectrum/table/src/TableView.tsx | 13 +- .../table/stories/ControllingResize.tsx | 83 ++ .../table/stories/Table.stories.tsx | 34 + .../table/test/TableSizing.test.js | 92 +- .../@react-stately/layout/src/TableLayout.ts | 15 +- .../table/src/useTableColumnResizeState.ts | 11 +- 10 files changed, 705 insertions(+), 541 deletions(-) create mode 100644 packages/@react-spectrum/table/stories/ControllingResize.tsx diff --git a/packages/@react-aria/table/stories/example-resizing.tsx b/packages/@react-aria/table/stories/example-resizing.tsx index 7ee99f82d3c..6cb27c64c0c 100644 --- a/packages/@react-aria/table/stories/example-resizing.tsx +++ b/packages/@react-aria/table/stories/example-resizing.tsx @@ -43,8 +43,8 @@ export function Table(props) { }); let [tableWidth, setTableWidth] = useState(0); - let getDefaultWidth = useCallback(() => 150, []); - let getDefaultMinWidth = useCallback(() => 50, []); + let getDefaultWidth = useCallback(() => undefined, []); + let getDefaultMinWidth = useCallback(() => 75, []); let layoutState = useTableColumnResizeState({ getDefaultWidth, getDefaultMinWidth, diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index 2273ba425bf..87fe8987a70 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -211,7 +211,7 @@ function ControlledTableResizing(props: {columns: Array<{name: string, uid: stri let columnsFR: ColumnData[] = [ {name: 'Name', uid: 'name', width: '1fr'}, {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Level', uid: 'level', width: '4fr'} + {name: 'Level', uid: 'level', width: '5fr'} ]; export const TableWithResizingFRsControlled = { @@ -224,7 +224,7 @@ let columnsSomeFR: ColumnData[] = [ {name: 'Type', uid: 'type', width: '1fr'}, {name: 'Height', uid: 'height'}, {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} + {name: 'Level', uid: 'level', width: '5fr'} ]; export const TableWithSomeResizingFRsControlled = { diff --git a/packages/@react-aria/table/test/ariaTableResizing.test.tsx b/packages/@react-aria/table/test/ariaTableResizing.test.tsx index 8738e1f88db..165da7034ac 100644 --- a/packages/@react-aria/table/test/ariaTableResizing.test.tsx +++ b/packages/@react-aria/table/test/ariaTableResizing.test.tsx @@ -64,530 +64,529 @@ function resizeCol(tree, col, delta) { fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); } -// assumption with all these tests, the controlling values we pass in aren't actually controlling -// the sizes, they are instead more like the default values that the controlling logic uses -describe('Aria Table Resizing', () => { - installPointerEvent(); - let offsetWidth, offsetHeight; - let onResize; - - beforeEach(function () { - offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 900); - offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); - jest.useFakeTimers(); - onResize = jest.fn(); - }); - - afterEach(function () { - act(() => {jest.runAllTimers();}); - offsetWidth.mockReset(); - offsetHeight.mockReset(); - onResize = null; - }); +function resizeTable(clientWidth, newValue) { + clientWidth.mockImplementation(() => newValue); + fireEvent(window, new Event('resize')); + act(() => {jest.runAllTimers()}); +} - describe.each` - allowsResizing - ${undefined} - ${true} - `('initial column sizes allowsResizing=$allowsResizing', ({allowsResizing}) => { - it('should handle no value if table was written with default widths', () => { - let columns = [ - {name: 'Name', id: 'name', allowsResizing}, - {name: 'Type', id: 'type', allowsResizing}, - {name: 'Level', id: 'level', allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([150, 150, 150]); - }); - it('should handle default pixel widths', () => { - let columns = [ - {name: 'Name', id: 'name', defaultWidth: 100, allowsResizing}, - {name: 'Type', id: 'type', defaultWidth: 100, allowsResizing}, - {name: 'Level', id: 'level', defaultWidth: 400, allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 400]); - }); - it('should handle default percent widths', () => { - let columns = [ - {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, - {name: 'Type', id: 'type', defaultWidth: '16%', allowsResizing}, - {name: 'Level', id: 'level', defaultWidth: '33%', allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([450, 144, 297]); - }); - it('should handle default fr widths', () => { - let columns = [ - {name: 'Name', id: 'name', defaultWidth: '4fr', allowsResizing}, - {name: 'Type', id: 'type', defaultWidth: '3fr', allowsResizing}, - {name: 'Level', id: 'level', defaultWidth: '2fr', allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([400, 300, 200]); - }); - it('should handle a mix of default widths', () => { - let columns = [ - {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, - {name: 'Type', id: 'type', defaultWidth: '2fr', allowsResizing}, - {name: 'Level', id: 'level', defaultWidth: 100, allowsResizing}, - {name: 'Height', id: 'height', allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([450, 200, 100, 150]); - }); - it('any single remaining column with an FR will take the remaining space, regardless of how many FRs it is "worth"', () => { - let columns = [ - {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, - {name: 'Type', id: 'type', defaultWidth: '1fr', allowsResizing}, - {name: 'Level', id: 'level', defaultWidth: 100, allowsResizing}, - {name: 'Height', id: 'height', allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([450, 200, 100, 150]); - }); - it('cannot size less than the minWidth', () => { - let columns = [ - {name: 'Name', id: 'name', minWidth: 500, defaultWidth: '50%', allowsResizing}, - {name: 'Type', id: 'type', minWidth: 100, defaultWidth: '1fr', allowsResizing}, - {name: 'Level', id: 'level', minWidth: 150, defaultWidth: 100, allowsResizing}, - {name: 'Height', id: 'height', minWidth: 200, allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([500, 100, 150, 200]); - }); - it('cannot size more than the maxWidth', () => { - let columns = [ - {name: 'Name', id: 'name', maxWidth: 400, defaultWidth: '50%', allowsResizing}, - {name: 'Type', id: 'type', maxWidth: 50, defaultWidth: '1fr', allowsResizing}, - {name: 'Level', id: 'level', maxWidth: 50, defaultWidth: 100, allowsResizing}, - {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([400, 50, 50, 100]); +export let resizingTests = (render, rerender, Table, ControlledTable, resizeCol, resizeTable) => { +// assumption with all these tests +// 1. the controlling values we pass in aren't actually controlling +// the sizes, they are instead more like the default values that the controlling logic uses +// 2. defaultWidth function and minDefaultWidth passed must be the same in any implementation using +// these tests, or the values will be wrong, if those functions were exposed we could generalize, but seems like a lot just for testing + describe('Aria Table Resizing', () => { + installPointerEvent(); + let clientWidth, clientHeight; + let onResize; + + beforeEach(function () { + clientWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 900); + clientHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + jest.useFakeTimers(); + onResize = jest.fn(); }); - it('minWidth can be a percent', () => { - let columns = [ - {name: 'Name', id: 'name', minWidth: '50%', defaultWidth: '30%', allowsResizing}, - {name: 'Type', id: 'type', maxWidth: 50, defaultWidth: '1fr', allowsResizing}, - {name: 'Level', id: 'level', maxWidth: 50, defaultWidth: 100, allowsResizing}, - {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([450, 50, 50, 100]); + + afterEach(function () { + act(() => { + jest.runAllTimers(); + }); + clientWidth.mockReset(); + clientHeight.mockReset(); + onResize = null; }); - it('maxWidth can be a percent', () => { - let columns = [ - {name: 'Name', id: 'name', maxWidth: '50%', defaultWidth: '70%', allowsResizing}, - {name: 'Type', id: 'type', maxWidth: 50, defaultWidth: '1fr', allowsResizing}, - {name: 'Level', id: 'level', maxWidth: 50, defaultWidth: 100, allowsResizing}, - {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([450, 50, 50, 100]); + + describe.each` + allowsResizing + ${undefined} + ${true} + `('initial column sizes allowsResizing=$allowsResizing', ({allowsResizing}) => { + it('should handle no value if table was written with default widths', () => { + let columns = [ + {name: 'Name', id: 'name', allowsResizing}, + {name: 'Type', id: 'type', allowsResizing}, + {name: 'Level', id: 'level', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([300, 300, 300]); + }); + it('should handle default pixel widths', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: 100, allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: 100, allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: 400, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 400]); + }); + it('should handle default percent widths', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: '16%', allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: '33%', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 144, 297]); + }); + it('should handle default fr widths', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: '4fr', allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: '3fr', allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: '2fr', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([400, 300, 200]); + }); + it('should handle a mix of default widths', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: '2fr', allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 233, 100, 117]); + }); + it('any single remaining column with an FR will take the remaining space, regardless of how many FRs it is "worth"', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: 100, allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 100, 100, 250]); + }); + it('cannot size less than the minWidth', () => { + let columns = [ + {name: 'Name', id: 'name', minWidth: 500, defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', minWidth: 100, defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', minWidth: 150, defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', minWidth: 200, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([500, 100, 150, 200]); + }); + it('cannot size more than the maxWidth', () => { + let columns = [ + {name: 'Name', id: 'name', maxWidth: 400, defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', maxWidth: 100, defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', maxWidth: 100, defaultWidth: 150, allowsResizing}, + {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([400, 100, 100, 100]); + }); + it('minWidth can be a percent', () => { + let columns = [ + {name: 'Name', id: 'name', minWidth: '50%', defaultWidth: '30%', allowsResizing}, + {name: 'Type', id: 'type', maxWidth: 100, defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', maxWidth: 100, defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 100, 100, 100]); + }); + it('maxWidth can be a percent', () => { + let columns = [ + {name: 'Name', id: 'name', maxWidth: '50%', defaultWidth: '70%', allowsResizing}, + {name: 'Type', id: 'type', maxWidth: 100, defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', maxWidth: 100, defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 100, 100, 100]); + }); }); - }); - describe('resizing', () => { - function mapFromWidths(columnNames, widths) { - return new Map(widths.map((width, i) => [columnNames[i].toLowerCase(), width])); - } - it.each` + describe('resizing', () => { + function mapFromWidths(columnNames, widths) { + return new Map(widths.map((width, i) => [columnNames[i].toLowerCase(), width])); + } + + it.each` col | delta | expected | expectedOnResize - ${'Name'} | ${-50} | ${[50, 110, 150, 150, 440]} | ${[50, '1fr', 150, 150, '4fr']} - ${'Name'} | ${50} | ${[150, 90, 150, 150, 360]} | ${[150, '1fr', 150, 150, '4fr']} - ${'Type'} | ${-50} | ${[100, 50, 150, 150, 450]} | ${[100, 50, 150, 150, '4fr']} - ${'Type'} | ${50} | ${[100, 150, 150, 150, 350]} | ${[100, 150, 150, 150, '4fr']} - ${'Height'} | ${-50} | ${[100, 100, 100, 150, 450]} | ${[100, 100, 100, 150, '4fr']} - ${'Height'} | ${50} | ${[100, 100, 200, 150, 350]} | ${[100, 100, 200, 150, '4fr']} - ${'Weight'} | ${-50} | ${[100, 100, 150, 100, 450]} | ${[100, 100, 150, 100, '4fr']} - ${'Weight'} | ${50} | ${[100, 100, 150, 200, 350]} | ${[100, 100, 150, 200, '4fr']} - ${'Level'} | ${-50} | ${[100, 100, 150, 150, 350]} | ${[100, 100, 150, 150, 350]} - ${'Level'} | ${50} | ${[100, 100, 150, 150, 450]} | ${[100, 100, 150, 150, 450]} + ${'Name'} | ${-50} | ${[75, 103, 103, 103, 516]} | ${[75, '1fr', '1fr', '1fr', '5fr']} + ${'Name'} | ${50} | ${[150, 94, 94, 94, 468]} | ${[150, '1fr', '1fr', '1fr', '5fr']} + ${'Type'} | ${-50} | ${[100, 75, 104, 104, 517]} | ${[100, 75, '1fr', '1fr', '5fr']} + ${'Type'} | ${50} | ${[100, 150, 93, 93, 464]} | ${[100, 150, '1fr', '1fr', '5fr']} + ${'Height'} | ${-50} | ${[100, 100, 75, 104, 521]} | ${[100, 100, 75, '1fr', '5fr']} + ${'Height'} | ${50} | ${[100, 100, 150, 92, 458]} | ${[100, 100, 150, '1fr', '5fr']} + ${'Weight'} | ${-50} | ${[100, 100, 100, 75, 525]} | ${[100, 100, 100, 75, '5fr']} + ${'Weight'} | ${50} | ${[100, 100, 100, 150, 450]} | ${[100, 100, 100, 150, '5fr']} + ${'Level'} | ${-50} | ${[100, 100, 100, 100, 450]} | ${[100, 100, 100, 100, 450]} + ${'Level'} | ${50} | ${[100, 100, 100, 100, 550]} | ${[100, 100, 100, 100, 550]} `('can resize $col to be $delta px different', - function ({col, delta, expected, expectedOnResize}) { + function ({col, delta, expected, expectedOnResize}) { + let columnNames = ['Name', 'Type', 'Height', 'Weight', 'Level']; + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 500]); + resizeCol(tree, col, delta); + expect(getColumnWidths(tree)).toStrictEqual(expected); + expect(onResize).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, expectedOnResize)); + }); + + it('cannot resize to be less than a minWidth, from start to end', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; let columnNames = ['Name', 'Type', 'Height', 'Weight', 'Level']; - let tree = render(); - resizeCol(tree, col, delta); - expect(getColumnWidths(tree)).toStrictEqual(expected); + + let tree = render(); + resizeCol(tree, 'Name', -50); // first column + expect(getColumnWidths(tree)).toStrictEqual([100, 114, 114, 114, 458]); expect(onResize).toHaveBeenCalledTimes(1); - expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, expectedOnResize)); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, '1fr', '1fr', '1fr', '4fr'])); + resizeCol(tree, 'Type', -50); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 117, 117, 466]); + expect(onResize).toHaveBeenCalledTimes(2); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, '1fr', '1fr', '4fr'])); + resizeCol(tree, 'Height', -100); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 120, 480]); + expect(onResize).toHaveBeenCalledTimes(3); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, '1fr', '4fr'])); + resizeCol(tree, 'Weight', -100); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 500]); + expect(onResize).toHaveBeenCalledTimes(4); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 100, '4fr'])); + resizeCol(tree, 'Level', -500); // last column + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); + expect(onResize).toHaveBeenCalledTimes(5); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 100, 100])); }); - it('cannot resize to be less than a minWidth, from start to end', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, - {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, - {name: 'Height', uid: 'height', minWidth: 100}, - {name: 'Weight', uid: 'weight', minWidth: 100}, - {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} - ]; - let columnNames = ['Name', 'Type', 'Height', 'Weight', 'Level']; - - let tree = render(); - resizeCol(tree, 'Name', -50); // first column - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); - expect(onResize).toHaveBeenCalledTimes(1); - expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, '1fr', 150, 150, '4fr'])); - resizeCol(tree, 'Type', -50); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); - expect(onResize).toHaveBeenCalledTimes(2); - expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 150, 150, '4fr'])); - resizeCol(tree, 'Height', -100); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 150, 450]); - expect(onResize).toHaveBeenCalledTimes(3); - expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 150, '4fr'])); - resizeCol(tree, 'Weight', -100); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 500]); - expect(onResize).toHaveBeenCalledTimes(4); - expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 100, '4fr'])); - resizeCol(tree, 'Level', -500); // last column - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); - expect(onResize).toHaveBeenCalledTimes(5); - expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 100, 100])); - }); - - it('cannot resize to be less than a minWidth, from end to start', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, - {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, - {name: 'Height', uid: 'height', minWidth: 100}, - {name: 'Weight', uid: 'weight', minWidth: 100}, - {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} - ]; - - let tree = render(); - resizeCol(tree, 'Level', -500); // last column - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 100]); - resizeCol(tree, 'Weight', -100); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 100, 100]); - resizeCol(tree, 'Height', -100); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); - resizeCol(tree, 'Type', -100); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); - resizeCol(tree, 'Name', -500); // first column - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); - }); - - it('cannot resize to be more than a maxWidth, from start to end', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr', maxWidth: 150}, - {name: 'Type', uid: 'type', width: '1fr', maxWidth: 150}, - {name: 'Height', uid: 'height', maxWidth: 200}, - {name: 'Weight', uid: 'weight', maxWidth: 200}, - {name: 'Level', uid: 'level', width: '4fr', maxWidth: 500} - ]; - - let tree = render(); - resizeCol(tree, 'Name', 150); - expect(getColumnWidths(tree)).toStrictEqual([150, 90, 150, 150, 360]); - resizeCol(tree, 'Type', 150); - expect(getColumnWidths(tree)).toStrictEqual([150, 150, 150, 150, 300]); - resizeCol(tree, 'Height', 100); - expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 150, 250]); - resizeCol(tree, 'Weight', 100); - expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 200]); - resizeCol(tree, 'Level', 400); - expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 500]); - }); + it('cannot resize to be less than a minWidth, from end to start', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; + + let tree = render(); + resizeCol(tree, 'Level', -500); // last column + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 100]); + resizeCol(tree, 'Weight', -100); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 100, 100]); + resizeCol(tree, 'Height', -100); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 100, 100, 100]); + resizeCol(tree, 'Type', -100); + expect(getColumnWidths(tree)).toStrictEqual([113, 100, 100, 100, 100]); + resizeCol(tree, 'Name', -500); // first column + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); + }); - it('cannot resize to be more than a maxWidth, from end to start', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr', maxWidth: 150}, - {name: 'Type', uid: 'type', width: '1fr', maxWidth: 150}, - {name: 'Height', uid: 'height', maxWidth: 200}, - {name: 'Weight', uid: 'weight', maxWidth: 200}, - {name: 'Level', uid: 'level', width: '4fr', maxWidth: 500} - ]; - - let tree = render(); - resizeCol(tree, 'Level', 150); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 500]); - resizeCol(tree, 'Weight', 150); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 200, 500]); - resizeCol(tree, 'Height', 100); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 200, 200, 500]); - resizeCol(tree, 'Type', 100); - expect(getColumnWidths(tree)).toStrictEqual([100, 150, 200, 200, 500]); - resizeCol(tree, 'Name', 400); - expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 500]); - }); + it('cannot resize to be more than a maxWidth, from start to end', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', maxWidth: 150}, + {name: 'Type', uid: 'type', width: '1fr', maxWidth: 150}, + {name: 'Height', uid: 'height', maxWidth: 200}, + {name: 'Weight', uid: 'weight', maxWidth: 200}, + {name: 'Level', uid: 'level', width: '4fr', maxWidth: 500} + ]; + + let tree = render(); + resizeCol(tree, 'Name', 150); + expect(getColumnWidths(tree)).toStrictEqual([150, 107, 107, 107, 429]); + resizeCol(tree, 'Type', 150); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 100, 100, 400]); + resizeCol(tree, 'Height', 150); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 80, 320]); + resizeCol(tree, 'Weight', 200); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 200]); + resizeCol(tree, 'Level', 400); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 500]); + }); - it('resizing the starter column will preserve fr column ratios to the right', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - resizeCol(tree, 'Name', -50); - expect(getColumnWidths(tree)).toStrictEqual([50, 110, 150, 150, 440]); - resizeCol(tree, 'Name', 50); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); - }); + it('cannot resize to be more than a maxWidth, from end to start', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', maxWidth: 150}, + {name: 'Type', uid: 'type', width: '1fr', maxWidth: 150}, + {name: 'Height', uid: 'height', maxWidth: 200}, + {name: 'Weight', uid: 'weight', maxWidth: 200}, + {name: 'Level', uid: 'level', width: '4fr', maxWidth: 500} + ]; + + let tree = render(); + resizeCol(tree, 'Level', 150); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 500]); + resizeCol(tree, 'Weight', 150); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 200, 500]); + resizeCol(tree, 'Height', 100); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 200, 200, 500]); + resizeCol(tree, 'Type', 100); + expect(getColumnWidths(tree)).toStrictEqual([113, 150, 200, 200, 500]); + resizeCol(tree, 'Name', 400); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 500]); + }); - it('resizing the last column will lock columns to pixels to the left', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - resizeCol(tree, 'Level', -50); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 350]); - resizeCol(tree, 'Level', 50); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); - }); + it('resizing the starter column will preserve fr column ratios to the right', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render() + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + resizeCol(tree, 'Name', -50); + expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); + resizeCol(tree, 'Name', 38); // send it back to original size + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + }); - it('can handle removing a column', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - tree.rerender(); - expect(getColumnWidths(tree)).toStrictEqual([125, 125, 150, 500]); - }); + it('resizing the last column will lock columns to pixels to the left', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Level', -50); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 400]); + resizeCol(tree, 'Level', 50); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + }); - it('can handle adding a column', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([125, 125, 150, 500]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - tree.rerender(); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); - }); + it('can handle removing a column', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([129, 129, 128, 514]); + }); - it('can handle resizing, then removing an uncontrolled column, then adding the column again', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - resizeCol(tree, 'Name', -50); - expect(getColumnWidths(tree)).toStrictEqual([50, 110, 150, 150, 440]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - tree.rerender(); - expect(getColumnWidths(tree)).toStrictEqual([50, 140, 150, 560]); - tree.rerender(); - expect(getColumnWidths(tree)).toStrictEqual([50, 110, 150, 150, 440]); - }); + it('can handle adding a column', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([129, 129, 128, 514]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + }); - it('can handle resizing, then removing an controlled column, then adding the column again', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - resizeCol(tree, 'Name', -50); - expect(getColumnWidths(tree)).toStrictEqual([50, 110, 150, 150, 440]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'} - ]; - tree.rerender(); - expect(getColumnWidths(tree)).toStrictEqual([50, 550, 150, 150]); - tree.rerender(); - expect(getColumnWidths(tree)).toStrictEqual([50, 110, 150, 150, 440]); - }); + it('can handle resizing, then removing an uncontrolled column, then adding the column again', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Name', -50); + expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([75, 138, 137, 550]); + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); + }); - it('can add new columns after resizing', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'} - ]; - - let tree = render(); - resizeCol(tree, 'Name', -50); - expect(getColumnWidths(tree)).toStrictEqual([325, 425, 150]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - tree.rerender(); - expect(getColumnWidths(tree)).toStrictEqual([325, 125, 150, 150, 150]); - }); + it('can handle resizing, then removing an controlled column, then adding the column again', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Name', -50); + expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([75, 275, 275, 275]); + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); + }); - it('can remove and re-add the resized column', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - resizeCol(tree, 'Type', -50); - expect(getColumnWidths(tree)).toStrictEqual([100, 50, 150, 150, 450]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - tree.rerender(); - expect(getColumnWidths(tree)).toStrictEqual([100, 150, 150, 500]); - tree.rerender(); - expect(getColumnWidths(tree)).toStrictEqual([100, 50, 150, 150, 450]); - }); + it('can add new columns after resizing', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'} + ]; + + let tree = render(); + resizeCol(tree, 'Name', -50); + expect(getColumnWidths(tree)).toStrictEqual([250, 325, 325]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([250, 163, 162, 163, 162]); + }); - it('can resize smaller if the minWidth gets smaller', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, - {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, - {name: 'Height', uid: 'height', minWidth: 100}, - {name: 'Weight', uid: 'weight', minWidth: 100}, - {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); - resizeCol(tree, 'Type', -100); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, - {name: 'Type', uid: 'type', width: '1fr', minWidth: 50}, - {name: 'Height', uid: 'height', minWidth: 100}, - {name: 'Weight', uid: 'weight', minWidth: 100}, - {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} - ]; - tree.rerender(); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); - resizeCol(tree, 'Type', -100); - expect(getColumnWidths(tree)).toStrictEqual([100, 50, 150, 150, 450]); - }); - }); + it('can remove and re-add the resized column', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Type', -50); + expect(getColumnWidths(tree)).toStrictEqual([113, 75, 119, 119, 474]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([113, 131, 131, 525]); + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([113, 75, 119, 119, 474]); + }); - describe('resizing table', () => { - it('will not affect pixel widths', () => { - let columns = [ - {name: 'Name', uid: 'name', width: 100}, - {name: 'Type', uid: 'type', width: 100}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: 400} - ]; - - let tree = render(); - offsetWidth.mockImplementation(() => 1000); - fireEvent(window, new Event('resize')); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + it('can resize smaller if the minWidth gets smaller', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + resizeCol(tree, 'Type', -100); + expect(getColumnWidths(tree)).toStrictEqual([113, 100, 115, 114, 458]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 50}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([113, 100, 115, 114, 458]); + resizeCol(tree, 'Type', -100); + expect(getColumnWidths(tree)).toStrictEqual([113, 50, 123, 123, 491]); + }); }); - it('will resize all percent columns', () => { - let columns = [ - {name: 'Name', uid: 'name', width: '20%'}, - {name: 'Type', uid: 'type', width: '20%'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '40%'} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([180, 180, 150, 150, 360]); - offsetWidth.mockImplementation(() => 1000); - fireEvent(window, new Event('resize')); - expect(getColumnWidths(tree)).toStrictEqual([200, 200, 150, 150, 400]); - }); + describe('resizing table', () => { + it('will not affect pixel widths', () => { + let columns = [ + {name: 'Name', uid: 'name', width: 100}, + {name: 'Type', uid: 'type', width: 100}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: 400} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + resizeTable(clientWidth, 1000); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 200, 200, 400]); + }); - it('will resize all fr columns', () => { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); - offsetWidth.mockImplementation(() => 1000); - fireEvent(window, new Event('resize')); - expect(getColumnWidths(tree)).toStrictEqual([117, 117, 150, 150, 466]); - }); + it('will resize all percent columns', () => { + let columns = [ + {name: 'Name', uid: 'name', width: '20%'}, + {name: 'Type', uid: 'type', width: '20%'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '40%'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([180, 180, 90, 90, 360]); + resizeTable(clientWidth, 1000); + expect(getColumnWidths(tree)).toStrictEqual([200, 200, 100, 100, 400]); + }); - it('will resize all fr columns only after percent columns', () => { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '20%'}, - {name: 'Height', uid: 'height', width: '20%'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([78, 180, 180, 150, 312]); - offsetWidth.mockImplementation(() => 1000); - fireEvent(window, new Event('resize')); - expect(getColumnWidths(tree)).toStrictEqual([90, 200, 200, 150, 360]); - }); + it('will resize all fr columns', () => { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + resizeTable(clientWidth, 1000); + expect(getColumnWidths(tree)).toStrictEqual([125, 125, 125, 125, 500]); + }); - it('will resize all fr columns only after percent columns', () => { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '20%'}, - {name: 'Height', uid: 'height', width: '20%'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([78, 180, 180, 150, 312]); - offsetWidth.mockImplementation(() => 1000); - fireEvent(window, new Event('resize')); - expect(getColumnWidths(tree)).toStrictEqual([90, 200, 200, 150, 360]); + it('will resize all fr columns only after percent columns', () => { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '20%'}, + {name: 'Height', uid: 'height', width: '20%'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([90, 180, 180, 90, 360]); + resizeTable(clientWidth, 1000); + expect(getColumnWidths(tree)).toStrictEqual([100, 200, 200, 100, 400]); + }); }); }); -}); +}; + +resizingTests(render, (tree, ...args) => tree.rerender(...args), Table, TableWithSomeResizingFRsControlled, resizeCol, resizeTable); function Table(props: {columns: {id: Key, name: string}[], rows}) { let {columns, rows, ...args} = props; diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 7717d890b44..fcc896fa45f 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -138,7 +138,6 @@ function ScrollView(props: ScrollViewProps, ref: RefObject) { h = Math.min(h, contentSize.height); } } - if (state.width !== w || state.height !== h) { state.width = w; state.height = h; diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index e3d85a079ac..9cdc04e34b4 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -403,6 +403,14 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo let loadingState = collection.body.props.loadingState; let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; let onLoadMore = collection.body.props.onLoadMore; + let transitionDuration = 220; + if (isLoading) { + transitionDuration = 160; + } + if (layout.columnLayout.resizingColumn != null) { + // while resizing, prop changes should not cause animations + transitionDuration = 0; + } let state = useVirtualizerState({ layout, collection, @@ -412,7 +420,7 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo bodyRef.current.scrollTop = rect.y; setScrollLeft(bodyRef.current, direction, rect.x); }, - transitionDuration: isLoading ? 160 : 220 + transitionDuration }); let {virtualizerProps} = useVirtualizer({ @@ -631,9 +639,6 @@ function ResizableTableColumnHeader(props) { let columnProps = column.props as SpectrumColumnProps; - if (columnProps.width && columnProps.allowsResizing) { - throw new Error('Controlled state is not yet supported with column resizing. Please use defaultWidth for uncontrolled column resizing or remove the allowsResizing prop.'); - } let {isFocusVisible, focusProps} = useFocusRing(); const onMenuSelect = (key) => { diff --git a/packages/@react-spectrum/table/stories/ControllingResize.tsx b/packages/@react-spectrum/table/stories/ControllingResize.tsx new file mode 100644 index 00000000000..a2a8553b69d --- /dev/null +++ b/packages/@react-spectrum/table/stories/ControllingResize.tsx @@ -0,0 +1,83 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; +import {Checkbox} from '@react-spectrum/checkbox'; +import {Flex} from '@react-spectrum/layout'; +import {Form} from '@react-spectrum/form'; +import React, {Key, useCallback, useMemo, useState} from 'react'; +import {Button} from '@react-spectrum/button'; + +let defaultColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '5fr'} +]; + +let defaultRows = [ + {id: 1, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, + {id: 2, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, + {id: 3, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, + {id: 4, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'}, + {id: 5, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, + {id: 6, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, + {id: 7, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, + {id: 8, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'}, + {id: 9, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, + {id: 10, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, + {id: 11, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, + {id: 12, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'} +]; + +export function ControllingResize(props) { + let {columns = defaultColumns, rows = defaultRows, onResize, ...otherProps} = props; + let [widths, _setWidths] = useState>(() => new Map(columns.filter(col => col.width).map((col) => [col.uid as Key, col.width]))); + + let setWidths = useCallback((vals: Map) => { + let controlledKeys = new Set(columns.filter(col => col.width).map((col) => col.uid as Key)); + let newVals = new Map(Array.from(vals).filter(([key]) => controlledKeys.has(key))); + _setWidths(newVals); + onResize?.(vals); + }, [columns]); + let [savedCols, setSavedCols] = useState(widths); + let [renderKey, setRenderKey] = useState(() => Math.random()); + let cols = useMemo(() => columns.map(col => ({...col})), [columns, widths]); + + return ( +
+ + +
Current saved column state: {'{'}{Array.from(savedCols).map(([key, entry]) => `${key} => ${entry}`).join(',')}{'}'}
+
+ + + {column => {column.name}} + + + {item => ( + + {key => {item[key]}} + + )} + + +
+
+ ); +} diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index 12abd791d9b..8302d61ffe6 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -38,6 +38,7 @@ import {TextField} from '@react-spectrum/textfield'; import {useAsyncList, useListData} from '@react-stately/data'; import {useFilter} from '@react-aria/i18n'; import {View} from '@react-spectrum/view'; +import {ControllingResize} from './ControllingResize'; let columns = [ {name: 'Foo', key: 'foo'}, @@ -1368,8 +1369,41 @@ storiesOf('TableView', module) ), {description: {data: 'Using browser zoom should not trigger an infinite resizing loop. CMD+"+" to zoom in and CMD+"-" to zoom out.'}} + ) + .add( + 'allowsResizing, controlled, no widths', + () => ( + + ) + ) + .add( + 'allowsResizing, controlled, some widths', + () => ( + + ) + ) + .add( + 'allowsResizing, controlled, all widths', + () => ( + + ) ); + +let columnsFR = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Level', uid: 'level', width: '4fr'} +]; + +let columnsSomeFR = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} +]; + function AsyncLoadingExample(props) { const {isResizable} = props; interface Item { diff --git a/packages/@react-spectrum/table/test/TableSizing.test.js b/packages/@react-spectrum/table/test/TableSizing.test.js index e2fe182b85a..7abd6d91981 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.js +++ b/packages/@react-spectrum/table/test/TableSizing.test.js @@ -10,7 +10,6 @@ * governing permissions and limitations under the License. */ - jest.mock('@react-aria/live-announcer'); import {act, render as renderComponent, within} from '@testing-library/react'; import {ActionButton} from '@react-spectrum/button'; @@ -19,10 +18,12 @@ import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; import {fireEvent, installPointerEvent, triggerTouch} from '@react-spectrum/test-utils'; import {HidingColumns} from '../stories/HidingColumns'; import {Provider} from '@react-spectrum/provider'; -import React from 'react'; +import React, {Key} from 'react'; import {setInteractionModality} from '@react-aria/interactions'; import {theme} from '@react-spectrum/theme-default'; import userEvent from '@testing-library/user-event'; +import {ControllingResize} from '../stories/ControllingResize'; +import {resizingTests} from '@react-aria/table/test/ariaTableResizing.test'; let columns = [ {name: 'Foo', key: 'foo'}, @@ -55,6 +56,26 @@ for (let i = 1; i <= 100; i++) { manyItems.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i, baz: 'Baz ' + i}); } +let render = (children, scale = 'medium') => { + let tree = renderComponent( + + {children} + + ); + // account for table column resizing to do initial pass due to relayout from useTableColumnResizeState render + act(() => {jest.runAllTimers();}); + return tree; +}; + +let rerender = (tree, children, scale = 'medium') => { + let newTree = tree.rerender( + + {children} + + ); + act(() => {jest.runAllTimers();}); + return newTree; +}; describe('TableViewSizing', function () { let offsetWidth, offsetHeight; @@ -73,27 +94,6 @@ describe('TableViewSizing', function () { act(() => {jest.runAllTimers();}); }); - let render = (children, scale = 'medium') => { - let tree = renderComponent( - - {children} - - ); - // account for table column resizing to do initial pass due to relayout from useTableColumnResizeState render - act(() => {jest.runAllTimers();}); - return tree; - }; - - let rerender = (tree, children, scale = 'medium') => { - let newTree = tree.rerender( - - {children} - - ); - act(() => {jest.runAllTimers();}); - return newTree; - }; - describe('layout', function () { describe('row heights', function () { let renderTable = (props, scale) => render( @@ -572,7 +572,7 @@ describe('TableViewSizing', function () { }); }); - describe("mutiple columns are bounded but earlier columns are 'less bounded' than future columns", () => { + describe("multiple columns are bounded but earlier columns are 'less bounded' than future columns", () => { it("should satisfy the conditions of all columns but also allocate remaining space to the 'less bounded' previous columns", () => { let tree = render( @@ -1536,6 +1536,48 @@ describe('TableViewSizing', function () { let tooltip = getByRole('tooltip'); expect(tooltip).toBeVisible(); }); - }); }); + + +function getColumnWidths(tree) { + let rows = tree.getAllByRole('row'); + return Array.from(rows[0].childNodes).map((cell) => Number(cell.style.width.replace('px', ''))); +} + +// I'd use tree.getByRole(role, {name: text}) here, but it's unbearably slow. +function getColumn(tree, name) { + // Find by text, then go up to the element with the cell role. + let el = tree.getByText(name); + while (el && !/columnheader/.test(el.getAttribute('role'))) { + el = el.parentElement; + } + + return el; +} + +function resizeCol(tree, col, delta) { + let column = getColumn(tree, col); + + // trigger pointer modality + fireEvent.pointerMove(tree.container); + + fireEvent.pointerEnter(column); + let resizer = within(column).getByRole('slider'); + fireEvent.pointerEnter(resizer); + + // actual locations do not matter, the delta matters between events for the calculation of useMove + fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 0, pageY: 30}); + fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: delta, pageY: 25}); + fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); + act(() => {jest.runAllTimers();}); +} + + +function resizeTable(clientWidth, newValue) { + clientWidth.mockImplementation(() => newValue); + fireEvent(window, new Event('resize')); + act(() => {jest.runAllTimers()}); +} + +resizingTests(render, rerender, ControllingResize, ControllingResize, resizeCol, resizeTable); diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 285283458e9..64f831e87a5 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -181,6 +181,7 @@ export class TableLayout extends ListLayout { controlledWidths: Map>; uncontrolledWidths: Map>; widths: Map; + lastVirtualizerWidth: number; constructor(options: TableLayoutOptions) { super(options); @@ -189,6 +190,7 @@ export class TableLayout extends ListLayout { this.disableSticky = this.checkChrome105(); this.columnLayout = options.columnLayout; this.getSplitColumns(); + this.lastVirtualizerWidth = 0; this.widths = new Map(Array.from(this.uncontrolledWidths).map(([key, col]) => [key, col.props.defaultWidth ?? this.columnLayout.getDefaultWidth?.(col.props)] )); @@ -224,11 +226,9 @@ export class TableLayout extends ListLayout { let newControlled = new Map(Array.from(this.controlledWidths).map(([key, entry]) => [key, entry.props.width])); let newSizes = this.columnLayout.resizeColumnWidth(this.virtualizer.visibleRect.width, this.collection, newControlled, this.widths, column.key, width); - if (!column.props.width) { - let map = new Map(Array.from(this.uncontrolledWidths).map(([key]) => [key, newSizes.get(key)])); - map.set(column.key, width); - this.widths = map; - } + let map = new Map(Array.from(this.uncontrolledWidths).map(([key]) => [key, newSizes.get(key)])); + map.set(column.key, width); + this.widths = map; // relayoutNow still uses setState, should happen at the same time the parent // component's state is processed as a result of props.onColumnResize this.virtualizer.relayoutNow({sizeChanged: true}); @@ -277,11 +277,13 @@ export class TableLayout extends ListLayout { c.props.width !== this.lastCollection.columns[i].props.width || c.props.minWidth !== this.lastCollection.columns[i].props.minWidth || c.props.maxWidth !== this.lastCollection.columns[i].props.maxWidth - ) + ) || + this.virtualizer.visibleRect.width !== this.lastVirtualizerWidth ) { // Invalidate everything in this layout pass. Will be reset in ListLayout on the next pass. this.invalidateEverything = true; } + this.lastVirtualizerWidth = this.virtualizer.visibleRect.width; // Track whether we were previously loading. This is used to adjust the animations of async loading vs inserts. let loadingState = this.collection.body.props.loadingState; @@ -298,6 +300,7 @@ export class TableLayout extends ListLayout { } this.columnWidths = this.columnLayout.buildColumnWidths(this.virtualizer.visibleRect.width, this.collection, cWidths); + let header = this.buildHeader(); let body = this.buildBody(0); this.lastPersistedKeys = null; diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index d2cd54f70ce..587c2f2f592 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -52,7 +52,7 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps, } return acc; }, [new Map(), new Map()]) - , [state.collection.columns]); + , [state.collection.columns]); // is this a safe thing to memo on? what if a single column changes? // uncontrolled column widths let [widths, setWidths] = useState>(() => new Map( @@ -79,11 +79,10 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps, let newControlled = new Map(Array.from(controlledWidths).map(([key, entry]) => [key, entry.props.width])); let newSizes = columnLayout.resizeColumnWidth(tableWidth, state.collection, newControlled, widths, column.key, width); - if (!column.props.width) { - let map = new Map(Array.from(uncontrolledWidths).map(([key]) => [key, newSizes.get(key)])); - map.set(column.key, width); - setWidths(map); - } + let map = new Map(Array.from(uncontrolledWidths).map(([key]) => [key, newSizes.get(key)])); + map.set(column.key, width); + setWidths(map); + return newSizes; }, [controlledWidths, uncontrolledWidths, props.onColumnResize, setWidths, tableWidth, columnLayout, state.collection, widths]); From 83c2bd1bad35e300472d570cdb92c399fa38d5fb Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 29 Nov 2022 21:05:06 +1100 Subject: [PATCH 08/42] rename some props, refactor resizer so it sets on top of everything --- .../components/table/index.css | 8 +- .../components/table/skin.css | 4 +- .../table/src/useTableColumnHeader.ts | 13 +- .../table/src/useTableColumnResize.ts | 12 +- .../@react-spectrum/table/src/TableView.tsx | 190 ++++++++++-------- .../table/stories/ControllingResize.tsx | 2 +- .../table/stories/Table.stories.tsx | 2 +- ...bleSizing.test.js => TableSizing.test.tsx} | 80 ++++---- .../@react-stately/layout/src/TableLayout.ts | 29 ++- packages/@react-stately/layout/src/index.ts | 2 +- .../table/src/useTableColumnResizeState.ts | 35 ++-- packages/@react-types/table/src/index.d.ts | 14 +- 12 files changed, 213 insertions(+), 178 deletions(-) rename packages/@react-spectrum/table/test/{TableSizing.test.js => TableSizing.test.tsx} (95%) diff --git a/packages/@adobe/spectrum-css-temp/components/table/index.css b/packages/@adobe/spectrum-css-temp/components/table/index.css index 4384583a26e..a4ad7b8114a 100644 --- a/packages/@adobe/spectrum-css-temp/components/table/index.css +++ b/packages/@adobe/spectrum-css-temp/components/table/index.css @@ -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; diff --git a/packages/@adobe/spectrum-css-temp/components/table/skin.css b/packages/@adobe/spectrum-css-temp/components/table/skin.css index 73845d5aec8..9a597f6e39f 100644 --- a/packages/@adobe/spectrum-css-temp/components/table/skin.css +++ b/packages/@adobe/spectrum-css-temp/components/table/skin.css @@ -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 { diff --git a/packages/@react-aria/table/src/useTableColumnHeader.ts b/packages/@react-aria/table/src/useTableColumnHeader.ts index 428f1710212..1a50bd5ecbf 100644 --- a/packages/@react-aria/table/src/useTableColumnHeader.ts +++ b/packages/@react-aria/table/src/useTableColumnHeader.ts @@ -28,10 +28,8 @@ export interface AriaTableColumnHeaderProps { node: GridNode, /** 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 + /** Where focus should go when it arrives at a cell. This can be used to send focus to a Menu Trigger. */ + focusMode?: 'child' | 'cell' } export interface TableColumnHeaderAria { @@ -49,7 +47,7 @@ export function useTableColumnHeader(props: AriaTableColumnHeaderProps, state 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); + let {gridCellProps} = useGridCell(props, state, ref); let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single'; @@ -64,11 +62,6 @@ export function useTableColumnHeader(props: AriaTableColumnHeaderProps, state // Needed to pick up the focusable context, enabling things like Tooltips for example let {focusableProps} = useFocusable({}, ref); - // try to just delete this, but figure out why it causes an extra focus target - if (props.hasMenu) { - pressProps = {}; - } - let ariaSort: DOMAttributes['aria-sort'] = null; let isSortedColumn = state.sortDescriptor?.column === node.key; let sortDirection = state.sortDescriptor?.direction; diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 2a14dbf9355..f6f86a44d53 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -57,6 +57,12 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st stateRef.current = layoutState; const stringFormatter = useLocalizedStringFormatter(intlMessages); let id = useId(); + let min = Math.floor(stateRef.current.getColumnMinWidth(item.key)); + let max = Math.floor(stateRef.current.getColumnMaxWidth(item.key)); + if (max === Infinity) { + max = Number.MAX_SAFE_INTEGER; + } + let value = Math.floor(stateRef.current.getColumnWidth(item.key)); let {direction} = useLocale(); let {keyboardProps} = useKeyboard({ @@ -105,12 +111,6 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } } }); - let min = Math.floor(stateRef.current.getColumnMinWidth(item.key)); - let max = Math.floor(stateRef.current.getColumnMaxWidth(item.key)); - if (max === Infinity) { - max = Number.MAX_SAFE_INTEGER; - } - let value = Math.floor(stateRef.current.getColumnWidth(item.key)); let ariaProps = { 'aria-label': props.label, diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 9cdc04e34b4..7641c742c96 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -33,19 +33,13 @@ import {layoutInfoToStyle, ScrollView, setScrollLeft, useVirtualizer, Virtualize import {Nubbin} from './Nubbin'; import {ProgressCircle} from '@react-spectrum/progress'; import React, {Key, ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import {Rect, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer'; +import {Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {Resizer} from './Resizer'; -import {SpectrumColumnProps, SpectrumTableProps} from '@react-types/table'; +import {ColumnSize, SpectrumColumnProps, SpectrumTableProps} from '@react-types/table'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; import stylesOverrides from './table.css'; -import {TableColumnResizeState, TableState, useTableColumnResizeState, useTableState} from '@react-stately/table'; -import {TableLayout} from '@react-stately/layout'; -import {Tooltip, TooltipTrigger} from '@react-spectrum/tooltip'; -import {useButton} from '@react-aria/button'; -import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; -import {useProvider, useProviderProps} from '@react-spectrum/provider'; +import {TableColumnLayout, TableLayout} from '@react-stately/layout'; import { - TableLayoutState, useTable, useTableCell, useTableColumnHeader, @@ -55,8 +49,12 @@ import { useTableSelectAllCheckbox, useTableSelectionCheckbox } from '@react-aria/table'; +import {TableState, useTableState} from '@react-stately/table'; +import {Tooltip, TooltipTrigger} from '@react-spectrum/tooltip'; +import {useButton} from '@react-aria/button'; +import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useProvider, useProviderProps} from '@react-spectrum/provider'; import {VisuallyHidden} from '@react-aria/visually-hidden'; -import {TableColumnLayout} from '@react-stately/layout/src/TableLayout'; const DEFAULT_HEADER_HEIGHT = { medium: 34, @@ -90,17 +88,17 @@ const SELECTION_CELL_DEFAULT_WIDTH = { interface TableContextValue { state: TableState, - columnState: TableLayoutState layout: TableLayout, headerRowHovered: boolean, isInResizeMode: boolean, setIsInResizeMode: (val: boolean) => void, isEmpty: boolean, onFocusedResizer: () => void, - onColumnResizeStart: () => void, - onColumnResize: (widths: Map) => void, - onColumnResizeEnd: () => void, - onMoveResizer: (e: MoveMoveEvent) => void + onResizeStart: (key: Key) => void, + onResize: (widths: Map) => void, + onResizeEnd: (key: Key) => void, + onMoveResizer: (e: MoveMoveEvent) => void, + isQuiet: boolean } const TableContext = React.createContext>(null); @@ -356,9 +354,13 @@ function TableView(props: SpectrumTableProps, ref: DOMRef { + setIsInResizeMode(false); + props.onResizeEnd?.(val); + }, [props.onResizeEnd, setIsInResizeMode]); return ( - + (props: SpectrumTableProps, ref: DOMRef acc + layout.getColumnWidth(key), 0) - 2; - let resizerAtEdge = resizerPosition > Math.max(state.virtualizer.contentSize.width, state.virtualizer.visibleRect.width) - 3; - // this should be fine, every movement of the resizer causes a rerender - // scrolling can cause it to lag for a moment, but it's always updated - let resizerInVisibleRegion = resizerPosition < state.virtualizer.visibleRect.width + (isNaN(bodyRef.current?.scrollLeft) ? 0 : bodyRef.current?.scrollLeft); - let shouldHardCornerResizeCorner = resizerAtEdge && resizerInVisibleRegion; + let resizerPosition = layout.columnLayout.getResizerPosition() - state.virtualizer.visibleRect.x - 1; + if (isQuiet) { + resizerPosition = resizerPosition - 1; + } + // TODO: can I introduce this wrapper? style and classname would be applied below return (
- {state.visibleViews[0]} + {...mergeProps(otherProps, virtualizerProps)}> +
+ {state.visibleViews[0]} +
+ + {state.visibleViews[1]} +
- state.virtualizer.visibleRect.width ? 'hidden' : undefined, + isolation: 'isolate', + pointerEvents: 'none' + }}> +
- {state.visibleViews[1]} -
- + )}> + +
+
); @@ -554,13 +570,14 @@ function TableColumnHeader(props) { let ref = useRef(null); let {state, isEmpty} = useTableContext(); let {pressProps, isPressed} = usePress({isDisabled: isEmpty}); + let columnProps = column.props as SpectrumColumnProps; + let {columnHeaderProps} = useTableColumnHeader({ node: column, - isVirtualized: true + isVirtualized: true, + focusMode: columnProps.allowsSorting ? 'child' : 'cell' }, state, ref); - let columnProps = column.props as SpectrumColumnProps; - let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty}); const allProps = [columnHeaderProps, hoverProps, pressProps]; @@ -624,13 +641,24 @@ function ResizableTableColumnHeader(props) { let ref = useRef(null); let triggerRef = useRef(null); let resizingRef = useRef(null); - let {state, columnState, layout, onColumnResizeStart, onColumnResize, onColumnResizeEnd, headerRowHovered, setIsInResizeMode, isInResizeMode, isEmpty, onFocusedResizer, onMoveResizer} = useTableContext(); + let { + state, + layout, + onResizeStart, + onResize, + onResizeEnd, + headerRowHovered, + setIsInResizeMode, + isEmpty, + onFocusedResizer, + onMoveResizer + } = useTableContext(); let stringFormatter = useLocalizedStringFormatter(intlMessages); let {pressProps, isPressed} = usePress({isDisabled: isEmpty}); let {columnHeaderProps} = useTableColumnHeader({ node: column, isVirtualized: true, - hasMenu: true + focusMode: 'child' }, state, ref); let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty}); @@ -753,9 +781,9 @@ function ResizableTableColumnHeader(props) { column={column} layout={layout} showResizer={showResizer} - onResizeStart={onColumnResizeStart} - onResize={onColumnResize} - onResizeEnd={onColumnResizeEnd} + onResizeStart={onResizeStart} + onResize={onResize} + onResizeEnd={onResizeEnd} triggerRef={useUnwrapDOMRef(triggerRef)} onMoveResizer={onMoveResizer} />
-
- -
@@ -790,7 +807,8 @@ function TableSelectAllCell({column}) { let isSingleSelectionMode = state.selectionManager.selectionMode === 'single'; let {columnHeaderProps} = useTableColumnHeader({ node: column, - isVirtualized: true + isVirtualized: true, + focusMode: 'child' }, state, ref); let {checkboxProps} = useTableSelectAllCheckbox(state); diff --git a/packages/@react-spectrum/table/stories/ControllingResize.tsx b/packages/@react-spectrum/table/stories/ControllingResize.tsx index a2a8553b69d..4e0f80568a4 100644 --- a/packages/@react-spectrum/table/stories/ControllingResize.tsx +++ b/packages/@react-spectrum/table/stories/ControllingResize.tsx @@ -65,7 +65,7 @@ export function ControllingResize(props) { }}>Restore Cols
Current saved column state: {'{'}{Array.from(savedCols).map(([key, entry]) => `${key} => ${entry}`).join(',')}{'}'}
- + {column => {column.name}} diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index 8302d61ffe6..34d0ec7e235 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -1306,7 +1306,7 @@ storiesOf('TableView', module) .add( 'allowsResizing, onColumnResize action', () => ( - + File Name Type diff --git a/packages/@react-spectrum/table/test/TableSizing.test.js b/packages/@react-spectrum/table/test/TableSizing.test.tsx similarity index 95% rename from packages/@react-spectrum/table/test/TableSizing.test.js rename to packages/@react-spectrum/table/test/TableSizing.test.tsx index 7abd6d91981..b3e791c4eba 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.js +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -645,9 +645,9 @@ describe('TableViewSizing', function () { it('dragging the resizer works - desktop', () => { jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); - let onColumnResizeEnd = jest.fn(); + let onResizeEnd = jest.fn(); let tree = render( - + Foo Bar @@ -694,8 +694,8 @@ describe('TableViewSizing', function () { expect(row.childNodes[1].style.width).toBe('200px'); expect(row.childNodes[2].style.width).toBe('200px'); } - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith('foo'); // actual locations do not matter, the delta matters between events for the calculation of useMove fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 30}); @@ -708,8 +708,8 @@ describe('TableViewSizing', function () { expect(row.childNodes[1].style.width).toBe('190px'); expect(row.childNodes[2].style.width).toBe('190px'); } - expect(onColumnResizeEnd).toHaveBeenCalledTimes(2); - expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledTimes(2); + expect(onResizeEnd).toHaveBeenCalledWith('foo'); fireEvent.pointerLeave(resizer, {pointerType: 'mouse', pointerId: 1}); fireEvent.pointerLeave(resizableHeader, {pointerType: 'mouse', pointerId: 1}); @@ -719,9 +719,9 @@ describe('TableViewSizing', function () { }); it('dragging the resizer works - mobile', () => { - let onColumnResizeEnd = jest.fn(); + let onResizeEnd = jest.fn(); let tree = render( - + Foo Bar @@ -768,8 +768,8 @@ describe('TableViewSizing', function () { expect(row.childNodes[1].style.width).toBe('200px'); expect(row.childNodes[2].style.width).toBe('200px'); } - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith('foo'); // actual locations do not matter, the delta matters between events for the calculation of useMove fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 30}); @@ -782,8 +782,8 @@ describe('TableViewSizing', function () { expect(row.childNodes[1].style.width).toBe('190px'); expect(row.childNodes[2].style.width).toBe('190px'); } - expect(onColumnResizeEnd).toHaveBeenCalledTimes(2); - expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledTimes(2); + expect(onResizeEnd).toHaveBeenCalledWith('foo'); fireEvent.pointerLeave(resizer, {pointerType: 'mouse', pointerId: 1}); fireEvent.pointerLeave(resizableHeader, {pointerType: 'mouse', pointerId: 1}); @@ -799,9 +799,9 @@ describe('TableViewSizing', function () { it('dragging the resizer works - desktop', () => { setInteractionModality('pointer'); jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); - let onColumnResizeEnd = jest.fn(); + let onResizeEnd = jest.fn(); let tree = render( - + Foo Bar @@ -872,17 +872,17 @@ describe('TableViewSizing', function () { act(() => resizer.blur()); act(() => {jest.runAllTimers();}); - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith('foo'); expect(tree.queryByRole('slider')).toBeNull(); }); it('dragging the resizer works - mobile', () => { setInteractionModality('pointer'); - let onColumnResizeEnd = jest.fn(); + let onResizeEnd = jest.fn(); let tree = render( - + Foo Bar @@ -957,8 +957,8 @@ describe('TableViewSizing', function () { act(() => resizer.blur()); act(() => {jest.runAllTimers();}); - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith('foo'); expect(tree.queryByRole('slider')).toBeNull(); }); @@ -967,9 +967,9 @@ describe('TableViewSizing', function () { describe('keyboard', () => { it('arrow keys the resizer works - desktop', async () => { jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); - let onColumnResizeEnd = jest.fn(); + let onResizeEnd = jest.fn(); let tree = render( - + Foo Bar @@ -1064,17 +1064,17 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Escape'}); fireEvent.keyUp(document.activeElement, {key: 'Escape'}); - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith('foo'); expect(document.activeElement).toBe(resizableHeader); expect(tree.queryByRole('slider')).toBeNull(); }); it('arrow keys the resizer works - mobile', async () => { - let onColumnResizeEnd = jest.fn(); + let onResizeEnd = jest.fn(); let tree = render( - + Foo Bar @@ -1145,8 +1145,8 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Escape'}); fireEvent.keyUp(document.activeElement, {key: 'Escape'}); - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith('foo'); expect(document.activeElement).toBe(resizableHeader); @@ -1154,9 +1154,9 @@ describe('TableViewSizing', function () { }); it('can exit resize via Enter', async () => { jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); - let onColumnResizeEnd = jest.fn(); + let onResizeEnd = jest.fn(); let tree = render( - + Foo Bar @@ -1196,8 +1196,8 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Enter'}); fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith('foo'); expect(document.activeElement).toBe(resizableHeader); @@ -1205,9 +1205,9 @@ describe('TableViewSizing', function () { }); it('can exit resize via Tab', async () => { jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); - let onColumnResizeEnd = jest.fn(); + let onResizeEnd = jest.fn(); let tree = render( - + Foo Bar @@ -1246,8 +1246,8 @@ describe('TableViewSizing', function () { expect(document.activeElement).toBe(resizer); userEvent.tab(); - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith('foo'); expect(document.activeElement).toBe(resizableHeader); @@ -1255,9 +1255,9 @@ describe('TableViewSizing', function () { }); it('can exit resize via shift Tab', async () => { jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); - let onColumnResizeEnd = jest.fn(); + let onResizeEnd = jest.fn(); let tree = render( - + Foo Bar @@ -1296,8 +1296,8 @@ describe('TableViewSizing', function () { expect(document.activeElement).toBe(resizer); userEvent.tab({shift: true}); - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith('foo'); expect(document.activeElement).toBe(resizableHeader); diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 64f831e87a5..892007848ce 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -15,8 +15,7 @@ import { getMaxWidth, getMinWidth, isStatic, - parseFractionalUnit, - parseStaticWidth + parseFractionalUnit } from './TableUtils'; import {GridNode} from '@react-types/grid'; import {Key} from 'react'; @@ -38,8 +37,8 @@ export class TableColumnLayout { getDefaultWidth: (props) => string | number; getDefaultMinWidth: (props) => string | number; columnWidths: Map = new Map(); + resizerPositions: Map = new Map(); changedColumns: Map = new Map(); - uncontrolledColumnWidths: Map = new Map(); constructor(options: TableColumnLayoutOptions) { this.getDefaultWidth = options.getDefaultWidth; @@ -56,23 +55,31 @@ export class TableColumnLayout { this.resizingColumn = key; } + getResizerPosition(): number { + return this.resizerPositions.get(this.resizingColumn); + } + getColumnWidth(key: Key): number { return this.columnWidths.get(key) ?? 0; } + // TODO: need to send a grid node in so we can use getDefaultMinWidth getColumnMinWidth(minWidth: number | string, tableWidth: number): number { - return getMinWidth(minWidth, tableWidth); + return getMinWidth(minWidth ?? this.getDefaultMinWidth({}), tableWidth); } getColumnMaxWidth(maxWidth: number | string, tableWidth: number): number { return getMaxWidth(maxWidth, tableWidth); } - resizeColumnWidth(tableWidth: number, collection: TableCollection, controlledWidths, uncontrolledWidths, col = null, width) { + resizeColumnWidth(tableWidth: number, collection: TableCollection, controlledWidths: Map, uncontrolledWidths: Map, col = null, width: number) { let prevColumnWidths = this.columnWidths; // resizing a column let resizeIndex = Infinity; - let resizingChanged = new Map(Array.from(controlledWidths).concat(Array.from(uncontrolledWidths))); + let controlledArray = Array.from(controlledWidths); + let uncontrolledArray = Array.from(uncontrolledWidths); + let combinedArray = controlledArray.concat(uncontrolledArray); + let resizingChanged = new Map(combinedArray); let frKeys = new Map(); let percentKeys = new Map(); let frKeysToTheRight = new Map(); @@ -86,7 +93,7 @@ export class TableColumnLayout { if (col !== column.key && !column.column.props.width && !isStatic(uncontrolledWidths.get(column.key))) { // uncontrolled don't have props.width for us, so instead get from our state frKey = column.key; - frKeys.set(column.key, parseFractionalUnit(uncontrolledWidths.get(column.key))); + frKeys.set(column.key, parseFractionalUnit(uncontrolledWidths.get(column.key) as string)); } else if (col !== column.key && !isStatic(column.column.props.width) && !uncontrolledWidths.get(column.key)) { // controlledWidths will be the same in the collection frKey = column.key; @@ -149,6 +156,7 @@ export class TableColumnLayout { buildColumnWidths(tableWidth: number, collection: TableCollection, controlledWidths) { this.columnWidths = new Map(); + this.resizerPositions = new Map(); // initial layout or table/window resizing let columnWidths = calculateColumnSizes( @@ -160,8 +168,12 @@ export class TableColumnLayout { ); // columns going in will be the same order as the columns coming out + let resizerPosition = 0; columnWidths.forEach((width, index) => { - this.columnWidths.set(collection.columns[index].key, width); + let key = collection.columns[index].key; + this.columnWidths.set(key, width); + resizerPosition += width; + this.resizerPositions.set(key, resizerPosition); }); return this.columnWidths; } @@ -235,6 +247,7 @@ export class TableLayout extends ListLayout { return newSizes; } + // TODO: need to trigger a state update to turn off the resize blue bar onColumnResizeEnd(column: GridNode): void { this.columnLayout.setResizingColumn(null); } diff --git a/packages/@react-stately/layout/src/index.ts b/packages/@react-stately/layout/src/index.ts index 7197e7e5fb8..a999eb76f75 100644 --- a/packages/@react-stately/layout/src/index.ts +++ b/packages/@react-stately/layout/src/index.ts @@ -11,4 +11,4 @@ */ export type {ListLayoutOptions, LayoutNode} from './ListLayout'; export {ListLayout} from './ListLayout'; -export {TableLayout} from './TableLayout'; +export {TableLayout, TableColumnLayout} from './TableLayout'; diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 587c2f2f592..9ec53d5cbf0 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -1,8 +1,26 @@ +import {ColumnSize} from '@react-types/table'; import {GridNode} from '@react-types/grid'; import {Key, useCallback, useMemo, useState} from 'react'; import {TableColumnLayout} from '@react-stately/layout/src/TableLayout'; +export interface TableColumnResizeStateProps { + /** + * Current width of the table or table viewport that the columns + * should be calculated against. + **/ + tableWidth: number, + /** A function that is called to find the default width for a given column. */ + getDefaultWidth: (node: GridNode) => ColumnSize, + /** A function that is called to find the default minWidth for a given column. */ + getDefaultMinWidth: (node: GridNode) => ColumnSize, + /** Callback that is invoked during the entirety of the resize event. */ + onColumnResize?: (widths: Map) => void, + /** Callback that is invoked when the resize event is started. */ + onColumnResizeStart?: (key: Key) => void, + /** Callback that is invoked when the resize event is ended. */ + onColumnResizeEnd?: (key: Key) => void +} export interface TableColumnResizeState { /** Trigger a resize and recalculation. */ onColumnResize: (column: GridNode, width: number) => void, @@ -10,25 +28,18 @@ export interface TableColumnResizeState { onColumnResizeStart: (column: GridNode) => void, /** Callback for when onColumnResize has ended. */ onColumnResizeEnd: (column: GridNode) => void, + /** Gets the current width for the specified column. */ getColumnWidth: (key: Key) => number, + /** Gets the current minWidth for the specified column. */ getColumnMinWidth: (key: Key) => number, + /** Gets the current maxWidth for the specified column. */ getColumnMaxWidth: (key: Key) => number, + /** Currently calculated widths for all columns. */ widths: Map } -export interface TableColumnResizeStateProps { - tableWidth: number, - getDefaultWidth, - getDefaultMinWidth, - /** Callback that is invoked during the entirety of the resize event. */ - onColumnResize?: (widths: Map) => void, - /** Callback that is invoked when the resize event is started. */ - onColumnResizeStart?: (key: Key) => void, - /** Callback that is invoked when the resize event is ended. */ - onColumnResizeEnd?: (key: Key) => void -} -export function useTableColumnResizeState(props: TableColumnResizeStateProps, state): TableColumnResizeState { +export function useTableColumnResizeState(props: TableColumnResizeStateProps, state): TableColumnResizeState { let { getDefaultWidth, getDefaultMinWidth diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index ad9e99d0af9..ffc159be50e 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -14,6 +14,7 @@ import {AriaLabelingProps, AsyncLoadable, CollectionChildren, DOMProps, LoadingS import {GridCollection, GridNode} from '@react-types/grid'; import {Key, ReactElement, ReactNode} from 'react'; +export type ColumnSize = number | `${number}fr` | `${number}%`; export interface TableProps extends MultipleSelection, Sortable { /** The elements that make up the table. Includes the TableHeader, TableBody, Columns, and Rows. */ children: [ReactElement>, ReactElement>], @@ -40,14 +41,13 @@ export interface SpectrumTableProps extends TableProps, SpectrumSelectionP onAction?: (key: Key) => void, /** * Handler that is called when a user performs a column resize. + * Can be used with the width property on columns to put the column widths into + * a controlled state. * @private */ - onColumnResize?: (widths: Map) => void, - /** - * Handler that is called when a column resize ends. - * @private - */ - onColumnResizeEnd?: (key: Key) => void + onResize?: (widths: Map) => void, + onResizeStart?: (key: Key) => void, + onResizeEnd?: (key: Key) => void } export interface TableHeaderProps { @@ -67,7 +67,7 @@ export interface ColumnProps { /** A list of child columns used when dynamically rendering nested child columns. */ childColumns?: T[], /** The width of the column. */ - width?: number | string, + width?: ColumnSize, /** The minimum width of the column. */ minWidth?: number | string, /** The maximum width of the column. */ From 36804cb1ec62b07b1ca01fcd64b7c4604e9e696a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 30 Nov 2022 17:04:32 +1100 Subject: [PATCH 09/42] restore logic to fix tests --- .../components/table/index.css | 1 + packages/@react-aria/table/package.json | 1 - .../table/src/useTableColumnHeader.ts | 15 +- .../table/src/useTableColumnResize.ts | 13 +- .../@react-spectrum/table/src/TableView.tsx | 132 ++++++++---------- .../@react-stately/layout/src/TableLayout.ts | 8 -- packages/@react-types/table/src/index.d.ts | 16 +-- 7 files changed, 84 insertions(+), 102 deletions(-) diff --git a/packages/@adobe/spectrum-css-temp/components/table/index.css b/packages/@adobe/spectrum-css-temp/components/table/index.css index a4ad7b8114a..46ce4b2aa7a 100644 --- a/packages/@adobe/spectrum-css-temp/components/table/index.css +++ b/packages/@adobe/spectrum-css-temp/components/table/index.css @@ -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 */ diff --git a/packages/@react-aria/table/package.json b/packages/@react-aria/table/package.json index 35d727c117e..99bb20097bb 100644 --- a/packages/@react-aria/table/package.json +++ b/packages/@react-aria/table/package.json @@ -30,7 +30,6 @@ "@react-aria/live-announcer": "^3.1.1", "@react-aria/selection": "^3.12.0", "@react-aria/utils": "^3.14.1", - "@react-stately/layout": "^3.9.0", "@react-stately/table": "^3.6.0", "@react-stately/virtualizer": "^3.4.0", "@react-types/checkbox": "^3.4.1", diff --git a/packages/@react-aria/table/src/useTableColumnHeader.ts b/packages/@react-aria/table/src/useTableColumnHeader.ts index 1a50bd5ecbf..4e2a708b6e7 100644 --- a/packages/@react-aria/table/src/useTableColumnHeader.ts +++ b/packages/@react-aria/table/src/useTableColumnHeader.ts @@ -23,13 +23,13 @@ import {useGridCell} from '@react-aria/grid'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {usePress} from '@react-aria/interactions'; -export interface AriaTableColumnHeaderProps { +export interface AriaTableColumnHeaderProps { /** 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, + node: GridNode, /** Whether the [column header](https://www.w3.org/TR/wai-aria-1.1/#columnheader) is contained in a virtual scroller. */ isVirtualized?: boolean, /** Where focus should go when it arrives at a cell. This can be used to send focus to a Menu Trigger. */ - focusMode?: 'child' | 'cell' + hasMenu?: boolean } export interface TableColumnHeaderAria { @@ -43,11 +43,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(props: AriaTableColumnHeaderProps, state: TableState, ref: RefObject): TableColumnHeaderAria { +export function useTableColumnHeader(props: AriaTableColumnHeaderProps, state: TableState, ref: RefObject): 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, state, ref); + let {gridCellProps} = useGridCell({...props, focusMode: node.props.isSelectionCell || props.hasMenu || node.props.allowsSorting ? 'child' : 'cell'}, state, ref); let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single'; @@ -59,6 +59,11 @@ export function useTableColumnHeader(props: AriaTableColumnHeaderProps, state ref }); + // try to just delete this, but figure out why it causes an extra focus target + if (props.hasMenu) { + pressProps = {}; + } + // Needed to pick up the focusable context, enabling things like Tooltips for example let {focusableProps} = useFocusable({}, ref); diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index f6f86a44d53..3185314648b 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -21,7 +21,6 @@ import intlMessages from '../intl/*.json'; import {TableState} from '@react-stately/table'; import {useKeyboard, useMove, usePress} from '@react-aria/interactions'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; -import {TableLayout} from '@react-stately/layout'; export interface TableColumnResizeAria { inputProps: DOMAttributes, @@ -57,12 +56,6 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st stateRef.current = layoutState; const stringFormatter = useLocalizedStringFormatter(intlMessages); let id = useId(); - let min = Math.floor(stateRef.current.getColumnMinWidth(item.key)); - let max = Math.floor(stateRef.current.getColumnMaxWidth(item.key)); - if (max === Infinity) { - max = Number.MAX_SAFE_INTEGER; - } - let value = Math.floor(stateRef.current.getColumnWidth(item.key)); let {direction} = useLocale(); let {keyboardProps} = useKeyboard({ @@ -112,6 +105,12 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } }); + let min = Math.floor(stateRef.current.getColumnMinWidth(item.key)); + let max = Math.floor(stateRef.current.getColumnMaxWidth(item.key)); + if (max === Infinity) { + max = Number.MAX_SAFE_INTEGER; + } + let value = Math.floor(stateRef.current.getColumnWidth(item.key)); let ariaProps = { 'aria-label': props.label, 'aria-orientation': 'horizontal' as 'horizontal', diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 7641c742c96..5f559dea032 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -354,9 +354,9 @@ function TableView(props: SpectrumTableProps, ref: DOMRef { + let onResizeEnd = useCallback((key) => { setIsInResizeMode(false); - props.onResizeEnd?.(val); + props.onResizeEnd?.(key); }, [props.onResizeEnd, setIsInResizeMode]); return ( @@ -478,78 +478,60 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo } }, [state.contentSize, state.virtualizer, state.isAnimating, onLoadMore, isLoading]); - let resizerPosition = layout.columnLayout.getResizerPosition() - state.virtualizer.visibleRect.x - 1; - if (isQuiet) { - resizerPosition = resizerPosition - 1; - } + let resizerPosition = layout.columnLayout.getResizerPosition() - 2; + + let resizerAtEdge = resizerPosition > Math.max(state.virtualizer.contentSize.width, state.virtualizer.visibleRect.width) - 3; + // this should be fine, every movement of the resizer causes a rerender + // scrolling can cause it to lag for a moment, but it's always updated + let resizerInVisibleRegion = resizerPosition < state.virtualizer.visibleRect.width + (isNaN(bodyRef.current?.scrollLeft) ? 0 : bodyRef.current?.scrollLeft); + let shouldHardCornerResizeCorner = resizerAtEdge && resizerInVisibleRegion; // TODO: can I introduce this wrapper? style and classname would be applied below return (
-
- {state.visibleViews[0]} -
- - {state.visibleViews[1]} - -
-
state.virtualizer.visibleRect.width ? 'hidden' : undefined, - isolation: 'isolate', - pointerEvents: 'none' - }}> -
+ {state.visibleViews[0]} +
+ - -
-
+ ) + } + tabIndex={-1} + style={{flex: 1}} + innerStyle={{overflow: 'visible', transition: state.isAnimating ? `none ${state.virtualizer.transitionDuration}ms` : undefined}} + ref={bodyRef} + contentSize={state.contentSize} + onVisibleRectChange={chain(onVisibleRectChange, onVisibleRectChangeProp)} + onScrollStart={state.startScrolling} + onScrollEnd={state.endScrolling} + onScroll={onScroll}> + {state.visibleViews[1]} +
+
); @@ -574,8 +556,7 @@ function TableColumnHeader(props) { let {columnHeaderProps} = useTableColumnHeader({ node: column, - isVirtualized: true, - focusMode: columnProps.allowsSorting ? 'child' : 'cell' + isVirtualized: true }, state, ref); let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty}); @@ -651,14 +632,15 @@ function ResizableTableColumnHeader(props) { setIsInResizeMode, isEmpty, onFocusedResizer, - onMoveResizer + onMoveResizer, + isInResizeMode } = useTableContext(); let stringFormatter = useLocalizedStringFormatter(intlMessages); let {pressProps, isPressed} = usePress({isDisabled: isEmpty}); let {columnHeaderProps} = useTableColumnHeader({ node: column, isVirtualized: true, - focusMode: 'child' + hasMenu: true }, state, ref); let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty}); @@ -792,9 +774,20 @@ function ResizableTableColumnHeader(props) { styles, 'spectrum-Table-colResizeIndicator', { - 'spectrum-Table-colResizeIndicator--visible': resizingColumn != null + 'spectrum-Table-colResizeIndicator--visible': resizingColumn != null, + 'spectrum-Table-colResizeIndicator--resizing': resizingColumn === column.key } )}> +
+ +
@@ -807,8 +800,7 @@ function TableSelectAllCell({column}) { let isSingleSelectionMode = state.selectionManager.selectionMode === 'single'; let {columnHeaderProps} = useTableColumnHeader({ node: column, - isVirtualized: true, - focusMode: 'child' + isVirtualized: true }, state, ref); let {checkboxProps} = useTableSelectAllCheckbox(state); diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 892007848ce..5716b9c8195 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -38,19 +38,12 @@ export class TableColumnLayout { getDefaultMinWidth: (props) => string | number; columnWidths: Map = new Map(); resizerPositions: Map = new Map(); - changedColumns: Map = new Map(); constructor(options: TableColumnLayoutOptions) { this.getDefaultWidth = options.getDefaultWidth; this.getDefaultMinWidth = options.getDefaultMinWidth; } - // can probably delete this - setResizeColumnWidth(width: number): void { - this.changedColumns.set(this.resizingColumn, width); - } - - // can probably delete this setResizingColumn(key: Key | null): void { this.resizingColumn = key; } @@ -247,7 +240,6 @@ export class TableLayout extends ListLayout { return newSizes; } - // TODO: need to trigger a state update to turn off the resize blue bar onColumnResizeEnd(column: GridNode): void { this.columnLayout.setResizingColumn(null); } diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index ffc159be50e..b90046a9829 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -69,18 +69,12 @@ export interface ColumnProps { /** The width of the column. */ width?: ColumnSize, /** The minimum width of the column. */ - minWidth?: number | string, + minWidth?: ColumnSize, /** The maximum width of the column. */ - maxWidth?: number | string, - /** - * The default width of the column. - * @private - */ - defaultWidth?: number | string, - /** - * Whether the column allows resizing. - * @private - */ + maxWidth?: ColumnSize, + /** The default width of the column. */ + defaultWidth?: ColumnSize, + /** Whether the column allows resizing. */ allowsResizing?: boolean, /** Whether the column allows sorting. */ allowsSorting?: boolean, From 0bed81d24a2602d6c97ccc8d283b1c8ed9e9f984 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 30 Nov 2022 21:25:42 +1100 Subject: [PATCH 10/42] fix min/max calculations --- .../table/src/useTableColumnResize.ts | 2 +- .../table/test/ariaTableResizing.test.tsx | 20 +++++++++++++++++-- .../@react-stately/layout/src/TableLayout.ts | 20 ++++++++++++------- .../table/src/useTableColumnResizeState.ts | 6 ++---- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 3185314648b..234611c9771 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -72,7 +72,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st const {moveProps} = useMove({ onMoveStart() { columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key); - stateRef.current.onColumnResizeStart(item); + stateRef.current.onColumnResizeStart(item.key); props.onResizeStart?.(item.key); }, onMove(e) { diff --git a/packages/@react-aria/table/test/ariaTableResizing.test.tsx b/packages/@react-aria/table/test/ariaTableResizing.test.tsx index 165da7034ac..30933234f3f 100644 --- a/packages/@react-aria/table/test/ariaTableResizing.test.tsx +++ b/packages/@react-aria/table/test/ariaTableResizing.test.tsx @@ -67,7 +67,7 @@ function resizeCol(tree, col, delta) { function resizeTable(clientWidth, newValue) { clientWidth.mockImplementation(() => newValue); fireEvent(window, new Event('resize')); - act(() => {jest.runAllTimers()}); + act(() => {jest.runAllTimers();}); } export let resizingTests = (render, rerender, Table, ControlledTable, resizeCol, resizeTable) => { @@ -226,6 +226,11 @@ export let resizingTests = (render, rerender, Table, ControlledTable, resizeCol, expect(getColumnWidths(tree)).toStrictEqual(expected); expect(onResize).toHaveBeenCalledTimes(1); expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, expectedOnResize)); + let resizers = tree.getAllByRole('slider'); + resizers.forEach(resizer => { + expect(resizer).toHaveAttribute('min', `${75}`); + expect(resizer).toHaveAttribute('max', `${Number.MAX_SAFE_INTEGER}`); + }); }); it('cannot resize to be less than a minWidth, from start to end', function () { @@ -259,6 +264,11 @@ export let resizingTests = (render, rerender, Table, ControlledTable, resizeCol, expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); expect(onResize).toHaveBeenCalledTimes(5); expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 100, 100])); + let resizers = tree.getAllByRole('slider'); + resizers.forEach(resizer => { + expect(resizer).toHaveAttribute('min', `${100}`); + expect(resizer).toHaveAttribute('max', `${Number.MAX_SAFE_INTEGER}`); + }); }); it('cannot resize to be less than a minWidth, from end to start', function () { @@ -303,6 +313,12 @@ export let resizingTests = (render, rerender, Table, ControlledTable, resizeCol, expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 200]); resizeCol(tree, 'Level', 400); expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 500]); + let resizers = tree.getAllByRole('slider'); + let expectedMaxWidths = [150, 150, 200, 200, 500]; + resizers.forEach((resizer, i) => { + expect(resizer).toHaveAttribute('min', `${75}`); + expect(resizer).toHaveAttribute('max', `${expectedMaxWidths[i]}`); + }); }); it('cannot resize to be more than a maxWidth, from end to start', function () { @@ -336,7 +352,7 @@ export let resizingTests = (render, rerender, Table, ControlledTable, resizeCol, {name: 'Level', uid: 'level', width: '4fr'} ]; - let tree = render() + let tree = render(); expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); resizeCol(tree, 'Name', -50); expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 5716b9c8195..d05a4967491 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -37,6 +37,8 @@ export class TableColumnLayout { getDefaultWidth: (props) => string | number; getDefaultMinWidth: (props) => string | number; columnWidths: Map = new Map(); + columnMinWidths: Map = new Map(); + columnMaxWidths: Map = new Map(); resizerPositions: Map = new Map(); constructor(options: TableColumnLayoutOptions) { @@ -56,13 +58,12 @@ export class TableColumnLayout { return this.columnWidths.get(key) ?? 0; } - // TODO: need to send a grid node in so we can use getDefaultMinWidth - getColumnMinWidth(minWidth: number | string, tableWidth: number): number { - return getMinWidth(minWidth ?? this.getDefaultMinWidth({}), tableWidth); + getColumnMinWidth(key: Key): number { + return this.columnMinWidths.get(key); } - getColumnMaxWidth(maxWidth: number | string, tableWidth: number): number { - return getMaxWidth(maxWidth, tableWidth); + getColumnMaxWidth(key: Key): number { + return this.columnMaxWidths.get(key); } resizeColumnWidth(tableWidth: number, collection: TableCollection, controlledWidths: Map, uncontrolledWidths: Map, col = null, width: number) { @@ -149,6 +150,8 @@ export class TableColumnLayout { buildColumnWidths(tableWidth: number, collection: TableCollection, controlledWidths) { this.columnWidths = new Map(); + this.columnMinWidths = new Map(); + this.columnMaxWidths = new Map(); this.resizerPositions = new Map(); // initial layout or table/window resizing @@ -164,7 +167,10 @@ export class TableColumnLayout { let resizerPosition = 0; columnWidths.forEach((width, index) => { let key = collection.columns[index].key; + let column = collection.columns[index]; this.columnWidths.set(key, width); + this.columnMinWidths.set(key, getMinWidth(column.column.props.minWidth ?? this.getDefaultMinWidth(column.props), tableWidth)); + this.columnMaxWidths.set(key, getMaxWidth(column.column.props.maxWidth, tableWidth)); resizerPosition += width; this.resizerPositions.set(key, resizerPosition); }); @@ -210,7 +216,7 @@ export class TableLayout extends ListLayout { if (!column) { return 0; } - return this.columnLayout.getColumnMinWidth(column.props.minWidth, this.virtualizer.visibleRect.width); + return this.columnLayout.getColumnMinWidth(key); } getColumnMaxWidth(key: Key): number { @@ -218,7 +224,7 @@ export class TableLayout extends ListLayout { if (!column) { return 0; } - return this.columnLayout.getColumnMaxWidth(column.props.minWidth, this.virtualizer.visibleRect.width); + return this.columnLayout.getColumnMaxWidth(key); } // outside, where this is called, should call props.onColumnResizeStart... diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 9ec53d5cbf0..24045667f2a 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -109,14 +109,12 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< // done let getColumnMinWidth = useCallback((key: Key) => { - let columnMinWidth = state.collection.columns.find(col => col.key === key).props.minWidth; - return columnLayout.getColumnMinWidth(columnMinWidth, tableWidth); + return columnLayout.getColumnMinWidth(key); }, [columnLayout, state.collection, tableWidth]); // done let getColumnMaxWidth = useCallback((key: Key) => { - let columnMaxWidth = state.collection.columns.find(col => col.key === key).props.maxWidth; - return columnLayout.getColumnMaxWidth(columnMaxWidth, tableWidth); + return columnLayout.getColumnMaxWidth(key); }, [columnLayout, state.collection, tableWidth]); let setResizingColumn = useCallback((key: Key) => { From f39e26b90e2666af07cf1319b32a5d23fe08752d Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 30 Nov 2022 23:59:31 +1100 Subject: [PATCH 11/42] fix all types and lint --- .../table/src/useTableColumnResize.ts | 26 +- .../table/stories/example-resizing.tsx | 16 +- .../table/stories/useTable.stories.tsx | 15 +- .../@react-spectrum/table/src/Resizer.tsx | 10 +- .../@react-spectrum/table/src/TableView.tsx | 29 +- .../table/stories/ControllingResize.tsx | 32 +- .../table/stories/Table.stories.tsx | 8 +- .../table/test/TableSizing.test.tsx | 302 +++++++++--------- .../@react-stately/layout/src/TableLayout.ts | 30 +- .../@react-stately/layout/src/TableUtils.ts | 15 +- .../table/src/useTableColumnResizeState.ts | 87 ++--- 11 files changed, 301 insertions(+), 269 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 234611c9771..2ee7cb0745d 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -11,6 +11,7 @@ */ 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'; @@ -39,20 +40,19 @@ export interface AriaTableColumnResizeProps { onResizeEnd: (key: Key) => void } -export interface TableLayoutState { +export interface TableLayoutState { getColumnWidth: (key: Key) => number, getColumnMinWidth: (key: Key) => number, getColumnMaxWidth: (key: Key) => number, - setResizingColumn: (key: Key | null) => void, resizingColumn: Key, onColumnResizeStart: (key: Key) => void, - onColumnResize: (column: GridNode, width: number) => void, + onColumnResize: (column: Key, width: number) => Map, onColumnResizeEnd: (key: Key) => void } -export function useTableColumnResize(props: AriaTableColumnResizeProps, state: TableState, layoutState: TableLayoutState, ref: RefObject): TableColumnResizeAria { +export function useTableColumnResize(props: AriaTableColumnResizeProps, state: TableState, layoutState: TableLayoutState, ref: RefObject): TableColumnResizeAria { let {column: item, triggerRef, isDisabled} = props; - const stateRef = useRef>(null); + const stateRef = useRef(null); stateRef.current = layoutState; const stringFormatter = useLocalizedStringFormatter(intlMessages); let id = useId(); @@ -89,8 +89,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st // if moving up/down only, no need to resize if (deltaX !== 0) { columnResizeWidthRef.current += deltaX; - let sizes = stateRef.current.onColumnResize(item, columnResizeWidthRef.current); - props.onMove?.(e, columnResizeWidthRef.current); + let sizes = stateRef.current.onColumnResize(item.key, columnResizeWidthRef.current); + props.onMove?.(e); props.onResize?.(sizes); } }, @@ -99,7 +99,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st columnResizeWidthRef.current = 0; props.onMoveEnd?.(e); if (pointerType === 'mouse') { - stateRef.current.onColumnResizeEnd(item); + stateRef.current.onColumnResizeEnd(item.key); props.onResizeEnd?.(item.key); } } @@ -136,7 +136,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } else { nextValue = currentWidth - 10; } - stateRef.current.onColumnResize(item, nextValue); + stateRef.current.onColumnResize(item.key, nextValue); props.onMove({pointerType: 'virtual'} as MoveMoveEvent); props.onMoveEnd({pointerType: 'virtual'} as MoveEndEvent); }; @@ -146,8 +146,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') { return; } - if (e.pointerType === 'virtual' && layoutState.columnLayout.resizingColumn != null) { - stateRef.current.onColumnResizeEnd(item); + if (e.pointerType === 'virtual' && layoutState.resizingColumn != null) { + stateRef.current.onColumnResizeEnd(item.key); focusSafely(triggerRef.current); return; } @@ -176,12 +176,12 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, 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); + stateRef.current.onColumnResizeStart(item.key); props.onResizeStart?.(item.key); state.setKeyboardNavigationDisabled(true); }, onBlur: () => { - stateRef.current.onColumnResizeEnd(item); + stateRef.current.onColumnResizeEnd(item.key); props.onResizeEnd?.(item.key); state.setKeyboardNavigationDisabled(false); }, diff --git a/packages/@react-aria/table/stories/example-resizing.tsx b/packages/@react-aria/table/stories/example-resizing.tsx index 6cb27c64c0c..238b9f4b4e9 100644 --- a/packages/@react-aria/table/stories/example-resizing.tsx +++ b/packages/@react-aria/table/stories/example-resizing.tsx @@ -10,11 +10,7 @@ * governing permissions and limitations under the License. */ -import {mergeProps, useResizeObserver} from '@react-aria/utils'; -import React, {useCallback, useLayoutEffect, useState} from 'react'; -import {useCheckbox} from '@react-aria/checkbox'; -import {FocusRing, useFocusRing} from '@react-aria/focus'; -import {useRef} from 'react'; +import ariaStyles from './resizing.css'; import { AriaTableColumnResizeProps, useTable, @@ -27,12 +23,16 @@ import { useTableSelectAllCheckbox, useTableSelectionCheckbox } from '@react-aria/table'; +import {classNames} from '@react-spectrum/utils'; +import {FocusRing, useFocusRing} from '@react-aria/focus'; +import {mergeProps, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; +import React, {useCallback, useState} from 'react'; +import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; +import {useCheckbox} from '@react-aria/checkbox'; +import {useRef} from 'react'; import {useTableColumnResizeState, useTableState} from '@react-stately/table'; import {useToggleState} from '@react-stately/toggle'; import {VisuallyHidden} from '@react-aria/visually-hidden'; -import {classNames} from '@react-spectrum/utils'; -import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; -import ariaStyles from './resizing.css'; export function Table(props) { let [showSelectionCheckboxes, setShowSelectionCheckboxes] = useState(props.selectionStyle !== 'highlight'); diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index 87fe8987a70..671f2ba2831 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -13,10 +13,10 @@ import {action} from '@storybook/addon-actions'; import {Table as BackwardCompatTable} from './example-backwards-compat'; import {Cell, Column, Row, TableBody, TableHeader} from '@react-stately/table'; +import {ColumnSize, SpectrumTableProps} from '@react-types/table'; import {Meta, Story} from '@storybook/react'; import React, {Key, useCallback, useMemo, useState} from 'react'; import {Table as ResizingTable} from './example-resizing'; -import {SpectrumTableProps} from '@react-types/table'; import {Table} from './example'; const meta: Meta> = { @@ -132,8 +132,8 @@ export const TableWithResizingNoProps = { interface ColumnData { name: string, uid: string, - defaultWidth?: number | string, - width?: number | string + defaultWidth?: ColumnSize, + width?: ColumnSize } let columnsDefaultFR: ColumnData[] = [ {name: 'Name', uid: 'name', defaultWidth: '1fr'}, @@ -163,18 +163,19 @@ export const TableWithResizingFRs = { ) }; -function ControlledTableResizing(props: {columns: Array<{name: string, uid: string, width: string}>, rows, onResize}) { +function ControlledTableResizing(props: {columns: Array<{name: string, uid: string, width: ColumnSize}>, rows, onResize}) { let {columns, rows = defaultRows, onResize, ...otherProps} = props; - let [widths, _setWidths] = useState>(() => new Map(columns.filter(col => col.width).map((col) => [col.uid as Key, col.width]))); + let [widths, _setWidths] = useState>(() => new Map(columns.filter(col => col.width).map((col) => [col.uid as Key, col.width]))); - let setWidths = useCallback((vals: Map) => { + let setWidths = useCallback((vals: Map) => { let controlledKeys = new Set(columns.filter(col => col.width).map((col) => col.uid as Key)); let newVals = new Map(Array.from(vals).filter(([key]) => controlledKeys.has(key))); _setWidths(newVals); onResize?.(vals); - }, [columns]); + }, [columns, onResize]); let [savedCols, setSavedCols] = useState(widths); let [renderKey, setRenderKey] = useState(Math.random()); + // eslint-disable-next-line react-hooks/exhaustive-deps let cols = useMemo(() => columns.map(col => ({...col})), [columns, widths]); return ( diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index f8eddc9410a..ac1c595fb09 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -13,13 +13,13 @@ import {useTableContext} from './TableView'; import {VisuallyHidden} from '@react-aria/visually-hidden'; interface ResizerProps { - layout: TableLayoutState, + layout: TableLayoutState, column: GridNode, showResizer: boolean, triggerRef: RefObject, - onResizeStart: () => void, + onResizeStart: (key: Key) => void, onResize: (widths: Map) => void, - onResizeEnd: () => void, + onResizeEnd: (key: Key) => void, onMoveResizer: (e: MoveMoveEvent) => void } @@ -28,10 +28,10 @@ function Resizer(props: ResizerProps, ref: RefObject) { let {state, isEmpty} = useTableContext(); let stringFormatter = useLocalizedStringFormatter(intlMessages); let {direction} = useLocale(); - const stateRef = useRef>(null); + const stateRef = useRef(null); stateRef.current = layout; - let {inputProps, resizerProps} = useTableColumnResize({ + let {inputProps, resizerProps} = useTableColumnResize({ ...props, label: stringFormatter.format('columnResizer'), isDisabled: isEmpty, diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 5f559dea032..b4d3b401649 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -22,6 +22,7 @@ import { useStyleProps, useUnwrapDOMRef } from '@react-spectrum/utils'; +import {ColumnSize, SpectrumColumnProps, SpectrumTableProps} from '@react-types/table'; import {DOMRef, FocusableRef, MoveMoveEvent} from '@react-types/shared'; import {FocusRing, FocusScope, useFocusRing} from '@react-aria/focus'; import {getInteractionModality, useHover, usePress} from '@react-aria/interactions'; @@ -35,10 +36,14 @@ import {ProgressCircle} from '@react-spectrum/progress'; import React, {Key, ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {Resizer} from './Resizer'; -import {ColumnSize, SpectrumColumnProps, SpectrumTableProps} from '@react-types/table'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; import stylesOverrides from './table.css'; import {TableColumnLayout, TableLayout} from '@react-stately/layout'; +import {TableState, useTableState} from '@react-stately/table'; +import {Tooltip, TooltipTrigger} from '@react-spectrum/tooltip'; +import {useButton} from '@react-aria/button'; +import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useProvider, useProviderProps} from '@react-spectrum/provider'; import { useTable, useTableCell, @@ -49,11 +54,6 @@ import { useTableSelectAllCheckbox, useTableSelectionCheckbox } from '@react-aria/table'; -import {TableState, useTableState} from '@react-stately/table'; -import {Tooltip, TooltipTrigger} from '@react-spectrum/tooltip'; -import {useButton} from '@react-aria/button'; -import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; -import {useProvider, useProviderProps} from '@react-spectrum/provider'; import {VisuallyHidden} from '@react-aria/visually-hidden'; const DEFAULT_HEADER_HEIGHT = { @@ -108,7 +108,7 @@ export function useTableContext() { function TableView(props: SpectrumTableProps, ref: DOMRef) { props = useProviderProps(props); - let {isQuiet, onAction} = props; + let {isQuiet, onAction, onResizeEnd: propsOnResizeEnd} = props; let {styleProps} = useStyleProps(props); let [showSelectionCheckboxes, setShowSelectionCheckboxes] = useState(props.selectionStyle !== 'highlight'); @@ -177,6 +177,8 @@ function TableView(props: SpectrumTableProps, ref: DOMRef(props: SpectrumTableProps, ref: DOMRef { setIsInResizeMode(false); - props.onResizeEnd?.(key); - }, [props.onResizeEnd, setIsInResizeMode]); + propsOnResizeEnd?.(key); + }, [propsOnResizeEnd, setIsInResizeMode]); return ( @@ -401,7 +403,6 @@ function TableView(props: SpectrumTableProps, ref: DOMRef { - if (layout.columnLayout.resizingColumn === column.key) { + if (layout.resizingColumn === column.key) { // focusSafely won't actually focus because the focus moves from the menuitem to the body during the after transition wait // without the immediate timeout, Android Chrome doesn't move focus to the resizer if (isMobile) { @@ -703,9 +704,9 @@ function ResizableTableColumnHeader(props) { }, 0); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layout.columnLayout.resizingColumn, column.key]); + }, [layout.resizingColumn, column.key]); - let resizingColumn = layout.columnLayout.resizingColumn; + let resizingColumn = layout.resizingColumn; let showResizer = !isEmpty && ((headerRowHovered && getInteractionModality() !== 'keyboard') || resizingColumn != null); return ( diff --git a/packages/@react-spectrum/table/stories/ControllingResize.tsx b/packages/@react-spectrum/table/stories/ControllingResize.tsx index 4e0f80568a4..e5794dafb3f 100644 --- a/packages/@react-spectrum/table/stories/ControllingResize.tsx +++ b/packages/@react-spectrum/table/stories/ControllingResize.tsx @@ -10,14 +10,25 @@ * governing permissions and limitations under the License. */ +import {Button} from '@react-spectrum/button'; import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; -import {Checkbox} from '@react-spectrum/checkbox'; -import {Flex} from '@react-spectrum/layout'; -import {Form} from '@react-spectrum/form'; +import {ColumnSize} from '@react-types/table'; import React, {Key, useCallback, useMemo, useState} from 'react'; -import {Button} from '@react-spectrum/button'; -let defaultColumns = [ +export interface PokemonColumn { + name: string, + uid: string, + width?: ColumnSize +} +export interface PokemonData { + id: number, + name: string, + type: string, + level: string, + weight: string, + height: string +} +let defaultColumns: PokemonColumn[] = [ {name: 'Name', uid: 'name', width: '1fr'}, {name: 'Type', uid: 'type', width: '1fr'}, {name: 'Height', uid: 'height'}, @@ -25,7 +36,7 @@ let defaultColumns = [ {name: 'Level', uid: 'level', width: '5fr'} ]; -let defaultRows = [ +let defaultRows: PokemonData[] = [ {id: 1, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, {id: 2, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, {id: 3, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, @@ -40,18 +51,19 @@ let defaultRows = [ {id: 12, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'} ]; -export function ControllingResize(props) { +export function ControllingResize(props: {columns?: PokemonColumn[], rows?: PokemonData[], onResize?: (sizes: Map) => void, [name: string]: any}) { let {columns = defaultColumns, rows = defaultRows, onResize, ...otherProps} = props; - let [widths, _setWidths] = useState>(() => new Map(columns.filter(col => col.width).map((col) => [col.uid as Key, col.width]))); + let [widths, _setWidths] = useState>(() => new Map(columns.filter(col => col.width).map((col) => [col.uid as Key, col.width]))); - let setWidths = useCallback((vals: Map) => { + let setWidths = useCallback((vals: Map) => { let controlledKeys = new Set(columns.filter(col => col.width).map((col) => col.uid as Key)); let newVals = new Map(Array.from(vals).filter(([key]) => controlledKeys.has(key))); _setWidths(newVals); onResize?.(vals); - }, [columns]); + }, [onResize, columns, _setWidths]); let [savedCols, setSavedCols] = useState(widths); let [renderKey, setRenderKey] = useState(() => Math.random()); + // eslint-disable-next-line react-hooks/exhaustive-deps let cols = useMemo(() => columns.map(col => ({...col})), [columns, widths]); return ( diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index 34d0ec7e235..8a3671c47a7 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -17,6 +17,7 @@ import {Breadcrumbs, Item} from '@react-spectrum/breadcrumbs'; import {ButtonGroup} from '@react-spectrum/buttongroup'; import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; import {Content} from '@react-spectrum/view'; +import {ControllingResize, PokemonColumn} from './ControllingResize'; import {CRUDExample} from './CRUDExample'; import Delete from '@spectrum-icons/workflow/Delete'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; @@ -38,7 +39,6 @@ import {TextField} from '@react-spectrum/textfield'; import {useAsyncList, useListData} from '@react-stately/data'; import {useFilter} from '@react-aria/i18n'; import {View} from '@react-spectrum/view'; -import {ControllingResize} from './ControllingResize'; let columns = [ {name: 'Foo', key: 'foo'}, @@ -1332,7 +1332,7 @@ storiesOf('TableView', module) .add( 'allowsResizing, onColumnResizeEnd action', () => ( - + File name for reference Type @@ -1390,13 +1390,13 @@ storiesOf('TableView', module) ); -let columnsFR = [ +let columnsFR: PokemonColumn[] = [ {name: 'Name', uid: 'name', width: '1fr'}, {name: 'Type', uid: 'type', width: '1fr'}, {name: 'Level', uid: 'level', width: '4fr'} ]; -let columnsSomeFR = [ +let columnsSomeFR: PokemonColumn[] = [ {name: 'Name', uid: 'name', width: '1fr'}, {name: 'Type', uid: 'type', width: '1fr'}, {name: 'Height', uid: 'height'}, diff --git a/packages/@react-spectrum/table/test/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx index b3e791c4eba..d9684a2fc6c 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.tsx +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -15,15 +15,16 @@ import {act, render as renderComponent, within} from '@testing-library/react'; import {ActionButton} from '@react-spectrum/button'; import Add from '@spectrum-icons/workflow/Add'; import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; +import {ControllingResize} from '../stories/ControllingResize'; import {fireEvent, installPointerEvent, triggerTouch} from '@react-spectrum/test-utils'; import {HidingColumns} from '../stories/HidingColumns'; import {Provider} from '@react-spectrum/provider'; import React, {Key} from 'react'; +import {resizingTests} from '@react-aria/table/test/ariaTableResizing.test'; +import {Scale} from '@react-types/provider'; import {setInteractionModality} from '@react-aria/interactions'; import {theme} from '@react-spectrum/theme-default'; import userEvent from '@testing-library/user-event'; -import {ControllingResize} from '../stories/ControllingResize'; -import {resizingTests} from '@react-aria/table/test/ariaTableResizing.test'; let columns = [ {name: 'Foo', key: 'foo'}, @@ -56,7 +57,7 @@ for (let i = 1; i <= 100; i++) { manyItems.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i, baz: 'Baz ' + i}); } -let render = (children, scale = 'medium') => { +let render = (children, scale: Scale = 'medium') => { let tree = renderComponent( {children} @@ -67,7 +68,7 @@ let render = (children, scale = 'medium') => { return tree; }; -let rerender = (tree, children, scale = 'medium') => { +let rerender = (tree, children, scale: Scale = 'medium') => { let newTree = tree.rerender( {children} @@ -96,7 +97,7 @@ describe('TableViewSizing', function () { describe('layout', function () { describe('row heights', function () { - let renderTable = (props, scale) => render( + let renderTable = (props = {}, scale: Scale = 'medium') => render( {column => {column.name}} @@ -123,7 +124,7 @@ describe('TableViewSizing', function () { expect(rows[2].style.top).toBe('41px'); expect(rows[2].style.height).toBe('41px'); - for (let cell of [...rows[1].childNodes, ...rows[2].childNodes]) { + for (let cell of ([...rows[1].childNodes, ...rows[2].childNodes] as HTMLElement[])) { expect(cell.style.top).toBe('0px'); expect(cell.style.height).toBe('40px'); } @@ -141,7 +142,7 @@ describe('TableViewSizing', function () { expect(rows[2].style.top).toBe('51px'); expect(rows[2].style.height).toBe('51px'); - for (let cell of [...rows[1].childNodes, ...rows[2].childNodes]) { + for (let cell of ([...rows[1].childNodes, ...rows[2].childNodes] as HTMLElement[])) { expect(cell.style.top).toBe('0px'); expect(cell.style.height).toBe('50px'); } @@ -159,7 +160,7 @@ describe('TableViewSizing', function () { expect(rows[2].style.top).toBe('33px'); expect(rows[2].style.height).toBe('33px'); - for (let cell of [...rows[1].childNodes, ...rows[2].childNodes]) { + for (let cell of ([...rows[1].childNodes, ...rows[2].childNodes] as HTMLElement[])) { expect(cell.style.top).toBe('0px'); expect(cell.style.height).toBe('32px'); } @@ -177,7 +178,7 @@ describe('TableViewSizing', function () { expect(rows[2].style.top).toBe('41px'); expect(rows[2].style.height).toBe('41px'); - for (let cell of [...rows[1].childNodes, ...rows[2].childNodes]) { + for (let cell of ([...rows[1].childNodes, ...rows[2].childNodes] as HTMLElement[])) { expect(cell.style.top).toBe('0px'); expect(cell.style.height).toBe('40px'); } @@ -195,7 +196,7 @@ describe('TableViewSizing', function () { expect(rows[2].style.top).toBe('49px'); expect(rows[2].style.height).toBe('49px'); - for (let cell of [...rows[1].childNodes, ...rows[2].childNodes]) { + for (let cell of ([...rows[1].childNodes, ...rows[2].childNodes] as HTMLElement[])) { expect(cell.style.top).toBe('0px'); expect(cell.style.height).toBe('48px'); } @@ -213,7 +214,7 @@ describe('TableViewSizing', function () { expect(rows[2].style.top).toBe('61px'); expect(rows[2].style.height).toBe('61px'); - for (let cell of [...rows[1].childNodes, ...rows[2].childNodes]) { + for (let cell of ([...rows[1].childNodes, ...rows[2].childNodes] as HTMLElement[])) { expect(cell.style.top).toBe('0px'); expect(cell.style.height).toBe('60px'); } @@ -235,12 +236,12 @@ describe('TableViewSizing', function () { expect(rows[2].style.top).toBe('65px'); expect(rows[2].style.height).toBe('49px'); - for (let cell of rows[1].childNodes) { + for (let cell of ([...rows[1].childNodes] as HTMLElement[])) { expect(cell.style.top).toBe('0px'); expect(cell.style.height).toBe('64px'); } - for (let cell of rows[2].childNodes) { + for (let cell of ([...rows[2].childNodes] as HTMLElement[])) { expect(cell.style.top).toBe('0px'); expect(cell.style.height).toBe('48px'); } @@ -279,17 +280,17 @@ describe('TableViewSizing', function () { expect(rows[2].style.top).toBe('82px'); expect(rows[2].style.height).toBe('34px'); - for (let cell of rows[0].childNodes) { + for (let cell of ([...rows[0].childNodes] as HTMLElement[])) { expect(cell.style.top).toBe('0px'); expect(cell.style.height).toBe('34px'); } - for (let cell of rows[1].childNodes) { + for (let cell of ([...rows[1].childNodes] as HTMLElement[])) { expect(cell.style.top).toBe('0px'); expect(cell.style.height).toBe('48px'); } - for (let cell of rows[2].childNodes) { + for (let cell of ([...rows[2].childNodes] as HTMLElement[])) { expect(cell.style.top).toBe('0px'); expect(cell.style.height).toBe('34px'); } @@ -300,7 +301,7 @@ describe('TableViewSizing', function () { // To test https://github.com/adobe/react-spectrum/issues/1885 it('should not throw error if selection mode changes with overflowMode="wrap" and selection was controlled', function () { function ControlledSelection(props) { - let [selectedKeys, setSelectedKeys] = React.useState(new Set([])); + let [selectedKeys, setSelectedKeys] = React.useState | 'all'>(new Set([])); return ( @@ -338,15 +339,15 @@ describe('TableViewSizing', function () { for (let [index, cell] of headerRow.childNodes.entries()) { // 4 because there is a checkbox column - expect(Number(cell.style.zIndex)).toBe(4 - index + 1); + expect(Number((cell as HTMLElement).style.zIndex)).toBe(4 - index + 1); } for (let row of bodyRows) { for (let [index, cell] of row.childNodes.entries()) { if (index === 0) { - expect(cell.style.zIndex).toBe('2'); + expect((cell as HTMLElement).style.zIndex).toBe('2'); } else { - expect(cell.style.zIndex).toBe('1'); + expect((cell as HTMLElement).style.zIndex).toBe('1'); } } } @@ -373,10 +374,10 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('38px'); - expect(row.childNodes[1].style.width).toBe('321px'); - expect(row.childNodes[2].style.width).toBe('321px'); - expect(row.childNodes[3].style.width).toBe('320px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('38px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('321px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('321px'); + expect((row.childNodes[3] as HTMLElement).style.width).toBe('320px'); } }); @@ -399,10 +400,10 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('48px'); - expect(row.childNodes[1].style.width).toBe('317px'); - expect(row.childNodes[2].style.width).toBe('318px'); - expect(row.childNodes[3].style.width).toBe('317px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('48px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('317px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('318px'); + expect((row.childNodes[3] as HTMLElement).style.width).toBe('317px'); } }); @@ -427,9 +428,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('200px'); - expect(row.childNodes[1].style.width).toBe('500px'); - expect(row.childNodes[2].style.width).toBe('300px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('500px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('300px'); } }); @@ -454,10 +455,10 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('38px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('381px'); - expect(row.childNodes[3].style.width).toBe('381px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('38px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('381px'); + expect((row.childNodes[3] as HTMLElement).style.width).toBe('381px'); } }); @@ -482,9 +483,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('100px'); - expect(row.childNodes[1].style.width).toBe('500px'); - expect(row.childNodes[2].style.width).toBe('400px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('100px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('500px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('400px'); } }); @@ -509,10 +510,10 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('38px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('500px'); - expect(row.childNodes[3].style.width).toBe('262px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('38px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('500px'); + expect((row.childNodes[3] as HTMLElement).style.width).toBe('262px'); } }); @@ -537,9 +538,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('200px'); - expect(row.childNodes[1].style.width).toBe('300px'); - expect(row.childNodes[2].style.width).toBe('500px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('300px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('500px'); } }); @@ -565,9 +566,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('600px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } }); }); @@ -594,9 +595,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('300px'); - expect(row.childNodes[1].style.width).toBe('500px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('300px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('500px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } }); }); @@ -619,21 +620,21 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); - expect(rows[0].childNodes[0].style.width).toBe('230px'); - expect(rows[0].childNodes[1].style.width).toBe('770px'); + expect((rows[0].childNodes[0] as HTMLElement).style.width).toBe('230px'); + expect((rows[0].childNodes[1] as HTMLElement).style.width).toBe('770px'); - expect(rows[1].childNodes[0].style.width).toBe('230px'); - expect(rows[1].childNodes[1].style.width).toBe('385px'); - expect(rows[1].childNodes[2].style.width).toBe('193px'); - expect(rows[1].childNodes[3].style.width).toBe('192px'); + expect((rows[1].childNodes[0] as HTMLElement).style.width).toBe('230px'); + expect((rows[1].childNodes[1] as HTMLElement).style.width).toBe('385px'); + expect((rows[1].childNodes[2] as HTMLElement).style.width).toBe('193px'); + expect((rows[1].childNodes[3] as HTMLElement).style.width).toBe('192px'); for (let row of rows.slice(2)) { - expect(row.childNodes[0].style.width).toBe('38px'); - expect(row.childNodes[1].style.width).toBe('192px'); - expect(row.childNodes[2].style.width).toBe('193px'); - expect(row.childNodes[3].style.width).toBe('192px'); - expect(row.childNodes[4].style.width).toBe('193px'); - expect(row.childNodes[5].style.width).toBe('192px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('38px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('192px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('193px'); + expect((row.childNodes[3] as HTMLElement).style.width).toBe('192px'); + expect((row.childNodes[4] as HTMLElement).style.width).toBe('193px'); + expect((row.childNodes[5] as HTMLElement).style.width).toBe('192px'); } }); }); @@ -670,9 +671,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('600px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } let resizableHeader = tree.getAllByRole('columnheader')[0]; @@ -690,9 +691,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('595px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('595px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } expect(onResizeEnd).toHaveBeenCalledTimes(1); expect(onResizeEnd).toHaveBeenCalledWith('foo'); @@ -704,9 +705,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('620px'); - expect(row.childNodes[1].style.width).toBe('190px'); - expect(row.childNodes[2].style.width).toBe('190px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); } expect(onResizeEnd).toHaveBeenCalledTimes(2); expect(onResizeEnd).toHaveBeenCalledWith('foo'); @@ -744,9 +745,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('600px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } let resizableHeader = tree.getAllByRole('columnheader')[0]; @@ -764,9 +765,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('595px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('595px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } expect(onResizeEnd).toHaveBeenCalledTimes(1); expect(onResizeEnd).toHaveBeenCalledWith('foo'); @@ -778,9 +779,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('620px'); - expect(row.childNodes[1].style.width).toBe('190px'); - expect(row.childNodes[2].style.width).toBe('190px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); } expect(onResizeEnd).toHaveBeenCalledTimes(2); expect(onResizeEnd).toHaveBeenCalledWith('foo'); @@ -825,9 +826,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('600px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } let header = tree.getAllByRole('columnheader')[0]; @@ -851,9 +852,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('595px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('595px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } // actual locations do not matter, the delta matters between events for the calculation of useMove @@ -863,9 +864,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('620px'); - expect(row.childNodes[1].style.width).toBe('190px'); - expect(row.childNodes[2].style.width).toBe('190px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); } // tapping on the document.body doesn't cause a blur in jest because the body isn't focusable, so just call blur @@ -907,9 +908,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('600px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } let header = tree.getAllByRole('columnheader')[0]; @@ -936,9 +937,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('595px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('595px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } // actual locations do not matter, the delta matters between events for the calculation of useMove @@ -948,9 +949,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('620px'); - expect(row.childNodes[1].style.width).toBe('190px'); - expect(row.childNodes[2].style.width).toBe('190px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); } // tapping on the document.body doesn't cause a blur in jest because the body isn't focusable, so just call blur @@ -997,9 +998,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('600px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } fireEvent.keyDown(document.activeElement, {key: 'Enter'}); @@ -1021,9 +1022,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('620px'); - expect(row.childNodes[1].style.width).toBe('190px'); - expect(row.childNodes[2].style.width).toBe('190px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); } fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); @@ -1033,9 +1034,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('600px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); @@ -1045,9 +1046,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('620px'); - expect(row.childNodes[1].style.width).toBe('190px'); - expect(row.childNodes[2].style.width).toBe('190px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); } fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); @@ -1057,9 +1058,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('600px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } fireEvent.keyDown(document.activeElement, {key: 'Escape'}); @@ -1102,9 +1103,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('600px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } fireEvent.keyDown(document.activeElement, {key: 'Enter'}); @@ -1126,9 +1127,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('620px'); - expect(row.childNodes[1].style.width).toBe('190px'); - expect(row.childNodes[2].style.width).toBe('190px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); } fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); @@ -1138,9 +1139,9 @@ describe('TableViewSizing', function () { for (let row of rows) { - expect(row.childNodes[0].style.width).toBe('600px'); - expect(row.childNodes[1].style.width).toBe('200px'); - expect(row.childNodes[2].style.width).toBe('200px'); + expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } fireEvent.keyDown(document.activeElement, {key: 'Escape'}); @@ -1310,7 +1311,7 @@ describe('TableViewSizing', function () { it('should support removing columns', function () { let tree = render(); - let checkbox = tree.getByLabelText('Net Budget'); + let checkbox = tree.getByLabelText('Net Budget') as HTMLInputElement; expect(checkbox.checked).toBe(true); let table = tree.getByRole('grid'); @@ -1330,7 +1331,7 @@ describe('TableViewSizing', function () { userEvent.click(checkbox); expect(checkbox.checked).toBe(false); - act(() => jest.runAllTimers()); + act(() => {jest.runAllTimers();}); columns = within(table).getAllByRole('columnheader'); expect(columns).toHaveLength(5); @@ -1348,13 +1349,13 @@ describe('TableViewSizing', function () { it('should support adding columns', function () { let tree = render(); - let checkbox = tree.getByLabelText('Net Budget'); + let checkbox = tree.getByLabelText('Net Budget') as HTMLInputElement; expect(checkbox.checked).toBe(true); userEvent.click(checkbox); expect(checkbox.checked).toBe(false); - act(() => jest.runAllTimers()); + act(() => {jest.runAllTimers();}); let table = tree.getByRole('grid'); let columns = within(table).getAllByRole('columnheader'); @@ -1363,7 +1364,7 @@ describe('TableViewSizing', function () { userEvent.click(checkbox); expect(checkbox.checked).toBe(true); - act(() => jest.runAllTimers()); + act(() => {jest.runAllTimers();}); columns = within(table).getAllByRole('columnheader'); expect(columns).toHaveLength(6); @@ -1387,39 +1388,39 @@ describe('TableViewSizing', function () { } let tree = render(); - act(() => jest.runAllTimers()); + act(() => {jest.runAllTimers();}); let table = tree.getByRole('grid'); let columns = within(table).getAllByRole('columnheader'); expect(columns).toHaveLength(6); let rows = tree.getAllByRole('row'); - let oldWidth = rows[1].childNodes[1].style.width; + let oldWidth = (rows[1].childNodes[1] as HTMLElement).style.width; - let audienceCheckbox = tree.getByLabelText('Audience Type'); - let budgetCheckbox = tree.getByLabelText('Net Budget'); - let targetCheckbox = tree.getByLabelText('Target OTP'); - let reachCheckbox = tree.getByLabelText('Reach'); + let audienceCheckbox = tree.getByLabelText('Audience Type') as HTMLInputElement; + let budgetCheckbox = tree.getByLabelText('Net Budget') as HTMLInputElement; + let targetCheckbox = tree.getByLabelText('Target OTP') as HTMLInputElement; + let reachCheckbox = tree.getByLabelText('Reach') as HTMLInputElement; userEvent.click(audienceCheckbox); expect(audienceCheckbox.checked).toBe(false); - act(() => jest.runAllTimers()); + act(() => {jest.runAllTimers();}); oldWidth = compareWidths(rows[1], oldWidth); userEvent.click(budgetCheckbox); expect(budgetCheckbox.checked).toBe(false); - act(() => jest.runAllTimers()); + act(() => {jest.runAllTimers();}); oldWidth = compareWidths(rows[1], oldWidth); userEvent.click(targetCheckbox); expect(targetCheckbox.checked).toBe(false); - act(() => jest.runAllTimers()); + act(() => {jest.runAllTimers();}); oldWidth = compareWidths(rows[1], oldWidth); // This previously failed, the first column wouldn't update its width // when the 2nd to last column was removed userEvent.click(reachCheckbox); expect(reachCheckbox.checked).toBe(false); - act(() => jest.runAllTimers()); + act(() => {jest.runAllTimers();}); oldWidth = compareWidths(rows[1], oldWidth); columns = within(table).getAllByRole('columnheader'); expect(columns).toHaveLength(2); @@ -1427,14 +1428,14 @@ describe('TableViewSizing', function () { // Re-add the column and check that the width decreases userEvent.click(audienceCheckbox); expect(audienceCheckbox.checked).toBe(true); - act(() => jest.runAllTimers()); - expect(parseInt(rows[1].childNodes[1].style.width, 10)).toBeLessThan(parseInt(oldWidth, 10)); + act(() => {jest.runAllTimers();}); + expect(parseInt((rows[1].childNodes[1] as HTMLElement).style.width, 10)).toBeLessThan(parseInt(oldWidth, 10)); }); }); describe('headerless columns', function () { - let renderTable = (props, scale, showDivider = false) => render( + let renderTable = (props = {}, scale: Scale = 'medium', showDivider = false) => render( Foo @@ -1477,16 +1478,16 @@ describe('TableViewSizing', function () { expect(className.includes('spectrum-Table-cell--hideHeader')).toBeTruthy(); expect(headers[0]).toHaveTextContent('Foo'); // visually hidden syle - expect(headers[1].childNodes[0].style.clipPath).toBe('inset(50%)'); - expect(headers[1].childNodes[0].style.width).toBe('1px'); - expect(headers[1].childNodes[0].style.height).toBe('1px'); + expect((headers[1].childNodes[0] as HTMLElement).style.clipPath).toBe('inset(50%)'); + expect((headers[1].childNodes[0] as HTMLElement).style.width).toBe('1px'); + expect((headers[1].childNodes[0] as HTMLElement).style.height).toBe('1px'); expect(headers[1]).not.toBeEmptyDOMElement(); let rows = within(rowgroups[1]).getAllByRole('row'); expect(rows).toHaveLength(1); // The width of headerless column - expect(rows[0].childNodes[1].style.width).toBe('36px'); + expect((rows[0].childNodes[1] as HTMLElement).style.width).toBe('36px'); let rowheader = within(rows[0]).getByRole('rowheader'); expect(rowheader).toHaveTextContent('Foo 1'); let actionCell = within(rows[0]).getAllByRole('gridcell'); @@ -1507,7 +1508,7 @@ describe('TableViewSizing', function () { let rows = within(rowgroups[1]).getAllByRole('row'); expect(rows).toHaveLength(1); // The width of headerless column - expect(rows[0].childNodes[1].style.width).toBe('44px'); + expect((rows[0].childNodes[1] as HTMLElement).style.width).toBe('44px'); }); it('renders table with headerless column and divider', function () { @@ -1519,7 +1520,7 @@ describe('TableViewSizing', function () { let rows = within(rowgroups[1]).getAllByRole('row'); expect(rows).toHaveLength(1); // The width of headerless column with divider - expect(rows[0].childNodes[1].style.width).toBe('37px'); + expect((rows[0].childNodes[1] as HTMLElement).style.width).toBe('37px'); }); it('renders table with headerless column with tooltip', function () { @@ -1540,11 +1541,6 @@ describe('TableViewSizing', function () { }); -function getColumnWidths(tree) { - let rows = tree.getAllByRole('row'); - return Array.from(rows[0].childNodes).map((cell) => Number(cell.style.width.replace('px', ''))); -} - // I'd use tree.getByRole(role, {name: text}) here, but it's unbearably slow. function getColumn(tree, name) { // Find by text, then go up to the element with the cell role. @@ -1577,7 +1573,7 @@ function resizeCol(tree, col, delta) { function resizeTable(clientWidth, newValue) { clientWidth.mockImplementation(() => newValue); fireEvent(window, new Event('resize')); - act(() => {jest.runAllTimers()}); + act(() => {jest.runAllTimers();}); } resizingTests(render, rerender, ControllingResize, ControllingResize, resizeCol, resizeTable); diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index d05a4967491..5101d406d26 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -17,14 +17,15 @@ import { isStatic, parseFractionalUnit } from './TableUtils'; +import {ColumnSize, TableCollection} from '@react-types/table'; import {GridNode} from '@react-types/grid'; import {Key} from 'react'; import {LayoutInfo, Point, Rect, Size} from '@react-stately/virtualizer'; import {LayoutNode, ListLayout, ListLayoutOptions} from './ListLayout'; -import {TableCollection} from '@react-types/table'; type TableLayoutOptions = ListLayoutOptions & { - columnLayout: TableColumnLayout + columnLayout: TableColumnLayout, + initialCollection: TableCollection } interface TableColumnLayoutOptions { @@ -66,14 +67,14 @@ export class TableColumnLayout { return this.columnMaxWidths.get(key); } - resizeColumnWidth(tableWidth: number, collection: TableCollection, controlledWidths: Map, uncontrolledWidths: Map, col = null, width: number) { + resizeColumnWidth(tableWidth: number, collection: TableCollection, controlledWidths: Map, uncontrolledWidths: Map, col = null, width: number): Map { let prevColumnWidths = this.columnWidths; // resizing a column let resizeIndex = Infinity; let controlledArray = Array.from(controlledWidths); let uncontrolledArray = Array.from(uncontrolledWidths); let combinedArray = controlledArray.concat(uncontrolledArray); - let resizingChanged = new Map(combinedArray); + let resizingChanged = new Map(combinedArray); let frKeys = new Map(); let percentKeys = new Map(); let frKeysToTheRight = new Map(); @@ -123,7 +124,7 @@ export class TableColumnLayout { // set all new column widths for onResize event // columns going in will be the same order as the columns coming out - let newWidths = new Map(); + let newWidths = new Map(); // set all column widths based on calculateColumnSize columnWidths.forEach((width, index) => { let key = collection.columns[index].key; @@ -191,7 +192,7 @@ export class TableLayout extends ListLayout { columnLayout: TableColumnLayout; controlledWidths: Map>; uncontrolledWidths: Map>; - widths: Map; + widths: Map; lastVirtualizerWidth: number; constructor(options: TableLayoutOptions) { @@ -207,6 +208,10 @@ export class TableLayout extends ListLayout { )); } + get resizingColumn(): Key { + return this.columnLayout.resizingColumn; + } + getColumnWidth(key: Key): number { return this.columnLayout.getColumnWidth(key) ?? 0; } @@ -228,17 +233,17 @@ export class TableLayout extends ListLayout { } // outside, where this is called, should call props.onColumnResizeStart... - onColumnResizeStart(column: GridNode): void { - this.columnLayout.setResizingColumn(column.key); + onColumnResizeStart(key: Key): void { + this.columnLayout.setResizingColumn(key); } // only way to call props.onColumnResize with the new size outside of Layout is to send the result back - onColumnResize(column: GridNode, width: number): Map { + onColumnResize(key: Key, width: number): Map { let newControlled = new Map(Array.from(this.controlledWidths).map(([key, entry]) => [key, entry.props.width])); - let newSizes = this.columnLayout.resizeColumnWidth(this.virtualizer.visibleRect.width, this.collection, newControlled, this.widths, column.key, width); + let newSizes = this.columnLayout.resizeColumnWidth(this.virtualizer.visibleRect.width, this.collection, newControlled, this.widths, key, width); let map = new Map(Array.from(this.uncontrolledWidths).map(([key]) => [key, newSizes.get(key)])); - map.set(column.key, width); + map.set(key, width); this.widths = map; // relayoutNow still uses setState, should happen at the same time the parent // component's state is processed as a result of props.onColumnResize @@ -246,7 +251,8 @@ export class TableLayout extends ListLayout { return newSizes; } - onColumnResizeEnd(column: GridNode): void { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onColumnResizeEnd(key: Key): void { this.columnLayout.setResizingColumn(null); } diff --git a/packages/@react-stately/layout/src/TableUtils.ts b/packages/@react-stately/layout/src/TableUtils.ts index 6f90a21637e..859d89126db 100644 --- a/packages/@react-stately/layout/src/TableUtils.ts +++ b/packages/@react-stately/layout/src/TableUtils.ts @@ -1,3 +1,16 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {ColumnSize} from '@react-types/table'; import {Key} from 'react'; // numbers and percents are considered static. *fr units or a lack of units are considered dynamic. @@ -128,7 +141,7 @@ export interface IndexedColumn { delta?: number } -export function calculateColumnSizes(availableWidth: number, columns: IColumn[], changedColumns: Map, getDefaultWidth, getDefaultMinWidth) { +export function calculateColumnSizes(availableWidth: number, columns: IColumn[], changedColumns: Map, getDefaultWidth, getDefaultMinWidth) { let remainingSpace = availableWidth; let {staticColumns, dynamicColumns} = columns.reduce((acc, column, index) => { let width = changedColumns.get(column.key) != null ? changedColumns.get(column.key) : column.width ?? column.defaultWidth ?? getDefaultWidth?.(index) ?? '1fr'; diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 24045667f2a..7802ca656e4 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -1,3 +1,14 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ import {ColumnSize} from '@react-types/table'; import {GridNode} from '@react-types/grid'; @@ -21,13 +32,13 @@ export interface TableColumnResizeStateProps { /** Callback that is invoked when the resize event is ended. */ onColumnResizeEnd?: (key: Key) => void } -export interface TableColumnResizeState { +export interface TableColumnResizeState { /** Trigger a resize and recalculation. */ - onColumnResize: (column: GridNode, width: number) => void, + onColumnResize: (key: Key, width: number) => void, /** Callback for when onColumnResize has started. */ - onColumnResizeStart: (column: GridNode) => void, + onColumnResizeStart: (key: Key) => void, /** Callback for when onColumnResize has ended. */ - onColumnResizeEnd: (column: GridNode) => void, + onColumnResizeEnd: (key: Key) => void, /** Gets the current width for the specified column. */ getColumnWidth: (key: Key) => number, /** Gets the current minWidth for the specified column. */ @@ -39,10 +50,12 @@ export interface TableColumnResizeState { } -export function useTableColumnResizeState(props: TableColumnResizeStateProps, state): TableColumnResizeState { +export function useTableColumnResizeState(props: TableColumnResizeStateProps, state): TableColumnResizeState { let { getDefaultWidth, - getDefaultMinWidth + getDefaultMinWidth, + onColumnResizeStart: propsOnColumnResizeStart, + onColumnResizeEnd: propsOnColumnResizeEnd } = props; let columnLayout = useMemo( @@ -66,7 +79,7 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< , [state.collection.columns]); // is this a safe thing to memo on? what if a single column changes? // uncontrolled column widths - let [widths, setWidths] = useState>(() => new Map( + let [widths, setWidths] = useState>(() => new Map( Array.from(uncontrolledWidths).map(([key, col]) => [key, col.props.defaultWidth ?? getDefaultWidth?.(col.props)] )) @@ -78,76 +91,66 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< } else { return [col.key, controlledWidths.get(col.key).props.width]; } - })), [state.collection.columns, uncontrolledWidths, controlledWidths]); + })), [state.collection.columns, uncontrolledWidths, controlledWidths, widths]); + let onColumnResizeStart = useCallback((key: Key) => { + columnLayout.setResizingColumn(key); + propsOnColumnResizeStart?.(key); + }, [columnLayout, propsOnColumnResizeStart]); - let onColumnResizeStart = useCallback((column: GridNode) => { - columnLayout.setResizingColumn(column.key); - props.onColumnResizeStart && props.onColumnResizeStart(column.key); - }, [columnLayout, props.onColumnResizeStart]); - - let onColumnResize = useCallback((column: GridNode, width: number): Map => { + // TODO: move props.on* all into this file and layout, or move them all out to the aria handler..., stately would be preferable + let onColumnResize = useCallback((key: Key, width: number): Map => { let newControlled = new Map(Array.from(controlledWidths).map(([key, entry]) => [key, entry.props.width])); - let newSizes = columnLayout.resizeColumnWidth(tableWidth, state.collection, newControlled, widths, column.key, width); + let newSizes = columnLayout.resizeColumnWidth(tableWidth, state.collection, newControlled, widths, key, width); let map = new Map(Array.from(uncontrolledWidths).map(([key]) => [key, newSizes.get(key)])); - map.set(column.key, width); + map.set(key, width); setWidths(map); return newSizes; - }, [controlledWidths, uncontrolledWidths, props.onColumnResize, setWidths, tableWidth, columnLayout, state.collection, widths]); + }, [controlledWidths, uncontrolledWidths, setWidths, tableWidth, columnLayout, state.collection, widths]); - let onColumnResizeEnd = useCallback((column: GridNode) => { + let onColumnResizeEnd = useCallback((key: Key) => { columnLayout.setResizingColumn(null); - props.onColumnResizeEnd && props.onColumnResizeEnd(column.key); - }, [columnLayout, props.onColumnResizeEnd]); + propsOnColumnResizeEnd?.(key); + }, [columnLayout, propsOnColumnResizeEnd]); // done - let getColumnWidth = useCallback((key: Key) => { - return columnLayout.getColumnWidth(key); - }, [columnLayout]); + let getColumnWidth = useCallback((key: Key) => + columnLayout.getColumnWidth(key) + , [columnLayout]); // done - let getColumnMinWidth = useCallback((key: Key) => { - return columnLayout.getColumnMinWidth(key); - }, [columnLayout, state.collection, tableWidth]); + let getColumnMinWidth = useCallback((key: Key) => + columnLayout.getColumnMinWidth(key) + , [columnLayout]); // done - let getColumnMaxWidth = useCallback((key: Key) => { - return columnLayout.getColumnMaxWidth(key); - }, [columnLayout, state.collection, tableWidth]); - - let setResizingColumn = useCallback((key: Key) => { - columnLayout.setResizingColumn(key); - }, [columnLayout]); - - let setResizeColumnWidth = useCallback((width) => { - columnLayout.setResizeColumnWidth(width); - }, [columnLayout]); + let getColumnMaxWidth = useCallback((key: Key) => + columnLayout.getColumnMaxWidth(key) + , [columnLayout]); let columnWidths = useMemo(() => columnLayout.buildColumnWidths(tableWidth, state.collection, cWidths) - , [tableWidth, state.collection, cWidths]); + , [tableWidth, state.collection, cWidths, columnLayout]); return useMemo(() => ({ + resizingColumn: columnLayout.resizingColumn, onColumnResize, onColumnResizeStart, onColumnResizeEnd, getColumnWidth, getColumnMinWidth, getColumnMaxWidth, - setResizingColumn, - setResizeColumnWidth, widths: columnWidths }), [ + columnLayout.resizingColumn, onColumnResize, onColumnResizeStart, onColumnResizeEnd, getColumnWidth, getColumnMinWidth, getColumnMaxWidth, - setResizingColumn, - setResizeColumnWidth, columnWidths ]); } From a8ab1221b8ea0761422b6abf1f2e9c36d16e0f39 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 1 Dec 2022 06:02:02 +1100 Subject: [PATCH 12/42] fix import --- packages/@react-stately/table/src/useTableColumnResizeState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 7802ca656e4..6dab3afa5c3 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -13,7 +13,7 @@ import {ColumnSize} from '@react-types/table'; import {GridNode} from '@react-types/grid'; import {Key, useCallback, useMemo, useState} from 'react'; -import {TableColumnLayout} from '@react-stately/layout/src/TableLayout'; +import {TableColumnLayout} from '@react-stately/layout'; export interface TableColumnResizeStateProps { /** From af599ac380051c187fcc634f24f0c5c7bdb6d5c3 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 11:45:43 +1100 Subject: [PATCH 13/42] onResizeEnd called once with sizes --- .../table/src/useTableColumnResize.ts | 48 +- .../table/stories/example-resizing.tsx | 11 +- .../table/test/ariaTableResizing.test.tsx | 562 +---------------- .../table/test/tableResizingTests.tsx | 574 ++++++++++++++++++ .../@react-spectrum/table/src/TableView.tsx | 6 +- .../table/test/TableSizing.test.tsx | 35 +- .../@react-stately/layout/src/TableLayout.ts | 4 + 7 files changed, 650 insertions(+), 590 deletions(-) create mode 100644 packages/@react-aria/table/test/tableResizingTests.tsx diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 2ee7cb0745d..724b040fb39 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -51,11 +51,13 @@ export interface TableLayoutState { } export function useTableColumnResize(props: AriaTableColumnResizeProps, state: TableState, layoutState: TableLayoutState, ref: RefObject): TableColumnResizeAria { - let {column: item, triggerRef, isDisabled} = props; + let {column: item, triggerRef, isDisabled, onResizeStart, onResize, onResizeEnd} = props; const stateRef = useRef(null); stateRef.current = layoutState; const stringFormatter = useLocalizedStringFormatter(intlMessages); let id = useId(); + let isResizing = useRef(false); + let lastSize = useRef(null); let {direction} = useLocale(); let {keyboardProps} = useKeyboard({ @@ -68,12 +70,34 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } }); + let startResize = useCallback((item) => { + if (!isResizing.current) { + stateRef.current.onColumnResizeStart(item.key); + onResizeStart?.(item.key); + } + isResizing.current = true; + }, [isResizing, stateRef, onResizeStart]); + + let resize = useCallback((item, newWidth) => { + let sizes = stateRef.current.onColumnResize(item.key, newWidth); + onResize?.(sizes); + lastSize.current = sizes; + }, [stateRef, onResize]); + + let endResize = useCallback((item) => { + if (isResizing.current) { + stateRef.current.onColumnResizeEnd(item.key); + onResizeEnd?.(lastSize.current); + } + isResizing.current = false; + lastSize.current = null; + }, [isResizing, stateRef, onResizeEnd]); + const columnResizeWidthRef = useRef(0); const {moveProps} = useMove({ onMoveStart() { columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key); - stateRef.current.onColumnResizeStart(item.key); - props.onResizeStart?.(item.key); + startResize(item); }, onMove(e) { let {deltaX, deltaY, pointerType} = e; @@ -86,12 +110,11 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } deltaX *= 10; } + props.onMove?.(e); // if moving up/down only, no need to resize if (deltaX !== 0) { columnResizeWidthRef.current += deltaX; - let sizes = stateRef.current.onColumnResize(item.key, columnResizeWidthRef.current); - props.onMove?.(e); - props.onResize?.(sizes); + resize(item, columnResizeWidthRef.current); } }, onMoveEnd(e) { @@ -99,8 +122,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st columnResizeWidthRef.current = 0; props.onMoveEnd?.(e); if (pointerType === 'mouse') { - stateRef.current.onColumnResizeEnd(item.key); - props.onResizeEnd?.(item.key); + endResize(item); } } }); @@ -136,7 +158,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } else { nextValue = currentWidth - 10; } - stateRef.current.onColumnResize(item.key, nextValue); + resize(item, nextValue); props.onMove({pointerType: 'virtual'} as MoveMoveEvent); props.onMoveEnd({pointerType: 'virtual'} as MoveEndEvent); }; @@ -147,7 +169,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st return; } if (e.pointerType === 'virtual' && layoutState.resizingColumn != null) { - stateRef.current.onColumnResizeEnd(item.key); + endResize(item); focusSafely(triggerRef.current); return; } @@ -176,13 +198,11 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, 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.key); - props.onResizeStart?.(item.key); + startResize(item); state.setKeyboardNavigationDisabled(true); }, onBlur: () => { - stateRef.current.onColumnResizeEnd(item.key); - props.onResizeEnd?.(item.key); + endResize(item); state.setKeyboardNavigationDisabled(false); }, onChange, diff --git a/packages/@react-aria/table/stories/example-resizing.tsx b/packages/@react-aria/table/stories/example-resizing.tsx index 238b9f4b4e9..fe3e2be145b 100644 --- a/packages/@react-aria/table/stories/example-resizing.tsx +++ b/packages/@react-aria/table/stories/example-resizing.tsx @@ -85,7 +85,7 @@ export function Table(props) { {[...headerRow.childNodes].map(column => column.props.isSelectionCell ? - : + : )} ))} @@ -125,12 +125,13 @@ export function TableHeaderRow({item, state, children, className}) { ); } -function Resizer({column, state, layoutState, onResize}) { +function Resizer({column, state, layoutState, onResize, onResizeEnd}) { let ref = useRef(null); let {resizerProps, inputProps} = useTableColumnResize({ column, label: 'Resizer', - onResize: onResize + onResize, + onResizeEnd } as AriaTableColumnResizeProps, state, layoutState, ref); return ( @@ -161,7 +162,7 @@ function Resizer({column, state, layoutState, onResize}) { ); } -export function TableColumnHeader({column, state, widths, layoutState, onResize}) { +export function TableColumnHeader({column, state, widths, layoutState, onResize, onResizeEnd}) { let ref = useRef(); let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); let {isFocusVisible, focusProps} = useFocusRing(); @@ -195,7 +196,7 @@ export function TableColumnHeader({column, state, widths, layoutState, onResize} { column.props.allowsResizing && - + } diff --git a/packages/@react-aria/table/test/ariaTableResizing.test.tsx b/packages/@react-aria/table/test/ariaTableResizing.test.tsx index 30933234f3f..934006e9ef1 100644 --- a/packages/@react-aria/table/test/ariaTableResizing.test.tsx +++ b/packages/@react-aria/table/test/ariaTableResizing.test.tsx @@ -10,36 +10,19 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, installPointerEvent} from '@react-spectrum/test-utils'; +import {act, fireEvent} from '@react-spectrum/test-utils'; import {Cell, Column, Row, TableBody, TableHeader} from '@react-stately/table'; import {composeStories} from '@storybook/testing-react'; import React, {Key} from 'react'; import {render} from '@testing-library/react'; import {Table as ResizingTable} from '../stories/example-resizing'; +import {resizingTests} from './tableResizingTests'; +import {setInteractionModality} from '@react-aria/interactions'; import * as stories from '../stories/useTable.stories'; import {within} from '@testing-library/dom'; -let {TableWithSomeResizingFRsControlled} = composeStories(stories); - -let rows = [ - {id: 1, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, - {id: 2, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, - {id: 3, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, - {id: 4, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'}, - {id: 5, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, - {id: 6, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, - {id: 7, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, - {id: 8, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'}, - {id: 9, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, - {id: 10, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, - {id: 11, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, - {id: 12, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'} -]; -function getColumnWidths(tree) { - let rows = tree.getAllByRole('row') as HTMLElement[]; - return Array.from(rows[0].childNodes).map((cell: HTMLElement) => Number(cell.style.width.replace('px', ''))); -} +let {TableWithSomeResizingFRsControlled} = composeStories(stories); // I'd use tree.getByRole(role, {name: text}) here, but it's unbearably slow. function getColumn(tree, name) { @@ -53,6 +36,7 @@ function getColumn(tree, name) { } function resizeCol(tree, col, delta) { + act(() => {setInteractionModality('pointer');}); let column = getColumn(tree, col); let resizer = within(column).getByRole('slider'); @@ -70,539 +54,9 @@ function resizeTable(clientWidth, newValue) { act(() => {jest.runAllTimers();}); } -export let resizingTests = (render, rerender, Table, ControlledTable, resizeCol, resizeTable) => { -// assumption with all these tests -// 1. the controlling values we pass in aren't actually controlling -// the sizes, they are instead more like the default values that the controlling logic uses -// 2. defaultWidth function and minDefaultWidth passed must be the same in any implementation using -// these tests, or the values will be wrong, if those functions were exposed we could generalize, but seems like a lot just for testing - describe('Aria Table Resizing', () => { - installPointerEvent(); - let clientWidth, clientHeight; - let onResize; - - beforeEach(function () { - clientWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 900); - clientHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); - jest.useFakeTimers(); - onResize = jest.fn(); - }); - - afterEach(function () { - act(() => { - jest.runAllTimers(); - }); - clientWidth.mockReset(); - clientHeight.mockReset(); - onResize = null; - }); - - describe.each` - allowsResizing - ${undefined} - ${true} - `('initial column sizes allowsResizing=$allowsResizing', ({allowsResizing}) => { - it('should handle no value if table was written with default widths', () => { - let columns = [ - {name: 'Name', id: 'name', allowsResizing}, - {name: 'Type', id: 'type', allowsResizing}, - {name: 'Level', id: 'level', allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([300, 300, 300]); - }); - it('should handle default pixel widths', () => { - let columns = [ - {name: 'Name', id: 'name', defaultWidth: 100, allowsResizing}, - {name: 'Type', id: 'type', defaultWidth: 100, allowsResizing}, - {name: 'Level', id: 'level', defaultWidth: 400, allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 400]); - }); - it('should handle default percent widths', () => { - let columns = [ - {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, - {name: 'Type', id: 'type', defaultWidth: '16%', allowsResizing}, - {name: 'Level', id: 'level', defaultWidth: '33%', allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([450, 144, 297]); - }); - it('should handle default fr widths', () => { - let columns = [ - {name: 'Name', id: 'name', defaultWidth: '4fr', allowsResizing}, - {name: 'Type', id: 'type', defaultWidth: '3fr', allowsResizing}, - {name: 'Level', id: 'level', defaultWidth: '2fr', allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([400, 300, 200]); - }); - it('should handle a mix of default widths', () => { - let columns = [ - {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, - {name: 'Type', id: 'type', defaultWidth: '2fr', allowsResizing}, - {name: 'Level', id: 'level', defaultWidth: 100, allowsResizing}, - {name: 'Height', id: 'height', allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([450, 233, 100, 117]); - }); - it('any single remaining column with an FR will take the remaining space, regardless of how many FRs it is "worth"', () => { - let columns = [ - {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, - {name: 'Type', id: 'type', defaultWidth: 100, allowsResizing}, - {name: 'Level', id: 'level', defaultWidth: 100, allowsResizing}, - {name: 'Height', id: 'height', allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([450, 100, 100, 250]); - }); - it('cannot size less than the minWidth', () => { - let columns = [ - {name: 'Name', id: 'name', minWidth: 500, defaultWidth: '50%', allowsResizing}, - {name: 'Type', id: 'type', minWidth: 100, defaultWidth: '1fr', allowsResizing}, - {name: 'Level', id: 'level', minWidth: 150, defaultWidth: 100, allowsResizing}, - {name: 'Height', id: 'height', minWidth: 200, allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([500, 100, 150, 200]); - }); - it('cannot size more than the maxWidth', () => { - let columns = [ - {name: 'Name', id: 'name', maxWidth: 400, defaultWidth: '50%', allowsResizing}, - {name: 'Type', id: 'type', maxWidth: 100, defaultWidth: '1fr', allowsResizing}, - {name: 'Level', id: 'level', maxWidth: 100, defaultWidth: 150, allowsResizing}, - {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([400, 100, 100, 100]); - }); - it('minWidth can be a percent', () => { - let columns = [ - {name: 'Name', id: 'name', minWidth: '50%', defaultWidth: '30%', allowsResizing}, - {name: 'Type', id: 'type', maxWidth: 100, defaultWidth: '1fr', allowsResizing}, - {name: 'Level', id: 'level', maxWidth: 100, defaultWidth: 100, allowsResizing}, - {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([450, 100, 100, 100]); - }); - it('maxWidth can be a percent', () => { - let columns = [ - {name: 'Name', id: 'name', maxWidth: '50%', defaultWidth: '70%', allowsResizing}, - {name: 'Type', id: 'type', maxWidth: 100, defaultWidth: '1fr', allowsResizing}, - {name: 'Level', id: 'level', maxWidth: 100, defaultWidth: 100, allowsResizing}, - {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} - ]; - let tree = render(
); - expect(getColumnWidths(tree)).toStrictEqual([450, 100, 100, 100]); - }); - }); - - describe('resizing', () => { - function mapFromWidths(columnNames, widths) { - return new Map(widths.map((width, i) => [columnNames[i].toLowerCase(), width])); - } - - it.each` - col | delta | expected | expectedOnResize - ${'Name'} | ${-50} | ${[75, 103, 103, 103, 516]} | ${[75, '1fr', '1fr', '1fr', '5fr']} - ${'Name'} | ${50} | ${[150, 94, 94, 94, 468]} | ${[150, '1fr', '1fr', '1fr', '5fr']} - ${'Type'} | ${-50} | ${[100, 75, 104, 104, 517]} | ${[100, 75, '1fr', '1fr', '5fr']} - ${'Type'} | ${50} | ${[100, 150, 93, 93, 464]} | ${[100, 150, '1fr', '1fr', '5fr']} - ${'Height'} | ${-50} | ${[100, 100, 75, 104, 521]} | ${[100, 100, 75, '1fr', '5fr']} - ${'Height'} | ${50} | ${[100, 100, 150, 92, 458]} | ${[100, 100, 150, '1fr', '5fr']} - ${'Weight'} | ${-50} | ${[100, 100, 100, 75, 525]} | ${[100, 100, 100, 75, '5fr']} - ${'Weight'} | ${50} | ${[100, 100, 100, 150, 450]} | ${[100, 100, 100, 150, '5fr']} - ${'Level'} | ${-50} | ${[100, 100, 100, 100, 450]} | ${[100, 100, 100, 100, 450]} - ${'Level'} | ${50} | ${[100, 100, 100, 100, 550]} | ${[100, 100, 100, 100, 550]} - `('can resize $col to be $delta px different', - function ({col, delta, expected, expectedOnResize}) { - let columnNames = ['Name', 'Type', 'Height', 'Weight', 'Level']; - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 500]); - resizeCol(tree, col, delta); - expect(getColumnWidths(tree)).toStrictEqual(expected); - expect(onResize).toHaveBeenCalledTimes(1); - expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, expectedOnResize)); - let resizers = tree.getAllByRole('slider'); - resizers.forEach(resizer => { - expect(resizer).toHaveAttribute('min', `${75}`); - expect(resizer).toHaveAttribute('max', `${Number.MAX_SAFE_INTEGER}`); - }); - }); - - it('cannot resize to be less than a minWidth, from start to end', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, - {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, - {name: 'Height', uid: 'height', minWidth: 100}, - {name: 'Weight', uid: 'weight', minWidth: 100}, - {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} - ]; - let columnNames = ['Name', 'Type', 'Height', 'Weight', 'Level']; - - let tree = render(); - resizeCol(tree, 'Name', -50); // first column - expect(getColumnWidths(tree)).toStrictEqual([100, 114, 114, 114, 458]); - expect(onResize).toHaveBeenCalledTimes(1); - expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, '1fr', '1fr', '1fr', '4fr'])); - resizeCol(tree, 'Type', -50); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 117, 117, 466]); - expect(onResize).toHaveBeenCalledTimes(2); - expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, '1fr', '1fr', '4fr'])); - resizeCol(tree, 'Height', -100); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 120, 480]); - expect(onResize).toHaveBeenCalledTimes(3); - expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, '1fr', '4fr'])); - resizeCol(tree, 'Weight', -100); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 500]); - expect(onResize).toHaveBeenCalledTimes(4); - expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 100, '4fr'])); - resizeCol(tree, 'Level', -500); // last column - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); - expect(onResize).toHaveBeenCalledTimes(5); - expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 100, 100])); - let resizers = tree.getAllByRole('slider'); - resizers.forEach(resizer => { - expect(resizer).toHaveAttribute('min', `${100}`); - expect(resizer).toHaveAttribute('max', `${Number.MAX_SAFE_INTEGER}`); - }); - }); - - it('cannot resize to be less than a minWidth, from end to start', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, - {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, - {name: 'Height', uid: 'height', minWidth: 100}, - {name: 'Weight', uid: 'weight', minWidth: 100}, - {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} - ]; - - let tree = render(); - resizeCol(tree, 'Level', -500); // last column - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 100]); - resizeCol(tree, 'Weight', -100); - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 100, 100]); - resizeCol(tree, 'Height', -100); - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 100, 100, 100]); - resizeCol(tree, 'Type', -100); - expect(getColumnWidths(tree)).toStrictEqual([113, 100, 100, 100, 100]); - resizeCol(tree, 'Name', -500); // first column - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); - }); - - it('cannot resize to be more than a maxWidth, from start to end', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr', maxWidth: 150}, - {name: 'Type', uid: 'type', width: '1fr', maxWidth: 150}, - {name: 'Height', uid: 'height', maxWidth: 200}, - {name: 'Weight', uid: 'weight', maxWidth: 200}, - {name: 'Level', uid: 'level', width: '4fr', maxWidth: 500} - ]; - - let tree = render(); - resizeCol(tree, 'Name', 150); - expect(getColumnWidths(tree)).toStrictEqual([150, 107, 107, 107, 429]); - resizeCol(tree, 'Type', 150); - expect(getColumnWidths(tree)).toStrictEqual([150, 150, 100, 100, 400]); - resizeCol(tree, 'Height', 150); - expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 80, 320]); - resizeCol(tree, 'Weight', 200); - expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 200]); - resizeCol(tree, 'Level', 400); - expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 500]); - let resizers = tree.getAllByRole('slider'); - let expectedMaxWidths = [150, 150, 200, 200, 500]; - resizers.forEach((resizer, i) => { - expect(resizer).toHaveAttribute('min', `${75}`); - expect(resizer).toHaveAttribute('max', `${expectedMaxWidths[i]}`); - }); - }); - - it('cannot resize to be more than a maxWidth, from end to start', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr', maxWidth: 150}, - {name: 'Type', uid: 'type', width: '1fr', maxWidth: 150}, - {name: 'Height', uid: 'height', maxWidth: 200}, - {name: 'Weight', uid: 'weight', maxWidth: 200}, - {name: 'Level', uid: 'level', width: '4fr', maxWidth: 500} - ]; - - let tree = render(); - resizeCol(tree, 'Level', 150); - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 500]); - resizeCol(tree, 'Weight', 150); - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 200, 500]); - resizeCol(tree, 'Height', 100); - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 200, 200, 500]); - resizeCol(tree, 'Type', 100); - expect(getColumnWidths(tree)).toStrictEqual([113, 150, 200, 200, 500]); - resizeCol(tree, 'Name', 400); - expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 500]); - }); - - it('resizing the starter column will preserve fr column ratios to the right', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); - resizeCol(tree, 'Name', -50); - expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); - resizeCol(tree, 'Name', 38); // send it back to original size - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); - }); - - it('resizing the last column will lock columns to pixels to the left', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - resizeCol(tree, 'Level', -50); - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 400]); - resizeCol(tree, 'Level', 50); - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); - }); - - it('can handle removing a column', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - rerender(tree, ); - expect(getColumnWidths(tree)).toStrictEqual([129, 129, 128, 514]); - }); - - it('can handle adding a column', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([129, 129, 128, 514]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - rerender(tree, ); - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); - }); - - it('can handle resizing, then removing an uncontrolled column, then adding the column again', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - resizeCol(tree, 'Name', -50); - expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - rerender(tree, ); - expect(getColumnWidths(tree)).toStrictEqual([75, 138, 137, 550]); - rerender(tree, ); - expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); - }); - - it('can handle resizing, then removing an controlled column, then adding the column again', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - resizeCol(tree, 'Name', -50); - expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'} - ]; - rerender(tree, ); - expect(getColumnWidths(tree)).toStrictEqual([75, 275, 275, 275]); - rerender(tree, ); - expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); - }); - - it('can add new columns after resizing', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'} - ]; - - let tree = render(); - resizeCol(tree, 'Name', -50); - expect(getColumnWidths(tree)).toStrictEqual([250, 325, 325]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - rerender(tree, ); - expect(getColumnWidths(tree)).toStrictEqual([250, 163, 162, 163, 162]); - }); - - it('can remove and re-add the resized column', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - resizeCol(tree, 'Type', -50); - expect(getColumnWidths(tree)).toStrictEqual([113, 75, 119, 119, 474]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - rerender(tree, ); - expect(getColumnWidths(tree)).toStrictEqual([113, 131, 131, 525]); - rerender(tree, ); - expect(getColumnWidths(tree)).toStrictEqual([113, 75, 119, 119, 474]); - }); - - it('can resize smaller if the minWidth gets smaller', function () { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, - {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, - {name: 'Height', uid: 'height', minWidth: 100}, - {name: 'Weight', uid: 'weight', minWidth: 100}, - {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); - resizeCol(tree, 'Type', -100); - expect(getColumnWidths(tree)).toStrictEqual([113, 100, 115, 114, 458]); - let newColumns = [ - {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, - {name: 'Type', uid: 'type', width: '1fr', minWidth: 50}, - {name: 'Height', uid: 'height', minWidth: 100}, - {name: 'Weight', uid: 'weight', minWidth: 100}, - {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} - ]; - rerender(tree, ); - expect(getColumnWidths(tree)).toStrictEqual([113, 100, 115, 114, 458]); - resizeCol(tree, 'Type', -100); - expect(getColumnWidths(tree)).toStrictEqual([113, 50, 123, 123, 491]); - }); - }); - - describe('resizing table', () => { - it('will not affect pixel widths', () => { - let columns = [ - {name: 'Name', uid: 'name', width: 100}, - {name: 'Type', uid: 'type', width: 100}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: 400} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); - resizeTable(clientWidth, 1000); - expect(getColumnWidths(tree)).toStrictEqual([100, 100, 200, 200, 400]); - }); - - it('will resize all percent columns', () => { - let columns = [ - {name: 'Name', uid: 'name', width: '20%'}, - {name: 'Type', uid: 'type', width: '20%'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '40%'} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([180, 180, 90, 90, 360]); - resizeTable(clientWidth, 1000); - expect(getColumnWidths(tree)).toStrictEqual([200, 200, 100, 100, 400]); - }); - - it('will resize all fr columns', () => { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '1fr'}, - {name: 'Height', uid: 'height'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); - resizeTable(clientWidth, 1000); - expect(getColumnWidths(tree)).toStrictEqual([125, 125, 125, 125, 500]); - }); - - it('will resize all fr columns only after percent columns', () => { - let columns = [ - {name: 'Name', uid: 'name', width: '1fr'}, - {name: 'Type', uid: 'type', width: '20%'}, - {name: 'Height', uid: 'height', width: '20%'}, - {name: 'Weight', uid: 'weight'}, - {name: 'Level', uid: 'level', width: '4fr'} - ]; - - let tree = render(); - expect(getColumnWidths(tree)).toStrictEqual([90, 180, 180, 90, 360]); - resizeTable(clientWidth, 1000); - expect(getColumnWidths(tree)).toStrictEqual([100, 200, 200, 100, 400]); - }); - }); - }); -}; - -resizingTests(render, (tree, ...args) => tree.rerender(...args), Table, TableWithSomeResizingFRsControlled, resizeCol, resizeTable); +describe('Aria Table', () => { + resizingTests(render, (tree, ...args) => tree.rerender(...args), Table, TableWithSomeResizingFRsControlled, resizeCol, resizeTable); +}); function Table(props: {columns: {id: Key, name: string}[], rows}) { let {columns, rows, ...args} = props; diff --git a/packages/@react-aria/table/test/tableResizingTests.tsx b/packages/@react-aria/table/test/tableResizingTests.tsx new file mode 100644 index 00000000000..9464fd22ed1 --- /dev/null +++ b/packages/@react-aria/table/test/tableResizingTests.tsx @@ -0,0 +1,574 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, installPointerEvent} from '@react-spectrum/test-utils'; + +import React from 'react'; + +let rows = [ + {id: 1, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, + {id: 2, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, + {id: 3, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, + {id: 4, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'}, + {id: 5, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, + {id: 6, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, + {id: 7, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, + {id: 8, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'}, + {id: 9, name: 'Charizard', type: 'Fire, Flying', level: '67', weight: '200lbs', height: '5\'7"'}, + {id: 10, name: 'Blastoise', type: 'Water', level: '56', weight: '188lbs', height: '5\'3"'}, + {id: 11, name: 'Venusaur', type: 'Grass, Poison', level: '83', weight: '220lbs', height: '6\'7"'}, + {id: 12, name: 'Pikachu', type: 'Electric', level: '100', weight: '13lbs', height: '1\'4"'} +]; + +function getColumnWidths(tree) { + let rows = tree.getAllByRole('row') as HTMLElement[]; + return Array.from(rows[0].childNodes).map((cell: HTMLElement) => Number(cell.style.width.replace('px', ''))); +} + +export let resizingTests = (render, rerender, Table, ControlledTable, resizeCol, resizeTable) => { +// assumption with all these tests +// 1. the controlling values we pass in aren't actually controlling +// the sizes, they are instead more like the default values that the controlling logic uses +// 2. defaultWidth function and minDefaultWidth passed must be the same in any implementation using +// these tests, or the values will be wrong, if those functions were exposed we could generalize, but seems like a lot just for testing + describe('Resizing', () => { + installPointerEvent(); + let clientWidth, clientHeight; + let onResize; + + beforeEach(function () { + clientWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 900); + clientHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + jest.useFakeTimers(); + onResize = jest.fn(); + }); + + afterEach(function () { + act(() => { + jest.runAllTimers(); + }); + clientWidth.mockReset(); + clientHeight.mockReset(); + onResize.mockReset(); + onResize = null; + }); + + describe.each` + allowsResizing + ${undefined} + ${true} + `('initial column sizes allowsResizing=$allowsResizing', ({allowsResizing}) => { + it('should handle no value if table was written with default widths', () => { + let columns = [ + {name: 'Name', id: 'name', allowsResizing}, + {name: 'Type', id: 'type', allowsResizing}, + {name: 'Level', id: 'level', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([300, 300, 300]); + }); + it('should handle default pixel widths', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: 100, allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: 100, allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: 400, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 400]); + }); + it('should handle default percent widths', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: '16%', allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: '33%', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 144, 297]); + }); + it('should handle default fr widths', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: '4fr', allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: '3fr', allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: '2fr', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([400, 300, 200]); + }); + it('should handle a mix of default widths', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: '2fr', allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 233, 100, 117]); + }); + it('any single remaining column with an FR will take the remaining space, regardless of how many FRs it is "worth"', () => { + let columns = [ + {name: 'Name', id: 'name', defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', defaultWidth: 100, allowsResizing}, + {name: 'Level', id: 'level', defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 100, 100, 250]); + }); + it('cannot size less than the minWidth', () => { + let columns = [ + {name: 'Name', id: 'name', minWidth: 500, defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', minWidth: 100, defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', minWidth: 150, defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', minWidth: 200, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([500, 100, 150, 200]); + }); + it('cannot size more than the maxWidth', () => { + let columns = [ + {name: 'Name', id: 'name', maxWidth: 400, defaultWidth: '50%', allowsResizing}, + {name: 'Type', id: 'type', maxWidth: 100, defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', maxWidth: 100, defaultWidth: 150, allowsResizing}, + {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([400, 100, 100, 100]); + }); + it('minWidth can be a percent', () => { + let columns = [ + {name: 'Name', id: 'name', minWidth: '50%', defaultWidth: '30%', allowsResizing}, + {name: 'Type', id: 'type', maxWidth: 100, defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', maxWidth: 100, defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 100, 100, 100]); + }); + it('maxWidth can be a percent', () => { + let columns = [ + {name: 'Name', id: 'name', maxWidth: '50%', defaultWidth: '70%', allowsResizing}, + {name: 'Type', id: 'type', maxWidth: 100, defaultWidth: '1fr', allowsResizing}, + {name: 'Level', id: 'level', maxWidth: 100, defaultWidth: 100, allowsResizing}, + {name: 'Height', id: 'height', maxWidth: 100, allowsResizing} + ]; + let tree = render(
); + expect(getColumnWidths(tree)).toStrictEqual([450, 100, 100, 100]); + }); + }); + + describe('interactions', () => { + function mapFromWidths(columnNames, widths) { + return new Map(widths.map((width, i) => [columnNames[i].toLowerCase(), width])); + } + + it.each` + col | delta | expected | expectedOnResize + ${'Name'} | ${-50} | ${[75, 103, 103, 103, 516]} | ${[75, '1fr', '1fr', '1fr', '5fr']} + ${'Name'} | ${50} | ${[150, 94, 94, 94, 468]} | ${[150, '1fr', '1fr', '1fr', '5fr']} + ${'Type'} | ${-50} | ${[100, 75, 104, 104, 517]} | ${[100, 75, '1fr', '1fr', '5fr']} + ${'Type'} | ${50} | ${[100, 150, 93, 93, 464]} | ${[100, 150, '1fr', '1fr', '5fr']} + ${'Height'} | ${-50} | ${[100, 100, 75, 104, 521]} | ${[100, 100, 75, '1fr', '5fr']} + ${'Height'} | ${50} | ${[100, 100, 150, 92, 458]} | ${[100, 100, 150, '1fr', '5fr']} + ${'Weight'} | ${-50} | ${[100, 100, 100, 75, 525]} | ${[100, 100, 100, 75, '5fr']} + ${'Weight'} | ${50} | ${[100, 100, 100, 150, 450]} | ${[100, 100, 100, 150, '5fr']} + ${'Level'} | ${-50} | ${[100, 100, 100, 100, 450]} | ${[100, 100, 100, 100, 450]} + ${'Level'} | ${50} | ${[100, 100, 100, 100, 550]} | ${[100, 100, 100, 100, 550]} + `('can resize $col to be $delta px different', + function ({col, delta, expected, expectedOnResize}) { + let columnNames = ['Name', 'Type', 'Height', 'Weight', 'Level']; + let onResizeEnd = jest.fn(); + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 500]); + + resizeCol(tree, col, delta); + + expect(getColumnWidths(tree)).toStrictEqual(expected); + expect(onResize).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, expectedOnResize)); + let resizers = tree.getAllByRole('slider'); + resizers.forEach(resizer => { + expect(resizer).toHaveAttribute('min', `${75}`); + expect(resizer).toHaveAttribute('max', `${Number.MAX_SAFE_INTEGER}`); + }); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith(mapFromWidths(columnNames, expectedOnResize)); + }); + + it('cannot resize to be less than a minWidth, from start to end', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; + let columnNames = ['Name', 'Type', 'Height', 'Weight', 'Level']; + + let tree = render(); + resizeCol(tree, 'Name', -50); // first column + expect(getColumnWidths(tree)).toStrictEqual([100, 114, 114, 114, 458]); + expect(onResize).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, '1fr', '1fr', '1fr', '4fr'])); + resizeCol(tree, 'Type', -50); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 117, 117, 466]); + expect(onResize).toHaveBeenCalledTimes(2); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, '1fr', '1fr', '4fr'])); + resizeCol(tree, 'Height', -100); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 120, 480]); + expect(onResize).toHaveBeenCalledTimes(3); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, '1fr', '4fr'])); + resizeCol(tree, 'Weight', -100); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 500]); + expect(onResize).toHaveBeenCalledTimes(4); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 100, '4fr'])); + resizeCol(tree, 'Level', -500); // last column + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); + expect(onResize).toHaveBeenCalledTimes(5); + expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, [100, 100, 100, 100, 100])); + let resizers = tree.getAllByRole('slider'); + resizers.forEach(resizer => { + expect(resizer).toHaveAttribute('min', `${100}`); + expect(resizer).toHaveAttribute('max', `${Number.MAX_SAFE_INTEGER}`); + }); + }); + + it('cannot resize to be less than a minWidth, from end to start', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; + + let tree = render(); + resizeCol(tree, 'Level', -500); // last column + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 100]); + resizeCol(tree, 'Weight', -100); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 100, 100]); + resizeCol(tree, 'Height', -100); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 100, 100, 100]); + resizeCol(tree, 'Type', -100); + expect(getColumnWidths(tree)).toStrictEqual([113, 100, 100, 100, 100]); + resizeCol(tree, 'Name', -500); // first column + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 100, 100, 100]); + }); + + it('cannot resize to be more than a maxWidth, from start to end', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', maxWidth: 150}, + {name: 'Type', uid: 'type', width: '1fr', maxWidth: 150}, + {name: 'Height', uid: 'height', maxWidth: 200}, + {name: 'Weight', uid: 'weight', maxWidth: 200}, + {name: 'Level', uid: 'level', width: '4fr', maxWidth: 500} + ]; + + let tree = render(); + resizeCol(tree, 'Name', 150); + expect(getColumnWidths(tree)).toStrictEqual([150, 107, 107, 107, 429]); + resizeCol(tree, 'Type', 150); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 100, 100, 400]); + resizeCol(tree, 'Height', 150); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 80, 320]); + resizeCol(tree, 'Weight', 200); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 200]); + resizeCol(tree, 'Level', 400); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 500]); + let resizers = tree.getAllByRole('slider'); + let expectedMaxWidths = [150, 150, 200, 200, 500]; + resizers.forEach((resizer, i) => { + expect(resizer).toHaveAttribute('min', `${75}`); + expect(resizer).toHaveAttribute('max', `${expectedMaxWidths[i]}`); + }); + }); + + it('cannot resize to be more than a maxWidth, from end to start', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', maxWidth: 150}, + {name: 'Type', uid: 'type', width: '1fr', maxWidth: 150}, + {name: 'Height', uid: 'height', maxWidth: 200}, + {name: 'Weight', uid: 'weight', maxWidth: 200}, + {name: 'Level', uid: 'level', width: '4fr', maxWidth: 500} + ]; + + let tree = render(); + resizeCol(tree, 'Level', 150); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 500]); + resizeCol(tree, 'Weight', 150); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 200, 500]); + resizeCol(tree, 'Height', 100); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 200, 200, 500]); + resizeCol(tree, 'Type', 100); + expect(getColumnWidths(tree)).toStrictEqual([113, 150, 200, 200, 500]); + resizeCol(tree, 'Name', 400); + expect(getColumnWidths(tree)).toStrictEqual([150, 150, 200, 200, 500]); + }); + + it('resizing the starter column will preserve fr column ratios to the right', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + resizeCol(tree, 'Name', -50); + expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); + resizeCol(tree, 'Name', 38); // send it back to original size + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + }); + + it('resizing the last column will lock columns to pixels to the left', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Level', -50); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 400]); + resizeCol(tree, 'Level', 50); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + }); + + it('can handle removing a column', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([129, 129, 128, 514]); + }); + + it('can handle adding a column', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([129, 129, 128, 514]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + }); + + it('can handle resizing, then removing an uncontrolled column, then adding the column again', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Name', -50); + expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([75, 138, 137, 550]); + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); + }); + + it('can handle resizing, then removing an controlled column, then adding the column again', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Name', -50); + expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([75, 275, 275, 275]); + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([75, 118, 118, 118, 471]); + }); + + it('can add new columns after resizing', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'} + ]; + + let tree = render(); + resizeCol(tree, 'Name', -50); + expect(getColumnWidths(tree)).toStrictEqual([250, 325, 325]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([250, 163, 162, 163, 162]); + }); + + it('can remove and re-add the resized column', function () { + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + resizeCol(tree, 'Type', -50); + expect(getColumnWidths(tree)).toStrictEqual([113, 75, 119, 119, 474]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([113, 131, 131, 525]); + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([113, 75, 119, 119, 474]); + }); + + it('can resize smaller if the minWidth gets smaller', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + resizeCol(tree, 'Type', -100); + expect(getColumnWidths(tree)).toStrictEqual([113, 100, 115, 114, 458]); + let newColumns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 50}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; + rerender(tree, ); + expect(getColumnWidths(tree)).toStrictEqual([113, 100, 115, 114, 458]); + resizeCol(tree, 'Type', -100); + expect(getColumnWidths(tree)).toStrictEqual([113, 50, 123, 123, 491]); + }); + }); + + describe('resizing table', () => { + it('will not affect pixel widths', () => { + let columns = [ + {name: 'Name', uid: 'name', width: 100}, + {name: 'Type', uid: 'type', width: 100}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: 400} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 150, 150, 400]); + resizeTable(clientWidth, 1000); + expect(getColumnWidths(tree)).toStrictEqual([100, 100, 200, 200, 400]); + }); + + it('will resize all percent columns', () => { + let columns = [ + {name: 'Name', uid: 'name', width: '20%'}, + {name: 'Type', uid: 'type', width: '20%'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '40%'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([180, 180, 90, 90, 360]); + resizeTable(clientWidth, 1000); + expect(getColumnWidths(tree)).toStrictEqual([200, 200, 100, 100, 400]); + }); + + it('will resize all fr columns', () => { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + resizeTable(clientWidth, 1000); + expect(getColumnWidths(tree)).toStrictEqual([125, 125, 125, 125, 500]); + }); + + it('will resize all fr columns only after percent columns', () => { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr'}, + {name: 'Type', uid: 'type', width: '20%'}, + {name: 'Height', uid: 'height', width: '20%'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '4fr'} + ]; + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([90, 180, 180, 90, 360]); + resizeTable(clientWidth, 1000); + expect(getColumnWidths(tree)).toStrictEqual([100, 200, 200, 100, 400]); + }); + }); + }); +}; diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index b4d3b401649..c3ff4820132 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -410,7 +410,7 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo if (isLoading) { transitionDuration = 160; } - if (layout.columnLayout.resizingColumn != null) { + if (layout.resizingColumn != null) { // while resizing, prop changes should not cause animations transitionDuration = 0; } @@ -479,7 +479,7 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo } }, [state.contentSize, state.virtualizer, state.isAnimating, onLoadMore, isLoading]); - let resizerPosition = layout.columnLayout.getResizerPosition() - 2; + let resizerPosition = layout.getResizerPosition() - 2; let resizerAtEdge = resizerPosition > Math.max(state.virtualizer.contentSize.width, state.virtualizer.visibleRect.width) - 3; // this should be fine, every movement of the resizer causes a rerender @@ -531,7 +531,7 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo {state.visibleViews[1]}
+ style={{[direction === 'ltr' ? 'left' : 'right']: `${resizerPosition}px`, height: `${Math.max(state.virtualizer.contentSize.height, state.virtualizer.visibleRect.height)}px`, display: layout.resizingColumn ? 'block' : 'none'}} />
diff --git a/packages/@react-spectrum/table/test/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx index d9684a2fc6c..09655859efe 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.tsx +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -10,17 +10,19 @@ * governing permissions and limitations under the License. */ + jest.mock('@react-aria/live-announcer'); import {act, render as renderComponent, within} from '@testing-library/react'; import {ActionButton} from '@react-spectrum/button'; import Add from '@spectrum-icons/workflow/Add'; import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; +import {ColumnSize} from '@react-types/table'; import {ControllingResize} from '../stories/ControllingResize'; import {fireEvent, installPointerEvent, triggerTouch} from '@react-spectrum/test-utils'; import {HidingColumns} from '../stories/HidingColumns'; import {Provider} from '@react-spectrum/provider'; import React, {Key} from 'react'; -import {resizingTests} from '@react-aria/table/test/ariaTableResizing.test'; +import {resizingTests} from '@react-aria/table/test/tableResizingTests'; import {Scale} from '@react-types/provider'; import {setInteractionModality} from '@react-aria/interactions'; import {theme} from '@react-spectrum/theme-default'; @@ -696,7 +698,7 @@ describe('TableViewSizing', function () { expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 595], ['bar', '1fr'], ['baz', '1fr']])); // actual locations do not matter, the delta matters between events for the calculation of useMove fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 30}); @@ -710,7 +712,7 @@ describe('TableViewSizing', function () { expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); } expect(onResizeEnd).toHaveBeenCalledTimes(2); - expect(onResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 595], ['bar', '1fr'], ['baz', '1fr']])); fireEvent.pointerLeave(resizer, {pointerType: 'mouse', pointerId: 1}); fireEvent.pointerLeave(resizableHeader, {pointerType: 'mouse', pointerId: 1}); @@ -770,7 +772,7 @@ describe('TableViewSizing', function () { expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); } expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 595], ['bar', '1fr'], ['baz', '1fr']])); // actual locations do not matter, the delta matters between events for the calculation of useMove fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 30}); @@ -784,7 +786,7 @@ describe('TableViewSizing', function () { expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); } expect(onResizeEnd).toHaveBeenCalledTimes(2); - expect(onResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 595], ['bar', '1fr'], ['baz', '1fr']])); fireEvent.pointerLeave(resizer, {pointerType: 'mouse', pointerId: 1}); fireEvent.pointerLeave(resizableHeader, {pointerType: 'mouse', pointerId: 1}); @@ -874,7 +876,7 @@ describe('TableViewSizing', function () { act(() => {jest.runAllTimers();}); expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 620], ['bar', '1fr'], ['baz', '1fr']])); expect(tree.queryByRole('slider')).toBeNull(); }); @@ -959,7 +961,7 @@ describe('TableViewSizing', function () { act(() => {jest.runAllTimers();}); expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 620], ['bar', '1fr'], ['baz', '1fr']])); expect(tree.queryByRole('slider')).toBeNull(); }); @@ -1066,7 +1068,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Escape'}); fireEvent.keyUp(document.activeElement, {key: 'Escape'}); expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); expect(document.activeElement).toBe(resizableHeader); @@ -1147,7 +1149,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Escape'}); fireEvent.keyUp(document.activeElement, {key: 'Escape'}); expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); expect(document.activeElement).toBe(resizableHeader); @@ -1198,7 +1200,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Enter'}); fireEvent.keyUp(document.activeElement, {key: 'Enter'}); expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledWith(null); expect(document.activeElement).toBe(resizableHeader); @@ -1233,7 +1235,6 @@ describe('TableViewSizing', function () { expect(document.activeElement).toBe(resizableHeader); expect(tree.queryByRole('slider')).toBeNull(); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); fireEvent.keyUp(document.activeElement, {key: 'Enter'}); @@ -1248,7 +1249,9 @@ describe('TableViewSizing', function () { userEvent.tab(); expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith('foo'); + // TODO: should call with null or the currently calculated widths? + // might be hard to call with current values + expect(onResizeEnd).toHaveBeenCalledWith(null); expect(document.activeElement).toBe(resizableHeader); @@ -1298,7 +1301,7 @@ describe('TableViewSizing', function () { userEvent.tab({shift: true}); expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith('foo'); + expect(onResizeEnd).toHaveBeenCalledWith(null); expect(document.activeElement).toBe(resizableHeader); @@ -1556,6 +1559,7 @@ function resizeCol(tree, col, delta) { let column = getColumn(tree, col); // trigger pointer modality + act(() => {setInteractionModality('pointer');}); fireEvent.pointerMove(tree.container); fireEvent.pointerEnter(column); @@ -1564,6 +1568,7 @@ function resizeCol(tree, col, delta) { // actual locations do not matter, the delta matters between events for the calculation of useMove fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 0, pageY: 30}); + act(() => {jest.runAllTimers();}); fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: delta, pageY: 25}); fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); act(() => {jest.runAllTimers();}); @@ -1576,4 +1581,6 @@ function resizeTable(clientWidth, newValue) { act(() => {jest.runAllTimers();}); } -resizingTests(render, rerender, ControllingResize, ControllingResize, resizeCol, resizeTable); +describe('RSP TableView', () => { + resizingTests(render, rerender, ControllingResize, ControllingResize, resizeCol, resizeTable); +}); diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 5101d406d26..7ae27be11c3 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -212,6 +212,10 @@ export class TableLayout extends ListLayout { return this.columnLayout.resizingColumn; } + getResizerPosition(): Key { + return this.columnLayout.getResizerPosition(); + } + getColumnWidth(key: Key): number { return this.columnLayout.getColumnWidth(key) ?? 0; } From d0c5f73eebf51df92a29ccfa379ea08509d30282 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 11:47:47 +1100 Subject: [PATCH 14/42] fix aria story missing resize handles --- packages/@react-aria/table/stories/useTable.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index 671f2ba2831..16d45cfc686 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -113,7 +113,7 @@ export const TableWithResizingNoProps = { {column => ( - + {column.name} )} From 16873d998b25d05ab54cbbff416676a3f36063b9 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 11:56:37 +1100 Subject: [PATCH 15/42] Add story descriptions --- .../table/stories/useTable.stories.tsx | 16 ++++++++++-- .../table/stories/Table.stories.tsx | 25 ++++++++++++++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index 16d45cfc686..fc3d1a97789 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -217,7 +217,13 @@ let columnsFR: ColumnData[] = [ export const TableWithResizingFRsControlled = { args: {columns: columnsFR}, - render: (args) => + render: (args) => , + parameters: {description: {data: ` + You can use the buttons to save and restore the column widths. When restoring, + you will see a quick flash because the entire table is re-rendered. This + mimics what would happen if an app reloaded the whole page and restored a saved + column width state. + `}} }; let columnsSomeFR: ColumnData[] = [ @@ -230,5 +236,11 @@ let columnsSomeFR: ColumnData[] = [ export const TableWithSomeResizingFRsControlled = { args: {columns: columnsSomeFR}, - render: (args) => + render: (args) => , + parameters: {description: {data: ` + You can use the buttons to save and restore the column widths. When restoring, + you will see a quick flash because the entire table is re-rendered. This + mimics what would happen if an app reloaded the whole page and restored a saved + column width state. + `}} }; diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index 8a3671c47a7..fbbab828e4a 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -39,6 +39,7 @@ import {TextField} from '@react-spectrum/textfield'; import {useAsyncList, useListData} from '@react-stately/data'; import {useFilter} from '@react-aria/i18n'; import {View} from '@react-spectrum/view'; +import {parameters} from '../../../../.storybook/preview'; let columns = [ {name: 'Foo', key: 'foo'}, @@ -1374,19 +1375,37 @@ storiesOf('TableView', module) 'allowsResizing, controlled, no widths', () => ( - ) + ), + {description: {data: ` + You can use the buttons to save and restore the column widths. When restoring, + you will see a quick flash because the entire table is re-rendered. This + mimics what would happen if an app reloaded the whole page and restored a saved + column width state. + `}} ) .add( 'allowsResizing, controlled, some widths', () => ( - ) + ), + {description: {data: ` + You can use the buttons to save and restore the column widths. When restoring, + you will see a quick flash because the entire table is re-rendered. This + mimics what would happen if an app reloaded the whole page and restored a saved + column width state. + `}} ) .add( 'allowsResizing, controlled, all widths', () => ( - ) + ), + {description: {data: ` + You can use the buttons to save and restore the column widths. When restoring, + you will see a quick flash because the entire table is re-rendered. This + mimics what would happen if an app reloaded the whole page and restored a saved + column width state. + `}} ); From a50b0ec9fb608d3e7edc32b7da61aeab306d5ab6 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 12:36:30 +1100 Subject: [PATCH 16/42] Fix resizer gets stuck visible --- .../@react-spectrum/table/src/TableView.tsx | 26 +++++++++++++------ .../table/stories/Table.stories.tsx | 7 +++-- packages/@react-types/table/src/index.d.ts | 8 +++--- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index c3ff4820132..17d6449819a 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -135,6 +135,9 @@ function TableView(props: SpectrumTableProps, ref: DOMRef(props: SpectrumTableProps, ref: DOMRef { + let onResizeStart = useCallback(() => { + setIsResizing(true); + }, [setIsResizing]); + let onResizeEnd = useCallback((widths) => { setIsInResizeMode(false); - propsOnResizeEnd?.(key); - }, [propsOnResizeEnd, setIsInResizeMode]); + setIsResizing(false); + propsOnResizeEnd?.(widths); + }, [propsOnResizeEnd, setIsInResizeMode, setIsResizing]); return ( - + { - if (layout.resizingColumn === column.key) { + if (prevResizingColumn.current !== resizingColumn && + resizingColumn != null && + resizingColumn === column.key) { // focusSafely won't actually focus because the focus moves from the menuitem to the body during the after transition wait // without the immediate timeout, Android Chrome doesn't move focus to the resizer if (isMobile) { @@ -703,10 +714,9 @@ function ResizableTableColumnHeader(props) { onFocusedResizer(); }, 0); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layout.resizingColumn, column.key]); + prevResizingColumn.current = resizingColumn; + }, [resizingColumn, column.key, isMobile, onFocusedResizer, resizingRef, prevResizingColumn]); - let resizingColumn = layout.resizingColumn; let showResizer = !isEmpty && ((headerRowHovered && getInteractionModality() !== 'keyboard') || resizingColumn != null); return ( diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index fbbab828e4a..0d22376b22e 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -39,7 +39,6 @@ import {TextField} from '@react-spectrum/textfield'; import {useAsyncList, useListData} from '@react-stately/data'; import {useFilter} from '@react-aria/i18n'; import {View} from '@react-spectrum/view'; -import {parameters} from '../../../../.storybook/preview'; let columns = [ {name: 'Foo', key: 'foo'}, @@ -1376,7 +1375,7 @@ storiesOf('TableView', module) () => ( ), - {description: {data: ` + {description: {data: ` You can use the buttons to save and restore the column widths. When restoring, you will see a quick flash because the entire table is re-rendered. This mimics what would happen if an app reloaded the whole page and restored a saved @@ -1388,7 +1387,7 @@ storiesOf('TableView', module) () => ( ), - {description: {data: ` + {description: {data: ` You can use the buttons to save and restore the column widths. When restoring, you will see a quick flash because the entire table is re-rendered. This mimics what would happen if an app reloaded the whole page and restored a saved @@ -1400,7 +1399,7 @@ storiesOf('TableView', module) () => ( ), - {description: {data: ` + {description: {data: ` You can use the buttons to save and restore the column widths. When restoring, you will see a quick flash because the entire table is re-rendered. This mimics what would happen if an app reloaded the whole page and restored a saved diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index b90046a9829..47ff8be6cec 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -43,11 +43,13 @@ export interface SpectrumTableProps extends TableProps, SpectrumSelectionP * Handler that is called when a user performs a column resize. * Can be used with the width property on columns to put the column widths into * a controlled state. - * @private */ onResize?: (widths: Map) => void, - onResizeStart?: (key: Key) => void, - onResizeEnd?: (key: Key) => void + /** + * Handler that is called after a user performs a column resize. + * Can be used to store the widths of columns for another future session. + */ + onResizeEnd?: (widths: Map) => void } export interface TableHeaderProps { From 2137c2ff668d2ed558e7dfd28741d96fe8eae1c2 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 13:36:26 +1100 Subject: [PATCH 17/42] simplify column header focus mode --- packages/@react-aria/table/src/useTableColumnHeader.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnHeader.ts b/packages/@react-aria/table/src/useTableColumnHeader.ts index 4e2a708b6e7..a17d9d5d90f 100644 --- a/packages/@react-aria/table/src/useTableColumnHeader.ts +++ b/packages/@react-aria/table/src/useTableColumnHeader.ts @@ -47,7 +47,7 @@ export function useTableColumnHeader(props: AriaTableColumnHeaderProps, st 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); + let {gridCellProps} = useGridCell({...props, focusMode: 'child'}, state, ref); let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single'; @@ -59,11 +59,6 @@ export function useTableColumnHeader(props: AriaTableColumnHeaderProps, st ref }); - // try to just delete this, but figure out why it causes an extra focus target - if (props.hasMenu) { - pressProps = {}; - } - // Needed to pick up the focusable context, enabling things like Tooltips for example let {focusableProps} = useFocusable({}, ref); From ceca13ee1eb864840b0c77fb1cfe105cf12deec5 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 15:53:29 +1100 Subject: [PATCH 18/42] clean up and code sharing --- .../table/src/useTableColumnHeader.ts | 2 +- .../table/src/useTableColumnResize.ts | 36 ++++++-- .../virtualizer/src/ScrollView.tsx | 1 + .../@react-spectrum/table/src/Resizer.tsx | 4 +- .../@react-spectrum/table/src/TableView.tsx | 4 +- .../@react-stately/layout/src/TableLayout.ts | 88 ++++++++++--------- .../table/src/useTableColumnResizeState.ts | 44 ++++------ 7 files changed, 103 insertions(+), 76 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnHeader.ts b/packages/@react-aria/table/src/useTableColumnHeader.ts index a17d9d5d90f..dcf44ef8d5c 100644 --- a/packages/@react-aria/table/src/useTableColumnHeader.ts +++ b/packages/@react-aria/table/src/useTableColumnHeader.ts @@ -46,7 +46,7 @@ export interface TableColumnHeaderAria { export function useTableColumnHeader(props: AriaTableColumnHeaderProps, state: TableState, ref: RefObject): 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 + // 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'; diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 724b040fb39..b011fd65fd0 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -29,24 +29,46 @@ export interface TableColumnResizeAria { } export interface AriaTableColumnResizeProps { + /** 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, + /** Aria label for the hidden input. Gets read when resizing. */ label: string, - triggerRef: RefObject, + /** + * 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, + /** 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) => void, + /** Called when resizing ends. */ onResizeEnd: (key: Key) => void } 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. */ onColumnResize: (column: Key, width: number) => Map, + /** Called to update the state that resizing has ended. */ onColumnResizeEnd: (key: Key) => void } @@ -62,7 +84,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st 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); @@ -168,9 +190,11 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') { return; } - if (e.pointerType === 'virtual' && layoutState.resizingColumn != null) { + if (e.pointerType === 'virtual' && stateRef.current.resizingColumn != null) { endResize(item); - focusSafely(triggerRef.current); + if (triggerRef?.current) { + focusSafely(triggerRef.current); + } return; } focusInput(); diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index fcc896fa45f..7717d890b44 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -138,6 +138,7 @@ function ScrollView(props: ScrollViewProps, ref: RefObject) { h = Math.min(h, contentSize.height); } } + if (state.width !== w || state.height !== h) { state.width = w; state.height = h; diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index ac1c595fb09..b90be6035ab 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -61,8 +61,8 @@ function Resizer(props: ResizerProps, ref: RefObject) { display: showResizer ? undefined : 'none', touchAction: 'none' }; - let isEResizable = layout.getColumnMinWidth(column.key) >= layout.getColumnWidth(column.key); - let isWResizable = layout.getColumnMaxWidth(column.key) <= layout.getColumnWidth(column.key); + let isEResizable = stateRef.current.getColumnMinWidth(column.key) >= stateRef.current.getColumnWidth(column.key); + let isWResizable = stateRef.current.getColumnMaxWidth(column.key) <= stateRef.current.getColumnWidth(column.key); return ( <> diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index d17c2ebf6ae..31deb7e3a9d 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -451,6 +451,9 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo } }, state, domRef); + // this effect runs whenever the contentSize changes, it doesn't matter what the content size is + // only that it changes in a resize, and when that happens, we want to sync the body to the + // header scroll position useEffect(() => { if (lastResizeInteractionModality.current === 'keyboard' && headerRef.current.contains(document.activeElement)) { document.activeElement?.scrollIntoView?.({block: 'nearest', inline: 'nearest'}); @@ -494,7 +497,6 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo let resizerInVisibleRegion = resizerPosition < state.virtualizer.visibleRect.width + (isNaN(bodyRef.current?.scrollLeft) ? 0 : bodyRef.current?.scrollLeft); let shouldHardCornerResizeCorner = resizerAtEdge && resizerInVisibleRegion; - // TODO: can I introduce this wrapper? style and classname would be applied below return (
string | number } +/** Takes an array of columns and splits it into 2 maps of columns with controlled and columns with uncontrolled widths. */ +export function splitColumnsIntoControlledAndUncontrolled(columns): [Map>, Map>] { + return columns.reduce((acc, col) => { + if (col.props.width !== undefined) { + acc[0].set(col.key, col); + } else { + acc[1].set(col.key, col); + } + return acc; + }, [new Map(), new Map()]); +} + +/** Takes uncontrolled and controlled widths and joins them into a single Map. */ +export function recombineColumns(columns, uncontrolledWidths, uncontrolledColumns, controlledColumns): Map { + return new Map(columns.map(col => { + if (uncontrolledColumns.has(col.key)) { + return [col.key, uncontrolledWidths.get(col.key)]; + } else { + return [col.key, controlledColumns.get(col.key).props.width]; + } + })); +} + +/** Used to make an initial Map of the uncontrolled widths based on default widths. */ +export function getInitialUncontrolledWidths(uncontrolledColumns, columnLayout): Map { + return new Map(Array.from(uncontrolledColumns).map(([key, col]) => + [key, col.props.defaultWidth ?? columnLayout.getDefaultWidth?.(col.props)] + )); +} + export class TableColumnLayout { resizingColumn: Key | null; getDefaultWidth: (props) => string | number; @@ -190,9 +220,9 @@ export class TableLayout extends ListLayout { persistedIndices: Map = new Map(); private disableSticky: boolean; columnLayout: TableColumnLayout; - controlledWidths: Map>; - uncontrolledWidths: Map>; - widths: Map; + controlledColumns: Map>; + uncontrolledColumns: Map>; + uncontrolledWidths: Map; lastVirtualizerWidth: number; constructor(options: TableLayoutOptions) { @@ -201,11 +231,11 @@ export class TableLayout extends ListLayout { this.stickyColumnIndices = []; this.disableSticky = this.checkChrome105(); this.columnLayout = options.columnLayout; - this.getSplitColumns(); + let [controlledColumns, uncontrolledColumns] = splitColumnsIntoControlledAndUncontrolled(this.collection.columns); + this.controlledColumns = controlledColumns; + this.uncontrolledColumns = uncontrolledColumns; this.lastVirtualizerWidth = 0; - this.widths = new Map(Array.from(this.uncontrolledWidths).map(([key, col]) => - [key, col.props.defaultWidth ?? this.columnLayout.getDefaultWidth?.(col.props)] - )); + this.uncontrolledWidths = getInitialUncontrolledWidths(uncontrolledColumns, this.columnLayout); } get resizingColumn(): Key { @@ -243,51 +273,29 @@ export class TableLayout extends ListLayout { // only way to call props.onColumnResize with the new size outside of Layout is to send the result back onColumnResize(key: Key, width: number): Map { - let newControlled = new Map(Array.from(this.controlledWidths).map(([key, entry]) => [key, entry.props.width])); - let newSizes = this.columnLayout.resizeColumnWidth(this.virtualizer.visibleRect.width, this.collection, newControlled, this.widths, key, width); + let newControlled = new Map(Array.from(this.controlledColumns).map(([key, entry]) => [key, entry.props.width])); + let newSizes = this.columnLayout.resizeColumnWidth(this.virtualizer.visibleRect.width, this.collection, newControlled, this.uncontrolledWidths, key, width); - let map = new Map(Array.from(this.uncontrolledWidths).map(([key]) => [key, newSizes.get(key)])); + let map = new Map(Array.from(this.uncontrolledColumns).map(([key]) => [key, newSizes.get(key)])); map.set(key, width); - this.widths = map; + this.uncontrolledWidths = map; // relayoutNow still uses setState, should happen at the same time the parent // component's state is processed as a result of props.onColumnResize this.virtualizer.relayoutNow({sizeChanged: true}); return newSizes; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onColumnResizeEnd(key: Key): void { + onColumnResizeEnd(): void { this.columnLayout.setResizingColumn(null); } - getSplitColumns() { - let [controlledWidths, uncontrolledWidths] = this.collection.columns.reduce((acc, col) => { - if (col.props.width !== undefined) { - acc[0].set(col.key, col); - } else { - acc[1].set(col.key, col); - } - return acc; - }, [new Map(), new Map()]); - this.controlledWidths = controlledWidths; - this.uncontrolledWidths = uncontrolledWidths; - } - - recombineColumns() { - return new Map(this.collection.columns.map(col => { - if (this.uncontrolledWidths.has(col.key)) { - return [col.key, this.widths.get(col.key)]; - } else { - return [col.key, this.controlledWidths.get(col.key).props.width]; - } - })); - } - buildCollection(): LayoutNode[] { - this.getSplitColumns(); - let cWidths = this.recombineColumns(); - // I think this runs every render cycle? - // Which would mean that we'd be behind by one render since invalidate + let [controlledColumns, uncontrolledColumns] = splitColumnsIntoControlledAndUncontrolled(this.collection.columns); + this.controlledColumns = controlledColumns; + this.uncontrolledColumns = uncontrolledColumns; + let cWidths = recombineColumns(this.collection.columns, this.uncontrolledWidths, uncontrolledColumns, controlledColumns); + + // We will be behind by one render for column prop changes since invalidate // will take a render to resolve. // If columns changed, clear layout cache. if ( diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 6dab3afa5c3..0e49633131e 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -11,6 +11,11 @@ */ import {ColumnSize} from '@react-types/table'; +import { + getInitialUncontrolledWidths, + recombineColumns, + splitColumnsIntoControlledAndUncontrolled +} from '@react-stately/layout/src/TableLayout'; import {GridNode} from '@react-types/grid'; import {Key, useCallback, useMemo, useState} from 'react'; import {TableColumnLayout} from '@react-stately/layout'; @@ -67,31 +72,18 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< ); let tableWidth = props.tableWidth ?? 0; - let [controlledWidths, uncontrolledWidths]: [Map>, Map>] = useMemo(() => - state.collection.columns.reduce((acc, col) => { - if (col.props.width !== undefined) { - acc[0].set(col.key, col); - } else { - acc[1].set(col.key, col); - } - return acc; - }, [new Map(), new Map()]) - , [state.collection.columns]); // is this a safe thing to memo on? what if a single column changes? + let [controlledColumns, uncontrolledColumns]: [Map>, Map>] = useMemo(() => + splitColumnsIntoControlledAndUncontrolled(state.collection.columns) + , [state.collection.columns]); // uncontrolled column widths - let [widths, setWidths] = useState>(() => new Map( - Array.from(uncontrolledWidths).map(([key, col]) => - [key, col.props.defaultWidth ?? getDefaultWidth?.(col.props)] - )) + let [uncontrolledWidths, setUncontrolledWidths] = useState>(() => + getInitialUncontrolledWidths(uncontrolledColumns, columnLayout) ); // combine columns back into one map that maintains same order as the columns - let cWidths = useMemo(() => new Map(state.collection.columns.map(col => { - if (uncontrolledWidths.has(col.key)) { - return [col.key, widths.get(col.key)]; - } else { - return [col.key, controlledWidths.get(col.key).props.width]; - } - })), [state.collection.columns, uncontrolledWidths, controlledWidths, widths]); + let cWidths = useMemo(() => + recombineColumns(state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns) + , [state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns]); let onColumnResizeStart = useCallback((key: Key) => { columnLayout.setResizingColumn(key); @@ -100,15 +92,15 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< // TODO: move props.on* all into this file and layout, or move them all out to the aria handler..., stately would be preferable let onColumnResize = useCallback((key: Key, width: number): Map => { - let newControlled = new Map(Array.from(controlledWidths).map(([key, entry]) => [key, entry.props.width])); - let newSizes = columnLayout.resizeColumnWidth(tableWidth, state.collection, newControlled, widths, key, width); + let newControlled = new Map(Array.from(controlledColumns).map(([key, entry]) => [key, entry.props.width])); + let newSizes = columnLayout.resizeColumnWidth(tableWidth, state.collection, newControlled, uncontrolledWidths, key, width); - let map = new Map(Array.from(uncontrolledWidths).map(([key]) => [key, newSizes.get(key)])); + let map = new Map(Array.from(uncontrolledColumns).map(([key]) => [key, newSizes.get(key)])); map.set(key, width); - setWidths(map); + setUncontrolledWidths(map); return newSizes; - }, [controlledWidths, uncontrolledWidths, setWidths, tableWidth, columnLayout, state.collection, widths]); + }, [controlledColumns, uncontrolledColumns, setUncontrolledWidths, tableWidth, columnLayout, state.collection, uncontrolledWidths]); let onColumnResizeEnd = useCallback((key: Key) => { columnLayout.setResizingColumn(null); From e9c21b72df0dc63115463037693f099e0bb2980c Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 15:56:01 +1100 Subject: [PATCH 19/42] fix lint --- packages/@react-stately/layout/src/TableLayout.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index f254d47a051..928f6fdba66 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -220,8 +220,8 @@ export class TableLayout extends ListLayout { persistedIndices: Map = new Map(); private disableSticky: boolean; columnLayout: TableColumnLayout; - controlledColumns: Map>; - uncontrolledColumns: Map>; + controlledColumns: Map>; + uncontrolledColumns: Map>; uncontrolledWidths: Map; lastVirtualizerWidth: number; From 1f57a19f30a9b95dce4f3f35b1b9f775c469e2d0 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 15:59:40 +1100 Subject: [PATCH 20/42] add test from other PR --- .../table/test/TableSizing.test.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/@react-spectrum/table/test/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx index 09655859efe..7763516bbdd 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.tsx +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -519,6 +519,34 @@ describe('TableViewSizing', function () { } }); + it('should support minWidth and width working together', function () { + let tree = render( + + + Foo + Bar + Baz + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + let rows = tree.getAllByRole('row'); + + for (let row of rows) { + expect((row.childNodes[0] as HTMLElement).style.width).toBe('38px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('500px'); + expect((row.childNodes[3] as HTMLElement).style.width).toBe('262px'); + } + }); + it('should support maxWidth', function () { let tree = render( From acbc4c7db64659fc09635a953765f57c0288e678 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 16:00:35 +1100 Subject: [PATCH 21/42] add max width as well --- .../table/test/TableSizing.test.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/@react-spectrum/table/test/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx index 7763516bbdd..b7fcfef1a00 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.tsx +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -574,6 +574,33 @@ describe('TableViewSizing', function () { } }); + it('should support maxWidth and width working together', function () { + let tree = render( + + + Foo + Bar + Baz + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + let rows = tree.getAllByRole('row'); + + for (let row of rows) { + expect((row.childNodes[0] as HTMLElement).style.width).toBe('200px'); + expect((row.childNodes[1] as HTMLElement).style.width).toBe('300px'); + expect((row.childNodes[2] as HTMLElement).style.width).toBe('500px'); + } + }); + describe('bounded constraint on columns where dynamic columns exist before the bounded columns', () => { it('should fulfill the constraints of the static columns and give remaining width to previously defined dynamic columns', () => { let tree = render( From 35bc3a0bb202705a2849c7b7e70322e0999dcc4a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 16:07:03 +1100 Subject: [PATCH 22/42] Makes types clearer --- packages/@react-types/table/src/index.d.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 47ff8be6cec..61765f7e96c 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -14,7 +14,13 @@ import {AriaLabelingProps, AsyncLoadable, CollectionChildren, DOMProps, LoadingS import {GridCollection, GridNode} from '@react-types/grid'; import {Key, ReactElement, ReactNode} from 'react'; -export type ColumnSize = number | `${number}fr` | `${number}%`; +/** Widths that result in a constant pixel value for the same Table width. */ +export type ColumnStaticWidth = number | `${number}` | `${number}%`; // match regex: /^(\d+)(?=%$)/ +/** Widths that change size in relation to the remaining space and in ratio to other dynamic columns. */ +export type ColumnDynamicWidth = `${number}fr`; // match regex: /^(\d+)(?=fr$)/ +/** All possible sizes a column can be assigned. */ +export type ColumnSize = ColumnStaticWidth | ColumnDynamicWidth; + export interface TableProps extends MultipleSelection, Sortable { /** The elements that make up the table. Includes the TableHeader, TableBody, Columns, and Rows. */ children: [ReactElement>, ReactElement>], From eb36ff6138a0f8b265344a014dd8c4f694e3c15b Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 16:11:17 +1100 Subject: [PATCH 23/42] clarify min/max width types --- packages/@react-types/table/src/index.d.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 61765f7e96c..05b7f47cf1e 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -15,11 +15,11 @@ import {GridCollection, GridNode} from '@react-types/grid'; import {Key, ReactElement, ReactNode} from 'react'; /** Widths that result in a constant pixel value for the same Table width. */ -export type ColumnStaticWidth = number | `${number}` | `${number}%`; // match regex: /^(\d+)(?=%$)/ +export type ColumnStaticSize = number | `${number}` | `${number}%`; // match regex: /^(\d+)(?=%$)/ /** Widths that change size in relation to the remaining space and in ratio to other dynamic columns. */ -export type ColumnDynamicWidth = `${number}fr`; // match regex: /^(\d+)(?=fr$)/ +export type ColumnDynamicSize = `${number}fr`; // match regex: /^(\d+)(?=fr$)/ /** All possible sizes a column can be assigned. */ -export type ColumnSize = ColumnStaticWidth | ColumnDynamicWidth; +export type ColumnSize = ColumnStaticSize | ColumnDynamicSize; export interface TableProps extends MultipleSelection, Sortable { /** The elements that make up the table. Includes the TableHeader, TableBody, Columns, and Rows. */ @@ -77,9 +77,9 @@ export interface ColumnProps { /** The width of the column. */ width?: ColumnSize, /** The minimum width of the column. */ - minWidth?: ColumnSize, + minWidth?: ColumnStaticSize, /** The maximum width of the column. */ - maxWidth?: ColumnSize, + maxWidth?: ColumnStaticSize, /** The default width of the column. */ defaultWidth?: ColumnSize, /** Whether the column allows resizing. */ From a04a7a2fd3cc26a8865670eddc8d74b81224ac23 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 16:15:03 +1100 Subject: [PATCH 24/42] fix exports --- packages/@react-stately/layout/src/index.ts | 8 +++++++- .../@react-stately/table/src/useTableColumnResizeState.ts | 6 +++--- packages/@react-types/table/src/index.d.ts | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/@react-stately/layout/src/index.ts b/packages/@react-stately/layout/src/index.ts index a999eb76f75..c329c6f8c3b 100644 --- a/packages/@react-stately/layout/src/index.ts +++ b/packages/@react-stately/layout/src/index.ts @@ -11,4 +11,10 @@ */ export type {ListLayoutOptions, LayoutNode} from './ListLayout'; export {ListLayout} from './ListLayout'; -export {TableLayout, TableColumnLayout} from './TableLayout'; +export { + TableLayout, + TableColumnLayout, + recombineColumns, + splitColumnsIntoControlledAndUncontrolled, + getInitialUncontrolledWidths +} from './TableLayout'; diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 0e49633131e..85e4f8a859c 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -14,11 +14,11 @@ import {ColumnSize} from '@react-types/table'; import { getInitialUncontrolledWidths, recombineColumns, - splitColumnsIntoControlledAndUncontrolled -} from '@react-stately/layout/src/TableLayout'; + splitColumnsIntoControlledAndUncontrolled, + TableColumnLayout +} from '@react-stately/layout'; import {GridNode} from '@react-types/grid'; import {Key, useCallback, useMemo, useState} from 'react'; -import {TableColumnLayout} from '@react-stately/layout'; export interface TableColumnResizeStateProps { /** diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 05b7f47cf1e..346d889aa39 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -143,7 +143,7 @@ export type CellElement = ReactElement; export type CellRenderer = (columnKey: Key) => CellElement; export interface TableCollection extends GridCollection { - // TODO perhaps elaborate on this? maybe not clear enought, essentially returns the table header rows (e.g. in a tiered headers table, will return the nodes containing the top tier column, next tier, etc) + // TODO perhaps elaborate on this? maybe not clear enough, essentially returns the table header rows (e.g. in a tiered headers table, will return the nodes containing the top tier column, next tier, etc) /** A list of header row nodes in the table. */ headerRows: GridNode[], /** A list of column nodes in the table. */ From d0acbf77c1f8a63cbe57a9cdfee3dad6e2ffecbf Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 16:36:52 +1100 Subject: [PATCH 25/42] fix css specificity --- packages/@adobe/spectrum-css-temp/components/table/index.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@adobe/spectrum-css-temp/components/table/index.css b/packages/@adobe/spectrum-css-temp/components/table/index.css index 46ce4b2aa7a..fbcae25d601 100644 --- a/packages/@adobe/spectrum-css-temp/components/table/index.css +++ b/packages/@adobe/spectrum-css-temp/components/table/index.css @@ -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); From 0be248915ba0e9db17c0d24db090afaa51d6d336 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 5 Dec 2022 17:07:07 +1100 Subject: [PATCH 26/42] address resizers getting stuck in hovered state --- .../@react-spectrum/table/src/TableView.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 31deb7e3a9d..c77ae4ea428 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -98,7 +98,9 @@ interface TableContextValue { onResize: (widths: Map) => void, onResizeEnd: (key: Key) => void, onMoveResizer: (e: MoveMoveEvent) => void, - isQuiet: boolean + isQuiet: boolean, + headerMenuOpen: boolean, + setHeaderMenuOpen: (val: boolean) => void } const TableContext = React.createContext>(null); @@ -191,6 +193,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef(props: SpectrumTableProps, ref: DOMRef + - + {columnProps.allowsSorting && @@ -933,10 +938,10 @@ function TableRow({item, children, hasActions, ...otherProps}) { } function TableHeaderRow({item, children, style, ...props}) { - let {state} = useTableContext(); + let {state, headerMenuOpen} = useTableContext(); let ref = useRef(); let {rowProps} = useTableHeaderRow({node: item, isVirtualized: true}, state, ref); - let {hoverProps} = useHover(props); + let {hoverProps} = useHover({...props, isDisabled: headerMenuOpen}); return (
From c68d962c4b114c76a58f90617523372ab7c7b7d5 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 6 Dec 2022 09:55:25 +1100 Subject: [PATCH 27/42] Make sure input "value" updates --- .../table/test/tableResizingTests.tsx | 3 +- .../@react-spectrum/table/src/Resizer.tsx | 7 +- .../@react-spectrum/table/src/TableView.tsx | 128 ++++++++++-------- .../table/test/TableSizing.test.tsx | 24 ++-- 4 files changed, 97 insertions(+), 65 deletions(-) diff --git a/packages/@react-aria/table/test/tableResizingTests.tsx b/packages/@react-aria/table/test/tableResizingTests.tsx index 9464fd22ed1..bc5f44561b4 100644 --- a/packages/@react-aria/table/test/tableResizingTests.tsx +++ b/packages/@react-aria/table/test/tableResizingTests.tsx @@ -195,7 +195,8 @@ export let resizingTests = (render, rerender, Table, ControlledTable, resizeCol, expect(onResize).toHaveBeenCalledTimes(1); expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, expectedOnResize)); let resizers = tree.getAllByRole('slider'); - resizers.forEach(resizer => { + resizers.forEach((resizer, index) => { + expect(resizer).toHaveAttribute('value', `${expected[index]}`) expect(resizer).toHaveAttribute('min', `${75}`); expect(resizer).toHaveAttribute('max', `${Number.MAX_SAFE_INTEGER}`); }); diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index b90be6035ab..8386f1acaa5 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -9,7 +9,7 @@ import React, {Key, RefObject, useRef} from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; import {TableLayoutState, useTableColumnResize} from '@react-aria/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; -import {useTableContext} from './TableView'; +import {useTableContext, useVirtualizerContext} from './TableView'; import {VisuallyHidden} from '@react-aria/visually-hidden'; interface ResizerProps { @@ -26,6 +26,11 @@ interface ResizerProps { function Resizer(props: ResizerProps, ref: RefObject) { let {column, showResizer, layout} = props; let {state, isEmpty} = useTableContext(); + // Virtualizer re-renders, but these components are all cached + // in order to get around that and cause a rerender here, we use context + // but we don't actually need the value, that is available in the layout object + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let {width} = useVirtualizerContext(); let stringFormatter = useLocalizedStringFormatter(intlMessages); let {direction} = useLocale(); const stateRef = useRef(null); diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index c77ae4ea428..b23afbab802 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -108,6 +108,11 @@ export function useTableContext() { return useContext(TableContext); } +const VirtualizerContext = React.createContext(null); +export function useVirtualizerContext() { + return useContext(VirtualizerContext); +} + function TableView(props: SpectrumTableProps, ref: DOMRef) { props = useProviderProps(props); let {isQuiet, onAction, onResizeEnd: propsOnResizeEnd} = props; @@ -500,53 +505,62 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo let resizerInVisibleRegion = resizerPosition < state.virtualizer.visibleRect.width + (isNaN(bodyRef.current?.scrollLeft) ? 0 : bodyRef.current?.scrollLeft); let shouldHardCornerResizeCorner = resizerAtEdge && resizerInVisibleRegion; + // minimize re-render caused on Resizers by memoing this + let resizingColumnWidth = layout.getColumnWidth(layout.resizingColumn); + let resizingColumn = useMemo(() => ({ + width: resizingColumnWidth, + key: layout.resizingColumn + }), [resizingColumnWidth, layout.resizingColumn]); + return ( - -
+ +
- {state.visibleViews[0]} -
- - {state.visibleViews[1]} + {...mergeProps(otherProps, virtualizerProps)} + ref={domRef}>
- -
-
+ role="presentation" + className={classNames(styles, 'spectrum-Table-headWrapper')} + style={{ + width: visibleRect.width, + height: headerHeight, + overflow: 'hidden', + position: 'relative', + willChange: state.isScrolling ? 'scroll-position' : '', + transition: state.isAnimating ? `none ${state.virtualizer.transitionDuration}ms` : undefined + }} + ref={headerRef}> + {state.visibleViews[0]} +
+ + {state.visibleViews[1]} +
+ +
+
+ ); } @@ -703,26 +717,34 @@ function ResizableTableColumnHeader(props) { let resizingColumn = layout.resizingColumn; let prevResizingColumn = useRef(null); + let timeout = useRef(null); useEffect(() => { if (prevResizingColumn.current !== resizingColumn && resizingColumn != null && resizingColumn === column.key) { + if (timeout.current) { + clearTimeout(timeout.current); + } // focusSafely won't actually focus because the focus moves from the menuitem to the body during the after transition wait // without the immediate timeout, Android Chrome doesn't move focus to the resizer + let focusResizer = () => { + resizingRef.current.focus(); + onFocusedResizer(); + timeout.current = null; + }; if (isMobile) { - setTimeout(() => { - resizingRef.current.focus(); - onFocusedResizer(); - }, 400); + timeout.current = setTimeout(focusResizer, 400); return; } - setTimeout(() => { - resizingRef.current.focus(); - onFocusedResizer(); - }, 0); + timeout.current = setTimeout(focusResizer, 0); } prevResizingColumn.current = resizingColumn; - }, [resizingColumn, column.key, isMobile, onFocusedResizer, resizingRef, prevResizingColumn]); + }, [resizingColumn, column.key, isMobile, onFocusedResizer, resizingRef, prevResizingColumn, timeout]); + + // eslint-disable-next-line arrow-body-style + useEffect(() => { + return () => clearTimeout(timeout.current); + }, []); let showResizer = !isEmpty && ((headerRowHovered && getInteractionModality() !== 'keyboard') || resizingColumn != null); diff --git a/packages/@react-spectrum/table/test/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx index b7fcfef1a00..080021a0c36 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.tsx +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -746,7 +746,7 @@ describe('TableViewSizing', function () { fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 25}); fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); - + expect(resizer).toHaveAttribute('value', '595'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('595px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); @@ -760,7 +760,7 @@ describe('TableViewSizing', function () { fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 620, pageY: 25}); fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); - + expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); @@ -820,7 +820,7 @@ describe('TableViewSizing', function () { fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 25}); fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); - + expect(resizer).toHaveAttribute('value', '595'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('595px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); @@ -834,7 +834,7 @@ describe('TableViewSizing', function () { fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 620, pageY: 25}); fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); - + expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); @@ -907,7 +907,7 @@ describe('TableViewSizing', function () { fireEvent.pointerMove(resizer, {pointerType: 'touch', pointerId: 1, pageX: 595, pageY: 25}); fireEvent.pointerUp(resizer, {pointerType: 'touch', pointerId: 1}); - + expect(resizer).toHaveAttribute('value', '595'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('595px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); @@ -920,6 +920,7 @@ describe('TableViewSizing', function () { fireEvent.pointerUp(resizer, {pointerType: 'touch', pointerId: 1}); + expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); @@ -993,6 +994,7 @@ describe('TableViewSizing', function () { fireEvent.pointerUp(resizer, {pointerType: 'touch', pointerId: 1}); + expect(resizer).toHaveAttribute('value', '595'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('595px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); @@ -1005,6 +1007,7 @@ describe('TableViewSizing', function () { fireEvent.pointerUp(resizer, {pointerType: 'touch', pointerId: 1}); + expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); @@ -1077,7 +1080,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); - + expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); @@ -1090,6 +1093,7 @@ describe('TableViewSizing', function () { fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); + expect(resizer).toHaveAttribute('value', '600'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); @@ -1101,7 +1105,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); - + expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); @@ -1113,7 +1117,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); - + expect(resizer).toHaveAttribute('value', '600'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); @@ -1182,7 +1186,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); - + expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); @@ -1194,7 +1198,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); - + expect(resizer).toHaveAttribute('value', '600'); for (let row of rows) { expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); From ca8f4230c5efb9f248f0a028257ac60093706f78 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 6 Dec 2022 10:03:09 +1100 Subject: [PATCH 28/42] fix lint --- packages/@react-aria/table/test/tableResizingTests.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/table/test/tableResizingTests.tsx b/packages/@react-aria/table/test/tableResizingTests.tsx index bc5f44561b4..5ffab950fe5 100644 --- a/packages/@react-aria/table/test/tableResizingTests.tsx +++ b/packages/@react-aria/table/test/tableResizingTests.tsx @@ -196,7 +196,7 @@ export let resizingTests = (render, rerender, Table, ControlledTable, resizeCol, expect(onResize).toHaveBeenCalledWith(mapFromWidths(columnNames, expectedOnResize)); let resizers = tree.getAllByRole('slider'); resizers.forEach((resizer, index) => { - expect(resizer).toHaveAttribute('value', `${expected[index]}`) + expect(resizer).toHaveAttribute('value', `${expected[index]}`); expect(resizer).toHaveAttribute('min', `${75}`); expect(resizer).toHaveAttribute('max', `${Number.MAX_SAFE_INTEGER}`); }); From cf36abc24c3d3dc856d4cc8ef9441a3d79d4713e Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 7 Dec 2022 06:10:42 +1100 Subject: [PATCH 29/42] remove unused api --- packages/@react-aria/table/src/useTableColumnHeader.ts | 4 +--- packages/@react-spectrum/table/src/TableView.tsx | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnHeader.ts b/packages/@react-aria/table/src/useTableColumnHeader.ts index dcf44ef8d5c..6e2b840c5fd 100644 --- a/packages/@react-aria/table/src/useTableColumnHeader.ts +++ b/packages/@react-aria/table/src/useTableColumnHeader.ts @@ -27,9 +27,7 @@ export interface AriaTableColumnHeaderProps { /** 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, /** Whether the [column header](https://www.w3.org/TR/wai-aria-1.1/#columnheader) is contained in a virtual scroller. */ - isVirtualized?: boolean, - /** Where focus should go when it arrives at a cell. This can be used to send focus to a Menu Trigger. */ - hasMenu?: boolean + isVirtualized?: boolean } export interface TableColumnHeaderAria { diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index b23afbab802..b51f57b2131 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -668,8 +668,7 @@ function ResizableTableColumnHeader(props) { let {pressProps, isPressed} = usePress({isDisabled: isEmpty}); let {columnHeaderProps} = useTableColumnHeader({ node: column, - isVirtualized: true, - hasMenu: true + isVirtualized: true }, state, ref); let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty || headerMenuOpen}); From 6ff2ac2985189de13fb071a394b36588c217336f Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 7 Dec 2022 11:49:33 +1100 Subject: [PATCH 30/42] Code reviews --- packages/@react-aria/table/src/index.ts | 2 +- .../table/src/useTableColumnResize.ts | 27 ++++--------------- .../@react-spectrum/table/src/Resizer.tsx | 8 +++--- .../@react-spectrum/table/src/TableView.tsx | 4 +-- .../table/stories/Table.stories.tsx | 16 +++++++---- .../@react-stately/layout/src/TableLayout.ts | 6 ++--- packages/@react-stately/table/package.json | 2 -- .../table/src/useTableColumnResizeState.ts | 3 --- packages/@react-types/table/src/index.d.ts | 17 ++++++++++++ 9 files changed, 42 insertions(+), 43 deletions(-) diff --git a/packages/@react-aria/table/src/index.ts b/packages/@react-aria/table/src/index.ts index ee36ea7c905..2898c3b9a44 100644 --- a/packages/@react-aria/table/src/index.ts +++ b/packages/@react-aria/table/src/index.ts @@ -31,4 +31,4 @@ export type {AriaTableColumnHeaderProps, TableColumnHeaderAria} from './useTable export type {AriaTableCellProps, TableCellAria} from './useTableCell'; export type {TableHeaderRowAria} from './useTableHeaderRow'; export type {AriaTableSelectionCheckboxProps, TableSelectionCheckboxAria, TableSelectAllCheckboxAria} from './useTableSelectionCheckbox'; -export type {AriaTableColumnResizeProps, TableColumnResizeAria, TableLayoutState} from './useTableColumnResize'; +export type {AriaTableColumnResizeProps, TableColumnResizeAria} from './useTableColumnResize'; diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index b011fd65fd0..7106231e391 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -11,7 +11,6 @@ */ 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'; @@ -19,6 +18,7 @@ import {getColumnHeaderId} from './utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; +import {TableLayoutState} from '@react-types/table'; import {TableState} from '@react-stately/table'; import {useKeyboard, useMove, usePress} from '@react-aria/interactions'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -55,23 +55,6 @@ export interface AriaTableColumnResizeProps { onResizeEnd: (key: Key) => void } -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. */ - onColumnResize: (column: Key, width: number) => Map, - /** Called to update the state that resizing has ended. */ - onColumnResizeEnd: (key: Key) => void -} - export function useTableColumnResize(props: AriaTableColumnResizeProps, state: TableState, layoutState: TableLayoutState, ref: RefObject): TableColumnResizeAria { let {column: item, triggerRef, isDisabled, onResizeStart, onResize, onResizeEnd} = props; const stateRef = useRef(null); @@ -98,13 +81,13 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st onResizeStart?.(item.key); } isResizing.current = true; - }, [isResizing, stateRef, onResizeStart]); + }, [isResizing, onResizeStart]); let resize = useCallback((item, newWidth) => { let sizes = stateRef.current.onColumnResize(item.key, newWidth); onResize?.(sizes); lastSize.current = sizes; - }, [stateRef, onResize]); + }, [onResize]); let endResize = useCallback((item) => { if (isResizing.current) { @@ -113,7 +96,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } isResizing.current = false; lastSize.current = null; - }, [isResizing, stateRef, onResizeEnd]); + }, [isResizing, onResizeEnd]); const columnResizeWidthRef = useRef(0); const {moveProps} = useMove({ @@ -180,9 +163,9 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } else { nextValue = currentWidth - 10; } - resize(item, nextValue); props.onMove({pointerType: 'virtual'} as MoveMoveEvent); props.onMoveEnd({pointerType: 'virtual'} as MoveEndEvent); + resize(item, nextValue); }; let {pressProps} = usePress({ diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index 8386f1acaa5..5179fdbb806 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -7,13 +7,13 @@ import intlMessages from '../intl/*.json'; import {MoveMoveEvent} from '@react-types/shared'; import React, {Key, RefObject, useRef} from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; -import {TableLayoutState, useTableColumnResize} from '@react-aria/table'; +import {TableLayoutState} from '@react-types/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useTableColumnResize} from '@react-aria/table'; import {useTableContext, useVirtualizerContext} from './TableView'; import {VisuallyHidden} from '@react-aria/visually-hidden'; interface ResizerProps { - layout: TableLayoutState, column: GridNode, showResizer: boolean, triggerRef: RefObject, @@ -24,8 +24,8 @@ interface ResizerProps { } function Resizer(props: ResizerProps, ref: RefObject) { - let {column, showResizer, layout} = props; - let {state, isEmpty} = useTableContext(); + let {column, showResizer} = props; + let {state, isEmpty, layout} = useTableContext(); // Virtualizer re-renders, but these components are all cached // in order to get around that and cause a rerender here, we use context // but we don't actually need the value, that is available in the layout object diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index b51f57b2131..dc32f4cb637 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -98,7 +98,6 @@ interface TableContextValue { onResize: (widths: Map) => void, onResizeEnd: (key: Key) => void, onMoveResizer: (e: MoveMoveEvent) => void, - isQuiet: boolean, headerMenuOpen: boolean, setHeaderMenuOpen: (val: boolean) => void } @@ -377,7 +376,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef + ( - + ), {description: {data: ` You can use the buttons to save and restore the column widths. When restoring, - you will see a quick flash because the entire table is re-rendered. This - mimics what would happen if an app reloaded the whole page and restored a saved - column width state. + you will notice that the entire table reverts, this is because no columns are controlled. `}} ) .add( @@ -1391,7 +1389,8 @@ storiesOf('TableView', module) You can use the buttons to save and restore the column widths. When restoring, you will see a quick flash because the entire table is re-rendered. This mimics what would happen if an app reloaded the whole page and restored a saved - column width state. + column width state. This is a "some widths" controlled story. It cannot restore + the widths of the columns that it does not manage. Height and weight are uncontrolled. `}} ) .add( @@ -1407,6 +1406,13 @@ storiesOf('TableView', module) `}} ); +let uncontrolledColumns: PokemonColumn[] = [ + {name: 'Name', uid: 'name'}, + {name: 'Type', uid: 'type'}, + {name: 'Height', uid: 'height'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level'} +]; let columnsFR: PokemonColumn[] = [ {name: 'Name', uid: 'name', width: '1fr'}, diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 928f6fdba66..e943083cd8c 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -36,7 +36,7 @@ interface TableColumnLayoutOptions { /** Takes an array of columns and splits it into 2 maps of columns with controlled and columns with uncontrolled widths. */ export function splitColumnsIntoControlledAndUncontrolled(columns): [Map>, Map>] { return columns.reduce((acc, col) => { - if (col.props.width !== undefined) { + if (col.props.width != null) { acc[0].set(col.key, col); } else { acc[1].set(col.key, col); @@ -179,7 +179,7 @@ export class TableColumnLayout { return newWidths; } - buildColumnWidths(tableWidth: number, collection: TableCollection, controlledWidths) { + buildColumnWidths(tableWidth: number, collection: TableCollection, widths: Map) { this.columnWidths = new Map(); this.columnMinWidths = new Map(); this.columnMaxWidths = new Map(); @@ -189,7 +189,7 @@ export class TableColumnLayout { let columnWidths = calculateColumnSizes( tableWidth, collection.columns.map(col => ({...col.column.props, key: col.key})), - controlledWidths, + widths, (i) => this.getDefaultWidth(collection.columns[i].props), (i) => this.getDefaultMinWidth(collection.columns[i].props) ); diff --git a/packages/@react-stately/table/package.json b/packages/@react-stately/table/package.json index 71d2fbcf3bc..3cb995e4eb9 100644 --- a/packages/@react-stately/table/package.json +++ b/packages/@react-stately/table/package.json @@ -22,12 +22,10 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-aria/utils": "^3.14.1", "@react-stately/collections": "^3.5.0", "@react-stately/grid": "^3.4.1", "@react-stately/layout": "^3.9.0", "@react-stately/selection": "^3.11.1", - "@react-stately/utils": "^3.5.1", "@react-types/grid": "^3.1.5", "@react-types/shared": "^3.16.0", "@react-types/table": "^3.3.3", diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 85e4f8a859c..607dbebefe4 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -107,17 +107,14 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< propsOnColumnResizeEnd?.(key); }, [columnLayout, propsOnColumnResizeEnd]); - // done let getColumnWidth = useCallback((key: Key) => columnLayout.getColumnWidth(key) , [columnLayout]); - // done let getColumnMinWidth = useCallback((key: Key) => columnLayout.getColumnMinWidth(key) , [columnLayout]); - // done let getColumnMaxWidth = useCallback((key: Key) => columnLayout.getColumnMaxWidth(key) , [columnLayout]); diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 346d889aa39..1f701a2d2b6 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -153,3 +153,20 @@ export interface TableCollection extends GridCollection { /** The node that makes up the body of the table. */ body: GridNode } + +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. */ + onColumnResize: (column: Key, width: number) => Map, + /** Called to update the state that resizing has ended. */ + onColumnResizeEnd: (key: Key) => void +} From f5a26b4250fd46d19cfb16fd8978d1b284faec33 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 7 Dec 2022 13:10:34 +1100 Subject: [PATCH 31/42] code reviews --- .../table/src/useTableColumnResize.ts | 26 ++++--- .../@react-spectrum/table/src/Resizer.tsx | 5 +- .../@react-spectrum/table/src/TableView.tsx | 3 + .../@react-stately/layout/src/TableLayout.ts | 69 ++++++++++--------- packages/@react-stately/layout/src/index.ts | 8 +-- .../table/src/useTableColumnResizeState.ts | 13 ++-- 6 files changed, 57 insertions(+), 67 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 7106231e391..dd1bdeaa148 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -57,8 +57,6 @@ export interface AriaTableColumnResizeProps { export function useTableColumnResize(props: AriaTableColumnResizeProps, state: TableState, layoutState: TableLayoutState, ref: RefObject): TableColumnResizeAria { let {column: item, triggerRef, isDisabled, onResizeStart, onResize, onResizeEnd} = props; - const stateRef = useRef(null); - stateRef.current = layoutState; const stringFormatter = useLocalizedStringFormatter(intlMessages); let id = useId(); let isResizing = useRef(false); @@ -77,31 +75,31 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let startResize = useCallback((item) => { if (!isResizing.current) { - stateRef.current.onColumnResizeStart(item.key); + layoutState.onColumnResizeStart(item.key); onResizeStart?.(item.key); } isResizing.current = true; - }, [isResizing, onResizeStart]); + }, [isResizing, onResizeStart, layoutState]); let resize = useCallback((item, newWidth) => { - let sizes = stateRef.current.onColumnResize(item.key, newWidth); + let sizes = layoutState.onColumnResize(item.key, newWidth); onResize?.(sizes); lastSize.current = sizes; - }, [onResize]); + }, [onResize, layoutState]); let endResize = useCallback((item) => { if (isResizing.current) { - stateRef.current.onColumnResizeEnd(item.key); + layoutState.onColumnResizeEnd(item.key); onResizeEnd?.(lastSize.current); } isResizing.current = false; lastSize.current = null; - }, [isResizing, onResizeEnd]); + }, [isResizing, onResizeEnd, layoutState]); const columnResizeWidthRef = useRef(0); const {moveProps} = useMove({ onMoveStart() { - columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key); + columnResizeWidthRef.current = layoutState.getColumnWidth(item.key); startResize(item); }, onMove(e) { @@ -132,12 +130,12 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } }); - 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', @@ -155,7 +153,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st }, [ref]); let onChange = (e: ChangeEvent) => { - let currentWidth = stateRef.current.getColumnWidth(item.key); + let currentWidth = layoutState.getColumnWidth(item.key); let nextValue = parseFloat(e.target.value); if (nextValue > currentWidth) { @@ -173,7 +171,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') { return; } - if (e.pointerType === 'virtual' && stateRef.current.resizingColumn != null) { + if (e.pointerType === 'virtual' && layoutState.resizingColumn != null) { endResize(item); if (triggerRef?.current) { focusSafely(triggerRef.current); diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index 5179fdbb806..392351df2dd 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -28,9 +28,8 @@ function Resizer(props: ResizerProps, ref: RefObject) { let {state, isEmpty, layout} = useTableContext(); // Virtualizer re-renders, but these components are all cached // in order to get around that and cause a rerender here, we use context - // but we don't actually need the value, that is available in the layout object - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let {width} = useVirtualizerContext(); + // but we don't actually need any value, they are available on the layout object + useVirtualizerContext(); let stringFormatter = useLocalizedStringFormatter(intlMessages); let {direction} = useLocale(); const stateRef = useRef(null); diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index dc32f4cb637..2e4448e102c 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -140,7 +140,10 @@ function TableView(props: SpectrumTableProps, ref: DOMRef string | number } -/** Takes an array of columns and splits it into 2 maps of columns with controlled and columns with uncontrolled widths. */ -export function splitColumnsIntoControlledAndUncontrolled(columns): [Map>, Map>] { - return columns.reduce((acc, col) => { - if (col.props.width != null) { - acc[0].set(col.key, col); - } else { - acc[1].set(col.key, col); - } - return acc; - }, [new Map(), new Map()]); -} - -/** Takes uncontrolled and controlled widths and joins them into a single Map. */ -export function recombineColumns(columns, uncontrolledWidths, uncontrolledColumns, controlledColumns): Map { - return new Map(columns.map(col => { - if (uncontrolledColumns.has(col.key)) { - return [col.key, uncontrolledWidths.get(col.key)]; - } else { - return [col.key, controlledColumns.get(col.key).props.width]; - } - })); -} - -/** Used to make an initial Map of the uncontrolled widths based on default widths. */ -export function getInitialUncontrolledWidths(uncontrolledColumns, columnLayout): Map { - return new Map(Array.from(uncontrolledColumns).map(([key, col]) => - [key, col.props.defaultWidth ?? columnLayout.getDefaultWidth?.(col.props)] - )); -} - export class TableColumnLayout { resizingColumn: Key | null; getDefaultWidth: (props) => string | number; @@ -77,6 +47,37 @@ export class TableColumnLayout { this.getDefaultMinWidth = options.getDefaultMinWidth; } + + /** Takes an array of columns and splits it into 2 maps of columns with controlled and columns with uncontrolled widths. */ + static splitColumnsIntoControlledAndUncontrolled(columns): [Map>, Map>] { + return columns.reduce((acc, col) => { + if (col.props.width != null) { + acc[0].set(col.key, col); + } else { + acc[1].set(col.key, col); + } + return acc; + }, [new Map(), new Map()]); + } + + /** Takes uncontrolled and controlled widths and joins them into a single Map. */ + static recombineColumns(columns, uncontrolledWidths, uncontrolledColumns, controlledColumns): Map { + return new Map(columns.map(col => { + if (uncontrolledColumns.has(col.key)) { + return [col.key, uncontrolledWidths.get(col.key)]; + } else { + return [col.key, controlledColumns.get(col.key).props.width]; + } + })); + } + + /** Used to make an initial Map of the uncontrolled widths based on default widths. */ + static getInitialUncontrolledWidths(uncontrolledColumns, columnLayout): Map { + return new Map(Array.from(uncontrolledColumns).map(([key, col]) => + [key, col.props.defaultWidth ?? columnLayout.getDefaultWidth?.(col.props)] + )); + } + setResizingColumn(key: Key | null): void { this.resizingColumn = key; } @@ -231,11 +232,11 @@ export class TableLayout extends ListLayout { this.stickyColumnIndices = []; this.disableSticky = this.checkChrome105(); this.columnLayout = options.columnLayout; - let [controlledColumns, uncontrolledColumns] = splitColumnsIntoControlledAndUncontrolled(this.collection.columns); + let [controlledColumns, uncontrolledColumns] = TableColumnLayout.splitColumnsIntoControlledAndUncontrolled(this.collection.columns); this.controlledColumns = controlledColumns; this.uncontrolledColumns = uncontrolledColumns; this.lastVirtualizerWidth = 0; - this.uncontrolledWidths = getInitialUncontrolledWidths(uncontrolledColumns, this.columnLayout); + this.uncontrolledWidths = TableColumnLayout.getInitialUncontrolledWidths(uncontrolledColumns, this.columnLayout); } get resizingColumn(): Key { @@ -290,10 +291,10 @@ export class TableLayout extends ListLayout { } buildCollection(): LayoutNode[] { - let [controlledColumns, uncontrolledColumns] = splitColumnsIntoControlledAndUncontrolled(this.collection.columns); + let [controlledColumns, uncontrolledColumns] = TableColumnLayout.splitColumnsIntoControlledAndUncontrolled(this.collection.columns); this.controlledColumns = controlledColumns; this.uncontrolledColumns = uncontrolledColumns; - let cWidths = recombineColumns(this.collection.columns, this.uncontrolledWidths, uncontrolledColumns, controlledColumns); + let cWidths = TableColumnLayout.recombineColumns(this.collection.columns, this.uncontrolledWidths, uncontrolledColumns, controlledColumns); // We will be behind by one render for column prop changes since invalidate // will take a render to resolve. diff --git a/packages/@react-stately/layout/src/index.ts b/packages/@react-stately/layout/src/index.ts index c329c6f8c3b..a999eb76f75 100644 --- a/packages/@react-stately/layout/src/index.ts +++ b/packages/@react-stately/layout/src/index.ts @@ -11,10 +11,4 @@ */ export type {ListLayoutOptions, LayoutNode} from './ListLayout'; export {ListLayout} from './ListLayout'; -export { - TableLayout, - TableColumnLayout, - recombineColumns, - splitColumnsIntoControlledAndUncontrolled, - getInitialUncontrolledWidths -} from './TableLayout'; +export {TableLayout, TableColumnLayout} from './TableLayout'; diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 607dbebefe4..000de99794d 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -11,14 +11,9 @@ */ import {ColumnSize} from '@react-types/table'; -import { - getInitialUncontrolledWidths, - recombineColumns, - splitColumnsIntoControlledAndUncontrolled, - TableColumnLayout -} from '@react-stately/layout'; import {GridNode} from '@react-types/grid'; import {Key, useCallback, useMemo, useState} from 'react'; +import {TableColumnLayout} from '@react-stately/layout'; export interface TableColumnResizeStateProps { /** @@ -73,16 +68,16 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< let tableWidth = props.tableWidth ?? 0; let [controlledColumns, uncontrolledColumns]: [Map>, Map>] = useMemo(() => - splitColumnsIntoControlledAndUncontrolled(state.collection.columns) + TableColumnLayout.splitColumnsIntoControlledAndUncontrolled(state.collection.columns) , [state.collection.columns]); // uncontrolled column widths let [uncontrolledWidths, setUncontrolledWidths] = useState>(() => - getInitialUncontrolledWidths(uncontrolledColumns, columnLayout) + TableColumnLayout.getInitialUncontrolledWidths(uncontrolledColumns, columnLayout) ); // combine columns back into one map that maintains same order as the columns let cWidths = useMemo(() => - recombineColumns(state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns) + TableColumnLayout.recombineColumns(state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns) , [state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns]); let onColumnResizeStart = useCallback((key: Key) => { From 918ddd9fcdbd2102cd71e5c01f07513b1f6d34c1 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 8 Dec 2022 07:50:30 +1100 Subject: [PATCH 32/42] more reviews --- .../@react-stately/layout/src/TableLayout.ts | 28 ++++++++++--------- .../table/src/useTableColumnResizeState.ts | 15 +++++----- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 42357d64981..affe79a5f42 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -29,14 +29,14 @@ type TableLayoutOptions = ListLayoutOptions & { } interface TableColumnLayoutOptions { - getDefaultWidth: (props) => string | number, - getDefaultMinWidth: (props) => string | number + getDefaultWidth: (props) => ColumnSize, + getDefaultMinWidth: (props) => ColumnSize } export class TableColumnLayout { resizingColumn: Key | null; - getDefaultWidth: (props) => string | number; - getDefaultMinWidth: (props) => string | number; + getDefaultWidth: (props) => ColumnSize; + getDefaultMinWidth: (props) => ColumnSize; columnWidths: Map = new Map(); columnMinWidths: Map = new Map(); columnMaxWidths: Map = new Map(); @@ -49,7 +49,7 @@ export class TableColumnLayout { /** Takes an array of columns and splits it into 2 maps of columns with controlled and columns with uncontrolled widths. */ - static splitColumnsIntoControlledAndUncontrolled(columns): [Map>, Map>] { + splitColumnsIntoControlledAndUncontrolled(columns: Array>): [Map>, Map>] { return columns.reduce((acc, col) => { if (col.props.width != null) { acc[0].set(col.key, col); @@ -61,7 +61,7 @@ export class TableColumnLayout { } /** Takes uncontrolled and controlled widths and joins them into a single Map. */ - static recombineColumns(columns, uncontrolledWidths, uncontrolledColumns, controlledColumns): Map { + recombineColumns(columns: Array>, uncontrolledWidths: Map, uncontrolledColumns: Map>, controlledColumns: Map>): Map { return new Map(columns.map(col => { if (uncontrolledColumns.has(col.key)) { return [col.key, uncontrolledWidths.get(col.key)]; @@ -72,9 +72,9 @@ export class TableColumnLayout { } /** Used to make an initial Map of the uncontrolled widths based on default widths. */ - static getInitialUncontrolledWidths(uncontrolledColumns, columnLayout): Map { + getInitialUncontrolledWidths(uncontrolledColumns: Map>): Map { return new Map(Array.from(uncontrolledColumns).map(([key, col]) => - [key, col.props.defaultWidth ?? columnLayout.getDefaultWidth?.(col.props)] + [key, col.props.defaultWidth ?? this.getDefaultWidth?.(col.props)] )); } @@ -232,11 +232,11 @@ export class TableLayout extends ListLayout { this.stickyColumnIndices = []; this.disableSticky = this.checkChrome105(); this.columnLayout = options.columnLayout; - let [controlledColumns, uncontrolledColumns] = TableColumnLayout.splitColumnsIntoControlledAndUncontrolled(this.collection.columns); + let [controlledColumns, uncontrolledColumns] = this.columnLayout.splitColumnsIntoControlledAndUncontrolled(this.collection.columns); this.controlledColumns = controlledColumns; this.uncontrolledColumns = uncontrolledColumns; this.lastVirtualizerWidth = 0; - this.uncontrolledWidths = TableColumnLayout.getInitialUncontrolledWidths(uncontrolledColumns, this.columnLayout); + this.uncontrolledWidths = this.columnLayout.getInitialUncontrolledWidths(uncontrolledColumns); } get resizingColumn(): Key { @@ -282,7 +282,9 @@ export class TableLayout extends ListLayout { this.uncontrolledWidths = map; // relayoutNow still uses setState, should happen at the same time the parent // component's state is processed as a result of props.onColumnResize - this.virtualizer.relayoutNow({sizeChanged: true}); + if (this.uncontrolledWidths.size > 0) { + this.virtualizer.relayoutNow({sizeChanged: true}); + } return newSizes; } @@ -291,10 +293,10 @@ export class TableLayout extends ListLayout { } buildCollection(): LayoutNode[] { - let [controlledColumns, uncontrolledColumns] = TableColumnLayout.splitColumnsIntoControlledAndUncontrolled(this.collection.columns); + let [controlledColumns, uncontrolledColumns] = this.columnLayout.splitColumnsIntoControlledAndUncontrolled(this.collection.columns); this.controlledColumns = controlledColumns; this.uncontrolledColumns = uncontrolledColumns; - let cWidths = TableColumnLayout.recombineColumns(this.collection.columns, this.uncontrolledWidths, uncontrolledColumns, controlledColumns); + let cWidths = this.columnLayout.recombineColumns(this.collection.columns, this.uncontrolledWidths, uncontrolledColumns, controlledColumns); // We will be behind by one render for column prop changes since invalidate // will take a render to resolve. diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 000de99794d..2e54bd6d4c0 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -67,25 +67,24 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< ); let tableWidth = props.tableWidth ?? 0; - let [controlledColumns, uncontrolledColumns]: [Map>, Map>] = useMemo(() => - TableColumnLayout.splitColumnsIntoControlledAndUncontrolled(state.collection.columns) - , [state.collection.columns]); + let [controlledColumns, uncontrolledColumns] = useMemo(() => + columnLayout.splitColumnsIntoControlledAndUncontrolled(state.collection.columns) + , [state.collection.columns, columnLayout]); // uncontrolled column widths - let [uncontrolledWidths, setUncontrolledWidths] = useState>(() => - TableColumnLayout.getInitialUncontrolledWidths(uncontrolledColumns, columnLayout) + let [uncontrolledWidths, setUncontrolledWidths] = useState(() => + columnLayout.getInitialUncontrolledWidths(uncontrolledColumns) ); // combine columns back into one map that maintains same order as the columns let cWidths = useMemo(() => - TableColumnLayout.recombineColumns(state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns) - , [state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns]); + columnLayout.recombineColumns(state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns) + , [state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns, columnLayout]); let onColumnResizeStart = useCallback((key: Key) => { columnLayout.setResizingColumn(key); propsOnColumnResizeStart?.(key); }, [columnLayout, propsOnColumnResizeStart]); - // TODO: move props.on* all into this file and layout, or move them all out to the aria handler..., stately would be preferable let onColumnResize = useCallback((key: Key, width: number): Map => { let newControlled = new Map(Array.from(controlledColumns).map(([key, entry]) => [key, entry.props.width])); let newSizes = columnLayout.resizeColumnWidth(tableWidth, state.collection, newControlled, uncontrolledWidths, key, width); From e7d878325fa6deb6583f7d788c531478c0284bbd Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 13 Dec 2022 07:50:07 +1100 Subject: [PATCH 33/42] add missed type --- packages/@react-stately/table/src/useTableColumnResizeState.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 2e54bd6d4c0..ae3fe6c46a5 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -14,6 +14,7 @@ import {ColumnSize} from '@react-types/table'; import {GridNode} from '@react-types/grid'; import {Key, useCallback, useMemo, useState} from 'react'; import {TableColumnLayout} from '@react-stately/layout'; +import {TableState} from './useTableState'; export interface TableColumnResizeStateProps { /** @@ -50,7 +51,7 @@ export interface TableColumnResizeState { } -export function useTableColumnResizeState(props: TableColumnResizeStateProps, state): TableColumnResizeState { +export function useTableColumnResizeState(props: TableColumnResizeStateProps, state: TableState): TableColumnResizeState { let { getDefaultWidth, getDefaultMinWidth, From 62dfe3d09e4cd582746cc151800cd3766a2378d8 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 13 Dec 2022 08:10:57 +1100 Subject: [PATCH 34/42] fix types and remove read/write render ref --- packages/@react-spectrum/table/src/Resizer.tsx | 13 +++++-------- packages/@react-types/table/src/index.d.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index 392351df2dd..b5ee1e7cb86 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -5,9 +5,8 @@ import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; import {MoveMoveEvent} from '@react-types/shared'; -import React, {Key, RefObject, useRef} from 'react'; +import React, {Key, RefObject} from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; -import {TableLayoutState} from '@react-types/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useTableColumnResize} from '@react-aria/table'; import {useTableContext, useVirtualizerContext} from './TableView'; @@ -32,8 +31,6 @@ function Resizer(props: ResizerProps, ref: RefObject) { useVirtualizerContext(); let stringFormatter = useLocalizedStringFormatter(intlMessages); let {direction} = useLocale(); - const stateRef = useRef(null); - stateRef.current = layout; let {inputProps, resizerProps} = useTableColumnResize({ ...props, @@ -43,9 +40,9 @@ function Resizer(props: ResizerProps, ref: RefObject) { document.body.classList.remove(classNames(styles, 'resize-ew')); document.body.classList.remove(classNames(styles, 'resize-e')); document.body.classList.remove(classNames(styles, 'resize-w')); - if (stateRef.current.getColumnMinWidth(column.key) >= stateRef.current.getColumnWidth(column.key)) { + if (layout.getColumnMinWidth(column.key) >= layout.getColumnWidth(column.key)) { document.body.classList.add(direction === 'rtl' ? classNames(styles, 'resize-w') : classNames(styles, 'resize-e')); - } else if (stateRef.current.getColumnMaxWidth(column.key) <= stateRef.current.getColumnWidth(column.key)) { + } else if (layout.getColumnMaxWidth(column.key) <= layout.getColumnWidth(column.key)) { document.body.classList.add(direction === 'rtl' ? classNames(styles, 'resize-e') : classNames(styles, 'resize-w')); } else { document.body.classList.add(classNames(styles, 'resize-ew')); @@ -65,8 +62,8 @@ function Resizer(props: ResizerProps, ref: RefObject) { display: showResizer ? undefined : 'none', touchAction: 'none' }; - let isEResizable = stateRef.current.getColumnMinWidth(column.key) >= stateRef.current.getColumnWidth(column.key); - let isWResizable = stateRef.current.getColumnMaxWidth(column.key) <= stateRef.current.getColumnWidth(column.key); + let isEResizable = layout.getColumnMinWidth(column.key) >= layout.getColumnWidth(column.key); + let isWResizable = layout.getColumnMaxWidth(column.key) <= layout.getColumnWidth(column.key); return ( <> diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 1f701a2d2b6..23ee6263332 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -50,12 +50,12 @@ export interface SpectrumTableProps extends TableProps, SpectrumSelectionP * Can be used with the width property on columns to put the column widths into * a controlled state. */ - onResize?: (widths: Map) => void, + onResize?: (widths: Map) => void, /** * Handler that is called after a user performs a column resize. * Can be used to store the widths of columns for another future session. */ - onResizeEnd?: (widths: Map) => void + onResizeEnd?: (widths: Map) => void } export interface TableHeaderProps { @@ -165,7 +165,10 @@ export interface TableLayoutState { 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. */ + /** + * 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, /** Called to update the state that resizing has ended. */ onColumnResizeEnd: (key: Key) => void From c1b2a75c0a8982bcdb1f29c1b3fc92632def2635 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 13 Dec 2022 13:42:21 +1100 Subject: [PATCH 35/42] re-type getDefaultWidth and add strict nulls --- .../table/stories/useTable.stories.tsx | 6 ++-- .../@react-spectrum/table/src/TableView.tsx | 4 +-- .../table/stories/ControllingResize.tsx | 2 +- .../@react-stately/layout/src/TableLayout.ts | 32 +++++++++---------- .../table/src/useTableColumnResizeState.ts | 4 +-- packages/@react-types/table/src/index.d.ts | 8 ++--- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index fc3d1a97789..b46beb2f8a2 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -132,8 +132,8 @@ export const TableWithResizingNoProps = { interface ColumnData { name: string, uid: string, - defaultWidth?: ColumnSize, - width?: ColumnSize + defaultWidth?: ColumnSize | null, + width?: ColumnSize | null } let columnsDefaultFR: ColumnData[] = [ {name: 'Name', uid: 'name', defaultWidth: '1fr'}, @@ -163,7 +163,7 @@ export const TableWithResizingFRs = { ) }; -function ControlledTableResizing(props: {columns: Array<{name: string, uid: string, width: ColumnSize}>, rows, onResize}) { +function ControlledTableResizing(props: {columns: Array<{name: string, uid: string, width?: ColumnSize | null}>, rows, onResize}) { let {columns, rows = defaultRows, onResize, ...otherProps} = props; let [widths, _setWidths] = useState>(() => new Map(columns.filter(col => col.width).map((col) => [col.uid as Key, col.width]))); diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 2e4448e102c..4d9c96b6dcb 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -121,7 +121,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef { + const getDefaultWidth = useCallback(({props: {hideHeader, isSelectionCell, showDivider}}: GridNode): ColumnSize | null | undefined => { if (hideHeader) { let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; return showDivider ? width + 1 : width; @@ -130,7 +130,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef { + const getDefaultMinWidth = useCallback(({props: {hideHeader, isSelectionCell, showDivider}}: GridNode): ColumnSize | null | undefined => { if (hideHeader) { let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; return showDivider ? width + 1 : width; diff --git a/packages/@react-spectrum/table/stories/ControllingResize.tsx b/packages/@react-spectrum/table/stories/ControllingResize.tsx index e5794dafb3f..08f4c236710 100644 --- a/packages/@react-spectrum/table/stories/ControllingResize.tsx +++ b/packages/@react-spectrum/table/stories/ControllingResize.tsx @@ -18,7 +18,7 @@ import React, {Key, useCallback, useMemo, useState} from 'react'; export interface PokemonColumn { name: string, uid: string, - width?: ColumnSize + width?: ColumnSize | null } export interface PokemonData { id: number, diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index affe79a5f42..493c39bbbb6 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -28,28 +28,28 @@ type TableLayoutOptions = ListLayoutOptions & { initialCollection: TableCollection } -interface TableColumnLayoutOptions { - getDefaultWidth: (props) => ColumnSize, - getDefaultMinWidth: (props) => ColumnSize +interface TableColumnLayoutOptions { + getDefaultWidth?: (column: GridNode) => ColumnSize | null | undefined, + getDefaultMinWidth?: (column: GridNode) => ColumnSize | null | undefined } export class TableColumnLayout { resizingColumn: Key | null; - getDefaultWidth: (props) => ColumnSize; - getDefaultMinWidth: (props) => ColumnSize; + getDefaultWidth: (column: GridNode) => ColumnSize | null | undefined; + getDefaultMinWidth: (column: GridNode) => ColumnSize | null | undefined; columnWidths: Map = new Map(); columnMinWidths: Map = new Map(); columnMaxWidths: Map = new Map(); resizerPositions: Map = new Map(); - constructor(options: TableColumnLayoutOptions) { + constructor(options: TableColumnLayoutOptions) { this.getDefaultWidth = options.getDefaultWidth; this.getDefaultMinWidth = options.getDefaultMinWidth; } /** Takes an array of columns and splits it into 2 maps of columns with controlled and columns with uncontrolled widths. */ - splitColumnsIntoControlledAndUncontrolled(columns: Array>): [Map>, Map>] { + splitColumnsIntoControlledAndUncontrolled(columns: Array>): [Map>, Map>] { return columns.reduce((acc, col) => { if (col.props.width != null) { acc[0].set(col.key, col); @@ -61,7 +61,7 @@ export class TableColumnLayout { } /** Takes uncontrolled and controlled widths and joins them into a single Map. */ - recombineColumns(columns: Array>, uncontrolledWidths: Map, uncontrolledColumns: Map>, controlledColumns: Map>): Map { + recombineColumns(columns: Array>, uncontrolledWidths: Map, uncontrolledColumns: Map>, controlledColumns: Map>): Map { return new Map(columns.map(col => { if (uncontrolledColumns.has(col.key)) { return [col.key, uncontrolledWidths.get(col.key)]; @@ -72,9 +72,9 @@ export class TableColumnLayout { } /** Used to make an initial Map of the uncontrolled widths based on default widths. */ - getInitialUncontrolledWidths(uncontrolledColumns: Map>): Map { + getInitialUncontrolledWidths(uncontrolledColumns: Map>): Map { return new Map(Array.from(uncontrolledColumns).map(([key, col]) => - [key, col.props.defaultWidth ?? this.getDefaultWidth?.(col.props)] + [key, col.props.defaultWidth ?? this.getDefaultWidth?.(col) ?? '1fr'] )); } @@ -115,7 +115,7 @@ export class TableColumnLayout { // to the right or left of the resizing column collection.columns.forEach((column, i) => { let frKey; - minWidths.set(column.key, this.getDefaultMinWidth(collection.columns[i].props)); + minWidths.set(column.key, this.getDefaultMinWidth(collection.columns[i])); if (col !== column.key && !column.column.props.width && !isStatic(uncontrolledWidths.get(column.key))) { // uncontrolled don't have props.width for us, so instead get from our state frKey = column.key; @@ -149,8 +149,8 @@ export class TableColumnLayout { tableWidth, collection.columns.map(col => ({...col.column.props, key: col.key})), resizingChanged, - (i) => this.getDefaultWidth(collection.columns[i].props), - (i) => this.getDefaultMinWidth(collection.columns[i].props) + (i) => this.getDefaultWidth(collection.columns[i]), + (i) => this.getDefaultMinWidth(collection.columns[i]) ); // set all new column widths for onResize event @@ -191,8 +191,8 @@ export class TableColumnLayout { tableWidth, collection.columns.map(col => ({...col.column.props, key: col.key})), widths, - (i) => this.getDefaultWidth(collection.columns[i].props), - (i) => this.getDefaultMinWidth(collection.columns[i].props) + (i) => this.getDefaultWidth(collection.columns[i]), + (i) => this.getDefaultMinWidth(collection.columns[i]) ); // columns going in will be the same order as the columns coming out @@ -201,7 +201,7 @@ export class TableColumnLayout { let key = collection.columns[index].key; let column = collection.columns[index]; this.columnWidths.set(key, width); - this.columnMinWidths.set(key, getMinWidth(column.column.props.minWidth ?? this.getDefaultMinWidth(column.props), tableWidth)); + this.columnMinWidths.set(key, getMinWidth(column.column.props.minWidth ?? this.getDefaultMinWidth(column), tableWidth)); this.columnMaxWidths.set(key, getMaxWidth(column.column.props.maxWidth, tableWidth)); resizerPosition += width; this.resizerPositions.set(key, resizerPosition); diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index ae3fe6c46a5..7f1d93810ea 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -23,9 +23,9 @@ export interface TableColumnResizeStateProps { **/ tableWidth: number, /** A function that is called to find the default width for a given column. */ - getDefaultWidth: (node: GridNode) => ColumnSize, + getDefaultWidth?: (node: GridNode) => ColumnSize | null | undefined, /** A function that is called to find the default minWidth for a given column. */ - getDefaultMinWidth: (node: GridNode) => ColumnSize, + getDefaultMinWidth?: (node: GridNode) => ColumnSize | null | undefined, /** Callback that is invoked during the entirety of the resize event. */ onColumnResize?: (widths: Map) => void, /** Callback that is invoked when the resize event is started. */ diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 23ee6263332..19099b3c6cd 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -75,13 +75,13 @@ export interface ColumnProps { /** A list of child columns used when dynamically rendering nested child columns. */ childColumns?: T[], /** The width of the column. */ - width?: ColumnSize, + width?: ColumnSize | null, /** The minimum width of the column. */ - minWidth?: ColumnStaticSize, + minWidth?: ColumnStaticSize | null, /** The maximum width of the column. */ - maxWidth?: ColumnStaticSize, + maxWidth?: ColumnStaticSize | null, /** The default width of the column. */ - defaultWidth?: ColumnSize, + defaultWidth?: ColumnSize | null, /** Whether the column allows resizing. */ allowsResizing?: boolean, /** Whether the column allows sorting. */ From 67560cbf2dfa2e3d4740401f557e88453eacdd47 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 13 Dec 2022 14:55:27 +1100 Subject: [PATCH 36/42] remove some memos, use existing value for resizer --- .../@react-stately/layout/src/TableLayout.ts | 28 ++----------- .../table/src/useTableColumnResizeState.ts | 42 +++++++------------ 2 files changed, 20 insertions(+), 50 deletions(-) diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 493c39bbbb6..63fe3b09168 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -34,20 +34,17 @@ interface TableColumnLayoutOptions { } export class TableColumnLayout { - resizingColumn: Key | null; getDefaultWidth: (column: GridNode) => ColumnSize | null | undefined; getDefaultMinWidth: (column: GridNode) => ColumnSize | null | undefined; columnWidths: Map = new Map(); columnMinWidths: Map = new Map(); columnMaxWidths: Map = new Map(); - resizerPositions: Map = new Map(); constructor(options: TableColumnLayoutOptions) { this.getDefaultWidth = options.getDefaultWidth; this.getDefaultMinWidth = options.getDefaultMinWidth; } - /** Takes an array of columns and splits it into 2 maps of columns with controlled and columns with uncontrolled widths. */ splitColumnsIntoControlledAndUncontrolled(columns: Array>): [Map>, Map>] { return columns.reduce((acc, col) => { @@ -78,14 +75,6 @@ export class TableColumnLayout { )); } - setResizingColumn(key: Key | null): void { - this.resizingColumn = key; - } - - getResizerPosition(): number { - return this.resizerPositions.get(this.resizingColumn); - } - getColumnWidth(key: Key): number { return this.columnWidths.get(key) ?? 0; } @@ -184,7 +173,6 @@ export class TableColumnLayout { this.columnWidths = new Map(); this.columnMinWidths = new Map(); this.columnMaxWidths = new Map(); - this.resizerPositions = new Map(); // initial layout or table/window resizing let columnWidths = calculateColumnSizes( @@ -196,15 +184,12 @@ export class TableColumnLayout { ); // columns going in will be the same order as the columns coming out - let resizerPosition = 0; columnWidths.forEach((width, index) => { let key = collection.columns[index].key; let column = collection.columns[index]; this.columnWidths.set(key, width); this.columnMinWidths.set(key, getMinWidth(column.column.props.minWidth ?? this.getDefaultMinWidth(column), tableWidth)); this.columnMaxWidths.set(key, getMaxWidth(column.column.props.maxWidth, tableWidth)); - resizerPosition += width; - this.resizerPositions.set(key, resizerPosition); }); return this.columnWidths; } @@ -225,6 +210,7 @@ export class TableLayout extends ListLayout { uncontrolledColumns: Map>; uncontrolledWidths: Map; lastVirtualizerWidth: number; + resizingColumn: Key | null; constructor(options: TableLayoutOptions) { super(options); @@ -239,12 +225,8 @@ export class TableLayout extends ListLayout { this.uncontrolledWidths = this.columnLayout.getInitialUncontrolledWidths(uncontrolledColumns); } - get resizingColumn(): Key { - return this.columnLayout.resizingColumn; - } - getResizerPosition(): Key { - return this.columnLayout.getResizerPosition(); + return this.getLayoutInfo(this.resizingColumn)?.rect.maxX; } getColumnWidth(key: Key): number { @@ -269,7 +251,7 @@ export class TableLayout extends ListLayout { // outside, where this is called, should call props.onColumnResizeStart... onColumnResizeStart(key: Key): void { - this.columnLayout.setResizingColumn(key); + this.resizingColumn = key; } // only way to call props.onColumnResize with the new size outside of Layout is to send the result back @@ -289,7 +271,7 @@ export class TableLayout extends ListLayout { } onColumnResizeEnd(): void { - this.columnLayout.setResizingColumn(null); + this.resizingColumn = null; } buildCollection(): LayoutNode[] { @@ -298,8 +280,6 @@ export class TableLayout extends ListLayout { this.uncontrolledColumns = uncontrolledColumns; let cWidths = this.columnLayout.recombineColumns(this.collection.columns, this.uncontrolledWidths, uncontrolledColumns, controlledColumns); - // We will be behind by one render for column prop changes since invalidate - // will take a render to resolve. // If columns changed, clear layout cache. if ( !this.lastCollection || diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 7f1d93810ea..62584effd35 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -56,9 +56,11 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< getDefaultWidth, getDefaultMinWidth, onColumnResizeStart: propsOnColumnResizeStart, - onColumnResizeEnd: propsOnColumnResizeEnd + onColumnResizeEnd: propsOnColumnResizeEnd, + tableWidth = 0 } = props; + let [resizingColumn, setResizingColumn] = useState(null); let columnLayout = useMemo( () => new TableColumnLayout({ getDefaultWidth, @@ -67,7 +69,6 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< [getDefaultWidth, getDefaultMinWidth] ); - let tableWidth = props.tableWidth ?? 0; let [controlledColumns, uncontrolledColumns] = useMemo(() => columnLayout.splitColumnsIntoControlledAndUncontrolled(state.collection.columns) , [state.collection.columns, columnLayout]); @@ -82,9 +83,9 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< , [state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns, columnLayout]); let onColumnResizeStart = useCallback((key: Key) => { - columnLayout.setResizingColumn(key); + setResizingColumn(key); propsOnColumnResizeStart?.(key); - }, [columnLayout, propsOnColumnResizeStart]); + }, [propsOnColumnResizeStart, setResizingColumn]); let onColumnResize = useCallback((key: Key, width: number): Map => { let newControlled = new Map(Array.from(controlledColumns).map(([key, entry]) => [key, entry.props.width])); @@ -98,43 +99,32 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< }, [controlledColumns, uncontrolledColumns, setUncontrolledWidths, tableWidth, columnLayout, state.collection, uncontrolledWidths]); let onColumnResizeEnd = useCallback((key: Key) => { - columnLayout.setResizingColumn(null); + setResizingColumn(null); propsOnColumnResizeEnd?.(key); - }, [columnLayout, propsOnColumnResizeEnd]); - - let getColumnWidth = useCallback((key: Key) => - columnLayout.getColumnWidth(key) - , [columnLayout]); - - let getColumnMinWidth = useCallback((key: Key) => - columnLayout.getColumnMinWidth(key) - , [columnLayout]); - - let getColumnMaxWidth = useCallback((key: Key) => - columnLayout.getColumnMaxWidth(key) - , [columnLayout]); + }, [propsOnColumnResizeEnd, setResizingColumn]); let columnWidths = useMemo(() => columnLayout.buildColumnWidths(tableWidth, state.collection, cWidths) , [tableWidth, state.collection, cWidths, columnLayout]); return useMemo(() => ({ - resizingColumn: columnLayout.resizingColumn, + resizingColumn, onColumnResize, onColumnResizeStart, onColumnResizeEnd, - getColumnWidth, - getColumnMinWidth, - getColumnMaxWidth, + getColumnWidth: (key: Key) => + columnLayout.getColumnWidth(key), + getColumnMinWidth: (key: Key) => + columnLayout.getColumnMinWidth(key), + getColumnMaxWidth: (key: Key) => + columnLayout.getColumnMaxWidth(key), widths: columnWidths }), [ - columnLayout.resizingColumn, + columnLayout, + resizingColumn, onColumnResize, onColumnResizeStart, onColumnResizeEnd, - getColumnWidth, - getColumnMinWidth, - getColumnMaxWidth, columnWidths ]); } From 2cc7c6820cb678f117b1b9e79421e31655a28d04 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 13 Dec 2022 14:59:31 +1100 Subject: [PATCH 37/42] rename confusing variable --- packages/@react-stately/layout/src/TableLayout.ts | 4 ++-- .../@react-stately/table/src/useTableColumnResizeState.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 63fe3b09168..aed1358d6b4 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -278,7 +278,7 @@ export class TableLayout extends ListLayout { let [controlledColumns, uncontrolledColumns] = this.columnLayout.splitColumnsIntoControlledAndUncontrolled(this.collection.columns); this.controlledColumns = controlledColumns; this.uncontrolledColumns = uncontrolledColumns; - let cWidths = this.columnLayout.recombineColumns(this.collection.columns, this.uncontrolledWidths, uncontrolledColumns, controlledColumns); + let colWidths = this.columnLayout.recombineColumns(this.collection.columns, this.uncontrolledWidths, uncontrolledColumns, controlledColumns); // If columns changed, clear layout cache. if ( @@ -311,7 +311,7 @@ export class TableLayout extends ListLayout { } } - this.columnWidths = this.columnLayout.buildColumnWidths(this.virtualizer.visibleRect.width, this.collection, cWidths); + this.columnWidths = this.columnLayout.buildColumnWidths(this.virtualizer.visibleRect.width, this.collection, colWidths); let header = this.buildHeader(); let body = this.buildBody(0); diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 62584effd35..bace563b222 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -78,7 +78,7 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< columnLayout.getInitialUncontrolledWidths(uncontrolledColumns) ); // combine columns back into one map that maintains same order as the columns - let cWidths = useMemo(() => + let colWidths = useMemo(() => columnLayout.recombineColumns(state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns) , [state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns, columnLayout]); @@ -104,8 +104,8 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< }, [propsOnColumnResizeEnd, setResizingColumn]); let columnWidths = useMemo(() => - columnLayout.buildColumnWidths(tableWidth, state.collection, cWidths) - , [tableWidth, state.collection, cWidths, columnLayout]); + columnLayout.buildColumnWidths(tableWidth, state.collection, colWidths) + , [tableWidth, state.collection, colWidths, columnLayout]); return useMemo(() => ({ resizingColumn, From afb1591b84ab7f981ea933540ec834c9684b9a2a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 13 Dec 2022 15:33:28 +1100 Subject: [PATCH 38/42] move column layout to table stately --- .../@react-spectrum/table/src/TableView.tsx | 4 +- .../@react-stately/layout/src/TableLayout.ts | 180 +---------------- packages/@react-stately/layout/src/index.ts | 2 +- .../table/src/TableColumnLayout.ts | 184 ++++++++++++++++++ .../{layout => table}/src/TableUtils.ts | 0 packages/@react-stately/table/src/index.ts | 1 + .../table/src/useTableColumnResizeState.ts | 2 +- .../{layout => table}/test/TableUtils.test.js | 2 +- packages/@react-types/table/src/index.d.ts | 31 +++ 9 files changed, 224 insertions(+), 182 deletions(-) create mode 100644 packages/@react-stately/table/src/TableColumnLayout.ts rename packages/@react-stately/{layout => table}/src/TableUtils.ts (100%) rename packages/@react-stately/{layout => table}/test/TableUtils.test.js (99%) diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 4d9c96b6dcb..e0370f88584 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -38,8 +38,8 @@ import {Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualize import {Resizer} from './Resizer'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; import stylesOverrides from './table.css'; -import {TableColumnLayout, TableLayout} from '@react-stately/layout'; -import {TableState, useTableState} from '@react-stately/table'; +import {TableColumnLayout, TableState, useTableState} from '@react-stately/table'; +import {TableLayout} from '@react-stately/layout'; import {Tooltip, TooltipTrigger} from '@react-spectrum/tooltip'; import {useButton} from '@react-aria/button'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index aed1358d6b4..fcbf953c2f1 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -10,191 +10,17 @@ * governing permissions and limitations under the License. */ -import { - calculateColumnSizes, - getMaxWidth, - getMinWidth, - isStatic, - parseFractionalUnit -} from './TableUtils'; -import {ColumnSize, TableCollection} from '@react-types/table'; +import {ColumnSize, ITableColumnLayout, TableCollection} from '@react-types/table'; import {GridNode} from '@react-types/grid'; import {Key} from 'react'; import {LayoutInfo, Point, Rect, Size} from '@react-stately/virtualizer'; import {LayoutNode, ListLayout, ListLayoutOptions} from './ListLayout'; type TableLayoutOptions = ListLayoutOptions & { - columnLayout: TableColumnLayout, + columnLayout: ITableColumnLayout, initialCollection: TableCollection } -interface TableColumnLayoutOptions { - getDefaultWidth?: (column: GridNode) => ColumnSize | null | undefined, - getDefaultMinWidth?: (column: GridNode) => ColumnSize | null | undefined -} - -export class TableColumnLayout { - getDefaultWidth: (column: GridNode) => ColumnSize | null | undefined; - getDefaultMinWidth: (column: GridNode) => ColumnSize | null | undefined; - columnWidths: Map = new Map(); - columnMinWidths: Map = new Map(); - columnMaxWidths: Map = new Map(); - - constructor(options: TableColumnLayoutOptions) { - this.getDefaultWidth = options.getDefaultWidth; - this.getDefaultMinWidth = options.getDefaultMinWidth; - } - - /** Takes an array of columns and splits it into 2 maps of columns with controlled and columns with uncontrolled widths. */ - splitColumnsIntoControlledAndUncontrolled(columns: Array>): [Map>, Map>] { - return columns.reduce((acc, col) => { - if (col.props.width != null) { - acc[0].set(col.key, col); - } else { - acc[1].set(col.key, col); - } - return acc; - }, [new Map(), new Map()]); - } - - /** Takes uncontrolled and controlled widths and joins them into a single Map. */ - recombineColumns(columns: Array>, uncontrolledWidths: Map, uncontrolledColumns: Map>, controlledColumns: Map>): Map { - return new Map(columns.map(col => { - if (uncontrolledColumns.has(col.key)) { - return [col.key, uncontrolledWidths.get(col.key)]; - } else { - return [col.key, controlledColumns.get(col.key).props.width]; - } - })); - } - - /** Used to make an initial Map of the uncontrolled widths based on default widths. */ - getInitialUncontrolledWidths(uncontrolledColumns: Map>): Map { - return new Map(Array.from(uncontrolledColumns).map(([key, col]) => - [key, col.props.defaultWidth ?? this.getDefaultWidth?.(col) ?? '1fr'] - )); - } - - getColumnWidth(key: Key): number { - return this.columnWidths.get(key) ?? 0; - } - - getColumnMinWidth(key: Key): number { - return this.columnMinWidths.get(key); - } - - getColumnMaxWidth(key: Key): number { - return this.columnMaxWidths.get(key); - } - - resizeColumnWidth(tableWidth: number, collection: TableCollection, controlledWidths: Map, uncontrolledWidths: Map, col = null, width: number): Map { - let prevColumnWidths = this.columnWidths; - // resizing a column - let resizeIndex = Infinity; - let controlledArray = Array.from(controlledWidths); - let uncontrolledArray = Array.from(uncontrolledWidths); - let combinedArray = controlledArray.concat(uncontrolledArray); - let resizingChanged = new Map(combinedArray); - let frKeys = new Map(); - let percentKeys = new Map(); - let frKeysToTheRight = new Map(); - let minWidths = new Map(); - // freeze columns to the left to their previous pixel value - // at the same time count how many total FR's are in play and which of those FRs are - // to the right or left of the resizing column - collection.columns.forEach((column, i) => { - let frKey; - minWidths.set(column.key, this.getDefaultMinWidth(collection.columns[i])); - if (col !== column.key && !column.column.props.width && !isStatic(uncontrolledWidths.get(column.key))) { - // uncontrolled don't have props.width for us, so instead get from our state - frKey = column.key; - frKeys.set(column.key, parseFractionalUnit(uncontrolledWidths.get(column.key) as string)); - } else if (col !== column.key && !isStatic(column.column.props.width) && !uncontrolledWidths.get(column.key)) { - // controlledWidths will be the same in the collection - frKey = column.key; - frKeys.set(column.key, parseFractionalUnit(column.column.props.width)); - } else if (col !== column.key && column.column.props.width?.endsWith?.('%')) { - percentKeys.set(column.key, column.column.props.width); - } - // don't freeze columns to the right of the resizing one - if (resizeIndex < i) { - if (frKey) { - frKeysToTheRight.set(frKey, frKeys.get(frKey)); - } - return; - } - // we already know the new size of the resizing column - if (column.key === col) { - resizeIndex = i; - return; - } - // freeze column to previous value - resizingChanged.set(column.key, prevColumnWidths.get(column.key)); - }); - resizingChanged.set(col, Math.floor(width)); - - // predict pixels sizes for all columns based on resize - let columnWidths = calculateColumnSizes( - tableWidth, - collection.columns.map(col => ({...col.column.props, key: col.key})), - resizingChanged, - (i) => this.getDefaultWidth(collection.columns[i]), - (i) => this.getDefaultMinWidth(collection.columns[i]) - ); - - // set all new column widths for onResize event - // columns going in will be the same order as the columns coming out - let newWidths = new Map(); - // set all column widths based on calculateColumnSize - columnWidths.forEach((width, index) => { - let key = collection.columns[index].key; - newWidths.set(key, width); - }); - - // add FR's back as they were to columns to the right - Array.from(frKeys).forEach(([key]) => { - if (frKeysToTheRight.has(key)) { - newWidths.set(key, `${frKeysToTheRight.get(key)}fr`); - } - }); - - // put back in percents - Array.from(percentKeys).forEach(([key, width]) => { - // resizing locks a column to a px width - if (key === col) { - return; - } - newWidths.set(key, width); - }); - return newWidths; - } - - buildColumnWidths(tableWidth: number, collection: TableCollection, widths: Map) { - this.columnWidths = new Map(); - this.columnMinWidths = new Map(); - this.columnMaxWidths = new Map(); - - // initial layout or table/window resizing - let columnWidths = calculateColumnSizes( - tableWidth, - collection.columns.map(col => ({...col.column.props, key: col.key})), - widths, - (i) => this.getDefaultWidth(collection.columns[i]), - (i) => this.getDefaultMinWidth(collection.columns[i]) - ); - - // columns going in will be the same order as the columns coming out - columnWidths.forEach((width, index) => { - let key = collection.columns[index].key; - let column = collection.columns[index]; - this.columnWidths.set(key, width); - this.columnMinWidths.set(key, getMinWidth(column.column.props.minWidth ?? this.getDefaultMinWidth(column), tableWidth)); - this.columnMaxWidths.set(key, getMaxWidth(column.column.props.maxWidth, tableWidth)); - }); - return this.columnWidths; - } -} - export class TableLayout extends ListLayout { collection: TableCollection; lastCollection: TableCollection; @@ -205,7 +31,7 @@ export class TableLayout extends ListLayout { lastPersistedKeys: Set = null; persistedIndices: Map = new Map(); private disableSticky: boolean; - columnLayout: TableColumnLayout; + columnLayout: ITableColumnLayout; controlledColumns: Map>; uncontrolledColumns: Map>; uncontrolledWidths: Map; diff --git a/packages/@react-stately/layout/src/index.ts b/packages/@react-stately/layout/src/index.ts index a999eb76f75..7197e7e5fb8 100644 --- a/packages/@react-stately/layout/src/index.ts +++ b/packages/@react-stately/layout/src/index.ts @@ -11,4 +11,4 @@ */ export type {ListLayoutOptions, LayoutNode} from './ListLayout'; export {ListLayout} from './ListLayout'; -export {TableLayout, TableColumnLayout} from './TableLayout'; +export {TableLayout} from './TableLayout'; diff --git a/packages/@react-stately/table/src/TableColumnLayout.ts b/packages/@react-stately/table/src/TableColumnLayout.ts new file mode 100644 index 00000000000..042dc364e41 --- /dev/null +++ b/packages/@react-stately/table/src/TableColumnLayout.ts @@ -0,0 +1,184 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + calculateColumnSizes, + getMaxWidth, + getMinWidth, + isStatic, + parseFractionalUnit +} from './TableUtils'; +import {ColumnSize, ITableColumnLayout, TableCollection, TableColumnLayoutOptions} from '@react-types/table'; +import {GridNode} from '@react-types/grid'; +import {Key} from 'react'; + +export class TableColumnLayout implements ITableColumnLayout { + getDefaultWidth: (column: GridNode) => ColumnSize | null | undefined; + getDefaultMinWidth: (column: GridNode) => ColumnSize | null | undefined; + columnWidths: Map = new Map(); + columnMinWidths: Map = new Map(); + columnMaxWidths: Map = new Map(); + + constructor(options: TableColumnLayoutOptions) { + this.getDefaultWidth = options.getDefaultWidth; + this.getDefaultMinWidth = options.getDefaultMinWidth; + } + + /** Takes an array of columns and splits it into 2 maps of columns with controlled and columns with uncontrolled widths. */ + splitColumnsIntoControlledAndUncontrolled(columns: Array>): [Map>, Map>] { + return columns.reduce((acc, col) => { + if (col.props.width != null) { + acc[0].set(col.key, col); + } else { + acc[1].set(col.key, col); + } + return acc; + }, [new Map(), new Map()]); + } + + /** Takes uncontrolled and controlled widths and joins them into a single Map. */ + recombineColumns(columns: Array>, uncontrolledWidths: Map, uncontrolledColumns: Map>, controlledColumns: Map>): Map { + return new Map(columns.map(col => { + if (uncontrolledColumns.has(col.key)) { + return [col.key, uncontrolledWidths.get(col.key)]; + } else { + return [col.key, controlledColumns.get(col.key).props.width]; + } + })); + } + + /** Used to make an initial Map of the uncontrolled widths based on default widths. */ + getInitialUncontrolledWidths(uncontrolledColumns: Map>): Map { + return new Map(Array.from(uncontrolledColumns).map(([key, col]) => + [key, col.props.defaultWidth ?? this.getDefaultWidth?.(col) ?? '1fr'] + )); + } + + getColumnWidth(key: Key): number { + return this.columnWidths.get(key) ?? 0; + } + + getColumnMinWidth(key: Key): number { + return this.columnMinWidths.get(key); + } + + getColumnMaxWidth(key: Key): number { + return this.columnMaxWidths.get(key); + } + + resizeColumnWidth(tableWidth: number, collection: TableCollection, controlledWidths: Map, uncontrolledWidths: Map, col = null, width: number): Map { + let prevColumnWidths = this.columnWidths; + // resizing a column + let resizeIndex = Infinity; + let controlledArray = Array.from(controlledWidths); + let uncontrolledArray = Array.from(uncontrolledWidths); + let combinedArray = controlledArray.concat(uncontrolledArray); + let resizingChanged = new Map(combinedArray); + let frKeys = new Map(); + let percentKeys = new Map(); + let frKeysToTheRight = new Map(); + let minWidths = new Map(); + // freeze columns to the left to their previous pixel value + // at the same time count how many total FR's are in play and which of those FRs are + // to the right or left of the resizing column + collection.columns.forEach((column, i) => { + let frKey; + minWidths.set(column.key, this.getDefaultMinWidth(collection.columns[i])); + if (col !== column.key && !column.column.props.width && !isStatic(uncontrolledWidths.get(column.key))) { + // uncontrolled don't have props.width for us, so instead get from our state + frKey = column.key; + frKeys.set(column.key, parseFractionalUnit(uncontrolledWidths.get(column.key) as string)); + } else if (col !== column.key && !isStatic(column.column.props.width) && !uncontrolledWidths.get(column.key)) { + // controlledWidths will be the same in the collection + frKey = column.key; + frKeys.set(column.key, parseFractionalUnit(column.column.props.width)); + } else if (col !== column.key && column.column.props.width?.endsWith?.('%')) { + percentKeys.set(column.key, column.column.props.width); + } + // don't freeze columns to the right of the resizing one + if (resizeIndex < i) { + if (frKey) { + frKeysToTheRight.set(frKey, frKeys.get(frKey)); + } + return; + } + // we already know the new size of the resizing column + if (column.key === col) { + resizeIndex = i; + return; + } + // freeze column to previous value + resizingChanged.set(column.key, prevColumnWidths.get(column.key)); + }); + resizingChanged.set(col, Math.floor(width)); + + // predict pixels sizes for all columns based on resize + let columnWidths = calculateColumnSizes( + tableWidth, + collection.columns.map(col => ({...col.column.props, key: col.key})), + resizingChanged, + (i) => this.getDefaultWidth(collection.columns[i]), + (i) => this.getDefaultMinWidth(collection.columns[i]) + ); + + // set all new column widths for onResize event + // columns going in will be the same order as the columns coming out + let newWidths = new Map(); + // set all column widths based on calculateColumnSize + columnWidths.forEach((width, index) => { + let key = collection.columns[index].key; + newWidths.set(key, width); + }); + + // add FR's back as they were to columns to the right + Array.from(frKeys).forEach(([key]) => { + if (frKeysToTheRight.has(key)) { + newWidths.set(key, `${frKeysToTheRight.get(key)}fr`); + } + }); + + // put back in percents + Array.from(percentKeys).forEach(([key, width]) => { + // resizing locks a column to a px width + if (key === col) { + return; + } + newWidths.set(key, width); + }); + return newWidths; + } + + buildColumnWidths(tableWidth: number, collection: TableCollection, widths: Map) { + this.columnWidths = new Map(); + this.columnMinWidths = new Map(); + this.columnMaxWidths = new Map(); + + // initial layout or table/window resizing + let columnWidths = calculateColumnSizes( + tableWidth, + collection.columns.map(col => ({...col.column.props, key: col.key})), + widths, + (i) => this.getDefaultWidth(collection.columns[i]), + (i) => this.getDefaultMinWidth(collection.columns[i]) + ); + + // columns going in will be the same order as the columns coming out + columnWidths.forEach((width, index) => { + let key = collection.columns[index].key; + let column = collection.columns[index]; + this.columnWidths.set(key, width); + this.columnMinWidths.set(key, getMinWidth(column.column.props.minWidth ?? this.getDefaultMinWidth(column), tableWidth)); + this.columnMaxWidths.set(key, getMaxWidth(column.column.props.maxWidth, tableWidth)); + }); + return this.columnWidths; + } +} diff --git a/packages/@react-stately/layout/src/TableUtils.ts b/packages/@react-stately/table/src/TableUtils.ts similarity index 100% rename from packages/@react-stately/layout/src/TableUtils.ts rename to packages/@react-stately/table/src/TableUtils.ts diff --git a/packages/@react-stately/table/src/index.ts b/packages/@react-stately/table/src/index.ts index 64c6371af7f..34ba6e512e3 100644 --- a/packages/@react-stately/table/src/index.ts +++ b/packages/@react-stately/table/src/index.ts @@ -23,3 +23,4 @@ export {Row} from './Row'; export {Cell} from './Cell'; export {Section} from '@react-stately/collections'; export {TableCollection} from './TableCollection'; +export {TableColumnLayout} from './TableColumnLayout'; diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index bace563b222..877ac1eb378 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -13,7 +13,7 @@ import {ColumnSize} from '@react-types/table'; import {GridNode} from '@react-types/grid'; import {Key, useCallback, useMemo, useState} from 'react'; -import {TableColumnLayout} from '@react-stately/layout'; +import {TableColumnLayout} from './TableColumnLayout'; import {TableState} from './useTableState'; export interface TableColumnResizeStateProps { diff --git a/packages/@react-stately/layout/test/TableUtils.test.js b/packages/@react-stately/table/test/TableUtils.test.js similarity index 99% rename from packages/@react-stately/layout/test/TableUtils.test.js rename to packages/@react-stately/table/test/TableUtils.test.js index 745efe2c1aa..7a18c47f381 100644 --- a/packages/@react-stately/layout/test/TableUtils.test.js +++ b/packages/@react-stately/table/test/TableUtils.test.js @@ -1,5 +1,5 @@ import {calculateColumnSizes} from '../src/TableUtils'; -import {TableColumnLayout} from '../src/TableLayout'; +import {TableColumnLayout} from '../src/TableColumnLayout'; describe('TableUtils', () => { describe('column building', () => { diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 19099b3c6cd..d9fc8343216 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -154,6 +154,37 @@ export interface TableCollection extends GridCollection { body: GridNode } +export interface ITableColumnLayout { + getDefaultWidth: (column: GridNode) => ColumnSize | null | undefined, + getDefaultMinWidth: (column: GridNode) => ColumnSize | null | undefined, + columnWidths: Map, + columnMinWidths: Map, + columnMaxWidths: Map, + + /** Takes an array of columns and splits it into 2 maps of columns with controlled and columns with uncontrolled widths. */ + splitColumnsIntoControlledAndUncontrolled(columns: Array>): [Map>, Map>], + + /** Takes uncontrolled and controlled widths and joins them into a single Map. */ + recombineColumns(columns: Array>, uncontrolledWidths: Map, uncontrolledColumns: Map>, controlledColumns: Map>): Map, + + /** Used to make an initial Map of the uncontrolled widths based on default widths. */ + getInitialUncontrolledWidths(uncontrolledColumns: Map>): Map, + + getColumnWidth(key: Key): number, + + getColumnMinWidth(key: Key): number, + + getColumnMaxWidth(key: Key): number, + + resizeColumnWidth(tableWidth: number, collection: TableCollection, controlledWidths: Map, uncontrolledWidths: Map, col: Key, width: number): Map, + + buildColumnWidths(tableWidth: number, collection: TableCollection, widths: Map): Map +} +export interface TableColumnLayoutOptions { + getDefaultWidth?: (column: GridNode) => ColumnSize | null | undefined, + getDefaultMinWidth?: (column: GridNode) => ColumnSize | null | undefined +} + export interface TableLayoutState { /** Get the current width of the specified column. */ getColumnWidth: (key: Key) => number, From a7372f820da41159fc7395c2dbdf61ff700f95f0 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 13 Dec 2022 17:15:12 +1100 Subject: [PATCH 39/42] add resizing hide header story --- .../table/stories/ControllingResize.tsx | 4 ++-- .../table/stories/Table.stories.tsx | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/table/stories/ControllingResize.tsx b/packages/@react-spectrum/table/stories/ControllingResize.tsx index 08f4c236710..eb10be055a2 100644 --- a/packages/@react-spectrum/table/stories/ControllingResize.tsx +++ b/packages/@react-spectrum/table/stories/ControllingResize.tsx @@ -11,11 +11,11 @@ */ import {Button} from '@react-spectrum/button'; -import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; +import {Cell, Column, Row, SpectrumColumnProps, TableBody, TableHeader, TableView} from '../'; import {ColumnSize} from '@react-types/table'; import React, {Key, useCallback, useMemo, useState} from 'react'; -export interface PokemonColumn { +export interface PokemonColumn extends Omit, 'children'> { name: string, uid: string, width?: ColumnSize | null diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index bae21ef8a3b..16f241a98aa 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -1404,6 +1404,15 @@ storiesOf('TableView', module) mimics what would happen if an app reloaded the whole page and restored a saved column width state. `}} + ) + .add( + 'allowsResizing, controlled, hideHeader', + () => ( + + ), + {description: {data: ` + Hide headers columns should not be resizable. + `}} ); let uncontrolledColumns: PokemonColumn[] = [ @@ -1420,6 +1429,12 @@ let columnsFR: PokemonColumn[] = [ {name: 'Level', uid: 'level', width: '4fr'} ]; +let columnsFRHideHeaders: PokemonColumn[] = [ + {name: 'Name', uid: 'name', hideHeader: true}, + {name: 'Type', uid: 'type', width: 300, hideHeader: true}, + {name: 'Level', uid: 'level', width: '4fr'} +]; + let columnsSomeFR: PokemonColumn[] = [ {name: 'Name', uid: 'name', width: '1fr'}, {name: 'Type', uid: 'type', width: '1fr'}, From dff4b4d69903b0aedf41604932cdf22c754ed855 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 14 Dec 2022 07:02:10 +1100 Subject: [PATCH 40/42] move types --- packages/@react-stately/layout/package.json | 1 + .../@react-stately/layout/src/TableLayout.ts | 7 ++--- .../table/src/TableColumnLayout.ts | 4 +-- packages/@react-types/table/src/index.d.ts | 26 ------------------- 4 files changed, 7 insertions(+), 31 deletions(-) diff --git a/packages/@react-stately/layout/package.json b/packages/@react-stately/layout/package.json index 5f84dfe091f..89d58e10285 100644 --- a/packages/@react-stately/layout/package.json +++ b/packages/@react-stately/layout/package.json @@ -17,6 +17,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-stately/table": "^3.6.0", "@react-stately/virtualizer": "^3.4.0", "@react-types/grid": "^3.1.5", "@react-types/shared": "^3.16.0", diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index fcbf953c2f1..4b56b69b128 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -10,14 +10,15 @@ * governing permissions and limitations under the License. */ -import {ColumnSize, ITableColumnLayout, TableCollection} from '@react-types/table'; +import {ColumnSize, TableCollection} from '@react-types/table'; import {GridNode} from '@react-types/grid'; import {Key} from 'react'; import {LayoutInfo, Point, Rect, Size} from '@react-stately/virtualizer'; import {LayoutNode, ListLayout, ListLayoutOptions} from './ListLayout'; +import {TableColumnLayout} from '@react-stately/table'; type TableLayoutOptions = ListLayoutOptions & { - columnLayout: ITableColumnLayout, + columnLayout: TableColumnLayout, initialCollection: TableCollection } @@ -31,7 +32,7 @@ export class TableLayout extends ListLayout { lastPersistedKeys: Set = null; persistedIndices: Map = new Map(); private disableSticky: boolean; - columnLayout: ITableColumnLayout; + columnLayout: TableColumnLayout; controlledColumns: Map>; uncontrolledColumns: Map>; uncontrolledWidths: Map; diff --git a/packages/@react-stately/table/src/TableColumnLayout.ts b/packages/@react-stately/table/src/TableColumnLayout.ts index 042dc364e41..0d020493a45 100644 --- a/packages/@react-stately/table/src/TableColumnLayout.ts +++ b/packages/@react-stately/table/src/TableColumnLayout.ts @@ -17,11 +17,11 @@ import { isStatic, parseFractionalUnit } from './TableUtils'; -import {ColumnSize, ITableColumnLayout, TableCollection, TableColumnLayoutOptions} from '@react-types/table'; +import {ColumnSize, TableCollection, TableColumnLayoutOptions} from '@react-types/table'; import {GridNode} from '@react-types/grid'; import {Key} from 'react'; -export class TableColumnLayout implements ITableColumnLayout { +export class TableColumnLayout { getDefaultWidth: (column: GridNode) => ColumnSize | null | undefined; getDefaultMinWidth: (column: GridNode) => ColumnSize | null | undefined; columnWidths: Map = new Map(); diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index d9fc8343216..b13442057cd 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -154,32 +154,6 @@ export interface TableCollection extends GridCollection { body: GridNode } -export interface ITableColumnLayout { - getDefaultWidth: (column: GridNode) => ColumnSize | null | undefined, - getDefaultMinWidth: (column: GridNode) => ColumnSize | null | undefined, - columnWidths: Map, - columnMinWidths: Map, - columnMaxWidths: Map, - - /** Takes an array of columns and splits it into 2 maps of columns with controlled and columns with uncontrolled widths. */ - splitColumnsIntoControlledAndUncontrolled(columns: Array>): [Map>, Map>], - - /** Takes uncontrolled and controlled widths and joins them into a single Map. */ - recombineColumns(columns: Array>, uncontrolledWidths: Map, uncontrolledColumns: Map>, controlledColumns: Map>): Map, - - /** Used to make an initial Map of the uncontrolled widths based on default widths. */ - getInitialUncontrolledWidths(uncontrolledColumns: Map>): Map, - - getColumnWidth(key: Key): number, - - getColumnMinWidth(key: Key): number, - - getColumnMaxWidth(key: Key): number, - - resizeColumnWidth(tableWidth: number, collection: TableCollection, controlledWidths: Map, uncontrolledWidths: Map, col: Key, width: number): Map, - - buildColumnWidths(tableWidth: number, collection: TableCollection, widths: Map): Map -} export interface TableColumnLayoutOptions { getDefaultWidth?: (column: GridNode) => ColumnSize | null | undefined, getDefaultMinWidth?: (column: GridNode) => ColumnSize | null | undefined From 9b94c176a54b33d295ace66a6e43837f48801210 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 14 Dec 2022 07:33:13 +1100 Subject: [PATCH 41/42] Fix onResizeEnd call --- .../table/src/useTableColumnResize.ts | 3 +++ .../table/test/tableResizingTests.tsx | 18 ++++++++++++++++++ .../table/test/TableSizing.test.tsx | 6 +++--- packages/@react-stately/table/package.json | 1 - 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index dd1bdeaa148..87743ec4f00 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -88,6 +88,9 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st }, [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); diff --git a/packages/@react-aria/table/test/tableResizingTests.tsx b/packages/@react-aria/table/test/tableResizingTests.tsx index 5ffab950fe5..6e0c3aa6803 100644 --- a/packages/@react-aria/table/test/tableResizingTests.tsx +++ b/packages/@react-aria/table/test/tableResizingTests.tsx @@ -508,6 +508,24 @@ export let resizingTests = (render, rerender, Table, ControlledTable, resizeCol, resizeCol(tree, 'Type', -100); expect(getColumnWidths(tree)).toStrictEqual([113, 50, 123, 123, 491]); }); + + it('onResize end called with values even if no resizing took place', function () { + let columns = [ + {name: 'Name', uid: 'name', width: '1fr', minWidth: 100}, + {name: 'Type', uid: 'type', width: '1fr', minWidth: 100}, + {name: 'Height', uid: 'height', minWidth: 100}, + {name: 'Weight', uid: 'weight', minWidth: 100}, + {name: 'Level', uid: 'level', width: '4fr', minWidth: 100} + ]; + let columnNames = ['Name', 'Type', 'Height', 'Weight', 'Level']; + let onResizeEnd = jest.fn(); + + let tree = render(); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + resizeCol(tree, 'Type', 0); + expect(onResizeEnd).toHaveBeenCalledWith(mapFromWidths(columnNames, [113, 112, '1fr', '1fr', '4fr'])); + expect(getColumnWidths(tree)).toStrictEqual([113, 112, 113, 112, 450]); + }); }); describe('resizing table', () => { diff --git a/packages/@react-spectrum/table/test/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx index 080021a0c36..3d57aa455a9 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.tsx +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -1259,7 +1259,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Enter'}); fireEvent.keyUp(document.activeElement, {key: 'Enter'}); expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith(null); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); expect(document.activeElement).toBe(resizableHeader); @@ -1310,7 +1310,7 @@ describe('TableViewSizing', function () { expect(onResizeEnd).toHaveBeenCalledTimes(1); // TODO: should call with null or the currently calculated widths? // might be hard to call with current values - expect(onResizeEnd).toHaveBeenCalledWith(null); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); expect(document.activeElement).toBe(resizableHeader); @@ -1360,7 +1360,7 @@ describe('TableViewSizing', function () { userEvent.tab({shift: true}); expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith(null); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); expect(document.activeElement).toBe(resizableHeader); diff --git a/packages/@react-stately/table/package.json b/packages/@react-stately/table/package.json index c6365c94fa3..b141fd5e1af 100644 --- a/packages/@react-stately/table/package.json +++ b/packages/@react-stately/table/package.json @@ -19,7 +19,6 @@ "dependencies": { "@react-stately/collections": "^3.5.0", "@react-stately/grid": "^3.4.1", - "@react-stately/layout": "^3.9.0", "@react-stately/selection": "^3.11.1", "@react-types/grid": "^3.1.5", "@react-types/shared": "^3.16.0", From e905d3d47dd30a89c6fb887ef31846c1aa61740c Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 14 Dec 2022 08:08:25 +1100 Subject: [PATCH 42/42] move types --- .../table/src/useTableColumnResize.ts | 23 ++++++++++++++++- .../table/src/TableColumnLayout.ts | 7 +++++- packages/@react-types/table/src/index.d.ts | 25 ------------------- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 87743ec4f00..dca31621bb3 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -11,6 +11,7 @@ */ 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'; @@ -18,7 +19,6 @@ import {getColumnHeaderId} from './utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {TableLayoutState} from '@react-types/table'; import {TableState} from '@react-stately/table'; import {useKeyboard, useMove, usePress} from '@react-aria/interactions'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -55,6 +55,27 @@ export interface AriaTableColumnResizeProps { onResizeEnd: (key: Key) => void } + +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, + /** Called to update the state that resizing has ended. */ + onColumnResizeEnd: (key: Key) => void +} + export function useTableColumnResize(props: AriaTableColumnResizeProps, state: TableState, layoutState: TableLayoutState, ref: RefObject): TableColumnResizeAria { let {column: item, triggerRef, isDisabled, onResizeStart, onResize, onResizeEnd} = props; const stringFormatter = useLocalizedStringFormatter(intlMessages); diff --git a/packages/@react-stately/table/src/TableColumnLayout.ts b/packages/@react-stately/table/src/TableColumnLayout.ts index 0d020493a45..f7d03d1574d 100644 --- a/packages/@react-stately/table/src/TableColumnLayout.ts +++ b/packages/@react-stately/table/src/TableColumnLayout.ts @@ -17,10 +17,15 @@ import { isStatic, parseFractionalUnit } from './TableUtils'; -import {ColumnSize, TableCollection, TableColumnLayoutOptions} from '@react-types/table'; +import {ColumnSize, TableCollection} from '@react-types/table'; import {GridNode} from '@react-types/grid'; import {Key} from 'react'; +export interface TableColumnLayoutOptions { + getDefaultWidth?: (column: GridNode) => ColumnSize | null | undefined, + getDefaultMinWidth?: (column: GridNode) => ColumnSize | null | undefined +} + export class TableColumnLayout { getDefaultWidth: (column: GridNode) => ColumnSize | null | undefined; getDefaultMinWidth: (column: GridNode) => ColumnSize | null | undefined; diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index b13442057cd..b4c722d18eb 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -153,28 +153,3 @@ export interface TableCollection extends GridCollection { /** The node that makes up the body of the table. */ body: GridNode } - -export interface TableColumnLayoutOptions { - getDefaultWidth?: (column: GridNode) => ColumnSize | null | undefined, - getDefaultMinWidth?: (column: GridNode) => ColumnSize | null | undefined -} - -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, - /** Called to update the state that resizing has ended. */ - onColumnResizeEnd: (key: Key) => void -}