diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index 57f3e32bced..221426e17da 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(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, { role: 'grid', id, - 'aria-multiselectable': state.selectionManager.selectionMode === 'multiple' ? 'true' : undefined + 'aria-multiselectable': manager.selectionMode === 'multiple' ? 'true' : undefined }, - state.isKeyboardNavigationDisabled ? {} : collectionProps, + state.isKeyboardNavigationDisabled ? navDisabledHandlers : collectionProps, descriptionProps ); 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 4d5f9c3afb4..81f77f0e18e 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 { @@ -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. */ @@ -63,14 +64,31 @@ export function useTableColumnResize(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); + let resizeOnFocus = !!triggerRef?.current; + if (editModeEnabled) { + if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { + e.preventDefault(); + if (resizeOnFocus) { + // switch focus back to the column header on anything that ends edit mode + focusSafely(triggerRef.current); + } else { + endResize(item); + state.tableState.setKeyboardNavigationDisabled(false); + } + } + } else if (!resizeOnFocus) { + // Continue propagation on keydown events so they still bubbles to useSelectableCollection and are handled there + e.continuePropagation(); + + if (e.key === 'Enter') { + startResize(item); + state.tableState.setKeyboardNavigationDisabled(true); + } } } }); @@ -91,10 +109,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); } @@ -126,20 +145,31 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } }, onMoveEnd(e) { + let resizeOnFocus = !!triggerRef?.current; let {pointerType} = e; columnResizeWidthRef.current = 0; - if (pointerType === 'mouse') { + if (pointerType === 'mouse' || (pointerType === 'touch' && !resizeOnFocus)) { endResize(item); } } }); + 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) { max = Number.MAX_SAFE_INTEGER; } 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 descriptionProps = useDescription(description); let ariaProps = { 'aria-label': props.label, 'aria-orientation': 'horizontal' as 'horizontal', @@ -148,7 +178,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st 'type': 'range', min, max, - value + value, + ...descriptionProps }; const focusInput = useCallback(() => { @@ -175,21 +206,28 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st return; } if (e.pointerType === 'virtual' && state.resizingColumn != null) { + let resizeOnFocus = !!triggerRef?.current; endResize(item); - if (triggerRef?.current) { + if (resizeOnFocus) { focusSafely(triggerRef.current); } 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. focusInput(); + + // 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(); - } else if (e.pointerType !== 'virtual') { - if (triggerRef?.current) { - focusSafely(triggerRef.current); - } + let resizeOnFocus = !!triggerRef?.current; + if (((e.pointerType === 'touch' && !resizeOnFocus) || e.pointerType === 'mouse') && state.resizingColumn != null) { + endResize(item); } } }); @@ -197,17 +235,24 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st return { resizerProps: mergeProps( keyboardProps, - moveProps, + {...moveProps, onKeyDown}, pressProps ), inputProps: mergeProps( { id, + // Override browser default margin. Without this, scrollIntoViewport will think we need to scroll the input into view + style: { + margin: '0px' + }, 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); + 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 + startResize(item); + state.tableState.setKeyboardNavigationDisabled(true); + } }, onBlur: () => { endResize(item); 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..64ff83c9b7d --- /dev/null +++ b/packages/@react-aria/table/stories/docs-example.css @@ -0,0 +1,100 @@ +.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 { + width: 6px; + background-color: grey; + cursor: col-resize; + height: auto; + touch-action: none; + flex: 0 0 auto; + box-sizing: border-box; + border: 2px; + border-style: none solid; + border-color: transparent; + background-clip: content-box; + + &.focus { + background-color: orange; + } + + &.resizing { + border-color: orange; + background-color: transparent; + } + } + + .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 new file mode 100644 index 00000000000..47d3df1f982 --- /dev/null +++ b/packages/@react-aria/table/stories/example-docs.tsx @@ -0,0 +1,229 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import ariaStyles from './docs-example.css'; +import {classNames} from '@react-spectrum/utils'; +import {mergeProps} from '@react-aria/utils'; +import React, {useCallback} 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 getDefaultMinWidth = useCallback(() => { + return 40; + }, []); + + let layoutState = useTableColumnResizeState({ + // Matches the width of the table itself + tableWidth: 300, + getDefaultMinWidth + }, 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, children, className}) { + 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 {columnHeaderProps} = useTableColumnHeader({node: column}, state, ref); + let allowsResizing = column.props.allowsResizing; + + return ( + +
+ + {allowsResizing && + + } +
+ + ); +} + +function Button(props) { + let ref = props.buttonRef; + let {focusProps, isFocusVisible} = useFocusRing(); + let {buttonProps} = useButton(props, ref); + return ; +} + +function Resizer(props) { + let {column, layoutState, onResizeStart, onResize, onResizeEnd} = props; + let ref = useRef(); + let {resizerProps, inputProps} = useTableColumnResize({ + column, + label: 'Resizer', + onResizeStart, + onResize, + onResizeEnd + }, 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/resizing.css b/packages/@react-aria/table/stories/resizing.css index 3fc4e15b7ee..e1425d43330 100644 --- a/packages/@react-aria/table/stories/resizing.css +++ b/packages/@react-aria/table/stories/resizing.css @@ -1,3 +1,14 @@ +/* + * Copyright 2023 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. + */ .aria-table, .aria-table * { diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index b46beb2f8a2..6ba4b3b01c0 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'; @@ -244,3 +245,73 @@ export const TableWithSomeResizingFRsControlled = { column width state. `}} }; + +export const DocExample = { + args: {}, + render: (args) => ( + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {columnKey => {item[columnKey]}} + + )} + + + ) +}; + +export const DocExampleControlled = { + args: {columns: columnsFR}, + render: (args) => ( + + ) +}; + +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]}} + + )} + + + ); +} 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/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 922ae69d5eb..54f34b2a061 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -737,6 +737,7 @@ function ResizableTableColumnHeader(props) { case 'resize': layout.startResize(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..295b4cea92c 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.tsx +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -1260,7 +1260,6 @@ describe('TableViewSizing', function () { fireEvent.keyUp(document.activeElement, {key: 'Enter'}); 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(); @@ -1311,7 +1310,6 @@ describe('TableViewSizing', function () { // 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(); @@ -1355,13 +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(1); expect(onResizeEnd).toHaveBeenCalledWith(new Map([['foo', 600], ['bar', '1fr'], ['baz', '1fr']])); - expect(document.activeElement).toBe(resizableHeader); expect(tree.queryByRole('slider')).toBeNull(); @@ -1656,6 +1652,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();}); }