@dloizides/ui-nav 1.2.0 → 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';
@@ -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,
@@ -305,6 +350,32 @@ var APP_SHELL_SUFFIX = {
305
350
  error: "-error",
306
351
  forbidden: "-forbidden"
307
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
+ };
308
379
  var TEXT_ON_PRIMARY = "#ffffff";
309
380
  function isAccountState(account) {
310
381
  return "displayName" in account;
@@ -342,11 +413,11 @@ var Topbar = ({
342
413
  /* @__PURE__ */ jsx(View, { style: navStyles.topbarLeft, children: left }),
343
414
  /* @__PURE__ */ jsxs(View, { style: navStyles.topbarRight, children: [
344
415
  language ? /* @__PURE__ */ jsx(
345
- TouchableOpacity,
416
+ FocusableTouchable,
346
417
  {
347
418
  accessibilityHint: language.hint,
348
419
  accessibilityLabel: language.label,
349
- accessibilityRole: "button",
420
+ ringColor: primaryColor,
350
421
  style: navStyles.topbarRowItem,
351
422
  onPress: language.onPress,
352
423
  children: /* @__PURE__ */ jsx(Text, { style: [navStyles.topbarLabel, { color: colors.text }], children: language.label })
@@ -359,11 +430,11 @@ var Topbar = ({
359
430
  ] }) : null,
360
431
  richAccount ? /* @__PURE__ */ jsxs(View, { style: navStyles.userBlock, children: [
361
432
  richAccount.onAccount ? /* @__PURE__ */ jsx(
362
- TouchableOpacity,
433
+ FocusableTouchable,
363
434
  {
364
435
  accessibilityHint: richAccount.displayName,
365
436
  accessibilityLabel: richAccount.displayName,
366
- accessibilityRole: "button",
437
+ ringColor: primaryColor,
367
438
  testID: NAV_TEST_IDS.accountName,
368
439
  onPress: richAccount.onAccount,
369
440
  children: /* @__PURE__ */ jsx(Text, { style: [navStyles.userName, { color: colors.text }], children: richAccount.displayName })
@@ -387,11 +458,11 @@ var Topbar = ({
387
458
  ) : null
388
459
  ] }) : null,
389
460
  legacyAccount ? /* @__PURE__ */ jsx(
390
- TouchableOpacity,
461
+ FocusableTouchable,
391
462
  {
392
463
  accessibilityHint: legacyAccount.hint,
393
464
  accessibilityLabel: legacyAccount.label,
394
- accessibilityRole: "button",
465
+ ringColor: primaryColor,
395
466
  style: [navStyles.accountBtn, { backgroundColor: primaryColor }],
396
467
  testID: legacyAccount.testID,
397
468
  onPress: legacyAccount.onPress,
@@ -399,11 +470,11 @@ var Topbar = ({
399
470
  }
400
471
  ) : null,
401
472
  upgrade ? /* @__PURE__ */ jsx(
402
- TouchableOpacity,
473
+ FocusableTouchable,
403
474
  {
404
475
  accessibilityHint: upgrade.hint,
405
476
  accessibilityLabel: upgrade.label,
406
- accessibilityRole: "button",
477
+ ringColor: primaryColor,
407
478
  style: [navStyles.accountBtn, { backgroundColor: primaryColor }],
408
479
  testID: upgrade.testID ?? NAV_TEST_IDS.accountUpgrade,
409
480
  onPress: upgrade.onPress,
@@ -411,12 +482,11 @@ var Topbar = ({
411
482
  }
412
483
  ) : null,
413
484
  /* @__PURE__ */ jsx(
414
- TouchableOpacity,
485
+ FocusableTouchable,
415
486
  {
416
- accessible: true,
417
487
  accessibilityHint: logout.hint,
418
488
  accessibilityLabel: logout.label,
419
- accessibilityRole: "button",
489
+ ringColor: primaryColor,
420
490
  style: [navStyles.accountBtn, { backgroundColor: primaryColor }],
421
491
  testID: logout.testID,
422
492
  onPress: richAccount ? richAccount.onLogout : logout.onPress,
@@ -429,10 +499,75 @@ var Topbar = ({
429
499
  );
430
500
  };
431
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";
432
567
  var DEFAULT_COLLAPSE_BELOW = 760;
433
568
  var MENU_GLYPH = "\u2630";
434
- function activeAriaProps(isActive) {
435
- return isActive ? { "aria-current": "page" } : {};
569
+ function ariaControlsProps(id) {
570
+ return { "aria-controls": id };
436
571
  }
437
572
  var NavBar = ({
438
573
  items,
@@ -452,7 +587,9 @@ var NavBar = ({
452
587
  const colors = theme.colors;
453
588
  const primaryColor = theme.palette.primary["500"];
454
589
  const { width } = useWindowDimensions();
590
+ const reducedMotion = useReducedMotion();
455
591
  const [open, setOpen] = useState(false);
592
+ const [toggleFocused, setToggleFocused] = useState(false);
456
593
  const collapsed = width < collapseBelow;
457
594
  const showLinks = !collapsed || open;
458
595
  const toggleMenu = useCallback(() => setOpen((v) => !v), []);
@@ -463,33 +600,16 @@ var NavBar = ({
463
600
  },
464
601
  [onNavigate]
465
602
  );
466
- const activeLinkStyle = useMemo(
467
- () => ({ backgroundColor: primaryColor, borderRadius: ACTIVE_BORDER_RADIUS }),
468
- [primaryColor]
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]
469
612
  );
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
613
  return /* @__PURE__ */ jsx(
494
614
  View,
495
615
  {
@@ -504,21 +624,44 @@ var NavBar = ({
504
624
  children: /* @__PURE__ */ jsxs(View, { style: navBarStyles.inner, children: [
505
625
  brand !== void 0 ? /* @__PURE__ */ jsx(View, { style: navBarStyles.brand, children: brand }) : null,
506
626
  collapsed ? /* @__PURE__ */ jsx(
507
- TouchableOpacity,
627
+ Pressable,
508
628
  {
509
- accessible: true,
510
629
  accessibilityHint: menuHint,
511
630
  accessibilityLabel: menuLabel,
512
631
  accessibilityRole: "button",
513
632
  accessibilityState: { expanded: open },
514
633
  "aria-expanded": open,
515
- style: navBarStyles.toggle,
634
+ style: [navBarStyles.toggle, focusRingStyle(toggleFocused, primaryColor)],
516
635
  testID: NAV_TEST_IDS.navBarToggle,
636
+ onBlur: () => setToggleFocused(false),
637
+ onFocus: () => setToggleFocused(true),
517
638
  onPress: toggleMenu,
639
+ ...ariaControlsProps(LINKS_REGION_ID),
518
640
  children: typeof renderMenuIcon === "function" ? renderMenuIcon(colors.text, open) : /* @__PURE__ */ jsx(Text, { style: [navBarStyles.toggleGlyph, { color: colors.text }], children: MENU_GLYPH })
519
641
  }
520
642
  ) : null,
521
- showLinks ? /* @__PURE__ */ jsx(View, { style: collapsed ? navBarStyles.linksStacked : navBarStyles.links, testID: NAV_TEST_IDS.navBarLinks, children: items.map(renderLink) }) : 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,
522
665
  showLinks && right !== void 0 ? /* @__PURE__ */ jsx(View, { style: collapsed ? navBarStyles.rightStacked : navBarStyles.right, children: right }) : null
523
666
  ] })
524
667
  }