From dabc8626f150cabb09bb2b1c300d62a3d4d418ae Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 24 Jun 2026 16:05:52 +1000 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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]);