@helloimjolopez-pathway/pathway-tokens 5.3.0 → 6.0.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.
@@ -0,0 +1,795 @@
1
+ /**
2
+ * SideNav — Pathway Design System
3
+ *
4
+ * Importable React component module. Source of truth for the SideNav
5
+ * implementation; the standalone demo (sidenav.html) and the Storybook
6
+ * stories both consume this.
7
+ *
8
+ * EXEMPT FROM FIGMA OVERRIDES (2026-09-07). Token sync and the component
9
+ * pipeline pull from Figma normally for everything else, but this component is
10
+ * NOT re-synced unless the user explicitly asks for it in that session. A
11
+ * reconciliation run will report drift here against Figma and that report is
12
+ * accurate; the correct action is still to leave this file alone, name the
13
+ * drift, and wait. Do not resolve it by fetching Figma. See CLAUDE.md 1.1.
14
+ *
15
+ * Spec: components/sidenav/sidenav-spec.md
16
+ * Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40003951-2927
17
+ */
18
+
19
+ import React, { useState, useRef } from "react";
20
+ import { Scrollable } from "../scrollbar/scrollbar.jsx";
21
+ import ReactDOM from "react-dom";
22
+
23
+ /*
24
+ * ─── DESIGN TOKENS ───────────────────────────────────────────────────────────
25
+ *
26
+ * Every value below is a `var()` reference to a real custom property, never a
27
+ * resolved hex. This is deliberate and it is a behavioural fix, not a style
28
+ * preference:
29
+ *
30
+ * This module previously called `t("no fill (rest paints nothing)")`, which reads
31
+ * src/tokens/tokens.js and returns a LIGHT-MODE HEX at module load. That baked
32
+ * one theme into the bundle, so Midnight Mode could not work at all in the coded
33
+ * component no matter what the consumer did. `t()` also returns the token id
34
+ * unchanged when it cannot resolve a name, which emits invalid CSS that the
35
+ * browser silently drops — so a renamed token failed as a colour that quietly
36
+ * reverted to inherited, with no error anywhere.
37
+ *
38
+ * Referencing the custom property instead means the cascade resolves the value,
39
+ * so `[data-theme="midnight"]` just works, and a missing token is visible in
40
+ * devtools as an unresolved var rather than disappearing.
41
+ *
42
+ * WHAT THE CONSUMER MUST LOAD (see sidenav-spec.md §Consuming):
43
+ * themes/light.css + themes/midnight.css → every --semantic-color-* below
44
+ * layout.css → every --semantic-layout-units-* below
45
+ * type.css → the Semantic: Type scale (compose it)
46
+ *
47
+ * Token names verified against the Figma Variables panel on 2026-09-02.
48
+ */
49
+ const c = (name) => `var(--semantic-color-${name})`;
50
+ const u = (name, fallback) => `var(--semantic-layout-units-${name}, ${fallback}px)`;
51
+
52
+ // Negate a length that may be either a number or a var() reference. Used for the
53
+ // scroll-region edge bleed, where `-value` on a string would give NaN.
54
+ const neg = (value) =>
55
+ typeof value === "number" ? -value : `calc(-1 * ${value})`;
56
+
57
+ export const T = {
58
+ fill: {
59
+ // Rest has NO fill. The Figma token was deleted so that resting genuinely
60
+ // paints nothing rather than painting the sheet colour over the sheet.
61
+ navBase: "transparent",
62
+ navHover: c("fill-action-selection-hover"),
63
+ navActive: c("fill-action-selection-selected"),
64
+ navTrail: c("fill-action-selection-trail"),
65
+ infoSubtle: c("stroke-static-neutral-subtle"), // container right border
66
+ },
67
+ surface: { navLight: c("fill-surface-sheet") },
68
+ // Label, icon and chevron all resolve to the SAME token per state. A nav item
69
+ // is one interactive surface, so its foreground must not split across two
70
+ // ramps that can drift apart.
71
+ text: {
72
+ navBase: c("foreground-action-secondary-rest"),
73
+ navHover: c("foreground-action-secondary-hover"),
74
+ navActive: c("foreground-action-secondary-pressed"),
75
+ navDisabled: c("foreground-action-disabled"),
76
+ secondary: c("foreground-static-neutral-subtle"), // PopoverMenu.SectionLabel
77
+ secondarySubtle: c("foreground-static-neutral-subtle"), // NavSectionLabel
78
+ },
79
+ icon: {
80
+ navBase: c("foreground-action-secondary-rest"),
81
+ navHover: c("foreground-action-secondary-hover"),
82
+ navActive: c("foreground-action-secondary-pressed"),
83
+ navDisabled: c("foreground-action-disabled"),
84
+ actionSecondary: c("foreground-action-secondary-rest"), // CollapseButton
85
+ },
86
+ // The active-item stripe takes the loud brand blue straight from the Action
87
+ // tier rather than owning a token of its own.
88
+ indicator: c("fill-action-primary-pressed"),
89
+ radius: u("cornerradius-medium", 8),
90
+ };
91
+
92
+ /*
93
+ * ─── LAYOUT ──────────────────────────────────────────────────────────────────
94
+ * Values that exist as semantic tokens reference them, with the Figma value as
95
+ * the var() fallback so the component still lays out correctly if a consumer
96
+ * loads only the colour themes. Values with no token in Figma stay numeric and
97
+ * say so.
98
+ */
99
+ export const L = {
100
+ navPadH: u("padding-base", 16),
101
+ navColPadH: u("padding-medium", 12),
102
+ navPadTop: u("padding-tight", 8),
103
+ navW: 240, // no token in Figma — expanded sidebar width
104
+ navWcol: 72, // no token in Figma — collapsed rail width
105
+ menuGap: u("gap-xtight", 6),
106
+ menuPadT: u("padding-tight", 8),
107
+ menuPadB: u("padding-xxwide", 56),
108
+ itemH: u("accessibility-touch-target-aa-height", 44),
109
+ iconWrap: 24, // no token in Figma — leading icon wrapper
110
+ iconInner: 16, // Material Symbols Rounded frame size, per design-system-spec §7.2
111
+ rowPadH: u("padding-tight", 8),
112
+ textPad: u("padding-xtight", 6),
113
+ childIndent: 24, // no token in Figma — level-1 left indent
114
+ stripeW: 4, // no token in Figma — indicator stripe width
115
+ colPadL: u("padding-medium", 12),
116
+ colPadR: u("padding-tight", 8),
117
+ collapseGap: u("padding-xxtight", 4),
118
+ };
119
+
120
+ // ─── IndicatorStripe ──────────────────────────────────────────────────────────
121
+ export function IndicatorStripe({ visible }) {
122
+ return (
123
+ <div style={{ width: L.stripeW, alignSelf: "stretch", display: "flex",
124
+ flexDirection: "column", alignItems: "flex-start",
125
+ justifyContent: "center", padding: "4px 0", flexShrink: 0 }}>
126
+ <div style={{ flex: "1 0 0", width: L.stripeW, minHeight: 1,
127
+ borderRadius: "0 8px 8px 0",
128
+ backgroundColor: visible ? T.indicator : "transparent",
129
+ transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
130
+ </div>
131
+ );
132
+ }
133
+
134
+ // ─── SideNavItem ──────────────────────────────────────────────────────────────
135
+ export function SideNavItem({
136
+ item, isActive, isTrail, isExpanded, isSidebarCollapsed,
137
+ isChild, onClick, onToggle,
138
+ popoverOpen, anchorRect, onPopoverEnter, onPopoverLeave, activeId,
139
+ chevronUp, chevronDown, collapseIcon, expandIcon,
140
+ }) {
141
+ const [hovered, setHovered] = useState(false);
142
+ const hasChildren = !!(item.children && item.children.length);
143
+ const itemRef = useRef(null);
144
+
145
+ const isExpandedGrouper = hasChildren && isExpanded && !isSidebarCollapsed;
146
+ const isCollapsedActiveGrouper = isTrail && (!isExpanded || isSidebarCollapsed);
147
+ const isCollapsedGrouper = isSidebarCollapsed && hasChildren;
148
+ const showStripe = isCollapsedActiveGrouper || (isActive && !isCollapsedGrouper);
149
+
150
+ // A disabled item takes no fill and no hover, so it cannot look interactive.
151
+ const isDisabled = !!item.disabled;
152
+
153
+ const fillBg =
154
+ isDisabled ? T.fill.navBase
155
+ : (isActive || isCollapsedActiveGrouper) ? T.fill.navActive
156
+ : isExpandedGrouper ? T.fill.navTrail
157
+ : hovered ? T.fill.navHover
158
+ : T.fill.navBase;
159
+ const textColor =
160
+ isDisabled ? T.text.navDisabled
161
+ : (isActive || isCollapsedActiveGrouper || isExpandedGrouper) ? T.text.navActive
162
+ : hovered ? T.text.navHover
163
+ : T.text.navBase;
164
+ const iconColor =
165
+ isDisabled ? T.icon.navDisabled
166
+ : (isActive || isCollapsedActiveGrouper) ? T.icon.navActive
167
+ : hovered ? T.icon.navHover
168
+ : T.icon.navBase;
169
+
170
+ const handleClick = () => {
171
+ if (isDisabled) return;
172
+ if (hasChildren && !isSidebarCollapsed) onToggle(item.id);
173
+ else onClick(item.id);
174
+ };
175
+
176
+ return (
177
+ <div ref={itemRef} style={{ position: "relative" }}>
178
+ <div role="button" tabIndex={isDisabled ? -1 : 0}
179
+ aria-current={isActive ? "page" : undefined}
180
+ aria-disabled={isDisabled ? true : undefined}
181
+ aria-expanded={hasChildren && !isSidebarCollapsed ? isExpanded : undefined}
182
+ onClick={handleClick}
183
+ onKeyDown={e => !isDisabled && (e.key === "Enter" || e.key === " ") && handleClick()}
184
+ onMouseEnter={() => {
185
+ if (isDisabled) return;
186
+ setHovered(true);
187
+ if (isSidebarCollapsed && onPopoverEnter) {
188
+ const rect = itemRef.current ? itemRef.current.getBoundingClientRect() : null;
189
+ onPopoverEnter(item.id, rect);
190
+ }
191
+ }}
192
+ onMouseLeave={() => {
193
+ if (isDisabled) return;
194
+ setHovered(false);
195
+ if (isSidebarCollapsed && onPopoverLeave) onPopoverLeave();
196
+ }}
197
+ style={{ display: "flex", alignItems: "center", minHeight: L.itemH, width: "100%",
198
+ borderRadius: T.radius, backgroundColor: fillBg,
199
+ cursor: isDisabled ? "not-allowed" : "pointer",
200
+ overflow: "hidden", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)", userSelect: "none" }}>
201
+
202
+ <IndicatorStripe visible={showStripe} />
203
+
204
+ {/* Leading icon — Level 0 only */}
205
+ {!isChild && item.icon && (
206
+ <div style={{ width: L.iconWrap, height: L.iconWrap, display: "flex",
207
+ alignItems: "center", justifyContent: "center", flexShrink: 0,
208
+ marginLeft: L.rowPadH, color: iconColor }}>
209
+ {typeof item.icon === "function"
210
+ ? item.icon({ size: L.iconInner, color: iconColor })
211
+ : typeof item.icon === "string"
212
+ ? <span className="material-symbols-rounded" aria-hidden="true"
213
+ style={{ fontSize: L.iconInner, lineHeight: 1, color: iconColor,
214
+ fontVariationSettings: "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20" }}>
215
+ {item.icon}
216
+ </span>
217
+ : React.cloneElement(item.icon, { style: { color: iconColor } })}
218
+ </div>
219
+ )}
220
+
221
+ {/* Label */}
222
+ <div style={{ flex: 1, minWidth: 0,
223
+ paddingLeft: isChild ? 32 : L.textPad,
224
+ paddingRight: (!isChild && hasChildren) ? 0 : L.rowPadH,
225
+ maxWidth: isSidebarCollapsed ? 0 : 200,
226
+ opacity: isSidebarCollapsed ? 0 : 1,
227
+ overflow: "hidden",
228
+ pointerEvents: isSidebarCollapsed ? "none" : "auto",
229
+ transition: "max-width var(--motion-duration-6) var(--motion-easing-emphasized), opacity var(--motion-duration-3) var(--motion-easing-standard)" }}>
230
+ <p style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 500,
231
+ fontSize: 14, lineHeight: "20px", letterSpacing: "0.3px", color: textColor, margin: 0,
232
+ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
233
+ transition: "color var(--motion-duration-3) var(--motion-easing-standard)" }}>
234
+ {item.label}
235
+ </p>
236
+ </div>
237
+
238
+ {/* Chevron — groupers only */}
239
+ {!isChild && hasChildren && (
240
+ <div style={{ width: 40, height: L.iconWrap, display: "flex",
241
+ alignItems: "center", justifyContent: "center", flexShrink: 0,
242
+ maxWidth: isSidebarCollapsed ? 0 : 40,
243
+ opacity: isSidebarCollapsed ? 0 : 1,
244
+ overflow: "hidden",
245
+ transition: "max-width var(--motion-duration-6) var(--motion-easing-emphasized), opacity var(--motion-duration-3) var(--motion-easing-standard)" }}>
246
+ {/* Chevron rotation matches accordion timing — --motion-duration-5 + --motion-easing-accordion */}
247
+ <div style={{ transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
248
+ transition: "transform var(--motion-duration-5) var(--motion-easing-accordion)" }}>
249
+ {chevronDown ? chevronDown({ size: 10, color: iconColor }) : <ChevDown size={10} color={iconColor} />}
250
+ </div>
251
+ </div>
252
+ )}
253
+ </div>
254
+
255
+ {/* Hover-safe bridge */}
256
+ {isSidebarCollapsed && popoverOpen && anchorRect && (
257
+ <div style={{ position: "fixed", left: anchorRect.right, top: anchorRect.top,
258
+ width: 8, height: anchorRect.height, zIndex: 999 }}
259
+ onMouseEnter={() => {
260
+ const rect = itemRef.current ? itemRef.current.getBoundingClientRect() : anchorRect;
261
+ onPopoverEnter && onPopoverEnter(item.id, rect);
262
+ }} />
263
+ )}
264
+
265
+ {/* Collapsed grouper → flyout popover */}
266
+ {isSidebarCollapsed && hasChildren && popoverOpen && anchorRect &&
267
+ <CollapsedPopover item={item} onClick={onClick} anchorRect={anchorRect}
268
+ activeId={activeId}
269
+ onMouseEnter={() => {
270
+ const rect = itemRef.current ? itemRef.current.getBoundingClientRect() : anchorRect;
271
+ onPopoverEnter && onPopoverEnter(item.id, rect);
272
+ }}
273
+ onMouseLeave={onPopoverLeave} />
274
+ }
275
+
276
+ {/* Collapsed destination → tooltip */}
277
+ {isSidebarCollapsed && !hasChildren && popoverOpen && anchorRect &&
278
+ <SideNavTooltip label={item.label} anchorRect={anchorRect}
279
+ onMouseEnter={() => {
280
+ const rect = itemRef.current ? itemRef.current.getBoundingClientRect() : anchorRect;
281
+ onPopoverEnter && onPopoverEnter(item.id, rect);
282
+ }}
283
+ onMouseLeave={onPopoverLeave} />
284
+ }
285
+ </div>
286
+ );
287
+ }
288
+
289
+ // ─── SideNavTooltip ─────────────────────────────────────────────────────────
290
+ export function SideNavTooltip({ label, anchorRect, onMouseEnter, onMouseLeave }) {
291
+ if (!anchorRect) return null;
292
+ return ReactDOM.createPortal(
293
+ <div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
294
+ style={{ position: "fixed",
295
+ left: anchorRect.right + 8,
296
+ top: anchorRect.top + anchorRect.height / 2,
297
+ zIndex: 1000, backgroundColor: "#fff",
298
+ border: "0.5px solid #f6f6f6", borderRadius: 8,
299
+ boxShadow: "2px 2px 8px 0px rgba(0,0,0,0.03)",
300
+ padding: "6px 8px", whiteSpace: "nowrap", pointerEvents: "auto",
301
+ animation: "popoverInCentered var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
302
+ <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
303
+ fontSize: 14, lineHeight: "20px", letterSpacing: "0.02px", color: "#202020" }}>
304
+ {label}
305
+ </span>
306
+ </div>,
307
+ document.body
308
+ );
309
+ }
310
+
311
+ // ─── SectionLabel ─────────────────────────────────────────────────────────────
312
+ // Figma node 40006794-5977 — building block used in CollapsedPopover headers.
313
+ // Tokens: Foreground/Static/Neutral/Light (#7b7b7b), Label/Section/Small/Semibold
314
+ // (11px / 600 / 16px / 0.6px letter-spacing, uppercase)
315
+ // Padding: Padding/Tight (12px left), Padding/XTight (8px vertical)
316
+ export function SectionLabel({ label }) {
317
+ return (
318
+ <div style={{ display: "flex", alignItems: "center", width: "100%",
319
+ paddingLeft: 12, paddingTop: 8, paddingBottom: 8 }}>
320
+ <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
321
+ fontSize: 11, lineHeight: "16px", letterSpacing: "0.6px",
322
+ color: T.text.secondary, textTransform: "uppercase",
323
+ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
324
+ {label}
325
+ </span>
326
+ </div>
327
+ );
328
+ }
329
+
330
+ // ─── CollapsedPopover ─────────────────────────────────────────────────────────
331
+ export function CollapsedPopover({ item, onClick, anchorRect, onMouseEnter, onMouseLeave, activeId }) {
332
+ if (!anchorRect) return null;
333
+ return ReactDOM.createPortal(
334
+ <div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
335
+ style={{ position: "fixed", left: anchorRect.right + 8, top: anchorRect.top,
336
+ zIndex: 1000, backgroundColor: "#fff",
337
+ border: "0.5px solid #ededed", borderRadius: 8,
338
+ boxShadow: "2px 2px 8px 4px rgba(0,0,0,0.03)",
339
+ padding: 6, minWidth: 200, pointerEvents: "auto",
340
+ animation: "popoverIn var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
341
+ {/* Section label — Figma component 40006794-5977 */}
342
+ <div style={{ borderBottom: "0.5px solid #ededed" }}>
343
+ <SectionLabel label={item.label} />
344
+ </div>
345
+ {item.children.map(child =>
346
+ <PopoverRow key={child.id} item={child} onClick={onClick} activeId={activeId} />
347
+ )}
348
+ </div>,
349
+ document.body
350
+ );
351
+ }
352
+
353
+ // ─── PopoverRow ───────────────────────────────────────────────────────────────
354
+ export function PopoverRow({ item, onClick, activeId }) {
355
+ const [h, setH] = useState(false);
356
+ const isActive = item.id === activeId;
357
+ const bg = isActive ? T.fill.navActive : h ? T.fill.navHover : "transparent";
358
+ const color = isActive ? T.text.navActive : h ? T.text.navHover : T.text.navBase;
359
+ return (
360
+ <div onClick={() => onClick(item.id)}
361
+ onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
362
+ style={{ display: "flex", alignItems: "center", minHeight: 40, borderRadius: 8,
363
+ overflow: "hidden", backgroundColor: bg, cursor: "pointer",
364
+ transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
365
+ <div style={{ width: 4, alignSelf: "stretch", borderRadius: "0 4px 4px 0",
366
+ backgroundColor: isActive ? T.indicator : "transparent",
367
+ flexShrink: 0, transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
368
+ <span style={{ padding: "0 8px", fontFamily: "'Red Hat Text',sans-serif",
369
+ fontWeight: 400, fontSize: 14, lineHeight: "20px", letterSpacing: "0.02px",
370
+ color, transition: "color var(--motion-duration-3) var(--motion-easing-standard)", flex: 1 }}>
371
+ {item.label}
372
+ </span>
373
+ </div>
374
+ );
375
+ }
376
+
377
+ // ─── CollapseButton ───────────────────────────────────────────────────────────
378
+ // Redesigned in Figma (2026-05-12): now uses Slot.RowStart (Module.Mark icon)
379
+ // + Slot.RowEnd (Action Icon). The action icon uses Foreground/Action/Secondary/Rest
380
+ // (#6b6b6b) — different from nav item icons. SVG assets fetched from Figma directly.
381
+ export function CollapseButton({ isSidebarCollapsed, onToggle, collapseIcon, expandIcon }) {
382
+ const [h, setH] = useState(false);
383
+ const labelColor = h ? T.text.navHover : T.text.navBase;
384
+ const actionIconColor = T.icon.actionSecondary; // #6b6b6b — always static, no hover change
385
+ return (
386
+ <div style={{ width: "100%" }}>
387
+ <div style={{ height: 1, backgroundColor: T.fill.infoSubtle, marginBottom: L.collapseGap }} />
388
+ <div onClick={onToggle} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
389
+ aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
390
+ role="button" tabIndex={0}
391
+ onKeyDown={e => (e.key === "Enter" || e.key === " ") && onToggle()}
392
+ style={{ display: "flex", alignItems: "center", minHeight: L.itemH, width: "100%",
393
+ borderRadius: T.radius, backgroundColor: h ? T.fill.navHover : T.fill.navBase,
394
+ cursor: "pointer", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)", overflow: "hidden" }}>
395
+
396
+ {/* Slot.RowStart — 4px stripe column (structural, always present) */}
397
+ <div style={{ width: L.stripeW, alignSelf: "stretch", flexShrink: 0 }} />
398
+
399
+ {/* Container.Main — label (hidden when collapsed) */}
400
+ <div style={{ flex: 1, paddingLeft: L.rowPadH,
401
+ maxWidth: isSidebarCollapsed ? 0 : 200,
402
+ opacity: isSidebarCollapsed ? 0 : 1,
403
+ overflow: "hidden",
404
+ transition: "max-width var(--motion-duration-5) var(--motion-easing-standard), opacity var(--motion-duration-3) var(--motion-easing-standard)",
405
+ pointerEvents: isSidebarCollapsed ? "none" : "auto" }}>
406
+ <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 500, fontSize: 14,
407
+ lineHeight: "20px", letterSpacing: "0.3px", color: labelColor,
408
+ transition: "color var(--motion-duration-3) var(--motion-easing-standard)", whiteSpace: "nowrap" }}>
409
+ Collapse
410
+ </span>
411
+ </div>
412
+
413
+ {/* Slot.RowEnd — Action Icon (right_panel_open / left_panel_open from Figma) */}
414
+ <div style={{ width: 40, height: L.iconWrap, display: "flex",
415
+ alignItems: "center", justifyContent: "center", flexShrink: 0,
416
+ marginLeft: isSidebarCollapsed ? "auto" : 0,
417
+ paddingLeft: isSidebarCollapsed ? 0 : 0 }}>
418
+ {isSidebarCollapsed
419
+ ? (expandIcon
420
+ ? expandIcon({ size: 12, color: actionIconColor })
421
+ : <LeftPanelOpenIcon color={actionIconColor} />)
422
+ : (collapseIcon
423
+ ? collapseIcon({ size: 12, color: actionIconColor })
424
+ : <RightPanelOpenIcon color={actionIconColor} />)}
425
+ </div>
426
+ </div>
427
+ </div>
428
+ );
429
+ }
430
+
431
+ // ─── SideNav (top-level container) ────────────────────────────────────────────
432
+ // ── NavSectionLabel ──────────────────────────────────────────────────────────
433
+ // Optional in-nav section heading. Figma: SideNav.SectionLabel (node 40006794:5975).
434
+ // Tokens: Label/Section/Small/Semibold (11px/600/16px/0.6px), Foreground/Static/Neutral/Subtle (#606060)
435
+ // Container: h-[40px], pl-[4px], pr-[4px], py-[8px]
436
+ // In collapsed rail: hidden — replaced by a Divider (rendered by SideNav itself, see §2.3)
437
+ // ── NavHeader ────────────────────────────────────────────────────────────────
438
+ // Sits at the TOP of the SideNav. 48px row with the expand/collapse action icon
439
+ // in Slot.RowEnd (expanded) or centered (collapsed). Divider below.
440
+ // Matches Figma "Slot.NavHeader" (node 40007331:7794).
441
+ // Hidden on mobile (<768px) — TopNav hamburger is the sole toggle there.
442
+ export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIcon }) {
443
+ const [h, setH] = useState(false);
444
+ const actionIconColor = T.icon.actionSecondary; // #6b6b6b — always static
445
+ return (
446
+ <div style={{ width: "100%", flexShrink: 0 }}>
447
+ <div onClick={onToggle} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
448
+ aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
449
+ role="button" tabIndex={0}
450
+ onKeyDown={e => (e.key === "Enter" || e.key === " ") && onToggle()}
451
+ style={{ display: "flex", alignItems: "center", height: L.itemH, width: "100%",
452
+ borderRadius: T.radius, cursor: "pointer",
453
+ backgroundColor: h ? T.fill.navHover : "transparent",
454
+ transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
455
+ {isSidebarCollapsed ? (
456
+ /* Collapsed: centered action icon */
457
+ <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
458
+ {expandIcon
459
+ ? expandIcon({ size: 12, color: actionIconColor })
460
+ : <LeftPanelOpenIcon color={actionIconColor} />}
461
+ </div>
462
+ ) : (
463
+ /* Expanded: right-aligned action icon in Slot.RowEnd */
464
+ <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "flex-end" }}>
465
+ <div style={{ width: 36, height: 36, display: "flex",
466
+ alignItems: "center", justifyContent: "center" }}>
467
+ {collapseIcon
468
+ ? collapseIcon({ size: 12, color: actionIconColor })
469
+ : <RightPanelOpenIcon color={actionIconColor} />}
470
+ </div>
471
+ </div>
472
+ )}
473
+ </div>
474
+ {/* Divider — Stroke/Static/Neutral/Subtle, py-[2px] in Figma */}
475
+ <div style={{ padding: "2px 0" }}>
476
+ <div style={{ height: 1, backgroundColor: T.fill.infoSubtle }} />
477
+ </div>
478
+ </div>
479
+ );
480
+ }
481
+
482
+ export function NavSectionLabel({ label }) {
483
+ return (
484
+ <div style={{ display: "flex", alignItems: "center", height: 40, width: "100%",
485
+ paddingLeft: 4, paddingRight: 4, paddingTop: 8, paddingBottom: 8,
486
+ flexShrink: 0 }}>
487
+ <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
488
+ fontSize: 11, lineHeight: "16px", letterSpacing: "0.6px",
489
+ textTransform: "uppercase", color: T.text.secondarySubtle || "#606060",
490
+ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
491
+ {label}
492
+ </span>
493
+ </div>
494
+ );
495
+ }
496
+
497
+ // ── BulletDot ────────────────────────────────────────────────────────────────
498
+ // 6px filled circle — leading icon for SideNavListSection's ListItem.
499
+ // Color cycles Base / Hover / Active just like SideNavItem icons.
500
+ export function BulletDot({ color }) {
501
+ return (
502
+ <div style={{ width: 6, height: 6, borderRadius: "50%",
503
+ backgroundColor: color, flexShrink: 0 }} />
504
+ );
505
+ }
506
+
507
+ // ── ListItem ─────────────────────────────────────────────────────────────────
508
+ // Figma: SideNavItemList (node 40007332:6994). Used inside SideNavListSection.
509
+ // 48px min-height, 12px/400/18px/0.6px Text/Body/XSmall/Regular.
510
+ // Same Base/Hover/Active token cycle as SideNavItem destinations.
511
+ export function ListItem({ item, isActive, onClick }) {
512
+ const [hovered, setHovered] = useState(false);
513
+ const bg = isActive ? T.fill.navActive : hovered ? T.fill.navHover : "transparent";
514
+ const color = isActive ? T.text.navActive : hovered ? T.text.navHover : T.text.navBase;
515
+ const dotColor = isActive ? T.icon.navActive : hovered ? T.icon.navHover : T.icon.navBase;
516
+ return (
517
+ <div onClick={() => onClick && onClick(item.id)}
518
+ onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
519
+ role="button" tabIndex={0}
520
+ onKeyDown={e => (e.key === "Enter" || e.key === " ") && onClick && onClick(item.id)}
521
+ style={{ display: "flex", alignItems: "center", minHeight: L.itemH, width: "100%",
522
+ borderRadius: T.radius, backgroundColor: bg, cursor: "pointer",
523
+ overflow: "hidden", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)",
524
+ userSelect: "none" }}>
525
+ <IndicatorStripe visible={isActive} />
526
+ <div style={{ width: L.iconWrap, height: L.iconWrap, display: "flex",
527
+ alignItems: "center", justifyContent: "center", flexShrink: 0,
528
+ marginLeft: L.rowPadH }}>
529
+ <BulletDot color={dotColor} />
530
+ </div>
531
+ <div style={{ flex: 1, minWidth: 0, paddingLeft: L.textPad, paddingRight: L.rowPadH }}>
532
+ <p style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
533
+ fontSize: 12, lineHeight: "18px", letterSpacing: "0.6px", color,
534
+ margin: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
535
+ transition: "color var(--motion-duration-3) var(--motion-easing-standard)" }}>
536
+ {item.label}
537
+ </p>
538
+ </div>
539
+ </div>
540
+ );
541
+ }
542
+
543
+ // ── SideNavListSection ───────────────────────────────────────────────────────
544
+ // Figma: SideNav.ListSection (node 40007332:8034). A labelled grouping of flat
545
+ // nav items — no icons, no children, no expand/collapse. Used for contextual
546
+ // link lists (Recent Content, Pinned, Bookmarks). Only shown in expanded sidebar.
547
+ export function SideNavListSection({ label, items, activeId, onNavigate }) {
548
+ return (
549
+ <div style={{ display: "flex", flexDirection: "column", gap: 6, width: "100%", flexShrink: 0 }}>
550
+ <NavSectionLabel label={label} />
551
+ <div style={{ display: "flex", flexDirection: "column" }}>
552
+ {items.map(item => (
553
+ <ListItem key={item.id} item={item}
554
+ isActive={item.id === activeId}
555
+ onClick={onNavigate} />
556
+ ))}
557
+ </div>
558
+ </div>
559
+ );
560
+ }
561
+
562
+ export function SideNav({
563
+ items,
564
+ sections,
565
+ listSection,
566
+ activeId,
567
+ onNavigate,
568
+ collapsed = false,
569
+ onCollapseChange,
570
+ hideCollapseButton = false,
571
+ defaultExpanded = {},
572
+ className = "",
573
+ }) {
574
+ const [expanded, setExpanded] = useState(defaultExpanded);
575
+ const [popoverId, setPopoverId] = useState(null);
576
+ const [popoverRect, setPopoverRect] = useState(null);
577
+ const timerRef = useRef(null);
578
+
579
+ // Build a single flat list of all nav items for trail computation, regardless of
580
+ // whether the data was passed as flat `items` or as `sections` (which contain items).
581
+ // This keeps the active/trail logic unchanged from the flat-items API.
582
+ const flatItems = sections
583
+ ? sections.flatMap(s => s.items)
584
+ : (items || []);
585
+
586
+ const trailParentId = (() => {
587
+ const p = flatItems.find(item => item.children && item.children.some(c => c.id === activeId));
588
+ return p ? p.id : null;
589
+ })();
590
+
591
+ // Accordion behaviour: opening one grouper auto-closes any other that's open.
592
+ // Matches the demo HTML (2026-05-13).
593
+ const toggleExpand = id => setExpanded(prev => {
594
+ const isNowOpen = !prev[id];
595
+ if (isNowOpen) return { [id]: true }; // close all others
596
+ const next = { ...prev }; delete next[id]; return next;
597
+ });
598
+ const onPopoverEnter = (id, rect) => {
599
+ clearTimeout(timerRef.current);
600
+ setPopoverId(id);
601
+ if (rect) setPopoverRect(rect);
602
+ };
603
+ const onPopoverLeave = () => {
604
+ timerRef.current = setTimeout(() => {
605
+ setPopoverId(null);
606
+ setPopoverRect(null);
607
+ }, 300);
608
+ };
609
+
610
+ // Renders one nav item + its animated child list. Shared between flat-items and
611
+ // sectioned renders so behaviour is identical regardless of which API is used.
612
+ const renderItem = (item) => {
613
+ const hasChildren = !!(item.children && item.children.length);
614
+ const isActive = item.id === activeId;
615
+ const isTrail = item.id === trailParentId;
616
+ const isExp = !!expanded[item.id];
617
+ return (
618
+ <div key={item.id}>
619
+ <SideNavItem item={item} isActive={isActive} isTrail={isTrail}
620
+ isExpanded={isExp} isSidebarCollapsed={collapsed}
621
+ isChild={false} onClick={onNavigate} onToggle={toggleExpand}
622
+ popoverOpen={popoverId === item.id}
623
+ anchorRect={popoverId === item.id ? popoverRect : null}
624
+ onPopoverEnter={onPopoverEnter} onPopoverLeave={onPopoverLeave}
625
+ activeId={activeId} />
626
+ {/* Level 1 children — animated accordion. grid-template-rows 0fr→1fr at
627
+ --motion-duration-5 + --motion-easing-accordion, with inner wrapper opacity
628
+ fade --motion-duration-4 (70ms delay on expand, no delay on collapse). */}
629
+ {hasChildren && !collapsed && (
630
+ <div style={{
631
+ display: "grid",
632
+ gridTemplateRows: isExp ? "1fr" : "0fr",
633
+ marginTop: isExp ? L.menuGap : 0,
634
+ transition: "grid-template-rows var(--motion-duration-5) var(--motion-easing-accordion), margin-top var(--motion-duration-5) var(--motion-easing-accordion)" }}>
635
+ <div style={{
636
+ overflow: "hidden",
637
+ display: "flex", flexDirection: "column", gap: L.menuGap,
638
+ opacity: isExp ? 1 : 0,
639
+ transition: isExp
640
+ ? "opacity var(--motion-duration-4) var(--motion-easing-standard) 70ms"
641
+ : "opacity var(--motion-duration-3) var(--motion-easing-standard)" }}>
642
+ {item.children.map(child => (
643
+ <SideNavItem key={child.id} item={child}
644
+ isActive={child.id === activeId} isTrail={false}
645
+ isExpanded={false} isSidebarCollapsed={false}
646
+ isChild={true} onClick={onNavigate} onToggle={() => {}} />
647
+ ))}
648
+ </div>
649
+ </div>
650
+ )}
651
+ </div>
652
+ );
653
+ };
654
+
655
+ return (
656
+ <nav className={className} style={{
657
+ width: collapsed ? L.navWcol : L.navW,
658
+ height: "100%",
659
+ backgroundColor: T.surface.navLight,
660
+ display: "flex", flexDirection: "column",
661
+ paddingTop: L.navPadTop,
662
+ paddingBottom: 0,
663
+ paddingLeft: collapsed ? L.navColPadH : L.navPadH,
664
+ paddingRight: collapsed ? L.navColPadH : L.navPadH,
665
+ boxSizing: "border-box", flexShrink: 0,
666
+ // Smooth-spring curve — soft personality, no overshoot.
667
+ transition: "width var(--motion-duration-6) var(--motion-easing-emphasized)",
668
+ borderRight: `0.5px solid ${T.fill.infoSubtle}`,
669
+ overflow: "hidden", // the inner menu scrolls; the nav itself does not
670
+ }}>
671
+ {/* NavHeader — TOP of nav, PINNED (flexShrink:0) so it stays visible while the
672
+ item list scrolls beneath it. Hidden on mobile (<768px). */}
673
+ {!hideCollapseButton && (
674
+ <div style={{ flexShrink: 0 }}>
675
+ <NavHeader isSidebarCollapsed={collapsed}
676
+ onToggle={() => onCollapseChange && onCollapseChange(!collapsed)} />
677
+ </div>
678
+ )}
679
+
680
+ {/* SideNavMenu — the ONLY scroll region, wrapped in <Scrollable> so the custom
681
+ overlay scrollbar (identical on every OS, never affects layout/padding) handles
682
+ overflow. flex:1 + minHeight:0 lets it shrink below content height; the pinned
683
+ header above never moves. Applies in BOTH expanded (240px) and collapsed (72px) states. */}
684
+ <Scrollable
685
+ style={{ flex: 1, minHeight: 0,
686
+ // Bleed the scroll region out to the nav's TRUE right edge so the overlay thumb
687
+ // hugs the edge in BOTH states (the nav's border-box paddingRight would otherwise
688
+ // inset the thumb by 16px expanded / 12px on the 72px rail — badly off on the rail).
689
+ // negated through calc() because these are var() references, and negating
690
+ // a string would produce NaN
691
+ marginRight: neg(collapsed ? L.navColPadH : L.navPadH) }}
692
+ viewStyle={{ display: "flex", flexDirection: "column", gap: L.menuGap,
693
+ paddingTop: L.menuPadT,
694
+ // Re-add the content's right inset INSIDE the scroll view so items keep their
695
+ // position; only the thumb moves to the edge. (Spec rule: padding on viewStyle,
696
+ // never on the wrapper — keeps the thumb flush on any scroll surface.)
697
+ paddingRight: collapsed ? L.navColPadH : L.navPadH }}
698
+ >
699
+
700
+ {/* Sectioned render — when `sections` prop is provided */}
701
+ {sections && sections.map(({ section, items: secItems }, sIdx) => (
702
+ <div key={section} style={{ display: "flex", flexDirection: "column",
703
+ gap: L.menuGap, flexShrink: 0 }}>
704
+ {/* Expanded: NavSectionLabel; fades to 0 height as rail collapses */}
705
+ <div style={{ opacity: collapsed ? 0 : 1,
706
+ maxHeight: collapsed ? 0 : 40, overflow: "hidden",
707
+ transition: "opacity var(--motion-duration-4) var(--motion-easing-standard), max-height var(--motion-duration-5) var(--motion-easing-spring)",
708
+ flexShrink: 0 }}>
709
+ <NavSectionLabel label={section} />
710
+ </div>
711
+ {/* Collapsed rail: Divider replaces the section label (skip first section) */}
712
+ {sIdx > 0 && (
713
+ <div style={{ opacity: collapsed ? 1 : 0,
714
+ maxHeight: collapsed ? 5 : 0, overflow: "hidden",
715
+ transition: "opacity var(--motion-duration-4) var(--motion-easing-standard), max-height var(--motion-duration-5) var(--motion-easing-spring)",
716
+ padding: "2px 0", flexShrink: 0 }}>
717
+ <div style={{ height: 1, backgroundColor: T.fill.infoSubtle }} />
718
+ </div>
719
+ )}
720
+ {secItems.map(renderItem)}
721
+ </div>
722
+ ))}
723
+
724
+ {/* Flat render — when only `items` is provided (backward compatibility) */}
725
+ {!sections && (items || []).map(renderItem)}
726
+
727
+ {/* SideNavListSection — optional. Hidden in collapsed rail. */}
728
+ {listSection && (
729
+ <div style={{ opacity: collapsed ? 0 : 1,
730
+ maxHeight: collapsed ? 0 : 400, overflow: "hidden",
731
+ transition: "opacity var(--motion-duration-4) var(--motion-easing-standard), max-height var(--motion-duration-5) var(--motion-easing-spring)" }}>
732
+ <SideNavListSection label={listSection.label} items={listSection.items}
733
+ activeId={activeId} onNavigate={onNavigate} />
734
+ </div>
735
+ )}
736
+
737
+ {/* Bottom spacer — fills remaining height */}
738
+ <div style={{ flex: 1, minHeight: L.menuPadB }} />
739
+ </Scrollable>
740
+ </nav>
741
+ );
742
+ }
743
+
744
+ // ─── Built-in chevron icons ───────────────────────────────────────────────────
745
+ function ChevDown({ size = 10, color }) {
746
+ return (
747
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none">
748
+ <path d="M3 5.5L8 10.5L13 5.5" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
749
+ </svg>
750
+ );
751
+ }
752
+ function ChevUp({ size = 10, color }) {
753
+ return (
754
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none">
755
+ <path d="M3 10.5L8 5.5L13 10.5" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
756
+ </svg>
757
+ );
758
+ }
759
+
760
+ // ─── Collapse / Expand action icons (fetched from Figma 2026-05-12) ──────────
761
+ //
762
+ // PINNED 2026-09-07 — DO NOT RE-SYNC THESE TWO FROM FIGMA.
763
+ //
764
+ // The Figma SideBar Expand/Collapse component was changed after this date to a
765
+ // different icon. Jo has explicitly rejected that change for the repo, for
766
+ // Storybook and for <pathway-sidenav>. The panel-style pair below is the
767
+ // intended design and stays.
768
+ //
769
+ // This is a deliberate divergence from Figma, which normally wins for component
770
+ // visual design (CLAUDE.md §1). So if a reconciliation run reports these as
771
+ // drift, that report is correct and the answer is "leave it". Do not resolve it
772
+ // by pulling Figma, and do not silently re-fetch node 40006793:3783. Ask first
773
+ // if you think it should change.
774
+ // ─────────────────────────────────────────────────────────────────────────────
775
+ // right_panel_open — used on the collapse button when sidebar is EXPANDED (click to collapse)
776
+ // Token: Foreground/Action/Secondary/Rest (#6b6b6b)
777
+ // Figma component: SideBar Expand/Collapse, Type=Collapse, node 40006793:3783
778
+ function RightPanelOpenIcon({ size = 12, color = T.icon.actionSecondary }) {
779
+ return (
780
+ <svg width={size} height={size} viewBox="0 0 12 12" fill="none">
781
+ <path d="M5.66667 7.86667V4.13333C5.66667 3.97778 5.6 3.87222 5.46667 3.81667C5.33333 3.76111 5.21111 3.78889 5.1 3.9L3.46667 5.53333C3.33333 5.66667 3.26667 5.82222 3.26667 6C3.26667 6.17778 3.33333 6.33333 3.46667 6.46667L5.1 8.1C5.21111 8.21111 5.33333 8.23889 5.46667 8.18333C5.6 8.12778 5.66667 8.02222 5.66667 7.86667ZM1.33333 12C0.966667 12 0.652778 11.8694 0.391667 11.6083C0.130556 11.3472 0 11.0333 0 10.6667V1.33333C0 0.966667 0.130556 0.652778 0.391667 0.391667C0.652778 0.130556 0.966667 0 1.33333 0H10.6667C11.0333 0 11.3472 0.130556 11.6083 0.391667C11.8694 0.652778 12 0.966667 12 1.33333V10.6667C12 11.0333 11.8694 11.3472 11.6083 11.6083C11.3472 11.8694 11.0333 12 10.6667 12H1.33333ZM8.66667 10.6667H10.6667V1.33333H8.66667V10.6667ZM7.33333 10.6667V1.33333H1.33333V10.6667H7.33333Z" fill={color} />
782
+ </svg>
783
+ );
784
+ }
785
+
786
+ // left_panel_open — used on the collapse button when sidebar is COLLAPSED (click to expand)
787
+ // Token: Foreground/Action/Secondary/Rest (#6b6b6b)
788
+ // Figma component: SideBar Expand/Collapse, Type=Expand, node 40006793:3783
789
+ function LeftPanelOpenIcon({ size = 12, color = T.icon.actionSecondary }) {
790
+ return (
791
+ <svg width={size} height={size} viewBox="0 0 12 12" fill="none">
792
+ <path d="M6.33333 4.13333V7.86667C6.33333 8.02222 6.4 8.12778 6.53333 8.18333C6.66667 8.23889 6.78889 8.21111 6.9 8.1L8.53333 6.46667C8.66667 6.33333 8.73333 6.17778 8.73333 6C8.73333 5.82222 8.66667 5.66667 8.53333 5.53333L6.9 3.9C6.78889 3.78889 6.66667 3.76111 6.53333 3.81667C6.4 3.87222 6.33333 3.97778 6.33333 4.13333ZM1.33333 12C0.966667 12 0.652778 11.8694 0.391667 11.6083C0.130556 11.3472 0 11.0333 0 10.6667V1.33333C0 0.966667 0.130556 0.652778 0.391667 0.391667C0.652778 0.130556 0.966667 0 1.33333 0H10.6667C11.0333 0 11.3472 0.130556 11.6083 0.391667C11.8694 0.652778 12 0.966667 12 1.33333V10.6667C12 11.0333 11.8694 11.3472 11.6083 11.6083C11.3472 11.8694 11.0333 12 10.6667 12H1.33333ZM3.33333 10.6667V1.33333H1.33333V10.6667H3.33333ZM4.66667 10.6667H10.6667V1.33333H4.66667V10.6667Z" fill={color} />
793
+ </svg>
794
+ );
795
+ }