@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.
- package/README.md +12 -5
- package/components/scrollbar/scrollbar.jsx +184 -0
- package/components/sidenav/sidenav.jsx +795 -0
- package/dist/breakpoints.css +11 -0
- package/dist/layout-contextual.css +52 -0
- package/dist/layout.css +51 -0
- package/dist/motion.css +23 -0
- package/dist/primitives.css +356 -0
- package/dist/themes/light.css +332 -0
- package/dist/themes/midnight.css +332 -0
- package/dist/tokens.js +14378 -69460
- package/dist/tokens.json +4765 -10337
- package/dist/type.css +49 -0
- package/package.json +43 -7
- package/dist/tokens.css +0 -2646
|
@@ -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
|
+
}
|