@dloizides/ui-nav 1.4.0 → 1.6.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,5 +1,32 @@
1
1
  # Changelog
2
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
+
3
30
  ## 1.4.0
4
31
 
5
32
  Add an optional **`sidebar`** slot to `AppShell` — the back-office layout.
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;
@@ -245,8 +272,18 @@ interface AppShellProps {
245
272
  * supplied, the region below header/nav/banner lays out as a row: the rail on the
246
273
  * left, the scrolling content column on the right. Omit for the default vertical
247
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`.
248
279
  */
249
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>;
250
287
  /** Banner slot (e.g. verification-pending). */
251
288
  banner?: React.ReactNode;
252
289
  /** Content-width policy. Defaults to full-bleed. */
@@ -275,7 +312,7 @@ interface AppShellProps {
275
312
  testID: string;
276
313
  children: React.ReactNode;
277
314
  }
278
- declare const AppShell: ({ header, nav, sidebar, 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;
279
316
 
280
317
  /**
281
318
  * Default testIDs for `@dloizides/ui-nav`. Kept as a small central map (mirrors
@@ -305,6 +342,12 @@ declare const APP_SHELL_SUFFIX: {
305
342
  readonly nav: "-nav";
306
343
  /** The persistent left rail of the back-office layout (only when `sidebar` is supplied). */
307
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";
308
351
  readonly banner: "-banner";
309
352
  readonly pending: "-pending";
310
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;
@@ -245,8 +272,18 @@ interface AppShellProps {
245
272
  * supplied, the region below header/nav/banner lays out as a row: the rail on the
246
273
  * left, the scrolling content column on the right. Omit for the default vertical
247
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`.
248
279
  */
249
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>;
250
287
  /** Banner slot (e.g. verification-pending). */
251
288
  banner?: React.ReactNode;
252
289
  /** Content-width policy. Defaults to full-bleed. */
@@ -275,7 +312,7 @@ interface AppShellProps {
275
312
  testID: string;
276
313
  children: React.ReactNode;
277
314
  }
278
- declare const AppShell: ({ header, nav, sidebar, 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;
279
316
 
280
317
  /**
281
318
  * Default testIDs for `@dloizides/ui-nav`. Kept as a small central map (mirrors
@@ -305,6 +342,12 @@ declare const APP_SHELL_SUFFIX: {
305
342
  readonly nav: "-nav";
306
343
  /** The persistent left rail of the back-office layout (only when `sidebar` is supplied). */
307
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";
308
351
  readonly banner: "-banner";
309
352
  readonly pending: "-pending";
310
353
  readonly loading: "-loading";
package/dist/index.js CHANGED
@@ -76,7 +76,7 @@ var navBarStyles = reactNative.StyleSheet.create({
76
76
  rowGap: 4,
77
77
  paddingVertical: 8
78
78
  },
79
- brand: { marginRight: 16 },
79
+ brand: { marginRight: 16, flexShrink: 0 },
80
80
  toggle: {
81
81
  marginLeft: "auto",
82
82
  paddingHorizontal: 10,
@@ -89,10 +89,19 @@ var navBarStyles = reactNative.StyleSheet.create({
89
89
  justifyContent: "center"
90
90
  },
91
91
  toggleGlyph: { fontSize: 20, fontWeight: "700" },
92
+ // Expanded links live inside a horizontal ScrollView so the bar stays ONE ROW no matter how many
93
+ // items an app passes: they never wrap onto a second line — any overflow scrolls instead, with the
94
+ // brand (left) and right slot pinned. `linksScroll` is the ScrollView's own box (a shrinkable,
95
+ // growable flex child that may collapse to 0 and scroll); `links` is its non-wrapping content row.
96
+ linksScroll: {
97
+ flexGrow: 1,
98
+ flexShrink: 1,
99
+ minWidth: 0
100
+ },
92
101
  links: {
93
102
  flexDirection: "row",
94
103
  alignItems: "center",
95
- flexWrap: "wrap",
104
+ flexWrap: "nowrap",
96
105
  columnGap: 4,
97
106
  rowGap: 4
98
107
  },
@@ -121,6 +130,7 @@ var navBarStyles = reactNative.StyleSheet.create({
121
130
  linkText: { fontSize: 15, fontWeight: "600" },
122
131
  right: {
123
132
  marginLeft: "auto",
133
+ flexShrink: 0,
124
134
  flexDirection: "row",
125
135
  alignItems: "center",
126
136
  columnGap: 8
@@ -348,6 +358,12 @@ var APP_SHELL_SUFFIX = {
348
358
  nav: "-nav",
349
359
  /** The persistent left rail of the back-office layout (only when `sidebar` is supplied). */
350
360
  sidebar: "-sidebar",
361
+ /** Mobile hamburger that opens the nav drawer (back-office layout, below the breakpoint). */
362
+ menuToggle: "-menu-toggle",
363
+ /** The mobile nav drawer panel (holds the `sidebar` slot when opened on a narrow viewport). */
364
+ drawer: "-drawer",
365
+ /** The mobile nav drawer scrim (tap to close). */
366
+ scrim: "-drawer-scrim",
351
367
  banner: "-banner",
352
368
  pending: "-pending",
353
369
  loading: "-loading",
@@ -644,33 +660,115 @@ var NavBar = ({
644
660
  children: typeof renderMenuIcon === "function" ? renderMenuIcon(colors.text, open) : /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navBarStyles.toggleGlyph, { color: colors.text }], children: MENU_GLYPH })
645
661
  }
646
662
  ) : null,
647
- showLinks ? /* @__PURE__ */ jsxRuntime.jsx(
648
- reactNative.View,
649
- {
650
- nativeID: LINKS_REGION_ID,
651
- style: collapsed ? navBarStyles.linksStacked : navBarStyles.links,
652
- testID: NAV_TEST_IDS.navBarLinks,
653
- children: items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
654
- NavBarLink,
655
- {
656
- collapsed,
657
- colors: linkColors,
658
- iconSize: NAV_ICON_SIZE,
659
- isActive: isRouteActive(pathname, item.route),
660
- item,
661
- navigateHint,
662
- reducedMotion,
663
- onPress: handlePress
664
- },
665
- item.key
666
- ))
667
- }
668
- ) : null,
663
+ showLinks ? (() => {
664
+ const links = items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
665
+ NavBarLink,
666
+ {
667
+ collapsed,
668
+ colors: linkColors,
669
+ iconSize: NAV_ICON_SIZE,
670
+ isActive: isRouteActive(pathname, item.route),
671
+ item,
672
+ navigateHint,
673
+ reducedMotion,
674
+ onPress: handlePress
675
+ },
676
+ item.key
677
+ ));
678
+ return collapsed ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { nativeID: LINKS_REGION_ID, style: navBarStyles.linksStacked, testID: NAV_TEST_IDS.navBarLinks, children: links }) : /* @__PURE__ */ jsxRuntime.jsx(
679
+ reactNative.ScrollView,
680
+ {
681
+ horizontal: true,
682
+ showsHorizontalScrollIndicator: false,
683
+ nativeID: LINKS_REGION_ID,
684
+ style: navBarStyles.linksScroll,
685
+ contentContainerStyle: navBarStyles.links,
686
+ testID: NAV_TEST_IDS.navBarLinks,
687
+ children: links
688
+ }
689
+ );
690
+ })() : null,
669
691
  showLinks && right !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: collapsed ? navBarStyles.rightStacked : navBarStyles.right, children: right }) : null
