Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 136 additions & 32 deletions packages/dev/s2-docs/pages/react-aria/NavigationTree.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,55 @@ export const description = 'A navigation component that displays a nested, hiera
<ExampleSwitcher>
```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';

<RoutedNavigationTree defaultSelectedRoute="/photos">
{({selectedRoute}) => (
<NavigationTree aria-label="Files" selectedRoute={selectedRoute} defaultExpandedKeys={['files']}>
<NavigationTreeItem id="home" href="/home" title="Home" />
<NavigationTreeItem id="files" href="/files" title="Files">
<NavigationTreeItem id="photos" href="/photos" title="Photos" />
<NavigationTreeItem id="videos" href="/videos" title="Videos" />
<NavigationTreeItem id="home" href="/home" textValue="Home">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Home</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
</NavigationTreeItem>
<NavigationTreeItem id="shared" title="Shared">
<NavigationTreeItem id="food" href="/food" title="Food" />
<NavigationTreeItem id="drinks" href="/drinks" title="Drinks" />
<NavigationTreeItem id="files" href="/files" textValue="Files">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Files</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
<NavigationTreeItem id="photos" href="/photos" textValue="Photos">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Photos</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
</NavigationTreeItem>
<NavigationTreeItem id="videos" href="/videos" textValue="Videos">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Videos</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
</NavigationTreeItem>
</NavigationTreeItem>
<NavigationTreeItem id="shared" textValue="Shared">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Shared</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
<NavigationTreeItem id="food" href="/food" textValue="Food">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Food</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
</NavigationTreeItem>
<NavigationTreeItem id="drinks" href="/drinks" textValue="Drinks">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Drinks</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
</NavigationTreeItem>
</NavigationTreeItem>
</NavigationTree>
)}
Expand All @@ -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';

<RoutedNavigationTree defaultSelectedRoute="/photos">
{({selectedRoute}) => (
<NavigationTree aria-label="Files" selectedRoute={selectedRoute} defaultExpandedKeys={['files']}>
<NavigationTreeItem id="home" href="/home" title="Home" />
<NavigationTreeItem id="files" href="/files" title="Files">
<NavigationTreeItem id="photos" href="/photos" title="Photos" />
<NavigationTreeItem id="videos" href="/videos" title="Videos" />
<NavigationTreeItem id="home" href="/home" textValue="Home">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Home</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
</NavigationTreeItem>
<NavigationTreeItem id="files" href="/files" textValue="Files">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Files</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
<NavigationTreeItem id="photos" href="/photos" textValue="Photos">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Photos</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
</NavigationTreeItem>
<NavigationTreeItem id="videos" href="/videos" textValue="Videos">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Videos</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
</NavigationTreeItem>
</NavigationTreeItem>
<NavigationTreeItem id="shared" title="Shared">
<NavigationTreeItem id="food" href="/food" title="Food" />
<NavigationTreeItem id="drinks" href="/drinks" title="Drinks" />
<NavigationTreeItem id="shared" textValue="Shared">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Shared</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
<NavigationTreeItem id="food" href="/food" textValue="Food">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Food</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
</NavigationTreeItem>
<NavigationTreeItem id="drinks" href="/drinks" textValue="Drinks">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Drinks</NavigationTreeItemLink>
<Button variant="quiet" aria-label="More options"><MoreHorizontal size={16} aria-hidden /></Button>
</NavigationTreeItemContent>
</NavigationTreeItem>
</NavigationTreeItem>
</NavigationTree>
)}
Expand All @@ -61,18 +131,13 @@ export const description = 'A navigation component that displays a nested, hiera

</ExampleSwitcher>

<InlineAlert variant="notice">
<Heading>Accessibility</Heading>
<Content>`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 `<nav>` element with an `aria-label` so assistive technology users can quickly find it.</Content>
</InlineAlert>

## 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() {
Expand All @@ -87,7 +152,13 @@ function Example() {
{({selectedRoute}) => (
/*- begin highlight -*/
<NavigationTree aria-label="Sections" items={items} selectedRoute={selectedRoute}>
{item => <NavigationTreeItem href={item.url} title={item.label} />}
{item => (
<NavigationTreeItem href={item.url} textValue={item.label}>
<NavigationTreeItemContent>
<NavigationTreeItemLink>{item.label}</NavigationTreeItemLink>
</NavigationTreeItemContent>
</NavigationTreeItem>
)}
</NavigationTree>
/*- end highlight -*/
)}
Expand All @@ -96,13 +167,18 @@ function Example() {
}
```

<InlineAlert variant="notice">
<Heading>Accessibility</Heading>
<Content>`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 `<nav>` element with an `aria-label` so assistive technology users can quickly find it.</Content>
</InlineAlert>

### 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';

<RoutedNavigationTree defaultSelectedRoute="/projects/apollo">
Expand All @@ -111,14 +187,30 @@ import {RoutedNavigationTree} from './RoutedNavigationTree';
{/*- begin highlight -*/}
<NavigationTreeSection>
<NavigationTreeHeader>Personal</NavigationTreeHeader>
<NavigationTreeItem href="/home" title="Home" />
<NavigationTreeItem href="/starred" title="Starred" />
<NavigationTreeItem href="/home" textValue="Home">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Home</NavigationTreeItemLink>
</NavigationTreeItemContent>
</NavigationTreeItem>
<NavigationTreeItem href="/starred" textValue="Starred">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Starred</NavigationTreeItemLink>
</NavigationTreeItemContent>
</NavigationTreeItem>
</NavigationTreeSection>
{/*- end highlight -*/}
<NavigationTreeSection>
<NavigationTreeHeader>Projects</NavigationTreeHeader>
<NavigationTreeItem href="/projects/apollo" title="Apollo" />
<NavigationTreeItem href="/projects/gemini" title="Gemini" />
<NavigationTreeItem href="/projects/apollo" textValue="Apollo">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Apollo</NavigationTreeItemLink>
</NavigationTreeItemContent>
</NavigationTreeItem>
<NavigationTreeItem href="/projects/gemini" textValue="Gemini">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Gemini</NavigationTreeItemLink>
</NavigationTreeItemContent>
</NavigationTreeItem>
</NavigationTreeSection>
</NavigationTree>
)}
Expand All @@ -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';

