@dloizides/ui-nav 1.38.0 → 1.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,556 +1,560 @@
1
- # Changelog
2
-
3
- ## 1.38.0
4
-
5
- - NAV-BRAND-1 D-NAVB-3 "One brand options prop": `NavShell` takes one `brand={{ placement, railWidth, drawerHeader, drawerWidth }}` prop instead of the four 1.37.0 props; `Sidebar.width` removed (the shell sets the rail width through `containerStyle`). The top-bar brand cell carries the top bar background and bottom border, so the header line runs over the rail.
6
-
7
- ## 1.37.0
8
-
9
- - NAV-BRAND-1 "Wider sidebar, logo and name in the top bar": `NavShell` gains `brandPlacement`, `sidebarWidth`, `drawerHeader`, `drawerWidth` (defaults = today); `Sidebar.width`; `AppShell.mobileMenu` takes `header` / `width` / `sidebar`. Exports `SIDEBAR_WIDTH_PX`, `NAV_BRAND_DEFAULTS`.
10
-
11
- ## 1.35.0
12
-
13
- - NAV-SCROLL-1 "Opening sidebar groups never hides the items below" (AC-UI-1, AC-UI-2): `Sidebar` items (header slot + list) scroll in a `ScrollView` between the pinned title/search and the pinned footer, with a `theme.colors.surface` fade on each edge that hides items. Inside `MobileDrawer` opening a top-level group closes the other (through `NavAccordionContext`, which `MobileDrawer` provides; no new `Sidebar` prop). `NavExpandableItem` takes optional controlled `expanded` + `onToggle`. No prop changes for existing consumers.
14
-
15
- ## 1.28.0
16
-
17
- - INVOICE-5 "Edit invoices before sending, shared background and menu hover" (AC-UI-3, AC-UI-6): `AppShell` draws `theme.backdrop` (the `@dloizides/theme-web` 1.5.0 `resolveBackdrop` shape, typed here as `AppShellBackdrop`): its colour and web image on the root, and for `horizon` a brand band behind header and nav that the content overlaps by `overlap.compact` (< 768 px) or `overlap.wide`. No `theme.backdrop` keeps today's flat background.
18
-
19
- ## 1.27.0
20
-
21
- - INVOICE-5 "Edit invoices before sending, shared background and menu hover" (AC-UI-2, AC-UI-6): `NavBar` takes `hoverFeedback` (`NavHoverFeedback`: `default`, `tint-pill`, `underline`, `lift`, `sliding-highlight`); the new phone `BottomTabBar` takes `tabFeedback` (`TabFeedback`: `default`, `indicator-pill`, `dim-spring`, `sliding-bar`, `icon-lift`). Timings come from the `@dloizides/ui-motion` tokens (<= 300 ms); reduced motion keeps every option still. `default` keeps today's look, so existing apps do not change.
22
-
23
- ## 1.26.2
24
-
25
- - KEFI-LAUNCH-1 "Do every open Kefi task before the public launch": a long account name no longer overlaps the `Topbar` center slot at 360 px. The center slot keeps its content width (`flexShrink: 0`); only the account name gives way. The overlap dates from 1.24.0, when the slot was added.
26
-
27
- ## 1.26.1
28
-
29
- - KEFI-LAUNCH-1 "Do every open Kefi task before the public launch": the `Topbar` left slot no longer wraps beside a long account name at 360 px. The left slot keeps its width (`flexShrink: 0`) and the account name truncates to one line.
30
-
31
- ## 1.25.0
32
-
33
- - `PillNav` / `PillNavMenu` get an optional `menuHeading`: a visible, non-focusable heading at the top of the open `Menu` that also names it (needs `@dloizides/ui-layout` >= 1.30.0) (KEFI-EDITOR-HEADER-1 "Compact the landing editor header").
34
-
35
- ## 1.24.0
36
-
37
- - `Topbar` gets an optional `center` slot between `left` and the actions (testID `topbar-center`); callers that do not pass it render as before (KEFI-EDITOR-HEADER-1 "Compact the landing editor header").
38
- - `PillNav` gets `variant={PillNavVariant.Menu}`: one compact "Current ▾" trigger (44px pill) opening a menu of the same items through the shared `ModalDropdown` (role menu, arrow keys / Home / End / Enter / Escape, highlight starts on the current item). New optional props `menuHint`, `menuTestID`, `dropdownVariant`; new exports `PillNavVariant`, `resolvePillMenuLabel`. Default stays `Pills`.
39
-
40
- ## 1.23.2
41
-
42
- - Topbar account-name button and language toggle get a 44x44 minimum hit box (KEFI-HEADER-HITBOX-1 "kefi-web header account control reaches 44x44").
43
-
44
- ## 1.23.1
45
-
46
- **Overlay z-indexes come from the shared `@dloizides/design-tokens` `Z_INDEX` scale (UI-ZINDEX-SCALE-1 "Shared overlay z-index scale").**
47
-
48
- - `MobileDrawer` overlay uses `Z_INDEX.drawer`; `CommandPalette` overlay uses `Z_INDEX.palette`.
49
- - Values are unchanged (50 / 60), so stacking behaviour is identical. The drawer's scrim/panel
50
- order (1 / 2) is local to the overlay and stays as is.
51
- - New dependency: `@dloizides/design-tokens` `^1.13.0`.
52
-
53
- ## 1.23.0
54
-
55
- **The NavBar's stacked mobile menu now scrolls within the viewport (SHOWCASE-1).**
56
-
57
- Below `collapseBelow` the open hamburger menu grew to its natural height (734px with ~15 items on
58
- ui.dloizides.com at 360x640). Portals set `body { overflow: hidden }`, so the last items sat below
59
- the fold and neither wheel nor touch could reach them — in every portal with more than ~10 items.
60
-
61
- - The stacked links region is now a `ScrollView` capped to `windowHeight - closedBarHeight -
62
- stackedRightSlot - chrome` (pure `computeStackedMenuMaxHeight`, unit-tested; floor of three 44px
63
- rows). Rows keep their 44px minimum; nothing depends on hover.
64
-
65
- ## 1.20.0
66
-
67
- **The auth-pending and page-loading spinners now OVERLAY stably-mounted children instead of
68
- replacing them — closing a latent deep-link-collapse trap in the shared shell.**
69
-
70
- `AppShell`'s `gate.pending` branch and `appShellContent`'s `state.loading` branch each `return`ed a
71
- full-screen spinner *instead of* `children`. Replacing the subtree unmounts whatever `children` host
72
- — in a portal that is a navigator/`<Slot/>` — and on remount React Navigation re-derives its default
73
- route, so a deep-linked sub-route silently collapses to its default on every reload. (This is the
74
- exact class of bug that hit kefi-web's organizer portal, fixed there in its `OnboardingGate`; the
75
- same shape lived dormant here because no consumer passes `gate=`/`state=` yet.)
76
-
77
- - **`AppShell` (`gate.pending`)** now renders the shell tree normally and paints the pending spinner
78
- as an opaque `position:absolute` overlay on top, so `children` mount once and are never torn down
79
- across `pending → authenticated` (proven by a "same child instance, no remount" test).
80
- - **`appShellContent` (`state.loading`)** likewise overlays the loading spinner over mounted
81
- `children` rather than swapping them out. The `loadingHost` wrapper is gated on `state` being
82
- DEFINED, not on `loading`'s live value — so the element type at the mount point is invariant across
83
- a `loading` true↔false toggle and `children` are reconciled in place, never remounted on the edge
84
- (also proven by a "same child instance, no remount" test). Consumers that pass no `state` render
85
- `children` bare, byte-identical to 1.19.0. Error / forbidden cards are TERMINAL and still replace;
86
- unauthenticated still returns null + `onRedirect`.
87
- - **Rule encoded in the docblock:** a *transitional* status overlays; only a *terminal* state
88
- replaces. Corollary for consumers — `children` mount beneath the pending overlay, so gate data
89
- FETCHES on auth state (`enabled: isAuthenticated`), not on mount. No public API changed; behavior
90
- is unchanged for any consumer not passing `gate`/`state`.
91
-
92
- ## 1.19.0
93
-
94
- **The "More ▾" overflow menu now opens at a readable width instead of cramping onto the trigger's tiny footprint.**
95
-
96
- `NavOverflowMenu` rendered the shared `@dloizides/ui-layout` `ModalDropdown` without a `menuMinWidth`,
97
- so the popover matched the width of the compact "More ▾" pill (~its own label). Option labels
98
- ("Settings", "API docs") wrapped onto two lines, and the resulting tall rows overflowed the menu's
99
- max-height — so the lower items scrolled out of view and read as missing.
100
-
101
- - **`NavOverflowMenu`** now passes a 200px `menuMinWidth` floor, so the overflow menu opens wide
102
- enough to show each label on one line. `ModalDropdown`/`InlineMenu` clamp a floored menu inside the
103
- viewport (ui-layout ≥ 1.24.0, now the peer floor), so the extra width never pushes it off the right
104
- edge. No public API changed.
105
-
106
- ## 1.18.0
107
-
108
- Root-cause fix for sidebar leaf co-activation. Behavioral/additive — no breaking API.
109
-
110
- - **Fix (two sidebar leaves showed `aria-current="page"` at once).** On the live finreg
111
- `/accounting/journals`, BOTH "Chart of Accounts" (route `/accounting`) and "Journals"
112
- (route `/accounting/journals`) were marked active, because the active predicate was purely
113
- prefix-based (`pathname === route || pathname.startsWith(route + '/')`) — a module-home leaf
114
- routed at `/accounting` is a prefix of every `/accounting/*` sibling, so it co-activated on all
115
- of them. (Second occurrence of this class; the first, CRM Contacts at `/crm`, was worked around
116
- by moving the route.) Now resolution is **most-specific (longest) match wins**: among a nav
117
- model's LEAF items, at most ONE is active for any path — the leaf whose route is the longest
118
- prefix match. On `/accounting/journals` only Journals is active; on `/accounting` exactly only
119
- Chart of Accounts is. For nav models with no parent/child route overlap (the common case)
120
- behaviour is unchanged.
121
- - **New export** `resolveActiveRoute(items, pathname)`: flattens the tree to its leaf routes and
122
- returns the single longest matching route (honouring per-leaf `exact`), or `undefined`.
123
- - `Sidebar` resolves the winning route once over the full leaf set and passes `activeRoute` to
124
- each row; `NavExpandableItem` marks a leaf active iff `item.route === activeRoute` (falls back
125
- to the per-item predicate when used standalone — backward compatible). Group HEADER expansion
126
- (`hasActiveDescendant`) is a separate concern and is unchanged.
127
- - The same winner-based resolution now backs `NavBar`, `PillNav`, `CollapsedRail` and
128
- `NavOverflowMenu`, so the latent prefix co-activation cannot resurface in the horizontal navs.
129
- - **Additive API**: new optional `NavItem.exact?: boolean` — a leaf active ONLY on its exact
130
- path (never a nested one). A complementary escape hatch; the longest-match default is the real
131
- fix, so no consumer needs to set it.
132
- - `isRouteActive(pathname, route, exact?)` gains an optional third arg; the two-arg call is
133
- unchanged.
134
-
135
- ## 1.17.0
136
-
137
- Two live-defect fixes to the shared sidebar chrome. Behavioral/additive — no breaking API.
138
-
139
- - **Fix (two search bars on desktop → mutually exclusive by affordance).** 1.16.1 shipped the
140
- desktop inline `SidebarSearch` field, but the ⌘K `CommandPaletteTrigger` could be present at
141
- the same time, so a wide viewport showed BOTH. `Sidebar` now owns both affordances and renders
142
- exactly ONE, chosen by its own viewport width via `resolveSearchAffordance`:
143
- - inline affordance (≥768) → the inline field ONLY; the ⌘K trigger is suppressed.
144
- - palette affordance (<768) → the ⌘K trigger ONLY; the inline field is suppressed.
145
- - The ⌘K keyboard shortcut (`useCommandPaletteHotkey`) is untouched and stays bound at every width.
146
- - **Additive API**: new `Sidebar.paletteTrigger?: SidebarPaletteTriggerConfig`
147
- (`{ label, hint, shortcut, onOpen, testID? }`), forwarded from `NavShellSideRail.paletteTrigger`.
148
- Consumers that wire neither, or only the inline field, are unchanged (the inline field simply
149
- no longer renders below 768, where a persistent field would steal vertical space).
150
-
151
- - **Fix (hover wash never appeared on RN-web).** The nav rows were `TouchableOpacity`, which in
152
- react-native-web has NO hover support and silently drops `onHoverIn`/`onHoverOut` — so the row's
153
- `hovered` state never toggled and the primary-tinted overlay stayed at opacity 0. The leaf and
154
- group-header rows in `NavExpandableItem` are now `Pressable`, whose `useHover` responder actually
155
- drives `hovered`, lifting the token-tinted overlay to the hover wash (`HOVER_TINT_OPACITY`, ~160ms
156
- reduced-motion-gated transition) — distinct from and lighter than the active tint. Token-only
157
- (colours via the theme; no raw hex/rgb/hsl). aria wiring (`aria-current` / `aria-expanded`, which
158
- `MobileDrawer` depends on) is preserved across the switch.
159
-
160
- ## 1.16.1
161
-
162
- - **Fix (NavShell forwards inline-search props to the inner `Sidebar`).** 1.16.0 added
163
- desktop inline search to `Sidebar` (`enableInlineSearch`, `search`, controlled
164
- `searchQuery` / `onSearchChange`), but all seven portals consume the higher-level
165
- `NavShell` (via `sideRail`), which did **not** forward those props — so inline search was
166
- unreachable by every real consumer. `NavShellSideRail` now carries the same four fields
167
- (`enableInlineSearch?`, `search?: SidebarSearchConfig`, `searchQuery?`, `onSearchChange?`)
168
- and threads them onto the `<Sidebar>` it builds. Desktop-vs-mobile resolution is unchanged
169
- (inline ≥768, ⌘K palette <768 via `resolveSearchAffordance`).
170
- - **Purely additive / backward compatible**: with the fields unset, `NavShell` renders
171
- exactly as 1.16.0. No breaking change; no styling touched.
172
-
173
- ## 1.16.0
174
-
175
- - **Feature (docs-style sidebar rail — hover wash, tinted active pill, rotating chevron).**
176
- The `Sidebar` / `NavExpandableItem` rows now read like the aml-screening docs navigation:
177
- each row carries a PRIMARY-TINTED overlay whose opacity eases `0 → hover → active` as a
178
- smooth ~160ms CSS cross-fade on web (gated by `prefers-reduced-motion`, omitted on native).
179
- - **Hover** lights a row with a subtle brand wash (`HOVER_TINT_OPACITY` = 0.06); **active**
180
- deepens it to a soft brand pill (`ACTIVE_TINT_OPACITY` = 0.14) over the transparent
181
- left-accent gutter every leaf already reserved, so the active leaf shows a tinted pill + a
182
- coloured left bar + brand-coloured label — no layout shift, all from `theme.palette.primary`.
183
- - **Rotating chevron.** Expandable groups now get a built-in `SidebarChevron` (a `›` glyph
184
- that rotates to `⌄` on expand, tweened on web) when the caller supplies no `renderChevron`.
185
- A caller-supplied `renderChevron` still wins, so existing icon-set chevrons are unaffected.
186
- - Row corner radius softened to `NAV_ROW_RADIUS` (8px) with the rounded overlay clipped to it.
187
- - No public API change to existing props; hover uses RNW's `onHoverIn`/`onHoverOut` (no-op on
188
- native). New exports: `SidebarChevron`, `NAV_ROW_RADIUS`, `ACTIVE_TINT_OPACITY`,
189
- `HOVER_TINT_OPACITY`.
190
-
191
- - **Feature (desktop INLINE sidebar search — opt-in — with the ⌘K palette kept for mobile).**
192
- The owner found the ⌘K command-palette popup inconvenient on desktop. `Sidebar` now accepts
193
- an opt-in persistent inline search field at the top of the rail that narrows the nav list
194
- LIVE as you type.
195
- - **New `Sidebar` props (all additive, default OFF):** `enableInlineSearch` + a pre-localized
196
- `search: SidebarSearchConfig` (`placeholder` / `hint` / `clearLabel` / `clearHint` /
197
- `emptyText`), plus optional controlled `searchQuery` + `onSearchChange` (the sidebar owns the
198
- state when omitted). With the flag off the render tree is byte-identical to 1.15 — the mobile
199
- drawer, which should keep using the ⌘K palette, is untouched.
200
- - **New exports:** `SidebarSearch` (the field), `filterNavItems` / `isFilterActive` /
201
- `searchTokens` (the pure tree-prune, same all-tokens-must-match semantics as `filterCommands`
202
- so inline + ⌘K rank identically), and `resolveSearchAffordance(viewport)` +
203
- `DEFAULT_SEARCH_BREAKPOINT` — the pure `'inline'` (≥768px) vs `'palette'` (<768px) rule so a
204
- shell can wire desktop-inline / mobile-palette from one function. The ⌘K hotkey
205
- (`useCommandPaletteHotkey`) stays bound at every width.
206
-
207
- ## 1.15.0
208
-
209
- - **Feature (an expandable nav group now ANIMATES its expand/collapse instead of snapping).**
210
- `NavExpandableItem` used to conditionally render its children (`{expanded ? … : null}`),
211
- so a sidebar/drawer group's leaves appeared and vanished INSTANTLY on toggle — a hard,
212
- jarring cut with no transition. The group's children are now wrapped in the
213
- `@dloizides/ui-motion` `<Collapse open={expanded}>`, which measures the children's natural
214
- height and animates a real `Animated.Value` height on WEB and native alike (the height code
215
- path RN's native-only `LayoutAnimation` never gave the web build), collapsing to instant
216
- under `prefers-reduced-motion`.
217
- - **Collapsed leaves stay non-interactive.** Per the `Collapse` contract the children remain
218
- MOUNTED at all times (their state and a11y focus order are preserved); when collapsed they
219
- are clipped to zero height under `overflow: hidden`, so a collapsed group's leaves are
220
- neither visible nor tappable in a real browser even though they are in the tree. This is a
221
- deliberate change from the prior "absent from the DOM when collapsed" behaviour.
222
- - **`aria-expanded` and the mobile-drawer contract are intact.** The group header still emits
223
- `aria-expanded` (the `ariaExpandedProps` spread is unchanged), so `MobileDrawer` still tells
224
- a group TOGGLE apart from a navigating LEAF and does not dismiss the drawer when a group is
225
- expanded on mobile. The active-leaf accent bar, docs-style section-header styling, chevron
226
- slot, testIDs, and all a11y roles/labels/hints are unchanged.
227
- - **No public API change.** `NavExpandableItemProps` / `NavItem` are untouched — the animation
228
- is derived from the `expanded` state the component already tracked. `@dloizides/ui-motion`
229
- (`^1.0.1`) is added as a runtime dependency; all 7 portals gain the animated group on their
230
- next bump for free. Consumers with no expandable groups are visually unaffected.
231
-
232
- ## 1.13.1
233
-
234
- - **Fix (mobile drawer: a sub-item / leaf tap dismissed the drawer WITHOUT navigating).**
235
- On a phone-width viewport, tapping a leaf inside the open `MobileDrawer` closed the drawer
236
- but the route never changed — verified live on finreg via Playwright (real Chromium, real
237
- pointer events); every jsdom unit test stayed green because it wired `onClose` to an inert
238
- `jest.fn()` that never unmounts anything.
239
- - **Root cause.** `MobileDrawer` closes on a nav press via a NATIVE
240
- `panel.addEventListener('click', …)`. react-native-web fires a leaf's `onPress` from its
241
- `onClick`, dispatched through REACT's synthetic event system whose root listener sits on the
242
- app-root container — an ANCESTOR of the panel. So on a real click the native panel listener
243
- runs FIRST (during the DOM bubble), and closing synchronously there ran `setDrawerOpen(false)`,
244
- unmounting the panel and tearing the leaf out of the tree BEFORE React could dispatch its
245
- `onPress`. The navigation call never ran.
246
- - **Fix.** The drawer's close is now DEFERRED to the next macrotask (`setTimeout(…, 0)`), so
247
- React finishes dispatching the tapped leaf's `onPress` (the navigation) for the current click;
248
- the panel then unmounts on the following tick, after the route has already changed. Scrim tap
249
- and group-header toggle are unchanged.
250
- - **No API change.** All 7 portals get the fix on their next bump.
251
-
252
- ## 1.11.0
253
-
254
- - **Feature (an expandable nav group now auto-opens when the current route is inside it).**
255
- `NavExpandableItem` used to mount every group collapsed (`useState(false)`), so on a fresh
256
- page load the group containing the active route was closed and its active leaf was hidden.
257
- A persistent-rail console that groups its nav by module (e.g. finreg-web's Payments / CRM /
258
- Accounting rail) needs the active module's group open on load, the others collapsed — with
259
- no way for the caller to express that, since expansion state was internal and uncontrolled.
260
- - The group now initializes its expansion from whether the current route is inside it:
261
- `useState(() => hasActiveDescendant(item, pathname))`, where `hasActiveDescendant` is true
262
- when the item's own route is active (`isRouteActive`) or any descendant's is (recursive, so
263
- it holds at any nesting depth). The group of the active route mounts EXPANDED (active leaf
264
- visible + highlighted); a group with no active descendant mounts COLLAPSED — exactly the
265
- prior behaviour.
266
- - **No API change.** `NavItem` is unchanged — the behaviour is derived from the `pathname`
267
- the component already receives. Consumers with no expandable groups, or whose groups hold no
268
- active route, are byte-for-byte unaffected; all 7 portals gain the auto-open on their next
269
- bump for free.
270
- - Guarded by `src/navExpandableItem.test.tsx` (RED-first): the two "mounts EXPANDED" assertions
271
- FAIL against the pre-change `useState(false)` (the active group's child is absent from the DOM)
272
- and pass after; the "mounts COLLAPSED" + "header always renders" assertions lock the
273
- backward-compatible half.
274
-
275
- ## 1.10.2
276
-
277
- - **Fix (the mobile nav DRAWER was not clickable — a tap on a nav item did not navigate, it
278
- just closed the drawer / did nothing, in all seven portals below 768px).**
279
- `MobileDrawer` rendered a full-bleed scrim `Pressable` (`StyleSheet.absoluteFillObject`) and a
280
- panel `View` with `{ position: 'absolute', top: 0, bottom: 0, left: 0 }` — **no width and no
281
- zIndex**. Reproduced structurally: the panel's computed `width` was empty and its `zIndex` was
282
- `0`; the scrim's computed `left`/`right` were both `0px` at `zIndex` `0`. So the scrim FULLY
283
- overlapped the panel at the SAME stacking level, and on react-native-web the equal-zIndex scrim
284
- won the pointer (the estate's known "RN-web Pressable swallows a sibling/child press" trap):
285
- the tap closed the drawer and the nav-item's `TouchableOpacity` `onPress` (→ the app's router)
286
- never fired.
287
- - Fixed once, in the kit: the panel now has an explicit drawer **width** (`280px`), a solid
288
- surface background, and a `zIndex` **above** the scrim; the scrim is anchored BESIDE the panel
289
- (`left: DRAWER_WIDTH`) so its tap-to-close hit area never overlaps the panel. Nav-item taps
290
- reach the `Sidebar` leaves; taps beside the drawer still close it.
291
- - Guarded by `src/mobileDrawer.test.tsx` (RED-first): three structural assertions read the
292
- resolved geometry via `getComputedStyle` and FAIL against the pre-fix panel/scrim (no width,
293
- equal zIndex, overlapping scrim); two behavioural assertions lock the contract (nav-item tap
294
- navigates + closes; scrim tap only closes).
295
- - **No API change.** Consumers need no edit — bump the dependency and the drawer works.
296
-
297
- ## 1.10.1
298
-
299
- - **Fix (a plain-string slot logged `console.error` on EVERY render, in all seven portals).**
300
- Every caller-supplied slot in the kit is typed `React.ReactNode`, which legitimately includes
301
- `string` — and callers pass exactly that (`brand: FM('app.name')`, a plain localized string).
302
- The slot was dropped straight into a `<View>`, and react-native-web refuses to render a bare
303
- text node there: `Unexpected text node: <s>. A text node cannot be a child of a <View>.`
304
- Nothing in the type system objected, so the defect only ever surfaced as browser console
305
- noise — which is why it survived to 1.10.0 across the whole fleet.
306
- - Fixed once, in the kit, via the new internal `renderTextSlot` helper: a `string`/`number`
307
- slot is wrapped in `<Text>` (routed through the theme's `colors.text`, so it honours dark
308
- mode rather than defaulting to black); an element slot passes through **untouched**, so
309
- every existing caller renders byte-identically. Array slots have each string member wrapped
310
- individually, keys preserved.
311
- - Applied to **all 12 slot render sites**, not just the reported one — `NavBar` (`brand`,
312
- `right`), `Topbar` (`left`, `notificationSlot`), `Sidebar` (`header`, `footer`),
313
- `CollapsedRail` (`header`, `footer`), `AppShell` (`header`, `nav`, `sidebar`,
314
- `collapsedSidebar`, `banner`) and `MobileDrawer` (`sidebar`).
315
- - Guarded by `src/rawTextSlots.test.tsx`, which spies on `console.error` and asserts silence.
316
- Unit tests that only assert "the slot rendered" pass against the bug; this one does not.
317
- - **No API change.** Consumers need no edit — the `<Text>` wrapper they were expected to
318
- remember is now the kit's job.
319
-
320
- ## 1.10.0
321
-
322
- - **Fix (`layout` was not authoritative — `layout="side"` rendered a top bar anyway).**
323
- `wantsTopBar` carried an extra `(layout === 'side' && hasTopBar)` clause, so passing a
324
- `topBar` **config** with `layout="side"` mounted the bar regardless — making `'side'` and
325
- `'both'` render **pixel-identically** and `layout` unobservable. `layout` now decides which
326
- REGIONS exist; `topBar` is only the CONTENT of the top region. A `'side'` app that also wants
327
- a bar asks for `layout="both"`, which is what that combination always meant.
328
- - Found by `apps/ui-showcase`, which renders every orientation side by side — a product app
329
- picks ONE orientation and never sees the other, which is why this survived to 1.9.0.
330
- - **Not breaking in this fleet:** no app passed `layout="side"` + `topBar`. The four `'side'`
331
- portals (erevna, katalogos, zygos, agora) pass a `header` NODE, not a `topBar` config.
332
- - The `hasHeader` clause is **deliberately kept**: `header` is the documented escape hatch by
333
- which a `'side'` app supplies its own ready-made header (the structured `Topbar` with
334
- language / notification / user slots). It is an explicit "render THIS node", not a config
335
- whose presence is silently read as an orientation change.
336
- - **a11y: `AccountState.accountHint`** — the account-name button's `accessibilityHint` was
337
- hardcoded to `displayName`, byte-identical to its `accessibilityLabel`, so a screen reader
338
- announced the name twice and never said what pressing it does — and the consumer had no way
339
- to correct it. The sibling `upgrade?: TopbarAction` on the same interface has always carried
340
- a `hint`; this closes that inconsistency. Falls back to `displayName`, so existing callers
341
- are unaffected.
342
- - **a11y: `AccountState.accountTestID`** — the account-name button's testID was hardcoded, with
343
- no override, unlike `TopbarAction.testID?` / `NavItem.testID?` elsewhere in the package.
344
- - **a11y: `Topbar.language.testID`** — the inline `language` prop type declared no `testID`, so
345
- the repo's testID+label+hint standard was **structurally unsatisfiable** for that one button
346
- while every other Topbar action (all `TopbarAction`) accepted one.
347
-
348
- > Note for consumers: this react-native-web build does **not** forward `accessibilityHint` to
349
- > the DOM at all — rendered buttons carry `aria-label` but no hint attribute. The hint is a
350
- > NATIVE-only guarantee today. That is pre-existing and unchanged here, but it means DOM-level
351
- > tests cannot distinguish a threaded hint from a dropped one; `topbarA11y.test.tsx` therefore
352
- > pins the prop threading directly.
353
-
354
- ## 1.9.0
355
-
356
- **`NavBar.contentMaxWidth` — a full-bleed bar with a centred content column.**
357
- An optional content-column cap (px), forwarded via `NavShell.topBar.contentMaxWidth`.
358
- When set, the OUTER `NavBar` container — which carries the bar background + bottom
359
- border (from `barTheme` / theme surface + any `containerStyle`, e.g. the AML
360
- radial-glow appbar) — stays FULL WIDTH (full-bleed), while the INNER row (brand +
361
- links + right) is capped to `contentMaxWidth` and centred. This restores the v1-console
362
- `.ui-appbar` (full-bleed) + `.ui-appbar-inner { max-width; margin: 0 auto }` shape:
363
- previously an app had to put the cap on the container's `containerStyle`, which also
364
- capped the dark background and left light gutters on wide viewports. Omit it and the
365
- inner spans the full container (unchanged — byte-identical to before). The priority+
366
- overflow "…" fit measures the capped inner's rendered width, so it keeps working
367
- against the content column, not the full-bleed container. Purely additive.
368
-
369
- ## 1.8.0
370
-
371
- The navigation shell becomes the ONE shell for every portal: a first-class
372
- **top | side | both** layout, plus the capabilities harvested from every RN-web
373
- portal's bespoke nav/shell so nothing is lost when they migrate. Purely additive —
374
- `Sidebar`, `Topbar`, `NavBar`, `Nav`, `AppShell`, and the role helpers keep their
375
- exact signatures; every new prop/component is optional.
376
-
377
- **`NavShell` — top bar, side rail, or BOTH.** One component owns the whole page
378
- composition, chosen by a single `layout` prop (`'top' | 'side' | 'both'`). It wires
379
- a top bar into `AppShell`'s `header` slot and a side rail into its `sidebar` slot,
380
- and inherits every `AppShell` capability (auth gate, loading/error/forbidden state
381
- cards, width policy, banner, the responsive hamburger + overlay drawer). `'both'`
382
- is the AML-console shape — a top bar (brand / global links + overflow "…" / user)
383
- AND a side rail (sections) together — now first-class instead of hand-wired. The
384
- top bar is built from a `topBar` config (a `NavBar`), the side rail from a
385
- `sideRail` config (a `Sidebar`); an explicit `header` node overrides the built top
386
- bar for apps that want a structured `Topbar`.
387
-
388
- **Three-tier responsive rail (`AppShell.collapsedSidebar`).** Opts the `sidebar`
389
- layout into the erevna/katalogos scheme: the full rail on desktop, an intermediate
390
- persistent **collapsed (icon-only) rail** on a tablet band, and the overlay drawer
391
- on a phone. Supply a `collapsedSidebar` node (and optionally `collapsedRailRange`,
392
- default `{ min: 768, max: 1024 }`) — omit it and the layout stays the unchanged
393
- two-tier scheme. New `CollapsedRail` component renders an icon-only rail from the
394
- same `NavItem[]` (icon or first-letter glyph fallback, header/footer slots). New
395
- `resolveRailMode` pure resolver + `RAIL_FULL_BREAKPOINT` / `DEFAULT_COLLAPSED_RAIL_MAX`.
396
- `NavShell.sideRail.collapsed` builds it config-driven. New testID suffix
397
- `-collapsed-sidebar`.
398
-
399
- **`NavBar.barTheme` — dark bar over a light page.** An optional chrome-theme
400
- override re-provisions the `@dloizides/ui-feedback` theme for the bar only (the AML
401
- v1-console dark appbar over light content), re-passing the live `t` / `navigate` so
402
- labels keep localizing. Forwarded via `NavShell.topBar.barTheme`. Style-only —
403
- colours still flow through the theme. (Custom appbar backgrounds like AML's radial
404
- glow stay expressible via `containerStyle`.)
405
-
406
- **`PillNav` — coral cross-navigation (kefi).** A row of fully-rounded pills from a
407
- `NavItem[]`, the active one wearing the accent (theme `palette.primary` = kefi's
408
- coral). Folds in kefi's **visibility rule**: hides (renders `null`) unless there
409
- are at least `minItems` entries (default 1; kefi passes 2).
410
-
411
- **`DarkModeControl` — 3-way colour scheme (erevna/katalogos).** A controlled
412
- Light / Dark / System control in two variants: `"segmented"` (a highlighted
413
- segment row) and `"cycle"` (an icon+label button that advances to the next option
414
- on press). Value-agnostic string options, pre-localized labels/hints.
415
-
416
- **Refactor (no behaviour change).** `AppShell`'s page-state cards moved to
417
- `appShellContent.tsx` (`useContentBody` / `ShellMessage` / `ShellState`) to keep
418
- `AppShell` focused on layout; the shell's render tree is unchanged.
419
-
420
- New exports: `NavShell` (+ `NavShellProps` / `NavShellLayout` / `NavShellTopBar` /
421
- `NavShellSideRail` / `NavShellCollapsedRail`), `CollapsedRail` (+ `CollapsedRailProps`),
422
- `PillNav` (+ `PillNavProps`), `DarkModeControl` (+ `DarkModeControlProps` /
423
- `DarkModeOption` / `DarkModeVariant`), `resolveRailMode` (+ `RailMode` /
424
- `CollapsedRailRange` / `ResolveRailModeArgs`), `RAIL_FULL_BREAKPOINT`,
425
- `DEFAULT_COLLAPSED_RAIL_MAX`, `pillNavStyles`, `darkModeStyles`, `collapsedRailStyles`.
426
-
427
- ## 1.7.0
428
-
429
- Two additive navigation capabilities on the horizontal bar, plus a unifying component.
430
-
431
- **Priority+ overflow ("…" menu) on `NavBar`.** Above the `collapseBelow` breakpoint the
432
- inline links sit on ONE non-wrapping row; when they don't all fit, a measure pass
433
- (`useNavOverflow` + the pure `computeVisibleCount`) keeps the leading items that fit inline
434
- and collapses the rest behind a **"More ▾"** trigger — the shared `@dloizides/ui-layout`
435
- `ModalDropdown`, so the overflow menu is portalled, keyboard-navigable, and dismissed on
436
- outside-click / Escape like every other menu. No item is ever cut off; the surplus simply
437
- moves into the dropdown. When the active route lives in the overflow set the trigger wears
438
- the accent pill so it stays visibly reachable. BELOW `collapseBelow` the whole set still
439
- collapses into the responsive hamburger drawer (the two mechanisms never overlap). Two new
440
- optional props localize the trigger: `overflowLabel` (visible + accessible, default `"More"`)
441
- and `overflowHint`. New `NAV_TEST_IDS.navBarOverflow` (`navbar-overflow`).
442
-
443
- **`Nav` — one component, two orientations.** `<Nav orientation="horizontal" />` renders the
444
- `NavBar`; `<Nav orientation="vertical" />` renders the `Sidebar` — from the SAME `NavItem[]`,
445
- so an app flips between a top bar and a side rail without swapping components. `orientation`
446
- defaults to `"horizontal"`; the remaining props are a discriminated union of the delegate's
447
- props (TypeScript enforces the right prop set per orientation). Vertical nav is the existing
448
- `Sidebar` (stacked, vertically scrolling — no overflow menu needed).
449
-
450
- **Backwards compatible.** Purely additive — `Sidebar`, `Topbar`, `NavBar`, `AppShell`, and
451
- the role-gating helpers keep their exact signatures; the new overflow props are optional
452
- (default `"More"` / `""`) so aml-v2 (the only `NavBar` consumer) and every `Sidebar`/`Topbar`
453
- consumer (erevna / katalogos / kefi / agora) are untouched. New exports (`Nav`, `NavProps`,
454
- `NavOrientation`, `NavOverflowMenu`, `NavOverflowMenuProps`, `NAV_LINK_GAP`) are additive.
455
-
456
- ## 1.6.1
457
-
458
- Keep the expanded links on ONE row — the row must be `nowrap` so `flex-shrink` distributes
459
- negative space (a `wrap` row drops the right slot onto a 2nd line instead of shrinking the
460
- links). Renames the internal expanded links style to `linksRow` (grow + shrink-to-0,
461
- `minWidth:0`, `overflow:hidden`) with a per-link `linkCell` (never shrinks, so each link keeps
462
- its natural measured width). Brand + right stay pinned (`flexShrink:0`).
463
-
464
- ## 1.6.0
465
-
466
- Keep the bar to ONE row — horizontal-scroll / clip the links instead of wrapping the bar
467
- onto a second line when brand + links + right slot overflow the available width.
468
-
469
- ## 1.5.0
470
-
471
- Make the `AppShell` back-office `sidebar` layout **responsive** — a mobile drawer.
472
-
473
- The `sidebar` slot (1.4.0) always rendered a persistent fixed-width left rail in a
474
- `flexDirection:'row'` body with no width breakpoint, so below ~768px the content column
475
- starved (headings wrapped a character per line, inputs truncated) — the merchant admin was
476
- unusable on a phone. Now `AppShell` reads `useWindowDimensions`:
477
-
478
- - **Desktop (≥768px): IDENTICAL to 1.4.0** — the persistent rail + row layout, untouched.
479
- Purely additive; no consuming product's desktop layout changes.
480
- - **Below 768px:** the rail is not rendered beside the content; instead a **hamburger**
481
- (in the header) opens the same `Sidebar` as an overlay **drawer** (a tap-to-close scrim +
482
- a left panel) and the content takes the full width. The drawer closes on a scrim tap and
483
- on a nav-item press (web: a bubbled click on a `role="button"` / link inside the panel).
484
-
485
- Accessibility: the hamburger has a testID + `accessibilityLabel` + `accessibilityHint` and a
486
- themed focus ring; the drawer panel is `role="dialog"` `aria-modal`; the scrim is a labelled
487
- button. New optional prop `mobileMenu?: Partial<DrawerLabels>` supplies pre-localized
488
- open/close labels (English defaults otherwise — this package has no i18n). New testID
489
- suffixes: `-menu-toggle`, `-drawer`, `-drawer-scrim`.
490
-
491
- **Backwards compatible.** No breaking prop changes — consumers pass the same `sidebar` slot.
492
- Omit `sidebar` and the tree is byte-identical to 1.4.0 (erevna / katalogos / kefi untouched).
493
- Only a `sidebar` consumer on a narrow viewport sees the drawer. Pinned by tests asserting the
494
- rail is present on desktop and absent (hamburger present) on a narrow viewport.
495
-
496
- ## 1.4.0
497
-
498
- Add an optional **`sidebar`** slot to `AppShell` — the back-office layout.
499
-
500
- `AppShell` could only stack vertically (header → nav strip → banner → scrolling content),
501
- so a product with a persistent LEFT RAIL (Agora's merchant admin: products / categories /
502
- coupons / settings) had to hand-compose `Topbar` + `Sidebar` + a `ScrollView` row and give
503
- up the shell's auth gate and loading/error/forbidden state cards. Supplying `sidebar` now
504
- lays the body out as a ROW — the rail on the left, the width-disciplined scrolling content
505
- column on the right — while every existing feature (gate, state cards, `width`,
506
- `chromeAlignment`) keeps working.
507
-
508
- The new region is exposed as `` `${testID}${APP_SHELL_SUFFIX.sidebar}` `` (`-sidebar`).
509
-
510
- **Backwards compatible.** Omit `sidebar` and the render tree is byte-identical to 1.3.0 —
511
- erevna / katalogos / kefi / aml-v2 are untouched. Pinned by a test asserting no `-sidebar`
512
- region exists when the slot is omitted.
513
-
514
- ## 1.3.0
515
-
516
- Give the `NavBar` the v1 `.gn-links` look + a full accessibility pass (and extend the
517
- same a11y bar to `Sidebar`/`Topbar`).
518
-
519
- **Look & hover (NavBar).** Each link is a rounded-pill target with the v1 hover
520
- affordance — muted at rest, and on HOVER the text brightens to full contrast while a
521
- subtle rounded-pill background appears; the ACTIVE link is a solid accent pill. All
522
- theme-driven (rest = `textSecondary`, hover text = `text`, hover bg = `surfaceElevated`,
523
- active/ring = `palette.primary`). Hover/focus are web-only (`onHoverIn/Out`, `onFocus/Blur`
524
- via `Pressable`); native stays at the rest style. The hover cross-fade is cheap CSS and is
525
- disabled under `prefers-reduced-motion: reduce` (new `useReducedMotion`). Backwards
526
- compatible — the hover pill is the default (aml-v2 is the only `NavBar` consumer) and the
527
- brand/right slots, responsive hamburger collapse, and `containerStyle` width behaviour are
528
- unchanged.
529
-
530
- **Accessibility.** NavBar toggle now carries `aria-controls` pointing at the collapsible
531
- links region (id via `nativeID`), keeps `aria-expanded`, and gains a themed keyboard focus
532
- ring; links keep `aria-current="page"` + `role="link"` and gain a focus ring; link/toggle
533
- touch targets are now ≥44×44. `Sidebar` leaves gain `aria-current="page"` + a focus ring;
534
- `Topbar` action buttons gain a focus ring (new internal `FocusableTouchable`). Focus rings
535
- never remove the outline without a themed replacement.
536
-
537
- ## 1.2.0
538
-
539
- Add `NavBar` — a config-driven **horizontal top navigation bar**, the top-bar
540
- counterpart to `Sidebar`. It renders the same caller-supplied `NavItem[]` as inline
541
- links across the top (brand left slot + inline links + free-form right slot), with
542
- an active-link highlight (`aria-current="page"`) and a responsive collapse to a
543
- hamburger toggle below a configurable breakpoint (`collapseBelow`, default `760`) —
544
- mirroring the v1 AML console `.global-nav`. Theme-driven (UiProvider colours),
545
- `role="navigation"` landmark, keyboard-operable links. Purely additive: `Sidebar`,
546
- `Topbar`, `AppShell`, and the role-gating helpers are unchanged. New `NAV_TEST_IDS`
547
- entries `navBarToggle` / `navBarLinks`.
548
-
549
- ## 1.0.0
550
-
551
- Initial release. `Sidebar` (leaf + expandable items, active-route highlight, header/footer
552
- slots), `Topbar` (logo/language/notification/user/account/logout slots), and the
553
- `accessibleNavItems` / `roleRoutesToNavItems` role-gating helpers (reusing
554
- `resolveAccessibleRoutes` from `@dloizides/auth-web`). Chrome + metrics ported verbatim from
555
- the byte-identical erevna-web / katalogos-web nav twins; every colour reads from the
556
- `@dloizides/ui-feedback` UiProvider theme.
1
+ # Changelog
2
+
3
+ ## 1.38.1
4
+
5
+ - NAV-BRAND-1 "Wider sidebar, logo and name in the top bar" AC-UI-2: a `full` phone drawer takes its width and height from the window (`useWindowDimensions`), not from the shell root, so a sibling below the shell (FinReg `BuildInfoFooter`) no longer leaves it 24 px short. The default 280 px drawer is unchanged.
6
+
7
+ ## 1.38.0
8
+
9
+ - NAV-BRAND-1 D-NAVB-3 "One brand options prop": `NavShell` takes one `brand={{ placement, railWidth, drawerHeader, drawerWidth }}` prop instead of the four 1.37.0 props; `Sidebar.width` removed (the shell sets the rail width through `containerStyle`). The top-bar brand cell carries the top bar background and bottom border, so the header line runs over the rail.
10
+
11
+ ## 1.37.0
12
+
13
+ - NAV-BRAND-1 "Wider sidebar, logo and name in the top bar": `NavShell` gains `brandPlacement`, `sidebarWidth`, `drawerHeader`, `drawerWidth` (defaults = today); `Sidebar.width`; `AppShell.mobileMenu` takes `header` / `width` / `sidebar`. Exports `SIDEBAR_WIDTH_PX`, `NAV_BRAND_DEFAULTS`.
14
+
15
+ ## 1.35.0
16
+
17
+ - NAV-SCROLL-1 "Opening sidebar groups never hides the items below" (AC-UI-1, AC-UI-2): `Sidebar` items (header slot + list) scroll in a `ScrollView` between the pinned title/search and the pinned footer, with a `theme.colors.surface` fade on each edge that hides items. Inside `MobileDrawer` opening a top-level group closes the other (through `NavAccordionContext`, which `MobileDrawer` provides; no new `Sidebar` prop). `NavExpandableItem` takes optional controlled `expanded` + `onToggle`. No prop changes for existing consumers.
18
+
19
+ ## 1.28.0
20
+
21
+ - INVOICE-5 "Edit invoices before sending, shared background and menu hover" (AC-UI-3, AC-UI-6): `AppShell` draws `theme.backdrop` (the `@dloizides/theme-web` 1.5.0 `resolveBackdrop` shape, typed here as `AppShellBackdrop`): its colour and web image on the root, and for `horizon` a brand band behind header and nav that the content overlaps by `overlap.compact` (< 768 px) or `overlap.wide`. No `theme.backdrop` keeps today's flat background.
22
+
23
+ ## 1.27.0
24
+
25
+ - INVOICE-5 "Edit invoices before sending, shared background and menu hover" (AC-UI-2, AC-UI-6): `NavBar` takes `hoverFeedback` (`NavHoverFeedback`: `default`, `tint-pill`, `underline`, `lift`, `sliding-highlight`); the new phone `BottomTabBar` takes `tabFeedback` (`TabFeedback`: `default`, `indicator-pill`, `dim-spring`, `sliding-bar`, `icon-lift`). Timings come from the `@dloizides/ui-motion` tokens (<= 300 ms); reduced motion keeps every option still. `default` keeps today's look, so existing apps do not change.
26
+
27
+ ## 1.26.2
28
+
29
+ - KEFI-LAUNCH-1 "Do every open Kefi task before the public launch": a long account name no longer overlaps the `Topbar` center slot at 360 px. The center slot keeps its content width (`flexShrink: 0`); only the account name gives way. The overlap dates from 1.24.0, when the slot was added.
30
+
31
+ ## 1.26.1
32
+
33
+ - KEFI-LAUNCH-1 "Do every open Kefi task before the public launch": the `Topbar` left slot no longer wraps beside a long account name at 360 px. The left slot keeps its width (`flexShrink: 0`) and the account name truncates to one line.
34
+
35
+ ## 1.25.0
36
+
37
+ - `PillNav` / `PillNavMenu` get an optional `menuHeading`: a visible, non-focusable heading at the top of the open `Menu` that also names it (needs `@dloizides/ui-layout` >= 1.30.0) (KEFI-EDITOR-HEADER-1 "Compact the landing editor header").
38
+
39
+ ## 1.24.0
40
+
41
+ - `Topbar` gets an optional `center` slot between `left` and the actions (testID `topbar-center`); callers that do not pass it render as before (KEFI-EDITOR-HEADER-1 "Compact the landing editor header").
42
+ - `PillNav` gets `variant={PillNavVariant.Menu}`: one compact "Current ▾" trigger (44px pill) opening a menu of the same items through the shared `ModalDropdown` (role menu, arrow keys / Home / End / Enter / Escape, highlight starts on the current item). New optional props `menuHint`, `menuTestID`, `dropdownVariant`; new exports `PillNavVariant`, `resolvePillMenuLabel`. Default stays `Pills`.
43
+
44
+ ## 1.23.2
45
+
46
+ - Topbar account-name button and language toggle get a 44x44 minimum hit box (KEFI-HEADER-HITBOX-1 "kefi-web header account control reaches 44x44").
47
+
48
+ ## 1.23.1
49
+
50
+ **Overlay z-indexes come from the shared `@dloizides/design-tokens` `Z_INDEX` scale (UI-ZINDEX-SCALE-1 "Shared overlay z-index scale").**
51
+
52
+ - `MobileDrawer` overlay uses `Z_INDEX.drawer`; `CommandPalette` overlay uses `Z_INDEX.palette`.
53
+ - Values are unchanged (50 / 60), so stacking behaviour is identical. The drawer's scrim/panel
54
+ order (1 / 2) is local to the overlay and stays as is.
55
+ - New dependency: `@dloizides/design-tokens` `^1.13.0`.
56
+
57
+ ## 1.23.0
58
+
59
+ **The NavBar's stacked mobile menu now scrolls within the viewport (SHOWCASE-1).**
60
+
61
+ Below `collapseBelow` the open hamburger menu grew to its natural height (734px with ~15 items on
62
+ ui.dloizides.com at 360x640). Portals set `body { overflow: hidden }`, so the last items sat below
63
+ the fold and neither wheel nor touch could reach them — in every portal with more than ~10 items.
64
+
65
+ - The stacked links region is now a `ScrollView` capped to `windowHeight - closedBarHeight -
66
+ stackedRightSlot - chrome` (pure `computeStackedMenuMaxHeight`, unit-tested; floor of three 44px
67
+ rows). Rows keep their 44px minimum; nothing depends on hover.
68
+
69
+ ## 1.20.0
70
+
71
+ **The auth-pending and page-loading spinners now OVERLAY stably-mounted children instead of
72
+ replacing them — closing a latent deep-link-collapse trap in the shared shell.**
73
+
74
+ `AppShell`'s `gate.pending` branch and `appShellContent`'s `state.loading` branch each `return`ed a
75
+ full-screen spinner *instead of* `children`. Replacing the subtree unmounts whatever `children` host
76
+ — in a portal that is a navigator/`<Slot/>` — and on remount React Navigation re-derives its default
77
+ route, so a deep-linked sub-route silently collapses to its default on every reload. (This is the
78
+ exact class of bug that hit kefi-web's organizer portal, fixed there in its `OnboardingGate`; the
79
+ same shape lived dormant here because no consumer passes `gate=`/`state=` yet.)
80
+
81
+ - **`AppShell` (`gate.pending`)** now renders the shell tree normally and paints the pending spinner
82
+ as an opaque `position:absolute` overlay on top, so `children` mount once and are never torn down
83
+ across `pending → authenticated` (proven by a "same child instance, no remount" test).
84
+ - **`appShellContent` (`state.loading`)** likewise overlays the loading spinner over mounted
85
+ `children` rather than swapping them out. The `loadingHost` wrapper is gated on `state` being
86
+ DEFINED, not on `loading`'s live value — so the element type at the mount point is invariant across
87
+ a `loading` true↔false toggle and `children` are reconciled in place, never remounted on the edge
88
+ (also proven by a "same child instance, no remount" test). Consumers that pass no `state` render
89
+ `children` bare, byte-identical to 1.19.0. Error / forbidden cards are TERMINAL and still replace;
90
+ unauthenticated still returns null + `onRedirect`.
91
+ - **Rule encoded in the docblock:** a *transitional* status overlays; only a *terminal* state
92
+ replaces. Corollary for consumers — `children` mount beneath the pending overlay, so gate data
93
+ FETCHES on auth state (`enabled: isAuthenticated`), not on mount. No public API changed; behavior
94
+ is unchanged for any consumer not passing `gate`/`state`.
95
+
96
+ ## 1.19.0
97
+
98
+ **The "More ▾" overflow menu now opens at a readable width instead of cramping onto the trigger's tiny footprint.**
99
+
100
+ `NavOverflowMenu` rendered the shared `@dloizides/ui-layout` `ModalDropdown` without a `menuMinWidth`,
101
+ so the popover matched the width of the compact "More ▾" pill (~its own label). Option labels
102
+ ("Settings", "API docs") wrapped onto two lines, and the resulting tall rows overflowed the menu's
103
+ max-height — so the lower items scrolled out of view and read as missing.
104
+
105
+ - **`NavOverflowMenu`** now passes a 200px `menuMinWidth` floor, so the overflow menu opens wide
106
+ enough to show each label on one line. `ModalDropdown`/`InlineMenu` clamp a floored menu inside the
107
+ viewport (ui-layout ≥ 1.24.0, now the peer floor), so the extra width never pushes it off the right
108
+ edge. No public API changed.
109
+
110
+ ## 1.18.0
111
+
112
+ Root-cause fix for sidebar leaf co-activation. Behavioral/additive — no breaking API.
113
+
114
+ - **Fix (two sidebar leaves showed `aria-current="page"` at once).** On the live finreg
115
+ `/accounting/journals`, BOTH "Chart of Accounts" (route `/accounting`) and "Journals"
116
+ (route `/accounting/journals`) were marked active, because the active predicate was purely
117
+ prefix-based (`pathname === route || pathname.startsWith(route + '/')`) — a module-home leaf
118
+ routed at `/accounting` is a prefix of every `/accounting/*` sibling, so it co-activated on all
119
+ of them. (Second occurrence of this class; the first, CRM Contacts at `/crm`, was worked around
120
+ by moving the route.) Now resolution is **most-specific (longest) match wins**: among a nav
121
+ model's LEAF items, at most ONE is active for any path — the leaf whose route is the longest
122
+ prefix match. On `/accounting/journals` only Journals is active; on `/accounting` exactly only
123
+ Chart of Accounts is. For nav models with no parent/child route overlap (the common case)
124
+ behaviour is unchanged.
125
+ - **New export** `resolveActiveRoute(items, pathname)`: flattens the tree to its leaf routes and
126
+ returns the single longest matching route (honouring per-leaf `exact`), or `undefined`.
127
+ - `Sidebar` resolves the winning route once over the full leaf set and passes `activeRoute` to
128
+ each row; `NavExpandableItem` marks a leaf active iff `item.route === activeRoute` (falls back
129
+ to the per-item predicate when used standalone — backward compatible). Group HEADER expansion
130
+ (`hasActiveDescendant`) is a separate concern and is unchanged.
131
+ - The same winner-based resolution now backs `NavBar`, `PillNav`, `CollapsedRail` and
132
+ `NavOverflowMenu`, so the latent prefix co-activation cannot resurface in the horizontal navs.
133
+ - **Additive API**: new optional `NavItem.exact?: boolean` — a leaf active ONLY on its exact
134
+ path (never a nested one). A complementary escape hatch; the longest-match default is the real
135
+ fix, so no consumer needs to set it.
136
+ - `isRouteActive(pathname, route, exact?)` gains an optional third arg; the two-arg call is
137
+ unchanged.
138
+
139
+ ## 1.17.0
140
+
141
+ Two live-defect fixes to the shared sidebar chrome. Behavioral/additive — no breaking API.
142
+
143
+ - **Fix (two search bars on desktop → mutually exclusive by affordance).** 1.16.1 shipped the
144
+ desktop inline `SidebarSearch` field, but the ⌘K `CommandPaletteTrigger` could be present at
145
+ the same time, so a wide viewport showed BOTH. `Sidebar` now owns both affordances and renders
146
+ exactly ONE, chosen by its own viewport width via `resolveSearchAffordance`:
147
+ - inline affordance (≥768) → the inline field ONLY; the ⌘K trigger is suppressed.
148
+ - palette affordance (<768) → the ⌘K trigger ONLY; the inline field is suppressed.
149
+ - The ⌘K keyboard shortcut (`useCommandPaletteHotkey`) is untouched and stays bound at every width.
150
+ - **Additive API**: new `Sidebar.paletteTrigger?: SidebarPaletteTriggerConfig`
151
+ (`{ label, hint, shortcut, onOpen, testID? }`), forwarded from `NavShellSideRail.paletteTrigger`.
152
+ Consumers that wire neither, or only the inline field, are unchanged (the inline field simply
153
+ no longer renders below 768, where a persistent field would steal vertical space).
154
+
155
+ - **Fix (hover wash never appeared on RN-web).** The nav rows were `TouchableOpacity`, which in
156
+ react-native-web has NO hover support and silently drops `onHoverIn`/`onHoverOut` — so the row's
157
+ `hovered` state never toggled and the primary-tinted overlay stayed at opacity 0. The leaf and
158
+ group-header rows in `NavExpandableItem` are now `Pressable`, whose `useHover` responder actually
159
+ drives `hovered`, lifting the token-tinted overlay to the hover wash (`HOVER_TINT_OPACITY`, ~160ms
160
+ reduced-motion-gated transition) — distinct from and lighter than the active tint. Token-only
161
+ (colours via the theme; no raw hex/rgb/hsl). aria wiring (`aria-current` / `aria-expanded`, which
162
+ `MobileDrawer` depends on) is preserved across the switch.
163
+
164
+ ## 1.16.1
165
+
166
+ - **Fix (NavShell forwards inline-search props to the inner `Sidebar`).** 1.16.0 added
167
+ desktop inline search to `Sidebar` (`enableInlineSearch`, `search`, controlled
168
+ `searchQuery` / `onSearchChange`), but all seven portals consume the higher-level
169
+ `NavShell` (via `sideRail`), which did **not** forward those props — so inline search was
170
+ unreachable by every real consumer. `NavShellSideRail` now carries the same four fields
171
+ (`enableInlineSearch?`, `search?: SidebarSearchConfig`, `searchQuery?`, `onSearchChange?`)
172
+ and threads them onto the `<Sidebar>` it builds. Desktop-vs-mobile resolution is unchanged
173
+ (inline ≥768, ⌘K palette <768 via `resolveSearchAffordance`).
174
+ - **Purely additive / backward compatible**: with the fields unset, `NavShell` renders
175
+ exactly as 1.16.0. No breaking change; no styling touched.
176
+
177
+ ## 1.16.0
178
+
179
+ - **Feature (docs-style sidebar rail — hover wash, tinted active pill, rotating chevron).**
180
+ The `Sidebar` / `NavExpandableItem` rows now read like the aml-screening docs navigation:
181
+ each row carries a PRIMARY-TINTED overlay whose opacity eases `0 → hover → active` as a
182
+ smooth ~160ms CSS cross-fade on web (gated by `prefers-reduced-motion`, omitted on native).
183
+ - **Hover** lights a row with a subtle brand wash (`HOVER_TINT_OPACITY` = 0.06); **active**
184
+ deepens it to a soft brand pill (`ACTIVE_TINT_OPACITY` = 0.14) over the transparent
185
+ left-accent gutter every leaf already reserved, so the active leaf shows a tinted pill + a
186
+ coloured left bar + brand-coloured label — no layout shift, all from `theme.palette.primary`.
187
+ - **Rotating chevron.** Expandable groups now get a built-in `SidebarChevron` (a `›` glyph
188
+ that rotates to `⌄` on expand, tweened on web) when the caller supplies no `renderChevron`.
189
+ A caller-supplied `renderChevron` still wins, so existing icon-set chevrons are unaffected.
190
+ - Row corner radius softened to `NAV_ROW_RADIUS` (8px) with the rounded overlay clipped to it.
191
+ - No public API change to existing props; hover uses RNW's `onHoverIn`/`onHoverOut` (no-op on
192
+ native). New exports: `SidebarChevron`, `NAV_ROW_RADIUS`, `ACTIVE_TINT_OPACITY`,
193
+ `HOVER_TINT_OPACITY`.
194
+
195
+ - **Feature (desktop INLINE sidebar search — opt-in — with the ⌘K palette kept for mobile).**
196
+ The owner found the ⌘K command-palette popup inconvenient on desktop. `Sidebar` now accepts
197
+ an opt-in persistent inline search field at the top of the rail that narrows the nav list
198
+ LIVE as you type.
199
+ - **New `Sidebar` props (all additive, default OFF):** `enableInlineSearch` + a pre-localized
200
+ `search: SidebarSearchConfig` (`placeholder` / `hint` / `clearLabel` / `clearHint` /
201
+ `emptyText`), plus optional controlled `searchQuery` + `onSearchChange` (the sidebar owns the
202
+ state when omitted). With the flag off the render tree is byte-identical to 1.15 — the mobile
203
+ drawer, which should keep using the ⌘K palette, is untouched.
204
+ - **New exports:** `SidebarSearch` (the field), `filterNavItems` / `isFilterActive` /
205
+ `searchTokens` (the pure tree-prune, same all-tokens-must-match semantics as `filterCommands`
206
+ so inline + ⌘K rank identically), and `resolveSearchAffordance(viewport)` +
207
+ `DEFAULT_SEARCH_BREAKPOINT` — the pure `'inline'` (≥768px) vs `'palette'` (<768px) rule so a
208
+ shell can wire desktop-inline / mobile-palette from one function. The ⌘K hotkey
209
+ (`useCommandPaletteHotkey`) stays bound at every width.
210
+
211
+ ## 1.15.0
212
+
213
+ - **Feature (an expandable nav group now ANIMATES its expand/collapse instead of snapping).**
214
+ `NavExpandableItem` used to conditionally render its children (`{expanded ? … : null}`),
215
+ so a sidebar/drawer group's leaves appeared and vanished INSTANTLY on toggle — a hard,
216
+ jarring cut with no transition. The group's children are now wrapped in the
217
+ `@dloizides/ui-motion` `<Collapse open={expanded}>`, which measures the children's natural
218
+ height and animates a real `Animated.Value` height on WEB and native alike (the height code
219
+ path RN's native-only `LayoutAnimation` never gave the web build), collapsing to instant
220
+ under `prefers-reduced-motion`.
221
+ - **Collapsed leaves stay non-interactive.** Per the `Collapse` contract the children remain
222
+ MOUNTED at all times (their state and a11y focus order are preserved); when collapsed they
223
+ are clipped to zero height under `overflow: hidden`, so a collapsed group's leaves are
224
+ neither visible nor tappable in a real browser even though they are in the tree. This is a
225
+ deliberate change from the prior "absent from the DOM when collapsed" behaviour.
226
+ - **`aria-expanded` and the mobile-drawer contract are intact.** The group header still emits
227
+ `aria-expanded` (the `ariaExpandedProps` spread is unchanged), so `MobileDrawer` still tells
228
+ a group TOGGLE apart from a navigating LEAF and does not dismiss the drawer when a group is
229
+ expanded on mobile. The active-leaf accent bar, docs-style section-header styling, chevron
230
+ slot, testIDs, and all a11y roles/labels/hints are unchanged.
231
+ - **No public API change.** `NavExpandableItemProps` / `NavItem` are untouched — the animation
232
+ is derived from the `expanded` state the component already tracked. `@dloizides/ui-motion`
233
+ (`^1.0.1`) is added as a runtime dependency; all 7 portals gain the animated group on their
234
+ next bump for free. Consumers with no expandable groups are visually unaffected.
235
+
236
+ ## 1.13.1
237
+
238
+ - **Fix (mobile drawer: a sub-item / leaf tap dismissed the drawer WITHOUT navigating).**
239
+ On a phone-width viewport, tapping a leaf inside the open `MobileDrawer` closed the drawer
240
+ but the route never changed — verified live on finreg via Playwright (real Chromium, real
241
+ pointer events); every jsdom unit test stayed green because it wired `onClose` to an inert
242
+ `jest.fn()` that never unmounts anything.
243
+ - **Root cause.** `MobileDrawer` closes on a nav press via a NATIVE
244
+ `panel.addEventListener('click', …)`. react-native-web fires a leaf's `onPress` from its
245
+ `onClick`, dispatched through REACT's synthetic event system whose root listener sits on the
246
+ app-root container — an ANCESTOR of the panel. So on a real click the native panel listener
247
+ runs FIRST (during the DOM bubble), and closing synchronously there ran `setDrawerOpen(false)`,
248
+ unmounting the panel and tearing the leaf out of the tree BEFORE React could dispatch its
249
+ `onPress`. The navigation call never ran.
250
+ - **Fix.** The drawer's close is now DEFERRED to the next macrotask (`setTimeout(…, 0)`), so
251
+ React finishes dispatching the tapped leaf's `onPress` (the navigation) for the current click;
252
+ the panel then unmounts on the following tick, after the route has already changed. Scrim tap
253
+ and group-header toggle are unchanged.
254
+ - **No API change.** All 7 portals get the fix on their next bump.
255
+
256
+ ## 1.11.0
257
+
258
+ - **Feature (an expandable nav group now auto-opens when the current route is inside it).**
259
+ `NavExpandableItem` used to mount every group collapsed (`useState(false)`), so on a fresh
260
+ page load the group containing the active route was closed and its active leaf was hidden.
261
+ A persistent-rail console that groups its nav by module (e.g. finreg-web's Payments / CRM /
262
+ Accounting rail) needs the active module's group open on load, the others collapsed — with
263
+ no way for the caller to express that, since expansion state was internal and uncontrolled.
264
+ - The group now initializes its expansion from whether the current route is inside it:
265
+ `useState(() => hasActiveDescendant(item, pathname))`, where `hasActiveDescendant` is true
266
+ when the item's own route is active (`isRouteActive`) or any descendant's is (recursive, so
267
+ it holds at any nesting depth). The group of the active route mounts EXPANDED (active leaf
268
+ visible + highlighted); a group with no active descendant mounts COLLAPSED — exactly the
269
+ prior behaviour.
270
+ - **No API change.** `NavItem` is unchanged — the behaviour is derived from the `pathname`
271
+ the component already receives. Consumers with no expandable groups, or whose groups hold no
272
+ active route, are byte-for-byte unaffected; all 7 portals gain the auto-open on their next
273
+ bump for free.
274
+ - Guarded by `src/navExpandableItem.test.tsx` (RED-first): the two "mounts EXPANDED" assertions
275
+ FAIL against the pre-change `useState(false)` (the active group's child is absent from the DOM)
276
+ and pass after; the "mounts COLLAPSED" + "header always renders" assertions lock the
277
+ backward-compatible half.
278
+
279
+ ## 1.10.2
280
+
281
+ - **Fix (the mobile nav DRAWER was not clickable — a tap on a nav item did not navigate, it
282
+ just closed the drawer / did nothing, in all seven portals below 768px).**
283
+ `MobileDrawer` rendered a full-bleed scrim `Pressable` (`StyleSheet.absoluteFillObject`) and a
284
+ panel `View` with `{ position: 'absolute', top: 0, bottom: 0, left: 0 }` — **no width and no
285
+ zIndex**. Reproduced structurally: the panel's computed `width` was empty and its `zIndex` was
286
+ `0`; the scrim's computed `left`/`right` were both `0px` at `zIndex` `0`. So the scrim FULLY
287
+ overlapped the panel at the SAME stacking level, and on react-native-web the equal-zIndex scrim
288
+ won the pointer (the estate's known "RN-web Pressable swallows a sibling/child press" trap):
289
+ the tap closed the drawer and the nav-item's `TouchableOpacity` `onPress` (→ the app's router)
290
+ never fired.
291
+ - Fixed once, in the kit: the panel now has an explicit drawer **width** (`280px`), a solid
292
+ surface background, and a `zIndex` **above** the scrim; the scrim is anchored BESIDE the panel
293
+ (`left: DRAWER_WIDTH`) so its tap-to-close hit area never overlaps the panel. Nav-item taps
294
+ reach the `Sidebar` leaves; taps beside the drawer still close it.
295
+ - Guarded by `src/mobileDrawer.test.tsx` (RED-first): three structural assertions read the
296
+ resolved geometry via `getComputedStyle` and FAIL against the pre-fix panel/scrim (no width,
297
+ equal zIndex, overlapping scrim); two behavioural assertions lock the contract (nav-item tap
298
+ navigates + closes; scrim tap only closes).
299
+ - **No API change.** Consumers need no edit — bump the dependency and the drawer works.
300
+
301
+ ## 1.10.1
302
+
303
+ - **Fix (a plain-string slot logged `console.error` on EVERY render, in all seven portals).**
304
+ Every caller-supplied slot in the kit is typed `React.ReactNode`, which legitimately includes
305
+ `string` — and callers pass exactly that (`brand: FM('app.name')`, a plain localized string).
306
+ The slot was dropped straight into a `<View>`, and react-native-web refuses to render a bare
307
+ text node there: `Unexpected text node: <s>. A text node cannot be a child of a <View>.`
308
+ Nothing in the type system objected, so the defect only ever surfaced as browser console
309
+ noise — which is why it survived to 1.10.0 across the whole fleet.
310
+ - Fixed once, in the kit, via the new internal `renderTextSlot` helper: a `string`/`number`
311
+ slot is wrapped in `<Text>` (routed through the theme's `colors.text`, so it honours dark
312
+ mode rather than defaulting to black); an element slot passes through **untouched**, so
313
+ every existing caller renders byte-identically. Array slots have each string member wrapped
314
+ individually, keys preserved.
315
+ - Applied to **all 12 slot render sites**, not just the reported one — `NavBar` (`brand`,
316
+ `right`), `Topbar` (`left`, `notificationSlot`), `Sidebar` (`header`, `footer`),
317
+ `CollapsedRail` (`header`, `footer`), `AppShell` (`header`, `nav`, `sidebar`,
318
+ `collapsedSidebar`, `banner`) and `MobileDrawer` (`sidebar`).
319
+ - Guarded by `src/rawTextSlots.test.tsx`, which spies on `console.error` and asserts silence.
320
+ Unit tests that only assert "the slot rendered" pass against the bug; this one does not.
321
+ - **No API change.** Consumers need no edit — the `<Text>` wrapper they were expected to
322
+ remember is now the kit's job.
323
+
324
+ ## 1.10.0
325
+
326
+ - **Fix (`layout` was not authoritative — `layout="side"` rendered a top bar anyway).**
327
+ `wantsTopBar` carried an extra `(layout === 'side' && hasTopBar)` clause, so passing a
328
+ `topBar` **config** with `layout="side"` mounted the bar regardless — making `'side'` and
329
+ `'both'` render **pixel-identically** and `layout` unobservable. `layout` now decides which
330
+ REGIONS exist; `topBar` is only the CONTENT of the top region. A `'side'` app that also wants
331
+ a bar asks for `layout="both"`, which is what that combination always meant.
332
+ - Found by `apps/ui-showcase`, which renders every orientation side by side — a product app
333
+ picks ONE orientation and never sees the other, which is why this survived to 1.9.0.
334
+ - **Not breaking in this fleet:** no app passed `layout="side"` + `topBar`. The four `'side'`
335
+ portals (erevna, katalogos, zygos, agora) pass a `header` NODE, not a `topBar` config.
336
+ - The `hasHeader` clause is **deliberately kept**: `header` is the documented escape hatch by
337
+ which a `'side'` app supplies its own ready-made header (the structured `Topbar` with
338
+ language / notification / user slots). It is an explicit "render THIS node", not a config
339
+ whose presence is silently read as an orientation change.
340
+ - **a11y: `AccountState.accountHint`** — the account-name button's `accessibilityHint` was
341
+ hardcoded to `displayName`, byte-identical to its `accessibilityLabel`, so a screen reader
342
+ announced the name twice and never said what pressing it does — and the consumer had no way
343
+ to correct it. The sibling `upgrade?: TopbarAction` on the same interface has always carried
344
+ a `hint`; this closes that inconsistency. Falls back to `displayName`, so existing callers
345
+ are unaffected.
346
+ - **a11y: `AccountState.accountTestID`** — the account-name button's testID was hardcoded, with
347
+ no override, unlike `TopbarAction.testID?` / `NavItem.testID?` elsewhere in the package.
348
+ - **a11y: `Topbar.language.testID`** — the inline `language` prop type declared no `testID`, so
349
+ the repo's testID+label+hint standard was **structurally unsatisfiable** for that one button
350
+ while every other Topbar action (all `TopbarAction`) accepted one.
351
+
352
+ > Note for consumers: this react-native-web build does **not** forward `accessibilityHint` to
353
+ > the DOM at all — rendered buttons carry `aria-label` but no hint attribute. The hint is a
354
+ > NATIVE-only guarantee today. That is pre-existing and unchanged here, but it means DOM-level
355
+ > tests cannot distinguish a threaded hint from a dropped one; `topbarA11y.test.tsx` therefore
356
+ > pins the prop threading directly.
357
+
358
+ ## 1.9.0
359
+
360
+ **`NavBar.contentMaxWidth` — a full-bleed bar with a centred content column.**
361
+ An optional content-column cap (px), forwarded via `NavShell.topBar.contentMaxWidth`.
362
+ When set, the OUTER `NavBar` container — which carries the bar background + bottom
363
+ border (from `barTheme` / theme surface + any `containerStyle`, e.g. the AML
364
+ radial-glow appbar) — stays FULL WIDTH (full-bleed), while the INNER row (brand +
365
+ links + right) is capped to `contentMaxWidth` and centred. This restores the v1-console
366
+ `.ui-appbar` (full-bleed) + `.ui-appbar-inner { max-width; margin: 0 auto }` shape:
367
+ previously an app had to put the cap on the container's `containerStyle`, which also
368
+ capped the dark background and left light gutters on wide viewports. Omit it and the
369
+ inner spans the full container (unchanged — byte-identical to before). The priority+
370
+ overflow "…" fit measures the capped inner's rendered width, so it keeps working
371
+ against the content column, not the full-bleed container. Purely additive.
372
+
373
+ ## 1.8.0
374
+
375
+ The navigation shell becomes the ONE shell for every portal: a first-class
376
+ **top | side | both** layout, plus the capabilities harvested from every RN-web
377
+ portal's bespoke nav/shell so nothing is lost when they migrate. Purely additive —
378
+ `Sidebar`, `Topbar`, `NavBar`, `Nav`, `AppShell`, and the role helpers keep their
379
+ exact signatures; every new prop/component is optional.
380
+
381
+ **`NavShell` — top bar, side rail, or BOTH.** One component owns the whole page
382
+ composition, chosen by a single `layout` prop (`'top' | 'side' | 'both'`). It wires
383
+ a top bar into `AppShell`'s `header` slot and a side rail into its `sidebar` slot,
384
+ and inherits every `AppShell` capability (auth gate, loading/error/forbidden state
385
+ cards, width policy, banner, the responsive hamburger + overlay drawer). `'both'`
386
+ is the AML-console shape — a top bar (brand / global links + overflow "…" / user)
387
+ AND a side rail (sections) together — now first-class instead of hand-wired. The
388
+ top bar is built from a `topBar` config (a `NavBar`), the side rail from a
389
+ `sideRail` config (a `Sidebar`); an explicit `header` node overrides the built top
390
+ bar for apps that want a structured `Topbar`.
391
+
392
+ **Three-tier responsive rail (`AppShell.collapsedSidebar`).** Opts the `sidebar`
393
+ layout into the erevna/katalogos scheme: the full rail on desktop, an intermediate
394
+ persistent **collapsed (icon-only) rail** on a tablet band, and the overlay drawer
395
+ on a phone. Supply a `collapsedSidebar` node (and optionally `collapsedRailRange`,
396
+ default `{ min: 768, max: 1024 }`) — omit it and the layout stays the unchanged
397
+ two-tier scheme. New `CollapsedRail` component renders an icon-only rail from the
398
+ same `NavItem[]` (icon or first-letter glyph fallback, header/footer slots). New
399
+ `resolveRailMode` pure resolver + `RAIL_FULL_BREAKPOINT` / `DEFAULT_COLLAPSED_RAIL_MAX`.
400
+ `NavShell.sideRail.collapsed` builds it config-driven. New testID suffix
401
+ `-collapsed-sidebar`.
402
+
403
+ **`NavBar.barTheme` — dark bar over a light page.** An optional chrome-theme
404
+ override re-provisions the `@dloizides/ui-feedback` theme for the bar only (the AML
405
+ v1-console dark appbar over light content), re-passing the live `t` / `navigate` so
406
+ labels keep localizing. Forwarded via `NavShell.topBar.barTheme`. Style-only —
407
+ colours still flow through the theme. (Custom appbar backgrounds like AML's radial
408
+ glow stay expressible via `containerStyle`.)
409
+
410
+ **`PillNav` — coral cross-navigation (kefi).** A row of fully-rounded pills from a
411
+ `NavItem[]`, the active one wearing the accent (theme `palette.primary` = kefi's
412
+ coral). Folds in kefi's **visibility rule**: hides (renders `null`) unless there
413
+ are at least `minItems` entries (default 1; kefi passes 2).
414
+
415
+ **`DarkModeControl` — 3-way colour scheme (erevna/katalogos).** A controlled
416
+ Light / Dark / System control in two variants: `"segmented"` (a highlighted
417
+ segment row) and `"cycle"` (an icon+label button that advances to the next option
418
+ on press). Value-agnostic string options, pre-localized labels/hints.
419
+
420
+ **Refactor (no behaviour change).** `AppShell`'s page-state cards moved to
421
+ `appShellContent.tsx` (`useContentBody` / `ShellMessage` / `ShellState`) to keep
422
+ `AppShell` focused on layout; the shell's render tree is unchanged.
423
+
424
+ New exports: `NavShell` (+ `NavShellProps` / `NavShellLayout` / `NavShellTopBar` /
425
+ `NavShellSideRail` / `NavShellCollapsedRail`), `CollapsedRail` (+ `CollapsedRailProps`),
426
+ `PillNav` (+ `PillNavProps`), `DarkModeControl` (+ `DarkModeControlProps` /
427
+ `DarkModeOption` / `DarkModeVariant`), `resolveRailMode` (+ `RailMode` /
428
+ `CollapsedRailRange` / `ResolveRailModeArgs`), `RAIL_FULL_BREAKPOINT`,
429
+ `DEFAULT_COLLAPSED_RAIL_MAX`, `pillNavStyles`, `darkModeStyles`, `collapsedRailStyles`.
430
+
431
+ ## 1.7.0
432
+
433
+ Two additive navigation capabilities on the horizontal bar, plus a unifying component.
434
+
435
+ **Priority+ overflow ("…" menu) on `NavBar`.** Above the `collapseBelow` breakpoint the
436
+ inline links sit on ONE non-wrapping row; when they don't all fit, a measure pass
437
+ (`useNavOverflow` + the pure `computeVisibleCount`) keeps the leading items that fit inline
438
+ and collapses the rest behind a **"More ▾"** trigger — the shared `@dloizides/ui-layout`
439
+ `ModalDropdown`, so the overflow menu is portalled, keyboard-navigable, and dismissed on
440
+ outside-click / Escape like every other menu. No item is ever cut off; the surplus simply
441
+ moves into the dropdown. When the active route lives in the overflow set the trigger wears
442
+ the accent pill so it stays visibly reachable. BELOW `collapseBelow` the whole set still
443
+ collapses into the responsive hamburger drawer (the two mechanisms never overlap). Two new
444
+ optional props localize the trigger: `overflowLabel` (visible + accessible, default `"More"`)
445
+ and `overflowHint`. New `NAV_TEST_IDS.navBarOverflow` (`navbar-overflow`).
446
+
447
+ **`Nav` — one component, two orientations.** `<Nav orientation="horizontal" />` renders the
448
+ `NavBar`; `<Nav orientation="vertical" />` renders the `Sidebar` — from the SAME `NavItem[]`,
449
+ so an app flips between a top bar and a side rail without swapping components. `orientation`
450
+ defaults to `"horizontal"`; the remaining props are a discriminated union of the delegate's
451
+ props (TypeScript enforces the right prop set per orientation). Vertical nav is the existing
452
+ `Sidebar` (stacked, vertically scrolling — no overflow menu needed).
453
+
454
+ **Backwards compatible.** Purely additive — `Sidebar`, `Topbar`, `NavBar`, `AppShell`, and
455
+ the role-gating helpers keep their exact signatures; the new overflow props are optional
456
+ (default `"More"` / `""`) so aml-v2 (the only `NavBar` consumer) and every `Sidebar`/`Topbar`
457
+ consumer (erevna / katalogos / kefi / agora) are untouched. New exports (`Nav`, `NavProps`,
458
+ `NavOrientation`, `NavOverflowMenu`, `NavOverflowMenuProps`, `NAV_LINK_GAP`) are additive.
459
+
460
+ ## 1.6.1
461
+
462
+ Keep the expanded links on ONE row — the row must be `nowrap` so `flex-shrink` distributes
463
+ negative space (a `wrap` row drops the right slot onto a 2nd line instead of shrinking the
464
+ links). Renames the internal expanded links style to `linksRow` (grow + shrink-to-0,
465
+ `minWidth:0`, `overflow:hidden`) with a per-link `linkCell` (never shrinks, so each link keeps
466
+ its natural measured width). Brand + right stay pinned (`flexShrink:0`).
467
+
468
+ ## 1.6.0
469
+
470
+ Keep the bar to ONE row — horizontal-scroll / clip the links instead of wrapping the bar
471
+ onto a second line when brand + links + right slot overflow the available width.
472
+
473
+ ## 1.5.0
474
+
475
+ Make the `AppShell` back-office `sidebar` layout **responsive** — a mobile drawer.
476
+
477
+ The `sidebar` slot (1.4.0) always rendered a persistent fixed-width left rail in a
478
+ `flexDirection:'row'` body with no width breakpoint, so below ~768px the content column
479
+ starved (headings wrapped a character per line, inputs truncated) — the merchant admin was
480
+ unusable on a phone. Now `AppShell` reads `useWindowDimensions`:
481
+
482
+ - **Desktop (≥768px): IDENTICAL to 1.4.0** — the persistent rail + row layout, untouched.
483
+ Purely additive; no consuming product's desktop layout changes.
484
+ - **Below 768px:** the rail is not rendered beside the content; instead a **hamburger**
485
+ (in the header) opens the same `Sidebar` as an overlay **drawer** (a tap-to-close scrim +
486
+ a left panel) and the content takes the full width. The drawer closes on a scrim tap and
487
+ on a nav-item press (web: a bubbled click on a `role="button"` / link inside the panel).
488
+
489
+ Accessibility: the hamburger has a testID + `accessibilityLabel` + `accessibilityHint` and a
490
+ themed focus ring; the drawer panel is `role="dialog"` `aria-modal`; the scrim is a labelled
491
+ button. New optional prop `mobileMenu?: Partial<DrawerLabels>` supplies pre-localized
492
+ open/close labels (English defaults otherwise — this package has no i18n). New testID
493
+ suffixes: `-menu-toggle`, `-drawer`, `-drawer-scrim`.
494
+
495
+ **Backwards compatible.** No breaking prop changes — consumers pass the same `sidebar` slot.
496
+ Omit `sidebar` and the tree is byte-identical to 1.4.0 (erevna / katalogos / kefi untouched).
497
+ Only a `sidebar` consumer on a narrow viewport sees the drawer. Pinned by tests asserting the
498
+ rail is present on desktop and absent (hamburger present) on a narrow viewport.
499
+
500
+ ## 1.4.0
501
+
502
+ Add an optional **`sidebar`** slot to `AppShell` — the back-office layout.
503
+
504
+ `AppShell` could only stack vertically (header → nav strip → banner → scrolling content),
505
+ so a product with a persistent LEFT RAIL (Agora's merchant admin: products / categories /
506
+ coupons / settings) had to hand-compose `Topbar` + `Sidebar` + a `ScrollView` row and give
507
+ up the shell's auth gate and loading/error/forbidden state cards. Supplying `sidebar` now
508
+ lays the body out as a ROW — the rail on the left, the width-disciplined scrolling content
509
+ column on the right — while every existing feature (gate, state cards, `width`,
510
+ `chromeAlignment`) keeps working.
511
+
512
+ The new region is exposed as `` `${testID}${APP_SHELL_SUFFIX.sidebar}` `` (`-sidebar`).
513
+
514
+ **Backwards compatible.** Omit `sidebar` and the render tree is byte-identical to 1.3.0 —
515
+ erevna / katalogos / kefi / aml-v2 are untouched. Pinned by a test asserting no `-sidebar`
516
+ region exists when the slot is omitted.
517
+
518
+ ## 1.3.0
519
+
520
+ Give the `NavBar` the v1 `.gn-links` look + a full accessibility pass (and extend the
521
+ same a11y bar to `Sidebar`/`Topbar`).
522
+
523
+ **Look & hover (NavBar).** Each link is a rounded-pill target with the v1 hover
524
+ affordance — muted at rest, and on HOVER the text brightens to full contrast while a
525
+ subtle rounded-pill background appears; the ACTIVE link is a solid accent pill. All
526
+ theme-driven (rest = `textSecondary`, hover text = `text`, hover bg = `surfaceElevated`,
527
+ active/ring = `palette.primary`). Hover/focus are web-only (`onHoverIn/Out`, `onFocus/Blur`
528
+ via `Pressable`); native stays at the rest style. The hover cross-fade is cheap CSS and is
529
+ disabled under `prefers-reduced-motion: reduce` (new `useReducedMotion`). Backwards
530
+ compatible — the hover pill is the default (aml-v2 is the only `NavBar` consumer) and the
531
+ brand/right slots, responsive hamburger collapse, and `containerStyle` width behaviour are
532
+ unchanged.
533
+
534
+ **Accessibility.** NavBar toggle now carries `aria-controls` pointing at the collapsible
535
+ links region (id via `nativeID`), keeps `aria-expanded`, and gains a themed keyboard focus
536
+ ring; links keep `aria-current="page"` + `role="link"` and gain a focus ring; link/toggle
537
+ touch targets are now ≥44×44. `Sidebar` leaves gain `aria-current="page"` + a focus ring;
538
+ `Topbar` action buttons gain a focus ring (new internal `FocusableTouchable`). Focus rings
539
+ never remove the outline without a themed replacement.
540
+
541
+ ## 1.2.0
542
+
543
+ Add `NavBar` — a config-driven **horizontal top navigation bar**, the top-bar
544
+ counterpart to `Sidebar`. It renders the same caller-supplied `NavItem[]` as inline
545
+ links across the top (brand left slot + inline links + free-form right slot), with
546
+ an active-link highlight (`aria-current="page"`) and a responsive collapse to a
547
+ hamburger toggle below a configurable breakpoint (`collapseBelow`, default `760`) —
548
+ mirroring the v1 AML console `.global-nav`. Theme-driven (UiProvider colours),
549
+ `role="navigation"` landmark, keyboard-operable links. Purely additive: `Sidebar`,
550
+ `Topbar`, `AppShell`, and the role-gating helpers are unchanged. New `NAV_TEST_IDS`
551
+ entries `navBarToggle` / `navBarLinks`.
552
+
553
+ ## 1.0.0
554
+
555
+ Initial release. `Sidebar` (leaf + expandable items, active-route highlight, header/footer
556
+ slots), `Topbar` (logo/language/notification/user/account/logout slots), and the
557
+ `accessibleNavItems` / `roleRoutesToNavItems` role-gating helpers (reusing
558
+ `resolveAccessibleRoutes` from `@dloizides/auth-web`). Chrome + metrics ported verbatim from
559
+ the byte-identical erevna-web / katalogos-web nav twins; every colour reads from the
560
+ `@dloizides/ui-feedback` UiProvider theme.