@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.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';
@@ -79,7 +79,12 @@ var navBarStyles = StyleSheet.create({
79
79
  marginLeft: "auto",
80
80
  paddingHorizontal: 10,
81
81
  paddingVertical: 6,
82
- borderRadius: 4
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"
83
88
  },
84
89
  toggleGlyph: { fontSize: 20, fontWeight: "700" },
85
90
  links: {
@@ -104,7 +109,10 @@ var navBarStyles = StyleSheet.create({
104
109
  paddingHorizontal: 14,
105
110
  borderRadius: 8,
106
111
  flexDirection: "row",
107
- alignItems: "center"
112
+ alignItems: "center",
113
+ // a11y: comfortable ≥44 tall touch target (rounded-pill hover/active affordance).
114
+ minHeight: 44,
115
+ justifyContent: "center"
108
116
  },
109
117
  linkStacked: { paddingVertical: 10 },
110
118
  linkIcon: { marginRight: 6 },
@@ -147,6 +155,31 @@ var expandableStyles = StyleSheet.create({
147
155
  var BASE_INDENT = 12;
148
156
  var NAV_ICON_SIZE = 14;
149
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
+ }
150
183
  var NavExpandableItem = ({
151
184
  item,
152
185
  pathname,
@@ -158,6 +191,7 @@ var NavExpandableItem = ({
158
191
  depth = 0
159
192
  }) => {
160
193
  const [expanded, setExpanded] = useState(false);
194
+ const [focused, setFocused] = useState(false);
161
195
  const { theme } = useUi();
162
196
  const colors = theme.colors;
163
197
  const primaryColor = theme.palette.primary["500"];
@@ -178,9 +212,18 @@ var NavExpandableItem = ({
178
212
  accessibilityHint: navigateHint(item.label),
179
213
  accessibilityLabel: item.label,
180
214
  accessibilityRole: "button",
181
- 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
+ ],
182
222
  testID: item.testID ?? item.key,
223
+ onBlur: () => setFocused(false),
224
+ onFocus: () => setFocused(true),
183
225
  onPress: () => onNavigate(item.route),
226
+ ...ariaCurrentProps(isActive),
184
227
  children: [
185
228
  typeof item.renderIcon === "function" ? /* @__PURE__ */ jsx(View, { style: expandableStyles.iconWrapper, children: item.renderIcon(colors.textSecondary, NAV_ICON_SIZE) }) : null,
186
229
  /* @__PURE__ */ jsx(
@@ -204,8 +247,10 @@ var NavExpandableItem = ({
204
247
  accessibilityLabel: item.label,
205
248
  accessibilityRole: "button",
206
249
  accessibilityState: { expanded },
207
- style: [expandableStyles.header, headerPaddingStyle],
250
+ style: [expandableStyles.header, headerPaddingStyle, focusRingStyle(focused, primaryColor)],
208
251
  testID: item.testID ?? item.key,
252
+ onBlur: () => setFocused(false),
253
+ onFocus: () => setFocused(true),
209
254
  onPress: toggle,
210
255
  children: [
211
256
  typeof item.renderIcon === "function" ? /* @__PURE__ */ jsx(View, { style: expandableStyles.iconWrapper, children: item.renderIcon(colors.text, NAV_ICON_SIZE) }) : null,
@@ -299,12 +344,40 @@ var APP_SHELL_SUFFIX = {
299
344
  content: "-content",
300
345
  header: "-header",
301
346
  nav: "-nav",
347
+ /** The persistent left rail of the back-office layout (only when `sidebar` is supplied). */
348
+ sidebar: "-sidebar",
302
349
  banner: "-banner",
303
350
  pending: "-pending",
304
351
  loading: "-loading",
305
352
  error: "-error",
306
353
  forbidden: "-forbidden"
307
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] = useState(false);
366
+ return /* @__PURE__ */ jsx(
367
+ 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
+ };
308
381
  var TEXT_ON_PRIMARY = "#ffffff";
309
382
  function isAccountState(account) {
310
383
  return "displayName" in account;
@@ -342,11 +415,11 @@ var Topbar = ({
342
415
  /* @__PURE__ */ jsx(View, { style: navStyles.topbarLeft, children: left }),
343
416
  /* @__PURE__ */ jsxs(View, { style: navStyles.topbarRight, children: [
344
417
  language ? /* @__PURE__ */ jsx(
345
- TouchableOpacity,
418
+ FocusableTouchable,
346
419
  {
347
420
  accessibilityHint: language.hint,
348
421
  accessibilityLabel: language.label,
349
- accessibilityRole: "button",
422
+ ringColor: primaryColor,
350
423
  style: navStyles.topbarRowItem,
351
424
  onPress: language.onPress,
352
425
  children: /* @__PURE__ */ jsx(Text, { style: [navStyles.topbarLabel, { color: colors.text }], children: language.label })
@@ -359,11 +432,11 @@ var Topbar = ({
359
432
  ] }) : null,
360
433
  richAccount ? /* @__PURE__ */ jsxs(View, { style: navStyles.userBlock, children: [
361
434
  richAccount.onAccount ? /* @__PURE__ */ jsx(
362
- TouchableOpacity,
435
+ FocusableTouchable,
363
436
  {
364
437
  accessibilityHint: richAccount.displayName,
365
438
  accessibilityLabel: richAccount.displayName,
366
- accessibilityRole: "button",
439
+ ringColor: primaryColor,
367
440
  testID: NAV_TEST_IDS.accountName,
368
441
  onPress: richAccount.onAccount,
369
442
  children: /* @__PURE__ */ jsx(Text, { style: [navStyles.userName, { color: colors.text }], children: richAccount.displayName })
@@ -387,11 +460,11 @@ var Topbar = ({
387
460
  ) : null
388
461
  ] }) : null,
389
462
  legacyAccount ? /* @__PURE__ */ jsx(
390
- TouchableOpacity,
463
+ FocusableTouchable,
391
464
  {
392
465
  accessibilityHint: legacyAccount.hint,
393
466
  accessibilityLabel: legacyAccount.label,
394
- accessibilityRole: "button",
467
+ ringColor: primaryColor,
395
468
  style: [navStyles.accountBtn, { backgroundColor: primaryColor }],
396
469
  testID: legacyAccount.testID,
397
470
  onPress: legacyAccount.onPress,
@@ -399,11 +472,11 @@ var Topbar = ({
399
472
  }
400
473
  ) : null,
401
474
  upgrade ? /* @__PURE__ */ jsx(
402
- TouchableOpacity,
475
+ FocusableTouchable,
403
476
  {
404
477
  accessibilityHint: upgrade.hint,
405
478
  accessibilityLabel: upgrade.label,
406
- accessibilityRole: "button",
479
+ ringColor: primaryColor,
407
480
  style: [navStyles.accountBtn, { backgroundColor: primaryColor }],
408
481
  testID: upgrade.testID ?? NAV_TEST_IDS.accountUpgrade,
409
482
  onPress: upgrade.onPress,
@@ -411,12 +484,11 @@ var Topbar = ({
411
484
  }
412
485
  ) : null,
413
486
  /* @__PURE__ */ jsx(
414
- TouchableOpacity,
487
+ FocusableTouchable,
415
488
  {
416
- accessible: true,
417
489
  accessibilityHint: logout.hint,
418
490
  accessibilityLabel: logout.label,
419
- accessibilityRole: "button",
491
+ ringColor: primaryColor,
420
492
  style: [navStyles.accountBtn, { backgroundColor: primaryColor }],
421
493
  testID: logout.testID,
422
494
  onPress: richAccount ? richAccount.onLogout : logout.onPress,
@@ -429,10 +501,75 @@ var Topbar = ({
429
501
  );
430
502
  };
431
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] = useState(false);
518
+ const [focused, setFocused] = 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__ */ jsxs(
523
+ 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__ */ jsx(View, { style: navBarStyles.linkIcon, children: item.renderIcon(textColor, iconSize) }) : null,
545
+ /* @__PURE__ */ jsx(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] = useState(() => getReducedMotionQuery()?.matches ?? false);
557
+ 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";
432
569
  var DEFAULT_COLLAPSE_BELOW = 760;
433
570
  var MENU_GLYPH = "\u2630";
434
- function activeAriaProps(isActive) {
435
- return isActive ? { "aria-current": "page" } : {};
571
+ function ariaControlsProps(id) {
572
+ return { "aria-controls": id };
436
573
  }
437
574
  var NavBar = ({
438
575
  items,
@@ -452,7 +589,9 @@ var NavBar = ({
452
589
  const colors = theme.colors;
453
590
  const primaryColor = theme.palette.primary["500"];
454
591
  const { width } = useWindowDimensions();
592
+ const reducedMotion = useReducedMotion();
455
593
  const [open, setOpen] = useState(false);
594
+ const [toggleFocused, setToggleFocused] = useState(false);
456
595
  const collapsed = width < collapseBelow;
457
596
  const showLinks = !collapsed || open;
458
597
  const toggleMenu = useCallback(() => setOpen((v) => !v), []);
@@ -463,33 +602,16 @@ var NavBar = ({
463
602
  },
464
603
  [onNavigate]
465
604
  );
466
- const activeLinkStyle = useMemo(
467
- () => ({ backgroundColor: primaryColor, borderRadius: ACTIVE_BORDER_RADIUS }),
468
- [primaryColor]
605
+ const linkColors = 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]
469
614
  );
470
- const renderLink = (item) => {
471
- const isActive = isRouteActive(pathname, item.route);
472
- const textColor = isActive ? TEXT_ON_PRIMARY2 : colors.textSecondary;
473
- return /* @__PURE__ */ jsxs(
474
- TouchableOpacity,
475
- {
476
- accessible: true,
477
- accessibilityHint: navigateHint(item.label),
478
- accessibilityLabel: item.label,
479
- accessibilityRole: "link",
480
- accessibilityState: { selected: isActive },
481
- style: [navBarStyles.link, collapsed ? navBarStyles.linkStacked : void 0, isActive ? activeLinkStyle : void 0],
482
- testID: item.testID ?? item.key,
483
- onPress: () => handlePress(item.route),
484
- ...activeAriaProps(isActive),
485
- children: [
486
- typeof item.renderIcon === "function" ? /* @__PURE__ */ jsx(View, { style: navBarStyles.linkIcon, children: item.renderIcon(textColor, NAV_ICON_SIZE) }) : null,
487
- /* @__PURE__ */ jsx(Text, { style: [navBarStyles.linkText, { color: textColor }], children: item.label })
488
- ]
489
- },
490
- item.key
491
- );
492
- };
493
615
  return /* @__PURE__ */ jsx(
494
616
  View,
495
617
  {
@@ -504,21 +626,44 @@ var NavBar = ({
504
626
  children: /* @__PURE__ */ jsxs(View, { style: navBarStyles.inner, children: [
505
627
  brand !== void 0 ? /* @__PURE__ */ jsx(View, { style: navBarStyles.brand, children: brand }) : null,
506
628
  collapsed ? /* @__PURE__ */ jsx(
507
- TouchableOpacity,
629
+ Pressable,
508
630
  {
509
- accessible: true,
510
631
  accessibilityHint: menuHint,
511
632
  accessibilityLabel: menuLabel,
512
633
  accessibilityRole: "button",
513
634
  accessibilityState: { expanded: open },
514
635
  "aria-expanded": open,
515
- style: navBarStyles.toggle,
636
+ style: [navBarStyles.toggle, focusRingStyle(toggleFocused, primaryColor)],
516
637
  testID: NAV_TEST_IDS.navBarToggle,
638
+ onBlur: () => setToggleFocused(false),
639
+ onFocus: () => setToggleFocused(true),
517
640
  onPress: toggleMenu,
641
+ ...ariaControlsProps(LINKS_REGION_ID),
518
642
  children: typeof renderMenuIcon === "function" ? renderMenuIcon(colors.text, open) : /* @__PURE__ */ jsx(Text, { style: [navBarStyles.toggleGlyph, { color: colors.text }], children: MENU_GLYPH })
519
643
  }
520
644
  ) : null,
521
- showLinks ? /* @__PURE__ */ jsx(View, { style: collapsed ? navBarStyles.linksStacked : navBarStyles.links, testID: NAV_TEST_IDS.navBarLinks, children: items.map(renderLink) }) : null,
645
+ showLinks ? /* @__PURE__ */ jsx(
646
+ 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__ */ 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,
522
667
  showLinks && right !== void 0 ? /* @__PURE__ */ jsx(View, { style: collapsed ? navBarStyles.rightStacked : navBarStyles.right, children: right }) : null
523
668
  ] })
524
669
  }
@@ -546,6 +691,8 @@ var styles = StyleSheet.create({
546
691
  centerFill: { flex: 1, justifyContent: "center", alignItems: "center" },
547
692
  scroll: { flex: 1 },
548
693
  scrollContent: { flexGrow: 1 },
694
+ // Back-office layout: a persistent left rail beside the scrolling content column.
695
+ bodyRow: { flex: 1, flexDirection: "row" },
549
696
  columnFull: { flex: 1 },
550
697
  columnCapped: { flex: 1, width: "100%", alignSelf: "center" },
551
698
  // Non-flex variant used to cap the nav strip's inner content to the content
@@ -593,6 +740,7 @@ function useContentBody(state, children, testID) {
593
740
  var AppShell = ({
594
741
  header,
595
742
  nav,
743
+ sidebar,
596
744
  banner,
597
745
  width = "full",
598
746
  contentPadding = DEFAULT_CONTENT_PADDING,
@@ -615,19 +763,23 @@ var AppShell = ({
615
763
  if (isUnauthenticated) return null;
616
764
  const columnStyle = maxWidth === "full" ? styles.columnFull : [styles.columnCapped, { maxWidth }];
617
765
  const navInnerStyle = chromeAlignment === "content" && maxWidth !== "full" ? [styles.chromeCapped, { maxWidth, paddingHorizontal: contentPadding }] : void 0;
766
+ const scroller = /* @__PURE__ */ jsx(
767
+ ScrollView,
768
+ {
769
+ contentContainerStyle: [styles.scrollContent, { padding: contentPadding }],
770
+ style: styles.scroll,
771
+ testID: `${testID}${APP_SHELL_SUFFIX.content}`,
772
+ children: /* @__PURE__ */ jsx(View, { style: columnStyle, children: body })
773
+ }
774
+ );
618
775
  return /* @__PURE__ */ jsxs(View, { style: [styles.root, { backgroundColor: theme.colors.background }], testID, children: [
619
776
  /* @__PURE__ */ jsx(View, { testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: header }),
620
777
  nav !== void 0 ? /* @__PURE__ */ jsx(View, { testID: `${testID}${APP_SHELL_SUFFIX.nav}`, children: navInnerStyle !== void 0 ? /* @__PURE__ */ jsx(View, { style: navInnerStyle, children: nav }) : nav }) : null,
621
778
  banner !== void 0 ? /* @__PURE__ */ jsx(View, { testID: `${testID}${APP_SHELL_SUFFIX.banner}`, children: banner }) : null,
622
- /* @__PURE__ */ jsx(
623
- ScrollView,
624
- {
625
- contentContainerStyle: [styles.scrollContent, { padding: contentPadding }],
626
- style: styles.scroll,
627
- testID: `${testID}${APP_SHELL_SUFFIX.content}`,
628
- children: /* @__PURE__ */ jsx(View, { style: columnStyle, children: body })
629
- }
630
- )
779
+ sidebar !== void 0 ? /* @__PURE__ */ jsxs(View, { style: styles.bodyRow, children: [
780
+ /* @__PURE__ */ jsx(View, { testID: `${testID}${APP_SHELL_SUFFIX.sidebar}`, children: sidebar }),
781
+ scroller
782
+ ] }) : scroller
631
783
  ] });
632
784
  };
633
785
  function roleRoutesToNavItems(routes, translate) {