# @dloizides/ui-nav

Config-driven, brand-agnostic React Native (RN-web) **navigation shell** for the
dloizides.com portfolio: `Sidebar` + `Topbar`, promoted from the byte-identical
erevna-web / katalogos-web nav twins. The *chrome* is shared; the *item data* stays with
your app — the components render a caller-supplied `NavItem[]` (labels already localized,
icons as render slots) and route every colour through the shared `@dloizides/ui-feedback`
UiProvider theme.

## Install

```bash
npm install @dloizides/ui-nav @dloizides/ui-feedback @dloizides/auth-web
```

Peer deps: `@dloizides/ui-feedback >= 1.2.0`, `@dloizides/auth-web >= 1.5.0`, `react >= 18`,
`react-native >= 0.74` (use `react-native-web` on web).

## Usage

```tsx
import { Sidebar, Topbar, accessibleNavItems, type NavItem } from '@dloizides/ui-nav';

const items: NavItem[] = buildItems(); // your role-filtered / grouped items

<Sidebar
  items={items}
  pathname={usePathname()}
  onNavigate={(route) => router.push(route)}
  title={FM('menu.title')}
  regionLabel={FM('accessibility.navigationRegion')}
  navigateHint={(label) => FM('menu.navigateToHint', label)}
  expandHint={FM('menu.expandSection')}
  collapseHint={FM('menu.collapseSection')}
  renderChevron={(open, color, size) => <SvgIcon name={open ? 'chevronUp' : 'chevronDown'} color={color} size={size} />}
  header={<HomeShortcut />}
  footer={<><DarkModeToggle /><LogoutButton /></>}
/>

<Topbar
  left={<TenantLogo />}
  language={{ label: FM(`topbar.lang.${locale}`), hint: FM('topbar.langHint'), onPress: toggleLocale }}
  notificationSlot={<NotificationBell />}
  user={{ name: displayName, email }}
  logout={{ label: FM('topbar.logout'), hint: FM('topbar.logoutHint'), onPress: logout, testID: 'logout-button' }}
/>
```

### Horizontal top bar (`NavBar`)

Prefer the nav links **on top** (a horizontal menu bar) instead of the left rail?
`NavBar` renders the SAME `NavItem[]` model horizontally — brand slot on the left,
inline links, a free-form right slot (auth / user / logout) — and collapses the
links behind a hamburger below a configurable breakpoint (default `760`, matching
the v1 AML console `.global-nav`). It is purely additive; `Sidebar` is unchanged.

```tsx
import { NavBar, type NavItem } from '@dloizides/ui-nav';

<NavBar
  items={items}                              // same role-filtered NavItem[] as Sidebar
  pathname={usePathname()}
  onNavigate={(route) => router.push(route)}
  regionLabel={FM('accessibility.navigationRegion')}
  navigateHint={(label) => FM('menu.navigateToHint', label)}
  brand={<BrandLogo />}                       // left slot
  right={<AuthLink />}                         // right slot (auth / user / logout)
  menuLabel={FM('menu.toggle')}               // a11y label for the hamburger
  menuHint={FM('menu.toggleHint')}
  collapseBelow={760}                          // hamburger under this viewport width
  overflowLabel={FM('menu.more')}             // "More ▾" priority+ trigger label
  overflowHint={FM('menu.moreHint')}
/>
```

**Priority+ overflow ("…" menu).** Above `collapseBelow` the links sit on ONE
non-wrapping row. When they don't all fit, a measure pass keeps the leading items
that fit inline and collapses the rest behind a **"More ▾"** trigger — the shared
`@dloizides/ui-layout` `ModalDropdown` — so no item is ever cut off; the surplus
moves into a portalled, keyboard-navigable dropdown. If the active route lives in
the overflow set, the trigger wears the accent pill so it stays reachable. Localize
the trigger with the optional `overflowLabel` (visible + accessible, default `"More"`)
and `overflowHint`. Below `collapseBelow` the whole set still collapses into the
responsive hamburger drawer, so the two mechanisms never overlap.

### One component, two orientations (`Nav`)

`Nav` renders EITHER the horizontal `NavBar` or the vertical `Sidebar` from the same
`NavItem[]`, chosen by a single `orientation` prop — flip between a top bar and a side
rail without swapping components or re-plumbing props. `orientation` defaults to
`"horizontal"`; the remaining props are a discriminated union of the delegate's props
(TypeScript enforces the right set per orientation — e.g. `title` only on vertical,
`collapseBelow` / `overflowLabel` only on horizontal).

