diff --git a/packages/@adobe/spectrum-css-temp/components/table/index.css b/packages/@adobe/spectrum-css-temp/components/table/index.css index 9afc14fbad3..e51a7a8364f 100644 --- a/packages/@adobe/spectrum-css-temp/components/table/index.css +++ b/packages/@adobe/spectrum-css-temp/components/table/index.css @@ -86,6 +86,32 @@ svg.spectrum-Table-sortedIcon { } } +.spectrum-Table-columnResizer { + display: flex; + align-items: center; + justify-content: end; + box-sizing: border-box; + position: absolute; + top: 0; + right: 0px; + width: 6px; + height: 100%; + cursor: col-resize; + user-select: none; + z-index: 3; + + &::after { + content: ""; + position: absolute; + z-index: 2; + display: block; + box-sizing: border-box; + width: 1px; + height: 100%; + background-color: red; + } +} + .spectrum-Table-cell--alignCenter { text-align: center; } @@ -236,6 +262,14 @@ svg.spectrum-Table-sortedIcon { border-inline-end-width: var(--spectrum-table-divider-border-size); } +.spectrum-Table-cell--divider { + &.is-resizable { + &:hover { + border-inline-end-width: 3px; + } + } +} + .spectrum-Table-row { position: relative; cursor: default; diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts new file mode 100644 index 00000000000..74823af8c97 --- /dev/null +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -0,0 +1,41 @@ +/* + * 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 {snapValueToStep} from '@react-aria/utils'; +import {useMove} from '@react-aria/interactions'; +import {useRef} from 'react'; + +export function useTableColumnResize(state, item): any { + const stateRef = useRef(null); + stateRef.current = state; + + const columnResizeWidthRef = useRef(null); + const {moveProps} = useMove({ + onMoveStart() { + stateRef.current.setCurrentResizeColumn(item); + stateRef.current.addResizedColumn(item.key); + columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key); + }, + onMove({deltaX}) { + columnResizeWidthRef.current += deltaX; + stateRef.current.setResizeDelta(columnResizeWidthRef.current); + }, + onMoveEnd() { + stateRef.current.setCurrentResizeColumn(); + columnResizeWidthRef.current = 0; + } + }); + + return { + resizerProps: moveProps + }; +} diff --git a/packages/@react-spectrum/slider/src/SliderThumb.tsx b/packages/@react-spectrum/slider/src/SliderThumb.tsx index e6e5a591ffc..ce886610b4c 100644 --- a/packages/@react-spectrum/slider/src/SliderThumb.tsx +++ b/packages/@react-spectrum/slider/src/SliderThumb.tsx @@ -46,6 +46,8 @@ export function SliderThumb(props: SliderThumbProps) { let {direction} = useLocale(); let cssDirection = direction === 'rtl' ? 'right' : 'left'; + console.log('SliderThumb.tsx', state.isThumbDragging(index)); + return (
+ ); +} diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 779820f740a..1bec015d61f 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -18,11 +18,29 @@ import {FocusRing, useFocusRing} from '@react-aria/focus'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {layoutInfoToStyle, ScrollView, setScrollLeft, useVirtualizer, VirtualizerItem} from '@react-aria/virtualizer'; +import { + layoutInfoToStyle, + ScrollView, + setScrollLeft, + useVirtualizer, + VirtualizerItem +} from '@react-aria/virtualizer'; import {mergeProps, useLayoutEffect} from '@react-aria/utils'; import {ProgressCircle} from '@react-spectrum/progress'; -import React, {ReactElement, useCallback, useContext, useMemo, useRef, useState} from 'react'; -import {Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; +import React, { + ReactElement, + useCallback, + useContext, + 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'; @@ -80,20 +98,32 @@ function useTableContext() { return useContext(TableContext); } -function TableView(props: SpectrumTableProps, ref: DOMRef) { +function TableView( + props: SpectrumTableProps, + ref: DOMRef +) { props = useProviderProps(props); let {isQuiet, onAction} = props; let {styleProps} = useStyleProps(props); - let [showSelectionCheckboxes, setShowSelectionCheckboxes] = useState(props.selectionStyle !== 'highlight'); + let [showSelectionCheckboxes, setShowSelectionCheckboxes] = useState( + props.selectionStyle !== 'highlight' + ); let state = useTableState({ ...props, showSelectionCheckboxes, - selectionBehavior: props.selectionStyle === 'highlight' ? 'replace' : 'toggle' + selectionBehavior: + props.selectionStyle === 'highlight' ? 'replace' : 'toggle' }); + let columnWidths = state.columnWidths(); + let currentResizeColumn = state.currentResizeColumn(); + let resizeDelta = state.resizeDelta(); + + // If the selection behavior changes in state, we need to update showSelectionCheckboxes here due to the circular dependency... - let shouldShowCheckboxes = state.selectionManager.selectionBehavior !== 'replace'; + let shouldShowCheckboxes = + state.selectionManager.selectionBehavior !== 'replace'; if (shouldShowCheckboxes !== showSelectionCheckboxes) { setShowSelectionCheckboxes(shouldShowCheckboxes); } @@ -103,29 +133,34 @@ function TableView(props: SpectrumTableProps, ref: DOMRef new TableLayout({ - // If props.rowHeight is auto, then use estimated heights based on scale, otherwise use fixed heights. - rowHeight: props.overflowMode === 'wrap' - ? null - : ROW_HEIGHTS[density][scale], - estimatedRowHeight: props.overflowMode === 'wrap' - ? ROW_HEIGHTS[density][scale] - : null, - headingHeight: props.overflowMode === 'wrap' - ? null - : DEFAULT_HEADER_HEIGHT[scale], - estimatedHeadingHeight: props.overflowMode === 'wrap' - ? DEFAULT_HEADER_HEIGHT[scale] - : null, - getDefaultWidth: ({hideHeader, isSelectionCell, showDivider}) => { - if (hideHeader) { - let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; - return showDivider ? width + 1 : width; - } else if (isSelectionCell) { - return SELECTION_CELL_DEFAULT_WIDTH[scale]; - } - } - }), [props.overflowMode, scale, density]); + 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' ? null : ROW_HEIGHTS[density][scale], + estimatedRowHeight: + props.overflowMode === 'wrap' ? ROW_HEIGHTS[density][scale] : null, + headingHeight: + props.overflowMode === 'wrap' ? null : DEFAULT_HEADER_HEIGHT[scale], + estimatedHeadingHeight: + props.overflowMode === 'wrap' ? DEFAULT_HEADER_HEIGHT[scale] : null, + getDefaultWidth: ({hideHeader, isSelectionCell, showDivider}) => { + if (hideHeader) { + let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; + return showDivider ? width + 1 : width; + } else if (isSelectionCell) { + return SELECTION_CELL_DEFAULT_WIDTH[scale]; + } + }, + columnWidths, + getColumnWidth: state.getColumnWidth, + setColumnWidth: state.setColumnWidth, + hasResizedColumn: state.hasResizedColumn, + currentResizeColumn, + resizeDelta + }), + [props.overflowMode, scale, density, resizeDelta] + ); let {direction} = useLocale(); layout.collection = state.collection; @@ -138,17 +173,24 @@ function TableView(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); + let renderWrapper = ( + parent: View, + reusableView: View, + children: View[], + renderChildren: (views: View[]) => ReactElement[] + ) => { + let style = layoutInfoToStyle( + reusableView.layoutInfo, + direction, + parent && parent.layoutInfo + ); if (style.overflow === 'hidden') { style.overflow = 'visible'; // needed to support position: sticky } if (reusableView.viewType === 'rowgroup') { return ( - + {renderChildren(children)} ); @@ -156,9 +198,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef + {renderChildren(children)} ); @@ -192,18 +232,14 @@ function TableView(props: SpectrumTableProps, ref: DOMRef + className={classNames( + styles, + 'spectrum-Table-cellWrapper', + classNames(stylesOverrides, { + 'react-spectrum-Table-cellWrapper': !reusableView.layoutInfo + .estimatedSize + }) + )} /> ); }; @@ -243,27 +279,33 @@ function TableView(props: SpectrumTableProps, ref: DOMRef ); } - - return ; + return ( + <> + + {item.props.allowsResizing && } + + ); case 'loader': return ( 0 ? formatMessage('loadingMore') : formatMessage('loading')} /> + aria-label={ + state.collection.size > 0 + ? formatMessage('loadingMore') + : formatMessage('loading') + } /> ); case 'empty': { - let emptyState = props.renderEmptyState ? props.renderEmptyState() : null; + let emptyState = props.renderEmptyState + ? props.renderEmptyState() + : null; if (emptyState == null) { return null; } - return ( - - {emptyState} - - ); + return {emptyState}; } } }; @@ -273,21 +315,16 @@ function TableView(props: SpectrumTableProps, ref: DOMRef(props: SpectrumTableProps, ref: DOMRef(); let bodyRef = useRef(); @@ -318,23 +363,27 @@ function TableVirtualizer({layout, collection, focusedKey, renderView, renderWra transitionDuration: isLoading ? 160 : 220 }); - let {virtualizerProps} = useVirtualizer({ - focusedKey, - scrollToItem(key) { - let item = collection.getItem(key); - let column = collection.columns[0]; - state.virtualizer.scrollToItem(key, { - duration: 0, - // Prevent scrolling to the top when clicking on column headers. - shouldScrollY: item?.type !== 'column', - // Offset scroll position by width of selection cell - // (which is sticky and will overlap the cell we're scrolling to). - offsetX: column.props.isSelectionCell - ? layout.columnWidths.get(column.key) - : 0 - }); - } - }, state, domRef); + let {virtualizerProps} = useVirtualizer( + { + focusedKey, + scrollToItem(key) { + let item = collection.getItem(key); + let column = collection.columns[0]; + state.virtualizer.scrollToItem(key, { + duration: 0, + // Prevent scrolling to the top when clicking on column headers. + shouldScrollY: item?.type !== 'column', + // Offset scroll position by width of selection cell + // (which is sticky and will overlap the cell we're scrolling to). + offsetX: column.props.isSelectionCell + ? layout.columnWidths.get(column.key) + : 0 + }); + } + }, + state, + domRef + ); let headerHeight = layout.getLayoutInfo('header')?.rect.height || 0; let visibleRect = state.virtualizer.visibleRect; @@ -344,16 +393,20 @@ function TableVirtualizer({layout, collection, focusedKey, renderView, renderWra headerRef.current.scrollLeft = bodyRef.current.scrollLeft; }, [bodyRef]); - let onVisibleRectChange = useCallback((rect: Rect) => { - state.setVisibleRect(rect); + let onVisibleRectChange = useCallback( + (rect: Rect) => { + state.setVisibleRect(rect); - if (!isLoading && onLoadMore) { - let scrollOffset = state.virtualizer.contentSize.height - rect.height * 2; - if (rect.y > scrollOffset) { - onLoadMore(); + if (!isLoading && onLoadMore) { + let scrollOffset = + state.virtualizer.contentSize.height - rect.height * 2; + if (rect.y > scrollOffset) { + onLoadMore(); + } } - } - }, [onLoadMore, isLoading, state.setVisibleRect, state.virtualizer]); + }, + [onLoadMore, isLoading, state.setVisibleRect, state.virtualizer] + ); useLayoutEffect(() => { if (!isLoading && onLoadMore && !state.isAnimating) { @@ -361,12 +414,16 @@ function TableVirtualizer({layout, collection, focusedKey, renderView, renderWra onLoadMore(); } } - }, [state.contentSize, state.virtualizer, state.isAnimating, onLoadMore, isLoading]); + }, [ + state.contentSize, + state.virtualizer, + state.isAnimating, + onLoadMore, + isLoading + ]); return ( -
+
{state.visibleViews[0]} @@ -385,7 +444,12 @@ function TableVirtualizer({layout, collection, focusedKey, renderView, renderWra role="presentation" className={classNames(styles, 'spectrum-Table-body')} style={{flex: 1}} - innerStyle={{overflow: 'visible', transition: state.isAnimating ? `none ${state.virtualizer.transitionDuration}ms` : undefined}} + innerStyle={{ + overflow: 'visible', + transition: state.isAnimating + ? `none ${state.virtualizer.transitionDuration}ms` + : undefined + }} ref={bodyRef} contentSize={state.contentSize} onVisibleRectChange={onVisibleRectChange} @@ -402,7 +466,10 @@ function TableHeader({children, ...otherProps}) { let {rowGroupProps} = useTableRowGroup(); return ( -
+
{children}
); @@ -411,10 +478,14 @@ function TableHeader({children, ...otherProps}) { function TableColumnHeader({column}) { let ref = useRef(); let state = useTableContext(); - let {columnHeaderProps} = useTableColumnHeader({ - node: column, - isVirtualized: true - }, state, ref); + let {columnHeaderProps} = useTableColumnHeader( + { + node: column, + isVirtualized: true + }, + state, + ref + ); let columnProps = column.props as SpectrumColumnProps; let {hoverProps, isHovered} = useHover({}); @@ -424,35 +495,38 @@ function TableColumnHeader({column}) {
1, - 'react-spectrum-Table-cell--alignEnd': columnProps.align === 'end' - } - ) - ) - }> - {columnProps.hideHeader ? - {column.rendered} : -
{column.rendered}
- } - {columnProps.allowsSorting && - - } - + className={classNames( + styles, + 'spectrum-Table-headCell', + { + 'is-sortable': columnProps.allowsSorting, + 'is-sorted-desc': + state.sortDescriptor?.column === column.key && + state.sortDescriptor?.direction === 'descending', + 'is-sorted-asc': + state.sortDescriptor?.column === column.key && + state.sortDescriptor?.direction === 'ascending', + 'is-hovered': isHovered, + 'spectrum-Table-cell--hideHeader': columnProps.hideHeader + }, + classNames(stylesOverrides, 'react-spectrum-Table-cell', { + 'react-spectrum-Table-cell--alignCenter': + columnProps.align === 'center' || column.colspan > 1, + 'react-spectrum-Table-cell--alignEnd': columnProps.align === 'end' + }) + )}> + {columnProps.hideHeader ? ( + {column.rendered} + ) : ( +
+ {column.rendered} +
+ )} + {columnProps.allowsSorting && ( + + )}
); @@ -462,10 +536,14 @@ function TableSelectAllCell({column}) { let ref = useRef(); let state = useTableContext(); let isSingleSelectionMode = state.selectionManager.selectionMode === 'single'; - let {columnHeaderProps} = useTableColumnHeader({ - node: column, - isVirtualized: true - }, state, ref); + let {columnHeaderProps} = useTableColumnHeader( + { + node: column, + isVirtualized: true + }, + state, + ref + ); let {checkboxProps} = useTableSelectAllCheckbox(state); let {hoverProps, isHovered} = useHover({}); @@ -475,16 +553,14 @@ function TableSelectAllCell({column}) {
+ className={classNames( + styles, + 'spectrum-Table-headCell', + 'spectrum-Table-checkboxCell', + { + 'is-hovered': isHovered + } + )}> { /* In single selection mode, the checkbox will be hidden. @@ -492,14 +568,17 @@ function TableSelectAllCell({column}) { we use a VisuallyHidden component to include the aria-label from the checkbox, which for single selection will be "Select." */ - isSingleSelectionMode && - {checkboxProps['aria-label']} + isSingleSelectionMode && ( + {checkboxProps['aria-label']} + ) }
@@ -550,21 +629,17 @@ function TableRow({item, children, hasActions, ...otherProps}) {
+ className={classNames(styles, 'spectrum-Table-row', { + 'is-active': isPressed, + 'is-selected': isSelected, + 'spectrum-Table-row--highlightSelection': + state.selectionManager.selectionBehavior === 'replace' && + (isSelected || state.selectionManager.isSelected(item.nextKey)), + 'is-focused': isFocusVisibleWithin, + 'focus-ring': isFocusVisible, + 'is-hovered': isHovered, + 'is-disabled': isDisabled + })}> {children}
); @@ -573,7 +648,11 @@ function TableRow({item, children, hasActions, ...otherProps}) { function TableHeaderRow({item, children, style}) { let state = useTableContext(); let ref = useRef(); - let {rowProps} = useTableHeaderRow({node: item, isVirtualized: true}, state, ref); + let {rowProps} = useTableHeaderRow( + {node: item, isVirtualized: true}, + state, + ref + ); return (
@@ -586,38 +665,41 @@ function TableCheckboxCell({cell}) { let ref = useRef(); let state = useTableContext(); let isDisabled = state.disabledKeys.has(cell.parentKey); - let {gridCellProps} = useTableCell({ - node: cell, - isVirtualized: true - }, state, ref); + let {gridCellProps} = useTableCell( + { + node: cell, + isVirtualized: true + }, + state, + ref + ); - let {checkboxProps} = useTableSelectionCheckbox({key: cell.parentKey}, state); + let {checkboxProps} = useTableSelectionCheckbox( + {key: cell.parentKey}, + state + ); return (
- {state.selectionManager.selectionMode !== 'none' && + className={classNames( + styles, + 'spectrum-Table-cell', + 'spectrum-Table-checkboxCell', + { + 'is-disabled': isDisabled + }, + classNames(stylesOverrides, 'react-spectrum-Table-cell') + )}> + {state.selectionManager.selectionMode !== 'none' && ( - } + )}
); @@ -628,43 +710,38 @@ function TableCell({cell}) { let ref = useRef(); let columnProps = cell.column.props as SpectrumColumnProps; let isDisabled = state.disabledKeys.has(cell.parentKey); - let {gridCellProps} = useTableCell({ - node: cell, - isVirtualized: true - }, state, ref); + let {gridCellProps} = useTableCell( + { + node: cell, + isVirtualized: true + }, + state, + ref + ); return (
- + className={classNames( + styles, + 'spectrum-Table-cell', + { + 'spectrum-Table-cell--divider': + columnProps.showDivider && cell.column.nextKey !== null, + 'spectrum-Table-cell--hideHeader': columnProps.hideHeader, + 'is-disabled': isDisabled + }, + classNames(stylesOverrides, 'react-spectrum-Table-cell', { + 'react-spectrum-Table-cell--alignStart': + columnProps.align === 'start', + 'react-spectrum-Table-cell--alignCenter': + columnProps.align === 'center', + 'react-spectrum-Table-cell--alignEnd': columnProps.align === 'end' + }) + )}> + {cell.rendered}
@@ -677,8 +754,13 @@ function CenteredWrapper({children}) { return (
+ aria-rowindex={ + state.collection.headerRows.length + state.collection.size + 1 + } + className={classNames( + stylesOverrides, + 'react-spectrum-Table-centeredWrapper' + )}>
{children}
@@ -689,5 +771,7 @@ function CenteredWrapper({children}) { /** * Tables are containers for displaying information. They allow users to quickly scan, sort, compare, and take action on large amounts of data. */ -const _TableView = React.forwardRef(TableView) as (props: SpectrumTableProps & {ref?: DOMRef}) => ReactElement; +const _TableView = React.forwardRef(TableView) as ( + props: SpectrumTableProps & { ref?: DOMRef } +) => ReactElement; export {_TableView as TableView}; diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index b8a348f774b..7d5a76dcf2a 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -28,7 +28,7 @@ import {IllustratedMessage} from '@react-spectrum/illustratedmessage'; import {Link} from '@react-spectrum/link'; import {LoadingState, SelectionMode} from '@react-types/shared'; import {Radio, RadioGroup} from '@react-spectrum/radio'; -import React, {Key, useState} from 'react'; +import React, {Key, useMemo, useState} from 'react'; import {SearchField} from '@react-spectrum/searchfield'; import {storiesOf} from '@storybook/react'; import {Switch} from '@react-spectrum/switch'; @@ -1022,7 +1022,61 @@ storiesOf('TableView', module) ) ) - .add('table with breadcrumb navigation', () => ); + .add('table with breadcrumb navigation', () => ) + .add( + 'resizable columns', + () => ( + + + File Name + Type + Size + + + + 2018 Proposal + PDF + 214 KB + + + Budget + XLS + 120 KB + + + + ) + ) + .add( + 'resizable columns, uncontrolled, flex', + () => ( + + + File Name + Type + Size + + + + 2018 Proposal + PDF + 214 KB + + + Budget + XLS + 120 KB + + + + ) + ) + .add( + 'resizable columns, controlled', + () => ( + + ) + ); function AsyncLoadingExample() { interface Item { @@ -1371,3 +1425,60 @@ export function TableWithBreadcrumbs() { ); } + +function ResizableColumnsControlled() { + let columns = [ + { + key: 0, + label: 'File Name', + allowsResizing: true, + width: 300 + }, + { + key: 1, + label: 'Type', + allowsResizing: true, + width: 200 + }, + { + key: 2, + label: 'Size', + allowsResizing: true, + width: 96 + } + ]; + + let [columnState, setColumnState] = useState(columns); + + let onResize = (key, prevWidth, newWidth) => { + if (newWidth !== prevWidth) { + let updatedColumnState = columnState.map(c => key === c.key ? {...c, width: newWidth} : c); + setColumnState(updatedColumnState); + + } + + }; + + + return ( + + + { + columnState.map(c => useMemo(() => ( onResize(c.key, c.width, newWidth)}>{c.label}), [c.width])) + } + + + + 2018 Proposal + PDF + 214 KB + + + Budget + XLS + 120 KB + + + + ); +} diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 99bf5760662..3ac463dbe83 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -18,21 +18,40 @@ import {LayoutNode, ListLayout, ListLayoutOptions} from './ListLayout'; type TableLayoutOptions = ListLayoutOptions & { - getDefaultWidth: (props) => string | number + getDefaultWidth: (props) => string | number, + columnWidths: Map, + getColumnWidth: (key: Key) => number, + setColumnWidth: (column: any, width: number) => void, + hasResizedColumn: (key: Key) => boolean, + currentResizeColumn: any, + resizeDelta: number } export class TableLayout extends ListLayout { collection: TableCollection; lastCollection: TableCollection; columnWidths: Map; + columnWidthsRef: Map; stickyColumnIndices: number[]; getDefaultWidth: (props) => string | number; + getColumnWidth_: (key: Key) => number; + setColumnWidth_: (column: any, width: number) => void; + hasResizedColumn: (key: Key) => boolean; + currentResizeColumn: any; + resizeDelta: number; wasLoading = false; isLoading = false; constructor(options: TableLayoutOptions) { super(options); this.getDefaultWidth = options.getDefaultWidth; + this.columnWidths = options.columnWidths; + this.getColumnWidth_ = options.getColumnWidth; + this.setColumnWidth_ = options.setColumnWidth; + this.hasResizedColumn = options.hasResizedColumn; + this.currentResizeColumn = options.currentResizeColumn; + this.columnWidthsRef = this.columnWidths; + this.resizeDelta = options.resizeDelta; } @@ -52,7 +71,26 @@ export class TableLayout extends ListLayout { this.wasLoading = this.isLoading; this.isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; - this.buildColumnWidths(); + // only rebuild columns that come after the column being resized, if no column is being resized, they will all be built + const resizeIndex = this.collection.columns.findIndex(column => column.key === this.currentResizeColumn?.key); + // if resizing, set the column width for the resized column to the delta bounded by it's min/max + if (resizeIndex > -1) { + const columnProps = this.collection.columns[resizeIndex].props; + const boundedResizeDelta = Math.max(this.getMinWidth(columnProps?.minWidth), Math.min(this.getMaxWidth(columnProps.maxWidth), this.resizeDelta)); + + if (columnProps.width) { + // Controlled component - explicit width is defined and should always win. + // Unsure: Set column width to current width but call onResize with the new width? + this.setColumnWidth_(this.currentResizeColumn, columnProps.width); + columnProps.onResize && columnProps.onResize(boundedResizeDelta); + } else { + this.setColumnWidth(this.currentResizeColumn, boundedResizeDelta); + } + } + const affectedResizeColumns = this.collection.columns.slice(resizeIndex + 1, this.collection.columns.length); + const remainingSpace = this.collection.columns.slice(0, resizeIndex + 1).reduce((acc, column) => acc - this.getColumnWidth_(column.key), this.virtualizer.visibleRect.width); + this.buildColumnWidths(affectedResizeColumns, remainingSpace); + let header = this.buildHeader(); let body = this.buildBody(0); body.layoutInfo.rect.width = Math.max(header.layoutInfo.rect.width, body.layoutInfo.rect.width); @@ -63,19 +101,30 @@ export class TableLayout extends ListLayout { ]; } - buildColumnWidths() { - this.columnWidths = new Map(); + setColumnWidth(column, newWidth) { + this.setColumnWidth_(column, newWidth); + column.props.onResize && column.props.onResize(newWidth); + } + + buildColumnWidths(affectedResizeColumns: GridNode[], remainingSpace: number) { this.stickyColumnIndices = []; + // if there was a column resized, set it's width and mark it as static somehow. + // Pass 1: set widths for all explicitly defined columns. let remainingColumns = new Set>(); - let remainingSpace = this.virtualizer.visibleRect.width; - for (let column of this.collection.columns) { + for (let column of affectedResizeColumns) { let props = column.props as ColumnProps; - let width = props.width ?? this.getDefaultWidth(props); - if (width != null) { + let width; + if (props.width) { + width = props.width; + } else { + width = this.hasResizedColumn(column.key) ? this.getColumnWidth_(column.key) : props.defaultWidth ?? this.getDefaultWidth(props); + } + if (this.getIsStatic(width)) { let w = this.parseWidth(width); - this.columnWidths.set(column.key, w); + this.columnWidthsRef.set(column.key, w); + this.setColumnWidth(column, w); remainingSpace -= w; } else { remainingColumns.add(column); @@ -90,21 +139,101 @@ export class TableLayout extends ListLayout { // Pass 2: if there are remaining columns, then distribute the remaining space evenly. if (remainingColumns.size > 0) { - let columnWidth = remainingSpace / (this.collection.columns.length - this.columnWidths.size); - + const remCols = this.getDynamicColumnWidths( + Array.from(remainingColumns), + remainingSpace + ); + let i = 0; for (let column of remainingColumns) { - let props = column.props as ColumnProps; - let minWidth = props.minWidth != null ? this.parseWidth(props.minWidth) : 75; - let maxWidth = props.maxWidth != null ? this.parseWidth(props.maxWidth) : Infinity; - let width = Math.max(minWidth, Math.min(maxWidth, columnWidth)); - - this.columnWidths.set(column.key, width); - remainingSpace -= width; - if (width !== columnWidth) { - columnWidth = remainingSpace / (this.collection.columns.length - this.columnWidths.size); - } + this.columnWidthsRef.set(column.key, remCols[i].columnWidth); + this.setColumnWidth(column, remCols[i].columnWidth); + i++; } } + + } + + getIsStatic(width: number | string): boolean { + return ( + width != null && (typeof width === 'number' || width.match(/^(\d+)%$/) !== null) + ); + } + + getDynamicColumnWidths(remainingColumns, remainingSpace) { + let columns = this.mapColumns(remainingColumns, remainingSpace); + + columns.sort((a, b) => b.delta - a.delta); + columns = this.solveWidths(columns, remainingSpace); + columns.sort((a, b) => a.index - b.index); + + return columns; + } + + mapColumns(remainingColumns, remainingSpace) { + let remainingFractions = remainingColumns.reduce( + (sum, column) => sum + this.parseFractionalUnit(column.props.defaultWidth), + 0 + ); + + let columns = [...remainingColumns].map((column, index) => { + const targetWidth = + (this.parseFractionalUnit(column.props.defaultWidth) * remainingSpace) / remainingFractions; + + return { + ...column, + index, + delta: Math.max( + 0, + this.getMinWidth(column.minWidth) - targetWidth, + targetWidth - this.getMaxWidth(column.maxWidth) + ) + }; + }); + + return columns; + } + + solveWidths(remainingColumns, remainingSpace) { + let remainingFractions = remainingColumns.reduce( + (sum, col) => sum + this.parseFractionalUnit(col.props.defaultWidth), + 0 + ); + + for (let i = 0; i < remainingColumns.length; i++) { + const column = remainingColumns[i]; + + const targetWidth = + (this.parseFractionalUnit(column.props.defaultWidth) * remainingSpace) / remainingFractions; + + let width = Math.max( + this.getMinWidth(column.minWidth), + Math.min(targetWidth, this.getMaxWidth(column.maxWidth)) + ); + column.columnWidth = width; + remainingSpace -= width; + remainingFractions -= this.parseFractionalUnit(column.props.defaultWidth); + } + + return remainingColumns; + } + + parseFractionalUnit(width: string): number { + if (!width) { + return 1; + } + return parseInt(width.match(/(?<=^flex-)(\d+)/g)[0], 10); + } + + getMinWidth(minWidth: number | string): number { + return minWidth !== undefined && minWidth !== null + ? this.parseWidth(minWidth) + : 75; + } + + getMaxWidth(maxWidth: number | string): number { + return maxWidth !== undefined && maxWidth !== null + ? this.parseWidth(maxWidth) + : Infinity; } parseWidth(width: number | string): number { @@ -183,12 +312,13 @@ export class TableLayout extends ListLayout { } } + // used to get the column widths when rendering to the DOM getColumnWidth(node: GridNode) { let colspan = node.colspan ?? 1; let width = 0; for (let i = 0; i < colspan; i++) { let column = this.collection.columns[node.index + i]; - width += this.columnWidths.get(column.key); + width += this.getColumnWidth_(column.key); } return width; diff --git a/packages/@react-stately/table/package.json b/packages/@react-stately/table/package.json index ea894ecf443..d0184ab5790 100644 --- a/packages/@react-stately/table/package.json +++ b/packages/@react-stately/table/package.json @@ -21,6 +21,7 @@ "@react-stately/collections": "^3.3.3", "@react-stately/grid": "^3.1.0", "@react-stately/selection": "^3.8.0", + "@react-stately/utils": "^3.3.0", "@react-types/grid": "^3.0.0", "@react-types/shared": "^3.10.0", "@react-types/table": "^3.1.0" diff --git a/packages/@react-stately/table/src/useTableState.ts b/packages/@react-stately/table/src/useTableState.ts index a0ffb553574..f03789b86e8 100644 --- a/packages/@react-stately/table/src/useTableState.ts +++ b/packages/@react-stately/table/src/useTableState.ts @@ -13,7 +13,7 @@ import {CollectionBase, Node, SelectionMode, Sortable, SortDescriptor, SortDirection} from '@react-types/shared'; import {GridState, useGridState} from '@react-stately/grid'; import {TableCollection as ITableCollection} from '@react-types/table'; -import {Key, useMemo} from 'react'; +import {Key, useMemo, useRef, useState} from 'react'; import {MultipleSelectionStateProps} from '@react-stately/selection'; import {TableCollection} from './TableCollection'; import {useCollection} from '@react-stately/collections'; @@ -26,7 +26,20 @@ export interface TableState extends GridState> { /** The current sorted column and direction. */ sortDescriptor: SortDescriptor, /** Calls the provided onSortChange handler with the provided column key and sort direction. */ - sort(columnKey: Key): void + sort(columnKey: Key): void, + + columnWidths(): Map, + getColumnWidth(key: Key): number, + setColumnWidth(column: any, width: number), + + hasResizedColumn(key: Key): boolean, + addResizedColumn(key: Key), + + currentResizeColumn(): Key, + setCurrentResizeColumn(key: Key), + + resizeDelta(): number, + setResizeDelta(deltaX: number) } export interface CollectionBuilderContext { @@ -35,7 +48,10 @@ export interface CollectionBuilderContext { columns: Node[] } -export interface TableStateProps extends CollectionBase, MultipleSelectionStateProps, Sortable { +export interface TableStateProps + extends CollectionBase, + MultipleSelectionStateProps, + Sortable { /** Whether the row selection checkboxes should be displayed. */ showSelectionCheckboxes?: boolean } @@ -49,14 +65,73 @@ const OPPOSITE_SORT_DIRECTION = { * Provides state management for a table component. Handles building a collection * of columns and rows from props. In addition, it tracks row selection and manages sort order changes. */ -export function useTableState(props: TableStateProps): TableState { +export function useTableState( + props: TableStateProps +): TableState { let {selectionMode = 'none'} = props; - let context = useMemo(() => ({ - showSelectionCheckboxes: props.showSelectionCheckboxes && selectionMode !== 'none', - selectionMode, - columns: [] - }), [props.children, props.showSelectionCheckboxes, selectionMode]); + // map of the columns and their width, key is the column key, value is the width + // TODO: switch to useControlledState + const [columnWidths, setColumnWidths] = useState>(new Map()); + // set of all the column keys that have been resized + const [resizedColumns, setResizedColumns] = useState>(new Set()); + // current column key that is being resized + const [currentResizeColumn, setCurrentResizeColumn] = useState(null); + // resize delta + const [resizeDelta, setResizeDelta] = useState(0); + + + // map of the columns and their width, key is the column key, value is the width + const columnWidthsRef = useRef>(null); + columnWidthsRef.current = columnWidths; + const resizedColumnsRef = useRef>(null); + resizedColumnsRef.current = resizedColumns; + const currentResizeColumnRef = useRef(null); + currentResizeColumnRef.current = currentResizeColumn; + const resizeDeltaRef = useRef(null); + resizeDeltaRef.current = resizeDelta; + + function getColumnWidth(key: Key): number { + return columnWidthsRef.current.get(key); + } + + function setColumnWidthNew(column: any, width: number) { + columnWidthsRef.current.set(column.key, width); + // new map so that change detection is triggered + setColumnWidths(new Map(columnWidthsRef.current)); + } + + function hasResizedColumn(key: Key): boolean { + return resizedColumns.has(key); + } + + function addResizedColumn(key: Key) { + if (resizedColumnsRef.current.has(key)) { + return; + } + resizedColumnsRef.current.add(key); + setResizedColumns(new Set(resizedColumnsRef.current)); + } + + function updatedCurrentResizeColumn(key: Key) { + currentResizeColumnRef.current = key; + setCurrentResizeColumn(currentResizeColumnRef.current); + } + + function updateResizeDelta(deltaX: number) { + resizeDeltaRef.current = deltaX; + setResizeDelta(resizeDeltaRef.current); + } + + let context = useMemo( + () => ({ + showSelectionCheckboxes: + props.showSelectionCheckboxes && selectionMode !== 'none', + selectionMode, + columns: [] + }), + [props.children, props.showSelectionCheckboxes, selectionMode] + ); let collection = useCollection>( props, @@ -78,6 +153,15 @@ export function useTableState(props: TableStateProps): Tabl ? OPPOSITE_SORT_DIRECTION[props.sortDescriptor.direction] : 'ascending' }); - } + }, + columnWidths: () => columnWidths, + getColumnWidth, + setColumnWidth: setColumnWidthNew, + hasResizedColumn, + addResizedColumn, + currentResizeColumn: () => currentResizeColumn, + setCurrentResizeColumn: updatedCurrentResizeColumn, + resizeDelta: () => resizeDelta, + setResizeDelta: updateResizeDelta }; } diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 7d548a8b45b..64e54840427 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -62,7 +62,12 @@ export interface ColumnProps { minWidth?: number | string, /** The maximum width of the column. */ maxWidth?: number | string, - // defaultWidth?: number | string + /** The default width of the column. */ + defaultWidth?: number | string, + /** Whether the column allows resizing. */ + allowsResizing?: boolean, + /** Whether the column allows resizing. */ + onResize?: (width: number) => void, /** Whether the column allows sorting. */ allowsSorting?: boolean, /** Whether a column is a [row header](https://www.w3.org/TR/wai-aria-1.1/#rowheader) and should be announced by assistive technology during row navigation. */