@dloizides/ui-nav 1.2.0 → 1.4.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
@@ -81,7 +81,12 @@ var navBarStyles = reactNative.StyleSheet.create({
81
81
  marginLeft: "auto",
82
82
  paddingHorizontal: 10,
83
83
  paddingVertical: 6,
84
- borderRadius: 4
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"
85
90
  },
86
91
  toggleGlyph: { fontSize: 20, fontWeight: "700" },
87
92
  links: {
@@ -106,7 +111,10 @@ var navBarStyles = reactNative.StyleSheet.create({
106
111
  paddingHorizontal: 14,
107
112
  borderRadius: 8,
108
113
  flexDirection: "row",
109
- alignItems: "center"
114
+ alignItems: "center",
115
+ // a11y: comfortable ≥44 tall touch target (rounded-pill hover/active affordance).
116
+ minHeight: 44,
117
+ justifyContent: "center"
110
118
  },
111
119
  linkStacked: { paddingVertical: 10 },
112
120
  linkIcon: { marginRight: 6 },
@@ -149,6 +157,31 @@ var expandableStyles = reactNative.StyleSheet.create({
149
157
  var BASE_INDENT = 12;
150
158
  var NAV_ICON_SIZE = 14;
151
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
+ }
152
185
  var NavExpandableItem = ({
153
186
  item,
154
187
  pathname,
@@ -160,6 +193,7 @@ var NavExpandableItem = ({
160
193
  depth = 0
161
194
  }) => {
162
195
  const [expanded, setExpanded] = react.useState(false);
196
+ const [focused, setFocused] = react.useState(false);
163
197
  const { theme } = uiFeedback.useUi();
164
198
  const colors = theme.colors;
165
199
  const primaryColor = theme.palette.primary["500"];
@@ -180,9 +214,18 @@ var NavExpandableItem = ({
180
214
  accessibilityHint: navigateHint(item.label),
181
215
  accessibilityLabel: item.label,
182
216
  accessibilityRole: "button",
183
- 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
+ ],
184
224
  testID: item.testID ?? item.key,
225
+ onBlur: () => setFocused(false),
226
+ onFocus: () => setFocused(true),
185
227
  onPress: () => onNavigate(item.route),
228
+ ...ariaCurrentProps(isActive),
186
229
  children: [
187
230
  typeof item.renderIcon === "function" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: expandableStyles.iconWrapper, children: item.renderIcon(colors.textSecondary, NAV_ICON_SIZE) }) : null,
188
231
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -206,8 +249,10 @@ var NavExpandableItem = ({
206
249
  accessibilityLabel: item.label,
207
250
  accessibilityRole: "button",
208
251
  accessibilityState: { expanded },
209
- style: [expandableStyles.header, headerPaddingStyle],
252
+ style: [expandableStyles.header, headerPaddingStyle, focusRingStyle(focused, primaryColor)],
210
253
  testID: item.testID ?? item.key,
254
+ onBlur: () => setFocused(false),
255
+ onFocus: () => setFocused(true),
211
256
  onPress: toggle,
212
257
  children: [
213
258
  typeof item.renderIcon === "function" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: expandableStyles.iconWrapper, children: item.renderIcon(colors.text, NAV_ICON_SIZE) }) : null,
@@ -301,12 +346,40 @@ var APP_SHELL_SUFFIX = {
301
346
  content: "-content",
302
347
  header: "-header",
303
348
  nav: "-nav",
349
+ /** The persistent left rail of the back-office layout (only when `sidebar` is supplied). */
350
+ sidebar: "-sidebar",
304
351
  banner: "-banner",
305
352
  pending: "-pending",
306
353
  loading: "-loading",
307
354
  error: "-error",
308
355
  forbidden: "-forbidden"
309
356
  };
357
+ var FocusableTouchable = ({
358
+ onPress,
359
+ accessibilityLabel,
360
+ accessibilityHint,
361
+ accessibilityRole = "button",
362
+ ringColor,
363
+ style,
364
+ testID,
365
+ children
366
+ }) => {
367
+ const [focused, setFocused] = react.useState(false);
368
+ return /* @__PURE__ */ jsxRuntime.jsx(
369
+ reactNative.Pressable,
370
+ {
371
+ accessibilityHint,
372
+ accessibilityLabel,
373
+ accessibilityRole,
374
+ style: [style, focusRingStyle(focused, ringColor)],
375
+ testID,
376
+ onBlur: () => setFocused(false),
377
+ onFocus: () => setFocused(true),
378
+ onPress,
379
+ children
380
+ }
381
+ );
382
+ };
310
383
  var TEXT_ON_PRIMARY = "#ffffff";
311
384
  function isAccountState(account) {
312
385
  return "displayName" in account;
@@ -344,11 +417,11 @@ var Topbar = ({
344
417
  /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navStyles.topbarLeft, children: left }),
345
418
  /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: navStyles.topbarRight, children: [
346
419
  language ? /* @__PURE__ */ jsxRuntime.jsx(
347
- reactNative.TouchableOpacity,
420
+ FocusableTouchable,
348
421
  {
349
422
  accessibilityHint: language.hint,
350
423
  accessibilityLabel: language.label,
351
- accessibilityRole: "button",
424
+ ringColor: primaryColor,
352
425
  style: navStyles.topbarRowItem,
353
426
  onPress: language.onPress,
354
427
  children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navStyles.topbarLabel, { color: colors.text }], children: language.label })
@@ -361,11 +434,11 @@ var Topbar = ({
361
434
  ] }) : null,
362
435
  richAccount ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: navStyles.userBlock, children: [
363
436
  richAccount.onAccount ? /* @__PURE__ */ jsxRuntime.jsx(
364
- reactNative.TouchableOpacity,
437
+ FocusableTouchable,
365
438
  {
366
439
  accessibilityHint: richAccount.displayName,
367
440
  accessibilityLabel: richAccount.displayName,
368
- accessibilityRole: "button",
441
+ ringColor: primaryColor,
369
442
  testID: NAV_TEST_IDS.accountName,
370
443
  onPress: richAccount.onAccount,
371
444
  children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navStyles.userName, { color: colors.text }], children: richAccount.displayName })
@@ -389,11 +462,11 @@ var Topbar = ({
389
462
  ) : null
390
463
  ] }) : null,
391
464
  legacyAccount ? /* @__PURE__ */ jsxRuntime.jsx(
392
- reactNative.TouchableOpacity,
465
+ FocusableTouchable,
393
466
  {
394
467
  accessibilityHint: legacyAccount.hint,
395
468
  accessibilityLabel: legacyAccount.label,
396
- accessibilityRole: "button",
469
+ ringColor: primaryColor,
397
470
  style: [navStyles.accountBtn, { backgroundColor: primaryColor }],
398
471
  testID: legacyAccount.testID,
399
472
  onPress: legacyAccount.onPress,
@@ -401,11 +474,11 @@ var Topbar = ({
401
474
  }
402
475
  ) : null,
403
476
  upgrade ? /* @__PURE__ */ jsxRuntime.jsx(
404
- reactNative.TouchableOpacity,
477
+ FocusableTouchable,
405
478
  {
406
479
  accessibilityHint: upgrade.hint,
407
480
  accessibilityLabel: upgrade.label,
408
- accessibilityRole: "button",
481
+ ringColor: primaryColor,
409
482
  style: [navStyles.accountBtn, { backgroundColor: primaryColor }],
410
483
  testID: upgrade.testID ?? NAV_TEST_IDS.accountUpgrade,
411
484
  onPress: upgrade.onPress,
@@ -413,12 +486,11 @@ var Topbar = ({
413
486
  }
414
487
  ) : null,
415
488
  /* @__PURE__ */ jsxRuntime.jsx(
416
- reactNative.TouchableOpacity,
489
+ FocusableTouchable,
417
490
  {
418
- accessible: true,
419
491
  accessibilityHint: logout.hint,
420
492
  accessibilityLabel: logout.label,
421
- accessibilityRole: "button",
493
+ ringColor: primaryColor,
422
494
  style: [navStyles.accountBtn, { backgroundColor: primaryColor }],
423
495
  testID: logout.testID,
424
496
  onPress: richAccount ? richAccount.onLogout : logout.onPress,
@@ -431,10 +503,75 @@ var Topbar = ({
431
503
  );
432
504
  };
433
505
  var TEXT_ON_PRIMARY2 = "#ffffff";
506
+ function ariaCurrentProps2(isActive) {
507
+ return isActive ? { "aria-current": "page" } : {};
508
+ }
509
+ var NavBarLink = ({
510
+ item,
511
+ isActive,
512
+ collapsed,
513
+ iconSize,
514
+ reducedMotion,
515
+ colors,
516
+ navigateHint,
517
+ onPress
518
+ }) => {
519
+ const [hovered, setHovered] = react.useState(false);
520
+ const [focused, setFocused] = react.useState(false);
521
+ const isHovered = hovered && !isActive;
522
+ const textColor = isActive ? TEXT_ON_PRIMARY2 : isHovered ? colors.hoverText : colors.rest;
523
+ const pillStyle = isActive ? { backgroundColor: colors.activeBg } : isHovered ? { backgroundColor: colors.hoverBg } : void 0;
524
+ return /* @__PURE__ */ jsxRuntime.jsxs(
525
+ reactNative.Pressable,
526
+ {
527
+ accessibilityHint: navigateHint(item.label),
528
+ accessibilityLabel: item.label,
529
+ accessibilityRole: "link",
530
+ accessibilityState: { selected: isActive },
531
+ style: [
532
+ navBarStyles.link,
533
+ collapsed ? navBarStyles.linkStacked : void 0,
534
+ pillStyle,
535
+ hoverTransitionStyle(reducedMotion),
536
+ focusRingStyle(focused, colors.ring)
537
+ ],
538
+ testID: item.testID ?? item.key,
539
+ onBlur: () => setFocused(false),
540
+ onFocus: () => setFocused(true),
541
+ onHoverIn: () => setHovered(true),
542
+ onHoverOut: () => setHovered(false),
543
+ onPress: () => onPress(item.route),
544
+ ...ariaCurrentProps2(isActive),
545
+ children: [
546
+ typeof item.renderIcon === "function" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navBarStyles.linkIcon, children: item.renderIcon(textColor, iconSize) }) : null,
547
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navBarStyles.linkText, { color: textColor }], children: item.label })
548
+ ]
549
+ }
550
+ );
551
+ };
552
+ var REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
553
+ function getReducedMotionQuery() {
554
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return void 0;
555
+ return window.matchMedia(REDUCED_MOTION_QUERY);
556
+ }
557
+ function useReducedMotion() {
558
+ const [reduced, setReduced] = react.useState(() => getReducedMotionQuery()?.matches ?? false);
559
+ react.useEffect(() => {
560
+ const mql = getReducedMotionQuery();
561
+ if (mql === void 0 || mql.addEventListener === void 0) return void 0;
562
+ const onChange = () => setReduced(mql.matches);
563
+ mql.addEventListener("change", onChange);
564
+ return () => {
565
+ mql.removeEventListener?.("change", onChange);
566
+ };
567
+ }, []);
568
+ return reduced;
569
+ }
570
+ var LINKS_REGION_ID = "navbar-links-region";
434
571
  var DEFAULT_COLLAPSE_BELOW = 760;
435
572
  var MENU_GLYPH = "\u2630";
436
- function activeAriaProps(isActive) {
437
- return isActive ? { "aria-current": "page" } : {};
573
+ function ariaControlsProps(id) {
574
+ return { "aria-controls": id };
438
575
  }
439
576
  var NavBar = ({
440
577
  items,
@@ -454,7 +591,9 @@ var NavBar = ({
454
591
  const colors = theme.colors;
455
592
  const primaryColor = theme.palette.primary["500"];
456
593
  const { width } = reactNative.useWindowDimensions();
594
+ const reducedMotion = useReducedMotion();
457
595
  const [open, setOpen] = react.useState(false);
596
+ const [toggleFocused, setToggleFocused] = react.useState(false);
458
597
  const collapsed = width < collapseBelow;
459
598
  const showLinks = !collapsed || open;
460
599
  const toggleMenu = react.useCallback(() => setOpen((v) => !v), []);
@@ -465,33 +604,16 @@ var NavBar = ({
465
604
  },
466
605
  [onNavigate]
467
606
  );
468
- const activeLinkStyle = react.useMemo(
469
- () => ({ backgroundColor: primaryColor, borderRadius: ACTIVE_BORDER_RADIUS }),
470
- [primaryColor]
607
+ const linkColors = react.useMemo(
608
+ () => ({
609
+ rest: colors.textSecondary,
610
+ hoverText: colors.text,
611
+ hoverBg: colors.surfaceElevated,
612
+ activeBg: primaryColor,
613
+ ring: primaryColor
614
+ }),
615
+ [colors.textSecondary, colors.text, colors.surfaceElevated, primaryColor]
471
616
  );
472
- const renderLink = (item) => {
473
- const isActive = isRouteActive(pathname, item.route);
474
- const textColor = isActive ? TEXT_ON_PRIMARY2 : colors.textSecondary;
475
- return /* @__PURE__ */ jsxRuntime.jsxs(
476
- reactNative.TouchableOpacity,
477
- {
478
- accessible: true,
479
- accessibilityHint: navigateHint(item.label),
480
- accessibilityLabel: item.label,
481
- accessibilityRole: "link",
482
- accessibilityState: { selected: isActive },
483
- style: [navBarStyles.link, collapsed ? navBarStyles.linkStacked : void 0, isActive ? activeLinkStyle : void 0],
484
- testID: item.testID ?? item.key,
485
- onPress: () => handlePress(item.route),
486
- ...activeAriaProps(isActive),
487
- children: [
488
- typeof item.renderIcon === "function" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navBarStyles.linkIcon, children: item.renderIcon(textColor, NAV_ICON_SIZE) }) : null,
489
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navBarStyles.linkText, { color: textColor }], children: item.label })
490
- ]
491
- },
492
- item.key
493
- );
494
- };
495
617
  return /* @__PURE__ */ jsxRuntime.jsx(
496
618
  reactNative.View,
497
619
  {
@@ -506,21 +628,44 @@ var NavBar = ({
506
628
  children: /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: navBarStyles.inner, children: [
507
629
  brand !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navBarStyles.brand, children: brand }) : null,
508
630
  collapsed ? /* @__PURE__ */ jsxRuntime.jsx(
509
- reactNative.TouchableOpacity,
631
+ reactNative.Pressable,
510
632
  {
511
- accessible: true,
512
633
  accessibilityHint: menuHint,
513
634
  accessibilityLabel: menuLabel,
514
635
  accessibilityRole: "button",
515
636
  accessibilityState: { expanded: open },
516
637
  "aria-expanded": open,
517
- style: navBarStyles.toggle,
638
+ style: [navBarStyles.toggle, focusRingStyle(toggleFocused, primaryColor)],
518
639
  testID: NAV_TEST_IDS.navBarToggle,
640
+ onBlur: () => setToggleFocused(false),
641
+ onFocus: () => setToggleFocused(true),
519
642
  onPress: toggleMenu,
643
+ ...ariaControlsProps(LINKS_REGION_ID),
520
644
  children: typeof renderMenuIcon === "function" ? renderMenuIcon(colors.text, open) : /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navBarStyles.toggleGlyph, { color: colors.text }], children: MENU_GLYPH })
521
645
  }
522
646
  ) : null,
523
- showLinks ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: collapsed ? navBarStyles.linksStacked : navBarStyles.links, testID: NAV_TEST_IDS.navBarLinks, children: items.map(renderLink) }) : null,
647
+ showLinks ? /* @__PURE__ */ jsxRuntime.jsx(
648
+ reactNative.View,
649
+ {
650
+ nativeID: LINKS_REGION_ID,
651
+ style: collapsed ? navBarStyles.linksStacked : navBarStyles.links,
652
+ testID: NAV_TEST_IDS.navBarLinks,
653
+ children: items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
654
+ NavBarLink,
655
+ {
656
+ collapsed,
657
+ colors: linkColors,
658
+ iconSize: NAV_ICON_SIZE,
659
+ isActive: isRouteActive(pathname, item.route),
660
+ item,
661
+ navigateHint,
662
+ reducedMotion,
663
+ onPress: handlePress
664
+ },
665
+ item.key
666
+ ))
667
+ }
668
+ ) : null,
524
669
  showLinks && right !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: collapsed ? navBarStyles.rightStacked : navBarStyles.right, children: right }) : null
525
670
  ] })
