diff --git a/packages/@adobe/spectrum-css-temp/components/table/index.css b/packages/@adobe/spectrum-css-temp/components/table/index.css index 4384583a26e..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); @@ -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; @@ -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/@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 efbae0ed932..6e2b840c5fd 100644 --- a/packages/@react-aria/table/src/useTableColumnHeader.ts +++ b/packages/@react-aria/table/src/useTableColumnHeader.ts @@ -23,15 +23,11 @@ import {useGridCell} from '@react-aria/grid'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {usePress} from '@react-aria/interactions'; -export interface AriaTableColumnHeaderProps { +export interface AriaTableColumnHeaderProps { /** 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, - /** Whether the column has a menu in the header, this changes interactions with the header. - * @private - */ - hasMenu?: boolean + isVirtualized?: boolean } export interface TableColumnHeaderAria { @@ -45,11 +41,11 @@ export interface TableColumnHeaderAria { * @param state - State of the table, as returned by `useTableState`. * @param ref - The ref attached to the column header element. */ -export function useTableColumnHeader(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, focusMode: node.props.isSelectionCell || props.hasMenu || node.props.allowsSorting ? 'child' : 'cell'}, state, ref); + // if there are no focusable children, the column header will focus the cell + let {gridCellProps} = useGridCell({...props, focusMode: 'child'}, state, ref); let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single'; @@ -64,10 +60,6 @@ export function useTableColumnHeader(props: AriaTableColumnHeaderProps, state // Needed to pick up the focusable context, enabling things like Tooltips for example let {focusableProps} = useFocusable({}, ref); - if (props.hasMenu) { - pressProps = {}; - } - let ariaSort: DOMAttributes['aria-sort'] = null; let isSortedColumn = state.sortDescriptor?.column === node.key; let sortDirection = state.sortDescriptor?.direction; diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index bba0c8bf28e..dca31621bb3 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -10,7 +10,8 @@ * governing permissions and limitations under the License. */ -import {ChangeEvent, RefObject, useCallback, useRef} from 'react'; +import {ChangeEvent, Key, RefObject, useCallback, useRef} from 'react'; +import {ColumnSize} from '@react-types/table'; import {DOMAttributes, MoveEndEvent, MoveMoveEvent} from '@react-types/shared'; import {focusSafely} from '@react-aria/focus'; import {focusWithoutScrolling, mergeProps, useId} from '@react-aria/utils'; @@ -18,7 +19,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'; @@ -28,25 +29,64 @@ 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 function useTableColumnResize(props: AriaTableColumnResizeProps, state: TableState, columnState: TableColumnResizeState, ref: RefObject): TableColumnResizeAria { - let {column: item, triggerRef, isDisabled} = props; - const stateRef = useRef>(null); - stateRef.current = columnState; + +export interface TableLayoutState { + /** Get the current width of the specified column. */ + getColumnWidth: (key: Key) => number, + /** Get the current min width of the specified column. */ + getColumnMinWidth: (key: Key) => number, + /** Get the current max width of the specified column. */ + getColumnMaxWidth: (key: Key) => number, + /** Get the currently resizing column. */ + resizingColumn: Key, + /** Called to update the state that resizing has started. */ + onColumnResizeStart: (key: Key) => void, + /** + * Called to update the state that a resize event has occurred. + * Returns the new widths for all columns based on the resized column. + **/ + onColumnResize: (column: Key, width: number) => Map, + /** 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); let id = useId(); + let isResizing = useRef(false); + let lastSize = useRef(null); let {direction} = useLocale(); let {keyboardProps} = useKeyboard({ onKeyDown: (e) => { - if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { + if (triggerRef?.current && (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab')) { e.preventDefault(); // switch focus back to the column header on anything that ends edit mode focusSafely(triggerRef.current); @@ -54,11 +94,37 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } }); + let startResize = useCallback((item) => { + if (!isResizing.current) { + layoutState.onColumnResizeStart(item.key); + onResizeStart?.(item.key); + } + isResizing.current = true; + }, [isResizing, onResizeStart, layoutState]); + + let resize = useCallback((item, newWidth) => { + let sizes = layoutState.onColumnResize(item.key, newWidth); + onResize?.(sizes); + lastSize.current = sizes; + }, [onResize, layoutState]); + + let endResize = useCallback((item) => { + if (lastSize.current == null) { + lastSize.current = layoutState.onColumnResize(item.key, layoutState.getColumnWidth(item.key)); + } + if (isResizing.current) { + layoutState.onColumnResizeEnd(item.key); + onResizeEnd?.(lastSize.current); + } + isResizing.current = false; + lastSize.current = null; + }, [isResizing, onResizeEnd, layoutState]); + const columnResizeWidthRef = useRef(0); const {moveProps} = useMove({ onMoveStart() { - columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key); - stateRef.current.onColumnResizeStart(item); + columnResizeWidthRef.current = layoutState.getColumnWidth(item.key); + startResize(item); }, onMove(e) { let {deltaX, deltaY, pointerType} = e; @@ -71,29 +137,29 @@ 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; - stateRef.current.onColumnResize(item, columnResizeWidthRef.current); - props.onMove(e); + resize(item, columnResizeWidthRef.current); } }, onMoveEnd(e) { let {pointerType} = e; columnResizeWidthRef.current = 0; - props.onMoveEnd(e); + props.onMoveEnd?.(e); if (pointerType === 'mouse') { - stateRef.current.onColumnResizeEnd(item); + endResize(item); } } }); - let min = Math.floor(stateRef.current.getColumnMinWidth(item.key)); - let max = Math.floor(stateRef.current.getColumnMaxWidth(item.key)); + + let min = Math.floor(layoutState.getColumnMinWidth(item.key)); + let max = Math.floor(layoutState.getColumnMaxWidth(item.key)); if (max === Infinity) { max = Number.MAX_SAFE_INTEGER; } - let value = Math.floor(stateRef.current.getColumnWidth(item.key)); - + let value = Math.floor(layoutState.getColumnWidth(item.key)); let ariaProps = { 'aria-label': props.label, 'aria-orientation': 'horizontal' as 'horizontal', @@ -111,7 +177,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) { @@ -119,7 +185,9 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } else { nextValue = currentWidth - 10; } - stateRef.current.onColumnResize(item, nextValue); + props.onMove({pointerType: 'virtual'} as MoveMoveEvent); + props.onMoveEnd({pointerType: 'virtual'} as MoveEndEvent); + resize(item, nextValue); }; let {pressProps} = usePress({ @@ -127,9 +195,11 @@ 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) { - stateRef.current.onColumnResizeEnd(item); - focusSafely(triggerRef.current); + if (e.pointerType === 'virtual' && layoutState.resizingColumn != null) { + endResize(item); + if (triggerRef?.current) { + focusSafely(triggerRef.current); + } return; } focusInput(); @@ -138,7 +208,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); + } } } }); @@ -155,11 +227,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); + startResize(item); state.setKeyboardNavigationDisabled(true); }, onBlur: () => { - stateRef.current.onColumnResizeEnd(item); + 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 new file mode 100644 index 00000000000..fe3e2be145b --- /dev/null +++ b/packages/@react-aria/table/stories/example-resizing.tsx @@ -0,0 +1,312 @@ +/* + * 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 ariaStyles from './resizing.css'; +import { + AriaTableColumnResizeProps, + useTable, + useTableCell, + useTableColumnHeader, + useTableColumnResize, + useTableHeaderRow, + useTableRow, + useTableRowGroup, + 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'; + +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(() => undefined, []); + let getDefaultMinWidth = useCallback(() => 75, []); + 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, onResizeEnd}) { + let ref = useRef(null); + let {resizerProps, inputProps} = useTableColumnResize({ + column, + label: 'Resizer', + onResize, + onResizeEnd + } as AriaTableColumnResizeProps, state, layoutState, ref); + + return ( + <> + +
+ + + +
+
+ + ); +} +export function TableColumnHeader({column, state, widths, layoutState, onResize, onResizeEnd}) { + 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..b46beb2f8a2 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -13,9 +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 from 'react'; -import {SpectrumTableProps} from '@react-types/table'; +import React, {Key, useCallback, useMemo, useState} from 'react'; +import {Table as ResizingTable} from './example-resizing'; import {Table} from './example'; const meta: Meta> = { @@ -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,141 @@ 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?: ColumnSize | null, + width?: ColumnSize | null +} +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?: 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]))); + + 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]); + 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 ( +
+ + +
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: '5fr'} +]; + +export const TableWithResizingFRsControlled = { + args: {columns: columnsFR}, + 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[] = [ + {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'} +]; + +export const TableWithSomeResizingFRsControlled = { + args: {columns: columnsSomeFR}, + 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-aria/table/test/ariaTableResizing.test.tsx b/packages/@react-aria/table/test/ariaTableResizing.test.tsx new file mode 100644 index 00000000000..934006e9ef1 --- /dev/null +++ b/packages/@react-aria/table/test/ariaTableResizing.test.tsx @@ -0,0 +1,81 @@ +/* + * 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} 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); + +// 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) { + act(() => {setInteractionModality('pointer');}); + 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}); +} + +function resizeTable(clientWidth, newValue) { + clientWidth.mockImplementation(() => newValue); + fireEvent(window, new Event('resize')); + act(() => {jest.runAllTimers();}); +} + +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; + return ( + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + + + ); +} diff --git a/packages/@react-aria/table/test/tableResizingTests.tsx b/packages/@react-aria/table/test/tableResizingTests.tsx new file mode 100644 index 00000000000..6e0c3aa6803 --- /dev/null +++ b/packages/@react-aria/table/test/tableResizingTests.tsx @@ -0,0 +1,593 @@ +/* + * 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, index) => { + expect(resizer).toHaveAttribute('value', `${expected[index]}`); + 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]); + }); + + 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', () => { + 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/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index f3c08e6794c..b5ee1e7cb86 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -5,30 +5,34 @@ 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} from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; -import {TableColumnResizeState} from '@react-stately/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useTableColumnResize} from '@react-aria/table'; -import {useTableContext} from './TableView'; +import {useTableContext, useVirtualizerContext} from './TableView'; import {VisuallyHidden} from '@react-aria/visually-hidden'; interface ResizerProps { column: GridNode, showResizer: boolean, triggerRef: RefObject, + onResizeStart: (key: Key) => void, + onResize: (widths: Map) => void, + onResizeEnd: (key: Key) => void, onMoveResizer: (e: MoveMoveEvent) => void } function Resizer(props: ResizerProps, ref: RefObject) { let {column, showResizer} = props; - let {state, columnState, isEmpty} = useTableContext(); + 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 any value, they are available on the layout object + useVirtualizerContext(); let stringFormatter = useLocalizedStringFormatter(intlMessages); let {direction} = useLocale(); - const stateRef = useRef>(null); - stateRef.current = columnState; - let {inputProps, resizerProps} = useTableColumnResize({ + let {inputProps, resizerProps} = useTableColumnResize({ ...props, label: stringFormatter.format('columnResizer'), isDisabled: isEmpty, @@ -36,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')); @@ -50,7 +54,7 @@ function Resizer(props: ResizerProps, ref: RefObject) { document.body.classList.remove(classNames(styles, 'resize-e')); document.body.classList.remove(classNames(styles, 'resize-w')); } - }, state, columnState, ref); + }, state, layout, ref); let style = { cursor: undefined, @@ -58,8 +62,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 75033e3615e..e0370f88584 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'; @@ -32,13 +33,12 @@ 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} 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'; import stylesOverrides from './table.css'; -import {TableColumnResizeState, TableState, useTableColumnResizeState, 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'; @@ -89,50 +89,70 @@ const SELECTION_CELL_DEFAULT_WIDTH = { interface TableContextValue { state: TableState, layout: TableLayout, - columnState: TableColumnResizeState, headerRowHovered: boolean, isInResizeMode: boolean, setIsInResizeMode: (val: boolean) => void, isEmpty: boolean, onFocusedResizer: () => void, - onMoveResizer: (e: MoveMoveEvent) => void + onResizeStart: (key: Key) => void, + onResize: (widths: Map) => void, + onResizeEnd: (key: Key) => void, + onMoveResizer: (e: MoveMoveEvent) => void, + headerMenuOpen: boolean, + setHeaderMenuOpen: (val: boolean) => void } - const TableContext = React.createContext>(null); 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} = props; + let {isQuiet, onAction, onResizeEnd: propsOnResizeEnd} = props; let {styleProps} = useStyleProps(props); let [showSelectionCheckboxes, setShowSelectionCheckboxes] = useState(props.selectionStyle !== 'highlight'); let {direction} = useLocale(); let {scale} = useProvider(); - const getDefaultWidth = useCallback(({hideHeader, isSelectionCell, showDivider}) => { + 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; + } else if (isSelectionCell) { + return SELECTION_CELL_DEFAULT_WIDTH[scale]; + } + }, [scale]); + + 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; } else if (isSelectionCell) { return SELECTION_CELL_DEFAULT_WIDTH[scale]; } + return 75; }, [scale]); + // Starts when the user selects resize from the menu, ends when resizing ends + // used to control the visibility of the resizer Nubbin let [isInResizeMode, setIsInResizeMode] = useState(false); + // Starts when the resizer is actually moved + // entering resizing/exiting resizing doesn't trigger a render + // with table layout, so we need to track it here + let [, setIsResizing] = useState(false); let state = useTableState({ ...props, showSelectionCheckboxes, selectionBehavior: props.selectionStyle === 'highlight' ? 'replace' : 'toggle' }); - const columnState = useTableColumnResizeState({...mergeProps(props, {onColumnResizeEnd: () => { - 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'; if (shouldShowCheckboxes !== showSelectionCheckboxes) { @@ -145,6 +165,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' @@ -158,12 +185,14 @@ 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); @@ -339,9 +369,17 @@ function TableView(props: SpectrumTableProps, ref: DOMRef { + setIsResizing(true); + }, [setIsResizing]); + let onResizeEnd = useCallback((widths) => { + setIsInResizeMode(false); + setIsResizing(false); + propsOnResizeEnd?.(widths); + }, [propsOnResizeEnd, setIsInResizeMode, setIsResizing]); return ( - + (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 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.resizingColumn != null) { + // while resizing, prop changes should not cause animations + transitionDuration = 0; + } let state = useVirtualizerState({ layout, collection, @@ -397,7 +440,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({ @@ -418,11 +461,9 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo } }, state, domRef); - // If columnwidths change, need to relayout. - useLayoutEffect(() => { - state.virtualizer.relayoutNow({sizeChanged: true}); - }, [getColumnWidth, state.virtualizer]); - + // 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'}); @@ -439,8 +480,6 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo }, [bodyRef, headerRef]); let onVisibleRectChange = useCallback((rect: Rect) => { - setTableWidth(rect.width); - state.setVisibleRect(rect); if (!isLoading && onLoadMore) { @@ -460,66 +499,70 @@ function TableVirtualizer({layout, collection, lastResizeInteractionModality, fo } }, [state.contentSize, state.virtualizer, state.isAnimating, onLoadMore, isLoading]); - let keysBefore = []; - let key = columnState.currentlyResizingColumn; - 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 = 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 // 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; + // 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]} + {...mergeProps(otherProps, virtualizerProps)} + ref={domRef}> +
+ {state.visibleViews[0]} +
+ + {state.visibleViews[1]} +
+
- - {state.visibleViews[1]} - {columnState.currentlyResizingColumn != null &&
} - -
- + + ); } @@ -538,13 +581,13 @@ 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 }, state, ref); - let columnProps = column.props as SpectrumColumnProps; - let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty}); const allProps = [columnHeaderProps, hoverProps, pressProps]; @@ -608,24 +651,34 @@ 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, + layout, + onResizeStart, + onResize, + onResizeEnd, + headerRowHovered, + setIsInResizeMode, + isEmpty, + onFocusedResizer, + onMoveResizer, + isInResizeMode, + headerMenuOpen, + setHeaderMenuOpen + } = 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}); + let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty || headerMenuOpen}); const allProps = [columnHeaderProps, pressProps, hoverProps]; 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) => { @@ -637,7 +690,7 @@ function ResizableTableColumnHeader(props) { state.sort(column.key, 'descending'); break; case 'resize': - columnState.onColumnResizeStart(column); + layout.onColumnResizeStart(column.key); setIsInResizeMode(true); break; } @@ -663,26 +716,38 @@ function ResizableTableColumnHeader(props) { }, [allowsSorting]); let isMobile = useIsMobileDevice(); + let resizingColumn = layout.resizingColumn; + let prevResizingColumn = useRef(null); + let timeout = useRef(null); useEffect(() => { - if (columnState.currentlyResizingColumn === column.key) { + 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); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [columnState.currentlyResizingColumn, column.key, isMobile]); + prevResizingColumn.current = resizingColumn; + }, [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') || columnState.currentlyResizingColumn != null); + let showResizer = !isEmpty && ((headerRowHovered && getInteractionModality() !== 'keyboard') || resizingColumn != null); return ( @@ -713,7 +778,7 @@ function ResizableTableColumnHeader(props) { ) ) }> - + {columnProps.allowsSorting && @@ -723,7 +788,7 @@ function ResizableTableColumnHeader(props) {
{column.rendered}
} { - columnProps.allowsResizing && columnState.currentlyResizingColumn === null && + columnProps.allowsResizing && resizingColumn == null && }
@@ -738,6 +803,9 @@ function ResizableTableColumnHeader(props) { ref={resizingRef} column={column} showResizer={showResizer} + onResizeStart={onResizeStart} + onResize={onResize} + onResizeEnd={onResizeEnd} triggerRef={useUnwrapDOMRef(triggerRef)} onMoveResizer={onMoveResizer} />
@@ -892,10 +960,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 (
diff --git a/packages/@react-spectrum/table/stories/ControllingResize.tsx b/packages/@react-spectrum/table/stories/ControllingResize.tsx new file mode 100644 index 00000000000..eb10be055a2 --- /dev/null +++ b/packages/@react-spectrum/table/stories/ControllingResize.tsx @@ -0,0 +1,95 @@ +/* + * 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 {Button} from '@react-spectrum/button'; +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 extends Omit, 'children'> { + name: string, + uid: string, + width?: ColumnSize | null +} +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'}, + {name: 'Weight', uid: 'weight'}, + {name: 'Level', uid: 'level', width: '5fr'} +]; + +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"'}, + {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: {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 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); + }, [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 ( +
+ + +
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..16f241a98aa 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'; @@ -1305,7 +1306,7 @@ storiesOf('TableView', module) .add( 'allowsResizing, onColumnResize action', () => ( - + File Name Type @@ -1331,7 +1332,7 @@ storiesOf('TableView', module) .add( 'allowsResizing, onColumnResizeEnd action', () => ( - + File name for reference Type @@ -1368,8 +1369,80 @@ 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', + () => ( + + ), + {description: {data: ` + You can use the buttons to save and restore the column widths. When restoring, + you will notice that the entire table reverts, this is because no columns are controlled. + `}} + ) + .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. 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( + '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. + `}} + ) + .add( + 'allowsResizing, controlled, hideHeader', + () => ( + + ), + {description: {data: ` + Hide headers columns should not be resizable. + `}} ); +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'}, + {name: 'Type', uid: 'type', width: '1fr'}, + {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'}, + {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/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index 8a15c61a6ab..22321f6fd24 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. @@ -813,42 +821,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( @@ -1644,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; @@ -3918,10 +3934,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.tsx similarity index 71% rename from packages/@react-spectrum/table/test/TableSizing.test.js rename to packages/@react-spectrum/table/test/TableSizing.test.tsx index e58296d9a17..3d57aa455a9 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.js +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -16,10 +16,14 @@ 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 from 'react'; +import React, {Key} from 'react'; +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'; import userEvent from '@testing-library/user-event'; @@ -55,6 +59,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: 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: Scale = 'medium') => { + let newTree = tree.rerender( + + {children} + + ); + act(() => {jest.runAllTimers();}); + return newTree; +}; describe('TableViewSizing', function () { let offsetWidth, offsetHeight; @@ -73,21 +97,9 @@ describe('TableViewSizing', function () { act(() => {jest.runAllTimers();}); }); - let render = (children, scale = 'medium') => renderComponent( - - {children} - - ); - - let rerender = (tree, children, scale = 'medium') => tree.rerender( - - {children} - - ); - describe('layout', function () { describe('row heights', function () { - let renderTable = (props, scale) => render( + let renderTable = (props = {}, scale: Scale = 'medium') => render( {column => {column.name}} @@ -114,7 +126,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'); } @@ -132,7 +144,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'); } @@ -150,7 +162,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'); } @@ -168,7 +180,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'); } @@ -186,7 +198,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'); } @@ -204,7 +216,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'); } @@ -226,12 +238,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'); } @@ -270,17 +282,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'); } @@ -291,7 +303,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 ( @@ -329,15 +341,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'); } } } @@ -364,10 +376,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('320px'); - expect(row.childNodes[2].style.width).toBe('321px'); - expect(row.childNodes[3].style.width).toBe('321px'); + 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'); } }); @@ -390,10 +402,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('317px'); - expect(row.childNodes[3].style.width).toBe('318px'); + 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'); } }); @@ -418,9 +430,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'); } }); @@ -445,10 +457,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'); } }); @@ -473,9 +485,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'); } }); @@ -500,10 +512,38 @@ 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'); + } + }); + + 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'); } }); @@ -528,9 +568,36 @@ 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'); + } + }); + + 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'); } }); @@ -556,14 +623,14 @@ 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'); } }); }); - 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( @@ -585,9 +652,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'); } }); }); @@ -610,21 +677,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('384px'); - expect(rows[1].childNodes[2].style.width).toBe('193px'); - expect(rows[1].childNodes[3].style.width).toBe('193px'); + 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('192px'); - 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[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'); } }); }); @@ -636,9 +703,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 @@ -661,9 +728,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]; @@ -679,50 +746,28 @@ 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].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(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 595 - }, { - key: 'bar', - width: 200 - }, { - key: 'baz', - width: 200 - } - ]); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + 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}); 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].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(onColumnResizeEnd).toHaveBeenCalledTimes(2); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 620 - }, { - key: 'bar', - width: 190 - }, { - key: 'baz', - width: 190 - } - ]); + expect(onResizeEnd).toHaveBeenCalledTimes(2); + 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}); @@ -732,9 +777,9 @@ describe('TableViewSizing', function () { }); it('dragging the resizer works - mobile', () => { - let onColumnResizeEnd = jest.fn(); + let onResizeEnd = jest.fn(); let tree = render( - + Foo Bar @@ -757,9 +802,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]; @@ -775,50 +820,28 @@ 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].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(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 595 - }, { - key: 'bar', - width: 200 - }, { - key: 'baz', - width: 200 - } - ]); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + 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}); 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].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(onColumnResizeEnd).toHaveBeenCalledTimes(2); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 620 - }, { - key: 'bar', - width: 190 - }, { - key: 'baz', - width: 190 - } - ]); + expect(onResizeEnd).toHaveBeenCalledTimes(2); + 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}); @@ -834,9 +857,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 @@ -860,9 +883,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]; @@ -884,11 +907,11 @@ 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].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 @@ -897,38 +920,28 @@ describe('TableViewSizing', function () { fireEvent.pointerUp(resizer, {pointerType: 'touch', pointerId: 1}); + expect(resizer).toHaveAttribute('value', '620'); 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 act(() => resizer.blur()); act(() => {jest.runAllTimers();}); - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 620 - }, { - key: 'bar', - width: 190 - }, { - key: 'baz', - width: 190 - } - ]); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 620], ['bar', '1fr'], ['baz', '1fr']])); 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 @@ -953,9 +966,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]; @@ -981,10 +994,11 @@ describe('TableViewSizing', function () { fireEvent.pointerUp(resizer, {pointerType: 'touch', pointerId: 1}); + expect(resizer).toHaveAttribute('value', '595'); 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 @@ -993,29 +1007,19 @@ describe('TableViewSizing', function () { fireEvent.pointerUp(resizer, {pointerType: 'touch', pointerId: 1}); + expect(resizer).toHaveAttribute('value', '620'); 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 act(() => resizer.blur()); act(() => {jest.runAllTimers();}); - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([ - { - key: 'foo', - width: 620 - }, { - key: 'bar', - width: 190 - }, { - key: 'baz', - width: 190 - } - ]); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 620], ['bar', '1fr'], ['baz', '1fr']])); expect(tree.queryByRole('slider')).toBeNull(); }); @@ -1024,9 +1028,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 @@ -1054,9 +1058,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'}); @@ -1076,11 +1080,11 @@ 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].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'}); @@ -1089,10 +1093,11 @@ describe('TableViewSizing', function () { fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); + expect(resizer).toHaveAttribute('value', '600'); 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'}); @@ -1100,11 +1105,11 @@ 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].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'}); @@ -1112,37 +1117,26 @@ 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].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'}); 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(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); 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 @@ -1170,9 +1164,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'}); @@ -1192,11 +1186,11 @@ 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].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'}); @@ -1204,28 +1198,17 @@ 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].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'}); 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(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); expect(document.activeElement).toBe(resizableHeader); @@ -1233,9 +1216,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 @@ -1275,8 +1258,8 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Enter'}); fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([]); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); expect(document.activeElement).toBe(resizableHeader); @@ -1284,9 +1267,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 @@ -1311,7 +1294,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'}); @@ -1325,8 +1307,10 @@ describe('TableViewSizing', function () { expect(document.activeElement).toBe(resizer); userEvent.tab(); - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([]); + 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(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); expect(document.activeElement).toBe(resizableHeader); @@ -1334,9 +1318,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 @@ -1375,8 +1359,8 @@ describe('TableViewSizing', function () { expect(document.activeElement).toBe(resizer); userEvent.tab({shift: true}); - expect(onColumnResizeEnd).toHaveBeenCalledTimes(1); - expect(onColumnResizeEnd).toHaveBeenCalledWith([]); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); expect(document.activeElement).toBe(resizableHeader); @@ -1389,7 +1373,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'); @@ -1409,7 +1393,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); @@ -1427,13 +1411,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'); @@ -1442,7 +1426,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); @@ -1466,39 +1450,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); @@ -1506,14 +1490,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 @@ -1556,16 +1540,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'); @@ -1586,7 +1570,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 () { @@ -1598,7 +1582,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 () { @@ -1615,6 +1599,47 @@ describe('TableViewSizing', function () { let tooltip = getByRole('tooltip'); expect(tooltip).toBeVisible(); }); - }); }); + + +// 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 + act(() => {setInteractionModality('pointer');}); + 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}); + act(() => {jest.runAllTimers();}); + 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();}); +} + +describe('RSP TableView', () => { + resizingTests(render, rerender, ControllingResize, ControllingResize, resizeCol, resizeTable); +}); 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 39bedd4a380..4b56b69b128 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -10,50 +10,138 @@ * governing permissions and limitations under the License. */ +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'; +import {TableColumnLayout} from '@react-stately/table'; +type TableLayoutOptions = ListLayoutOptions & { + columnLayout: TableColumnLayout, + initialCollection: TableCollection +} export class TableLayout extends ListLayout { collection: TableCollection; lastCollection: TableCollection; - getColumnWidth: (key: Key) => number; + columnWidths: Map = new Map(); stickyColumnIndices: number[]; wasLoading = false; isLoading = false; lastPersistedKeys: Set = null; persistedIndices: Map = new Map(); private disableSticky: boolean; - - constructor(options: ListLayoutOptions) { + columnLayout: TableColumnLayout; + controlledColumns: Map>; + uncontrolledColumns: Map>; + uncontrolledWidths: Map; + lastVirtualizerWidth: number; + resizingColumn: Key | null; + + constructor(options: TableLayoutOptions) { super(options); + this.collection = options.initialCollection; this.stickyColumnIndices = []; this.disableSticky = this.checkChrome105(); + this.columnLayout = options.columnLayout; + let [controlledColumns, uncontrolledColumns] = this.columnLayout.splitColumnsIntoControlledAndUncontrolled(this.collection.columns); + this.controlledColumns = controlledColumns; + this.uncontrolledColumns = uncontrolledColumns; + this.lastVirtualizerWidth = 0; + this.uncontrolledWidths = this.columnLayout.getInitialUncontrolledWidths(uncontrolledColumns); + } + + getResizerPosition(): Key { + return this.getLayoutInfo(this.resizingColumn)?.rect.maxX; + } + + getColumnWidth(key: Key): number { + return this.columnLayout.getColumnWidth(key) ?? 0; + } + + getColumnMinWidth(key: Key): number { + let column = this.collection.columns.find(col => col.key === key); + if (!column) { + return 0; + } + return this.columnLayout.getColumnMinWidth(key); + } + + getColumnMaxWidth(key: Key): number { + let column = this.collection.columns.find(col => col.key === key); + if (!column) { + return 0; + } + return this.columnLayout.getColumnMaxWidth(key); } + // outside, where this is called, should call props.onColumnResizeStart... + onColumnResizeStart(key: Key): void { + this.resizingColumn = key; + } + + // 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.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.uncontrolledColumns).map(([key]) => [key, newSizes.get(key)])); + map.set(key, width); + 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 + if (this.uncontrolledWidths.size > 0) { + this.virtualizer.relayoutNow({sizeChanged: true}); + } + return newSizes; + } + + onColumnResizeEnd(): void { + this.resizingColumn = null; + } buildCollection(): LayoutNode[] { + let [controlledColumns, uncontrolledColumns] = this.columnLayout.splitColumnsIntoControlledAndUncontrolled(this.collection.columns); + this.controlledColumns = controlledColumns; + this.uncontrolledColumns = uncontrolledColumns; + let colWidths = this.columnLayout.recombineColumns(this.collection.columns, this.uncontrolledWidths, uncontrolledColumns, controlledColumns); + // 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 + ) || + 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; this.wasLoading = this.isLoading; this.isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; + 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); + } + } + + this.columnWidths = this.columnLayout.buildColumnWidths(this.virtualizer.visibleRect.width, this.collection, colWidths); 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); @@ -131,14 +219,14 @@ 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]; if (column?.key != null) { - width += this.getColumnWidth(column.key); + width += this.columnWidths.get(column.key); } } @@ -169,7 +257,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); @@ -270,7 +358,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/table/src/TableColumnLayout.ts b/packages/@react-stately/table/src/TableColumnLayout.ts new file mode 100644 index 00000000000..f7d03d1574d --- /dev/null +++ b/packages/@react-stately/table/src/TableColumnLayout.ts @@ -0,0 +1,189 @@ +/* + * 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, 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; + 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/table/src/TableUtils.ts b/packages/@react-stately/table/src/TableUtils.ts new file mode 100644 index 00000000000..859d89126db --- /dev/null +++ b/packages/@react-stately/table/src/TableUtils.ts @@ -0,0 +1,174 @@ +/* + * 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. +export function isStatic(width: number | string): boolean { + return width != null && (!isNaN(width as number) || (String(width)).match(/^(\d+)(?=%$)/) !== null); +} + +export function parseFractionalUnit(width: string): number { + if (!width) { + return 1; + } + 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 parseFloat(match[0]); +} + +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 * (parseFloat(match[0]) / 100); + } + return width; +} + + +export function getMaxWidth(maxWidth: number | string, tableWidth: number): number { + return maxWidth != null + ? parseStaticWidth(maxWidth, tableWidth) + : Number.MAX_SAFE_INTEGER; +} + +// 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) + : 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.width || column.column.defaultWidth) as string) : sum, + 0 + ); + + let columns = dynamicColumns.map((column) => { + if (!column) { + return null; + } + const targetWidth = + (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) + ); + + 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 + col.width : sum, + 0 + ); + + dynamicColumns.forEach((column) => { + if (!column) { + return null; + } + const targetWidth = + (column.width * availableSpace) / fractions; + let width = Math.max( + getMinWidth(column.column.minWidth, tableWidth), + Math.min(Math.round(targetWidth), getMaxWidth(column.column.maxWidth, tableWidth)) + ); + availableSpace -= width; + fractions -= column.width; + column.width = width; + }); +} + +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 minWidth = column.minWidth ?? getDefaultMinWidth?.(index); + column.minWidth = minWidth; + + if (isStatic(width)) { + let w = parseStaticWidth(width, availableWidth); + w = Math.max( + 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: w} 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/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 aadaf2ee07f..877ac1eb378 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -1,226 +1,130 @@ - -import {ColumnProps} from '@react-types/table'; -import {getContentWidth, getDynamicColumnWidths, getMaxWidth, getMinWidth, isStatic, parseStaticWidth} from './utils'; +/* + * 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'; -import {Key, MutableRefObject, useCallback, useRef, useState} from 'react'; - -interface AffectedColumnWidth { - /** The column key. */ - key: Key, - /** The column width. */ - width: number +import {Key, useCallback, useMemo, useState} from 'react'; +import {TableColumnLayout} from './TableColumnLayout'; +import {TableState} from './useTableState'; + +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 | null | undefined, + /** A function that is called to find the default minWidth for a given column. */ + 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. */ + onColumnResizeStart?: (key: Key) => void, + /** Callback that is invoked when the resize event is ended. */ + onColumnResizeEnd?: (key: Key) => void } -interface AffectedColumnWidths extends Array {} - -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, +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, - /** Getter for column width. */ + onColumnResizeEnd: (key: Key) => void, + /** Gets the current width for the specified column. */ getColumnWidth: (key: Key) => number, - /** Getter for column min width. */ + /** Gets the current minWidth for the specified column. */ getColumnMinWidth: (key: Key) => number, - /** Getter for column max widths. */ + /** Gets the current maxWidth for the specified column. */ getColumnMaxWidth: (key: Key) => number, - /** Key of column currently being resized. */ - currentlyResizingColumn: Key | null -} - -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, - /** Callback that is invoked when the resize event is ended. */ - onColumnResizeEnd?: (affectedColumnWidths: AffectedColumnWidths) => void, - /** The default table width. */ - tableWidth?: number -} - -interface ColumnState { - columns: GridNode[] + /** Currently calculated widths for all columns. */ + widths: Map } -export function useTableColumnResizeState(props: TableColumnResizeStateProps, state: ColumnState): TableColumnResizeState { - const {getDefaultWidth, tableWidth: defaultTableWidth = null} = props; - const {columns} = state; - const columnsRef = useRef[]>([]); - const tableWidth = useRef(defaultTableWidth); - 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); - } - } - } - - 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); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - function onColumnResizeEnd(column: GridNode) { - props.onColumnResizeEnd && isResizing.current && props.onColumnResizeEnd(affectedColumnWidthsRef.current); - setCurrentlyResizingColumn(null); - 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, +export function useTableColumnResizeState(props: TableColumnResizeStateProps, state: TableState): TableColumnResizeState { + let { + getDefaultWidth, + getDefaultMinWidth, + onColumnResizeStart: propsOnColumnResizeStart, + onColumnResizeEnd: propsOnColumnResizeEnd, + tableWidth = 0 + } = props; + + let [resizingColumn, setResizingColumn] = useState(null); + let columnLayout = useMemo( + () => new TableColumnLayout({ + getDefaultWidth, + getDefaultMinWidth + }), + [getDefaultWidth, getDefaultMinWidth] + ); + + let [controlledColumns, uncontrolledColumns] = useMemo(() => + columnLayout.splitColumnsIntoControlledAndUncontrolled(state.collection.columns) + , [state.collection.columns, columnLayout]); + + // uncontrolled column widths + let [uncontrolledWidths, setUncontrolledWidths] = useState(() => + columnLayout.getInitialUncontrolledWidths(uncontrolledColumns) + ); + // combine columns back into one map that maintains same order as the columns + let colWidths = useMemo(() => + columnLayout.recombineColumns(state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns) + , [state.collection.columns, uncontrolledWidths, uncontrolledColumns, controlledColumns, columnLayout]); + + let onColumnResizeStart = useCallback((key: Key) => { + setResizingColumn(key); + propsOnColumnResizeStart?.(key); + }, [propsOnColumnResizeStart, setResizingColumn]); + + 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); + + let map = new Map(Array.from(uncontrolledColumns).map(([key]) => [key, newSizes.get(key)])); + map.set(key, width); + setUncontrolledWidths(map); + + return newSizes; + }, [controlledColumns, uncontrolledColumns, setUncontrolledWidths, tableWidth, columnLayout, state.collection, uncontrolledWidths]); + + let onColumnResizeEnd = useCallback((key: Key) => { + setResizingColumn(null); + propsOnColumnResizeEnd?.(key); + }, [propsOnColumnResizeEnd, setResizingColumn]); + + let columnWidths = useMemo(() => + columnLayout.buildColumnWidths(tableWidth, state.collection, colWidths) + , [tableWidth, state.collection, colWidths, columnLayout]); + + return useMemo(() => ({ + resizingColumn, + onColumnResize, + onColumnResizeStart, + onColumnResizeEnd, + getColumnWidth: (key: Key) => + columnLayout.getColumnWidth(key), + getColumnMinWidth: (key: Key) => + columnLayout.getColumnMinWidth(key), + getColumnMaxWidth: (key: Key) => + columnLayout.getColumnMaxWidth(key), + widths: columnWidths + }), [ + columnLayout, + resizingColumn, onColumnResize, onColumnResizeStart, onColumnResizeEnd, - getColumnWidth, - getColumnMinWidth, - getColumnMaxWidth, - currentlyResizingColumn - }; + columnWidths + ]); } 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/TableUtils.test.js b/packages/@react-stately/table/test/TableUtils.test.js new file mode 100644 index 00000000000..7a18c47f381 --- /dev/null +++ b/packages/@react-stately/table/test/TableUtils.test.js @@ -0,0 +1,162 @@ +import {calculateColumnSizes} from '../src/TableUtils'; +import {TableColumnLayout} from '../src/TableColumnLayout'; + +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/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..b4c722d18eb 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -14,6 +14,13 @@ import {AriaLabelingProps, AsyncLoadable, CollectionChildren, DOMProps, LoadingS 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 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 ColumnDynamicSize = `${number}fr`; // match regex: /^(\d+)(?=fr$)/ +/** All possible sizes a column can be assigned. */ +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. */ children: [ReactElement>, ReactElement>], @@ -40,14 +47,15 @@ export interface SpectrumTableProps extends TableProps, SpectrumSelectionP onAction?: (key: Key) => void, /** * Handler that is called when a user performs a column resize. - * @private + * Can be used with the width property on columns to put the column widths into + * a controlled state. */ - onColumnResize?: (affectedColumns: {key: Key, width: number}[]) => void, + onResize?: (widths: Map) => void, /** - * Handler that is called when a column resize ends. - * @private + * Handler that is called after a user performs a column resize. + * Can be used to store the widths of columns for another future session. */ - onColumnResizeEnd?: (affectedColumns: {key: Key, width: number}[]) => void + onResizeEnd?: (widths: Map) => void } export interface TableHeaderProps { @@ -67,20 +75,14 @@ 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 | null, /** The minimum width of the column. */ - minWidth?: number | string, + minWidth?: ColumnStaticSize | null, /** 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?: ColumnStaticSize | null, + /** The default width of the column. */ + defaultWidth?: ColumnSize | null, + /** Whether the column allows resizing. */ allowsResizing?: boolean, /** Whether the column allows sorting. */ allowsSorting?: boolean, @@ -141,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. */ 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 +-->