From dde2ec22b0893fcfdb071199f0b6e35f151f8eed Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 24 Jan 2023 17:22:24 -0800 Subject: [PATCH 01/64] progress for making aria table resizing example --- packages/@react-aria/table/docs/useTable.mdx | 7 +- .../table/stories/example-docs.tsx | 389 ++++++++++++++++++ .../table/stories/useTable.stories.tsx | 35 +- .../table/docs/useTableState.mdx | 5 + 4 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 packages/@react-aria/table/stories/example-docs.tsx diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 054e9e9c7a6..91999552fb5 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -414,7 +414,6 @@ import {VisuallyHidden} from '@react-aria/visually-hidden'; function TableSelectAllCell({column, state}) { let ref = useRef(); - let isSingleSelectionMode = state.selectionManager.selectionMode === 'single'; let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); let {checkboxProps} = useTableSelectAllCheckbox(state); @@ -423,7 +422,7 @@ function TableSelectAllCell({column, state}) { {...columnHeaderProps} ref={ref}> {state.selectionManager.selectionMode === 'single' - ? {inputProps['aria-label']} + ? {checkboxProps['aria-label']} : } @@ -494,6 +493,10 @@ function Checkbox(props) { + +// TODO add resizing mentions above and have a separate section detailing how to add it +## Resizing Example + ## Usage ### Dynamic collections diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx new file mode 100644 index 00000000000..ee42e5a04ba --- /dev/null +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -0,0 +1,389 @@ +/* + * 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. + */ + +// TODO: don't need this, replace with styles +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 {useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup, useTableSelectAllCheckbox, useTableSelectionCheckbox} from '@react-aria/table'; +import {useTableColumnResizeState, useTableState} from '@react-stately/table'; +import {useToggleState} from '@react-stately/toggle'; +import {VisuallyHidden} from '@react-aria/visually-hidden'; + +export function Table(props) { + let [showSelectionCheckboxes, setShowSelectionCheckboxes] = useState(props.selectionStyle !== 'highlight'); + let state = useTableState({ + ...props, + showSelectionCheckboxes, + selectionBehavior: props.selectionStyle === 'highlight' ? 'replace' : 'toggle' + }); + // 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, + // Table itself is made scrollable instead of the body so that the + // column header and row scroll positions are the same + scrollRef: ref + }, + state, + ref + ); + + // TODO: look at the CSS for the resizing example, replace classnames with style + + // Table column resizing stuff + let [tableWidth, setTableWidth] = useState(0); + let getDefaultWidth = useCallback((node) => { + // selection cell column should always take up a specific width, doesn't need to be resizable + if (node.props.isSelectionCell) { + return 20; + } + return; + }, []); + let getDefaultMinWidth = useCallback((node) => { + // selection cell column should always take up a specific width, doesn't need to be resizable + if (node.props.isSelectionCell) { + return 20; + } + return 75; + }, []); + let layoutState = useTableColumnResizeState({ + getDefaultWidth, + getDefaultMinWidth, + tableWidth + }, state); + let {widths} = layoutState; + + + // TODO: not sure why we need the below, is it if width isn't provided? + // Asked Rob, it is if the Table's width is changed by a resize event (e.g. width is a percentage of the page), doesn't apply for the + // static width case + // Remove for final doc example cuz it is probably too + useLayoutEffect(() => { + if (bodyRef && bodyRef.current) { + setTableWidth(bodyRef.current.clientWidth); + } + }, []); + useResizeObserver({ref, onResize: () => setTableWidth(bodyRef.current.clientWidth)}); + console.log('table width', tableWidth, widths) + + // TODO: move certain stylings into the components themselves + return ( + // TODO: just take props for styles (width, height) + + + {collection.headerRows.map(headerRow => ( + + {[...headerRow.childNodes].map(column => + column.props.isSelectionCell + ? + // TODO include the resize stuff? for controlled? + : + )} + + ))} + + + {[...collection.body.childNodes].map(row => ( + + {[...row.childNodes].map(cell => + cell.props.isSelectionCell + ? + : + )} + + ))} + +
+ ); +} + +// Needs to be forward ref for bodyRef +export const TableRowGroup = React.forwardRef((props, ref) => { + let {type: Element, style, children} = props; + let {rowGroupProps} = useTableRowGroup(); + return ( + + {children} + + ); +}); + + +function TableHeaderRow({item, state, children, style = {}}) { + let ref = useRef(); + let {rowProps} = useTableHeaderRow({node: item}, state, ref); + + return ( + + {children} + + ); +} + +function Resizer({column, state, layoutState, onResizeStart, onResize, onResizeEnd}) { + let ref = useRef(null); + let {resizerProps, inputProps} = useTableColumnResize({ + column, + label: 'Resizer', + onResizeStart, + onResize, + onResizeEnd, + tableState: state + }, layoutState, ref); + + return ( + <> + +
+ + + +
+
+ + ); +} + +export function TableColumnHeader({column, state, widths, layoutState, onResizeStart, onResize, onResizeEnd}) { + let ref = useRef(); + let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); + let {isFocusVisible, focusProps} = useFocusRing(); + let arrowIcon = state.sortDescriptor?.direction === 'ascending' ? '▲' : '▼'; +// TODO: figure out why the resizer is being focused, how is TableView omitting it from being one of the children that +// useGridCell would try to focus + return ( + 1 ? 'center' : 'left', + padding: '5px 10px', + // TODO switch to box shadow? + outline: isFocusVisible ? '2px solid orange' : 'none', + cursor: 'default', + // New stuff + width: widths.get(column.key), + display: 'block', + flex: '0 0 auto', + boxSizing: 'border-box' + }} + ref={ref}> + {/* TODO: make resizer triggerable via keyboard, maybe make a menutrigger for hitting enter on the tablecolumn header */} +
+
+ {column.rendered} + {column.props.allowsSorting && + + } +
+ { + column.props.allowsResizing && + + } +
+ + ); +} + +export function TableRow({item, children, state, style}) { + let ref = useRef(); + let isSelected = state.selectionManager.isSelected(item.key); + let {rowProps, isPressed} = 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; + // TODO: why is this isSelected a thing + // This is here since the table is now a fixed width with overflow, so it is relying on + let isSelected = state.selectionManager.isSelected(cell.parentKey); + + return ( + + {cell.rendered} + + ); +} + +function TableCheckboxCell({cell, state, widths}) { + let ref = useRef(); + let {gridCellProps} = useTableCell({node: cell}, state, ref); + let {checkboxProps} = useTableSelectionCheckbox({key: cell.parentKey}, state); + let column = cell.column; + + return ( + + + + ); +} + +function TableSelectAllCell({column, state, widths}) { + let ref = useRef(); + let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); + let {checkboxProps} = useTableSelectAllCheckbox(state); + + return ( + + {state.selectionManager.selectionMode === 'single' + ? {checkboxProps['aria-label']} + : + } + + ); +} + +function Checkbox(props) { + let ref = React.useRef(); + let state = useToggleState(props); + let {inputProps} = useCheckbox(props, state, ref); + return ; +} + + +// // TODO add menu button diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index b46beb2f8a2..0c8fb9d2f8b 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -14,6 +14,7 @@ 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 {Table as DocsTable} from './example-docs'; import {Meta, Story} from '@storybook/react'; import React, {Key, useCallback, useMemo, useState} from 'react'; import {Table as ResizingTable} from './example-resizing'; @@ -26,7 +27,7 @@ const meta: Meta> = { export default meta; let columns = [ - {name: 'Name', uid: 'name'}, + {name: 'Naglwakenglkawnegklnakwlen glkawen glkawn gkaw neglkme', uid: 'name'}, {name: 'Type', uid: 'type'}, {name: 'Level', uid: 'level'} ]; @@ -48,12 +49,12 @@ let defaultRows = [ const Template: Story> = (args) => ( <> - - + {/* + */} {column => ( - + {column.name} )} @@ -66,8 +67,8 @@ const Template: Story> = (args) => ( )}
- - + {/* + */} ); @@ -101,6 +102,28 @@ const TemplateBackwardsCompat: Story> = (args) => ( export const ScrollTesting = Template.bind({}); ScrollTesting.args = {}; +export const DocExample = { + args: {}, + render: (args) => ( + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + + + ) +}; + export const ActionTesting = Template.bind({}); ActionTesting.args = {selectionBehavior: 'replace', selectionStyle: 'highlight', onAction: action('onAction')}; diff --git a/packages/@react-stately/table/docs/useTableState.mdx b/packages/@react-stately/table/docs/useTableState.mdx index 2175aabc78a..db81e6c8bd5 100644 --- a/packages/@react-stately/table/docs/useTableState.mdx +++ b/packages/@react-stately/table/docs/useTableState.mdx @@ -30,6 +30,7 @@ keywords: [table, state, grid] ## API + @@ -38,9 +39,13 @@ keywords: [table, state, grid] ## Interface +TODO: add table column resize state here or make a new page for table column reisze state? +### ## Example See the docs for [useTable](/react-aria/useTable.html) in react-aria for an example of `useTableState`, `Cell`, `Column`, `Row`, `TableBody`, and `TableHeader`. + +TODO: add table column resize state From 1057b99500eb84d5db5642bd3dd15c004b71c0d7 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 25 Jan 2023 16:14:42 -0800 Subject: [PATCH 02/64] styling updates and addition of menu button for resizing --- .../table/stories/example-docs.tsx | 329 +++++++++++++++--- 1 file changed, 271 insertions(+), 58 deletions(-) diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx index ee42e5a04ba..83034194925 100644 --- a/packages/@react-aria/table/stories/example-docs.tsx +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -12,17 +12,26 @@ // TODO: don't need this, replace with styles import {classNames} from '@react-spectrum/utils'; +import {DismissButton, Overlay, usePopover} from '@react-aria/overlays'; import {FocusRing, useFocusRing} from '@react-aria/focus'; +import {Item} from '@react-stately/collections'; 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 {useButton} from '@react-aria/button'; import {useCheckbox} from '@react-aria/checkbox'; -import {useRef} from 'react'; +import {useMemo, useRef} from 'react'; +import {useMenu} from '@react-aria/menu'; +import {useMenuItem} from '@react-aria/menu'; +import {useMenuTrigger} from '@react-aria/menu'; +import {useMenuTriggerState} from '@react-stately/menu'; import {useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup, useTableSelectAllCheckbox, useTableSelectionCheckbox} from '@react-aria/table'; import {useTableColumnResizeState, useTableState} from '@react-stately/table'; import {useToggleState} from '@react-stately/toggle'; +import {useTreeState} from '@react-stately/tree'; import {VisuallyHidden} from '@react-aria/visually-hidden'; + export function Table(props) { let [showSelectionCheckboxes, setShowSelectionCheckboxes] = useState(props.selectionStyle !== 'highlight'); let state = useTableState({ @@ -44,6 +53,8 @@ export function Table(props) { onRowAction: props.onAction, // Table itself is made scrollable instead of the body so that the // column header and row scroll positions are the same + // Not great still because the column headers are position sticky and thus throw off the scrolling items into view when going upwards + // Perhaps just put the table into a container that has overflow hidden? scrollRef: ref }, state, @@ -79,14 +90,14 @@ export function Table(props) { // TODO: not sure why we need the below, is it if width isn't provided? // Asked Rob, it is if the Table's width is changed by a resize event (e.g. width is a percentage of the page), doesn't apply for the // static width case - // Remove for final doc example cuz it is probably too + // Remove for final doc example cuz it is probably too complicated, TODO test it? useLayoutEffect(() => { if (bodyRef && bodyRef.current) { setTableWidth(bodyRef.current.clientWidth); } }, []); useResizeObserver({ref, onResize: () => setTableWidth(bodyRef.current.clientWidth)}); - console.log('table width', tableWidth, widths) + // console.log('table width', tableWidth, widths) // TODO: move certain stylings into the components themselves return ( @@ -96,13 +107,13 @@ export function Table(props) { ref={ref} style={{ borderCollapse: 'collapse', - // TODO: get rid of width and stuff in favor of style provided by user + // TODO: get rid of width and stuff in favor of style provided by user? width: '800px', height: '300px', // makes the table actually size itself, removing them makes it grow continuously on render display: 'block', position: 'relative', - // Make the table overflow so that columns are included in the scrolling. TableView makes the body scrollable + // Make the table overflow so that columns are included in the scrolling. TableView makes the body scrollable via overflow: 'auto' }}> {collection.headerRows.map(headerRow => ( @@ -141,18 +148,12 @@ export function Table(props) { {[...collection.body.childNodes].map(row => ( @@ -168,38 +169,47 @@ export function Table(props) { ); } +// done // Needs to be forward ref for bodyRef -export const TableRowGroup = React.forwardRef((props, ref) => { +const TableRowGroup = React.forwardRef((props, ref) => { let {type: Element, style, children} = props; let {rowGroupProps} = useTableRowGroup(); return ( - + {children} ); }); -function TableHeaderRow({item, state, children, style = {}}) { +// done +function TableHeaderRow({item, state, children}) { let ref = useRef(); let {rowProps} = useTableHeaderRow({node: item}, state, ref); - return ( - + // Override default tr display + {children} ); } -function Resizer({column, state, layoutState, onResizeStart, onResize, onResizeEnd}) { - let ref = useRef(null); + +const Resizer = React.forwardRef((props, ref) => { + let {column, layoutState, onResizeStart, onResize, onResizeEnd, triggerRef} = props; let {resizerProps, inputProps} = useTableColumnResize({ column, label: 'Resizer', onResizeStart, onResize, onResizeEnd, - tableState: state + triggerRef }, layoutState, ref); return ( @@ -213,7 +223,8 @@ function Resizer({column, state, layoutState, onResizeStart, onResize, onResizeE height: '20px', border: '1px solid red', touchAction: 'none', - flex: '0 0 auto' + flex: '0 0 auto', + boxSizing: 'border-box' }} {...resizerProps}> @@ -226,15 +237,112 @@ function Resizer({column, state, layoutState, onResizeStart, onResize, onResizeE ); -} +}); +// TODO add menu if resize is allowed +// TODO: fix keyboard navigation between columns. +// right now arrow right will move from a column menu button to the resizer because the resizer is focusable and usegridCell will think it should be the next child to be focused +// second issue is that I can't go from column to column via left arrow export function TableColumnHeader({column, state, widths, layoutState, onResizeStart, onResize, onResizeEnd}) { - let ref = useRef(); + let ref = useRef(null); + let resizerRef = useRef(null); + let triggerRef = useRef(null); let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); let {isFocusVisible, focusProps} = useFocusRing(); + // TODO test if sorting still works let arrowIcon = state.sortDescriptor?.direction === 'ascending' ? '▲' : '▼'; // TODO: figure out why the resizer is being focused, how is TableView omitting it from being one of the children that // useGridCell would try to focus + + // TODO: ask if I should even accomadate sorting in this example + let allowsSorting = column.props?.allowsSorting; + let allowsResizing = column.props.allowsResizing; + + const onMenuSelect = (key) => { + switch (key) { + case 'sort-asc': + state.sort(column.key, 'ascending'); + break; + case 'sort-desc': + state.sort(column.key, 'descending'); + break; + case 'resize': + layoutState.onColumnResizeStart(column.key); + if (resizerRef) { + setTimeout(() => resizerRef.current.focus(), 0); + } + break; + } + }; + + let items = useMemo(() => { + let options = [ + allowsSorting ? { + label: 'Sort ascending', + id: 'sort-asc' + } : undefined, + allowsSorting ? { + label: 'Sort descending', + id: 'sort-desc' + } : undefined, + { + label: 'Resize column', + id: 'resize' + } + ]; + return options; + }, [allowsSorting]); + + let contents = allowsResizing ? ( + <> + + {(item) => ( + + {item.label} + + )} + + {column.props.allowsSorting && + + } + + + ) : + ( +
+ + {column.rendered} + {column.props.allowsSorting && + + } +
+ ); + + return ( 1 ? 'center' : 'left', padding: '5px 10px', // TODO switch to box shadow? - outline: isFocusVisible ? '2px solid orange' : 'none', + // outline: isFocusVisible ? '2px solid orange' : 'none', + outline: 'none', + boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', cursor: 'default', // New stuff width: widths.get(column.key), @@ -254,30 +364,14 @@ export function TableColumnHeader({column, state, widths, layoutState, onResizeS ref={ref}> {/* TODO: make resizer triggerable via keyboard, maybe make a menutrigger for hitting enter on the tablecolumn header */}
-
- {column.rendered} - {column.props.allowsSorting && - - } -
- { - column.props.allowsResizing && - - } + {contents}
); } -export function TableRow({item, children, state, style}) { +// done +export function TableRow({item, children, state}) { let ref = useRef(); let isSelected = state.selectionManager.isSelected(item.key); let {rowProps, isPressed} = useTableRow({ @@ -296,9 +390,13 @@ export function TableRow({item, children, state, style}) { ? 'var(--spectrum-alias-highlight-hover)' : 'none', color: isSelected ? 'white' : null, - // TODO: Switch to box shadow - outline: isFocusVisible ? '2px solid orange' : 'none', - ...style + // TODO: Switch to box shadow, new stuff + // outline: isFocusVisible ? '2px solid orange' : 'none', + outline: 'none', + boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', + display: 'flex', + // Make the row extend pass the table width so the background is consistent + width: 'fit-content' }} {...mergeProps(rowProps, focusProps)} ref={ref}> @@ -307,22 +405,24 @@ export function TableRow({item, children, state, style}) { ); } +// done export function TableCell({cell, state, widths}) { let ref = useRef(); let {gridCellProps} = useTableCell({node: cell}, state, ref); let {isFocusVisible, focusProps} = useFocusRing(); let column = cell.column; - // TODO: why is this isSelected a thing - // This is here since the table is now a fixed width with overflow, so it is relying on - let isSelected = state.selectionManager.isSelected(cell.parentKey); return ( {state.selectionManager.selectionMode === 'single' @@ -385,5 +489,114 @@ function Checkbox(props) { return ; } +const MenuButton = React.forwardRef((props, ref) => { + // Create state based on the incoming props + let state = useMenuTriggerState(props); + + // Get props for the button and menu elements + let {menuTriggerProps, menuProps} = useMenuTrigger({}, state, ref); + + return ( + <> + + {state.isOpen && + + + + } + + ); +}); + + +function Menu(props) { + // Create menu state based on the incoming props + let state = useTreeState(props); + + // Get props for the menu element + let ref = React.useRef(); + let {menuProps} = useMenu(props, state, ref); + + return ( +
    + {[...state.collection].map(item => ( + item.type === 'section' + ? + : + ))} +
+ ); +} + +function MenuItem({item, state}) { + // Get props for the menu item element + let ref = React.useRef(); + let {menuItemProps, isFocused, isSelected, isDisabled} = useMenuItem({key: item.key}, state, ref); -// // TODO add menu button + return ( +
  • + {item.rendered} + {isSelected && } +
  • + ); +} + +function Popover({children, state, ...props}) { + let ref = React.useRef(); + let {popoverRef = ref, triggerRef} = props; + let {popoverProps, underlayProps} = usePopover({ + ...props, + popoverRef, + triggerRef + }, state); + + return ( + +
    +
    + + {children} + +
    + + ); +} + +function Button(props) { + let ref = props.buttonRef; + let {buttonProps} = useButton(props, ref); + return ; +} From dd43fa4c4725eb18c2de1e0e420ad6b823475bf0 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 26 Jan 2023 17:18:12 -0800 Subject: [PATCH 03/64] fixing it so user doesnt focus the resizer immediately when using left/right arrows still a bit iffy, cant seem to get focus to move between the columns, def something with the menutrigger... --- .../@react-aria/table/stories/example-docs.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx index 83034194925..f1ca8ac7222 100644 --- a/packages/@react-aria/table/stories/example-docs.tsx +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -13,6 +13,7 @@ // TODO: don't need this, replace with styles import {classNames} from '@react-spectrum/utils'; import {DismissButton, Overlay, usePopover} from '@react-aria/overlays'; +import {getInteractionModality, useHover} from '@react-aria/interactions'; import {FocusRing, useFocusRing} from '@react-aria/focus'; import {Item} from '@react-stately/collections'; import {mergeProps, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; @@ -61,8 +62,6 @@ export function Table(props) { ref ); - // TODO: look at the CSS for the resizing example, replace classnames with style - // Table column resizing stuff let [tableWidth, setTableWidth] = useState(0); let getDefaultWidth = useCallback((node) => { @@ -202,7 +201,7 @@ function TableHeaderRow({item, state, children}) { const Resizer = React.forwardRef((props, ref) => { - let {column, layoutState, onResizeStart, onResize, onResizeEnd, triggerRef} = props; + let {column, layoutState, onResizeStart, onResize, onResizeEnd, triggerRef, showResizer} = props; let {resizerProps, inputProps} = useTableColumnResize({ column, label: 'Resizer', @@ -224,7 +223,8 @@ const Resizer = React.forwardRef((props, ref) => { border: '1px solid red', touchAction: 'none', flex: '0 0 auto', - boxSizing: 'border-box' + boxSizing: 'border-box', + visibility: showResizer ? 'visible' : 'hidden' }} {...resizerProps}> @@ -239,7 +239,6 @@ const Resizer = React.forwardRef((props, ref) => { ); }); -// TODO add menu if resize is allowed // TODO: fix keyboard navigation between columns. // right now arrow right will move from a column menu button to the resizer because the resizer is focusable and usegridCell will think it should be the next child to be focused // second issue is that I can't go from column to column via left arrow @@ -249,10 +248,10 @@ export function TableColumnHeader({column, state, widths, layoutState, onResizeS let triggerRef = useRef(null); let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); let {isFocusVisible, focusProps} = useFocusRing(); + let {hoverProps, isHovered} = useHover({}); + let showResizer = isHovered || layoutState.resizingColumn === column.key; // TODO test if sorting still works let arrowIcon = state.sortDescriptor?.direction === 'ascending' ? '▲' : '▼'; -// TODO: figure out why the resizer is being focused, how is TableView omitting it from being one of the children that -// useGridCell would try to focus // TODO: ask if I should even accomadate sorting in this example let allowsSorting = column.props?.allowsSorting; @@ -321,7 +320,7 @@ export function TableColumnHeader({column, state, widths, layoutState, onResizeS {arrowIcon} } - + ) : ( @@ -345,7 +344,7 @@ export function TableColumnHeader({column, state, widths, layoutState, onResizeS return ( 1 ? 'center' : 'left', @@ -362,7 +361,6 @@ export function TableColumnHeader({column, state, widths, layoutState, onResizeS boxSizing: 'border-box' }} ref={ref}> - {/* TODO: make resizer triggerable via keyboard, maybe make a menutrigger for hitting enter on the tablecolumn header */}
    {contents}
    From 82c93d3c0441f52b2a655382ffc54e1e09cbe212 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 27 Jan 2023 14:11:36 -0800 Subject: [PATCH 04/64] making column resize actually get called if provided --- .../@react-stately/table/src/useTableColumnResizeState.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 54021dd3aaf..b57332655df 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -64,6 +64,7 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< getDefaultMinWidth, onColumnResizeStart: propsOnColumnResizeStart, onColumnResizeEnd: propsOnColumnResizeEnd, + onColumnResize: propsOnColumnResize, tableWidth = 0 } = props; @@ -101,9 +102,9 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< let map = new Map(Array.from(uncontrolledColumns).map(([key]) => [key, newSizes.get(key)])); map.set(key, width); setUncontrolledWidths(map); - + propsOnColumnResize(newSizes); return newSizes; - }, [controlledColumns, uncontrolledColumns, setUncontrolledWidths, tableWidth, columnLayout, state.collection, uncontrolledWidths]); + }, [controlledColumns, uncontrolledColumns, setUncontrolledWidths, tableWidth, columnLayout, state.collection, uncontrolledWidths, propsOnColumnResize]); let onColumnResizeEnd = useCallback((key: Key) => { setResizingColumn(null); From 9d16cdda3bcf4b075e49b317e15938eb04e8e3be Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 27 Jan 2023 15:04:25 -0800 Subject: [PATCH 05/64] fixing bugs, testing various cases, adding description to useTableColumnResizeState --- .../table/stories/example-docs.tsx | 24 ++- .../table/stories/useTable.stories.tsx | 161 +++++++++++++++--- .../table/src/useTableColumnResizeState.ts | 11 +- 3 files changed, 154 insertions(+), 42 deletions(-) diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx index f1ca8ac7222..38b41693251 100644 --- a/packages/@react-aria/table/stories/example-docs.tsx +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -13,7 +13,6 @@ // TODO: don't need this, replace with styles import {classNames} from '@react-spectrum/utils'; import {DismissButton, Overlay, usePopover} from '@react-aria/overlays'; -import {getInteractionModality, useHover} from '@react-aria/interactions'; import {FocusRing, useFocusRing} from '@react-aria/focus'; import {Item} from '@react-stately/collections'; import {mergeProps, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; @@ -21,6 +20,7 @@ import React, {useCallback, useState} from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; import {useButton} from '@react-aria/button'; import {useCheckbox} from '@react-aria/checkbox'; +import {useHover} from '@react-aria/interactions'; import {useMemo, useRef} from 'react'; import {useMenu} from '@react-aria/menu'; import {useMenuItem} from '@react-aria/menu'; @@ -81,7 +81,10 @@ export function Table(props) { let layoutState = useTableColumnResizeState({ getDefaultWidth, getDefaultMinWidth, - tableWidth + tableWidth, + onColumnResize: props.onColumnResize, + onColumnResizeEnd: props.onColumnResizeEnd, + onColumnResizeStart: props.onColumnResizeStart }, state); let {widths} = layoutState; @@ -96,7 +99,6 @@ export function Table(props) { } }, []); useResizeObserver({ref, onResize: () => setTableWidth(bodyRef.current.clientWidth)}); - // console.log('table width', tableWidth, widths) // TODO: move certain stylings into the components themselves return ( @@ -210,7 +212,7 @@ const Resizer = React.forwardRef((props, ref) => { onResizeEnd, triggerRef }, layoutState, ref); - + // TODO: update the look of the resizer in general (get rid of FocusRing also, just use focusVisible) return ( <> @@ -239,9 +241,7 @@ const Resizer = React.forwardRef((props, ref) => { ); }); -// TODO: fix keyboard navigation between columns. -// right now arrow right will move from a column menu button to the resizer because the resizer is focusable and usegridCell will think it should be the next child to be focused -// second issue is that I can't go from column to column via left arrow + export function TableColumnHeader({column, state, widths, layoutState, onResizeStart, onResize, onResizeEnd}) { let ref = useRef(null); let resizerRef = useRef(null); @@ -250,10 +250,7 @@ export function TableColumnHeader({column, state, widths, layoutState, onResizeS let {isFocusVisible, focusProps} = useFocusRing(); let {hoverProps, isHovered} = useHover({}); let showResizer = isHovered || layoutState.resizingColumn === column.key; - // TODO test if sorting still works let arrowIcon = state.sortDescriptor?.direction === 'ascending' ? '▲' : '▼'; - - // TODO: ask if I should even accomadate sorting in this example let allowsSorting = column.props?.allowsSorting; let allowsResizing = column.props.allowsResizing; @@ -492,12 +489,13 @@ const MenuButton = React.forwardRef((props, ref) => { let state = useMenuTriggerState(props); // Get props for the button and menu elements - let {menuTriggerProps, menuProps} = useMenuTrigger({}, state, ref); - + let {menuTriggerProps, menuProps} = useMenuTrigger({trigger: 'press'}, state, ref); + // Continue propagation of keydown event so that useSelectableCollection can properly recieve the bubbled left/right arrowkey presses + let onKeyDown = (e) => e.continuePropagation(); return ( <> + {state.isOpen && + + + + } + + ); +}); +``` + +Menu +
    + Show code + +```tsx example export=true render=false +function Menu(props) { + // Create menu state based on the incoming props + let state = useTreeState(props); + + // Get props for the menu element + let ref = React.useRef(); + let {menuProps} = useMenu(props, state, ref); + + return ( +
      + {[...state.collection].map(item => ( + item.type === 'section' + ? + : + ))} +
    + ); +} +``` + +MenuItem + +
    + Show code + +```tsx example export=true render=false +function MenuItem({item, state}) { + // Get props for the menu item element + let ref = React.useRef(); + let {menuItemProps, isFocused, isSelected, isDisabled} = useMenuItem({key: item.key}, state, ref); + + return ( +
  • + {item.rendered} + {isSelected && } +
  • + ); +} +``` + +Popover + +
    + Show code + +```tsx example export=true render=false +function Popover({children, state, ...props}) { + let ref = React.useRef(); + let {popoverRef = ref, triggerRef} = props; + let {popoverProps, underlayProps} = usePopover({ + ...props, + popoverRef, + triggerRef + }, state); + + return ( + +
    +
    + + {children} + +
    + + ); +} +``` + +Button +// TODO: add text snippets for these sections, add imports for all these things (useButton etc), add import 'from-component-library' + +
    + Show code + +```tsx example export=true render=false +function Button(props) { + let ref = props.buttonRef; + let {buttonProps} = useButton(props, ref); + return ; +} +``` ## Usage diff --git a/packages/@react-stately/table/docs/useTableColumnResizeState.mdx b/packages/@react-stately/table/docs/useTableColumnResizeState.mdx index 51083a6df99..4988cd723f9 100644 --- a/packages/@react-stately/table/docs/useTableColumnResizeState.mdx +++ b/packages/@react-stately/table/docs/useTableColumnResizeState.mdx @@ -33,7 +33,6 @@ keywords: [table, state, grid] ## Interface -### ## Example diff --git a/packages/@react-stately/table/docs/useTableState.mdx b/packages/@react-stately/table/docs/useTableState.mdx index db81e6c8bd5..2175aabc78a 100644 --- a/packages/@react-stately/table/docs/useTableState.mdx +++ b/packages/@react-stately/table/docs/useTableState.mdx @@ -30,7 +30,6 @@ keywords: [table, state, grid] ## API - @@ -39,13 +38,9 @@ keywords: [table, state, grid] ## Interface -TODO: add table column resize state here or make a new page for table column reisze state? -### ## Example See the docs for [useTable](/react-aria/useTable.html) in react-aria for an example of `useTableState`, `Cell`, `Column`, `Row`, `TableBody`, and `TableHeader`. - -TODO: add table column resize state From 77d58ca23aba9654069045dd9572bf4a77409fc9 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 30 Jan 2023 13:21:10 -0800 Subject: [PATCH 09/64] fix docs example render issues --- packages/@react-aria/table/docs/useTable.mdx | 397 +++++++++++-------- 1 file changed, 229 insertions(+), 168 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 60e93388908..68d66c2f417 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -33,7 +33,7 @@ keywords: [table, aria, grid] @@ -48,9 +48,12 @@ keywords: [table, aria, grid] + ## Features +// TODO: talk about table column resizing + A table can be built using the [<table>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/table), [<tr>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/tr), [<td>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td), and other table specific HTML elements, but is very limited in functionality especially when it comes to user interactions. HTML tables are meant for static content, rather than tables with rich interactions like focusable elements within cells, keyboard navigation, row selection, sorting, etc. @@ -487,7 +490,7 @@ function Checkbox(props) { let ref = React.useRef(); let state = useToggleState(props); let {inputProps} = useCheckbox(props, state, ref); - return ; + return ; } ``` @@ -537,7 +540,7 @@ function ResizableColumnsTable(props) { ); /*- begin highlight -*/ - let getDefaultWidth = useCallback((node) => { + let getDefaultWidth = React.useCallback((node) => { // selection cell column should always take up a specific width, doesn't need to be resizable if (node.props.isSelectionCell) { return 20; @@ -545,7 +548,7 @@ function ResizableColumnsTable(props) { return; }, []); - let getDefaultMinWidth = useCallback((node) => { + let getDefaultMinWidth = React.useCallback((node) => { // selection cell column should always take up a specific width, doesn't need to be resizable if (node.props.isSelectionCell) { return 20; @@ -677,7 +680,7 @@ function ResizableTableRowGroup({type: Element, style, children}) { ``` ```tsx example export=true render=false -function TableHeaderRow({item, state, children}) { +function ResizableTableHeaderRow({item, state, children}) { let ref = useRef(); let {rowProps} = useTableHeaderRow({node: item}, state, ref); @@ -695,9 +698,12 @@ function TableHeaderRow({item, state, children}) { } ``` +// TODO import stuff from component library like Resizer and stuff, add highlighting ```tsx example export=true render=false -function ResizableTableColumnHeader({column, state}) { -let ref = useRef(null); +import {useHover} from '@react-aria/interactions'; + +function ResizableTableColumnHeader({column, state, widths, layoutState, onResizeStart, onResize, onResizeEnd}) { + let ref = useRef(null); let resizerRef = useRef(null); let triggerRef = useRef(null); let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); @@ -725,7 +731,7 @@ let ref = useRef(null); } }; - let items = useMemo(() => { + let items = React.useMemo(() => { let options = [ allowsSorting ? { label: 'Sort ascending', @@ -820,140 +826,13 @@ let ref = useRef(null); } ``` -### Resizable table body - -```tsx example export=true render=false -function ResizableTableRow({item, children, state}) { - let ref = useRef(); - let isSelected = state.selectionManager.isSelected(item.key); - let {rowProps, isPressed} = useTableRow({ - node: item - }, state, ref); - let {isFocusVisible, focusProps} = useFocusRing(); - - return ( - - {children} - - ); -} -``` - -```tsx example export=true render=false -function ResizableTableCell({cell, state, widths}) { - let ref = useRef(); - let {gridCellProps} = useTableCell({node: cell}, state, ref); - let {isFocusVisible, focusProps} = useFocusRing(); - - return ( - - {cell.rendered} - - ); -} -``` - -### Checkbox changes (refine section title) - -```tsx example export=true render=false -function ResizableTableCheckboxCell({cell, state, widths}) { - let ref = useRef(); - let {gridCellProps} = useTableCell({node: cell}, state, ref); - let {checkboxProps} = useTableSelectionCheckbox({key: cell.parentKey}, state); - let column = cell.column; - - return ( - - - - ); -} -``` - -```tsx example export=true render=false -function ResizableTableSelectAllCell({column, state, width}) { - let ref = useRef(); - let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); - let {checkboxProps} = useTableSelectAllCheckbox(state); - - return ( - - {state.selectionManager.selectionMode === 'single' - ? {checkboxProps['aria-label']} - : - } - - ); -} -``` - -TODO: collapse the below or not? - -
    - Show code - -```tsx example export=true render=false -function Checkbox(props) { - let ref = React.useRef(); - let state = useToggleState(props); - let {inputProps} = useCheckbox(props, state, ref); - // TODO: highlight the below - return ; -} -``` - ### Resizer TODO: move to different place? Closer to where it is actually being used ```tsx example export=true render=false +import {useTableColumnResize} from '@react-aria/table'; + const Resizer = React.forwardRef((props, ref) => { let {column, layoutState, onResizeStart, onResize, onResizeEnd, triggerRef, showResizer} = props; let {resizerProps, inputProps} = useTableColumnResize({ @@ -966,41 +845,43 @@ const Resizer = React.forwardRef((props, ref) => { }, layoutState, ref); // TODO: update the look of the resizer in general (get rid of FocusRing also, just use focusVisible) return ( - <> - -
    - - - -
    -
    - +
    + + + +
    ); }); ``` -### Column header menu -MenuButton +### MenuButton + +The `MenuButton` is rendered +
    Show code ```tsx example export=true render=false +import {useMenuTriggerState} from '@react-stately/menu'; +import {useMenuTrigger} from '@react-aria/menu'; +import {Item} from '@react-stately/collections'; + const MenuButton = React.forwardRef((props, ref) => { // Create state based on the incoming props let state = useMenuTriggerState(props); @@ -1029,11 +910,20 @@ const MenuButton = React.forwardRef((props, ref) => { }); ``` -Menu +
    + +### Menu + +The `Menu` is used to display the various actions available to the column, rendered with a `Popover` when the user presses the column header. +This is built using the [useMenu](useMenu.html) and [useTreeState](/react-stately/useTreeState.html) hooks. +
    Show code ```tsx example export=true render=false +import {useMenu} from '@react-aria/menu'; +import {useTreeState} from '@react-stately/tree'; + function Menu(props) { // Create menu state based on the incoming props let state = useTreeState(props); @@ -1062,12 +952,18 @@ function Menu(props) { } ``` -MenuItem +
    + +### MenuItem + +The `MenuItem` is used to render the items in the menu. This is built using the [useMenuItem](useMenu.html) hook.
    Show code ```tsx example export=true render=false +import {useMenuItem} from '@react-aria/menu'; + function MenuItem({item, state}) { // Get props for the menu item element let ref = React.useRef(); @@ -1093,12 +989,21 @@ function MenuItem({item, state}) { } ``` -Popover +
    + +### Popover + +The `Popover` component is used to contain the menu. +It can be shared between many other components, including [ComboBox](useComboBox.html), +[Select](useSelect.html), and others. +See [usePopover](usePopover.html) for more examples of popovers.
    Show code ```tsx example export=true render=false +import {usePopover, Overlay, DismissButton} from '@react-aria/overlays'; + function Popover({children, state, ...props}) { let ref = React.useRef(); let {popoverRef = ref, triggerRef} = props; @@ -1128,13 +1033,18 @@ function Popover({children, state, ...props}) { } ``` -Button -// TODO: add text snippets for these sections, add imports for all these things (useButton etc), add import 'from-component-library' +
    + +### Button + +The `Button` component is used in the above example to toggle the menu. It is built using the [useButton](useButton.html) hook, and can be shared with many other components.
    Show code ```tsx example export=true render=false +import {useButton} from '@react-aria/button'; + function Button(props) { let ref = props.buttonRef; let {buttonProps} = useButton(props, ref); @@ -1142,6 +1052,157 @@ function Button(props) { } ``` +
    + + +### Resizable table body + +```tsx example export=true render=false +function ResizableTableRow({item, children, state}) { + let ref = useRef(); + let isSelected = state.selectionManager.isSelected(item.key); + let {rowProps, isPressed} = useTableRow({ + node: item + }, state, ref); + let {isFocusVisible, focusProps} = useFocusRing(); + + return ( + + {children} + + ); +} +``` + +```tsx example export=true render=false +function ResizableTableCell({cell, state, widths}) { + let ref = useRef(); + let {gridCellProps} = useTableCell({node: cell}, state, ref); + let {isFocusVisible, focusProps} = useFocusRing(); + let column = cell.column; + + return ( + + {cell.rendered} + + ); +} +``` + +### Checkbox changes (refine section title) + +```tsx example export=true render=false +function ResizableTableCheckboxCell({cell, state, widths}) { + let ref = useRef(); + let {gridCellProps} = useTableCell({node: cell}, state, ref); + let {checkboxProps} = useTableSelectionCheckbox({key: cell.parentKey}, state); + let column = cell.column; + + return ( + + + + ); +} +``` + +```tsx example export=true render=false +function ResizableTableSelectAllCell({column, state, widths}) { + let ref = useRef(); + let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); + let {checkboxProps} = useTableSelectAllCheckbox(state); + + return ( + + {state.selectionManager.selectionMode === 'single' + ? {checkboxProps['aria-label']} + : + } + + ); +} +``` + +```tsx example + + + Name + Type + Level + + + + Charizard + Fire, Flying + 67 + + + Blastoise + Water + 56 + + + Venusaur + Grass, Poison + 83 + + + Pikachu + Electric + 100 + + + +``` + + ## Usage ### Dynamic collections From 5f6fb07b8f3f5ffa6c57665f157b113bf5743f26 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 30 Jan 2023 13:31:30 -0800 Subject: [PATCH 10/64] remove onColumnResize/Start/End from useTableColumnResizeState these are unecessary since the state returns the resizing column already and we have onResize handlers in useTableColumnResize --- .../table/src/useTableColumnResize.ts | 2 +- .../table/src/useTableColumnResizeState.ts | 24 +++++-------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index aadcc565716..2e2ac7301dd 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -87,7 +87,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st lastSize.current = state.onColumnResize(item.key, state.getColumnWidth(item.key)); } if (isResizing.current) { - state.onColumnResizeEnd(item.key); + state.onColumnResizeEnd(); onResizeEnd?.(lastSize.current); } isResizing.current = false; diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index b02b17de7bd..9fee2bd8e02 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -25,13 +25,7 @@ export interface TableColumnResizeStateProps { /** 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 + getDefaultMinWidth?: (node: GridNode) => ColumnSize | null | undefined } export interface TableColumnResizeState { /** @@ -42,7 +36,7 @@ export interface TableColumnResizeState { /** Callback for when onColumnResize has started. */ onColumnResizeStart: (key: Key) => void, /** Callback for when onColumnResize has ended. */ - onColumnResizeEnd: (key: Key) => void, + onColumnResizeEnd: () => void, /** Gets the current width for the specified column. */ getColumnWidth: (key: Key) => number, /** Gets the current minWidth for the specified column. */ @@ -62,9 +56,6 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< let { getDefaultWidth, getDefaultMinWidth, - onColumnResizeStart: propsOnColumnResizeStart, - onColumnResizeEnd: propsOnColumnResizeEnd, - onColumnResize: propsOnColumnResize, tableWidth = 0 } = props; @@ -92,8 +83,7 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< let onColumnResizeStart = useCallback((key: Key) => { setResizingColumn(key); - propsOnColumnResizeStart?.(key); - }, [propsOnColumnResizeStart, setResizingColumn]); + }, [setResizingColumn]); let onColumnResize = useCallback((key: Key, width: number): Map => { let newControlled = new Map(Array.from(controlledColumns).map(([key, entry]) => [key, entry.props.width])); @@ -102,14 +92,12 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< let map = new Map(Array.from(uncontrolledColumns).map(([key]) => [key, newSizes.get(key)])); map.set(key, width); setUncontrolledWidths(map); - propsOnColumnResize?.(newSizes); return newSizes; - }, [controlledColumns, uncontrolledColumns, setUncontrolledWidths, tableWidth, columnLayout, state.collection, uncontrolledWidths, propsOnColumnResize]); + }, [controlledColumns, uncontrolledColumns, setUncontrolledWidths, tableWidth, columnLayout, state.collection, uncontrolledWidths]); - let onColumnResizeEnd = useCallback((key: Key) => { + let onColumnResizeEnd = useCallback(() => { setResizingColumn(null); - propsOnColumnResizeEnd?.(key); - }, [propsOnColumnResizeEnd, setResizingColumn]); + }, [setResizingColumn]); let columnWidths = useMemo(() => columnLayout.buildColumnWidths(tableWidth, state.collection, colWidths) From 9cb87b38132a260f5f852f8b9ce70febe29ffcc8 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 30 Jan 2023 17:04:18 -0800 Subject: [PATCH 11/64] docs first draft --- packages/@react-aria/table/docs/useTable.mdx | 182 ++++++++++++++---- .../table/stories/example-docs.tsx | 34 ++-- .../table/src/useTableColumnResizeState.ts | 2 +- 3 files changed, 158 insertions(+), 60 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 68d66c2f417..b3447a60886 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -52,8 +52,6 @@ keywords: [table, aria, grid] ## Features -// TODO: talk about table column resizing - A table can be built using the [<table>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/table), [<tr>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/tr), [<td>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td), and other table specific HTML elements, but is very limited in functionality especially when it comes to user interactions. HTML tables are meant for static content, rather than tables with rich interactions like focusable elements within cells, keyboard navigation, row selection, sorting, etc. @@ -77,6 +75,7 @@ HTML tables are meant for static content, rather than tables with rich interacti * Ensures that selections are announced using an ARIA live region * Support for using HTML table elements, or custom element types (e.g. `
    `) for layout flexibility * Virtualized scrolling support for performance with large tables +* Support for resizable columns ## Anatomy @@ -245,7 +244,8 @@ function TableColumnHeader({column, state}) { style={{ textAlign: column.colspan > 1 ? 'center' : 'left', padding: '5px 10px', - outline: isFocusVisible ? '2px solid orange' : 'none', + outline: 'none', + boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', cursor: 'default' }} ref={ref}> @@ -294,7 +294,8 @@ function TableRow({item, children, state}) { ? 'var(--spectrum-alias-highlight-hover)' : 'none', color: isSelected ? 'white' : null, - outline: isFocusVisible ? '2px solid orange' : 'none' + outline: 'none', + boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', }} {...mergeProps(rowProps, focusProps)} ref={ref}> @@ -323,7 +324,8 @@ function TableCell({cell, state}) { {...mergeProps(gridCellProps, focusProps)} style={{ padding: '5px 10px', - outline: isFocusVisible ? '2px solid orange' : 'none', + outline: 'none', + boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', cursor: 'default' }} ref={ref}> @@ -498,13 +500,26 @@ function Checkbox(props) { ## Resizable Columns -// Introduce useTableColumnResize and useTableColumnResizeState and say what they do +For resizable column support, two additional hooks need to be added to the table implementation above. The +hook from `@react-stately/table` is responsible for initializing and tracking the widths of every column in your table, returning functions that you can use to +update the column widths during a column resize operation. Note that this state is supplementary to the state returned by . -// highlight the changes made to the previous table implementation (might want to collapse some of them) +The second column resizing hook is . This hook handles the interactions for the a table column's resizer +element, allowing the user to drag the resizer or use the keyboard arrows to expand the column's width. Be sure to pass the state returned by +to this hook so the tracked widths can be updated appropriately. We'll walk through all the changes to the previous table implementation step by step below. ### Table -TODO: rename everything so the imports dont clash +As mentioned previously, we first need to call to initialize and update the widths for our table's columns. +By providing the hook with `getDefaultMinWidth` and `getDefaultWidth`, we can give our table selection checkbox column a pre-determined width while allowing the other columns divide up the remaining +width equally initially. We'll pass the state returned by along with any user defined `onResize` handlers +to our `ResizableTableColumnHeaders` so it can be used by . The `widths` from this state are provided to each table element so +the table cells in the body match the columns at all times. + +The various style changes below are to make the table itself scrollable and to support table body/column widths greater than the 400px applied to the table itself. + +
    + Show code ```tsx example export=true render=false /*- begin highlight -*/ @@ -532,6 +547,7 @@ function ResizableColumnsTable(props) { { ...props, /*- begin highlight -*/ + // The table itself is scrollable rather than just the body scrollRef: ref /*- end highlight -*/ }, @@ -559,7 +575,6 @@ function ResizableColumnsTable(props) { let layoutState = useTableColumnResizeState({ getDefaultWidth, getDefaultMinWidth, - // TODO: Match with the Table's width, is it too complicated to add the useResizeObserver code to measure the actual width? tableWidth: 400 }, state); let {widths} = layoutState; @@ -608,7 +623,6 @@ function ResizableColumnsTable(props) { column={column} state={state} /*- begin highlight -*/ - widths={widths} layoutState={layoutState} onResizeStart={onResizeStart} onResize={onResize} @@ -658,9 +672,17 @@ function ResizableColumnsTable(props) { } ``` +
    + ### Resizable table header +The `TableRowGroup` and `TableHeaderRow` only require minor style changes to accommodate the the new `display` changes made in +`ResizableColumnsTable`. + +
    + Show code + ```tsx example export=true render=false function ResizableTableRowGroup({type: Element, style, children}) { let {rowGroupProps} = useTableRowGroup(); @@ -679,6 +701,11 @@ function ResizableTableRowGroup({type: Element, style, children}) { } ``` +
    + +
    + Show code + ```tsx example export=true render=false function ResizableTableHeaderRow({item, state, children}) { let ref = useRef(); @@ -698,11 +725,27 @@ function ResizableTableHeaderRow({item, state, children}) { } ``` -// TODO import stuff from component library like Resizer and stuff, add highlighting +
    + +The `TableColumnHeader` is where we see the bulk of the changes required to support resizable columns. First of all, we need to accommodate a `Resizer` element in every resizable column that +the user can drag or focus to perform a resize operation. To avoid interfering with the existing grid keyboard navigation, we only display this resizer when +the column is hovered or if the column itself is being resized. This however introduces another issue: how will keyboard, screen reader, and touch users begin a resize operation if the resizer +is only visible on hover? We can't just have the user press on the column header since that action is reserved for sorting. + +To resolve this, we need to render a menu button as the column header if the column is resizable, allowing non-mouse users to trigger any available action on the column. For resizing, this would +cause focus to shift to the resizer itself, displaying the resizer for touch screen users to drag and letting keyboard and screen readers resize the column via keyboard arrows and screenreader operations +accordingly. + +
    + Show code + ```tsx example export=true render=false +// Reuse the MenuButton from your component library. See below for details. +import {MenuButton} from 'your-component-library'; import {useHover} from '@react-aria/interactions'; -function ResizableTableColumnHeader({column, state, widths, layoutState, onResizeStart, onResize, onResizeEnd}) { +function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, onResize, onResizeEnd}) { + let {widths} = layoutState; let ref = useRef(null); let resizerRef = useRef(null); let triggerRef = useRef(null); @@ -749,6 +792,12 @@ function ResizableTableColumnHeader({column, state, widths, layoutState, onResiz return options; }, [allowsSorting]); + let sortIcon = ( + + ); + let contents = allowsResizing ? ( <> )} - {column.props.allowsSorting && - - } + {column.props.allowsSorting && sortIcon} ) : @@ -790,15 +835,10 @@ function ResizableTableColumnHeader({column, state, widths, layoutState, onResiz }}> {column.rendered} - {column.props.allowsSorting && - - } + {column.props.allowsSorting && sortIcon}
    ); - return ( 1 ? 'center' : 'left', padding: '5px 10px', - // TODO switch to box shadow? - // outline: isFocusVisible ? '2px solid orange' : 'none', outline: 'none', boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', cursor: 'default', - // New stuff width: widths.get(column.key), display: 'block', flex: '0 0 auto', @@ -826,9 +863,20 @@ function ResizableTableColumnHeader({column, state, widths, layoutState, onResiz } ``` +
    + ### Resizer -TODO: move to different place? Closer to where it is actually being used +As described above, we need to implement an element that the user can drag/interact with to resize a column. Here we'll use the +hook to create a visible resizer div for physical drag operations and a visually hidden input responsible for keyboard and screenreader interactions, similar to a [slider](useSlider.html). +Users can press and drag on the visible resizer to trigger the `onResize` callbacks and update the tracked column widths accordingly. When focused, keyboard users can use the arrow keys to trigger the same +resize events and use Enter, Esc, or Space to exit resizing. Similarly, touch screen readers can swipe up and down to +resize the column and double tapping to trigger a virtual click exits resizing. + +Note that we apply `visibility: hidden` to hide the resizer so that we reserve space for it in the column header at all times. + +
    + Show code ```tsx example export=true render=false import {useTableColumnResize} from '@react-aria/table'; @@ -843,7 +891,7 @@ const Resizer = React.forwardRef((props, ref) => { onResizeEnd, triggerRef }, layoutState, ref); - // TODO: update the look of the resizer in general (get rid of FocusRing also, just use focusVisible) + // TODO: update the visual look of the resizer and have a focus style return (
    { }); ``` +
    ### MenuButton -The `MenuButton` is rendered +The `MenuButton` is used in the `ResizableTableColumnHeader` to display a list of available actions available to a column. It is built using the [useMenuTrigger](useMenu.html) +and [useTreeState](/react-stately/useTreeState.html) hooks, and can be shared with many other components. Note that you'll have to call `continuePropagation()` in `onKeyDown` +to properly bubble the `keydown` event up to `useSelectableCollection` for grid keyboard navigation.
    Show code @@ -882,14 +933,19 @@ import {useMenuTriggerState} from '@react-stately/menu'; import {useMenuTrigger} from '@react-aria/menu'; import {Item} from '@react-stately/collections'; +// Reuse the Button, Menu, and Popover from your component library. See below for details. +import {Button, Menu, Popover} from 'your-component-library'; + const MenuButton = React.forwardRef((props, ref) => { // Create state based on the incoming props let state = useMenuTriggerState(props); // Get props for the button and menu elements let {menuTriggerProps, menuProps} = useMenuTrigger({trigger: 'press'}, state, ref); + /*- begin highlight -*/ // Continue propagation of keydown event so that the left/right arrow key presses properly bubble to the table (useSelectableCollection) let onKeyDown = (e) => e.continuePropagation(); + /*- end highlight -*/ return ( <>
    - ### Resizable table body +Similar to `TableRowGroup` and `TableHeaderRow`, `TableRow` and `TableCell` only require minor style changes to +accommodate the the new `display` changes made in the `ResizableColumnsTable`. The changes to `TableRow` are made to extend the +background of the row to the full width of its child cells. `TableCell` now receives its width from +and handles text overflow. + +
    + Show code + ```tsx example export=true render=false function ResizableTableRow({item, children, state}) { let ref = useRef(); @@ -1079,9 +1143,10 @@ function ResizableTableRow({item, children, state}) { color: isSelected ? 'white' : null, outline: 'none', boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', + /*- begin highlight -*/ display: 'flex', - // Make the row extend pass the table width so the background is consistent width: 'fit-content' + /*- end highlight -*/ }} {...mergeProps(rowProps, focusProps)} ref={ref}> @@ -1091,12 +1156,19 @@ function ResizableTableRow({item, children, state}) { } ``` +
    + +
    + Show code + ```tsx example export=true render=false function ResizableTableCell({cell, state, widths}) { let ref = useRef(); let {gridCellProps} = useTableCell({node: cell}, state, ref); let {isFocusVisible, focusProps} = useFocusRing(); + /*- begin highlight -*/ let column = cell.column; + /*- end highlight -*/ return ( {cell.rendered} @@ -1122,30 +1195,53 @@ function ResizableTableCell({cell, state, widths}) { } ``` -### Checkbox changes (refine section title) +
    + +### Resizable checkbox + +The `TableCheckboxCell` and the `TableSelectAllCell` now have their widths dictated by the state returned by +and receive minor style changes to accommodate that. + +
    + Show code ```tsx example export=true render=false function ResizableTableCheckboxCell({cell, state, widths}) { let ref = useRef(); let {gridCellProps} = useTableCell({node: cell}, state, ref); let {checkboxProps} = useTableSelectionCheckbox({key: cell.parentKey}, state); + /*- begin highlight -*/ let column = cell.column; + /*- end highlight -*/ return ( - + + ); } ``` +
    + +
    + Show code + ```tsx example export=true render=false function ResizableTableSelectAllCell({column, state, widths}) { let ref = useRef(); @@ -1155,21 +1251,32 @@ function ResizableTableSelectAllCell({column, state, widths}) { return ( {state.selectionManager.selectionMode === 'single' ? {checkboxProps['aria-label']} + /*- begin highlight -*/ : + /*- end highlight -*/ } ); } ``` +
    + + +And with that, all necessary changes to the previous table implementation have been made and we now have a table that supports resizable columns! +The example below supports resizing via mouse, keyboard, touch, and screen reader interactions. Previous behaviors such as selection and sorting are +also still supported. + ```tsx example @@ -1202,7 +1309,6 @@ function ResizableTableSelectAllCell({column, state, widths}) { ``` - ## Usage ### Dynamic collections diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx index 4935267de77..4254365ddc6 100644 --- a/packages/@react-aria/table/stories/example-docs.tsx +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -15,8 +15,8 @@ import {classNames} from '@react-spectrum/utils'; import {DismissButton, Overlay, usePopover} from '@react-aria/overlays'; import {FocusRing, useFocusRing} from '@react-aria/focus'; import {Item} from '@react-stately/collections'; -import {mergeProps, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; -import React, {useCallback, useState} from 'react'; +import {mergeProps} from '@react-aria/utils'; +import React, {useCallback} from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; import {useButton} from '@react-aria/button'; import {useCheckbox} from '@react-aria/checkbox'; @@ -39,10 +39,6 @@ export function Table(props) { onResizeStart, onResize, onResizeEnd - // TODO: omit from aria example, unneeded really? - // onColumnResizeStart, - // onColumnResize, - // onColumnResizeEnd } = props; let state = useTableState({ ...props, @@ -85,10 +81,7 @@ export function Table(props) { getDefaultWidth, getDefaultMinWidth, // TODO: sync this since we aren't including the resize obs - tableWidth: 800, - // onColumnResize, - // onColumnResizeEnd, - // onColumnResizeStart + tableWidth: 800 }, state); let {widths} = layoutState; @@ -246,7 +239,8 @@ const Resizer = React.forwardRef((props, ref) => { }); -export function TableColumnHeader({column, state, widths, layoutState, onResizeStart, onResize, onResizeEnd}) { +export function TableColumnHeader({column, state, layoutState, onResizeStart, onResize, onResizeEnd}) { + let {widths} = layoutState; let ref = useRef(null); let resizerRef = useRef(null); let triggerRef = useRef(null); @@ -293,6 +287,12 @@ export function TableColumnHeader({column, state, widths, layoutState, onResizeS return options; }, [allowsSorting]); + let sortIcon = ( + + ); + let contents = allowsResizing ? ( <> )} - {column.props.allowsSorting && - - } + {column.props.allowsSorting && sortIcon} ) : @@ -334,11 +330,7 @@ export function TableColumnHeader({column, state, widths, layoutState, onResizeS }}> {column.rendered} - {column.props.allowsSorting && - - } + {column.props.allowsSorting && sortIcon}
    ); diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index b8a34a4d8c3..bb74637ce14 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -97,7 +97,7 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< map.set(key, width); setUncontrolledWidths(map); return newSizes; - }, [controlledColumns, uncontrolledColumns, setUncontrolledWidths, tableWidth, columnLayout, state.collection, uncontrolledWidths, propsOnColumnResize]); + }, [controlledColumns, uncontrolledColumns, setUncontrolledWidths, tableWidth, columnLayout, state.collection, uncontrolledWidths]); let onColumnResizeEnd = useCallback(() => { setResizingColumn(null); From b4775ae4f3cbccbfa716fe46bdca99ffa8015580 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 30 Jan 2023 17:07:52 -0800 Subject: [PATCH 12/64] remove local test story for now --- .../table/stories/example-docs.tsx | 594 ------------------ .../table/stories/useTable.stories.tsx | 134 +--- 2 files changed, 2 insertions(+), 726 deletions(-) delete mode 100644 packages/@react-aria/table/stories/example-docs.tsx diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx deleted file mode 100644 index 4254365ddc6..00000000000 --- a/packages/@react-aria/table/stories/example-docs.tsx +++ /dev/null @@ -1,594 +0,0 @@ -/* - * 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. - */ - -// TODO: don't need this, replace with styles -import {classNames} from '@react-spectrum/utils'; -import {DismissButton, Overlay, usePopover} from '@react-aria/overlays'; -import {FocusRing, useFocusRing} from '@react-aria/focus'; -import {Item} from '@react-stately/collections'; -import {mergeProps} from '@react-aria/utils'; -import React, {useCallback} from 'react'; -import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; -import {useButton} from '@react-aria/button'; -import {useCheckbox} from '@react-aria/checkbox'; -import {useHover} from '@react-aria/interactions'; -import {useMemo, useRef} from 'react'; -import {useMenu} from '@react-aria/menu'; -import {useMenuItem} from '@react-aria/menu'; -import {useMenuTrigger} from '@react-aria/menu'; -import {useMenuTriggerState} from '@react-stately/menu'; -import {useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup, useTableSelectAllCheckbox, useTableSelectionCheckbox} from '@react-aria/table'; -import {useTableColumnResizeState, useTableState} from '@react-stately/table'; -import {useToggleState} from '@react-stately/toggle'; -import {useTreeState} from '@react-stately/tree'; -import {VisuallyHidden} from '@react-aria/visually-hidden'; - -export function Table(props) { - let { - selectionMode, - selectionBehavior, - onResizeStart, - onResize, - onResizeEnd - } = props; - let state = useTableState({ - ...props, - showSelectionCheckboxes: selectionMode === 'multiple' && selectionBehavior !== 'replace' - }); - - let ref = useRef(null); - let {collection} = state; - let {gridProps} = useTable( - { - ...props, - // Table itself is made scrollable instead of the body so that the - // column header and row scroll positions are the same - // Not great still because the column headers are position sticky and thus throw off the scrolling items into view when going upwards - // Perhaps just put the table into a container that has overflow hidden? - scrollRef: ref - }, - state, - ref - ); - // Table column resizing stuff - let bodyRef = useRef(null); - - // let [tableWidth, setTableWidth] = useState(0); - let getDefaultWidth = useCallback((node) => { - // selection cell column should always take up a specific width, doesn't need to be resizable - if (node.props.isSelectionCell) { - return 20; - } - return; - }, []); - let getDefaultMinWidth = useCallback((node) => { - // selection cell column should always take up a specific width, doesn't need to be resizable - if (node.props.isSelectionCell) { - return 20; - } - return 75; - }, []); - let layoutState = useTableColumnResizeState({ - getDefaultWidth, - getDefaultMinWidth, - // TODO: sync this since we aren't including the resize obs - tableWidth: 800 - }, state); - let {widths} = layoutState; - - - // TODO: not sure why we need the below, is it if width isn't provided? - // Asked Rob, it is if the Table's width is changed by a resize event (e.g. width is a percentage of the page), doesn't apply for the - // static width case - // Remove for final doc example cuz it is probably too complicated, TODO test it? - // useLayoutEffect(() => { - // if (bodyRef && bodyRef.current) { - // setTableWidth(bodyRef.current.clientWidth); - // } - // }, []); - // useResizeObserver({ref, onResize: () => setTableWidth(bodyRef.current.clientWidth)}); - - // TODO: move certain stylings into the components themselves - return ( - // TODO: just take props for styles (width, height) - - - {collection.headerRows.map(headerRow => ( - - {[...headerRow.childNodes].map(column => - column.props.isSelectionCell - ? - // TODO include the resize stuff? for controlled? - : - )} - - ))} - - - {[...collection.body.childNodes].map(row => ( - - {[...row.childNodes].map(cell => - cell.props.isSelectionCell - ? - : - )} - - ))} - -
    - ); -} - -// done -// Needs to be forward ref for bodyRef -const TableRowGroup = React.forwardRef((props, ref) => { - let {type: Element, style, children} = props; - let {rowGroupProps} = useTableRowGroup(); - return ( - - {children} - - ); -}); - - -// done -function TableHeaderRow({item, state, children}) { - let ref = useRef(); - let {rowProps} = useTableHeaderRow({node: item}, state, ref); - return ( - // Override default tr display - - {children} - - ); -} - - -const Resizer = React.forwardRef((props, ref) => { - let {column, layoutState, onResizeStart, onResize, onResizeEnd, triggerRef, showResizer} = props; - let {resizerProps, inputProps} = useTableColumnResize({ - column, - label: 'Resizer', - onResizeStart, - onResize, - onResizeEnd, - triggerRef - }, layoutState, ref); - // TODO: update the look of the resizer in general (get rid of FocusRing also, just use focusVisible) - return ( - <> - -
    - - - -
    -
    - - ); -}); - - -export function TableColumnHeader({column, state, layoutState, onResizeStart, onResize, onResizeEnd}) { - let {widths} = layoutState; - let ref = useRef(null); - let resizerRef = useRef(null); - let triggerRef = useRef(null); - let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); - let {isFocusVisible, focusProps} = useFocusRing(); - let {hoverProps, isHovered} = useHover({}); - let showResizer = isHovered || layoutState.resizingColumn === column.key; - let arrowIcon = state.sortDescriptor?.direction === 'ascending' ? '▲' : '▼'; - let allowsSorting = column.props?.allowsSorting; - let allowsResizing = column.props.allowsResizing; - - const onMenuSelect = (key) => { - switch (key) { - case 'sort-asc': - state.sort(column.key, 'ascending'); - break; - case 'sort-desc': - state.sort(column.key, 'descending'); - break; - case 'resize': - layoutState.onColumnResizeStart(column.key); - if (resizerRef) { - setTimeout(() => resizerRef.current.focus(), 0); - } - break; - } - }; - - let items = useMemo(() => { - let options = [ - allowsSorting ? { - label: 'Sort ascending', - id: 'sort-asc' - } : undefined, - allowsSorting ? { - label: 'Sort descending', - id: 'sort-desc' - } : undefined, - { - label: 'Resize column', - id: 'resize' - } - ]; - return options; - }, [allowsSorting]); - - let sortIcon = ( - - ); - - let contents = allowsResizing ? ( - <> - - {(item) => ( - - {item.label} - - )} - - {column.props.allowsSorting && sortIcon} - - - ) : - ( -
    - - {column.rendered} - {column.props.allowsSorting && sortIcon} -
    - ); - - - return ( - 1 ? 'center' : 'left', - padding: '5px 10px', - // TODO switch to box shadow? - // outline: isFocusVisible ? '2px solid orange' : 'none', - outline: 'none', - boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', - cursor: 'default', - // New stuff - width: widths.get(column.key), - display: 'block', - flex: '0 0 auto', - boxSizing: 'border-box' - }} - ref={ref}> -
    - {contents} -
    - - ); -} - -// done -export function TableRow({item, children, state}) { - let ref = useRef(); - let isSelected = state.selectionManager.isSelected(item.key); - let {rowProps, isPressed} = useTableRow({ - node: item - }, state, ref); - let {isFocusVisible, focusProps} = useFocusRing(); - - return ( - - {children} - - ); -} - -// done -export function TableCell({cell, state, widths}) { - let ref = useRef(); - let {gridCellProps} = useTableCell({node: cell}, state, ref); - let {isFocusVisible, focusProps} = useFocusRing(); - let column = cell.column; - - return ( - - {cell.rendered} - - ); -} - -// done -function TableCheckboxCell({cell, state, widths}) { - let ref = useRef(); - let {gridCellProps} = useTableCell({node: cell}, state, ref); - let {checkboxProps} = useTableSelectionCheckbox({key: cell.parentKey}, state); - let column = cell.column; - - return ( - - - - ); -} - -// done -function TableSelectAllCell({column, state, widths}) { - let ref = useRef(); - let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); - let {checkboxProps} = useTableSelectAllCheckbox(state); - - return ( - - {state.selectionManager.selectionMode === 'single' - ? {checkboxProps['aria-label']} - : - } - - ); -} - -function Checkbox(props) { - let ref = React.useRef(); - let state = useToggleState(props); - let {inputProps} = useCheckbox(props, state, ref); - return ; -} - -const MenuButton = React.forwardRef((props, ref) => { - // Create state based on the incoming props - let state = useMenuTriggerState(props); - - // Get props for the button and menu elements - let {menuTriggerProps, menuProps} = useMenuTrigger({trigger: 'press'}, state, ref); - // Continue propagation of keydown event so that useSelectableCollection can properly recieve the bubbled left/right arrowkey presses - let onKeyDown = (e) => e.continuePropagation(); - return ( - <> - - {state.isOpen && - - - - } - - ); -}); - - -function Menu(props) { - // Create menu state based on the incoming props - let state = useTreeState(props); - - // Get props for the menu element - let ref = React.useRef(); - let {menuProps} = useMenu(props, state, ref); - - return ( -
      - {[...state.collection].map(item => ( - item.type === 'section' - ? - : - ))} -
    - ); -} - -function MenuItem({item, state}) { - // Get props for the menu item element - let ref = React.useRef(); - let {menuItemProps, isFocused, isSelected, isDisabled} = useMenuItem({key: item.key}, state, ref); - - return ( -
  • - {item.rendered} - {isSelected && } -
  • - ); -} - -function Popover({children, state, ...props}) { - let ref = React.useRef(); - let {popoverRef = ref, triggerRef} = props; - let {popoverProps, underlayProps} = usePopover({ - ...props, - popoverRef, - triggerRef - }, state); - - return ( - -
    -
    - - {children} - -
    - - ); -} - -function Button(props) { - let ref = props.buttonRef; - let {buttonProps} = useButton(props, ref); - return ; -} diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index 76c39109b23..b46beb2f8a2 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -14,12 +14,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 {Table as DocsTable} from './example-docs'; import {Meta, Story} from '@storybook/react'; import React, {Key, useCallback, useMemo, useState} from 'react'; import {Table as ResizingTable} from './example-resizing'; import {Table} from './example'; -import {useAsyncList} from 'react-stately'; const meta: Meta> = { title: 'useTable' @@ -28,7 +26,7 @@ const meta: Meta> = { export default meta; let columns = [ - {name: 'Naglwakenglkawnegklnakwlen glkawen glkawn gkaw neglkme', uid: 'name'}, + {name: 'Name', uid: 'name'}, {name: 'Type', uid: 'type'}, {name: 'Level', uid: 'level'} ]; @@ -55,7 +53,7 @@ const Template: Story> = (args) => ( {column => ( - + {column.name} )} @@ -246,131 +244,3 @@ export const TableWithSomeResizingFRsControlled = { column width state. `}} }; - -function ControlledDocsTable(args) { - let {columns, ...otherArgs} = args; - let [widths, _setWidths] = useState(() => new Map(columns.filter(col => col.width).map((col) => [col.uid as Key, col.width]))); - let setWidths = useCallback((newWidths) => { - let controlledKeys = new Set(columns.filter(col => col.width).map((col) => col.uid as Key)); - let newVals = new Map(Array.from(newWidths).filter(([key]) => controlledKeys.has(key))); - _setWidths(newVals); - }, [columns]); - - // Needed to get past column caching so new sizes actually are rendered - let cols = useMemo(() => columnsFR.map(col => ({...col})), [widths]); - return ( - - - {column => ( - - {column.name} - - )} - - - {item => ( - - {columnKey => {item[columnKey]}} - - )} - - - ); -} - -function AsyncSortTable() { - let list = useAsyncList({ - async load({signal}) { - let res = await fetch('https://swapi.py4e.com/api/people/?search', { - signal - }); - let json = await res.json(); - return { - items: json.results - }; - }, - async sort({items, sortDescriptor}) { - return { - items: items.sort((a, b) => { - let first = a[sortDescriptor.column]; - let second = b[sortDescriptor.column]; - let cmp = (parseInt(first, 10) || first) < (parseInt(second, 10) || second) - ? -1 - : 1; - if (sortDescriptor.direction === 'descending') { - cmp *= -1; - } - return cmp; - }) - }; - } - }); - - return ( - - - Name - Height - Mass - Birth Year - - - {(item: any) => ( - - {(columnKey) => {item[columnKey]}} - - )} - - - ); -} - -export const DocExample = { - args: {}, - render: (args) => ( - - - {column => ( - - {column.name} - - )} - - - {item => ( - - {columnKey => {item[columnKey]}} - - )} - - - ) -}; - -export const DocExampleControlled = { - args: {columns: columnsFR}, - render: (args) => ( - - ) -}; - -export const DocExampleWithSorting = { - args: {}, - render: () => ( - - ) -}; From 799c1079195e3e96b21147baecc5f03af5b2d0bc Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 30 Jan 2023 17:45:51 -0800 Subject: [PATCH 13/64] small cleanup --- packages/@react-aria/table/docs/useTable.mdx | 2 +- .../@react-stately/table/docs/useTableColumnResizeState.mdx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index b3447a60886..0bcac6912cc 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -867,7 +867,7 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, ### Resizer -As described above, we need to implement an element that the user can drag/interact with to resize a column. Here we'll use the +As described above, we need to implement an element that the user can drag/interact with to resize a column. Here we'll use the hook to create a visible resizer div for physical drag operations and a visually hidden input responsible for keyboard and screenreader interactions, similar to a [slider](useSlider.html). Users can press and drag on the visible resizer to trigger the `onResize` callbacks and update the tracked column widths accordingly. When focused, keyboard users can use the arrow keys to trigger the same resize events and use Enter, Esc, or Space to exit resizing. Similarly, touch screen readers can swipe up and down to diff --git a/packages/@react-stately/table/docs/useTableColumnResizeState.mdx b/packages/@react-stately/table/docs/useTableColumnResizeState.mdx index 4988cd723f9..00228f1d717 100644 --- a/packages/@react-stately/table/docs/useTableColumnResizeState.mdx +++ b/packages/@react-stately/table/docs/useTableColumnResizeState.mdx @@ -37,6 +37,5 @@ keywords: [table, state, grid] ## Example -// TODO: link to the resizing section specifically See the docs for [useTable](/react-aria/useTable.html#resizable-columns) in react-aria for an example of using `useTableColumnResizeState` with `useTableState` to create a table with resizable columns. From da230159f7944624a438b42514e8669619d5eefd Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 31 Jan 2023 11:55:19 -0800 Subject: [PATCH 14/64] updating resizer in docs --- packages/@react-aria/table/docs/useTable.mdx | 21 +++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 0bcac6912cc..192f73e4a94 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -516,7 +516,7 @@ width equally initially. We'll pass the state returned by . The `widths` from this state are provided to each table element so the table cells in the body match the columns at all times. -The various style changes below are to make the table itself scrollable and to support table body/column widths greater than the 400px applied to the table itself. +The various style changes below are to make the table itself scrollable and to support table body/column widths greater than the 300px applied to the table itself.
    Show code @@ -575,7 +575,7 @@ function ResizableColumnsTable(props) { let layoutState = useTableColumnResizeState({ getDefaultWidth, getDefaultMinWidth, - tableWidth: 400 + tableWidth: 300 }, state); let {widths} = layoutState; /*- end highlight -*/ @@ -586,7 +586,7 @@ function ResizableColumnsTable(props) { style={{ borderCollapse: 'collapse', /*- begin highlight -*/ - width: '400px', + width: '300px', height: '200px', display: 'block', position: 'relative', @@ -871,7 +871,7 @@ As described above, we need to implement an element that the user can drag/inter hook to create a visible resizer div for physical drag operations and a visually hidden input responsible for keyboard and screenreader interactions, similar to a [slider](useSlider.html). Users can press and drag on the visible resizer to trigger the `onResize` callbacks and update the tracked column widths accordingly. When focused, keyboard users can use the arrow keys to trigger the same resize events and use Enter, Esc, or Space to exit resizing. Similarly, touch screen readers can swipe up and down to -resize the column and double tapping to trigger a virtual click exits resizing. +resize the column and double tap to exit resizing. Note that we apply `visibility: hidden` to hide the resizer so that we reserve space for it in the column header at all times. @@ -891,19 +891,22 @@ const Resizer = React.forwardRef((props, ref) => { onResizeEnd, triggerRef }, layoutState, ref); - // TODO: update the visual look of the resizer and have a focus style + return (
    From e2ccf0dc57a8624bf755e7f782b7ccdf053ba16f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 31 Jan 2023 13:50:06 -0800 Subject: [PATCH 15/64] fixing talkback and Safari aria resizer focus issues --- packages/@react-aria/table/docs/useTable.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 192f73e4a94..2da852ec1aa 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -768,7 +768,8 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, case 'resize': layoutState.onColumnResizeStart(column.key); if (resizerRef) { - setTimeout(() => resizerRef.current.focus(), 0); + // Brief delay before moving focus to resizer input for screenreaders/Safari + setTimeout(() => resizerRef.current?.focus(), 400); } break; } From 28b5bd0d2c16a0c463e33e014f86691587fc1d93 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 31 Jan 2023 14:19:40 -0800 Subject: [PATCH 16/64] fixing checkbox rendering in iOS Safari --- packages/@react-aria/table/docs/useTable.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 2da852ec1aa..1b7b5d5dc1d 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -1233,7 +1233,7 @@ function ResizableTableCheckboxCell({cell, state, widths}) { @@ -1266,7 +1266,7 @@ function ResizableTableSelectAllCell({column, state, widths}) { {state.selectionManager.selectionMode === 'single' ? {checkboxProps['aria-label']} /*- begin highlight -*/ - : + : /*- end highlight -*/ } From cdd97e1eb0e618a829e166adc691227343a3fada Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 31 Jan 2023 15:06:17 -0800 Subject: [PATCH 17/64] editing/proofreading --- packages/@react-aria/table/docs/useTable.mdx | 28 ++++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 1b7b5d5dc1d..fc73b2083b2 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -504,19 +504,19 @@ For resizable column support, two additional hooks need to be added to the table hook from `@react-stately/table` is responsible for initializing and tracking the widths of every column in your table, returning functions that you can use to update the column widths during a column resize operation. Note that this state is supplementary to the state returned by . -The second column resizing hook is . This hook handles the interactions for the a table column's resizer +The second column resizing hook is . This hook handles the interactions for a table column's resizer element, allowing the user to drag the resizer or use the keyboard arrows to expand the column's width. Be sure to pass the state returned by -to this hook so the tracked widths can be updated appropriately. We'll walk through all the changes to the previous table implementation step by step below. +to this hook so the tracked widths can be updated appropriately. We'll walk through all the required changes to the previous table implementation step by step below. ### Table -As mentioned previously, we first need to call to initialize and update the widths for our table's columns. -By providing the hook with `getDefaultMinWidth` and `getDefaultWidth`, we can give our table selection checkbox column a pre-determined width while allowing the other columns divide up the remaining -width equally initially. We'll pass the state returned by along with any user defined `onResize` handlers +As mentioned previously, we first need to call to initialize the widths for our table's columns. +By providing the hook with `getDefaultMinWidth` and `getDefaultWidth`, we can give our table selection checkbox column a pre-determined width while allowing the remaining width to be divided up equally amongst the +other columns. We'll pass the state returned by along with any user defined `onResize` handlers to our `ResizableTableColumnHeaders` so it can be used by . The `widths` from this state are provided to each table element so -the table cells in the body match the columns at all times. +the table cells in the body match their parent column's widths at all times. -The various style changes below are to make the table itself scrollable and to support table body/column widths greater than the 300px applied to the table itself. +The various style changes below are to make the table itself scrollable when the row content overflows and to support table body/column widths greater than the 300px applied to the table itself.
    Show code @@ -677,7 +677,7 @@ function ResizableColumnsTable(props) { ### Resizable table header -The `TableRowGroup` and `TableHeaderRow` only require minor style changes to accommodate the the new `display` changes made in +The `TableRowGroup` and `TableHeaderRow` only require minor style changes to accommodate the new `display` changes made in `ResizableColumnsTable`.
    @@ -730,10 +730,10 @@ function ResizableTableHeaderRow({item, state, children}) { The `TableColumnHeader` is where we see the bulk of the changes required to support resizable columns. First of all, we need to accommodate a `Resizer` element in every resizable column that the user can drag or focus to perform a resize operation. To avoid interfering with the existing grid keyboard navigation, we only display this resizer when the column is hovered or if the column itself is being resized. This however introduces another issue: how will keyboard, screen reader, and touch users begin a resize operation if the resizer -is only visible on hover? We can't just have the user press on the column header since that action is reserved for sorting. +is only visible on hover? We can't just have the user press on the column header since that action was previously reserved for sorting. -To resolve this, we need to render a menu button as the column header if the column is resizable, allowing non-mouse users to trigger any available action on the column. For resizing, this would -cause focus to shift to the resizer itself, displaying the resizer for touch screen users to drag and letting keyboard and screen readers resize the column via keyboard arrows and screenreader operations +To resolve this, we need to render a menu button in the column header if the column is resizable, allowing non-mouse users to select from a list of available actions on the column. For resizing, selecting the corresponding option +should cause focus to shift to the resizer itself, displaying the resizer for touch screen users to drag and letting keyboard and screen readers resize the column via keyboard arrows and screen reader operations accordingly.
    @@ -871,7 +871,7 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, As described above, we need to implement an element that the user can drag/interact with to resize a column. Here we'll use the hook to create a visible resizer div for physical drag operations and a visually hidden input responsible for keyboard and screenreader interactions, similar to a [slider](useSlider.html). Users can press and drag on the visible resizer to trigger the `onResize` callbacks and update the tracked column widths accordingly. When focused, keyboard users can use the arrow keys to trigger the same -resize events and use Enter, Esc, or Space to exit resizing. Similarly, touch screen readers can swipe up and down to +resize events and press Enter, Esc, or Space to exit resizing. Similarly, touch screen readers can swipe up and down to resize the column and double tap to exit resizing. Note that we apply `visibility: hidden` to hide the resizer so that we reserve space for it in the column header at all times. @@ -926,7 +926,7 @@ const Resizer = React.forwardRef((props, ref) => { ### MenuButton The `MenuButton` is used in the `ResizableTableColumnHeader` to display a list of available actions available to a column. It is built using the [useMenuTrigger](useMenu.html) -and [useTreeState](/react-stately/useTreeState.html) hooks, and can be shared with many other components. Note that you'll have to call `continuePropagation()` in `onKeyDown` +and [useMenuTriggerState](/react-stately/useMenuTriggerState.html) hooks, and can be shared with many other components. Note that you'll have to call `continuePropagation()` in `onKeyDown` to properly bubble the `keydown` event up to `useSelectableCollection` for grid keyboard navigation.
    @@ -1118,7 +1118,7 @@ function Button(props) { ### Resizable table body Similar to `TableRowGroup` and `TableHeaderRow`, `TableRow` and `TableCell` only require minor style changes to -accommodate the the new `display` changes made in the `ResizableColumnsTable`. The changes to `TableRow` are made to extend the +accommodate the new `display` changes made in the `ResizableColumnsTable`. The changes to `TableRow` are made to extend the background of the row to the full width of its child cells. `TableCell` now receives its width from and handles text overflow. From 2d7b4419f3cc5ebdf99a81c2199af33dc3d3d5ea Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 3 Feb 2023 14:17:55 -0800 Subject: [PATCH 18/64] move useTableColumnResizeState to useTableState docs --- .../table/docs/useTableColumnResizeState.mdx | 41 ------------------- .../table/docs/useTableState.mdx | 11 ++++- 2 files changed, 9 insertions(+), 43 deletions(-) delete mode 100644 packages/@react-stately/table/docs/useTableColumnResizeState.mdx diff --git a/packages/@react-stately/table/docs/useTableColumnResizeState.mdx b/packages/@react-stately/table/docs/useTableColumnResizeState.mdx deleted file mode 100644 index 00228f1d717..00000000000 --- a/packages/@react-stately/table/docs/useTableColumnResizeState.mdx +++ /dev/null @@ -1,41 +0,0 @@ -{/* 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 {Layout} from '@react-spectrum/docs'; -export default Layout; - -import docs from 'docs:@react-stately/table'; -import {ClassAPI, HeaderInfo, FunctionAPI, PageDescription} from '@react-spectrum/docs'; -import packageData from '@react-stately/table/package.json'; - ---- -category: Collections -keywords: [table, state, grid] ---- - -# useTableColumnResizeState - -{docs.exports.useTableColumnResizeState.description} - - - -## API - - - -## Interface - - - -## Example - -See the docs for [useTable](/react-aria/useTable.html#resizable-columns) in react-aria for an example of using `useTableColumnResizeState` -with `useTableState` to create a table with resizable columns. diff --git a/packages/@react-stately/table/docs/useTableState.mdx b/packages/@react-stately/table/docs/useTableState.mdx index 2175aabc78a..0db46cd9a11 100644 --- a/packages/@react-stately/table/docs/useTableState.mdx +++ b/packages/@react-stately/table/docs/useTableState.mdx @@ -25,11 +25,12 @@ keywords: [table, state, grid] + componentNames={['useTableState', 'useTableColumnResizeState']} /> ## API + @@ -38,9 +39,15 @@ keywords: [table, state, grid] ## Interface +### useTableState + +### useTableColumnResizeState + + + ## Example -See the docs for [useTable](/react-aria/useTable.html) in react-aria for an example of `useTableState`, `Cell`, `Column`, +See the docs for [useTable](/react-aria/useTable.html) in react-aria for an example of `useTableState`, `useTableColumnResizeState`, `Cell`, `Column`, `Row`, `TableBody`, and `TableHeader`. From dc889ef38c87d239a9aba882414ac7fba474a22f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 3 Feb 2023 14:55:32 -0800 Subject: [PATCH 19/64] moving resizable table section and addressing smaller changes --- packages/@react-aria/table/docs/useTable.mdx | 834 ++++++++++--------- 1 file changed, 418 insertions(+), 416 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index fc73b2083b2..fc1724e36ef 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -498,6 +498,313 @@ function Checkbox(props) {
    +## Usage + +### Dynamic collections + +So far, our examples have shown static collections, where the data is hard coded. +Dynamic collections, as shown below, can be used when the table data comes from an external data source such as an API, or updates over time. +In the example below, both the columns and the rows are provided to the table via a render function. You can also make the columns static and +only the rows dynamic. + +```tsx example export=true +function ExampleTable(props) { + let columns = [ + {name: 'Name', key: 'name'}, + {name: 'Type', key: 'type'}, + {name: 'Date Modified', key: 'date'} + ]; + + let rows = [ + {id: 1, name: 'Games', date: '6/7/2020', type: 'File folder'}, + {id: 2, name: 'Program Files', date: '4/7/2021', type: 'File folder'}, + {id: 3, name: 'bootmgr', date: '11/20/2010', type: 'System file'}, + {id: 4, name: 'log.txt', date: '1/18/2016', type: 'Text Document'} + ]; + + return ( +
    + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + +
    + ); +} +``` + +### Single selection + +By default, `useTableState` doesn't allow row selection but this can be enabled using the `selectionMode` prop. Use `defaultSelectedKeys` to provide a default set of selected rows. +Note that the value of the selected keys must match the `key` prop of the row. + +The example below enables single selection mode, and uses `defaultSelectedKeys` to select the row with key equal to "2". +A user can click on a different row to change the selection, or click on the same row again to deselect it entirely. + +```tsx example +// Using the example above + +``` + +### Multiple selection + +Multiple selection can be enabled by setting `selectionMode` to `multiple`. + +```tsx example +// Using the example above + +``` + +### Disallow empty selection + +Table also supports a `disallowEmptySelection` prop which forces the user to have at least one row in the Table selected at all times. +In this mode, if a single row is selected and the user presses it, it will not be deselected. + +```tsx example +// Using the example above + +``` + +### Controlled selection + +To programmatically control row selection, use the `selectedKeys` prop paired with the `onSelectionChange` callback. The `key` prop from the selected rows will +be passed into the callback when the row is pressed, allowing you to update state accordingly. + +```tsx example export=true +function PokemonTable(props) { + let columns = [ + {name: 'Name', uid: 'name'}, + {name: 'Type', uid: 'type'}, + {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'} + ]; + + let [selectedKeys, setSelectedKeys] = React.useState(new Set([2])); + + return ( + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + +
    + ); +} +``` + +### Disabled rows + +You can disable specific rows by providing an array of keys to `useTableState` via the `disabledKeys` prop. This will prevent rows from being selectable as shown in the example below. +Note that you are responsible for the styling of disabled rows, however, the selection checkbox will be automatically disabled. + +```tsx example +// Using the same table as above + +``` + +### Selection behavior + +By default, `useTable` uses the `"toggle"` selection behavior, which behaves like a checkbox group: clicking, tapping, or pressing the Space or Enter keys toggles selection for the focused row. Using the arrow keys moves focus but does not change selection. The `"toggle"` selection mode is often paired with a column of checkboxes in each row as an explicit affordance for selection. + +When the `selectionBehavior` prop is set to `"replace"`, clicking a row with the mouse _replaces_ the selection with only that row. Using the arrow keys moves both focus and selection. To select multiple rows, modifier keys such as Ctrl, Cmd, and Shift can be used. To move focus without moving selection, the Ctrl key on Windows or the Option key on macOS can be held while pressing the arrow keys. Holding this modifier while pressing the Space key toggles selection for the focused row, which allows multiple selection of non-contiguous items. On touch screen devices, selection always behaves as toggle since modifier keys may not be available. This behavior emulates native platforms such as macOS and Windows, and is often used when checkboxes in each row are not desired. + +```tsx example + +``` + +### Row actions +`useTable` supports row actions via the `onRowAction` prop, which is useful for functionality such as navigation. In the default `"toggle"` selection behavior, when nothing is selected, clicking or tapping the row triggers the row action. +When at least one item is selected, the table is in selection mode, and clicking or tapping a row toggles the selection. Actions may also be triggered via the Enter key, and selection using the Space key. + +This behavior is slightly different in the `"replace"` selection behavior, where single clicking selects the row and actions are performed via double click. On touch devices, the action becomes the primary tap interaction, +and a long press enters into selection mode, which temporarily swaps the selection behavior to `"toggle"` to perform selection (you may wish to display checkboxes when this happens). Deselecting all items exits selection mode +and reverts the selection behavior back to `"replace"`. Keyboard behaviors are unaffected. + +```tsx example +
    + alert(`Opening item ${key}...`)} /> + alert(`Opening item ${key}...`)} /> +
    +``` + +### Sorting + +Table supports sorting its data when a column header is pressed. To designate that a Column should support sorting, provide it with +the `allowsSorting` prop. The Table accepts a `sortDescriptor` prop that defines the current column key to sort by and the sort direction (ascending/descending). +When the user presses a sortable column header, the column's key and sort direction is passed into the `onSortChange` callback, allowing you to update +the `sortDescriptor` appropriately. + +This example performs client side sorting by passing a `sort` function to the [useAsyncList](../react-stately/useAsyncList.html) hook. +See the docs for more information on how to perform server side sorting. + +```tsx example +import {useAsyncList} from '@react-stately/data'; + +function AsyncSortTable() { + let list = useAsyncList({ + async load({signal}) { + let res = await fetch(`https://swapi.py4e.com/api/people/?search`, {signal}); + let json = await res.json(); + return { + items: json.results + }; + }, + async sort({items, sortDescriptor}) { + return { + items: items.sort((a, b) => { + let first = a[sortDescriptor.column]; + let second = b[sortDescriptor.column]; + let cmp = (parseInt(first) || first) < (parseInt(second) || second) ? -1 : 1; + if (sortDescriptor.direction === 'descending') { + cmp *= -1; + } + return cmp; + }) + }; + } + }); + + return ( + + + Name + Height + Mass + Birth Year + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + +
    + ); +} +``` + +### Nested columns + +Columns can be nested to create column groups. This will result in more than one header row to be created, with the `colspan` +attribute of each column header cell set to the appropriate value so that the columns line up. Data for the leaf columns +appears in each row of the table body. + +This example also shows the use of the `isRowHeader` prop for `Column`, which controls which columns are included in the +accessibility name for each row. By default, only the first column is included, but in some cases more than one column may +be used to represent the row. In this example, the first and last name columns are combined to form the ARIA label for the row. +Only leaf columns may be marked as row headers. + +```tsx example + + + + First Name + Last Name + + + Age + Birthday + + + + + Sam + Smith + 36 + May 3 + + + Julia + Jones + 24 + February 10 + + + Peter + Parker + 28 + September 7 + + + Bruce + Wayne + 32 + December 18 + + +
    +``` + +### Dynamic nested columns + +Nested columns can also be defined dynamically using the function syntax and the `childColumns` prop. +The following example is the same as the example above, but defined dynamically. + +```tsx example +let columns = [ + {name: 'Name', key: 'name', children: [ + {name: 'First Name', key: 'first', isRowHeader: true}, + {name: 'Last Name', key: 'last', isRowHeader: true} + ]}, + {name: 'Information', key: 'info', children: [ + {name: 'Age', key: 'age'}, + {name: 'Birthday', key: 'birthday'} + ]} +]; + +let rows = [ + {id: 1, first: 'Sam', last: 'Smith', age: 36, birthday: 'May 3'}, + {id: 2, first: 'Julia', last: 'Jones', age: 24, birthday: 'February 10'}, + {id: 3, first: 'Peter', last: 'Parker', age: 28, birthday: 'September 7'}, + {id: 4, first: 'Bruce', last: 'Wayne', age: 32, birthday: 'December 18'} +]; + + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + +
    +``` + + ## Resizable Columns For resizable column support, two additional hooks need to be added to the table implementation above. The @@ -740,8 +1047,8 @@ accordingly. Show code ```tsx example export=true render=false -// Reuse the MenuButton from your component library. See below for details. -import {MenuButton} from 'your-component-library'; +// Reuse the MenuTrigger from your component library. See below for details. +import {MenuTrigger} from 'your-component-library'; import {useHover} from '@react-aria/interactions'; function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, onResize, onResizeEnd}) { @@ -769,7 +1076,7 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, layoutState.onColumnResizeStart(column.key); if (resizerRef) { // Brief delay before moving focus to resizer input for screenreaders/Safari - setTimeout(() => resizerRef.current?.focus(), 400); + setTimeout(() => resizerRef.current?.focus(), 50); } break; } @@ -801,7 +1108,7 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, let contents = allowsResizing ? ( <> - )} - + {column.props.allowsSorting && sortIcon} @@ -902,7 +1210,7 @@ const Resizer = React.forwardRef((props, ref) => { height: 'auto', border: '2px', borderStyle: 'none solid', - borderColor: layoutState.resizingColumn === column.key ? 'blue' : 'grey', + borderColor: layoutState.resizingColumn === column.key ? 'orange' : 'grey', touchAction: 'none', flex: '0 0 auto', boxSizing: 'border-box', @@ -923,9 +1231,9 @@ const Resizer = React.forwardRef((props, ref) => {
    -### MenuButton +### MenuTrigger -The `MenuButton` is used in the `ResizableTableColumnHeader` to display a list of available actions available to a column. It is built using the [useMenuTrigger](useMenu.html) +The `MenuTrigger` is used in the `ResizableTableColumnHeader` to display a list of available actions available to a column. It is built using the [useMenuTrigger](useMenu.html) and [useMenuTriggerState](/react-stately/useMenuTriggerState.html) hooks, and can be shared with many other components. Note that you'll have to call `continuePropagation()` in `onKeyDown` to properly bubble the `keydown` event up to `useSelectableCollection` for grid keyboard navigation. @@ -940,7 +1248,7 @@ import {Item} from '@react-stately/collections'; // Reuse the Button, Menu, and Popover from your component library. See below for details. import {Button, Menu, Popover} from 'your-component-library'; -const MenuButton = React.forwardRef((props, ref) => { +const MenuTrigger = React.forwardRef((props, ref) => { // Create state based on the incoming props let state = useMenuTriggerState(props); @@ -1179,444 +1487,138 @@ function ResizableTableCell({cell, state, widths}) { {...mergeProps(gridCellProps, focusProps)} style={{ padding: '5px 10px', - cursor: 'default', - outline: 'none', - boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', - /*- begin highlight -*/ - width: widths.get(column.key), - overflow: 'hidden', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - display: 'block', - flex: '0 0 auto', - boxSizing: 'border-box' - /*- end highlight -*/ - }} - ref={ref}> - {cell.rendered} - - ); -} -``` - -
    - -### Resizable checkbox - -The `TableCheckboxCell` and the `TableSelectAllCell` now have their widths dictated by the state returned by -and receive minor style changes to accommodate that. - -
    - Show code - -```tsx example export=true render=false -function ResizableTableCheckboxCell({cell, state, widths}) { - let ref = useRef(); - let {gridCellProps} = useTableCell({node: cell}, state, ref); - let {checkboxProps} = useTableSelectionCheckbox({key: cell.parentKey}, state); - /*- begin highlight -*/ - let column = cell.column; - /*- end highlight -*/ - - return ( - - - - - ); -} -``` - -
    - -
    - Show code - -```tsx example export=true render=false -function ResizableTableSelectAllCell({column, state, widths}) { - let ref = useRef(); - let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); - let {checkboxProps} = useTableSelectAllCheckbox(state); - - return ( - - {state.selectionManager.selectionMode === 'single' - ? {checkboxProps['aria-label']} - /*- begin highlight -*/ - : - /*- end highlight -*/ - } - - ); -} -``` - -
    - - -And with that, all necessary changes to the previous table implementation have been made and we now have a table that supports resizable columns! -The example below supports resizing via mouse, keyboard, touch, and screen reader interactions. Previous behaviors such as selection and sorting are -also still supported. - -```tsx example - - - Name - Type - Level - - - - Charizard - Fire, Flying - 67 - - - Blastoise - Water - 56 - - - Venusaur - Grass, Poison - 83 - - - Pikachu - Electric - 100 - - - -``` - -## Usage - -### Dynamic collections - -So far, our examples have shown static collections, where the data is hard coded. -Dynamic collections, as shown below, can be used when the table data comes from an external data source such as an API, or updates over time. -In the example below, both the columns and the rows are provided to the table via a render function. You can also make the columns static and -only the rows dynamic. - -```tsx example export=true -function ExampleTable(props) { - let columns = [ - {name: 'Name', key: 'name'}, - {name: 'Type', key: 'type'}, - {name: 'Date Modified', key: 'date'} - ]; - - let rows = [ - {id: 1, name: 'Games', date: '6/7/2020', type: 'File folder'}, - {id: 2, name: 'Program Files', date: '4/7/2021', type: 'File folder'}, - {id: 3, name: 'bootmgr', date: '11/20/2010', type: 'System file'}, - {id: 4, name: 'log.txt', date: '1/18/2016', type: 'Text Document'} - ]; - - return ( - - - {column => ( - - {column.name} - - )} - - - {item => ( - - {columnKey => {item[columnKey]}} - - )} - -
    - ); -} -``` - -### Single selection - -By default, `useTableState` doesn't allow row selection but this can be enabled using the `selectionMode` prop. Use `defaultSelectedKeys` to provide a default set of selected rows. -Note that the value of the selected keys must match the `key` prop of the row. - -The example below enables single selection mode, and uses `defaultSelectedKeys` to select the row with key equal to "2". -A user can click on a different row to change the selection, or click on the same row again to deselect it entirely. - -```tsx example -// Using the example above - -``` - -### Multiple selection - -Multiple selection can be enabled by setting `selectionMode` to `multiple`. - -```tsx example -// Using the example above - -``` - -### Disallow empty selection - -Table also supports a `disallowEmptySelection` prop which forces the user to have at least one row in the Table selected at all times. -In this mode, if a single row is selected and the user presses it, it will not be deselected. - -```tsx example -// Using the example above - -``` - -### Controlled selection - -To programmatically control row selection, use the `selectedKeys` prop paired with the `onSelectionChange` callback. The `key` prop from the selected rows will -be passed into the callback when the row is pressed, allowing you to update state accordingly. - -```tsx example export=true -function PokemonTable(props) { - let columns = [ - {name: 'Name', uid: 'name'}, - {name: 'Type', uid: 'type'}, - {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'} - ]; - - let [selectedKeys, setSelectedKeys] = React.useState(new Set([2])); - - return ( - - - {column => ( - - {column.name} - - )} - - - {item => ( - - {columnKey => {item[columnKey]}} - - )} - -
    + cursor: 'default', + outline: 'none', + boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', + /*- begin highlight -*/ + width: widths.get(column.key), + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + display: 'block', + flex: '0 0 auto', + boxSizing: 'border-box' + /*- end highlight -*/ + }} + ref={ref}> + {cell.rendered} + ); } ``` -### Disabled rows - -You can disable specific rows by providing an array of keys to `useTableState` via the `disabledKeys` prop. This will prevent rows from being selectable as shown in the example below. -Note that you are responsible for the styling of disabled rows, however, the selection checkbox will be automatically disabled. - -```tsx example -// Using the same table as above - -``` - -### Selection behavior +
    -By default, `useTable` uses the `"toggle"` selection behavior, which behaves like a checkbox group: clicking, tapping, or pressing the Space or Enter keys toggles selection for the focused row. Using the arrow keys moves focus but does not change selection. The `"toggle"` selection mode is often paired with a column of checkboxes in each row as an explicit affordance for selection. +### Resizable checkbox -When the `selectionBehavior` prop is set to `"replace"`, clicking a row with the mouse _replaces_ the selection with only that row. Using the arrow keys moves both focus and selection. To select multiple rows, modifier keys such as Ctrl, Cmd, and Shift can be used. To move focus without moving selection, the Ctrl key on Windows or the Option key on macOS can be held while pressing the arrow keys. Holding this modifier while pressing the Space key toggles selection for the focused row, which allows multiple selection of non-contiguous items. On touch screen devices, selection always behaves as toggle since modifier keys may not be available. This behavior emulates native platforms such as macOS and Windows, and is often used when checkboxes in each row are not desired. +The `TableCheckboxCell` and the `TableSelectAllCell` now have their widths dictated by the state returned by +and receive minor style changes to accommodate that. -```tsx example - -``` +
    + Show code -### Row actions -`useTable` supports row actions via the `onRowAction` prop, which is useful for functionality such as navigation. In the default `"toggle"` selection behavior, when nothing is selected, clicking or tapping the row triggers the row action. -When at least one item is selected, the table is in selection mode, and clicking or tapping a row toggles the selection. Actions may also be triggered via the Enter key, and selection using the Space key. +```tsx example export=true render=false +function ResizableTableCheckboxCell({cell, state, widths}) { + let ref = useRef(); + let {gridCellProps} = useTableCell({node: cell}, state, ref); + let {checkboxProps} = useTableSelectionCheckbox({key: cell.parentKey}, state); + /*- begin highlight -*/ + let column = cell.column; + /*- end highlight -*/ -This behavior is slightly different in the `"replace"` selection behavior, where single clicking selects the row and actions are performed via double click. On touch devices, the action becomes the primary tap interaction, -and a long press enters into selection mode, which temporarily swaps the selection behavior to `"toggle"` to perform selection (you may wish to display checkboxes when this happens). Deselecting all items exits selection mode -and reverts the selection behavior back to `"replace"`. Keyboard behaviors are unaffected. + return ( + -```tsx example -
    - alert(`Opening item ${key}...`)} /> - alert(`Opening item ${key}...`)} /> -
    + + + ); +} ``` -### Sorting - -Table supports sorting its data when a column header is pressed. To designate that a Column should support sorting, provide it with -the `allowsSorting` prop. The Table accepts a `sortDescriptor` prop that defines the current column key to sort by and the sort direction (ascending/descending). -When the user presses a sortable column header, the column's key and sort direction is passed into the `onSortChange` callback, allowing you to update -the `sortDescriptor` appropriately. - -This example performs client side sorting by passing a `sort` function to the [useAsyncList](../react-stately/useAsyncList.html) hook. -See the docs for more information on how to perform server side sorting. +
    -```tsx example -import {useAsyncList} from '@react-stately/data'; +
    + Show code -function AsyncSortTable() { - let list = useAsyncList({ - async load({signal}) { - let res = await fetch(`https://swapi.py4e.com/api/people/?search`, {signal}); - let json = await res.json(); - return { - items: json.results - }; - }, - async sort({items, sortDescriptor}) { - return { - items: items.sort((a, b) => { - let first = a[sortDescriptor.column]; - let second = b[sortDescriptor.column]; - let cmp = (parseInt(first) || first) < (parseInt(second) || second) ? -1 : 1; - if (sortDescriptor.direction === 'descending') { - cmp *= -1; - } - return cmp; - }) - }; - } - }); +```tsx example export=true render=false +function ResizableTableSelectAllCell({column, state, widths}) { + let ref = useRef(); + let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); + let {checkboxProps} = useTableSelectAllCheckbox(state); return ( - - - Name - Height - Mass - Birth Year - - - {item => ( - - {columnKey => {item[columnKey]}} - - )} - -
    + + {state.selectionManager.selectionMode === 'single' + ? {checkboxProps['aria-label']} + /*- begin highlight -*/ + : + /*- end highlight -*/ + } + ); } ``` -### Nested columns +
    -Columns can be nested to create column groups. This will result in more than one header row to be created, with the `colspan` -attribute of each column header cell set to the appropriate value so that the columns line up. Data for the leaf columns -appears in each row of the table body. -This example also shows the use of the `isRowHeader` prop for `Column`, which controls which columns are included in the -accessibility name for each row. By default, only the first column is included, but in some cases more than one column may -be used to represent the row. In this example, the first and last name columns are combined to form the ARIA label for the row. -Only leaf columns may be marked as row headers. +And with that, all necessary changes to the previous table implementation have been made and we now have a table that supports resizable columns! +The example below supports resizing via mouse, keyboard, touch, and screen reader interactions. Previous behaviors such as selection and sorting are +also still supported. ```tsx example - + - - First Name - Last Name - - - Age - Birthday - + Name + Type + Level - - Sam - Smith - 36 - May 3 + + Charizard + Fire, Flying + 67 - - Julia - Jones - 24 - February 10 + + Blastoise + Water + 56 - - Peter - Parker - 28 - September 7 + + Venusaur + Grass, Poison + 83 - - Bruce - Wayne - 32 - December 18 + + Pikachu + Electric + 100 -
    -``` - -### Dynamic nested columns - -Nested columns can also be defined dynamically using the function syntax and the `childColumns` prop. -The following example is the same as the example above, but defined dynamically. - -```tsx example -let columns = [ - {name: 'Name', key: 'name', children: [ - {name: 'First Name', key: 'first', isRowHeader: true}, - {name: 'Last Name', key: 'last', isRowHeader: true} - ]}, - {name: 'Information', key: 'info', children: [ - {name: 'Age', key: 'age'}, - {name: 'Birthday', key: 'birthday'} - ]} -]; - -let rows = [ - {id: 1, first: 'Sam', last: 'Smith', age: 36, birthday: 'May 3'}, - {id: 2, first: 'Julia', last: 'Jones', age: 24, birthday: 'February 10'}, - {id: 3, first: 'Peter', last: 'Parker', age: 28, birthday: 'September 7'}, - {id: 4, first: 'Bruce', last: 'Wayne', age: 32, birthday: 'December 18'} -]; - - - - {column => ( - - {column.name} - - )} - - - {item => ( - - {columnKey => {item[columnKey]}} - - )} - -
    + ``` ## Internationalization From ff526bac362d3ed133f6abde1e22d88fa7fb5cd5 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 3 Feb 2023 17:53:11 -0800 Subject: [PATCH 20/64] simplifying example --- packages/@react-aria/table/docs/useTable.mdx | 223 ++++--------------- 1 file changed, 39 insertions(+), 184 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index fc1724e36ef..bda0112243e 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -813,13 +813,13 @@ update the column widths during a column resize operation. Note that this state The second column resizing hook is . This hook handles the interactions for a table column's resizer element, allowing the user to drag the resizer or use the keyboard arrows to expand the column's width. Be sure to pass the state returned by -to this hook so the tracked widths can be updated appropriately. We'll walk through all the required changes to the previous table implementation step by step below. +to this hook so the tracked widths can be updated appropriately. We'll walk through all the required changes to the previous table implementation step by step below. For simplicity's sake, we'll be +omitting support for selection and sorting. ### Table As mentioned previously, we first need to call to initialize the widths for our table's columns. -By providing the hook with `getDefaultMinWidth` and `getDefaultWidth`, we can give our table selection checkbox column a pre-determined width while allowing the remaining width to be divided up equally amongst the -other columns. We'll pass the state returned by along with any user defined `onResize` handlers +We'll pass the state returned by along with any user defined `onResize` handlers to our `ResizableTableColumnHeaders` so it can be used by . The `widths` from this state are provided to each table element so the table cells in the body match their parent column's widths at all times. @@ -835,18 +835,13 @@ import {useTableColumnResizeState} from '@react-stately/table'; function ResizableColumnsTable(props) { let { - selectionMode, - selectionBehavior, /*- begin highlight -*/ onResizeStart, onResize, onResizeEnd /*- end highlight -*/ } = props; - let state = useTableState({ - ...props, - showSelectionCheckboxes: selectionMode === 'multiple' && selectionBehavior !== 'replace' - }); + let state = useTableState(props); let ref = useRef(); let {collection} = state; @@ -863,25 +858,8 @@ function ResizableColumnsTable(props) { ); /*- begin highlight -*/ - let getDefaultWidth = React.useCallback((node) => { - // selection cell column should always take up a specific width, doesn't need to be resizable - if (node.props.isSelectionCell) { - return 20; - } - return; - }, []); - - let getDefaultMinWidth = React.useCallback((node) => { - // selection cell column should always take up a specific width, doesn't need to be resizable - if (node.props.isSelectionCell) { - return 20; - } - return 75; - }, []); - let layoutState = useTableColumnResizeState({ - getDefaultWidth, - getDefaultMinWidth, + // Matches the width of the table itself tableWidth: 300 }, state); let {widths} = layoutState; @@ -914,30 +892,19 @@ function ResizableColumnsTable(props) { }}> {collection.headerRows.map(headerRow => ( - {[...headerRow.childNodes].map(column => - column.props.isSelectionCell ? ( - - ) : ( - - ) - )} + {[...headerRow.childNodes].map(column => ( + + ))} ))} @@ -950,27 +917,16 @@ function ResizableColumnsTable(props) { type="tbody"> {[...collection.body.childNodes].map(row => ( - {[...row.childNodes].map(cell => - cell.props.isSelectionCell ? ( - - ) : ( - - ) - )} + {[...row.childNodes].map(cell => ( + + ))} ))} @@ -1037,7 +993,7 @@ function ResizableTableHeaderRow({item, state, children}) { The `TableColumnHeader` is where we see the bulk of the changes required to support resizable columns. First of all, we need to accommodate a `Resizer` element in every resizable column that the user can drag or focus to perform a resize operation. To avoid interfering with the existing grid keyboard navigation, we only display this resizer when the column is hovered or if the column itself is being resized. This however introduces another issue: how will keyboard, screen reader, and touch users begin a resize operation if the resizer -is only visible on hover? We can't just have the user press on the column header since that action was previously reserved for sorting. +is only visible on hover? We can't just have the user press on the column header since that action is reserved for sorting. To resolve this, we need to render a menu button in the column header if the column is resizable, allowing non-mouse users to select from a list of available actions on the column. For resizing, selecting the corresponding option should cause focus to shift to the resizer itself, displaying the resizer for touch screen users to drag and letting keyboard and screen readers resize the column via keyboard arrows and screen reader operations @@ -1060,51 +1016,27 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, let {isFocusVisible, focusProps} = useFocusRing(); let {hoverProps, isHovered} = useHover({}); let showResizer = isHovered || layoutState.resizingColumn === column.key; - let arrowIcon = state.sortDescriptor?.direction === 'ascending' ? '▲' : '▼'; - let allowsSorting = column.props?.allowsSorting; let allowsResizing = column.props.allowsResizing; - const onMenuSelect = (key) => { + const onMenuSelect = React.useCallback((key) => { switch (key) { - case 'sort-asc': - state.sort(column.key, 'ascending'); - break; - case 'sort-desc': - state.sort(column.key, 'descending'); - break; case 'resize': layoutState.onColumnResizeStart(column.key); - if (resizerRef) { - // Brief delay before moving focus to resizer input for screenreaders/Safari - setTimeout(() => resizerRef.current?.focus(), 50); - } + // Brief delay before moving focus to resizer input for screenreaders/Safari + setTimeout(() => resizerRef.current?.focus(), 50); break; } - }; + }, [layoutState, column.key]); let items = React.useMemo(() => { let options = [ - allowsSorting ? { - label: 'Sort ascending', - id: 'sort-asc' - } : undefined, - allowsSorting ? { - label: 'Sort descending', - id: 'sort-desc' - } : undefined, { label: 'Resize column', id: 'resize' } ]; return options; - }, [allowsSorting]); - - let sortIcon = ( - - ); + }, []); let contents = allowsResizing ? ( <> @@ -1130,7 +1062,6 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, )} - {column.props.allowsSorting && sortIcon} ) : @@ -1144,7 +1075,6 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, }}> {column.rendered} - {column.props.allowsSorting && sortIcon}
    ); @@ -1435,13 +1365,15 @@ and handles text overflow. ```tsx example export=true render=false function ResizableTableRow({item, children, state}) { + // Same as previous TableRow implementation + ///- begin collapse -/// let ref = useRef(); let isSelected = state.selectionManager.isSelected(item.key); let {rowProps, isPressed} = useTableRow({ node: item }, state, ref); let {isFocusVisible, focusProps} = useFocusRing(); - + ///- end collapse -/// return ( -### Resizable checkbox - -The `TableCheckboxCell` and the `TableSelectAllCell` now have their widths dictated by the state returned by -and receive minor style changes to accommodate that. - -
    - Show code - -```tsx example export=true render=false -function ResizableTableCheckboxCell({cell, state, widths}) { - let ref = useRef(); - let {gridCellProps} = useTableCell({node: cell}, state, ref); - let {checkboxProps} = useTableSelectionCheckbox({key: cell.parentKey}, state); - /*- begin highlight -*/ - let column = cell.column; - /*- end highlight -*/ - - return ( - - - - - ); -} -``` - -
    - -
    - Show code - -```tsx example export=true render=false -function ResizableTableSelectAllCell({column, state, widths}) { - let ref = useRef(); - let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); - let {checkboxProps} = useTableSelectAllCheckbox(state); - - return ( - - {state.selectionManager.selectionMode === 'single' - ? {checkboxProps['aria-label']} - /*- begin highlight -*/ - : - /*- end highlight -*/ - } - - ); -} -``` - -
    - - And with that, all necessary changes to the previous table implementation have been made and we now have a table that supports resizable columns! -The example below supports resizing via mouse, keyboard, touch, and screen reader interactions. Previous behaviors such as selection and sorting are -also still supported. +The example below supports resizing via mouse, keyboard, touch, and screen reader interactions. To see an example with sorting and selection, see the styled example! ```tsx example - + Name Type From ef23f533bb03861c3b76c34d5737b5546da58c1c Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 3 Feb 2023 17:56:21 -0800 Subject: [PATCH 21/64] collapsing some parts of the code and trying to display the code blocks as is --- packages/@react-aria/table/docs/useTable.mdx | 39 ++------------------ 1 file changed, 3 insertions(+), 36 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index bda0112243e..0a5f820b709 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -825,9 +825,6 @@ the table cells in the body match their parent column's widths at all times. The various style changes below are to make the table itself scrollable when the row content overflows and to support table body/column widths greater than the 300px applied to the table itself. -
    - Show code - ```tsx example export=true render=false /*- begin highlight -*/ import {useTableColumnResizeState} from '@react-stately/table'; @@ -935,17 +932,11 @@ function ResizableColumnsTable(props) { } ``` -
    - - ### Resizable table header The `TableRowGroup` and `TableHeaderRow` only require minor style changes to accommodate the new `display` changes made in `ResizableColumnsTable`. -
    - Show code - ```tsx example export=true render=false function ResizableTableRowGroup({type: Element, style, children}) { let {rowGroupProps} = useTableRowGroup(); @@ -964,11 +955,6 @@ function ResizableTableRowGroup({type: Element, style, children}) { } ``` -
    - -
    - Show code - ```tsx example export=true render=false function ResizableTableHeaderRow({item, state, children}) { let ref = useRef(); @@ -988,8 +974,6 @@ function ResizableTableHeaderRow({item, state, children}) { } ``` -
    - The `TableColumnHeader` is where we see the bulk of the changes required to support resizable columns. First of all, we need to accommodate a `Resizer` element in every resizable column that the user can drag or focus to perform a resize operation. To avoid interfering with the existing grid keyboard navigation, we only display this resizer when the column is hovered or if the column itself is being resized. This however introduces another issue: how will keyboard, screen reader, and touch users begin a resize operation if the resizer @@ -999,9 +983,6 @@ To resolve this, we need to render a menu button in the column header if the col should cause focus to shift to the resizer itself, displaying the resizer for touch screen users to drag and letting keyboard and screen readers resize the column via keyboard arrows and screen reader operations accordingly. -
    - Show code - ```tsx example export=true render=false // Reuse the MenuTrigger from your component library. See below for details. import {MenuTrigger} from 'your-component-library'; @@ -1102,8 +1083,6 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, } ``` -
    - ### Resizer As described above, we need to implement an element that the user can drag/interact with to resize a column. Here we'll use the @@ -1114,9 +1093,6 @@ resize the column and double tap to exit resizing. Note that we apply `visibility: hidden` to hide the resizer so that we reserve space for it in the column header at all times. -
    - Show code - ```tsx example export=true render=false import {useTableColumnResize} from '@react-aria/table'; @@ -1159,8 +1135,6 @@ const Resizer = React.forwardRef((props, ref) => { }); ``` -
    - ### MenuTrigger The `MenuTrigger` is used in the `ResizableTableColumnHeader` to display a list of available actions available to a column. It is built using the [useMenuTrigger](useMenu.html) @@ -1360,9 +1334,6 @@ accommodate the new `display` changes made in the `ResizableColumnsTable`. The c background of the row to the full width of its child cells. `TableCell` now receives its width from and handles text overflow. -
    - Show code - ```tsx example export=true render=false function ResizableTableRow({item, children, state}) { // Same as previous TableRow implementation @@ -1400,16 +1371,14 @@ function ResizableTableRow({item, children, state}) { } ``` -
    - -
    - Show code - ```tsx example export=true render=false function ResizableTableCell({cell, state, widths}) { + // Same as previous TableCell implementation + ///- begin collapse -/// let ref = useRef(); let {gridCellProps} = useTableCell({node: cell}, state, ref); let {isFocusVisible, focusProps} = useFocusRing(); + ///- end collapse -/// /*- begin highlight -*/ let column = cell.column; /*- end highlight -*/ @@ -1439,8 +1408,6 @@ function ResizableTableCell({cell, state, widths}) { } ``` -
    - And with that, all necessary changes to the previous table implementation have been made and we now have a table that supports resizable columns! The example below supports resizing via mouse, keyboard, touch, and screen reader interactions. To see an example with sorting and selection, see the styled example! From 4243cd7439eeaaf89cee843b3e230c44e22b5535 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 6 Feb 2023 14:31:24 -0800 Subject: [PATCH 22/64] addressing review comments --- packages/@react-aria/table/docs/useTable.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 0a5f820b709..f731379d5a2 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -831,13 +831,13 @@ import {useTableColumnResizeState} from '@react-stately/table'; /*- end highlight -*/ function ResizableColumnsTable(props) { + /*- begin highlight -*/ let { - /*- begin highlight -*/ onResizeStart, onResize, onResizeEnd - /*- end highlight -*/ } = props; + /*- end highlight -*/ let state = useTableState(props); let ref = useRef(); @@ -1111,7 +1111,7 @@ const Resizer = React.forwardRef((props, ref) => {
    { ### Menu The `Menu` is used to display the various actions available to the column, rendered with a `Popover` when the user presses the column header. -This is built using the [useMenu](useMenu.html) and [useTreeState](/react-stately/useTreeState.html) hooks. +This is built using the [useMenu](useMenu.html) and [useTreeState](../react-stately/useTreeState.html) hooks.
    Show code From dfdc4cd8bf4f61f1b2f38e35d380d0e1d03ccbd2 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 6 Feb 2023 17:37:37 -0800 Subject: [PATCH 23/64] add tailwind example --- .../@react-aria/table/docs/Table-tailwind.png | Bin 0 -> 92779 bytes packages/@react-aria/table/docs/useTable.mdx | 14 +++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 packages/@react-aria/table/docs/Table-tailwind.png diff --git a/packages/@react-aria/table/docs/Table-tailwind.png b/packages/@react-aria/table/docs/Table-tailwind.png new file mode 100644 index 0000000000000000000000000000000000000000..42359dadeb4b858a5e0bd5e5484e01ae1eff607c GIT binary patch literal 92779 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 @@ -1443,6 +1446,15 @@ The example below supports resizing via mouse, keyboard, touch, and screen reade ``` +### Styled examples + + + + ## Internationalization `useTable` handles some aspects of internationalization automatically. From 8dfce343278cdd53051a639ca02d42a2dc0ed9d9 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 7 Feb 2023 13:47:07 -0800 Subject: [PATCH 24/64] forgot to add some additional comments --- packages/@react-aria/table/docs/useTable.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 65bb4c844de..0366084c910 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -872,6 +872,9 @@ function ResizableColumnsTable(props) { /*- begin highlight -*/ width: '300px', height: '200px', + // Override the table's default display with "block" so that our defined column widths + // are respected. Without this and other table element display overrides, the columns + // can grow/shrink beyond their applied "width" display: 'block', position: 'relative', overflow: 'auto' From bd405cff63e2e38be590fe372ab64d9d75d78fb1 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 7 Feb 2023 14:15:44 -0800 Subject: [PATCH 25/64] override native focus ring for button --- packages/@react-aria/table/docs/useTable.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 0366084c910..c9193f0e465 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -1325,8 +1325,12 @@ import {useButton} from '@react-aria/button'; function Button(props) { let ref = props.buttonRef; + let { focusProps, isFocusVisible } = useFocusRing(); + let outline = isFocusVisible + ? '2px solid orange' + : 'none'; let {buttonProps} = useButton(props, ref); - return ; + return ; } ``` From 7e7b117503442691314836a3795a20a71e085300 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 7 Feb 2023 14:33:46 -0800 Subject: [PATCH 26/64] update copy as per review and re-collapse code blocks collapsed code blocks feel better on mobile so I changed it back to that --- packages/@react-aria/table/docs/useTable.mdx | 36 +++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index c9193f0e465..18b6c97656f 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -827,6 +827,9 @@ the table cells in the body match their parent column's widths at all times. The various style changes below are to make the table itself scrollable when the row content overflows and to support table body/column widths greater than the 300px applied to the table itself. +
    + Show code + ```tsx example export=true render=false /*- begin highlight -*/ import {useTableColumnResizeState} from '@react-stately/table'; @@ -937,11 +940,16 @@ function ResizableColumnsTable(props) { } ``` +
    + ### Resizable table header The `TableRowGroup` and `TableHeaderRow` only require minor style changes to accommodate the new `display` changes made in `ResizableColumnsTable`. +
    + Show code + ```tsx example export=true render=false function ResizableTableRowGroup({type: Element, style, children}) { let {rowGroupProps} = useTableRowGroup(); @@ -959,6 +967,10 @@ function ResizableTableRowGroup({type: Element, style, children}) { ); } ``` +
    + +
    + Show code ```tsx example export=true render=false function ResizableTableHeaderRow({item, state, children}) { @@ -979,15 +991,20 @@ function ResizableTableHeaderRow({item, state, children}) { } ``` +
    + The `TableColumnHeader` is where we see the bulk of the changes required to support resizable columns. First of all, we need to accommodate a `Resizer` element in every resizable column that the user can drag or focus to perform a resize operation. To avoid interfering with the existing grid keyboard navigation, we only display this resizer when the column is hovered or if the column itself is being resized. This however introduces another issue: how will keyboard, screen reader, and touch users begin a resize operation if the resizer -is only visible on hover? We can't just have the user press on the column header since that action is reserved for sorting. +is only visible on hover? We could have the user press on the column header to trigger a resize operation, but that action is often reserved for sorting and may feel unexpected to a user. To resolve this, we need to render a menu button in the column header if the column is resizable, allowing non-mouse users to select from a list of available actions on the column. For resizing, selecting the corresponding option should cause focus to shift to the resizer itself, displaying the resizer for touch screen users to drag and letting keyboard and screen readers resize the column via keyboard arrows and screen reader operations accordingly. +
    + Show code + ```tsx example export=true render=false // Reuse the MenuTrigger from your component library. See below for details. import {MenuTrigger} from 'your-component-library'; @@ -1088,6 +1105,8 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, } ``` +
    + ### Resizer As described above, we need to implement an element that the user can drag/interact with to resize a column. Here we'll use the @@ -1098,6 +1117,9 @@ resize the column and double tap to exit resizing. Note that we apply `visibility: hidden` to hide the resizer so that we reserve space for it in the column header at all times. +
    + Show code + ```tsx example export=true render=false import {useTableColumnResize} from '@react-aria/table'; @@ -1140,6 +1162,8 @@ const Resizer = React.forwardRef((props, ref) => { }); ``` +
    + ### MenuTrigger The `MenuTrigger` is used in the `ResizableTableColumnHeader` to display a list of available actions available to a column. It is built using the [useMenuTrigger](useMenu.html) @@ -1343,6 +1367,9 @@ accommodate the new `display` changes made in the `ResizableColumnsTable`. The c background of the row to the full width of its child cells. `TableCell` now receives its width from and handles text overflow. +
    + Show code + ```tsx example export=true render=false function ResizableTableRow({item, children, state}) { // Same as previous TableRow implementation @@ -1380,6 +1407,11 @@ function ResizableTableRow({item, children, state}) { } ``` +
    + +
    + Show code + ```tsx example export=true render=false function ResizableTableCell({cell, state, widths}) { // Same as previous TableCell implementation @@ -1417,6 +1449,8 @@ function ResizableTableCell({cell, state, widths}) { } ``` +
    + And with that, all necessary changes to the previous table implementation have been made and we now have a table that supports resizable columns! The example below supports resizing via mouse, keyboard, touch, and screen reader interactions. To see an example with sorting and selection, see the [styled example](#styled-examples)! From 312def9830ef17d41300545890ca432a557e6761 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 9 Feb 2023 09:41:25 -0800 Subject: [PATCH 27/64] uncollapsing code blocks --- packages/@react-aria/table/docs/useTable.mdx | 35 -------------------- 1 file changed, 35 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 18b6c97656f..e48d800f2e9 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -827,9 +827,6 @@ the table cells in the body match their parent column's widths at all times. The various style changes below are to make the table itself scrollable when the row content overflows and to support table body/column widths greater than the 300px applied to the table itself. -
    - Show code - ```tsx example export=true render=false /*- begin highlight -*/ import {useTableColumnResizeState} from '@react-stately/table'; @@ -940,16 +937,11 @@ function ResizableColumnsTable(props) { } ``` -
    - ### Resizable table header The `TableRowGroup` and `TableHeaderRow` only require minor style changes to accommodate the new `display` changes made in `ResizableColumnsTable`. -
    - Show code - ```tsx example export=true render=false function ResizableTableRowGroup({type: Element, style, children}) { let {rowGroupProps} = useTableRowGroup(); @@ -967,10 +959,6 @@ function ResizableTableRowGroup({type: Element, style, children}) { ); } ``` -
    - -
    - Show code ```tsx example export=true render=false function ResizableTableHeaderRow({item, state, children}) { @@ -991,8 +979,6 @@ function ResizableTableHeaderRow({item, state, children}) { } ``` -
    - The `TableColumnHeader` is where we see the bulk of the changes required to support resizable columns. First of all, we need to accommodate a `Resizer` element in every resizable column that the user can drag or focus to perform a resize operation. To avoid interfering with the existing grid keyboard navigation, we only display this resizer when the column is hovered or if the column itself is being resized. This however introduces another issue: how will keyboard, screen reader, and touch users begin a resize operation if the resizer @@ -1002,9 +988,6 @@ To resolve this, we need to render a menu button in the column header if the col should cause focus to shift to the resizer itself, displaying the resizer for touch screen users to drag and letting keyboard and screen readers resize the column via keyboard arrows and screen reader operations accordingly. -
    - Show code - ```tsx example export=true render=false // Reuse the MenuTrigger from your component library. See below for details. import {MenuTrigger} from 'your-component-library'; @@ -1105,8 +1088,6 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, } ``` -
    - ### Resizer As described above, we need to implement an element that the user can drag/interact with to resize a column. Here we'll use the @@ -1117,9 +1098,6 @@ resize the column and double tap to exit resizing. Note that we apply `visibility: hidden` to hide the resizer so that we reserve space for it in the column header at all times. -
    - Show code - ```tsx example export=true render=false import {useTableColumnResize} from '@react-aria/table'; @@ -1154,7 +1132,6 @@ const Resizer = React.forwardRef((props, ref) => {
    @@ -1162,8 +1139,6 @@ const Resizer = React.forwardRef((props, ref) => { }); ``` - - ### MenuTrigger The `MenuTrigger` is used in the `ResizableTableColumnHeader` to display a list of available actions available to a column. It is built using the [useMenuTrigger](useMenu.html) @@ -1367,9 +1342,6 @@ accommodate the new `display` changes made in the `ResizableColumnsTable`. The c background of the row to the full width of its child cells. `TableCell` now receives its width from and handles text overflow. -
    - Show code - ```tsx example export=true render=false function ResizableTableRow({item, children, state}) { // Same as previous TableRow implementation @@ -1407,11 +1379,6 @@ function ResizableTableRow({item, children, state}) { } ``` -
    - -
    - Show code - ```tsx example export=true render=false function ResizableTableCell({cell, state, widths}) { // Same as previous TableCell implementation @@ -1449,8 +1416,6 @@ function ResizableTableCell({cell, state, widths}) { } ``` -
    - And with that, all necessary changes to the previous table implementation have been made and we now have a table that supports resizable columns! The example below supports resizing via mouse, keyboard, touch, and screen reader interactions. To see an example with sorting and selection, see the [styled example](#styled-examples)! From 128edfa159f60fabc2c95db84347cdf507afac99 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 13 Feb 2023 17:34:30 -0800 Subject: [PATCH 28/64] Rough example of press header to start resizing --- packages/@react-aria/table/docs/useTable.mdx | 285 ++----------------- 1 file changed, 24 insertions(+), 261 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index e48d800f2e9..3abd5836698 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -989,84 +989,32 @@ should cause focus to shift to the resizer itself, displaying the resizer for to accordingly. ```tsx example export=true render=false -// Reuse the MenuTrigger from your component library. See below for details. -import {MenuTrigger} from 'your-component-library'; -import {useHover} from '@react-aria/interactions'; +import {useDescription} from '@react-aria/utils'; +import {useHover, usePress} from '@react-aria/interactions'; function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, onResize, onResizeEnd}) { - let {widths} = layoutState; + let {widths} = layoutState; let ref = useRef(null); let resizerRef = useRef(null); - let triggerRef = useRef(null); let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); let {isFocusVisible, focusProps} = useFocusRing(); let {hoverProps, isHovered} = useHover({}); let showResizer = isHovered || layoutState.resizingColumn === column.key; let allowsResizing = column.props.allowsResizing; - const onMenuSelect = React.useCallback((key) => { - switch (key) { - case 'resize': - layoutState.onColumnResizeStart(column.key); - // Brief delay before moving focus to resizer input for screenreaders/Safari - setTimeout(() => resizerRef.current?.focus(), 50); - break; + let {pressProps} = usePress({ + isDisabled: !allowsResizing, + onPress() { + layoutState.onColumnResizeStart(column.key); + // Brief delay before moving focus to resizer input for screenreaders/Safari + setTimeout(() => resizerRef.current?.focus(), 50); } - }, [layoutState, column.key]); - - let items = React.useMemo(() => { - let options = [ - { - label: 'Resize column', - id: 'resize' - } - ]; - return options; - }, []); - - let contents = allowsResizing ? ( - <> - - {(item) => ( - - {item.label} - - )} - - - - ) : - ( -
    - - {column.rendered} -
    - ); + }); + let descriptionProps = useDescription('Press to start resizing the column.'); return ( 1 ? 'center' : 'left', @@ -1081,7 +1029,18 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, }} ref={ref}>
    - {contents} +
    + {column.rendered} +
    + {allowsResizing && + + }
    ); @@ -1139,202 +1098,6 @@ const Resizer = React.forwardRef((props, ref) => { }); ``` -### MenuTrigger - -The `MenuTrigger` is used in the `ResizableTableColumnHeader` to display a list of available actions available to a column. It is built using the [useMenuTrigger](useMenu.html) -and [useMenuTriggerState](/react-stately/useMenuTriggerState.html) hooks, and can be shared with many other components. Note that you'll have to call `continuePropagation()` in `onKeyDown` -to properly bubble the `keydown` event up to `useSelectableCollection` for grid keyboard navigation. - -
    - Show code - -```tsx example export=true render=false -import {useMenuTriggerState} from '@react-stately/menu'; -import {useMenuTrigger} from '@react-aria/menu'; -import {Item} from '@react-stately/collections'; - -// Reuse the Button, Menu, and Popover from your component library. See below for details. -import {Button, Menu, Popover} from 'your-component-library'; - -const MenuTrigger = React.forwardRef((props, ref) => { - // Create state based on the incoming props - let state = useMenuTriggerState(props); - - // Get props for the button and menu elements - let {menuTriggerProps, menuProps} = useMenuTrigger({trigger: 'press'}, state, ref); - /*- begin highlight -*/ - // Continue propagation of keydown event so that the left/right arrow key presses properly bubble to the table (useSelectableCollection) - let onKeyDown = (e) => e.continuePropagation(); - /*- end highlight -*/ - return ( - <> - - {state.isOpen && - - - - } - - ); -}); -``` - -
    - -### Menu - -The `Menu` is used to display the various actions available to the column, rendered with a `Popover` when the user presses the column header. -This is built using the [useMenu](useMenu.html) and [useTreeState](../react-stately/useTreeState.html) hooks. - -
    - Show code - -```tsx example export=true render=false -import {useMenu} from '@react-aria/menu'; -import {useTreeState} from '@react-stately/tree'; - -// Reuse the MenuItem from your component library. See below for details. -import {MenuItem} from 'your-component-library'; - -function Menu(props) { - // Create menu state based on the incoming props - let state = useTreeState(props); - - // Get props for the menu element - let ref = React.useRef(); - let {menuProps} = useMenu(props, state, ref); - - return ( -
      - {[...state.collection].map(item => ( - - ))} -
    - ); -} -``` - -
    - -### MenuItem - -The `MenuItem` is used to render the items in the menu. This is built using the [useMenuItem](useMenu.html) hook. - -
    - Show code - -```tsx example export=true render=false -import {useMenuItem} from '@react-aria/menu'; - -function MenuItem({item, state}) { - // Get props for the menu item element - let ref = React.useRef(); - let {menuItemProps, isFocused, isSelected, isDisabled} = useMenuItem({key: item.key}, state, ref); - - return ( -
  • - {item.rendered} - {isSelected && } -
  • - ); -} -``` - -
    - -### Popover - -The `Popover` component is used to contain the menu. -It can be shared between many other components, including [ComboBox](useComboBox.html), -[Select](useSelect.html), and others. -See [usePopover](usePopover.html) for more examples of popovers. - -
    - Show code - -```tsx example export=true render=false -import {usePopover, Overlay, DismissButton} from '@react-aria/overlays'; - -function Popover({children, state, ...props}) { - let ref = React.useRef(); - let {popoverRef = ref, triggerRef} = props; - let {popoverProps, underlayProps} = usePopover({ - ...props, - popoverRef, - triggerRef - }, state); - - return ( - -
    -
    - - {children} - -
    - - ); -} -``` - -
    - -### Button - -The `Button` component is used in the above example to toggle the menu. It is built using the [useButton](useButton.html) hook, and can be shared with many other components. - -
    - Show code - -```tsx example export=true render=false -import {useButton} from '@react-aria/button'; - -function Button(props) { - let ref = props.buttonRef; - let { focusProps, isFocusVisible } = useFocusRing(); - let outline = isFocusVisible - ? '2px solid orange' - : 'none'; - let {buttonProps} = useButton(props, ref); - return ; -} -``` - -
    - ### Resizable table body Similar to `TableRowGroup` and `TableHeaderRow`, `TableRow` and `TableCell` only require minor style changes to From 02c2487e26e53b30b0aaf1cb66ff112af8e9ef95 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Feb 2023 11:03:59 -0800 Subject: [PATCH 29/64] Updating column resize to support mode where resizer is always visible split out from https://github.com/adobe/react-spectrum/pull/4061, see that PR for more details and alternative approaches --- packages/@react-aria/grid/src/useGrid.ts | 30 +++++++++++- .../table/src/useTableColumnResize.ts | 48 ++++++++++++------- .../@react-spectrum/table/src/TableView.tsx | 3 +- .../table/test/TableSizing.test.tsx | 15 ++---- 4 files changed, 65 insertions(+), 31 deletions(-) diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index badf65ddea2..a2f477af464 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -16,7 +16,7 @@ import {GridCollection} from '@react-types/grid'; import {GridKeyboardDelegate} from './GridKeyboardDelegate'; import {gridMap} from './utils'; import {GridState} from '@react-stately/grid'; -import {Key, RefObject, useMemo} from 'react'; +import {Key, RefObject, useCallback, useMemo} from 'react'; import {useCollator, useLocale} from '@react-aria/i18n'; import {useGridSelectionAnnouncement} from './useGridSelectionAnnouncement'; import {useHighlightSelectionDescription} from './useHighlightSelectionDescription'; @@ -72,6 +72,7 @@ export function useGrid(props: GridProps, state: GridState(props: GridProps, state: GridState { + if (manager.isFocused) { + // If a focus event bubbled through a portal, reset focus state. + if (!e.currentTarget.contains(e.target)) { + manager.setFocused(false); + } + + return; + } + + // Focus events can bubble through portals. Ignore these events. + if (!e.currentTarget.contains(e.target)) { + return; + } + + manager.setFocused(true); + }, [manager]); + + // Continue to track collection focused state even if keyboard navigation is disabled + let navDisabledHandlers = useMemo(() => ({ + onBlur: collectionProps.onBlur, + onFocus + }), [onFocus, collectionProps.onBlur]); + let gridProps: DOMAttributes = mergeProps( domProps, { @@ -114,7 +140,7 @@ export function useGrid(props: GridProps, state: GridState(props: AriaTableColumnResizeProps, st let id = useId(); let isResizing = useRef(false); let lastSize = useRef(null); + let editModeEnabled = state.tableState.isKeyboardNavigationDisabled; let {direction} = useLocale(); let {keyboardProps} = useKeyboard({ onKeyDown: (e) => { - 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); + if (editModeEnabled) { + 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); + } + } else { + // Continue propagation on ArrowRight/Left so event bubbles to useSelectableCollection + if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft')) { + e.continuePropagation(); + } + + if (e.key === 'Enter') { + state.tableState.setKeyboardNavigationDisabled(true); + } } } }); let startResize = useCallback((item) => { if (!isResizing.current) { - lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); - state.startResize(item.key); + lastSize.current = state.onColumnResize(item.key, state.getColumnWidth(item.key)); + state.onColumnResizeStart(item.key); onResizeStart?.(lastSize.current); } isResizing.current = true; }, [isResizing, onResizeStart, state]); let resize = useCallback((item, newWidth) => { - let sizes = state.updateResizedColumns(item.key, newWidth); + let sizes = state.onColumnResize(item.key, newWidth); onResize?.(sizes); lastSize.current = sizes; }, [onResize, state]); let endResize = useCallback((item) => { if (lastSize.current == null) { - lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); + lastSize.current = state.onColumnResize(item.key, state.getColumnWidth(item.key)); } + + state.onColumnResizeEnd(); if (isResizing.current) { - state.endResize(); onResizeEnd?.(lastSize.current); } isResizing.current = false; @@ -134,6 +147,13 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } }); + let onKeyDown = useCallback((e) => { + if (editModeEnabled) { + moveProps.onKeyDown(e); + } + }, [editModeEnabled, moveProps]); + + let min = Math.floor(state.getColumnMinWidth(item.key)); let max = Math.floor(state.getColumnMaxWidth(item.key)); if (max === Infinity) { @@ -186,7 +206,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st onPress: (e) => { if (e.pointerType === 'touch') { focusInput(); - } else if (e.pointerType !== 'virtual') { + } else if (e.pointerType !== 'virtual' && e.pointerType !== 'keyboard') { if (triggerRef?.current) { focusSafely(triggerRef.current); } @@ -197,18 +217,12 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st return { resizerProps: mergeProps( keyboardProps, - moveProps, + {...moveProps, onKeyDown}, pressProps ), inputProps: mergeProps( { id, - 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 - startResize(item); - state.tableState.setKeyboardNavigationDisabled(true); - }, onBlur: () => { endResize(item); state.tableState.setKeyboardNavigationDisabled(false); diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 91c91d0f9f3..41c283d3019 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -732,8 +732,9 @@ function ResizableTableColumnHeader(props) { state.sort(column.key, 'descending'); break; case 'resize': - layout.startResize(column.key); + layout.onColumnResizeStart(column.key); setIsInResizeMode(true); + state.setKeyboardNavigationDisabled(true); break; } }; diff --git a/packages/@react-spectrum/table/test/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx index 4e930cdff0a..81d7285b236 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.tsx +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -1258,9 +1258,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Enter'}); fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); - + expect(onResizeEnd).toHaveBeenCalledTimes(0); expect(document.activeElement).toBe(resizableHeader); expect(tree.queryByRole('slider')).toBeNull(); @@ -1307,11 +1305,7 @@ describe('TableViewSizing', function () { expect(document.activeElement).toBe(resizer); userEvent.tab(); - 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(onResizeEnd).toHaveBeenCalledTimes(0); expect(document.activeElement).toBe(resizableHeader); expect(tree.queryByRole('slider')).toBeNull(); @@ -1359,9 +1353,7 @@ describe('TableViewSizing', function () { expect(document.activeElement).toBe(resizer); userEvent.tab({shift: true}); - expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); - + expect(onResizeEnd).toHaveBeenCalledTimes(0); expect(document.activeElement).toBe(resizableHeader); expect(tree.queryByRole('slider')).toBeNull(); @@ -1656,6 +1648,7 @@ function resizeCol(tree, col, delta) { 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}); + act(() => {jest.runAllTimers();}); fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); act(() => {jest.runAllTimers();}); } From a00fa21593caf09b81a4ae6be67ad5823e78190f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Feb 2023 11:09:52 -0800 Subject: [PATCH 30/64] update to match latest changes to api --- packages/@react-aria/table/src/useTableColumnResize.ts | 10 +++++----- packages/@react-spectrum/table/src/TableView.tsx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 7ae6b5f2141..aae52ed8362 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -89,25 +89,25 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let startResize = useCallback((item) => { if (!isResizing.current) { - lastSize.current = state.onColumnResize(item.key, state.getColumnWidth(item.key)); - state.onColumnResizeStart(item.key); + lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); + state.startResize(item.key); onResizeStart?.(lastSize.current); } isResizing.current = true; }, [isResizing, onResizeStart, state]); let resize = useCallback((item, newWidth) => { - let sizes = state.onColumnResize(item.key, newWidth); + let sizes = state.updateResizedColumns(item.key, newWidth); onResize?.(sizes); lastSize.current = sizes; }, [onResize, state]); let endResize = useCallback((item) => { if (lastSize.current == null) { - lastSize.current = state.onColumnResize(item.key, state.getColumnWidth(item.key)); + lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); } - state.onColumnResizeEnd(); + state.endResize(); if (isResizing.current) { onResizeEnd?.(lastSize.current); } diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 41c283d3019..e840e8e4d21 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -732,7 +732,7 @@ function ResizableTableColumnHeader(props) { state.sort(column.key, 'descending'); break; case 'resize': - layout.onColumnResizeStart(column.key); + layout.startResize(column.key); setIsInResizeMode(true); state.setKeyboardNavigationDisabled(true); break; From c270a5ae694abb6772db0393b68faf5ce05977af Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Feb 2023 11:36:54 -0800 Subject: [PATCH 31/64] mimic docs example --- .../table/stories/example-docs.tsx | 287 ++++++++++++++++++ .../table/stories/useTable.stories.tsx | 136 ++++++++- 2 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 packages/@react-aria/table/stories/example-docs.tsx diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx new file mode 100644 index 00000000000..2d3d9cb5551 --- /dev/null +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -0,0 +1,287 @@ +/* + * Copyright 2021 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {mergeProps} from '@react-aria/utils'; +import React, {RefObject} from 'react'; +import {useButton} from 'react-aria'; +import {useFocusRing} from '@react-aria/focus'; +import {useRef} from 'react'; +import {useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup} from '@react-aria/table'; +import {useTableColumnResizeState, useTableState} from '@react-stately/table'; +import {VisuallyHidden} from '@react-aria/visually-hidden'; + +export function Table(props) { + let { + onResizeStart, + onResize, + onResizeEnd + } = props; + + let state = useTableState(props); + let ref = useRef(); + let {collection} = state; + let {gridProps} = useTable( + { + ...props, + // The table itself is scrollable rather than just the body + scrollRef: ref + }, + state, + ref + ); + + let layoutState = useTableColumnResizeState({ + // Matches the width of the table itself + tableWidth: 300 + }, state); + let {widths} = layoutState; + + return ( + + + {collection.headerRows.map(headerRow => ( + + {[...headerRow.childNodes].map(column => ( + + ))} + + ))} + + + {[...collection.body.childNodes].map(row => ( + + {[...row.childNodes].map(cell => ( + + ))} + + ))} + +
    + ); +} + +function ResizableTableRowGroup({type: Element, style, children}) { + let {rowGroupProps} = useTableRowGroup(); + return ( + + {children} + + ); +} + +function ResizableTableHeaderRow({item, state, children}) { + let ref = useRef(); + let {rowProps} = useTableHeaderRow({node: item}, state, ref); + + return ( + + {children} + + ); +} + +function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, onResize, onResizeEnd}) { + let {widths} = layoutState; + let ref = useRef(null); + let resizerRef = useRef(null); + let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); + let {isFocusVisible, focusProps} = useFocusRing(); + let allowsResizing = column.props.allowsResizing; + + return ( + 1 ? 'center' : 'left', + padding: '5px 10px', + outline: 'none', + boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', + cursor: 'default', + width: widths.get(column.key), + display: 'block', + flex: '0 0 auto', + boxSizing: 'border-box' + }} + ref={ref}> +
    + + {allowsResizing && + + } +
    + + ); +} + +function Button(props) { + let ref = props.buttonRef; + let {focusProps, isFocusVisible} = useFocusRing(); + let outline = isFocusVisible + ? '2px solid orange' + : 'none'; + let {buttonProps} = useButton(props, ref); + return ; +} + +const Resizer = React.forwardRef((props: any, ref: RefObject) => { + let {column, layoutState, onResizeStart, onResize, onResizeEnd, triggerRef} = props; + let {resizerProps, inputProps} = useTableColumnResize({ + column, + label: 'Resizer', + onResizeStart, + onResize, + onResizeEnd, + triggerRef + }, layoutState, ref); + let {focusProps, isFocusVisible} = useFocusRing(); + + return ( +
    + + + +
    + ); +}); + +function ResizableTableRow({item, children, state}) { + let ref = useRef(); + let isSelected = state.selectionManager.isSelected(item.key); + let {rowProps, isPressed} = useTableRow({ + node: item + }, state, ref); + let {isFocusVisible, focusProps} = useFocusRing(); + + return ( + + {children} + + ); +} + +function ResizableTableCell({cell, state, widths}) { + let ref = useRef(); + let {gridCellProps} = useTableCell({node: cell}, state, ref); + let {isFocusVisible, focusProps} = useFocusRing(); + let column = cell.column; + + return ( + + {cell.rendered} + + ); +} diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index b46beb2f8a2..dc8a5731404 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -14,10 +14,12 @@ 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 {Table as DocsTable} from './example-docs'; import {Meta, Story} from '@storybook/react'; import React, {Key, useCallback, useMemo, useState} from 'react'; import {Table as ResizingTable} from './example-resizing'; import {Table} from './example'; +import {useAsyncList} from 'react-stately'; const meta: Meta> = { title: 'useTable' @@ -26,7 +28,7 @@ const meta: Meta> = { export default meta; let columns = [ - {name: 'Name', uid: 'name'}, + {name: 'Naglwakenglkawnegklnakwlen glkawen glkawn gkaw neglkme', uid: 'name'}, {name: 'Type', uid: 'type'}, {name: 'Level', uid: 'level'} ]; @@ -53,7 +55,7 @@ const Template: Story> = (args) => ( {column => ( - + {column.name} )} @@ -244,3 +246,133 @@ export const TableWithSomeResizingFRsControlled = { column width state. `}} }; + +function ControlledDocsTable(props: {columns: Array<{name: string, uid: string, width?: ColumnSize | null}>, rows, onResize}) { + let {columns, ...otherProps} = props; + let [widths, _setWidths] = useState(() => new Map(columns.filter(col => col.width).map((col) => [col.uid as Key, col.width]))); + let setWidths = useCallback((newWidths: Map) => { + let controlledKeys = new Set(columns.filter(col => col.width).map((col) => col.uid as Key)); + let newVals = new Map(Array.from(newWidths).filter(([key]) => controlledKeys.has(key))); + _setWidths(newVals); + }, [columns]); + + // Needed to get past column caching so new sizes actually are rendered + // eslint-disable-next-line react-hooks/exhaustive-deps + let cols = useMemo(() => columns.map(col => ({...col})), [widths, columns]); + return ( + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + + + ); +} + +function AsyncSortTable() { + let list = useAsyncList({ + async load({signal}) { + let res = await fetch('https://swapi.py4e.com/api/people/?search', { + signal + }); + let json = await res.json(); + return { + items: json.results + }; + }, + async sort({items, sortDescriptor}) { + return { + items: items.sort((a, b) => { + let first = a[sortDescriptor.column]; + let second = b[sortDescriptor.column]; + let cmp = (parseInt(first, 10) || first) < (parseInt(second, 10) || second) + ? -1 + : 1; + if (sortDescriptor.direction === 'descending') { + cmp *= -1; + } + return cmp; + }) + }; + } + }); + + return ( + + + Name + Height + Mass + Birth Year + + + {(item: any) => ( + + {(columnKey) => {item[columnKey]}} + + )} + + + ); +} + +export const DocExample = { + args: {}, + render: (args) => ( + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + + + ) +}; + +export const DocExampleControlled = { + args: {columns: columnsFR}, + render: (args) => ( + + ) +}; + +export const DocExampleWithSorting = { + args: {}, + render: () => ( + + ) +}; From f6a3d076a36723815194046f73a7481a5ec2944e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Feb 2023 11:39:17 -0800 Subject: [PATCH 32/64] forgot to clean up some things --- packages/@react-aria/table/stories/useTable.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index dc8a5731404..f00e1de3f5f 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -28,7 +28,7 @@ const meta: Meta> = { export default meta; let columns = [ - {name: 'Naglwakenglkawnegklnakwlen glkawen glkawn gkaw neglkme', uid: 'name'}, + {name: 'Name', uid: 'name'}, {name: 'Type', uid: 'type'}, {name: 'Level', uid: 'level'} ]; @@ -55,7 +55,7 @@ const Template: Story> = (args) => (
    {column => ( - + {column.name} )} From cc62d52c459b95261739b75f0073a963ba915fc1 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Feb 2023 17:11:48 -0800 Subject: [PATCH 33/64] pulling in code changes from docs PR get rid of inline styles and fix case where there isnt a separate trigger for starting column resize --- .../table/src/useTableColumnResize.ts | 11 +- .../table/stories/docs-example.css | 94 ++++++++++++++++ .../table/stories/example-docs.tsx | 101 ++++-------------- 3 files changed, 121 insertions(+), 85 deletions(-) create mode 100644 packages/@react-aria/table/stories/docs-example.css diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index aae52ed8362..35f7197b316 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -69,10 +69,15 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let {keyboardProps} = useKeyboard({ onKeyDown: (e) => { if (editModeEnabled) { - if (triggerRef?.current && (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab')) { + if (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); + if (triggerRef?.current) { + // switch focus back to the column header on anything that ends edit mode + focusSafely(triggerRef.current); + } else { + endResize(item); + state.tableState.setKeyboardNavigationDisabled(false); + } } } else { // Continue propagation on ArrowRight/Left so event bubbles to useSelectableCollection diff --git a/packages/@react-aria/table/stories/docs-example.css b/packages/@react-aria/table/stories/docs-example.css new file mode 100644 index 00000000000..230378d39cc --- /dev/null +++ b/packages/@react-aria/table/stories/docs-example.css @@ -0,0 +1,94 @@ +.aria-table { + border-collapse: collapse; + width: 300px; + height: 200px; + display: block; + position: relative; + overflow: auto; + + .aria-table-rowGroup { + display: block; + } + + .aria-table-rowGroupHeader { + border-bottom: 2px solid var(--spectrum-global-color-gray-800); + position: sticky; + top: 0; + background: var(--spectrum-gray-100); + width: fit-content; + } + + .aria-table-rowGroupBody { + max-height: 200px; + } + + .aria-table-row { + display: flex; + } + + .aria-table-headerCell { + padding: 5px 10px; + outline: none; + cursor: default; + display: block; + flex: 0 0 auto; + box-sizing: border-box; + text-align: left; + + .aria-table-headerTitle { + width: 100%; + text-align: left; + border: none; + background: transparent; + flex: 1 1 auto; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-inline-start: -6px; + outline: none; + + &.focus { + outline: 2px solid orange; + } + } + } + + .aria-table-resizer { + cursor: col-resize; + width: 6px; + height: auto; + border: 2px; + border-style: none solid; + border-color: grey; + touch-action: none; + flex: 0 0 auto; + box-sizing: border-box; + margin-left: 4px; + + &.focus { + border-color: orange; + } + } + + .aria-table-row { + display: flex; + width: fit-content; + } + + .aria-table-cell { + padding: 5px 10px; + outline: none; + cursor: default; + display: block; + flex: 0 0 auto; + box-sizing: border-box; + box-shadow: none; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + &.focus { + box-shadow: inset 0 0 0 2px orange; + } + } +} diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx index 2d3d9cb5551..85631dab4cb 100644 --- a/packages/@react-aria/table/stories/example-docs.tsx +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +import ariaStyles from './docs-example.css'; +import {classNames} from '@react-spectrum/utils'; import {mergeProps} from '@react-aria/utils'; import React, {RefObject} from 'react'; import {useButton} from 'react-aria'; @@ -49,24 +51,10 @@ export function Table(props) {
    + className={classNames(ariaStyles, 'aria-table')}> + className={classNames(ariaStyles, 'aria-table-rowGroupHeader')}> {collection.headerRows.map(headerRow => ( {[...headerRow.childNodes].map(column => ( @@ -83,9 +71,7 @@ export function Table(props) { ))} {[...collection.body.childNodes].map(row => ( @@ -103,15 +89,12 @@ export function Table(props) { ); } -function ResizableTableRowGroup({type: Element, style, children}) { +function ResizableTableRowGroup({type: Element, children, className}) { let {rowGroupProps} = useTableRowGroup(); return ( + className={classNames(ariaStyles, 'aria-table-rowGroup', className)}> {children} ); @@ -125,7 +108,7 @@ function ResizableTableHeaderRow({item, state, children}) { + className={classNames(ariaStyles, 'aria-table-row')}> {children} ); @@ -136,42 +119,23 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, let ref = useRef(null); let resizerRef = useRef(null); let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); - let {isFocusVisible, focusProps} = useFocusRing(); let allowsResizing = column.props.allowsResizing; return ( @@ -181,40 +145,25 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, function Button(props) { let ref = props.buttonRef; let {focusProps, isFocusVisible} = useFocusRing(); - let outline = isFocusVisible - ? '2px solid orange' - : 'none'; let {buttonProps} = useButton(props, ref); - return ; + return ; } const Resizer = React.forwardRef((props: any, ref: RefObject) => { - let {column, layoutState, onResizeStart, onResize, onResizeEnd, triggerRef} = props; + let {column, layoutState, onResizeStart, onResize, onResizeEnd} = props; let {resizerProps, inputProps} = useTableColumnResize({ column, label: 'Resizer', onResizeStart, onResize, - onResizeEnd, - triggerRef + onResizeEnd }, layoutState, ref); let {focusProps, isFocusVisible} = useFocusRing(); return (
    @@ -267,19 +215,8 @@ function ResizableTableCell({cell, state, widths}) { return (
    From 7353190830b9a9c0504acae27a1beff0a2929674 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Feb 2023 17:13:39 -0800 Subject: [PATCH 34/64] Add separate mode for triggering resizing manually via Enter (#4061) * adding separate mode for enabling resize this make it so the user would have to hit Enter to start resizing. This allows us to have the resizers always be visible and still preserve grid navigation between columns. * skip test for now so build works for testing * wrap useMove so it doesnt trigger keyboard handlers when edit mode isnt enabled * update tableview so that it enters edit mode when choosing to resize via menu still one bug where manager.isFocused is set to false for somereason after confirming the resize * fixing bugs fixes bug where selection manager would stop tracking if the manager is focused or not if keyboard nav was disabled. This was a bug where the user wouldnt be able to move table focus after confirming a resize operation. Adds new prop to useTableColumnResize for triggering resizeStart if resizer if focused. This is needed for our table since we dont have a way to call resizeStart programmatically out side of useTableColumnResize and we dont want to call it on focus for the aria example since that should require a manual trigger of Enter to enter resize mode * updating description and missing logic * getting rid of shouldResizeOnFocus prop * fix skipped test turns out the simulated resize operation was blurring before pressEnd finished * updated docs to move inline css into classnames also updates copy and handles case where trigger ref isnt provided --- packages/@react-aria/table/docs/useTable.mdx | 356 +++++++++++------- .../table/src/useTableColumnResize.ts | 11 +- .../table/stories/docs-example.css | 94 +++++ .../table/stories/example-docs.tsx | 101 +---- 4 files changed, 335 insertions(+), 227 deletions(-) create mode 100644 packages/@react-aria/table/stories/docs-example.css diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 3abd5836698..27b4cb6fa2f 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -816,7 +816,7 @@ update the column widths during a column resize operation. Note that this state The second column resizing hook is . This hook handles the interactions for a table column's resizer element, allowing the user to drag the resizer or use the keyboard arrows to expand the column's width. Be sure to pass the state returned by to this hook so the tracked widths can be updated appropriately. We'll walk through all the required changes to the previous table implementation step by step below. For simplicity's sake, we'll be -omitting support for selection and sorting. +omitting support for selection, sorting, and nested columns. ### Table @@ -866,32 +866,15 @@ function ResizableColumnsTable(props) { return (
    1 ? 'center' : 'left', - padding: '5px 10px', - outline: 'none', - boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', - cursor: 'default', width: widths.get(column.key), - display: 'block', - flex: '0 0 auto', - boxSizing: 'border-box' }} ref={ref}>
    {allowsResizing && - + }
    {cell.rendered}
    + - + type="thead"> {collection.headerRows.map(headerRow => ( {[...headerRow.childNodes].map(column => ( @@ -912,9 +895,7 @@ function ResizableColumnsTable(props) { {[...collection.body.childNodes].map(row => ( @@ -937,23 +918,53 @@ function ResizableColumnsTable(props) { } ``` +
    + Show CSS + +```css +/* + * Override the table's default display with "block" so that our defined column widths + * are respected. Without this and other table element display overrides, the columns + * can grow/shrink beyond their applied "width" +*/ +.aria-table { + border-collapse: collapse; + width: 300px; + height: 200px; + display: block; + position: relative; + overflow: auto; +} + + +.aria-table-rowGroupHeader { + border-bottom: 2px solid var(--spectrum-global-color-gray-800); + position: sticky; + top: 0; + background: var(--spectrum-gray-100); + width: fit-content; +} + +.aria-table-rowGroupBody { + max-height: 200px; +} +``` +
    + ### Resizable table header The `TableRowGroup` and `TableHeaderRow` only require minor style changes to accommodate the new `display` changes made in `ResizableColumnsTable`. ```tsx example export=true render=false -function ResizableTableRowGroup({type: Element, style, children}) { +function ResizableTableRowGroup({type: Element, className, children}) { let {rowGroupProps} = useTableRowGroup(); return ( + /*- begin highlight -*/ + className={`aria-table-rowGroup ${className}`} + /*- end highlight -*/ + {...rowGroupProps}> {children} ); @@ -968,78 +979,57 @@ function ResizableTableHeaderRow({item, state, children}) { return (
    + ref={ref}> {children} ); } ``` -The `TableColumnHeader` is where we see the bulk of the changes required to support resizable columns. First of all, we need to accommodate a `Resizer` element in every resizable column that -the user can drag or focus to perform a resize operation. To avoid interfering with the existing grid keyboard navigation, we only display this resizer when -the column is hovered or if the column itself is being resized. This however introduces another issue: how will keyboard, screen reader, and touch users begin a resize operation if the resizer -is only visible on hover? We could have the user press on the column header to trigger a resize operation, but that action is often reserved for sorting and may feel unexpected to a user. +
    + Show CSS + +```css +.aria-table-rowGroup { + display: block; +} -To resolve this, we need to render a menu button in the column header if the column is resizable, allowing non-mouse users to select from a list of available actions on the column. For resizing, selecting the corresponding option -should cause focus to shift to the resizer itself, displaying the resizer for touch screen users to drag and letting keyboard and screen readers resize the column via keyboard arrows and screen reader operations -accordingly. +.aria-table-row { + display: flex; +} +``` +
    + +The `TableColumnHeader` is where we see the bulk of the changes required to support resizable columns. First of all, we need to accommodate a `Resizer` element in every resizable column that +the user can drag or focus to perform a resize operation. Since the resizer will be a focusable element within the table header, we need to make the header title a focusable element as well so keyboard +focus won't be immediately sent to the resizer as you navigate between the column headers. Finally, we apply the computed width of our column from +to the header element. ```tsx example export=true render=false -import {useDescription} from '@react-aria/utils'; -import {useHover, usePress} from '@react-aria/interactions'; +// Reuse the Button from your component library. See below for details. +import {Button} from 'your-component-library'; function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, onResize, onResizeEnd}) { - let {widths} = layoutState; + let {widths} = layoutState; + let allowsResizing = column.props.allowsResizing; let ref = useRef(null); - let resizerRef = useRef(null); let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); - let {isFocusVisible, focusProps} = useFocusRing(); - let {hoverProps, isHovered} = useHover({}); - let showResizer = isHovered || layoutState.resizingColumn === column.key; - let allowsResizing = column.props.allowsResizing; - - let {pressProps} = usePress({ - isDisabled: !allowsResizing, - onPress() { - layoutState.onColumnResizeStart(column.key); - // Brief delay before moving focus to resizer input for screenreaders/Safari - setTimeout(() => resizerRef.current?.focus(), 50); - } - }); - let descriptionProps = useDescription('Press to start resizing the column.'); return ( @@ -1047,6 +1037,60 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, } ``` +
    + Show CSS + +```css +.aria-table-headerCell { + padding: 5px 10px; + outline: none; + cursor: default; + display: block; + flex: 0 0 auto; + box-sizing: border-box; + box-shadow: none; + text-align: left; +} + +.aria-table-headerTitle { + width: 100%; + text-align: left; + border: none; + background: transparent; + flex: 1 1 auto; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-inline-start: -6px; + outline: none; +} + +.aria-table-headerTitle.focus { + outline: 2px solid orange; +} +``` +
    + +### Button + +The `Button` component is used in the above example to represent the table column header title. It is built using the [useButton](useButton.html) hook, and can be shared with many other components. + +
    + Show code + +```tsx example export=true render=false +import {useButton} from '@react-aria/button'; + +function Button(props) { + let ref = useRef(null); + let {focusProps, isFocusVisible} = useFocusRing(); + let {buttonProps} = useButton(props, ref); + return ; +} +``` + +
    + ### Resizer As described above, we need to implement an element that the user can drag/interact with to resize a column. Here we'll use the @@ -1055,49 +1099,60 @@ Users can press and drag on the visible resizer to trigger the `onResize` callba resize events and press Enter, Esc, or Space to exit resizing. Similarly, touch screen readers can swipe up and down to resize the column and double tap to exit resizing. -Note that we apply `visibility: hidden` to hide the resizer so that we reserve space for it in the column header at all times. - ```tsx example export=true render=false import {useTableColumnResize} from '@react-aria/table'; -const Resizer = React.forwardRef((props, ref) => { - let {column, layoutState, onResizeStart, onResize, onResizeEnd, triggerRef, showResizer} = props; +function Resizer(props) { + let {column, layoutState, onResizeStart, onResize, onResizeEnd} = props; + let ref = useRef(null); let {resizerProps, inputProps} = useTableColumnResize({ column, label: 'Resizer', onResizeStart, onResize, - onResizeEnd, - triggerRef + onResizeEnd }, layoutState, ref); + let {focusProps, isFocusVisible} = useFocusRing(); return (
    + {...mergeProps(inputProps, focusProps)} />
    ); -}); +}; +``` + +
    + Show CSS + +```css +.aria-table-resizer { + cursor: col-resize; + width: 6px; + height: auto; + border: 2px; + border-style: none solid; + border-color: grey; + touch-action: none; + flex: 0 0 auto; + box-sizing: border-box; + margin-left: 4px; +} + +.aria-table-resizer.focus { + border-color: orange; +} ``` +
    + ### Resizable table body Similar to `TableRowGroup` and `TableHeaderRow`, `TableRow` and `TableCell` only require minor style changes to @@ -1107,7 +1162,7 @@ and handles text overflow. ```tsx example export=true render=false function ResizableTableRow({item, children, state}) { - // Same as previous TableRow implementation + // Same as previous TableRow implementation... ///- begin collapse -/// let ref = useRef(); let isSelected = state.selectionManager.isSelected(item.key); @@ -1118,24 +1173,15 @@ function ResizableTableRow({item, children, state}) { ///- end collapse -/// return (
    + /*- end highlight -*/ + {...mergeProps(rowProps, focusProps)} + ref={ref} + > {children} ); @@ -1144,34 +1190,24 @@ function ResizableTableRow({item, children, state}) { ```tsx example export=true render=false function ResizableTableCell({cell, state, widths}) { - // Same as previous TableCell implementation + /*- begin highlight -*/ + let column = cell.column; + /*- end highlight -*/ + // Same as previous TableCell implementation... ///- begin collapse -/// let ref = useRef(); let {gridCellProps} = useTableCell({node: cell}, state, ref); let {isFocusVisible, focusProps} = useFocusRing(); ///- end collapse -/// - /*- begin highlight -*/ - let column = cell.column; - /*- end highlight -*/ - return ( @@ -1179,6 +1215,42 @@ function ResizableTableCell({cell, state, widths}) { } ``` +
    + Show CSS + +```css +.aria-table-row { + display: flex; + width: fit-content; + box-shadow: none; + outline: none; + background: none; +} + +.aria-table-row.focus { + box-shadow: inset 0 0 0 2px orange; +} + +.aria-table-cell { + padding: 5px 10px; + outline: none; + cursor: default; + display: block; + flex: 0 0 auto; + box-sizing: border-box; + box-shadow: none; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.aria-table-cell.focus { + box-shadow: inset 0 0 0 2px orange; +} +``` + +
    + And with that, all necessary changes to the previous table implementation have been made and we now have a table that supports resizable columns! The example below supports resizing via mouse, keyboard, touch, and screen reader interactions. To see an example with sorting and selection, see the [styled example](#styled-examples)! diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index aae52ed8362..35f7197b316 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -69,10 +69,15 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let {keyboardProps} = useKeyboard({ onKeyDown: (e) => { if (editModeEnabled) { - if (triggerRef?.current && (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab')) { + if (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); + if (triggerRef?.current) { + // switch focus back to the column header on anything that ends edit mode + focusSafely(triggerRef.current); + } else { + endResize(item); + state.tableState.setKeyboardNavigationDisabled(false); + } } } else { // Continue propagation on ArrowRight/Left so event bubbles to useSelectableCollection diff --git a/packages/@react-aria/table/stories/docs-example.css b/packages/@react-aria/table/stories/docs-example.css new file mode 100644 index 00000000000..230378d39cc --- /dev/null +++ b/packages/@react-aria/table/stories/docs-example.css @@ -0,0 +1,94 @@ +.aria-table { + border-collapse: collapse; + width: 300px; + height: 200px; + display: block; + position: relative; + overflow: auto; + + .aria-table-rowGroup { + display: block; + } + + .aria-table-rowGroupHeader { + border-bottom: 2px solid var(--spectrum-global-color-gray-800); + position: sticky; + top: 0; + background: var(--spectrum-gray-100); + width: fit-content; + } + + .aria-table-rowGroupBody { + max-height: 200px; + } + + .aria-table-row { + display: flex; + } + + .aria-table-headerCell { + padding: 5px 10px; + outline: none; + cursor: default; + display: block; + flex: 0 0 auto; + box-sizing: border-box; + text-align: left; + + .aria-table-headerTitle { + width: 100%; + text-align: left; + border: none; + background: transparent; + flex: 1 1 auto; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-inline-start: -6px; + outline: none; + + &.focus { + outline: 2px solid orange; + } + } + } + + .aria-table-resizer { + cursor: col-resize; + width: 6px; + height: auto; + border: 2px; + border-style: none solid; + border-color: grey; + touch-action: none; + flex: 0 0 auto; + box-sizing: border-box; + margin-left: 4px; + + &.focus { + border-color: orange; + } + } + + .aria-table-row { + display: flex; + width: fit-content; + } + + .aria-table-cell { + padding: 5px 10px; + outline: none; + cursor: default; + display: block; + flex: 0 0 auto; + box-sizing: border-box; + box-shadow: none; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + &.focus { + box-shadow: inset 0 0 0 2px orange; + } + } +} diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx index 2d3d9cb5551..85631dab4cb 100644 --- a/packages/@react-aria/table/stories/example-docs.tsx +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +import ariaStyles from './docs-example.css'; +import {classNames} from '@react-spectrum/utils'; import {mergeProps} from '@react-aria/utils'; import React, {RefObject} from 'react'; import {useButton} from 'react-aria'; @@ -49,24 +51,10 @@ export function Table(props) {
    1 ? 'center' : 'left', - padding: '5px 10px', - outline: 'none', - boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', - cursor: 'default', - width: widths.get(column.key), - display: 'block', - flex: '0 0 auto', - boxSizing: 'border-box' - }} + {...columnHeaderProps} + className="aria-table-headerCell" + style={{width: widths.get(column.key)}} ref={ref}>
    -
    +
    + {allowsResizing && - + }
    {cell.rendered}
    + className={classNames(ariaStyles, 'aria-table')}> + className={classNames(ariaStyles, 'aria-table-rowGroupHeader')}> {collection.headerRows.map(headerRow => ( {[...headerRow.childNodes].map(column => ( @@ -83,9 +71,7 @@ export function Table(props) { ))} {[...collection.body.childNodes].map(row => ( @@ -103,15 +89,12 @@ export function Table(props) { ); } -function ResizableTableRowGroup({type: Element, style, children}) { +function ResizableTableRowGroup({type: Element, children, className}) { let {rowGroupProps} = useTableRowGroup(); return ( + className={classNames(ariaStyles, 'aria-table-rowGroup', className)}> {children} ); @@ -125,7 +108,7 @@ function ResizableTableHeaderRow({item, state, children}) { + className={classNames(ariaStyles, 'aria-table-row')}> {children} ); @@ -136,42 +119,23 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, let ref = useRef(null); let resizerRef = useRef(null); let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); - let {isFocusVisible, focusProps} = useFocusRing(); let allowsResizing = column.props.allowsResizing; return ( @@ -181,40 +145,25 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, function Button(props) { let ref = props.buttonRef; let {focusProps, isFocusVisible} = useFocusRing(); - let outline = isFocusVisible - ? '2px solid orange' - : 'none'; let {buttonProps} = useButton(props, ref); - return ; + return ; } const Resizer = React.forwardRef((props: any, ref: RefObject) => { - let {column, layoutState, onResizeStart, onResize, onResizeEnd, triggerRef} = props; + let {column, layoutState, onResizeStart, onResize, onResizeEnd} = props; let {resizerProps, inputProps} = useTableColumnResize({ column, label: 'Resizer', onResizeStart, onResize, - onResizeEnd, - triggerRef + onResizeEnd }, layoutState, ref); let {focusProps, isFocusVisible} = useFocusRing(); return (
    @@ -267,19 +215,8 @@ function ResizableTableCell({cell, state, widths}) { return (
    From caefaf7487836ef0a0b51bd5fd78da6c0a1e5d5d Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Feb 2023 17:54:14 -0800 Subject: [PATCH 35/64] remove sorting story and cleanup --- .../table/stories/example-docs.tsx | 14 ++--- .../table/stories/useTable.stories.tsx | 58 ------------------- 2 files changed, 7 insertions(+), 65 deletions(-) diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx index 85631dab4cb..33d2566f5fe 100644 --- a/packages/@react-aria/table/stories/example-docs.tsx +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -13,7 +13,7 @@ import ariaStyles from './docs-example.css'; import {classNames} from '@react-spectrum/utils'; import {mergeProps} from '@react-aria/utils'; -import React, {RefObject} from 'react'; +import React from 'react'; import {useButton} from 'react-aria'; import {useFocusRing} from '@react-aria/focus'; import {useRef} from 'react'; @@ -117,7 +117,6 @@ function ResizableTableHeaderRow({item, state, children}) { function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, onResize, onResizeEnd}) { let {widths} = layoutState; let ref = useRef(null); - let resizerRef = useRef(null); let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); let allowsResizing = column.props.allowsResizing; @@ -126,7 +125,7 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, {...mergeProps(columnHeaderProps)} className={classNames(ariaStyles, 'aria-table-headerCell')} style={{ - width: widths.get(column.key), + width: widths.get(column.key) }} ref={ref}>
    @@ -135,7 +134,7 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, {column.rendered} {allowsResizing && - + }
    @@ -149,8 +148,9 @@ function Button(props) { return ; } -const Resizer = React.forwardRef((props: any, ref: RefObject) => { +function Resizer(props) { let {column, layoutState, onResizeStart, onResize, onResizeEnd} = props; + let ref = useRef(); let {resizerProps, inputProps} = useTableColumnResize({ column, label: 'Resizer', @@ -172,7 +172,7 @@ const Resizer = React.forwardRef((props: any, ref: RefObject) ); -}); +} function ResizableTableRow({item, children, state}) { let ref = useRef(); @@ -197,7 +197,7 @@ function ResizableTableRow({item, children, state}) { : 'none', color: isSelected ? 'white' : null, outline: 'none', - boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', + boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none' }} {...mergeProps(rowProps, focusProps)} ref={ref}> diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index f00e1de3f5f..f692b4a219c 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -19,7 +19,6 @@ import {Meta, Story} from '@storybook/react'; import React, {Key, useCallback, useMemo, useState} from 'react'; import {Table as ResizingTable} from './example-resizing'; import {Table} from './example'; -import {useAsyncList} from 'react-stately'; const meta: Meta> = { title: 'useTable' @@ -283,56 +282,6 @@ function ControlledDocsTable(props: {columns: Array<{name: string, uid: string, ); } -function AsyncSortTable() { - let list = useAsyncList({ - async load({signal}) { - let res = await fetch('https://swapi.py4e.com/api/people/?search', { - signal - }); - let json = await res.json(); - return { - items: json.results - }; - }, - async sort({items, sortDescriptor}) { - return { - items: items.sort((a, b) => { - let first = a[sortDescriptor.column]; - let second = b[sortDescriptor.column]; - let cmp = (parseInt(first, 10) || first) < (parseInt(second, 10) || second) - ? -1 - : 1; - if (sortDescriptor.direction === 'descending') { - cmp *= -1; - } - return cmp; - }) - }; - } - }); - - return ( - - - Name - Height - Mass - Birth Year - - - {(item: any) => ( - - {(columnKey) => {item[columnKey]}} - - )} - - - ); -} - export const DocExample = { args: {}, render: (args) => ( @@ -369,10 +318,3 @@ export const DocExampleControlled = { ) }; - -export const DocExampleWithSorting = { - args: {}, - render: () => ( - - ) -}; From 01f2d31a9e60a37be0eb19eb83fa37b5a0e587ce Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 17 Feb 2023 09:47:39 -0800 Subject: [PATCH 36/64] starting resize on press for indicator this unfortunately causes a difference in behavior between starting a drag on menu (no resizestart until move) and starting a drag via mouse/touch (resizestart immediately on press) --- .../@react-aria/table/src/useTableColumnResize.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 35f7197b316..f6d5ab16ddf 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -86,6 +86,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } if (e.key === 'Enter') { + startResize(item); state.tableState.setKeyboardNavigationDisabled(true); } } @@ -199,6 +200,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') { return; } + if (e.pointerType === 'virtual' && state.resizingColumn != null) { endResize(item); if (triggerRef?.current) { @@ -206,14 +208,25 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } return; } + focusInput(); + if ((e.pointerType === 'mouse' || e.pointerType === 'virtual') && state.resizingColumn == null) { + startResize(item); + } }, onPress: (e) => { if (e.pointerType === 'touch') { focusInput(); - } else if (e.pointerType !== 'virtual' && e.pointerType !== 'keyboard') { + if (state.resizingColumn == null) { + startResize(item); + } + } + + if (e.pointerType === 'mouse') { if (triggerRef?.current) { focusSafely(triggerRef.current); + } else { + endResize(item); } } } From 5daa07a0ecd7f6a15b8634adf02392b5edb85cc6 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 17 Feb 2023 11:38:37 -0800 Subject: [PATCH 37/64] using triggerRef existance to determine if behavior is resize on focus one test is still breaking, debugging --- .../table/src/useTableColumnResize.ts | 40 ++++++++++--------- .../@react-spectrum/table/src/Resizer.tsx | 1 + .../table/test/TableSizing.test.tsx | 12 ++++-- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index f6d5ab16ddf..fe8c46ed09a 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -36,7 +36,8 @@ export interface AriaTableColumnResizeProps { label: string, /** * 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. + * focus will be returned there when resizing is done. If it isn't provided, it is assumed that the resizer is + * visible at all time and keyboard resizing is started via pressing Enter on the resizer and not on focus. * */ triggerRef?: RefObject, /** If resizing is disabled. */ @@ -79,7 +80,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st state.tableState.setKeyboardNavigationDisabled(false); } } - } else { + } else if (!triggerRef?.current) { // Continue propagation on ArrowRight/Left so event bubbles to useSelectableCollection if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft')) { e.continuePropagation(); @@ -113,8 +114,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); } - state.endResize(); if (isResizing.current) { + state.endResize(); onResizeEnd?.(lastSize.current); } isResizing.current = false; @@ -147,7 +148,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st onMoveEnd(e) { let {pointerType} = e; columnResizeWidthRef.current = 0; - if (pointerType === 'mouse') { + if (pointerType === 'mouse' || (pointerType === 'touch' && !triggerRef?.current)) { endResize(item); } } @@ -200,7 +201,6 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') { return; } - if (e.pointerType === 'virtual' && state.resizingColumn != null) { endResize(item); if (triggerRef?.current) { @@ -209,25 +209,19 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st return; } + // Sometimes onPress won't trigger for quick taps on mobile so we want to focus the input so blurring away + // can cancel resize mode for us. Also handles interaction where we are resizing focusInput(); - if ((e.pointerType === 'mouse' || e.pointerType === 'virtual') && state.resizingColumn == null) { + + // If resizer is always visible, mobile screenreader user can access the visually hidden resizer directly and thus we don't need + // to handle a virtual click to start the resizer. + if (e.pointerType !== 'virtual') { startResize(item); } }, onPress: (e) => { - if (e.pointerType === 'touch') { - focusInput(); - if (state.resizingColumn == null) { - startResize(item); - } - } - - if (e.pointerType === 'mouse') { - if (triggerRef?.current) { - focusSafely(triggerRef.current); - } else { - endResize(item); - } + if (((e.pointerType === 'touch' && !triggerRef?.current) || e.pointerType === 'mouse') && state.resizingColumn != null) { + endResize(item); } } }); @@ -241,6 +235,14 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st inputProps: mergeProps( { id, + onFocus: () => { + if (triggerRef?.current) { + // 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 + startResize(item); + state.tableState.setKeyboardNavigationDisabled(true); + } + }, onBlur: () => { endResize(item); state.tableState.setKeyboardNavigationDisabled(false); diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index d35f04bdb09..f89565837de 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -65,6 +65,7 @@ function Resizer(props: ResizerProps, ref: RefObject) { mergeProps(props, { label: stringFormatter.format('columnResizer'), isDisabled: isEmpty, + shouldResizeOnFocus: true, onResizeStart: () => { if (getInteractionModality() === 'pointer') { if (layout.getColumnMinWidth(column.key) >= layout.getColumnWidth(column.key)) { diff --git a/packages/@react-spectrum/table/test/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx index 81d7285b236..295b4cea92c 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.tsx +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -1258,7 +1258,8 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'Enter'}); fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - expect(onResizeEnd).toHaveBeenCalledTimes(0); + 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(); @@ -1305,7 +1306,10 @@ describe('TableViewSizing', function () { expect(document.activeElement).toBe(resizer); userEvent.tab(); - expect(onResizeEnd).toHaveBeenCalledTimes(0); + 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); expect(tree.queryByRole('slider')).toBeNull(); @@ -1349,11 +1353,11 @@ describe('TableViewSizing', function () { act(() => {jest.runAllTimers();}); let resizer = tree.getByRole('slider'); - expect(document.activeElement).toBe(resizer); userEvent.tab({shift: true}); - expect(onResizeEnd).toHaveBeenCalledTimes(0); + 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(); From 9834b8be6d689ba4a7e7cbea52557cefd304628f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 17 Feb 2023 12:05:33 -0800 Subject: [PATCH 38/64] fixing test test would blur on rerender causing a column width update even though resizing wasnt happening. Changed conditonal so calling endResize only causes value updates if we are resizing --- packages/@react-aria/table/src/useTableColumnResize.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index fe8c46ed09a..65dc10c29da 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -110,11 +110,11 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st }, [onResize, state]); let endResize = useCallback((item) => { - if (lastSize.current == null) { - lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); - } - if (isResizing.current) { + if (lastSize.current == null) { + lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); + } + state.endResize(); onResizeEnd?.(lastSize.current); } @@ -210,7 +210,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } // Sometimes onPress won't trigger for quick taps on mobile so we want to focus the input so blurring away - // can cancel resize mode for us. Also handles interaction where we are resizing + // can cancel resize mode for us. focusInput(); // If resizer is always visible, mobile screenreader user can access the visually hidden resizer directly and thus we don't need From d84ac1df45b5548cc53c077408cbe4989647d2b3 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 17 Feb 2023 13:45:02 -0800 Subject: [PATCH 39/64] make resizer single line for focus --- .../@react-aria/table/stories/docs-example.css | 16 +++++++++++----- .../@react-aria/table/stories/example-docs.tsx | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/table/stories/docs-example.css b/packages/@react-aria/table/stories/docs-example.css index 230378d39cc..ee3584ce7ec 100644 --- a/packages/@react-aria/table/stories/docs-example.css +++ b/packages/@react-aria/table/stories/docs-example.css @@ -54,19 +54,25 @@ } .aria-table-resizer { - cursor: col-resize; width: 6px; + background-color: grey; + cursor: col-resize; height: auto; - border: 2px; - border-style: none solid; - border-color: grey; touch-action: none; flex: 0 0 auto; box-sizing: border-box; - margin-left: 4px; + border: 2px; + border-style: none solid; + border-color: transparent; + background-clip: content-box; &.focus { + background-color: orange; + } + + &.resizing { border-color: orange; + background: transparent; } } diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx index 33d2566f5fe..b182dbb2b93 100644 --- a/packages/@react-aria/table/stories/example-docs.tsx +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -163,7 +163,7 @@ function Resizer(props) { return (
    Date: Fri, 17 Feb 2023 13:47:23 -0800 Subject: [PATCH 40/64] nit reorganizing --- .../table/stories/useTable.stories.tsx | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index f692b4a219c..b99f08d3e6c 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -246,42 +246,6 @@ export const TableWithSomeResizingFRsControlled = { `}} }; -function ControlledDocsTable(props: {columns: Array<{name: string, uid: string, width?: ColumnSize | null}>, rows, onResize}) { - let {columns, ...otherProps} = props; - let [widths, _setWidths] = useState(() => new Map(columns.filter(col => col.width).map((col) => [col.uid as Key, col.width]))); - let setWidths = useCallback((newWidths: Map) => { - let controlledKeys = new Set(columns.filter(col => col.width).map((col) => col.uid as Key)); - let newVals = new Map(Array.from(newWidths).filter(([key]) => controlledKeys.has(key))); - _setWidths(newVals); - }, [columns]); - - // Needed to get past column caching so new sizes actually are rendered - // eslint-disable-next-line react-hooks/exhaustive-deps - let cols = useMemo(() => columns.map(col => ({...col})), [widths, columns]); - return ( - - - {column => ( - - {column.name} - - )} - - - {item => ( - - {columnKey => {item[columnKey]}} - - )} - - - ); -} - export const DocExample = { args: {}, render: (args) => ( @@ -318,3 +282,39 @@ export const DocExampleControlled = { ) }; + +function ControlledDocsTable(props: {columns: Array<{name: string, uid: string, width?: ColumnSize | null}>, rows, onResize}) { + let {columns, ...otherProps} = props; + let [widths, _setWidths] = useState(() => new Map(columns.filter(col => col.width).map((col) => [col.uid as Key, col.width]))); + let setWidths = useCallback((newWidths: Map) => { + let controlledKeys = new Set(columns.filter(col => col.width).map((col) => col.uid as Key)); + let newVals = new Map(Array.from(newWidths).filter(([key]) => controlledKeys.has(key))); + _setWidths(newVals); + }, [columns]); + + // Needed to get past column caching so new sizes actually are rendered + // eslint-disable-next-line react-hooks/exhaustive-deps + let cols = useMemo(() => columns.map(col => ({...col})), [widths, columns]); + return ( + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + + + ); +} From e0288679f45ed3e0464cc901d697b7bcb965f8fd Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 17 Feb 2023 14:01:38 -0800 Subject: [PATCH 41/64] update docs example for resizer styling --- packages/@react-aria/table/docs/useTable.mdx | 18 ++++++++++++------ .../@react-aria/table/stories/docs-example.css | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 27b4cb6fa2f..226bdf8916b 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -1117,7 +1117,7 @@ function Resizer(props) { return (
    Date: Fri, 17 Feb 2023 14:05:29 -0800 Subject: [PATCH 42/64] mimic docs example remove selection from example to mirror docs --- packages/@react-aria/table/stories/docs-example.css | 2 +- packages/@react-aria/table/stories/useTable.stories.tsx | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/table/stories/docs-example.css b/packages/@react-aria/table/stories/docs-example.css index ee3584ce7ec..64ff83c9b7d 100644 --- a/packages/@react-aria/table/stories/docs-example.css +++ b/packages/@react-aria/table/stories/docs-example.css @@ -72,7 +72,7 @@ &.resizing { border-color: orange; - background: transparent; + background-color: transparent; } } diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index b99f08d3e6c..6ba4b3b01c0 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -250,10 +250,7 @@ export const DocExample = { args: {}, render: (args) => ( Date: Fri, 17 Feb 2023 15:07:48 -0800 Subject: [PATCH 43/64] adding description for keyboard users for Enter to start resizing this is for the aria table example where resizing is entered manually via Enter while focused on the resizer --- packages/@react-aria/table/intl/en-US.json | 3 ++- .../table/src/useTableColumnResize.ts | 23 +++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/@react-aria/table/intl/en-US.json b/packages/@react-aria/table/intl/en-US.json index ff040bb66ee..78a7a208161 100644 --- a/packages/@react-aria/table/intl/en-US.json +++ b/packages/@react-aria/table/intl/en-US.json @@ -6,5 +6,6 @@ "descending": "descending", "ascendingSort": "sorted by column {columnName} in ascending order", "descendingSort": "sorted by column {columnName} in descending order", - "columnSize": "{value} pixels" + "columnSize": "{value} pixels", + "resizerDescription": "Press Enter to start resizing" } diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 65dc10c29da..c8c34ef8bb2 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -13,13 +13,13 @@ import {ChangeEvent, Key, RefObject, useCallback, useRef} from 'react'; import {DOMAttributes, FocusableElement} from '@react-types/shared'; import {focusSafely} from '@react-aria/focus'; -import {focusWithoutScrolling, mergeProps, useId} from '@react-aria/utils'; +import {focusWithoutScrolling, mergeProps, useDescription, useId} from '@react-aria/utils'; import {getColumnHeaderId} from './utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; import {TableColumnResizeState} from '@react-stately/table'; -import {useKeyboard, useMove, usePress} from '@react-aria/interactions'; +import {useInteractionModality, useKeyboard, useMove, usePress} from '@react-aria/interactions'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; export interface TableColumnResizeAria { @@ -65,6 +65,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let isResizing = useRef(false); let lastSize = useRef(null); let editModeEnabled = state.tableState.isKeyboardNavigationDisabled; + let resizeOnFocus = !!triggerRef?.current; let {direction} = useLocale(); let {keyboardProps} = useKeyboard({ @@ -72,7 +73,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st if (editModeEnabled) { if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { e.preventDefault(); - if (triggerRef?.current) { + if (resizeOnFocus) { // switch focus back to the column header on anything that ends edit mode focusSafely(triggerRef.current); } else { @@ -80,7 +81,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st state.tableState.setKeyboardNavigationDisabled(false); } } - } else if (!triggerRef?.current) { + } else if (!resizeOnFocus) { // Continue propagation on ArrowRight/Left so event bubbles to useSelectableCollection if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft')) { e.continuePropagation(); @@ -148,7 +149,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st onMoveEnd(e) { let {pointerType} = e; columnResizeWidthRef.current = 0; - if (pointerType === 'mouse' || (pointerType === 'touch' && !triggerRef?.current)) { + if (pointerType === 'mouse' || (pointerType === 'touch' && !resizeOnFocus)) { endResize(item); } } @@ -167,6 +168,9 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st max = Number.MAX_SAFE_INTEGER; } let value = Math.floor(state.getColumnWidth(item.key)); + let modality = useInteractionModality(); + let description = !resizeOnFocus && modality === 'keyboard' && !isResizing.current ? stringFormatter.format('resizerDescription') : undefined; + let descriptionProps = useDescription(description); let ariaProps = { 'aria-label': props.label, 'aria-orientation': 'horizontal' as 'horizontal', @@ -175,7 +179,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st 'type': 'range', min, max, - value + value, + ...descriptionProps }; const focusInput = useCallback(() => { @@ -203,7 +208,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } if (e.pointerType === 'virtual' && state.resizingColumn != null) { endResize(item); - if (triggerRef?.current) { + if (resizeOnFocus) { focusSafely(triggerRef.current); } return; @@ -220,7 +225,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } }, onPress: (e) => { - if (((e.pointerType === 'touch' && !triggerRef?.current) || e.pointerType === 'mouse') && state.resizingColumn != null) { + if (((e.pointerType === 'touch' && !resizeOnFocus) || e.pointerType === 'mouse') && state.resizingColumn != null) { endResize(item); } } @@ -236,7 +241,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st { id, onFocus: () => { - if (triggerRef?.current) { + if (resizeOnFocus) { // 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 startResize(item); From 1f4213fae824d6d9e5a62896b519e7be0fe140d1 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 17 Feb 2023 15:09:49 -0800 Subject: [PATCH 44/64] fixing issue where tab wasnt exiting the table when focused on the reizer --- packages/@react-aria/table/src/useTableColumnResize.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index c8c34ef8bb2..37f39415364 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -82,8 +82,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } } } else if (!resizeOnFocus) { - // Continue propagation on ArrowRight/Left so event bubbles to useSelectableCollection - if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft')) { + // Continue propagation on ArrowRight/Left/Tab so event bubbles to useSelectableCollection + if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft' || e.key === 'Tab')) { e.continuePropagation(); } From 2bc566f1a0912cbc5a1b5674169f6e67bfe43207 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 17 Feb 2023 15:23:12 -0800 Subject: [PATCH 45/64] adding min width for columns to avoid weirdness with trying to collapse 0 --- packages/@react-aria/table/stories/example-docs.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx index b182dbb2b93..6e86787c6ea 100644 --- a/packages/@react-aria/table/stories/example-docs.tsx +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -13,7 +13,7 @@ import ariaStyles from './docs-example.css'; import {classNames} from '@react-spectrum/utils'; import {mergeProps} from '@react-aria/utils'; -import React from 'react'; +import React, {useCallback} from 'react'; import {useButton} from 'react-aria'; import {useFocusRing} from '@react-aria/focus'; import {useRef} from 'react'; @@ -41,9 +41,14 @@ export function Table(props) { ref ); + let getDefaultMinWidth = useCallback((node) => { + return 40; + }, []); + let layoutState = useTableColumnResizeState({ // Matches the width of the table itself - tableWidth: 300 + tableWidth: 300, + getDefaultMinWidth }, state); let {widths} = layoutState; From 54b732732613fed0b0cd775cba4d36020b467efd Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 17 Feb 2023 15:23:44 -0800 Subject: [PATCH 46/64] fix lint --- packages/@react-aria/table/stories/example-docs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/table/stories/example-docs.tsx b/packages/@react-aria/table/stories/example-docs.tsx index 6e86787c6ea..9874ef9945c 100644 --- a/packages/@react-aria/table/stories/example-docs.tsx +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -41,7 +41,7 @@ export function Table(props) { ref ); - let getDefaultMinWidth = useCallback((node) => { + let getDefaultMinWidth = useCallback(() => { return 40; }, []); From ff56eabc0865de6aba7ec15486e8e238cfb8fcf9 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 17 Feb 2023 15:32:07 -0800 Subject: [PATCH 47/64] avoid weirdness with width 0 by setting a min width --- packages/@react-aria/table/docs/useTable.mdx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 226bdf8916b..a88b287f9e3 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -829,6 +829,7 @@ The various style changes below are to make the table itself scrollable when the ```tsx example export=true render=false /*- begin highlight -*/ +import {useCallback} from 'react'; import {useTableColumnResizeState} from '@react-stately/table'; /*- end highlight -*/ @@ -857,9 +858,15 @@ function ResizableColumnsTable(props) { ); /*- begin highlight -*/ + // Set the minimum width of the columns to 40px + let getDefaultMinWidth = useCallback(() => { + return 40; + }, []); + let layoutState = useTableColumnResizeState({ // Matches the width of the table itself - tableWidth: 300 + tableWidth: 300, + getDefaultMinWidth }, state); let {widths} = layoutState; /*- end highlight -*/ From dc7aaa632a74c064fc466729269222058dae0629 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 17 Feb 2023 16:02:30 -0800 Subject: [PATCH 48/64] propagate all keydown events if we arent in resize mode and have always visible resizers forgot that we also have other keyboard combos like cmd + a or escape that should also be handled by useSelectableCollection --- packages/@react-aria/table/src/useTableColumnResize.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 37f39415364..b2d0d526de1 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -82,10 +82,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } } } else if (!resizeOnFocus) { - // Continue propagation on ArrowRight/Left/Tab so event bubbles to useSelectableCollection - if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft' || e.key === 'Tab')) { - e.continuePropagation(); - } + // Continue propagation on keydown events so they still bubbles to useSelectableCollection and are handled there + e.continuePropagation(); if (e.key === 'Enter') { startResize(item); From c2a8a7f316a8a2ca4c28a19fcde01588f36eaa0b Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 17 Feb 2023 16:40:49 -0800 Subject: [PATCH 49/64] removing ref read in render --- packages/@react-aria/table/src/useTableColumnResize.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index b2d0d526de1..d7b4f463b8e 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -65,11 +65,11 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let isResizing = useRef(false); let lastSize = useRef(null); let editModeEnabled = state.tableState.isKeyboardNavigationDisabled; - let resizeOnFocus = !!triggerRef?.current; let {direction} = useLocale(); let {keyboardProps} = useKeyboard({ onKeyDown: (e) => { + let resizeOnFocus = !!triggerRef?.current; if (editModeEnabled) { if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { e.preventDefault(); @@ -145,6 +145,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } }, onMoveEnd(e) { + let resizeOnFocus = !!triggerRef?.current; let {pointerType} = e; columnResizeWidthRef.current = 0; if (pointerType === 'mouse' || (pointerType === 'touch' && !resizeOnFocus)) { @@ -167,7 +168,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } let value = Math.floor(state.getColumnWidth(item.key)); let modality = useInteractionModality(); - let description = !resizeOnFocus && modality === 'keyboard' && !isResizing.current ? stringFormatter.format('resizerDescription') : undefined; + let description = !triggerRef && modality === 'keyboard' && !isResizing.current ? stringFormatter.format('resizerDescription') : undefined; let descriptionProps = useDescription(description); let ariaProps = { 'aria-label': props.label, @@ -205,6 +206,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st return; } if (e.pointerType === 'virtual' && state.resizingColumn != null) { + let resizeOnFocus = !!triggerRef?.current; endResize(item); if (resizeOnFocus) { focusSafely(triggerRef.current); @@ -223,6 +225,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } }, onPress: (e) => { + let resizeOnFocus = !!triggerRef?.current; if (((e.pointerType === 'touch' && !resizeOnFocus) || e.pointerType === 'mouse') && state.resizingColumn != null) { endResize(item); } @@ -239,6 +242,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st { id, onFocus: () => { + let resizeOnFocus = !!triggerRef?.current; if (resizeOnFocus) { // 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 From e1b1abff5093eb7d2bf1780373532e1cc73c2fa0 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 28 Feb 2023 14:07:08 -0800 Subject: [PATCH 50/64] add aria description to input for virtual modality too if the user enters the table using control+option+arrow keys in voiceover, they will be virtual modality so we want the description for press enter to resize to be audible there --- packages/@react-aria/table/src/useTableColumnResize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index d7b4f463b8e..3f51a7107c6 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -168,7 +168,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } let value = Math.floor(state.getColumnWidth(item.key)); let modality = useInteractionModality(); - let description = !triggerRef && modality === 'keyboard' && !isResizing.current ? stringFormatter.format('resizerDescription') : undefined; + let description = !triggerRef && (modality === 'keyboard' || modality === 'virtual') && !isResizing.current ? stringFormatter.format('resizerDescription') : undefined; let descriptionProps = useDescription(description); let ariaProps = { 'aria-label': props.label, From cf63a95d449ec69e20147cbcbc3ff99add0025c7 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 1 Mar 2023 13:03:55 -0800 Subject: [PATCH 51/64] addressing review comments --- packages/@react-aria/grid/src/useGrid.ts | 6 +++--- packages/@react-aria/table/stories/example-docs.tsx | 2 +- packages/@react-aria/table/stories/resizing.css | 11 +++++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index a2f477af464..dbf4b928503 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -93,7 +93,7 @@ export function useGrid(props: GridProps, state: GridState(props: GridProps, state: GridState(props: GridProps, state: GridState Date: Wed, 1 Mar 2023 13:28:12 -0800 Subject: [PATCH 52/64] fix some copy --- packages/@react-aria/table/docs/useTable.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index a88b287f9e3..e6a74d6236b 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -1102,9 +1102,9 @@ function Button(props) { As described above, we need to implement an element that the user can drag/interact with to resize a column. Here we'll use the hook to create a visible resizer div for physical drag operations and a visually hidden input responsible for keyboard and screenreader interactions, similar to a [slider](useSlider.html). -Users can press and drag on the visible resizer to trigger the `onResize` callbacks and update the tracked column widths accordingly. When focused, keyboard users can use the arrow keys to trigger the same -resize events and press Enter, Esc, or Space to exit resizing. Similarly, touch screen readers can swipe up and down to -resize the column and double tap to exit resizing. +Users can press and drag on the visible resizer to trigger the `onResize` callbacks and update the tracked column widths accordingly. When focused, keyboard users can begin resizing the column by pressing Enter. +Once resizing is activated, they can use the arrow keys to trigger the same resize events and press Enter, Esc, or Space to exit resizing. Touch screen reader users can swipe +left or right to focus the column's resizer input and swipe up and down to resize the column. ```tsx example export=true render=false import {useTableColumnResize} from '@react-aria/table'; From f44068df1e96088aa36630b7cd66b8fdcabd569b Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 1 Mar 2023 14:23:56 -0800 Subject: [PATCH 53/64] prevent extraneous scrolling when keyboard navigating to the resizer margin applied on the visually hidden input makes scrollIntoView think it needs to scroll it --- packages/@react-aria/table/stories/docs-example.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/@react-aria/table/stories/docs-example.css b/packages/@react-aria/table/stories/docs-example.css index 64ff83c9b7d..ba3311fd139 100644 --- a/packages/@react-aria/table/stories/docs-example.css +++ b/packages/@react-aria/table/stories/docs-example.css @@ -74,6 +74,10 @@ border-color: orange; background-color: transparent; } + + input { + margin: 0px; + } } .aria-table-row { From 982b3ad1988c2642e13ef0c563403813e763b67c Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 1 Mar 2023 14:49:50 -0800 Subject: [PATCH 54/64] fixing extraneous scrolling behavior --- packages/@react-aria/table/docs/useTable.mdx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index e6a74d6236b..e30be5ac585 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -1129,6 +1129,7 @@ function Resizer(props) {
    @@ -1162,6 +1163,10 @@ function Resizer(props) { border-color: orange; background-color: transparent; } + +.aria-table-resizer-input { + margin: 0px; +} ``` From 60a66c5b44cc8503daa99ecf1bbd6864096cfa52 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 3 Mar 2023 11:55:30 -0800 Subject: [PATCH 55/64] address review comments --- packages/@react-aria/table/src/useTableColumnResize.ts | 6 +++++- packages/@react-aria/table/stories/docs-example.css | 4 ---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 3f51a7107c6..711c62bfe0a 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -168,7 +168,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } let value = Math.floor(state.getColumnWidth(item.key)); let modality = useInteractionModality(); - let description = !triggerRef && (modality === 'keyboard' || modality === 'virtual') && !isResizing.current ? stringFormatter.format('resizerDescription') : undefined; + let description = triggerRef?.current != null && (modality === 'keyboard' || modality === 'virtual') && !isResizing.current ? stringFormatter.format('resizerDescription') : undefined; let descriptionProps = useDescription(description); let ariaProps = { 'aria-label': props.label, @@ -241,6 +241,10 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st inputProps: mergeProps( { id, + // Override browser default margin. Without this, scrollIntoViewport will think we need to scroll the input into view + style: { + margin: '0px' + }, onFocus: () => { let resizeOnFocus = !!triggerRef?.current; if (resizeOnFocus) { diff --git a/packages/@react-aria/table/stories/docs-example.css b/packages/@react-aria/table/stories/docs-example.css index ba3311fd139..64ff83c9b7d 100644 --- a/packages/@react-aria/table/stories/docs-example.css +++ b/packages/@react-aria/table/stories/docs-example.css @@ -74,10 +74,6 @@ border-color: orange; background-color: transparent; } - - input { - margin: 0px; - } } .aria-table-row { From b228c3a162d2f40c0b1ae92bdc12b671d4e6321a Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 3 Mar 2023 12:04:23 -0800 Subject: [PATCH 56/64] fix logic --- packages/@react-aria/table/src/useTableColumnResize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 711c62bfe0a..81f77f0e18e 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -168,7 +168,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } let value = Math.floor(state.getColumnWidth(item.key)); let modality = useInteractionModality(); - let description = triggerRef?.current != null && (modality === 'keyboard' || modality === 'virtual') && !isResizing.current ? stringFormatter.format('resizerDescription') : undefined; + let description = triggerRef?.current == null && (modality === 'keyboard' || modality === 'virtual') && !isResizing.current ? stringFormatter.format('resizerDescription') : undefined; let descriptionProps = useDescription(description); let ariaProps = { 'aria-label': props.label, From 00f3ff1ab2c1d4bcd02be0d31e16d6414e1eadb5 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 3 Mar 2023 12:16:12 -0800 Subject: [PATCH 57/64] removing margin style in favor of hook provided style --- packages/@react-aria/table/docs/useTable.mdx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index ec764f0f18e..c8c027752de 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -1147,7 +1147,6 @@ function Resizer(props) {
    @@ -1181,10 +1180,6 @@ function Resizer(props) { border-color: orange; background-color: transparent; } - -.aria-table-resizer-input { - margin: 0px; -} ``` From b7e4dc8c58ea0918cf75275acf3b874ec7aad1ad Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 20 Mar 2023 15:14:49 -0700 Subject: [PATCH 58/64] fix typescript lint --- packages/@react-aria/table/docs/useTable.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index c8c027752de..cb335b4eecb 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -1196,8 +1196,7 @@ function ResizableTableRow({item, children, state}) { // Same as previous TableRow implementation... ///- begin collapse -/// let ref = useRef(); - let isSelected = state.selectionManager.isSelected(item.key); - let {rowProps, isPressed} = useTableRow({ + let {rowProps} = useTableRow({ node: item }, state, ref); let {isFocusVisible, focusProps} = useFocusRing(); From 97b5b7b609d57ae928c8ccdfaee8ef0e908d635e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 20 Mar 2023 15:29:15 -0700 Subject: [PATCH 59/64] make resize handle larger --- packages/@react-aria/table/docs/useTable.mdx | 6 +++--- packages/@react-aria/table/stories/docs-example.css | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index cb335b4eecb..14f603ee6fa 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -1159,14 +1159,14 @@ function Resizer(props) { ```css .aria-table-resizer { - width: 6px; + width: 15px; background-color: grey; cursor: col-resize; - height: auto; + height: 30px; touch-action: none; flex: 0 0 auto; box-sizing: border-box; - border: 2px; + border: 5px; border-style: none solid; border-color: transparent; background-clip: content-box; diff --git a/packages/@react-aria/table/stories/docs-example.css b/packages/@react-aria/table/stories/docs-example.css index 64ff83c9b7d..99816bedfa7 100644 --- a/packages/@react-aria/table/stories/docs-example.css +++ b/packages/@react-aria/table/stories/docs-example.css @@ -54,14 +54,14 @@ } .aria-table-resizer { - width: 6px; + width: 15px; background-color: grey; cursor: col-resize; - height: auto; + height: 30px; touch-action: none; flex: 0 0 auto; box-sizing: border-box; - border: 2px; + border: 5px; border-style: none solid; border-color: transparent; background-clip: content-box; From 31c070afd0da57f0a8cf607fa1fae7f1d73e33c5 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 21 Mar 2023 10:42:13 -0700 Subject: [PATCH 60/64] fix resizer style on drag --- packages/@react-aria/table/docs/useTable.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index 14f603ee6fa..afd0f043f6d 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -1176,7 +1176,7 @@ function Resizer(props) { background-color: orange; } -.aria-table-resizer.focus.resizing { +.aria-table-resizer.resizing { border-color: orange; background-color: transparent; } From 6c17a72c625908bc66eb0d9be47dc1fb46dfa34a Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 23 Mar 2023 15:39:06 -0700 Subject: [PATCH 61/64] Simplify aria table resizing docs example (#4253) --- packages/@react-aria/table/docs/useTable.mdx | 286 ++++--------------- 1 file changed, 52 insertions(+), 234 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index afd0f043f6d..fd0af790c0d 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -840,35 +840,25 @@ omitting support for selection, sorting, and nested columns. As mentioned previously, we first need to call to initialize the widths for our table's columns. We'll pass the state returned by along with any user defined `onResize` handlers -to our `ResizableTableColumnHeaders` so it can be used by . The `widths` from this state are provided to each table element so -the table cells in the body match their parent column's widths at all times. +to our `ResizableTableColumnHeaders` so it can be used by . -The various style changes below are to make the table itself scrollable when the row content overflows and to support table body/column widths greater than the 300px applied to the table itself. +The various style changes below are to add a wrapper div so the table is scrollable when the row content overflows and to support table body/column widths greater than the 300px applied to the table itself. ```tsx example export=true render=false -/*- begin highlight -*/ import {useCallback} from 'react'; import {useTableColumnResizeState} from '@react-stately/table'; -/*- end highlight -*/ function ResizableColumnsTable(props) { - /*- begin highlight -*/ - let { - onResizeStart, - onResize, - onResizeEnd - } = props; - /*- end highlight -*/ let state = useTableState(props); - + let scrollRef = useRef(); let ref = useRef(); let {collection} = state; let {gridProps} = useTable( { ...props, /*- begin highlight -*/ - // The table itself is scrollable rather than just the body - scrollRef: ref + // The table wrapper is scrollable rather than just the body + scrollRef /*- end highlight -*/ }, state, @@ -886,59 +876,46 @@ function ResizableColumnsTable(props) { tableWidth: 300, getDefaultMinWidth }, state); - let {widths} = layoutState; /*- end highlight -*/ + return ( -
    1 ? 'center' : 'left', - padding: '5px 10px', - outline: 'none', - boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none', - cursor: 'default', width: widths.get(column.key), - display: 'block', - flex: '0 0 auto', - boxSizing: 'border-box' }} ref={ref}>
    {allowsResizing && - + }
    {cell.rendered}
    - - {collection.headerRows.map(headerRow => ( - - {[...headerRow.childNodes].map(column => ( - - ))} - - ))} - - - {[...collection.body.childNodes].map(row => ( - - {[...row.childNodes].map(cell => ( - - ))} - - ))} - -
    + /*- begin highlight -*/ +
    + {/*- end highlight -*/} + + + {collection.headerRows.map(headerRow => ( + + {[...headerRow.childNodes].map(column => ( + + ))} + + ))} + + + {[...collection.body.childNodes].map(row => ( + + {[...row.childNodes].map(cell => ( + + ))} + + ))} + +
    +
    ); } ``` @@ -947,87 +924,27 @@ function ResizableColumnsTable(props) { Show CSS ```css -/* - * Override the table's default display with "block" so that our defined column widths - * are respected. Without this and other table element display overrides, the columns - * can grow/shrink beyond their applied "width" -*/ -.aria-table { - border-collapse: collapse; +.aria-table-wrapper { width: 300px; - height: 200px; - display: block; - position: relative; overflow: auto; } - -.aria-table-rowGroupHeader { - border-bottom: 2px solid var(--spectrum-global-color-gray-800); - position: sticky; - top: 0; - background: var(--spectrum-gray-100); +.aria-table { + border-collapse: collapse; + table-layout: fixed; width: fit-content; -} -.aria-table-rowGroupBody { - max-height: 200px; + & td { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } } ``` ### Resizable table header -The `TableRowGroup` and `TableHeaderRow` only require minor style changes to accommodate the new `display` changes made in -`ResizableColumnsTable`. - -```tsx example export=true render=false -function ResizableTableRowGroup({type: Element, className, children}) { - let {rowGroupProps} = useTableRowGroup(); - return ( - - {children} - - ); -} -``` - -```tsx example export=true render=false -function ResizableTableHeaderRow({item, state, children}) { - let ref = useRef(); - let {rowProps} = useTableHeaderRow({node: item}, state, ref); - - return ( - - {children} - - ); -} -``` - -
    - Show CSS - -```css -.aria-table-rowGroup { - display: block; -} - -.aria-table-row { - display: flex; -} -``` -
    - The `TableColumnHeader` is where we see the bulk of the changes required to support resizable columns. First of all, we need to accommodate a `Resizer` element in every resizable column that the user can drag or focus to perform a resize operation. Since the resizer will be a focusable element within the table header, we need to make the header title a focusable element as well so keyboard focus won't be immediately sent to the resizer as you navigate between the column headers. Finally, we apply the computed width of our column from @@ -1070,8 +987,6 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, padding: 5px 10px; outline: none; cursor: default; - display: block; - flex: 0 0 auto; box-sizing: border-box; box-shadow: none; text-align: left; @@ -1184,103 +1099,6 @@ function Resizer(props) { -### Resizable table body - -Similar to `TableRowGroup` and `TableHeaderRow`, `TableRow` and `TableCell` only require minor style changes to -accommodate the new `display` changes made in the `ResizableColumnsTable`. The changes to `TableRow` are made to extend the -background of the row to the full width of its child cells. `TableCell` now receives its width from -and handles text overflow. - -```tsx example export=true render=false -function ResizableTableRow({item, children, state}) { - // Same as previous TableRow implementation... - ///- begin collapse -/// - let ref = useRef(); - let {rowProps} = useTableRow({ - node: item - }, state, ref); - let {isFocusVisible, focusProps} = useFocusRing(); - ///- end collapse -/// - return ( - - {children} - - ); -} -``` - -```tsx example export=true render=false -function ResizableTableCell({cell, state, widths}) { - /*- begin highlight -*/ - let column = cell.column; - /*- end highlight -*/ - // Same as previous TableCell implementation... - ///- begin collapse -/// - let ref = useRef(); - let {gridCellProps} = useTableCell({node: cell}, state, ref); - let {isFocusVisible, focusProps} = useFocusRing(); - ///- end collapse -/// - return ( - - {cell.rendered} - - ); -} -``` - -
    - Show CSS - -```css -.aria-table-row { - display: flex; - width: fit-content; - box-shadow: none; - outline: none; - background: none; -} - -.aria-table-row.focus { - box-shadow: inset 0 0 0 2px orange; -} - -.aria-table-cell { - padding: 5px 10px; - outline: none; - cursor: default; - display: block; - flex: 0 0 auto; - box-sizing: border-box; - box-shadow: none; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.aria-table-cell.focus { - box-shadow: inset 0 0 0 2px orange; -} -``` - -
    - And with that, all necessary changes to the previous table implementation have been made and we now have a table that supports resizable columns! The example below supports resizing via mouse, keyboard, touch, and screen reader interactions. To see an example with sorting and selection, see the [styled example](#styled-examples)! From c80d29845ed486c5b81363c15214f1504140e2e0 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 23 Mar 2023 16:08:12 -0700 Subject: [PATCH 62/64] update example to match updated hooks --- packages/@react-aria/table/docs/useTable.mdx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/table/docs/useTable.mdx b/packages/@react-aria/table/docs/useTable.mdx index fd0af790c0d..7d1af1d8c54 100644 --- a/packages/@react-aria/table/docs/useTable.mdx +++ b/packages/@react-aria/table/docs/useTable.mdx @@ -955,7 +955,6 @@ to the header element. import {Button} from 'your-component-library'; function ResizableTableColumnHeader({column, state, layoutState, onResizeStart, onResize, onResizeEnd}) { - let {widths} = layoutState; let allowsResizing = column.props.allowsResizing; let ref = useRef(null); let {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); @@ -964,7 +963,7 @@ function ResizableTableColumnHeader({column, state, layoutState, onResizeStart,