@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.mjs CHANGED
@@ -1,7 +1,8 @@
1
- import { StyleSheet, Platform, TouchableOpacity, View, Text, useWindowDimensions, Pressable, ScrollView, ActivityIndicator } from 'react-native';
1
+ import { StyleSheet, Platform, TouchableOpacity, View, Text, useWindowDimensions, Pressable, ActivityIndicator, ScrollView } from 'react-native';
2
2
  import { useUi } from '@dloizides/ui-feedback';
3
- import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
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: {
@@ -74,6 +76,13 @@ var navBarStyles = StyleSheet.create({
74
76
  rowGap: 4,
75
77
  paddingVertical: 8
76
78
  },
79
+ // EXPANDED: the row must NOT wrap — otherwise flexbox drops the right slot onto a 2nd line the
80
+ // moment brand+links+right overflow, instead of SHRINKING the (scrollable) links. `flex-shrink`
81
+ // only distributes negative space on a `nowrap` line; with `wrap` it wraps first and never shrinks.
82
+ // Collapsed keeps `wrap` so the stacked links/right (width:100%) can drop to their own lines.
83
+ innerNoWrap: {
84
+ flexWrap: "nowrap"
85
+ },
77
86
  brand: { marginRight: 16, flexShrink: 0 },
78
87
  toggle: {
79
88
  marginLeft: "auto",
@@ -87,22 +96,26 @@ var navBarStyles = StyleSheet.create({
87
96
  justifyContent: "center"
88
97
  },
89
98
  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: {
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: {
95
105
  flexGrow: 1,
96
106
  flexShrink: 1,
97
- minWidth: 0
98
- },
99
- links: {
107
+ minWidth: 0,
100
108
  flexDirection: "row",
101
109
  alignItems: "center",
102
110
  flexWrap: "nowrap",
103
- columnGap: 4,
104
- rowGap: 4
111
+ columnGap: NAV_LINK_GAP,
112
+ overflow: "hidden"
105
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" },
106
119
  // Collapsed (below breakpoint): links drop onto their own full-width line,
107
120
  // stacked vertically — mirrors the v1 `.gn-links` responsive behaviour.
108
121
  linksStacked: {
@@ -348,7 +361,9 @@ var NAV_TEST_IDS = {
348
361
  /** responsive hamburger toggle in the horizontal NavBar (shown below the breakpoint). */
349
362
  navBarToggle: "navbar-toggle",
350
363
  /** links container in the horizontal NavBar. */
351
- navBarLinks: "navbar-links"
364
+ navBarLinks: "navbar-links",
365
+ /** the "More ▾" priority+ overflow trigger in the horizontal NavBar (shown when items spill). */
366
+ navBarOverflow: "navbar-overflow"
352
367
  };
353
368
  var APP_SHELL_SUFFIX = {
354
369
  content: "-content",
@@ -563,6 +578,108 @@ var NavBarLink = ({
563
578
  }
564
579
  );
565
580
  };
581
+ var TEXT_ON_PRIMARY3 = "#ffffff";
582
+ var NO_ACTIVE_ROUTE = "";
583
+ var NavOverflowMenu = ({
584
+ items,
585
+ pathname,
586
+ onNavigate,
587
+ colors,
588
+ label,
589
+ hint,
590
+ testID,
591
+ variant
592
+ }) => {
593
+ const options = useMemo(() => items.map((item) => ({ label: item.label, value: item.route })), [items]);
594
+ const activeRoute = useMemo(
595
+ () => items.find((item) => isRouteActive(pathname, item.route))?.route ?? NO_ACTIVE_ROUTE,
596
+ [items, pathname]
597
+ );
598
+ const optionTestID = useMemo(() => {
599
+ const byRoute = new Map(items.map((item) => [item.route, item.testID ?? item.key]));
600
+ return (route) => byRoute.get(route) ?? `${testID}-option-${route}`;
601
+ }, [items, testID]);
602
+ const hasActive = activeRoute !== NO_ACTIVE_ROUTE;
603
+ const textColor = hasActive ? TEXT_ON_PRIMARY3 : colors.rest;
604
+ const pillStyle = hasActive ? { backgroundColor: colors.activeBg } : void 0;
605
+ return /* @__PURE__ */ jsx(
606
+ ModalDropdown,
607
+ {
608
+ accessibilityHint: hint,
609
+ accessibilityLabel: label,
610
+ optionTestID,
611
+ options,
612
+ testID,
613
+ value: activeRoute,
614
+ variant,
615
+ onChange: onNavigate,
616
+ renderTrigger: ({ isOpen }) => /* @__PURE__ */ jsxs(View, { style: [navBarStyles.link, navBarStyles.overflowTrigger, pillStyle], children: [
617
+ /* @__PURE__ */ jsx(Text, { style: [navBarStyles.linkText, { color: textColor }], children: label }),
618
+ /* @__PURE__ */ jsx(Text, { style: [navBarStyles.overflowChevron, { color: textColor }], children: isOpen ? "\u25B4" : "\u25BE" })
619
+ ] })
620
+ }
621
+ );
622
+ };
623
+
624
+ // src/computeVisibleCount.ts
625
+ function sum(values) {
626
+ return values.reduce((acc, value) => acc + value, 0);
627
+ }
628
+ function computeVisibleCount({ availableWidth, itemWidths, moreWidth, gap }) {
629
+ const total = itemWidths.length;
630
+ if (total === 0) return 0;
631
+ if (availableWidth <= 0 || itemWidths.some((width) => width <= 0)) return total;
632
+ const fullRowWidth = sum(itemWidths) + gap * (total - 1);
633
+ if (fullRowWidth <= availableWidth) return total;
634
+ const reservedForMore = moreWidth + gap;
635
+ let used = 0;
636
+ let count = 0;
637
+ for (let index = 0; index < total; index += 1) {
638
+ const width = itemWidths[index] ?? 0;
639
+ const withGap = used + (count > 0 ? gap : 0) + width;
640
+ if (withGap + reservedForMore > availableWidth) break;
641
+ used = withGap;
642
+ count += 1;
643
+ }
644
+ return count;
645
+ }
646
+
647
+ // src/useNavOverflow.ts
648
+ function resizeWidthBuffer(previous, count) {
649
+ const next = new Array(count).fill(0);
650
+ const shared = Math.min(previous.length, count);
651
+ for (let index = 0; index < shared; index += 1) next[index] = previous[index] ?? 0;
652
+ return next;
653
+ }
654
+ function useNavOverflow(itemCount, gap) {
655
+ const [availableWidth, setAvailableWidthState] = useState(0);
656
+ const [moreWidth, setMoreWidthState] = useState(0);
657
+ const [itemWidths, setItemWidths] = useState(() => new Array(itemCount).fill(0));
658
+ useEffect(() => {
659
+ setItemWidths((previous) => previous.length === itemCount ? previous : resizeWidthBuffer(previous, itemCount));
660
+ }, [itemCount]);
661
+ const setItemWidth = useCallback((index, width) => {
662
+ setItemWidths((previous) => {
663
+ if (index < 0 || index >= previous.length || previous[index] === width) return previous;
664
+ const next = previous.slice();
665
+ next[index] = width;
666
+ return next;
667
+ });
668
+ }, []);
669
+ const setAvailableWidth = useCallback(
670
+ (width) => setAvailableWidthState((previous) => previous === width ? previous : width),
671
+ []
672
+ );
673
+ const setMoreWidth = useCallback(
674
+ (width) => setMoreWidthState((previous) => previous === width ? previous : width),
675
+ []
676
+ );
677
+ const visibleCount = useMemo(
678
+ () => computeVisibleCount({ availableWidth, itemWidths, moreWidth, gap }),
679
+ [availableWidth, itemWidths, moreWidth, gap]
680
+ );
681
+ return { visibleCount, setAvailableWidth, setItemWidth, setMoreWidth };
682
+ }
566
683
  var REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
567
684
  function getReducedMotionQuery() {
568
685
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return void 0;
@@ -599,6 +716,8 @@ var NavBar = ({
599
716
  menuHint = "",
600
717
  renderMenuIcon,
601
718
  collapseBelow = DEFAULT_COLLAPSE_BELOW,
719
+ overflowLabel = "More",
720
+ overflowHint = "",
602
721
  containerStyle
603
722
  }) => {
604
723
  const { theme } = useUi();
@@ -608,6 +727,7 @@ var NavBar = ({
608
727
  const reducedMotion = useReducedMotion();
609
728
  const [open, setOpen] = useState(false);
610
729
  const [toggleFocused, setToggleFocused] = useState(false);
730
+ const { visibleCount, setAvailableWidth, setItemWidth, setMoreWidth } = useNavOverflow(items.length, NAV_LINK_GAP);
611
731
  const collapsed = width < collapseBelow;
612
732
  const showLinks = !collapsed || open;
613
733
  const toggleMenu = useCallback(() => setOpen((v) => !v), []);
@@ -639,7 +759,7 @@ var NavBar = ({
639
759
  { backgroundColor: colors.surface, borderBottomColor: colors.border },
640
760
  containerStyle
641
761
  ],
642
- children: /* @__PURE__ */ jsxs(View, { style: navBarStyles.inner, children: [
762
+ children: /* @__PURE__ */ jsxs(View, { style: [navBarStyles.inner, collapsed ? null : navBarStyles.innerNoWrap], children: [
643
763
  brand !== void 0 ? /* @__PURE__ */ jsx(View, { style: navBarStyles.brand, children: brand }) : null,
644
764
  collapsed ? /* @__PURE__ */ jsx(
645
765
  Pressable,
@@ -659,7 +779,7 @@ var NavBar = ({
659
779
  }
660
780
  ) : null,
661
781
  showLinks ? (() => {
662
- const links = items.map((item) => /* @__PURE__ */ jsx(
782
+ const renderLink = (item) => /* @__PURE__ */ jsx(
663
783
  NavBarLink,
664
784
  {
665
785
  collapsed,
@@ -670,19 +790,50 @@ var NavBar = ({
670
790
  navigateHint,
671
791
  reducedMotion,
672
792
  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,
793
+ }
794
+ );
795
+ if (collapsed) {
796
+ 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)) });
797
+ }
798
+ const visibleItems = items.slice(0, visibleCount);
799
+ const overflowItems = items.slice(visibleCount);
800
+ return /* @__PURE__ */ jsxs(
801
+ View,
678
802
  {
679
- horizontal: true,
680
- showsHorizontalScrollIndicator: false,
681
803
  nativeID: LINKS_REGION_ID,
682
- style: navBarStyles.linksScroll,
683
- contentContainerStyle: navBarStyles.links,
804
+ style: navBarStyles.linksRow,
684
805
  testID: NAV_TEST_IDS.navBarLinks,
685
- children: links
806
+ onLayout: (event) => setAvailableWidth(event.nativeEvent.layout.width),
807
+ children: [
808
+ visibleItems.map((item, index) => /* @__PURE__ */ jsx(
809
+ View,
810
+ {
811
+ style: navBarStyles.linkCell,
812
+ onLayout: (event) => setItemWidth(index, event.nativeEvent.layout.width),
813
+ children: renderLink(item)
814
+ },
815
+ item.key
816
+ )),
817
+ overflowItems.length > 0 ? /* @__PURE__ */ jsx(
818
+ View,
819
+ {
820
+ style: navBarStyles.linkCell,
821
+ onLayout: (event) => setMoreWidth(event.nativeEvent.layout.width),
822
+ children: /* @__PURE__ */ jsx(
823
+ NavOverflowMenu,
824
+ {
825
+ colors: linkColors,
826
+ hint: overflowHint,
827
+ items: overflowItems,
828
+ label: overflowLabel,
829
+ pathname,
830
+ testID: NAV_TEST_IDS.navBarOverflow,
831
+ onNavigate: handlePress
832
+ }
833
+ )
834
+ }
835
+ ) : null
836
+ ]
686
837
  }
687
838
  );
688
839
  })() : null,
@@ -691,6 +842,12 @@ var NavBar = ({
691
842
  }
692
843
  );
693
844
  };
845
+ var Nav = (props) => {
846
+ if (props.orientation === "vertical") {
847
+ return /* @__PURE__ */ jsx(Sidebar, { ...props });
848
+ }
849
+ return /* @__PURE__ */ jsx(NavBar, { ...props });
850
+ };
694
851
  var MOBILE_BREAKPOINT = 768;
695
852
  var SCRIM_COLOR = "rgba(0, 0, 0, 0.5)";
696
853
  var MENU_GLYPH2 = "\u2630";
@@ -928,6 +1085,6 @@ function accessibleNavItems(user, table, translate) {
928
1085
  return roleRoutesToNavItems(resolveAccessibleRoutes(user, table), translate);
929
1086
  }
930
1087
 
931
- 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 };
1088
+ export { ACTIVE_BORDER_RADIUS, APP_SHELL_SUFFIX, AppShell, BASE_INDENT, CHEVRON_ICON_SIZE, NAV_ICON_SIZE, NAV_LINK_GAP, NAV_TEST_IDS, Nav, NavBar, NavExpandableItem, NavOverflowMenu, Sidebar, Topbar, accessibleNavItems, expandableStyles, isRouteActive, navStyles, resolveContentMaxWidth, roleRoutesToNavItems, useContentMaxWidth };
932
1089
  //# sourceMappingURL=index.mjs.map
933
1090
  //# sourceMappingURL=index.mjs.map