@dloizides/ui-nav 1.3.0 → 1.5.0

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,45 +1,90 @@
1
- # Changelog
2
-
3
- ## 1.3.0
4
-
5
- Give the `NavBar` the v1 `.gn-links` look + a full accessibility pass (and extend the
6
- same a11y bar to `Sidebar`/`Topbar`).
7
-
8
- **Look & hover (NavBar).** Each link is a rounded-pill target with the v1 hover
9
- affordance muted at rest, and on HOVER the text brightens to full contrast while a
10
- subtle rounded-pill background appears; the ACTIVE link is a solid accent pill. All
11
- theme-driven (rest = `textSecondary`, hover text = `text`, hover bg = `surfaceElevated`,
12
- active/ring = `palette.primary`). Hover/focus are web-only (`onHoverIn/Out`, `onFocus/Blur`
13
- via `Pressable`); native stays at the rest style. The hover cross-fade is cheap CSS and is
14
- disabled under `prefers-reduced-motion: reduce` (new `useReducedMotion`). Backwards
15
- compatible the hover pill is the default (aml-v2 is the only `NavBar` consumer) and the
16
- brand/right slots, responsive hamburger collapse, and `containerStyle` width behaviour are
17
- unchanged.
18
-
19
- **Accessibility.** NavBar toggle now carries `aria-controls` pointing at the collapsible
20
- links region (id via `nativeID`), keeps `aria-expanded`, and gains a themed keyboard focus
21
- ring; links keep `aria-current="page"` + `role="link"` and gain a focus ring; link/toggle
22
- touch targets are now ≥44×44. `Sidebar` leaves gain `aria-current="page"` + a focus ring;
23
- `Topbar` action buttons gain a focus ring (new internal `FocusableTouchable`). Focus rings
24
- never remove the outline without a themed replacement.
25
-
26
- ## 1.2.0
27
-
28
- Add `NavBar` a config-driven **horizontal top navigation bar**, the top-bar
29
- counterpart to `Sidebar`. It renders the same caller-supplied `NavItem[]` as inline
30
- links across the top (brand left slot + inline links + free-form right slot), with
31
- an active-link highlight (`aria-current="page"`) and a responsive collapse to a
32
- hamburger toggle below a configurable breakpoint (`collapseBelow`, default `760`)
33
- mirroring the v1 AML console `.global-nav`. Theme-driven (UiProvider colours),
34
- `role="navigation"` landmark, keyboard-operable links. Purely additive: `Sidebar`,
35
- `Topbar`, `AppShell`, and the role-gating helpers are unchanged. New `NAV_TEST_IDS`
36
- entries `navBarToggle` / `navBarLinks`.
37
-
38
- ## 1.0.0
39
-
40
- Initial release. `Sidebar` (leaf + expandable items, active-route highlight, header/footer
41
- slots), `Topbar` (logo/language/notification/user/account/logout slots), and the
42
- `accessibleNavItems` / `roleRoutesToNavItems` role-gating helpers (reusing
43
- `resolveAccessibleRoutes` from `@dloizides/auth-web`). Chrome + metrics ported verbatim from
44
- the byte-identical erevna-web / katalogos-web nav twins; every colour reads from the
45
- `@dloizides/ui-feedback` UiProvider theme.
1
+ # Changelog
2
+
3
+ ## 1.5.0
4
+
5
+ Make the `AppShell` back-office `sidebar` layout **responsive** a mobile drawer.
6
+
7
+ The `sidebar` slot (1.4.0) always rendered a persistent fixed-width left rail in a
8
+ `flexDirection:'row'` body with no width breakpoint, so below ~768px the content column
9
+ starved (headings wrapped a character per line, inputs truncated) the merchant admin was
10
+ unusable on a phone. Now `AppShell` reads `useWindowDimensions`:
11
+
12
+ - **Desktop (≥768px): IDENTICAL to 1.4.0** the persistent rail + row layout, untouched.
13
+ Purely additive; no consuming product's desktop layout changes.
14
+ - **Below 768px:** the rail is not rendered beside the content; instead a **hamburger**
15
+ (in the header) opens the same `Sidebar` as an overlay **drawer** (a tap-to-close scrim +
16
+ a left panel) and the content takes the full width. The drawer closes on a scrim tap and
17
+ on a nav-item press (web: a bubbled click on a `role="button"` / link inside the panel).
18
+
19
+ Accessibility: the hamburger has a testID + `accessibilityLabel` + `accessibilityHint` and a
20
+ themed focus ring; the drawer panel is `role="dialog"` `aria-modal`; the scrim is a labelled
21
+ button. New optional prop `mobileMenu?: Partial<DrawerLabels>` supplies pre-localized
22
+ open/close labels (English defaults otherwise this package has no i18n). New testID
23
+ suffixes: `-menu-toggle`, `-drawer`, `-drawer-scrim`.
24
+
25
+ **Backwards compatible.** No breaking prop changes — consumers pass the same `sidebar` slot.
26
+ Omit `sidebar` and the tree is byte-identical to 1.4.0 (erevna / katalogos / kefi untouched).
27
+ Only a `sidebar` consumer on a narrow viewport sees the drawer. Pinned by tests asserting the
28
+ rail is present on desktop and absent (hamburger present) on a narrow viewport.
29
+
30
+ ## 1.4.0
31
+
32
+ Add an optional **`sidebar`** slot to `AppShell` the back-office layout.
33
+
34
+ `AppShell` could only stack vertically (header → nav strip → banner → scrolling content),
35
+ so a product with a persistent LEFT RAIL (Agora's merchant admin: products / categories /
36
+ coupons / settings) had to hand-compose `Topbar` + `Sidebar` + a `ScrollView` row and give
37
+ up the shell's auth gate and loading/error/forbidden state cards. Supplying `sidebar` now
38
+ lays the body out as a ROW — the rail on the left, the width-disciplined scrolling content
39
+ column on the right — while every existing feature (gate, state cards, `width`,
40
+ `chromeAlignment`) keeps working.
41
+
42
+ The new region is exposed as `` `${testID}${APP_SHELL_SUFFIX.sidebar}` `` (`-sidebar`).
43
+
44
+ **Backwards compatible.** Omit `sidebar` and the render tree is byte-identical to 1.3.0 —
45
+ erevna / katalogos / kefi / aml-v2 are untouched. Pinned by a test asserting no `-sidebar`
46
+ region exists when the slot is omitted.
47
+
48
+ ## 1.3.0
49
+
50
+ Give the `NavBar` the v1 `.gn-links` look + a full accessibility pass (and extend the
51
+ same a11y bar to `Sidebar`/`Topbar`).
52
+
53
+ **Look & hover (NavBar).** Each link is a rounded-pill target with the v1 hover
54
+ affordance — muted at rest, and on HOVER the text brightens to full contrast while a
55
+ subtle rounded-pill background appears; the ACTIVE link is a solid accent pill. All
56
+ theme-driven (rest = `textSecondary`, hover text = `text`, hover bg = `surfaceElevated`,
57
+ active/ring = `palette.primary`). Hover/focus are web-only (`onHoverIn/Out`, `onFocus/Blur`
58
+ via `Pressable`); native stays at the rest style. The hover cross-fade is cheap CSS and is
59
+ disabled under `prefers-reduced-motion: reduce` (new `useReducedMotion`). Backwards
60
+ compatible — the hover pill is the default (aml-v2 is the only `NavBar` consumer) and the
61
+ brand/right slots, responsive hamburger collapse, and `containerStyle` width behaviour are
62
+ unchanged.
63
+
64
+ **Accessibility.** NavBar toggle now carries `aria-controls` pointing at the collapsible
65
+ links region (id via `nativeID`), keeps `aria-expanded`, and gains a themed keyboard focus
66
+ ring; links keep `aria-current="page"` + `role="link"` and gain a focus ring; link/toggle
67
+ touch targets are now ≥44×44. `Sidebar` leaves gain `aria-current="page"` + a focus ring;
68
+ `Topbar` action buttons gain a focus ring (new internal `FocusableTouchable`). Focus rings
69
+ never remove the outline without a themed replacement.
70
+
71
+ ## 1.2.0
72
+
73
+ Add `NavBar` — a config-driven **horizontal top navigation bar**, the top-bar
74
+ counterpart to `Sidebar`. It renders the same caller-supplied `NavItem[]` as inline
75
+ links across the top (brand left slot + inline links + free-form right slot), with
76
+ an active-link highlight (`aria-current="page"`) and a responsive collapse to a
77
+ hamburger toggle below a configurable breakpoint (`collapseBelow`, default `760`) —
78
+ mirroring the v1 AML console `.global-nav`. Theme-driven (UiProvider colours),
79
+ `role="navigation"` landmark, keyboard-operable links. Purely additive: `Sidebar`,
80
+ `Topbar`, `AppShell`, and the role-gating helpers are unchanged. New `NAV_TEST_IDS`
81
+ entries `navBarToggle` / `navBarLinks`.
82
+
83
+ ## 1.0.0
84
+
85
+ Initial release. `Sidebar` (leaf + expandable items, active-route highlight, header/footer
86
+ slots), `Topbar` (logo/language/notification/user/account/logout slots), and the
87
+ `accessibleNavItems` / `roleRoutesToNavItems` role-gating helpers (reusing
88
+ `resolveAccessibleRoutes` from `@dloizides/auth-web`). Chrome + metrics ported verbatim from
89
+ the byte-identical erevna-web / katalogos-web nav twins; every colour reads from the
90
+ `@dloizides/ui-feedback` UiProvider theme.
package/dist/index.d.mts CHANGED
@@ -195,6 +195,33 @@ interface NavBarProps {
195
195
  }
