From 8b71adf51a819cd48e0526583ed3c3deca4acbc9 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 4 Jun 2026 11:09:10 -0700 Subject: [PATCH 01/13] initial changes to support textfields in gridlists --- .../stories/GridList.stories.tsx | 120 ++++++++++++++- .../stories/Tree.stories.tsx | 139 +++++++++++++++++- .../test/GridList.test.js | 54 +++++++ .../src/gridlist/useGridListItem.ts | 64 +++++++- packages/react-aria/src/select/useSelect.ts | 2 - .../react-aria/src/selection/useTypeSelect.ts | 6 +- 6 files changed, 372 insertions(+), 13 deletions(-) diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 7c42b76baf4..5a5b8629843 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -28,10 +28,12 @@ import { GridListSection } from '../src/GridList'; import {Heading} from '../src/Heading'; +import {Input} from '../src/Input'; import {Key} from '@react-types/shared'; import {ListLayout, Size, WaterfallLayout} from 'react-stately/useVirtualizerState'; import {LoadingSpinner} from './utils'; import {LoadingState} from '@react-types/shared'; +import {Menu, MenuItem, MenuTrigger} from '../src/Menu'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import {Modal, ModalOverlay, ModalOverlayProps} from '../src/Modal'; import {Popover} from '../src/Popover'; @@ -39,6 +41,8 @@ import React, {JSX, useState} from 'react'; import styles from '../example/index.css'; import {Tag, TagGroup, TagList} from '../src/TagGroup'; import {Text} from '../src/Text'; +import {TextField} from '../src/TextField'; +import {Toolbar} from '../src/Toolbar'; import {useAsyncList} from 'react-stately/useAsyncList'; import {useListData} from 'react-stately/useListData'; import {Virtualizer} from '../src/Virtualizer'; @@ -312,13 +316,21 @@ GridListSectionExample.story = { }; export function VirtualizedGridListSection() { - let sections: {id: string; name: string; children: {id: string; name: string}[]}[] = []; + let sections: { + id: string; + name: string; + children: {id: string; name: string}[]; + }[] = []; for (let s = 0; s < 10; s++) { let items: {id: string; name: string}[] = []; for (let i = 0; i < 3; i++) { items.push({id: `item_${s}_${i}`, name: `Section ${s}, Item ${i}`}); } - sections.push({id: `section_${s}`, name: `Section ${s}`, children: items}); + sections.push({ + id: `section_${s}`, + name: `Section ${s}`, + children: items + }); } return ( @@ -360,7 +372,9 @@ const VirtualizedGridListRender = (args: GridListProps & {isLoading: boolea let {dragAndDropHooks} = useDragAndDrop({ getItems: keys => { - return [...keys].map(key => ({'text/plain': list.getItem(key)?.name ?? ''})); + return [...keys].map(key => ({ + 'text/plain': list.getItem(key)?.name ?? '' + })); }, onReorder(e) { if (e.target.dropPosition === 'before') { @@ -953,3 +967,103 @@ export const AsyncGridListGridVirtualized: StoryObj { + let isHorizontalStack = args.orientation === 'horizontal' && args.layout !== 'grid'; + return ( + <> + + + + RAC TextField + + + + + + Raw input + + + TextField + Button + + + {' '} + + + + Toolbar + + + + + + + + Menu + {/* TODO: hitting escape to close the menu, returns focus to the row. + Tabbing back from the external input also focuses the trggerbutton rather than the row. Tabbing back into the textfield row focuses the row */} + + + + + Cut + Copy + Paste + + + + + + + + ); +}; + +GridListWithTextfield.story = { + args: { + layout: 'stack', + orientation: 'vertical', + escapeKeyBehavior: 'clearSelection', + shouldSelectOnPressUp: false, + disallowTypeAhead: false + }, + argTypes: { + layout: { + control: 'radio', + options: ['stack', 'grid'] + }, + orientation: { + control: 'radio', + options: ['vertical', 'horizontal'] + }, + keyboardNavigationBehavior: { + control: 'radio', + options: ['arrow', 'tab'] + }, + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] + }, + escapeKeyBehavior: { + control: 'radio', + options: ['clearSelection', 'none'] + } + } +}; diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 4e305216973..4899aaac152 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -16,17 +16,18 @@ import {Checkbox, CheckboxProps} from '../src/Checkbox'; import {classNames} from '@adobe/react-spectrum/private/utils/classNames'; import {Collection} from 'react-aria/Collection'; import {DroppableCollectionReorderEvent, Key} from '@react-types/shared'; +import {Input} from '../src/Input'; import {isTextDropItem, useDragAndDrop} from '../exports/useDragAndDrop'; import {ListLayout} from 'react-stately/useVirtualizerState'; -import {Menu, MenuTrigger} from '../src/Menu'; - +import {Menu, MenuItem, MenuTrigger} from '../src/Menu'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; - import {MyMenuItem} from './utils'; import {Popover} from '../src/Popover'; import React, {JSX, ReactNode, useCallback, useState} from 'react'; import styles from '../example/index.css'; import {Text} from '../src/Text'; +import {TextField} from '../src/TextField'; +import {Toolbar} from '../src/Toolbar'; import { Tree, TreeHeader, @@ -54,6 +55,7 @@ export type TreeStory = StoryFn; interface StaticTreeItemProps extends TreeItemProps { title?: string; children: ReactNode; + interactive?: ReactNode; } interface MyCheckboxProps extends CheckboxProps { @@ -117,6 +119,7 @@ const StaticTreeItem = (props: StaticTreeItemProps) => { )} {props.title || props.children} + {props.interactive} @@ -1801,3 +1804,133 @@ export const HugeVirtualizedTree: StoryObj = { }, render: args => }; + +// TODO: bugs to investigate +// clicking on the textfield and hitting space in the textfield when selection is enabled causes selection to be toggled +// cant add spaces in the parent rows textfield? +function TreeWithTextField(props: TreeProps) { + return ( + <> + + + + + + }> + RAC TextField + + }> + Raw input + + + + + }> + + + + + + + }> + TextField + Button + + + + + + + }> + Toolbar + + }> + }> + Nested child 1 + + + + + + Cut + Copy + Paste + + + + }> + Nested child 2 + + + + + + + ); +} + +export const TreeWithTextFieldStory: StoryObj = { + render: args => , + args: { + selectionMode: 'none', + selectionBehavior: 'toggle', + disabledBehavior: 'selection' + }, + argTypes: { + // TODO: add later + // keyboardNavigationBehavior: { + // control: 'radio', + // options: ['arrow', 'tab'] + // }, + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] + }, + disabledBehavior: { + control: 'radio', + options: ['selection', 'all'] + } + }, + name: 'Tree with Textfield' +}; diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 5875fe1bd51..8fb5a7707c4 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -1145,6 +1145,60 @@ describe('GridList', () => { expect(document.activeElement).toBe(items[0]); }); + it('should not navigate rows when arrow keys are pressed while a text input child has focus', async () => { + let {getAllByRole, getByRole} = render( + + + Apple + + Banana + + ); + + let rows = getAllByRole('row'); + let input = getByRole('textbox'); + + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(input); + }); + + it('should not trigger typeahead when typing in a text input child', async () => { + let {getAllByRole, getByRole} = render( + + + Apple + + + Banana + + + ); + + let rows = getAllByRole('row'); + let input = getByRole('textbox'); + + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('b'); + expect(document.activeElement).toBe(input); + expect(input).toHaveValue('b'); + }); + + it('should not trigger selection when typing in the text input child', async () => { + // TODO: implement, right now it will select + }); + it('should not propagate the checkbox context from selection into other cells', async () => { let tree = render( diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index d70ea835356..194a8cbcf10 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -310,7 +310,7 @@ export function useGridListItem( } }; - let onKeyDown = e => { + let onKeyDown = (e: ReactKeyboardEvent) => { let activeElement = getActiveElement(); if ( !nodeContains(e.currentTarget, getEventTarget(e) as Element) || @@ -320,6 +320,57 @@ export function useGridListItem( return; } + if (keyboardNavigationBehavior === 'tab') { + // TODO: try out Rob's useTypeSelect change in place of this stop propagation for character keys? + // will still need to stop arrow key propagation otherwise useSelectableCollection recieves the event and moves focus + // should it just stop propagation for all events? + if (activeElement !== ref.current) { + if ( + isArrowKey(e.key) || + isCharacterKey(e.key) || + (e.key === '' && state.selectionManager.selectionMode !== 'none') + ) { + e.stopPropagation(); + return; + } + } + + // TODO: for tree expansion since we turn off the capturing listener if keyboardNavigationBehavior = tab + // copied from above, extract into helper later + // need to support tab keyboard navigation in tree + if ('expandedKeys' in state && activeElement === ref.current) { + if ( + e.key === EXPANSION_KEYS['expand'][direction] && + state.selectionManager.focusedKey === node.key && + hasChildRows && + !state.expandedKeys.has(node.key) + ) { + state.toggleKey(node.key); + e.stopPropagation(); + return; + } else if ( + e.key === EXPANSION_KEYS['collapse'][direction] && + state.selectionManager.focusedKey === node.key + ) { + // If item is collapsible, collapse it; else move to parent + if (hasChildRows && state.expandedKeys.has(node.key)) { + state.toggleKey(node.key); + e.stopPropagation(); + return; + } else if ( + !state.expandedKeys.has(node.key) && + node.parentKey && + state.collection.getItem(node.parentKey)?.type === 'item' + ) { + // Item is a leaf or already collapsed, move focus to parent + state.selectionManager.setFocusedKey(node.parentKey); + e.stopPropagation(); + return; + } + } + } + } + switch (e.key) { case 'Tab': { if (keyboardNavigationBehavior === 'tab') { @@ -351,7 +402,7 @@ export function useGridListItem( let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, { role: 'row', - onKeyDownCapture, + onKeyDownCapture: keyboardNavigationBehavior === 'arrow' ? onKeyDownCapture : undefined, onKeyDown, onFocus, // 'aria-label': [(node.textValue || undefined), rowAnnouncement].filter(Boolean).join(', '), @@ -419,3 +470,12 @@ function getDirectChildren(parent: RSNode, collection: Collection( errorMessage: props.errorMessage || validationErrors }); - typeSelectProps.onKeyDown = typeSelectProps.onKeyDownCapture; - delete typeSelectProps.onKeyDownCapture; if (state.selectionManager.selectionMode === 'multiple') { typeSelectProps = {}; } diff --git a/packages/react-aria/src/selection/useTypeSelect.ts b/packages/react-aria/src/selection/useTypeSelect.ts index eae1215dad4..21334c58f92 100644 --- a/packages/react-aria/src/selection/useTypeSelect.ts +++ b/packages/react-aria/src/selection/useTypeSelect.ts @@ -103,9 +103,9 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { return { typeSelectProps: { - // Using a capturing listener to catch the keydown event before - // other hooks in order to handle the Spacebar event. - onKeyDownCapture: keyboardDelegate.getKeyForSearch ? onKeyDown : undefined + // TODO: now that this is not capturing, will need to make sure other collection components/hooks + // work properly with it (aka now space for selection will alway take priority) + onKeyDown: keyboardDelegate.getKeyForSearch ? onKeyDown : undefined } }; } From f491a18b2a4c47fae5eb38aa80ca2b796a6521c2 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 4 Jun 2026 15:51:27 -0700 Subject: [PATCH 02/13] use robs event leak type select and prevent space from triggering selection when in input field --- .../stories/GridList.stories.tsx | 12 +-- .../stories/Tree.stories.tsx | 3 +- .../test/GridList.test.js | 36 ++++++- .../test/TagGroup.test.js | 5 + .../src/gridlist/useGridListItem.ts | 34 ++++--- .../react-aria/src/selection/useTypeSelect.ts | 98 ++++++++++++++----- 6 files changed, 140 insertions(+), 48 deletions(-) diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 5a5b8629843..9dc48496b6e 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -969,7 +969,7 @@ export const AsyncGridListGridVirtualized: StoryObj { let isHorizontalStack = args.orientation === 'horizontal' && args.layout !== 'grid'; return ( @@ -987,23 +987,23 @@ export const GridListWithTextfield: GridListStory = args => { gridAutoFlow: args.orientation === 'horizontal' ? 'column' : 'row' }} {...args}> - + RAC TextField - + Raw input - + TextField + Button {' '} - + Toolbar @@ -1011,7 +1011,7 @@ export const GridListWithTextfield: GridListStory = args => { - + Menu {/* TODO: hitting escape to close the menu, returns focus to the row. Tabbing back from the external input also focuses the trggerbutton rather than the row. Tabbing back into the textfield row focuses the row */} diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 4899aaac152..5ca6401c28b 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -1806,8 +1806,7 @@ export const HugeVirtualizedTree: StoryObj = { }; // TODO: bugs to investigate -// clicking on the textfield and hitting space in the textfield when selection is enabled causes selection to be toggled -// cant add spaces in the parent rows textfield? +// clicking on the textfield when selection is enabled causes selection to be toggled function TreeWithTextField(props: TreeProps) { return ( <> diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 8fb5a7707c4..367445d83fa 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -1148,10 +1148,12 @@ describe('GridList', () => { it('should not navigate rows when arrow keys are pressed while a text input child has focus', async () => { let {getAllByRole, getByRole} = render( - + Apple - Banana + + Banana + ); @@ -1195,8 +1197,34 @@ describe('GridList', () => { expect(input).toHaveValue('b'); }); - it('should not trigger selection when typing in the text input child', async () => { - // TODO: implement, right now it will select + it('should not trigger selection when pressing Space in a text input child', async () => { + using onSelectionChange = jest.fn(); + let {getAllByRole, getByRole} = render( + + + Apple + + + Banana + + + ); + + let rows = getAllByRole('row'); + let input = getByRole('textbox'); + + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard(' '); + expect(input).toHaveValue(' '); + expect(onSelectionChange).not.toHaveBeenCalled(); }); it('should not propagate the checkbox context from selection into other cells', async () => { diff --git a/packages/react-aria-components/test/TagGroup.test.js b/packages/react-aria-components/test/TagGroup.test.js index e94c84b7686..8f615682656 100644 --- a/packages/react-aria-components/test/TagGroup.test.js +++ b/packages/react-aria-components/test/TagGroup.test.js @@ -660,6 +660,11 @@ describe('TagGroup', () => { expect(onRemove).toHaveBeenCalledTimes(2); expect(onRemove).toHaveBeenLastCalledWith(new Set(['cat'])); + // TODO: a change in behavior since taggroup is a gridlist with "tab" keyboard navigation behavior + // previously you could go to the next tab via arrow keys when you were focused on the close button + await user.keyboard('{Shift>}{Tab}{/Shift}'); + expect(tags[0]).toHaveFocus(); + await user.keyboard('{ArrowRight}'); expect(tags[1]).toHaveFocus(); diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 194a8cbcf10..48df2ee6550 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -321,18 +321,17 @@ export function useGridListItem( } if (keyboardNavigationBehavior === 'tab') { - // TODO: try out Rob's useTypeSelect change in place of this stop propagation for character keys? - // will still need to stop arrow key propagation otherwise useSelectableCollection recieves the event and moves focus - // should it just stop propagation for all events? - if (activeElement !== ref.current) { - if ( - isArrowKey(e.key) || - isCharacterKey(e.key) || - (e.key === '' && state.selectionManager.selectionMode !== 'none') - ) { - e.stopPropagation(); - return; - } + // TODO: Added Rob's useTypeSelect changes, but that only stops if type select is in progress + // This will stop arrow key navigation and typeselect from bubbling up + // (note that this breaks TagGroup's old behavior of using arrow keys to move from "x" button to next tag and typeselect when inside a card/row) + // should it just stop propagation for all events since we can't rely on non-RAC components stopping propagation even they handled the event + // Will need to do something similar for click? + if ( + activeElement !== ref.current && + (isArrowKey(e.key) || isCharacterKey(e.key) || e.key === 'Enter') + ) { + e.stopPropagation(); + return; } // TODO: for tree expansion since we turn off the capturing listener if keyboardNavigationBehavior = tab @@ -403,7 +402,6 @@ export function useGridListItem( let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, { role: 'row', onKeyDownCapture: keyboardNavigationBehavior === 'arrow' ? onKeyDownCapture : undefined, - onKeyDown, onFocus, // 'aria-label': [(node.textValue || undefined), rowAnnouncement].filter(Boolean).join(', '), 'aria-label': node['aria-label'] || node.textValue || undefined, @@ -418,6 +416,16 @@ export function useGridListItem( id: getRowId(state, node.key) }); + // TODO we need to guard against space/enter triggering selection/row link via usePress (from itemProps) so check if propagation + // is stopped. this also fixes space not working in a textfield in a tree parent row + let baseOnKeyDown = rowProps.onKeyDown; + rowProps.onKeyDown = (e: ReactKeyboardEvent) => { + onKeyDown(e as ReactKeyboardEvent); + if (!e.isPropagationStopped()) { + baseOnKeyDown?.(e); + } + }; + if (isVirtualized) { let {collection} = state; let nodes = [...collection]; diff --git a/packages/react-aria/src/selection/useTypeSelect.ts b/packages/react-aria/src/selection/useTypeSelect.ts index 21334c58f92..ad7e00d4304 100644 --- a/packages/react-aria/src/selection/useTypeSelect.ts +++ b/packages/react-aria/src/selection/useTypeSelect.ts @@ -12,7 +12,7 @@ import {DOMAttributes, Key, KeyboardDelegate} from '@react-types/shared'; import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; -import {KeyboardEvent, useRef} from 'react'; +import {KeyboardEvent, useEffect, useRef} from 'react'; import {MultipleSelectionManager} from 'react-stately/useMultipleSelectionState'; /** @@ -50,41 +50,75 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { let state = useRef<{search: string; timeout: ReturnType | undefined}>({ search: '', timeout: undefined - }).current; + }); + + let onKeyDownCapture = (e: KeyboardEvent) => { + // if we're in the middle of a search, then a spacebar should be treated as a search and we should not propagate the event + // since we handle this one in a capture phase, we should ignore it in the bubble phase + if (state.current.search.length > 0 && e.key === ' ') { + e.preventDefault(); + if ( + !('continuePropagation' in e) || + ('continuePropagation' in e && !e.isPropagationStopped()) + ) { + e.stopPropagation(); + } + state.current.search += ' '; + + if (keyboardDelegate.getKeyForSearch != null) { + // Use the delegate to find a key to focus. + // Prioritize items after the currently focused item, falling back to searching the whole list. + let key = keyboardDelegate.getKeyForSearch( + state.current.search, + selectionManager.focusedKey + ); + + // If no key found, search from the top. + if (key == null) { + key = keyboardDelegate.getKeyForSearch(state.current.search); + } + + if (key != null) { + selectionManager.setFocusedKey(key); + if (onTypeSelect) { + onTypeSelect(key); + } + } + } + + clearTimeout(state.current.timeout); + state.current.timeout = setTimeout(() => { + state.current.search = ''; + }, TYPEAHEAD_DEBOUNCE_WAIT_MS); + } + }; let onKeyDown = (e: KeyboardEvent) => { + if (e.altKey) { + return; + } + let character = getStringForKey(e.key); if ( !character || e.ctrlKey || e.metaKey || + e.altKey || !nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement) || - (state.search.length === 0 && character === ' ') + (state.current.search.length === 0 && character === ' ') ) { return; } - // Do not propagate the Spacebar event if it's meant to be part of the search. - // When we time out, the search term becomes empty, hence the check on length. - // Trimming is to account for the case of pressing the Spacebar more than once, - // which should cycle through the selection/deselection of the focused item. - if (character === ' ' && state.search.trim().length > 0) { - e.preventDefault(); - if (!('continuePropagation' in e)) { - e.stopPropagation(); - } - } - - state.search += character; + state.current.search += character; if (keyboardDelegate.getKeyForSearch != null) { // Use the delegate to find a key to focus. // Prioritize items after the currently focused item, falling back to searching the whole list. - let key = keyboardDelegate.getKeyForSearch(state.search, selectionManager.focusedKey); + let key = keyboardDelegate.getKeyForSearch(state.current.search, selectionManager.focusedKey); - // If no key found, search from the top. if (key == null) { - key = keyboardDelegate.getKeyForSearch(state.search); + key = keyboardDelegate.getKeyForSearch(state.current.search); } if (key != null) { @@ -92,19 +126,37 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { if (onTypeSelect) { onTypeSelect(key); } + e.preventDefault(); + if (!('continuePropagation' in e)) { + e.stopPropagation(); + } + } else { + // if still nothing then the type to select is done and everything is reset + state.current.search = ''; + clearTimeout(state.current.timeout); + state.current.timeout = undefined; + return; } } - clearTimeout(state.timeout); - state.timeout = setTimeout(() => { - state.search = ''; + clearTimeout(state.current.timeout); + state.current.timeout = setTimeout(() => { + state.current.search = ''; }, TYPEAHEAD_DEBOUNCE_WAIT_MS); }; + useEffect(() => { + let timeout = state.current.timeout; + return () => { + clearTimeout(timeout); + }; + }, [state]); + return { typeSelectProps: { - // TODO: now that this is not capturing, will need to make sure other collection components/hooks - // work properly with it (aka now space for selection will alway take priority) + // Using a capturing listener to catch the keydown event before + // other hooks in order to handle the Spacebar event. + onKeyDownCapture: keyboardDelegate.getKeyForSearch ? onKeyDownCapture : undefined, onKeyDown: keyboardDelegate.getKeyForSearch ? onKeyDown : undefined } }; From 8fd5392a7a2d262aa0b90ab6e7355252f455d06f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 4 Jun 2026 16:28:49 -0700 Subject: [PATCH 03/13] fix tests, but also use a more robust check instead of active element the failing tests didnt focus the element when triggering a keypress --- packages/react-aria/src/gridlist/useGridListItem.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 48df2ee6550..d27bac3fcad 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -327,7 +327,7 @@ export function useGridListItem( // should it just stop propagation for all events since we can't rely on non-RAC components stopping propagation even they handled the event // Will need to do something similar for click? if ( - activeElement !== ref.current && + getEventTarget(e) !== ref.current && (isArrowKey(e.key) || isCharacterKey(e.key) || e.key === 'Enter') ) { e.stopPropagation(); From 10beb035a60728e3a047d4b037ef615f8e26cc8e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 5 Jun 2026 10:33:37 -0700 Subject: [PATCH 04/13] add S2 Card story and tests for tree and gridlist --- .../s2/stories/CardView.stories.tsx | 42 +++- .../stories/Tree.stories.tsx | 2 +- .../test/GridList.test.js | 187 ++++++++++-------- .../react-aria-components/test/Tree.test.tsx | 89 ++++++++- 4 files changed, 230 insertions(+), 90 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/CardView.stories.tsx b/packages/@react-spectrum/s2/stories/CardView.stories.tsx index e5efd0212a4..2f00aba5d14 100644 --- a/packages/@react-spectrum/s2/stories/CardView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/CardView.stories.tsx @@ -27,6 +27,7 @@ import {MenuItem} from '../src/Menu'; import type {Meta, StoryObj} from '@storybook/react'; import {SkeletonCollection} from '../src/SkeletonCollection'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import {TextField} from '../src/TextField'; import {useAsyncList} from 'react-stately/useAsyncList'; const meta: Meta = { @@ -72,7 +73,15 @@ const avatarSize = { XL: 32 } as const; -export function PhotoCard({item, layout}: {item: Item; layout: string}) { +export function PhotoCard({ + item, + layout, + interactive +}: { + item: Item; + layout: string; + interactive?: React.ReactNode; +}) { return ( {({size}) => ( @@ -112,12 +121,20 @@ export function PhotoCard({item, layout}: {item: Item; layout: string}) {
- - {item.user.name} +
+ + {item.user.name} +
+ {interactive}
@@ -126,7 +143,7 @@ export function PhotoCard({item, layout}: {item: Item; layout: string}) { ); } -export const ExampleRender = (args: CardViewProps) => { +export const ExampleRender = (args: CardViewProps & {interactive?: React.ReactNode}) => { let list = useAsyncList({ async load({signal, cursor, items}) { let page = cursor || 1; @@ -155,7 +172,9 @@ export const ExampleRender = (args: CardViewProps) => { onLoadMore={args.loadingState === 'idle' ? list.loadMore : undefined} styles={cardViewStyles}> - {item => } + {item => ( + + )} {(loadingState === 'loading' || loadingState === 'loadingMore') && ( @@ -288,3 +307,14 @@ export const CollectionCards: Story = { onAction: undefined } }; + +export const CardViewWithTextField: Story = { + render: args => ( + } /> + ), + args: { + loadingState: 'idle', + onAction: undefined, + selectionMode: 'multiple' + } +}; diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 5ca6401c28b..3d192f14c54 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -1807,7 +1807,7 @@ export const HugeVirtualizedTree: StoryObj = { // TODO: bugs to investigate // clicking on the textfield when selection is enabled causes selection to be toggled -function TreeWithTextField(props: TreeProps) { +export function TreeWithTextField(props: TreeProps) { return ( <> diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 367445d83fa..2ec0df5d91a 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -1145,88 +1145,6 @@ describe('GridList', () => { expect(document.activeElement).toBe(items[0]); }); - it('should not navigate rows when arrow keys are pressed while a text input child has focus', async () => { - let {getAllByRole, getByRole} = render( - - - Apple - - - Banana - - - ); - - let rows = getAllByRole('row'); - let input = getByRole('textbox'); - - await user.tab(); - expect(document.activeElement).toBe(rows[0]); - - await user.tab(); - expect(document.activeElement).toBe(input); - - await user.keyboard('{ArrowDown}'); - expect(document.activeElement).toBe(input); - await user.keyboard('{ArrowUp}'); - expect(document.activeElement).toBe(input); - }); - - it('should not trigger typeahead when typing in a text input child', async () => { - let {getAllByRole, getByRole} = render( - - - Apple - - - Banana - - - ); - - let rows = getAllByRole('row'); - let input = getByRole('textbox'); - - await user.tab(); - expect(document.activeElement).toBe(rows[0]); - await user.tab(); - expect(document.activeElement).toBe(input); - - await user.keyboard('b'); - expect(document.activeElement).toBe(input); - expect(input).toHaveValue('b'); - }); - - it('should not trigger selection when pressing Space in a text input child', async () => { - using onSelectionChange = jest.fn(); - let {getAllByRole, getByRole} = render( - - - Apple - - - Banana - - - ); - - let rows = getAllByRole('row'); - let input = getByRole('textbox'); - - await user.tab(); - expect(document.activeElement).toBe(rows[0]); - await user.tab(); - expect(document.activeElement).toBe(input); - - await user.keyboard(' '); - expect(input).toHaveValue(' '); - expect(onSelectionChange).not.toHaveBeenCalled(); - }); - it('should not propagate the checkbox context from selection into other cells', async () => { let tree = render( @@ -1915,4 +1833,109 @@ describe('GridList', () => { } ); }); + + describe('tab navigation and textfields', () => { + it.each([ + ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}], + ['layout="grid"', {layout: 'grid'}] + ])( + 'should not navigate rows when arrow keys are pressed while a text input child has focus (%s)', + async (_, listProps) => { + let {getAllByRole, getByRole} = render( + + + Apple + + + Banana + + + ); + + let rows = getAllByRole('row'); + let input = getByRole('textbox'); + + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(input); + } + ); + + it.each([ + ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}], + ['layout="grid"', {layout: 'grid'}] + ])( + 'should not trigger typeahead when typing in a text input child (%s)', + async (_, listProps) => { + let {getAllByRole, getByRole} = render( + + + Apple + + + Banana + + + ); + + let rows = getAllByRole('row'); + let input = getByRole('textbox'); + + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('b'); + expect(document.activeElement).toBe(input); + expect(input).toHaveValue('b'); + } + ); + + it.each([ + ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}], + ['layout="grid"', {layout: 'grid'}] + ])( + 'should not trigger selection when pressing Space in a text input child (%s)', + async (_, listProps) => { + let onSelectionChange = jest.fn(); + let {getAllByRole, getByRole} = render( + + + Apple + + + Banana + + + ); + + let rows = getAllByRole('row'); + let input = getByRole('textbox'); + + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard(' '); + expect(input).toHaveValue(' '); + expect(onSelectionChange).not.toHaveBeenCalled(); + } + ); + }); }); diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index d9072b35a17..2e0f634ad75 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -47,7 +47,8 @@ import {Virtualizer} from '../src/Virtualizer'; let { EmptyTreeStaticStory: EmptyLoadingTree, LoadingStoryDepOnTopStory: LoadingMoreTree, - TreeWithDragAndDrop + TreeWithDragAndDrop, + TreeWithTextFieldStory } = composeStories(stories); let onSelectionChange = jest.fn(); @@ -2838,6 +2839,92 @@ describe('Tree', () => { expect(rows[18]).toHaveAttribute('aria-posinset', '1'); expect(rows[18]).toHaveAttribute('aria-setsize', '1'); }); + + describe('tab navigation and textfields', () => { + it('should not navigate rows when arrow keys are pressed while a text input child has focus', async () => { + let {getByRole} = render(); + let treeTester = testUtilUser.createTester('Tree', {root: getByRole('treegrid')}); + let rows = treeTester.getRows(); + let input = getByRole('textbox', {name: 'Name'}); + + // tab past the before tree input + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(input); + }); + + it('should not trigger typeahead when typing in a text input child', async () => { + let {getByRole} = render(); + let treeTester = testUtilUser.createTester('Tree', {root: getByRole('treegrid')}); + let rows = treeTester.getRows(); + let input = getByRole('textbox', {name: 'Name'}); + + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('row'); + expect(document.activeElement).toBe(input); + expect(input).toHaveValue('row'); + }); + + it('should not trigger selection when pressing Space in a text input child of a leaf row', async () => { + let onSelectionChange = jest.fn(); + let {getByRole} = render( + + ); + let treeTester = testUtilUser.createTester('Tree', {root: getByRole('treegrid')}); + let rows = treeTester.getRows(); + let input = getByRole('textbox', {name: 'Name'}); + + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard(' '); + expect(input).toHaveValue(' '); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should allow typing space in the text input child of a parent row', async () => { + let onSelectionChange = jest.fn(); + let {getByRole} = render( + + ); + let treeTester = testUtilUser.createTester('Tree', {root: getByRole('treegrid')}); + let rows = treeTester.getRows(); + + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.tab(); + await user.tab(); + let parentInput = getByRole('textbox', {name: 'row 1 input'}); + expect(document.activeElement).toBe(parentInput); + + await user.keyboard(' '); + expect(parentInput).toHaveValue(' '); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + }); }); AriaTreeTests({ From 5dfa72fe4bb72a3cf806ba54bf21af067bcaff4e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 5 Jun 2026 11:42:40 -0700 Subject: [PATCH 05/13] make presses on components in rows not trigger selection --- .../stories/GridList.stories.tsx | 92 +++++++++++++++++-- .../stories/Tree.stories.tsx | 47 ++++++++-- .../test/GridList.test.js | 74 +++++++++++++-- .../src/gridlist/useGridListItem.ts | 35 ++++++- 4 files changed, 224 insertions(+), 24 deletions(-) diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 9dc48496b6e..5adc1814509 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -12,9 +12,10 @@ import {action} from 'storybook/actions'; import {Button} from '../src/Button'; -import {Checkbox, CheckboxProps} from '../src/Checkbox'; +import {Checkbox, CheckboxGroup, CheckboxProps} from '../src/Checkbox'; import {classNames} from '@adobe/react-spectrum/private/utils/classNames'; import {Collection} from 'react-aria/Collection'; +import {ComboBox} from '../src/ComboBox'; import {Dialog, DialogTrigger} from '../src/Dialog'; import {DropIndicator, useDragAndDrop} from '../src/useDragAndDrop'; import {GridLayout} from '../src/GridLayout'; @@ -30,8 +31,9 @@ import { import {Heading} from '../src/Heading'; import {Input} from '../src/Input'; import {Key} from '@react-types/shared'; +import {ListBox} from '../src/ListBox'; import {ListLayout, Size, WaterfallLayout} from 'react-stately/useVirtualizerState'; -import {LoadingSpinner} from './utils'; +import {LoadingSpinner, MyListBoxItem} from './utils'; import {LoadingState} from '@react-types/shared'; import {Menu, MenuItem, MenuTrigger} from '../src/Menu'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; @@ -47,6 +49,7 @@ import {useAsyncList} from 'react-stately/useAsyncList'; import {useListData} from 'react-stately/useListData'; import {Virtualizer} from '../src/Virtualizer'; import './styles.css'; +import {Radio, RadioGroup} from '../src/RadioGroup'; export default { title: 'React Aria Components/GridList', @@ -968,12 +971,16 @@ export const AsyncGridListGridVirtualized: StoryObj { + return
No results
; +}; + // TODO: bugs to investigate // clicking on the textfield when selection is enabled causes selection to be toggled export const GridListWithTextfield: GridListStory = args => { let isHorizontalStack = args.orientation === 'horizontal' && args.layout !== 'grid'; return ( - <> +
{ Toolbar - - - + + + @@ -1026,9 +1033,80 @@ export const GridListWithTextfield: GridListStory = args => { + + RadioGroup + + + Dog + + + Cat + + + Dragon + + + + + CheckboxGroup + + + + Soccer + + + + Baseball + + + + Basketball + + + + + ComboBox + +
+ + +
+ + + Foo + Bar + Baz + Google + + +
+
- +
); }; diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 3d192f14c54..9313fb48120 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -15,13 +15,15 @@ import {Button} from '../src/Button'; import {Checkbox, CheckboxProps} from '../src/Checkbox'; import {classNames} from '@adobe/react-spectrum/private/utils/classNames'; import {Collection} from 'react-aria/Collection'; +import {ComboBox} from '../src/ComboBox'; import {DroppableCollectionReorderEvent, Key} from '@react-types/shared'; import {Input} from '../src/Input'; import {isTextDropItem, useDragAndDrop} from '../exports/useDragAndDrop'; +import {ListBox} from '../src/ListBox'; import {ListLayout} from 'react-stately/useVirtualizerState'; import {Menu, MenuItem, MenuTrigger} from '../src/Menu'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; -import {MyMenuItem} from './utils'; +import {MyListBoxItem, MyMenuItem} from './utils'; import {Popover} from '../src/Popover'; import React, {JSX, ReactNode, useCallback, useState} from 'react'; import styles from '../example/index.css'; @@ -47,7 +49,7 @@ import './styles.css'; export default { title: 'React Aria Components/Tree', component: Tree, - excludeStories: ['TreeExampleStaticRender'] + excludeStories: ['TreeExampleStaticRender', 'TreeWithTextField'] } as Meta; export type TreeStory = StoryFn; @@ -1805,15 +1807,19 @@ export const HugeVirtualizedTree: StoryObj = { render: args => }; +let comboboxEmptyState = () => { + return
No results
; +}; + // TODO: bugs to investigate // clicking on the textfield when selection is enabled causes selection to be toggled export function TreeWithTextField(props: TreeProps) { return ( - <> +
(props: TreeProps) { textValue="Toolbar" interactive={ - - - + + + }> Toolbar @@ -1877,7 +1883,30 @@ export function TreeWithTextField(props: TreeProps) { }> + interactive={ + +
+ + +
+ + + Foo + Bar + Baz + Google + + +
+ }> Nested child 1
(props: TreeProps) {
- +
); } diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 2ec0df5d91a..8772dd3720e 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -1841,7 +1841,7 @@ describe('GridList', () => { ])( 'should not navigate rows when arrow keys are pressed while a text input child has focus (%s)', async (_, listProps) => { - let {getAllByRole, getByRole} = render( + let {getByRole} = render( Apple @@ -1852,7 +1852,8 @@ describe('GridList', () => { ); - let rows = getAllByRole('row'); + let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')}); + let rows = gridListTester.getRows(); let input = getByRole('textbox'); await user.tab(); @@ -1877,7 +1878,7 @@ describe('GridList', () => { ])( 'should not trigger typeahead when typing in a text input child (%s)', async (_, listProps) => { - let {getAllByRole, getByRole} = render( + let {getByRole} = render( Apple @@ -1888,7 +1889,8 @@ describe('GridList', () => { ); - let rows = getAllByRole('row'); + let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')}); + let rows = gridListTester.getRows(); let input = getByRole('textbox'); await user.tab(); @@ -1909,7 +1911,7 @@ describe('GridList', () => { 'should not trigger selection when pressing Space in a text input child (%s)', async (_, listProps) => { let onSelectionChange = jest.fn(); - let {getAllByRole, getByRole} = render( + let {getByRole} = render( { ); - let rows = getAllByRole('row'); + let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')}); + let rows = gridListTester.getRows(); let input = getByRole('textbox'); await user.tab(); @@ -1937,5 +1940,64 @@ describe('GridList', () => { expect(onSelectionChange).not.toHaveBeenCalled(); } ); + + it.each([ + ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}], + ['layout="grid"', {layout: 'grid'}] + ])( + 'should not trigger selection when clicking on a tabbable child element (%s)', + async (_, listProps) => { + let onSelectionChange = jest.fn(); + let {getByRole} = render( + + + Apple + + + Banana + + + ); + + let input = getByRole('textbox'); + await user.click(input); + expect(document.activeElement).toBe(input); + expect(onSelectionChange).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}], + ['layout="grid"', {layout: 'grid'}] + ])( + 'should still trigger selection when clicking on a row with no tabbable children (%s)', + async (_, listProps) => { + let onSelectionChange = jest.fn(); + let {getByRole} = render( + + + Apple + + + Banana + + + ); + + let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')}); + let rows = gridListTester.getRows(); + await user.click(rows[0]); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['item1'])); + } + ); }); }); diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index d27bac3fcad..7c17e58fb86 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -30,8 +30,15 @@ import { import {getFocusableTreeWalker} from '../focus/FocusScope'; import {getRowId, listMap} from './utils'; import {getScrollParent} from '../utils/getScrollParent'; -import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react'; +import { + HTMLAttributes, + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, + useRef +} from 'react'; import {isFocusVisible} from '../interactions/useFocusVisible'; +import {isTabbable} from '../utils/isFocusable'; import type {ListState} from 'react-stately/useListState'; import {mergeProps} from '../utils/mergeProps'; import {scrollIntoViewport} from '../utils/scrollIntoView'; @@ -416,7 +423,9 @@ export function useGridListItem( id: getRowId(state, node.key) }); - // TODO we need to guard against space/enter triggering selection/row link via usePress (from itemProps) so check if propagation + // TODO: guarding against selection when firing space/enter/click on a element in a row is technically not only limited to textfields so I + // am not making it specific to keyboardNavigationBehavior = tab, but maybe we should still? + // we need to guard against space/enter triggering selection/row link via usePress (from itemProps) so check if propagation // is stopped. this also fixes space not working in a textfield in a tree parent row let baseOnKeyDown = rowProps.onKeyDown; rowProps.onKeyDown = (e: ReactKeyboardEvent) => { @@ -426,6 +435,28 @@ export function useGridListItem( } }; + // guard against presses triggering row selecition when they happen on elements within the row + // am currently assuming if it is tabbable it is interactive, but maybe can use a different kind of check + let baseOnPointerDown = rowProps.onPointerDown; + rowProps.onPointerDown = (e: ReactPointerEvent) => { + let target = getEventTarget(e) as Element | null; + if (target && target !== ref.current && isTabbable(target)) { + e.stopPropagation(); + return; + } + baseOnPointerDown?.(e); + }; + + let baseOnMouseDown = rowProps.onMouseDown; + rowProps.onMouseDown = (e: ReactMouseEvent) => { + let target = getEventTarget(e) as Element | null; + if (target && target !== ref.current && isTabbable(target)) { + e.stopPropagation(); + return; + } + baseOnMouseDown?.(e); + }; + if (isVirtualized) { let {collection} = state; let nodes = [...collection]; From 0aecbf1425281e1d518582701856c9f83e14b6c7 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 5 Jun 2026 15:35:28 -0700 Subject: [PATCH 06/13] add docs for rac --- packages/@react-spectrum/s2/src/TreeView.tsx | 1 + .../dev/s2-docs/pages/react-aria/GridList.mdx | 55 ++++++++++++++++++- .../dev/s2-docs/pages/react-aria/Tree.mdx | 55 ++++++++++++++++++- .../stories/GridList.stories.tsx | 2 - .../stories/Tree.stories.tsx | 14 ++--- packages/react-aria/src/tree/useTree.ts | 5 +- starters/docs/src/ComboBox.css | 2 + starters/docs/src/ComboBox.tsx | 2 +- starters/docs/src/Tree.tsx | 12 +++- 9 files changed, 127 insertions(+), 21 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index 4573d2d5331..73abdd4bb11 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -104,6 +104,7 @@ export interface TreeViewProps | 'selectionBehavior' | 'onScroll' | 'onCellAction' + | 'keyboardNavigationBehavior' | keyof GlobalDOMAttributes >, UnsafeStyles, diff --git a/packages/dev/s2-docs/pages/react-aria/GridList.mdx b/packages/dev/s2-docs/pages/react-aria/GridList.mdx index c9511c40470..90eee7f2a2f 100644 --- a/packages/dev/s2-docs/pages/react-aria/GridList.mdx +++ b/packages/dev/s2-docs/pages/react-aria/GridList.mdx @@ -672,7 +672,7 @@ function Example(props) { Use the `layout` and `orientation` props to arrange items in horizontal and vertical stacks and grids. This affects keyboard navigation and drag and drop behavior. -```tsx render docs={docs.exports.GridList} links={docs.links} props={['layout', 'orientation', 'keyboardNavigationBehavior']} initialProps={{layout: 'grid', orientation: 'horizontal', keyboardNavigationBehavior: 'tab'}} wide +```tsx render docs={docs.exports.GridList} links={docs.links} props={['layout', 'orientation']} initialProps={{layout: 'grid', orientation: 'horizontal'}} wide "use client"; import {GridList, GridListItem, Text} from 'vanilla-starter/GridList'; @@ -704,6 +704,59 @@ let photos = [
``` +## Keyboard navigation + +By default, GridList uses arrow key navigation to move focus into rows. Set `keyboardNavigationBehavior="tab"` to have Tab move focus in and out of a row. + +```tsx render +"use client"; +import {GridList, GridListItem, Text} from 'vanilla-starter/GridList'; +import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox'; + +///- begin collapse -/// +function PermissionPicker({label}) { + return ( + + Can view + Can comment + Can edit + + ); +} +///- end collapse -/// + + + + + Desert Sunset + PNG • 2/3/2024 + + + + + Hiking Trail + JPEG • 1/10/2022 + + + + + Lion + JPEG • 8/28/2021 + + + + + Mountain Sunrise + PNG • 3/15/2015 + + + +``` + ## Drag and drop GridList supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the list as a whole, on individual items, insert new items between existing ones, or reorder items. React Aria supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the [drag and drop guide](dnd?component=GridList) to learn more. diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index 9094137b4c2..3803c657d8e 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -257,7 +257,7 @@ import {Tree, TreeHeader, TreeItem, TreeSection} from 'vanilla-starter/Tree'; - + Documents @@ -322,6 +322,59 @@ function Example(props) { } ``` +## Keyboard navigation + +By default, Tree uses arrow key navigation to move focus into rows. Set `keyboardNavigationBehavior="tab"` to have Option move focus in and out of a row. + +```tsx render +"use client"; +import {Tree, TreeItem, TreeItemContent} from 'vanilla-starter/Tree'; +import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox'; + +///- begin collapse -/// +function PermissionPicker({label}) { + return ( + + Can view + Can comment + Can edit + + ); +} +///- end collapse -/// + + + + + + Weekly Report.pdf + + + + + + Budget.xlsx + + + + + + + + Sunset.jpg + + + + + +``` + ## Drag and drop Tree supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the list as a whole, on individual items, insert new items between existing ones, or reorder items. React Aria supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the [drag and drop guide](dnd?component=Tree) to learn more. diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 5adc1814509..5b2e8b07582 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -975,8 +975,6 @@ let comboboxEmptyState = () => { return
No results
; }; -// TODO: bugs to investigate -// clicking on the textfield when selection is enabled causes selection to be toggled export const GridListWithTextfield: GridListStory = args => { let isHorizontalStack = args.orientation === 'horizontal' && args.layout !== 'grid'; return ( diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 9313fb48120..b5920c692f6 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -1811,8 +1811,6 @@ let comboboxEmptyState = () => { return
No results
; }; -// TODO: bugs to investigate -// clicking on the textfield when selection is enabled causes selection to be toggled export function TreeWithTextField(props: TreeProps) { return (
@@ -1822,8 +1820,7 @@ export function TreeWithTextField(props: TreeProps) { style={{width: 400}} aria-label="tree with textfields" disabledKeys={['rawinput']} - // TODO: cast for now, tab behavior to be added to Tree later (will need tests/docs and whatnot)? - {...({keyboardNavigationBehavior: 'tab'} as any)} + keyboardNavigationBehavior="tab" {...props}> = { disabledBehavior: 'selection' }, argTypes: { - // TODO: add later - // keyboardNavigationBehavior: { - // control: 'radio', - // options: ['arrow', 'tab'] - // }, + keyboardNavigationBehavior: { + control: 'radio', + options: ['arrow', 'tab'] + }, selectionMode: { control: 'radio', options: ['none', 'single', 'multiple'] diff --git a/packages/react-aria/src/tree/useTree.ts b/packages/react-aria/src/tree/useTree.ts index 6a2f38793aa..dd98bd85c9e 100644 --- a/packages/react-aria/src/tree/useTree.ts +++ b/packages/react-aria/src/tree/useTree.ts @@ -21,10 +21,7 @@ import {TreeState} from 'react-stately/useTreeState'; export interface TreeProps extends GridListProps {} -export interface AriaTreeProps extends Omit< - AriaGridListProps, - 'keyboardNavigationBehavior' -> {} +export interface AriaTreeProps extends AriaGridListProps {} export interface AriaTreeOptions extends Omit< AriaGridListOptions, 'children' | 'shouldFocusWrap' diff --git a/starters/docs/src/ComboBox.css b/starters/docs/src/ComboBox.css index 12f8d59edef..6a9f4a33013 100644 --- a/starters/docs/src/ComboBox.css +++ b/starters/docs/src/ComboBox.css @@ -2,6 +2,8 @@ @import './TextField.css'; .react-aria-ComboBox { + display: flex; + flex-direction: column; color: var(--text-color); width: calc(var(--spacing) * 50); diff --git a/starters/docs/src/ComboBox.tsx b/starters/docs/src/ComboBox.tsx index e0ec8c027a6..80ef180ebb9 100644 --- a/starters/docs/src/ComboBox.tsx +++ b/starters/docs/src/ComboBox.tsx @@ -35,7 +35,7 @@ export function ComboBox({ }: ComboBoxProps) { return ( - + {label && }
diff --git a/starters/docs/src/Tree.tsx b/starters/docs/src/Tree.tsx index 3928dfd5a4e..425e7ea2481 100644 --- a/starters/docs/src/Tree.tsx +++ b/starters/docs/src/Tree.tsx @@ -48,15 +48,21 @@ export function TreeItemContent( } export interface TreeItemProps extends Partial { - title: React.ReactNode; + title?: React.ReactNode; } export function TreeItem(props: TreeItemProps) { let textValue = typeof props.title === 'string' ? props.title : ''; return ( - {props.title} - {props.children} + {props.title != null ? ( + <> + {props.title} + {props.children} + + ) : ( + props.children + )} ); } From 4c352924ac3b815a39e111d6c82ab4c62ee17a65 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 8 Jun 2026 11:19:49 -0700 Subject: [PATCH 07/13] update docs and dedupe tree logic in useGridListItem keyboard handlers --- .../dev/s2-docs/pages/react-aria/GridList.mdx | 44 +++---- .../dev/s2-docs/pages/react-aria/Tree.mdx | 1 + .../src/gridlist/useGridListItem.ts | 115 ++++++++---------- 3 files changed, 73 insertions(+), 87 deletions(-) diff --git a/packages/dev/s2-docs/pages/react-aria/GridList.mdx b/packages/dev/s2-docs/pages/react-aria/GridList.mdx index 90eee7f2a2f..dd8fb401865 100644 --- a/packages/dev/s2-docs/pages/react-aria/GridList.mdx +++ b/packages/dev/s2-docs/pages/react-aria/GridList.mdx @@ -714,6 +714,16 @@ import {GridList, GridListItem, Text} from 'vanilla-starter/GridList'; import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox'; ///- begin collapse -/// +///- begin collapse -/// +let photos = [ + {id: 1, title: 'Desert Sunset', description: 'PNG • 2/3/2024', src: 'https://images.unsplash.com/photo-1705034598432-1694e203cdf3?q=80&w=600&auto=format&fit=crop'}, + {id: 2, title: 'Hiking Trail', description: 'JPEG • 1/10/2022', src: 'https://images.unsplash.com/photo-1722233987129-61dc344db8b6?q=80&w=600&auto=format&fit=crop'}, + {id: 3, title: 'Lion', description: 'JPEG • 8/28/2021', src: 'https://images.unsplash.com/photo-1629812456605-4a044aa38fbc?q=80&w=600&auto=format&fit=crop'}, + {id: 4, title: 'Mountain Sunrise', description: 'PNG • 3/15/2015', src: 'https://images.unsplash.com/photo-1722172118908-1a97c312ce8c?q=80&w=600&auto=format&fit=crop'}, + {id: 5, title: 'Giraffe tongue', description: 'PNG • 11/27/2019', src: 'https://images.unsplash.com/photo-1574870111867-089730e5a72b?q=80&w=600&auto=format&fit=crop'}, + {id: 6, title: 'Golden Hour', description: 'WEBP • 7/24/2024', src: 'https://images.unsplash.com/photo-1718378037953-ab21bf2cf771?q=80&w=600&auto=format&fit=crop'}, +]; + function PermissionPicker({label}) { return ( @@ -729,31 +739,17 @@ function PermissionPicker({label}) { /*- begin highlight -*/ keyboardNavigationBehavior="tab" /*- end highlight -*/ + items={photos} + selectionMode="multiple" aria-label="Shared files"> - - - Desert Sunset - PNG • 2/3/2024 - - - - - Hiking Trail - JPEG • 1/10/2022 - - - - - Lion - JPEG • 8/28/2021 - - - - - Mountain Sunrise - PNG • 3/15/2015 - - + {item => ( + + + {item.title} + {item.description} + + + )} ``` diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index 3803c657d8e..7ede3dd972e 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -347,6 +347,7 @@ function PermissionPicker({label}) { /*- begin highlight -*/ keyboardNavigationBehavior="tab" /*- end highlight -*/ + selectionMode="multiple" defaultExpandedKeys={['documents', 'photos']} aria-label="Shared files" style={{width: 420}}> diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 7c17e58fb86..d92412132c5 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -188,36 +188,10 @@ export function useGridListItem( let walker = getFocusableTreeWalker(ref.current); walker.currentNode = activeElement; - if ('expandedKeys' in state && activeElement === ref.current) { - if ( - e.key === EXPANSION_KEYS['expand'][direction] && - state.selectionManager.focusedKey === node.key && - hasChildRows && - !state.expandedKeys.has(node.key) - ) { - state.toggleKey(node.key); - e.stopPropagation(); - return; - } else if ( - e.key === EXPANSION_KEYS['collapse'][direction] && - state.selectionManager.focusedKey === node.key - ) { - // If item is collapsible, collapse it; else move to parent - if (hasChildRows && state.expandedKeys.has(node.key)) { - state.toggleKey(node.key); - e.stopPropagation(); - return; - } else if ( - !state.expandedKeys.has(node.key) && - node.parentKey && - state.collection.getItem(node.parentKey)?.type === 'item' - ) { - // Item is a leaf or already collapsed, move focus to parent - state.selectionManager.setFocusedKey(node.parentKey); - e.stopPropagation(); - return; - } - } + if ( + handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current) + ) { + return; } switch (e.key) { @@ -341,39 +315,10 @@ export function useGridListItem( return; } - // TODO: for tree expansion since we turn off the capturing listener if keyboardNavigationBehavior = tab - // copied from above, extract into helper later - // need to support tab keyboard navigation in tree - if ('expandedKeys' in state && activeElement === ref.current) { - if ( - e.key === EXPANSION_KEYS['expand'][direction] && - state.selectionManager.focusedKey === node.key && - hasChildRows && - !state.expandedKeys.has(node.key) - ) { - state.toggleKey(node.key); - e.stopPropagation(); - return; - } else if ( - e.key === EXPANSION_KEYS['collapse'][direction] && - state.selectionManager.focusedKey === node.key - ) { - // If item is collapsible, collapse it; else move to parent - if (hasChildRows && state.expandedKeys.has(node.key)) { - state.toggleKey(node.key); - e.stopPropagation(); - return; - } else if ( - !state.expandedKeys.has(node.key) && - node.parentKey && - state.collection.getItem(node.parentKey)?.type === 'item' - ) { - // Item is a leaf or already collapsed, move focus to parent - state.selectionManager.setFocusedKey(node.parentKey); - e.stopPropagation(); - return; - } - } + if ( + handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current) + ) { + return; } } @@ -484,6 +429,50 @@ export function useGridListItem( }; } +function handleTreeExpansionKeys( + e: ReactKeyboardEvent, + state: ListState | TreeState, + node: RSNode, + hasChildRows: boolean | undefined, + direction: string, + activeElement: Element | null, + rowRef: FocusableElement | null +): boolean { + if (!('expandedKeys' in state) || activeElement !== rowRef) { + return false; + } + if ( + e.key === EXPANSION_KEYS['expand'][direction] && + state.selectionManager.focusedKey === node.key && + hasChildRows && + !state.expandedKeys.has(node.key) + ) { + state.toggleKey(node.key); + e.stopPropagation(); + return true; + } else if ( + e.key === EXPANSION_KEYS['collapse'][direction] && + state.selectionManager.focusedKey === node.key + ) { + // If item is collapsible, collapse it; else move to parent + if (hasChildRows && state.expandedKeys.has(node.key)) { + state.toggleKey(node.key); + e.stopPropagation(); + return true; + } else if ( + !state.expandedKeys.has(node.key) && + node.parentKey && + state.collection.getItem(node.parentKey)?.type === 'item' + ) { + // Item is a leaf or already collapsed, move focus to parent + state.selectionManager.setFocusedKey(node.parentKey); + e.stopPropagation(); + return true; + } + } + return false; +} + function last(walker: TreeWalker) { let next: FocusableElement | null = null; let last: FocusableElement | null = null; From 4e8d6f519590cd1fb05b5176de35777b35c73b98 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 9 Jun 2026 10:45:25 -0700 Subject: [PATCH 08/13] create stories for table with textfields --- packages/react-aria-components/src/Table.tsx | 7 + .../stories/GridList.stories.tsx | 4 +- .../stories/Table.stories.tsx | 192 +++++++++++++++++- 3 files changed, 197 insertions(+), 6 deletions(-) diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 4b1b4092e47..fdb066e6e6f 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -607,6 +607,13 @@ export interface TableProps * @default 'all' */ disabledBehavior?: DisabledBehavior; + /** + * Whether keyboard navigation to focusable elements within the cells is + * via the left/right arrow keys or the tab key. + * + * @default 'arrow' + */ + keyboardNavigationBehavior?: 'arrow' | 'tab'; /** Handler that is called when a user performs an action on the row. */ onRowAction?: (key: Key) => void; /** diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 5b2e8b07582..f8f3baea393 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -1112,9 +1112,7 @@ GridListWithTextfield.story = { args: { layout: 'stack', orientation: 'vertical', - escapeKeyBehavior: 'clearSelection', - shouldSelectOnPressUp: false, - disallowTypeAhead: false + escapeKeyBehavior: 'clearSelection' }, argTypes: { layout: { diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index e6f5bebd729..31a6f3bc130 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -26,20 +26,26 @@ import { TableHeader, TableLoadMoreItem } from '../src/Table'; -import {Checkbox, CheckboxProps} from '../src/Checkbox'; +import {Checkbox, CheckboxGroup, CheckboxProps} from '../src/Checkbox'; import {Collection} from 'react-aria/Collection'; +import {ComboBox} from '../src/ComboBox'; import {Dialog, DialogTrigger} from '../src/Dialog'; import {DropIndicator, isTextDropItem, useDragAndDrop} from '../exports/useDragAndDrop'; import {Heading} from '../src/Heading'; -import {LoadingSpinner, MyMenuItem} from './utils'; -import {Menu, MenuTrigger} from '../src/Menu'; +import {Input} from '../src/Input'; +import {ListBox} from '../src/ListBox'; +import {LoadingSpinner, MyListBoxItem, MyMenuItem} from './utils'; +import {Menu, MenuItem, MenuTrigger} from '../src/Menu'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import {Modal, ModalOverlay} from '../src/Modal'; import {Popover} from '../src/Popover'; +import {Radio, RadioGroup} from '../src/RadioGroup'; import React, {JSX, startTransition, Suspense, useState} from 'react'; import {Selection} from '@react-types/shared'; import styles from '../example/index.css'; import {TableLayout} from '../src/TableLayout'; +import {TextField} from '../src/TextField'; +import {Toolbar} from '../src/Toolbar'; import {useAsyncList} from 'react-stately/useAsyncList'; import {useListData} from 'react-stately/useListData'; import {Virtualizer} from '../src/Virtualizer'; @@ -2181,3 +2187,183 @@ export const TableSectionDnd: TableStory = args => { ); }; + +let comboboxEmptyState = () => { + return
No results
; +}; + +export const TableWithTextfield: TableStory = args => { + return ( + + + + + + Col 1 + Col 2 + Col 3 + Col 4 + + + + + + + RAC Textfield + + + + + + Raw input + + + + + + + + + TextField + Button + + {' '} + + + {' '} + + + Toolbar + + {' '} + + + + + + + + + + + + + Menu + + {' '} + + + + + Cut + Copy + Paste + + + + + RadioGroup + + {' '} + + + Dog + + + Cat + + + Dragon + + + + + + + + + CheckboxGroup + + {' '} + + + + Soccer + + + + Baseball + + + + Basketball + + + + ComboBox + + +
+ + +
+ + + Foo + Bar + Baz + Google + + +
+
+
+
+
+ ); +}; + +TableWithTextfield.story = { + argTypes: { + keyboardNavigationBehavior: { + control: 'radio', + options: ['arrow', 'tab'] + }, + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] + } + } +}; From dde69eb2f6910b04be75ac6d3529d073e4b1af98 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 9 Jun 2026 13:05:57 -0700 Subject: [PATCH 09/13] support textfields in tables --- packages/react-aria-components/src/Table.tsx | 7 - .../stories/GridList.stories.tsx | 4 +- .../stories/Table.stories.tsx | 327 +++++++++--------- packages/react-aria/src/grid/useGrid.ts | 13 +- packages/react-aria/src/grid/useGridCell.ts | 87 ++++- packages/react-aria/src/grid/utils.ts | 1 + .../src/gridlist/useGridListItem.ts | 1 + .../react-stately/src/table/useTableState.ts | 7 + 8 files changed, 268 insertions(+), 179 deletions(-) diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index fdb066e6e6f..4b1b4092e47 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -607,13 +607,6 @@ export interface TableProps * @default 'all' */ disabledBehavior?: DisabledBehavior; - /** - * Whether keyboard navigation to focusable elements within the cells is - * via the left/right arrow keys or the tab key. - * - * @default 'arrow' - */ - keyboardNavigationBehavior?: 'arrow' | 'tab'; /** Handler that is called when a user performs an action on the row. */ onRowAction?: (key: Key) => void; /** diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index f8f3baea393..55adce21e64 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -979,7 +979,7 @@ export const GridListWithTextfield: GridListStory = args => { let isHorizontalStack = args.orientation === 'horizontal' && args.layout !== 'grid'; return (
- + { - +
); }; diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 31a6f3bc130..c6b2c19c480 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -2194,176 +2194,173 @@ let comboboxEmptyState = () => { export const TableWithTextfield: TableStory = args => { return ( - - - - - - Col 1 - Col 2 - Col 3 - Col 4 - - - - - - - RAC Textfield - - - - - - Raw input - - - - - - +
+ +
+ + - - TextField + Button - - {' '} - - - {' '} - - - Toolbar - - {' '} - - - - - - - + + Col 1 + Col 2 + Col 3 + Col 4 + + + + + + + RAC Textfield + + + + + + Raw input + + + + + + + + + TextField + Button + + + + + + + Toolbar + + + + + + + + - - - - - Menu - - {' '} - - - - - Cut - Copy - Paste - - - - - RadioGroup - - {' '} - - - Dog - - - Cat - - - Dragon - - - - - - - - - CheckboxGroup - - {' '} - - - - Soccer - - - - Baseball - - -
+ + + Foo + Bar + Baz + Google + + +
+ + + + + +
); }; -TableWithTextfield.story = { - argTypes: { - keyboardNavigationBehavior: { - control: 'radio', - options: ['arrow', 'tab'] - }, - selectionMode: { - control: 'radio', - options: ['none', 'single', 'multiple'] - }, - selectionBehavior: { - control: 'radio', - options: ['toggle', 'replace'] - } +TableWithTextfield.argTypes = { + keyboardNavigationBehavior: { + control: 'radio', + options: ['arrow', 'tab'] + }, + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] } }; diff --git a/packages/react-aria/src/grid/useGrid.ts b/packages/react-aria/src/grid/useGrid.ts index b9897552a7f..88244b23e98 100644 --- a/packages/react-aria/src/grid/useGrid.ts +++ b/packages/react-aria/src/grid/useGrid.ts @@ -82,6 +82,13 @@ export interface GridProps extends DOMProps, AriaLabelingProps { escapeKeyBehavior?: 'clearSelection' | 'none'; /** Whether selection should occur on press up instead of press down. */ shouldSelectOnPressUp?: boolean; + /** + * Whether keyboard navigation to focusable elements within grid cells is + * via arrow keys or the tab key. + * + * @default 'arrow' + */ + keyboardNavigationBehavior?: 'arrow' | 'tab'; } export interface GridAria { @@ -113,7 +120,8 @@ export function useGrid( onRowAction, onCellAction, escapeKeyBehavior = 'clearSelection', - shouldSelectOnPressUp + shouldSelectOnPressUp, + keyboardNavigationBehavior = 'arrow' } = props; let {selectionManager: manager} = state; @@ -164,7 +172,8 @@ export function useGrid( gridMap.set(state, { keyboardDelegate: delegate, actions: {onRowAction, onCellAction}, - shouldSelectOnPressUp + shouldSelectOnPressUp, + keyboardNavigationBehavior }); let descriptionProps = useHighlightSelectionDescription({ diff --git a/packages/react-aria/src/grid/useGridCell.ts b/packages/react-aria/src/grid/useGridCell.ts index a7f366eab2d..ce6b395251f 100644 --- a/packages/react-aria/src/grid/useGridCell.ts +++ b/packages/react-aria/src/grid/useGridCell.ts @@ -27,8 +27,14 @@ import { import {gridMap} from './utils'; import {GridState} from 'react-stately/private/grid/useGridState'; import {isFocusVisible} from '../interactions/useFocusVisible'; +import {isTabbable} from '../utils/isFocusable'; import {mergeProps} from '../utils/mergeProps'; -import {KeyboardEvent as ReactKeyboardEvent, useRef} from 'react'; +import { + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, + useRef +} from 'react'; import {scrollIntoViewport} from '../utils/scrollIntoView'; import {useLocale} from '../i18n/I18nProvider'; import {useSelectableItem} from '../selection/useSelectableItem'; @@ -82,9 +88,14 @@ export function useGridCell>( let {direction} = useLocale(); let { keyboardDelegate, - actions: {onCellAction} + actions: {onCellAction}, + keyboardNavigationBehavior } = 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. let keyWhenFocused = useRef(null); @@ -252,6 +263,44 @@ export function useGridCell>( } }; + let onKeyDown = (e: ReactKeyboardEvent) => { + let activeElement = getActiveElement(); + if ( + !nodeContains(e.currentTarget, getEventTarget(e) as Element) || + state.isKeyboardNavigationDisabled || + !ref.current || + !activeElement + ) { + return; + } + + if (keyboardNavigationBehavior === 'tab') { + if ( + getEventTarget(e) !== ref.current && + (isArrowKey(e.key) || isCharacterKey(e.key) || e.key === 'Enter') + ) { + e.stopPropagation(); + 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 = activeElement; + let next = e.shiftKey ? walker.previousNode() : walker.nextNode(); + + if (next) { + e.stopPropagation(); + } + } + } + } + }; + // 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 => { @@ -280,7 +329,8 @@ export function useGridCell>( let gridCellProps: DOMAttributes = mergeProps(itemProps, { role: 'gridcell', - onKeyDownCapture, + onKeyDownCapture: keyboardNavigationBehavior === 'tab' ? undefined : onKeyDownCapture, + onKeyDown: keyboardNavigationBehavior === 'tab' ? onKeyDown : undefined, 'aria-colspan': node.colSpan, 'aria-colindex': node.colIndex != null ? node.colIndex + 1 : undefined, // aria-colindex is 1-based colSpan: isVirtualized ? undefined : node.colSpan, @@ -291,6 +341,29 @@ export function useGridCell>( gridCellProps['aria-colindex'] = (node.colIndex ?? node.index) + 1; // aria-colindex is 1-based } + // TODO: same logic as in useGridListItem + // doesn't have the keydown handler part since we don't seem to have the same problem where Enter + // triggers selection when in a textfield + let baseOnPointerDown = gridCellProps.onPointerDown; + gridCellProps.onPointerDown = (e: ReactPointerEvent) => { + let target = getEventTarget(e) as Element | null; + if (target && target !== ref.current && isTabbable(target)) { + e.stopPropagation(); + return; + } + baseOnPointerDown?.(e); + }; + + let baseOnMouseDown = gridCellProps.onMouseDown; + gridCellProps.onMouseDown = (e: ReactMouseEvent) => { + let target = getEventTarget(e) as Element | null; + if (target && target !== ref.current && isTabbable(target)) { + e.stopPropagation(); + return; + } + baseOnMouseDown?.(e); + }; + // When pressing with a pointer and cell selection is not enabled, usePress will be applied to the // row rather than the cell. However, when the row is draggable, usePress cannot preventDefault // on pointer down, so the browser will try to focus the cell which has a tabIndex applied. @@ -329,3 +402,11 @@ function last(walker: TreeWalker) { } while (last); return next; } + +function isArrowKey(key: string): boolean { + return key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight'; +} + +function isCharacterKey(key: string): boolean { + return key.length === 1 || !/^[A-Z]/i.test(key); +} diff --git a/packages/react-aria/src/grid/utils.ts b/packages/react-aria/src/grid/utils.ts index 35d756903b2..1d785b96405 100644 --- a/packages/react-aria/src/grid/utils.ts +++ b/packages/react-aria/src/grid/utils.ts @@ -22,6 +22,7 @@ interface GridMapShared { onCellAction?: (key: Key) => void; }; shouldSelectOnPressUp?: boolean; + keyboardNavigationBehavior?: 'arrow' | 'tab'; } // Used to share: diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index d92412132c5..816463e3c89 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -307,6 +307,7 @@ export function useGridListItem( // (note that this breaks TagGroup's old behavior of using arrow keys to move from "x" button to next tag and typeselect when inside a card/row) // should it just stop propagation for all events since we can't rely on non-RAC components stopping propagation even they handled the event // Will need to do something similar for click? + // TODO: have it stop on all events that bubbled up from the cell (will need to let Tab go through since we need useSelectableCollection to handle that) if ( getEventTarget(e) !== ref.current && (isArrowKey(e.key) || isCharacterKey(e.key) || e.key === 'Enter') diff --git a/packages/react-stately/src/table/useTableState.ts b/packages/react-stately/src/table/useTableState.ts index 3445d9c9dbb..9d0fa83f766 100644 --- a/packages/react-stately/src/table/useTableState.ts +++ b/packages/react-stately/src/table/useTableState.ts @@ -49,6 +49,13 @@ export interface TableProps extends MultipleSelection, Sortable, Expandable { shouldSelectOnPressUp?: boolean; /** The id of the column that displays hierarchical data. */ treeColumn?: Key; + /** + * Whether keyboard navigation to focusable elements within the cells is + * via the left/right arrow keys or the tab key. + * + * @default 'arrow' + */ + keyboardNavigationBehavior?: 'arrow' | 'tab'; } export interface TableState extends GridState> { From 8ea84d46875176b6b03188689d773b64bd6790ac Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 9 Jun 2026 13:56:55 -0700 Subject: [PATCH 10/13] add tests --- .../test/GridList.test.js | 5 +- .../react-aria-components/test/Table.test.js | 183 ++++++++++++++++++ 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 8772dd3720e..7d835f8c94c 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -1908,7 +1908,7 @@ describe('GridList', () => { ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}], ['layout="grid"', {layout: 'grid'}] ])( - 'should not trigger selection when pressing Space in a text input child (%s)', + 'should not trigger selection when pressing Space or Enter in a text input child (%s)', async (_, listProps) => { let onSelectionChange = jest.fn(); let {getByRole} = render( @@ -1938,6 +1938,9 @@ describe('GridList', () => { await user.keyboard(' '); expect(input).toHaveValue(' '); expect(onSelectionChange).not.toHaveBeenCalled(); + + await user.keyboard('{Enter}'); + expect(onSelectionChange).not.toHaveBeenCalled(); } ); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 1f83529ca71..de84c1b8a80 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -232,6 +232,42 @@ let EditableTable = ({ ); +let TabModeTable = props => ( + + + + Name + + Type + Notes + + + + Games + File folder + + + + + + + Program Files + File folder + + + + + + bootmgr + System file + + + + + +
+); + let DraggableTable = props => { let {dragAndDropHooks} = useDragAndDrop({ getItems: keys => [...keys].map(key => ({'text/plain': key})), @@ -3467,6 +3503,153 @@ describe('Table', () => { expect(tableTester.getFooterRows()).toHaveLength(1); expect(tableTester.getFooterRows()[0]).toHaveTextContent('Blah'); }); + + describe("keyboardNavigationBehavior='tab' and textfields in row", () => { + it('Tab from a focused cell moves focus to the first tabbable child', async () => { + let {getByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cells[cells.length - 1]); + await user.tab(); + expect(document.activeElement).toBe(getByRole('textbox', {name: 'Games notes'})); + }); + + it('Tab from a cell with no tabbable children or from the last child in a cell exits the table', async () => { + let {getAllByRole, getByRole} = render( +
+ + + +
+ ); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let rowheader = tableTester.getRowHeaders()[0]; + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + await user.tab(); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(rowheader); + await user.tab(); + let buttons = getAllByRole('button'); + expect(document.activeElement).toBe(buttons[2]); + + await user.tab({shift: true}); + await user.keyboard('{ArrowLeft}'); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cells[cells.length - 1]); + await user.tab(); + expect(document.activeElement).toBe(getByRole('textbox', {name: 'Games notes'})); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Button next to input'})); + await user.tab(); + expect(document.activeElement).toBe(buttons[2]); + }); + + it('Shift+Tab from a child returns focus to the cell', async () => { + let {getByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cells[cells.length - 1]); + await user.tab(); + expect(document.activeElement).toBe(getByRole('textbox', {name: 'Games notes'})); + await user.tab({shift: true}); + expect(document.activeElement).toBe(cells[cells.length - 1]); + }); + + it('should not navigate to next cell when arrow keys are pressed while a text input child has focus', async () => { + let {getByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cells[cells.length - 1]); + await user.tab(); + let input = getByRole('textbox', {name: 'Games notes'}); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(input); + }); + + it('should not trigger typeahead when typing in a text input child', async () => { + let {getByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cells[cells.length - 1]); + await user.tab(); + + let input = getByRole('textbox', {name: 'Games notes'}); + expect(document.activeElement).toBe(input); + await user.type(input, 'Games'); + expect(input).toHaveValue('Games'); + expect(document.activeElement).toBe(input); + }); + + it('should not trigger selection when pressing Space or Enter in a text input child', async () => { + let {getByRole} = render( + + ); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cells[cells.length - 1]); + await user.tab(); + let input = getByRole('textbox', {name: 'Games notes'}); + expect(document.activeElement).toBe(input); + + await user.keyboard(' '); + expect(input).toHaveValue(' '); + expect(onSelectionChange).not.toHaveBeenCalled(); + + await user.keyboard('{Enter}'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should not trigger selection when clicking on a tabbable child element', async () => { + let {getByRole} = render( + + ); + let input = getByRole('textbox', {name: 'Games notes'}); + + await user.click(input); + expect(document.activeElement).toBe(input); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should still trigger selection when clicking on a row with no tabbable children ', async () => { + let {getByRole} = render( + + ); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let row = tableTester.getRows()[0]; + + await user.click(row); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1'])); + }); + }); }); function HidingColumnsExample({dynamic = false}) { From fd92d8f62faed1f7fe81ea6d4d12d1a1fd32e970 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 9 Jun 2026 14:41:26 -0700 Subject: [PATCH 11/13] add table docs and update copy --- .../dev/s2-docs/pages/react-aria/GridList.mdx | 1 + .../dev/s2-docs/pages/react-aria/Table.mdx | 63 ++++++++++++++++++- .../dev/s2-docs/pages/react-aria/Tree.mdx | 1 + packages/dev/s2-docs/pages/s2/TableView.mdx | 62 ++++++++++++++++++ .../test/TagGroup.test.js | 2 +- 5 files changed, 127 insertions(+), 2 deletions(-) diff --git a/packages/dev/s2-docs/pages/react-aria/GridList.mdx b/packages/dev/s2-docs/pages/react-aria/GridList.mdx index dd8fb401865..74464237e46 100644 --- a/packages/dev/s2-docs/pages/react-aria/GridList.mdx +++ b/packages/dev/s2-docs/pages/react-aria/GridList.mdx @@ -707,6 +707,7 @@ let photos = [ ## Keyboard navigation By default, GridList uses arrow key navigation to move focus into rows. Set `keyboardNavigationBehavior="tab"` to have Tab move focus in and out of a row. +Use this when rows contain interactive elements such as text fields, where arrow keys and typing in the field should not trigger grid navigation or selection. ```tsx render "use client"; diff --git a/packages/dev/s2-docs/pages/react-aria/Table.mdx b/packages/dev/s2-docs/pages/react-aria/Table.mdx index 465f29684b3..ccf76b4cabf 100644 --- a/packages/dev/s2-docs/pages/react-aria/Table.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Table.mdx @@ -217,7 +217,7 @@ function FileTable() { {column => ( - {column.id === 'price' + {column.id === 'price' ? item.price.toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}) : item[column.id]} @@ -725,6 +725,67 @@ function subscribe(fn) { } ``` +## Keyboard navigation + +By default, Table uses arrow key navigation to move focus into cells. Set `keyboardNavigationBehavior="tab"` to have Tab move focus in and out of a cell. +Use this when cells contain interactive elements such as text fields, where arrow keys and typing in the field should not trigger grid navigation or selection. + +```tsx render +"use client"; +import {Table, TableHeader, Column, Row, TableBody, Cell} from 'vanilla-starter/Table'; +import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox'; + +function PermissionPicker({label}) { + return ( + + Can view + Can comment + Can edit + + ); +} + + + + Name + Type + Date Modified + Permission + + + + Games + Folder + 6/7/2023 + + + + Applications + Folder + 4/7/2025 + + + + 2024 Financial Report + PDF Document + 12/30/2024 + + + + Job Posting + Text Document + 1/18/2025 + + + +
+``` + ## Drag and drop Table supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the table as a whole, on individual rows, insert new rows between existing ones, or reorder rows. React Aria supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the [drag and drop guide](dnd?component=Table) to learn more. diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index 7ede3dd972e..2f626756fba 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -325,6 +325,7 @@ function Example(props) { ## Keyboard navigation By default, Tree uses arrow key navigation to move focus into rows. Set `keyboardNavigationBehavior="tab"` to have Option move focus in and out of a row. +Use this when rows contain interactive elements such as text fields, where arrow keys and typing in the field should not trigger grid navigation or selection. ```tsx render "use client"; diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index b41064e81b1..ff9ae04d9fb 100644 --- a/packages/dev/s2-docs/pages/s2/TableView.mdx +++ b/packages/dev/s2-docs/pages/s2/TableView.mdx @@ -947,6 +947,68 @@ function subscribe(fn) { } ``` +## Keyboard navigation + +By default, TableView uses arrow key navigation to move focus into cells. Set `keyboardNavigationBehavior="tab"` to have Tab move focus in and out of a cell. + +```tsx render type="s2" +"use client"; +import {TableView, TableHeader, Column, TableBody, Row, Cell} from '@react-spectrum/s2/TableView'; +import {ComboBox, ComboBoxItem} from '@react-spectrum/s2/ComboBox'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +function PermissionPicker({label}) { + return ( + + Can view + Can comment + Can edit + + ); +} + + + + Name + Type + Date Modified + Permission + + + + Games + Folder + 6/7/2023 + + + + Applications + Folder + 4/7/2025 + + + + 2024 Financial Report + PDF Document + 12/30/2024 + + + + Job Posting + Text Document + 1/18/2025 + + + + +``` + ## Drag and drop Table supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the table as a whole, on individual rows, insert new rows between existing ones, or reorder rows. See the [drag and drop guide](dnd?component=TableView) to learn more. diff --git a/packages/react-aria-components/test/TagGroup.test.js b/packages/react-aria-components/test/TagGroup.test.js index 8f615682656..ca686a9bd64 100644 --- a/packages/react-aria-components/test/TagGroup.test.js +++ b/packages/react-aria-components/test/TagGroup.test.js @@ -662,7 +662,7 @@ describe('TagGroup', () => { // TODO: a change in behavior since taggroup is a gridlist with "tab" keyboard navigation behavior // previously you could go to the next tab via arrow keys when you were focused on the close button - await user.keyboard('{Shift>}{Tab}{/Shift}'); + await user.tab({shift: true}); expect(tags[0]).toHaveFocus(); await user.keyboard('{ArrowRight}'); From d5261df08bd737dc6fd35c304c969b184d3b92f2 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 9 Jun 2026 14:44:28 -0700 Subject: [PATCH 12/13] fix missing s2 table columnheader focus ring and add focus ring to the select all column cell --- packages/@react-spectrum/s2/src/TableView.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 5b257b067f5..23e3f19e317 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -690,7 +690,7 @@ function CellFocusRing() { className={style({ ...cellFocus, position: 'absolute', - top: 'var(--topFocusRing)', + top: 'var(--topFocusRing, 0)', bottom: 0, insetStart: 0, insetEnd: 0, @@ -1216,11 +1216,9 @@ export const TableHeader = /*#__PURE__*/ (forwardRef as forwardRefType)(function className={selectAllCheckboxColumn({isQuiet})}> {({isFocusVisible}) => ( <> + {isFocusVisible && } {selectionMode === 'single' && ( - <> - {isFocusVisible && } - - + )} {selectionMode === 'multiple' && ( From ef4718659c927ff9c33eadb9a2663a9194cc7653 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 9 Jun 2026 15:14:13 -0700 Subject: [PATCH 13/13] formatting --- packages/@react-spectrum/s2/src/TableView.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 23e3f19e317..56e469dfd8d 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -1217,9 +1217,7 @@ export const TableHeader = /*#__PURE__*/ (forwardRef as forwardRefType)(function {({isFocusVisible}) => ( <> {isFocusVisible && } - {selectionMode === 'single' && ( - - )} + {selectionMode === 'single' && } {selectionMode === 'multiple' && ( )}