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