```tsx
import { Nav, type NavItem } from '@dloizides/ui-nav';

<Nav orientation="horizontal" items={items} pathname={p} onNavigate={go} regionLabel={r} />  // NavBar
<Nav orientation="vertical"   items={items} pathname={p} onNavigate={go} title={t} regionLabel={r} />  // Sidebar
```

`Nav` is purely additive: `Sidebar` / `Topbar` / `NavBar` stay exported and unchanged.

### One shell, three layouts — top | side | **both** (`NavShell`)

Where `Nav` answers the single-axis question (a `NavBar` **or** a `Sidebar`),
`NavShell` owns the whole page composition and renders a **top bar**, a **side
rail**, or **BOTH together**, chosen by a single `layout` prop. It wires a top bar
into `AppShell`'s `header` slot and a side rail into its `sidebar` slot, and
inherits every `AppShell` capability (auth gate, loading/error/forbidden state
cards, width policy, banner, the responsive hamburger + overlay drawer).

```tsx
import { NavShell, type NavItem } from '@dloizides/ui-nav';

// layout="both" — a top bar (brand / global links + overflow "…" / user) AND a
// side rail (sections) together (the AML-console shape), now first-class.
<NavShell
  layout="both"
  pathname={usePathname()}
  onNavigate={(route) => router.push(route)}
  regionLabel={FM('a11y.nav')}
  topBar={{
    items: globalItems,                 // top links (overflow-capable)
    brand: <BrandLogo />,
    right: <><LanguageSwitcher /><UserChip /></>,
    overflowLabel: FM('menu.more'),
    barTheme: darkChromeTheme,          // optional: dark bar over a light page
    contentMaxWidth: 1180,              // optional: full-bleed bar, inner capped + centred
  }}
  sideRail={{
    items: sectionItems,                // side sections (leaf + expandable)
    title: FM('menu.title'),
    footer: <><DarkModeControl … /><LogoutButton /></>,
    collapsed: { range: { min: 768, max: 1024 } },   // optional 3-tier icon rail
  }}
  gate={{ pending, authenticated, onRedirect: () => router.push('/login') }}
  state={{ loading, error, forbidden }}
  width={{ max: 1180, wideMax: 1400, wideMinViewport: 1600 }}
  testID="app-shell"
>
  <PageContent />
</NavShell>
```

- `layout="top"` → a top bar only (`topBar`); no rail.
- `layout="side"` → a header + a persistent left rail of sections (`sideRail`); pass a
  structured `<Topbar>` as `header` for the language/notification/user slots.
- `layout="both"` → both, together.

`NavShell` composes existing components, so it stays purely additive — `AppShell`,
`NavBar`, and `Sidebar` are unchanged.

### Three-tier responsive rail (`AppShell.collapsedSidebar` / `CollapsedRail`)

