From 4c389a54668782ac2ea460a811ff36f56b61905a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 31 Jul 2026 15:19:36 +1000 Subject: [PATCH 1/6] feat: RAC SideNav --- packages/@react-spectrum/s2/src/SideNav.tsx | 350 ++--------- .../pages/react-aria/RoutedSideNav.tsx | 17 + .../dev/s2-docs/pages/react-aria/SideNav.mdx | 204 +++++++ .../dev/s2-docs/pages/react-aria/router.tsx | 37 ++ .../react-aria-components/exports/SideNav.ts | 41 ++ .../react-aria-components/exports/index.ts | 18 + .../react-aria-components/src/SideNav.tsx | 458 +++++++++++++++ .../stories/SideNav.stories.tsx | 111 ++++ .../test/SideNav.test.tsx | 544 ++++++++++++++++++ starters/docs/src/SideNav.css | 115 ++++ starters/docs/src/SideNav.tsx | 70 +++ starters/tailwind/src/SideNav.tsx | 122 ++++ 12 files changed, 1795 insertions(+), 292 deletions(-) create mode 100644 packages/dev/s2-docs/pages/react-aria/RoutedSideNav.tsx create mode 100644 packages/dev/s2-docs/pages/react-aria/SideNav.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/router.tsx create mode 100644 packages/react-aria-components/exports/SideNav.ts create mode 100644 packages/react-aria-components/src/SideNav.tsx create mode 100644 packages/react-aria-components/stories/SideNav.stories.tsx create mode 100644 packages/react-aria-components/test/SideNav.test.tsx create mode 100644 starters/docs/src/SideNav.css create mode 100644 starters/docs/src/SideNav.tsx create mode 100644 starters/tailwind/src/SideNav.tsx diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 3c489729283..89baf10be9a 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -22,45 +22,25 @@ import { UnsafeStyles } from './style-utils' with {type: 'macro'}; import Chevron from '../ui-icons/Chevron'; -import { - Collection, - DOMRef, - forwardRefType, - GlobalDOMAttributes, - Key, - Node, - RouterOptions -} from '@react-types/shared'; -import { - createContext, - forwardRef, - ReactNode, - RefObject, - useContext, - useEffect, - useRef, - useState -} from 'react'; +import {createContext, forwardRef, ReactNode, useContext, useRef, useState} from 'react'; +import {DOMRef, forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; import {IconContext} from './Icon'; import {Link} from 'react-aria-components/Link'; import {pressScale} from './pressScale'; import {Provider, useContextProps} from 'react-aria-components/slots'; import { - TreeItemProps as RACTreeItemProps, - TreeProps as RACTreeProps, - Tree, - TreeHeader, - TreeHeaderProps, - TreeItem, - TreeItemContent, - TreeItemContentProps, - TreeItemRenderProps, - TreeRenderProps, - TreeSection, - TreeSectionProps -} from 'react-aria-components/Tree'; + SideNav as RACSideNav, + SideNavHeader as RACSideNavHeader, + SideNavHeaderProps as RACSideNavHeaderProps, + SideNavItem as RACSideNavItem, + SideNavItemContent as RACSideNavItemContent, + SideNavItemProps as RACSideNavItemProps, + SideNavProps as RACSideNavProps, + SideNavSection as RACSideNavSection, + SideNavSectionProps as RACSideNavSectionProps, + SideNavItemContentRenderProps +} from 'react-aria-components/SideNav'; import {Text, TextContext} from './Content'; -import {TreeState} from 'react-stately/useTreeState'; import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; import {useScale} from './utils'; @@ -68,27 +48,8 @@ import {useScale} from './utils'; export interface SideNavProps extends Omit< - RACTreeProps, - | 'style' - | 'className' - | 'render' - | 'onAction' - | 'onRowAction' - | 'selectionBehavior' - | 'onScroll' - | 'onCellAction' - | 'onSelectionChange' - | 'selectedKeys' - | 'defaultSelectedKeys' - | 'disabledBehavior' - | 'selectionMode' - | 'escapeKeyBehavior' - | 'shouldSelectOnPressUp' - | 'disallowEmptySelection' - | 'renderEmptyState' - | 'keyboardNavigationBehavior' - | 'dragAndDropHooks' // To be implemented - | keyof GlobalDOMAttributes + RACSideNavProps, + 'style' | 'className' | 'render' | 'selectedRoute' | keyof GlobalDOMAttributes >, UnsafeStyles { /** The route that is currently selected. */ @@ -98,7 +59,7 @@ export interface SideNavProps } export interface SideNavItemProps extends Omit< - RACTreeItemProps, + RACSideNavItemProps, | 'className' | 'style' | 'render' @@ -132,7 +93,7 @@ const sideNavWrapper = style( // 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({ +const tree = style({ ...focusRing(), outlineOffset: -2, // make certain we are visible inside overflow hidden containers userSelect: 'none', @@ -151,14 +112,6 @@ const tree = style({ } }); -interface InternalSideNavContextValue { - /** The route that is currently selected. */ - selectedRoute?: string | null; - /** The last route the focused key was synced to; dedupes the focus sync across items. */ - syncedRouteRef?: RefObject; -} -let InternalSideNavContext = createContext({}); - /** * A SideNav provides users with a way to navigate nested hierarchical set of links. */ @@ -170,29 +123,22 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid let domRef = useDOMRef(ref); - // 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); - return (
- - tree(renderProps)} - selectionMode="none" - keyboardNavigationBehavior="tab"> - {children} - - + tree(renderProps)}> + {children} +
); }); -const treeRow = style({ +const treeRow = style({ outlineStyle: 'none', position: 'relative', display: 'flex', @@ -308,18 +254,6 @@ const treeActionMenu = style({ const SideNavItemLinkContext = createContext<{ isDisabled?: boolean; - href?: string; - hrefLang?: string; - target?: string; - rel?: string; - download?: string | boolean; - ping?: string; - referrerPolicy?: ReferrerPolicy; - routerOptions?: RouterOptions; - // 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; }>({}); @@ -328,42 +262,24 @@ const SideNavInternalItemContext = createContext<{setLinkPressed?: (isPressed: b ); export const SideNavItem = (props: SideNavItemProps): ReactNode => { - let {href, hrefLang, target, rel, download, ping, referrerPolicy, routerOptions, ...rest} = props; - - let hasLink = href != null && href.length > 0; let [isLinkPressed, setLinkPressed] = useState(false); let rowRef = useRef(null); // oxlint-disable-next-line react-compiler let scaling = pressScale(rowRef); return ( - - - scaling({isPressed: isLinkPressed || isPressed})} - href={href} - focusMode={hasLink ? 'child' : undefined} - allowsArrowNavigation - className={renderProps => treeRow(renderProps)} - /> - - + + scaling({isPressed: isLinkPressed || isPressed})} + className={renderProps => treeRow(renderProps)} + /> + ); }; -export interface SideNavItemContentProps extends Omit { +export interface SideNavItemContentProps { /** Rendered contents of the side nav item or child items. */ children: ReactNode; } @@ -399,74 +315,18 @@ const indicator = style<{isDisabled: boolean; isSelected: boolean; isHovered: bo borderRadius: 'full' }); -// 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. -// 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, expandedKeys} = state; - useEffect(() => { - if ( - selectedRoute == null || - syncedRouteRef == null || - syncedRouteRef.current === selectedRoute - ) { - return; - } - let key = findKeyForRoute(collection, selectedRoute); - if (key != null) { - key = closestVisibleKey(collection, expandedKeys, key); - syncedRouteRef.current = selectedRoute; - selectionManager.setFocusedKey(key); - } - }, [selectedRoute, collection, expandedKeys, syncedRouteRef, selectionManager]); -} - export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => { let {children} = props; let scale = useScale(); - let linkProps = useContext(SideNavItemLinkContext); let {setLinkPressed} = useContext(SideNavInternalItemContext); - let {selectedRoute} = useContext(InternalSideNavContext); - return ( - - {({ - isExpanded, - hasChildItems, - isDisabled, - isSelected, - id, - state, - isHovered, - isPressed, - isFocusVisible, - isFocusVisibleWithin - }) => { - return ( - - {children} - - ); - }} - + + {(renderProps: SideNavItemContentRenderProps) => ( + + {children} + + )} + ); }; @@ -475,48 +335,34 @@ const SideNavItemContentInner = props => { isExpanded, hasChildItems, isDisabled, - isSelected, - setLinkPressed, - linkProps, - scale, - id, - state, - selectedRoute, + isCurrent, + isCurrentAncestor, isHovered, isFocusVisible, - isFocusVisibleWithin, + scale, + setLinkPressed, 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); - - let hasLink = linkProps.href != null && linkProps.href.length > 0; - return ( <>
{ extends Omit< - TreeSectionProps, + RACSideNavSectionProps, 'value' | 'render' | 'style' | 'className' > {} export function SideNavSection(props: SideNavSectionProps) { return ( - + {props.children} - + ); } export interface SideNavHeaderProps extends Omit< - TreeHeaderProps, + RACSideNavHeaderProps, 'value' | 'render' | 'style' | 'className' > {} export const SideNavHeader = (props: SideNavHeaderProps): ReactNode => { return ( - { height: 16 })}> {props.children} - + ); }; @@ -681,15 +523,10 @@ export interface SideNavItemLinkProps { export const SideNavItemLink = (props: SideNavItemLinkProps): ReactNode => { let {children} = props; - let {selectedRoute} = useContext(InternalSideNavContext); - let linkProps = useContext(SideNavItemLinkContext); + let linkFocus = useContext(SideNavItemLinkContext); return ( - + { ); }; - -// 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?.href === route) { - return key; - } - } - 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) { - let parent = collection.getItem(node.parentKey); - if (parent?.type === 'item' && !expandedKeys.has(node.parentKey)) { - target = node.parentKey; - } - node = parent; - } - return target; -} - -// Cache so each row doesn't have to walk up the tree every time -let selectedAncestorsCache = new WeakMap< - Collection>, - {selection: unknown; ancestors: 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 cached = selectedAncestorsCache.get(collection); - if (cached && cached.selection === selectedRoute) { - return cached.ancestors; - } - - let matchKey = findKeyForRoute(collection, selectedRoute); - - let ancestors = new Set(); - 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: selectedRoute, ancestors}); - return ancestors; -} - -// 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, selectedRoute).has(id); -} diff --git a/packages/dev/s2-docs/pages/react-aria/RoutedSideNav.tsx b/packages/dev/s2-docs/pages/react-aria/RoutedSideNav.tsx new file mode 100644 index 00000000000..89bb0fc3e11 --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/RoutedSideNav.tsx @@ -0,0 +1,17 @@ +'use client'; +import {RouterProvider} from 'react-aria-components'; +import React, {ReactNode, useState} from 'react'; + +export function RoutedSideNav(props: { + children: ({selectedRoute}: {selectedRoute: string}) => ReactNode; + defaultSelectedRoute: string; +}) { + let {children} = props; + let [selectedRoute, setSelectedRoute] = useState(props.defaultSelectedRoute); + + let updateSelection = (href: string) => { + setSelectedRoute(href); + }; + + return {children({selectedRoute})}; +} diff --git a/packages/dev/s2-docs/pages/react-aria/SideNav.mdx b/packages/dev/s2-docs/pages/react-aria/SideNav.mdx new file mode 100644 index 00000000000..cc00b607199 --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/SideNav.mdx @@ -0,0 +1,204 @@ +import {Layout} from '../../src/Layout'; +export default Layout; + +import docs from 'docs:react-aria-components'; +import '../../tailwind/tailwind.css'; +import {InlineAlert, Heading, Content} from '@react-spectrum/s2'; + +export const tags = ['navigation', 'nav', 'sidebar']; +export const version = 'alpha'; +export const description = 'A navigation component that displays a nested, hierarchical set of links, with support for keyboard navigation and a current route indicator.'; + +# SideNav + +{docs.exports.SideNav.description} + + + ```tsx render docs={docs.exports.SideNav} links={docs.links} props={[]} type="vanilla" files={["starters/docs/src/SideNav.tsx", "starters/docs/src/SideNav.css", "./RoutedSideNav.tsx"]} + "use client"; + import {SideNav, SideNavItem} from 'vanilla-starter/SideNav'; + import {RoutedSideNav} from './RoutedSideNav'; + + + {({selectedRoute}) => ( + + + + + + + + + )} + + ``` + + ```tsx render docs={docs.exports.SideNav} links={docs.links} props={[]} type="tailwind" files={["starters/tailwind/src/SideNav.tsx", "./RoutedSideNav.tsx"]} + "use client"; + import {SideNav, SideNavItem} from 'tailwind-starter/SideNav'; + import {RoutedSideNav} from './RoutedSideNav'; + + + {({selectedRoute}) => ( + + + + + + + + + )} + + ``` + + + + + Accessibility + `SideNav` renders as a tree so keyboard users can navigate and expand the hierarchy. When it acts as the main navigation for a page, place it inside a [navigation landmark](https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/navigation.html): wrap the `SideNav` in a `