diff --git a/packages/dev/s2-docs/pages/react-aria/NavigationTree.mdx b/packages/dev/s2-docs/pages/react-aria/NavigationTree.mdx
index b2ccc1a31de..86eee574ece 100644
--- a/packages/dev/s2-docs/pages/react-aria/NavigationTree.mdx
+++ b/packages/dev/s2-docs/pages/react-aria/NavigationTree.mdx
@@ -17,20 +17,55 @@ export const description = 'A navigation component that displays a nested, hiera
```tsx render docs={docs.exports.NavigationTree} links={docs.links} props={[]} type="vanilla" files={["starters/docs/src/NavigationTree.tsx", "starters/docs/src/NavigationTree.css", "packages/dev/s2-docs/pages/react-aria/RoutedNavigationTree.tsx"]}
"use client";
- import {NavigationTree, NavigationTreeItem} from 'vanilla-starter/NavigationTree';
+ import {NavigationTree, NavigationTreeItem, NavigationTreeItemContent, NavigationTreeItemLink} from 'vanilla-starter/NavigationTree';
+ import {Button} from 'vanilla-starter/Button';
+ import {MoreHorizontal} from 'lucide-react';
import {RoutedNavigationTree} from './RoutedNavigationTree';
{({selectedRoute}) => (
-
-
-
-
+
+
+ Home
+
+
-
-
-
+
+
+ Files
+
+
+
+
+ Photos
+
+
+
+
+
+ Videos
+
+
+
+
+
+
+ Shared
+
+
+
+
+ Food
+
+
+
+
+
+ Drinks
+
+
+
)}
@@ -39,20 +74,55 @@ export const description = 'A navigation component that displays a nested, hiera
```tsx render docs={docs.exports.NavigationTree} links={docs.links} props={[]} type="tailwind" files={["starters/tailwind/src/NavigationTree.tsx", "packages/dev/s2-docs/pages/react-aria/RoutedNavigationTree.tsx"]}
"use client";
- import {NavigationTree, NavigationTreeItem} from 'tailwind-starter/NavigationTree';
+ import {NavigationTree, NavigationTreeItem, NavigationTreeItemContent, NavigationTreeItemLink} from 'tailwind-starter/NavigationTree';
+ import {Button} from 'tailwind-starter/Button';
+ import {MoreHorizontal} from 'lucide-react';
import {RoutedNavigationTree} from './RoutedNavigationTree';
{({selectedRoute}) => (
-
-
-
-
+
+
+ Home
+
+
+
+
+
+ Files
+
+
+
+
+ Photos
+
+
+
+
+
+ Videos
+
+
+
-
-
-
+
+
+ Shared
+
+
+
+
+ Food
+
+
+
+
+
+ Drinks
+
+
+
)}
@@ -61,18 +131,13 @@ export const description = 'A navigation component that displays a nested, hiera
-
- Accessibility
- `NavigationTree` 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 `NavigationTree` in a `
-
-
## Content
`NavigationTree` follows the [Collection Components API](collections?component=NavigationTree), accepting both static and dynamic collections. The example above shows a static collection. This example shows a dynamic collection, passing a list of objects to the `items` prop and a function to render the children.
```tsx render files={["packages/dev/s2-docs/pages/react-aria/RoutedNavigationTree.tsx"]}
"use client";
-import {NavigationTree, NavigationTreeItem} from 'vanilla-starter/NavigationTree';
+import {NavigationTree, NavigationTreeItem, NavigationTreeItemContent, NavigationTreeItemLink} from 'vanilla-starter/NavigationTree';
import {RoutedNavigationTree} from './RoutedNavigationTree';
function Example() {
@@ -87,7 +152,13 @@ function Example() {
{({selectedRoute}) => (
/*- begin highlight -*/
- {item => }
+ {item => (
+
+
+ {item.label}
+
+
+ )}
/*- end highlight -*/
)}
@@ -96,13 +167,18 @@ function Example() {
}
```
+
+ Accessibility
+ `NavigationTree` 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 `NavigationTree` in a `
+
+
### Sections
Use `NavigationTreeSection` to group related items, with an optional `NavigationTreeHeader` to label each group. Sections without a header must have an `aria-label`.
```tsx render files={["packages/dev/s2-docs/pages/react-aria/RoutedNavigationTree.tsx"]}
"use client";
-import {NavigationTree, NavigationTreeItem, NavigationTreeSection, NavigationTreeHeader} from 'vanilla-starter/NavigationTree';
+import {NavigationTree, NavigationTreeItem, NavigationTreeItemContent, NavigationTreeItemLink, NavigationTreeSection, NavigationTreeHeader} from 'vanilla-starter/NavigationTree';
import {RoutedNavigationTree} from './RoutedNavigationTree';
@@ -111,14 +187,30 @@ import {RoutedNavigationTree} from './RoutedNavigationTree';
{/*- begin highlight -*/}
Personal
-
-
+
+
+ Home
+
+
+
+
+ Starred
+
+
{/*- end highlight -*/}
Projects
-
-
+
+
+ Apollo
+
+
+
+
+ Gemini
+
+
)}
@@ -133,7 +225,7 @@ Combine `NavigationTree` with a client side router by wrapping your app in a [Ro
```tsx render
"use client";
-import {NavigationTree, NavigationTreeItem} from 'vanilla-starter/NavigationTree';
+import {NavigationTree, NavigationTreeItem, NavigationTreeItemContent, NavigationTreeItemLink} from 'vanilla-starter/NavigationTree';
import {RouterProvider} from 'react-aria-components';
import {useState} from 'react';
@@ -144,9 +236,21 @@ function Example() {
{/*- end highlight -*/}
-
-
-
+
+
+ Inbox
+
+
+
+
+ Drafts
+
+
+
+
+ Sent
+
+
);
diff --git a/packages/dev/s2-docs/src/ComponentCard.tsx b/packages/dev/s2-docs/src/ComponentCard.tsx
index 9e4bc0d261f..975f89cffa5 100644
--- a/packages/dev/s2-docs/src/ComponentCard.tsx
+++ b/packages/dev/s2-docs/src/ComponentCard.tsx
@@ -268,6 +268,7 @@ const componentIllustrations: Record = {
Meter: [MeterLight, MeterDark],
'Migrating to Spectrum 2': [MigratingLight, MigratingDark],
Modal: [DialogLight, DialogDark],
+ NavigationTree: [SideNavLight, SideNavDark],
NumberField: [NumberFieldLight, NumberFieldDark],
Picker: [PickerLight, PickerDark],
Popover: [PopoverLight, PopoverDark],
diff --git a/packages/react-aria-components/src/NavigationTree.tsx b/packages/react-aria-components/src/NavigationTree.tsx
index df406d98431..ee9cbf66e14 100644
--- a/packages/react-aria-components/src/NavigationTree.tsx
+++ b/packages/react-aria-components/src/NavigationTree.tsx
@@ -22,6 +22,7 @@ import {
import {Collection, forwardRefType, Key, Node, RouterOptions} from '@react-types/shared';
import {getScrollParent} from 'react-aria/private/utils/getScrollParent';
import {LinkContext} from './Link';
+import {nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions';
import React, {
createContext,
ForwardedRef,
@@ -60,6 +61,8 @@ interface InternalNavigationTreeContextValue {
selectedRoute?: string | null;
/** The last route the focused key was synced to; dedupes the focus sync across items. */
syncedRouteRef?: RefObject;
+ /** The NavigationTree's root element, used to keep current-route scrolling inside the tree. */
+ treeRef?: RefObject;
}
const InternalNavigationTreeContext = createContext({});
@@ -130,13 +133,14 @@ export const NavigationTree = /*#__PURE__*/ (forwardRef as forwardRefType)(funct
[props, ref] = useContextProps(props, ref, NavigationTreeContext);
let {className, style, children, selectedRoute, ...rest} = props;
let syncedRouteRef = useRef(undefined);
- let context = useMemo(() => ({selectedRoute, syncedRouteRef}), [selectedRoute]);
+ let treeRef = useObjectRef(ref);
+ let context = useMemo(() => ({selectedRoute, syncedRouteRef, treeRef}), [selectedRoute, treeRef]);
return (
0;
let isCurrent = hasLink && href === selectedRoute;
let [isLinkFocused, setLinkFocused] = useState(false);
@@ -232,9 +236,15 @@ export const NavigationTreeItem = /*#__PURE__*/ (forwardRef as forwardRefType)(
useEffect(() => {
if (isCurrent && objRef.current) {
let scrollParent = getScrollParent(objRef.current, true) as HTMLElement;
- scrollIntoView(scrollParent, objRef.current, {block: 'center'});
+ // Only scroll the tree's own scroll container into view — never an outer ancestor such as the
+ // page. When the tree isn't its own scroll container the nearest scroll parent is outside the
+ // tree, so we skip rather than hijack the surrounding scroll position.
+ let treeRoot = treeRef?.current;
+ if (treeRoot && nodeContains(treeRoot, scrollParent)) {
+ scrollIntoView(scrollParent, objRef.current, {block: 'center'});
+ }
}
- }, [isCurrent, objRef]);
+ }, [isCurrent, objRef, treeRef]);
return (
state.toggleKey(id) : undefined
};
return {renderChildren};
}
diff --git a/packages/react-aria-components/test/NavigationTree.test.tsx b/packages/react-aria-components/test/NavigationTree.test.tsx
index b8a02e71539..df0d8f2482b 100644
--- a/packages/react-aria-components/test/NavigationTree.test.tsx
+++ b/packages/react-aria-components/test/NavigationTree.test.tsx
@@ -112,6 +112,26 @@ function NoLinkActionMenuNavigationTreeExample(props: Partial>) {
+ return (
+
+
+
+ Section
+
+
+
+
+ Section 2
+
+
+
+
+ );
+}
+
// Section 1 has an aria-label instead of a NavigationTreeHeader (as is allowed by the underlying
// TreeSection); Section 2 uses a NavigationTreeHeader so both default classes get covered.
function SectionNavigationTreeExample(props: Partial>) {
@@ -348,6 +368,18 @@ describe('NavigationTree', () => {
expect(within(sectionRow).getByRole('button', {name: 'More actions'})).not.toHaveFocus();
});
+ it('toggles expansion when pressing the label (a no-href Link) of a parent row', async () => {
+ let {getByRole} = render();
+ let sectionRow = getByRole('row', {name: 'Section'});
+ expect(sectionRow).toHaveAttribute('aria-expanded', 'false');
+
+ await user.click(within(sectionRow).getByRole('link', {name: 'Section'}));
+ expect(sectionRow).toHaveAttribute('aria-expanded', 'true');
+
+ await user.click(within(sectionRow).getByRole('link', {name: 'Section'}));
+ expect(sectionRow).toHaveAttribute('aria-expanded', 'false');
+ });
+
it('arrow left from a deep leaf steps to parent, collapses it, then moves to the grandparent', async () => {
let {getByRole, queryByRole} = render(
{({hasChildItems}: NavigationTreeItemContentRenderProps) => (
<>
- {/* The label is rendered as a Link so it becomes the row's focusable child. It picks up
- * href + aria-current automatically from the NavigationTree. Rows without an href render as a
- * span instead of an anchor. */}
- {props.children}
- {hasChildItems && (
-
- )}
+ {props.children}
+
>
)}
);
}
-export interface NavigationTreeItemProps extends Partial {
- title?: React.ReactNode;
-}
-
-export function NavigationTreeItem(props: NavigationTreeItemProps) {
- let textValue = typeof props.title === 'string' ? props.title : '';
- return (
-
- {props.title != null ? (
- <>
- {props.title}
- {props.children}
- >
- ) : (
- props.children
- )}
-
- );
+export function NavigationTreeItem(props: AriaNavigationTreeItemProps) {
+ return ;
}
export function NavigationTreeSection(props: NavigationTreeSectionProps) {
@@ -68,3 +51,7 @@ export function NavigationTreeSection(props: NavigationTreeSec
export function NavigationTreeHeader(props: NavigationTreeHeaderProps) {
return ;
}
+
+export function NavigationTreeItemLink(props: LinkProps) {
+ return ;
+}
diff --git a/starters/docs/stories/NavigationTree.stories.tsx b/starters/docs/stories/NavigationTree.stories.tsx
new file mode 100644
index 00000000000..a338580061f
--- /dev/null
+++ b/starters/docs/stories/NavigationTree.stories.tsx
@@ -0,0 +1,121 @@
+import {Button} from '../src/Button';
+import type {Meta, StoryFn} from '@storybook/react';
+import {MoreHorizontal} from 'lucide-react';
+import {
+ NavigationTree,
+ NavigationTreeItem,
+ NavigationTreeItemContent,
+ NavigationTreeItemLink,
+ NavigationTreeSection,
+ NavigationTreeHeader
+} from '../src/NavigationTree';
+import React, {type ReactNode, useState} from 'react';
+import {RouterProvider} from 'react-aria-components';
+
+const meta: Meta = {
+ component: NavigationTree,
+ parameters: {
+ layout: 'centered'
+ },
+ tags: ['autodocs']
+};
+
+export default meta;
+
+type Story = StoryFn;
+
+function RoutedNavigationTree(props: {
+ children: ({selectedRoute}: {selectedRoute: string}) => ReactNode;
+ defaultSelectedRoute: string;
+}) {
+ let [selectedRoute, setSelectedRoute] = useState(props.defaultSelectedRoute);
+ return (
+ {props.children({selectedRoute})}
+ );
+}
+
+export const Example: Story = args => (
+
+ {({selectedRoute}) => (
+
+
+
+ Home
+
+
+
+
+
+ Files
+
+
+
+ Photos
+
+
+
+
+ Videos
+
+
+
+
+
+ Shared
+
+
+
+ Food
+
+
+
+
+ Drinks
+
+
+
+
+ )}
+
+);
+
+export const Sections: Story = args => (
+
+ {({selectedRoute}) => (
+
+
+ Personal
+
+
+ Home
+
+
+
+
+ Starred
+
+
+
+
+ Projects
+
+
+ Apollo
+
+
+
+
+ Gemini
+
+
+
+
+ )}
+
+);
diff --git a/starters/tailwind/src/NavigationTree.tsx b/starters/tailwind/src/NavigationTree.tsx
index 76f390a9f44..861a52e746d 100644
--- a/starters/tailwind/src/NavigationTree.tsx
+++ b/starters/tailwind/src/NavigationTree.tsx
@@ -2,6 +2,7 @@
import {
Button,
Link,
+ type LinkProps,
NavigationTree as AriaNavigationTree,
NavigationTreeHeader as AriaNavigationTreeHeader,
type NavigationTreeHeaderProps,
@@ -31,13 +32,13 @@ export function NavigationTree({children, ...props}: NavigationTreeProps)
}
// The focus ring lives on the row (not the link) so it spans the whole item. Hover/current/ancestor
-// state is surfaced as a leading-edge indicator on the Link (see linkStyles) rather than a full-row
-// background. The row is a `group` so the Link can react to the row's data-* attributes. isFocusVisible
+// state is surfaced as a leading-edge indicator (see indicatorStyles) rather than a full-row
+// background. The row is a `group` so the indicator can react to the row's data-* attributes. isFocusVisible
// comes from the render props; RAC's isFocusVisible already follows the link (it is not true when
// another child, e.g. a button, is focused).
const itemStyles = tv({
extend: focusRing,
- base: 'group relative font-sans flex items-center rounded-md cursor-default select-none text-neutral-800 dark:text-neutral-200 -outline-offset-2',
+ base: 'group relative font-sans flex items-center rounded-md cursor-default select-none text-neutral-800 dark:text-neutral-200 -outline-offset-2 [-webkit-tap-highlight-color:transparent]',
variants: {
isDisabled: {
true: 'text-neutral-300 dark:text-neutral-600 forced-colors:text-[GrayText]'
@@ -45,28 +46,11 @@ const itemStyles = tv({
}
});
-// A single `before` pseudo-element on the Link is the leading-edge indicator; the row's data-*
-// attributes (via `group-[...]`) decide how it looks so the three states never overlap:
-// - Hover pill (neutral, full height): only rows that render as a link (`data-href`) light up on
-// hover, matching RAC's data-hovered on actionable rows.
-// - Current pill (blue, full height): the selected row, but only when it is not also hovered, so
-// hovering the selected row shows the neutral hover pill instead (never both).
-// - Ancestor dot (neutral, short): a collapsed ancestor of the current route shows a small dot in
-// the same spot. It shortens the height (higher specificity than the base) and shares the neutral
-// color with hover, so hovering a collapsed ancestor still reads as the dot, not a full pill.
-// Works for ancestors that render as a link or a plain span since it keys off the row, not the element.
const linkStyles = tv({
base:
'relative flex-1 min-w-0 flex items-center gap-2 py-1.5 px-2 text-sm no-underline text-current cursor-pointer outline-none ' +
// A row without an href renders its label as a non-interactive span, so it should not look clickable.
- 'group-[:not([data-href])]:cursor-default ' +
- "before:content-[''] before:absolute before:start-0.5 before:top-1/2 before:h-[1lh] before:w-1 before:-translate-y-1/2 before:rounded-full before:forced-color-adjust-none " +
- 'group-[[data-hovered][data-href]]:before:bg-neutral-400 dark:group-[[data-hovered][data-href]]:before:bg-neutral-500 ' +
- 'group-[[data-current]:not([data-hovered])]:before:bg-blue-600 dark:group-[[data-current]:not([data-hovered])]:before:bg-blue-400 ' +
- 'group-[[data-current-ancestor]:not([data-expanded])]:before:h-1 group-[[data-current-ancestor]:not([data-expanded])]:before:bg-neutral-400 dark:group-[[data-current-ancestor]:not([data-expanded])]:before:bg-neutral-500 ' +
- // In forced-colors mode authored backgrounds are dropped, so whenever the indicator is showing
- // (any of the three states above) render it as the system Highlight color instead.
- 'forced-colors:group-[:is([data-hovered][data-href],[data-current]:not([data-hovered]),[data-current-ancestor]:not([data-expanded]))]:before:bg-[Highlight]',
+ 'group-[:not([data-href])]:cursor-default',
variants: {
isDisabled: {
true: 'cursor-default'
@@ -74,6 +58,24 @@ const linkStyles = tv({
}
});
+// The leading-edge indicator is a real, presentational element (aria-hidden). Its variant is chosen in
+// JS (see NavigationTreeItemContent) and surfaced as a single `data-indicator` attribute, so the styles
+// stay flat and precedence lives in one place instead of relying on CSS specificity:
+// - current (blue pill): the selected row. Because the variant is picked in JS, it keeps this color
+// even while hovered.
+// - ancestor (neutral dot): a collapsed ancestor of the current route.
+// - hover (neutral pill): any actionable row on hover.
+const indicatorStyles = tv({
+ base:
+ 'absolute start-0.5 top-1/2 -translate-y-1/2 w-0.5 h-[1lh] rounded-full forced-color-adjust-none ' +
+ // Only real link rows (the group row has data-href) get the hover pill.
+ 'group-[[data-href]]:data-[indicator=hover]:bg-neutral-400 dark:group-[[data-href]]:data-[indicator=hover]:bg-neutral-500 ' +
+ 'data-[indicator=current]:bg-blue-600 dark:data-[indicator=current]:bg-blue-400 ' +
+ 'data-[indicator=ancestor]:h-1 data-[indicator=ancestor]:w-1 data-[indicator=ancestor]:bg-neutral-400 dark:data-[indicator=ancestor]:bg-neutral-500 ' +
+ // In forced-colors mode authored backgrounds are dropped, so render any visible state as Highlight.
+ 'forced-colors:data-[indicator]:bg-[Highlight]'
+});
+
const expandButton = tv({
extend: focusRing,
base: 'shrink-0 w-6 h-6 flex items-center justify-center rounded-md border-0 p-0 bg-transparent cursor-default [-webkit-tap-highlight-color:transparent] -outline-offset-2'
@@ -88,41 +90,48 @@ const chevron = tv({
}
});
+const NavTreeLinkContext = React.createContext<{
+ indicator: 'current' | 'ancestor' | undefined;
+}>({indicator: undefined});
+
export function NavigationTreeItemContent(props: {children?: React.ReactNode}) {
return (
- {({level, hasChildItems, isDisabled, isExpanded}) => (
- <>
- {level > 1 && (
-
- )}
- {props.children}
- {hasChildItems && (
-
);
}
-export interface NavigationTreeItemProps extends Partial {
- title?: React.ReactNode;
-}
-
-export function NavigationTreeItem(props: NavigationTreeItemProps) {
- let textValue = typeof props.title === 'string' ? props.title : '';
- return (
-
- {props.title}
- {props.children}
-
- );
+export function NavigationTreeItem(props: AriaNavigationTreeItemProps) {
+ return ;
}
export function NavigationTreeSection(props: NavigationTreeSectionProps) {
@@ -137,3 +146,25 @@ export function NavigationTreeHeader(props: NavigationTreeHeaderProps) {
/>
);
}
+
+export interface NavigationTreeItemLinkProps extends Omit {
+ children?: React.ReactNode;
+}
+
+export function NavigationTreeItemLink(props: NavigationTreeItemLinkProps) {
+ let {indicator} = React.useContext(NavTreeLinkContext);
+ return (
+ linkStyles({isDisabled})}>
+ {({isHovered}) => (
+ <>
+
+ {props.children}
+ >
+ )}
+
+ );
+}
diff --git a/starters/tailwind/stories/NavigationTree.stories.tsx b/starters/tailwind/stories/NavigationTree.stories.tsx
new file mode 100644
index 00000000000..474da7d5799
--- /dev/null
+++ b/starters/tailwind/stories/NavigationTree.stories.tsx
@@ -0,0 +1,119 @@
+import {Button} from '../src/Button';
+import {type Meta} from '@storybook/react';
+import {MoreHorizontal} from 'lucide-react';
+import {
+ NavigationTree,
+ NavigationTreeItem,
+ NavigationTreeItemContent,
+ NavigationTreeItemLink,
+ NavigationTreeSection,
+ NavigationTreeHeader
+} from '../src/NavigationTree';
+import React, {type ReactNode, useState} from 'react';
+import {RouterProvider} from 'react-aria-components';
+
+const meta: Meta = {
+ component: NavigationTree,
+ parameters: {
+ layout: 'centered'
+ },
+ tags: ['autodocs']
+};
+
+export default meta;
+
+function RoutedNavigationTree(props: {
+ children: ({selectedRoute}: {selectedRoute: string}) => ReactNode;
+ defaultSelectedRoute: string;
+}) {
+ let [selectedRoute, setSelectedRoute] = useState(props.defaultSelectedRoute);
+ return (
+ {props.children({selectedRoute})}
+ );
+}
+
+export const Example = (args: any) => (
+
+ {({selectedRoute}) => (
+
+
+
+ Home
+
+
+
+
+
+
+
+ Files
+
+
+
+ Photos
+
+
+
+
+ Videos
+
+
+
+
+
+ Shared
+
+
+
+ Food
+
+
+
+
+ Drinks
+
+
+
+
+ )}
+
+);
+
+export const Sections = (args: any) => (
+
+ {({selectedRoute}) => (
+
+
+ Personal
+
+
+ Home
+
+
+
+
+ Starred
+
+
+
+
+ Projects
+
+
+ Apollo
+
+
+
+
+ Gemini
+
+
+
+
+ )}
+
+);