670
692
  ] })
671
693
  }
672
694
  );
673
695
  };
696
+ var MOBILE_BREAKPOINT = 768;
697
+ var SCRIM_COLOR = "rgba(0, 0, 0, 0.5)";
698
+ var MENU_GLYPH2 = "\u2630";
699
+ var MENU_MIN_TARGET = 44;
700
+ var MENU_GLYPH_FONT_SIZE = 20;
701
+ var MENU_TOGGLE_PADDING = 12;
702
+ var DRAWER_Z_INDEX = 50;
703
+ var NAV_ACTIVATABLE_SELECTOR = 'a, [role="button"], [role="link"]';
704
+ var DEFAULT_DRAWER_LABELS = {
705
+ openLabel: "Menu",
706
+ openHint: "Open the navigation menu",
707
+ closeLabel: "Close menu",
708
+ closeHint: "Close the navigation menu"
709
+ };
710
+ var styles = reactNative.StyleSheet.create({
711
+ overlay: { ...reactNative.StyleSheet.absoluteFillObject, zIndex: DRAWER_Z_INDEX },
712
+ scrim: { ...reactNative.StyleSheet.absoluteFillObject, backgroundColor: SCRIM_COLOR },
713
+ panel: { position: "absolute", top: 0, bottom: 0, left: 0 },
714
+ menuToggle: {
715
+ minWidth: MENU_MIN_TARGET,
716
+ minHeight: MENU_MIN_TARGET,
717
+ alignItems: "center",
718
+ justifyContent: "center",
719
+ paddingHorizontal: MENU_TOGGLE_PADDING
720
+ },
721
+ menuToggleGlyph: { fontSize: MENU_GLYPH_FONT_SIZE, fontWeight: "700" }
722
+ });
723
+ var MenuToggle = ({ label, hint, onOpen, testID }) => {
724
+ const { theme } = uiFeedback.useUi();
725
+ return /* @__PURE__ */ jsxRuntime.jsx(
726
+ FocusableTouchable,
727
+ {
728
+ accessibilityHint: hint,
729
+ accessibilityLabel: label,
730
+ ringColor: theme.palette.primary["500"],
731
+ style: styles.menuToggle,
732
+ testID,
733
+ onPress: onOpen,
734
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles.menuToggleGlyph, { color: theme.colors.text }], children: MENU_GLYPH2 })
735
+ }
736
+ );
737
+ };
738
+ var MobileDrawer = ({
739
+ sidebar,
740
+ labels,
741
+ onClose,
742
+ drawerTestID,
743
+ scrimTestID
744
+ }) => {
745
+ const panelRef = react.useRef(null);
746
+ react.useEffect(() => {
747
+ const node = panelRef.current;
748
+ if (node === null || typeof node.addEventListener !== "function") return void 0;
749
+ const handleClick = (event) => {
750
+ const target = event.target;
751
+ const onNavItem = target !== null && target.closest(NAV_ACTIVATABLE_SELECTOR) !== null;
752
+ if (onNavItem) onClose();
753
+ };
754
+ node.addEventListener("click", handleClick);
755
+ return () => node.removeEventListener("click", handleClick);
756
+ }, [onClose]);
757
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles.overlay, children: [
758
+ /* @__PURE__ */ jsxRuntime.jsx(
759
+ reactNative.Pressable,
760
+ {
761
+ accessibilityHint: labels.closeHint,
762
+ accessibilityLabel: labels.closeLabel,
763
+ accessibilityRole: "button",
764
+ style: styles.scrim,
765
+ testID: scrimTestID,
766
+ onPress: onClose
767
+ }
768
+ ),
769
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { ref: panelRef, "aria-modal": true, role: "dialog", style: styles.panel, testID: drawerTestID, children: sidebar })
770
+ ] });
771
+ };
674
772
  function resolveContentMaxWidth(width, viewport) {
675
773
  if (width === "full") return "full";
676
774
  const wideMinViewport = width.wideMinViewport ?? Number.POSITIVE_INFINITY;
@@ -688,11 +786,14 @@ var CARD_BORDER_WIDTH = 1;
688
786
  var CARD_TITLE_FONT_SIZE = 16;
689
787
  var CARD_MESSAGE_FONT_SIZE = 14;
690
788
  var CARD_TITLE_MARGIN_BOTTOM = 8;
691
- var styles = reactNative.StyleSheet.create({
789
+ var styles2 = reactNative.StyleSheet.create({
692
790
  root: { flex: 1 },
693
791
  centerFill: { flex: 1, justifyContent: "center", alignItems: "center" },
694
792
  scroll: { flex: 1 },
695
793
  scrollContent: { flexGrow: 1 },
794
+ // Mobile back-office header: the hamburger sits inline before the header slot.
795
+ headerRow: { flexDirection: "row", alignItems: "center" },
796
+ headerFill: { flex: 1 },
696
797
  // Back-office layout: a persistent left rail beside the scrolling content column.
697
798
  bodyRow: { flex: 1, flexDirection: "row" },
698
799
  columnFull: { flex: 1 },
@@ -718,11 +819,11 @@ function MessageCard({
718
819
  return /* @__PURE__ */ jsxRuntime.jsxs(
719
820
  reactNative.View,
720
821
  {
721
- style: [styles.card, { backgroundColor: colors.surface, borderColor: accentColor }],
822
+ style: [styles2.card, { backgroundColor: colors.surface, borderColor: accentColor }],
722
823
  testID,
723
824
  children: [
724
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles.cardTitle, { color: accentColor }], children: message.titleText }),
725
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles.cardMessage, { color: colors.textSecondary }], children: message.messageText })
825
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles2.cardTitle, { color: accentColor }], children: message.titleText }),
826
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles2.cardMessage, { color: colors.textSecondary }], children: message.messageText })
726
827
  ]