Expand All @@ -144,9 +236,21 @@ function Example() {
<RouterProvider navigate={setRoute}>
{/*- end highlight -*/}
<NavigationTree aria-label="Mail" selectedRoute={route}>
<NavigationTreeItem href="/inbox" title="Inbox" />
<NavigationTreeItem href="/drafts" title="Drafts" />
<NavigationTreeItem href="/sent" title="Sent" />
<NavigationTreeItem href="/inbox" textValue="Inbox">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Inbox</NavigationTreeItemLink>
</NavigationTreeItemContent>
</NavigationTreeItem>
<NavigationTreeItem href="/drafts" textValue="Drafts">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Drafts</NavigationTreeItemLink>
</NavigationTreeItemContent>
</NavigationTreeItem>
<NavigationTreeItem href="/sent" textValue="Sent">
<NavigationTreeItemContent>
<NavigationTreeItemLink>Sent</NavigationTreeItemLink>
</NavigationTreeItemContent>
</NavigationTreeItem>
</NavigationTree>
</RouterProvider>
);
Expand Down
1 change: 1 addition & 0 deletions packages/dev/s2-docs/src/ComponentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ const componentIllustrations: Record<string, [string, string] | undefined> = {
Meter: [MeterLight, MeterDark],
'Migrating to Spectrum 2': [MigratingLight, MigratingDark],
Modal: [DialogLight, DialogDark],
NavigationTree: [SideNavLight, SideNavDark],
NumberField: [NumberFieldLight, NumberFieldDark],
Picker: [PickerLight, PickerDark],
Popover: [PopoverLight, PopoverDark],
Expand Down
25 changes: 18 additions & 7 deletions packages/react-aria-components/src/NavigationTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | undefined>;
/** The NavigationTree's root element, used to keep current-route scrolling inside the tree. */
treeRef?: RefObject<HTMLDivElement | null>;
}
const InternalNavigationTreeContext = createContext<InternalNavigationTreeContextValue>({});

Expand Down Expand Up @@ -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<string | undefined>(undefined);
let context = useMemo(() => ({selectedRoute, syncedRouteRef}), [selectedRoute]);
let treeRef = useObjectRef(ref);
let context = useMemo(() => ({selectedRoute, syncedRouteRef, treeRef}), [selectedRoute, treeRef]);

return (
<InternalNavigationTreeContext.Provider value={context}>
<Tree
{...rest}
ref={ref}
ref={treeRef}
className={className ?? 'react-aria-NavigationTree'}
style={style}
selectionMode="none"
Expand Down Expand Up @@ -213,7 +217,7 @@ export const NavigationTreeItem = /*#__PURE__*/ (forwardRef as forwardRefType)(
render,
...rest
} = props;
let {selectedRoute} = useContext(InternalNavigationTreeContext);
let {selectedRoute, treeRef} = useContext(InternalNavigationTreeContext);
let hasLink = href != null && href.length > 0;
let isCurrent = hasLink && href === selectedRoute;
let [isLinkFocused, setLinkFocused] = useState(false);
Expand All @@ -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 (
<NavigationTreeItemLinkContext.Provider
Expand Down Expand Up @@ -427,7 +437,7 @@ interface NavigationTreeItemContentInnerProps {
function NavigationTreeItemContentInner(props: NavigationTreeItemContentInnerProps): ReactNode {
let {treeRenderProps, linkCtx, children} = props;
let {isCurrent, isLinkFocused, setLinkFocused, ...linkProps} = linkCtx;
let {state, isFocusVisible, isFocusVisibleWithin} = treeRenderProps;
let {state, isFocusVisible, isFocusVisibleWithin, id, hasChildItems} = treeRenderProps;
let {isCurrentAncestor} = useContext(NavigationTreeItemStateContext);

useRouteFocusSync({state});
Expand All @@ -446,7 +456,8 @@ function NavigationTreeItemContentInner(props: NavigationTreeItemContentInnerPro
let linkContextValue = {
...linkProps,
'aria-current': isCurrent ? 'page' : undefined,
onFocusChange: setLinkFocused
onFocusChange: setLinkFocused,
onPress: linkProps.href == null && hasChildItems ? () => state.toggleKey(id) : undefined
};
return <Provider values={[[LinkContext, linkContextValue]]}>{renderChildren}</Provider>;
}
Expand Down
Loading