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 ( + - + 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 (
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}