UNPKG

alouette

Version:

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

393 lines (304 loc) 13.2 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. 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.