@dloizides/ui-nav 1.6.0 → 1.7.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.js CHANGED
@@ -2,10 +2,15 @@
2
2
 
3
3
  var reactNative = require('react-native');
4
4
  var uiFeedback = require('@dloizides/ui-feedback');
5
- var react = require('react');
5
+ var React5 = require('react');
6
6
  var jsxRuntime = require('react/jsx-runtime');
7
+ var uiLayout = require('@dloizides/ui-layout');
7
8
  var authWeb = require('@dloizides/auth-web');
8
9
 
10
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
11
+
12
+ var React5__default = /*#__PURE__*/_interopDefault(React5);
13
+
9
14
  // src/Sidebar.tsx
10
15
 
11
16
  // src/isRouteActive.ts
@@ -14,6 +19,7 @@ function isRouteActive(pathname, route) {
14
19
  return pathname === route || pathname.startsWith(`${route}/`);
15
20
  }
16
21
  var ACTIVE_BORDER_RADIUS = 4;
22
+ var NAV_LINK_GAP = 4;
17
23
  var navStyles = reactNative.StyleSheet.create({
18
24
  // --- Sidebar ---
19
25
  sidebarContainer: {
@@ -76,6 +82,13 @@ var navBarStyles = reactNative.StyleSheet.create({
76
82
  rowGap: 4,
77
83
  paddingVertical: 8
78
84
  },
85
+ // EXPANDED: the row must NOT wrap — otherwise flexbox drops the right slot onto a 2nd line the
86
+ // moment brand+links+right overflow, instead of SHRINKING the (scrollable) links. `flex-shrink`
87
+ // only distributes negative space on a `nowrap` line; with `wrap` it wraps first and never shrinks.
88
+ // Collapsed keeps `wrap` so the stacked links/right (width:100%) can drop to their own lines.
89
+ innerNoWrap: {
90
+ flexWrap: "nowrap"
91
+ },
79
92
  brand: { marginRight: 16, flexShrink: 0 },
80
93
  toggle: {
81
94
  marginLeft: "auto",
@@ -89,22 +102,26 @@ var navBarStyles = reactNative.StyleSheet.create({
89
102
  justifyContent: "center"
90
103
  },
91
104
  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: {
105
+ // Expanded links live on ONE ROW that never wraps: it is a shrinkable/growable flex child that
106
+ // fills the space between the pinned brand (left) and right slot, and CLIPS overflow. A measure
107
+ // pass (priority+) then keeps only the leading items that fit inline and collapses the rest behind
108
+ // the "More ▾" overflow trigger so no item is ever cut off; the surplus moves into a dropdown.
109
+ // `overflow:'hidden'` only masks the single unmeasured first frame before the fit resolves.
110
+ linksRow: {
97
111
  flexGrow: 1,
98
112
  flexShrink: 1,
99
- minWidth: 0
100
- },
101
- links: {
113
+ minWidth: 0,
102
114
  flexDirection: "row",
103
115
  alignItems: "center",
104
116
  flexWrap: "nowrap",
105
- columnGap: 4,
106
- rowGap: 4
117
+ columnGap: NAV_LINK_GAP,
118
+ overflow: "hidden"
107
119
  },
120
+ // Each inline link + the overflow trigger sit in a natural-width cell that never shrinks, so their
121
+ // measured widths are their true content widths (what the priority+ fit computation reasons about).
122
+ linkCell: { flexShrink: 0 },
123
+ overflowTrigger: { columnGap: 4 },
124
+ overflowChevron: { fontSize: 12, fontWeight: "700" },
108
125
  // Collapsed (below breakpoint): links drop onto their own full-width line,
109
126
  // stacked vertically — mirrors the v1 `.gn-links` responsive behaviour.
110
127
  linksStacked: {
@@ -202,21 +219,21 @@ var NavExpandableItem = ({
202
219
  renderChevron,
203
220
  depth = 0
204
221
  }) => {
205
- const [expanded, setExpanded] = react.useState(false);
206
- const [focused, setFocused] = react.useState(false);
222
+ const [expanded, setExpanded] = React5.useState(false);
223
+ const [focused, setFocused] = React5.useState(false);
207
224
  const { theme } = uiFeedback.useUi();
208
225
  const colors = theme.colors;
209
226
  const primaryColor = theme.palette.primary["500"];
210
- const toggle = react.useCallback(() => setExpanded((v) => !v), []);
227
+ const toggle = React5.useCallback(() => setExpanded((v) => !v), []);
211
228
  const indent = depth * BASE_INDENT;
212
229
  const hasChildren = Array.isArray(item.children) && item.children.length > 0;
213
230
  const isActive = isRouteActive(pathname, item.route);
214
- const activeItemStyle = react.useMemo(
231
+ const activeItemStyle = React5.useMemo(
215
232
  () => ({ backgroundColor: colors.border, borderRadius: ACTIVE_BORDER_RADIUS }),
216
233
  [colors.border]
217
234
  );
218
- const paddingStyle = react.useMemo(() => ({ paddingLeft: indent + BASE_INDENT }), [indent]);
219
- const headerPaddingStyle = react.useMemo(() => ({ paddingLeft: indent }), [indent]);
235
+ const paddingStyle = React5.useMemo(() => ({ paddingLeft: indent + BASE_INDENT }), [indent]);
236
+ const headerPaddingStyle = React5.useMemo(() => ({ paddingLeft: indent }), [indent]);
220
237
  if (!hasChildren)
221
238
  return /* @__PURE__ */ jsxRuntime.jsxs(
222
239
  reactNative.TouchableOpacity,
@@ -350,7 +367,9 @@ var NAV_TEST_IDS = {
350
367
  /** responsive hamburger toggle in the horizontal NavBar (shown below the breakpoint). */
351
368
  navBarToggle: "navbar-toggle",
352
369
  /** links container in the horizontal NavBar. */
353
- navBarLinks: "navbar-links"
370
+ navBarLinks: "navbar-links",
371
+ /** the "More ▾" priority+ overflow trigger in the horizontal NavBar (shown when items spill). */
372
+ navBarOverflow: "navbar-overflow"
354
373
  };
355
374
  var APP_SHELL_SUFFIX = {
356
375
  content: "-content",
@@ -380,7 +399,7 @@ var FocusableTouchable = ({
380
399
  testID,
381
400
  children
382
401
  }) => {
383
- const [focused, setFocused] = react.useState(false);
402
+ const [focused, setFocused] = React5.useState(false);
384
403
  return /* @__PURE__ */ jsxRuntime.jsx(
385
404
  reactNative.Pressable,
386
405
  {
@@ -532,8 +551,8 @@ var NavBarLink = ({
532
551
  navigateHint,
533
552
  onPress
534
553
  }) => {
535
- const [hovered, setHovered] = react.useState(false);
536
- const [focused, setFocused] = react.useState(false);
554
+ const [hovered, setHovered] = React5.useState(false);
555
+ const [focused, setFocused] = React5.useState(false);
537
556
  const isHovered = hovered && !isActive;
538
557
  const textColor = isActive ? TEXT_ON_PRIMARY2 : isHovered ? colors.hoverText : colors.rest;
539
558
  const pillStyle = isActive ? { backgroundColor: colors.activeBg } : isHovered ? { backgroundColor: colors.hoverBg } : void 0;
@@ -565,14 +584,116 @@ var NavBarLink = ({
565
584
  }
566
585
  );
567
586
  };
587
+ var TEXT_ON_PRIMARY3 = "#ffffff";
588
+ var NO_ACTIVE_ROUTE = "";
589
+ var NavOverflowMenu = ({
590
+ items,
591
+ pathname,
592
+ onNavigate,
593
+ colors,
594
+ label,
595
+ hint,
596
+ testID,
597
+ variant
598
+ }) => {
599
+ const options = React5.useMemo(() => items.map((item) => ({ label: item.label, value: item.route })), [items]);
600
+ const activeRoute = React5.useMemo(
601
+ () => items.find((item) => isRouteActive(pathname, item.route))?.route ?? NO_ACTIVE_ROUTE,
602
+ [items, pathname]
603
+ );
604
+ const optionTestID = React5.useMemo(() => {
605
+ const byRoute = new Map(items.map((item) => [item.route, item.testID ?? item.key]));
606
+ return (route) => byRoute.get(route) ?? `${testID}-option-${route}`;
607
+ }, [items, testID]);
608
+ const hasActive = activeRoute !== NO_ACTIVE_ROUTE;
609
+ const textColor = hasActive ? TEXT_ON_PRIMARY3 : colors.rest;
610
+ const pillStyle = hasActive ? { backgroundColor: colors.activeBg } : void 0;
611
+ return /* @__PURE__ */ jsxRuntime.jsx(
612
+ uiLayout.ModalDropdown,
613
+ {
614
+ accessibilityHint: hint,
615
+ accessibilityLabel: label,
616
+ optionTestID,
617
+ options,
618
+ testID,
619
+ value: activeRoute,
620
+ variant,
621
+ onChange: onNavigate,
622
+ renderTrigger: ({ isOpen }) => /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [navBarStyles.link, navBarStyles.overflowTrigger, pillStyle], children: [
623
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navBarStyles.linkText, { color: textColor }], children: label }),
624
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navBarStyles.overflowChevron, { color: textColor }], children: isOpen ? "\u25B4" : "\u25BE" })
625
+ ] })
626
+ }
627
+ );
628
+ };
629
+
630
+ // src/computeVisibleCount.ts
631
+ function sum(values) {
632
+ return values.reduce((acc, value) => acc + value, 0);
633
+ }
634
+ function computeVisibleCount({ availableWidth, itemWidths, moreWidth, gap }) {
635
+ const total = itemWidths.length;
636
+ if (total === 0) return 0;
637
+ if (availableWidth <= 0 || itemWidths.some((width) => width <= 0)) return total;
638
+ const fullRowWidth = sum(itemWidths) + gap * (total - 1);
639
+ if (fullRowWidth <= availableWidth) return total;
640
+ const reservedForMore = moreWidth + gap;
641
+ let used = 0;
642
+ let count = 0;
643
+ for (let index = 0; index < total; index += 1) {
644
+ const width = itemWidths[index] ?? 0;
645
+ const withGap = used + (count > 0 ? gap : 0) + width;
646
+ if (withGap + reservedForMore > availableWidth) break;
647
+ used = withGap;
648
+ count += 1;
649
+ }
650
+ return count;
651
+ }
652
+
653
+ // src/useNavOverflow.ts
654
+ function resizeWidthBuffer(previous, count) {
655
+ const next = new Array(count).fill(0);
656
+ const shared = Math.min(previous.length, count);
657
+ for (let index = 0; index < shared; index += 1) next[index] = previous[index] ?? 0;
658
+ return next;
659
+ }
660
+ function useNavOverflow(itemCount, gap) {
661
+ const [availableWidth, setAvailableWidthState] = React5.useState(0);
662
+ const [moreWidth, setMoreWidthState] = React5.useState(0);
663
+ const [itemWidths, setItemWidths] = React5.useState(() => new Array(itemCount).fill(0));
664
+ React5.useEffect(() => {
665
+ setItemWidths((previous) => previous.length === itemCount ? previous : resizeWidthBuffer(previous, itemCount));
666
+ }, [itemCount]);
667
+ const setItemWidth = React5.useCallback((index, width) => {
668
+ setItemWidths((previous) => {
669
+ if (index < 0 || index >= previous.length || previous[index] === width) return previous;
670
+ const next = previous.slice();
671
+ next[index] = width;
672
+ return next;
673
+ });
674
+ }, []);
675
+ const setAvailableWidth = React5.useCallback(
676
+ (width) => setAvailableWidthState((previous) => previous === width ? previous : width),
677
+ []
678
+ );
679
+ const setMoreWidth = React5.useCallback(
680
+ (width) => setMoreWidthState((previous) => previous === width ? previous : width),
681
+ []
682
+ );
683
+ const visibleCount = React5.useMemo(
684
+ () => computeVisibleCount({ availableWidth, itemWidths, moreWidth, gap }),
685
+ [availableWidth, itemWidths, moreWidth, gap]
686
+ );
687
+ return { visibleCount, setAvailableWidth, setItemWidth, setMoreWidth };
688
+ }
568
689
  var REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
569
690
  function getReducedMotionQuery() {
570
691
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return void 0;
571
692
  return window.matchMedia(REDUCED_MOTION_QUERY);
572
693
  }
573
694
  function useReducedMotion() {
574
- const [reduced, setReduced] = react.useState(() => getReducedMotionQuery()?.matches ?? false);
575
- react.useEffect(() => {
695
+ const [reduced, setReduced] = React5.useState(() => getReducedMotionQuery()?.matches ?? false);
696
+ React5.useEffect(() => {
576
697
  const mql = getReducedMotionQuery();
577
698
  if (mql === void 0 || mql.addEventListener === void 0) return void 0;
578
699
  const onChange = () => setReduced(mql.matches);
@@ -601,6 +722,8 @@ var NavBar = ({
601
722
  menuHint = "",
602
723
  renderMenuIcon,
603
724
  collapseBelow = DEFAULT_COLLAPSE_BELOW,
725
+ overflowLabel = "More",
726
+ overflowHint = "",
604
727
  containerStyle
605
728
  }) => {
606
729
  const { theme } = uiFeedback.useUi();
@@ -608,19 +731,20 @@ var NavBar = ({
608
731
  const primaryColor = theme.palette.primary["500"];
609
732
  const { width } = reactNative.useWindowDimensions();
610
733
  const reducedMotion = useReducedMotion();
611
- const [open, setOpen] = react.useState(false);
612
- const [toggleFocused, setToggleFocused] = react.useState(false);
734
+ const [open, setOpen] = React5.useState(false);
735
+ const [toggleFocused, setToggleFocused] = React5.useState(false);
736
+ const { visibleCount, setAvailableWidth, setItemWidth, setMoreWidth } = useNavOverflow(items.length, NAV_LINK_GAP);
613
737
  const collapsed = width < collapseBelow;
614
738
  const showLinks = !collapsed || open;
615
- const toggleMenu = react.useCallback(() => setOpen((v) => !v), []);
616
- const handlePress = react.useCallback(
739
+ const toggleMenu = React5.useCallback(() => setOpen((v) => !v), []);
740
+ const handlePress = React5.useCallback(
617
741
  (route) => {
618
742
  setOpen(false);
619
743
  onNavigate(route);
620
744
  },
621
745
  [onNavigate]
622
746
  );
623
- const linkColors = react.useMemo(
747
+ const linkColors = React5.useMemo(
624
748
  () => ({
625
749
  rest: colors.textSecondary,
626
750
  hoverText: colors.text,
@@ -641,7 +765,7 @@ var NavBar = ({
641
765
  { backgroundColor: colors.surface, borderBottomColor: colors.border },
642
766
  containerStyle
643
767
  ],
644
- children: /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: navBarStyles.inner, children: [
768
+ children: /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [navBarStyles.inner, collapsed ? null : navBarStyles.innerNoWrap], children: [
645
769
  brand !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navBarStyles.brand, children: brand }) : null,
646
770
  collapsed ? /* @__PURE__ */ jsxRuntime.jsx(
647
771
  reactNative.Pressable,
@@ -661,7 +785,7 @@ var NavBar = ({
661
785
  }
662
786
  ) : null,
663
787
  showLinks ? (() => {
664
- const links = items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
788
+ const renderLink = (item) => /* @__PURE__ */ jsxRuntime.jsx(
665
789
  NavBarLink,
666
790
  {
667
791
  collapsed,
@@ -672,19 +796,50 @@ var NavBar = ({
672
796
  navigateHint,
673
797
  reducedMotion,
674
798
  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,
799
+ }
800
+ );
801
+ if (collapsed) {
802
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { nativeID: LINKS_REGION_ID, style: navBarStyles.linksStacked, testID: NAV_TEST_IDS.navBarLinks, children: items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(React5__default.default.Fragment, { children: renderLink(item) }, item.key)) });
803
+ }
804
+ const visibleItems = items.slice(0, visibleCount);
805
+ const overflowItems = items.slice(visibleCount);
806
+ return /* @__PURE__ */ jsxRuntime.jsxs(
807
+ reactNative.View,
680
808
  {
681
- horizontal: true,
682
- showsHorizontalScrollIndicator: false,
683
809
  nativeID: LINKS_REGION_ID,
684
- style: navBarStyles.linksScroll,
685
- contentContainerStyle: navBarStyles.links,
810
+ style: navBarStyles.linksRow,
686
811
  testID: NAV_TEST_IDS.navBarLinks,
687
- children: links
812
+ onLayout: (event) => setAvailableWidth(event.nativeEvent.layout.width),
813
+ children: [
814
+ visibleItems.map((item, index) => /* @__PURE__ */ jsxRuntime.jsx(
815
+ reactNative.View,
816
+ {
817
+ style: navBarStyles.linkCell,
818
+ onLayout: (event) => setItemWidth(index, event.nativeEvent.layout.width),
819
+ children: renderLink(item)
820
+ },
821
+ item.key
822
+ )),
823
+ overflowItems.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(
824
+ reactNative.View,
825
+ {
826
+ style: navBarStyles.linkCell,
827
+ onLayout: (event) => setMoreWidth(event.nativeEvent.layout.width),
828
+ children: /* @__PURE__ */ jsxRuntime.jsx(
829
+ NavOverflowMenu,
830
+ {
831
+ colors: linkColors,
832
+ hint: overflowHint,
833
+ items: overflowItems,
834
+ label: overflowLabel,
835
+ pathname,
836
+ testID: NAV_TEST_IDS.navBarOverflow,
837
+ onNavigate: handlePress
838
+ }
839
+ )
840
+ }
841
+ ) : null
842
+ ]
688
843
  }
689
844
  );
690
845
  })() : null,
@@ -693,6 +848,12 @@ var NavBar = ({
693
848
  }
694
849
  );
695
850
  };
851
+ var Nav = (props) => {
852
+ if (props.orientation === "vertical") {
853
+ return /* @__PURE__ */ jsxRuntime.jsx(Sidebar, { ...props });
854
+ }
855
+ return /* @__PURE__ */ jsxRuntime.jsx(NavBar, { ...props });
856
+ };
696
857
  var MOBILE_BREAKPOINT = 768;
697
858
  var SCRIM_COLOR = "rgba(0, 0, 0, 0.5)";
698
859
  var MENU_GLYPH2 = "\u2630";
@@ -742,8 +903,8 @@ var MobileDrawer = ({
742
903
  drawerTestID,
743
904
  scrimTestID
744
905
  }) => {
745
- const panelRef = react.useRef(null);
746
- react.useEffect(() => {
906
+ const panelRef = React5.useRef(null);
907
+ React5.useEffect(() => {
747
908
  const node = panelRef.current;
748
909
  if (node === null || typeof node.addEventListener !== "function") return void 0;
749
910
  const handleClick = (event) => {
@@ -861,15 +1022,15 @@ var AppShell = ({
861
1022
  const { width: viewportWidth } = reactNative.useWindowDimensions();
862
1023
  const isNarrow = viewportWidth < MOBILE_BREAKPOINT;
863
1024
  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(() => {
1025
+ const [drawerOpen, setDrawerOpen] = React5.useState(false);
1026
+ const openDrawer = React5.useCallback(() => setDrawerOpen(true), []);
1027
+ const closeDrawer = React5.useCallback(() => setDrawerOpen(false), []);
1028
+ React5.useEffect(() => {
868
1029
  if (!isNarrow && drawerOpen) setDrawerOpen(false);
869
1030
  }, [isNarrow, drawerOpen]);
870
1031
  const drawerLabels = { ...DEFAULT_DRAWER_LABELS, ...mobileMenu };
871
1032
  const isUnauthenticated = gate !== void 0 && !gate.pending && !gate.authenticated;
872
- react.useEffect(() => {
1033
+ React5.useEffect(() => {
873
1034
  if (isUnauthenticated && gate !== void 0) gate.onRedirect();
874
1035
  }, [isUnauthenticated, gate]);
875
1036
  if (gate?.pending === true)
@@ -936,9 +1097,12 @@ exports.AppShell = AppShell;
936
1097
  exports.BASE_INDENT = BASE_INDENT;
937
1098
  exports.CHEVRON_ICON_SIZE = CHEVRON_ICON_SIZE;
938
1099
  exports.NAV_ICON_SIZE = NAV_ICON_SIZE;
1100
+ exports.NAV_LINK_GAP = NAV_LINK_GAP;
939
1101
  exports.NAV_TEST_IDS = NAV_TEST_IDS;
1102
+ exports.Nav = Nav;
940
1103
  exports.NavBar = NavBar;
941
1104
  exports.NavExpandableItem = NavExpandableItem;
1105
+ exports.NavOverflowMenu = NavOverflowMenu;
942
1106
  exports.Sidebar = Sidebar;
943
1107
  exports.Topbar = Topbar;
944
1108
  exports.accessibleNavItems = accessibleNavItems;