196
196
  declare const NavBar: ({ items, pathname, onNavigate, regionLabel, navigateHint, brand, right, menuLabel, menuHint, renderMenuIcon, collapseBelow, containerStyle, }: NavBarProps) => React.ReactElement;
197
197
 
198
+ /**
199
+ * MobileDrawer — the below-the-breakpoint face of `AppShell`'s back-office
200
+ * `sidebar` layout. On a narrow viewport (<768px) the persistent left rail would
201
+ * starve the content column, so instead of the rail we render a hamburger toggle
202
+ * (`MenuToggle`) in the header and slide the same `Sidebar` in as an overlay
203
+ * drawer (a scrim + a left panel). The drawer closes on a scrim tap and on any
204
+ * nav-item press inside the panel (web: a bubbled click on a `role="button"` /
205
+ * link element). Desktop is untouched — AppShell never mounts this above 768px.
206
+ *
207
+ * Contract discipline (same as the rest of `@dloizides/ui-nav`): NO FM / router /
208
+ * store imports. Labels are pre-localized strings (English defaults, overridable
209
+ * by the consumer via `AppShell`'s `mobileMenu` prop); every colour is read from
210
+ * the `@dloizides/ui-feedback` UiProvider theme.
211
+ */
212
+
213
+ /** Pre-localized labels for the drawer's hamburger + scrim. */
214
+ interface DrawerLabels {
215
+ /** Accessible name of the hamburger that opens the drawer. */
216
+ openLabel: string;
217
+ /** Accessible hint of the hamburger that opens the drawer. */
218
+ openHint: string;
219
+ /** Accessible name of the scrim (tap to close). */
220
+ closeLabel: string;
221
+ /** Accessible hint of the scrim (tap to close). */
222
+ closeHint: string;
223
+ }
224
+
198
225
  /** Content-width policy: a capped column, optionally wider past a viewport breakpoint, or full-bleed. */