526
671
  }
@@ -548,6 +693,8 @@ var styles = reactNative.StyleSheet.create({
548
693
  centerFill: { flex: 1, justifyContent: "center", alignItems: "center" },
549
694
  scroll: { flex: 1 },
550
695
  scrollContent: { flexGrow: 1 },
696
+ // Back-office layout: a persistent left rail beside the scrolling content column.
697
+ bodyRow: { flex: 1, flexDirection: "row" },
551
698
  columnFull: { flex: 1 },
552
699
  columnCapped: { flex: 1, width: "100%", alignSelf: "center" },
553
700
  // Non-flex variant used to cap the nav strip's inner content to the content
@@ -595,6 +742,7 @@ function useContentBody(state, children, testID) {
595
742
  var AppShell = ({
596
743
  header,
597
744
  nav,
745
+ sidebar,
598
746
  banner,
599
747
  width = "full",
600
748
  contentPadding = DEFAULT_CONTENT_PADDING,
@@ -617,19 +765,23 @@ var AppShell = ({
617
765
  if (isUnauthenticated) return null;
618
766
  const columnStyle = maxWidth === "full" ? styles.columnFull : [styles.columnCapped, { maxWidth }];
619
767
  const navInnerStyle = chromeAlignment === "content" && maxWidth !== "full" ? [styles.chromeCapped, { maxWidth, paddingHorizontal: contentPadding }] : void 0;
768
+ const scroller = /* @__PURE__ */ jsxRuntime.jsx(
769
+ reactNative.ScrollView,
770
+ {
771
+ contentContainerStyle: [styles.scrollContent, { padding: contentPadding }],
772
+ style: styles.scroll,
773
+ testID: `${testID}${APP_SHELL_SUFFIX.content}`,
774
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: columnStyle, children: body })
775
+ }
776
+ );
620
777
  return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles.root, { backgroundColor: theme.colors.background }], testID, children: [
621
778
  /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: header }),
622
779
  nav !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.nav}`, children: navInnerStyle !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navInnerStyle, children: nav }) : nav }) : null,
623
780
  banner !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.banner}`, children: banner }) : null,
624
- /* @__PURE__ */ jsxRuntime.jsx(
625
- reactNative.ScrollView,
626
- {
627
- contentContainerStyle: [styles.scrollContent, { padding: contentPadding }],
628
- style: styles.scroll,
629
- testID: `${testID}${APP_SHELL_SUFFIX.content}`,
630
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: columnStyle, children: body })
631
- }
632
- )
781
+ sidebar !== void 0 ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles.bodyRow, children: [
782
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.sidebar}`, children: sidebar }),
783
+ scroller
784
+ ] }) : scroller
633
785
  ] });
634
786
  };
635
787
  function roleRoutesToNavItems(routes, translate) {