From ec5b76dbb5bfaf9659cced5273f8f416b43386ca Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 24 Jun 2026 16:05:52 +1000 Subject: [PATCH 01/40] 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 1d2aed86b81..8d1738ab52e 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 0f81662bf9eb26593b18a09e6f56f0fca56764f1 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 25 Jun 2026 10:41:58 +1000 Subject: [PATCH 02/40] 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 8d1738ab52e..b6800edafc9 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 61049ce2d446960eca2853c39c9079009d3268f1 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 26 Jun 2026 08:52:12 +1000 Subject: [PATCH 03/40] 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 d2c9fbf3cdb7f8559c9a5ca5782316b1b3baabbc Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 6 Jul 2026 16:23:49 +1000 Subject: [PATCH 04/40] 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 b6800edafc9..8d1738ab52e 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 50db7453aa51fe1a502bb0d075dea953dbd8ae5d Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 7 Jul 2026 14:55:30 +1000 Subject: [PATCH 05/40] 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 8d1738ab52e..1d2aed86b81 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 75ffebea7fda0a19db6a93a655157b24bcc8de98 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 7 Jul 2026 15:13:39 +1000 Subject: [PATCH 06/40] 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 20727ee6b3ec0bcac0476a5570ea312abf437c8f Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 9 Jul 2026 16:09:09 +1000 Subject: [PATCH 07/40] 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 dd4abb4be1c3ca94d2f1bd8949df463d11534676 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 10 Jul 2026 15:10:59 +1000 Subject: [PATCH 08/40] 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 85a62e4fcb005e28c23ca856182da09b501ad45b Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 10 Jul 2026 15:21:33 +1000 Subject: [PATCH 09/40] 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 ff6c876842345b877312d7511faaffe55af6d931 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 13 Jul 2026 17:27:47 +1000 Subject: [PATCH 10/40] 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 b30c850f97f..e72133c7b1d 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -378,6 +378,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 956dfdc77da1e404c8648cfc5fc2a74a5d6cf147 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 13 Jul 2026 17:32:28 +1000 Subject: [PATCH 11/40] 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 ec7a08872bbbf07dec1551339d69700c24761e2b Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 13 Jul 2026 17:33:55 +1000 Subject: [PATCH 12/40] 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 16374ee57a56f902bcd5054ed9778ada1ea89b7d Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 13 Jul 2026 18:32:40 +1000 Subject: [PATCH 13/40] 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 401d15774faf8154e24049a3e597ad5825392056 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 14 Jul 2026 15:20:25 +1000 Subject: [PATCH 14/40] 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 15/40] 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 1e606a5abf2e24f46f2399fb03ef3fc6ecd4327e Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 08:29:53 +1000 Subject: [PATCH 16/40] 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 c8a927f620ca629f830dfd73661ce41405886c4a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 10:20:24 +1000 Subject: [PATCH 17/40] 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 e9c0c11c328aca302599afdafdeea789d62e560c Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 10:33:35 +1000 Subject: [PATCH 18/40] 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 f30af23fb4c0476e6808293bba49fdd5c6ef40ae Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 11:05:18 +1000 Subject: [PATCH 19/40] 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 64e9965803131ab9ed97a5e7caa4d0cec675d79a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 11:33:10 +1000 Subject: [PATCH 20/40] 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 c7c9b24760e68226bad75f6f5ecdb7ab4796b97f Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 11:39:22 +1000 Subject: [PATCH 21/40] 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 1073f01149474b080c506a27eff020146a3bb322 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 15 Jul 2026 15:15:59 +1000 Subject: [PATCH 22/40] 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 6d0f7cf11259ceb69eb22956ac21093ebed818cd Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 16 Jul 2026 08:41:56 +1000 Subject: [PATCH 23/40] 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 840dd76566a1b49a3ec77ec45cbe74c8e6a03871 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 16 Jul 2026 09:41:03 +1000 Subject: [PATCH 24/40] 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 + } } }; From 447c3353558ecc0be3052f923bde2e54a8f67a48 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 21 Jul 2026 11:55:50 +1000 Subject: [PATCH 25/40] Fix keyboard navigation with sections --- packages/@react-spectrum/s2/src/SideNav.tsx | 5 +- .../@react-spectrum/s2/test/SideNav.test.tsx | 68 ++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index ad8ca063edb..27d7b035989 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -699,10 +699,11 @@ function closestVisibleKey( let target = key; let node = collection.getItem(key); while (node?.parentKey != null) { - if (!expandedKeys.has(node.parentKey)) { + let parent = collection.getItem(node.parentKey); + if (parent?.type === 'item' && !expandedKeys.has(node.parentKey)) { target = node.parentKey; } - node = collection.getItem(node.parentKey); + node = parent; } return target; } diff --git a/packages/@react-spectrum/s2/test/SideNav.test.tsx b/packages/@react-spectrum/s2/test/SideNav.test.tsx index 1590127d675..1e95cb8199c 100644 --- a/packages/@react-spectrum/s2/test/SideNav.test.tsx +++ b/packages/@react-spectrum/s2/test/SideNav.test.tsx @@ -18,10 +18,12 @@ import React from 'react'; import {RouterProvider} from 'react-aria-components'; import { SideNav, + SideNavHeader, SideNavItem, SideNavItemContent, SideNavItemLink, - SideNavProps + SideNavProps, + SideNavSection } from '../src/SideNav'; import {Text} from '../src/Content'; import userEvent, {UserEvent} from '@testing-library/user-event'; @@ -296,6 +298,70 @@ describe('SideNav', () => { expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); }); + it('can tab onto the selected item when items are grouped in sections', async () => { + // A Section ancestor is always visible; it must not be treated as a collapsed parent (which + // would move the tab stop onto the section, leaving no focusable link and breaking tabbing in). + function RoutedSideNavExample(props: Partial>) { + let [selectedRoute, setSelectedRoute] = React.useState('/projects'); + return ( + + + + Photography + + + + Your files + + + + + + Work + + + + Your libraries + + + + + + Projects 2 + + + + + + + + ); + } + let {getByRole} = render(); + act(() => { + jest.runAllTimers(); + }); + + await user.tab(); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowDown}'); + expect(getByRole('link', {name: 'Projects 2'})).toHaveFocus(); + await user.keyboard('{Enter}'); + expect(getByRole('link', {name: 'Projects 2'})).toHaveAttribute('aria-current', 'page'); + + await user.keyboard('{ArrowUp}'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + await user.keyboard('{ArrowUp}'); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + await user.keyboard('{Enter}'); + expect(getByRole('link', {name: 'Your files'})).toHaveAttribute('aria-current', 'page'); + + await user.keyboard('{ArrowDown}'); + 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( Date: Tue, 21 Jul 2026 11:57:04 +1000 Subject: [PATCH 26/40] fix current selection bold --- packages/@react-spectrum/s2/src/SideNav.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 27d7b035989..9ce5d53bb16 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -243,6 +243,7 @@ const treeCellGrid = style({ forcedColors: 'ButtonText' }, fontWeight: { + isSelected: 'bold', isDescendantSelected: 'bold' }, forcedColorAdjust: 'none' From 85e88ff9fd6a023fdfdac4222a55ed114e285b63 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 21 Jul 2026 12:04:28 +1000 Subject: [PATCH 27/40] fix heights --- packages/@react-spectrum/s2/stories/SideNav.stories.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx index e105b07136d..f266bb2f972 100644 --- a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -142,7 +142,7 @@ export const Example: SideNavStoryObj = { export const ExampleCustomWidth: SideNavStoryObj = { render: SideNavExampleStatic, args: { - styles: style({width: 400}) + styles: style({width: 400, height: 240}) }, parameters: { docs: { @@ -154,7 +154,7 @@ export const ExampleCustomWidth: SideNavStoryObj = { export const ExampleAutoWidth: SideNavStoryObj = { render: SideNavExampleStatic, args: { - styles: style({width: 'auto'}) + styles: style({width: 'auto', height: 240}) }, parameters: { docs: { @@ -164,7 +164,7 @@ export const ExampleAutoWidth: SideNavStoryObj = { }; const SideNavSectionsExample = args => ( - + Photography From 57e9445ca7c86bd13bdfa52035d0c965e97023bf Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 21 Jul 2026 13:44:32 +1000 Subject: [PATCH 28/40] add press scaling to items --- packages/@react-spectrum/s2/src/SideNav.tsx | 45 ++++++++++++++----- .../react-aria-components/test/Link.test.js | 9 ++++ packages/react-aria/src/link/useLink.ts | 2 + 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 9ce5d53bb16..4f9996e23df 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -43,6 +43,8 @@ import { import {GridListHeaderProps, GridListSectionProps} from 'react-aria-components/GridList'; import {IconContext} from './Icon'; import {Link} from 'react-aria-components/Link'; +import {mergeProps} from 'react-aria/mergeProps'; +import {pressScale} from './pressScale'; import {Provider, useContextProps} from 'react-aria-components/slots'; import { TreeItemProps as RACTreeItemProps, @@ -319,6 +321,8 @@ const SideNavItemLinkContext = createContext<{ // Lets the row track whether the link (as opposed to another focusable child like an ActionMenu // trigger) is the focused element, so the row focus ring can follow the link specifically. onFocusChange?: (isFocused: boolean) => void; + // So we can scale the row when the link is pressed. + onPressChange?: (isPressed: boolean) => void; }>({}); export const SideNavItem = (props: SideNavItemProps): ReactNode => { @@ -426,6 +430,7 @@ export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => id, state, isHovered, + isPressed, isFocusVisible, isFocusVisibleWithin }) => { @@ -441,6 +446,7 @@ export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => state={state} selectedRoute={selectedRoute} isHovered={isHovered} + isPressed={isPressed} isFocusVisible={isFocusVisible} isFocusVisibleWithin={isFocusVisibleWithin}> {children} @@ -463,35 +469,44 @@ const SideNavItemContentInner = props => { state, selectedRoute, isHovered, + isPressed, isFocusVisible, isFocusVisibleWithin, children } = props; + + useRouteFocusSync({state}); + // 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); - - useRouteFocusSync({state}); + let [isLinkPressed, setLinkPressed] = useState(false); + let cellRef = useRef(null); let hasLink = linkProps.href != null && linkProps.href.length > 0; + // oxlint-disable-next-line react-compiler + let itemScaling = pressScale(cellRef)({isPressed: isLinkPressed || isPressed}); + return ( <>
-
+ })} + style={itemScaling}> +
{ [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, isDisabled, onFocusChange: setLinkFocused}], + [ + SideNavItemLinkContext, + { + ...linkProps, + isDisabled, + onFocusChange: setLinkFocused, + onPressChange: setLinkPressed + } + ], [ IconContext, { diff --git a/packages/react-aria-components/test/Link.test.js b/packages/react-aria-components/test/Link.test.js index 93b483ad9ef..aca49b9be42 100644 --- a/packages/react-aria-components/test/Link.test.js +++ b/packages/react-aria-components/test/Link.test.js @@ -142,12 +142,18 @@ describe('Link', () => { it('should support press state', async () => { let onPress = jest.fn(); + let onPressStart = jest.fn(); + let onPressEnd = jest.fn(); + let onPressChange = jest.fn(); let onClick = jest.fn(); let onClickCapture = jest.fn(); let {getByRole} = render( (isPressed ? 'pressed' : '')} onPress={onPress} + onPressStart={onPressStart} + onPressEnd={onPressEnd} + onPressChange={onPressChange} onClick={onClick} onClickCapture={onClickCapture}> Test @@ -166,6 +172,9 @@ describe('Link', () => { expect(link).not.toHaveAttribute('data-pressed'); expect(link).not.toHaveClass('pressed'); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledTimes(2); expect(onPress).toHaveBeenCalledTimes(1); expect(onClick).toHaveBeenCalledTimes(1); expect(onClickCapture).toHaveBeenCalledTimes(1); diff --git a/packages/react-aria/src/link/useLink.ts b/packages/react-aria/src/link/useLink.ts index ec49c5e578f..fc2ad403476 100644 --- a/packages/react-aria/src/link/useLink.ts +++ b/packages/react-aria/src/link/useLink.ts @@ -59,6 +59,7 @@ export function useLink(props: AriaLinkOptions, ref: RefObject Date: Tue, 21 Jul 2026 14:58:26 +1000 Subject: [PATCH 29/40] fix lint --- packages/@react-spectrum/s2/src/SideNav.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 4f9996e23df..4ff1364f00a 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -43,7 +43,6 @@ import { import {GridListHeaderProps, GridListSectionProps} from 'react-aria-components/GridList'; import {IconContext} from './Icon'; import {Link} from 'react-aria-components/Link'; -import {mergeProps} from 'react-aria/mergeProps'; import {pressScale} from './pressScale'; import {Provider, useContextProps} from 'react-aria-components/slots'; import { From 61323a1af0674fa94f02fc644e4219598e555b83 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 21 Jul 2026 17:00:02 +1000 Subject: [PATCH 30/40] setup alpha and card --- packages/dev/s2-docs/pages/s2/SideNav.mdx | 3 ++- packages/dev/s2-docs/src/ComponentCard.tsx | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/dev/s2-docs/pages/s2/SideNav.mdx b/packages/dev/s2-docs/pages/s2/SideNav.mdx index b5699bd533b..36833d5bc4a 100644 --- a/packages/dev/s2-docs/pages/s2/SideNav.mdx +++ b/packages/dev/s2-docs/pages/s2/SideNav.mdx @@ -5,6 +5,7 @@ import docs from 'docs:@react-spectrum/s2'; export const tags = ['hierarchy', 'navigation', 'nested']; export const relatedPages = []; +export const version = 'alpha'; export const description = 'Displays hierarchical navigation with collapsing.'; # SideNav @@ -50,7 +51,7 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; ## 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 +SideNav does not 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 diff --git a/packages/dev/s2-docs/src/ComponentCard.tsx b/packages/dev/s2-docs/src/ComponentCard.tsx index b6818f603d6..22493efec04 100644 --- a/packages/dev/s2-docs/src/ComponentCard.tsx +++ b/packages/dev/s2-docs/src/ComponentCard.tsx @@ -156,6 +156,8 @@ import SelectBoxGroupDark from 'url:../assets/component-illustrations/dark/Selec import SelectBoxGroupLight from 'url:../assets/component-illustrations/light/SelectBoxGroup.avif'; import SelectionDark from 'url:../assets/component-illustrations/dark/Selection.avif'; import SelectionLight from 'url:../assets/component-illustrations/light/Selection.avif'; +import SideNavDark from 'url:../assets/component-illustrations/dark/Tree.avif'; +import SideNavLight from 'url:../assets/component-illustrations/light/Tree.avif'; import SkeletonDark from 'url:../assets/component-illustrations/dark/Skeleton.avif'; import SkeletonLight from 'url:../assets/component-illustrations/light/Skeleton.avif'; import SliderDark from 'url:../assets/component-illustrations/dark/Slider.avif'; @@ -276,6 +278,7 @@ const componentIllustrations: Record = { Select: [PickerLight, PickerDark], SelectBoxGroup: [SelectBoxGroupLight, SelectBoxGroupDark], Separator: [DividerLight, DividerDark], + SideNav: [SideNavLight, SideNavDark], Skeleton: [SkeletonLight, SkeletonDark], Slider: [SliderLight, SliderDark], StatusLight: [StatusLightLight, StatusLightDark], From 18162b9a8c2feccc12c1156b2cfaa6588b1f683e Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 22 Jul 2026 10:34:43 +1000 Subject: [PATCH 31/40] fix types --- packages/@react-spectrum/s2/src/SideNav.tsx | 26 +++++++------------ .../react-aria-components/exports/Tree.ts | 3 ++- packages/react-aria-components/src/Tree.tsx | 13 ++++------ 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 4ff1364f00a..51310d2e5a6 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -40,7 +40,6 @@ import { StylesPropWithHeight, UnsafeStyles } from './style-utils' with {type: 'macro'}; -import {GridListHeaderProps, GridListSectionProps} from 'react-aria-components/GridList'; import {IconContext} from './Icon'; import {Link} from 'react-aria-components/Link'; import {pressScale} from './pressScale'; @@ -50,12 +49,14 @@ import { TreeProps as RACTreeProps, Tree, TreeHeader, + TreeHeaderProps, TreeItem, TreeItemContent, TreeItemContentProps, TreeItemRenderProps, TreeRenderProps, - TreeSection + TreeSection, + TreeSectionProps } from 'react-aria-components/Tree'; import {Text, TextContext} from './Content'; import {TreeState} from 'react-stately/useTreeState'; @@ -70,6 +71,7 @@ export interface SideNavProps | 'style' | 'className' | 'render' + | 'onAction' | 'onRowAction' | 'selectionBehavior' | 'onScroll' @@ -121,15 +123,7 @@ const sideNavWrapper = style( 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 - } - } + overflow: 'clip' }, getAllowedOverrides({height: true}) ); @@ -148,6 +142,8 @@ const tree = style({ overflowY: 'auto', overflowX: 'hidden', boxSizing: 'border-box', + paddingBottom: 0, + scrollPaddingBottom: 0, '--indent': { type: 'width', value: 16 @@ -185,10 +181,6 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid tree(renderProps)} selectionMode="none" keyboardNavigationBehavior="tab"> @@ -633,7 +625,7 @@ function ExpandableRowChevron(props: ExpandableRowChevronProps) { } export interface SideNavSectionProps extends Omit< - GridListSectionProps, + TreeSectionProps, 'value' | 'render' | 'style' | 'className' > {} @@ -646,7 +638,7 @@ export function SideNavSection(props: SideNavSectionProps) } export interface SideNavHeaderProps extends Omit< - GridListHeaderProps, + TreeHeaderProps, 'value' | 'render' | 'style' | 'className' > {} diff --git a/packages/react-aria-components/exports/Tree.ts b/packages/react-aria-components/exports/Tree.ts index d647e67c5c8..532aa53e9b6 100644 --- a/packages/react-aria-components/exports/Tree.ts +++ b/packages/react-aria-components/exports/Tree.ts @@ -25,7 +25,8 @@ export { TreeStateContext } from '../src/Tree'; export type { - GridListSectionProps, + TreeSectionProps, + TreeHeaderProps, TreeProps, TreeRenderProps, TreeEmptyStateRenderProps, diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 1d2aed86b81..0822fd90294 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -1260,19 +1260,14 @@ function RootDropIndicator() { ); } -export interface GridListSectionProps - extends SectionProps, DOMRenderProps<'div', undefined> {} +export interface TreeSectionProps extends SectionProps, DOMRenderProps<'div', undefined> {} /** * A TreeSection represents a section within a Tree. */ export const TreeSection = /*#__PURE__*/ createBranchComponent( SectionNode, - ( - props: GridListSectionProps, - ref: ForwardedRef, - item: Node - ) => { + (props: TreeSectionProps, ref: ForwardedRef, item: Node) => { let state = useContext(TreeStateContext)!; let {CollectionBranch} = useContext(CollectionRendererContext); let headingRef = useRef(null); @@ -1309,7 +1304,9 @@ export const TreeSection = /*#__PURE__*/ createBranchComponent( } ); -export const TreeHeader = (props: GridListHeaderProps): ReactNode => { +export interface TreeHeaderProps extends GridListHeaderProps {} + +export const TreeHeader = (props: TreeHeaderProps): ReactNode => { return ( {props.children} From 33b7d0beb51c2bc517d51f5e9352febc821fdf77 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 23 Jul 2026 11:49:16 +1000 Subject: [PATCH 32/40] review comments --- packages/@react-spectrum/s2/src/SideNav.tsx | 33 +++++- packages/dev/s2-docs/pages/s2/SideNav.mdx | 114 ++++++++++++++------ 2 files changed, 113 insertions(+), 34 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 51310d2e5a6..7f8d927457b 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, space, style} from '../style' with {type: 'macro'}; +import {baseColor, focusRing, fontRelative, size, 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'; @@ -36,6 +36,7 @@ import { useState } from 'react'; import { + centerPadding, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles @@ -211,6 +212,10 @@ const treeRow = style({ marginTop: { default: space(6), ':first-child': 0 + }, + '--centerPadding': { + type: 'paddingTop', + value: centerPadding() } }); @@ -239,6 +244,7 @@ const treeCellGrid = style({ isSelected: 'bold', isDescendantSelected: 'bold' }, + transition: 'default', forcedColorAdjust: 'none' }); @@ -253,7 +259,7 @@ const treeIcon = style({ const treeContent = style({ gridArea: 'content', - paddingY: '[7px]' // padding shouldn't scale + paddingY: `--centerPadding` }); let treeRowFocusRing = style({ @@ -687,6 +693,29 @@ export const SideNavItemLink = (props: SideNavItemLinkProps): ReactNode => { ] ]}> {typeof children === 'string' ? {children} : children} +
> + + {typeof children === 'string' ? {children} : children} + +
); diff --git a/packages/dev/s2-docs/pages/s2/SideNav.mdx b/packages/dev/s2-docs/pages/s2/SideNav.mdx index 36833d5bc4a..5b56a30063d 100644 --- a/packages/dev/s2-docs/pages/s2/SideNav.mdx +++ b/packages/dev/s2-docs/pages/s2/SideNav.mdx @@ -24,7 +24,7 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; aria-label="Files" /* PROPS */ selectedRoute={selectedRoute} - styles={style({height: 240})} + styles={style({height: 240, width: 210})} defaultExpandedKeys={['guidelines', 'color']}> Guidelines @@ -49,16 +49,6 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; ``` -## Selection - -SideNav does not 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. @@ -96,7 +86,7 @@ let items: Item[] = [ {({selectedRoute}) => ( @@ -159,7 +149,7 @@ let items: Item[] = [ {({selectedRoute}) => ( @@ -200,9 +190,15 @@ let items: Item[] = [ ``` -## Sections +## Routing -A SideNav can contain sections to group items together. They are non-collapsible and non-interactive. +SideNav uses the `href` prop as a key for selection. +`id` is 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`. ```tsx render type="s2" files={['packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx']} "use client"; @@ -219,7 +215,77 @@ import AudioWave from '@react-spectrum/s2/icons/AudioWave'; {({selectedRoute}) => ( - + + + + + + Files + + + + + + + + Your Libraries + + + + + + + Photos + + + + + + )} + +``` + +## 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, Text} from '@react-spectrum/s2/SideNav'; +import {RoutedSideNav} from './RoutedSideNav'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +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 Download from '@react-spectrum/s2/icons/Download'; +import Apps from '@react-spectrum/s2/icons/Apps'; + + + {({selectedRoute}) => ( + + {/*- begin highlight -*/} + + Favorites + {/*- end highlight -*/} + + + + + Applications + + + + + + + + Downloads + + + + Workspaces @@ -262,22 +328,6 @@ import AudioWave from '@react-spectrum/s2/icons/AudioWave'; - - - - - Deleted - - - - - - - Audio - - - - )} From 94e2b7fdfa9f14ed1ebf2842c673e35209663262 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 23 Jul 2026 13:16:18 +1000 Subject: [PATCH 33/40] fix typo --- packages/@react-spectrum/s2/src/SideNav.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 7f8d927457b..c2fc0c5dade 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -12,7 +12,14 @@ import {ActionButtonGroupContext} from './ActionButtonGroup'; import {ActionMenuContext} from './ActionMenu'; -import {baseColor, focusRing, fontRelative, size, space, style} from '../style' with {type: 'macro'}; +import { + baseColor, + focusRing, + fontRelative, + size, + 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'; @@ -701,7 +708,7 @@ export const SideNavItemLink = (props: SideNavItemLinkProps): ReactNode => { fontWeight: 'bold', overflow: 'hidden', gridArea: 'content' - })}>> + })}> { } ] ]}> - {typeof children === 'string' ? {children} : children} + {typeof children === 'string' ? {children} : children}
From 91782ecf20129d133bf58cd9ff3c5fa31815220d Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 23 Jul 2026 13:20:12 +1000 Subject: [PATCH 34/40] and lint --- packages/@react-spectrum/s2/src/SideNav.tsx | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index c2fc0c5dade..2bb8f3b3c8c 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -12,16 +12,15 @@ import {ActionButtonGroupContext} from './ActionButtonGroup'; import {ActionMenuContext} from './ActionMenu'; -import { - baseColor, - focusRing, - fontRelative, - size, - space, - 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 { + centerPadding, + getAllowedOverrides, + StylesPropWithHeight, + UnsafeStyles +} from './style-utils' with {type: 'macro'}; import Chevron from '../ui-icons/Chevron'; import { Collection, @@ -42,12 +41,6 @@ import { useRef, useState } from 'react'; -import { - centerPadding, - getAllowedOverrides, - StylesPropWithHeight, - UnsafeStyles -} from './style-utils' with {type: 'macro'}; import {IconContext} from './Icon'; import {Link} from 'react-aria-components/Link'; import {pressScale} from './pressScale'; From 1a02d040e92dd89fa736e2b0f3a65b19db16e3ad Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 23 Jul 2026 14:23:52 +1000 Subject: [PATCH 35/40] fix docs ts --- packages/dev/s2-docs/pages/s2/SideNav.mdx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/SideNav.mdx b/packages/dev/s2-docs/pages/s2/SideNav.mdx index 5b56a30063d..04252dc6430 100644 --- a/packages/dev/s2-docs/pages/s2/SideNav.mdx +++ b/packages/dev/s2-docs/pages/s2/SideNav.mdx @@ -202,7 +202,7 @@ If a SideNavItem has an `href`, then you must pass a `SideNavItemLink` as a chil ```tsx render type="s2" files={['packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx']} "use client"; -import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, SideNavSection, SideNavHeader, Text} from '@react-spectrum/s2/SideNav'; +import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, 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'; @@ -210,8 +210,6 @@ 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'; {({selectedRoute}) => ( From b7b5702227f21b54f59e6c21e17ac0314dc2ece9 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 23 Jul 2026 14:25:08 +1000 Subject: [PATCH 36/40] forgot to save --- packages/dev/s2-docs/pages/s2/SideNav.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/dev/s2-docs/pages/s2/SideNav.mdx b/packages/dev/s2-docs/pages/s2/SideNav.mdx index 04252dc6430..40056888a4c 100644 --- a/packages/dev/s2-docs/pages/s2/SideNav.mdx +++ b/packages/dev/s2-docs/pages/s2/SideNav.mdx @@ -206,7 +206,6 @@ import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, Text} from '@ 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'; From 998239d9cb2f1bae40e7ace94278521ff5f4f699 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 23 Jul 2026 16:05:35 +1000 Subject: [PATCH 37/40] fingers crossed last one --- packages/dev/s2-docs/pages/s2/SideNav.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/dev/s2-docs/pages/s2/SideNav.mdx b/packages/dev/s2-docs/pages/s2/SideNav.mdx index 40056888a4c..35561179796 100644 --- a/packages/dev/s2-docs/pages/s2/SideNav.mdx +++ b/packages/dev/s2-docs/pages/s2/SideNav.mdx @@ -205,7 +205,6 @@ If a SideNavItem has an `href`, then you must pass a `SideNavItemLink` as a chil import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, 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 CCLibrary from '@react-spectrum/s2/icons/CCLibrary'; import Files from '@react-spectrum/s2/icons/Files'; import Images from '@react-spectrum/s2/icons/Images'; From 298b95c688609a413cf50d9eb1a31169e97afbe8 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 24 Jul 2026 10:20:48 +1000 Subject: [PATCH 38/40] make a router that looks like React Router, inert hidden text --- packages/@react-spectrum/s2/src/SideNav.tsx | 2 +- packages/dev/s2-docs/pages/s2/SideNav.mdx | 74 ++++++++++++--------- packages/dev/s2-docs/pages/s2/router.tsx | 37 +++++++++++ 3 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 packages/dev/s2-docs/pages/s2/router.tsx diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 2bb8f3b3c8c..8943a4879c6 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -694,6 +694,7 @@ export const SideNavItemLink = (props: SideNavItemLinkProps): ReactNode => { ]}> {typeof children === 'string' ? {children} : children}
{ [ IconContext, { - render: centerBaseline({slot: 'icon', styles: treeIcon}), styles: style({display: 'none'}) } ] diff --git a/packages/dev/s2-docs/pages/s2/SideNav.mdx b/packages/dev/s2-docs/pages/s2/SideNav.mdx index 35561179796..1923129fab3 100644 --- a/packages/dev/s2-docs/pages/s2/SideNav.mdx +++ b/packages/dev/s2-docs/pages/s2/SideNav.mdx @@ -192,53 +192,65 @@ let items: Item[] = [ ## Routing -SideNav uses the `href` prop as a key for selection. -`id` is 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. +to a router or other state management solution. The example below derives `selectedRoute` from the router's current location, +and provides React Aria's `RouterProvider` with the router's `navigate` function so that clicking an item performs a client side navigation. If a SideNavItem has an `href`, then you must pass a `SideNavItemLink` as a child of the `SideNavItemContent`. -```tsx render type="s2" files={['packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx']} +See Routing setup in [Getting Started](./getting-started) for more info. + +```tsx render type="s2" files={['packages/dev/s2-docs/pages/s2/router.tsx']} "use client"; import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, Text} from '@react-spectrum/s2/SideNav'; -import {RoutedSideNav} from './RoutedSideNav'; +import {RouterProvider} from 'react-aria-components'; +import {MemoryRouter, useLocation, useNavigate} from './router'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import CCLibrary from '@react-spectrum/s2/icons/CCLibrary'; import Files from '@react-spectrum/s2/icons/Files'; import Images from '@react-spectrum/s2/icons/Images'; - - {({selectedRoute}) => ( - - - - - - Files - - - - - - - - Your Libraries - - - +function FilesSideNav() { + /*- begin highlight -*/ + let navigate = useNavigate(); + let {pathname} = useLocation(); + + return ( + + + {/*- end highlight -*/} + - - Photos + + Files - - - )} - + + + + + Your Libraries + + + + + + + Photos + + + + + + + ); +} + + + + ``` ## Sections diff --git a/packages/dev/s2-docs/pages/s2/router.tsx b/packages/dev/s2-docs/pages/s2/router.tsx new file mode 100644 index 00000000000..4e37c970ee8 --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/router.tsx @@ -0,0 +1,37 @@ +'use client'; +import React, {createContext, ReactNode, useContext, useState} from 'react'; + +// A tiny in-memory router that mirrors the parts of the `react-router` API used +// to integrate with React Aria. In a real app, `MemoryRouter`, `useNavigate`, and +// `useLocation` would come from the `react-router` package — the wiring above is +// identical. + +interface Location { + pathname: string; +} + +interface RouterContextValue { + location: Location; + navigate: (pathname: string) => void; +} + +const RouterContext = createContext({ + location: {pathname: '/'}, + navigate: () => {} +}); + +export function MemoryRouter(props: {initialEntries?: string[]; children: ReactNode}) { + let {initialEntries = ['/'], children} = props; + let [location, setLocation] = useState({pathname: initialEntries[0]}); + let navigate = (pathname: string) => setLocation({pathname}); + + return {children}; +} + +export function useLocation(): Location { + return useContext(RouterContext).location; +} + +export function useNavigate(): (pathname: string) => void { + return useContext(RouterContext).navigate; +} From 6cea9efd40749afc843f40161ad80f3cc9738371 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 24 Jul 2026 10:27:42 +1000 Subject: [PATCH 39/40] fix types and inert --- packages/@react-spectrum/s2/src/SideNav.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 8943a4879c6..abd3c7d0f93 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -64,6 +64,7 @@ import {TreeState} from 'react-stately/useTreeState'; import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; import {useScale} from './utils'; +import {inertValue} from 'react-aria/private/utils/inertValue'; export interface SideNavProps extends @@ -694,7 +695,8 @@ export const SideNavItemLink = (props: SideNavItemLinkProps): ReactNode => { ]}> {typeof children === 'string' ? {children} : children}
Date: Fri, 24 Jul 2026 10:33:21 +1000 Subject: [PATCH 40/40] remove space reserving, it doesn't work --- packages/@react-spectrum/s2/src/SideNav.tsx | 25 --------------------- 1 file changed, 25 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index abd3c7d0f93..499af41e28d 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -64,7 +64,6 @@ import {TreeState} from 'react-stately/useTreeState'; import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; import {useScale} from './utils'; -import {inertValue} from 'react-aria/private/utils/inertValue'; export interface SideNavProps extends @@ -694,30 +693,6 @@ export const SideNavItemLink = (props: SideNavItemLinkProps): ReactNode => { ] ]}> {typeof children === 'string' ? {children} : children} -
- - {typeof children === 'string' ? {children} : children} - -
);