diff --git a/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch b/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch index a602426af6f..fc4c114ded1 100644 --- a/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch +++ b/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch @@ -11,6 +11,19 @@ index 39a24b8f2ccdc52739d130480ab18975073616cb..0c3f5199401c15b90230c25a02de364e } UI.clearInitialValue(el); } +diff --git a/dist/cjs/event/behavior/keydown.js b/dist/cjs/event/behavior/keydown.js +index 55027cb256f66b808d17280dc01bc55a796a1032..993d5de5a838a711d7ae009344354772a42ed0c1 100644 +--- a/dist/cjs/event/behavior/keydown.js ++++ b/dist/cjs/event/behavior/keydown.js +@@ -110,7 +110,7 @@ const keydownBehavior = { + }, + Tab: (event, target, instance)=>{ + return ()=>{ +- const dest = getTabDestination.getTabDestination(target, instance.system.keyboard.modifiers.Shift); ++ const dest = getTabDestination.getTabDestination(document.activeElement, instance.system.keyboard.modifiers.Shift); + focus.focusElement(dest); + if (selection.hasOwnSelection(dest)) { + UI.setUISelection(dest, { diff --git a/dist/cjs/utils/focus/getActiveElement.js b/dist/cjs/utils/focus/getActiveElement.js index d25f3a8ef67e856e43614559f73012899c0b53d7..4ed9ee45565ed438ee9284d8d3043c0bd50463eb 100644 --- a/dist/cjs/utils/focus/getActiveElement.js diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index 5a8c9935efd..c9d7a3f9f89 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -10,13 +10,13 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, DOMAttributes, DOMProps, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; +import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; import {GridCollection} from '@react-types/grid'; import {GridKeyboardDelegate} from './GridKeyboardDelegate'; import {gridMap} from './utils'; import {GridState} from '@react-stately/grid'; -import {useCallback, useMemo} from 'react'; +import {KeyboardEvent, useCallback, useMemo} from 'react'; import {useCollator, useLocale} from '@react-aria/i18n'; import {useGridSelectionAnnouncement} from './useGridSelectionAnnouncement'; import {useHasTabbableChild} from '@react-aria/focus'; @@ -64,7 +64,13 @@ export interface GridProps extends DOMProps, AriaLabelingProps { */ escapeKeyBehavior?: 'clearSelection' | 'none', /** Whether selection should occur on press up instead of press down. */ - shouldSelectOnPressUp?: boolean + shouldSelectOnPressUp?: boolean, + /** + * Whether keyboard navigation to focusable elements within grid list items is + * via the left/right arrow keys or the tab key. + * @default 'arrow' + */ + keyboardNavigationBehavior?: 'arrow' | 'tab' } export interface GridAria { @@ -90,7 +96,8 @@ export function useGrid(props: GridProps, state: GridState(props: GridProps, state: GridState>) => { + if (keyboardNavigationBehavior === 'tab') { + if (e.target === ref.current + || (e.target as HTMLElement).getAttribute('role') === 'gridcell' + || (e.target as HTMLElement).getAttribute('role') === 'row' + || (e.target as HTMLElement).getAttribute('role') === 'presentation' // special case for gridlist thanks to redispatch of keyboard event by gridcell, theoretically no other presentation elements should get focus + || (e.target instanceof HTMLInputElement && (e.target as HTMLInputElement).getAttribute('type') === 'checkbox') + || e.key === 'Tab') { + originalOnKeyDown?.(e); + } + } + if (keyboardNavigationBehavior !== 'tab') { + originalOnKeyDown?.(e); + } + }, [originalOnKeyDown, keyboardNavigationBehavior, ref]); + collectionProps.onKeyDown = onKeyDown; + + let originalOnKeyDownCapture = collectionProps.onKeyDownCapture; + let onKeyDownCapture = useCallback((e: BaseEvent>) => { + if (keyboardNavigationBehavior === 'tab') { + if (e.target === ref.current + || (e.target as HTMLElement).getAttribute('role') === 'gridcell' + || (e.target as HTMLElement).getAttribute('role') === 'row' + || (e.target as HTMLElement).getAttribute('role') === 'presentation' // special case for gridlist thanks to redispatch of keyboard event by gridcell, theoretically no other presentation elements should get focus + || (e.target instanceof HTMLInputElement && (e.target as HTMLInputElement).getAttribute('type') === 'checkbox') + || e.key === 'Tab') { + originalOnKeyDownCapture?.(e); + } + } + if (keyboardNavigationBehavior !== 'tab') { + originalOnKeyDownCapture?.(e); + } + }, [originalOnKeyDownCapture, keyboardNavigationBehavior, ref]); + collectionProps.onKeyDownCapture = onKeyDownCapture; + let id = useId(props.id); - gridMap.set(state, {keyboardDelegate: delegate, actions: {onRowAction, onCellAction}, shouldSelectOnPressUp}); + gridMap.set(state, {keyboardDelegate: delegate, keyboardNavigationBehavior, actions: {onRowAction, onCellAction}, shouldSelectOnPressUp}); let descriptionProps = useHighlightSelectionDescription({ selectionManager: manager, diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index b8e09c29cdd..76776c62119 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -13,7 +13,7 @@ import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared'; import {focusSafely, isFocusVisible} from '@react-aria/interactions'; import {getFocusableTreeWalker} from '@react-aria/focus'; -import {getScrollParent, mergeProps, scrollIntoViewport} from '@react-aria/utils'; +import {getScrollParent, isFocusable, mergeProps, scrollIntoViewport} from '@react-aria/utils'; import {GridCollection, GridNode} from '@react-types/grid'; import {gridMap} from './utils'; import {GridState} from '@react-stately/grid'; @@ -62,7 +62,10 @@ export function useGridCell>(props: GridCellProps } = props; let {direction} = useLocale(); - let {keyboardDelegate, actions: {onCellAction}} = gridMap.get(state)!; + let {keyboardDelegate, keyboardNavigationBehavior, actions: {onCellAction}} = gridMap.get(state)!; + if (keyboardNavigationBehavior === 'tab') { + focusMode = 'cell'; + } // We need to track the key of the item at the time it was last focused so that we force // focus to go to the item when the DOM node is reused for a different item in a virtualizer. @@ -113,6 +116,10 @@ export function useGridCell>(props: GridCellProps return; } + if (keyboardNavigationBehavior === 'tab' && e.target !== ref.current) { + return; + } + let walker = getFocusableTreeWalker(ref.current); walker.currentNode = document.activeElement; @@ -171,7 +178,7 @@ export function useGridCell>(props: GridCellProps ? walker.previousNode() as FocusableElement : walker.nextNode() as FocusableElement; - if (focusMode === 'child' && focusable === ref.current) { + if ((focusMode === 'child' && focusable === ref.current) || (keyboardNavigationBehavior === 'tab')) { focusable = null; } @@ -186,6 +193,7 @@ export function useGridCell>(props: GridCellProps // We prevent the capturing event from reaching children of the cell, e.g. pickers. // We want arrow keys to navigate to the next cell instead. We need to re-dispatch // the event from a higher parent so it still bubbles and gets handled by useSelectableCollection. + // Note: This may dispatch on something that isn't the cell nor the row, so we will need to handle that in useGrid. ref.current.parentElement?.dispatchEvent( new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) ); @@ -224,6 +232,48 @@ export function useGridCell>(props: GridCellProps } }; + let onKeyDown = (e) => { + if (!e.currentTarget.contains(e.target as Element) || !ref.current || !document.activeElement) { + return; + } + + switch (e.key) { + case 'Tab': { + if (keyboardNavigationBehavior === 'tab') { + // If there is another focusable element within this item, stop propagation so the tab key + // is handled by the browser and not by useSelectableCollection (which would take us out of the list). + let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); + walker.currentNode = document.activeElement; + let next = e.shiftKey ? walker.previousNode() : walker.nextNode(); + + if (next) { + e.stopPropagation(); + } + } + } + } + }; + + // Prevent pressing space to select the row. + let originalOnKeyDown = itemProps.onKeyDown; + itemProps.onKeyDown = (e) => { + if (keyboardNavigationBehavior === 'tab' && e.key === ' ' && e.target !== ref.current) { + e.stopPropagation(); + return; + } + originalOnKeyDown?.(e); + }; + + // Prevent clicking on a focusable element from selecting the row. + let originalPointerDown = itemProps.onPointerDown; + itemProps.onPointerDown = (e) => { + if (keyboardNavigationBehavior === 'tab' && isFocusable(e.target as Element) && e.target !== ref.current) { + e.stopPropagation(); + return; + } + originalPointerDown?.(e); + }; + // Grid cells can have focusable elements inside them. In this case, focus should // be marshalled to that element rather than focusing the cell itself. let onFocus = (e) => { @@ -253,6 +303,7 @@ export function useGridCell>(props: GridCellProps let gridCellProps: DOMAttributes = mergeProps(itemProps, { role: 'gridcell', onKeyDownCapture, + onKeyDown, 'aria-colspan': node.colSpan, 'aria-colindex': node.colIndex != null ? node.colIndex + 1 : undefined, // aria-colindex is 1-based colSpan: isVirtualized ? undefined : node.colSpan, diff --git a/packages/@react-aria/grid/src/utils.ts b/packages/@react-aria/grid/src/utils.ts index eb4defb3d47..5f1cf119597 100644 --- a/packages/@react-aria/grid/src/utils.ts +++ b/packages/@react-aria/grid/src/utils.ts @@ -16,6 +16,7 @@ import type {Key, KeyboardDelegate} from '@react-types/shared'; interface GridMapShared { keyboardDelegate: KeyboardDelegate, + keyboardNavigationBehavior: 'arrow' | 'tab', actions: { onRowAction?: (key: Key) => void, onCellAction?: (key: Key) => void diff --git a/packages/@react-aria/gridlist/src/useGridList.ts b/packages/@react-aria/gridlist/src/useGridList.ts index 055602c8146..8d6206b2d2d 100644 --- a/packages/@react-aria/gridlist/src/useGridList.ts +++ b/packages/@react-aria/gridlist/src/useGridList.ts @@ -12,6 +12,7 @@ import { AriaLabelingProps, + BaseEvent, CollectionBase, DisabledBehavior, DOMAttributes, @@ -24,6 +25,7 @@ import { RefObject } from '@react-types/shared'; import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; +import {KeyboardEvent, useCallback} from 'react'; import {listMap} from './utils'; import {ListState} from '@react-stately/list'; import {useGridSelectionAnnouncement, useHighlightSelectionDescription} from '@react-aria/grid'; @@ -144,6 +146,38 @@ export function useGridList(props: AriaGridListOptions, state: ListState>) => { + if (keyboardNavigationBehavior === 'tab') { + if (e.target === ref.current + || (e.target as HTMLElement).getAttribute('role') === 'gridcell' + || (e.target as HTMLElement).getAttribute('role') === 'row' + || e.key === 'Tab') { + originalOnKeyDown?.(e); + } + } + if (keyboardNavigationBehavior !== 'tab') { + originalOnKeyDown?.(e); + } + }, [originalOnKeyDown, keyboardNavigationBehavior, ref]); + listProps.onKeyDown = onKeyDown; + + let originalOnKeyDownCapture = listProps.onKeyDownCapture; + let onKeyDownCapture = useCallback((e: BaseEvent>) => { + if (keyboardNavigationBehavior === 'tab') { + if (e.target === ref.current + || (e.target as HTMLElement).getAttribute('role') === 'gridcell' + || (e.target as HTMLElement).getAttribute('role') === 'row' + || e.key === 'Tab') { + originalOnKeyDownCapture?.(e); + } + } + if (keyboardNavigationBehavior !== 'tab') { + originalOnKeyDownCapture?.(e); + } + }, [originalOnKeyDownCapture, keyboardNavigationBehavior, ref]); + listProps.onKeyDownCapture = onKeyDownCapture; + let id = useId(props.id); listMap.set(state, {id, onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp}); diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 296bb37e31d..f4b77be0d95 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, getScrollParent, mergeProps, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; +import {chain, getScrollParent, isFocusable, mergeProps, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; import {getRowId, listMap} from './utils'; @@ -135,6 +135,10 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt return; } + if (keyboardNavigationBehavior === 'tab' && e.target !== ref.current) { + return; + } + let walker = getFocusableTreeWalker(ref.current); walker.currentNode = document.activeElement; @@ -227,22 +231,6 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt } }; - let onFocus = (e) => { - keyWhenFocused.current = node.key; - if (e.target !== ref.current) { - // useSelectableItem only handles setting the focused key when - // the focused element is the row itself. We also want to - // set the focused key when a child element receives focus. - // If focus is currently visible (e.g. the user is navigating with the keyboard), - // then skip this. We want to restore focus to the previously focused row - // in that case since the list should act like a single tab stop. - if (!isFocusVisible()) { - state.selectionManager.setFocusedKey(node.key); - } - return; - } - }; - let onKeyDown = (e) => { if (!e.currentTarget.contains(e.target as Element) || !ref.current || !document.activeElement) { return; @@ -265,6 +253,42 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt } }; + // Prevent pressing space to select the row. + let originalOnKeyDown = itemProps.onKeyDown; + itemProps.onKeyDown = (e) => { + if (keyboardNavigationBehavior === 'tab' && e.key === ' ' && e.target !== ref.current) { + e.stopPropagation(); + return; + } + originalOnKeyDown?.(e); + }; + + // Prevent clicking on a focusable element from selecting the row. + let originalPointerDown = itemProps.onPointerDown; + itemProps.onPointerDown = (e) => { + if (keyboardNavigationBehavior === 'tab' && isFocusable(e.target as Element) && e.target !== ref.current) { + e.stopPropagation(); + return; + } + originalPointerDown?.(e); + }; + + let onFocus = (e) => { + keyWhenFocused.current = node.key; + if (e.target !== ref.current) { + // useSelectableItem only handles setting the focused key when + // the focused element is the row itself. We also want to + // set the focused key when a child element receives focus. + // If focus is currently visible (e.g. the user is navigating with the keyboard), + // then skip this. We want to restore focus to the previously focused row + // in that case since the list should act like a single tab stop. + if (!isFocusVisible()) { + state.selectionManager.setFocusedKey(node.key); + } + return; + } + }; + let syntheticLinkProps = useSyntheticLinkProps(node.props); let linkProps = itemStates.hasAction ? syntheticLinkProps : {}; // TODO: re-add when we get translations and fix this for iOS VO diff --git a/packages/@react-spectrum/list/test/ListViewDnd.test.js b/packages/@react-spectrum/list/test/ListViewDnd.test.js index 7fa741629b4..04ad1c5b2c9 100644 --- a/packages/@react-spectrum/list/test/ListViewDnd.test.js +++ b/packages/@react-spectrum/list/test/ListViewDnd.test.js @@ -2425,7 +2425,6 @@ describe('ListView', function () { tree.rerender(); await user.tab({shift: true}); - await user.tab({shift: true}); await beginDrag(tree); await user.tab(); // Should automatically jump to the folder target since we didn't provide onRootDrop and onInsert diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index c08479b0263..dc4fde4a2ba 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -13,20 +13,37 @@ import {action} from '@storybook/addon-actions'; import { ActionButton, + ActionMenu, Cell, + Checkbox, + CheckboxGroup, + ColorArea, Column, + ComboBox, + ComboBoxItem, Content, + DatePicker, Heading, IllustratedMessage, Link, MenuItem, MenuSection, + NumberField, + Picker, + PickerItem, + Radio, + RadioGroup, Row, + Slider, + Switch, TableBody, TableHeader, TableView, TableViewProps, - Text + Tag, + TagGroup, + Text, + TextField } from '../src'; import {categorizeArgTypes} from './utils'; import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; @@ -931,6 +948,166 @@ export const ColSpan: StoryObj = { } }; +let data: {id: string, name: string, description: string, type: string}[] = [ + {id: '1', name: 'Name', description: 'Who you are', type: 'text'}, + {id: '2', name: 'Date of birth', description: 'For horoscopes', type: 'date'}, + {id: '3', name: 'Favourite colour', description: 'For your personality', type: 'combobox'}, + {id: '4', name: 'Pets', description: 'For your enjoyment', type: 'picker'}, + {id: '5', name: 'Allowance', description: 'For your future', type: 'number'}, + {id: '6', name: 'Height', description: 'In inches, for your basketball career', type: 'slider'}, + {id: '7', name: 'Actions', description: 'To take right now', type: 'menu'}, + {id: '8', name: 'Checkbox', description: 'To check', type: 'checkbox'}, + {id: '9', name: 'Radio', description: 'To choose', type: 'radio'}, + {id: '10', name: 'Wall colour', description: 'So your room sparks joy', type: 'color'}, + {id: '11', name: 'References', description: 'Handy link to your favourite website', type: 'link'}, + {id: '12', name: 'Mythical dogs', description: 'Which would you adopt', type: 'tags'}, + {id: '13', name: 'Superstitions enabled', description: 'Whether or not 13 is bad luck', type: 'switch'} +]; + +let dataColumns = [ + {name: 'Name', id: 'name', isRowHeader: true, minWidth: 200}, + {name: 'Data', id: 'editable', minWidth: 300}, + {name: 'Description', id: 'description', minWidth: 200} +]; + +let formatOptions: Intl.NumberFormatOptions = { + style: 'currency', + currency: 'USD' +}; + +export const TableWithTextFields: StoryObj = { + render: (args) => ( + + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + if (column.name === 'Data') { + switch (item.type) { + case 'text': + return
; + case 'date': + return
; + case 'combobox': + return ( + +
+ + Red + Green + Blue + +
+
+ ); + case 'picker': + return ( + +
+ + Cat + Dog + Bird + +
+
+ ); + case 'number': + return
; + case 'slider': + return
; + case 'menu': + return ( + +
+ + Copy + Delete + +
+
+ ); + case 'checkbox': + return ( + +
+ + Airpods + Kindle + +
+
+ ); + case 'radio': + return ( + +
+ + Chicken + Veggie + +
+
+ ); + case 'color': + return ( + +
+ +
+
+ ); + case 'link': + return ( + +
Adobe
+
+ ); + case 'tags': + return ( + +
+ + Cerberus + Gellert + Fenris + +
+
+ ); + case 'switch': + return ( + +
+ +
+
+ ); + } + } + return {item[column.id]}; + }} +
+ )} +
+
+ ), + args: { + ...Example.args, + keyboardNavigationBehavior: 'tab' + }, + parameters: { + docs: { + disable: true + } + } +}; + Example.parameters = { docs: { source: { diff --git a/packages/@react-spectrum/table/test/TableDnd.test.js b/packages/@react-spectrum/table/test/TableDnd.test.js index a97231f92a7..db7988f0841 100644 --- a/packages/@react-spectrum/table/test/TableDnd.test.js +++ b/packages/@react-spectrum/table/test/TableDnd.test.js @@ -2145,7 +2145,6 @@ describe('TableView', function () { }); act(() => jest.runAllTimers()); await user.tab({shift: true}); - await user.tab({shift: true}); await user.keyboard('{ArrowLeft}'); // Drop on folder in same table @@ -2301,10 +2300,6 @@ describe('TableView', function () { await user.keyboard('{Escape}'); tree.rerender(); - await user.tab({shift: true}); - await user.tab({shift: true}); - await user.keyboard('{ArrowLeft}'); - await user.keyboard('{ArrowRight}'); let grids = tree.getAllByRole('grid'); let rowgroup = within(grids[0]).getAllByRole('rowgroup')[1]; diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 18d838e4efc..f973a884ccc 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -320,7 +320,13 @@ export interface TableProps extends Omit, 'children'>, Sty /** Handler that is called when a user performs an action on the row. */ onRowAction?: (key: Key) => void, /** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the Table. */ - dragAndDropHooks?: DragAndDropHooks + dragAndDropHooks?: DragAndDropHooks, + /** + * Whether keyboard navigation to focusable elements within grid list items is + * via the left/right arrow keys or the tab key. + * @default 'arrow' + */ + keyboardNavigationBehavior?: 'arrow' | 'tab' } /** diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index a58143c296e..bf045e2b25f 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -26,6 +26,8 @@ import { GridListLoadMoreItem, GridListProps, Heading, + Input, + Label, ListLayout, Modal, ModalOverlay, @@ -35,6 +37,7 @@ import { Tag, TagGroup, TagList, + TextField, useDragAndDrop, Virtualizer } from 'react-aria-components'; @@ -476,3 +479,43 @@ export let GridListInModalPicker: StoryObj = } } }; + +export let GridListWithTextFields: StoryObj = { + render: (args) => { + return ( + + {(item) => ( + + {({isSelected}) => ( + <> + {isSelected &&
{'\u2713'}
} + + + + + + + + + + + + + + )} +
+ )} +
+ ); + } +}; diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 3dc1f0bf7f7..5ce8d4cd101 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -21,6 +21,7 @@ import { GridList, GridListContext, GridListItem, + Input, Label, ListLayout, Modal, @@ -28,6 +29,7 @@ import { Tag, TagGroup, TagList, + TextField, useDragAndDrop, Virtualizer } from '../'; @@ -701,10 +703,7 @@ describe('GridList', () => { expect(document.activeElement).toBe(items[1]); await user.tab(); - expect(document.activeElement).toBe(buttonRef.current); - - await user.tab(); - expect(document.activeElement).toBe(document.body); + expect(document.body).toHaveFocus(); }); it('should support rendering a TagGroup with tabbing navigation inside a GridListItem', async () => { @@ -1328,7 +1327,7 @@ describe('GridList', () => { let {getByRole} = renderGridList({}, {onAction, onPressStart, onPressEnd, onPress, onClick}); let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')}); await gridListTester.triggerRowAction({row: 1, interactionType}); - + expect(onAction).toHaveBeenCalledTimes(1); expect(onPressStart).toHaveBeenCalledTimes(1); expect(onPressEnd).toHaveBeenCalledTimes(1); @@ -1336,4 +1335,60 @@ describe('GridList', () => { expect(onClick).toHaveBeenCalledTimes(1); }); }); + + describe('editing', () => { + it('should render a gridlist with edit mode', async () => { + let onSelectionChange = jest.fn(); + let tree = render( + + {(item) => ( + + + + + + + + + + + + + + + )} + + ); + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); + await user.tab(); + await user.tab(); + let row1Inputs = [...gridListTester.rows[0].querySelectorAll('input')]; + expect(row1Inputs[0]).toHaveFocus(); + await user.keyboard('Chris E'); + expect(row1Inputs[0]).toHaveFocus(); + expect(row1Inputs[0]).toHaveValue('Chris E'); + + await user.tab(); + expect(row1Inputs[1]).toHaveFocus(); + await user.keyboard('{Enter}'); + expect(onSelectionChange).not.toHaveBeenCalled(); + + await user.tab(); + expect(row1Inputs[2]).toHaveFocus(); + + await user.keyboard('{ArrowLeft}'); + expect(row1Inputs[2]).toHaveFocus(); + + await user.keyboard('{ArrowRight}'); + expect(row1Inputs[2]).toHaveFocus(); + }); + }); }); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 667096ceabd..45dd35c2559 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -11,7 +11,7 @@ */ import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render, setupIntersectionObserverMock, triggerLongPress, within} from '@react-spectrum/test-utils-internal'; -import {Button, Cell, Checkbox, Collection, Column, ColumnResizer, Dialog, DialogTrigger, DropIndicator, Label, Modal, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, TableLoadMoreItem, Tag, TagGroup, TagList, useDragAndDrop, useTableOptions, Virtualizer} from '../'; +import {Button, Cell, Checkbox, Collection, Column, ColumnResizer, Dialog, DialogTrigger, DropIndicator, Input, Label, Modal, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, TableLoadMoreItem, Tag, TagGroup, TagList, TextField, useDragAndDrop, useTableOptions, Virtualizer} from '../'; import {composeStories} from '@storybook/react'; import {DataTransfer, DragEvent} from '@react-aria/dnd/test/mocks'; import React, {useMemo, useState} from 'react'; @@ -2646,7 +2646,7 @@ describe('Table', () => { let {getByRole} = renderTable({rowProps: {onAction, onPressStart, onPressEnd, onPress, onClick}}); let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); await tableTester.triggerRowAction({row: 1, interactionType}); - + expect(onAction).toHaveBeenCalledTimes(1); expect(onPressStart).toHaveBeenCalledTimes(1); expect(onPressEnd).toHaveBeenCalledTimes(1); @@ -2654,6 +2654,257 @@ describe('Table', () => { expect(onClick).toHaveBeenCalledTimes(1); }); }); + + describe('Editable fields in cells', () => { + describe.each(['none', 'single', 'multiple'])('selectionMode: %s', (selectionMode) => { + it('should support editing a textfield in a cell in a table with keyboard interactions', async () => { + let {getByRole, getAllByRole} = render( + <> + + + + + + Name + Type + Description + + + + + + + Games + File folder + + + + + + + + + + + + + + Fonts + Font folder + + + + + + + + + + +
+ + + ); + + // Keyboard navigate to first textfield in first row + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid'), interactionType: 'keyboard'}); + let inputs = getAllByRole('textbox'); + let button = getByRole('button', {name: 'After'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + if (selectionMode === 'none') { + expect(tableTester.cells({element: tableTester.rows[0]})[2]).toHaveFocus(); + } else { + // in selection modes, account for extra checkbox column + await user.keyboard('{ArrowRight}'); + expect(tableTester.cells({element: tableTester.rows[0]})[3]).toHaveFocus(); + } + await user.tab(); + expect(inputs[0]).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(inputs[0]).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(inputs[0]).toHaveFocus(); + // Type a string that would trigger a typeahead or selection if we weren't in a textfield + await user.keyboard('B '); + expect(tableTester.selectedRows).toHaveLength(0); + expect(inputs[0]).toHaveFocus(); + + // Navigate to second textfield in first row + await user.tab(); + expect(inputs[1]).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(inputs[1]).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(inputs[1]).toHaveFocus(); + // Type a string that would trigger a typeahead or selection if we weren't in a textfield + await user.keyboard('E '); + expect(tableTester.selectedRows).toHaveLength(0); + expect(inputs[1]).toHaveFocus(); + + await user.tab(); + expect(button).toHaveFocus(); + + // Come back to the table, we should remember roughly where we were, in this case, on the cell containing the input. + // We may want this to focus the input itself instead of the cell. + await user.tab({shift: true}); + if (selectionMode === 'none') { + expect(tableTester.cells({element: tableTester.rows[0]})[2]).toHaveFocus(); + } else { + expect(tableTester.cells({element: tableTester.rows[0]})[3]).toHaveFocus(); + } + }); + + describe('pointer interactions', () => { + installPointerEvent(); + + it('should support editing a textfield in a cell in a table with mouse interactions', async () => { + let {getByRole, getAllByRole} = render( + <> + + + + + + Name + Type + Description + + + + + + + Games + File folder + + + + + + + + + + + + + + Fonts + Font folder + + + + + + + + + + +
+ + + ); + + // click on the first textfield in the first row + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let inputs = getAllByRole('textbox'); + await user.click(inputs[0]); + expect(inputs[0]).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(inputs[0]).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(inputs[0]).toHaveFocus(); + // Type a string that would trigger a typeahead or selection if we weren't in a textfield + await user.keyboard('B '); + expect(tableTester.selectedRows).toHaveLength(0); + expect(inputs[0]).toHaveFocus(); + + // click on the second textfield in the first row + await user.click(inputs[1]); + expect(inputs[1]).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(inputs[1]).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(inputs[1]).toHaveFocus(); + // Type a string that would trigger a typeahead or selection if we weren't in a textfield + await user.keyboard('E '); + expect(tableTester.selectedRows).toHaveLength(0); + expect(inputs[1]).toHaveFocus(); + }); + }); + }); + + it('should support navigation with a disabled textfield in a cell in a non-selectable table', async () => { + let {getByRole, getAllByRole} = render( + <> + + + Name + Type + Description + + + + Games + File folder + + + + + + + + + + + Fonts + Font folder + + + + + + + + + + +
+ + + ); + + // Keyboard navigate to first textfield in first row + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid'), interactionType: 'keyboard'}); + let inputs = getAllByRole('textbox'); + let button = getByRole('button', {name: 'After'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + await user.tab(); + expect(inputs[1]).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(inputs[1]).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(inputs[1]).toHaveFocus(); + // Type a string that would trigger a typeahead or selection if we weren't in a textfield + await user.keyboard('B '); + expect(tableTester.selectedRows).toHaveLength(0); + expect(inputs[1]).toHaveFocus(); + + await user.tab({shift: true}); + + await user.keyboard('{ArrowDown}'); + await user.tab(); + expect(button).toHaveFocus(); + }); + }); }); function HidingColumnsExample({dynamic = false}) { diff --git a/yarn.lock b/yarn.lock index 009a250feb7..b6353cf4e12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10209,10 +10209,10 @@ __metadata: "@testing-library/user-event@patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch": version: 14.6.1 - resolution: "@testing-library/user-event@patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch::version=14.6.1&hash=13cf21" + resolution: "@testing-library/user-event@patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch::version=14.6.1&hash=3511b9" peerDependencies: "@testing-library/dom": ">=7.21.4" - checksum: 10c0/ede32fec9345bb5e5c19a5abcb647d8c4704239f3f5417afe2914c1397067dae7ce547e46adfd4027c913f5735c0651ec530c73bdc5c7ea955efa860cc6a9dd9 + checksum: 10c0/5a3e378cfdcad1ae09b73141ba9ea5adb0e7ed0d9f6bf1c4ba3631c91554414c4f2ab255c23f08425d2d398daa11d745ead8ef7ba0d1de76e19252db0b5dbba3 languageName: node linkType: hard