diff --git a/packages/@adobe/spectrum-css-temp/components/table/index.css b/packages/@adobe/spectrum-css-temp/components/table/index.css index 8cac21b3f65..c2801095dd5 100644 --- a/packages/@adobe/spectrum-css-temp/components/table/index.css +++ b/packages/@adobe/spectrum-css-temp/components/table/index.css @@ -65,7 +65,6 @@ svg.spectrum-Table-sortedIcon { text-transform: uppercase; padding: var(--spectrum-table-header-padding-y) var(--spectrum-table-header-padding-x); transition: color var(--spectrum-global-animation-duration-100) ease-in-out; - cursor: default; outline: 0; border-radius: var(--spectrum-table-header-border-radius); @@ -89,13 +88,6 @@ svg.spectrum-Table-sortedIcon { } } -.spectrum-Table--resizingColumn { - .spectrum-Table-row, - .spectrum-Table-headCell { - cursor: col-resize; - } -} - .spectrum-Table-columnResizer { display: flex; justify-content: flex-end; @@ -105,24 +97,21 @@ svg.spectrum-Table-sortedIcon { inset-inline-end: 0px; inline-size: 10px; block-size: 100%; - cursor: col-resize; user-select: none; &::after { content: ""; - position: absolute; display: block; box-sizing: border-box; inline-size: 1px; block-size: 100%; - background-color: var(--spectrum-table-divider-border-color); } &:active, &:focus { + outline: none; &::after { inline-size: 2px; - background-color: var(--spectrum-global-color-blue-400); } } } diff --git a/packages/@adobe/spectrum-css-temp/components/table/skin.css b/packages/@adobe/spectrum-css-temp/components/table/skin.css index ad8f1b93c6f..9b26fa9039a 100644 --- a/packages/@adobe/spectrum-css-temp/components/table/skin.css +++ b/packages/@adobe/spectrum-css-temp/components/table/skin.css @@ -149,11 +149,11 @@ tbody.spectrum-Table-body { } /* Alternative to border on rows. Using box shadow since they don't take room unlike border which would cause wiggles - * in the hightlight case and displace the sticky indicator. Also allows for a nicer bottom curved border to match the container, + * in the highlight case and displace the sticky indicator. Also allows for a nicer bottom curved border to match the container, * the bottom border curved corners were cut off when using borders. */ - /* Box shadow for bottom border for non-selected rows that aren't immediatly above a selected row. Can't omit the bottom border for last row unlike listview + /* Box shadow for bottom border for non-selected rows that aren't immediately above a selected row. Can't omit the bottom border for last row unlike listview * due to how table rows always reserve 1px for the bottom border (results in a white gap on hover otherwise). */ &:after { @@ -169,7 +169,7 @@ tbody.spectrum-Table-body { pointer-events: none; } - /* Box shadow for bottom border for non-selected row that is immediatly above a selected row. */ + /* Box shadow for bottom border for non-selected row that is immediately above a selected row. */ &.is-next-selected { &:after { box-shadow: inset 0 -1px 0 0 var(--spectrum-global-color-blue-500); @@ -283,3 +283,16 @@ tbody.spectrum-Table-body { } } } + +.spectrum-Table-columnResizer { + &::after { + background-color: var(--spectrum-table-divider-border-color); + } + + &:active, + &:focus { + &::after { + background-color: var(--spectrum-global-color-blue-400); + } + } +} diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 99449471931..3412bc053b5 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -236,6 +236,11 @@ function useFocusContainment(scopeRef: RefObject, contain: boolea useLayoutEffect(() => { let scope = scopeRef.current; if (!contain) { + // if contain was changed, then we should cancel any ongoing waits to pull focus back into containment + if (raf.current) { + cancelAnimationFrame(raf.current); + raf.current = null; + } return; } @@ -310,7 +315,11 @@ function useFocusContainment(scopeRef: RefObject, contain: boolea // eslint-disable-next-line arrow-body-style useEffect(() => { - return () => cancelAnimationFrame(raf.current); + return () => { + if (raf.current) { + cancelAnimationFrame(raf.current); + } + }; }, [raf]); } @@ -462,7 +471,8 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus: boole if (restoreFocus && nodeToRestore && isElementInScope(document.activeElement, scopeRef.current)) { requestAnimationFrame(() => { - if (document.body.contains(nodeToRestore)) { + // Only restore focus if we've lost focus to the body, the alternative is that focus has been purposefully moved elsewhere + if (document.body.contains(nodeToRestore) && document.activeElement === document.body) { focusElement(nodeToRestore); } }); diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index add1c8192a9..87c5fb5561e 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -18,11 +18,7 @@ import userEvent from '@testing-library/user-event'; describe('FocusScope', function () { beforeEach(() => { - jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb()); - }); - - afterEach(() => { - window.requestAnimationFrame.mockRestore(); + jest.useFakeTimers(); }); describe('focus containment', function () { @@ -234,9 +230,11 @@ describe('FocusScope', function () { userEvent.tab(); fireEvent.focusIn(input2); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(input2); act(() => {input2.blur();}); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(input2); act(() => {outside.focus();}); @@ -263,9 +261,11 @@ describe('FocusScope', function () { userEvent.tab(); fireEvent.focusIn(input2); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(input2); act(() => {input2.blur();}); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(input2); fireEvent.focusOut(input2); expect(document.activeElement).toBe(input2); @@ -327,6 +327,7 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(input1); rerender(); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(outside); }); @@ -358,6 +359,7 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(input2); rerender(); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(outside); }); @@ -454,6 +456,7 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(dynamic); rerender(); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(outside); }); @@ -1068,14 +1071,19 @@ describe('FocusScope', function () { let child2 = getByTestId('child2'); let child3 = getByTestId('child3'); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(child1); userEvent.tab(); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(child2); userEvent.tab(); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(child3); userEvent.tab(); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(child1); userEvent.tab({shift: true}); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(child3); }); diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index d5ca5f90dc6..e82746eefb6 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -114,7 +114,7 @@ export function useGrid(props: GridProps, state: GridState>(props: GridCellProps onAction: onCellAction ? () => onCellAction(node.key) : onAction }); - let onKeyDown = (e: ReactKeyboardEvent) => { - if (!e.currentTarget.contains(e.target as HTMLElement)) { + let onKeyDownCapture = (e: ReactKeyboardEvent) => { + if (!e.currentTarget.contains(e.target as HTMLElement) || state.isKeyboardNavigationDisabled) { return; } @@ -225,7 +225,7 @@ export function useGridCell>(props: GridCellProps let gridCellProps: HTMLAttributes = mergeProps(itemProps, { role: 'gridcell', - onKeyDownCapture: onKeyDown, + onKeyDownCapture, onFocus }); diff --git a/packages/@react-aria/table/src/useTableColumnHeader.ts b/packages/@react-aria/table/src/useTableColumnHeader.ts index 358d78d2793..5f99c001f30 100644 --- a/packages/@react-aria/table/src/useTableColumnHeader.ts +++ b/packages/@react-aria/table/src/useTableColumnHeader.ts @@ -44,15 +44,18 @@ export function useTableColumnHeader(props: ColumnHeaderProps, state: TableSt let {node} = props; let allowsResizing = node.props.allowsResizing; let allowsSorting = node.props.allowsSorting; - let {gridCellProps} = useGridCell(props, state, ref); + // the selection cell column header needs to focus the checkbox within it but the other columns should focus the cell so that focus doesn't land on the resizer + let {gridCellProps} = useGridCell({...props, focusMode: node.props.isSelectionCell || node.props.allowsResizing || node.props.allowsSorting ? 'child' : 'cell'}, state, ref); let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single'; + let {pressProps} = usePress({ // Disabled for allowsResizing because if resizing is allowed, a menu trigger is added to the column header. - isDisabled: !allowsSorting || isSelectionCellDisabled || allowsResizing, + isDisabled: (!(allowsSorting || allowsResizing)) || isSelectionCellDisabled, onPress() { - state.sort(node.key); - } + !allowsResizing && state.sort(node.key); + }, + ref }); // Needed to pick up the focusable context, enabling things like Tooltips for example diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index bede08217cb..71e5867126f 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ -import {ColumnResizeState} from '@react-stately/table'; -import {focusSafely, useFocusable} from '@react-aria/focus'; +import {ColumnResizeState, TableState} from '@react-stately/table'; +import {focusSafely} from '@react-aria/focus'; import {GridNode} from '@react-types/grid'; import {HTMLAttributes, RefObject, useRef} from 'react'; import {mergeProps} from '@react-aria/utils'; @@ -23,41 +23,37 @@ interface ResizerAria { } interface ResizerProps { - column: GridNode + column: GridNode, + showResizer: boolean, + label: string } -export function useTableColumnResize(props: ResizerProps, state: ColumnResizeState, ref: RefObject): ResizerAria { - let {column: item} = props; +export function useTableColumnResize(props: ResizerProps, state: TableState & ColumnResizeState, ref: RefObject): ResizerAria { + let {column: item, showResizer} = props; const stateRef = useRef(null); // keep track of what the cursor on the body is so it can be restored back to that when done resizing const cursor = useRef(null); stateRef.current = state; let {direction} = useLocale(); - let {focusableProps} = useFocusable({excludeFromTabOrder: true}, ref); let {keyboardProps} = useKeyboard({ onKeyDown: (e) => { - if (e.key === 'Tab') { - // useKeyboard stops propagation by default. We want to continue propagation for tab so focus leaves the table - e.continuePropagation(); - } - if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { - // switch focus back to the column header on escape - const columnHeader = ref.current.previousSibling as HTMLElement; - if (columnHeader) { - focusSafely(columnHeader); - } + 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(ref.current.closest('[role="columnheader"]')); } } }); const columnResizeWidthRef = useRef(null); const {moveProps} = useMove({ - onMoveStart() { - stateRef.current.onColumnResizeStart(); + onMoveStart({pointerType}) { + if (pointerType !== 'keyboard') { + stateRef.current.onColumnResizeStart(item); + } columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key); cursor.current = document.body.style.cursor; - document.body.style.setProperty('cursor', 'col-resize'); }, onMove({deltaX, pointerType}) { if (direction === 'rtl') { @@ -70,18 +66,54 @@ export function useTableColumnResize(props: ResizerProps, state: ColumnRes } columnResizeWidthRef.current += deltaX; stateRef.current.onColumnResize(item, columnResizeWidthRef.current); + if (stateRef.current.getColumnMinWidth(item.key) >= stateRef.current.getColumnWidth(item.key)) { + document.body.style.setProperty('cursor', direction === 'rtl' ? 'w-resize' : 'e-resize'); + } else if (stateRef.current.getColumnMaxWidth(item.key) <= stateRef.current.getColumnWidth(item.key)) { + document.body.style.setProperty('cursor', direction === 'rtl' ? 'e-resize' : 'w-resize'); + } else { + document.body.style.setProperty('cursor', 'col-resize'); + } } }, - onMoveEnd() { - stateRef.current.onColumnResizeEnd(); + onMoveEnd({pointerType}) { + if (pointerType !== 'keyboard') { + stateRef.current.onColumnResizeEnd(item); + } columnResizeWidthRef.current = 0; document.body.style.cursor = cursor.current; } }); + let ariaProps = { + role: 'separator', + 'aria-label': props.label, + 'aria-orientation': 'vertical', + 'aria-labelledby': item.key, + 'aria-valuenow': stateRef.current.getColumnWidth(item.key), + 'aria-valuemin': stateRef.current.getColumnMinWidth(item.key), + 'aria-valuemax': stateRef.current.getColumnMaxWidth(item.key) + }; + return { resizerProps: { - ...mergeProps(moveProps, focusableProps, keyboardProps) + ...mergeProps( + moveProps, + { + onFocus: () => { + // useMove calls onMoveStart for every keypress, but we want resize start to only be called when we start resize mode + // call instead during focus and blur + stateRef.current.onColumnResizeStart(item); + state.setKeyboardNavigationDisabled(true); + }, + onBlur: () => { + stateRef.current.onColumnResizeEnd(item); + state.setKeyboardNavigationDisabled(false); + }, + tabIndex: showResizer ? 0 : undefined + }, + keyboardProps, + ariaProps + ) } }; } diff --git a/packages/@react-spectrum/menu/src/MenuTrigger.tsx b/packages/@react-spectrum/menu/src/MenuTrigger.tsx index f935517900c..980b3ca5bf3 100644 --- a/packages/@react-spectrum/menu/src/MenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/MenuTrigger.tsx @@ -82,8 +82,10 @@ function MenuTrigger(props: SpectrumMenuTriggerProps, ref: DOMRef) UNSAFE_className: classNames(styles, {'spectrum-Menu-popover': !isMobile}) }; + // Only contain focus while the menu is open. There is a fade out transition during which we may try to move focus. + // If we contain, then focus will be pulled back into the menu. let contents = ( - + {menu} diff --git a/packages/@react-spectrum/table/intl/ar-AE.json b/packages/@react-spectrum/table/intl/ar-AE.json index 54a3a96a8c9..405f0faf330 100644 --- a/packages/@react-spectrum/table/intl/ar-AE.json +++ b/packages/@react-spectrum/table/intl/ar-AE.json @@ -3,5 +3,6 @@ "loadingMore": "جارٍ تحميل المزيد...", "sortAscending": "Sort Ascending", "sortDescending": "Sort Descending", - "resizeColumn": "Resize column" + "resizeColumn": "Resize column", + "columnResizer": "Column resizer" } diff --git a/packages/@react-spectrum/table/intl/en-US.json b/packages/@react-spectrum/table/intl/en-US.json index a65ee31a1b8..16699f9a295 100644 --- a/packages/@react-spectrum/table/intl/en-US.json +++ b/packages/@react-spectrum/table/intl/en-US.json @@ -3,5 +3,6 @@ "loadingMore": "Loading more…", "sortAscending": "Sort Ascending", "sortDescending": "Sort Descending", - "resizeColumn": "Resize column" + "resizeColumn": "Resize column", + "columnResizer": "Column resizer" } diff --git a/packages/@react-spectrum/table/package.json b/packages/@react-spectrum/table/package.json index 2cc5d6425d8..42e2e5f2900 100644 --- a/packages/@react-spectrum/table/package.json +++ b/packages/@react-spectrum/table/package.json @@ -31,7 +31,6 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-aria/button": "^3.5.0", "@babel/runtime": "^7.6.2", "@react-aria/focus": "^3.6.0", "@react-aria/grid": "^3.3.0", diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index b42e3fad2f5..ffb12d4eb1e 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -1,30 +1,46 @@ /* eslint-disable jsx-a11y/role-supports-aria-props */ import {classNames} from '@react-spectrum/utils'; -import {FocusRing} from '@react-aria/focus'; -import React from 'react'; +import {GridNode} from '@react-types/grid'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import React, {RefObject} from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; +import {useLocale, useMessageFormatter} from '@react-aria/i18n'; import {useTableColumnResize} from '@react-aria/table'; import {useTableContext} from './TableView'; +interface ResizerProps { + column: GridNode, + showResizer: boolean +} + +function Resizer(props: ResizerProps, ref: RefObject) { + let {column, showResizer} = props; + let {state, columnState} = useTableContext(); + let formatMessage = useMessageFormatter(intlMessages); + let {direction} = useLocale(); + + let {resizerProps} = useTableColumnResize({...props, label: formatMessage('columnResizer')}, {...state, ...columnState}, ref); -function Resizer(props, ref) { - const {column} = props; - let {columnState} = useTableContext(); - let {resizerProps} = useTableColumnResize({column}, columnState, ref); + let style = { + cursor: undefined, + height: '100%', + display: showResizer ? undefined : 'none', + touchAction: 'none' + }; + if (columnState.getColumnMinWidth(column.key) >= columnState.getColumnWidth(column.key)) { + style.cursor = direction === 'rtl' ? 'w-resize' : 'e-resize'; + } else if (columnState.getColumnMaxWidth(column.key) <= columnState.getColumnWidth(column.key)) { + style.cursor = direction === 'rtl' ? 'e-resize' : 'w-resize'; + } else { + style.cursor = 'col-resize'; + } return ( - -
- +
); } diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 3784c2193fa..db9b5c7be3d 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -23,7 +23,7 @@ import intlMessages from '../intl/*.json'; import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; import {layoutInfoToStyle, ScrollView, setScrollLeft, useVirtualizer, VirtualizerItem} from '@react-aria/virtualizer'; import {ProgressCircle} from '@react-spectrum/progress'; -import React, {ReactElement, useCallback, useContext, useMemo, useRef, useState} from 'react'; +import React, {ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {Resizer} from './Resizer'; import {SpectrumColumnProps, SpectrumTableProps} from '@react-types/table'; @@ -31,7 +31,6 @@ import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; import stylesOverrides from './table.css'; import {TableLayout} from '@react-stately/layout'; import {Tooltip, TooltipTrigger} from '@react-spectrum/tooltip'; -import {useButton} from '@react-aria/button'; import {useHover} from '@react-aria/interactions'; import {useLocale, useMessageFormatter} from '@react-aria/i18n'; import {usePress} from '@react-aria/interactions'; @@ -113,7 +112,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef(props: SpectrumTableProps, ref: DOMRef; + return ; } return ( @@ -317,7 +316,6 @@ function TableView(props: SpectrumTableProps, ref: DOMRef(null); let {state} = useTableContext(); let {columnHeaderProps} = useTableColumnHeader({ node: column, isVirtualized: true }, state, ref); - let {buttonProps} = useButton({...props, elementType: 'div'}, ref); - let columnProps = column.props as SpectrumColumnProps; if (columnProps.width && columnProps.allowsResizing) { throw new Error('Controlled state is not yet supported with column resizing. Please use defaultWidth for uncontrolled column resizing or remove the allowsResizing prop.'); } - let {hoverProps, isHovered} = useHover({}); - if (columnProps.allowsResizing) { - // if we allow resizing, override the usePress that useTableColumnHeader generates so that clicking brings up the menu - // instead of sorting the column immediately - columnHeaderProps = {...columnHeaderProps, ...buttonProps}; - } + let {hoverProps, isHovered} = useHover(props); const allProps = [columnHeaderProps, hoverProps]; - return (
} + {props.children}
); } -function ResizableTableColumnHeader({column}) { +function ResizableTableColumnHeader(props) { + let {column} = props; let ref = useRef(); - let {state} = useTableContext(); + let {state, columnState} = useTableContext(); let formatMessage = useMessageFormatter(intlMessages); + let [isHovered, setIsHovered] = useState(false); const onMenuSelect = (key) => { switch (key) { @@ -536,41 +529,46 @@ function ResizableTableColumnHeader({column}) { state.sort(column.key, 'descending'); break; case 'resize': - // focusResizer, needs timeout so that it happens after the animation timeout for menu close - setTimeout(() => { - focusSafely(ref.current); - }, 360); + columnState.onColumnResizeStart(column); break; } }; let allowsSorting = column.props?.allowsSorting; let items = useMemo(() => { - let options = { - sortAscending: allowsSorting && { + let options = [ + allowsSorting ? { label: formatMessage('sortAscending'), id: 'sort-asc' - }, - sortDescending: allowsSorting && { + } : undefined, + allowsSorting ? { label: formatMessage('sortDescending'), id: 'sort-desc' - }, - resize: { + } : undefined, + { label: formatMessage('resizeColumn'), id: 'resize' } - }; - return Object.keys(options).reduce((acc, key) => { - if (options[key]) { - acc.push(options[key]); - } - return acc; - }, []); + ]; + return options; }, [allowsSorting]); + // if we're resizing another column, then hover shouldn't show the resizer of a different column + let showResizer = (isHovered && !columnState.currentlyResizingColumn) || columnState.currentlyResizingColumn === column.key; + + useEffect(() => { + if (columnState.currentlyResizingColumn === column.key) { + focusSafely(ref.current); + } + }, [columnState.currentlyResizingColumn, column.key]); return ( <> - + + + {(item) => ( @@ -579,7 +577,6 @@ function ResizableTableColumnHeader({column}) { )} - ); } diff --git a/packages/@react-spectrum/table/test/TableSizing.test.js b/packages/@react-spectrum/table/test/TableSizing.test.js index 90daf92e8bb..d8053b33fcc 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.js +++ b/packages/@react-spectrum/table/test/TableSizing.test.js @@ -15,6 +15,7 @@ import {act, render as renderComponent, within} from '@testing-library/react'; import {ActionButton} from '@react-spectrum/button'; import Add from '@spectrum-icons/workflow/Add'; import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; +import {fireEvent, installPointerEvent} from '@react-spectrum/test-utils'; import {HidingColumns} from '../stories/HidingColumns'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; @@ -622,6 +623,608 @@ describe('TableViewSizing', function () { }); }); + describe('resizing columns', function () { + describe('pointer', () => { + installPointerEvent(); + + it('dragging the resizer works - desktop', () => { + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); + let tree = render( + + + Foo + Bar + Baz + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + // trigger pointer modality + fireEvent.pointerMove(tree.container); + expect(tree.queryByRole('separator')).toBeNull(); + + let rows = tree.getAllByRole('row'); + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('600px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + let resizableHeader = tree.getAllByRole('columnheader')[0]; + + fireEvent.pointerEnter(resizableHeader); + expect(tree.getByRole('separator')).toBeVisible(); + let resizer = tree.getByRole('separator'); + + fireEvent.pointerEnter(resizer); + + // actual locations do not matter, the delta matters between events for the calculation of useMove + fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 600, pageY: 30}); + fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 25}); + fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); + + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('595px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + // actual locations do not matter, the delta matters between events for the calculation of useMove + fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 30}); + fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 620, pageY: 25}); + fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); + + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('620px'); + expect(row.childNodes[1].style.width).toBe('190px'); + expect(row.childNodes[2].style.width).toBe('190px'); + } + + fireEvent.pointerLeave(resizer, {pointerType: 'mouse', pointerId: 1}); + fireEvent.pointerLeave(resizableHeader, {pointerType: 'mouse', pointerId: 1}); + + expect(tree.queryByRole('separator')).toBeNull(); + }); + + it('dragging the resizer works - mobile', () => { + let tree = render( + + + Foo + Bar + Baz + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + // trigger pointer modality + fireEvent.pointerMove(tree.container); + expect(tree.queryByRole('separator')).toBeNull(); + + let rows = tree.getAllByRole('row'); + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('600px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + let resizableHeader = tree.getAllByRole('columnheader')[0]; + + fireEvent.pointerEnter(resizableHeader); + expect(tree.getByRole('separator')).toBeVisible(); + let resizer = tree.getByRole('separator'); + + fireEvent.pointerEnter(resizer); + + // actual locations do not matter, the delta matters between events for the calculation of useMove + fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 600, pageY: 30}); + fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 25}); + fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); + + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('595px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + // actual locations do not matter, the delta matters between events for the calculation of useMove + fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 30}); + fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 620, pageY: 25}); + fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); + + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('620px'); + expect(row.childNodes[1].style.width).toBe('190px'); + expect(row.childNodes[2].style.width).toBe('190px'); + } + + fireEvent.pointerLeave(resizer, {pointerType: 'mouse', pointerId: 1}); + fireEvent.pointerLeave(resizableHeader, {pointerType: 'mouse', pointerId: 1}); + + expect(tree.queryByRole('separator')).toBeNull(); + }); + }); + + describe('touch', () => { + installPointerEvent(); + + it('dragging the resizer works - desktop', () => { + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); + let tree = render( + + + Foo + Bar + Baz + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + fireEvent.pointerDown(document.body, {pointerType: 'touch', pointerId: 1}); + fireEvent.pointerUp(document.body, {pointerType: 'touch', pointerId: 1}); + act(() => {jest.runAllTimers();}); + + expect(tree.queryByRole('separator')).toBeNull(); + + let rows = tree.getAllByRole('row'); + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('600px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + let resizableHeader = tree.getAllByRole('columnheader')[0]; + + fireEvent.pointerDown(resizableHeader, {pointerType: 'touch', pointerId: 1}); + fireEvent.pointerUp(resizableHeader, {pointerType: 'touch', pointerId: 1}); + act(() => {jest.runAllTimers();}); + + let resizeMenuItem = tree.getAllByRole('menuitem')[0]; + + fireEvent.pointerDown(resizeMenuItem, {pointerType: 'touch', pointerId: 1}); + fireEvent.pointerUp(resizeMenuItem, {pointerType: 'touch', pointerId: 1}); + act(() => {jest.runAllTimers();}); + + expect(tree.getByRole('separator')).toBeVisible(); + let resizer = tree.getByRole('separator'); + + // actual locations do not matter, the delta matters between events for the calculation of useMove + fireEvent.pointerDown(resizer, {pointerType: 'touch', pointerId: 1, pageX: 600, pageY: 30}); + fireEvent.pointerMove(resizer, {pointerType: 'touch', pointerId: 1, pageX: 595, pageY: 25}); + fireEvent.pointerUp(resizer, {pointerType: 'touch', pointerId: 1}); + + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('595px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + // actual locations do not matter, the delta matters between events for the calculation of useMove + fireEvent.pointerDown(resizer, {pointerType: 'touch', pointerId: 1, pageX: 595, pageY: 30}); + fireEvent.pointerMove(resizer, {pointerType: 'touch', pointerId: 1, pageX: 620, pageY: 25}); + fireEvent.pointerUp(resizer, {pointerType: 'touch', pointerId: 1}); + + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('620px'); + expect(row.childNodes[1].style.width).toBe('190px'); + expect(row.childNodes[2].style.width).toBe('190px'); + } + + // tapping on the document.body doesn't cause a blur because the body isn't focusable, so just call blur + act(() => resizer.blur()); + act(() => {jest.runAllTimers();}); + + expect(tree.queryByRole('separator')).toBeNull(); + }); + + it('dragging the resizer works - mobile', () => { + let tree = render( + + + Foo + Bar + Baz + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + fireEvent.pointerDown(document.body, {pointerType: 'touch', pointerId: 1}); + fireEvent.pointerUp(document.body, {pointerType: 'touch', pointerId: 1}); + act(() => {jest.runAllTimers();}); + + expect(tree.queryByRole('separator')).toBeNull(); + + let rows = tree.getAllByRole('row'); + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('600px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + let resizableHeader = tree.getAllByRole('columnheader')[0]; + + fireEvent.pointerDown(resizableHeader, {pointerType: 'touch', pointerId: 1}); + fireEvent.pointerUp(resizableHeader, {pointerType: 'touch', pointerId: 1}); + act(() => {jest.runAllTimers();}); + + let resizeMenuItem = tree.getAllByRole('menuitem')[0]; + + fireEvent.pointerDown(resizeMenuItem, {pointerType: 'touch', pointerId: 1}); + fireEvent.pointerUp(resizeMenuItem, {pointerType: 'touch', pointerId: 1}); + act(() => {jest.runAllTimers();}); + + let resizer = tree.getByRole('separator'); + expect(resizer).toBeVisible(); + expect(document.activeElement).toBe(resizer); + + // actual locations do not matter, the delta matters between events for the calculation of useMove + fireEvent.pointerDown(resizer, {pointerType: 'touch', pointerId: 1, pageX: 600, pageY: 30}); + fireEvent.pointerMove(resizer, {pointerType: 'touch', pointerId: 1, pageX: 595, pageY: 25}); + fireEvent.pointerUp(resizer, {pointerType: 'touch', pointerId: 1}); + + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('595px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + // actual locations do not matter, the delta matters between events for the calculation of useMove + fireEvent.pointerDown(resizer, {pointerType: 'touch', pointerId: 1, pageX: 595, pageY: 30}); + fireEvent.pointerMove(resizer, {pointerType: 'touch', pointerId: 1, pageX: 620, pageY: 25}); + fireEvent.pointerUp(resizer, {pointerType: 'touch', pointerId: 1}); + + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('620px'); + expect(row.childNodes[1].style.width).toBe('190px'); + expect(row.childNodes[2].style.width).toBe('190px'); + } + + // tapping on the document.body doesn't cause a blur because the body isn't focusable, so just call blur + act(() => resizer.blur()); + act(() => {jest.runAllTimers();}); + + expect(tree.queryByRole('separator')).toBeNull(); + }); + }); + + describe('keyboard', () => { + it('arrow keys the resizer works - desktop', async () => { + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); + let tree = render( + + + Foo + Bar + Baz + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + userEvent.tab(); + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + + let resizableHeader = tree.getAllByRole('columnheader')[0]; + expect(document.activeElement).toBe(resizableHeader); + expect(tree.queryByRole('separator')).toBeNull(); + + let rows = tree.getAllByRole('row'); + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('600px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + act(() => {jest.runAllTimers();}); + act(() => {jest.runAllTimers();}); + + let resizer = tree.getByRole('separator'); + + expect(document.activeElement).toBe(resizer); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('620px'); + expect(row.childNodes[1].style.width).toBe('190px'); + expect(row.childNodes[2].style.width).toBe('190px'); + } + + fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); + fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); + + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('600px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + fireEvent.keyDown(document.activeElement, {key: 'Escape'}); + fireEvent.keyUp(document.activeElement, {key: 'Escape'}); + + expect(document.activeElement).toBe(resizableHeader); + + expect(tree.queryByRole('separator')).toBeNull(); + }); + it('arrow keys the resizer works - mobile', async () => { + let tree = render( + + + Foo + Bar + Baz + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + userEvent.tab(); + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + + let resizableHeader = tree.getAllByRole('columnheader')[0]; + expect(document.activeElement).toBe(resizableHeader); + expect(tree.queryByRole('separator')).toBeNull(); + + let rows = tree.getAllByRole('row'); + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('600px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + act(() => {jest.runAllTimers();}); + act(() => {jest.runAllTimers();}); + + let resizer = tree.getByRole('separator'); + + expect(document.activeElement).toBe(resizer); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('620px'); + expect(row.childNodes[1].style.width).toBe('190px'); + expect(row.childNodes[2].style.width).toBe('190px'); + } + + fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); + fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); + + + for (let row of rows) { + expect(row.childNodes[0].style.width).toBe('600px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + fireEvent.keyDown(document.activeElement, {key: 'Escape'}); + fireEvent.keyUp(document.activeElement, {key: 'Escape'}); + + expect(document.activeElement).toBe(resizableHeader); + + expect(tree.queryByRole('separator')).toBeNull(); + }); + it('can exit resize via Enter', async () => { + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); + let tree = render( + + + Foo + Bar + Baz + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + userEvent.tab(); + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + + let resizableHeader = tree.getAllByRole('columnheader')[0]; + expect(document.activeElement).toBe(resizableHeader); + expect(tree.queryByRole('separator')).toBeNull(); + + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + act(() => {jest.runAllTimers();}); + act(() => {jest.runAllTimers();}); + + let resizer = tree.getByRole('separator'); + + expect(document.activeElement).toBe(resizer); + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + + expect(document.activeElement).toBe(resizableHeader); + + expect(tree.queryByRole('separator')).toBeNull(); + }); + it('can exit resize via Tab', async () => { + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); + let tree = render( + + + Foo + Bar + Baz + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + userEvent.tab(); + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + + let resizableHeader = tree.getAllByRole('columnheader')[0]; + expect(document.activeElement).toBe(resizableHeader); + expect(tree.queryByRole('separator')).toBeNull(); + + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + act(() => {jest.runAllTimers();}); + act(() => {jest.runAllTimers();}); + + let resizer = tree.getByRole('separator'); + + expect(document.activeElement).toBe(resizer); + + userEvent.tab(); + + expect(document.activeElement).toBe(resizableHeader); + + expect(tree.queryByRole('separator')).toBeNull(); + }); + it('can exit resize via shift Tab', async () => { + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); + let tree = render( + + + Foo + Bar + Baz + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + userEvent.tab(); + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + + let resizableHeader = tree.getAllByRole('columnheader')[0]; + expect(document.activeElement).toBe(resizableHeader); + expect(tree.queryByRole('separator')).toBeNull(); + + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + act(() => {jest.runAllTimers();}); + act(() => {jest.runAllTimers();}); + + let resizer = tree.getByRole('separator'); + + expect(document.activeElement).toBe(resizer); + + userEvent.tab({shift: true}); + + expect(document.activeElement).toBe(resizableHeader); + + expect(tree.queryByRole('separator')).toBeNull(); + }); + }); + }); + describe('updating columns', function () { it('should support removing columns', function () { let tree = render(); diff --git a/packages/@react-stately/grid/src/useGridState.ts b/packages/@react-stately/grid/src/useGridState.ts index 6e5754ebdd1..211a7f57929 100644 --- a/packages/@react-stately/grid/src/useGridState.ts +++ b/packages/@react-stately/grid/src/useGridState.ts @@ -7,7 +7,9 @@ export interface GridState> { /** A set of keys for rows that are disabled. */ disabledKeys: Set, /** A selection manager to read and update row selection state. */ - selectionManager: SelectionManager + selectionManager: SelectionManager, + /** Whether keyboard navigation is disabled, such as when the arrow keys should be handled by a component within a cell. */ + isKeyboardNavigationDisabled: boolean } interface GridStateOptions> extends MultipleSelectionStateProps { @@ -54,6 +56,7 @@ export function useGridState>(prop return { collection, disabledKeys, + isKeyboardNavigationDisabled: false, selectionManager: new SelectionManager(collection, selectionState) }; } diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 9f30bbf3a67..180c73da3d1 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -17,25 +17,23 @@ export interface ColumnResizeState { columnWidths: MutableRefObject>, /** Setter for the table width. */ setTableWidth: (width: number) => void, - /** Trigger a resize and recalc. */ + /** Trigger a resize and recalculation. */ onColumnResize: (column: GridNode, width: number) => void, /** Callback for when onColumnResize has started. */ - onColumnResizeStart: () => void, - /** Callback for when onColumnResize has ended. */ - onColumnResizeEnd: () => void, + onColumnResizeStart: (column: GridNode) => void, + /** Callback for when onColumnResize has ended. */ + onColumnResizeEnd: (column: GridNode) => void, /** Getter for column width. */ - getColumnWidth(key: Key): number, - /** Getter for column min width. */ - getColumnMinWidth(key: Key): number, - /** Getter for column max widths. */ - getColumnMaxWidth(key: Key): number, - /** Boolean for if a column is being resized. */ - isResizingColumn: boolean + getColumnWidth: (key: Key) => number, + /** Getter for column min width. */ + getColumnMinWidth: (key: Key) => number, + /** Getter for column max widths. */ + getColumnMaxWidth: (key: Key) => number, + /** Key of column currently being resized. */ + currentlyResizingColumn: Key | null } -export interface ColumnResizeStateProps { - /** Collection of existing columns. */ - columns: GridNode[], +export interface ColumnResizeStateProps { /** Callback to determine what the default width of a column should be. */ getDefaultWidth?: (props) => string | number, /** Callback that is invoked during the entirety of the resize event. */ @@ -46,8 +44,13 @@ export interface ColumnResizeStateProps { tableWidth?: number } -export function useTableColumnResizeState(props: ColumnResizeStateProps): ColumnResizeState { - const {columns, getDefaultWidth, tableWidth: defaultTableWidth = null} = props; +interface ColumnState { + columns: GridNode[] +} + +export function useTableColumnResizeState(props: ColumnResizeStateProps, state: ColumnState): ColumnResizeState { + const {getDefaultWidth, tableWidth: defaultTableWidth = null} = props; + const {columns} = state; const columnsRef = useRef[]>([]); const tableWidth = useRef(defaultTableWidth); const isResizing = useRef(null); @@ -59,6 +62,8 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps): const [resizedColumns, setResizedColumns] = useState>(new Set()); const resizedColumnsRef = useRef>(resizedColumns); + const [currentlyResizingColumn, setCurrentlyResizingColumn] = useState(null); + function setColumnWidthsForRef(newWidths: Map) { columnWidthsRef.current = newWidths; // new map so that change detection is triggered @@ -122,7 +127,8 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps): } } - function onColumnResizeStart() { + function onColumnResizeStart(column: GridNode) { + setCurrentlyResizingColumn(column.key); isResizing.current = true; startResizeContentWidth.current = getContentWidth(columnWidthsRef.current); } @@ -133,7 +139,9 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps): props.onColumnResize && props.onColumnResize(affectedColumnWidthsRef.current); } - function onColumnResizeEnd() { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function onColumnResizeEnd(column: GridNode) { + setCurrentlyResizingColumn(null); isResizing.current = false; props.onColumnResizeEnd && props.onColumnResizeEnd(affectedColumnWidthsRef.current); affectedColumnWidthsRef.current = []; @@ -212,6 +220,6 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps): getColumnWidth, getColumnMinWidth, getColumnMaxWidth, - isResizingColumn: isResizing.current + currentlyResizingColumn }; } diff --git a/packages/@react-stately/table/src/useTableState.ts b/packages/@react-stately/table/src/useTableState.ts index 7255637e72a..f88d3c16a25 100644 --- a/packages/@react-stately/table/src/useTableState.ts +++ b/packages/@react-stately/table/src/useTableState.ts @@ -13,7 +13,7 @@ import {CollectionBase, Node, SelectionMode, Sortable, SortDescriptor, SortDirection} from '@react-types/shared'; import {GridState, useGridState} from '@react-stately/grid'; import {TableCollection as ITableCollection} from '@react-types/table'; -import {Key, useMemo} from 'react'; +import {Key, useMemo, useState} from 'react'; import {MultipleSelectionStateProps} from '@react-stately/selection'; import {TableCollection} from './TableCollection'; import {useCollection} from '@react-stately/collections'; @@ -26,7 +26,11 @@ export interface TableState extends GridState> { /** The current sorted column and direction. */ sortDescriptor: SortDescriptor, /** Calls the provided onSortChange handler with the provided column key and sort direction. */ - sort(columnKey: Key, direction?: 'ascending' | 'descending'): void + sort(columnKey: Key, direction?: 'ascending' | 'descending'): void, + /** Whether keyboard navigation is disabled, such as when the arrow keys should be handled by a component within a cell. */ + isKeyboardNavigationDisabled: boolean, + /** Set whether keyboard navigation is disabled, such as when the arrow keys should be handled by a component within a cell. */ + setKeyboardNavigationDisabled: (val: boolean) => void } export interface CollectionBuilderContext { @@ -50,6 +54,7 @@ const OPPOSITE_SORT_DIRECTION = { * of columns and rows from props. In addition, it tracks row selection and manages sort order changes. */ export function useTableState(props: TableStateProps): TableState { + let [isKeyboardNavigationDisabled, setKeyboardNavigationDisabled] = useState(false); let {selectionMode = 'none'} = props; let context = useMemo(() => ({ @@ -71,6 +76,8 @@ export function useTableState(props: TableStateProps): Tabl selectionManager, showSelectionCheckboxes: props.showSelectionCheckboxes || false, sortDescriptor: props.sortDescriptor, + isKeyboardNavigationDisabled, + setKeyboardNavigationDisabled, sort(columnKey: Key, direction?: 'ascending' | 'descending') { props.onSortChange({ column: columnKey, diff --git a/packages/@react-stately/table/test/useTableColumnResizeState.test.ts b/packages/@react-stately/table/test/useTableColumnResizeState.test.ts index a319d08a009..10f0915f858 100644 --- a/packages/@react-stately/table/test/useTableColumnResizeState.test.ts +++ b/packages/@react-stately/table/test/useTableColumnResizeState.test.ts @@ -11,6 +11,7 @@ */ import {getContentWidth, useTableColumnResizeState} from '../'; +import {GridNode} from '@react-types/grid'; import {renderHook} from '@react-spectrum/test-utils'; const createColumn = (key, columnProps) => ({ @@ -34,7 +35,7 @@ describe('useTableColumnResizeState', () => { createColumn('Weight', {allowsResizing: true, defaultWidth: 200}) ]; - const {result} = renderHook(() => useTableColumnResizeState({columns})); + const {result} = renderHook(() => useTableColumnResizeState>({}, {columns})); expect(result.current.columnWidths.current).toEqual(new Map([['Name', 300], ['Age', 100], ['Weight', 200]])); }); @@ -45,7 +46,7 @@ describe('useTableColumnResizeState', () => { createColumn('Weight', {allowsResizing: true, defaultWidth: '33%'}) ]; - const {result} = renderHook(() => useTableColumnResizeState({columns, tableWidth: 600})); + const {result} = renderHook(() => useTableColumnResizeState({tableWidth: 600}, {columns})); expect(result.current.columnWidths.current).toEqual(new Map([['Name', 300], ['Age', 96], ['Weight', 198]])); }); }); @@ -58,7 +59,7 @@ describe('useTableColumnResizeState', () => { createColumn('Weight', {allowsResizing: true}) ]; - const {result} = renderHook(() => useTableColumnResizeState({columns, tableWidth: 333})); + const {result} = renderHook(() => useTableColumnResizeState({tableWidth: 333}, {columns})); expect(result.current.columnWidths.current).toEqual(new Map([['Name', 111], ['Age', 111], ['Weight', 111]])); }); @@ -69,7 +70,7 @@ describe('useTableColumnResizeState', () => { createColumn('Weight', {allowsResizing: true, defaultWidth: '1fr'}) ]; - const {result} = renderHook(() => useTableColumnResizeState({columns, tableWidth: 1000})); + const {result} = renderHook(() => useTableColumnResizeState({tableWidth: 1000}, {columns})); expect(result.current.columnWidths.current).toEqual(new Map([['Name', 250], ['Age', 500], ['Weight', 250]])); }); }); @@ -82,7 +83,7 @@ describe('useTableColumnResizeState', () => { createColumn('Weight', {allowsResizing: true, defaultWidth: '1fr'}) ]; - const {result} = renderHook(() => useTableColumnResizeState({columns, tableWidth: 1000})); + const {result} = renderHook(() => useTableColumnResizeState({tableWidth: 1000}, {columns})); expect(result.current.columnWidths.current).toEqual(new Map([['Name', 85], ['Age', 610], ['Weight', 305]])); }); @@ -93,7 +94,7 @@ describe('useTableColumnResizeState', () => { createColumn('Weight', {allowsResizing: true, defaultWidth: '1fr'}) ]; - const {result} = renderHook(() => useTableColumnResizeState({columns, tableWidth: 1000})); + const {result} = renderHook(() => useTableColumnResizeState({tableWidth: 1000}, {columns})); expect(result.current.columnWidths.current).toEqual(new Map([['Name', 400], ['Age', 400], ['Weight', 200]])); }); @@ -106,7 +107,7 @@ describe('useTableColumnResizeState', () => { const tableWidth = 1000; - const {result} = renderHook(() => useTableColumnResizeState({columns, tableWidth})); + const {result} = renderHook(() => useTableColumnResizeState({tableWidth}, {columns})); const actualColumnWidths = getContentWidth(result.current.columnWidths.current); @@ -123,7 +124,7 @@ describe('useTableColumnResizeState', () => { const tableWidth = 1000; - const {result} = renderHook(() => useTableColumnResizeState({columns, tableWidth})); + const {result} = renderHook(() => useTableColumnResizeState({tableWidth}, {columns})); const actualColumnWidths = getContentWidth(result.current.columnWidths.current); @@ -140,7 +141,7 @@ describe('useTableColumnResizeState', () => { const tableWidth = 1000; - const {result} = renderHook(() => useTableColumnResizeState({columns, tableWidth})); + const {result} = renderHook(() => useTableColumnResizeState({tableWidth}, {columns})); expect(result.current.columnWidths.current).toEqual(new Map([['Name', 650], ['Age', 100], ['Weight', 250]])); }); @@ -164,7 +165,7 @@ describe('useTableColumnResizeState', () => { const tableWidth = 1000; - const {result} = renderHook(() => useTableColumnResizeState({columns, tableWidth})); + const {result} = renderHook(() => useTableColumnResizeState({tableWidth}, {columns})); expect(result.current.columnWidths.current).toEqual(new Map([['Name', 650], ['Age', 100], ['Weight', 250]])); }); @@ -178,7 +179,7 @@ describe('useTableColumnResizeState', () => { const tableWidth = 1000; - const {result} = renderHook(() => useTableColumnResizeState({columns, tableWidth})); + const {result} = renderHook(() => useTableColumnResizeState({tableWidth}, {columns})); expect(result.current.columnWidths.current).toEqual(new Map([['Name', 250], ['Age', 500], ['Weight', 250]])); }); @@ -192,7 +193,7 @@ describe('useTableColumnResizeState', () => { const tableWidth = 1000; - const {result} = renderHook(() => useTableColumnResizeState({columns, tableWidth})); + const {result} = renderHook(() => useTableColumnResizeState({tableWidth}, {columns})); expect(result.current.columnWidths.current).toEqual(new Map([['Name', 250], ['Age', 500], ['Weight', 250]])); });