-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: RAC NavigationTree #10404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: RAC NavigationTree #10404
Changes from all commits
7f7adc0
be3c293
058fa1a
0264fa4
0103d53
5bb0dfe
663e43a
39e6f9a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| 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.'; | ||
|
|
||
| # NavigationTree | ||
|
|
||
| <PageDescription>{docs.exports.NavigationTree.description}</PageDescription> | ||
|
|
||
| <ExampleSwitcher> | ||
| ```tsx render docs={docs.exports.NavigationTree} links={docs.links} props={[]} type="vanilla" files={["starters/docs/src/NavigationTree.tsx", "starters/docs/src/NavigationTree.css", "./RoutedNavigationTree.tsx"]} | ||
| "use client"; | ||
| import {NavigationTree, NavigationTreeItem} from 'vanilla-starter/NavigationTree'; | ||
| 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> | ||
| <NavigationTreeItem id="shared" href="/shared" title="Shared" /> | ||
| </NavigationTree> | ||
| )} | ||
| </RoutedNavigationTree> | ||
| ``` | ||
|
|
||
| ```tsx render docs={docs.exports.NavigationTree} links={docs.links} props={[]} type="tailwind" files={["starters/tailwind/src/NavigationTree.tsx", "./RoutedNavigationTree.tsx"]} | ||
| "use client"; | ||
| import {NavigationTree, NavigationTreeItem} from 'tailwind-starter/NavigationTree'; | ||
| 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> | ||
| <NavigationTreeItem id="shared" href="/shared" title="Shared" /> | ||
| </NavigationTree> | ||
| )} | ||
| </RoutedNavigationTree> | ||
| ``` | ||
|
|
||
| </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={["./RoutedNavigationTree.tsx"]} | ||
| "use client"; | ||
| import {NavigationTree, NavigationTreeItem} from 'vanilla-starter/NavigationTree'; | ||
| import {RoutedNavigationTree} from './RoutedNavigationTree'; | ||
|
|
||
| function Example() { | ||
| let items = [ | ||
| {id: 'overview', url: '/overview', label: 'Overview'}, | ||
| {id: 'reports', url: '/reports', label: 'Reports'}, | ||
| {id: 'settings', url: '/settings', label: 'Settings'} | ||
| ]; | ||
|
|
||
| return ( | ||
| <RoutedNavigationTree defaultSelectedRoute="/reports"> | ||
| {({selectedRoute}) => ( | ||
| /*- begin highlight -*/ | ||
| <NavigationTree aria-label="Sections" items={items} selectedRoute={selectedRoute}> | ||
| {item => <NavigationTreeItem href={item.url} title={item.label} />} | ||
| </NavigationTree> | ||
| /*- end highlight -*/ | ||
| )} | ||
| </RoutedNavigationTree> | ||
| ); | ||
| } | ||
| ``` | ||
|
|
||
| ## Current route | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. noticed that you can't see the focus ring on the selected item with the current styles |
||
|
|
||
| Each `NavigationTreeItem` accepts an `href`. Set the `selectedRoute` prop on the `NavigationTree` to the current page's path, and the item whose `href` matches is marked with `aria-current="page"` (and a `data-current` attribute for styling). | ||
|
|
||
| Combine `NavigationTree` with a client side router by wrapping your app in a [RouterProvider](routing.html) so that activating a link updates the route. Then set `selectedRoute`. In this example the router navigation is stored in local state to show the current route updating as you activate links. | ||
|
|
||
| ```tsx render | ||
| "use client"; | ||
| import {NavigationTree, NavigationTreeItem} from 'vanilla-starter/NavigationTree'; | ||
| import {RouterProvider} from 'react-aria-components'; | ||
| import {useState} from 'react'; | ||
|
|
||
| function Example() { | ||
| let [route, setRoute] = useState('/inbox'); | ||
| return ( | ||
| /*- begin highlight -*/ | ||
| <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" /> | ||
| </NavigationTree> | ||
| </RouterProvider> | ||
| ); | ||
| } | ||
| ``` | ||
|
|
||
| ## Sections | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nest the above sections under Content to match other pages |
||
|
|
||
| Use `NavigationTreeSection` to group related items, with an optional `NavigationTreeHeader` to label each group. | ||
|
|
||
| ```tsx render files={["./RoutedNavigationTree.tsx"]} | ||
| "use client"; | ||
| import {NavigationTree, NavigationTreeItem, NavigationTreeSection, NavigationTreeHeader} from 'vanilla-starter/NavigationTree'; | ||
| import {RoutedNavigationTree} from './RoutedNavigationTree'; | ||
|
|
||
| <RoutedNavigationTree defaultSelectedRoute="/projects/apollo"> | ||
| {({selectedRoute}) => ( | ||
| <NavigationTree aria-label="Workspace" selectedRoute={selectedRoute}> | ||
| {/*- begin highlight -*/} | ||
| <NavigationTreeSection> | ||
| <NavigationTreeHeader>Personal</NavigationTreeHeader> | ||
| <NavigationTreeItem href="/home" title="Home" /> | ||
| <NavigationTreeItem href="/starred" title="Starred" /> | ||
| </NavigationTreeSection> | ||
| {/*- end highlight -*/} | ||
| <NavigationTreeSection> | ||
| <NavigationTreeHeader>Projects</NavigationTreeHeader> | ||
| <NavigationTreeItem href="/projects/apollo" title="Apollo" /> | ||
| <NavigationTreeItem href="/projects/gemini" title="Gemini" /> | ||
| </NavigationTreeSection> | ||
| </NavigationTree> | ||
| )} | ||
| </RoutedNavigationTree> | ||
| ``` | ||
|
|
||
| ## API | ||
|
|
||
| ```tsx links={{NavigationTree: '#NavigationTree', NavigationTreeItem: '#NavigationTreeitem', NavigationTreeItemContent: '#NavigationTreeitemcontent', NavigationTreeSection: '#NavigationTreesection', NavigationTreeHeader: '#NavigationTreeheader', Link: 'Link'}} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would be nice to have an anatomy diagram... |
||
| <NavigationTree> | ||
| <NavigationTreeSection> | ||
| <NavigationTreeHeader /> | ||
| <NavigationTreeItem> | ||
| <NavigationTreeItemContent> | ||
| <Link /> | ||
| </NavigationTreeItemContent> | ||
| </NavigationTreeItem> | ||
| </NavigationTreeSection> | ||
| </NavigationTree> | ||
| ``` | ||
|
|
||
| ### NavigationTree | ||
|
|
||
| <PropTable component={docs.exports.NavigationTree} links={docs.links} showDescription /> | ||
|
|
||
| ### NavigationTreeItem | ||
|
|
||
| <PropTable component={docs.exports.NavigationTreeItem} links={docs.links} showDescription /> | ||
|
|
||
| ### NavigationTreeItemContent | ||
|
|
||
| <PropTable component={docs.exports.NavigationTreeItemContent} links={docs.links} showDescription /> | ||
|
|
||
| ### NavigationTreeSection | ||
|
|
||
| <PropTable component={docs.exports.NavigationTreeSection} links={docs.links} showDescription /> | ||
|
|
||
| ### NavigationTreeHeader | ||
|
|
||
| <PropTable component={docs.exports.NavigationTreeHeader} links={docs.links} showDescription /> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| 'use client'; | ||
| import {RouterProvider} from 'react-aria-components'; | ||
| import React, {ReactNode, useState} from 'react'; | ||
|
|
||
| export function RoutedNavigationTree(props: { | ||
| children: ({selectedRoute}: {selectedRoute: string}) => ReactNode; | ||
| defaultSelectedRoute: string; | ||
| }) { | ||
| let {children} = props; | ||
| let [selectedRoute, setSelectedRoute] = useState<string>(props.defaultSelectedRoute); | ||
|
|
||
| let updateSelection = (href: string) => { | ||
| setSelectedRoute(href); | ||
| }; | ||
|
|
||
| return <RouterProvider navigate={updateSelection}>{children({selectedRoute})}</RouterProvider>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<RouterContextValue>({ | ||
| location: {pathname: '/'}, | ||
| navigate: () => {} | ||
| }); | ||
|
|
||
| export function MemoryRouter(props: {initialEntries?: string[]; children: ReactNode}) { | ||
| let {initialEntries = ['/'], children} = props; | ||
| let [location, setLocation] = useState<Location>({pathname: initialEntries[0]}); | ||
| let navigate = (pathname: string) => setLocation({pathname}); | ||
|
|
||
| return <RouterContext.Provider value={{location, navigate}}>{children}</RouterContext.Provider>; | ||
| } | ||
|
|
||
| export function useLocation(): Location { | ||
| return useContext(RouterContext).location; | ||
| } | ||
|
|
||
| export function useNavigate(): (pathname: string) => void { | ||
| return useContext(RouterContext).navigate; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO the style is a bit heavy for a sidenav. Maybe we can make it a bit closer to what we have in S2 with the selection line