@dloizides/ui-nav 1.6.1 → 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: {
@@ -96,22 +102,26 @@ var navBarStyles = reactNative.StyleSheet.create({
96
102
  justifyContent: "center"
97
103
  },
98
104
  toggleGlyph: { fontSize: 20, fontWeight: "700" },
99
- // Expanded links live inside a horizontal ScrollView so the bar stays ONE ROW no matter how many
100
- // items an app passes: they never wrap onto a second line any overflow scrolls instead, with the
101
- // brand (left) and right slot pinned. `linksScroll` is the ScrollView's own box (a shrinkable,
102
- // growable flex child that may collapse to 0 and scroll); `links` is its non-wrapping content row.
103
- 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: {
104
111
  flexGrow: 1,
105
112
  flexShrink: 1,
106
- minWidth: 0
107
- },
108
- links: {
113
+ minWidth: 0,
109
114
  flexDirection: "row",
110
115
  alignItems: "center",
111
116
  flexWrap: "nowrap",
112
- columnGap: 4,
113
- rowGap: 4
117
+ columnGap: NAV_LINK_GAP,
118
+ overflow: "hidden"
114
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" },
115
125
  // Collapsed (below breakpoint): links drop onto their own full-width line,
116
126
  // stacked vertically — mirrors the v1 `.gn-links` responsive behaviour.
117
127
  linksStacked: {
@@ -209,21 +219,21 @@ var NavExpandableItem = ({
209
219
  renderChevron,
210
220
  depth = 0
211
221
  }) => {
212
- const [expanded, setExpanded] = react.useState(false);
213
- const [focused, setFocused] = react.useState(false);
222
+ const [expanded, setExpanded] = React5.useState(false);
223
+ const [focused, setFocused] = React5.useState(false);
214
224
  const { theme } = uiFeedback.useUi();
215
225
  const colors = theme.colors;
216
226
  const primaryColor = theme.palette.primary["500"];
217
- const toggle = react.useCallback(() => setExpanded((v) => !v), []);
227
+ const toggle = React5.useCallback(() => setExpanded((v) => !v), []);
218
228
  const indent = depth * BASE_INDENT;
219
229
  const hasChildren = Array.isArray(item.children) && item.children.length > 0;
220
230
  const isActive = isRouteActive(pathname, item.route);
221
- const activeItemStyle = react.useMemo(
231
+ const activeItemStyle = React5.useMemo(
222
232
  () => ({ backgroundColor: colors.border, borderRadius: ACTIVE_BORDER_RADIUS }),
223
233
  [colors.border]
224
234
  );
225
- const paddingStyle = react.useMemo(() => ({ paddingLeft: indent + BASE_INDENT }), [indent]);
226
- 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]);
227
237
  if (!hasChildren)
228
238
  return /* @__PURE__ */ jsxRuntime.jsxs(
229
239
  reactNative.TouchableOpacity,
@@ -357,7 +367,9 @@ var NAV_TEST_IDS = {
357
367
  /** responsive hamburger toggle in the horizontal NavBar (shown below the breakpoint). */
358
368
  navBarToggle: "navbar-toggle",
359
369
  /** links container in the horizontal NavBar. */
360
- 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"
361
373
  };
362
374
  var APP_SHELL_SUFFIX = {
363
375
  content: "-content",
@@ -387,7 +399,7 @@ var FocusableTouchable = ({
387
399
  testID,
388
400
  children
389
401
  }) => {
390
- const [focused, setFocused] = react.useState(false);
402
+ const [focused, setFocused] = React5.useState(false);
391
403
  return /* @__PURE__ */ jsxRuntime.jsx(
392
404
  reactNative.Pressable,
393
405
  {
@@ -539,8 +551,8 @@ var NavBarLink = ({
539
551
  navigateHint,
540
552
  onPress
541
553
  }) => {
542
- const [hovered, setHovered] = react.useState(false);
543
- const [focused, setFocused] = react.useState(false);
554
+ const [hovered, setHovered] = React5.useState(false);
555
+ const [focused, setFocused] = React5.useState(false);
544
556
  const isHovered = hovered && !isActive;
545
557
  const textColor = isActive ? TEXT_ON_PRIMARY2 : isHovered ? colors.hoverText : colors.rest;
546
558
  const pillStyle = isActive ? { backgroundColor: colors.activeBg } : isHovered ? { backgroundColor: colors.hoverBg } : void 0;
@@ -572,14 +584,116 @@ var NavBarLink = ({
572
584
  }
573
585
  );
574
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
+ }
575
689
  var REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
576
690
  function getReducedMotionQuery() {
577
691
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return void 0;
578
692
  return window.matchMedia(REDUCED_MOTION_QUERY);
579
693
  }
580
694
  function useReducedMotion() {
581
- const [reduced, setReduced] = react.useState(() => getReducedMotionQuery()?.matches ?? false);
582
- react.useEffect(() => {
695
+ const [reduced, setReduced] = React5.useState(() => getReducedMotionQuery()?.matches ?? false);
696
+ React5.useEffect(() => {
583
697
  const mql = getReducedMotionQuery();
584
698
  if (mql === void 0 || mql.addEventListener === void 0) return void 0;
585
699
  const onChange = () => setReduced(mql.matches);
@@ -608,6 +722,8 @@ var NavBar = ({
608
722
  menuHint = "",
609
723
  renderMenuIcon,
610
724
  collapseBelow = DEFAULT_COLLAPSE_BELOW,
725
+ overflowLabel = "More",
726
+ overflowHint = "",
611
727
  containerStyle
612
728
  }) => {
613
729
  const { theme } = uiFeedback.useUi();
@@ -615,19 +731,20 @@ var NavBar = ({
615
731
  const primaryColor = theme.palette.primary["500"];
616
732
  const { width } = reactNative.useWindowDimensions();
617
733
  const reducedMotion = useReducedMotion();
618
- const [open, setOpen] = react.useState(false);
619
- 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);
620
737
  const collapsed = width < collapseBelow;
621
738
  const showLinks = !collapsed || open;
622
- const toggleMenu = react.useCallback(() => setOpen((v) => !v), []);
623
- const handlePress = react.useCallback(
739
+ const toggleMenu = React5.useCallback(() => setOpen((v) => !v), []);
740
+ const handlePress = React5.useCallback(
624
741
  (route) => {
625
742
  setOpen(false);
626
743
  onNavigate(route);
627
744
  },
628
745
  [onNavigate]
629
746
  );
630
- const linkColors = react.useMemo(
747
+ const linkColors = React5.useMemo(
631
748
  () => ({
632
749
  rest: colors.textSecondary,
633
750
  hoverText: colors.text,
@@ -668,7 +785,7 @@ var NavBar = ({
668
785
  }
669
786
  ) : null,
670
787
  showLinks ? (() => {
671
- const links = items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
788
+ const renderLink = (item) => /* @__PURE__ */ jsxRuntime.jsx(
672
789
  NavBarLink,
673
790
  {
674
791
  collapsed,
@@ -679,19 +796,50 @@ var NavBar = ({
679
796
  navigateHint,
680
797
  reducedMotion,
681
798
  onPress: handlePress
682
- },
683
- item.key
684
- ));
685
- return collapsed ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { nativeID: LINKS_REGION_ID, style: navBarStyles.linksStacked, testID: NAV_TEST_IDS.navBarLinks, children: links }) : /* @__PURE__ */ jsxRuntime.jsx(
686
- 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,
687
808
  {
688
- horizontal: true,
689
- showsHorizontalScrollIndicator: false,
690
809
  nativeID: LINKS_REGION_ID,
691
- style: navBarStyles.linksScroll,
692
- contentContainerStyle: navBarStyles.links,
810
+ style: navBarStyles.linksRow,
693
811
  testID: NAV_TEST_IDS.navBarLinks,
694
- 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
+ ]
695
843
  }
696
844
  );
697
845
  })() : null,
@@ -700,6 +848,12 @@ var NavBar = ({
700
848
  }
701
849
  );
702
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
+ };
703
857
  var MOBILE_BREAKPOINT = 768;
704
858
  var SCRIM_COLOR = "rgba(0, 0, 0, 0.5)";
705
859
  var MENU_GLYPH2 = "\u2630";
@@ -749,8 +903,8 @@ var MobileDrawer = ({
749
903
  drawerTestID,
750
904
  scrimTestID
751
905
  }) => {
752
- const panelRef = react.useRef(null);
753
- react.useEffect(() => {
906
+ const panelRef = React5.useRef(null);
907
+ React5.useEffect(() => {
754
908
  const node = panelRef.current;
755
909
  if (node === null || typeof node.addEventListener !== "function") return void 0;
756
910
  const handleClick = (event) => {
@@ -868,15 +1022,15 @@ var AppShell = ({
868
1022
  const { width: viewportWidth } = reactNative.useWindowDimensions();
869
1023
  const isNarrow = viewportWidth < MOBILE_BREAKPOINT;
870
1024
  const useDrawer = sidebar !== void 0 && isNarrow;
871
- const [drawerOpen, setDrawerOpen] = react.useState(false);
872
- const openDrawer = react.useCallback(() => setDrawerOpen(true), []);
873
- const closeDrawer = react.useCallback(() => setDrawerOpen(false), []);
874
- 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(() => {
875
1029
  if (!isNarrow && drawerOpen) setDrawerOpen(false);
876
1030
  }, [isNarrow, drawerOpen]);
877
1031
  const drawerLabels = { ...DEFAULT_DRAWER_LABELS, ...mobileMenu };
878
1032
  const isUnauthenticated = gate !== void 0 && !gate.pending && !gate.authenticated;
879
- react.useEffect(() => {
1033
+ React5.useEffect(() => {
880
1034
  if (isUnauthenticated && gate !== void 0) gate.onRedirect();
881
1035
  }, [isUnauthenticated, gate]);
882
1036
  if (gate?.pending === true)
@@ -943,9 +1097,12 @@ exports.AppShell = AppShell;
943
1097
  exports.BASE_INDENT = BASE_INDENT;
944
1098
  exports.CHEVRON_ICON_SIZE = CHEVRON_ICON_SIZE;
945
1099
  exports.NAV_ICON_SIZE = NAV_ICON_SIZE;
1100
+ exports.NAV_LINK_GAP = NAV_LINK_GAP;
946
1101
  exports.NAV_TEST_IDS = NAV_TEST_IDS;
1102
+ exports.Nav = Nav;
947
1103
  exports.NavBar = NavBar;
948
1104
  exports.NavExpandableItem = NavExpandableItem;
1105
+ exports.NavOverflowMenu = NavOverflowMenu;
949
1106
  exports.Sidebar = Sidebar;
950
1107
  exports.Topbar = Topbar;
951
1108
  exports.accessibleNavItems = accessibleNavItems;