@helloimjolopez-pathway/pathway-components 0.7.1 → 0.7.2

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.
@@ -26,6 +26,17 @@
26
26
  import React, { useState, useRef, useEffect } from "react";
27
27
  import { Scrollable } from "../scrollbar/scrollbar.jsx";
28
28
  import ReactDOM from "react-dom";
29
+ import { BottomSheet } from "../bottom-sheet/bottom-sheet.jsx";
30
+ import { FOCUS_RING, FOCUS_RING_INSET, FOCUS_RING_REPLACE, injectFocusRing } from "../_shared/focus-ring.js";
31
+
32
+ // Anything collapsed out of sight is also out of reach. A closed accordion,
33
+ // the list section on the rail and the header slot on the rail stay mounted
34
+ // so they can animate, so they are made inert: otherwise Tab lands on a row
35
+ // nobody can see. React 18 has no `inert` prop, so it goes on as the plain
36
+ // attribute.
37
+ const inertIf = (hidden) => (hidden ? { inert: "", "aria-hidden": true } : {});
38
+
39
+ injectFocusRing();
29
40
 
30
41
  /*
31
42
  * ─── DESIGN TOKENS ───────────────────────────────────────────────────────────
@@ -252,7 +263,7 @@ export function SideNavItem({
252
263
 
253
264
  return (
254
265
  <div ref={itemRef} style={{ position: "relative" }}>
255
- <div role="button" tabIndex={isDisabled ? -1 : 0}
266
+ <div className={FOCUS_RING} role="button" tabIndex={isDisabled ? -1 : 0}
256
267
  aria-current={isActive ? "page" : undefined}
257
268
  aria-disabled={isDisabled ? true : undefined}
258
269
  aria-expanded={hasChildren && !isSidebarCollapsed ? isExpanded : undefined}
@@ -500,7 +511,7 @@ export function PopoverRow({ item, onClick, activeId }) {
500
511
  const color = T.text.strong;
501
512
  return (
502
513
  <div onClick={() => onClick(item.id)}
503
- role="menuitem" tabIndex={0}
514
+ className={FOCUS_RING_INSET} role="menuitem" tabIndex={0}
504
515
  aria-current={isActive ? "page" : undefined}
505
516
  onKeyDown={e => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), onClick(item.id))}
506
517
  onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
@@ -515,10 +526,10 @@ export function PopoverRow({ item, onClick, activeId }) {
515
526
  <div style={{ position: "absolute", left: 0, top: "var(--semantic-layout-units-padding-xtight)",
516
527
  bottom: "var(--semantic-layout-units-padding-xtight)", width: L.stripeW,
517
528
  borderRadius: `0 ${L.stripeRadius} ${L.stripeRadius} 0`,
518
- // PopoverMenu.Item's Stripe binds Fill/Action/Primary/Strong/Pressed in
519
- // Figma (40018914:3149), not the nav's Selection/Indicator. Same value
520
- // in Light; in Midnight they differ, so this follows Figma.
521
- backgroundColor: isActive ? c("fill-action-primary-strong-pressed") : "transparent",
529
+ // The same Fill/Action/Selection/Indicator as the nav's active stripe, so
530
+ // a selected menu row and the active nav item always match (PopoverMenu.Item
531
+ // in Figma was rebound to it on 2026-11-11).
532
+ backgroundColor: isActive ? T.indicator : "transparent",
522
533
  transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
523
534
  {/* Label/Menu/Base/Medium: 14 / 500 / 20 / 0.3 (the repo's `spacious`). */}