727
828
  }
728
829
  );
@@ -736,13 +837,14 @@ function useContentBody(state, children, testID) {
736
837
  if (state?.error)
737
838
  return /* @__PURE__ */ jsxRuntime.jsx(MessageCard, { accentColor: errorColor, message: state.error, testID: `${testID}${APP_SHELL_SUFFIX.error}` });
738
839
  if (state?.loading === true)
739
- 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" }) });
840
+ 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" }) });
740
841
  return children;
741
842
  }
742
843
  var AppShell = ({
743
844
  header,
744
845
  nav,
745
846
  sidebar,
847
+ mobileMenu,
746
848
  banner,
747
849
  width = "full",
748
850
  contentPadding = DEFAULT_CONTENT_PADDING,
@@ -756,32 +858,65 @@ var AppShell = ({
756
858
  const primary = theme.palette.primary["500"];
757
859
  const maxWidth = useContentMaxWidth(width);
758
860
  const body = useContentBody(state, children, testID);
861
+ const { width: viewportWidth } = reactNative.useWindowDimensions();
862
+ const isNarrow = viewportWidth < MOBILE_BREAKPOINT;
863
+ const useDrawer = sidebar !== void 0 && isNarrow;
864
+ const [drawerOpen, setDrawerOpen] = react.useState(false);
865
+ const openDrawer = react.useCallback(() => setDrawerOpen(true), []);
866
+ const closeDrawer = react.useCallback(() => setDrawerOpen(false), []);
867
+ react.useEffect(() => {
868
+ if (!isNarrow && drawerOpen) setDrawerOpen(false);
869
+ }, [isNarrow, drawerOpen]);
870
+ const drawerLabels = { ...DEFAULT_DRAWER_LABELS, ...mobileMenu };
759
871
  const isUnauthenticated = gate !== void 0 && !gate.pending && !gate.authenticated;
760
872
  react.useEffect(() => {
761
873
  if (isUnauthenticated && gate !== void 0) gate.onRedirect();
762
874
  }, [isUnauthenticated, gate]);
763
875
  if (gate?.pending === true)
764
- 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" }) });
876
+ 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" }) });
765
877
  if (isUnauthenticated) return null;
766
- const columnStyle = maxWidth === "full" ? styles.columnFull : [styles.columnCapped, { maxWidth }];
767
- const navInnerStyle = chromeAlignment === "content" && maxWidth !== "full" ? [styles.chromeCapped, { maxWidth, paddingHorizontal: contentPadding }] : void 0;
878
+ const columnStyle = maxWidth === "full" ? styles2.columnFull : [styles2.columnCapped, { maxWidth }];
879
+ const navInnerStyle = chromeAlignment === "content" && maxWidth !== "full" ? [styles2.chromeCapped, { maxWidth, paddingHorizontal: contentPadding }] : void 0;
768
880
  const scroller = /* @__PURE__ */ jsxRuntime.jsx(
769
881
  reactNative.ScrollView,
770
882
  {
771
- contentContainerStyle: [styles.scrollContent, { padding: contentPadding }],
772
- style: styles.scroll,
883
+ contentContainerStyle: [styles2.scrollContent, { padding: contentPadding }],
884
+ style: styles2.scroll,
773
885
  testID: `${testID}${APP_SHELL_SUFFIX.content}`,
774
886
  children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: columnStyle, children: body })
775
887
  }
776
888
  );
777
- return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles.root, { backgroundColor: theme.colors.background }], testID, children: [
778
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: header }),
889
+ const headerRegion = useDrawer ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles2.headerRow, testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: [
890
+ /* @__PURE__ */ jsxRuntime.jsx(
891
+ MenuToggle,
892
+ {
893
+ hint: drawerLabels.openHint,
894
+ label: drawerLabels.openLabel,
895
+ testID: `${testID}${APP_SHELL_SUFFIX.menuToggle}`,
896
+ onOpen: openDrawer
897
+ }
898
+ ),
899
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles2.headerFill, children: header })
900
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: header });
901
+ const showRail = sidebar !== void 0 && !isNarrow;
902
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles2.root, { backgroundColor: theme.colors.background }], testID, children: [
903
+ headerRegion,
779
904
  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,
780
905
  banner !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.banner}`, children: banner }) : null,
781
- sidebar !== void 0 ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles.bodyRow, children: [
906
+ showRail ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles2.bodyRow, children: [
782
907
  /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.sidebar}`, children: sidebar }),
783
908
  scroller
784
- ] }) : scroller
909
+ ] }) : scroller,
910
+ useDrawer && drawerOpen ? /* @__PURE__ */ jsxRuntime.jsx(
911
+ MobileDrawer,
912
+ {
913
+ drawerTestID: `${testID}${APP_SHELL_SUFFIX.drawer}`,
914
+ labels: drawerLabels,
915
+ scrimTestID: `${testID}${APP_SHELL_SUFFIX.scrim}`,
916
+ sidebar,
917
+ onClose: closeDrawer
918
+ }
919
+ ) : null
785
920
  ] });
786
921
  };
787
922
  function roleRoutesToNavItems(routes, translate) {