199
226
  type AppShellWidth = {
200
227
  max: number;
@@ -218,6 +245,13 @@ declare function useContentMaxWidth(width: AppShellWidth): number | 'full';
218
245
  * (pending spinner / redirect-when-unauthenticated) and page state cards
219
246
  * (loading / error / forbidden).
220
247
  *
248
+ * A BACK-OFFICE variant is supported via the optional `sidebar` slot (added for
249
+ * Agora's merchant admin): supply a `<Sidebar>` and the body below the header /
250
+ * nav / banner becomes a ROW — a persistent left rail beside the scrolling
251
+ * content column — instead of the default single vertical stack. Omit `sidebar`
252
+ * and the render tree is byte-identical to before, so every existing consumer
253
+ * (erevna / katalogos / kefi) is untouched.
254
+ *
221
255
  * Contract discipline (same as the rest of `@dloizides/ui-nav`): NO FM, router,
222
256
  * or store imports. The app passes wired elements, pre-localized strings, and
223
257
  * callbacks; every colour is routed through the `@dloizides/ui-feedback` theme.
@@ -233,6 +267,23 @@ interface AppShellProps {
233
267
  header: React.ReactNode;
234
268
  /** Per-app nav region (kefi coral pills / erevna sidebar+drawer). */
235
269
  nav?: React.ReactNode;
270
+ /**
271
+ * Optional persistent LEFT RAIL (a `<Sidebar>`), for back-office surfaces. When
272
+ * supplied, the region below header/nav/banner lays out as a row: the rail on the
273
+ * left, the scrolling content column on the right. Omit for the default vertical
274
+ * stack — the tree is then byte-identical to the pre-`sidebar` AppShell.
275
+ *
276
+ * Responsive: at/under ~768px the persistent rail is REPLACED by a hamburger (in
277
+ * the header) that opens the same `sidebar` as an overlay drawer, so the content
278
+ * takes the full width. Desktop (≥768px) is unchanged. No effect without `sidebar`.
279
+ */
280
+ sidebar?: React.ReactNode;
281
+ /**
282
+ * Pre-localized labels for the mobile nav drawer (only used in the `sidebar`
283
+ * layout, below the breakpoint): the hamburger's open label/hint and the scrim's
284
+ * close label/hint. Optional — English defaults apply. NO i18n in this package.
285
+ */
286
+ mobileMenu?: Partial<DrawerLabels>;
236
287
  /** Banner slot (e.g. verification-pending). */
237
288
  banner?: React.ReactNode;
238
289
  /** Content-width policy. Defaults to full-bleed. */
@@ -261,7 +312,7 @@ interface AppShellProps {
261
312
  testID: string;
262
313
  children: React.ReactNode;
263
314
  }
264
- declare const AppShell: ({ header, nav, banner, width, contentPadding, chromeAlignment, gate, state, testID, children, }: AppShellProps) => React.ReactElement | null;
315
+ declare const AppShell: ({ header, nav, sidebar, mobileMenu, banner, width, contentPadding, chromeAlignment, gate, state, testID, children, }: AppShellProps) => React.ReactElement | null;
265
316
 
266
317
  /**
267
318
  * Default testIDs for `@dloizides/ui-nav`. Kept as a small central map (mirrors
@@ -289,6 +340,14 @@ declare const APP_SHELL_SUFFIX: {
289
340
  readonly content: "-content";
290
341
  readonly header: "-header";
291
342
  readonly nav: "-nav";
343
+ /** The persistent left rail of the back-office layout (only when `sidebar` is supplied). */
344
+ readonly sidebar: "-sidebar";
345
+ /** Mobile hamburger that opens the nav drawer (back-office layout, below the breakpoint). */
346
+ readonly menuToggle: "-menu-toggle";
347
+ /** The mobile nav drawer panel (holds the `sidebar` slot when opened on a narrow viewport). */
348
+ readonly drawer: "-drawer";
349
+ /** The mobile nav drawer scrim (tap to close). */
350
+ readonly scrim: "-drawer-scrim";
292
351
  readonly banner: "-banner";
293
352
  readonly pending: "-pending";
294
353
  readonly loading: "-loading";
package/dist/index.d.ts CHANGED
@@ -195,6 +195,33 @@ interface NavBarProps {
195
195
  }
196
196
  declare const NavBar: ({ items, pathname, onNavigate, regionLabel, navigateHint, brand, right, menuLabel, menuHint, renderMenuIcon, collapseBelow, containerStyle, }: NavBarProps) => React.ReactElement;
197
197
 
198
+ /**
199
+ * MobileDrawer — the below-the-breakpoint face of `AppShell`'s back-office
200
+ * `sidebar` layout. On a narrow viewport (<768px) the persistent left rail would
201
+ * starve the content column, so instead of the rail we render a hamburger toggle
202
+ * (`MenuToggle`) in the header and slide the same `Sidebar` in as an overlay
203
+ * drawer (a scrim + a left panel). The drawer closes on a scrim tap and on any
204
+ * nav-item press inside the panel (web: a bubbled click on a `role="button"` /
205
+ * link element). Desktop is untouched — AppShell never mounts this above 768px.
206
+ *
207
+ * Contract discipline (same as the rest of `@dloizides/ui-nav`): NO FM / router /
208
+ * store imports. Labels are pre-localized strings (English defaults, overridable
209
+ * by the consumer via `AppShell`'s `mobileMenu` prop); every colour is read from
210
+ * the `@dloizides/ui-feedback` UiProvider theme.
211
+ */
212
+
213
+ /** Pre-localized labels for the drawer's hamburger + scrim. */
214
+ interface DrawerLabels {
215
+ /** Accessible name of the hamburger that opens the drawer. */
216
+ openLabel: string;
217
+ /** Accessible hint of the hamburger that opens the drawer. */
218
+ openHint: string;
219
+ /** Accessible name of the scrim (tap to close). */
220
+ closeLabel: string;
221
+ /** Accessible hint of the scrim (tap to close). */
222
+ closeHint: string;
223
+ }
224
+
198
225
  /** Content-width policy: a capped column, optionally wider past a viewport breakpoint, or full-bleed. */
199
226
  type AppShellWidth = {
200
227
  max: number;
@@ -218,6 +245,13 @@ declare function useContentMaxWidth(width: AppShellWidth): number | 'full';
218
245
  * (pending spinner / redirect-when-unauthenticated) and page state cards
219
246
  * (loading / error / forbidden).
220
247
  *
248
+ * A BACK-OFFICE variant is supported via the optional `sidebar` slot (added for
249
+ * Agora's merchant admin): supply a `<Sidebar>` and the body below the header /
250
+ * nav / banner becomes a ROW — a persistent left rail beside the scrolling
251
+ * content column — instead of the default single vertical stack. Omit `sidebar`
252
+ * and the render tree is byte-identical to before, so every existing consumer
253
+ * (erevna / katalogos / kefi) is untouched.
254
+ *
221
255
  * Contract discipline (same as the rest of `@dloizides/ui-nav`): NO FM, router,
222
256
  * or store imports. The app passes wired elements, pre-localized strings, and
223
257
  * callbacks; every colour is routed through the `@dloizides/ui-feedback` theme.
@@ -233,6 +267,23 @@ interface AppShellProps {
233
267
  header: React.ReactNode;
234
268
  /** Per-app nav region (kefi coral pills / erevna sidebar+drawer). */
235
269
  nav?: React.ReactNode;
270
+ /**
271
+ * Optional persistent LEFT RAIL (a `<Sidebar>`), for back-office surfaces. When
272
+ * supplied, the region below header/nav/banner lays out as a row: the rail on the
273
+ * left, the scrolling content column on the right. Omit for the default vertical
274
+ * stack — the tree is then byte-identical to the pre-`sidebar` AppShell.
275
+ *
276
+ * Responsive: at/under ~768px the persistent rail is REPLACED by a hamburger (in
277
+ * the header) that opens the same `sidebar` as an overlay drawer, so the content
278
+ * takes the full width. Desktop (≥768px) is unchanged. No effect without `sidebar`.
279
+ */
280
+ sidebar?: React.ReactNode;
281
+ /**
282
+ * Pre-localized labels for the mobile nav drawer (only used in the `sidebar`
283
+ * layout, below the breakpoint): the hamburger's open label/hint and the scrim's
284
+ * close label/hint. Optional — English defaults apply. NO i18n in this package.
285
+ */
286
+ mobileMenu?: Partial<DrawerLabels>;
236
287
  /** Banner slot (e.g. verification-pending). */
237
288
  banner?: React.ReactNode;
238
289
  /** Content-width policy. Defaults to full-bleed. */
@@ -261,7 +312,7 @@ interface AppShellProps {
261
312
  testID: string;
262
313
  children: React.ReactNode;
263
314
  }
264
- declare const AppShell: ({ header, nav, banner, width, contentPadding, chromeAlignment, gate, state, testID, children, }: AppShellProps) => React.ReactElement | null;
315
+ declare const AppShell: ({ header, nav, sidebar, mobileMenu, banner, width, contentPadding, chromeAlignment, gate, state, testID, children, }: AppShellProps) => React.ReactElement | null;
265
316
 
266
317
  /**
267
318
  * Default testIDs for `@dloizides/ui-nav`. Kept as a small central map (mirrors
@@ -289,6 +340,14 @@ declare const APP_SHELL_SUFFIX: {
289
340
  readonly content: "-content";
290
341
  readonly header: "-header";
291
342
  readonly nav: "-nav";
343
+ /** The persistent left rail of the back-office layout (only when `sidebar` is supplied). */
344
+ readonly sidebar: "-sidebar";
345
+ /** Mobile hamburger that opens the nav drawer (back-office layout, below the breakpoint). */
346
+ readonly menuToggle: "-menu-toggle";
347
+ /** The mobile nav drawer panel (holds the `sidebar` slot when opened on a narrow viewport). */
348
+ readonly drawer: "-drawer";
349
+ /** The mobile nav drawer scrim (tap to close). */
350
+ readonly scrim: "-drawer-scrim";
292
351
  readonly banner: "-banner";
293
352
  readonly pending: "-pending";
294
353
  readonly loading: "-loading";
package/dist/index.js CHANGED
@@ -346,6 +346,14 @@ var APP_SHELL_SUFFIX = {
346
346
  content: "-content",
347
347
  header: "-header",
348
348
  nav: "-nav",
349
+ /** The persistent left rail of the back-office layout (only when `sidebar` is supplied). */
350
+ sidebar: "-sidebar",
351
+ /** Mobile hamburger that opens the nav drawer (back-office layout, below the breakpoint). */
352
+ menuToggle: "-menu-toggle",
353
+ /** The mobile nav drawer panel (holds the `sidebar` slot when opened on a narrow viewport). */
354
+ drawer: "-drawer",
355
+ /** The mobile nav drawer scrim (tap to close). */
356
+ scrim: "-drawer-scrim",
349
357
  banner: "-banner",
350
358
  pending: "-pending",
351
359
  loading: "-loading",
@@ -669,6 +677,82 @@ var NavBar = ({
669
677
  }
670
678
  );
671
679
  };
680
+ var MOBILE_BREAKPOINT = 768;
681
+ var SCRIM_COLOR = "rgba(0, 0, 0, 0.5)";
682
+ var MENU_GLYPH2 = "\u2630";
683
+ var MENU_MIN_TARGET = 44;
684
+ var MENU_GLYPH_FONT_SIZE = 20;
685
+ var MENU_TOGGLE_PADDING = 12;
686
+ var DRAWER_Z_INDEX = 50;
687
+ var NAV_ACTIVATABLE_SELECTOR = 'a, [role="button"], [role="link"]';
688
+ var DEFAULT_DRAWER_LABELS = {
689
+ openLabel: "Menu",
690
+ openHint: "Open the navigation menu",
691
+ closeLabel: "Close menu",
692
+ closeHint: "Close the navigation menu"
693
+ };
694
+ var styles = reactNative.StyleSheet.create({
695
+ overlay: { ...reactNative.StyleSheet.absoluteFillObject, zIndex: DRAWER_Z_INDEX },
696
+ scrim: { ...reactNative.StyleSheet.absoluteFillObject, backgroundColor: SCRIM_COLOR },
697
+ panel: { position: "absolute", top: 0, bottom: 0, left: 0 },
698
+ menuToggle: {
699
+ minWidth: MENU_MIN_TARGET,
700
+ minHeight: MENU_MIN_TARGET,
701
+ alignItems: "center",
702
+ justifyContent: "center",
703
+ paddingHorizontal: MENU_TOGGLE_PADDING
704
+ },
705
+ menuToggleGlyph: { fontSize: MENU_GLYPH_FONT_SIZE, fontWeight: "700" }
706
+ });
707
+ var MenuToggle = ({ label, hint, onOpen, testID }) => {
708
+ const { theme } = uiFeedback.useUi();
709
+ return /* @__PURE__ */ jsxRuntime.jsx(
710
+ FocusableTouchable,
711
+ {
712
+ accessibilityHint: hint,
713
+ accessibilityLabel: label,
714
+ ringColor: theme.palette.primary["500"],
715
+ style: styles.menuToggle,
716
+ testID,
717
+ onPress: onOpen,
718
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles.menuToggleGlyph, { color: theme.colors.text }], children: MENU_GLYPH2 })
719
+ }
720
+ );
721
+ };
722
+ var MobileDrawer = ({
723
+ sidebar,
724
+ labels,
725
+ onClose,
726
+ drawerTestID,
727
+ scrimTestID
728
+ }) => {
729
+ const panelRef = react.useRef(null);
730
+ react.useEffect(() => {
731
+ const node = panelRef.current;
732
+ if (node === null || typeof node.addEventListener !== "function") return void 0;
733
+ const handleClick = (event) => {
734
+ const target = event.target;
735
+ const onNavItem = target !== null && target.closest(NAV_ACTIVATABLE_SELECTOR) !== null;
736
+ if (onNavItem) onClose();
737
+ };
738
+ node.addEventListener("click", handleClick);
739
+ return () => node.removeEventListener("click", handleClick);
740
+ }, [onClose]);
741
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles.overlay, children: [
742
+ /* @__PURE__ */ jsxRuntime.jsx(
743
+ reactNative.Pressable,
744
+ {
745
+ accessibilityHint: labels.closeHint,
746
+ accessibilityLabel: labels.closeLabel,
747
+ accessibilityRole: "button",
748
+ style: styles.scrim,
749
+ testID: scrimTestID,
750
+ onPress: onClose
751
+ }
752
+ ),
753
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { ref: panelRef, "aria-modal": true, role: "dialog", style: styles.panel, testID: drawerTestID, children: sidebar })
754
+ ] });
755
+ };
672
756
  function resolveContentMaxWidth(width, viewport) {
673
757
  if (width === "full") return "full";
674
758
  const wideMinViewport = width.wideMinViewport ?? Number.POSITIVE_INFINITY;
@@ -686,11 +770,16 @@ var CARD_BORDER_WIDTH = 1;
686
770
  var CARD_TITLE_FONT_SIZE = 16;
687
771
  var CARD_MESSAGE_FONT_SIZE = 14;
688
772
  var CARD_TITLE_MARGIN_BOTTOM = 8;
689
- var styles = reactNative.StyleSheet.create({
773
+ var styles2 = reactNative.StyleSheet.create({
690
774
  root: { flex: 1 },
691
775
  centerFill: { flex: 1, justifyContent: "center", alignItems: "center" },
692
776
  scroll: { flex: 1 },
693
777
  scrollContent: { flexGrow: 1 },
778
+ // Mobile back-office header: the hamburger sits inline before the header slot.
779
+ headerRow: { flexDirection: "row", alignItems: "center" },
780
+ headerFill: { flex: 1 },
781
+ // Back-office layout: a persistent left rail beside the scrolling content column.
782
+ bodyRow: { flex: 1, flexDirection: "row" },
694
783
  columnFull: { flex: 1 },
695
784
  columnCapped: { flex: 1, width: "100%", alignSelf: "center" },
696
785
  // Non-flex variant used to cap the nav strip's inner content to the content
@@ -714,11 +803,11 @@ function MessageCard({
714
803
  return /* @__PURE__ */ jsxRuntime.jsxs(
715
804
  reactNative.View,
716
805
  {
717
- style: [styles.card, { backgroundColor: colors.surface, borderColor: accentColor }],
806
+ style: [styles2.card, { backgroundColor: colors.surface, borderColor: accentColor }],
718
807
  testID,
719
808
  children: [
720
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles.cardTitle, { color: accentColor }], children: message.titleText }),
721
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles.cardMessage, { color: colors.textSecondary }], children: message.messageText })
809
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles2.cardTitle, { color: accentColor }], children: message.titleText }),
810
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles2.cardMessage, { color: colors.textSecondary }], children: message.messageText })
722
811
  ]
