UNPKG

alouette

Version:

A modern, customizable design system built on top of NativeWind v5 with configurable defaults

419 lines (327 loc) 14.4 kB
--- 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, and AppHeaderNav carries the same semantics as text links on an application bar. 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 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/layout/AppHeaderNav.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/layout/AppHeaderNavItem.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). ### Navigation on an application bar A header that has to fit brand, navigation and session on one line takes `AppHeaderNav` + `AppHeaderNavItem` (alouette-layout/SKILL.md) instead: the same `navigation` / `link` / `aria-current="page"` semantics and the same `href`-as-identity, over a lighter material — text destinations on the bar itself, the current one underlined in the group's accent, no track and no chip. It also takes a `badge` after the label, which the segmented item has no room for. Reach for `NavBar` when the navigation is the screen's main control (the stacked line of a header, a sidebar rail), and for `AppHeaderNav` when it shares the bar with everything else. ```tsx <AppHeaderNav aria-label="Main" value={pathname} onValueChange={router.push}> <AppHeaderNavItem href="/home" label="Home" icon={<HouseRegularIcon />} /> <AppHeaderNavItem href="/inbox" label="Inbox" aria-label="Inbox, 3 unread" badge={<Badge size="sm">3</Badge>} /> </AppHeaderNav> ``` ### 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> <Box className="surface" role="tabpanel" id="panel-week" aria-labelledby="tab-week">…</Box> ``` ### 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 <Box className="surface lowered flex-row"> <PressableBox variant="ghost">…</PressableBox> </Box> ``` 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.