@dloizides/ui-nav 1.6.1 → 1.8.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/dist/index.mjs CHANGED
@@ -1,7 +1,8 @@
1
- import { StyleSheet, Platform, TouchableOpacity, View, Text, useWindowDimensions, Pressable, ScrollView, ActivityIndicator } from 'react-native';
2
- import { useUi } from '@dloizides/ui-feedback';
3
- import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
1
+ import { StyleSheet, Platform, TouchableOpacity, View, Text, useWindowDimensions, ActivityIndicator, ScrollView, Pressable } from 'react-native';
2
+ import { useUi, UiProvider } from '@dloizides/ui-feedback';
3
+ import React5, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
4
4
  import { jsxs, jsx } from 'react/jsx-runtime';
5
+ import { ModalDropdown } from '@dloizides/ui-layout';
5
6
  import { resolveAccessibleRoutes } from '@dloizides/auth-web';
6
7
 
7
8
  // src/Sidebar.tsx
@@ -12,6 +13,7 @@ function isRouteActive(pathname, route) {
12
13
  return pathname === route || pathname.startsWith(`${route}/`);
13
14
  }
14
15
  var ACTIVE_BORDER_RADIUS = 4;
16
+ var NAV_LINK_GAP = 4;
15
17
  var navStyles = StyleSheet.create({
16
18
  // --- Sidebar ---
17
19
  sidebarContainer: {
@@ -94,22 +96,26 @@ var navBarStyles = StyleSheet.create({
94
96
  justifyContent: "center"
95
97
  },
96
98
  toggleGlyph: { fontSize: 20, fontWeight: "700" },
97
- // Expanded links live inside a horizontal ScrollView so the bar stays ONE ROW no matter how many
98
- // items an app passes: they never wrap onto a second line any overflow scrolls instead, with the
99
- // brand (left) and right slot pinned. `linksScroll` is the ScrollView's own box (a shrinkable,
100
- // growable flex child that may collapse to 0 and scroll); `links` is its non-wrapping content row.
101
- linksScroll: {
99
+ // Expanded links live on ONE ROW that never wraps: it is a shrinkable/growable flex child that
100
+ // fills the space between the pinned brand (left) and right slot, and CLIPS overflow. A measure
101
+ // pass (priority+) then keeps only the leading items that fit inline and collapses the rest behind
102
+ // the "More ▾" overflow trigger so no item is ever cut off; the surplus moves into a dropdown.
103
+ // `overflow:'hidden'` only masks the single unmeasured first frame before the fit resolves.
104
+ linksRow: {
102
105
  flexGrow: 1,
103
106
  flexShrink: 1,
104
- minWidth: 0
105
- },
106
- links: {
107
+ minWidth: 0,
107
108
  flexDirection: "row",
108
109
  alignItems: "center",
109
110
  flexWrap: "nowrap",
110
- columnGap: 4,
111
- rowGap: 4
111
+ columnGap: NAV_LINK_GAP,
112
+ overflow: "hidden"
112
113
  },
114
+ // Each inline link + the overflow trigger sit in a natural-width cell that never shrinks, so their
115
+ // measured widths are their true content widths (what the priority+ fit computation reasons about).
116
+ linkCell: { flexShrink: 0 },
117
+ overflowTrigger: { columnGap: 4 },
118
+ overflowChevron: { fontSize: 12, fontWeight: "700" },
113
119
  // Collapsed (below breakpoint): links drop onto their own full-width line,
114
120
  // stacked vertically — mirrors the v1 `.gn-links` responsive behaviour.
115
121
  linksStacked: {
@@ -149,6 +155,86 @@ var navBarStyles = StyleSheet.create({
149
155
  columnGap: 8
150
156
  }
151
157
  });
158
+ var pillNavStyles = StyleSheet.create({
159
+ // Cross-nav pill row (kefi coral dashboard switcher): fully-rounded pills, wraps.
160
+ container: {
161
+ flexDirection: "row",
162
+ flexWrap: "wrap",
163
+ alignItems: "center",
164
+ columnGap: 8,
165
+ rowGap: 8,
166
+ paddingVertical: 8
167
+ },
168
+ pill: {
169
+ paddingHorizontal: 16,
170
+ paddingVertical: 8,
171
+ borderRadius: 999,
172
+ minHeight: 36,
173
+ flexDirection: "row",
174
+ alignItems: "center",
175
+ columnGap: 6,
176
+ justifyContent: "center"
177
+ },
178
+ pillText: { fontSize: 14, fontWeight: "600" }
179
+ });
180
+ var darkModeStyles = StyleSheet.create({
181
+ // Segmented Light / Dark / System control.
182
+ segmentRow: {
183
+ flexDirection: "row",
184
+ alignItems: "center",
185
+ borderWidth: 1,
186
+ borderRadius: 8,
187
+ overflow: "hidden",
188
+ alignSelf: "flex-start"
189
+ },
190
+ segment: {
191
+ paddingHorizontal: 12,
192
+ paddingVertical: 8,
193
+ minHeight: 36,
194
+ flexDirection: "row",
195
+ alignItems: "center",
196
+ columnGap: 6,
197
+ justifyContent: "center"
198
+ },
199
+ segmentText: { fontSize: 13, fontWeight: "600" },
200
+ // Cycle-button variant.
201
+ cycle: {
202
+ paddingHorizontal: 12,
203
+ paddingVertical: 8,
204
+ minHeight: 44,
205
+ minWidth: 44,
206
+ borderRadius: 8,
207
+ flexDirection: "row",
208
+ alignItems: "center",
209
+ columnGap: 6,
210
+ justifyContent: "center"
211
+ },
212
+ cycleText: { fontSize: 13, fontWeight: "600" }
213
+ });
214
+ var collapsedRailStyles = StyleSheet.create({
215
+ // Intermediate (tablet) icon-only rail: a narrow vertical strip of icon buttons.
216
+ container: {
217
+ width: 64,
218
+ paddingTop: 16,
219
+ paddingHorizontal: 8,
220
+ borderRightWidth: 1,
221
+ height: "100%",
222
+ alignItems: "center"
223
+ },
224
+ // Each item is a ≥44×44 centred touch target with a rounded active/hover pill.
225
+ item: {
226
+ width: 48,
227
+ height: 48,
228
+ marginVertical: 4,
229
+ borderRadius: 8,
230
+ alignItems: "center",
231
+ justifyContent: "center"
232
+ },
233
+ // Glyph fallback (the label's first character) when an item has no renderIcon.
234
+ glyph: { fontSize: 18, fontWeight: "700" },
235
+ spacer: { flex: 1 },
236
+ slot: { alignItems: "center" }
237
+ });
152
238
  var expandableStyles = StyleSheet.create({
153
239
  childItem: {
154
240
  borderRadius: 4,
@@ -355,7 +441,9 @@ var NAV_TEST_IDS = {
355
441
  /** responsive hamburger toggle in the horizontal NavBar (shown below the breakpoint). */
356
442
  navBarToggle: "navbar-toggle",
357
443
  /** links container in the horizontal NavBar. */
358
- navBarLinks: "navbar-links"
444
+ navBarLinks: "navbar-links",
445
+ /** the "More ▾" priority+ overflow trigger in the horizontal NavBar (shown when items spill). */
446
+ navBarOverflow: "navbar-overflow"
359
447
  };
360
448
  var APP_SHELL_SUFFIX = {
361
449
  content: "-content",
@@ -363,6 +451,8 @@ var APP_SHELL_SUFFIX = {
363
451
  nav: "-nav",
364
452
  /** The persistent left rail of the back-office layout (only when `sidebar` is supplied). */
365
453
  sidebar: "-sidebar",
454
+ /** The intermediate persistent COLLAPSED (icon-only) rail (3-tier layout, tablet band). */
455
+ collapsedSidebar: "-collapsed-sidebar",
366
456
  /** Mobile hamburger that opens the nav drawer (back-office layout, below the breakpoint). */
367
457
  menuToggle: "-menu-toggle",
368
458
  /** The mobile nav drawer panel (holds the `sidebar` slot when opened on a narrow viewport). */
@@ -570,6 +660,108 @@ var NavBarLink = ({
570
660
  }
571
661
  );
572
662
  };
663
+ var TEXT_ON_PRIMARY3 = "#ffffff";
664
+ var NO_ACTIVE_ROUTE = "";
665
+ var NavOverflowMenu = ({
666
+ items,
667
+ pathname,
668
+ onNavigate,
669
+ colors,
670
+ label,
671
+ hint,
672
+ testID,
673
+ variant
674
+ }) => {
675
+ const options = useMemo(() => items.map((item) => ({ label: item.label, value: item.route })), [items]);
676
+ const activeRoute = useMemo(
677
+ () => items.find((item) => isRouteActive(pathname, item.route))?.route ?? NO_ACTIVE_ROUTE,
678
+ [items, pathname]
679
+ );
680
+ const optionTestID = useMemo(() => {
681
+ const byRoute = new Map(items.map((item) => [item.route, item.testID ?? item.key]));
682
+ return (route) => byRoute.get(route) ?? `${testID}-option-${route}`;
683
+ }, [items, testID]);
684
+ const hasActive = activeRoute !== NO_ACTIVE_ROUTE;
685
+ const textColor = hasActive ? TEXT_ON_PRIMARY3 : colors.rest;
686
+ const pillStyle = hasActive ? { backgroundColor: colors.activeBg } : void 0;
687
+ return /* @__PURE__ */ jsx(
688
+ ModalDropdown,
689
+ {
690
+ accessibilityHint: hint,
691
+ accessibilityLabel: label,
692
+ optionTestID,
693
+ options,
694
+ testID,
695
+ value: activeRoute,
696
+ variant,
697
+ onChange: onNavigate,
698
+ renderTrigger: ({ isOpen }) => /* @__PURE__ */ jsxs(View, { style: [navBarStyles.link, navBarStyles.overflowTrigger, pillStyle], children: [
699
+ /* @__PURE__ */ jsx(Text, { style: [navBarStyles.linkText, { color: textColor }], children: label }),
700
+ /* @__PURE__ */ jsx(Text, { style: [navBarStyles.overflowChevron, { color: textColor }], children: isOpen ? "\u25B4" : "\u25BE" })
701
+ ] })
702
+ }
703
+ );
704
+ };
705
+
706
+ // src/computeVisibleCount.ts
707
+ function sum(values) {
708
+ return values.reduce((acc, value) => acc + value, 0);
709
+ }
710
+ function computeVisibleCount({ availableWidth, itemWidths, moreWidth, gap }) {
711
+ const total = itemWidths.length;
712
+ if (total === 0) return 0;
713
+ if (availableWidth <= 0 || itemWidths.some((width) => width <= 0)) return total;
714
+ const fullRowWidth = sum(itemWidths) + gap * (total - 1);
715
+ if (fullRowWidth <= availableWidth) return total;
716
+ const reservedForMore = moreWidth + gap;
717
+ let used = 0;
718
+ let count = 0;
719
+ for (let index = 0; index < total; index += 1) {
720
+ const width = itemWidths[index] ?? 0;
721
+ const withGap = used + (count > 0 ? gap : 0) + width;
722
+ if (withGap + reservedForMore > availableWidth) break;
723
+ used = withGap;
724
+ count += 1;
725
+ }
726
+ return count;
727
+ }
728
+
729
+ // src/useNavOverflow.ts
730
+ function resizeWidthBuffer(previous, count) {
731
+ const next = new Array(count).fill(0);
732
+ const shared = Math.min(previous.length, count);
733
+ for (let index = 0; index < shared; index += 1) next[index] = previous[index] ?? 0;
734
+ return next;
735
+ }
736
+ function useNavOverflow(itemCount, gap) {
737
+ const [availableWidth, setAvailableWidthState] = useState(0);
738
+ const [moreWidth, setMoreWidthState] = useState(0);
739
+ const [itemWidths, setItemWidths] = useState(() => new Array(itemCount).fill(0));
740
+ useEffect(() => {
741
+ setItemWidths((previous) => previous.length === itemCount ? previous : resizeWidthBuffer(previous, itemCount));
742
+ }, [itemCount]);
743
+ const setItemWidth = useCallback((index, width) => {
744
+ setItemWidths((previous) => {
745
+ if (index < 0 || index >= previous.length || previous[index] === width) return previous;
746
+ const next = previous.slice();
747
+ next[index] = width;
748
+ return next;
749
+ });
750
+ }, []);
751
+ const setAvailableWidth = useCallback(
752
+ (width) => setAvailableWidthState((previous) => previous === width ? previous : width),
753
+ []
754
+ );
755
+ const setMoreWidth = useCallback(
756
+ (width) => setMoreWidthState((previous) => previous === width ? previous : width),
757
+ []
758
+ );
759
+ const visibleCount = useMemo(
760
+ () => computeVisibleCount({ availableWidth, itemWidths, moreWidth, gap }),
761
+ [availableWidth, itemWidths, moreWidth, gap]
762
+ );
763
+ return { visibleCount, setAvailableWidth, setItemWidth, setMoreWidth };
764
+ }
573
765
  var REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
574
766
  function getReducedMotionQuery() {
575
767
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return void 0;
@@ -594,7 +786,7 @@ var MENU_GLYPH = "\u2630";
594
786
  function ariaControlsProps(id) {
595
787
  return { "aria-controls": id };
596
788
  }
597
- var NavBar = ({
789
+ var NavBarInner = ({
598
790
  items,
599
791
  pathname,
600
792
  onNavigate,
@@ -606,6 +798,8 @@ var NavBar = ({
606
798
  menuHint = "",
607
799
  renderMenuIcon,
608
800
  collapseBelow = DEFAULT_COLLAPSE_BELOW,
801
+ overflowLabel = "More",
802
+ overflowHint = "",
609
803
  containerStyle
610
804
  }) => {
611
805
  const { theme } = useUi();
@@ -615,6 +809,7 @@ var NavBar = ({
615
809
  const reducedMotion = useReducedMotion();
616
810
  const [open, setOpen] = useState(false);
617
811
  const [toggleFocused, setToggleFocused] = useState(false);
812
+ const { visibleCount, setAvailableWidth, setItemWidth, setMoreWidth } = useNavOverflow(items.length, NAV_LINK_GAP);
618
813
  const collapsed = width < collapseBelow;
619
814
  const showLinks = !collapsed || open;
620
815
  const toggleMenu = useCallback(() => setOpen((v) => !v), []);
@@ -666,7 +861,7 @@ var NavBar = ({
666
861
  }
667
862
  ) : null,
668
863
  showLinks ? (() => {
669
- const links = items.map((item) => /* @__PURE__ */ jsx(
864
+ const renderLink = (item) => /* @__PURE__ */ jsx(
670
865
  NavBarLink,
671
866
  {
672
867
  collapsed,
@@ -677,19 +872,50 @@ var NavBar = ({
677
872
  navigateHint,
678
873
  reducedMotion,
679
874
  onPress: handlePress
680
- },
681
- item.key
682
- ));
683
- return collapsed ? /* @__PURE__ */ jsx(View, { nativeID: LINKS_REGION_ID, style: navBarStyles.linksStacked, testID: NAV_TEST_IDS.navBarLinks, children: links }) : /* @__PURE__ */ jsx(
684
- ScrollView,
875
+ }
876
+ );
877
+ if (collapsed) {
878
+ return /* @__PURE__ */ jsx(View, { nativeID: LINKS_REGION_ID, style: navBarStyles.linksStacked, testID: NAV_TEST_IDS.navBarLinks, children: items.map((item) => /* @__PURE__ */ jsx(React5.Fragment, { children: renderLink(item) }, item.key)) });
879
+ }
880
+ const visibleItems = items.slice(0, visibleCount);
881
+ const overflowItems = items.slice(visibleCount);
882
+ return /* @__PURE__ */ jsxs(
883
+ View,
685
884
  {
686
- horizontal: true,
687
- showsHorizontalScrollIndicator: false,
688
885
  nativeID: LINKS_REGION_ID,
689
- style: navBarStyles.linksScroll,
690
- contentContainerStyle: navBarStyles.links,
886
+ style: navBarStyles.linksRow,
691
887
  testID: NAV_TEST_IDS.navBarLinks,
692
- children: links
888
+ onLayout: (event) => setAvailableWidth(event.nativeEvent.layout.width),
889
+ children: [
890
+ visibleItems.map((item, index) => /* @__PURE__ */ jsx(
891
+ View,
892
+ {
893
+ style: navBarStyles.linkCell,
894
+ onLayout: (event) => setItemWidth(index, event.nativeEvent.layout.width),
895
+ children: renderLink(item)
896
+ },
897
+ item.key
898
+ )),
899
+ overflowItems.length > 0 ? /* @__PURE__ */ jsx(
900
+ View,
901
+ {
902
+ style: navBarStyles.linkCell,
903
+ onLayout: (event) => setMoreWidth(event.nativeEvent.layout.width),
904
+ children: /* @__PURE__ */ jsx(
905
+ NavOverflowMenu,
906
+ {
907
+ colors: linkColors,
908
+ hint: overflowHint,
909
+ items: overflowItems,
910
+ label: overflowLabel,
911
+ pathname,
912
+ testID: NAV_TEST_IDS.navBarOverflow,
913
+ onNavigate: handlePress
914
+ }
915
+ )
916
+ }
917
+ ) : null
918
+ ]
693
919
  }
694
920
  );
695
921
  })() : null,
@@ -698,7 +924,57 @@ var NavBar = ({
698
924
  }
699
925
  );
700
926
  };
701
- var MOBILE_BREAKPOINT = 768;
927
+ var NavBar = ({ barTheme, ...props }) => {
928
+ const { t, navigate } = useUi();
929
+ if (barTheme === void 0) return /* @__PURE__ */ jsx(NavBarInner, { ...props });
930
+ return /* @__PURE__ */ jsx(UiProvider, { navigate, t, theme: barTheme, children: /* @__PURE__ */ jsx(NavBarInner, { ...props }) });
931
+ };
932
+ var Nav = (props) => {
933
+ if (props.orientation === "vertical") {
934
+ return /* @__PURE__ */ jsx(Sidebar, { ...props });
935
+ }
936
+ return /* @__PURE__ */ jsx(NavBar, { ...props });
937
+ };
938
+ var CARD_PADDING = 20;
939
+ var CARD_BORDER_RADIUS = 12;
940
+ var CARD_BORDER_WIDTH = 1;
941
+ var CARD_TITLE_FONT_SIZE = 16;
942
+ var CARD_MESSAGE_FONT_SIZE = 14;
943
+ var CARD_TITLE_MARGIN_BOTTOM = 8;
944
+ var styles = StyleSheet.create({
945
+ centerFill: { flex: 1, justifyContent: "center", alignItems: "center" },
946
+ card: {
947
+ padding: CARD_PADDING,
948
+ borderRadius: CARD_BORDER_RADIUS,
949
+ borderWidth: CARD_BORDER_WIDTH
950
+ },
951
+ cardTitle: { fontSize: CARD_TITLE_FONT_SIZE, fontWeight: "700", marginBottom: CARD_TITLE_MARGIN_BOTTOM },
952
+ cardMessage: { fontSize: CARD_MESSAGE_FONT_SIZE }
953
+ });
954
+ function MessageCard({
955
+ message,
956
+ accentColor,
957
+ testID
958
+ }) {
959
+ const { theme } = useUi();
960
+ const colors = theme.colors;
961
+ return /* @__PURE__ */ jsxs(View, { style: [styles.card, { backgroundColor: colors.surface, borderColor: accentColor }], testID, children: [
962
+ /* @__PURE__ */ jsx(Text, { style: [styles.cardTitle, { color: accentColor }], children: message.titleText }),
963
+ /* @__PURE__ */ jsx(Text, { style: [styles.cardMessage, { color: colors.textSecondary }], children: message.messageText })
964
+ ] });
965
+ }
966
+ function useContentBody(state, children, testID) {
967
+ const { theme } = useUi();
968
+ const primary = theme.palette.primary["500"];
969
+ const errorColor = theme.semantic.error["500"];
970
+ if (state?.forbidden)
971
+ return /* @__PURE__ */ jsx(MessageCard, { accentColor: errorColor, message: state.forbidden, testID: `${testID}${APP_SHELL_SUFFIX.forbidden}` });
972
+ if (state?.error)
973
+ return /* @__PURE__ */ jsx(MessageCard, { accentColor: errorColor, message: state.error, testID: `${testID}${APP_SHELL_SUFFIX.error}` });
974
+ if (state?.loading === true)
975
+ return /* @__PURE__ */ jsx(View, { style: styles.centerFill, testID: `${testID}${APP_SHELL_SUFFIX.loading}`, children: /* @__PURE__ */ jsx(ActivityIndicator, { color: primary, size: "large" }) });
976
+ return children;
977
+ }
702
978
  var SCRIM_COLOR = "rgba(0, 0, 0, 0.5)";
703
979
  var MENU_GLYPH2 = "\u2630";
704
980
  var MENU_MIN_TARGET = 44;
@@ -712,7 +988,7 @@ var DEFAULT_DRAWER_LABELS = {
712
988
  closeLabel: "Close menu",
713
989
  closeHint: "Close the navigation menu"
714
990
  };
715
- var styles = StyleSheet.create({
991
+ var styles2 = StyleSheet.create({
716
992
  overlay: { ...StyleSheet.absoluteFillObject, zIndex: DRAWER_Z_INDEX },
717
993
  scrim: { ...StyleSheet.absoluteFillObject, backgroundColor: SCRIM_COLOR },
718
994
  panel: { position: "absolute", top: 0, bottom: 0, left: 0 },
@@ -733,10 +1009,10 @@ var MenuToggle = ({ label, hint, onOpen, testID }) => {
733
1009
  accessibilityHint: hint,
734
1010
  accessibilityLabel: label,
735
1011
  ringColor: theme.palette.primary["500"],
736
- style: styles.menuToggle,
1012
+ style: styles2.menuToggle,
737
1013
  testID,
738
1014
  onPress: onOpen,
739
- children: /* @__PURE__ */ jsx(Text, { style: [styles.menuToggleGlyph, { color: theme.colors.text }], children: MENU_GLYPH2 })
1015
+ children: /* @__PURE__ */ jsx(Text, { style: [styles2.menuToggleGlyph, { color: theme.colors.text }], children: MENU_GLYPH2 })
740
1016
  }
741
1017
  );
742
1018
  };
@@ -759,21 +1035,34 @@ var MobileDrawer = ({
759
1035
  node.addEventListener("click", handleClick);
760
1036
  return () => node.removeEventListener("click", handleClick);
761
1037
  }, [onClose]);
762
- return /* @__PURE__ */ jsxs(View, { style: styles.overlay, children: [
1038
+ return /* @__PURE__ */ jsxs(View, { style: styles2.overlay, children: [
763
1039
  /* @__PURE__ */ jsx(
764
1040
  Pressable,
765
1041
  {
766
1042
  accessibilityHint: labels.closeHint,
767
1043
  accessibilityLabel: labels.closeLabel,
768
1044
  accessibilityRole: "button",
769
- style: styles.scrim,
1045
+ style: styles2.scrim,
770
1046
  testID: scrimTestID,
771
1047
  onPress: onClose
772
1048
  }
773
1049
  ),
774
- /* @__PURE__ */ jsx(View, { ref: panelRef, "aria-modal": true, role: "dialog", style: styles.panel, testID: drawerTestID, children: sidebar })
1050
+ /* @__PURE__ */ jsx(View, { ref: panelRef, "aria-modal": true, role: "dialog", style: styles2.panel, testID: drawerTestID, children: sidebar })
775
1051
  ] });
776
1052
  };
1053
+
1054
+ // src/railMode.ts
1055
+ var RAIL_FULL_BREAKPOINT = 768;
1056
+ var DEFAULT_COLLAPSED_RAIL_MAX = 1024;
1057
+ function resolveRailMode({ viewport, hasSidebar, hasCollapsed, range }) {
1058
+ if (!hasSidebar) return "none";
1059
+ if (hasCollapsed) {
1060
+ if (viewport >= range.max) return "full";
1061
+ if (viewport >= range.min) return "collapsed";
1062
+ return "drawer";
1063
+ }
1064
+ return viewport >= RAIL_FULL_BREAKPOINT ? "full" : "drawer";
1065
+ }
777
1066
  function resolveContentMaxWidth(width, viewport) {
778
1067
  if (width === "full") return "full";
779
1068
  const wideMinViewport = width.wideMinViewport ?? Number.POSITIVE_INFINITY;
@@ -785,13 +1074,7 @@ function useContentMaxWidth(width) {
785
1074
  return resolveContentMaxWidth(width, viewport);
786
1075
  }
787
1076
  var DEFAULT_CONTENT_PADDING = 24;
788
- var CARD_PADDING = 20;
789
- var CARD_BORDER_RADIUS = 12;
790
- var CARD_BORDER_WIDTH = 1;
791
- var CARD_TITLE_FONT_SIZE = 16;
792
- var CARD_MESSAGE_FONT_SIZE = 14;
793
- var CARD_TITLE_MARGIN_BOTTOM = 8;
794
- var styles2 = StyleSheet.create({
1077
+ var styles3 = StyleSheet.create({
795
1078
  root: { flex: 1 },
796
1079
  centerFill: { flex: 1, justifyContent: "center", alignItems: "center" },
797
1080
  scroll: { flex: 1 },
@@ -805,50 +1088,14 @@ var styles2 = StyleSheet.create({
805
1088
  columnCapped: { flex: 1, width: "100%", alignSelf: "center" },
806
1089
  // Non-flex variant used to cap the nav strip's inner content to the content
807
1090
  // column so the nav aligns with the page content instead of running full-bleed.
808
- chromeCapped: { width: "100%", alignSelf: "center" },
809
- card: {
810
- padding: CARD_PADDING,
811
- borderRadius: CARD_BORDER_RADIUS,
812
- borderWidth: CARD_BORDER_WIDTH
813
- },
814
- cardTitle: { fontSize: CARD_TITLE_FONT_SIZE, fontWeight: "700", marginBottom: CARD_TITLE_MARGIN_BOTTOM },
815
- cardMessage: { fontSize: CARD_MESSAGE_FONT_SIZE }
1091
+ chromeCapped: { width: "100%", alignSelf: "center" }
816
1092
  });
817
- function MessageCard({
818
- message,
819
- accentColor,
820
- testID
821
- }) {
822
- const { theme } = useUi();
823
- const colors = theme.colors;
824
- return /* @__PURE__ */ jsxs(
825
- View,
826
- {
827
- style: [styles2.card, { backgroundColor: colors.surface, borderColor: accentColor }],
828
- testID,
829
- children: [
830
- /* @__PURE__ */ jsx(Text, { style: [styles2.cardTitle, { color: accentColor }], children: message.titleText }),
831
- /* @__PURE__ */ jsx(Text, { style: [styles2.cardMessage, { color: colors.textSecondary }], children: message.messageText })
832
- ]
833
- }
834
- );
835
- }
836
- function useContentBody(state, children, testID) {
837
- const { theme } = useUi();
838
- const primary = theme.palette.primary["500"];
839
- const errorColor = theme.semantic.error["500"];
840
- if (state?.forbidden)
841
- return /* @__PURE__ */ jsx(MessageCard, { accentColor: errorColor, message: state.forbidden, testID: `${testID}${APP_SHELL_SUFFIX.forbidden}` });
842
- if (state?.error)
843
- return /* @__PURE__ */ jsx(MessageCard, { accentColor: errorColor, message: state.error, testID: `${testID}${APP_SHELL_SUFFIX.error}` });
844
- if (state?.loading === true)
845
- return /* @__PURE__ */ jsx(View, { style: styles2.centerFill, testID: `${testID}${APP_SHELL_SUFFIX.loading}`, children: /* @__PURE__ */ jsx(ActivityIndicator, { color: primary, size: "large" }) });
846
- return children;
847
- }
848
1093
  var AppShell = ({
849
1094
  header,
850
1095
  nav,
851
1096
  sidebar,
1097
+ collapsedSidebar,
1098
+ collapsedRailRange,
852
1099
  mobileMenu,
853
1100
  banner,
854
1101
  width = "full",
@@ -864,34 +1111,40 @@ var AppShell = ({
864
1111
  const maxWidth = useContentMaxWidth(width);
865
1112
  const body = useContentBody(state, children, testID);
866
1113
  const { width: viewportWidth } = useWindowDimensions();
867
- const isNarrow = viewportWidth < MOBILE_BREAKPOINT;
868
- const useDrawer = sidebar !== void 0 && isNarrow;
1114
+ const railRange = collapsedRailRange ?? { min: RAIL_FULL_BREAKPOINT, max: DEFAULT_COLLAPSED_RAIL_MAX };
1115
+ const railMode = resolveRailMode({
1116
+ viewport: viewportWidth,
1117
+ hasSidebar: sidebar !== void 0,
1118
+ hasCollapsed: collapsedSidebar !== void 0,
1119
+ range: railRange
1120
+ });
1121
+ const useDrawer = railMode === "drawer";
869
1122
  const [drawerOpen, setDrawerOpen] = useState(false);
870
1123
  const openDrawer = useCallback(() => setDrawerOpen(true), []);
871
1124
  const closeDrawer = useCallback(() => setDrawerOpen(false), []);
872
1125
  useEffect(() => {
873
- if (!isNarrow && drawerOpen) setDrawerOpen(false);
874
- }, [isNarrow, drawerOpen]);
1126
+ if (!useDrawer && drawerOpen) setDrawerOpen(false);
1127
+ }, [useDrawer, drawerOpen]);
875
1128
  const drawerLabels = { ...DEFAULT_DRAWER_LABELS, ...mobileMenu };
876
1129
  const isUnauthenticated = gate !== void 0 && !gate.pending && !gate.authenticated;
877
1130
  useEffect(() => {
878
1131
  if (isUnauthenticated && gate !== void 0) gate.onRedirect();
879
1132
  }, [isUnauthenticated, gate]);
880
1133
  if (gate?.pending === true)
881
- return /* @__PURE__ */ jsx(View, { style: [styles2.root, styles2.centerFill, { backgroundColor: theme.colors.background }], testID: `${testID}${APP_SHELL_SUFFIX.pending}`, children: /* @__PURE__ */ jsx(ActivityIndicator, { color: primary, size: "large" }) });
1134
+ return /* @__PURE__ */ jsx(View, { style: [styles3.root, styles3.centerFill, { backgroundColor: theme.colors.background }], testID: `${testID}${APP_SHELL_SUFFIX.pending}`, children: /* @__PURE__ */ jsx(ActivityIndicator, { color: primary, size: "large" }) });
882
1135
  if (isUnauthenticated) return null;
883
- const columnStyle = maxWidth === "full" ? styles2.columnFull : [styles2.columnCapped, { maxWidth }];
884
- const navInnerStyle = chromeAlignment === "content" && maxWidth !== "full" ? [styles2.chromeCapped, { maxWidth, paddingHorizontal: contentPadding }] : void 0;
1136
+ const columnStyle = maxWidth === "full" ? styles3.columnFull : [styles3.columnCapped, { maxWidth }];
1137
+ const navInnerStyle = chromeAlignment === "content" && maxWidth !== "full" ? [styles3.chromeCapped, { maxWidth, paddingHorizontal: contentPadding }] : void 0;
885
1138
  const scroller = /* @__PURE__ */ jsx(
886
1139
  ScrollView,
887
1140
  {
888
- contentContainerStyle: [styles2.scrollContent, { padding: contentPadding }],
889
- style: styles2.scroll,
1141
+ contentContainerStyle: [styles3.scrollContent, { padding: contentPadding }],
1142
+ style: styles3.scroll,
890
1143
  testID: `${testID}${APP_SHELL_SUFFIX.content}`,
891
1144
  children: /* @__PURE__ */ jsx(View, { style: columnStyle, children: body })
892
1145
  }
893
1146
  );
894
- const headerRegion = useDrawer ? /* @__PURE__ */ jsxs(View, { style: styles2.headerRow, testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: [
1147
+ const headerRegion = useDrawer ? /* @__PURE__ */ jsxs(View, { style: styles3.headerRow, testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: [
895
1148
  /* @__PURE__ */ jsx(
896
1149
  MenuToggle,
897
1150
  {
@@ -901,15 +1154,15 @@ var AppShell = ({
901
1154
  onOpen: openDrawer
902
1155
  }
903
1156
  ),
904
- /* @__PURE__ */ jsx(View, { style: styles2.headerFill, children: header })
1157
+ /* @__PURE__ */ jsx(View, { style: styles3.headerFill, children: header })
905
1158
  ] }) : /* @__PURE__ */ jsx(View, { testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: header });
906
- const showRail = sidebar !== void 0 && !isNarrow;
907
- return /* @__PURE__ */ jsxs(View, { style: [styles2.root, { backgroundColor: theme.colors.background }], testID, children: [
1159
+ const railRegion = railMode === "full" ? /* @__PURE__ */ jsx(View, { testID: `${testID}${APP_SHELL_SUFFIX.sidebar}`, children: sidebar }) : railMode === "collapsed" ? /* @__PURE__ */ jsx(View, { testID: `${testID}${APP_SHELL_SUFFIX.collapsedSidebar}`, children: collapsedSidebar }) : null;
1160
+ return /* @__PURE__ */ jsxs(View, { style: [styles3.root, { backgroundColor: theme.colors.background }], testID, children: [
908
1161
  headerRegion,
909
1162
  nav !== void 0 ? /* @__PURE__ */ jsx(View, { testID: `${testID}${APP_SHELL_SUFFIX.nav}`, children: navInnerStyle !== void 0 ? /* @__PURE__ */ jsx(View, { style: navInnerStyle, children: nav }) : nav }) : null,
910
1163
  banner !== void 0 ? /* @__PURE__ */ jsx(View, { testID: `${testID}${APP_SHELL_SUFFIX.banner}`, children: banner }) : null,
911
- showRail ? /* @__PURE__ */ jsxs(View, { style: styles2.bodyRow, children: [
912
- /* @__PURE__ */ jsx(View, { testID: `${testID}${APP_SHELL_SUFFIX.sidebar}`, children: sidebar }),
1164
+ railRegion !== null ? /* @__PURE__ */ jsxs(View, { style: styles3.bodyRow, children: [
1165
+ railRegion,
913
1166
  scroller
914
1167
  ] }) : scroller,
915
1168
  useDrawer && drawerOpen ? /* @__PURE__ */ jsx(
@@ -924,6 +1177,261 @@ var AppShell = ({
924
1177
  ) : null
925
1178
  ] });
926
1179
  };
1180
+ var COLLAPSED_ICON_SIZE = 22;
1181
+ function glyphFor(label) {
1182
+ return label.trim().charAt(0).toUpperCase();
1183
+ }
1184
+ var CollapsedRail = ({
1185
+ items,
1186
+ pathname,
1187
+ onNavigate,
1188
+ regionLabel,
1189
+ navigateHint = (label) => label,
1190
+ header,
1191
+ footer,
1192
+ containerStyle
1193
+ }) => {
1194
+ const { theme } = useUi();
1195
+ const colors = theme.colors;
1196
+ const primaryColor = theme.palette.primary["500"];
1197
+ return /* @__PURE__ */ jsxs(
1198
+ View,
1199
+ {
1200
+ accessibilityRole: "none",
1201
+ "aria-label": regionLabel,
1202
+ role: "navigation",
1203
+ style: [
1204
+ collapsedRailStyles.container,
1205
+ { backgroundColor: colors.surface, borderRightColor: colors.border },
1206
+ containerStyle
1207
+ ],
1208
+ children: [
1209
+ header !== void 0 ? /* @__PURE__ */ jsx(View, { style: collapsedRailStyles.slot, children: header }) : null,
1210
+ items.map((item) => {
1211
+ const active = isRouteActive(pathname, item.route);
1212
+ const iconColor = active ? primaryColor : colors.textSecondary;
1213
+ return /* @__PURE__ */ jsx(
1214
+ FocusableTouchable,
1215
+ {
1216
+ accessibilityHint: navigateHint(item.label),
1217
+ accessibilityLabel: item.label,
1218
+ ringColor: primaryColor,
1219
+ style: [collapsedRailStyles.item, active ? { backgroundColor: colors.border } : void 0],
1220
+ testID: item.testID ?? item.key,
1221
+ onPress: () => onNavigate(item.route),
1222
+ children: typeof item.renderIcon === "function" ? item.renderIcon(iconColor, COLLAPSED_ICON_SIZE) : /* @__PURE__ */ jsx(Text, { style: [collapsedRailStyles.glyph, { color: iconColor }], children: glyphFor(item.label) })
1223
+ },
1224
+ item.key
1225
+ );
1226
+ }),
1227
+ /* @__PURE__ */ jsx(View, { style: collapsedRailStyles.spacer }),
1228
+ footer !== void 0 ? /* @__PURE__ */ jsx(View, { style: collapsedRailStyles.slot, children: footer }) : null
1229
+ ]
1230
+ }
1231
+ );
1232
+ };
1233
+ function wantsTopBar(layout, hasHeader, hasTopBar) {
1234
+ return layout === "top" || layout === "both" || hasHeader || layout === "side" && hasTopBar;
1235
+ }
1236
+ function wantsSideRail(layout, hasSideRail) {
1237
+ return (layout === "side" || layout === "both") && hasSideRail;
1238
+ }
1239
+ var NavShell = ({
1240
+ layout,
1241
+ pathname,
1242
+ onNavigate,
1243
+ regionLabel,
1244
+ navigateHint,
1245
+ topBar,
1246
+ header,
1247
+ sideRail,
1248
+ children,
1249
+ collapsedSidebar: collapsedSidebarProp,
1250
+ collapsedRailRange: collapsedRailRangeProp,
1251
+ ...shellProps
1252
+ }) => {
1253
+ const showTopBar = wantsTopBar(layout, header !== void 0, topBar !== void 0);
1254
+ const showSideRail = wantsSideRail(layout, sideRail !== void 0);
1255
+ const headerNode = header !== void 0 ? header : showTopBar && topBar !== void 0 ? /* @__PURE__ */ jsx(
1256
+ NavBar,
1257
+ {
1258
+ barTheme: topBar.barTheme,
1259
+ brand: topBar.brand,
1260
+ collapseBelow: topBar.collapseBelow,
1261
+ containerStyle: topBar.containerStyle,
1262
+ items: topBar.items,
1263
+ menuHint: topBar.menuHint,
1264
+ menuLabel: topBar.menuLabel,
1265
+ navigateHint,
1266
+ overflowHint: topBar.overflowHint,
1267
+ overflowLabel: topBar.overflowLabel,
1268
+ pathname,
1269
+ regionLabel,
1270
+ renderMenuIcon: topBar.renderMenuIcon,
1271
+ right: topBar.right,
1272
+ onNavigate
1273
+ }
1274
+ ) : null;
1275
+ const sidebarNode = showSideRail && sideRail !== void 0 ? /* @__PURE__ */ jsx(
1276
+ Sidebar,
1277
+ {
1278
+ collapseHint: sideRail.collapseHint,
1279
+ containerStyle: sideRail.containerStyle,
1280
+ expandHint: sideRail.expandHint,
1281
+ footer: sideRail.footer,
1282
+ header: sideRail.header,
1283
+ items: sideRail.items,
1284
+ navigateHint,
1285
+ pathname,
1286
+ regionLabel,
1287
+ renderChevron: sideRail.renderChevron,
1288
+ title: sideRail.title,
1289
+ onNavigate
1290
+ }
1291
+ ) : void 0;
1292
+ const collapsedCfg = showSideRail ? sideRail?.collapsed : void 0;
1293
+ const collapsedNode = collapsedCfg !== void 0 && sideRail !== void 0 ? /* @__PURE__ */ jsx(
1294
+ CollapsedRail,
1295
+ {
1296
+ footer: collapsedCfg.footer,
1297
+ header: collapsedCfg.header,
1298
+ items: collapsedCfg.items ?? sideRail.items,
1299
+ navigateHint,
1300
+ pathname,
1301
+ regionLabel,
1302
+ onNavigate
1303
+ }
1304
+ ) : collapsedSidebarProp;
1305
+ const collapsedRange = collapsedCfg?.range ?? collapsedRailRangeProp;
1306
+ return /* @__PURE__ */ jsx(
1307
+ AppShell,
1308
+ {
1309
+ ...shellProps,
1310
+ collapsedRailRange: collapsedRange,
1311
+ collapsedSidebar: collapsedNode,
1312
+ header: headerNode,
1313
+ sidebar: sidebarNode,
1314
+ children
1315
+ }
1316
+ );
1317
+ };
1318
+ var TEXT_ON_PRIMARY4 = "#ffffff";
1319
+ var DEFAULT_MIN_ITEMS = 1;
1320
+ var PillNav = ({
1321
+ items,
1322
+ pathname,
1323
+ onNavigate,
1324
+ regionLabel,
1325
+ navigateHint = (label) => label,
1326
+ minItems = DEFAULT_MIN_ITEMS,
1327
+ containerStyle
1328
+ }) => {
1329
+ const { theme } = useUi();
1330
+ const colors = theme.colors;
1331
+ const primaryColor = theme.palette.primary["500"];
1332
+ if (items.length < minItems) return null;
1333
+ return /* @__PURE__ */ jsx(
1334
+ View,
1335
+ {
1336
+ accessibilityRole: "none",
1337
+ "aria-label": regionLabel,
1338
+ role: "navigation",
1339
+ style: [pillNavStyles.container, containerStyle],
1340
+ children: items.map((item) => {
1341
+ const active = isRouteActive(pathname, item.route);
1342
+ const textColor = active ? TEXT_ON_PRIMARY4 : colors.textSecondary;
1343
+ const pillStyle = active ? { backgroundColor: primaryColor } : { backgroundColor: colors.surfaceElevated };
1344
+ return /* @__PURE__ */ jsxs(
1345
+ FocusableTouchable,
1346
+ {
1347
+ accessibilityHint: navigateHint(item.label),
1348
+ accessibilityLabel: item.label,
1349
+ accessibilityRole: "link",
1350
+ ringColor: primaryColor,
1351
+ style: [pillNavStyles.pill, pillStyle],
1352
+ testID: item.testID ?? item.key,
1353
+ onPress: () => onNavigate(item.route),
1354
+ children: [
1355
+ typeof item.renderIcon === "function" ? item.renderIcon(textColor, NAV_ICON_SIZE) : null,
1356
+ /* @__PURE__ */ jsx(Text, { style: [pillNavStyles.pillText, { color: textColor }], children: item.label })
1357
+ ]
1358
+ },
1359
+ item.key
1360
+ );
1361
+ })
1362
+ }
1363
+ );
1364
+ };
1365
+ var TEXT_ON_PRIMARY5 = "#ffffff";
1366
+ var DARK_MODE_ICON_SIZE = 16;
1367
+ var DarkModeControl = ({
1368
+ value,
1369
+ options,
1370
+ onChange,
1371
+ regionLabel,
1372
+ variant = "segmented",
1373
+ testID,
1374
+ containerStyle
1375
+ }) => {
1376
+ const { theme } = useUi();
1377
+ const colors = theme.colors;
1378
+ const primaryColor = theme.palette.primary["500"];
1379
+ const currentIndex = options.findIndex((option) => option.value === value);
1380
+ const current = currentIndex >= 0 ? options[currentIndex] : options[0];
1381
+ const advance = useCallback(() => {
1382
+ if (options.length === 0) return;
1383
+ const from = currentIndex >= 0 ? currentIndex : 0;
1384
+ const next = options[(from + 1) % options.length];
1385
+ if (next !== void 0) onChange(next.value);
1386
+ }, [options, currentIndex, onChange]);
1387
+ if (options.length === 0 || current === void 0) return null;
1388
+ if (variant === "cycle") {
1389
+ const iconColor = colors.text;
1390
+ return /* @__PURE__ */ jsx(View, { "aria-label": regionLabel, style: containerStyle, children: /* @__PURE__ */ jsxs(
1391
+ FocusableTouchable,
1392
+ {
1393
+ accessibilityHint: current.hint,
1394
+ accessibilityLabel: current.label,
1395
+ ringColor: primaryColor,
1396
+ style: [darkModeStyles.cycle, { backgroundColor: colors.surfaceElevated }],
1397
+ testID,
1398
+ onPress: advance,
1399
+ children: [
1400
+ typeof current.renderIcon === "function" ? current.renderIcon(iconColor, DARK_MODE_ICON_SIZE) : null,
1401
+ /* @__PURE__ */ jsx(Text, { style: [darkModeStyles.cycleText, { color: colors.text }], children: current.label })
1402
+ ]
1403
+ }
1404
+ ) });
1405
+ }
1406
+ return /* @__PURE__ */ jsx(
1407
+ View,
1408
+ {
1409
+ "aria-label": regionLabel,
1410
+ role: "group",
1411
+ style: [darkModeStyles.segmentRow, { borderColor: colors.border }, containerStyle],
1412
+ children: options.map((option) => {
1413
+ const active = option.value === value;
1414
+ const textColor = active ? TEXT_ON_PRIMARY5 : colors.textSecondary;
1415
+ return /* @__PURE__ */ jsxs(
1416
+ FocusableTouchable,
1417
+ {
1418
+ accessibilityHint: option.hint,
1419
+ accessibilityLabel: option.label,
1420
+ ringColor: primaryColor,
1421
+ style: [darkModeStyles.segment, active ? { backgroundColor: primaryColor } : void 0],
1422
+ testID: testID !== void 0 ? `${testID}-${option.value}` : void 0,
1423
+ onPress: () => onChange(option.value),
1424
+ children: [
1425
+ typeof option.renderIcon === "function" ? option.renderIcon(textColor, DARK_MODE_ICON_SIZE) : null,
1426
+ /* @__PURE__ */ jsx(Text, { style: [darkModeStyles.segmentText, { color: textColor }], children: option.label })
1427
+ ]
1428
+ },
1429
+ option.value
1430
+ );
1431
+ })
1432
+ }
1433
+ );
1434
+ };
927
1435
  function roleRoutesToNavItems(routes, translate) {
928
1436
  return routes.map((entry) => ({
929
1437
  key: entry.role,
@@ -935,6 +1443,6 @@ function accessibleNavItems(user, table, translate) {
935
1443
  return roleRoutesToNavItems(resolveAccessibleRoutes(user, table), translate);
936
1444
  }
937
1445
 
938
- export { ACTIVE_BORDER_RADIUS, APP_SHELL_SUFFIX, AppShell, BASE_INDENT, CHEVRON_ICON_SIZE, NAV_ICON_SIZE, NAV_TEST_IDS, NavBar, NavExpandableItem, Sidebar, Topbar, accessibleNavItems, expandableStyles, isRouteActive, navStyles, resolveContentMaxWidth, roleRoutesToNavItems, useContentMaxWidth };
1446
+ export { ACTIVE_BORDER_RADIUS, APP_SHELL_SUFFIX, AppShell, BASE_INDENT, CHEVRON_ICON_SIZE, CollapsedRail, DEFAULT_COLLAPSED_RAIL_MAX, DarkModeControl, NAV_ICON_SIZE, NAV_LINK_GAP, NAV_TEST_IDS, Nav, NavBar, NavExpandableItem, NavOverflowMenu, NavShell, PillNav, RAIL_FULL_BREAKPOINT, Sidebar, Topbar, accessibleNavItems, collapsedRailStyles, darkModeStyles, expandableStyles, isRouteActive, navStyles, pillNavStyles, resolveContentMaxWidth, resolveRailMode, roleRoutesToNavItems, useContentMaxWidth };
939
1447
  //# sourceMappingURL=index.mjs.map
940
1448
  //# sourceMappingURL=index.mjs.map