From 8b71adf51a819cd48e0526583ed3c3deca4acbc9 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 4 Jun 2026 11:09:10 -0700 Subject: [PATCH 01/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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' && ( )} From 9570eb4d8b595d0f3a03c3ced71794fdc45e8b07 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 11 Jun 2026 17:22:24 -0700 Subject: [PATCH 14/52] make editable table edit buttons focusable via tab when using tab keyboard behavior --- packages/@react-spectrum/s2/src/TableView.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 56e469dfd8d..034144940d6 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -448,6 +448,7 @@ export const TableView = forwardRef(function TableView( selectionStyle = 'checkbox', dragAndDropHooks, disabledBehavior = 'all', + keyboardNavigationBehavior = 'arrow', ...otherProps } = props; @@ -494,7 +495,8 @@ export const TableView = forwardRef(function TableView( setIsInResizeMode, selectionMode, selectionStyle, - disabledBehavior + disabledBehavior, + keyboardNavigationBehavior }), [ isQuiet, @@ -506,7 +508,8 @@ export const TableView = forwardRef(function TableView( setIsInResizeMode, selectionMode, selectionStyle, - disabledBehavior + disabledBehavior, + keyboardNavigationBehavior ] ); @@ -561,6 +564,7 @@ export const TableView = forwardRef(function TableView( onRowAction={onAction} dragAndDropHooks={dragAndDropHooks} disabledBehavior={disabledBehavior} + keyboardNavigationBehavior={keyboardNavigationBehavior} {...otherProps} selectedKeys={selectedKeys} defaultSelectedKeys={undefined} @@ -1643,7 +1647,7 @@ function EditableCellInner( let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); let dialogRef = useRef>(null); - let {density} = useContext(InternalTableContext); + let {density, keyboardNavigationBehavior} = useContext(InternalTableContext); let size: 'XS' | 'S' | 'M' | 'L' | 'XL' | undefined = 'M'; if (density === 'compact') { size = 'S'; @@ -1728,7 +1732,7 @@ function EditableCellInner( isPending: isSaving, isQuiet: !isSaving, size, - excludeFromTabOrder: true, + excludeFromTabOrder: keyboardNavigationBehavior === 'arrow', styles: style({ // TODO: really need access to display here instead, but not possible right now // will be addressable with displayOuter From 63114f1fae9041884dda73410a8886861142e967 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 18 Jun 2026 09:54:47 -0700 Subject: [PATCH 15/52] clean up uneeded code and mirror useGridListItem more closely --- packages/@react-spectrum/s2/src/TreeView.tsx | 1 - packages/react-aria/src/grid/useGridCell.ts | 13 +------------ packages/react-aria/src/gridlist/useGridListItem.ts | 9 --------- packages/react-aria/src/selection/useTypeSelect.ts | 4 ---- 4 files changed, 1 insertion(+), 26 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index 73abdd4bb11..4573d2d5331 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -104,7 +104,6 @@ export interface TreeViewProps | 'selectionBehavior' | 'onScroll' | 'onCellAction' - | 'keyboardNavigationBehavior' | keyof GlobalDOMAttributes >, UnsafeStyles, diff --git a/packages/react-aria/src/grid/useGridCell.ts b/packages/react-aria/src/grid/useGridCell.ts index ce6b395251f..a79f5ba84d0 100644 --- a/packages/react-aria/src/grid/useGridCell.ts +++ b/packages/react-aria/src/grid/useGridCell.ts @@ -275,10 +275,7 @@ export function useGridCell>( } if (keyboardNavigationBehavior === 'tab') { - if ( - getEventTarget(e) !== ref.current && - (isArrowKey(e.key) || isCharacterKey(e.key) || e.key === 'Enter') - ) { + if (getEventTarget(e) !== ref.current && e.key !== 'Tab') { e.stopPropagation(); return; } @@ -402,11 +399,3 @@ 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/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index eb227313d5a..511bf86fa8d 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -490,12 +490,3 @@ function getDirectChildren(parent: RSNode, collection: Collection { - if (e.altKey) { - return; - } - let character = getStringForKey(e.key); if ( !character || From 93180222fc8a1ffd852b857c3784c9dcaed2de61 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 18 Jun 2026 10:54:06 -0700 Subject: [PATCH 16/52] add focusMode to Cell, make S2 TableView auto focus selection checkboxes, column menus, etc when in tab nav mode --- packages/@react-spectrum/s2/src/TableView.tsx | 21 +++++++++--- packages/react-aria-components/src/Table.tsx | 17 ++++++++-- .../stories/Table.stories.tsx | 33 +++++++++++-------- packages/react-aria/src/grid/useGridCell.ts | 22 +++++++++---- packages/react-aria/src/table/useTableCell.ts | 6 ++++ .../src/table/useTableColumnHeader.ts | 12 +++++-- 6 files changed, 81 insertions(+), 30 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 034144940d6..2c0d6aeab4d 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -772,13 +772,15 @@ export interface ColumnProps extends Omit< * A column within a ``. */ export const Column = forwardRef(function Column(props: ColumnProps, ref: DOMRef) { - let {isQuiet} = useContext(InternalTableContext); + let {isQuiet, keyboardNavigationBehavior} = useContext(InternalTableContext); let {allowsResizing, children, align = 'start'} = props; let domRef = useDOMRef(ref); let isMenu = allowsResizing || !!props.menuItems; return ( {({isFocusVisible}) => ( <> @@ -2203,7 +2207,8 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row( ref: DOMRef ) { let {selectionBehavior, selectionMode, allowsDragging} = useTableOptions(); - let {selectionStyle, ...tableVisualOptions} = useContext(InternalTableContext); + let {selectionStyle, keyboardNavigationBehavior, ...tableVisualOptions} = + useContext(InternalTableContext); let domRef = useDOMRef(ref); let isInFooter = useContext(FooterContext); @@ -2233,6 +2238,9 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row( {({isFocusVisibleWithinRow}) => !(otherProps.isDisabled && tableVisualOptions.disabledBehavior === 'all') && ( @@ -2246,8 +2254,11 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row( selectionStyle === 'checkbox' && ( // Not sure what we want to do with this className, in Cell it currently overrides the className that would have been applied. // The `spread` otherProps must be after className in Cell. - // @ts-ignore - + )} diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 4b1b4092e47..41da97b3101 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1181,6 +1181,12 @@ export interface ColumnProps * ``. */ maxWidth?: ColumnStaticSize | null; + /** + * Whether the column header or its first focusable child element should be focused when the + * column header is focused. Defaults to 'child' in arrow keyboard navigation mode and 'cell' in + * tab keyboard navigation mode. + */ + focusMode?: 'child' | 'cell'; } class TableColumnNode extends CollectionNode { @@ -1212,7 +1218,7 @@ export const Column = /*#__PURE__*/ createLeafComponent( let state = useContext(TableStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let {columnHeaderProps, isPressed} = useTableColumnHeader( - {node: column, isVirtualized}, + {node: column, isVirtualized, focusMode: props.focusMode}, state, ref ); @@ -2062,6 +2068,12 @@ export interface CellProps textValue?: string; /** Indicates how many columns the data cell spans. */ colSpan?: number; + /** + * Whether the cell or its first focusable child element should be focused when the cell is + * focused. Defaults to 'child' in arrow keyboard navigation mode and 'cell' in tab keyboard + * navigation mode. + */ + focusMode?: 'child' | 'cell'; } class TableCellNode extends CollectionNode { @@ -2100,7 +2112,8 @@ export const Cell = /*#__PURE__*/ createLeafComponent( { node: cell, shouldSelectOnPressUp: !!dragState, - isVirtualized + isVirtualized, + focusMode: props.focusMode }, state, ref diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index c6b2c19c480..d8ae66e3f19 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -2193,6 +2193,8 @@ let comboboxEmptyState = () => { }; export const TableWithTextfield: TableStory = args => { + // @ts-ignore + let {autoFocusChildren} = args; return (
@@ -2202,7 +2204,7 @@ export const TableWithTextfield: TableStory = args => { keyboardNavigationBehavior="tab" {...args}> - + Col 1 @@ -2212,33 +2214,33 @@ export const TableWithTextfield: TableStory = args => { - + RAC Textfield - + Raw input - + - + TextField + Button - + Toolbar - + @@ -2246,13 +2248,12 @@ export const TableWithTextfield: TableStory = args => { - - + Menu - + @@ -2265,7 +2266,7 @@ export const TableWithTextfield: TableStory = args => { RadioGroup - + { - + CheckboxGroup - + @@ -2318,7 +2319,7 @@ export const TableWithTextfield: TableStory = args => { ComboBox - +
@@ -2362,5 +2363,9 @@ TableWithTextfield.argTypes = { selectionBehavior: { control: 'radio', options: ['toggle', 'replace'] + }, + // @ts-ignore + autoFocusChildren: { + control: 'boolean' } }; diff --git a/packages/react-aria/src/grid/useGridCell.ts b/packages/react-aria/src/grid/useGridCell.ts index a79f5ba84d0..c60675dbf8f 100644 --- a/packages/react-aria/src/grid/useGridCell.ts +++ b/packages/react-aria/src/grid/useGridCell.ts @@ -49,7 +49,8 @@ export interface GridCellProps { isVirtualized?: boolean; /** * Whether the cell or its first focusable child element should be focused when the grid cell is - * focused. + * focused. Defaults to 'child' in arrow keyboard navigation mode and 'cell' in + * tab keyboard navigation mode. */ focusMode?: 'child' | 'cell'; /** Whether selection should occur on press up instead of press down. */ @@ -83,7 +84,7 @@ export function useGridCell>( state: GridState, ref: RefObject ): GridCellAria { - let {node, isVirtualized, focusMode = 'child', shouldSelectOnPressUp, onAction} = props; + let {node, isVirtualized, focusMode: focusModeProp, shouldSelectOnPressUp, onAction} = props; let {direction} = useLocale(); let { @@ -91,10 +92,7 @@ export function useGridCell>( actions: {onCellAction}, keyboardNavigationBehavior } = gridMap.get(state)!; - - if (keyboardNavigationBehavior === 'tab') { - focusMode = 'cell'; - } + let focusMode = focusModeProp ?? (keyboardNavigationBehavior === 'tab' ? 'cell' : 'child'); // 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. @@ -315,7 +313,17 @@ export function useGridCell>( return; } - // If the cell itself is focused, wait a frame so that focus finishes propagatating + // if focus goes back to cell from child, make sure we don't refocus the cell if we are in focusMode=child + // since that would be a focus trap + if ( + focusMode === 'child' && + e.relatedTarget && + nodeContains(ref.current, e.relatedTarget as Element) + ) { + return; + } + + // If the cell itself is focused, wait a frame so that focus finishes propagating // up to the tree, and move focus to a focusable child if possible. requestAnimationFrame(() => { if (focusMode === 'child' && getActiveElement() === ref.current) { diff --git a/packages/react-aria/src/table/useTableCell.ts b/packages/react-aria/src/table/useTableCell.ts index df197a06a01..75c4684df4f 100644 --- a/packages/react-aria/src/table/useTableCell.ts +++ b/packages/react-aria/src/table/useTableCell.ts @@ -33,6 +33,12 @@ export interface AriaTableCellProps { * @deprecated */ onAction?: () => void; + /** + * Whether the cell or its first focusable child element should be focused when the cell is + * focused. Defaults to 'child' in arrow keyboard navigation mode and 'cell' in + * tab keyboard navigation mode. + */ + focusMode?: 'child' | 'cell'; } export interface TableCellAria { diff --git a/packages/react-aria/src/table/useTableColumnHeader.ts b/packages/react-aria/src/table/useTableColumnHeader.ts index f73ed5bd3a8..2d9eed8e22b 100644 --- a/packages/react-aria/src/table/useTableColumnHeader.ts +++ b/packages/react-aria/src/table/useTableColumnHeader.ts @@ -35,6 +35,12 @@ export interface AriaTableColumnHeaderProps { * virtual scroller. */ isVirtualized?: boolean; + /** + * Whether the column header or its first focusable child element should be focused when the + * column header is focused. Defaults to 'child' in arrow keyboard navigation mode and 'cell' in + * tab keyboard navigation mode. + */ + focusMode?: 'child' | 'cell'; } export interface TableColumnHeaderAria { @@ -58,8 +64,10 @@ export function useTableColumnHeader( ): TableColumnHeaderAria { let {node} = props; let allowsSorting = node.props.allowsSorting; - // if there are no focusable children, the column header will focus the cell - let {gridCellProps} = useGridCell({...props, focusMode: 'child'}, state, ref); + // TODO: I removed the default here so that in tab navigation will default to 'cell' rather than 'child' + // TBH it feels a bit disruptive to autofocus the child in tab navigation since you might just + // want to go through the cells but will then unexpectedly fall into a random cell and need to shift tab out + let {gridCellProps} = useGridCell(props, state, ref); let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single'; From 2558dd31dd983322141bc4d7e046afe03036b669 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 18 Jun 2026 11:52:37 -0700 Subject: [PATCH 17/52] add focusMode to gridListItem --- packages/@react-spectrum/ai/src/Chat.tsx | 5 +- .../ai/stories/Chat.stories.tsx | 5 +- .../react-aria-components/src/GridList.tsx | 8 ++- .../react-aria-components/test/Table.test.js | 64 +++++++++++++++++++ .../src/gridlist/useGridListItem.ts | 50 +++++++++++++-- 5 files changed, 124 insertions(+), 8 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 162837b1ba4..22daa001a95 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -328,7 +328,7 @@ const threadItemBase = style({ borderRadius: 'default' }); -export interface ThreadItemProps extends Pick { +export interface ThreadItemProps extends Pick { /** * Spectrum-defined styles, returned by the `style()` macro. */ @@ -340,7 +340,7 @@ export interface ThreadItemProps extends Pick mergeStyles(threadItemBase({...renderProps}), styles)}> {children} diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index ae2c03cd45a..82539174f4d 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -490,6 +490,8 @@ export function StreamingChat() { // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) return ( @@ -621,7 +623,8 @@ export function VirtualizedChat() { let message = isPending ? 'Generating response' : 'Response generated'; return ( - + // TODO: tentative, tbh feels awkward to me + {message} diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index cc24494e875..91a0aabc813 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -504,6 +504,11 @@ export interface GridListItemProps * on the collection's `selectionBehavior` prop and the interaction modality. */ onAction?: () => void; + /** + * Whether the row or its first focusable child element should be focused when the row is + * focused. Defaults to 'row'. + */ + focusMode?: 'child' | 'row'; } /** @@ -522,7 +527,8 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function { node: item, shouldSelectOnPressUp: !!dragState, - isVirtualized + isVirtualized, + focusMode: props.focusMode }, state, ref diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index de84c1b8a80..82ae73712f8 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -268,6 +268,36 @@ let TabModeTable = props => (
); +let TabModeFocusModeChildTable = props => ( + + + Name + Type + Action + + + + Games + File folder + + + + + + Program Files + File folder + + + + + +
+); + let DraggableTable = props => { let {dragAndDropHooks} = useDragAndDrop({ getItems: keys => [...keys].map(key => ({'text/plain': key})), @@ -3649,6 +3679,40 @@ describe('Table', () => { expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1'])); }); + + describe("focusMode='child'", () => { + it('arrow navigating to a focusMode="child" cell focuses the child directly', async () => { + let {getByRole} = render(); + await user.tab(); + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toBe(getByRole('button', {name: 'Games action'})); + }); + + // TODO: for some reason the cell containing the button doesn't get refocused when we shift tab in the test but it + // works in browser... + it.skip('Shift+Tab from a focusMode="child" child returns focus to the cell without redirecting back to child', async () => { + let {getByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + let actionCell = cells[cells.length - 1]; + let button = getByRole('button', {name: 'Games action'}); + + await user.tab(); + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toBe(button); + + await user.tab({shift: true}); + expect(document.activeElement).toBe(actionCell); + act(() => { + actionCell.focus(); + }); + act(() => jest.runAllTimers()); + expect(document.activeElement).toBe(actionCell); + }); + }); }); }); diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 511bf86fa8d..8ad94e28519 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -59,6 +59,13 @@ export interface AriaGridListItemOptions { shouldSelectOnPressUp?: boolean; /** Whether this item has children, even if not loaded yet. */ hasChildItems?: boolean; + /** + * Whether the row or its first focusable child element should be focused when the row is + * focused. + * + * @default 'row' + */ + focusMode?: 'child' | 'row'; } export interface GridListItemAria extends SelectableItemStates { @@ -94,7 +101,7 @@ export function useGridListItem( ref: RefObject ): GridListItemAria { // Copied from useGridCell + some modifications to make it not so grid specific - let {node, isVirtualized} = props; + let {node, isVirtualized, focusMode = 'row'} = props; // let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/gridlist'); let {direction} = useLocale(); @@ -106,12 +113,29 @@ export function useGridListItem( // focus to go to the item when the DOM node is reused for a different item in a virtualizer. let keyWhenFocused = useRef(null); let focus = () => { + if (ref.current === null) { + return; + } + + if (focusMode === 'child') { + // If focus is already on a focusable child within the row, early return so we don't shift focus + if (isFocusWithin(ref.current) && ref.current !== getActiveElement()) { + return; + } + + let treeWalker = getFocusableTreeWalker(ref.current); + let focusable = treeWalker.firstChild() as FocusableElement; + if (focusable) { + focusSafely(focusable); + return; + } + } + // Don't shift focus to the row if the active element is a element within the row already // (e.g. clicking on a row button) if ( - ref.current !== null && - ((keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !isFocusWithin(ref.current)) + (keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || + !isFocusWithin(ref.current) ) { focusSafely(ref.current); } @@ -288,6 +312,24 @@ export function useGridListItem( } return; } + + // if focus goes back to cell from child, make sure we don't refocus the cell if we are in focusMode=child + // since that would be a focus trap + if ( + focusMode === 'child' && + e.relatedTarget && + nodeContains(ref.current, e.relatedTarget as Element) + ) { + return; + } + + // If the cell itself is focused, wait a frame so that focus finishes propagating + // up to the tree, and move focus to a focusable child if possible. + requestAnimationFrame(() => { + if (focusMode === 'child' && getActiveElement() === ref.current) { + focus(); + } + }); }; let onKeyDown = (e: ReactKeyboardEvent) => { From 0d6d4293dde2a774e4fd4d940a16082970267f3f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 18 Jun 2026 16:35:25 -0700 Subject: [PATCH 18/52] add allowsArrowNavigation so arrow key navigation can be triggered even when in tab navigation and on a child of a cell/row --- packages/@react-spectrum/ai/src/Chat.tsx | 16 +++- .../ai/stories/Chat.stories.tsx | 5 +- packages/@react-spectrum/s2/src/TableView.tsx | 12 +-- .../react-aria-components/src/GridList.tsx | 9 +- packages/react-aria-components/src/Table.tsx | 22 ++++- .../stories/GridList.stories.tsx | 49 ++++++++--- .../stories/Table.stories.tsx | 85 ++++++++++++------- packages/react-aria/src/grid/useGridCell.ts | 18 +++- .../src/gridlist/useGridListItem.ts | 13 ++- packages/react-aria/src/table/useTableCell.ts | 6 ++ .../src/table/useTableColumnHeader.ts | 6 ++ 11 files changed, 181 insertions(+), 60 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 22daa001a95..13ef031b6fa 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -328,7 +328,10 @@ const threadItemBase = style({ borderRadius: 'default' }); -export interface ThreadItemProps extends Pick { +export interface ThreadItemProps extends Pick< + GridListItemProps, + 'children' | 'textValue' | 'focusMode' | 'allowsArrowNavigation' +> { /** * Spectrum-defined styles, returned by the `style()` macro. */ @@ -340,7 +343,15 @@ export interface ThreadItemProps extends Pick mergeStyles(threadItemBase({...renderProps}), styles)}> {children} diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index 82539174f4d..aecb2e07889 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -490,7 +490,7 @@ export function StreamingChat() { // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) return ( + {message} diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 2c0d6aeab4d..d5e2c132964 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -779,8 +779,10 @@ export const Column = forwardRef(function Column(props: ColumnProps, ref: DOMRef return ( {({isFocusVisible}) => ( <> @@ -2238,9 +2240,8 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row( {({isFocusVisibleWithinRow}) => !(otherProps.isDisabled && tableVisualOptions.disabledBehavior === 'all') && ( @@ -2258,7 +2259,8 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row( isSticky // @ts-ignore className={checkboxCellStyle} - focusMode={keyboardNavigationBehavior === 'tab' ? 'child' : undefined}> + focusMode={keyboardNavigationBehavior === 'tab' ? 'child' : undefined} + allowsArrowNavigation={keyboardNavigationBehavior === 'tab' || undefined}> )} diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 91a0aabc813..91a8e094252 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -509,6 +509,12 @@ export interface GridListItemProps * focused. Defaults to 'row'. */ focusMode?: 'child' | 'row'; + /** + * Whether the row should support arrow key navigation even when the containing collection uses + * tab keyboard navigation. Allows users to navigate between rows with arrow keys while + * focus is on an interactive child element within the row. + */ + allowsArrowNavigation?: boolean; } /** @@ -528,7 +534,8 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function node: item, shouldSelectOnPressUp: !!dragState, isVirtualized, - focusMode: props.focusMode + focusMode: props.focusMode, + allowsArrowNavigation: props.allowsArrowNavigation }, state, ref diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 41da97b3101..9ccb9239595 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1187,6 +1187,12 @@ export interface ColumnProps * tab keyboard navigation mode. */ focusMode?: 'child' | 'cell'; + /** + * Whether the column should support arrow key navigation even when the containing table uses tab + * keyboard navigation. Allows users to navigate between columns and rows with arrow keys while + * focus is on an interactive child element within the column header. + */ + allowsArrowNavigation?: boolean; } class TableColumnNode extends CollectionNode { @@ -1218,7 +1224,12 @@ export const Column = /*#__PURE__*/ createLeafComponent( let state = useContext(TableStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let {columnHeaderProps, isPressed} = useTableColumnHeader( - {node: column, isVirtualized, focusMode: props.focusMode}, + { + node: column, + isVirtualized, + focusMode: props.focusMode, + allowsArrowNavigation: props.allowsArrowNavigation + }, state, ref ); @@ -2074,6 +2085,12 @@ export interface CellProps * navigation mode. */ focusMode?: 'child' | 'cell'; + /** + * Whether the cell should support arrow key navigation even when the containing table uses + * tab keyboard navigation. Allows users to navigate between cells and rows with arrow keys while + * focus is on an interactive child element within the cell. + */ + allowsArrowNavigation?: boolean; } class TableCellNode extends CollectionNode { @@ -2113,7 +2130,8 @@ export const Cell = /*#__PURE__*/ createLeafComponent( node: cell, shouldSelectOnPressUp: !!dragState, isVirtualized, - focusMode: props.focusMode + focusMode: props.focusMode, + allowsArrowNavigation: props.allowsArrowNavigation }, state, ref diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 9710c97e31f..5cefd69b2c2 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -977,13 +977,27 @@ let comboboxEmptyState = () => { export const GridListWithTextfield: GridListStory = args => { let isHorizontalStack = args.orientation === 'horizontal' && args.layout !== 'grid'; + let { + // @ts-ignore + autoFocusChildren, + keyboardNavigationBehavior, + ...otherArgs + } = args; + + let focusMode = + autoFocusChildren && keyboardNavigationBehavior === 'tab' + ? 'child' + : (undefined as 'child' | undefined); + let allowsArrowNavigation = + autoFocusChildren && keyboardNavigationBehavior === 'tab' ? true : undefined; + return (
{ gridTemplate: args.layout === 'grid' ? 'repeat(3, 1fr) / repeat(3, 1fr)' : 'auto / 1fr', gridAutoFlow: args.orientation === 'horizontal' ? 'column' : 'row' }} - {...args}> - + {...otherArgs}> + RAC TextField - + Raw input - + TextField + Button {' '} - + ComboBox
@@ -1033,7 +1047,7 @@ export const GridListWithTextfield: GridListStory = args => { - + Toolbar @@ -1041,7 +1055,10 @@ 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 */} @@ -1056,7 +1073,7 @@ export const GridListWithTextfield: GridListStory = args => { - + RadioGroup { - + CheckboxGroup @@ -1112,7 +1129,8 @@ GridListWithTextfield.story = { args: { layout: 'stack', orientation: 'vertical', - escapeKeyBehavior: 'clearSelection' + escapeKeyBehavior: 'clearSelection', + keyboardNavigationBehavior: 'tab' }, argTypes: { layout: { @@ -1127,6 +1145,10 @@ GridListWithTextfield.story = { control: 'radio', options: ['arrow', 'tab'] }, + // @ts-ignore + autoFocusChildren: { + control: 'boolean' + }, selectionMode: { control: 'radio', options: ['none', 'single', 'multiple'] @@ -1139,5 +1161,10 @@ GridListWithTextfield.story = { control: 'radio', options: ['clearSelection', 'none'] } + }, + parameters: { + description: { + data: 'Note that toggling autoFocusChildren will make each row automatically focus its children. For the row with a menu, allowsArrowNavigation is also applied so that arrow keys can be used to move focus between rows still' + } } }; diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index d8ae66e3f19..1285f08033c 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -2193,18 +2193,29 @@ let comboboxEmptyState = () => { }; export const TableWithTextfield: TableStory = args => { - // @ts-ignore - let {autoFocusChildren} = args; + let { + // @ts-ignore + autoFocusChildren, + keyboardNavigationBehavior = 'tab', + selectionMode = 'multiple', + ...otherArgs + } = args; + let focusMode = + autoFocusChildren && keyboardNavigationBehavior === 'tab' + ? 'child' + : (undefined as 'child' | undefined); + let allowsArrowNavigation = + autoFocusChildren && keyboardNavigationBehavior === 'tab' ? true : undefined; return (
+ selectionMode={selectionMode} + keyboardNavigationBehavior={keyboardNavigationBehavior} + {...otherArgs}> - + Col 1 @@ -2214,33 +2225,33 @@ export const TableWithTextfield: TableStory = args => { - + RAC Textfield - + Raw input - + - + TextField + Button - + Toolbar - + @@ -2249,11 +2260,11 @@ export const TableWithTextfield: TableStory = args => { - + Menu - + @@ -2266,7 +2277,7 @@ export const TableWithTextfield: TableStory = args => { RadioGroup - + { - + CheckboxGroup - + @@ -2319,7 +2330,7 @@ export const TableWithTextfield: TableStory = args => { ComboBox - +
@@ -2351,21 +2362,31 @@ export const TableWithTextfield: TableStory = args => { ); }; -TableWithTextfield.argTypes = { - keyboardNavigationBehavior: { - control: 'radio', - options: ['arrow', 'tab'] - }, - selectionMode: { - control: 'radio', - options: ['none', 'single', 'multiple'] +TableWithTextfield.story = { + args: { + keyboardNavigationBehavior: 'tab' }, - selectionBehavior: { - control: 'radio', - options: ['toggle', 'replace'] + argTypes: { + keyboardNavigationBehavior: { + control: 'radio', + options: ['arrow', 'tab'] + }, + // @ts-ignore + autoFocusChildren: { + control: 'boolean' + }, + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] + } }, - // @ts-ignore - autoFocusChildren: { - control: 'boolean' + parameters: { + description: { + data: 'Note that toggling autoFocusChildren will make each cell automatically focus its children. For the selection checkboxes and menu, allowsArrowNavigation is also applied so that arrow keys can be used to move focus between cells and rows' + } } }; diff --git a/packages/react-aria/src/grid/useGridCell.ts b/packages/react-aria/src/grid/useGridCell.ts index c60675dbf8f..fe02f3dfa75 100644 --- a/packages/react-aria/src/grid/useGridCell.ts +++ b/packages/react-aria/src/grid/useGridCell.ts @@ -53,6 +53,12 @@ export interface GridCellProps { * tab keyboard navigation mode. */ focusMode?: 'child' | 'cell'; + /** + * Whether the cell should support arrow key navigation even when the containing collection uses + * tab keyboard navigation. Allows users to navigate between rows and cells with arrow keys while + * focus is on an interactive child element within the cell. + */ + allowsArrowNavigation?: boolean; /** Whether selection should occur on press up instead of press down. */ shouldSelectOnPressUp?: boolean; /** Indicates how many columns the data cell spans. */ @@ -84,7 +90,14 @@ export function useGridCell>( state: GridState, ref: RefObject ): GridCellAria { - let {node, isVirtualized, focusMode: focusModeProp, shouldSelectOnPressUp, onAction} = props; + let { + node, + isVirtualized, + focusMode: focusModeProp, + allowsArrowNavigation, + shouldSelectOnPressUp, + onAction + } = props; let {direction} = useLocale(); let { @@ -334,7 +347,8 @@ export function useGridCell>( let gridCellProps: DOMAttributes = mergeProps(itemProps, { role: 'gridcell', - onKeyDownCapture: keyboardNavigationBehavior === 'tab' ? undefined : onKeyDownCapture, + onKeyDownCapture: + keyboardNavigationBehavior !== 'tab' || allowsArrowNavigation ? onKeyDownCapture : undefined, onKeyDown: keyboardNavigationBehavior === 'tab' ? onKeyDown : undefined, 'aria-colspan': node.colSpan, 'aria-colindex': node.colIndex != null ? node.colIndex + 1 : undefined, // aria-colindex is 1-based diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 8ad94e28519..99cf29f55a5 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -66,6 +66,12 @@ export interface AriaGridListItemOptions { * @default 'row' */ focusMode?: 'child' | 'row'; + /** + * Whether the row should support arrow key navigation even when the containing collection uses + * tab keyboard navigation. Allows users to navigate between rows with arrow keys while + * focus is on an interactive child element within the row. + */ + allowsArrowNavigation?: boolean; } export interface GridListItemAria extends SelectableItemStates { @@ -101,7 +107,7 @@ export function useGridListItem( ref: RefObject ): GridListItemAria { // Copied from useGridCell + some modifications to make it not so grid specific - let {node, isVirtualized, focusMode = 'row'} = props; + let {node, isVirtualized, focusMode = 'row', allowsArrowNavigation} = props; // let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/gridlist'); let {direction} = useLocale(); @@ -389,7 +395,10 @@ export function useGridListItem( let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, { role: 'row', - onKeyDownCapture: keyboardNavigationBehavior === 'arrow' ? onKeyDownCapture : undefined, + onKeyDownCapture: + keyboardNavigationBehavior === 'arrow' || allowsArrowNavigation + ? onKeyDownCapture + : undefined, onFocus, // 'aria-label': [(node.textValue || undefined), rowAnnouncement].filter(Boolean).join(', '), 'aria-label': node['aria-label'] || node.textValue || undefined, diff --git a/packages/react-aria/src/table/useTableCell.ts b/packages/react-aria/src/table/useTableCell.ts index 75c4684df4f..45ce0dbe7bb 100644 --- a/packages/react-aria/src/table/useTableCell.ts +++ b/packages/react-aria/src/table/useTableCell.ts @@ -39,6 +39,12 @@ export interface AriaTableCellProps { * tab keyboard navigation mode. */ focusMode?: 'child' | 'cell'; + /** + * Whether the cell should support arrow key navigation even when the containing table uses + * tab keyboard navigation.Allows users to navigate between rows and cells with arrow keys while + * focus is on an interactive child element within the cell. + */ + allowsArrowNavigation?: boolean; } export interface TableCellAria { diff --git a/packages/react-aria/src/table/useTableColumnHeader.ts b/packages/react-aria/src/table/useTableColumnHeader.ts index 2d9eed8e22b..a4c606232b3 100644 --- a/packages/react-aria/src/table/useTableColumnHeader.ts +++ b/packages/react-aria/src/table/useTableColumnHeader.ts @@ -41,6 +41,12 @@ export interface AriaTableColumnHeaderProps { * tab keyboard navigation mode. */ focusMode?: 'child' | 'cell'; + /** + * Whether the column header should support arrow key navigation even when the containing table + * uses tab keyboard navigation. Allows users to navigate between columns with arrow keys while + * focus is on an interactive child element within the cell. + */ + allowsArrowNavigation?: boolean; } export interface TableColumnHeaderAria { From 9bd8d76ebaec21701797fdcb04af5a19e11d4bb3 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 19 Jun 2026 13:21:31 -0700 Subject: [PATCH 19/52] add allowsArrowNavigation and focusMode to tree, tests, and docs updates --- .../s2/test/TableView.test.tsx | 127 ++++++++++++++++++ .../dev/s2-docs/pages/react-aria/Table.mdx | 55 +++----- packages/dev/s2-docs/pages/s2/TableView.mdx | 55 +++----- packages/react-aria-components/src/Tree.tsx | 6 +- .../stories/GridList.stories.tsx | 15 +-- .../stories/Table.stories.tsx | 14 +- .../test/GridList.test.js | 57 ++++++++ .../react-aria-components/test/Table.test.js | 41 +++++- .../react-aria-components/test/Tree.test.tsx | 61 +++++++++ 9 files changed, 343 insertions(+), 88 deletions(-) diff --git a/packages/@react-spectrum/s2/test/TableView.test.tsx b/packages/@react-spectrum/s2/test/TableView.test.tsx index c217b1648ea..ffdff1238a3 100644 --- a/packages/@react-spectrum/s2/test/TableView.test.tsx +++ b/packages/@react-spectrum/s2/test/TableView.test.tsx @@ -21,6 +21,7 @@ import {pointerMap, User} from '@react-aria/test-utils'; import React, {useState} from 'react'; import {Tab, TabList, Tabs} from '../src/Tabs'; import {Text} from '../src/Content'; +import {useDragAndDrop} from '../src/useDragAndDrop'; import userEvent from '@testing-library/user-event'; // @ts-ignore @@ -292,4 +293,130 @@ describe('TableView', () => { await user.click(tableTester.getRows()[0]); expect(onSelectionChange).toHaveBeenCalled(); }); + + describe('tab keyboard navigation', () => { + function TabNavTable(props) { + let {dragAndDropHooks} = useDragAndDrop({ + getItems: keys => [...keys].map(key => ({'text/plain': `${key}`})) + }); + return ( + + + + + Filter + + + }> + Name + + + + Filter + + + }> + Type + + Notes + + + + Foo 1 + Bar 1 + + + + + + Foo 2 + Bar 2 + + + + + + Foo 3 + Bar 3 + + + + + + + ); + } + + it('row drag cell and checkbox cell auto focuses drag handle button/checkbox and allows arrow key nav', async () => { + let {getByRole, getAllByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let checkboxes = getAllByRole('checkbox'); + let dragButton1 = getByRole('button', {name: 'Drag Foo 1'}); + + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(dragButton1); + + await user.keyboard('{ArrowRight}'); + let row1 = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row1}); + expect(cells[1].contains(document.activeElement)).toBe(true); + expect(document.activeElement).toBe(checkboxes[1]); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(checkboxes[2]); + }); + + it('column headers and selectAll header auto focuses their menu trigger/checkbox and allows arrow key nav', async () => { + let {getByRole, getAllByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let checkboxes = getAllByRole('checkbox'); + let columns = tableTester.getColumns(); + + await user.tab(); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(columns[0]); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(checkboxes[0]); + expect(columns[1].contains(document.activeElement)).toBe(true); + + await user.keyboard('{ArrowRight}'); + act(() => jest.runAllTimers()); + expect(document.activeElement?.tagName.toLowerCase()).toBe('button'); + expect(columns[2].contains(document.activeElement)).toBe(true); + }); + + it('textfield in cell works with tab mode', async () => { + let {getByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let cells = tableTester.getCells({element: tableTester.getRows()[0]}); + let notesCell = cells[cells.length - 1]; + + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(notesCell); + + await user.tab(); + let input = getByRole('textbox', {name: 'Foo 1 notes'}); + expect(document.activeElement).toBe(input); + + await user.type(input, 'Foo'); + expect(input).toHaveValue('Foo'); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(input); + + await user.tab({shift: true}); + expect(document.activeElement).toBe(notesCell); + }); + }); }); diff --git a/packages/dev/s2-docs/pages/react-aria/Table.mdx b/packages/dev/s2-docs/pages/react-aria/Table.mdx index ccf76b4cabf..69720c29ce4 100644 --- a/packages/dev/s2-docs/pages/react-aria/Table.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Table.mdx @@ -733,17 +733,16 @@ Use this when cells contain interactive elements such as text fields, where arro ```tsx render "use client"; import {Table, TableHeader, Column, Row, TableBody, Cell} from 'vanilla-starter/Table'; -import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox'; +import {TextField} from 'vanilla-starter/TextField'; -function PermissionPicker({label}) { - return ( - - Can view - Can comment - Can edit - - ); -} +///- begin collapse -/// +let files = [ + {id: 'games', name: 'Games', type: 'Folder', date: '6/7/2023'}, + {id: 'apps', name: 'Applications', type: 'Folder', date: '4/7/2025'}, + {id: 'report', name: '2024 Financial Report', type: 'PDF Document', date: '12/30/2024'}, + {id: 'job', name: 'Job Posting', type: 'Text Document', date: '1/18/2025'}, +]; +///- end collapse -///
NameTypeDate Modified - Permission + Notes - - - 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 - - + + {item => ( + + {item.name} + {item.type} + {item.date} + + + )}
``` diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index ff9ae04d9fb..015a33dc05c 100644 --- a/packages/dev/s2-docs/pages/s2/TableView.mdx +++ b/packages/dev/s2-docs/pages/s2/TableView.mdx @@ -954,18 +954,17 @@ By default, TableView uses arrow key navigation to move focus into cells. Set `k ```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 {TextField} from '@react-spectrum/s2/TextField'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -function PermissionPicker({label}) { - return ( - - Can view - Can comment - Can edit - - ); -} +///- begin collapse -/// +let files = [ + {id: 'games', name: 'Games', type: 'Folder', date: '6/7/2023'}, + {id: 'apps', name: 'Applications', type: 'Folder', date: '4/7/2025'}, + {id: 'report', name: '2024 Financial Report', type: 'PDF Document', date: '12/30/2024'}, + {id: 'job', name: 'Job Posting', type: 'Text Document', date: '1/18/2025'}, +]; +///- end collapse -/// Name Type Date Modified - Permission + Notes - - - 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 - - + + {item => ( + + {item.name} + {item.type} + {item.date} + + + )} ``` diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index f76e87404be..4aea9d5184e 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -728,7 +728,7 @@ export interface TreeItemProps LinkDOMProps, HoverEvents, PressEvents, - Pick, + Pick, Omit, 'onClick'> { /** * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the @@ -783,7 +783,9 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( let {rowProps, gridCellProps, expandButtonProps, descriptionProps, ...states} = useTreeItem( { node: item, - shouldSelectOnPressUp: !!dragState + shouldSelectOnPressUp: !!dragState, + focusMode: props.focusMode, + allowsArrowNavigation: props.allowsArrowNavigation }, state, ref diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 5cefd69b2c2..0d0510b1d7a 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -975,14 +975,11 @@ let comboboxEmptyState = () => { return
No results
; }; -export const GridListWithTextfield: GridListStory = args => { +type GridListWithTextfieldArgs = GridListProps & {autoFocusChildren?: boolean}; + +const GridListWithTextfieldRender = (args: GridListWithTextfieldArgs) => { let isHorizontalStack = args.orientation === 'horizontal' && args.layout !== 'grid'; - let { - // @ts-ignore - autoFocusChildren, - keyboardNavigationBehavior, - ...otherArgs - } = args; + let {autoFocusChildren, keyboardNavigationBehavior, ...otherArgs} = args; let focusMode = autoFocusChildren && keyboardNavigationBehavior === 'tab' @@ -1125,7 +1122,8 @@ export const GridListWithTextfield: GridListStory = args => { ); }; -GridListWithTextfield.story = { +export const GridListWithTextfield: StoryObj = { + render: args => , args: { layout: 'stack', orientation: 'vertical', @@ -1145,7 +1143,6 @@ GridListWithTextfield.story = { control: 'radio', options: ['arrow', 'tab'] }, - // @ts-ignore autoFocusChildren: { control: 'boolean' }, diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 1285f08033c..25b200d055b 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -2192,9 +2192,15 @@ let comboboxEmptyState = () => { return
No results
; }; -export const TableWithTextfield: TableStory = args => { +type TableWithTextfieldArgs = { + autoFocusChildren?: boolean; + keyboardNavigationBehavior?: 'tab' | 'arrow'; + selectionMode?: 'none' | 'single' | 'multiple'; + selectionBehavior?: 'toggle' | 'replace'; +}; + +const TableWithTextfieldRender = (args: TableWithTextfieldArgs) => { let { - // @ts-ignore autoFocusChildren, keyboardNavigationBehavior = 'tab', selectionMode = 'multiple', @@ -2362,7 +2368,8 @@ export const TableWithTextfield: TableStory = args => { ); }; -TableWithTextfield.story = { +export const TableWithTextfield: StoryObj = { + render: args => , args: { keyboardNavigationBehavior: 'tab' }, @@ -2371,7 +2378,6 @@ TableWithTextfield.story = { control: 'radio', options: ['arrow', 'tab'] }, - // @ts-ignore autoFocusChildren: { control: 'boolean' }, diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index c5df18a2ff8..a45d1cec019 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -2082,4 +2082,61 @@ describe('GridList', () => { } ); }); + + describe('focusMode and allowsArrowNavigation', () => { + it('focusMode="child" auto-focuses first child when item receives focus', async () => { + let {getByRole} = render( + + + + + + + + + + + + ); + + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.tab({shift: true}); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + }); + + it('allowsArrowNavigation allows arrow key row navigation when focused on child', async () => { + let {getByRole} = render( + + + + + + + + + + + + ); + + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + }); + }); }); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 82ae73712f8..179f2c5f662 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -298,6 +298,36 @@ let TabModeFocusModeChildTable = props => ( ); +let TabModeFocusModeChildWithArrowNavTable = props => ( + + + Name + Type + Action + + + + Games + File folder + + + + + + Program Files + File folder + + + + + +
+); + let DraggableTable = props => { let {dragAndDropHooks} = useDragAndDrop({ getItems: keys => [...keys].map(key => ({'text/plain': key})), @@ -3685,10 +3715,18 @@ describe('Table', () => { let {getByRole} = render(); await user.tab(); await user.keyboard('{ArrowLeft}'); - act(() => jest.runAllTimers()); expect(document.activeElement).toBe(getByRole('button', {name: 'Games action'})); }); + it('allowsArrowNavigation allows arrow key row navigation when focused on child', async () => { + let {getByRole} = render(); + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Games action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Program Files action'})); + }); + // TODO: for some reason the cell containing the button doesn't get refocused when we shift tab in the test but it // works in browser... it.skip('Shift+Tab from a focusMode="child" child returns focus to the cell without redirecting back to child', async () => { @@ -3706,6 +3744,7 @@ describe('Table', () => { await user.tab({shift: true}); expect(document.activeElement).toBe(actionCell); + // TODO: even this doesn't work act(() => { actionCell.focus(); }); diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 2e0f634ad75..6e0d6de7b1e 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -2925,6 +2925,67 @@ describe('Tree', () => { expect(onSelectionChange).not.toHaveBeenCalled(); }); }); + + describe('focusMode and allowsArrowNavigation', () => { + it('focusMode="child" auto-focuses first child when item receives focus', async () => { + let {getByRole} = render( + + + + {() => ( + + )} + + + + + {() => ( + + )} + + + + ); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.tab({shift: true}); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + }); + + it('allowsArrowNavigation allows arrow key navigation when focused on child', async () => { + let {getByRole} = render( + + + + {() => ( + + )} + + + + + {() => ( + + )} + + + + ); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + }); + }); }); AriaTreeTests({ From d9541187c48969aedb0ced22b60b701aa2426a56 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 19 Jun 2026 14:39:09 -0700 Subject: [PATCH 20/52] add docs for focusMode/allowsArrowNavigation --- .../dev/s2-docs/pages/react-aria/GridList.mdx | 54 ++++++++++++++++++- .../dev/s2-docs/pages/react-aria/Table.mdx | 7 ++- starters/docs/src/Table.tsx | 11 +++- 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/dev/s2-docs/pages/react-aria/GridList.mdx b/packages/dev/s2-docs/pages/react-aria/GridList.mdx index 8f5e302a932..9dc1c1ccc23 100644 --- a/packages/dev/s2-docs/pages/react-aria/GridList.mdx +++ b/packages/dev/s2-docs/pages/react-aria/GridList.mdx @@ -732,7 +732,7 @@ let photos = [ items={photos} selectionMode="multiple" aria-label="Shared files"> - {item => ( + {item => ( @@ -744,6 +744,58 @@ let photos = [ ``` +You can further control if a row automatically focuses itself or its children on keyboard focus via `focusMode`. Futhermore, `allowsArrowNavigation` can be used to allow arrow key navigation from the row's children to adjacent rows. +This allows keyboard users to navigate to the contents of the row without needing to tab in and out of the row even when in tab keyboard navigation. +Be sure to only set `allowsArrowNavigation` on rows whose interactive content don't use arrow keys. + +```tsx render +"use client"; +import {GridList, GridListItem, Text} from 'vanilla-starter/GridList'; +import {Button} from 'vanilla-starter/Button'; +import {useState} from 'react'; + +///- begin collapse -/// +let initialItems = [ + {id: 1, name: 'Apple', image: 'https://images.unsplash.com/photo-1630563451961-ac2ff27616ab?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0'}, + {id: 2, name: 'Peach', image: 'https://images.unsplash.com/photo-1642372849486-f88b963cb734?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0'}, + {id: 3, name: 'Blueberry', image: 'https://images.unsplash.com/photo-1606757389667-45c2024f9fa4?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0'}, + {id: 4, name: 'Broccoli', image: 'https://images.unsplash.com/photo-1685504445355-0e7bdf90d415?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0'}, + {id: 5, name: 'Brussels Sprouts', image: 'https://images.unsplash.com/photo-1685504507286-dc290728c01a?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0'}, + {id: 6, name: 'Peas', image: 'https://images.unsplash.com/photo-1587411768345-867e228218c8?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0'}, +]; +///- end collapse -/// + +function Example() { + let [items, setItems] = useState(initialItems); + return ( + + {item => ( + + + {item.name} + + + )} + + ); +} +``` + ## 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/Table.mdx b/packages/dev/s2-docs/pages/react-aria/Table.mdx index 69720c29ce4..d1db6fc953e 100644 --- a/packages/dev/s2-docs/pages/react-aria/Table.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Table.mdx @@ -730,7 +730,7 @@ function subscribe(fn) { 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 +```tsx render files={["starters/docs/src/Table.tsx", "starters/docs/src/Table.css"]} "use client"; import {Table, TableHeader, Column, Row, TableBody, Cell} from 'vanilla-starter/Table'; import {TextField} from 'vanilla-starter/TextField'; @@ -769,6 +769,11 @@ let files = [ ``` +You can further control if a cell automatically focuses itself or its children on keyboard focus via `focusMode`. Futhermore, `allowsArrowNavigation` can be used to allow arrow key navigation from the cell's children to adjacent cells. +This allows keyboard users to navigate to the selection checkboxes without needing to tab in and out of the selection cell even when in tab keyboard navigation. +The vanilla CSS starter applies these to selection cells automatically, see the **Table.tsx** tab above. +Be sure to only set `allowsArrowNavigation` on cells whose interactive content don't use arrow keys. + ## 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/starters/docs/src/Table.tsx b/starters/docs/src/Table.tsx index f586b21b7fa..fa4c60ca315 100644 --- a/starters/docs/src/Table.tsx +++ b/starters/docs/src/Table.tsx @@ -75,6 +75,10 @@ export function TableHeader({columns, children, ...otherProps}: TableHeaderPr width={32} minWidth={32} style={{width: 32}} + ///- begin highlight -/// + focusMode="child" + allowsArrowNavigation + ///- end highlight -/// className="react-aria-Column button-base"> {selectionMode === 'multiple' && } @@ -97,7 +101,12 @@ export function Row({id, columns, children, ...otherProps}: RowProps) { )} {selectionBehavior === 'toggle' && ( - + )} From babfeb4ec475bd83e3a3e57cbffa57b9be21f1be Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 19 Jun 2026 14:58:26 -0700 Subject: [PATCH 21/52] move press selection prevention logic into useSelectableItem --- packages/react-aria/src/grid/useGridCell.ts | 31 +------------- .../src/gridlist/useGridListItem.ts | 31 +------------- .../src/selection/useSelectableItem.ts | 42 ++++++++++++++----- 3 files changed, 34 insertions(+), 70 deletions(-) diff --git a/packages/react-aria/src/grid/useGridCell.ts b/packages/react-aria/src/grid/useGridCell.ts index fe02f3dfa75..81041bd6512 100644 --- a/packages/react-aria/src/grid/useGridCell.ts +++ b/packages/react-aria/src/grid/useGridCell.ts @@ -27,14 +27,8 @@ 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, - MouseEvent as ReactMouseEvent, - PointerEvent as ReactPointerEvent, - useRef -} from 'react'; +import {KeyboardEvent as ReactKeyboardEvent, useRef} from 'react'; import {scrollIntoViewport} from '../utils/scrollIntoView'; import {useLocale} from '../i18n/I18nProvider'; import {useSelectableItem} from '../selection/useSelectableItem'; @@ -360,29 +354,6 @@ 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. diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 99cf29f55a5..72b3e7923f1 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -29,15 +29,8 @@ import { import {getFocusableTreeWalker} from '../focus/FocusScope'; import {getRowId, listMap} from './utils'; import {getScrollParent} from '../utils/getScrollParent'; -import { - HTMLAttributes, - KeyboardEvent as ReactKeyboardEvent, - MouseEvent as ReactMouseEvent, - PointerEvent as ReactPointerEvent, - useRef -} from 'react'; +import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, 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'; @@ -423,28 +416,6 @@ 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]; diff --git a/packages/react-aria/src/selection/useSelectableItem.ts b/packages/react-aria/src/selection/useSelectableItem.ts index 39f8189430c..92f48ee4969 100644 --- a/packages/react-aria/src/selection/useSelectableItem.ts +++ b/packages/react-aria/src/selection/useSelectableItem.ts @@ -26,6 +26,7 @@ import {focusSafely} from '../interactions/focusSafely'; import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; import {getCollectionId, isNonContiguousSelectionModifier} from './utils'; import {isCtrlKeyPressed} from '../utils/keyboard'; +import {isTabbable} from '../utils/isFocusable'; import {mergeProps} from '../utils/mergeProps'; import {moveVirtualFocus} from '../focus/virtualFocus'; import {MultipleSelectionManager} from 'react-stately/useMultipleSelectionState'; @@ -442,17 +443,38 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte } : undefined; + let mergedItemProps = mergeProps( + itemProps, + allowsSelection || hasPrimaryAction || (shouldUseVirtualFocus && !isDisabled) ? pressProps : {}, + longPressEnabled ? longPressProps : {}, + {onDoubleClick, onDragStartCapture, onClick, id}, + // Prevent DOM focus from moving on mouse down when using virtual focus + shouldUseVirtualFocus ? {onMouseDown: e => e.preventDefault()} : undefined + ); + + // Guard against presses triggering selection when they happen on interactive children. + let baseOnPointerDown = mergedItemProps.onPointerDown; + mergedItemProps.onPointerDown = e => { + let target = getEventTarget(e) as Element | null; + if (target && target !== ref.current && isTabbable(target)) { + e.stopPropagation(); + return; + } + baseOnPointerDown?.(e); + }; + + let baseOnMouseDown = mergedItemProps.onMouseDown; + mergedItemProps.onMouseDown = e => { + let target = getEventTarget(e) as Element | null; + if (target && target !== ref.current && isTabbable(target)) { + e.stopPropagation(); + return; + } + baseOnMouseDown?.(e); + }; + return { - itemProps: mergeProps( - itemProps, - allowsSelection || hasPrimaryAction || (shouldUseVirtualFocus && !isDisabled) - ? pressProps - : {}, - longPressEnabled ? longPressProps : {}, - {onDoubleClick, onDragStartCapture, onClick, id}, - // Prevent DOM focus from moving on mouse down when using virtual focus - shouldUseVirtualFocus ? {onMouseDown: e => e.preventDefault()} : undefined - ), + itemProps: mergedItemProps, isPressed, isSelected: manager.isSelected(key), isFocused: manager.isFocused && manager.focusedKey === key, From 06b53088dcec731f1d4a4b036c9d5bff0d244d22 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 19 Jun 2026 15:20:01 -0700 Subject: [PATCH 22/52] fix case where click on cell didnt trigger row selection --- .../src/selection/useSelectableItem.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/react-aria/src/selection/useSelectableItem.ts b/packages/react-aria/src/selection/useSelectableItem.ts index 92f48ee4969..e2e6338a514 100644 --- a/packages/react-aria/src/selection/useSelectableItem.ts +++ b/packages/react-aria/src/selection/useSelectableItem.ts @@ -456,7 +456,14 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte let baseOnPointerDown = mergedItemProps.onPointerDown; mergedItemProps.onPointerDown = e => { let target = getEventTarget(e) as Element | null; - if (target && target !== ref.current && isTabbable(target)) { + // skip if the target is itself a collection item aka a cell in a row + // TODO: is checking data-key too brittle? + if ( + target && + target !== ref.current && + isTabbable(target) && + !target.hasAttribute('data-key') + ) { e.stopPropagation(); return; } @@ -466,7 +473,12 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte let baseOnMouseDown = mergedItemProps.onMouseDown; mergedItemProps.onMouseDown = e => { let target = getEventTarget(e) as Element | null; - if (target && target !== ref.current && isTabbable(target)) { + if ( + target && + target !== ref.current && + isTabbable(target) && + !target.hasAttribute('data-key') + ) { e.stopPropagation(); return; } From 7da4f02beaed3593eb4683ccde33c13a0b7d6a65 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 22 Jun 2026 11:45:50 -0700 Subject: [PATCH 23/52] fix case with nested collections and other review comments --- .../react-aria-components/test/Table.test.js | 45 +++++++++++++++++++ packages/react-aria/src/grid/useGridCell.ts | 15 +++++-- .../src/gridlist/useGridListItem.ts | 18 +++++--- .../src/selection/useSelectableItem.ts | 35 ++++++++------- 4 files changed, 88 insertions(+), 25 deletions(-) diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 179f2c5f662..4826bc34aa9 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -328,6 +328,38 @@ let TabModeFocusModeChildWithArrowNavTable = props => ( ); +let TableWithTagGroupInCell = props => ( + + + Name + Tags + + + + Games + + + + Action + RPG + + + + + + Movies + + + + Drama + + + + + +
+); + let DraggableTable = props => { let {dragAndDropHooks} = useDragAndDrop({ getItems: keys => [...keys].map(key => ({'text/plain': key})), @@ -3698,6 +3730,19 @@ describe('Table', () => { expect(onSelectionChange).not.toHaveBeenCalled(); }); + it('should not trigger row selection when clicking a TagGroup tag nested inside a cell', async () => { + let {getByRole} = render(); + + // click on actual grid cell since that mimics what would happen in browser + // note that grid cell is not tabbable nor has the data-collection/etc attributes since those + // are on a wrapping div + let tagGrid = getByRole('grid', {name: 'Games tags'}); + let tagInnerCell = within(tagGrid).getAllByRole('gridcell')[0]; + + await user.click(tagInnerCell); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + it('should still trigger selection when clicking on a row with no tabbable children ', async () => { let {getByRole} = render( diff --git a/packages/react-aria/src/grid/useGridCell.ts b/packages/react-aria/src/grid/useGridCell.ts index 81041bd6512..64a2a4c66f5 100644 --- a/packages/react-aria/src/grid/useGridCell.ts +++ b/packages/react-aria/src/grid/useGridCell.ts @@ -19,6 +19,7 @@ import { nodeContains } from '../utils/shadowdom/DOMFunctions'; import {getFocusableTreeWalker} from '../focus/FocusScope'; +import {getOwnerDocument} from '../utils/domHelpers'; import {getScrollParent} from '../utils/getScrollParent'; import { IGridCollection as GridCollection, @@ -112,7 +113,10 @@ export function useGridCell>( let treeWalker = getFocusableTreeWalker(ref.current); if (focusMode === 'child') { // If focus is already on a focusable child within the cell, early return so we don't shift focus - if (isFocusWithin(ref.current) && ref.current !== getActiveElement()) { + if ( + isFocusWithin(ref.current) && + ref.current !== getActiveElement(getOwnerDocument(ref.current)) + ) { return; } @@ -147,7 +151,7 @@ export function useGridCell>( }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - let activeElement = getActiveElement(); + let activeElement = getActiveElement(getOwnerDocument(ref.current)); if ( !nodeContains(e.currentTarget, getEventTarget(e) as Element) || state.isKeyboardNavigationDisabled || @@ -269,7 +273,7 @@ export function useGridCell>( }; let onKeyDown = (e: ReactKeyboardEvent) => { - let activeElement = getActiveElement(); + let activeElement = getActiveElement(getOwnerDocument(ref.current)); if ( !nodeContains(e.currentTarget, getEventTarget(e) as Element) || state.isKeyboardNavigationDisabled || @@ -333,7 +337,10 @@ export function useGridCell>( // If the cell itself is focused, wait a frame so that focus finishes propagating // up to the tree, and move focus to a focusable child if possible. requestAnimationFrame(() => { - if (focusMode === 'child' && getActiveElement() === ref.current) { + if ( + focusMode === 'child' && + getActiveElement(getOwnerDocument(ref.current)) === ref.current + ) { focus(); } }); diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 72b3e7923f1..e032b9af863 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -27,6 +27,7 @@ import { nodeContains } from '../utils/shadowdom/DOMFunctions'; import {getFocusableTreeWalker} from '../focus/FocusScope'; +import {getOwnerDocument} from '../utils/domHelpers'; import {getRowId, listMap} from './utils'; import {getScrollParent} from '../utils/getScrollParent'; import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react'; @@ -118,14 +119,18 @@ export function useGridListItem( if (focusMode === 'child') { // If focus is already on a focusable child within the row, early return so we don't shift focus - if (isFocusWithin(ref.current) && ref.current !== getActiveElement()) { + if ( + isFocusWithin(ref.current) && + ref.current !== getActiveElement(getOwnerDocument(ref.current)) + ) { return; } - let treeWalker = getFocusableTreeWalker(ref.current); + let treeWalker = getFocusableTreeWalker(ref.current, {tabbable: true}); let focusable = treeWalker.firstChild() as FocusableElement; if (focusable) { focusSafely(focusable); + scrollIntoViewport(focusable, {containingElement: getScrollParent(focusable)}); return; } } @@ -198,7 +203,7 @@ export function useGridListItem( }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - let activeElement = getActiveElement(); + let activeElement = getActiveElement(getOwnerDocument(ref.current)); if ( !nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || @@ -325,14 +330,17 @@ export function useGridListItem( // If the cell itself is focused, wait a frame so that focus finishes propagating // up to the tree, and move focus to a focusable child if possible. requestAnimationFrame(() => { - if (focusMode === 'child' && getActiveElement() === ref.current) { + if ( + focusMode === 'child' && + getActiveElement(getOwnerDocument(ref.current)) === ref.current + ) { focus(); } }); }; let onKeyDown = (e: ReactKeyboardEvent) => { - let activeElement = getActiveElement(); + let activeElement = getActiveElement(getOwnerDocument(ref.current)); if ( !nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || diff --git a/packages/react-aria/src/selection/useSelectableItem.ts b/packages/react-aria/src/selection/useSelectableItem.ts index e2e6338a514..b68a25a7c4e 100644 --- a/packages/react-aria/src/selection/useSelectableItem.ts +++ b/packages/react-aria/src/selection/useSelectableItem.ts @@ -359,7 +359,8 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte }; } - itemProps['data-collection'] = getCollectionId(manager.collection); + let collectionId = getCollectionId(manager.collection); + itemProps['data-collection'] = collectionId; itemProps['data-key'] = key; itemPressProps.preventFocusOnPress = shouldUseVirtualFocus; @@ -452,18 +453,25 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte shouldUseVirtualFocus ? {onMouseDown: e => e.preventDefault()} : undefined ); - // Guard against presses triggering selection when they happen on interactive children. + // Guard against presses triggering selection when they happen on interactive children or collection items from different collections + // will need to trigger selection if the target is itself a collection item belonging to the same collection parent (aka a cell in a row) but + // not if the target is a child of a different collections aka taggroup in table cell. + let isChildInteraction = (target: Element) => { + let el: Element | null = target; + while (el && el !== ref.current) { + let elCollection = el.getAttribute('data-collection'); + if (elCollection != null) { + return elCollection !== collectionId; + } + el = el.parentElement; + } + return isTabbable(target); + }; + let baseOnPointerDown = mergedItemProps.onPointerDown; mergedItemProps.onPointerDown = e => { let target = getEventTarget(e) as Element | null; - // skip if the target is itself a collection item aka a cell in a row - // TODO: is checking data-key too brittle? - if ( - target && - target !== ref.current && - isTabbable(target) && - !target.hasAttribute('data-key') - ) { + if (target && target !== ref.current && isChildInteraction(target)) { e.stopPropagation(); return; } @@ -473,12 +481,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte let baseOnMouseDown = mergedItemProps.onMouseDown; mergedItemProps.onMouseDown = e => { let target = getEventTarget(e) as Element | null; - if ( - target && - target !== ref.current && - isTabbable(target) && - !target.hasAttribute('data-key') - ) { + if (target && target !== ref.current && isChildInteraction(target)) { e.stopPropagation(); return; } From f7e7e3ed3348a840d5db108ac6eca43ad9aad842 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 22 Jun 2026 16:17:21 -0700 Subject: [PATCH 24/52] clean up tests so it uses one table implementation --- .../react-aria-components/test/Table.test.js | 188 +++++++----------- 1 file changed, 69 insertions(+), 119 deletions(-) diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 4826bc34aa9..8f222250494 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -232,129 +232,58 @@ let EditableTable = ({ ); -let TabModeTable = props => ( - - - - Name - - Type - Notes - - - - Games - File folder - - - - - - - Program Files - File folder - - - - - - bootmgr - System file - - - - - -
-); - -let TabModeFocusModeChildTable = props => ( - +let TabModeTable = ({actionCellProps = {}, ...tableProps}) => ( +
Name Type - Action + Tags + Notes Games File folder - - - - - - Program Files - File folder - - + + + + Action + RPG + + - - -
-); - -let TabModeFocusModeChildWithArrowNavTable = props => ( - - - Name - Type - Action - - - - Games - File folder - - + + + Program Files File folder - - - - - -
-); - -let TableWithTagGroupInCell = props => ( - - - Name - Tags - - - - Games - + - Action - RPG + Office + + + - - Movies + + bootmgr + System file - + - Drama + Boot + + +
@@ -3599,7 +3528,9 @@ describe('Table', () => { 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 tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); let row = tableTester.getRows()[0]; let cells = tableTester.getCells({element: row}); await user.tab(); @@ -3617,7 +3548,9 @@ describe('Table', () => {
); - let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); let rowheader = tableTester.getRowHeaders()[0]; let row = tableTester.getRows()[0]; let cells = tableTester.getCells({element: row}); @@ -3643,7 +3576,9 @@ describe('Table', () => { it('Shift+Tab from a child returns focus to the cell', async () => { let {getByRole} = render(); - let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); let row = tableTester.getRows()[0]; let cells = tableTester.getCells({element: row}); await user.tab(); @@ -3657,7 +3592,9 @@ describe('Table', () => { 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 tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); let row = tableTester.getRows()[0]; let cells = tableTester.getCells({element: row}); @@ -3680,7 +3617,9 @@ describe('Table', () => { 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 tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); let row = tableTester.getRows()[0]; let cells = tableTester.getCells({element: row}); @@ -3700,7 +3639,9 @@ describe('Table', () => { let {getByRole} = render( ); - let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); let row = tableTester.getRows()[0]; let cells = tableTester.getCells({element: row}); @@ -3731,7 +3672,9 @@ describe('Table', () => { }); it('should not trigger row selection when clicking a TagGroup tag nested inside a cell', async () => { - let {getByRole} = render(); + let {getByRole} = render( + + ); // click on actual grid cell since that mimics what would happen in browser // note that grid cell is not tabbable nor has the data-collection/etc attributes since those @@ -3747,7 +3690,9 @@ describe('Table', () => { let {getByRole} = render( ); - let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); let row = tableTester.getRows()[0]; await user.click(row); @@ -3757,30 +3702,35 @@ describe('Table', () => { describe("focusMode='child'", () => { it('arrow navigating to a focusMode="child" cell focuses the child directly', async () => { - let {getByRole} = render(); + let {getByRole} = render(); await user.tab(); + // ArrowLeft navigation uses childFocusStrategy='last', so focuses the last child (button) await user.keyboard('{ArrowLeft}'); - expect(document.activeElement).toBe(getByRole('button', {name: 'Games action'})); + expect(document.activeElement).toBe(getByRole('button', {name: 'Button next to input'})); }); it('allowsArrowNavigation allows arrow key row navigation when focused on child', async () => { - let {getByRole} = render(); + let {getByRole} = render( + + ); await user.tab(); await user.keyboard('{ArrowLeft}'); - expect(document.activeElement).toBe(getByRole('button', {name: 'Games action'})); + expect(document.activeElement).toBe(getByRole('button', {name: 'Button next to input'})); await user.keyboard('{ArrowDown}'); - expect(document.activeElement).toBe(getByRole('button', {name: 'Program Files action'})); + expect(document.activeElement).toBe(getByRole('textbox', {name: 'Program Files notes'})); }); - // TODO: for some reason the cell containing the button doesn't get refocused when we shift tab in the test but it + // TODO: for some reason the cell containing the input doesn't get refocused when we shift tab in the test but it // works in browser... it.skip('Shift+Tab from a focusMode="child" child returns focus to the cell without redirecting back to child', async () => { - let {getByRole} = render(); - let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let {getByRole} = render(); + let tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); let row = tableTester.getRows()[0]; let cells = tableTester.getCells({element: row}); - let actionCell = cells[cells.length - 1]; - let button = getByRole('button', {name: 'Games action'}); + let notesCell = cells[cells.length - 1]; + let button = getByRole('button', {name: 'Button next to input'}); await user.tab(); await user.keyboard('{ArrowLeft}'); @@ -3788,13 +3738,13 @@ describe('Table', () => { expect(document.activeElement).toBe(button); await user.tab({shift: true}); - expect(document.activeElement).toBe(actionCell); + expect(document.activeElement).toBe(notesCell); // TODO: even this doesn't work act(() => { - actionCell.focus(); + notesCell.focus(); }); act(() => jest.runAllTimers()); - expect(document.activeElement).toBe(actionCell); + expect(document.activeElement).toBe(notesCell); }); }); }); From f156d28c578e7daaef1f034c09c1fc7b1e00b210 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 25 Jun 2026 15:55:28 -0700 Subject: [PATCH 25/52] make sure shift tabbing from a child of a cell doesnt move focus back to cell if focusMode="child" and in tab nav --- .../stories/Table.stories.tsx | 16 +++++++------- .../react-aria-components/test/Table.test.js | 22 +++++-------------- packages/react-aria/src/grid/useGridCell.ts | 5 ++++- 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 11062e66210..3e41436acf9 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -2235,13 +2235,13 @@ const TableWithTextfieldRender = (args: TableWithTextfieldArgs) => { RAC Textfield - + Raw input - + @@ -2250,14 +2250,14 @@ const TableWithTextfieldRender = (args: TableWithTextfieldArgs) => { TextField + Button - + Toolbar - + @@ -2270,7 +2270,7 @@ const TableWithTextfieldRender = (args: TableWithTextfieldArgs) => { Menu - + @@ -2283,7 +2283,7 @@ const TableWithTextfieldRender = (args: TableWithTextfieldArgs) => { RadioGroup - + { CheckboxGroup - + @@ -2336,7 +2336,7 @@ const TableWithTextfieldRender = (args: TableWithTextfieldArgs) => { ComboBox - +
diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 39792ca3fe8..6aa5da699a2 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -3758,17 +3758,10 @@ describe('Table', () => { expect(document.activeElement).toBe(getByRole('textbox', {name: 'Program Files notes'})); }); - // TODO: for some reason the cell containing the input doesn't get refocused when we shift tab in the test but it - // works in browser... - it.skip('Shift+Tab from a focusMode="child" child returns focus to the cell without redirecting back to child', async () => { + it('Shift+Tab from a focusMode="child" child skips the cell and does not return to it', async () => { let {getByRole} = render(); - let tableTester = testUtilUser.createTester('Table', { - root: getByRole('grid', {name: 'Tab mode table'}) - }); - let row = tableTester.getRows()[0]; - let cells = tableTester.getCells({element: row}); - let notesCell = cells[cells.length - 1]; let button = getByRole('button', {name: 'Button next to input'}); + let input = getByRole('textbox', {name: 'Games notes'}); await user.tab(); await user.keyboard('{ArrowLeft}'); @@ -3776,13 +3769,10 @@ describe('Table', () => { expect(document.activeElement).toBe(button); await user.tab({shift: true}); - expect(document.activeElement).toBe(notesCell); - // TODO: even this doesn't work - act(() => { - notesCell.focus(); - }); - act(() => jest.runAllTimers()); - expect(document.activeElement).toBe(notesCell); + expect(document.activeElement).toBe(input); + + await user.tab({shift: true}); + expect(document.activeElement).toBe(document.body); }); }); }); diff --git a/packages/react-aria/src/grid/useGridCell.ts b/packages/react-aria/src/grid/useGridCell.ts index 35afe88c9f5..b998a245ab8 100644 --- a/packages/react-aria/src/grid/useGridCell.ts +++ b/packages/react-aria/src/grid/useGridCell.ts @@ -355,7 +355,10 @@ export function useGridCell>( 'aria-colspan': node.colSpan, 'aria-colindex': node.colIndex != null ? node.colIndex + 1 : undefined, // aria-colindex is 1-based colSpan: isVirtualized ? undefined : node.colSpan, - onFocus + onFocus, + // make sure shift tabbing from a child of a cell doesnt move focus back to cell if focusMode="child" and in tab nav + // consistent with arrow nav and focusMode="child" since you can't go back to the cell there either + ...(focusMode === 'child' && keyboardNavigationBehavior === 'tab' ? {tabIndex: -1} : {}) }); if (isVirtualized) { From ab75b15f01f665a6f77ad607ab37ad42e16354c3 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 26 Jun 2026 11:12:21 -0700 Subject: [PATCH 26/52] add coverage tests --- .../test/GridList.test.js | 172 ++++++++++++++++++ .../react-aria-components/test/Table.test.js | 105 +++++++++++ .../react-aria-components/test/Tree.test.tsx | 129 +++++++++++++ 3 files changed, 406 insertions(+) diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index a45d1cec019..fa24ce1dacf 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -42,6 +42,7 @@ import {ListLayout} from 'react-stately/useVirtualizerState'; import {Modal} from '../src/Modal'; import {Popover} from '../src/Popover'; import React from 'react'; +import {I18nProvider} from 'react-aria/I18nProvider'; import {RouterProvider} from 'react-aria/private/utils/openLink'; import {Tag, TagGroup, TagList} from '../src/TagGroup'; import {Toolbar} from '../src/Toolbar'; @@ -1949,6 +1950,24 @@ describe('GridList', () => { } ); + it('should not trigger selection when clicking on a tabbable child in arrow navigation mode', async () => { + 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'}] @@ -2138,5 +2157,158 @@ describe('GridList', () => { await user.keyboard('{ArrowDown}'); expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); }); + + describe('focusMode="child" with default arrow navigation', () => { + it('ArrowDown from child navigates to first child of next item', async () => { + let {getByRole} = render( + + + + + + + + + + + + + + + ); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 3 action'})); + }); + + it('ArrowUp from child navigates to first child of previous item', async () => { + let {getByRole} = render( + + + + + + + + + + + ); + await user.tab(); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + }); + + it('ArrowLeft/Right cycles through children and row element', async () => { + let {getByRole, getAllByRole} = render( + + + + + + + ); + let rows = getAllByRole('row'); + let firstBtn = getByRole('button', {name: 'Item 1 first'}); + let lastBtn = getByRole('button', {name: 'Item 1 last'}); + await user.tab(); + expect(document.activeElement).toBe(firstBtn); + // ArrowRight: first → last → row (wraps) + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(lastBtn); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(rows[0]); + // ArrowLeft from row wraps to last; from last goes to first; from first goes to row + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(lastBtn); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(firstBtn); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(rows[0]); + // ArrowRight from row enters first child + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(firstBtn); + }); + + describe('RTL', () => { + it('ArrowLeft/Right RTL cycle: ArrowLeft advances forward, ArrowRight wraps to last child', async () => { + // In RTL: ArrowLeft = forward (next), ArrowRight = backward (prev) + let {getByRole, getAllByRole} = render( + + + + + + + + + ); + let rows = getAllByRole('row'); + let firstBtn = getByRole('button', {name: 'Item 1 first'}); + let lastBtn = getByRole('button', {name: 'Item 1 last'}); + await user.tab(); + expect(document.activeElement).toBe(firstBtn); + // ArrowLeft in RTL moves forward: first → last → row (lines 243-244) + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(lastBtn); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(rows[0]); + // ArrowRight in RTL wraps back: row → last child (lines 277-282) + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(lastBtn); + }); + }); + }); + + describe('focusMode="row" (default) with tab navigation', () => { + it('Tab into list focuses row, Tab from row enters child, Shift+Tab returns to row', async () => { + let {getByRole, getAllByRole} = render( + + + + + + + ); + let rows = getAllByRole('row'); + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action 2'})); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.tab({shift: true}); + expect(document.activeElement).toBe(rows[0]); + }); + }); }); }); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 6aa5da699a2..aadc3891b64 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -289,6 +289,40 @@ let TabModeTable = ({actionCellProps = {}, ...tableProps}) => ( ); +let ArrowModeTable = ({cellProps = {}, ...tableProps}) => ( + + + Name + Col 2 + Col 3 + + + + Row 1 + + + + + + + + + + + Row 2 + + + + + + + + + + +
+); + let DraggableTable = props => { let {dragAndDropHooks} = useDragAndDrop({ getItems: keys => [...keys].map(key => ({'text/plain': key})), @@ -3776,6 +3810,77 @@ describe('Table', () => { }); }); }); + + describe('cells with focusable children in arrow navigation mode', () => { + it('default focusMode: ArrowRight crosses from last child to first child of next cell, ArrowLeft reverses', async () => { + let {getByRole} = render(); + await user.tab(); + // Tab → row, ArrowRight×2 → col2 first child (col1 is text-only, focuses cell then crosses) + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C2 first'})); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C2 last'})); + // ArrowRight from last child crosses into first child of next cell + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C3 first'})); + // ArrowLeft from first child crosses back to last child of previous cell + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C2 last'})); + }); + + describe("focusMode='cell'", () => { + it('arrow navigation with focusMode="cell": cell element stays focused on navigate, arrows enter/exit children within cell', async () => { + // walker.previousNode() from first child returns cell root → stays on same cell + // (contrast with default focusMode="child" which crosses to prev cell's last child) + let {getByRole, getAllByRole} = render(); + let rows = getAllByRole('row'); + let col2Cell = within(rows[1]).getAllByRole('gridcell')[0]; + let col3Cell = within(rows[1]).getAllByRole('gridcell')[1]; + + await user.tab(); + // ArrowRight×2: row → col1 (text-only, focuses cell) → col2Cell (focusMode='cell' stays on cell element) + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(col2Cell); + + // ArrowRight from cell element enters first child + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C2 first'})); + + // ArrowRight within cell advances to next child + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C2 last'})); + + // ArrowLeft within cell retreats to previous child + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C2 first'})); + + // ArrowLeft from first child returns to same cell element (not previous cell's last child) + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(col2Cell); + + // Navigate forward to reach col3: col2Cell → first → last → col3Cell + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + // ArrowRight from last child of col2 enters next cell element (not its first child) + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(col3Cell); + + // ArrowRight from col3Cell enters its first child + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C3 first'})); + + // ArrowLeft from first child of col3 returns to col3Cell (same cell, not col2) + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(col3Cell); + + // ArrowLeft from cell element moves to previous cell element + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(col2Cell); + }); + }); + }); }); function HidingColumnsExample({dynamic = false}) { diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 6e0d6de7b1e..e838a889882 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -2985,6 +2985,135 @@ describe('Tree', () => { await user.keyboard('{ArrowDown}'); expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); }); + + describe('focusMode="child" with default arrow navigation', () => { + it('ArrowDown from child navigates to first child of next item', async () => { + let {getByRole} = render( + + + + + + + + + + + + + + + + + + + + + ); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 3 action'})); + }); + + it('ArrowUp from child navigates to first child of previous item', async () => { + let {getByRole} = render( + + + + + + + + + + + + + + + ); + await user.tab(); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + }); + }); + + describe('focusMode="row" (default) with tab navigation', () => { + it('Tab into tree focuses row, Tab from row enters child, Shift+Tab returns to row', async () => { + let {getByRole, getAllByRole} = render( + + + + + + + + + ); + let rows = getAllByRole('row'); + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action 2'})); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.tab({shift: true}); + expect(document.activeElement).toBe(rows[0]); + }); + + it('ArrowRight expands collapsed parent item in tab mode', async () => { + let {getAllByRole} = render( + + + + {({hasChildItems, isExpanded}) => ( + <> + {hasChildItems && } + Parent + + )} + + + {() => Child 1} + + + + {() => Item 2} + + + ); + let rows = getAllByRole('row'); + // Only parent and item2 visible initially (parent is collapsed) + expect(rows).toHaveLength(2); + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + // ArrowRight on a collapsed parent with children expands it (handleTreeExpansionKeys returns true) + await user.keyboard('{ArrowRight}'); + let expandedRows = getAllByRole('row'); + expect(expandedRows).toHaveLength(3); + }); + }); }); }); From 34e52a93df60536ff5deaf0b1891ee388bdb063c Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 26 Jun 2026 14:40:08 -0700 Subject: [PATCH 27/52] fix lint --- packages/react-aria-components/test/GridList.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index fa24ce1dacf..ebac9a247e8 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -34,6 +34,7 @@ import { GridListSection } from '../src/GridList'; import {GridListLoadMoreItem} from '../src/GridList'; +import {I18nProvider} from 'react-aria/I18nProvider'; import {Input} from '../src/Input'; import {installPointerEvent, User} from '@react-aria/test-utils'; import {Label} from '../src/Label'; @@ -42,7 +43,6 @@ import {ListLayout} from 'react-stately/useVirtualizerState'; import {Modal} from '../src/Modal'; import {Popover} from '../src/Popover'; import React from 'react'; -import {I18nProvider} from 'react-aria/I18nProvider'; import {RouterProvider} from 'react-aria/private/utils/openLink'; import {Tag, TagGroup, TagList} from '../src/TagGroup'; import {Toolbar} from '../src/Toolbar'; From dabc8626f150cabb09bb2b1c300d62a3d4d418ae Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 24 Jun 2026 16:05:52 +1000 Subject: [PATCH 28/52] feat: S2 SideNav --- packages/@react-spectrum/s2/src/SideNav.tsx | 725 ++++++++++++++++++ .../s2/stories/SideNav.stories.tsx | 209 +++++ packages/react-aria-components/src/Tree.tsx | 1 + 3 files changed, 935 insertions(+) create mode 100644 packages/@react-spectrum/s2/src/SideNav.tsx create mode 100644 packages/@react-spectrum/s2/stories/SideNav.stories.tsx diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx new file mode 100644 index 00000000000..170003a01aa --- /dev/null +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -0,0 +1,725 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {ActionButtonGroupContext} from './ActionButtonGroup'; +import {ActionMenuContext} from './ActionMenu'; +import { + baseColor, + color, + colorMix, + focusRing, + fontRelative, + style +} from '../style' with {type: 'macro'}; +import {focusSafely} from 'react-aria/private/interactions/focusSafely'; +import {getFocusableTreeWalker} from 'react-aria/private/focus/FocusScope'; +import { + getActiveElement, + getEventTarget, + isFocusWithin, + nodeContains +} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +import {Button, ButtonContext} from 'react-aria-components/Button'; +import {centerBaseline} from './CenterBaseline'; +import Chevron from '../ui-icons/Chevron'; +import {DOMRef, forwardRefType, GlobalDOMAttributes, Key} from '@react-types/shared'; +import {edgeToText} from '../style/spectrum-theme' with {type: 'macro'}; +import { + getAllowedOverrides, + StylesPropWithHeight, + UnsafeStyles +} from './style-utils' with {type: 'macro'}; +import {IconContext} from './Icon'; +import {Link} from 'react-aria-components/Link'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {Provider, useContextProps} from 'react-aria-components/slots'; +import { + TreeItemProps as RACTreeItemProps, + TreeProps as RACTreeProps, + Tree, + TreeItem, + TreeItemContent, + TreeItemContentProps, + TreeItemRenderProps, + TreeLoadMoreItem, + TreeLoadMoreItemProps, + TreeRenderProps, + TreeSection, + TreeHeader +} from 'react-aria-components/Tree'; +import React, { + createContext, + forwardRef, + JSXElementConstructor, + ReactElement, + ReactNode, + useContext, + useRef +} from 'react'; +import {Text, TextContext} from './Content'; +import {TreeState} from 'react-stately/useTreeState'; +import {useDOMRef} from './useDOMRef'; +import {useLocale} from 'react-aria/I18nProvider'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {useScale} from './utils'; +import {SelectionIndicator} from 'react-aria-components/SelectionIndicator'; + +interface S2SideNavProps {} + +interface SideNavStyleProps {} + +export interface TreeViewProps + extends + Omit< + RACTreeProps, + | 'style' + | 'className' + | 'render' + | 'onRowAction' + | 'selectionBehavior' + | 'onScroll' + | 'onCellAction' + | keyof GlobalDOMAttributes + >, + UnsafeStyles, + S2SideNavProps, + SideNavStyleProps { + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StylesPropWithHeight; +} + +export interface SideNavItemProps extends Omit< + RACTreeItemProps, + 'className' | 'style' | 'render' | 'onClick' | keyof GlobalDOMAttributes +> { + /** Whether this item has children, even if not loaded yet. */ + hasChildItems?: boolean; +} + +interface TreeRendererContextValue { + renderer?: (item) => ReactElement>; +} +const TreeRendererContext = createContext({}); + +const sideNavWrapper = style( + { + minHeight: 0, + height: 'full', + minWidth: 160, + display: 'flex', + isolation: 'isolate', + disableTapHighlight: true, + position: 'relative', + overflow: 'clip', + '--indicator-level-padding': { + type: 'width', + value: { + // 4 (start gap) + 10 (drag handle) + (hasCheckbox ? 16 + 8 : 0) + 40 (expand button) + // keep in sync with treeCellGrid gridTemplateColumns + default: 54 + } + } + }, + getAllowedOverrides({height: true}) +); + +// TODO: the below is needed so the borders of the top and bottom row isn't cut off if the TreeView is wrapped within a container by always reserving the 2px needed for the +// keyboard focus ring. Perhaps find a different way of rendering the outlines since the top of the item doesn't +// scroll into view due to how the ring is offset. Alternatively, have the tree render the top/bottom outline like it does in Listview +const tree = style({ + ...focusRing(), + outlineOffset: -2, // make certain we are visible inside overflow hidden containers + userSelect: 'none', + minHeight: 0, + minWidth: 0, + width: 'full', + height: 'full', + overflow: 'auto', + boxSizing: 'border-box', + '--indent': { + type: 'width', + value: 16 + } +}); + +let InternalSideNavContext = createContext({}); + +/** + * A tree view provides users with a way to navigate nested hierarchical information. + */ +export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function SideNav( + props: SideNavProps, + ref: DOMRef +) { + let {children, UNSAFE_className, UNSAFE_style} = props; + + let renderer; + if (typeof children === 'function') { + renderer = children; + } + + let domRef = useDOMRef(ref); + let scrollRef = useRef(null); + + return ( +
+ + + tree(renderProps)} + selectionBehavior="replace" + selectionMode="single" + ref={scrollRef}> + {props.children} + + + +
+ ); +}); + +const treeRow = style({ + outlineStyle: 'none', + position: 'relative', + display: 'flex', + height: 32, + width: 'full', + boxSizing: 'border-box', + font: 'ui', + color: { + default: baseColor('neutral-subdued'), + forcedColors: 'ButtonText' + }, + cursor: { + default: 'default', + isLink: 'pointer' + }, + '--borderRadiusTreeItem': { + type: 'borderTopStartRadius', + value: 'sm' + }, + borderRadius: 'sm', + marginTop: { + ':not([aria-posinset="1"])': '[6px]', + ':first-child': 0 + } +}); + +const treeCellGrid = style({ + display: 'grid', + width: 'full', + height: 'full', + boxSizing: 'border-box', + alignContent: 'center', + alignItems: 'center', + gridTemplateColumns: [12, 'auto', 'auto', '1fr', 'auto'], + gridTemplateRows: '1fr', + gridTemplateAreas: ['. level-padding icon content expand-button'], + paddingEnd: 4, // account for any focus rings on the last item in the cell + color: { + default: baseColor('neutral-subdued'), + isSelected: baseColor('neutral'), + isDisabled: { + default: 'gray-400', + forcedColors: 'GrayText' + }, + forcedColors: 'ButtonText' + }, + '--rowSelectedBorderColor': { + type: 'outlineColor', + value: { + default: 'gray-800', + isFocusVisible: 'focus-ring', + forcedColors: 'Highlight' + } + }, + '--rowForcedFocusBorderColor': { + type: 'outlineColor', + value: { + default: 'focus-ring', + forcedColors: 'Highlight' + } + }, + '--borderColor': { + type: 'borderColor', + value: { + default: 'blue-900', + forcedColors: 'ButtonBorder' + } + }, + forcedColorAdjust: 'none' +}); + +const treeIcon = style({ + gridArea: 'icon', + marginEnd: 'text-to-visual', + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + } +}); + +const treeContent = style({ + gridArea: 'content', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden' +}); + +let treeRowFocusRing = style({ + ...focusRing(), + outlineOffset: -2, + outlineWidth: 2, + outlineColor: { + default: 'focus-ring', + forcedColors: 'ButtonBorder' + }, + position: 'absolute', + inset: 0, + top: { + default: '[-1px]', + isFirstItem: 0 + }, + bottom: { + default: 0, + isNextSelected: '[-1px]', + isSelected: { + default: 0, + isNextSelected: 0 + } + }, + borderRadius: 'default', // tokens say 12... but that seems a lot, should it match selection in other collections? + zIndex: 1, + pointerEvents: 'none' +}); + +const SideNavItemContext = createContext<{href?: string}>({}); +export const SideNavItem = (props: SideNavItemProps, ref: DOMRef): ReactNode => { + let {href, hrefLang, target, rel, download, ping, referrerPolicy, routerOptions, ...rest} = props; + let backupRef = useRef(null); + let domRef = ref || backupRef; + + let keyWhenFocused = useRef(null); + let focus = () => { + if (domRef.current) { + let treeWalker = getFocusableTreeWalker(domRef.current); + // If focus is already on a focusable child within the cell, early return so we don't shift focus + if (isFocusWithin(domRef.current) && domRef.current !== getActiveElement()) { + return; + } + + let focusable = treeWalker.firstChild() as FocusableElement; + if (focusable) { + focusSafely(focusable); + return; + } + + if ( + (keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || + !isFocusWithin(domRef.current) + ) { + focusSafely(domRef.current); + } + } + }; + + let onFocus = (e: React.FocusEvent) => { + props?.onFocus?.(e); + requestAnimationFrame(() => { + focus(); + }); + }; + + return ( + + treeRow(renderProps)} + /> + + ); +}; + +export interface SideNavItemContentProps extends Omit { + /** Rendered contents of the tree item or child items. */ + children: ReactNode; +} + +const selectedIndicator = style<{isDisabled: boolean}>({ + position: 'absolute', + backgroundColor: { + default: 'neutral', + isDisabled: 'disabled', + forcedColors: { + default: 'Highlight', + isDisabled: 'GrayText' + } + }, + height: 18, + width: '[2px]', + contain: 'strict', + transition: { + default: '[translate,width,height]', + '@media (prefers-reduced-motion: reduce)': 'none' + }, + transitionDuration: 200, + transitionTimingFunction: 'out', + top: '50%', + transform: 'translateY(-50%)', + insetStart: 4, + borderStyle: 'none', + borderRadius: 'full' +}); + +const hoveredIndicator = style({ + position: 'absolute', + backgroundColor: { + default: 'neutral-subdued', + forcedColors: 'Highlight' + }, + height: 18, + width: '[2px]', + contain: 'strict', + top: '50%', + transform: 'translateY(-50%)', + insetStart: 4, + borderStyle: 'none', + borderRadius: 'full' +}); + +export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => { + let {children} = props; + let scale = useScale(); + let {href, hrefLang, target, rel, download, ping, referrerPolicy, routerOptions} = + useContext(SideNavItemContext); + let ref = useRef(null); + + if (href) { + return ( + + {({ + isExpanded, + hasChildItems, + isDisabled, + isSelected, + id, + state, + isHovered, + isFocusVisible + }) => { + return ( + <> + {isHovered &&
} + + + {({isFocusVisible: linkFocusVisible}) => { + return ( +
+ {(linkFocusVisible || isFocusVisible) && ( +
+ )} +
+ + {typeof children === 'string' ? {children} : children} + +
+ ); + }} + + + + ); + }} + + ); + } + + return ( + + {({ + isExpanded, + hasChildItems, + isDisabled, + isSelected, + id, + state, + isHovered, + isFocusVisible + }) => { + return ( + <> + {isHovered &&
} + +
+ {isFocusVisible && ( +
+ )} +
+ + {typeof children === 'string' ? {children} : children} + +
+ + + ); + }} + + ); +}; + +interface ExpandableRowChevronProps { + isExpanded?: boolean; + isDisabled?: boolean; + isRTL?: boolean; + scale: 'medium' | 'large'; + isHidden?: boolean; +} + +const expandButton = style({ + gridArea: 'expand-button', + color: { + default: 'inherit', + isDisabled: { + default: 'disabled', + forcedColors: 'GrayText' + } + }, + height: 32, + width: 32, + display: 'flex', + flexWrap: 'wrap', + alignContent: 'center', + justifyContent: 'center', + outlineStyle: 'none', + cursor: 'default', + transform: { + isExpanded: { + default: 'rotate(90deg)', + isRTL: 'rotate(-90deg)' + } + }, + padding: 0, + transition: 'default', + backgroundColor: 'transparent', + borderStyle: 'none', + disableTapHighlight: true, + visibility: { + isHidden: 'hidden' + } +}); + +function ExpandableRowChevron(props: ExpandableRowChevronProps) { + let expandButtonRef = useRef(null); + let [fullProps, ref] = useContextProps( + {...props, slot: 'chevron'}, + expandButtonRef, + ButtonContext + ); + let {isExpanded, scale, isHidden} = fullProps; + let {direction} = useLocale(); + + return ( + + ); +} + +export interface SideNavSectionProps extends SectionProps {} + +export function SideNavSection(props: SideNavSectionProps) { + return ( + + {props.children} + + ); +} + +export const SideNavHeader = forwardRef((props, ref) => { + return ( + + {props.children} + + ); +}); + +export interface SideNavCategoryProps extends Omit< + RACTreeItemProps, + | 'className' + | 'style' + | 'href' + | 'hrefLang' + | 'target' + | 'rel' + | 'download' + | 'ping' + | 'referrerPolicy' + | 'routerOptions' +> { + /** Whether this item has children, even if not loaded yet. */ + hasChildItems?: boolean; + counter?: number; +} + +export const SideNavCategory = (props: SideNavCategoryProps): ReactNode => { + return ( + + treeRow({ + ...renderProps + }) + } + /> + ); +}; + +function isNextSelected(id: Key | undefined, state: TreeState) { + if (id == null || !state) { + return false; + } + let keyAfter = state.collection.getKeyAfter(id); + + // We need to skip non-item nodes because the selection manager will map non-item nodes to their parent before checking selection + let node = keyAfter != null ? state.collection.getItem(keyAfter) : null; + while (node && node.type !== 'item' && keyAfter != null) { + keyAfter = state.collection.getKeyAfter(keyAfter); + node = keyAfter != null ? state.collection.getItem(keyAfter) : null; + } + + return keyAfter != null && state.selectionManager.isSelected(keyAfter); +} + +function isPrevSelected(id: Key | undefined, state: TreeState) { + if (id == null || !state) { + return false; + } + let keyBefore = state.collection.getKeyBefore(id); + return keyBefore != null && state.selectionManager.isSelected(keyBefore); +} + +function isFirstItem(id: Key | undefined, state: TreeState) { + if (id == null || !state) { + return false; + } + return state.collection.getFirstKey() === id; +} diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx new file mode 100644 index 00000000000..8839dc5cf20 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -0,0 +1,209 @@ +/** + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {action} from 'storybook/actions'; +import {categorizeArgTypes, getActionArgs} from './utils'; +import {Collection} from 'react-aria/Collection'; +import {Content, Heading, Text} from '../src/Content'; +import Copy from '../s2wf-icons/S2_Icon_Copy_20_N.svg'; +import Delete from '../s2wf-icons/S2_Icon_Delete_20_N.svg'; +import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; +import FileTxt from '../s2wf-icons/S2_Icon_FileText_20_N.svg'; +import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; +import type {Meta, StoryObj} from '@storybook/react'; +import React, {ReactElement, useCallback, useState} from 'react'; +import {style} from '../style' with {type: 'macro'}; +import { + SideNav, + SideNavItem, + SideNavItemContent, + SideNavSection, + SideNavHeader, + SideNavCategory +} from '../src/SideNav'; +import {useAsyncList} from 'react-stately/useAsyncList'; +import {useListData} from 'react-stately/useListData'; +import {useTreeData} from 'react-stately/useTreeData'; + +const events = ['onSelectionChange']; + +const meta: Meta = { + component: SideNav, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + args: {...getActionArgs(events)}, + argTypes: { + ...categorizeArgTypes('Events', [...events]), + children: {table: {disable: true}} + } +}; + +export default meta; + +type SideNavStoryObj = StoryObj; + +const SideNavExampleStatic = args => ( +
+ + + + Your files + + + + + + Your libraries + + + + Projects-1 + + + + Projects-1A + + + + + + Projects-2 + + + + + Projects-3 + + + + +
+); + +export const Example: SideNavStoryObj = { + render: SideNavExampleStatic, + args: {} +}; + +const SideNavSectionsExample = args => ( +
+ + + Photography + + + Your files + + + + + + Work + + + Your libraries + + + + Projects-1 + + + + Projects-1A + + + + + + Projects-2 + + + + + Projects-3 + + + + + +
+); + +export const SideNavSections = { + render: SideNavSectionsExample, + args: { + selectionMode: 'single' + } +}; + +const SideNavExampleCategory = args => ( +
+ + + + Your files + + + + + + Your libraries + + + + Projects-1 + + + + Projects-1A + + + + + + Projects-2 + + + + + Projects-3 + + + + +
+); + +export const Category = { + render: SideNavExampleCategory, + args: { + selectionMode: 'single' + } +}; diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index d826652f415..4280feb1ad8 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -975,6 +975,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( DOMProps, rowProps, focusProps, + {onFocus: props.onFocus, onBlur: props.onBlur}, hoverProps, focusWithinProps, draggableItem?.dragProps From ec9435313102a6ea21f14b6bd6cc8a9094a83f07 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 25 Jun 2026 10:41:58 +1000 Subject: [PATCH 29/52] hack a way for arrow key navigation to continue to work --- packages/@react-spectrum/s2/src/SideNav.tsx | 43 ++++--------------- .../s2/stories/SideNav.stories.tsx | 20 +++------ packages/react-aria-components/src/Tree.tsx | 3 +- .../src/gridlist/useGridListItem.ts | 27 ++++++++++-- 4 files changed, 39 insertions(+), 54 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 170003a01aa..567dffbd9b4 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -10,52 +10,34 @@ * governing permissions and limitations under the License. */ -import {ActionButtonGroupContext} from './ActionButtonGroup'; -import {ActionMenuContext} from './ActionMenu'; -import { - baseColor, - color, - colorMix, - focusRing, - fontRelative, - style -} from '../style' with {type: 'macro'}; -import {focusSafely} from 'react-aria/private/interactions/focusSafely'; -import {getFocusableTreeWalker} from 'react-aria/private/focus/FocusScope'; -import { - getActiveElement, - getEventTarget, - isFocusWithin, - nodeContains -} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +import {baseColor, focusRing, fontRelative, style} from '../style' with {type: 'macro'}; import {Button, ButtonContext} from 'react-aria-components/Button'; import {centerBaseline} from './CenterBaseline'; import Chevron from '../ui-icons/Chevron'; import {DOMRef, forwardRefType, GlobalDOMAttributes, Key} from '@react-types/shared'; import {edgeToText} from '../style/spectrum-theme' with {type: 'macro'}; +import {focusSafely} from 'react-aria/private/interactions/focusSafely'; +import {getActiveElement, isFocusWithin} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import { getAllowedOverrides, StylesPropWithHeight, UnsafeStyles } from './style-utils' with {type: 'macro'}; +import {getFocusableTreeWalker} from 'react-aria/private/focus/FocusScope'; import {IconContext} from './Icon'; import {Link} from 'react-aria-components/Link'; -// @ts-ignore -import intlMessages from '../intl/*.json'; import {Provider, useContextProps} from 'react-aria-components/slots'; import { TreeItemProps as RACTreeItemProps, TreeProps as RACTreeProps, Tree, + TreeHeader, TreeItem, TreeItemContent, - TreeItemContentProps, TreeItemRenderProps, - TreeLoadMoreItem, - TreeLoadMoreItemProps, TreeRenderProps, TreeSection, - TreeHeader + TreeStateContext } from 'react-aria-components/Tree'; import React, { createContext, @@ -66,13 +48,13 @@ import React, { useContext, useRef } from 'react'; +import {SelectionIndicator} from 'react-aria-components/SelectionIndicator'; import {Text, TextContext} from './Content'; import {TreeState} from 'react-stately/useTreeState'; import {useDOMRef} from './useDOMRef'; +import {useKeyboard} from 'react-aria/useKeyboard'; import {useLocale} from 'react-aria/I18nProvider'; -import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useScale} from './utils'; -import {SelectionIndicator} from 'react-aria-components/SelectionIndicator'; interface S2SideNavProps {} @@ -353,6 +335,7 @@ export const SideNavItem = (props: SideNavItemProps, ref: DOMRef value={{href, hrefLang, target, rel, download, ping, referrerPolicy, routerOptions}}> treeRow(renderProps)} @@ -709,14 +692,6 @@ function isNextSelected(id: Key | undefined, state: TreeState) { return keyAfter != null && state.selectionManager.isSelected(keyAfter); } -function isPrevSelected(id: Key | undefined, state: TreeState) { - if (id == null || !state) { - return false; - } - let keyBefore = state.collection.getKeyBefore(id); - return keyBefore != null && state.selectionManager.isSelected(keyBefore); -} - function isFirstItem(id: Key | undefined, state: TreeState) { if (id == null || !state) { return false; diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx index 8839dc5cf20..800d9a1095d 100644 --- a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -12,28 +12,18 @@ import {action} from 'storybook/actions'; import {categorizeArgTypes, getActionArgs} from './utils'; -import {Collection} from 'react-aria/Collection'; -import {Content, Heading, Text} from '../src/Content'; -import Copy from '../s2wf-icons/S2_Icon_Copy_20_N.svg'; -import Delete from '../s2wf-icons/S2_Icon_Delete_20_N.svg'; -import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; -import FileTxt from '../s2wf-icons/S2_Icon_FileText_20_N.svg'; import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; -import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; import type {Meta, StoryObj} from '@storybook/react'; -import React, {ReactElement, useCallback, useState} from 'react'; -import {style} from '../style' with {type: 'macro'}; +import React from 'react'; import { SideNav, + SideNavCategory, + SideNavHeader, SideNavItem, SideNavItemContent, - SideNavSection, - SideNavHeader, - SideNavCategory + SideNavSection } from '../src/SideNav'; -import {useAsyncList} from 'react-stately/useAsyncList'; -import {useListData} from 'react-stately/useListData'; -import {useTreeData} from 'react-stately/useTreeData'; +import {Text} from '../src/Content'; const events = ['onSelectionChange']; diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 4280feb1ad8..cf394d6e2a4 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -789,7 +789,8 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( node: item, shouldSelectOnPressUp: !!dragState, focusMode: props.focusMode, - allowsArrowNavigation: props.allowsArrowNavigation + allowsArrowNavigation: props.allowsArrowNavigation, + allowChildKeys: props.allowChildKeys }, state, ref diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 8d3a2139747..9e603535980 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -216,7 +216,16 @@ export function useGridListItem( walker.currentNode = activeElement; if ( - handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current) + handleTreeExpansionKeys( + e, + state, + node, + hasChildRows, + direction, + activeElement, + ref.current, + props.allowChildKeys + ) ) { return; } @@ -359,7 +368,16 @@ export function useGridListItem( } if ( - handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current) + handleTreeExpansionKeys( + e, + state, + node, + hasChildRows, + direction, + activeElement, + ref.current, + props.allowChildKeys + ) ) { return; } @@ -461,9 +479,10 @@ function handleTreeExpansionKeys( hasChildRows: boolean | undefined, direction: string, activeElement: Element | null, - rowRef: FocusableElement | null + rowRef: FocusableElement | null, + allowChildKeys: boolean ): boolean { - if (!('expandedKeys' in state) || activeElement !== rowRef) { + if (!('expandedKeys' in state) || (activeElement !== rowRef && !allowChildKeys)) { return false; } if ( From 4e5a5a56e369a6f4e3488434bd739bed5bf94756 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 26 Jun 2026 08:52:12 +1000 Subject: [PATCH 30/52] working --- packages/@react-spectrum/s2/src/SideNav.tsx | 2 ++ packages/@react-spectrum/s2/stories/SideNav.stories.tsx | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 567dffbd9b4..5774bef99c4 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -416,6 +416,8 @@ export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => {isHovered &&
} ( aria-label="test static tree" onExpandedChange={action('onExpandedChange')} onSelectionChange={action('onSelectionChange')}> - + Your files - + Your libraries From 972c1d61493cd0088d9b53c59dcc417a133fe754 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 6 Jul 2026 16:23:49 +1000 Subject: [PATCH 31/52] move to new grid navigation behaviours --- packages/@react-spectrum/s2/src/SideNav.tsx | 35 ++------------------- packages/react-aria-components/src/Tree.tsx | 3 +- 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 5774bef99c4..61e816d9a27 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -169,6 +169,8 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid className={renderProps => tree(renderProps)} selectionBehavior="replace" selectionMode="single" + keyboardNavigationBehavior="arrow" + disallowEmptySelection ref={scrollRef}> {props.children} @@ -299,44 +301,13 @@ export const SideNavItem = (props: SideNavItemProps, ref: DOMRef let backupRef = useRef(null); let domRef = ref || backupRef; - let keyWhenFocused = useRef(null); - let focus = () => { - if (domRef.current) { - let treeWalker = getFocusableTreeWalker(domRef.current); - // If focus is already on a focusable child within the cell, early return so we don't shift focus - if (isFocusWithin(domRef.current) && domRef.current !== getActiveElement()) { - return; - } - - let focusable = treeWalker.firstChild() as FocusableElement; - if (focusable) { - focusSafely(focusable); - return; - } - - if ( - (keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !isFocusWithin(domRef.current) - ) { - focusSafely(domRef.current); - } - } - }; - - let onFocus = (e: React.FocusEvent) => { - props?.onFocus?.(e); - requestAnimationFrame(() => { - focus(); - }); - }; - return ( treeRow(renderProps)} /> diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index cf394d6e2a4..4280feb1ad8 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -789,8 +789,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( node: item, shouldSelectOnPressUp: !!dragState, focusMode: props.focusMode, - allowsArrowNavigation: props.allowsArrowNavigation, - allowChildKeys: props.allowChildKeys + allowsArrowNavigation: props.allowsArrowNavigation }, state, ref From 4ce1fc069fe36de9322aa9b0fbb55f7f9759b40f Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 7 Jul 2026 14:55:30 +1000 Subject: [PATCH 32/52] remove allowChildKeys hack, add code to get in front of navigation, change API, add tests --- packages/@react-spectrum/s2/src/SideNav.tsx | 257 +++++++++--------- .../s2/stories/SideNav.stories.tsx | 27 +- .../@react-spectrum/s2/test/SideNav.test.tsx | 174 ++++++++++++ .../react-aria-components/exports/Tree.ts | 1 + packages/react-aria-components/src/Tree.tsx | 1 - .../src/gridlist/useGridListItem.ts | 27 +- 6 files changed, 320 insertions(+), 167 deletions(-) create mode 100644 packages/@react-spectrum/s2/test/SideNav.test.tsx diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 61e816d9a27..8eb622ce0ad 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -15,36 +15,36 @@ import {Button, ButtonContext} from 'react-aria-components/Button'; import {centerBaseline} from './CenterBaseline'; import Chevron from '../ui-icons/Chevron'; import {DOMRef, forwardRefType, GlobalDOMAttributes, Key} from '@react-types/shared'; -import {edgeToText} from '../style/spectrum-theme' with {type: 'macro'}; -import {focusSafely} from 'react-aria/private/interactions/focusSafely'; -import {getActiveElement, isFocusWithin} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import { getAllowedOverrides, StylesPropWithHeight, UnsafeStyles } from './style-utils' with {type: 'macro'}; -import {getFocusableTreeWalker} from 'react-aria/private/focus/FocusScope'; -import {IconContext} from './Icon'; -import {Link} from 'react-aria-components/Link'; -import {Provider, useContextProps} from 'react-aria-components/slots'; +import {getEventTarget} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import { + GridListSectionProps, TreeItemProps as RACTreeItemProps, TreeProps as RACTreeProps, Tree, TreeHeader, TreeItem, TreeItemContent, + TreeItemContentProps, TreeItemRenderProps, TreeRenderProps, - TreeSection, - TreeStateContext + TreeSection } from 'react-aria-components/Tree'; +import {IconContext} from './Icon'; +import {Link, LinkProps} from 'react-aria-components/Link'; +import {Provider, useContextProps} from 'react-aria-components/slots'; import React, { createContext, forwardRef, JSXElementConstructor, ReactElement, + KeyboardEvent as ReactKeyboardEvent, ReactNode, + RefObject, useContext, useRef } from 'react'; @@ -52,15 +52,10 @@ import {SelectionIndicator} from 'react-aria-components/SelectionIndicator'; import {Text, TextContext} from './Content'; import {TreeState} from 'react-stately/useTreeState'; import {useDOMRef} from './useDOMRef'; -import {useKeyboard} from 'react-aria/useKeyboard'; import {useLocale} from 'react-aria/I18nProvider'; import {useScale} from './utils'; -interface S2SideNavProps {} - -interface SideNavStyleProps {} - -export interface TreeViewProps +export interface SideNavProps extends Omit< RACTreeProps, @@ -74,12 +69,13 @@ export interface TreeViewProps | keyof GlobalDOMAttributes >, UnsafeStyles, - S2SideNavProps, SideNavStyleProps { /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight; } +interface SideNavStyleProps {} + export interface SideNavItemProps extends Omit< RACTreeItemProps, 'className' | 'style' | 'render' | 'onClick' | keyof GlobalDOMAttributes @@ -134,7 +130,14 @@ const tree = style({ } }); -let InternalSideNavContext = createContext({}); +interface InternalSideNavContextValue { + /** + * Ref to the tree state, bridged up from SideNavItemContent so arrow-key handling can toggle + * expansion. + */ + stateRef?: RefObject | null>; +} +let InternalSideNavContext = createContext({}); /** * A tree view provides users with a way to navigate nested hierarchical information. @@ -152,14 +155,46 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid let domRef = useDOMRef(ref); let scrollRef = useRef(null); + let stateRef = useRef | null>(null); + + // RAC swallows arrow keys at the collection level (stopPropagation during capture), so a handler + // on the link never sees them. Intercept here on an ancestor, before RAC's row handler runs, and + // expand a collapsed row when the expand arrow is pressed while focus is on its link. + let onKeyDownCapture = (e: ReactKeyboardEvent) => { + let state = stateRef.current; + if (!state) { + return; + } + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') { + return; + } + let link = getEventTarget(e).closest?.('a'); + if (!link) { + return; + } + let rowEl = link.closest('[role="row"]'); + // Only intercept to open a collapsed, expandable row; let RAC handle everything else + // (e.g. an already-expanded row moves focus into its children). + if (!rowEl || rowEl.getAttribute('aria-expanded') !== 'false') { + return; + } + let key = rowEl.dataset.key; + if (key == null) { + return; + } + state.toggleKey(key); + e.stopPropagation(); + e.preventDefault(); + }; return (
+ className={(UNSAFE_className ?? '') + sideNavWrapper(null, props.styles)} + style={UNSAFE_style} + onKeyDownCapture={onKeyDownCapture}> - + ({}); -export const SideNavItem = (props: SideNavItemProps, ref: DOMRef): ReactNode => { - let {href, hrefLang, target, rel, download, ping, referrerPolicy, routerOptions, ...rest} = props; - let backupRef = useRef(null); - let domRef = ref || backupRef; +const treeRowLink = style({ + // The link is a grid so its own children (icon/content) lay out via treeIcon/treeContent, + // while the anchor keeps its box (and stays focusable, unlike display: contents). + display: 'grid', + gridArea: 'content', + gridTemplateColumns: ['auto', '1fr'], + gridTemplateAreas: ['icon content'], + alignItems: 'center', + minWidth: 0, + outlineStyle: 'none', + textDecoration: 'none', + color: 'inherit', + cursor: 'pointer' +}); - return ( - - treeRow(renderProps)} - /> - - ); +const SideNavItemContext = createContext<{ + /** Whether the item is selected, used to mark its link with aria-current="page". */ + isCurrent?: boolean; +}>({}); + +export const SideNavItem = (props: SideNavItemProps): ReactNode => { + return treeRow(renderProps)} />; }; -export interface SideNavItemContentProps extends Omit { +export interface SideNavItemContentProps extends Omit { /** Rendered contents of the tree item or child items. */ children: ReactNode; } @@ -365,92 +404,8 @@ const hoveredIndicator = style({ export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => { let {children} = props; let scale = useScale(); - let {href, hrefLang, target, rel, download, ping, referrerPolicy, routerOptions} = - useContext(SideNavItemContext); let ref = useRef(null); - - if (href) { - return ( - - {({ - isExpanded, - hasChildItems, - isDisabled, - isSelected, - id, - state, - isHovered, - isFocusVisible - }) => { - return ( - <> - {isHovered &&
} - - - {({isFocusVisible: linkFocusVisible}) => { - return ( -
- {(linkFocusVisible || isFocusVisible) && ( -
- )} -
- - {typeof children === 'string' ? {children} : children} - -
- ); - }} - - - - ); - }} - - ); - } + let {stateRef} = useContext(InternalSideNavContext); return ( @@ -462,8 +417,12 @@ export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => id, state, isHovered, - isFocusVisible + isFocusVisibleWithin }) => { + // Bridge the tree state up to SideNav so its arrow-key handler can toggle expansion. + if (stateRef) { + stateRef.current = state; + } return ( <> {isHovered &&
} @@ -474,10 +433,10 @@ export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => isNextSelected: isNextSelected(id, state), isSelected })}> - {isFocusVisible && ( + {isFocusVisibleWithin && (
extends SectionProps {} +export interface SideNavSectionProps extends GridListSectionProps {} export function SideNavSection(props: SideNavSectionProps) { return ( @@ -604,19 +564,19 @@ export function SideNavSection(props: SideNavSectionProps) ); } -export const SideNavHeader = forwardRef((props, ref) => { +export const SideNavHeader = (props: {children: ReactNode}): ReactNode => { return ( {props.children} ); -}); +}; export interface SideNavCategoryProps extends Omit< RACTreeItemProps, @@ -649,6 +609,35 @@ export const SideNavCategory = (props: SideNavCategoryProps): ReactNode => { ); }; +interface SideNavItemLinkProps extends Omit { + /** Whether this item has children, even if not loaded yet. */ + hasChildItems?: boolean; + /** Rendered contents of the link. */ + children?: ReactNode; +} + +export const SideNavItemLink = (props: SideNavItemLinkProps): ReactNode => { + let {children} = props; + let {isCurrent} = useContext(SideNavItemContext); + return ( + + + {typeof children === 'string' ? {children} : children} + + + ); +}; + function isNextSelected(id: Key | undefined, state: TreeState) { if (id == null || !state) { return false; diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx index 7d9cabad805..ac9501bfa48 100644 --- a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -21,6 +21,7 @@ import { SideNavHeader, SideNavItem, SideNavItemContent, + SideNavItemLink, SideNavSection } from '../src/SideNav'; import {Text} from '../src/Content'; @@ -52,15 +53,19 @@ const SideNavExampleStatic = args => ( aria-label="test static tree" onExpandedChange={action('onExpandedChange')} onSelectionChange={action('onSelectionChange')}> - + - Your files - + + Your files + + - + - Your libraries + + Your libraries + @@ -104,16 +109,20 @@ const SideNavSectionsExample = args => ( Photography - Your files - + + Your files + + Work - + - Your libraries + + Your libraries + diff --git a/packages/@react-spectrum/s2/test/SideNav.test.tsx b/packages/@react-spectrum/s2/test/SideNav.test.tsx new file mode 100644 index 00000000000..9f31ccf2aac --- /dev/null +++ b/packages/@react-spectrum/s2/test/SideNav.test.tsx @@ -0,0 +1,174 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import React from 'react'; +import {RouterProvider} from 'react-aria-components'; +import { + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, + SideNavProps +} from '../src/SideNav'; +import {Text} from '../src/Content'; +import userEvent, {UserEvent} from '@testing-library/user-event'; + +function SideNavExample(props: SideNavProps = {}) { + return ( + + + + + Your files + + + + + + + + Your libraries + + + + + + Projects 1 + + + + + + + Projects 2 + + + + + + ); +} + +describe('SideNav', () => { + let user: UserEvent; + + beforeAll(function () { + user = userEvent.setup({delay: null, pointerMap}); + // jsdom doesn't implement getAnimations, which the selection indicator relies on. + Element.prototype.getAnimations = jest.fn().mockImplementation(() => []); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllMocks(); + act(() => { + jest.runAllTimers(); + }); + }); + + it('expands and collapses a level with the mouse', async () => { + let onExpandedChange = jest.fn(); + let {getByRole, queryByRole} = render(); + + let librariesRow = getByRole('row', {name: 'Your libraries'}); + expect(librariesRow).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); + + await user.click(within(librariesRow).getByRole('button')); + + expect(librariesRow).toHaveAttribute('aria-expanded', 'true'); + expect(getByRole('link', {name: 'Projects 1'})).toBeInTheDocument(); + expect(onExpandedChange).toHaveBeenLastCalledWith(new Set(['libraries'])); + + await user.click(within(librariesRow).getByRole('button')); + + expect(librariesRow).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); + expect(onExpandedChange).toHaveBeenLastCalledWith(new Set([])); + }); + + it('expands and collapses a level with the keyboard', async () => { + let {getByRole, queryByRole} = render(); + let librariesRow = getByRole('row', {name: 'Your libraries'}); + + // Move focus onto the "Your libraries" link. + await user.tab(); + await user.keyboard('{ArrowDown}'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + + // Right arrow on the link opens the level. + await user.keyboard('{ArrowRight}'); + expect(librariesRow).toHaveAttribute('aria-expanded', 'true'); + expect(getByRole('link', {name: 'Projects 1'})).toBeInTheDocument(); + + // Left arrow returns focus to the row, then collapses the level. + await user.keyboard('{ArrowLeft}'); + + // TODO: should only be one arrow left? + await user.keyboard('{ArrowLeft}'); + expect(librariesRow).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); + }); + + it('selects an item and marks its link with aria-current="page"', async () => { + let onSelectionChange = jest.fn(); + let {getByRole} = render(); + + let filesRow = getByRole('row', {name: 'Your files'}); + let filesLink = getByRole('link', {name: 'Your files'}); + expect(filesRow).toHaveAttribute('aria-selected', 'false'); + expect(filesLink).not.toHaveAttribute('aria-current'); + + // TODO: Why didn't this trigger the link? or did it do that AND selection? + await user.click(filesRow); + expect(filesRow).toHaveAttribute('aria-selected', 'true'); + expect(filesLink).toHaveAttribute('aria-current', 'page'); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['files'])); + + // Selection is single/replace, so selecting another item moves aria-current. + let librariesRow = getByRole('row', {name: 'Your libraries'}); + await user.click(librariesRow); + expect(filesLink).not.toHaveAttribute('aria-current'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveAttribute('aria-current', 'page'); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['libraries'])); + }); + + it('activates the link when clicked', async () => { + let navigate = jest.fn(); + let {getByRole} = render( + + + + ); + + await user.click(getByRole('link', {name: 'Your files'})); + expect(navigate).toHaveBeenCalledWith('/files', undefined); + }); + + it('activates the link when keyboard activated', async () => { + let navigate = jest.fn(); + let {getByRole} = render( + + + + ); + + // Tab moves focus to the first item's link. + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(navigate).toHaveBeenCalledWith('/files', undefined); + }); +}); diff --git a/packages/react-aria-components/exports/Tree.ts b/packages/react-aria-components/exports/Tree.ts index 49031e1c69a..d647e67c5c8 100644 --- a/packages/react-aria-components/exports/Tree.ts +++ b/packages/react-aria-components/exports/Tree.ts @@ -25,6 +25,7 @@ export { TreeStateContext } from '../src/Tree'; export type { + GridListSectionProps, TreeProps, TreeRenderProps, TreeEmptyStateRenderProps, diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 4280feb1ad8..d826652f415 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -975,7 +975,6 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( DOMProps, rowProps, focusProps, - {onFocus: props.onFocus, onBlur: props.onBlur}, hoverProps, focusWithinProps, draggableItem?.dragProps diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 9e603535980..8d3a2139747 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -216,16 +216,7 @@ export function useGridListItem( walker.currentNode = activeElement; if ( - handleTreeExpansionKeys( - e, - state, - node, - hasChildRows, - direction, - activeElement, - ref.current, - props.allowChildKeys - ) + handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current) ) { return; } @@ -368,16 +359,7 @@ export function useGridListItem( } if ( - handleTreeExpansionKeys( - e, - state, - node, - hasChildRows, - direction, - activeElement, - ref.current, - props.allowChildKeys - ) + handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current) ) { return; } @@ -479,10 +461,9 @@ function handleTreeExpansionKeys( hasChildRows: boolean | undefined, direction: string, activeElement: Element | null, - rowRef: FocusableElement | null, - allowChildKeys: boolean + rowRef: FocusableElement | null ): boolean { - if (!('expandedKeys' in state) || (activeElement !== rowRef && !allowChildKeys)) { + if (!('expandedKeys' in state) || activeElement !== rowRef) { return false; } if ( From 72fdb93f25466befcab29b74c90ae8bf2a7f1f64 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 7 Jul 2026 15:13:39 +1000 Subject: [PATCH 33/52] add a router to make it easier to test in the browser --- .../s2/stories/SideNav.stories.tsx | 225 +++++++++--------- 1 file changed, 118 insertions(+), 107 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx index ac9501bfa48..da5d932808b 100644 --- a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -14,7 +14,9 @@ import {action} from 'storybook/actions'; import {categorizeArgTypes, getActionArgs} from './utils'; import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; import type {Meta, StoryObj} from '@storybook/react'; -import React from 'react'; +import React, {ReactNode, useState} from 'react'; +import {RouterProvider} from 'react-aria-components'; +import {Selection} from '@react-types/shared'; import { SideNav, SideNavCategory, @@ -22,6 +24,7 @@ import { SideNavItem, SideNavItemContent, SideNavItemLink, + SideNavProps, SideNavSection } from '../src/SideNav'; import {Text} from '../src/Content'; @@ -45,25 +48,98 @@ export default meta; type SideNavStoryObj = StoryObj; +// Treats the SideNav as navigation: activating a link is intercepted by the RouterProvider so the +// page doesn't actually navigate, and the activated href drives the controlled selected key. Any +// non-link selection (e.g. keyboard or items without a link) flows through onSelectionChange. +function RoutedSideNav(props: SideNavProps & {children: ReactNode}) { + let {children, onSelectionChange, ...args} = props; + let [selectedKeys, setSelectedKeys] = useState(new Set(['Photos'])); + + let updateSelection = (keys: Selection) => { + setSelectedKeys(keys); + onSelectionChange?.(keys); + }; + + return ( +
+ updateSelection(new Set([href.replace(/^\//, '')]))}> + + {children} + + +
+ ); +} + const SideNavExampleStatic = args => ( -
- - + + + + + Your files + + + + + + + + Your libraries + + + - + Projects-1 + + + + Projects-1A + + + + + + Projects-2 + + + + + Projects-3 + + + + +); + +export const Example: SideNavStoryObj = { + render: SideNavExampleStatic, + args: {} +}; + +const SideNavSectionsExample = args => ( + + + Photography + + + Your files - + + + Work + - + Your libraries @@ -88,66 +164,8 @@ const SideNavExampleStatic = args => ( - -
-); - -export const Example: SideNavStoryObj = { - render: SideNavExampleStatic, - args: {} -}; - -const SideNavSectionsExample = args => ( -
- - - Photography - - - - Your files - - - - - - - Work - - - - Your libraries - - - - - Projects-1 - - - - Projects-1A - - - - - - Projects-2 - - - - - Projects-3 - - - - - -
+
+ ); export const SideNavSections = { @@ -158,46 +176,39 @@ export const SideNavSections = { }; const SideNavExampleCategory = args => ( -
- - + + + + Your files + + + + + + Your libraries + + - Your files - - - - - - Your libraries + Projects-1 - - - Projects-1 - - - - Projects-1A - - - - - - Projects-2 - - - + - Projects-3 + Projects-1A - - -
+
+ + + Projects-2 + + + + + Projects-3 + + + + ); export const Category = { From 9b13683ef62b1723e5fa2363241c978b823356d0 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 9 Jul 2026 16:09:09 +1000 Subject: [PATCH 34/52] add descendent selection styles, fix indicator no animation --- packages/@react-spectrum/s2/src/SideNav.tsx | 73 +++++++++++++++++---- 1 file changed, 62 insertions(+), 11 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 8eb622ce0ad..6dadd7ac5da 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -14,7 +14,14 @@ import {baseColor, focusRing, fontRelative, style} from '../style' with {type: ' import {Button, ButtonContext} from 'react-aria-components/Button'; import {centerBaseline} from './CenterBaseline'; import Chevron from '../ui-icons/Chevron'; -import {DOMRef, forwardRefType, GlobalDOMAttributes, Key} from '@react-types/shared'; +import { + Collection, + DOMRef, + forwardRefType, + GlobalDOMAttributes, + Key, + Node +} from '@react-types/shared'; import { getAllowedOverrides, StylesPropWithHeight, @@ -202,7 +209,6 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid scrollPaddingBottom: 0 }} className={renderProps => tree(renderProps)} - selectionBehavior="replace" selectionMode="single" keyboardNavigationBehavior="arrow" disallowEmptySelection @@ -256,12 +262,16 @@ const treeCellGrid = style({ color: { default: baseColor('neutral-subdued'), isSelected: baseColor('neutral'), + isDescendantSelected: baseColor('neutral'), isDisabled: { default: 'gray-400', forcedColors: 'GrayText' }, forcedColors: 'ButtonText' }, + fontWeight: { + isDescendantSelected: 'bold' + }, '--rowSelectedBorderColor': { type: 'outlineColor', value: { @@ -372,15 +382,14 @@ const selectedIndicator = style<{isDisabled: boolean}>({ height: 18, width: '[2px]', contain: 'strict', - transition: { - default: '[translate,width,height]', - '@media (prefers-reduced-motion: reduce)': 'none' - }, - transitionDuration: 200, - transitionTimingFunction: 'out', top: '50%', transform: 'translateY(-50%)', - insetStart: 4, + '--indicator-indent': { + type: 'width', + value: 4 + }, + insetStart: + '[calc(calc(var(--tree-item-level, 0) - 1) * var(--indent) + var(--indicator-indent))]', borderStyle: 'none', borderRadius: 'full' }); @@ -396,7 +405,12 @@ const hoveredIndicator = style({ contain: 'strict', top: '50%', transform: 'translateY(-50%)', - insetStart: 4, + '--indicator-indent': { + type: 'width', + value: 4 + }, + insetStart: + '[calc(calc(var(--tree-item-level, 0) - 1) * var(--indent) + var(--indicator-indent))]', borderStyle: 'none', borderRadius: 'full' }); @@ -431,7 +445,9 @@ export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => className={treeCellGrid({ isDisabled, isNextSelected: isNextSelected(id, state), - isSelected + isSelected, + isDescendantSelected: + !isExpanded && hasChildItems && hasSelectedDescendant(id, state) })}> {isFocusVisibleWithin && (
) { } return state.collection.getFirstKey() === id; } + +// Cache so each row doesn't have to walk up the tree every time +let selectedAncestorsCache = new WeakMap< + Collection>, + {selection: unknown; ancestors: Set} +>(); + +function getSelectedAncestors(state: TreeState): Set { + let {collection} = state; + let selection = state.selectionManager.selectedKeys; + let cached = selectedAncestorsCache.get(collection); + if (cached && cached.selection === selection) { + return cached.ancestors; + } + + let ancestors = new Set(); + for (let selectedKey of state.selectionManager.selectedKeys) { + let node = collection.getItem(selectedKey); + while (node?.parentKey != null && !ancestors.has(node.parentKey)) { + ancestors.add(node.parentKey); + node = collection.getItem(node.parentKey); + } + } + + selectedAncestorsCache.set(collection, {selection, ancestors}); + return ancestors; +} + +// Whether any selected item is a descendant of `id`. +function hasSelectedDescendant(id: Key | undefined, state: TreeState) { + if (id == null || !state) { + return false; + } + return getSelectedAncestors(state).has(id); +} From aa531bb2d4782a4b4deaeb9c1f0f6363e8c75ffc Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 10 Jul 2026 15:10:59 +1000 Subject: [PATCH 35/52] Moves to new API selectedRoute, fixes double arrow key navigate to parent, fixes initial focus --- packages/@react-spectrum/s2/src/SideNav.tsx | 439 +++++++++++------- .../s2/stories/SideNav.stories.tsx | 265 ++++++++--- .../@react-spectrum/s2/test/SideNav.test.tsx | 247 ++++++++-- 3 files changed, 690 insertions(+), 261 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 6dadd7ac5da..7688bda0f80 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +import {ActionButtonGroupContext} from './ActionButtonGroup'; +import {ActionMenuContext} from './ActionMenu'; import {baseColor, focusRing, fontRelative, style} from '../style' with {type: 'macro'}; import {Button, ButtonContext} from 'react-aria-components/Button'; import {centerBaseline} from './CenterBaseline'; @@ -20,7 +22,8 @@ import { forwardRefType, GlobalDOMAttributes, Key, - Node + Node, + RouterOptions } from '@react-types/shared'; import { getAllowedOverrides, @@ -42,7 +45,7 @@ import { TreeSection } from 'react-aria-components/Tree'; import {IconContext} from './Icon'; -import {Link, LinkProps} from 'react-aria-components/Link'; +import {Link} from 'react-aria-components/Link'; import {Provider, useContextProps} from 'react-aria-components/slots'; import React, { createContext, @@ -53,9 +56,10 @@ import React, { ReactNode, RefObject, useContext, - useRef + useEffect, + useRef, + useState } from 'react'; -import {SelectionIndicator} from 'react-aria-components/SelectionIndicator'; import {Text, TextContext} from './Content'; import {TreeState} from 'react-stately/useTreeState'; import {useDOMRef} from './useDOMRef'; @@ -73,12 +77,19 @@ export interface SideNavProps | 'selectionBehavior' | 'onScroll' | 'onCellAction' + | 'onSelectionChange' + | 'selectedKeys' + | 'defaultSelectedKeys' + | 'disabledBehavior' + | 'selectionMode' | keyof GlobalDOMAttributes >, UnsafeStyles, SideNavStyleProps { /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight; + /** The route that is currently selected. */ + selectedRoute?: string; } interface SideNavStyleProps {} @@ -129,7 +140,8 @@ const tree = style({ minWidth: 0, width: 'full', height: 'full', - overflow: 'auto', + overflowY: 'auto', + overflowX: 'hidden', boxSizing: 'border-box', '--indent': { type: 'width', @@ -143,6 +155,9 @@ interface InternalSideNavContextValue { * expansion. */ stateRef?: RefObject | null>; + selectedRoute?: string; + /** The last route the focused key was synced to; dedupes the focus sync across items. */ + syncedRouteRef?: RefObject; } let InternalSideNavContext = createContext({}); @@ -153,7 +168,7 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid props: SideNavProps, ref: DOMRef ) { - let {children, UNSAFE_className, UNSAFE_style} = props; + let {children, UNSAFE_className, UNSAFE_style, selectedRoute} = props; let renderer; if (typeof children === 'function') { @@ -163,6 +178,11 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid let domRef = useDOMRef(ref); let scrollRef = useRef(null); let stateRef = useRef | null>(null); + let {direction} = useLocale(); + + // Tracks the last route we moved the focused key to, so the focus sync (driven from + // RouteFocusSync, which has the built collection) only runs when the route actually changes + let syncedRouteRef = useRef(undefined); // RAC swallows arrow keys at the collection level (stopPropagation during capture), so a handler // on the link never sees them. Intercept here on an ancestor, before RAC's row handler runs, and @@ -175,23 +195,53 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') { return; } - let link = getEventTarget(e).closest?.('a'); - if (!link) { + let target = getEventTarget(e); + let link = target.closest?.('a'); + if (!link || link !== target) { return; } let rowEl = link.closest('[role="row"]'); - // Only intercept to open a collapsed, expandable row; let RAC handle everything else - // (e.g. an already-expanded row moves focus into its children). - if (!rowEl || rowEl.getAttribute('aria-expanded') !== 'false') { + if (!rowEl) { return; } let key = rowEl.dataset.key; if (key == null) { return; } - state.toggleKey(key); - e.stopPropagation(); - e.preventDefault(); + let node = state.collection.getItem(key); + // null = leaf, 'true' = expanded, 'false' = collapsed. + let ariaExpanded = rowEl.getAttribute('aria-expanded'); + let collapseKey = direction === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + let expandKey = direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + + // Move focus to the parent item. RAC's own parent-move (handleTreeExpansionKeys) only runs + // when the row itself has DOM focus, so with focusMode="child" (focus on the link) it never + // fires; replicate it here. Pointing the focused key at the parent makes useSelectableItem + // move DOM focus to it (and, in focusMode="child", into the parent's link). + let moveToParent = () => { + if (node?.parentKey != null && state.collection.getItem(node.parentKey)?.type === 'item') { + e.stopPropagation(); + e.preventDefault(); + state.selectionManager.setFocusedKey(node.parentKey); + } + }; + + if (e.key === collapseKey) { + if (ariaExpanded === 'true') { + // Expanded parent: collapse it (focus stays on the row). + e.stopPropagation(); + e.preventDefault(); + state.toggleKey(key); + } else { + // Leaf or already-collapsed row: step up to the parent. + moveToParent(); + } + } else if (e.key === expandKey && ariaExpanded === 'false') { + // Collapsed parent: expand it. + e.stopPropagation(); + e.preventDefault(); + state.toggleKey(key); + } }; return ( @@ -201,7 +251,7 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid style={UNSAFE_style} onKeyDownCapture={onKeyDownCapture}> - + void; }>({}); export const SideNavItem = (props: SideNavItemProps): ReactNode => { - return treeRow(renderProps)} />; + let {href, hrefLang, target, rel, download, ping, referrerPolicy, routerOptions, ...rest} = props; + + return ( + + 0 ? 'child' : undefined} + className={renderProps => treeRow(renderProps)} + /> + + ); }; export interface SideNavItemContentProps extends Omit { @@ -369,8 +438,12 @@ export interface SideNavItemContentProps extends Omit({ +const selectedIndicator = style<{isDisabled: boolean; isSelected: boolean}>({ position: 'absolute', + display: { + default: 'none', + isSelected: 'block' + }, backgroundColor: { default: 'neutral', isDisabled: 'disabled', @@ -415,11 +488,36 @@ const hoveredIndicator = style({ borderRadius: 'full' }); +// Moves the tree's focused key to the item matching selectedRoute. Lives here +// (rather than in SideNav) because it needs the built collection off `state`, which only exists +// after the tree has rendered. Runs when the route or the collection changes; the shared +// syncedRouteRef dedupes across items so it fires once per route change +function useRouteFocusSync({state}: {state: TreeState}): void { + let {selectedRoute, syncedRouteRef} = useContext(InternalSideNavContext); + let {collection, selectionManager} = state; + useEffect(() => { + if ( + selectedRoute == null || + syncedRouteRef == null || + syncedRouteRef.current === selectedRoute + ) { + return; + } + let key = findKeyForRoute(collection, selectedRoute); + if (key != null) { + syncedRouteRef.current = selectedRoute; + // selectionManager is recreated each render but delegates to stable state setters, so the + // value captured for [selectedRoute, collection] is safe to call here. + selectionManager.setFocusedKey(key); + } + }, [selectedRoute, collection, syncedRouteRef, selectionManager]); +} + export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => { let {children} = props; let scale = useScale(); - let ref = useRef(null); - let {stateRef} = useContext(InternalSideNavContext); + let linkProps = useContext(SideNavItemLinkContext); + let {stateRef, selectedRoute} = useContext(InternalSideNavContext); return ( @@ -431,68 +529,121 @@ export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => id, state, isHovered, + isFocusVisible, isFocusVisibleWithin }) => { - // Bridge the tree state up to SideNav so its arrow-key handler can toggle expansion. - if (stateRef) { - stateRef.current = state; - } return ( - <> - {isHovered &&
} - -
- {isFocusVisibleWithin && ( -
- )} -
- - {typeof children === 'string' ? {children} : children} - -
- - + + {children} + ); }} ); }; +const SideNaveItemContentInner = props => { + let { + isExpanded, + hasChildItems, + isDisabled, + isSelected, + linkProps, + scale, + id, + state, + stateRef, + selectedRoute, + isHovered, + isFocusVisible, + isFocusVisibleWithin, + children + } = props; + // Whether the link within this row is the focused element (any modality). Combined with the + // keyboard-only isFocusVisibleWithin below, this lets the row focus ring follow the link + // specifically and not other focusable children (e.g. an ActionMenu trigger). + let [isLinkFocused, setLinkFocused] = useState(false); + // Bridge the tree state up to SideNav so its arrow-key handler can toggle expansion. + useEffect(() => { + if (stateRef) { + stateRef.current = state; + } + }, [state, stateRef]); + + useRouteFocusSync({state}); + + return ( + <> + {isHovered &&
} +
+
+ {(isFocusVisible || (isFocusVisibleWithin && isLinkFocused)) && ( +
+ )} +
+ + {typeof children === 'string' ? {children} : children} + +
+ + + ); +}; + interface ExpandableRowChevronProps { isExpanded?: boolean; isDisabled?: boolean; @@ -594,49 +745,22 @@ export const SideNavHeader = (props: {children: ReactNode}): ReactNode => { ); }; -export interface SideNavCategoryProps extends Omit< - RACTreeItemProps, - | 'className' - | 'style' - | 'href' - | 'hrefLang' - | 'target' - | 'rel' - | 'download' - | 'ping' - | 'referrerPolicy' - | 'routerOptions' -> { - /** Whether this item has children, even if not loaded yet. */ - hasChildItems?: boolean; - counter?: number; -} - -export const SideNavCategory = (props: SideNavCategoryProps): ReactNode => { - return ( - - treeRow({ - ...renderProps - }) - } - /> - ); -}; - -interface SideNavItemLinkProps extends Omit { - /** Whether this item has children, even if not loaded yet. */ - hasChildItems?: boolean; +interface SideNavItemLinkProps { /** Rendered contents of the link. */ children?: ReactNode; } export const SideNavItemLink = (props: SideNavItemLinkProps): ReactNode => { let {children} = props; - let {isCurrent} = useContext(SideNavItemContext); + let {selectedRoute} = useContext(InternalSideNavContext); + let linkProps = useContext(SideNavItemLinkContext); + return ( - + { ); }; -function isNextSelected(id: Key | undefined, state: TreeState) { - if (id == null || !state) { - return false; - } - let keyAfter = state.collection.getKeyAfter(id); - - // We need to skip non-item nodes because the selection manager will map non-item nodes to their parent before checking selection - let node = keyAfter != null ? state.collection.getItem(keyAfter) : null; - while (node && node.type !== 'item' && keyAfter != null) { - keyAfter = state.collection.getKeyAfter(keyAfter); - node = keyAfter != null ? state.collection.getItem(keyAfter) : null; - } - - return keyAfter != null && state.selectionManager.isSelected(keyAfter); -} - -function isFirstItem(id: Key | undefined, state: TreeState) { - if (id == null || !state) { - return false; +// The collection key of the item whose href matches `route`, or null. getKeys() covers collapsed +// items too, and the href is stored as a data attribute so it doesn't trigger Tree's link handling. +function findKeyForRoute(collection: Collection>, route: string): Key | null { + for (let key of collection.getKeys()) { + if (collection.getItem(key)?.props?.['data-href'] === route) { + return key; + } } - return state.collection.getFirstKey() === id; + return null; } // Cache so each row doesn't have to walk up the tree every time @@ -683,31 +795,36 @@ let selectedAncestorsCache = new WeakMap< {selection: unknown; ancestors: Set} >(); -function getSelectedAncestors(state: TreeState): Set { +// The set of collection keys that are ancestors of the item matching `selectedRoute`. +function getSelectedAncestors(state: TreeState, selectedRoute: string): Set { let {collection} = state; - let selection = state.selectionManager.selectedKeys; let cached = selectedAncestorsCache.get(collection); - if (cached && cached.selection === selection) { + if (cached && cached.selection === selectedRoute) { return cached.ancestors; } + let matchKey = findKeyForRoute(collection, selectedRoute); + let ancestors = new Set(); - for (let selectedKey of state.selectionManager.selectedKeys) { - let node = collection.getItem(selectedKey); - while (node?.parentKey != null && !ancestors.has(node.parentKey)) { - ancestors.add(node.parentKey); - node = collection.getItem(node.parentKey); - } + let node = matchKey != null ? collection.getItem(matchKey) : null; + while (node?.parentKey != null && !ancestors.has(node.parentKey)) { + ancestors.add(node.parentKey); + node = collection.getItem(node.parentKey); } - selectedAncestorsCache.set(collection, {selection, ancestors}); + selectedAncestorsCache.set(collection, {selection: selectedRoute, ancestors}); return ancestors; } -// Whether any selected item is a descendant of `id`. -function hasSelectedDescendant(id: Key | undefined, state: TreeState) { - if (id == null || !state) { +// Whether the row `id` is an ancestor of the item matching `selectedRoute`, i.e. it has a +// selected descendant. Used to keep a collapsed parent styled when its selected child is hidden. +function hasSelectedDescendant( + id: Key | undefined, + state: TreeState, + selectedRoute: string | undefined +) { + if (id == null || selectedRoute == null || !state) { return false; } - return getSelectedAncestors(state).has(id); + return getSelectedAncestors(state, selectedRoute).has(id); } diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx index da5d932808b..d235c810fc8 100644 --- a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -11,15 +11,20 @@ */ import {action} from 'storybook/actions'; +import {ActionMenu} from '../src/ActionMenu'; import {categorizeArgTypes, getActionArgs} from './utils'; +import {Collection} from 'react-aria/Collection'; +import Copy from '../s2wf-icons/S2_Icon_Copy_20_N.svg'; +import Cut from '../s2wf-icons/S2_Icon_Cut_20_N.svg'; import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import {Keyboard, Text} from '../src/Content'; +import {MenuItem} from '../src/Menu'; import type {Meta, StoryObj} from '@storybook/react'; -import React, {ReactNode, useState} from 'react'; +import Paste from '../s2wf-icons/S2_Icon_Paste_20_N.svg'; +import React, {ReactElement, ReactNode, useState} from 'react'; import {RouterProvider} from 'react-aria-components'; -import {Selection} from '@react-types/shared'; import { SideNav, - SideNavCategory, SideNavHeader, SideNavItem, SideNavItemContent, @@ -27,7 +32,6 @@ import { SideNavProps, SideNavSection } from '../src/SideNav'; -import {Text} from '../src/Content'; const events = ['onSelectionChange']; @@ -52,21 +56,19 @@ type SideNavStoryObj = StoryObj; // page doesn't actually navigate, and the activated href drives the controlled selected key. Any // non-link selection (e.g. keyboard or items without a link) flows through onSelectionChange. function RoutedSideNav(props: SideNavProps & {children: ReactNode}) { - let {children, onSelectionChange, ...args} = props; - let [selectedKeys, setSelectedKeys] = useState(new Set(['Photos'])); + let {children, ...args} = props; + let [selectedRoute, setSelectedRoute] = useState(props.selectedRoute ?? '/Photos'); - let updateSelection = (keys: Selection) => { - setSelectedKeys(keys); - onSelectionChange?.(keys); + let updateSelection = (href: string) => { + setSelectedRoute(href); }; return (
- updateSelection(new Set([href.replace(/^\//, '')]))}> + @@ -79,38 +81,46 @@ function RoutedSideNav(props: SideNavProps & {children: ReactNode}) { const SideNavExampleStatic = args => ( - + - + Your files - + - + Your libraries - + - Projects-1 + + Projects-1 + - + - Projects-1A + + Projects-1A + - + - Projects-2 + + Projects-2 + - + - Projects-3 + + Projects-3 + @@ -123,12 +133,12 @@ export const Example: SideNavStoryObj = { }; const SideNavSectionsExample = args => ( - + Photography - + - + Your files @@ -137,30 +147,38 @@ const SideNavSectionsExample = args => ( Work - + - + Your libraries - + - Projects-1 + + Projects-1 + - + - Projects-1A + + Projects-1A + - + - Projects-2 + + Projects-2 + - + - Projects-3 + + Projects-3 + @@ -175,45 +193,148 @@ export const SideNavSections = { } }; -const SideNavExampleCategory = args => ( - - +interface SideNavItemType { + id: string; + name: string; + href?: string; + childItems?: SideNavItemType[]; +} + +let dynamicItems: SideNavItemType[] = [ + {id: 'Photos', name: 'Your files', href: '/Photos'}, + { + id: 'projects', + name: 'Projects', + href: '/projects', + childItems: [ + {id: 'projects-1', name: 'Projects-1', href: '/projects-1'}, + { + id: 'projects-2', + name: 'Projects-2', + childItems: [ + {id: 'projects-2A', name: 'Projects-2A', href: '/projects-2A'}, + {id: 'projects-2B', name: 'Projects-2B', href: '/projects-2B'}, + {id: 'projects-2C', name: 'Projects-2C', href: '/projects-2C'}, + {id: 'projects-2D', name: 'Projects-2D', href: '/projects-2D'}, + {id: 'projects-2E', name: 'Projects-2E', href: '/projects-2E'}, + {id: 'projects-2F', name: 'Projects-2F', href: '/projects-2F'} + ] + } + ] + }, + { + id: 'reports', + name: 'Reports', + href: '/reports', + childItems: [{id: 'reports-1', name: 'Reports-1', href: '/reports-1'}] + } +]; + +const DynamicSideNavItem = (props: SideNavItemType): ReactElement => { + let {id, name, href, childItems} = props; + return ( + - Your files - + {href && href.length > 0 && ( + + {name} + + )} + {(!href || href.length === 0) && {name}} + + {(item: SideNavItemType) => } + - + ); +}; + +const SideNavDynamicExample = (args: SideNavProps): ReactElement => { + let [selectedRoute, setSelectedRoute] = useState('/Photos'); + return ( +
+ + + {(item: SideNavItemType) => } + + +
+ ); +}; +type SideNavDynamicStoryObj = StoryObj; + +export const SideNavDynamic: SideNavDynamicStoryObj = { + render: args => , + args: {} +}; + +const DynamicWithActionsSideNavItem = (props: SideNavItemType): ReactElement => { + let {id, name, href, childItems} = props; + return ( + - Your libraries + {href && href.length > 0 && ( + + {name} + + )} + {(!href || href.length === 0) && {name}} + + alert('copy')}> + + Copy + Copy the selected text + ⌘C + + alert('cut')}> + + Cut + Cut the selected text + ⌘X + + alert('paste')}> + + Paste + Paste the copied text + ⌘V + + - - - Projects-1 - - - - Projects-1A - - - - - - Projects-2 - - - - - Projects-3 - - -
-
-); + + {(item: SideNavItemType) => } + +
+ ); +}; -export const Category = { - render: SideNavExampleCategory, - args: { - selectionMode: 'single' - } +const SideNavDynamicWithActionsExample = (args: SideNavProps): ReactElement => { + let [selectedRoute, setSelectedRoute] = useState('/Photos'); + return ( +
+ + + {(item: SideNavItemType) => } + + +
+ ); +}; +type SideNavDynamicWithActionsStoryObj = StoryObj; + +export const SideNavDynamicWithActions: SideNavDynamicWithActionsStoryObj = { + render: args => , + args: {}, + name: 'WithActions' }; diff --git a/packages/@react-spectrum/s2/test/SideNav.test.tsx b/packages/@react-spectrum/s2/test/SideNav.test.tsx index 9f31ccf2aac..882a1cd11ab 100644 --- a/packages/@react-spectrum/s2/test/SideNav.test.tsx +++ b/packages/@react-spectrum/s2/test/SideNav.test.tsx @@ -11,7 +11,9 @@ */ import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {ActionMenu} from '../src/ActionMenu'; import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import {MenuItem} from '../src/Menu'; import React from 'react'; import {RouterProvider} from 'react-aria-components'; import { @@ -27,30 +29,30 @@ import userEvent, {UserEvent} from '@testing-library/user-event'; function SideNavExample(props: SideNavProps = {}) { return ( - + - + Your files - + - + Your libraries - + - + Projects 1 - + - + Projects 2 @@ -60,6 +62,98 @@ function SideNavExample(props: SideNavProps = {}) { ); } +// A controlled wrapper mirroring how SideNav is used with a router: activating a link is +// intercepted by RouterProvider, and the navigated href becomes the controlled selectedRoute. +function RoutedSideNavExample(props: SideNavProps = {}) { + let [selectedRoute, setSelectedRoute] = React.useState('/files'); + return ( + + + + ); +} + +// Three levels deep: Your libraries > Projects 1 > Projects 1A. +function DeepSideNavExample(props: SideNavProps = {}) { + return ( + + + + + Your libraries + + + + + + Projects 1 + + + + + + Projects 1A + + + + + + + ); +} + +// An item whose row has both a link and a secondary action (ActionMenu). +function ActionMenuSideNavExample(props: SideNavProps = {}) { + return ( + + + + + Your files + + + + Edit + + + Delete + + + + + + ); +} + +// An item with no href and no link, but with a secondary action (ActionMenu). Focus should stay +// on the row rather than jumping into the ActionMenu trigger. +function NoLinkActionMenuSideNavExample(props: SideNavProps = {}) { + return ( + + + + + Your files + + + + + + Section + + + Edit + + + Delete + + + + + + ); +} + describe('SideNav', () => { let user: UserEvent; @@ -114,34 +208,131 @@ describe('SideNav', () => { // Left arrow returns focus to the row, then collapses the level. await user.keyboard('{ArrowLeft}'); - - // TODO: should only be one arrow left? - await user.keyboard('{ArrowLeft}'); expect(librariesRow).toHaveAttribute('aria-expanded', 'false'); expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); }); - it('selects an item and marks its link with aria-current="page"', async () => { - let onSelectionChange = jest.fn(); - let {getByRole} = render(); + it('marks the link matching selectedRoute with aria-current="page"', () => { + let {getByRole, rerender} = render(); - let filesRow = getByRole('row', {name: 'Your files'}); - let filesLink = getByRole('link', {name: 'Your files'}); - expect(filesRow).toHaveAttribute('aria-selected', 'false'); - expect(filesLink).not.toHaveAttribute('aria-current'); + expect(getByRole('link', {name: 'Your files'})).toHaveAttribute('aria-current', 'page'); + expect(getByRole('link', {name: 'Your libraries'})).not.toHaveAttribute('aria-current'); - // TODO: Why didn't this trigger the link? or did it do that AND selection? - await user.click(filesRow); - expect(filesRow).toHaveAttribute('aria-selected', 'true'); - expect(filesLink).toHaveAttribute('aria-current', 'page'); - expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['files'])); + // Changing the selected route moves aria-current. + rerender(); + expect(getByRole('link', {name: 'Your files'})).not.toHaveAttribute('aria-current'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveAttribute('aria-current', 'page'); + }); - // Selection is single/replace, so selecting another item moves aria-current. - let librariesRow = getByRole('row', {name: 'Your libraries'}); - await user.click(librariesRow); - expect(filesLink).not.toHaveAttribute('aria-current'); + it('updates aria-current when navigating via the router', async () => { + let {getByRole} = render(); + + expect(getByRole('link', {name: 'Your files'})).toHaveAttribute('aria-current', 'page'); + + // Activating another link navigates, which updates the controlled selectedRoute. + await user.click(getByRole('link', {name: 'Your libraries'})); + expect(getByRole('link', {name: 'Your files'})).not.toHaveAttribute('aria-current'); expect(getByRole('link', {name: 'Your libraries'})).toHaveAttribute('aria-current', 'page'); - expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['libraries'])); + }); + + it('moves the focused key to the selectedRoute item, even nested farther down', () => { + // Projects 2 is nested under (an expanded) Your libraries — farther down and visible. + let {getByRole} = render( + + ); + act(() => { + jest.runAllTimers(); + }); + + // Focusing the tree moves focus to its focused key. That key was set from selectedRoute, so + // focus lands on Projects 2 rather than the first item. + act(() => { + getByRole('treegrid').focus(); + }); + expect(getByRole('link', {name: 'Projects 2'})).toHaveFocus(); + }); + + it('arrow left from a deep leaf steps to parent, collapses it, then moves to the grandparent', async () => { + let {getByRole, queryByRole} = render( + + ); + act(() => { + jest.runAllTimers(); + }); + + // Focus starts on the deepest leaf (synced from selectedRoute). + act(() => { + getByRole('treegrid').focus(); + }); + expect(getByRole('link', {name: 'Projects 1A'})).toHaveFocus(); + + // 1st ArrowLeft: leaf has nothing to collapse, so focus moves up to its parent. + await user.keyboard('{ArrowLeft}'); + expect(getByRole('link', {name: 'Projects 1'})).toHaveFocus(); + expect(getByRole('row', {name: 'Projects 1'})).toHaveAttribute('aria-expanded', 'true'); + + // 2nd ArrowLeft: the parent is expanded, so it collapses; focus stays on it. + await user.keyboard('{ArrowLeft}'); + expect(getByRole('row', {name: 'Projects 1'})).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1A'})).toBeNull(); + expect(getByRole('link', {name: 'Projects 1'})).toHaveFocus(); + + // 3rd ArrowLeft: the parent is now collapsed, so focus moves up to the grandparent. + await user.keyboard('{ArrowLeft}'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + }); + + it('shows the row focus ring only when the link is focused, not the ActionMenu', async () => { + let {getByRole} = render(); + act(() => { + jest.runAllTimers(); + }); + + // The row focus ring is an extra element rendered into the cell grid alongside the link. + let link = getByRole('link', {name: 'Your files'}); + let cell = link.parentElement!; + + // Focus the link (focused key was synced to /files): the ring is present. + act(() => { + getByRole('treegrid').focus(); + }); + act(() => { + jest.runAllTimers(); + }); + expect(link).toHaveFocus(); + let childrenWithLinkFocused = cell.children.length; + + // Move focus to the ActionMenu trigger in the same row: the ring is gone. + await user.keyboard('{ArrowRight}'); + act(() => { + jest.runAllTimers(); + }); + expect(getByRole('button', {name: 'More actions'})).toHaveFocus(); + expect(cell.children.length).toBe(childrenWithLinkFocused - 1); + }); + + it('keeps focus on the row (not the ActionMenu) for an item with no href/link', async () => { + let {getByRole} = render(); + act(() => { + jest.runAllTimers(); + }); + + // Tab lands on the first item's link, ArrowDown moves to the link-less "Section" row. + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + act(() => { + jest.runAllTimers(); + }); + + // Focus stays on the row itself; it does not jump into the ActionMenu trigger. + let sectionRow = getByRole('row', {name: 'Section'}); + expect(sectionRow).toHaveFocus(); + expect(within(sectionRow).getByRole('button', {name: 'More actions'})).not.toHaveFocus(); }); it('activates the link when clicked', async () => { From ac3ccb6cb12d2cf743a029291280ca90b3bffa5f Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 10 Jul 2026 15:21:33 +1000 Subject: [PATCH 36/52] remove comment --- packages/@react-spectrum/s2/src/SideNav.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 7688bda0f80..124ae8e1ba7 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -506,8 +506,6 @@ function useRouteFocusSync({state}: {state: TreeState}): void { let key = findKeyForRoute(collection, selectedRoute); if (key != null) { syncedRouteRef.current = selectedRoute; - // selectionManager is recreated each render but delegates to stable state setters, so the - // value captured for [selectedRoute, collection] is safe to call here. selectionManager.setFocusedKey(key); } }, [selectedRoute, collection, syncedRouteRef, selectionManager]); From f3313d5e039e67fc1e813d86c7577c3404921672 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 13 Jul 2026 17:27:47 +1000 Subject: [PATCH 37/52] Add docs, exports, fix props, --- .../@react-spectrum/s2/exports/SideNav.ts | 20 ++ packages/@react-spectrum/s2/exports/index.ts | 16 + packages/@react-spectrum/s2/src/SideNav.tsx | 80 +++-- .../@react-spectrum/s2/test/SideNav.test.tsx | 22 +- .../dev/s2-docs/pages/s2/RoutedSideNav.tsx | 22 ++ packages/dev/s2-docs/pages/s2/SideNav.mdx | 305 ++++++++++++++++++ .../react-aria-components/exports/GridList.ts | 1 + .../react-aria-components/exports/index.ts | 1 + 8 files changed, 430 insertions(+), 37 deletions(-) create mode 100644 packages/@react-spectrum/s2/exports/SideNav.ts create mode 100644 packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx create mode 100644 packages/dev/s2-docs/pages/s2/SideNav.mdx diff --git a/packages/@react-spectrum/s2/exports/SideNav.ts b/packages/@react-spectrum/s2/exports/SideNav.ts new file mode 100644 index 00000000000..bc5c9090112 --- /dev/null +++ b/packages/@react-spectrum/s2/exports/SideNav.ts @@ -0,0 +1,20 @@ +export { + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, + SideNavSection, + SideNavHeader +} from '../src/SideNav'; +export {Collection} from 'react-aria/Collection'; +export type { + SideNavProps, + SideNavItemProps, + SideNavItemContentProps, + SideNavItemLinkProps, + SideNavSectionProps, + SideNavHeaderProps +} from '../src/SideNav'; +export type {Key} from '@react-types/shared'; + +export {Text} from '../src/Content'; diff --git a/packages/@react-spectrum/s2/exports/index.ts b/packages/@react-spectrum/s2/exports/index.ts index d6ded1a4569..f3b4ac44f67 100644 --- a/packages/@react-spectrum/s2/exports/index.ts +++ b/packages/@react-spectrum/s2/exports/index.ts @@ -120,6 +120,14 @@ export { SegmentedControlContext } from '../src/SegmentedControl'; export {SelectBox, SelectBoxGroup, SelectBoxGroupContext} from '../src/SelectBoxGroup'; +export { + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, + SideNavSection, + SideNavHeader +} from '../src/SideNav'; export {Slider, SliderContext} from '../src/Slider'; export {Skeleton, useIsSkeleton} from '../src/Skeleton'; export {SkeletonCollection} from '../src/SkeletonCollection'; @@ -259,6 +267,14 @@ export type {SelectBoxProps, SelectBoxGroupProps} from '../src/SelectBoxGroup'; export type {SliderProps} from '../src/Slider'; export type {RangeCalendarProps} from '../src/RangeCalendar'; export type {RangeSliderProps} from '../src/RangeSlider'; +export type { + SideNavProps, + SideNavItemProps, + SideNavItemContentProps, + SideNavItemLinkProps, + SideNavSectionProps, + SideNavHeaderProps +} from '../src/SideNav'; export type {SkeletonProps} from '../src/Skeleton'; export type {SkeletonCollectionProps} from '../src/SkeletonCollection'; export type {StatusLightProps} from '../src/StatusLight'; diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 124ae8e1ba7..7c57b03f4b8 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -25,14 +25,30 @@ import { Node, RouterOptions } from '@react-types/shared'; +import { + createContext, + forwardRef, + JSXElementConstructor, + ReactElement, + KeyboardEvent as ReactKeyboardEvent, + ReactNode, + RefObject, + useContext, + useEffect, + useRef, + useState +} from 'react'; import { getAllowedOverrides, StylesPropWithHeight, UnsafeStyles } from './style-utils' with {type: 'macro'}; import {getEventTarget} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +import {GridListHeaderProps, GridListSectionProps} from 'react-aria-components/GridList'; +import {IconContext} from './Icon'; +import {Link} from 'react-aria-components/Link'; +import {Provider, useContextProps} from 'react-aria-components/slots'; import { - GridListSectionProps, TreeItemProps as RACTreeItemProps, TreeProps as RACTreeProps, Tree, @@ -44,22 +60,6 @@ import { TreeRenderProps, TreeSection } from 'react-aria-components/Tree'; -import {IconContext} from './Icon'; -import {Link} from 'react-aria-components/Link'; -import {Provider, useContextProps} from 'react-aria-components/slots'; -import React, { - createContext, - forwardRef, - JSXElementConstructor, - ReactElement, - KeyboardEvent as ReactKeyboardEvent, - ReactNode, - RefObject, - useContext, - useEffect, - useRef, - useState -} from 'react'; import {Text, TextContext} from './Content'; import {TreeState} from 'react-stately/useTreeState'; import {useDOMRef} from './useDOMRef'; @@ -82,23 +82,39 @@ export interface SideNavProps | 'defaultSelectedKeys' | 'disabledBehavior' | 'selectionMode' + | 'escapeKeyBehavior' + | 'shouldSelectOnPressUp' + | 'disallowEmptySelection' + | 'renderEmptyState' + | 'keyboardNavigationBehavior' + | 'dragAndDropHooks' // To be implemented | keyof GlobalDOMAttributes >, UnsafeStyles, SideNavStyleProps { + /** The route that is currently selected. */ + selectedRoute: string; /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight; - /** The route that is currently selected. */ - selectedRoute?: string; } interface SideNavStyleProps {} export interface SideNavItemProps extends Omit< RACTreeItemProps, - 'className' | 'style' | 'render' | 'onClick' | keyof GlobalDOMAttributes + | 'className' + | 'style' + | 'render' + | 'onClick' + | 'allowsArrowNavigation' + | 'focusMode' + | 'value' + | 'onAction' + | keyof GlobalDOMAttributes > { - /** Whether this item has children, even if not loaded yet. */ + /** A string representation of the side nav item's contents, used for features like typeahead. */ + textValue: string; + /** Whether this item has children. */ hasChildItems?: boolean; } @@ -162,7 +178,7 @@ interface InternalSideNavContextValue { let InternalSideNavContext = createContext({}); /** - * A tree view provides users with a way to navigate nested hierarchical information. + * A SideNav provides users with a way to navigate nested hierarchical set of links. */ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function SideNav( props: SideNavProps, @@ -305,9 +321,9 @@ const treeCellGrid = style({ boxSizing: 'border-box', alignContent: 'center', alignItems: 'center', - gridTemplateColumns: [12, 'auto', '1fr', 'auto', 'auto'], + gridTemplateColumns: [12, 'auto', 'auto', '1fr', 'auto', 'auto'], gridTemplateRows: '1fr', - gridTemplateAreas: ['. level-padding content actions actionmenu'], + gridTemplateAreas: ['. level-padding icon content actions actionmenu'], paddingEnd: 4, // account for any focus rings on the last item in the cell color: { default: baseColor('neutral-subdued'), @@ -434,7 +450,7 @@ export const SideNavItem = (props: SideNavItemProps): ReactNode => { }; export interface SideNavItemContentProps extends Omit { - /** Rendered contents of the tree item or child items. */ + /** Rendered contents of the side nav item or child items. */ children: ReactNode; } @@ -719,7 +735,10 @@ function ExpandableRowChevron(props: ExpandableRowChevronProps) { ); } -export interface SideNavSectionProps extends GridListSectionProps {} +export interface SideNavSectionProps extends Omit< + GridListSectionProps, + 'value' | 'render' | 'style' | 'className' +> {} export function SideNavSection(props: SideNavSectionProps) { return ( @@ -729,7 +748,12 @@ export function SideNavSection(props: SideNavSectionProps) ); } -export const SideNavHeader = (props: {children: ReactNode}): ReactNode => { +export interface SideNavHeaderProps extends Omit< + GridListHeaderProps, + 'value' | 'render' | 'style' | 'className' +> {} + +export const SideNavHeader = (props: SideNavHeaderProps): ReactNode => { return ( { ); }; -interface SideNavItemLinkProps { +export interface SideNavItemLinkProps { /** Rendered contents of the link. */ children?: ReactNode; } diff --git a/packages/@react-spectrum/s2/test/SideNav.test.tsx b/packages/@react-spectrum/s2/test/SideNav.test.tsx index 882a1cd11ab..3b5490474c8 100644 --- a/packages/@react-spectrum/s2/test/SideNav.test.tsx +++ b/packages/@react-spectrum/s2/test/SideNav.test.tsx @@ -26,9 +26,10 @@ import { import {Text} from '../src/Content'; import userEvent, {UserEvent} from '@testing-library/user-event'; -function SideNavExample(props: SideNavProps = {}) { +function SideNavExample(props: Partial>) { + let {selectedRoute = '/files', ...rest} = props; return ( - + @@ -64,7 +65,7 @@ function SideNavExample(props: SideNavProps = {}) { // A controlled wrapper mirroring how SideNav is used with a router: activating a link is // intercepted by RouterProvider, and the navigated href becomes the controlled selectedRoute. -function RoutedSideNavExample(props: SideNavProps = {}) { +function RoutedSideNavExample(props: Partial>) { let [selectedRoute, setSelectedRoute] = React.useState('/files'); return ( @@ -74,9 +75,10 @@ function RoutedSideNavExample(props: SideNavProps = {}) { } // Three levels deep: Your libraries > Projects 1 > Projects 1A. -function DeepSideNavExample(props: SideNavProps = {}) { +function DeepSideNavExample(props: Partial>) { + let {selectedRoute = '/libraries', ...rest} = props; return ( - + @@ -103,9 +105,10 @@ function DeepSideNavExample(props: SideNavProps = {}) { } // An item whose row has both a link and a secondary action (ActionMenu). -function ActionMenuSideNavExample(props: SideNavProps = {}) { +function ActionMenuSideNavExample(props: Partial>) { + let {selectedRoute = '/files', ...rest} = props; return ( - + @@ -127,9 +130,10 @@ function ActionMenuSideNavExample(props: SideNavProps = {}) { // An item with no href and no link, but with a secondary action (ActionMenu). Focus should stay // on the row rather than jumping into the ActionMenu trigger. -function NoLinkActionMenuSideNavExample(props: SideNavProps = {}) { +function NoLinkActionMenuSideNavExample(props: Partial>) { + let {selectedRoute = '/files', ...rest} = props; return ( - + diff --git a/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx b/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx new file mode 100644 index 00000000000..97ddbc84619 --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx @@ -0,0 +1,22 @@ +'use client'; +import {RouterProvider} from 'react-aria-components'; +import React, {ReactNode, useState} from 'react'; + +export function RoutedSideNav(props: {children: ReactNode; defaultSelectedRoute?: string}) { + let {children} = props; + let [selectedRoute, setSelectedRoute] = useState(props.defaultSelectedRoute); + + let updateSelection = (href: string) => { + setSelectedRoute(href); + }; + + return ( + + {/** Use cloneElement as an example only. */} + {React.cloneElement( + React.Children.toArray(children)[0] as React.ReactElement, + {selectedRoute: selectedRoute} as any + )} + + ); +} diff --git a/packages/dev/s2-docs/pages/s2/SideNav.mdx b/packages/dev/s2-docs/pages/s2/SideNav.mdx new file mode 100644 index 00000000000..2572c25c7ff --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/SideNav.mdx @@ -0,0 +1,305 @@ +import {Layout} from '../../src/Layout'; +export default Layout; + +import docs from 'docs:@react-spectrum/s2'; + +export const tags = ['hierarchy', 'navigation', 'nested']; +export const relatedPages = []; +export const description = 'Displays hierarchical navigation with collapsing.'; + +# SideNav + +{docs.exports.SideNav.description} + +```tsx render docs={docs.exports.SideNav} links={docs.links} props={[]} type="s2" files={['packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx']} +"use client"; +import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink} from '@react-spectrum/s2/SideNav'; +import {RoutedSideNav} from './RoutedSideNav'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + + + + + Guidelines + + Style + + + Color + + Background Layers + + + + + Support + + Contact Us + + + + +``` + +## Selection + +SideNav doesn't support selection like other [Collections](collections). Instead of the `id`, it uses the `href` prop as a key for selection. Note that +`id` is still used for the expansion state and disabled items as not every item will have an `href`. + +SideNavs do not support an uncontrolled selection state, you are responsible for managing it through the `selectedRoute` prop. You may wire this up +to a router or other state management solution. + +If a SideNavItem has an `href`, then you must pass a `SideNavItemLink` as a child of the `SideNavItemContent`. + +## Content + +`SideNav` follows the [Collection Components API](collections), accepting both static and dynamic collections. This example shows a dynamic collection, passing a list of objects to the `items` prop, and a recursive function to render the children. + +```tsx render type="s2" files={['packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx']} +"use client"; +import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, Collection} from '@react-spectrum/s2/SideNav'; +import {RoutedSideNav} from './RoutedSideNav'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +///- begin collapse -/// +let items = [ + {id: 1, title: 'Documents', type: 'directory', children: [ + {id: 2, title: 'Project', type: 'directory', children: [ + {id: 3, title: 'Weekly Report', type: 'file', href: '/weekly-report'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ]} + ]}, + {id: 5, title: 'Photos', type: 'directory', children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ]} +]; +///- end collapse -/// + + + + {function renderItem(item) { + return ( + + {item.href ? {item.title} : item.title} + {/*- begin highlight -*/} + {/* recursively render children */} + {item.children && + {renderItem} + } + {/*- end highlight -*/} + + ); + }} + + +``` + +### Slots + +`SideNavItemContent` supports icons, `Text`, [ActionMenu](ActionMenu), and [ActionButtonGroup](ActionButtonGroup) as children. + +```tsx render type="s2" files={['packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx']} +"use client"; +import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, Collection, Text} from '@react-spectrum/s2/SideNav'; +import {RoutedSideNav} from './RoutedSideNav'; +import {ActionMenu, MenuItem} from '@react-spectrum/s2/ActionMenu'; +import Folder from '@react-spectrum/s2/icons/Folder'; +import File from '@react-spectrum/s2/icons/File'; +import Edit from '@react-spectrum/s2/icons/Edit'; +import Delete from '@react-spectrum/s2/icons/Delete'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +///- begin collapse -/// +let items = [ + {id: 1, title: 'Documents', type: 'directory', children: [ + {id: 2, title: 'Project', type: 'directory', children: [ + {id: 3, title: 'Weekly Report', type: 'file', href: '/weekly-report'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ]} + ]}, + {id: 5, title: 'Photos', type: 'directory', children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ]} +]; +///- end collapse -/// + + + + {function renderItem(item) { + return ( + + + {/*- begin highlight -*/} + { + item.href ? + {item.type === 'directory' ? : } + {item.title} + : <> + {item.type === 'directory' ? : } + {item.title} + + } + {/*- end highlight -*/} + + + + Edit + + + + Delete + + + + {item.children && + {renderItem} + } + + ); + }} + + +``` + +## Sections + +A SideNav can contain sections to group items together. They are non-collapsible and non-interactive. + +```tsx render type="s2" files={['packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx']} +"use client"; +import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, SideNavSection, SideNavHeader, Collection, Text} from '@react-spectrum/s2/SideNav'; +import {RoutedSideNav} from './RoutedSideNav'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import Delete from '@react-spectrum/s2/icons/Delete'; +import UserGroup from '@react-spectrum/s2/icons/UserGroup'; +import CCLibrary from '@react-spectrum/s2/icons/CCLibrary'; +import Files from '@react-spectrum/s2/icons/Files'; +import Images from '@react-spectrum/s2/icons/Images'; +import Animation from '@react-spectrum/s2/icons/Animation'; +import AudioWave from '@react-spectrum/s2/icons/AudioWave'; + + + + + Workspaces + + + + + Files + + + + + + + + Your Libraries + + + + + + + Photos + + + + + + + + + Shared with You + + + + + + + Animations + + + + + + + + + Deleted + + + + + + + Audio + + + + + + + +``` + +## API + +```tsx links={{SideNav: '#sidenav', SideNavItem: '#sidenavitem', SideNavItemContent: '#sidenavitemcontent', SideNavItemLink: '#sidenavitemlink', ActionMenu: 'ActionMenu', ActionButtonGroup: 'ActionButtonGroup', Icon: 'icons', Text: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/span'}} + + + + + + or + + + + + + + + + or + + + +``` + +### SideNav + + + +### SideNavItem + + + +### SideNavItemContent + + + +### SideNavItemLink + + + +### SideNavSection + + + +### SideNavHeader + + diff --git a/packages/react-aria-components/exports/GridList.ts b/packages/react-aria-components/exports/GridList.ts index c23901474ee..c1dee740968 100644 --- a/packages/react-aria-components/exports/GridList.ts +++ b/packages/react-aria-components/exports/GridList.ts @@ -25,6 +25,7 @@ export { } from '../src/GridList'; export {Collection, type CollectionProps} from 'react-aria/Collection'; export type { + GridListHeaderProps, GridListProps, GridListRenderProps, GridListItemProps, diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index 3b0a0720dc9..9fcb431580a 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -375,6 +375,7 @@ export type {FieldErrorProps, FieldErrorRenderProps} from '../src/FieldError'; export type {FileTriggerProps} from '../src/FileTrigger'; export type {FormProps} from '../src/Form'; export type { + GridListHeaderProps, GridListProps, GridListRenderProps, GridListItemProps, From e3fc099abeb032585746353d64d08aa6820de025 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 13 Jul 2026 17:32:28 +1000 Subject: [PATCH 38/52] make prop optional for now since it caused the docs to break --- packages/@react-spectrum/s2/src/SideNav.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 7c57b03f4b8..08900f8b300 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -93,7 +93,7 @@ export interface SideNavProps UnsafeStyles, SideNavStyleProps { /** The route that is currently selected. */ - selectedRoute: string; + selectedRoute?: string; /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight; } From c35fef98c2df7e5375bfb9308c108923e5ab80c3 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 13 Jul 2026 17:33:55 +1000 Subject: [PATCH 39/52] fix types --- packages/dev/s2-docs/pages/s2/SideNav.mdx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/SideNav.mdx b/packages/dev/s2-docs/pages/s2/SideNav.mdx index 2572c25c7ff..14ec7841e48 100644 --- a/packages/dev/s2-docs/pages/s2/SideNav.mdx +++ b/packages/dev/s2-docs/pages/s2/SideNav.mdx @@ -66,7 +66,15 @@ import {RoutedSideNav} from './RoutedSideNav'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; ///- begin collapse -/// -let items = [ +interface Item { + id: number; + title: string; + type: 'directory' | 'file'; + href?: string; + children?: Item[]; +} + +let items: Item[] = [ {id: 1, title: 'Documents', type: 'directory', children: [ {id: 2, title: 'Project', type: 'directory', children: [ {id: 3, title: 'Weekly Report', type: 'file', href: '/weekly-report'}, @@ -119,7 +127,14 @@ import Delete from '@react-spectrum/s2/icons/Delete'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; ///- begin collapse -/// -let items = [ +interface Item { + id: number; + title: string; + type: 'directory' | 'file'; + href?: string; + children?: Item[]; +} +let items: Item[] = [ {id: 1, title: 'Documents', type: 'directory', children: [ {id: 2, title: 'Project', type: 'directory', children: [ {id: 3, title: 'Weekly Report', type: 'file', href: '/weekly-report'}, From 5fa507faa37675e4eb00582612d80fd171cba097 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 13 Jul 2026 18:32:40 +1000 Subject: [PATCH 40/52] fix ts --- packages/dev/s2-docs/pages/s2/SideNav.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dev/s2-docs/pages/s2/SideNav.mdx b/packages/dev/s2-docs/pages/s2/SideNav.mdx index 14ec7841e48..0b05ce17541 100644 --- a/packages/dev/s2-docs/pages/s2/SideNav.mdx +++ b/packages/dev/s2-docs/pages/s2/SideNav.mdx @@ -196,7 +196,7 @@ A SideNav can contain sections to group items together. They are non-collapsible ```tsx render type="s2" files={['packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx']} "use client"; -import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, SideNavSection, SideNavHeader, Collection, Text} from '@react-spectrum/s2/SideNav'; +import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, SideNavSection, SideNavHeader, Text} from '@react-spectrum/s2/SideNav'; import {RoutedSideNav} from './RoutedSideNav'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import Delete from '@react-spectrum/s2/icons/Delete'; From e80b56c9fa08fea9bf7e01baf26e2e0e8573fffa Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 13 Jul 2026 10:40:48 -0700 Subject: [PATCH 41/52] restore table column header default --- packages/react-aria/src/table/useTableColumnHeader.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/react-aria/src/table/useTableColumnHeader.ts b/packages/react-aria/src/table/useTableColumnHeader.ts index a4c606232b3..7e1cbf1ac5d 100644 --- a/packages/react-aria/src/table/useTableColumnHeader.ts +++ b/packages/react-aria/src/table/useTableColumnHeader.ts @@ -70,10 +70,7 @@ export function useTableColumnHeader( ): TableColumnHeaderAria { let {node} = props; let allowsSorting = node.props.allowsSorting; - // TODO: I removed the default here so that in tab navigation will default to 'cell' rather than 'child' - // TBH it feels a bit disruptive to autofocus the child in tab navigation since you might just - // want to go through the cells but will then unexpectedly fall into a random cell and need to shift tab out - let {gridCellProps} = useGridCell(props, state, ref); + let {gridCellProps} = useGridCell({focusMode: 'child', ...props}, state, ref); let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single'; From 6f9bebe01c81eff9ff9d7ccc39f820761da74b0c Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 14 Jul 2026 15:20:25 +1000 Subject: [PATCH 42/52] fix click interaction on Categories and disabled items --- packages/@react-spectrum/s2/src/SideNav.tsx | 30 ++++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 08900f8b300..9909a19da4e 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -275,7 +275,7 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid scrollPaddingBottom: 0 }} className={renderProps => tree(renderProps)} - selectionMode="single" + selectionMode="none" keyboardNavigationBehavior="arrow" disallowEmptySelection ref={scrollRef}> @@ -406,7 +406,10 @@ const treeRowLink = style({ outlineStyle: 'none', textDecoration: 'none', color: 'inherit', - cursor: 'pointer' + cursor: { + default: 'pointer', + isDisabled: 'default' + } }); const treeActions = style({ @@ -420,6 +423,7 @@ const treeActionMenu = style({ }); const SideNavItemLinkContext = createContext<{ + isDisabled?: boolean; href?: string; hrefLang?: string; target?: string; @@ -436,13 +440,23 @@ const SideNavItemLinkContext = createContext<{ export const SideNavItem = (props: SideNavItemProps): ReactNode => { let {href, hrefLang, target, rel, download, ping, referrerPolicy, routerOptions, ...rest} = props; + let hasLink = href != null && href.length > 0; return ( + value={{ + href, + hrefLang, + target, + rel, + download, + ping, + referrerPolicy, + routerOptions + }}> 0 ? 'child' : undefined} + focusMode={hasLink ? 'child' : undefined} className={renderProps => treeRow(renderProps)} /> @@ -599,9 +613,11 @@ const SideNaveItemContentInner = props => { useRouteFocusSync({state}); + let hasLink = linkProps.href != null && linkProps.href.length > 0; + return ( <> - {isHovered &&
} + {isHovered && hasLink &&
}
{ [TextContext, {styles: treeContent}], // forward this so that it gets out of the fake dom's tree and into the real one, and // add onFocusChange so the link reports focus for the row focus ring. - [SideNavItemLinkContext, {...linkProps, onFocusChange: setLinkFocused}], + [SideNavItemLinkContext, {...linkProps, isDisabled, onFocusChange: setLinkFocused}], [ IconContext, { @@ -782,7 +798,7 @@ export const SideNavItemLink = (props: SideNavItemLinkProps): ReactNode => { {...props} {...linkProps} aria-current={selectedRoute === linkProps.href ? 'page' : undefined} - className={treeRowLink}> + className={treeRowLink({isDisabled: linkProps.isDisabled})}> Date: Tue, 14 Jul 2026 17:26:13 +1000 Subject: [PATCH 43/52] Move to GridList handling, fix colors, add tests and chromatic --- .../s2/chromatic/SideNav.stories.tsx | 258 ++++++++++++++++++ packages/@react-spectrum/s2/src/SideNav.tsx | 83 +----- .../@react-spectrum/s2/test/SideNav.test.tsx | 51 +++- .../src/gridlist/useGridListItem.ts | 31 ++- 4 files changed, 345 insertions(+), 78 deletions(-) create mode 100644 packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx diff --git a/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx b/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx new file mode 100644 index 00000000000..21e4b6916ad --- /dev/null +++ b/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx @@ -0,0 +1,258 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Collection} from 'react-aria/Collection'; +import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import type {Meta, StoryObj} from '@storybook/react'; +import {ReactElement} from 'react'; +import {screen, userEvent} from 'storybook/test'; +import { + SideNav, + SideNavHeader, + SideNavItem, + SideNavItemContent, + SideNavItemLink, + SideNavProps, + SideNavSection +} from '../src/SideNav'; +import {Text} from '../src/Content'; + +const meta: Meta = { + component: SideNav, + parameters: { + chromaticProvider: {disableAnimations: true} + }, + title: 'S2 Chromatic/SideNav' +}; + +export default meta; + +function SideNavExample(props: SideNavProps): ReactElement { + return ( +
+ + + + + Your files + + + + + + + + Your libraries + + + + + + Projects-1 + + + + + + Projects-1A + + + + + + + + Projects-2 + + + + + + + Projects-3 + + + + + +
+ ); +} + +export const Static: StoryObj = { + render: args => +}; + +// A nested item is selected while its collapsed ancestor shows the "has selected descendant" +// indicator (the parent row's selected state without the child being visible). +export const CollapsedSelectedAncestor: StoryObj = { + ...Static, + args: { + expandedKeys: [], + selectedRoute: '/projects-2' + } +}; + +function SideNavSectionsExample(props: SideNavProps): ReactElement { + return ( +
+ + + Photography + + + + Your files + + + + + + + Work + + + + Your libraries + + + + + + Projects-1 + + + + + + + Projects-2 + + + + + + +
+ ); +} + +export const Sections: StoryObj = { + render: args => +}; + +interface SideNavItemType { + id: string; + name: string; + href?: string; + childItems?: SideNavItemType[]; +} + +let rows: SideNavItemType[] = [ + {id: 'Photos', name: 'Your files', href: '/Photos'}, + { + id: 'projects', + name: 'Projects', + href: '/projects', + childItems: [ + {id: 'projects-1', name: 'Projects-1', href: '/projects-1'}, + { + id: 'projects-2', + name: 'Projects-2', + href: '/projects-2', + childItems: [ + {id: 'projects-2A', name: 'Projects-2A', href: '/projects-2A'}, + {id: 'projects-2B', name: 'Projects-2B', href: '/projects-2B'}, + {id: 'projects-2C', name: 'Projects-2C', href: '/projects-2C'} + ] + }, + {id: 'projects-3', name: 'Projects-3', href: '/projects-3'} + ] + }, + { + id: 'reports', + name: 'Reports', + href: '/reports', + childItems: [{id: 'reports-1', name: 'Reports-1', href: '/reports-1'}] + } +]; + +const DynamicSideNavItem = (props: SideNavItemType): ReactElement => { + let {id, name, href, childItems} = props; + return ( + + + + {name} + + + + {(item: SideNavItemType) => } + + + ); +}; + +function SideNavDynamicExample(props: SideNavProps): ReactElement { + return ( +
+ + {(item: SideNavItemType) => } + +
+ ); +} + +export const Dynamic: StoryObj = { + render: args => +}; + +// Hovering an item that has a link reveals the hover indicator bar to the left of the row. +// Rendered as a single instance (one color scheme + background) so the play function's role +// query resolves to exactly one SideNav. +export const Hovered: StoryObj = { + ...Static, + play: async () => { + // Hover a row that isn't the selected one (selectedRoute is /projects-2) so the hover + // indicator is distinct from the selection indicator. Hover the row itself (not the inner + // link): the row owns the useHover handlers, which use onPointerEnter/onMouseEnter and don't + // bubble, so hovering a child wouldn't trigger them. + let row = screen.getByRole('row', {name: 'Projects-3'}); + await userEvent.hover(row); + // Give the hover state time to settle before the screenshot is captured. + await new Promise(resolve => setTimeout(resolve, 100)); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 9909a19da4e..561e53c1549 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -30,7 +30,6 @@ import { forwardRef, JSXElementConstructor, ReactElement, - KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject, useContext, @@ -43,7 +42,6 @@ import { StylesPropWithHeight, UnsafeStyles } from './style-utils' with {type: 'macro'}; -import {getEventTarget} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {GridListHeaderProps, GridListSectionProps} from 'react-aria-components/GridList'; import {IconContext} from './Icon'; import {Link} from 'react-aria-components/Link'; @@ -194,78 +192,16 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid let domRef = useDOMRef(ref); let scrollRef = useRef(null); let stateRef = useRef | null>(null); - let {direction} = useLocale(); // Tracks the last route we moved the focused key to, so the focus sync (driven from // RouteFocusSync, which has the built collection) only runs when the route actually changes let syncedRouteRef = useRef(undefined); - // RAC swallows arrow keys at the collection level (stopPropagation during capture), so a handler - // on the link never sees them. Intercept here on an ancestor, before RAC's row handler runs, and - // expand a collapsed row when the expand arrow is pressed while focus is on its link. - let onKeyDownCapture = (e: ReactKeyboardEvent) => { - let state = stateRef.current; - if (!state) { - return; - } - if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') { - return; - } - let target = getEventTarget(e); - let link = target.closest?.('a'); - if (!link || link !== target) { - return; - } - let rowEl = link.closest('[role="row"]'); - if (!rowEl) { - return; - } - let key = rowEl.dataset.key; - if (key == null) { - return; - } - let node = state.collection.getItem(key); - // null = leaf, 'true' = expanded, 'false' = collapsed. - let ariaExpanded = rowEl.getAttribute('aria-expanded'); - let collapseKey = direction === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; - let expandKey = direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; - - // Move focus to the parent item. RAC's own parent-move (handleTreeExpansionKeys) only runs - // when the row itself has DOM focus, so with focusMode="child" (focus on the link) it never - // fires; replicate it here. Pointing the focused key at the parent makes useSelectableItem - // move DOM focus to it (and, in focusMode="child", into the parent's link). - let moveToParent = () => { - if (node?.parentKey != null && state.collection.getItem(node.parentKey)?.type === 'item') { - e.stopPropagation(); - e.preventDefault(); - state.selectionManager.setFocusedKey(node.parentKey); - } - }; - - if (e.key === collapseKey) { - if (ariaExpanded === 'true') { - // Expanded parent: collapse it (focus stays on the row). - e.stopPropagation(); - e.preventDefault(); - state.toggleKey(key); - } else { - // Leaf or already-collapsed row: step up to the parent. - moveToParent(); - } - } else if (e.key === expandKey && ariaExpanded === 'false') { - // Collapsed parent: expand it. - e.stopPropagation(); - e.preventDefault(); - state.toggleKey(key); - } - }; - return (
+ style={UNSAFE_style}> tree(renderProps)} selectionMode="none" - keyboardNavigationBehavior="arrow" + keyboardNavigationBehavior="tab" disallowEmptySelection ref={scrollRef}> {props.children} @@ -455,8 +391,9 @@ export const SideNavItem = (props: SideNavItemProps): ReactNode => { }}> treeRow(renderProps)} /> @@ -475,7 +412,7 @@ const selectedIndicator = style<{isDisabled: boolean; isSelected: boolean}>({ isSelected: 'block' }, backgroundColor: { - default: 'neutral', + default: 'gray-800', isDisabled: 'disabled', forcedColors: { default: 'Highlight', @@ -499,8 +436,12 @@ const selectedIndicator = style<{isDisabled: boolean; isSelected: boolean}>({ const hoveredIndicator = style({ position: 'absolute', + display: { + default: 'none', + isVisible: 'block' + }, backgroundColor: { - default: 'neutral-subdued', + default: 'gray-400', forcedColors: 'Highlight' }, height: 18, @@ -617,7 +558,7 @@ const SideNaveItemContentInner = props => { return ( <> - {isHovered && hasLink &&
} +
{ // items too, and the href is stored as a data attribute so it doesn't trigger Tree's link handling. function findKeyForRoute(collection: Collection>, route: string): Key | null { for (let key of collection.getKeys()) { - if (collection.getItem(key)?.props?.['data-href'] === route) { + if (collection.getItem(key)?.props?.href === route) { return key; } } diff --git a/packages/@react-spectrum/s2/test/SideNav.test.tsx b/packages/@react-spectrum/s2/test/SideNav.test.tsx index 3b5490474c8..9957a730550 100644 --- a/packages/@react-spectrum/s2/test/SideNav.test.tsx +++ b/packages/@react-spectrum/s2/test/SideNav.test.tsx @@ -74,7 +74,7 @@ function RoutedSideNavExample(props: Partial>) { ); } -// Three levels deep: Your libraries > Projects 1 > Projects 1A. +// libraries > Projects 1 > Projects 1A function DeepSideNavExample(props: Partial>) { let {selectedRoute = '/libraries', ...rest} = props; return ( @@ -153,6 +153,13 @@ function NoLinkActionMenuSideNavExample(props: Partial>) { + + + + Section 2 + + + ); @@ -310,7 +317,7 @@ describe('SideNav', () => { let childrenWithLinkFocused = cell.children.length; // Move focus to the ActionMenu trigger in the same row: the ring is gone. - await user.keyboard('{ArrowRight}'); + await user.tab(); act(() => { jest.runAllTimers(); }); @@ -339,6 +346,14 @@ describe('SideNav', () => { expect(within(sectionRow).getByRole('button', {name: 'More actions'})).not.toHaveFocus(); }); + it("clicking on a category item's content expands the category", async () => { + let {getByRole} = render(); + let sectionRow = getByRole('row', {name: 'Section'}); + + await user.click(within(sectionRow).getByText('Section')); + expect(sectionRow).toHaveAttribute('aria-expanded', 'true'); + }); + it('activates the link when clicked', async () => { let navigate = jest.fn(); let {getByRole} = render( @@ -359,11 +374,41 @@ describe('SideNav', () => { ); - // Tab moves focus to the first item's link. await user.tab(); expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); await user.keyboard('{Enter}'); expect(navigate).toHaveBeenCalledWith('/files', undefined); }); + + it('takes one tab to leave the sidenav from a link', async () => { + let {getByRole} = render( + <> + + + + ); + + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + + await user.tab(); + expect(getByRole('textbox')).toHaveFocus(); + }); + + it('takes one shift tab to leave the sidenav from a link', async () => { + let {getByRole} = render( + <> + + + + ); + + await user.tab(); + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + + await user.tab({shift: true}); + expect(getByRole('textbox')).toHaveFocus(); + }); }); diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 8d3a2139747..4292e3ec363 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -216,7 +216,16 @@ export function useGridListItem( walker.currentNode = activeElement; if ( - handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current) + handleTreeExpansionKeys( + e, + state, + node, + hasChildRows, + direction, + activeElement, + ref.current, + allowsArrowNavigation + ) ) { return; } @@ -359,7 +368,16 @@ export function useGridListItem( } if ( - handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current) + handleTreeExpansionKeys( + e, + state, + node, + hasChildRows, + direction, + activeElement, + ref.current, + allowsArrowNavigation + ) ) { return; } @@ -415,6 +433,10 @@ export function useGridListItem( id: getRowId(state, node.key) }); + if (focusMode === 'child' && allowsArrowNavigation) { + rowProps.tabIndex = -1; + } + // 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; @@ -461,9 +483,10 @@ function handleTreeExpansionKeys( hasChildRows: boolean | undefined, direction: string, activeElement: Element | null, - rowRef: FocusableElement | null + rowRef: FocusableElement | null, + allowsArrowNavigation: boolean | undefined ): boolean { - if (!('expandedKeys' in state) || activeElement !== rowRef) { + if (!('expandedKeys' in state) || (!allowsArrowNavigation && activeElement !== rowRef)) { return false; } if ( From f543e150530fde2bdb911c4a2d84bc70b78fb4ca Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 08:29:53 +1000 Subject: [PATCH 44/52] lots of cleanup --- .../s2/chromatic/SideNav.stories.tsx | 27 +++- packages/@react-spectrum/s2/src/SideNav.tsx | 122 +++++------------- 2 files changed, 50 insertions(+), 99 deletions(-) diff --git a/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx b/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx index 21e4b6916ad..5827ae85a1c 100644 --- a/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx @@ -232,16 +232,13 @@ export const Dynamic: StoryObj = { render: args => }; -// Hovering an item that has a link reveals the hover indicator bar to the left of the row. -// Rendered as a single instance (one color scheme + background) so the play function's role -// query resolves to exactly one SideNav. export const Hovered: StoryObj = { ...Static, play: async () => { + await new Promise(resolve => setTimeout(resolve, 100)); // Hover a row that isn't the selected one (selectedRoute is /projects-2) so the hover - // indicator is distinct from the selection indicator. Hover the row itself (not the inner - // link): the row owns the useHover handlers, which use onPointerEnter/onMouseEnter and don't - // bubble, so hovering a child wouldn't trigger them. + // indicator is distinct from the selection indicator. Hover the row itself, not the inner + // link let row = screen.getByRole('row', {name: 'Projects-3'}); await userEvent.hover(row); // Give the hover state time to settle before the screenshot is captured. @@ -256,3 +253,21 @@ export const Hovered: StoryObj = { } } }; + +export const Focused: StoryObj = { + ...Static, + play: async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + await userEvent.tab(); + // Give the focus state time to settle before the screenshot is captured. + await new Promise(resolve => setTimeout(resolve, 100)); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 561e53c1549..3351bf8944c 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -164,11 +164,7 @@ const tree = style({ }); interface InternalSideNavContextValue { - /** - * Ref to the tree state, bridged up from SideNavItemContent so arrow-key handling can toggle - * expansion. - */ - stateRef?: RefObject | null>; + /** The route that is currently selected. */ selectedRoute?: string; /** The last route the focused key was synced to; dedupes the focus sync across items. */ syncedRouteRef?: RefObject; @@ -182,7 +178,7 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid props: SideNavProps, ref: DOMRef ) { - let {children, UNSAFE_className, UNSAFE_style, selectedRoute} = props; + let {children, UNSAFE_className, UNSAFE_style, selectedRoute, ...rest} = props; let renderer; if (typeof children === 'function') { @@ -190,8 +186,6 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid } let domRef = useDOMRef(ref); - let scrollRef = useRef(null); - let stateRef = useRef | null>(null); // Tracks the last route we moved the focused key to, so the focus sync (driven from // RouteFocusSync, which has the built collection) only runs when the route actually changes @@ -203,18 +197,16 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid className={(UNSAFE_className ?? '') + sideNavWrapper(null, props.styles)} style={UNSAFE_style}> - + tree(renderProps)} selectionMode="none" - keyboardNavigationBehavior="tab" - disallowEmptySelection - ref={scrollRef}> + keyboardNavigationBehavior="tab"> {props.children} @@ -223,7 +215,7 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid ); }); -const treeRow = style({ +const treeRow = style({ outlineStyle: 'none', position: 'relative', display: 'flex', @@ -239,10 +231,6 @@ const treeRow = style({ +const indicator = style<{isDisabled: boolean; isSelected: boolean; isHovered: boolean}>({ position: 'absolute', display: { default: 'none', - isSelected: 'block' + isSelected: 'block', + isHovered: 'block' }, backgroundColor: { - default: 'gray-800', + isHovered: 'gray-400', + isSelected: 'gray-800', isDisabled: 'disabled', forcedColors: { default: 'Highlight', @@ -434,33 +402,8 @@ const selectedIndicator = style<{isDisabled: boolean; isSelected: boolean}>({ borderRadius: 'full' }); -const hoveredIndicator = style({ - position: 'absolute', - display: { - default: 'none', - isVisible: 'block' - }, - backgroundColor: { - default: 'gray-400', - forcedColors: 'Highlight' - }, - height: 18, - width: '[2px]', - contain: 'strict', - top: '50%', - transform: 'translateY(-50%)', - '--indicator-indent': { - type: 'width', - value: 4 - }, - insetStart: - '[calc(calc(var(--tree-item-level, 0) - 1) * var(--indent) + var(--indicator-indent))]', - borderStyle: 'none', - borderRadius: 'full' -}); - -// Moves the tree's focused key to the item matching selectedRoute. Lives here -// (rather than in SideNav) because it needs the built collection off `state`, which only exists +// Moves the tree's focused key to the item matching selectedRoute. Lives in items +// (rather than up in SideNav) because it needs the built collection off `state`, which only exists // after the tree has rendered. Runs when the route or the collection changes; the shared // syncedRouteRef dedupes across items so it fires once per route change function useRouteFocusSync({state}: {state: TreeState}): void { @@ -486,7 +429,7 @@ export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => let {children} = props; let scale = useScale(); let linkProps = useContext(SideNavItemLinkContext); - let {stateRef, selectedRoute} = useContext(InternalSideNavContext); + let {selectedRoute} = useContext(InternalSideNavContext); return ( @@ -511,7 +454,6 @@ export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => scale={scale} id={id} state={state} - stateRef={stateRef} selectedRoute={selectedRoute} isHovered={isHovered} isFocusVisible={isFocusVisible} @@ -534,7 +476,6 @@ const SideNaveItemContentInner = props => { scale, id, state, - stateRef, selectedRoute, isHovered, isFocusVisible, @@ -545,12 +486,6 @@ const SideNaveItemContentInner = props => { // keyboard-only isFocusVisibleWithin below, this lets the row focus ring follow the link // specifically and not other focusable children (e.g. an ActionMenu trigger). let [isLinkFocused, setLinkFocused] = useState(false); - // Bridge the tree state up to SideNav so its arrow-key handler can toggle expansion. - useEffect(() => { - if (stateRef) { - stateRef.current = state; - } - }, [state, stateRef]); useRouteFocusSync({state}); @@ -558,11 +493,11 @@ const SideNaveItemContentInner = props => { return ( <> -
{ isDescendantSelected: !isExpanded && hasChildItems && hasSelectedDescendant(id, state, selectedRoute) })}> - {(isFocusVisible || (isFocusVisibleWithin && isLinkFocused)) && ( -
- )} +
{ styles: style({size: fontRelative(20), flexShrink: 0}) } ], - [ActionButtonGroupContext, {styles: treeActions, isDisabled}], + [ActionButtonGroupContext, {styles: treeActions, isDisabled, size: 'S'}], [ActionMenuContext, {styles: treeActionMenu, isQuiet: true, isDisabled, size: 'S'}] ]}> {typeof children === 'string' ? {children} : children} @@ -715,9 +648,12 @@ export const SideNavHeader = (props: SideNavHeaderProps): ReactNode => { {props.children} From 5d3ceaefe1a7152f359e80980b505f58e19dcff4 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 10:20:24 +1000 Subject: [PATCH 45/52] remove useless test and add chromatic to replace it --- .../s2/chromatic/SideNav.stories.tsx | 61 +++++++ .../s2/stories/SideNav.stories.tsx | 156 +++++++++++++++++- .../@react-spectrum/s2/test/SideNav.test.tsx | 29 ---- 3 files changed, 215 insertions(+), 31 deletions(-) diff --git a/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx b/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx index 5827ae85a1c..eec82dff800 100644 --- a/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx @@ -10,8 +10,10 @@ * governing permissions and limitations under the License. */ +import {ActionMenu} from '../src/ActionMenu'; import {Collection} from 'react-aria/Collection'; import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import {MenuItem} from '../src/Menu'; import type {Meta, StoryObj} from '@storybook/react'; import {ReactElement} from 'react'; import {screen, userEvent} from 'storybook/test'; @@ -271,3 +273,62 @@ export const Focused: StoryObj = { } } }; + +const DynamicSideNavItemWithActions = (props: SideNavItemType): ReactElement => { + let {id, name, href, childItems} = props; + return ( + + + + {name} + + + + Edit + + + Delete + + + + + {(item: SideNavItemType) => } + + + ); +}; + +function SideNavDynamicExampleWithActions(props: SideNavProps): ReactElement { + return ( +
+ + {(item: SideNavItemType) => } + +
+ ); +} + +export const FocusedActionMenu: StoryObj = { + render: args => , + play: async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + await userEvent.tab(); + await new Promise(resolve => setTimeout(resolve, 100)); + await userEvent.tab(); + await new Promise(resolve => setTimeout(resolve, 100)); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx index d235c810fc8..cf7415d63b4 100644 --- a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -12,17 +12,20 @@ import {action} from 'storybook/actions'; import {ActionMenu} from '../src/ActionMenu'; +import {Avatar} from '../src/Avatar'; import {categorizeArgTypes, getActionArgs} from './utils'; import {Collection} from 'react-aria/Collection'; import Copy from '../s2wf-icons/S2_Icon_Copy_20_N.svg'; import Cut from '../s2wf-icons/S2_Icon_Cut_20_N.svg'; +import {Divider} from '../src/Divider'; import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; -import {Keyboard, Text} from '../src/Content'; +import {Heading, Keyboard, Text} from '../src/Content'; import {MenuItem} from '../src/Menu'; import type {Meta, StoryObj} from '@storybook/react'; import Paste from '../s2wf-icons/S2_Icon_Paste_20_N.svg'; -import React, {ReactElement, ReactNode, useState} from 'react'; +import React, {ReactElement, ReactNode, useRef, useState} from 'react'; import {RouterProvider} from 'react-aria-components'; +import {SearchField} from '../src/SearchField'; import { SideNav, SideNavHeader, @@ -32,6 +35,8 @@ import { SideNavProps, SideNavSection } from '../src/SideNav'; +import {style} from '../style' with {type: 'macro'}; +import {useLandmark} from 'react-aria'; const events = ['onSelectionChange']; @@ -338,3 +343,150 @@ export const SideNavDynamicWithActions: SideNavDynamicWithActionsStoryObj = { args: {}, name: 'WithActions' }; + +// Landmark wrappers used only by the story. useLandmark is called here in the app shell rather than +// inside SideNav, so the consumer owns landmark registration. Each renders a semantic element and +// spreads landmarkProps (role + labels). Press F6 / Shift+F6 to cycle landmarks with the keyboard. +function Banner(props: {children: ReactNode}): ReactElement { + let ref = useRef(null); + let {landmarkProps} = useLandmark({...props, role: 'banner', 'aria-label': 'Global'}, ref); + return ( +
+ {props.children} +
+ ); +} + +function Navigation(props: {'aria-label'?: string, children: ReactNode}): ReactElement { + let ref = useRef(null); + let {landmarkProps} = useLandmark({...props, role: 'navigation'}, ref); + return ( + + ); +} + +function Main(props: {'aria-label'?: string, children: ReactNode}): ReactElement { + let ref = useRef(null); + let {landmarkProps} = useLandmark({...props, role: 'main'}, ref); + return ( +
+ {props.children} +
+ ); +} + +const AppLayoutExample = (args: SideNavProps): ReactElement => { + let [selectedRoute, setSelectedRoute] = useState('/files'); + return ( + +
+ + Acme Cloud +
+ +
+ +
+
+ + + + + + Home + + + + + + + Your files + + + + + + + + Shared with you + + + + + + + Projects + + + + + + Website redesign + + + + + + + Mobile app + + + + + + + + Archive + + + + + +
+ Workspace +

+ This area is the main landmark and the side navigation on the left is wrapped in a + navigation landmark. Press F6 (or Shift+F6) to move keyboard focus between the + navigation, banner, and main landmarks. +

+ +

Current route: {selectedRoute}

+
+
+
+
+ ); +}; +type AppLayoutStoryObj = StoryObj; + +export const WithLandmark: AppLayoutStoryObj = { + render: args => , + args: {}, + name: 'With Landmark', + parameters: { + layout: 'fullscreen' + } +}; diff --git a/packages/@react-spectrum/s2/test/SideNav.test.tsx b/packages/@react-spectrum/s2/test/SideNav.test.tsx index 9957a730550..b260fd505df 100644 --- a/packages/@react-spectrum/s2/test/SideNav.test.tsx +++ b/packages/@react-spectrum/s2/test/SideNav.test.tsx @@ -296,35 +296,6 @@ describe('SideNav', () => { expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); }); - it('shows the row focus ring only when the link is focused, not the ActionMenu', async () => { - let {getByRole} = render(); - act(() => { - jest.runAllTimers(); - }); - - // The row focus ring is an extra element rendered into the cell grid alongside the link. - let link = getByRole('link', {name: 'Your files'}); - let cell = link.parentElement!; - - // Focus the link (focused key was synced to /files): the ring is present. - act(() => { - getByRole('treegrid').focus(); - }); - act(() => { - jest.runAllTimers(); - }); - expect(link).toHaveFocus(); - let childrenWithLinkFocused = cell.children.length; - - // Move focus to the ActionMenu trigger in the same row: the ring is gone. - await user.tab(); - act(() => { - jest.runAllTimers(); - }); - expect(getByRole('button', {name: 'More actions'})).toHaveFocus(); - expect(cell.children.length).toBe(childrenWithLinkFocused - 1); - }); - it('keeps focus on the row (not the ActionMenu) for an item with no href/link', async () => { let {getByRole} = render(); act(() => { From ce3af5df65dd394e1d7c2e957ca7a3cb119d3169 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 10:33:35 +1000 Subject: [PATCH 46/52] fix lint --- .../s2/stories/SideNav.stories.tsx | 32 +++++++++++++++---- .../@react-spectrum/s2/test/SideNav.test.tsx | 24 -------------- 2 files changed, 25 insertions(+), 31 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx index cf7415d63b4..d4e44722232 100644 --- a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -354,30 +354,44 @@ function Banner(props: {children: ReactNode}): ReactElement {
+ style={{ + display: 'flex', + alignItems: 'center', + gap: 12, + padding: 12, + borderBottom: '1px solid #d5d5d5' + }}> {props.children}
); } -function Navigation(props: {'aria-label'?: string, children: ReactNode}): ReactElement { +function Navigation(props: {'aria-label'?: string; children: ReactNode}): ReactElement { let ref = useRef(null); let {landmarkProps} = useLandmark({...props, role: 'navigation'}, ref); return ( ); } -function Main(props: {'aria-label'?: string, children: ReactNode}): ReactElement { +function Main(props: {'aria-label'?: string; children: ReactNode}): ReactElement { let ref = useRef(null); let {landmarkProps} = useLandmark({...props, role: 'main'}, ref); return ( -
+
{props.children}
); @@ -399,7 +413,9 @@ const AppLayoutExample = (args: SideNavProps): ReactElement => { overflow: 'hidden' }}> - Acme Cloud + + Acme Cloud +
@@ -466,7 +482,9 @@ const AppLayoutExample = (args: SideNavProps): ReactElement => {
- Workspace + + Workspace +

This area is the main landmark and the side navigation on the left is wrapped in a navigation landmark. Press F6 (or Shift+F6) to move keyboard focus between the diff --git a/packages/@react-spectrum/s2/test/SideNav.test.tsx b/packages/@react-spectrum/s2/test/SideNav.test.tsx index b260fd505df..040b00b7da6 100644 --- a/packages/@react-spectrum/s2/test/SideNav.test.tsx +++ b/packages/@react-spectrum/s2/test/SideNav.test.tsx @@ -104,30 +104,6 @@ function DeepSideNavExample(props: Partial>) { ); } -// An item whose row has both a link and a secondary action (ActionMenu). -function ActionMenuSideNavExample(props: Partial>) { - let {selectedRoute = '/files', ...rest} = props; - return ( - - - - - Your files - - - - Edit - - - Delete - - - - - - ); -} - // An item with no href and no link, but with a secondary action (ActionMenu). Focus should stay // on the row rather than jumping into the ActionMenu trigger. function NoLinkActionMenuSideNavExample(props: Partial>) { From 92120fc6fcb361f91f566b42d086cb96f18821bd Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 11:05:18 +1000 Subject: [PATCH 47/52] fix required props, cleanup --- .../s2/chromatic/SideNav.stories.tsx | 16 +- packages/@react-spectrum/s2/src/SideNav.tsx | 2 +- .../s2/stories/SideNav.stories.tsx | 3 - .../dev/s2-docs/pages/s2/RoutedSideNav.tsx | 15 +- packages/dev/s2-docs/pages/s2/SideNav.mdx | 259 +++++++++--------- 5 files changed, 149 insertions(+), 146 deletions(-) diff --git a/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx b/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx index eec82dff800..e5ee55f5c1b 100644 --- a/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx @@ -43,10 +43,10 @@ function SideNavExample(props: SideNavProps): ReactElement {

+ {...props} + selectedRoute="/projects-2"> @@ -114,10 +114,10 @@ function SideNavSectionsExample(props: SideNavProps): ReactElement {
+ {...props} + selectedRoute="/projects"> Photography @@ -220,10 +220,10 @@ function SideNavDynamicExample(props: SideNavProps): ReactEleme + {...props} + selectedRoute="/projects-2A"> {(item: SideNavItemType) => }
@@ -304,10 +304,10 @@ function SideNavDynamicExampleWithActions(props: SideNavProps): + {...props} + selectedRoute="/projects-2A"> {(item: SideNavItemType) => }
diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 3351bf8944c..a2aa06afb59 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -91,7 +91,7 @@ export interface SideNavProps UnsafeStyles, SideNavStyleProps { /** The route that is currently selected. */ - selectedRoute?: string; + selectedRoute: string; /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight; } diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx index d4e44722232..d6e1000e119 100644 --- a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -344,9 +344,6 @@ export const SideNavDynamicWithActions: SideNavDynamicWithActionsStoryObj = { name: 'WithActions' }; -// Landmark wrappers used only by the story. useLandmark is called here in the app shell rather than -// inside SideNav, so the consumer owns landmark registration. Each renders a semantic element and -// spreads landmarkProps (role + labels). Press F6 / Shift+F6 to cycle landmarks with the keyboard. function Banner(props: {children: ReactNode}): ReactElement { let ref = useRef(null); let {landmarkProps} = useLandmark({...props, role: 'banner', 'aria-label': 'Global'}, ref); diff --git a/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx b/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx index 97ddbc84619..fe718b91d74 100644 --- a/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx +++ b/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx @@ -2,7 +2,10 @@ import {RouterProvider} from 'react-aria-components'; import React, {ReactNode, useState} from 'react'; -export function RoutedSideNav(props: {children: ReactNode; defaultSelectedRoute?: string}) { +export function RoutedSideNav(props: { + children: ({selectedRoute}: {selectedRoute: string | undefined}) => ReactNode; + defaultSelectedRoute?: string; +}) { let {children} = props; let [selectedRoute, setSelectedRoute] = useState(props.defaultSelectedRoute); @@ -10,13 +13,5 @@ export function RoutedSideNav(props: {children: ReactNode; defaultSelectedRoute? setSelectedRoute(href); }; - return ( - - {/** Use cloneElement as an example only. */} - {React.cloneElement( - React.Children.toArray(children)[0] as React.ReactElement, - {selectedRoute: selectedRoute} as any - )} - - ); + return {children({selectedRoute})}; } diff --git a/packages/dev/s2-docs/pages/s2/SideNav.mdx b/packages/dev/s2-docs/pages/s2/SideNav.mdx index 0b05ce17541..b5699bd533b 100644 --- a/packages/dev/s2-docs/pages/s2/SideNav.mdx +++ b/packages/dev/s2-docs/pages/s2/SideNav.mdx @@ -18,30 +18,33 @@ import {RoutedSideNav} from './RoutedSideNav'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; - - - Guidelines - - Style - - - Color - - Background Layers + {({selectedRoute}) => ( + + + Guidelines + + Style + + + Color + + Background Layers + - - - Support - - Contact Us + + Support + + Contact Us + - - + + )} ``` @@ -89,25 +92,28 @@ let items: Item[] = [ ///- end collapse -/// - - {function renderItem(item) { - return ( - - {item.href ? {item.title} : item.title} - {/*- begin highlight -*/} - {/* recursively render children */} - {item.children && - {renderItem} - } - {/*- end highlight -*/} - - ); - }} - + {({selectedRoute}) => ( + + {function renderItem(item) { + return ( + + {item.href ? {item.title} : item.title} + {/*- begin highlight -*/} + {/* recursively render children */} + {item.children && + {renderItem} + } + {/*- end highlight -*/} + + ); + }} + + )} ``` @@ -149,44 +155,47 @@ let items: Item[] = [ ///- end collapse -/// - - {function renderItem(item) { - return ( - - - {/*- begin highlight -*/} - { - item.href ? - {item.type === 'directory' ? : } - {item.title} - : <> - {item.type === 'directory' ? : } - {item.title} - - } - {/*- end highlight -*/} - - - - Edit - - - - Delete - - - - {item.children && - {renderItem} - } - - ); - }} - + {({selectedRoute}) => ( + + {function renderItem(item) { + return ( + + + {/*- begin highlight -*/} + { + item.href ? + {item.type === 'directory' ? : } + {item.title} + : <> + {item.type === 'directory' ? : } + {item.title} + + } + {/*- end highlight -*/} + + + + Edit + + + + Delete + + + + {item.children && + {renderItem} + } + + ); + }} + + )} ``` @@ -208,67 +217,69 @@ import Animation from '@react-spectrum/s2/icons/Animation'; import AudioWave from '@react-spectrum/s2/icons/AudioWave'; - - - Workspaces - - - - - Files - - - - - - - - Your Libraries - - - + {({selectedRoute}) => ( + + + Workspaces + - - Photos + + Files - - - - - - Shared with You - - - + - - Animations + + Your Libraries + + + + + Photos + + + - - - - - - Deleted - - - + - - Audio + + Shared with You + + + + + Animations + + + - - - + + + + + Deleted + + + + + + + Audio + + + + + + + )} ``` From 43e6ffe62b363a1dcef52563100dac0be8b77bf8 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 11:33:10 +1000 Subject: [PATCH 48/52] add tests and fix lost focus case --- packages/@react-spectrum/s2/src/SideNav.tsx | 28 +++++- .../@react-spectrum/s2/test/SideNav.test.tsx | 96 +++++++++++++------ 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index a2aa06afb59..e63e4cf78d9 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -405,10 +405,12 @@ const indicator = style<{isDisabled: boolean; isSelected: boolean; isHovered: bo // Moves the tree's focused key to the item matching selectedRoute. Lives in items // (rather than up in SideNav) because it needs the built collection off `state`, which only exists // after the tree has rendered. Runs when the route or the collection changes; the shared -// syncedRouteRef dedupes across items so it fires once per route change +// syncedRouteRef dedupes across items so it fires once per route change. +// If the item is inside a collapsed parent, the focused key is moved to the closest +// visible ancestor instead of the hidden descendant. function useRouteFocusSync({state}: {state: TreeState}): void { let {selectedRoute, syncedRouteRef} = useContext(InternalSideNavContext); - let {collection, selectionManager} = state; + let {collection, selectionManager, expandedKeys} = state; useEffect(() => { if ( selectedRoute == null || @@ -419,10 +421,11 @@ function useRouteFocusSync({state}: {state: TreeState}): void { } let key = findKeyForRoute(collection, selectedRoute); if (key != null) { + key = closestVisibleKey(collection, expandedKeys, key); syncedRouteRef.current = selectedRoute; selectionManager.setFocusedKey(key); } - }, [selectedRoute, collection, syncedRouteRef, selectionManager]); + }, [selectedRoute, collection, expandedKeys, syncedRouteRef, selectionManager]); } export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => { @@ -704,6 +707,25 @@ function findKeyForRoute(collection: Collection>, route: string): return null; } +// Walks up from `key` to the closest ancestor that is actually rendered (i.e. all of its ancestors +// are expanded). Returns `key` unchanged when it is already visible. A collapsed ancestor hides +// everything beneath it, so the highest collapsed ancestor is the closest visible row. +function closestVisibleKey( + collection: Collection>, + expandedKeys: Set, + key: Key +): Key { + let target = key; + let node = collection.getItem(key); + while (node?.parentKey != null) { + if (!expandedKeys.has(node.parentKey)) { + target = node.parentKey; + } + node = collection.getItem(node.parentKey); + } + return target; +} + // Cache so each row doesn't have to walk up the tree every time let selectedAncestorsCache = new WeakMap< Collection>, diff --git a/packages/@react-spectrum/s2/test/SideNav.test.tsx b/packages/@react-spectrum/s2/test/SideNav.test.tsx index 040b00b7da6..1590127d675 100644 --- a/packages/@react-spectrum/s2/test/SideNav.test.tsx +++ b/packages/@react-spectrum/s2/test/SideNav.test.tsx @@ -104,6 +104,44 @@ function DeepSideNavExample(props: Partial>) { ); } +// A top-level leaf ("Your files") followed by a three-level branch: +// libraries > Projects 1 > Projects 1A +function ThreeLevelSideNavExample(props: Partial>) { + let {selectedRoute = '/files', ...rest} = props; + return ( + + + + + Your files + + + + + + + Your libraries + + + + + + Projects 1 + + + + + + Projects 1A + + + + + + + ); +} + // An item with no href and no link, but with a secondary action (ActionMenu). Focus should stay // on the row rather than jumping into the ActionMenu trigger. function NoLinkActionMenuSideNavExample(props: Partial>) { @@ -146,8 +184,6 @@ describe('SideNav', () => { beforeAll(function () { user = userEvent.setup({delay: null, pointerMap}); - // jsdom doesn't implement getAnimations, which the selection indicator relies on. - Element.prototype.getAnimations = jest.fn().mockImplementation(() => []); jest.useFakeTimers(); }); @@ -222,23 +258,44 @@ describe('SideNav', () => { expect(getByRole('link', {name: 'Your libraries'})).toHaveAttribute('aria-current', 'page'); }); - it('moves the focused key to the selectedRoute item, even nested farther down', () => { + it('moves the focused key to the selectedRoute item, even nested farther down', async () => { // Projects 2 is nested under (an expanded) Your libraries — farther down and visible. let {getByRole} = render( ); - act(() => { - jest.runAllTimers(); - }); - // Focusing the tree moves focus to its focused key. That key was set from selectedRoute, so - // focus lands on Projects 2 rather than the first item. - act(() => { - getByRole('treegrid').focus(); - }); + await user.tab(); expect(getByRole('link', {name: 'Projects 2'})).toHaveFocus(); }); + it('moves the focused key to the closest visible ancestor when selectedRoute is under a collapsed parent', async () => { + let {getByRole, queryByRole} = render(); + + expect(getByRole('row', {name: 'Your libraries'})).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 2'})).toBeNull(); + + await user.tab(); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + }); + + it('moves the focused key past an expanded ancestor that is itself hidden by a collapsed ancestor', async () => { + // libraries (collapsed) > Projects 1 (expanded) > Projects 1A (selectedRoute). Projects 1 is + // expanded, but it's still hidden because its own parent (libraries) is collapsed. Focus must + // skip the expanded-but-hidden Projects 1 and land on the closest rendered ancestor, libraries + // (not the first row, "Your files"). + let {getByRole, queryByRole} = render( + + ); + + // Both descendants are hidden behind the collapsed parent. + expect(getByRole('row', {name: 'Your libraries'})).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); + expect(queryByRole('link', {name: 'Projects 1A'})).toBeNull(); + + await user.tab(); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + }); + it('arrow left from a deep leaf steps to parent, collapses it, then moves to the grandparent', async () => { let {getByRole, queryByRole} = render( { selectedRoute="/projects-1A" /> ); - act(() => { - jest.runAllTimers(); - }); - - // Focus starts on the deepest leaf (synced from selectedRoute). - act(() => { - getByRole('treegrid').focus(); - }); + await user.tab(); expect(getByRole('link', {name: 'Projects 1A'})).toHaveFocus(); // 1st ArrowLeft: leaf has nothing to collapse, so focus moves up to its parent. @@ -274,18 +324,10 @@ describe('SideNav', () => { it('keeps focus on the row (not the ActionMenu) for an item with no href/link', async () => { let {getByRole} = render(); - act(() => { - jest.runAllTimers(); - }); - - // Tab lands on the first item's link, ArrowDown moves to the link-less "Section" row. await user.tab(); expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); await user.keyboard('{ArrowDown}'); - act(() => { - jest.runAllTimers(); - }); // Focus stays on the row itself; it does not jump into the ActionMenu trigger. let sectionRow = getByRole('row', {name: 'Section'}); From f06a4a6a2e7abd4e22c9e7c0c8142726de9e2546 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 11:39:22 +1000 Subject: [PATCH 49/52] restrict logic to tab keyboard nav --- 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 4292e3ec363..106ab557fcf 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -433,7 +433,7 @@ export function useGridListItem( id: getRowId(state, node.key) }); - if (focusMode === 'child' && allowsArrowNavigation) { + if (focusMode === 'child' && allowsArrowNavigation && keyboardNavigationBehavior === 'tab') { rowProps.tabIndex = -1; } From 58b458c062cc18c26421ec1b3cbca64abebb3c35 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 15:15:59 +1000 Subject: [PATCH 50/52] fix docs ts --- packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx b/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx index fe718b91d74..89bb0fc3e11 100644 --- a/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx +++ b/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx @@ -3,11 +3,11 @@ import {RouterProvider} from 'react-aria-components'; import React, {ReactNode, useState} from 'react'; export function RoutedSideNav(props: { - children: ({selectedRoute}: {selectedRoute: string | undefined}) => ReactNode; - defaultSelectedRoute?: string; + children: ({selectedRoute}: {selectedRoute: string}) => ReactNode; + defaultSelectedRoute: string; }) { let {children} = props; - let [selectedRoute, setSelectedRoute] = useState(props.defaultSelectedRoute); + let [selectedRoute, setSelectedRoute] = useState(props.defaultSelectedRoute); let updateSelection = (href: string) => { setSelectedRoute(href); From fd0d048505e6f961977f79da9b5e7b4ab0f19384 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 16 Jul 2026 08:41:56 +1000 Subject: [PATCH 51/52] review comments --- packages/@react-spectrum/s2/src/SideNav.tsx | 51 +++++++------------ .../s2/stories/SideNav.stories.tsx | 35 ++++++++----- 2 files changed, 39 insertions(+), 47 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index e63e4cf78d9..0031376d3cc 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -28,8 +28,6 @@ import { import { createContext, forwardRef, - JSXElementConstructor, - ReactElement, ReactNode, RefObject, useContext, @@ -88,16 +86,13 @@ export interface SideNavProps | 'dragAndDropHooks' // To be implemented | keyof GlobalDOMAttributes >, - UnsafeStyles, - SideNavStyleProps { + UnsafeStyles { /** The route that is currently selected. */ selectedRoute: string; /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight; } -interface SideNavStyleProps {} - export interface SideNavItemProps extends Omit< RACTreeItemProps, | 'className' @@ -116,11 +111,6 @@ export interface SideNavItemProps extends Omit< hasChildItems?: boolean; } -interface TreeRendererContextValue { - renderer?: (item) => ReactElement>; -} -const TreeRendererContext = createContext({}); - const sideNavWrapper = style( { minHeight: 0, @@ -180,11 +170,6 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid ) { let {children, UNSAFE_className, UNSAFE_style, selectedRoute, ...rest} = props; - let renderer; - if (typeof children === 'function') { - renderer = children; - } - let domRef = useDOMRef(ref); // Tracks the last route we moved the focused key to, so the focus sync (driven from @@ -196,21 +181,19 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid ref={domRef} className={(UNSAFE_className ?? '') + sideNavWrapper(null, props.styles)} style={UNSAFE_style}> - - - tree(renderProps)} - selectionMode="none" - keyboardNavigationBehavior="tab"> - {props.children} - - - + + tree(renderProps)} + selectionMode="none" + keyboardNavigationBehavior="tab"> + {children} + +
); }); @@ -448,7 +431,7 @@ export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => isFocusVisibleWithin }) => { return ( - isFocusVisible={isFocusVisible} isFocusVisibleWithin={isFocusVisibleWithin}> {children} - + ); }} ); }; -const SideNaveItemContentInner = props => { +const SideNavItemContentInner = props => { let { isExpanded, hasChildItems, diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx index d6e1000e119..0e38a3b2f4e 100644 --- a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -69,18 +69,16 @@ function RoutedSideNav(props: SideNavProps & {children: ReactNode}) { }; return ( -
- - - {children} - - -
+ + + {children} + + ); } @@ -133,10 +131,21 @@ const SideNavExampleStatic = args => ( ); export const Example: SideNavStoryObj = { - render: SideNavExampleStatic, + render: args => ( +
+ +
+ ), args: {} }; +export const ExampleCustomWidth: SideNavStoryObj = { + render: SideNavExampleStatic, + args: { + styles: style({width: 400}) + } +}; + const SideNavSectionsExample = args => ( From 05b4bcb73e84444e4ba733b264014364ccd7f040 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 16 Jul 2026 09:41:03 +1000 Subject: [PATCH 52/52] fix label text wrapping --- .../s2/chromatic/SideNav.stories.tsx | 74 +++++++++++++++++++ packages/@react-spectrum/s2/src/SideNav.tsx | 12 ++- .../s2/stories/SideNav.stories.tsx | 22 +++++- 3 files changed, 100 insertions(+), 8 deletions(-) diff --git a/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx b/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx index e5ee55f5c1b..02b72b5dbcf 100644 --- a/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx @@ -332,3 +332,77 @@ export const FocusedActionMenu: StoryObj): ReactElement { + return ( +
+ + + + + Your files but really long text so that it wraps + + + + + + + + Your libraries but really long text so that it wraps + + + + + + Projects-1 but really long text so that it wraps + + + + + + Projects-1A but really long text so that it wraps + + + + + + + + Projects-2 but really long text so that it wraps + + + + + + + Projects-3 but really long text so that it wraps + + + + + +
+ ); +} + +export const LongText: StoryObj = { + render: args => , + play: async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + await userEvent.tab(); + await new Promise(resolve => setTimeout(resolve, 100)); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 0031376d3cc..ad8ca063edb 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -12,7 +12,7 @@ import {ActionButtonGroupContext} from './ActionButtonGroup'; import {ActionMenuContext} from './ActionMenu'; -import {baseColor, focusRing, fontRelative, style} from '../style' with {type: 'macro'}; +import {baseColor, focusRing, fontRelative, space, style} from '../style' with {type: 'macro'}; import {Button, ButtonContext} from 'react-aria-components/Button'; import {centerBaseline} from './CenterBaseline'; import Chevron from '../ui-icons/Chevron'; @@ -202,7 +202,7 @@ const treeRow = style({ outlineStyle: 'none', position: 'relative', display: 'flex', - height: 32, + minHeight: 32, width: 'full', boxSizing: 'border-box', font: 'ui', @@ -216,7 +216,7 @@ const treeRow = style({ }, borderRadius: 'sm', marginTop: { - ':not([aria-posinset="1"])': '[6px]', + default: space(6), ':first-child': 0 } }); @@ -224,7 +224,7 @@ const treeRow = style({ const treeCellGrid = style({ display: 'grid', width: 'full', - height: 'full', + minHeight: 'full', boxSizing: 'border-box', alignContent: 'center', alignItems: 'center', @@ -259,9 +259,7 @@ const treeIcon = style({ const treeContent = style({ gridArea: 'content', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - overflow: 'hidden' + paddingY: '[7px]' // padding shouldn't scale }); let treeRowFocusRing = style({ diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx index 0e38a3b2f4e..e105b07136d 100644 --- a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -143,6 +143,23 @@ export const ExampleCustomWidth: SideNavStoryObj = { render: SideNavExampleStatic, args: { styles: style({width: 400}) + }, + parameters: { + docs: { + disable: true + } + } +}; + +export const ExampleAutoWidth: SideNavStoryObj = { + render: SideNavExampleStatic, + args: { + styles: style({width: 'auto'}) + }, + parameters: { + docs: { + disable: true + } } }; @@ -511,6 +528,9 @@ export const WithLandmark: AppLayoutStoryObj = { args: {}, name: 'With Landmark', parameters: { - layout: 'fullscreen' + layout: 'fullscreen', + docs: { + disable: true + } } };