@dloizides/ui-nav 1.1.1 → 1.3.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
@@ -61,6 +61,79 @@ var navStyles = reactNative.StyleSheet.create({
61
61
  planBadge: { marginTop: 2, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, alignSelf: "flex-end" },
62
62
  planBadgeText: { fontSize: 11, fontWeight: "700" }
63
63
  });
64
+ var navBarStyles = reactNative.StyleSheet.create({
65
+ // --- Horizontal top NavBar ---
66
+ container: {
67
+ borderBottomWidth: 1,
68
+ paddingHorizontal: 12
69
+ },
70
+ inner: {
71
+ minHeight: 56,
72
+ flexDirection: "row",
73
+ alignItems: "center",
74
+ flexWrap: "wrap",
75
+ columnGap: 8,
76
+ rowGap: 4,
77
+ paddingVertical: 8
78
+ },
79
+ brand: { marginRight: 16 },
80
+ toggle: {
81
+ marginLeft: "auto",
82
+ paddingHorizontal: 10,
83
+ paddingVertical: 6,
84
+ borderRadius: 4,
85
+ // a11y: keep the hamburger a comfortable ≥44×44 touch target.
86
+ minWidth: 44,
87
+ minHeight: 44,
88
+ alignItems: "center",
89
+ justifyContent: "center"
90
+ },
91
+ toggleGlyph: { fontSize: 20, fontWeight: "700" },
92
+ links: {
93
+ flexDirection: "row",
94
+ alignItems: "center",
95
+ flexWrap: "wrap",
96
+ columnGap: 4,
97
+ rowGap: 4
98
+ },
99
+ // Collapsed (below breakpoint): links drop onto their own full-width line,
100
+ // stacked vertically — mirrors the v1 `.gn-links` responsive behaviour.
101
+ linksStacked: {
102
+ width: "100%",
103
+ flexBasis: "100%",
104
+ flexDirection: "column",
105
+ alignItems: "stretch",
106
+ rowGap: 2,
107
+ marginTop: 8
108
+ },
109
+ link: {
110
+ paddingVertical: 8,
111
+ paddingHorizontal: 14,
112
+ borderRadius: 8,
113
+ flexDirection: "row",
114
+ alignItems: "center",
115
+ // a11y: comfortable ≥44 tall touch target (rounded-pill hover/active affordance).
116
+ minHeight: 44,
117
+ justifyContent: "center"
118
+ },
119
+ linkStacked: { paddingVertical: 10 },
120
+ linkIcon: { marginRight: 6 },
121
+ linkText: { fontSize: 15, fontWeight: "600" },
122
+ right: {
123
+ marginLeft: "auto",
124
+ flexDirection: "row",
125
+ alignItems: "center",
126
+ columnGap: 8
127
+ },
128
+ rightStacked: {
129
+ width: "100%",
130
+ flexBasis: "100%",
131
+ marginTop: 4,
132
+ flexDirection: "row",
133
+ alignItems: "center",
134
+ columnGap: 8
135
+ }
136
+ });
64
137
  var expandableStyles = reactNative.StyleSheet.create({
65
138
  childItem: {
66
139
  borderRadius: 4,
@@ -84,6 +157,31 @@ var expandableStyles = reactNative.StyleSheet.create({
84
157
  var BASE_INDENT = 12;
85
158
  var NAV_ICON_SIZE = 14;
86
159
  var CHEVRON_ICON_SIZE = 12;
160
+ var IS_WEB = reactNative.Platform.OS === "web";
161
+ var FOCUS_RING_WIDTH = 2;
162
+ var FOCUS_RING_OFFSET = 2;
163
+ var HOVER_TRANSITION_MS = 150;
164
+ function focusRingStyle(focused, ringColor) {
165
+ if (!IS_WEB || !focused) return void 0;
166
+ const ring = {
167
+ outlineWidth: FOCUS_RING_WIDTH,
168
+ outlineStyle: "solid",
169
+ outlineColor: ringColor,
170
+ outlineOffset: FOCUS_RING_OFFSET
171
+ };
172
+ return ring;
173
+ }
174
+ function hoverTransitionStyle(reducedMotion) {
175
+ if (!IS_WEB) return void 0;
176
+ const transition = {
177
+ transitionProperty: "color, background-color",
178
+ transitionDuration: reducedMotion ? "0ms" : `${HOVER_TRANSITION_MS}ms`
179
+ };
180
+ return transition;
181
+ }
182
+ function ariaCurrentProps(isActive) {
183
+ return isActive ? { "aria-current": "page" } : {};
184
+ }
87
185
  var NavExpandableItem = ({
88
186
  item,
89
187
  pathname,
@@ -95,6 +193,7 @@ var NavExpandableItem = ({
95
193
  depth = 0
96
194
  }) => {
97
195
  const [expanded, setExpanded] = react.useState(false);
196
+ const [focused, setFocused] = react.useState(false);
98
197
  const { theme } = uiFeedback.useUi();
99
198
  const colors = theme.colors;
100
199
  const primaryColor = theme.palette.primary["500"];
@@ -115,9 +214,18 @@ var NavExpandableItem = ({
115
214
  accessibilityHint: navigateHint(item.label),
116
215
  accessibilityLabel: item.label,
117
216
  accessibilityRole: "button",
118
- style: [expandableStyles.childItem, paddingStyle, isActive ? activeItemStyle : void 0],
217
+ accessibilityState: { selected: isActive },
218
+ style: [
219
+ expandableStyles.childItem,
220
+ paddingStyle,
221
+ isActive ? activeItemStyle : void 0,
222
+ focusRingStyle(focused, primaryColor)
223
+ ],
119
224
  testID: item.testID ?? item.key,
225
+ onBlur: () => setFocused(false),
226
+ onFocus: () => setFocused(true),
120
227
  onPress: () => onNavigate(item.route),
228
+ ...ariaCurrentProps(isActive),
121
229
  children: [
122
230
  typeof item.renderIcon === "function" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: expandableStyles.iconWrapper, children: item.renderIcon(colors.textSecondary, NAV_ICON_SIZE) }) : null,
123
231
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -141,8 +249,10 @@ var NavExpandableItem = ({
141
249
  accessibilityLabel: item.label,
142
250
  accessibilityRole: "button",
143
251
  accessibilityState: { expanded },
144
- style: [expandableStyles.header, headerPaddingStyle],
252
+ style: [expandableStyles.header, headerPaddingStyle, focusRingStyle(focused, primaryColor)],
145
253
  testID: item.testID ?? item.key,
254
+ onBlur: () => setFocused(false),
255
+ onFocus: () => setFocused(true),
146
256
  onPress: toggle,
147
257
  children: [
148
258
  typeof item.renderIcon === "function" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: expandableStyles.iconWrapper, children: item.renderIcon(colors.text, NAV_ICON_SIZE) }) : null,
@@ -226,7 +336,11 @@ var NAV_TEST_IDS = {
226
336
  /** small plan badge (free / pro / enterprise). */
227
337
  accountPlan: "topbar-account-plan",
228
338
  /** default testID for the rich header's upgrade action (overridable per-action). */
229
- accountUpgrade: "topbar-account-upgrade"
339
+ accountUpgrade: "topbar-account-upgrade",
340
+ /** responsive hamburger toggle in the horizontal NavBar (shown below the breakpoint). */
341
+ navBarToggle: "navbar-toggle",
342
+ /** links container in the horizontal NavBar. */
343
+ navBarLinks: "navbar-links"
230
344
  };
231
345
  var APP_SHELL_SUFFIX = {
232
346
  content: "-content",
@@ -238,6 +352,32 @@ var APP_SHELL_SUFFIX = {
238
352
  error: "-error",
239
353
  forbidden: "-forbidden"
240
354
  };
355
+ var FocusableTouchable = ({
356
+ onPress,
357
+ accessibilityLabel,
358
+ accessibilityHint,
359
+ accessibilityRole = "button",
360
+ ringColor,
361
+ style,
362
+ testID,
363
+ children
364
+ }) => {
365
+ const [focused, setFocused] = react.useState(false);
366
+ return /* @__PURE__ */ jsxRuntime.jsx(
367
+ reactNative.Pressable,
368
+ {
369
+ accessibilityHint,
370
+ accessibilityLabel,
371
+ accessibilityRole,
372
+ style: [style, focusRingStyle(focused, ringColor)],
373
+ testID,
374
+ onBlur: () => setFocused(false),
375
+ onFocus: () => setFocused(true),
376
+ onPress,
377
+ children
378
+ }
379
+ );
380
+ };
241
381
  var TEXT_ON_PRIMARY = "#ffffff";
242
382
  function isAccountState(account) {
243
383
  return "displayName" in account;
@@ -275,11 +415,11 @@ var Topbar = ({
275
415
  /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navStyles.topbarLeft, children: left }),
276
416
  /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: navStyles.topbarRight, children: [
277
417
  language ? /* @__PURE__ */ jsxRuntime.jsx(
278
- reactNative.TouchableOpacity,
418
+ FocusableTouchable,
279
419
  {
280
420
  accessibilityHint: language.hint,
281
421
  accessibilityLabel: language.label,
282
- accessibilityRole: "button",
422
+ ringColor: primaryColor,
283
423
  style: navStyles.topbarRowItem,
284
424
  onPress: language.onPress,
285
425
  children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navStyles.topbarLabel, { color: colors.text }], children: language.label })
@@ -292,11 +432,11 @@ var Topbar = ({
292
432
  ] }) : null,
293
433
  richAccount ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: navStyles.userBlock, children: [
294
434
  richAccount.onAccount ? /* @__PURE__ */ jsxRuntime.jsx(
295
- reactNative.TouchableOpacity,
435
+ FocusableTouchable,
296
436
  {
297
437
  accessibilityHint: richAccount.displayName,
298
438
  accessibilityLabel: richAccount.displayName,
299
- accessibilityRole: "button",
439
+ ringColor: primaryColor,
300
440
  testID: NAV_TEST_IDS.accountName,
301
441
  onPress: richAccount.onAccount,
302
442
  children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navStyles.userName, { color: colors.text }], children: richAccount.displayName })
@@ -320,11 +460,11 @@ var Topbar = ({
320
460
  ) : null
321
461
  ] }) : null,
322
462
  legacyAccount ? /* @__PURE__ */ jsxRuntime.jsx(
323
- reactNative.TouchableOpacity,
463
+ FocusableTouchable,
324
464
  {
325
465
  accessibilityHint: legacyAccount.hint,
326
466
  accessibilityLabel: legacyAccount.label,
327
- accessibilityRole: "button",
467
+ ringColor: primaryColor,
328
468
  style: [navStyles.accountBtn, { backgroundColor: primaryColor }],
329
469
  testID: legacyAccount.testID,
330
470
  onPress: legacyAccount.onPress,
@@ -332,11 +472,11 @@ var Topbar = ({
332
472
  }
333
473
  ) : null,
334
474
  upgrade ? /* @__PURE__ */ jsxRuntime.jsx(
335
- reactNative.TouchableOpacity,
475
+ FocusableTouchable,
336
476
  {
337
477
  accessibilityHint: upgrade.hint,
338
478
  accessibilityLabel: upgrade.label,
339
- accessibilityRole: "button",
479
+ ringColor: primaryColor,
340
480
  style: [navStyles.accountBtn, { backgroundColor: primaryColor }],
341
481
  testID: upgrade.testID ?? NAV_TEST_IDS.accountUpgrade,
342
482
  onPress: upgrade.onPress,
@@ -344,12 +484,11 @@ var Topbar = ({
344
484
  }
345
485
  ) : null,
346
486
  /* @__PURE__ */ jsxRuntime.jsx(
347
- reactNative.TouchableOpacity,
487
+ FocusableTouchable,
348
488
  {
349
- accessible: true,
350
489
  accessibilityHint: logout.hint,
351
490
  accessibilityLabel: logout.label,
352
- accessibilityRole: "button",
491
+ ringColor: primaryColor,
353
492
  style: [navStyles.accountBtn, { backgroundColor: primaryColor }],
354
493
  testID: logout.testID,
355
494
  onPress: richAccount ? richAccount.onLogout : logout.onPress,
@@ -361,6 +500,175 @@ var Topbar = ({
361
500
  }
362
501
  );
363
502
  };
503
+ var TEXT_ON_PRIMARY2 = "#ffffff";
504
+ function ariaCurrentProps2(isActive) {
505
+ return isActive ? { "aria-current": "page" } : {};
506
+ }
507
+ var NavBarLink = ({
508
+ item,
509
+ isActive,
510
+ collapsed,
511
+ iconSize,
512
+ reducedMotion,
513
+ colors,
514
+ navigateHint,
515
+ onPress
516
+ }) => {
517
+ const [hovered, setHovered] = react.useState(false);
518
+ const [focused, setFocused] = react.useState(false);
519
+ const isHovered = hovered && !isActive;
520
+ const textColor = isActive ? TEXT_ON_PRIMARY2 : isHovered ? colors.hoverText : colors.rest;
521
+ const pillStyle = isActive ? { backgroundColor: colors.activeBg } : isHovered ? { backgroundColor: colors.hoverBg } : void 0;
522
+ return /* @__PURE__ */ jsxRuntime.jsxs(
523
+ reactNative.Pressable,
524
+ {
525
+ accessibilityHint: navigateHint(item.label),
526
+ accessibilityLabel: item.label,
527
+ accessibilityRole: "link",
528
+ accessibilityState: { selected: isActive },
529
+ style: [
530
+ navBarStyles.link,
531
+ collapsed ? navBarStyles.linkStacked : void 0,
532
+ pillStyle,
533
+ hoverTransitionStyle(reducedMotion),
534
+ focusRingStyle(focused, colors.ring)
535
+ ],
536
+ testID: item.testID ?? item.key,
537
+ onBlur: () => setFocused(false),
538
+ onFocus: () => setFocused(true),
539
+ onHoverIn: () => setHovered(true),
540
+ onHoverOut: () => setHovered(false),
541
+ onPress: () => onPress(item.route),
542
+ ...ariaCurrentProps2(isActive),
543
+ children: [
544
+ typeof item.renderIcon === "function" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navBarStyles.linkIcon, children: item.renderIcon(textColor, iconSize) }) : null,
545
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navBarStyles.linkText, { color: textColor }], children: item.label })
546
+ ]
547
+ }
548
+ );
549
+ };
550
+ var REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
551
+ function getReducedMotionQuery() {
552
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return void 0;
553
+ return window.matchMedia(REDUCED_MOTION_QUERY);
554
+ }
555
+ function useReducedMotion() {
556
+ const [reduced, setReduced] = react.useState(() => getReducedMotionQuery()?.matches ?? false);
557
+ react.useEffect(() => {
558
+ const mql = getReducedMotionQuery();
559
+ if (mql === void 0 || mql.addEventListener === void 0) return void 0;
560
+ const onChange = () => setReduced(mql.matches);
561
+ mql.addEventListener("change", onChange);
562
+ return () => {
563
+ mql.removeEventListener?.("change", onChange);
564
+ };
565
+ }, []);
566
+ return reduced;
567
+ }
568
+ var LINKS_REGION_ID = "navbar-links-region";
569
+ var DEFAULT_COLLAPSE_BELOW = 760;
570
+ var MENU_GLYPH = "\u2630";
571
+ function ariaControlsProps(id) {
572
+ return { "aria-controls": id };
573
+ }
574
+ var NavBar = ({
575
+ items,
576
+ pathname,
577
+ onNavigate,
578
+ regionLabel,
579
+ navigateHint = (label) => label,
580
+ brand,
581
+ right,
582
+ menuLabel = "Menu",
583
+ menuHint = "",
584
+ renderMenuIcon,
585
+ collapseBelow = DEFAULT_COLLAPSE_BELOW,
586
+ containerStyle
587
+ }) => {
588
+ const { theme } = uiFeedback.useUi();
589
+ const colors = theme.colors;
590
+ const primaryColor = theme.palette.primary["500"];
591
+ const { width } = reactNative.useWindowDimensions();
592
+ const reducedMotion = useReducedMotion();
593
+ const [open, setOpen] = react.useState(false);
594
+ const [toggleFocused, setToggleFocused] = react.useState(false);
595
+ const collapsed = width < collapseBelow;
596
+ const showLinks = !collapsed || open;
597
+ const toggleMenu = react.useCallback(() => setOpen((v) => !v), []);
598
+ const handlePress = react.useCallback(
599
+ (route) => {
600
+ setOpen(false);
601
+ onNavigate(route);
602
+ },
603
+ [onNavigate]
604
+ );
605
+ const linkColors = react.useMemo(
606
+ () => ({
607
+ rest: colors.textSecondary,
608
+ hoverText: colors.text,
609
+ hoverBg: colors.surfaceElevated,
610
+ activeBg: primaryColor,
611
+ ring: primaryColor
612
+ }),
613
+ [colors.textSecondary, colors.text, colors.surfaceElevated, primaryColor]
614
+ );
615
+ return /* @__PURE__ */ jsxRuntime.jsx(
616
+ reactNative.View,
617
+ {
618
+ accessibilityRole: "none",
619
+ "aria-label": regionLabel,
620
+ role: "navigation",
621
+ style: [
622
+ navBarStyles.container,
623
+ { backgroundColor: colors.surface, borderBottomColor: colors.border },
624
+ containerStyle
625
+ ],
626
+ children: /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: navBarStyles.inner, children: [
627
+ brand !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navBarStyles.brand, children: brand }) : null,
628
+ collapsed ? /* @__PURE__ */ jsxRuntime.jsx(
629
+ reactNative.Pressable,
630
+ {
631
+ accessibilityHint: menuHint,
632
+ accessibilityLabel: menuLabel,
633
+ accessibilityRole: "button",
634
+ accessibilityState: { expanded: open },
635
+ "aria-expanded": open,
636
+ style: [navBarStyles.toggle, focusRingStyle(toggleFocused, primaryColor)],
637
+ testID: NAV_TEST_IDS.navBarToggle,
638
+ onBlur: () => setToggleFocused(false),
639
+ onFocus: () => setToggleFocused(true),
640
+ onPress: toggleMenu,
641
+ ...ariaControlsProps(LINKS_REGION_ID),
642
+ children: typeof renderMenuIcon === "function" ? renderMenuIcon(colors.text, open) : /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navBarStyles.toggleGlyph, { color: colors.text }], children: MENU_GLYPH })
643
+ }
644
+ ) : null,
645
+ showLinks ? /* @__PURE__ */ jsxRuntime.jsx(
646
+ reactNative.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__ */ jsxRuntime.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,
667
+ showLinks && right !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: collapsed ? navBarStyles.rightStacked : navBarStyles.right, children: right }) : null
668
+ ] })
669
+ }
670
+ );
671
+ };
364
672
  function resolveContentMaxWidth(width, viewport) {
365
673
  if (width === "full") return "full";
366
674
  const wideMinViewport = width.wideMinViewport ?? Number.POSITIVE_INFINITY;
@@ -485,6 +793,7 @@ exports.BASE_INDENT = BASE_INDENT;
485
793
  exports.CHEVRON_ICON_SIZE = CHEVRON_ICON_SIZE;
486
794
  exports.NAV_ICON_SIZE = NAV_ICON_SIZE;
487
795
  exports.NAV_TEST_IDS = NAV_TEST_IDS;
796
+ exports.NavBar = NavBar;
488
797
  exports.NavExpandableItem = NavExpandableItem;
489
798
  exports.Sidebar = Sidebar;
490
799
  exports.Topbar = Topbar;