524
535
  <span style={{ fontFamily: "'Red Hat Text',sans-serif",
@@ -547,10 +558,10 @@ const eventInside = (el, e) =>
547
558
  // Anatomy, read from the variants on 2026-11-06:
548
559
  // wrapper padding XTight top/bottom, XXXXTight sides (expanded); min 44
549
560
  // Container.Main 44 tall, radius CornerRadius/Base, padding XXTight sides,
550
- // gap 2, Button/BorderWidth stroke, Widget shadow
561
+ // gap 2, Button/BorderWidth stroke, Elevation/Lift shadow
551
562
  // Action Icon 36 (padding XTight) > 24 box > `apps` at 16
552
563
  // Label Label/Button/Small: 14 / 500 / 20 / 0.3, truncates
553
- // RowEnd 20 box > `chevron_right` at 14 (expanded only)
564
+ // (no trailing chevron: the trigger is the apps icon and the name)
554
565
  // PopoverMenu 2px to the right of the wrapper, top aligned: 220 to 360
555
566
  // wide, CornerRadius/Large, 0.5 Stroke/Static/Neutral/Faint,
556
567
  // Fill/Surface/Elevated, Elevation/Lift; items padding XTight /
@@ -561,16 +572,14 @@ const eventInside = (el, e) =>
561
572
  // Rest none / Primary/Subtle/Rest 0.75 / Static/Neutral/Bold
562
573
  // Hover Primary/Subtle/Hover / Primary/Strong/Hover / On Subtle/Hover
563
574
  // Pressed-Open Primary/Subtle/Pressed / Primary/Strong/Pressed 1px / On Subtle/Pressed
564
- // The chevron is Action/Secondary/Rest at rest and follows the label otherwise.
565
575
  //
566
576
  // Rail: Figma draws a 60 square that overhangs the 48 column by 6 a side. The
567
577
  // overhang is transparent padding only, and the scroll view clips horizontally,
568
578
  // so here the wrapper keeps the 8px vertical padding and the 48x44 Main sits
569
579
  // exactly where Figma puts it. The target is 48x60, above the AA 44.
570
580
  //
571
- // The Figma shadow colours the Widget geometry with Brand/600 @ 16%, which has
572
- // no semantic token; --elevation-widget is the same offsets on the system
573
- // shadow colour.
581
+ // The trigger casts Elevation/Lift (--elevation-lift), the same shadow as its
582
+ // menu. It never uses Fill/Shadow/Dropshadow.
574
583
  const APP_T = {
575
584
  restStroke: c("stroke-action-primary-subtle-rest"),
576
585
  hoverFill: c("fill-action-primary-subtle-hover"),
@@ -580,11 +589,13 @@ const APP_T = {
580
589
  openStroke: c("stroke-action-primary-strong-pressed"),
581
590
  openFg: c("foreground-action-primary-on-subtle-pressed"),
582
591
  restFg: c("foreground-static-neutral-bold"),
583
- restChevron: c("foreground-action-secondary-rest"),
584
592
  separator: c("stroke-static-neutral-base"),
585
593
  };
586
594
 
587
- export function AppSwitcher({ label: fallbackLabel, icon = "apps", home, apps = [], activeId, onSelect, collapsed = false }) {
595
+ // presentation "sheet" (mobile) opens the menu in a BottomSheet titled
596
+ // `sheetTitle` instead of the popover (Figma 40016737:141824).
597
+ export function AppSwitcher({ label: fallbackLabel, icon = "apps", home, apps = [], activeId, onSelect, collapsed = false,
598
+ presentation = "popover", sheetTitle }) {
588
599
  // The trigger names WHERE YOU ARE: the current app's label, so after choosing
589
600
  // Accounts Payable it reads Accounts Payable. `label` is only the fallback
590
601
  // for when no app is current.
@@ -598,16 +609,43 @@ export function AppSwitcher({ label: fallbackLabel, icon = "apps", home, apps =
598
609
  const btnRef = useRef(null);
599
610
  const menuRef = useRef(null);
600
611
 
612
+ const viaKey = useRef(false);
613
+
601
614
  const toggle = () => {
602
615
  if (open) { setOpen(false); return; }
603
616
  setRect(wrapRef.current ? wrapRef.current.getBoundingClientRect() : null);
604
617
  setOpen(true);
605
618
  };
619
+ // Opened from the keyboard, focus lands on the current application's row
620
+ // (the WAI-ARIA menu button pattern). The sheet places focus itself.
621
+ useEffect(() => {
622
+ if (!open || presentation === "sheet" || !viaKey.current) return;
623
+ viaKey.current = false;
624
+ const t = setTimeout(() => {
625
+ const rows = menuRef.current ? [...menuRef.current.querySelectorAll('[role="menuitem"]')] : [];
626
+ (rows.find(r => r.getAttribute("aria-current")) || rows[0])?.focus();
627
+ }, 0);
628
+ return () => clearTimeout(t);
629
+ }, [open, presentation]);
630
+ // Up, Down, Home and End move between rows, wrapping at the ends.
631
+ const onMenuKey = (e) => {
632
+ const rows = [...e.currentTarget.querySelectorAll('[role="menuitem"]')];
633
+ const here = rows.indexOf(document.activeElement);
634
+ let next = null;
635
+ if (e.key === "ArrowDown") next = (here + 1) % rows.length;
636
+ else if (e.key === "ArrowUp") next = (here - 1 + rows.length) % rows.length;
637
+ else if (e.key === "Home") next = 0;
638
+ else if (e.key === "End") next = rows.length - 1;
639
+ if (next === null || !rows.length) return;
640
+ e.preventDefault();
641
+ rows[next].focus();
642
+ };
606
643
  const choose = (id) => { setOpen(false); onSelect && onSelect(id); btnRef.current && btnRef.current.focus(); };
607
644
 
608
645
  // Close on a click outside either box, and on Escape (focus returns to the trigger).
646
+ // The BottomSheet owns both for itself, and a press inside it is not outside.
609
647
  useEffect(() => {
610
- if (!open) return;
648
+ if (!open || presentation === "sheet") return;
611
649
  const down = (e) => {
612
650
  if (eventInside(wrapRef.current, e)) return;
613
651
  if (eventInside(menuRef.current, e)) return;
@@ -617,11 +655,10 @@ export function AppSwitcher({ label: fallbackLabel, icon = "apps", home, apps =
617
655
  document.addEventListener("mousedown", down);
618
656
  document.addEventListener("keydown", key);
619
657
  return () => { document.removeEventListener("mousedown", down); document.removeEventListener("keydown", key); };
620
- }, [open]);
658
+ }, [open, presentation]);
621
659
 
622
660
  const active = open || press;
623
661
  const fg = active ? APP_T.openFg : hov ? APP_T.hoverFg : APP_T.restFg;
624
- const chevron = active ? APP_T.openFg : hov ? APP_T.hoverFg : APP_T.restChevron;
625
662
  const homeItem = home || { id: "__home", label: fallbackLabel };
626
663
 
627
664
  return (
@@ -632,7 +669,9 @@ export function AppSwitcher({ label: fallbackLabel, icon = "apps", home, apps =
632
669
  padding: collapsed
633
670
  ? `${L.textPad} 0`
634
671
  : `${L.textPad} ${L.chevPad}` }}>
635
- <button ref={btnRef} type="button" onClick={toggle}
672
+ <button ref={btnRef} className={FOCUS_RING_REPLACE} type="button" onClick={toggle}
673
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") viaKey.current = true;
674
+ if (e.key === "ArrowDown" && !open) { e.preventDefault(); toggle(); } }}
636
675
  aria-haspopup="menu" aria-expanded={open}
637
676
  aria-label={collapsed ? `Switch application: ${label}` : undefined}
638
677
  onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
@@ -646,7 +685,8 @@ export function AppSwitcher({ label: fallbackLabel, icon = "apps", home, apps =
646
685
  background: active ? APP_T.openFill : hov ? APP_T.hoverFill : "transparent",
647
686
  // Drawn inside, as Figma strokes, so 0.75 and 1px do not move the content.
648
687
  border: "none",
649
- boxShadow: `inset 0 0 0 ${active ? "var(--contextual-layout-units-button-borderwidth-selected, 1px)" : "var(--contextual-layout-units-button-borderwidth-rest, 0.75px)"} ${active ? APP_T.openStroke : hov ? APP_T.hoverStroke : APP_T.restStroke}, var(--elevation-widget)`,
688
+ "--pw-ring-keep-shadow": "var(--elevation-lift)",
689
+ boxShadow: `inset 0 0 0 ${active ? "var(--contextual-layout-units-button-borderwidth-selected, 1px)" : "var(--contextual-layout-units-button-borderwidth-rest, 0.75px)"} ${active ? APP_T.openStroke : hov ? APP_T.hoverStroke : APP_T.restStroke}, var(--elevation-lift)`,
650
690
  transition: "background-color var(--motion-duration-3) var(--motion-easing-standard), box-shadow var(--motion-duration-3) var(--motion-easing-standard)" }}>
651
691
  {/* Action Icon: 36 box, 24 wrapper, the glyph at its 16 frame */}
652
692
  <span style={{ width: 36, height: 36, display: "flex", alignItems: "center",
@@ -664,18 +704,23 @@ export function AppSwitcher({ label: fallbackLabel, icon = "apps", home, apps =
664
704
  color: fg, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
665
705
  transition: "color var(--motion-duration-3) var(--motion-easing-standard)" }}>{label}</span>
666
706
  )}
667
- {!collapsed && (
668
- <span style={{ width: 20, height: 20, display: "flex", alignItems: "center",
669
- justifyContent: "center", flexShrink: 0 }}>
670
- <span className="material-symbols-rounded" aria-hidden="true"
671
- style={{ fontSize: 14, lineHeight: 1, display: "block", color: chevron,
672
- fontVariationSettings: "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24" }}>chevron_right</span>
673
- </span>
674
- )}
675
707
  </button>
676
708
 
677
- {open && rect && ReactDOM.createPortal(
678
- <div ref={menuRef} role="menu" aria-label={label}
709
+ {presentation === "sheet" && (
710
+ <BottomSheet open={open} title={sheetTitle || label} menu returnFocusRef={btnRef}
711
+ onOpenChange={(next) => { if (!next) setOpen(false); }}>
712
+ <PopoverRow item={homeItem} onClick={choose} activeId={activeId} />
713
+ {apps.length > 0 && (
714
+ <div role="separator" style={{ padding: "var(--semantic-layout-units-padding-xtight)" }}>
715
+ <div style={{ borderTop: `0.5px solid ${APP_T.separator}` }} />
716
+ </div>
717
+ )}
718
+ {apps.map(a => <PopoverRow key={a.id} item={a} onClick={choose} activeId={activeId} />)}
719
+ </BottomSheet>
720
+ )}
721
+
722
+ {open && presentation !== "sheet" && rect && ReactDOM.createPortal(
723
+ <div ref={menuRef} role="menu" aria-label={label} onKeyDown={onMenuKey}
679
724
  style={{ position: "fixed", left: `calc(${rect.right}px + var(--semantic-layout-units-gap-xxxtight))`,
680
725
  top: rect.top, zIndex: 1000, boxSizing: "border-box",
681
726
  minWidth: 220, maxWidth: 360,
@@ -715,7 +760,7 @@ export function CollapseButton({ isSidebarCollapsed, onToggle, collapseIcon, exp
715
760
  <div style={{ height: 1, backgroundColor: T.fill.infoSubtle, marginBottom: L.collapseGap }} />
716
761
  <div onClick={onToggle} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
717
762
  aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
718
- role="button" tabIndex={0}
763
+ className={FOCUS_RING} role="button" tabIndex={0}
719
764
  onKeyDown={e => (e.key === "Enter" || e.key === " ") && onToggle()}
720
765
  style={{ display: "flex", alignItems: "center", minHeight: L.itemH, width: "100%",
721
766
  borderRadius: T.radius, backgroundColor: h ? T.fill.navHover : T.fill.navBase,
@@ -768,21 +813,30 @@ export function CollapseButton({ isSidebarCollapsed, onToggle, collapseIcon, exp
768
813
  // in Slot.RowEnd (expanded) or centered (collapsed). Divider below.
769
814
  // Matches Figma "Slot.NavHeader" (node 40007331:7794).
770
815
  // Hidden on mobile (<768px), TopNav hamburger is the sole toggle there.
771
- export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIcon }) {
816
+ // `headerStart` is Figma's Slot.RowStart: the module mark sits at the start of
817
+ // the row and the collapse control at the end. Only the 44 control is a
818
+ // button, so the mark is never a hit area. `hideToggle` keeps the row (and its
819
+ // mark) where the shell has no collapse control, as on the mobile overlay.
820
+ export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIcon, headerStart = null, hideToggle = false }) {
772
821
  const [h, setH] = useState(false);
773
822
  const actionIconColor = T.icon.actionSecondary; // var(--semantic-color-stroke-action-field-hover), always static
823
+ const start = !isSidebarCollapsed && headerStart;
774
824
  return (
775
825
  <div style={{ width: "100%", flexShrink: 0 }}>
826
+ <div style={{ display: "flex", alignItems: "center", height: L.itemH, width: "100%",
827
+ justifyContent: start ? "space-between" : (isSidebarCollapsed ? "center" : "flex-end"),
828
+ gap: "var(--semantic-layout-units-gap-xxtight)" }}>
829
+ {start && <div style={{ display: "flex", alignItems: "center", minWidth: 0, flex: 1 }}>{headerStart}</div>}
830
+ {!hideToggle && (
776
831
  <div onClick={onToggle} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
777
832
  aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
778
- role="button" tabIndex={0}
833
+ className={FOCUS_RING} role="button" tabIndex={0}
779
834
  onKeyDown={e => (e.key === "Enter" || e.key === " ") && onToggle()}
780
835
  // Figma SideBar Expand/Collapse: Container.Main 44 tall, the Action.Icon
781
836
  // is a 44 touch target holding a 28px Container.Icon (radius Unit/10),
782
837
  // which is what takes the hover fill. The whole row used to fill, and
783
838
  // the icon box was 36. The glyphs themselves stay pinned (see below).
784
- style={{ display: "flex", alignItems: "center", height: L.itemH, width: "100%",
785
- justifyContent: isSidebarCollapsed ? "center" : "flex-end", cursor: "pointer" }}>
839
+ style={{ display: "flex", alignItems: "center", height: L.itemH, flexShrink: 0, cursor: "pointer" }}>
786
840
  <div style={{ width: L.itemH, height: L.itemH, display: "flex",
787
841
  alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
788
842
  <div style={{ width: 28, height: 28, display: "flex", alignItems: "center",
@@ -799,6 +853,8 @@ export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIc
799
853
  </div>
800
854
  </div>
801
855
  </div>
856
+ )}
857
+ </div>
802
858
  {/* Divider, padding 4 vertical in Figma (was 2) */}
803
859
  <div style={{ padding: "var(--semantic-layout-units-padding-xxxtight) 0" }}>
804
860
  <div style={{ height: 1, backgroundColor: T.fill.infoSubtle }} />
@@ -847,7 +903,7 @@ export function ListItem({ item, isActive, onClick }) {
847
903
  return (
848
904
  <div onClick={() => onClick && onClick(item.id)}
849
905
  onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
850
- role="button" tabIndex={0}
906
+ className={FOCUS_RING} role="button" tabIndex={0}
851
907
  onKeyDown={e => (e.key === "Enter" || e.key === " ") && onClick && onClick(item.id)}
852
908
  // SideNavItem.List is a fixed 48 tall (was min 44).
853
909
  style={{ display: "flex", alignItems: "center", height: 48, minHeight: L.itemH, width: "100%",
@@ -937,6 +993,10 @@ export function SideNav({
937
993
  // { label, icon?, home?: {id,label}, apps: [{id,label}], activeId, onSelect }
938
994
  // Renders as the first entry of the scrolling menu, in both rail states.
939
995
  appSwitcher = null,
996
+ // Figma Slot.RowStart in the header: content at the start of the header row,
997
+ // beside the collapse control (a module shell puts its Module.Mark here).
998
+ // Expanded only; the rail has room for the control alone.
999
+ headerStart = null,
940
1000
  }) {
941
1001
  const [expanded, setExpanded] = useState(defaultExpanded);
942
1002
  const [popoverId, setPopoverId] = useState(null);
@@ -994,7 +1054,7 @@ export function SideNav({
994
1054
  --motion-duration-5 + --motion-easing-accordion, with inner wrapper opacity
995
1055
  fade --motion-duration-4 (70ms delay on expand, no delay on collapse). */}
996
1056
  {hasChildren && !collapsed && (
997
- <div style={{
1057
+ <div {...inertIf(!isExp)} style={{
998
1058
  display: "grid",
999
1059
  gridTemplateRows: isExp ? "1fr" : "0fr",
1000
1060
  marginTop: isExp ? L.menuGap : 0,
@@ -1037,10 +1097,11 @@ export function SideNav({
1037
1097
  }}>
1038
1098
  {/* NavHeader, TOP of nav, PINNED (flexShrink:0) so it stays visible while the
1039
1099
  item list scrolls beneath it. Hidden on mobile (<768px). */}
1040
- {(!hideCollapseButton || headerEnd) && (
1100
+ {(!hideCollapseButton || headerEnd || headerStart) && (
1041
1101
  <div style={{ flexShrink: 0 }}>
1042
- {!hideCollapseButton && (
1043
- <NavHeader isSidebarCollapsed={collapsed}
1102
+ {(!hideCollapseButton || headerStart) && (
1103
+ <NavHeader isSidebarCollapsed={collapsed} headerStart={headerStart}
1104
+ hideToggle={hideCollapseButton}
1044
1105
  onToggle={() => onCollapseChange && onCollapseChange(!collapsed)} />
1045
1106
  )}
1046
1107
  {/* headerEnd slot. Hidden on the 72px rail: the rail is only wide
@@ -1049,7 +1110,7 @@ export function SideNav({
1049
1110
  unmounting, on the same curve as the section labels, so the
1050
1111
  header does not jump when the rail toggles. */}
1051
1112
  {headerEnd && (
1052
- <div style={{
1113
+ <div {...inertIf(collapsed)} style={{
1053
1114
  opacity: collapsed ? 0 : 1,
1054
1115
  maxHeight: collapsed ? 0 : 200,
1055
1116
  overflow: "hidden",
@@ -1116,7 +1177,7 @@ export function SideNav({
1116
1177
 
1117
1178
  {/* SideNavListSection, optional. Hidden in collapsed rail. */}
1118
1179
  {listSection && (
1119
- <div style={{ opacity: collapsed ? 0 : 1,
1180
+ <div {...inertIf(collapsed)} style={{ opacity: collapsed ? 0 : 1,
1120
1181
  maxHeight: collapsed ? 0 : 400, overflow: "hidden",
1121
1182
  transition: "opacity var(--motion-duration-4) var(--motion-easing-standard), max-height var(--motion-duration-5) var(--motion-easing-spring)" }}>
1122
1183
  <SideNavListSection label={listSection.label} items={listSection.items}
@@ -75,7 +75,7 @@ export const T = {
75
75
  // option's own radius, and the offset shows the surface behind, so there is
76
76
  // no white halo on a dark page.
77
77
  focusOutline: `${CL("focusring-width")} solid ${SC("stroke-focusring-base")}`,
78
- focusOffset: CL("focusring-offset"),
78
+ focusOffset: "0",
79
79
  colour: `${M("duration-2")} ${M("easing-standard")}`,
80
80
  shape: `${M("duration-4")} ${M("easing-spring-soft")}`,
81
81
  };
@@ -167,7 +167,8 @@ export const ToggleButtonOption = forwardRef(function ToggleButtonOption({
167
167
  background: look.fill[state] || look.fill.rest,
168
168
  color: look.fg[state] || look.fg.rest,
169
169
  // Inset so the outline never adds width: selecting must not move the row.
170
- boxShadow: look.stroke ? `inset 0 0 0 ${T.border} ${look.stroke[state] || look.stroke.rest}` : "none",
170
+ // In Figma the Focused ring takes the outlined option's border's place.
171
+ boxShadow: look.stroke && !ring ? `inset 0 0 0 ${T.border} ${look.stroke[state] || look.stroke.rest}` : "none",
171
172
  outline: ring ? T.focusOutline : "none",
172
173
  outlineOffset: ring ? T.focusOffset : undefined,
173
174
  fontFamily: sz.fontFamily, fontSize: sz.fontSize, fontWeight: sz.fontWeight,
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import React, { useState, useEffect, useRef } from "react";
13
+ import { FOCUS_RING, FOCUS_RING_INSET, FOCUS_RING_LAYER, FOCUS_RING_REPLACE, injectFocusRing } from "../_shared/focus-ring.js";
13
14
  import { ActionIcon } from "../icon/action-icon.jsx";
14
15
  import { Icon } from "../icon/icon.jsx";
15
16
 
@@ -22,6 +23,8 @@ import { TopNavSearch, SearchInput } from "../search/search.jsx";
22
23
  import { OrgSwitcherPanel, DEMO_ORGS } from "../org-switcher/org-switcher.jsx";
23
24
  import { ModuleIcon, MODULE_LABELS, MODULES } from "../module-icon/module-icon.jsx";
24
25
 
26
+ injectFocusRing();
27
+
25
28
  // ─── DESIGN TOKENS ─────────────────────────────────────────────────────────────
26
29
  // Bound to live semantic CSS variables (CLAUDE.md §6). The TopNav bar is a dark
27
30
  // brand-blue surface, so every CONTROL on it resolves through the DARK-MODE token
@@ -377,13 +380,13 @@ function TopNavActionIcon({ icon, label, onClick }) {
377
380
  // so the clickable box collapsed to the 28 layer.
378
381
  <div style={{ display: "flex", alignItems: "center", justifyContent: "center",
379
382
  width: L.actionTarget, height: L.actionTarget, flexShrink: 0 }}>
380
- <button type="button" aria-label={label} onClick={onClick}
383
+ <button className={FOCUS_RING_LAYER} type="button" aria-label={label} onClick={onClick}
381
384
  onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
382
385
  onMouseDown={() => setPress(true)} onMouseUp={() => setPress(false)} onBlur={() => setPress(false)}
383
386
  style={{ display: "flex", alignItems: "center", justifyContent: "center",
384
387
  width: "100%", height: "100%", padding: 0,
385
388
  background: "transparent", border: "none", cursor: "pointer" }}>
386
- <span style={{ display: "flex", alignItems: "center", justifyContent: "center",
389
+ <span data-pw-ring-target="" style={{ display: "flex", alignItems: "center", justifyContent: "center",
387
390
  width: L.actionIconBox, height: L.actionIconBox, borderRadius: L.radius, background: fill,
388
391
  transition: "background var(--motion-duration-2) var(--motion-easing-standard)" }}>
389
392
  <Icon name={icon} size={L.actionIconGlyph}
@@ -427,7 +430,7 @@ export function TopNavProfile({ user, open, onToggle, mobile = false }) {
427
430
  // Padding/XXXXTight around the 48 button, so the profile took 52.
428
431
  <div style={{ position: "relative", display: "flex", alignItems: "center",
429
432
  justifyContent: "center", minHeight: L.touchTarget, minWidth: L.touchTarget }}>
430
- <button
433
+ <button className={FOCUS_RING_LAYER}
431
434
  onClick={onToggle}
432
435
  onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
433
436
  aria-haspopup="true" aria-expanded={open}
@@ -445,7 +448,7 @@ export function TopNavProfile({ user, open, onToggle, mobile = false }) {
445
448
  transition: "background var(--motion-duration-2) var(--motion-easing-standard)",
446
449
  }}
447
450
  >
448
- <div style={{
451
+ <div data-pw-ring-target="" style={{
449
452
  width: L.avatarSize, height: L.avatarSize, borderRadius: "50%",
450
453
  background: open ? T.avatarBgPressed : hov ? T.avatarBgHover : T.avatarBg,
451
454
  display: "flex", alignItems: "center", justifyContent: "center",
@@ -506,7 +509,7 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
506
509
  // The 48 slot: Padding/XXTight all round on desktop, XXTight by XXXTight on mobile.
507
510
  <div style={{ position: "relative", minWidth: 0,
508
511
  padding: mobile ? `${L.slotPadV} ${L.slotPadNarrow}` : L.slotPadV }}>
509
- <button
512
+ <button className={FOCUS_RING_REPLACE}
510
513
  onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
511
514
  onClick={onToggle}
512
515
  aria-haspopup="true" aria-expanded={open}
@@ -665,7 +668,7 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
665
668
  return (
666
669
  // The 48 slot, Padding/XXTight by Padding/XXXTight. This was a raw 4px 2px.
667
670
  <div style={{ position: "relative", padding: `${L.slotPadV} ${L.slotPadNarrow}` }}>
668
- <button
671
+ <button className={FOCUS_RING}
669
672
  onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
670
673
  onMouseDown={() => setPress(true)} onMouseUp={() => setPress(false)} onBlur={() => setPress(false)}
671
674
  onClick={onToggle}
@@ -727,7 +730,7 @@ function TopNavProfileMenu({ user }) {
727
730
  <div style={{ fontSize: "var(--semantic-type-font-size-xxs)", color: T.itemMeta, marginTop: 1 }}>{user.email}</div>
728
731
  </div>
729
732
  {["Profile settings", "Settings"].map(label => (
730
- <button key={label} role="menuitem"
733
+ <button className={FOCUS_RING_INSET} key={label} role="menuitem"
731
734
  style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "",
732
735
  borderRadius: "var(--semantic-layout-units-cornerradius-base)", fontFamily: "inherit", fontSize: "var(--semantic-type-font-size-s)",
733
736
  color: T.itemTextBase, background: "transparent",
@@ -735,7 +738,7 @@ function TopNavProfileMenu({ user }) {
735
738
  {label}
736
739
  </button>
737
740
  ))}
738
- <button role="menuitem"
741
+ <button className={FOCUS_RING_INSET} role="menuitem"
739
742
  style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "",
740
743
  borderRadius: "var(--semantic-layout-units-cornerradius-base)", fontFamily: "inherit", fontSize: "var(--semantic-type-font-size-s)",
741
744
  color: T.signOut, background: "transparent",
@@ -827,7 +830,7 @@ function PillarItem({ label, active, brandItem, tablet, onClick }) {
827
830
  const fg = active || press ? T.monoPressed : hov ? T.monoHover : T.monoBase;
828
831
  const type = brandItem ? (active ? NC_TYPE.sectionSm : NC_TYPE.menuLarge) : (active ? NC_TYPE.localBase : NC_TYPE.menuBase);
829
832
  return (
830
- <button type="button" aria-current={active ? "page" : undefined} onClick={onClick} {...handlers}
833
+ <button className={FOCUS_RING} type="button" aria-current={active ? "page" : undefined} onClick={onClick} {...handlers}
831
834
  style={{ display: "flex", alignItems: "center", minWidth: L.actionTarget, minHeight: L.actionTarget,
832
835
  padding: 0, border: "none", background: "transparent", cursor: "pointer", fontFamily: "inherit" }}>
833
836
  <span style={{ display: "flex", alignItems: "center", height: L.modInnerH, boxSizing: "border-box",
@@ -853,7 +856,7 @@ function NewCoOrgSwitcher({ org, open, onToggle, compact }) {
853
856
  const fill = open || press ? T.controlPressed : hov ? T.controlHover : "transparent";
854
857
  return (
855
858
  <div style={{ position: "relative", padding: L.slotPadV, minWidth: 0 }}>
856
- <button type="button" onClick={onToggle} {...handlers}
859
+ <button className={FOCUS_RING} type="button" onClick={onToggle} {...handlers}
857
860
  aria-haspopup="true" aria-expanded={open}
858
861
  aria-label={`Switch organisation: ${org.name}${org.campus ? ", " + org.campus : ""}`}
859
862
  style={{ display: "flex", alignItems: "center", boxSizing: "border-box", minHeight: L.modInnerH, minWidth: 0,
@@ -884,7 +887,7 @@ function PillarTrigger({ label, open, onToggle }) {
884
887
  const fill = open || press ? T.controlPressed : hov ? T.controlHover : "transparent";
885
888
  return (
886
889
  <div style={{ padding: `${L.slotPadV} ${L.slotPadNarrow}` }}>
887
- <button type="button" onClick={onToggle} aria-haspopup="true" aria-expanded={open} {...handlers}
890
+ <button className={FOCUS_RING} type="button" onClick={onToggle} aria-haspopup="true" aria-expanded={open} {...handlers}
888
891
  style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxxtight)", height: L.modInnerH,
889
892
  boxSizing: "border-box", padding: `0 10px 0 ${u("padding-medium")}`, /* pathway-allow: Figma Pillar.Trigger right padding 10 has no token (gap logged in the spec) */
890
893
  borderRadius: u("cornerradius-full"), border: "none", background: fill, cursor: "pointer", fontFamily: "inherit",
@@ -902,7 +905,7 @@ function PillarTrigger({ label, open, onToggle }) {
902
905
  function NewCoAction({ icon, label, badge, onClick }) {
903
906
  const { hov, press, handlers } = usePress();
904
907
  return (
905
- <button type="button" aria-label={label} onClick={onClick} {...handlers}
908
+ <button className={FOCUS_RING} type="button" aria-label={label} onClick={onClick} {...handlers}
906
909
  style={{ position: "relative", display: "flex", alignItems: "center", justifyContent: "center",
907
910
  width: L.actionTarget, height: L.actionTarget, padding: 0, border: "none", cursor: "pointer",
908
911
  borderRadius: L.radius, background: press ? T.controlPressed : hov ? T.controlHover : "transparent",
@@ -1204,7 +1207,7 @@ export function TopNav({
1204
1207
  >
1205
1208
  {modules.map(m => (
1206
1209
  <li key={m.id} role="option" aria-selected={m.id === currentModuleId}>
1207
- <button
1210
+ <button className={FOCUS_RING_INSET}
1208
1211
  onClick={() => handleModuleSelect(m.id)}
1209
1212
  style={{
1210
1213
  display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-medium)",
@@ -1306,7 +1309,7 @@ export function TopNav({
1306
1309
  >
1307
1310
  {/* Exit, to the LEFT and outside the field, so it can never be read
1308
1311
  as the field's own clear control */}
1309
- <button
1312
+ <button className={FOCUS_RING}
1310
1313
  type="button"
1311
1314
  aria-label="Back"
1312
1315
  onClick={() => setSearchExpanded(false)}