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