@dloizides/ui-nav
v1.26.0
Published
Config-driven, brand-agnostic React Native (RN-web) navigation shell — Sidebar + Topbar promoted from the byte-identical erevna-web/katalogos-web nav twins. Renders a caller-supplied NavItem[] (pre-localized labels, icon render slots), routes colours thro
Maintainers
Readme
@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
npm install @dloizides/ui-nav @dloizides/ui-feedback @dloizides/auth-webPeer 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
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.
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).
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} /> // SidebarNav 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).
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>asheaderfor 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
Sidebarinto a persistent inline field that narrows the nav list live as you type. It is OFF by default — passenableInlineSearch+ pre-localizedsearchlabels:<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 purefilterNavItems(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). TheuseCommandPaletteHotkeyshortcut 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