723
812
  }
724
813
  );
@@ -732,12 +821,14 @@ function useContentBody(state, children, testID) {
732
821
  if (state?.error)
733
822
  return /* @__PURE__ */ jsxRuntime.jsx(MessageCard, { accentColor: errorColor, message: state.error, testID: `${testID}${APP_SHELL_SUFFIX.error}` });
734
823
  if (state?.loading === true)
735
- return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles.centerFill, testID: `${testID}${APP_SHELL_SUFFIX.loading}`, children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.ActivityIndicator, { color: primary, size: "large" }) });
824
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles2.centerFill, testID: `${testID}${APP_SHELL_SUFFIX.loading}`, children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.ActivityIndicator, { color: primary, size: "large" }) });
736
825
  return children;
737
826
  }
738
827
  var AppShell = ({
739
828
  header,
740
829
  nav,
830
+ sidebar,
831
+ mobileMenu,
741
832
  banner,
742
833
  width = "full",
743
834
  contentPadding = DEFAULT_CONTENT_PADDING,
@@ -751,28 +842,65 @@ var AppShell = ({
751
842
  const primary = theme.palette.primary["500"];
752
843
  const maxWidth = useContentMaxWidth(width);
753
844
  const body = useContentBody(state, children, testID);
845
+ const { width: viewportWidth } = reactNative.useWindowDimensions();
846
+ const isNarrow = viewportWidth < MOBILE_BREAKPOINT;
847
+ const useDrawer = sidebar !== void 0 && isNarrow;
848
+ const [drawerOpen, setDrawerOpen] = react.useState(false);
849
+ const openDrawer = react.useCallback(() => setDrawerOpen(true), []);
850
+ const closeDrawer = react.useCallback(() => setDrawerOpen(false), []);
851
+ react.useEffect(() => {
852
+ if (!isNarrow && drawerOpen) setDrawerOpen(false);
853
+ }, [isNarrow, drawerOpen]);
854
+ const drawerLabels = { ...DEFAULT_DRAWER_LABELS, ...mobileMenu };
754
855
  const isUnauthenticated = gate !== void 0 && !gate.pending && !gate.authenticated;
755
856
  react.useEffect(() => {
756
857
  if (isUnauthenticated && gate !== void 0) gate.onRedirect();
757
858
  }, [isUnauthenticated, gate]);
758
859
  if (gate?.pending === true)
759
- return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles.root, styles.centerFill, { backgroundColor: theme.colors.background }], testID: `${testID}${APP_SHELL_SUFFIX.pending}`, children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.ActivityIndicator, { color: primary, size: "large" }) });
860
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles2.root, styles2.centerFill, { backgroundColor: theme.colors.background }], testID: `${testID}${APP_SHELL_SUFFIX.pending}`, children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.ActivityIndicator, { color: primary, size: "large" }) });
760
861
  if (isUnauthenticated) return null;
