alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
393 lines (304 loc) • 13.2 kB
Markdown
---
name: alouette-navigation
description: >
Move the user between places. NavBar and NavBarItem navigate between
destinations — each item is a real link announced as the current page, and
composes with expo Router; Tabs and Tab switch between views of one screen,
announced as a tab list. Both are built by composing their items, over the
same segmented bar, and both can shrink to a pill of icon-only chips; a NavBar
can also stand vertically as a sidebar rail. Breadcrumbs and BreadcrumbItem
render the trail back through the ancestors of the current page. Pick by
meaning, not by looks: navigation is never a RadioButtonGroup, which announces
a form value, and never a Link wrapped around a Text, which has no interactive
state. Load when building a tab bar, a section switcher, a breadcrumb trail,
or navigation between routes.
type: core
library: alouette
library_version: "22.11.0"
requires:
- alouette-theming
- alouette-actions
sources:
- "christophehurpeau/alouette:packages/alouette/src/ui/navigation/NavBar.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/navigation/NavBarItem.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/navigation/Tabs.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/navigation/Tab.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/selection/SelectionContext.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/selection/SegmentedBar.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/selection/SegmentedItem.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/navigation/Breadcrumbs.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/navigation/BreadcrumbItem.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/navigation/NavBar.stories.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/navigation/Tabs.stories.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/navigation/Breadcrumbs.stories.tsx"
---
This skill builds on alouette-theming. Read it first for the accent model.
# alouette — Navigation
Two segmented groups over one shared base (`src/ui/selection/`): a lowered 44px
bar whose selected item raises a chip. `NavBar` navigates between destinations,
`Tabs` switches views on the same screen. They differ only in accessibility
semantics — pick by what the press does, not by how it looks.
| | `NavBar` / `NavBarItem` | `Tabs` / `Tab` |
| --------------- | ----------------------- | --------------------------- |
| use for | routes, destinations | views on the current screen |
| container role | `navigation` | `tablist` |
| item role | `link` | `tab` |
| selected marker | `aria-current="page"` | `aria-selected` |
## Setup
```tsx
import { Tabs, Tab } from "alouette";
<Tabs aria-label="Period" defaultValue="week" onValueChange={setPeriod}>
<Tab value="day" label="Day" />
<Tab value="week" label="Week" />
<Tab value="month" label="Month" />
</Tabs>;
```
## Core Patterns
### Controlled vs uncontrolled
Both groups own the value: `defaultValue` for uncontrolled, `value` +
`onValueChange` for controlled. A `NavBar` backed by a router is controlled — its
value is the current route, matched against each item's `href`.
```tsx
<NavBar
aria-label="Main"
value={pathname}
onValueChange={(href) => router.push(href)}
>
<NavBarItem href="/home" label="Home" />
<NavBarItem href="/reports" label="Business Reports" />
</NavBar>
```
`NavBarItem` has no `value`: `href` is its identity. On web it renders a real
`<a href>` (native ignores it), and the item cancels the browser's own
navigation — routing stays the app's job, through `onValueChange` or `onPress`.
A disabled item drops its `href`, since a disabled pressable never sees the
press that would cancel it.
### Per-item onPress, and expo Router links
An item may navigate itself; its `onPress` then replaces the group's
`onValueChange` for that item. The group must be controlled in that case — its
internal value is never updated. A custom handler that routes must call
`event.preventDefault()` on web, or the anchor reloads the page under it.
`<Link asChild>` injects exactly those two props (`href` and a `preventDefault`
ing `onPress`), so it composes without repeating the route:
```tsx
import { Link } from "expo-router";
<NavBar aria-label="Main" value={pathname}>
<Link href="/home" asChild>
<NavBarItem label="Home" />
</Link>
</NavBar>;
```
### Vertical rail
`NavBar` takes `orientation="vertical"`: the same lowered track, stacked. Each
item keeps its 44px tap target and its chip spans the bar's width instead of
shrinking to its label. The bar is content-width — give it a `className` width
for a fixed rail. `Tabs` and `RadioButtonGroup` stay horizontal.
```tsx
<NavBar
aria-label="Main"
className="w-[220px]"
orientation="vertical"
value={pathname}
onValueChange={router.push}
>
<NavBarItem href="/home" label="Home" icon={<HouseRegularIcon />} />
<NavBarItem href="/reports" label="Business Reports" />
</NavBar>
```
`stretch` is the horizontal counterpart: the bar fills the width it is given and
its items share it equally, instead of hugging its destinations. That is what the
stacked line of an `AppHeader` wants (alouette-layout/SKILL.md).
### Icon-only pill
`variant="icon"` (on `NavBar`, `Tabs` and `RadioButtonGroup` alike) turns the bar
into a pill of square icon-only chips. The item renders its `icon` alone and
`label` stays its accessible name — so `label` is still required and
`getByRole(…, { name })` keeps working, and an item without an `icon` renders an
empty chip.
```tsx
<Tabs aria-label="View" variant="icon" defaultValue="list">
<Tab value="list" label="List" icon={<ListRegularIcon />} />
<Tab value="grid" label="Grid" icon={<SquaresFourRegularIcon />} />
</Tabs>
```
### Leading icon
`icon` takes a rendered icon element and is auto-sized and auto-tinted from the
item's selected/disabled state. `activeIcon` — typically the duotone twin of the
same glyph — replaces it while the item is hovered, focused or pressed, and for
as long as the item is selected: the current page in a `NavBar`, the selected
`Tab`, the checked `RadioButton`. A disabled item never swaps.
```tsx
import { HouseDuotoneIcon } from "alouette-icons/phosphor-icons/HouseDuotoneIcon";
import { HouseRegularIcon } from "alouette-icons/phosphor-icons/HouseRegularIcon";
<NavBarItem href="/home" label="Home" icon={<HouseRegularIcon />} />
<NavBarItem
href="/home"
label="Home"
icon={<HouseRegularIcon />}
activeIcon={<HouseDuotoneIcon />}
/>;
```
`activeAccent` tints `activeIcon` with an accent of its own, so the glyph changes
color as well as weight. It is on `NavBarItem`, `Tab` and `RadioButton` only —
`Button`, `IconButton` and `MenuItem` take `activeIcon` but not `activeAccent`.
```tsx
<NavBarItem
href="/archive"
label="Archive"
icon={<TrashRegularIcon />}
activeIcon={<TrashDuotoneIcon />}
activeAccent="danger"
/>
```
### Accent and disabled
`accent` themes the whole bar (the group wraps itself in the accent theme);
`disabled` on the group disables every item, `disabled` on an item disables just
that one.
```tsx
<Tabs aria-label="Period" accent="brand" defaultValue="week">
<Tab value="week" label="Week" />
<Tab disabled value="month" label="Month" />
</Tabs>
```
### Wiring tab panels
`Tab` passes `id` and `aria-controls` through; render the panel yourself and
point it back at the tab. `Tabs` renders no panel.
```tsx
<Tabs aria-label="Ranges" value={range} onValueChange={setRange}>
<Tab id="tab-week" aria-controls="panel-week" value="week" label="Week" />
</Tabs>
<Surface role="tabpanel" id="panel-week" aria-labelledby="tab-week">…</Surface>
```
### Breadcrumbs — the trail to the current page
`Breadcrumbs` is a `navigation` landmark holding `BreadcrumbItem`s from the root
down to the page being viewed. It is not a segmented bar: it has no ground of its
own, wraps on a narrow screen, and separates its crumbs with a caret (`separator`
takes another icon element). Every crumb but the last is a `LinkText`; the last
one is the current page, rendered as plain text carrying `aria-current="page"`.
```tsx
import { BreadcrumbItem, Breadcrumbs } from "alouette";
<Breadcrumbs onNavigate={router.push}>
<BreadcrumbItem href="/" label="Home" icon={<HouseRegularIcon />} />
<BreadcrumbItem href="/reports" label="Reports" />
<BreadcrumbItem href="/reports/q3" label="Q3" />
</Breadcrumbs>;
```
`onNavigate` receives the pressed crumb's `href` and cancels the anchor's own
navigation — routing stays the app's job. Without it (and without an item
`onPress`) the `<a>` navigates on web and native does nothing. `<Link asChild>`
composes here too, injecting the `href` and a `preventDefault`ing `onPress`. Give
the last crumb its own `href` anyway: the trail decides which one is current, by
position.
## Common Mistakes
### HIGH Passing an options array instead of children
Wrong:
```tsx
<Tabs options={[{ value: "day", label: "Day" }]} />
```
Correct:
```tsx
<Tabs aria-label="Period" defaultValue="day">
<Tab value="day" label="Day" />
</Tabs>
```
These are compose-children groups, like `RadioButtonGroup`. There is no
`options` prop; each child reads the selected value from the group's context.
Source: packages/alouette/src/ui/navigation/Tabs.tsx
### HIGH Using Tabs for route navigation (or NavBar for in-page views)
Wrong:
```tsx
<Tabs value={pathname} onValueChange={router.push}>
…
</Tabs>
```
Correct:
```tsx
<NavBar aria-label="Main" value={pathname} onValueChange={router.push}>
…
</NavBar>
```
The two render the same material but expose different semantics: `tab` promises
a panel on the same screen, `link` + `aria-current="page"` promises a
destination. Assistive tech announces them differently.
Source: packages/alouette/src/ui/navigation/NavBar.tsx; ui/navigation/Tabs.tsx
### HIGH Navigating with a RadioButtonGroup, or a Link wrapped around a Text
Wrong:
```tsx
<RadioButtonGroup value={pathname} onValueChange={router.push}>
<RadioButton value="/home" label="Home" />
</RadioButtonGroup>
<Link href="/reports">
<Text className="text-accent">Go to reports</Text>
</Link>
```
Correct:
```tsx
<NavBar aria-label="Main" value={pathname}>
<Link href="/home" asChild>
<NavBarItem label="Home" />
</Link>
<Link href="/reports" asChild>
<NavBarItem label="Business Reports" />
</Link>
</NavBar>
```
`RadioButtonGroup` renders the same bar, but it announces a form control:
`radiogroup` + `radio` + `aria-checked` promises a value being edited, not a
destination, and it emits no anchor on web. A `Link` around a `Text` is the
wrapper mistake from alouette-styling — the text gets no `interactive-*` state,
no focus-visible outline and no 44px target. Both cases are a `NavBar`; a
vertical list of destinations is `orientation="vertical"`, not a
`RadioButtonGroup`.
Source: packages/alouette/src/ui/navigation/NavBar.tsx; ui/inputs/RadioButtonGroup.tsx
### MEDIUM Item onPress on an uncontrolled group
Wrong:
```tsx
<NavBar aria-label="Main" defaultValue="/home">
<NavBarItem href="/settings" label="Settings" onPress={handlePress} />
</NavBar>
```
Correct:
```tsx
<NavBar aria-label="Main" value={pathname}>
<NavBarItem href="/settings" label="Settings" onPress={handlePress} />
</NavBar>
```
`onPress` replaces the group's selection callback, so the uncontrolled internal
value stays where it was and the bar never moves its chip.
Source: packages/alouette/src/ui/navigation/NavBarItem.tsx
### MEDIUM Navigating from an item onPress without preventDefault
Wrong:
```tsx
<NavBarItem
href="/settings"
label="Settings"
onPress={() => router.push("/settings")}
/>
```
Correct:
```tsx
<Link href="/settings" asChild>
<NavBarItem label="Settings" />
</Link>
```
An item with an `href` is a real anchor on web, so a handler that routes in JS
must cancel the browser default or the page reloads on top of the JS
navigation. The built-in handler does it; a custom one must too.
Source: packages/alouette/src/ui/navigation/NavBarItem.tsx
### MEDIUM Hand-rolling the bar with PressableBox
Wrong:
```tsx
<Surface variant="lowered" className="flex-row">
<PressableBox variant="ghost">…</PressableBox>
</Surface>
```
Correct: use `NavBar` / `Tabs`. The shared `SegmentedBar` / `SegmentedItem`
already give the 44px tap target inside a 44px bar, the chip cross-fade, the
`focus-visible` outline and the hover/active border — a hand-rolled bar drifts
from all four.
Source: packages/alouette/src/ui/selection/SegmentedBar.tsx; ui/selection/SegmentedItem.tsx
### LOW Omitting aria-label on the group
`NavBar` renders a navigation landmark and `Tabs` a tab list; both should be
named, especially when a screen has more than one.
Source: packages/alouette/src/ui/navigation/NavBar.tsx
See also: alouette-forms/SKILL.md for `RadioButtonGroup`, the same material with
`radiogroup` semantics; alouette-icons/SKILL.md for importing icon elements.