From 22fea1b0c1340beaf3a36b00cecfa2cbc6c06cb9 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 10 Jun 2022 18:04:55 -0700 Subject: [PATCH 01/14] Resizer keyboard navigable --- .../components/table/index.css | 10 +---- packages/@react-aria/grid/src/useGridCell.ts | 3 +- .../table/src/useTableColumnResize.ts | 29 ++++++++++--- .../@react-spectrum/table/intl/en-US.json | 3 +- .../@react-spectrum/table/src/Resizer.tsx | 43 ++++++++++++------- .../@react-spectrum/table/src/TableView.tsx | 15 +++---- .../table/src/useTableColumnResizeState.ts | 11 +++-- 7 files changed, 70 insertions(+), 44 deletions(-) diff --git a/packages/@adobe/spectrum-css-temp/components/table/index.css b/packages/@adobe/spectrum-css-temp/components/table/index.css index 8cac21b3f65..441a608e532 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,7 +97,6 @@ svg.spectrum-Table-sortedIcon { inset-inline-end: 0px; inline-size: 10px; block-size: 100%; - cursor: col-resize; user-select: none; &::after { @@ -120,6 +111,7 @@ svg.spectrum-Table-sortedIcon { &:active, &:focus { + outline: none; &::after { inline-size: 2px; background-color: var(--spectrum-global-color-blue-400); diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index edeb40f08b8..9e3ee9f9ae1 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -68,7 +68,8 @@ export function useGridCell>(props: GridCellProps let treeWalker = getFocusableTreeWalker(ref.current); if (focusMode === 'child') { // If focus is already on a focusable child within the cell, early return so we don't shift focus - if (ref.current.contains(document.activeElement) && ref.current !== document.activeElement) { + // We may have non-grid cell contained interactive elements but that live next to a grid cell + if (ref.current.parentElement.contains(document.activeElement) && ref.current !== document.activeElement) { return; } diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index bede08217cb..29fd53ee4e3 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -13,7 +13,7 @@ import {ColumnResizeState} from '@react-stately/table'; import {focusSafely, useFocusable} from '@react-aria/focus'; import {GridNode} from '@react-types/grid'; -import {HTMLAttributes, RefObject, useRef} from 'react'; +import {HTMLAttributes, RefObject, useEffect, useRef} from 'react'; import {mergeProps} from '@react-aria/utils'; import {useKeyboard, useMove} from '@react-aria/interactions'; import {useLocale} from '@react-aria/i18n'; @@ -23,7 +23,8 @@ interface ResizerAria { } interface ResizerProps { - column: GridNode + column: GridNode, + label: string } export function useTableColumnResize(props: ResizerProps, state: ColumnResizeState, ref: RefObject): ResizerAria { @@ -54,10 +55,9 @@ export function useTableColumnResize(props: ResizerProps, state: ColumnRes const columnResizeWidthRef = useRef(null); const {moveProps} = useMove({ onMoveStart() { - stateRef.current.onColumnResizeStart(); + stateRef.current.onColumnResizeStart(item.key); 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 +70,35 @@ 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', 'e-resize'); + } else if (stateRef.current.getColumnMaxWidth(item.key) <= stateRef.current.getColumnWidth(item.key)) { + document.body.style.setProperty('cursor', 'w-resize'); + } else { + document.body.style.setProperty('cursor', 'col-resize'); + } } }, onMoveEnd() { - stateRef.current.onColumnResizeEnd(); + stateRef.current.onColumnResizeEnd(item.key); 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, focusableProps, keyboardProps, ariaProps) } }; } 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/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index b42e3fad2f5..39fa7933b3c 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -1,30 +1,41 @@ /* eslint-disable jsx-a11y/role-supports-aria-props */ import {classNames} from '@react-spectrum/utils'; -import {FocusRing} from '@react-aria/focus'; +// @ts-ignore +import intlMessages from '../intl/*.json'; import React from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; +import {useMessageFormatter} from '@react-aria/i18n'; import {useTableColumnResize} from '@react-aria/table'; import {useTableContext} from './TableView'; function Resizer(props, ref) { - const {column} = props; + let {column, tableRef} = props; let {columnState} = useTableContext(); - let {resizerProps} = useTableColumnResize({column}, columnState, ref); + let formatMessage = useMessageFormatter(intlMessages); + + let {resizerProps} = useTableColumnResize({...props, label: formatMessage('columnResizer')}, columnState, ref); + + let style = { + cursor: undefined, + height: '100%' + }; + if (columnState.isResizingColumn && columnState.getResizingColumn() === column.key && tableRef.current) { + style.height = tableRef.current.offsetHeight; + } + if (columnState.getColumnMinWidth(column.key) >= columnState.getColumnWidth(column.key)) { + style.cursor = 'e-resize'; + } else if (columnState.getColumnMaxWidth(column.key) <= columnState.getColumnWidth(column.key)) { + style.cursor = '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..343ff429018 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -173,7 +173,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef + style={{...style, zIndex: 1}}> {renderChildren(children)} ); @@ -260,7 +260,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef; + return ; } return ( @@ -422,7 +422,7 @@ function TableVirtualizer({layout, collection, focusedKey, renderView, renderWra style={{ width: visibleRect.width, height: headerHeight, - overflow: 'hidden', + overflow: 'visible', position: 'relative', willChange: state.isScrolling ? 'scroll-position' : '', transition: state.isAnimating ? `none ${state.virtualizer.transitionDuration}ms` : undefined @@ -522,7 +522,8 @@ function TableColumnHeader(props) { ); } -function ResizableTableColumnHeader({column}) { +function ResizableTableColumnHeader(props) { + let {column} = props; let ref = useRef(); let {state} = useTableContext(); let formatMessage = useMessageFormatter(intlMessages); @@ -537,9 +538,7 @@ function ResizableTableColumnHeader({column}) { break; case 'resize': // focusResizer, needs timeout so that it happens after the animation timeout for menu close - setTimeout(() => { - focusSafely(ref.current); - }, 360); + focusSafely(ref.current); break; } }; @@ -579,7 +578,7 @@ function ResizableTableColumnHeader({column}) { )} - + ); } diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index 9f30bbf3a67..cc2d57284a6 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -59,6 +59,8 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps): const [resizedColumns, setResizedColumns] = useState>(new Set()); const resizedColumnsRef = useRef>(resizedColumns); + const currentlyResizingColumn = useRef(null); + function setColumnWidthsForRef(newWidths: Map) { columnWidthsRef.current = newWidths; // new map so that change detection is triggered @@ -122,7 +124,8 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps): } } - function onColumnResizeStart() { + function onColumnResizeStart(column: GridNode) { + currentlyResizingColumn.current = column; isResizing.current = true; startResizeContentWidth.current = getContentWidth(columnWidthsRef.current); } @@ -133,7 +136,8 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps): props.onColumnResize && props.onColumnResize(affectedColumnWidthsRef.current); } - function onColumnResizeEnd() { + function onColumnResizeEnd(column: GridNode) { + currentlyResizingColumn.current = null; isResizing.current = false; props.onColumnResizeEnd && props.onColumnResizeEnd(affectedColumnWidthsRef.current); affectedColumnWidthsRef.current = []; @@ -212,6 +216,7 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps): getColumnWidth, getColumnMinWidth, getColumnMaxWidth, - isResizingColumn: isResizing.current + isResizingColumn: isResizing.current, + getResizingColumn: () => currentlyResizingColumn.current }; } From ed9e201caf6947ce356fbe7dfe34096c069a5265 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 16 Jun 2022 13:56:25 -0700 Subject: [PATCH 02/14] enable only during hover/menu --- packages/@react-aria/grid/src/useGridCell.ts | 2 +- .../table/src/useTableColumnResize.ts | 24 ++++++++++++++----- .../@react-spectrum/table/src/Resizer.tsx | 21 +++++++++++----- .../@react-spectrum/table/src/TableView.tsx | 22 ++++++++++++----- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index 9e3ee9f9ae1..e568cf8a66a 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -69,7 +69,7 @@ export function useGridCell>(props: GridCellProps if (focusMode === 'child') { // If focus is already on a focusable child within the cell, early return so we don't shift focus // We may have non-grid cell contained interactive elements but that live next to a grid cell - if (ref.current.parentElement.contains(document.activeElement) && ref.current !== document.activeElement) { + if (ref.current.contains(document.activeElement) && ref.current !== document.activeElement) { return; } diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 29fd53ee4e3..bcbaa81ed68 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -11,9 +11,9 @@ */ import {ColumnResizeState} from '@react-stately/table'; -import {focusSafely, useFocusable} from '@react-aria/focus'; +import {focusSafely} from '@react-aria/focus'; import {GridNode} from '@react-types/grid'; -import {HTMLAttributes, RefObject, useEffect, useRef} from 'react'; +import {HTMLAttributes, RefObject, useRef} from 'react'; import {mergeProps} from '@react-aria/utils'; import {useKeyboard, useMove} from '@react-aria/interactions'; import {useLocale} from '@react-aria/i18n'; @@ -24,18 +24,20 @@ interface ResizerAria { interface ResizerProps { column: GridNode, + tableRef: RefObject, + showResizer: boolean, + onResizeDone: () => void, label: string } export function useTableColumnResize(props: ResizerProps, state: ColumnResizeState, ref: RefObject): ResizerAria { - let {column: item} = props; + let {column: item, showResizer, onResizeDone} = 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') { @@ -44,7 +46,7 @@ export function useTableColumnResize(props: ResizerProps, state: ColumnRes } 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; + const columnHeader = ref.current.parentElement as HTMLElement; if (columnHeader) { focusSafely(columnHeader); } @@ -98,7 +100,17 @@ export function useTableColumnResize(props: ResizerProps, state: ColumnRes return { resizerProps: { - ...mergeProps(moveProps, focusableProps, keyboardProps, ariaProps) + ...mergeProps( + moveProps, + { + onBlur: () => { + onResizeDone(); + }, + tabIndex: showResizer ? 0 : undefined + }, + keyboardProps, + ariaProps + ) } }; } diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index 39fa7933b3c..f333d64d765 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -1,16 +1,23 @@ /* eslint-disable jsx-a11y/role-supports-aria-props */ import {classNames} from '@react-spectrum/utils'; +import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; -import React from 'react'; +import React, {RefObject} from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; import {useMessageFormatter} from '@react-aria/i18n'; import {useTableColumnResize} from '@react-aria/table'; import {useTableContext} from './TableView'; +interface ResizerProps { + column: GridNode, + tableRef: RefObject, + showResizer: boolean, + onResizeDone: () => void +} -function Resizer(props, ref) { - let {column, tableRef} = props; +function Resizer(props: ResizerProps, ref: RefObject) { + let {column, tableRef, showResizer} = props; let {columnState} = useTableContext(); let formatMessage = useMessageFormatter(intlMessages); @@ -18,10 +25,12 @@ function Resizer(props, ref) { let style = { cursor: undefined, - height: '100%' + height: '100%', + display: showResizer ? 'block' : 'none' }; - if (columnState.isResizingColumn && columnState.getResizingColumn() === column.key && tableRef.current) { - style.height = tableRef.current.offsetHeight; + // always be 100% height? never? only while dragging? only while focused? + if (showResizer && tableRef.current) { + style.height = `${tableRef.current.offsetHeight}px`; } if (columnState.getColumnMinWidth(column.key) >= columnState.getColumnWidth(column.key)) { style.cursor = 'e-resize'; diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 343ff429018..4a265d84147 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -460,7 +460,7 @@ function TableHeader({children, ...otherProps}) { function TableColumnHeader(props) { let {column} = props; let ref = useRef(); - let {state} = useTableContext(); + let {state, columnState} = useTableContext(); let {columnHeaderProps} = useTableColumnHeader({ node: column, isVirtualized: true @@ -474,12 +474,15 @@ function TableColumnHeader(props) { 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({}); + let {hoverProps, isHovered} = useHover(props); 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}; } + if (columnState.isResizingColumn) { + delete columnHeaderProps.onKeyDownCapture; // see CardView where we also delete onKeyDownCapture + } const allProps = [columnHeaderProps, hoverProps]; @@ -517,6 +520,7 @@ function TableColumnHeader(props) { {columnProps.allowsSorting && } + {props.children}
); @@ -527,19 +531,24 @@ function ResizableTableColumnHeader(props) { let ref = useRef(); let {state} = useTableContext(); let formatMessage = useMessageFormatter(intlMessages); + let [isResizingAvailable, setIsResizingAvailable] = useState(false); + let [isHovered, setIsHovered] = useState(false); const onMenuSelect = (key) => { switch (key) { case 'sort-asc': state.sort(column.key, 'ascending'); + setIsResizingAvailable(false); break; case 'sort-desc': state.sort(column.key, 'descending'); + setIsResizingAvailable(false); break; case 'resize': - // focusResizer, needs timeout so that it happens after the animation timeout for menu close focusSafely(ref.current); break; + default: + setIsResizingAvailable(false); } }; let allowsSorting = column.props?.allowsSorting; @@ -568,8 +577,10 @@ function ResizableTableColumnHeader(props) { return ( <> - - + {if (e) {setIsResizingAvailable(true);}}}> + + setIsResizingAvailable(false)} /> + {(item) => ( @@ -578,7 +589,6 @@ function ResizableTableColumnHeader(props) { )} - ); } From afb0e62a32e5510e931af5c1a0761b7e68582395 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 16 Jun 2022 14:37:40 -0700 Subject: [PATCH 03/14] fix types and RTL --- .../table/src/useTableColumnResize.ts | 8 ++++---- .../@react-spectrum/table/intl/ar-AE.json | 3 ++- .../@react-spectrum/table/src/Resizer.tsx | 7 ++++--- .../@react-spectrum/table/src/TableView.tsx | 5 +++-- .../table/src/useTableColumnResizeState.ts | 19 +++++++++++-------- 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index bcbaa81ed68..87c7d332e99 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -57,7 +57,7 @@ export function useTableColumnResize(props: ResizerProps, state: ColumnRes const columnResizeWidthRef = useRef(null); const {moveProps} = useMove({ onMoveStart() { - stateRef.current.onColumnResizeStart(item.key); + stateRef.current.onColumnResizeStart(item); columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key); cursor.current = document.body.style.cursor; }, @@ -73,16 +73,16 @@ 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', 'e-resize'); + document.body.style.setProperty('cursor', direction === 'rtl' ? 'e-resize' : 'w-resize'); } else if (stateRef.current.getColumnMaxWidth(item.key) <= stateRef.current.getColumnWidth(item.key)) { - document.body.style.setProperty('cursor', 'w-resize'); + document.body.style.setProperty('cursor', direction === 'rtl' ? 'w-resize' : 'e-resize'); } else { document.body.style.setProperty('cursor', 'col-resize'); } } }, onMoveEnd() { - stateRef.current.onColumnResizeEnd(item.key); + stateRef.current.onColumnResizeEnd(item); columnResizeWidthRef.current = 0; document.body.style.cursor = cursor.current; } 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/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index f333d64d765..f5fbcf8ef10 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -5,7 +5,7 @@ import {GridNode} from '@react-types/grid'; import intlMessages from '../intl/*.json'; import React, {RefObject} from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; -import {useMessageFormatter} from '@react-aria/i18n'; +import {useLocale, useMessageFormatter} from '@react-aria/i18n'; import {useTableColumnResize} from '@react-aria/table'; import {useTableContext} from './TableView'; @@ -20,6 +20,7 @@ function Resizer(props: ResizerProps, ref: RefObject) { let {column, tableRef, showResizer} = props; let {columnState} = useTableContext(); let formatMessage = useMessageFormatter(intlMessages); + let {direction} = useLocale(); let {resizerProps} = useTableColumnResize({...props, label: formatMessage('columnResizer')}, columnState, ref); @@ -33,9 +34,9 @@ function Resizer(props: ResizerProps, ref: RefObject) { style.height = `${tableRef.current.offsetHeight}px`; } if (columnState.getColumnMinWidth(column.key) >= columnState.getColumnWidth(column.key)) { - style.cursor = 'e-resize'; + style.cursor = direction === 'rtl' ? 'e-resize' : 'w-resize'; } else if (columnState.getColumnMaxWidth(column.key) <= columnState.getColumnWidth(column.key)) { - style.cursor = 'w-resize'; + style.cursor = direction === 'rtl' ? 'w-resize' : 'e-resize'; } else { style.cursor = 'col-resize'; } diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 4a265d84147..927259e2e87 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -529,10 +529,11 @@ function TableColumnHeader(props) { function ResizableTableColumnHeader(props) { let {column} = props; let ref = useRef(); - let {state} = useTableContext(); + let {state, columnState} = useTableContext(); let formatMessage = useMessageFormatter(intlMessages); let [isResizingAvailable, setIsResizingAvailable] = useState(false); let [isHovered, setIsHovered] = useState(false); + let isResizing = columnState.getResizingColumn()?.key === column.key; const onMenuSelect = (key) => { switch (key) { @@ -579,7 +580,7 @@ function ResizableTableColumnHeader(props) { <> {if (e) {setIsResizingAvailable(true);}}}> - setIsResizingAvailable(false)} /> + setIsResizingAvailable(false)} /> {(item) => ( diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index cc2d57284a6..8b3167a11d6 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -17,20 +17,22 @@ 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, + onColumnResizeStart: (key: GridNode) => void, /** Callback for when onColumnResize has ended. */ - onColumnResizeEnd: () => void, + onColumnResizeEnd: (key: GridNode) => void, /** Getter for column width. */ - getColumnWidth(key: Key): number, + getColumnWidth: (key: Key) => number, /** Getter for column min width. */ - getColumnMinWidth(key: Key): number, + getColumnMinWidth: (key: Key) => number, /** Getter for column max widths. */ - getColumnMaxWidth(key: Key): number, + getColumnMaxWidth: (key: Key) => number, /** Boolean for if a column is being resized. */ - isResizingColumn: boolean + isResizingColumn: boolean, + /** Node of column currently being resized. */ + getResizingColumn: () => GridNode } export interface ColumnResizeStateProps { @@ -59,7 +61,7 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps): const [resizedColumns, setResizedColumns] = useState>(new Set()); const resizedColumnsRef = useRef>(resizedColumns); - const currentlyResizingColumn = useRef(null); + const currentlyResizingColumn = useRef>(null); function setColumnWidthsForRef(newWidths: Map) { columnWidthsRef.current = newWidths; @@ -136,6 +138,7 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps): props.onColumnResize && props.onColumnResize(affectedColumnWidthsRef.current); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars function onColumnResizeEnd(column: GridNode) { currentlyResizingColumn.current = null; isResizing.current = false; From cce3703335feefcf2a69537cd66b020135f8fdd4 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 27 Jun 2022 12:26:18 -0700 Subject: [PATCH 04/14] disable grid navigation when resizing --- packages/@react-aria/grid/src/useGrid.ts | 3 +- packages/@react-aria/grid/src/useGridCell.ts | 7 +- .../selection/src/useSelectableCollection.ts | 35 ++-- .../table/src/useTableColumnHeader.ts | 2 +- .../table/src/useTableColumnResize.ts | 12 +- .../@react-spectrum/table/src/Resizer.tsx | 7 +- .../@react-spectrum/table/src/TableView.tsx | 42 ++--- .../table/test/TableSizing.test.js | 159 ++++++++++++++++++ .../@react-stately/grid/src/useGridState.ts | 4 +- .../table/src/useTableColumnResizeState.ts | 13 +- .../@react-stately/table/src/useTableState.ts | 9 +- .../test/useTableColumnResizeState.test.ts | 25 +-- 12 files changed, 251 insertions(+), 67 deletions(-) diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index d5ca5f90dc6..a45bfc04499 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -95,7 +95,8 @@ export function useGrid(props: GridProps, state: GridState>(props: GridCellProps let treeWalker = getFocusableTreeWalker(ref.current); if (focusMode === 'child') { // If focus is already on a focusable child within the cell, early return so we don't shift focus - // We may have non-grid cell contained interactive elements but that live next to a grid cell if (ref.current.contains(document.activeElement) && ref.current !== document.activeElement) { return; } @@ -97,8 +96,8 @@ export function useGridCell>(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.disableNavigation) { return; } @@ -226,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/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index c78b28b5be6..6f099a9ea8c 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -78,7 +78,8 @@ interface SelectableCollectionOptions { * The ref attached to the scrollable body. Used to provide automatic scrolling on item focus for non-virtualized collections. * If not provided, defaults to the collection ref. */ - scrollRef?: RefObject + scrollRef?: RefObject, + disableNavigation?: boolean } interface SelectableCollectionAria { @@ -104,7 +105,8 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S allowsTabNavigation = false, isVirtualized, // If no scrollRef is provided, assume the collection ref is the scrollable region - scrollRef = ref + scrollRef = ref, + disableNavigation } = options; let {direction} = useLocale(); @@ -135,7 +137,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S switch (e.key) { case 'ArrowDown': { - if (delegate.getKeyBelow) { + if (delegate.getKeyBelow && !disableNavigation) { e.preventDefault(); let nextKey = manager.focusedKey != null ? delegate.getKeyBelow(manager.focusedKey) @@ -148,7 +150,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'ArrowUp': { - if (delegate.getKeyAbove) { + if (delegate.getKeyAbove && !disableNavigation) { e.preventDefault(); let nextKey = manager.focusedKey != null ? delegate.getKeyAbove(manager.focusedKey) @@ -161,7 +163,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'ArrowLeft': { - if (delegate.getKeyLeftOf) { + if (delegate.getKeyLeftOf && !disableNavigation) { e.preventDefault(); let nextKey = delegate.getKeyLeftOf(manager.focusedKey); navigateToKey(nextKey, direction === 'rtl' ? 'first' : 'last'); @@ -169,7 +171,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'ArrowRight': { - if (delegate.getKeyRightOf) { + if (delegate.getKeyRightOf && !disableNavigation) { e.preventDefault(); let nextKey = delegate.getKeyRightOf(manager.focusedKey); navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first'); @@ -177,7 +179,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'Home': - if (delegate.getFirstKey) { + if (delegate.getFirstKey && !disableNavigation) { e.preventDefault(); let firstKey = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e)); manager.setFocusedKey(firstKey); @@ -189,7 +191,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S } break; case 'End': - if (delegate.getLastKey) { + if (delegate.getLastKey && !disableNavigation) { e.preventDefault(); let lastKey = delegate.getLastKey(manager.focusedKey, isCtrlKeyPressed(e)); manager.setFocusedKey(lastKey); @@ -201,32 +203,37 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S } break; case 'PageDown': - if (delegate.getKeyPageBelow) { + if (delegate.getKeyPageBelow && !disableNavigation) { e.preventDefault(); let nextKey = delegate.getKeyPageBelow(manager.focusedKey); navigateToKey(nextKey); } break; case 'PageUp': - if (delegate.getKeyPageAbove) { + if (delegate.getKeyPageAbove && !disableNavigation) { e.preventDefault(); let nextKey = delegate.getKeyPageAbove(manager.focusedKey); navigateToKey(nextKey); } break; case 'a': - if (isCtrlKeyPressed(e) && manager.selectionMode === 'multiple' && disallowSelectAll !== true) { + // disabled navigation also means disabling selection i think, otherwise trying to type 'a' into a textfield would be disastrous + if (isCtrlKeyPressed(e) && manager.selectionMode === 'multiple' && disallowSelectAll !== true && !disableNavigation) { e.preventDefault(); manager.selectAll(); } break; case 'Escape': - e.preventDefault(); - if (!disallowEmptySelection) { - manager.clearSelection(); + // disabled navigation also means disabling selection i think, similar reason to case 'a' but trying to exit out of a resizer + if (!disableNavigation) { + e.preventDefault(); + if (!disallowEmptySelection) { + manager.clearSelection(); + } } break; case 'Tab': { + // the one thing that disabledNavigation probably shouldn't affect, the tab key if (!allowsTabNavigation) { // There may be elements that are "tabbable" inside a collection (e.g. in a grid cell). // However, collections should be treated as a single tab stop, with arrow key navigation internally. diff --git a/packages/@react-aria/table/src/useTableColumnHeader.ts b/packages/@react-aria/table/src/useTableColumnHeader.ts index 358d78d2793..36265e7ddea 100644 --- a/packages/@react-aria/table/src/useTableColumnHeader.ts +++ b/packages/@react-aria/table/src/useTableColumnHeader.ts @@ -44,7 +44,7 @@ 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); + let {gridCellProps} = useGridCell({...props, focusMode: node.props.isSelectionCell ? 'child' : 'cell'}, state, ref); let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single'; let {pressProps} = usePress({ diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 87c7d332e99..511a5c505e1 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {ColumnResizeState} from '@react-stately/table'; +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'; @@ -26,12 +26,13 @@ interface ResizerProps { column: GridNode, tableRef: RefObject, showResizer: boolean, + onResizeEntered: () => void, onResizeDone: () => void, label: string } -export function useTableColumnResize(props: ResizerProps, state: ColumnResizeState, ref: RefObject): ResizerAria { - let {column: item, showResizer, onResizeDone} = props; +export function useTableColumnResize(props: ResizerProps, state: TableState & ColumnResizeState, ref: RefObject): ResizerAria { + let {column: item, showResizer, onResizeEntered, onResizeDone} = 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); @@ -103,7 +104,12 @@ export function useTableColumnResize(props: ResizerProps, state: ColumnRes ...mergeProps( moveProps, { + onFocus: () => { + state.setDisableNavigation(true); + onResizeEntered(); + }, onBlur: () => { + state.setDisableNavigation(false); onResizeDone(); }, tabIndex: showResizer ? 0 : undefined diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index f5fbcf8ef10..5a353839b4c 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -13,16 +13,17 @@ interface ResizerProps { column: GridNode, tableRef: RefObject, showResizer: boolean, - onResizeDone: () => void + onResizeDone: () => void, + onResizeEntered: () => void } function Resizer(props: ResizerProps, ref: RefObject) { let {column, tableRef, showResizer} = props; - let {columnState} = useTableContext(); + let {state, columnState} = useTableContext(); let formatMessage = useMessageFormatter(intlMessages); let {direction} = useLocale(); - let {resizerProps} = useTableColumnResize({...props, label: formatMessage('columnResizer')}, columnState, ref); + let {resizerProps} = useTableColumnResize({...props, label: formatMessage('columnResizer')}, {...state, ...columnState}, ref); let style = { cursor: undefined, diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 927259e2e87..994f07e4412 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -113,7 +113,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef { - 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 = isResizingAvailable || (isHovered && !columnState.isResizingColumn) || isResizing; return ( <> - {if (e) {setIsResizingAvailable(true);}}}> + setIsResizingAvailable(e)}> - setIsResizingAvailable(false)} /> + setIsResizingAvailable(true)} + onResizeDone={() => setIsResizingAvailable(false)} /> {(item) => ( diff --git a/packages/@react-spectrum/table/test/TableSizing.test.js b/packages/@react-spectrum/table/test/TableSizing.test.js index 90daf92e8bb..4af22e1e035 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,164 @@ describe('TableViewSizing', function () { }); }); + describe('resizing columns', function () { + describe('pointer', () => { + installPointerEvent(); + + it('dragging the resizer works', () => { + let tree = render( + + + Foo + Bar + Baz + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + // trigger pointer modality + fireEvent.pointerMove(tree.container); + // TODO verify that separator isn't available + // expect(tree.getAllByRole('separator')).toHaveLength(); + + 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('button')[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.getByRole('separator')).not.toBeVisible(); + }); + }); + + describe('keyboard', () => { + // this test does not work yet, for some reason the blur created by moving focus to the resizer isn't closing the + // the menutrigger like it does in real life + it.skip('arrow keys the resizer works', 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('button')[0]; + expect(document.activeElement).toBe(resizableHeader); + // TODO verify that separator isn't available + // expect(tree.getAllByRole('separator')).toHaveLength(); + + 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('595px'); + expect(row.childNodes[1].style.width).toBe('200px'); + expect(row.childNodes[2].style.width).toBe('200px'); + } + + 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('620px'); + expect(row.childNodes[1].style.width).toBe('190px'); + expect(row.childNodes[2].style.width).toBe('190px'); + } + + fireEvent.keyDown(document.activeElement, {key: 'Escape'}); + fireEvent.keyUp(document.activeElement, {key: 'Escape'}); + + expect(document.activeElement).toBe(resizableHeader); + + // expect(tree.getByRole('separator')).not.toBeVisible(); + }); + }); + }); + 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..030f52a799c 100644 --- a/packages/@react-stately/grid/src/useGridState.ts +++ b/packages/@react-stately/grid/src/useGridState.ts @@ -7,7 +7,8 @@ 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, + disableNavigation: boolean } interface GridStateOptions> extends MultipleSelectionStateProps { @@ -54,6 +55,7 @@ export function useGridState>(prop return { collection, disabledKeys, + disableNavigation: 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 8b3167a11d6..099c22040bc 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -35,9 +35,7 @@ export interface ColumnResizeState { getResizingColumn: () => GridNode } -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. */ @@ -48,8 +46,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); diff --git a/packages/@react-stately/table/src/useTableState.ts b/packages/@react-stately/table/src/useTableState.ts index 7255637e72a..4dafecc0e44 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,9 @@ 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, + disableNavigation: boolean, + setDisableNavigation: (val: boolean) => void } export interface CollectionBuilderContext { @@ -50,6 +52,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 [disableNavigation, setDisableNavigation] = useState(false); let {selectionMode = 'none'} = props; let context = useMemo(() => ({ @@ -71,6 +74,8 @@ export function useTableState(props: TableStateProps): Tabl selectionManager, showSelectionCheckboxes: props.showSelectionCheckboxes || false, sortDescriptor: props.sortDescriptor, + disableNavigation, + setDisableNavigation, 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]])); }); From 39683e0659853c3ea8cf786c52d951a8b40dad3b Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 28 Jun 2022 11:28:01 -0700 Subject: [PATCH 05/14] fix color declarations --- .../components/table/index.css | 2 -- .../components/table/skin.css | 19 ++++++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/@adobe/spectrum-css-temp/components/table/index.css b/packages/@adobe/spectrum-css-temp/components/table/index.css index 441a608e532..f2abddad803 100644 --- a/packages/@adobe/spectrum-css-temp/components/table/index.css +++ b/packages/@adobe/spectrum-css-temp/components/table/index.css @@ -106,7 +106,6 @@ svg.spectrum-Table-sortedIcon { box-sizing: border-box; inline-size: 1px; block-size: 100%; - background-color: var(--spectrum-table-divider-border-color); } &:active, @@ -114,7 +113,6 @@ svg.spectrum-Table-sortedIcon { 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); + } + } +} From 5ec11a3c27fc48870219dd3522a4082d90c41e1a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 28 Jun 2022 13:47:26 -0700 Subject: [PATCH 06/14] need help debugging --- packages/@react-aria/focus/src/FocusScope.tsx | 2 ++ packages/@react-aria/selection/src/useSelectableCollection.ts | 2 ++ packages/@react-aria/selection/src/useSelectableItem.ts | 1 + packages/@react-aria/table/src/useTableColumnResize.ts | 4 +++- packages/@react-spectrum/menu/src/MenuTrigger.tsx | 1 + packages/@react-spectrum/table/test/TableSizing.test.js | 2 +- 6 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 99449471931..5325dcbe688 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -289,6 +289,7 @@ function useFocusContainment(scopeRef: RefObject, contain: boolea raf.current = requestAnimationFrame(() => { // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe if (scopeRef === activeScope && !isElementInChildScope(document.activeElement, scopeRef)) { + console.log('we are containing focus') activeScope = scopeRef; focusedNode.current = e.target; focusedNode.current.focus(); @@ -463,6 +464,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus: boole if (restoreFocus && nodeToRestore && isElementInScope(document.activeElement, scopeRef.current)) { requestAnimationFrame(() => { if (document.body.contains(nodeToRestore)) { + console.log('we are restoring focus') focusElement(nodeToRestore); } }); diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 6f099a9ea8c..c3e64746944 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -316,6 +316,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S // Refocus and scroll the focused item into view if it exists within the scrollable region. let element = scrollRef.current.querySelector(`[data-key="${manager.focusedKey}"]`) as HTMLElement; if (element) { + console.log('selectable collection does not like losing focus') // This prevents a flash of focus on the first/last element in the collection focusWithoutScrolling(element); scrollIntoView(scrollRef.current, element); @@ -353,6 +354,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S // If no default focus key is selected, focus the collection itself. if (focusedKey == null && !shouldUseVirtualFocus) { + console.log('auto focusing') focusSafely(ref.current); } } diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index 388db79414f..9541b71a7bd 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -138,6 +138,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte useEffect(() => { let isFocused = key === manager.focusedKey; if (isFocused && manager.isFocused && !shouldUseVirtualFocus && document.activeElement !== ref.current) { + console.log('selectable item does not like losing focus') if (focus) { focus(); } else { diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 511a5c505e1..5a6b57cf014 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -105,10 +105,12 @@ export function useTableColumnResize(props: ResizerProps, state: TableStat moveProps, { onFocus: () => { + console.log('resizer got focus') state.setDisableNavigation(true); onResizeEntered(); }, - onBlur: () => { + onBlur: (e) => { + console.log('resizer blurred for ', e.relatedTarget) state.setDisableNavigation(false); onResizeDone(); }, diff --git a/packages/@react-spectrum/menu/src/MenuTrigger.tsx b/packages/@react-spectrum/menu/src/MenuTrigger.tsx index f935517900c..4deb776b88b 100644 --- a/packages/@react-spectrum/menu/src/MenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/MenuTrigger.tsx @@ -93,6 +93,7 @@ function MenuTrigger(props: SpectrumMenuTriggerProps, ref: DOMRef) // On small screen devices, the menu is rendered in a tray, otherwise a popover. let overlay; if (isMobile) { + console.log('mobile') overlay = ( {contents} diff --git a/packages/@react-spectrum/table/test/TableSizing.test.js b/packages/@react-spectrum/table/test/TableSizing.test.js index 4af22e1e035..e8bb5cee1e2 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.js +++ b/packages/@react-spectrum/table/test/TableSizing.test.js @@ -700,7 +700,7 @@ describe('TableViewSizing', function () { describe('keyboard', () => { // this test does not work yet, for some reason the blur created by moving focus to the resizer isn't closing the // the menutrigger like it does in real life - it.skip('arrow keys the resizer works', async () => { + it.only('arrow keys the resizer works', async () => { let tree = render( From 0bc8e367a40715b8f20b38aecf1fdf2ab6561510 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 28 Jun 2022 16:30:03 -0700 Subject: [PATCH 07/14] Fix keyboard and touch focusing --- packages/@react-aria/focus/src/FocusScope.tsx | 2 -- .../selection/src/useSelectableCollection.ts | 2 -- .../selection/src/useSelectableItem.ts | 1 - .../table/src/useTableColumnResize.ts | 4 +-- packages/@react-spectrum/menu/src/Menu.tsx | 9 ++++- .../@react-spectrum/menu/src/MenuTrigger.tsx | 1 - .../@react-spectrum/table/src/Resizer.tsx | 6 +--- .../@react-spectrum/table/src/TableView.tsx | 21 +++++++++--- .../table/test/TableSizing.test.js | 33 ++++++++++--------- packages/@react-types/menu/src/index.d.ts | 5 ++- 10 files changed, 49 insertions(+), 35 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 5325dcbe688..99449471931 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -289,7 +289,6 @@ function useFocusContainment(scopeRef: RefObject, contain: boolea raf.current = requestAnimationFrame(() => { // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe if (scopeRef === activeScope && !isElementInChildScope(document.activeElement, scopeRef)) { - console.log('we are containing focus') activeScope = scopeRef; focusedNode.current = e.target; focusedNode.current.focus(); @@ -464,7 +463,6 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus: boole if (restoreFocus && nodeToRestore && isElementInScope(document.activeElement, scopeRef.current)) { requestAnimationFrame(() => { if (document.body.contains(nodeToRestore)) { - console.log('we are restoring focus') focusElement(nodeToRestore); } }); diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index c3e64746944..6f099a9ea8c 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -316,7 +316,6 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S // Refocus and scroll the focused item into view if it exists within the scrollable region. let element = scrollRef.current.querySelector(`[data-key="${manager.focusedKey}"]`) as HTMLElement; if (element) { - console.log('selectable collection does not like losing focus') // This prevents a flash of focus on the first/last element in the collection focusWithoutScrolling(element); scrollIntoView(scrollRef.current, element); @@ -354,7 +353,6 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S // If no default focus key is selected, focus the collection itself. if (focusedKey == null && !shouldUseVirtualFocus) { - console.log('auto focusing') focusSafely(ref.current); } } diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index 9541b71a7bd..388db79414f 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -138,7 +138,6 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte useEffect(() => { let isFocused = key === manager.focusedKey; if (isFocused && manager.isFocused && !shouldUseVirtualFocus && document.activeElement !== ref.current) { - console.log('selectable item does not like losing focus') if (focus) { focus(); } else { diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 5a6b57cf014..511a5c505e1 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -105,12 +105,10 @@ export function useTableColumnResize(props: ResizerProps, state: TableStat moveProps, { onFocus: () => { - console.log('resizer got focus') state.setDisableNavigation(true); onResizeEntered(); }, - onBlur: (e) => { - console.log('resizer blurred for ', e.relatedTarget) + onBlur: () => { state.setDisableNavigation(false); onResizeDone(); }, diff --git a/packages/@react-spectrum/menu/src/Menu.tsx b/packages/@react-spectrum/menu/src/Menu.tsx index 443abf57d70..5a2eacd40ec 100644 --- a/packages/@react-spectrum/menu/src/Menu.tsx +++ b/packages/@react-spectrum/menu/src/Menu.tsx @@ -16,7 +16,7 @@ import {MenuContext} from './context'; import {MenuItem} from './MenuItem'; import {MenuSection} from './MenuSection'; import {mergeProps, useSyncRef} from '@react-aria/utils'; -import React, {ReactElement, useContext} from 'react'; +import React, {ReactElement, useContext, useEffect} from 'react'; import {SpectrumMenuProps} from '@react-types/menu'; import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; import {useMenu} from '@react-aria/menu'; @@ -34,6 +34,13 @@ function Menu(props: SpectrumMenuProps, ref: DOMRef { + return () => { + props.onUnmount?.(); + }; + }, []); + return (
    ) // On small screen devices, the menu is rendered in a tray, otherwise a popover. let overlay; if (isMobile) { - console.log('mobile') overlay = ( {contents} diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index 5a353839b4c..01287ea0607 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -18,7 +18,7 @@ interface ResizerProps { } function Resizer(props: ResizerProps, ref: RefObject) { - let {column, tableRef, showResizer} = props; + let {column, showResizer} = props; let {state, columnState} = useTableContext(); let formatMessage = useMessageFormatter(intlMessages); let {direction} = useLocale(); @@ -30,10 +30,6 @@ function Resizer(props: ResizerProps, ref: RefObject) { height: '100%', display: showResizer ? 'block' : 'none' }; - // always be 100% height? never? only while dragging? only while focused? - if (showResizer && tableRef.current) { - style.height = `${tableRef.current.offsetHeight}px`; - } if (columnState.getColumnMinWidth(column.key) >= columnState.getColumnWidth(column.key)) { style.cursor = direction === 'rtl' ? 'e-resize' : 'w-resize'; } else if (columnState.getColumnMaxWidth(column.key) <= columnState.getColumnWidth(column.key)) { diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 994f07e4412..ad13a44fa0c 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -422,7 +422,7 @@ function TableVirtualizer({layout, collection, focusedKey, renderView, renderWra style={{ width: visibleRect.width, height: headerHeight, - overflow: 'visible', + overflow: 'hidden', position: 'relative', willChange: state.isScrolling ? 'scroll-position' : '', transition: state.isAnimating ? `none ${state.virtualizer.transitionDuration}ms` : undefined @@ -529,6 +529,7 @@ function ResizableTableColumnHeader(props) { let {state, columnState} = useTableContext(); let formatMessage = useMessageFormatter(intlMessages); let [isResizingAvailable, setIsResizingAvailable] = useState(false); + let shouldMoveFocus = useRef(false); let [isHovered, setIsHovered] = useState(false); let isResizing = columnState.getResizingColumn()?.key === column.key; @@ -543,7 +544,7 @@ function ResizableTableColumnHeader(props) { setIsResizingAvailable(false); break; case 'resize': - focusSafely(ref.current); + shouldMoveFocus.current = true; break; default: setIsResizingAvailable(false); @@ -568,7 +569,19 @@ function ResizableTableColumnHeader(props) { return options; }, [allowsSorting]); // if we're resizing another column, then hover shouldn't show the resizer of a different column - let showResizer = isResizingAvailable || (isHovered && !columnState.isResizingColumn) || isResizing; + let showResizer = isResizingAvailable || (isHovered && !columnState.isResizingColumn) || isResizing || shouldMoveFocus.current; + + // discuss with devon how to get around FocusScope's contain (prevents us from leaving focus scope until unmount) + restore raf (which is why there are two here) + let onUnmount = useCallback(() => { + if (shouldMoveFocus.current) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + focusSafely(ref.current); + shouldMoveFocus.current = false; + }); + }); + } + }, [ref, shouldMoveFocus]); return ( <> @@ -582,7 +595,7 @@ function ResizableTableColumnHeader(props) { onResizeEntered={() => setIsResizingAvailable(true)} onResizeDone={() => setIsResizingAvailable(false)} /> - + {(item) => ( {item.label} diff --git a/packages/@react-spectrum/table/test/TableSizing.test.js b/packages/@react-spectrum/table/test/TableSizing.test.js index e8bb5cee1e2..5fe56d9268d 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.js +++ b/packages/@react-spectrum/table/test/TableSizing.test.js @@ -626,6 +626,9 @@ describe('TableViewSizing', function () { describe('resizing columns', function () { describe('pointer', () => { installPointerEvent(); + beforeEach(() => { + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); + }); it('dragging the resizer works', () => { let tree = render( @@ -647,8 +650,7 @@ describe('TableViewSizing', function () { // trigger pointer modality fireEvent.pointerMove(tree.container); - // TODO verify that separator isn't available - // expect(tree.getAllByRole('separator')).toHaveLength(); + expect(tree.queryByRole('separator')).toBeNull(); let rows = tree.getAllByRole('row'); @@ -693,14 +695,16 @@ describe('TableViewSizing', function () { fireEvent.pointerLeave(resizer, {pointerType: 'mouse', pointerId: 1}); fireEvent.pointerLeave(resizableHeader, {pointerType: 'mouse', pointerId: 1}); - // expect(tree.getByRole('separator')).not.toBeVisible(); + expect(tree.queryByRole('separator')).toBeNull(); }); }); describe('keyboard', () => { - // this test does not work yet, for some reason the blur created by moving focus to the resizer isn't closing the - // the menutrigger like it does in real life - it.only('arrow keys the resizer works', async () => { + beforeEach(() => { + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); + }); + + it('arrow keys the resizer works', async () => { let tree = render( @@ -724,8 +728,7 @@ describe('TableViewSizing', function () { let resizableHeader = tree.getAllByRole('button')[0]; expect(document.activeElement).toBe(resizableHeader); - // TODO verify that separator isn't available - // expect(tree.getAllByRole('separator')).toHaveLength(); + expect(tree.queryByRole('separator')).toBeNull(); let rows = tree.getAllByRole('row'); @@ -754,9 +757,9 @@ describe('TableViewSizing', function () { 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'); + 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'}); @@ -766,9 +769,9 @@ describe('TableViewSizing', function () { 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'); + 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'}); @@ -776,7 +779,7 @@ describe('TableViewSizing', function () { expect(document.activeElement).toBe(resizableHeader); - // expect(tree.getByRole('separator')).not.toBeVisible(); + expect(tree.queryByRole('separator')).toBeNull(); }); }); }); diff --git a/packages/@react-types/menu/src/index.d.ts b/packages/@react-types/menu/src/index.d.ts index 06c2e4a6149..ed6ee7cc773 100644 --- a/packages/@react-types/menu/src/index.d.ts +++ b/packages/@react-types/menu/src/index.d.ts @@ -61,7 +61,10 @@ export interface MenuProps extends CollectionBase, MultipleSelection { } export interface AriaMenuProps extends MenuProps, DOMProps, AriaLabelingProps {} -export interface SpectrumMenuProps extends AriaMenuProps, StyleProps {} +export interface SpectrumMenuProps extends AriaMenuProps, StyleProps { + /** Called when the component is unmounted, useful because Menu has a delayed animated removal. */ + onUnmount?: () => void +} export interface SpectrumActionMenuProps extends CollectionBase, MenuTriggerProps, StyleProps, DOMProps, AriaLabelingProps { /** From 1c371c4af787b73344544954138bbb123053bbd4 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 28 Jun 2022 16:38:57 -0700 Subject: [PATCH 08/14] remove dead code, review --- packages/@react-aria/table/src/useTableColumnHeader.ts | 1 + packages/@react-aria/table/src/useTableColumnResize.ts | 4 ++-- packages/@react-spectrum/table/src/Resizer.tsx | 4 ++-- packages/@react-spectrum/table/src/TableView.tsx | 5 +++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnHeader.ts b/packages/@react-aria/table/src/useTableColumnHeader.ts index 36265e7ddea..e5c7f18a1fb 100644 --- a/packages/@react-aria/table/src/useTableColumnHeader.ts +++ b/packages/@react-aria/table/src/useTableColumnHeader.ts @@ -44,6 +44,7 @@ export function useTableColumnHeader(props: ColumnHeaderProps, state: TableSt let {node} = props; let allowsResizing = node.props.allowsResizing; let allowsSorting = node.props.allowsSorting; + // 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 ? 'child' : 'cell'}, state, ref); let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single'; diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 511a5c505e1..ed0c5965905 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -74,9 +74,9 @@ export function useTableColumnResize(props: ResizerProps, state: TableStat 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' ? 'e-resize' : 'w-resize'); - } else if (stateRef.current.getColumnMaxWidth(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'); } diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index 01287ea0607..443206f12a7 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -31,9 +31,9 @@ function Resizer(props: ResizerProps, ref: RefObject) { display: showResizer ? 'block' : 'none' }; if (columnState.getColumnMinWidth(column.key) >= columnState.getColumnWidth(column.key)) { - style.cursor = direction === 'rtl' ? 'e-resize' : 'w-resize'; - } else if (columnState.getColumnMaxWidth(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'; } diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index ad13a44fa0c..b697d680a1d 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -173,7 +173,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef + style={style}> {renderChildren(children)} ); @@ -545,6 +545,7 @@ function ResizableTableColumnHeader(props) { break; case 'resize': shouldMoveFocus.current = true; + setIsResizingAvailable(true); break; default: setIsResizingAvailable(false); @@ -585,7 +586,7 @@ function ResizableTableColumnHeader(props) { return ( <> - setIsResizingAvailable(e)}> + Date: Wed, 29 Jun 2022 18:11:05 -0700 Subject: [PATCH 09/14] Fix focus in mobile and touch --- packages/@react-aria/focus/src/FocusScope.tsx | 14 +- .../@react-aria/focus/test/FocusScope.test.js | 18 +- packages/@react-spectrum/menu/src/Menu.tsx | 9 +- .../@react-spectrum/menu/src/MenuTrigger.tsx | 4 +- .../@react-spectrum/table/src/TableView.tsx | 38 +-- .../table/test/TableSizing.test.js | 312 +++++++++++++++++- packages/@react-types/menu/src/index.d.ts | 5 +- 7 files changed, 352 insertions(+), 48 deletions(-) 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-spectrum/menu/src/Menu.tsx b/packages/@react-spectrum/menu/src/Menu.tsx index 5a2eacd40ec..443abf57d70 100644 --- a/packages/@react-spectrum/menu/src/Menu.tsx +++ b/packages/@react-spectrum/menu/src/Menu.tsx @@ -16,7 +16,7 @@ import {MenuContext} from './context'; import {MenuItem} from './MenuItem'; import {MenuSection} from './MenuSection'; import {mergeProps, useSyncRef} from '@react-aria/utils'; -import React, {ReactElement, useContext, useEffect} from 'react'; +import React, {ReactElement, useContext} from 'react'; import {SpectrumMenuProps} from '@react-types/menu'; import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; import {useMenu} from '@react-aria/menu'; @@ -34,13 +34,6 @@ function Menu(props: SpectrumMenuProps, ref: DOMRef { - return () => { - props.onUnmount?.(); - }; - }, []); - return (
      ) 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/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index b697d680a1d..51d48318e0e 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'; @@ -528,8 +528,7 @@ function ResizableTableColumnHeader(props) { let ref = useRef(); let {state, columnState} = useTableContext(); let formatMessage = useMessageFormatter(intlMessages); - let [isResizingAvailable, setIsResizingAvailable] = useState(false); - let shouldMoveFocus = useRef(false); + let [resizeMode, setResizeMode] = useState(false); let [isHovered, setIsHovered] = useState(false); let isResizing = columnState.getResizingColumn()?.key === column.key; @@ -537,18 +536,17 @@ function ResizableTableColumnHeader(props) { switch (key) { case 'sort-asc': state.sort(column.key, 'ascending'); - setIsResizingAvailable(false); + setResizeMode(false); break; case 'sort-desc': state.sort(column.key, 'descending'); - setIsResizingAvailable(false); + setResizeMode(false); break; case 'resize': - shouldMoveFocus.current = true; - setIsResizingAvailable(true); + setResizeMode(true); break; default: - setIsResizingAvailable(false); + setResizeMode(false); } }; let allowsSorting = column.props?.allowsSorting; @@ -570,19 +568,13 @@ function ResizableTableColumnHeader(props) { return options; }, [allowsSorting]); // if we're resizing another column, then hover shouldn't show the resizer of a different column - let showResizer = isResizingAvailable || (isHovered && !columnState.isResizingColumn) || isResizing || shouldMoveFocus.current; - - // discuss with devon how to get around FocusScope's contain (prevents us from leaving focus scope until unmount) + restore raf (which is why there are two here) - let onUnmount = useCallback(() => { - if (shouldMoveFocus.current) { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - focusSafely(ref.current); - shouldMoveFocus.current = false; - }); - }); + let showResizer = resizeMode || (isHovered && !columnState.isResizingColumn) || isResizing; + + useEffect(() => { + if (resizeMode) { + focusSafely(ref.current); } - }, [ref, shouldMoveFocus]); + }, [resizeMode]); return ( <> @@ -593,10 +585,10 @@ function ResizableTableColumnHeader(props) { tableRef={props.tableRef} column={column} showResizer={showResizer} - onResizeEntered={() => setIsResizingAvailable(true)} - onResizeDone={() => setIsResizingAvailable(false)} /> + onResizeEntered={() => setResizeMode(true)} + onResizeDone={() => setResizeMode(false)} /> - + {(item) => ( {item.label} diff --git a/packages/@react-spectrum/table/test/TableSizing.test.js b/packages/@react-spectrum/table/test/TableSizing.test.js index 5fe56d9268d..f51de75721d 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.js +++ b/packages/@react-spectrum/table/test/TableSizing.test.js @@ -626,11 +626,77 @@ describe('TableViewSizing', function () { describe('resizing columns', function () { describe('pointer', () => { installPointerEvent(); - beforeEach(() => { + + 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('button')[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', () => { + it('dragging the resizer works - mobile', () => { let tree = render( @@ -699,12 +765,248 @@ describe('TableViewSizing', function () { }); }); - describe('keyboard', () => { - beforeEach(() => { + 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('button')[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('button')[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(); }); + }); - it('arrow keys the resizer works', async () => { + 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('button')[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( diff --git a/packages/@react-types/menu/src/index.d.ts b/packages/@react-types/menu/src/index.d.ts index ed6ee7cc773..06c2e4a6149 100644 --- a/packages/@react-types/menu/src/index.d.ts +++ b/packages/@react-types/menu/src/index.d.ts @@ -61,10 +61,7 @@ export interface MenuProps extends CollectionBase, MultipleSelection { } export interface AriaMenuProps extends MenuProps, DOMProps, AriaLabelingProps {} -export interface SpectrumMenuProps extends AriaMenuProps, StyleProps { - /** Called when the component is unmounted, useful because Menu has a delayed animated removal. */ - onUnmount?: () => void -} +export interface SpectrumMenuProps extends AriaMenuProps, StyleProps {} export interface SpectrumActionMenuProps extends CollectionBase, MenuTriggerProps, StyleProps, DOMProps, AriaLabelingProps { /** From 8c66d5d2e5ebb10cab40fec026831f6d03d1a50b Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 1 Jul 2022 13:44:28 -0700 Subject: [PATCH 10/14] code review changes --- packages/@react-aria/grid/src/useGrid.ts | 2 +- packages/@react-aria/grid/src/useGridCell.ts | 2 +- .../selection/src/useSelectableCollection.ts | 31 ++++++++++-------- .../table/src/useTableColumnHeader.ts | 10 +++--- .../table/src/useTableColumnResize.ts | 32 +++++++++---------- packages/@react-spectrum/table/package.json | 1 - .../@react-spectrum/table/src/Resizer.tsx | 8 ++--- .../@react-spectrum/table/src/TableView.tsx | 31 ++++-------------- .../table/test/TableSizing.test.js | 12 +++---- .../@react-stately/grid/src/useGridState.ts | 5 +-- .../table/src/useTableColumnResizeState.ts | 25 +++++++-------- .../@react-stately/table/src/useTableState.ts | 12 ++++--- 12 files changed, 78 insertions(+), 93 deletions(-) diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index a45bfc04499..0eebca73d95 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -96,7 +96,7 @@ export function useGrid(props: GridProps, state: GridState>(props: GridCellProps }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!e.currentTarget.contains(e.target as HTMLElement) || state.disableNavigation) { + if (!e.currentTarget.contains(e.target as HTMLElement) || state.isKeyboardNavigationDisabled) { return; } diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 6f099a9ea8c..5a9aa0e0839 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -79,7 +79,8 @@ interface SelectableCollectionOptions { * If not provided, defaults to the collection ref. */ scrollRef?: RefObject, - disableNavigation?: boolean + /** Whether keyboard navigation is disabled, such as when you need to use the arrow keys to interact with an internal component. */ + isKeyboardNavigationDisabled?: boolean } interface SelectableCollectionAria { @@ -106,7 +107,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S isVirtualized, // If no scrollRef is provided, assume the collection ref is the scrollable region scrollRef = ref, - disableNavigation + isKeyboardNavigationDisabled } = options; let {direction} = useLocale(); @@ -137,7 +138,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S switch (e.key) { case 'ArrowDown': { - if (delegate.getKeyBelow && !disableNavigation) { + if (delegate.getKeyBelow && !isKeyboardNavigationDisabled) { e.preventDefault(); let nextKey = manager.focusedKey != null ? delegate.getKeyBelow(manager.focusedKey) @@ -150,7 +151,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'ArrowUp': { - if (delegate.getKeyAbove && !disableNavigation) { + if (delegate.getKeyAbove && !isKeyboardNavigationDisabled) { e.preventDefault(); let nextKey = manager.focusedKey != null ? delegate.getKeyAbove(manager.focusedKey) @@ -163,7 +164,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'ArrowLeft': { - if (delegate.getKeyLeftOf && !disableNavigation) { + if (delegate.getKeyLeftOf && !isKeyboardNavigationDisabled) { e.preventDefault(); let nextKey = delegate.getKeyLeftOf(manager.focusedKey); navigateToKey(nextKey, direction === 'rtl' ? 'first' : 'last'); @@ -171,7 +172,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'ArrowRight': { - if (delegate.getKeyRightOf && !disableNavigation) { + if (delegate.getKeyRightOf && !isKeyboardNavigationDisabled) { e.preventDefault(); let nextKey = delegate.getKeyRightOf(manager.focusedKey); navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first'); @@ -179,7 +180,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'Home': - if (delegate.getFirstKey && !disableNavigation) { + if (delegate.getFirstKey && !isKeyboardNavigationDisabled) { e.preventDefault(); let firstKey = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e)); manager.setFocusedKey(firstKey); @@ -191,7 +192,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S } break; case 'End': - if (delegate.getLastKey && !disableNavigation) { + if (delegate.getLastKey && !isKeyboardNavigationDisabled) { e.preventDefault(); let lastKey = delegate.getLastKey(manager.focusedKey, isCtrlKeyPressed(e)); manager.setFocusedKey(lastKey); @@ -203,14 +204,14 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S } break; case 'PageDown': - if (delegate.getKeyPageBelow && !disableNavigation) { + if (delegate.getKeyPageBelow && !isKeyboardNavigationDisabled) { e.preventDefault(); let nextKey = delegate.getKeyPageBelow(manager.focusedKey); navigateToKey(nextKey); } break; case 'PageUp': - if (delegate.getKeyPageAbove && !disableNavigation) { + if (delegate.getKeyPageAbove && !isKeyboardNavigationDisabled) { e.preventDefault(); let nextKey = delegate.getKeyPageAbove(manager.focusedKey); navigateToKey(nextKey); @@ -218,14 +219,14 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; case 'a': // disabled navigation also means disabling selection i think, otherwise trying to type 'a' into a textfield would be disastrous - if (isCtrlKeyPressed(e) && manager.selectionMode === 'multiple' && disallowSelectAll !== true && !disableNavigation) { + if (isCtrlKeyPressed(e) && manager.selectionMode === 'multiple' && disallowSelectAll !== true && !isKeyboardNavigationDisabled) { e.preventDefault(); manager.selectAll(); } break; case 'Escape': // disabled navigation also means disabling selection i think, similar reason to case 'a' but trying to exit out of a resizer - if (!disableNavigation) { + if (!isKeyboardNavigationDisabled) { e.preventDefault(); if (!disallowEmptySelection) { manager.clearSelection(); @@ -233,7 +234,11 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S } break; case 'Tab': { - // the one thing that disabledNavigation probably shouldn't affect, the tab key + if (isKeyboardNavigationDisabled && delegate.getKeyRightOf && delegate.getKeyLeftOf) { + e.preventDefault(); + let nextKey = delegate.getKeyRightOf(manager.focusedKey); + navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first'); + } if (!allowsTabNavigation) { // There may be elements that are "tabbable" inside a collection (e.g. in a grid cell). // However, collections should be treated as a single tab stop, with arrow key navigation internally. diff --git a/packages/@react-aria/table/src/useTableColumnHeader.ts b/packages/@react-aria/table/src/useTableColumnHeader.ts index e5c7f18a1fb..5f99c001f30 100644 --- a/packages/@react-aria/table/src/useTableColumnHeader.ts +++ b/packages/@react-aria/table/src/useTableColumnHeader.ts @@ -45,15 +45,17 @@ export function useTableColumnHeader(props: ColumnHeaderProps, state: TableSt let allowsResizing = node.props.allowsResizing; let allowsSorting = node.props.allowsSorting; // 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 ? 'child' : 'cell'}, state, ref); + 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 ed0c5965905..4575a584985 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -24,15 +24,12 @@ interface ResizerAria { interface ResizerProps { column: GridNode, - tableRef: RefObject, showResizer: boolean, - onResizeEntered: () => void, - onResizeDone: () => void, label: string } export function useTableColumnResize(props: ResizerProps, state: TableState & ColumnResizeState, ref: RefObject): ResizerAria { - let {column: item, showResizer, onResizeEntered, onResizeDone} = props; + 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); @@ -47,18 +44,17 @@ export function useTableColumnResize(props: ResizerProps, state: TableStat } if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { // switch focus back to the column header on escape - const columnHeader = ref.current.parentElement as HTMLElement; - if (columnHeader) { - focusSafely(columnHeader); - } + focusSafely(ref.current.closest('[role="columnheader"]')); } } }); const columnResizeWidthRef = useRef(null); const {moveProps} = useMove({ - onMoveStart() { - stateRef.current.onColumnResizeStart(item); + onMoveStart({pointerType}) { + if (pointerType !== 'keyboard') { + stateRef.current.onColumnResizeStart(item); + } columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key); cursor.current = document.body.style.cursor; }, @@ -82,8 +78,10 @@ export function useTableColumnResize(props: ResizerProps, state: TableStat } } }, - onMoveEnd() { - stateRef.current.onColumnResizeEnd(item); + onMoveEnd({pointerType}) { + if (pointerType !== 'keyboard') { + stateRef.current.onColumnResizeEnd(item); + } columnResizeWidthRef.current = 0; document.body.style.cursor = cursor.current; } @@ -105,12 +103,14 @@ export function useTableColumnResize(props: ResizerProps, state: TableStat moveProps, { onFocus: () => { - state.setDisableNavigation(true); - onResizeEntered(); + // 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.setIsKeyboardNavigationDisabled(true); }, onBlur: () => { - state.setDisableNavigation(false); - onResizeDone(); + stateRef.current.onColumnResizeEnd(item); + state.setIsKeyboardNavigationDisabled(false); }, tabIndex: showResizer ? 0 : undefined }, 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 443206f12a7..1b9a2df1125 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -11,10 +11,7 @@ import {useTableContext} from './TableView'; interface ResizerProps { column: GridNode, - tableRef: RefObject, - showResizer: boolean, - onResizeDone: () => void, - onResizeEntered: () => void + showResizer: boolean } function Resizer(props: ResizerProps, ref: RefObject) { @@ -28,7 +25,8 @@ function Resizer(props: ResizerProps, ref: RefObject) { let style = { cursor: undefined, height: '100%', - display: showResizer ? 'block' : 'none' + display: showResizer ? 'block' : 'none', + touchAction: 'none' }; if (columnState.getColumnMinWidth(column.key) >= columnState.getColumnWidth(column.key)) { style.cursor = direction === 'rtl' ? 'w-resize' : 'e-resize'; diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 51d48318e0e..db9b5c7be3d 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -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'; @@ -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) { @@ -475,14 +471,8 @@ function TableColumnHeader(props) { } let {hoverProps, isHovered} = useHover(props); - 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 = mergeProps(columnHeaderProps, buttonProps); - } const allProps = [columnHeaderProps, hoverProps]; - return (
      { switch (key) { case 'sort-asc': state.sort(column.key, 'ascending'); - setResizeMode(false); break; case 'sort-desc': state.sort(column.key, 'descending'); - setResizeMode(false); break; case 'resize': - setResizeMode(true); + columnState.onColumnResizeStart(column); break; - default: - setResizeMode(false); } }; let allowsSorting = column.props?.allowsSorting; @@ -568,13 +552,13 @@ function ResizableTableColumnHeader(props) { return options; }, [allowsSorting]); // if we're resizing another column, then hover shouldn't show the resizer of a different column - let showResizer = resizeMode || (isHovered && !columnState.isResizingColumn) || isResizing; + let showResizer = (isHovered && !columnState.currentlyResizingColumn) || columnState.currentlyResizingColumn === column.key; useEffect(() => { - if (resizeMode) { + if (columnState.currentlyResizingColumn === column.key) { focusSafely(ref.current); } - }, [resizeMode]); + }, [columnState.currentlyResizingColumn, column.key]); return ( <> @@ -582,11 +566,8 @@ function ResizableTableColumnHeader(props) { setResizeMode(true)} - onResizeDone={() => setResizeMode(false)} /> + showResizer={showResizer} /> {(item) => ( diff --git a/packages/@react-spectrum/table/test/TableSizing.test.js b/packages/@react-spectrum/table/test/TableSizing.test.js index f51de75721d..8a5fe177533 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.js +++ b/packages/@react-spectrum/table/test/TableSizing.test.js @@ -658,7 +658,7 @@ describe('TableViewSizing', function () { expect(row.childNodes[2].style.width).toBe('200px'); } - let resizableHeader = tree.getAllByRole('button')[0]; + let resizableHeader = tree.getAllByRole('columnheader')[0]; fireEvent.pointerEnter(resizableHeader); expect(tree.getByRole('separator')).toBeVisible(); @@ -726,7 +726,7 @@ describe('TableViewSizing', function () { expect(row.childNodes[2].style.width).toBe('200px'); } - let resizableHeader = tree.getAllByRole('button')[0]; + let resizableHeader = tree.getAllByRole('columnheader')[0]; fireEvent.pointerEnter(resizableHeader); expect(tree.getByRole('separator')).toBeVisible(); @@ -801,7 +801,7 @@ describe('TableViewSizing', function () { expect(row.childNodes[2].style.width).toBe('200px'); } - let resizableHeader = tree.getAllByRole('button')[0]; + let resizableHeader = tree.getAllByRole('columnheader')[0]; fireEvent.pointerDown(resizableHeader, {pointerType: 'touch', pointerId: 1}); fireEvent.pointerUp(resizableHeader, {pointerType: 'touch', pointerId: 1}); @@ -879,7 +879,7 @@ describe('TableViewSizing', function () { expect(row.childNodes[2].style.width).toBe('200px'); } - let resizableHeader = tree.getAllByRole('button')[0]; + let resizableHeader = tree.getAllByRole('columnheader')[0]; fireEvent.pointerDown(resizableHeader, {pointerType: 'touch', pointerId: 1}); fireEvent.pointerUp(resizableHeader, {pointerType: 'touch', pointerId: 1}); @@ -951,7 +951,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); - let resizableHeader = tree.getAllByRole('button')[0]; + let resizableHeader = tree.getAllByRole('columnheader')[0]; expect(document.activeElement).toBe(resizableHeader); expect(tree.queryByRole('separator')).toBeNull(); @@ -1028,7 +1028,7 @@ describe('TableViewSizing', function () { fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); - let resizableHeader = tree.getAllByRole('button')[0]; + let resizableHeader = tree.getAllByRole('columnheader')[0]; expect(document.activeElement).toBe(resizableHeader); expect(tree.queryByRole('separator')).toBeNull(); diff --git a/packages/@react-stately/grid/src/useGridState.ts b/packages/@react-stately/grid/src/useGridState.ts index 030f52a799c..f663473a638 100644 --- a/packages/@react-stately/grid/src/useGridState.ts +++ b/packages/@react-stately/grid/src/useGridState.ts @@ -8,7 +8,8 @@ export interface GridState> { disabledKeys: Set, /** A selection manager to read and update row selection state. */ selectionManager: SelectionManager, - disableNavigation: boolean + /** Whether keyboard navigation is disabled, such as when you need to use the arrow keys to interact with an internal component. */ + isKeyboardNavigationDisabled: boolean } interface GridStateOptions> extends MultipleSelectionStateProps { @@ -55,7 +56,7 @@ export function useGridState>(prop return { collection, disabledKeys, - disableNavigation: false, + 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 099c22040bc..180c73da3d1 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -20,19 +20,17 @@ export interface ColumnResizeState { /** Trigger a resize and recalculation. */ onColumnResize: (column: GridNode, width: number) => void, /** Callback for when onColumnResize has started. */ - onColumnResizeStart: (key: GridNode) => void, - /** Callback for when onColumnResize has ended. */ - onColumnResizeEnd: (key: GridNode) => 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. */ + /** Getter for column min width. */ getColumnMinWidth: (key: Key) => number, - /** Getter for column max widths. */ + /** Getter for column max widths. */ getColumnMaxWidth: (key: Key) => number, - /** Boolean for if a column is being resized. */ - isResizingColumn: boolean, - /** Node of column currently being resized. */ - getResizingColumn: () => GridNode + /** Key of column currently being resized. */ + currentlyResizingColumn: Key | null } export interface ColumnResizeStateProps { @@ -64,7 +62,7 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps, stat const [resizedColumns, setResizedColumns] = useState>(new Set()); const resizedColumnsRef = useRef>(resizedColumns); - const currentlyResizingColumn = useRef>(null); + const [currentlyResizingColumn, setCurrentlyResizingColumn] = useState(null); function setColumnWidthsForRef(newWidths: Map) { columnWidthsRef.current = newWidths; @@ -130,7 +128,7 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps, stat } function onColumnResizeStart(column: GridNode) { - currentlyResizingColumn.current = column; + setCurrentlyResizingColumn(column.key); isResizing.current = true; startResizeContentWidth.current = getContentWidth(columnWidthsRef.current); } @@ -143,7 +141,7 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps, stat // eslint-disable-next-line @typescript-eslint/no-unused-vars function onColumnResizeEnd(column: GridNode) { - currentlyResizingColumn.current = null; + setCurrentlyResizingColumn(null); isResizing.current = false; props.onColumnResizeEnd && props.onColumnResizeEnd(affectedColumnWidthsRef.current); affectedColumnWidthsRef.current = []; @@ -222,7 +220,6 @@ export function useTableColumnResizeState(props: ColumnResizeStateProps, stat getColumnWidth, getColumnMinWidth, getColumnMaxWidth, - isResizingColumn: isResizing.current, - getResizingColumn: () => currentlyResizingColumn.current + currentlyResizingColumn }; } diff --git a/packages/@react-stately/table/src/useTableState.ts b/packages/@react-stately/table/src/useTableState.ts index 4dafecc0e44..59660f09b93 100644 --- a/packages/@react-stately/table/src/useTableState.ts +++ b/packages/@react-stately/table/src/useTableState.ts @@ -27,8 +27,10 @@ export interface TableState extends GridState> { sortDescriptor: SortDescriptor, /** Calls the provided onSortChange handler with the provided column key and sort direction. */ sort(columnKey: Key, direction?: 'ascending' | 'descending'): void, - disableNavigation: boolean, - setDisableNavigation: (val: boolean) => void + /** Whether keyboard navigation is disabled, such as when you need to use the arrow keys to interact with an internal component. */ + isKeyboardNavigationDisabled: boolean, + /** Set whether keyboard navigation is disabled, such as when you need to use the arrow keys to interact with an internal component. */ + setIsKeyboardNavigationDisabled: (val: boolean) => void } export interface CollectionBuilderContext { @@ -52,7 +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 [disableNavigation, setDisableNavigation] = useState(false); + let [isKeyboardNavigationDisabled, setIsKeyboardNavigationDisabled] = useState(false); let {selectionMode = 'none'} = props; let context = useMemo(() => ({ @@ -74,8 +76,8 @@ export function useTableState(props: TableStateProps): Tabl selectionManager, showSelectionCheckboxes: props.showSelectionCheckboxes || false, sortDescriptor: props.sortDescriptor, - disableNavigation, - setDisableNavigation, + isKeyboardNavigationDisabled, + setIsKeyboardNavigationDisabled, sort(columnKey: Key, direction?: 'ascending' | 'descending') { props.onSortChange({ column: columnKey, From 95a7e445022d2fd08c10fa4c5134feeed464609c Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 1 Jul 2022 14:40:44 -0700 Subject: [PATCH 11/14] don't harm useSelectableCollection --- packages/@react-aria/grid/src/useGrid.ts | 5 +-- .../selection/src/useSelectableCollection.ts | 40 +++++++------------ .../table/src/useTableColumnResize.ts | 9 ++--- 3 files changed, 19 insertions(+), 35 deletions(-) diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index 0eebca73d95..e82746eefb6 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -95,8 +95,7 @@ export function useGrid(props: GridProps, state: GridState(props: GridProps, state: GridState, - /** Whether keyboard navigation is disabled, such as when you need to use the arrow keys to interact with an internal component. */ - isKeyboardNavigationDisabled?: boolean + scrollRef?: RefObject } interface SelectableCollectionAria { @@ -106,8 +104,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S allowsTabNavigation = false, isVirtualized, // If no scrollRef is provided, assume the collection ref is the scrollable region - scrollRef = ref, - isKeyboardNavigationDisabled + scrollRef = ref } = options; let {direction} = useLocale(); @@ -138,7 +135,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S switch (e.key) { case 'ArrowDown': { - if (delegate.getKeyBelow && !isKeyboardNavigationDisabled) { + if (delegate.getKeyBelow) { e.preventDefault(); let nextKey = manager.focusedKey != null ? delegate.getKeyBelow(manager.focusedKey) @@ -151,7 +148,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'ArrowUp': { - if (delegate.getKeyAbove && !isKeyboardNavigationDisabled) { + if (delegate.getKeyAbove) { e.preventDefault(); let nextKey = manager.focusedKey != null ? delegate.getKeyAbove(manager.focusedKey) @@ -164,7 +161,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'ArrowLeft': { - if (delegate.getKeyLeftOf && !isKeyboardNavigationDisabled) { + if (delegate.getKeyLeftOf) { e.preventDefault(); let nextKey = delegate.getKeyLeftOf(manager.focusedKey); navigateToKey(nextKey, direction === 'rtl' ? 'first' : 'last'); @@ -172,7 +169,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'ArrowRight': { - if (delegate.getKeyRightOf && !isKeyboardNavigationDisabled) { + if (delegate.getKeyRightOf) { e.preventDefault(); let nextKey = delegate.getKeyRightOf(manager.focusedKey); navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first'); @@ -180,7 +177,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'Home': - if (delegate.getFirstKey && !isKeyboardNavigationDisabled) { + if (delegate.getFirstKey) { e.preventDefault(); let firstKey = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e)); manager.setFocusedKey(firstKey); @@ -192,7 +189,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S } break; case 'End': - if (delegate.getLastKey && !isKeyboardNavigationDisabled) { + if (delegate.getLastKey) { e.preventDefault(); let lastKey = delegate.getLastKey(manager.focusedKey, isCtrlKeyPressed(e)); manager.setFocusedKey(lastKey); @@ -204,41 +201,32 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S } break; case 'PageDown': - if (delegate.getKeyPageBelow && !isKeyboardNavigationDisabled) { + if (delegate.getKeyPageBelow) { e.preventDefault(); let nextKey = delegate.getKeyPageBelow(manager.focusedKey); navigateToKey(nextKey); } break; case 'PageUp': - if (delegate.getKeyPageAbove && !isKeyboardNavigationDisabled) { + if (delegate.getKeyPageAbove) { e.preventDefault(); let nextKey = delegate.getKeyPageAbove(manager.focusedKey); navigateToKey(nextKey); } break; case 'a': - // disabled navigation also means disabling selection i think, otherwise trying to type 'a' into a textfield would be disastrous - if (isCtrlKeyPressed(e) && manager.selectionMode === 'multiple' && disallowSelectAll !== true && !isKeyboardNavigationDisabled) { + if (isCtrlKeyPressed(e) && manager.selectionMode === 'multiple' && disallowSelectAll !== true) { e.preventDefault(); manager.selectAll(); } break; case 'Escape': - // disabled navigation also means disabling selection i think, similar reason to case 'a' but trying to exit out of a resizer - if (!isKeyboardNavigationDisabled) { - e.preventDefault(); - if (!disallowEmptySelection) { - manager.clearSelection(); - } + e.preventDefault(); + if (!disallowEmptySelection) { + manager.clearSelection(); } break; case 'Tab': { - if (isKeyboardNavigationDisabled && delegate.getKeyRightOf && delegate.getKeyLeftOf) { - e.preventDefault(); - let nextKey = delegate.getKeyRightOf(manager.focusedKey); - navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first'); - } if (!allowsTabNavigation) { // There may be elements that are "tabbable" inside a collection (e.g. in a grid cell). // However, collections should be treated as a single tab stop, with arrow key navigation internally. diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 4575a584985..76f71e79d2e 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -38,12 +38,9 @@ export function useTableColumnResize(props: ResizerProps, state: TableStat let {direction} = useLocale(); 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 + 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"]')); } } From e08a7c973debf7762470a23b27324712cb9dff14 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 1 Jul 2022 14:46:07 -0700 Subject: [PATCH 12/14] add tests for exiting resize via keyboard --- .../table/test/TableSizing.test.js | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/packages/@react-spectrum/table/test/TableSizing.test.js b/packages/@react-spectrum/table/test/TableSizing.test.js index 8a5fe177533..d8053b33fcc 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.js +++ b/packages/@react-spectrum/table/test/TableSizing.test.js @@ -1081,6 +1081,145 @@ describe('TableViewSizing', function () { 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(); }); }); From 7a45ad524a3c2f11acb9852d45740f673f0bfc9c Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 1 Jul 2022 16:07:40 -0700 Subject: [PATCH 13/14] review comments --- packages/@react-aria/table/src/useTableColumnResize.ts | 4 ++-- packages/@react-stately/grid/src/useGridState.ts | 2 +- packages/@react-stately/table/src/useTableState.ts | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 76f71e79d2e..71e5867126f 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -103,11 +103,11 @@ export function useTableColumnResize(props: ResizerProps, state: TableStat // 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.setIsKeyboardNavigationDisabled(true); + state.setKeyboardNavigationDisabled(true); }, onBlur: () => { stateRef.current.onColumnResizeEnd(item); - state.setIsKeyboardNavigationDisabled(false); + state.setKeyboardNavigationDisabled(false); }, tabIndex: showResizer ? 0 : undefined }, diff --git a/packages/@react-stately/grid/src/useGridState.ts b/packages/@react-stately/grid/src/useGridState.ts index f663473a638..211a7f57929 100644 --- a/packages/@react-stately/grid/src/useGridState.ts +++ b/packages/@react-stately/grid/src/useGridState.ts @@ -8,7 +8,7 @@ export interface GridState> { disabledKeys: Set, /** A selection manager to read and update row selection state. */ selectionManager: SelectionManager, - /** Whether keyboard navigation is disabled, such as when you need to use the arrow keys to interact with an internal component. */ + /** Whether keyboard navigation is disabled, such as when the arrow keys should be handled by a component within a cell. */ isKeyboardNavigationDisabled: boolean } diff --git a/packages/@react-stately/table/src/useTableState.ts b/packages/@react-stately/table/src/useTableState.ts index 59660f09b93..059dbef4516 100644 --- a/packages/@react-stately/table/src/useTableState.ts +++ b/packages/@react-stately/table/src/useTableState.ts @@ -27,10 +27,10 @@ export interface TableState extends GridState> { sortDescriptor: SortDescriptor, /** Calls the provided onSortChange handler with the provided column key and sort direction. */ sort(columnKey: Key, direction?: 'ascending' | 'descending'): void, - /** Whether keyboard navigation is disabled, such as when you need to use the arrow keys to interact with an internal component. */ + /** 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 you need to use the arrow keys to interact with an internal component. */ - setIsKeyboardNavigationDisabled: (val: boolean) => void + setKeyboardNavigationDisabled: (val: boolean) => void } export interface CollectionBuilderContext { @@ -54,7 +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, setIsKeyboardNavigationDisabled] = useState(false); + let [isKeyboardNavigationDisabled, setKeyboardNavigationDisabled] = useState(false); let {selectionMode = 'none'} = props; let context = useMemo(() => ({ @@ -77,7 +77,7 @@ export function useTableState(props: TableStateProps): Tabl showSelectionCheckboxes: props.showSelectionCheckboxes || false, sortDescriptor: props.sortDescriptor, isKeyboardNavigationDisabled, - setIsKeyboardNavigationDisabled, + setKeyboardNavigationDisabled, sort(columnKey: Key, direction?: 'ascending' | 'descending') { props.onSortChange({ column: columnKey, From 9683f106f103edf0167bed364d5436d109616d93 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 1 Jul 2022 16:36:17 -0700 Subject: [PATCH 14/14] position of resizer handle and code comment --- packages/@adobe/spectrum-css-temp/components/table/index.css | 1 - packages/@react-spectrum/table/src/Resizer.tsx | 2 +- packages/@react-stately/table/src/useTableState.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/@adobe/spectrum-css-temp/components/table/index.css b/packages/@adobe/spectrum-css-temp/components/table/index.css index f2abddad803..c2801095dd5 100644 --- a/packages/@adobe/spectrum-css-temp/components/table/index.css +++ b/packages/@adobe/spectrum-css-temp/components/table/index.css @@ -101,7 +101,6 @@ svg.spectrum-Table-sortedIcon { &::after { content: ""; - position: absolute; display: block; box-sizing: border-box; inline-size: 1px; diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index 1b9a2df1125..ffb12d4eb1e 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -25,7 +25,7 @@ function Resizer(props: ResizerProps, ref: RefObject) { let style = { cursor: undefined, height: '100%', - display: showResizer ? 'block' : 'none', + display: showResizer ? undefined : 'none', touchAction: 'none' }; if (columnState.getColumnMinWidth(column.key) >= columnState.getColumnWidth(column.key)) { diff --git a/packages/@react-stately/table/src/useTableState.ts b/packages/@react-stately/table/src/useTableState.ts index 059dbef4516..f88d3c16a25 100644 --- a/packages/@react-stately/table/src/useTableState.ts +++ b/packages/@react-stately/table/src/useTableState.ts @@ -29,7 +29,7 @@ export interface TableState extends GridState> { 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 you need to use the arrow keys to interact with an internal component. */ + /** 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 }