@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.
- package/components/badge/badge.jsx +22 -18
- package/components/bottom-sheet/bottom-sheet.jsx +2 -4
- package/components/button/button.jsx +1 -1
- package/components/checkbox/checkbox.jsx +13 -3
- package/components/icon/action-icon.jsx +4 -2
- package/components/module-icon/module-icon.jsx +25 -0
- package/components/nav-shell/nav-shell.jsx +8 -28
- package/components/org-switcher/org-switcher.jsx +2 -2
- package/components/radio/radio.jsx +2 -2
- package/components/scroll-to-enable/scroll-to-enable.jsx +1 -1
- package/components/search/search.jsx +4 -0
- package/components/sidenav/accounting-side-nav.jsx +113 -0
- package/components/sidenav/sidenav.jsx +102 -41
- package/components/toggle-button/toggle-button.jsx +3 -2
- package/components/top-nav/top-nav.jsx +17 -14
- package/dist/pathway-accounting-sidenav.js +139 -0
- package/dist/pathway-components.js +45 -38
- package/dist/pathway-sidenav.js +16 -9
- package/package.json +4 -2
|
@@ -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
|
-
//
|
|
519
|
-
//
|
|
520
|
-
// in
|
|
521
|
-
backgroundColor: isActive ?
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
572
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
678
|
-
<
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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)}
|