Supply a `collapsedSidebar` (or `NavShell`'s `sideRail.collapsed`) to opt the rail
into the erevna/katalogos scheme: the full rail on desktop, an intermediate
persistent **collapsed (icon-only) rail** on a tablet band `[min, max)` (default
`{ min: 768, max: 1024 }`), and the overlay drawer on a phone. Omit it and the
layout stays the unchanged two-tier scheme (full rail ≥768px, drawer below).
`CollapsedRail` renders the icon rail from the same `NavItem[]` (each item's
`renderIcon`, or a first-letter glyph fallback), with header/footer slots. The pure
`resolveRailMode` decides the tier and is directly unit-testable.

### Dark bar over a light page (`NavBar.barTheme`)

Pass `barTheme` (a `UiTheme`) to re-provision the theme for the bar **only** — a
dark appbar above a light workspace (the AML v1 console). The live `t` / `navigate`
are re-passed so labels keep localizing; every colour still flows through the theme
(style-only). Custom appbar backgrounds (e.g. a radial-glow gradient) stay
expressible via `containerStyle`.

### Full-bleed bar with a centred content column (`NavBar.contentMaxWidth`)

Pass `contentMaxWidth` (px) — via `NavBar.contentMaxWidth` or
`NavShell.topBar.contentMaxWidth` — to make the bar **full-bleed** while its content
stays in a centred column. The OUTER container (which carries the bar background +
bottom border from `barTheme` / theme surface + any `containerStyle`, e.g. AML's
radial-glow appbar) spans the full viewport width; the INNER row (brand + links +
right) is capped to `contentMaxWidth` and centred — the v1-console `.ui-appbar`
(full-bleed) + `.ui-appbar-inner { max-width; margin: 0 auto }` shape. Omit it and
the inner spans the full container (unchanged). The priority+ overflow "…" fit
measures the capped inner's rendered width, so it keeps working against the content
column, not the full-bleed container.

### Cross-navigation pills (`PillNav`)

A row of fully-rounded pills from a `NavItem[]` — kefi's coral dashboard switcher.
The active pill wears the accent (theme `palette.primary`). Folds in kefi's rule:
it hides (renders `null`) unless there are at least `minItems` entries (default 1;
kefi passes `2`). Drop it into `AppShell`/`NavShell`'s `nav` slot.

### Colour-scheme control (`DarkModeControl`)

A controlled Light / Dark / System control (erevna/katalogos), in two variants:
`"segmented"` (a highlighted segment row) and `"cycle"` (an icon+label button that
advances to the next option per press). Options are pre-localized and value-agnostic
(strings), so map your own preference enum without this package importing it. Put it
in a `Sidebar`/`CollapsedRail` footer or a `NavBar` right slot.

An app that renders a `Sidebar` today switches to a top bar by swapping the
`Sidebar` element for `NavBar` (same `items` / `pathname` / `onNavigate`) — e.g.
the AML v2 app drops `NavBar` into the `AppShell` `header` (or `nav`) slot so its
links sit across the top like the rest of the console.

Each link is a rounded-pill target with the v1 `.gn-links` affordance: muted at
rest, brightening its text + showing a subtle pill background on **hover**, and a
solid accent pill when **active**. All colours are theme-driven (rest =
`textSecondary`, hover text = `text`, hover bg = `surfaceElevated`, active/ring =
`palette.primary`); hover/focus are web-only and the hover cross-fade honours
`prefers-reduced-motion`.

**Accessibility.** The container is a `role="navigation"` landmark named by
`regionLabel`; active links expose `aria-current="page"` + `role="link"`; the
responsive hamburger carries `role="button"`, `aria-expanded`, and `aria-controls`
pointing at the collapsible links region; every link/toggle is keyboard-operable
(Enter/Space) with a themed focus ring and a ≥44×44 touch target. `Sidebar` leaves
carry `aria-current="page"` and `Topbar` buttons a focus ring under the same bar.

### Search — desktop inline field · mobile ⌘K palette

The rail can be searched two ways, chosen by viewport. `resolveSearchAffordance(viewport)`
is the pure rule: `'inline'` at/above `DEFAULT_SEARCH_BREAKPOINT` (768px), `'palette'` below.

- **Desktop (wide):** opt the `Sidebar` into a persistent inline field that narrows the nav
  list live as you type. It is OFF by default — pass `enableInlineSearch` + pre-localized
  `search` labels:

  ```tsx
  <Sidebar
    items={items} pathname={p} onNavigate={go} title={t} regionLabel={r}
    enableInlineSearch
    search={{
      placeholder: FM('nav.search'), hint: FM('nav.searchHint'),
      clearLabel: FM('nav.clear'), clearHint: FM('nav.clearHint'),
      emptyText: FM('nav.noMatches'),
    }}
  />
  ```

  The sidebar owns the query (uncontrolled) unless you pass `searchQuery` + `onSearchChange`.
  Filtering is the pure `filterNavItems(items, query)` (all query tokens must match a label;
  a matched group keeps its children, a group with a matched descendant is kept pruned to it).

- **Mobile (narrow):** keep the ⌘K `CommandPalette` (a persistent field costs too much
  vertical space). The `useCommandPaletteHotkey` shortcut stays bound at every width.

So a shell renders `resolveSearchAffordance(width) === 'inline' ? <Sidebar enableInlineSearch …/>
: <CommandPalette …/>` (or simply passes `enableInlineSearch` only on the full-rail branch).

### Role gating

`accessibleNavItems(user, roleRouteTable, translate)` builds the `NavItem[]` a user's roles
unlock, reusing `resolveAccessibleRoutes` from `@dloizides/auth-web` (most privileged first,
empty when none) — no duplicated role logic.

## License

MIT