761
- const columnStyle = maxWidth === "full" ? styles.columnFull : [styles.columnCapped, { maxWidth }];
762
- const navInnerStyle = chromeAlignment === "content" && maxWidth !== "full" ? [styles.chromeCapped, { maxWidth, paddingHorizontal: contentPadding }] : void 0;
763
- return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles.root, { backgroundColor: theme.colors.background }], testID, children: [
764
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: header }),
862
+ const columnStyle = maxWidth === "full" ? styles2.columnFull : [styles2.columnCapped, { maxWidth }];
863
+ const navInnerStyle = chromeAlignment === "content" && maxWidth !== "full" ? [styles2.chromeCapped, { maxWidth, paddingHorizontal: contentPadding }] : void 0;
864
+ const scroller = /* @__PURE__ */ jsxRuntime.jsx(
865
+ reactNative.ScrollView,
866
+ {
867
+ contentContainerStyle: [styles2.scrollContent, { padding: contentPadding }],
868
+ style: styles2.scroll,
869
+ testID: `${testID}${APP_SHELL_SUFFIX.content}`,
870
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: columnStyle, children: body })
871
+ }
872
+ );
873
+ const headerRegion = useDrawer ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles2.headerRow, testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: [
874
+ /* @__PURE__ */ jsxRuntime.jsx(
875
+ MenuToggle,
876
+ {
877
+ hint: drawerLabels.openHint,
878
+ label: drawerLabels.openLabel,
879
+ testID: `${testID}${APP_SHELL_SUFFIX.menuToggle}`,
880
+ onOpen: openDrawer
881
+ }
882
+ ),
883
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles2.headerFill, children: header })
884
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: header });
885
+ const showRail = sidebar !== void 0 && !isNarrow;
886
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles2.root, { backgroundColor: theme.colors.background }], testID, children: [
887
+ headerRegion,
765
888
  nav !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.nav}`, children: navInnerStyle !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navInnerStyle, children: nav }) : nav }) : null,
766
889
  banner !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.banner}`, children: banner }) : null,
767
- /* @__PURE__ */ jsxRuntime.jsx(
768
- reactNative.ScrollView,
890
+ showRail ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles2.bodyRow, children: [
891
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.sidebar}`, children: sidebar }),
892
+ scroller
893
+ ] }) : scroller,
894
+ useDrawer && drawerOpen ? /* @__PURE__ */ jsxRuntime.jsx(
895
+ MobileDrawer,
769
896
  {
770
- contentContainerStyle: [styles.scrollContent, { padding: contentPadding }],
771
- style: styles.scroll,
772
- testID: `${testID}${APP_SHELL_SUFFIX.content}`,
773
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: columnStyle, children: body })
897
+ drawerTestID: `${testID}${APP_SHELL_SUFFIX.drawer}`,
898
+ labels: drawerLabels,
899
+ scrimTestID: `${testID}${APP_SHELL_SUFFIX.scrim}`,
900
+ sidebar,
901
+ onClose: closeDrawer
774
902
  }
775
- )
903
+ ) : null
776
904
  ] });
777
905
  };
778
906
  function roleRoutesToNavItems(routes, translate) {