@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.1
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/_shared/use-reduced-motion.jsx +49 -0
- package/components/button/button.jsx +512 -0
- package/components/icon/action-icon.jsx +155 -0
- package/components/icon/display-icon.jsx +137 -0
- package/components/icon/featured-icon.jsx +162 -0
- package/components/icon/icon.jsx +154 -0
- package/components/module-icon/module-icon.jsx +387 -0
- package/components/nav-shell/nav-shell.jsx +252 -0
- package/components/org-switcher/org-switcher.jsx +604 -0
- package/components/page-template/page-template.jsx +147 -0
- package/components/scrollbar/scrollbar.jsx +232 -0
- package/components/search/search.jsx +689 -0
- package/components/sidenav/sidenav.jsx +951 -0
- package/components/top-nav/top-nav.jsx +1003 -0
- package/package.json +27 -4
- package/README.md +0 -3
|
@@ -0,0 +1,951 @@
|
|
|
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=40004059-1375
|
|
17
|
+
* SideNav.Container, a COMPONENT_SET. The id cited here until
|
|
18
|
+
* 2026-10-07, 40003951-2927, does not exist in the file.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import React, { useState, useRef } from "react";
|
|
22
|
+
import { Scrollable } from "../scrollbar/scrollbar.jsx";
|
|
23
|
+
import ReactDOM from "react-dom";
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* ─── DESIGN TOKENS ───────────────────────────────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* Every value below is a `var()` reference to a real custom property, never a
|
|
29
|
+
* resolved hex. This is deliberate and it is a behavioural fix, not a style
|
|
30
|
+
* preference:
|
|
31
|
+
*
|
|
32
|
+
* This module previously called `t("no fill (rest paints nothing)")`, which reads
|
|
33
|
+
* src/tokens/tokens.js and returns a LIGHT-MODE HEX at module load. That baked
|
|
34
|
+
* one theme into the bundle, so Midnight Mode could not work at all in the coded
|
|
35
|
+
* component no matter what the consumer did. `t()` also returns the token id
|
|
36
|
+
* unchanged when it cannot resolve a name, which emits invalid CSS that the
|
|
37
|
+
* browser silently drops: so a renamed token failed as a colour that quietly
|
|
38
|
+
* reverted to inherited, with no error anywhere.
|
|
39
|
+
*
|
|
40
|
+
* Referencing the custom property instead means the cascade resolves the value,
|
|
41
|
+
* so `[data-theme="midnight"]` just works, and a missing token is visible in
|
|
42
|
+
* devtools as an unresolved var rather than disappearing.
|
|
43
|
+
*
|
|
44
|
+
* WHAT THE CONSUMER MUST LOAD (see sidenav-spec.md §Consuming):
|
|
45
|
+
* themes/light.css + themes/midnight.css → every --semantic-color-* below
|
|
46
|
+
* layout.css → every --semantic-layout-units-* below
|
|
47
|
+
* type.css → the Semantic: Type scale (compose it)
|
|
48
|
+
*
|
|
49
|
+
* Token names verified against the Figma Variables panel on 2026-09-02.
|
|
50
|
+
*/
|
|
51
|
+
const c = (name) => `var(--semantic-color-${name})`;
|
|
52
|
+
// Elevation is its own collection and already carries the whole shadow string,
|
|
53
|
+
// theme-resolved: light casts rgba(24,27,43,0.08), midnight casts
|
|
54
|
+
// rgba(0,1,19,0.70). A hand-written rgba(0,0,0,0.03) is invisible on a dark
|
|
55
|
+
// canvas, which is exactly how dark mode ended up looking flat.
|
|
56
|
+
const c2 = (name) => `var(${name})`;
|
|
57
|
+
const u = (name) => `var(--semantic-layout-units-${name})`;
|
|
58
|
+
// Component metrics live in the CONTEXTUAL collection, not the semantic one, so
|
|
59
|
+
// they need their own prefix. SideNav's two widths moved there on 2026-09-16:
|
|
60
|
+
// they were numeric literals with a "no token in Figma" comment, which was true
|
|
61
|
+
// until the tokens were created.
|
|
62
|
+
const x = (name) => `var(--contextual-layout-units-${name})`;
|
|
63
|
+
|
|
64
|
+
// Negate a length that may be either a number or a var() reference. Used for the
|
|
65
|
+
// scroll-region edge bleed, where `-value` on a string would give NaN.
|
|
66
|
+
const neg = (value) =>
|
|
67
|
+
typeof value === "number" ? -value : `calc(-1 * ${value})`;
|
|
68
|
+
|
|
69
|
+
export const T = {
|
|
70
|
+
fill: {
|
|
71
|
+
// Rest has NO fill. The Figma token was deleted so that resting genuinely
|
|
72
|
+
// paints nothing rather than painting the sheet colour over the sheet.
|
|
73
|
+
navBase: "transparent",
|
|
74
|
+
navHover: c("fill-action-selection-hover"),
|
|
75
|
+
navActive: c("fill-action-selection-selected"),
|
|
76
|
+
navTrail: c("fill-action-selection-trail"),
|
|
77
|
+
infoSubtle: c("stroke-static-neutral-base"), // container right border
|
|
78
|
+
},
|
|
79
|
+
surface: {
|
|
80
|
+
// Figma binds Fill/Surface/Elevated on SideNav.Container (node 40004059:1375).
|
|
81
|
+
// This was fill-static-neutral-mono, which is the same #ffffff in Light and
|
|
82
|
+
// therefore looked correct, but mono is the INVERSION ANCHOR: it is #ffffff
|
|
83
|
+
// in Midnight too. Elevated is #121523 there. So the nav stayed a white slab
|
|
84
|
+
// in dark mode while every surface around it went dark.
|
|
85
|
+
//
|
|
86
|
+
// It is also what a reader of this repo reports back as the nav's fill, which
|
|
87
|
+
// is how the drift surfaced: the value quoted did not match Figma.
|
|
88
|
+
navLight: c("fill-surface-elevated"), // the nav container itself
|
|
89
|
+
navElevated: c("fill-surface-elevated"), // PopoverMenu, per Figma's "Popover Menu"
|
|
90
|
+
},
|
|
91
|
+
// Label, icon and chevron all resolve to the SAME token per state. A nav item
|
|
92
|
+
// is one interactive surface, so its foreground must not split across two
|
|
93
|
+
// ramps that can drift apart.
|
|
94
|
+
text: {
|
|
95
|
+
navBase: c("foreground-action-secondary-rest"),
|
|
96
|
+
navHover: c("foreground-action-secondary-hover"),
|
|
97
|
+
navActive: c("foreground-action-secondary-pressed"),
|
|
98
|
+
navDisabled: c("foreground-action-disabled"),
|
|
99
|
+
secondary: c("foreground-static-neutral-base"), // PopoverMenu.SectionLabel
|
|
100
|
+
secondarySubtle: c("foreground-static-neutral-base"), // NavSectionLabel
|
|
101
|
+
},
|
|
102
|
+
icon: {
|
|
103
|
+
navBase: c("foreground-action-secondary-rest"),
|
|
104
|
+
navHover: c("foreground-action-secondary-hover"),
|
|
105
|
+
navActive: c("foreground-action-secondary-pressed"),
|
|
106
|
+
navDisabled: c("foreground-action-disabled"),
|
|
107
|
+
actionSecondary: c("foreground-action-secondary-rest"), // CollapseButton
|
|
108
|
+
},
|
|
109
|
+
// The active-item stripe takes the loud brand blue straight from the Action
|
|
110
|
+
// tier rather than owning a token of its own.
|
|
111
|
+
indicator: c("fill-action-primary-strong-pressed"),
|
|
112
|
+
radius: u("cornerradius-base"),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
* ─── LAYOUT ──────────────────────────────────────────────────────────────────
|
|
117
|
+
* Values that exist as semantic tokens reference them, with the Figma value as
|
|
118
|
+
* the var() fallback so the component still lays out correctly if a consumer
|
|
119
|
+
* loads only the colour themes. Values with no token in Figma stay numeric and
|
|
120
|
+
* say so.
|
|
121
|
+
*/
|
|
122
|
+
export const L = {
|
|
123
|
+
// SideNav is chrome that does NOT change across breakpoints, so its own
|
|
124
|
+
// metrics live in Contextual: Layout & Units rather than Responsive: Layout.
|
|
125
|
+
// Values measured from SideNav.Container in Figma: expanded padding 16, the
|
|
126
|
+
// collapsed rail 12, vertical 8, item gap 6. These were on the generic
|
|
127
|
+
// semantic scale until 2026-09-17, which meant a change to Padding/Base
|
|
128
|
+
// would silently move the nav.
|
|
129
|
+
navPadH: x("sidenav-padding-horizontal-expanded"),
|
|
130
|
+
navColPadH: x("sidenav-padding-horizontal-collapsed"),
|
|
131
|
+
navPadTop: x("sidenav-padding-vertical"),
|
|
132
|
+
// Read from SideNav.Container in Figma on 2026-09-16: every expanded variant
|
|
133
|
+
// is 250 wide (Base, Stroked, Mobile.Base, Mobile.Stroked) and every
|
|
134
|
+
// collapsed one is 72. Was 240, which no longer matched the component.
|
|
135
|
+
navW: x("sidenav-width-expanded"),
|
|
136
|
+
navWcol: x("sidenav-width-collapsed"),
|
|
137
|
+
menuGap: x("sidenav-gap-vertical"),
|
|
138
|
+
menuPadT: u("padding-tight"),
|
|
139
|
+
menuPadB: u("padding-xxwide"),
|
|
140
|
+
itemH: u("accessibility-touch-target-aa-height"),
|
|
141
|
+
iconWrap: 24, // no token in Figma, leading icon wrapper
|
|
142
|
+
iconInner: 16, // Material Symbols Rounded frame size, per design-system-spec §7.2
|
|
143
|
+
rowPadH: u("padding-tight"),
|
|
144
|
+
// Collapsed rail: container.rowStart's padding-right in Figma. It is what
|
|
145
|
+
// keeps the centred icon a hair left of the square's true centre, the same as
|
|
146
|
+
// the design.
|
|
147
|
+
railIconInset: u("padding-xxtight"),
|
|
148
|
+
textPad: u("padding-xtight"),
|
|
149
|
+
childIndent: 24, // no token in Figma, level-1 left indent
|
|
150
|
+
stripeW: 4, // no token in Figma, indicator stripe width
|
|
151
|
+
colPadL: u("padding-medium"),
|
|
152
|
+
colPadR: u("padding-tight"),
|
|
153
|
+
collapseGap: u("padding-xxtight"),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ─── IndicatorStripe ──────────────────────────────────────────────────────────
|
|
157
|
+
export function IndicatorStripe({ visible }) {
|
|
158
|
+
return (
|
|
159
|
+
<div style={{ width: L.stripeW, alignSelf: "stretch", display: "flex",
|
|
160
|
+
flexDirection: "column", alignItems: "flex-start",
|
|
161
|
+
justifyContent: "center", padding: "4px 0", flexShrink: 0 }}>
|
|
162
|
+
<div style={{ flex: "1 0 0", width: L.stripeW, minHeight: 1,
|
|
163
|
+
borderRadius: "0 8px 8px 0",
|
|
164
|
+
backgroundColor: visible ? T.indicator : "transparent",
|
|
165
|
+
transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── SideNavItem ──────────────────────────────────────────────────────────────
|
|
171
|
+
export function SideNavItem({
|
|
172
|
+
item, isActive, isTrail, isExpanded, isSidebarCollapsed,
|
|
173
|
+
isChild, onClick, onToggle,
|
|
174
|
+
popoverOpen, anchorRect, onPopoverEnter, onPopoverLeave, activeId,
|
|
175
|
+
chevronUp, chevronDown, collapseIcon, expandIcon,
|
|
176
|
+
}) {
|
|
177
|
+
const [hovered, setHovered] = useState(false);
|
|
178
|
+
const hasChildren = !!(item.children && item.children.length);
|
|
179
|
+
const itemRef = useRef(null);
|
|
180
|
+
|
|
181
|
+
const isExpandedGrouper = hasChildren && isExpanded && !isSidebarCollapsed;
|
|
182
|
+
const isCollapsedActiveGrouper = isTrail && (!isExpanded || isSidebarCollapsed);
|
|
183
|
+
const isCollapsedGrouper = isSidebarCollapsed && hasChildren;
|
|
184
|
+
const showStripe = isCollapsedActiveGrouper || (isActive && !isCollapsedGrouper);
|
|
185
|
+
|
|
186
|
+
// A disabled item takes no fill and no hover, so it cannot look interactive.
|
|
187
|
+
const isDisabled = !!item.disabled;
|
|
188
|
+
|
|
189
|
+
const fillBg =
|
|
190
|
+
isDisabled ? T.fill.navBase
|
|
191
|
+
: (isActive || isCollapsedActiveGrouper) ? T.fill.navActive
|
|
192
|
+
: isExpandedGrouper ? T.fill.navTrail
|
|
193
|
+
: hovered ? T.fill.navHover
|
|
194
|
+
: T.fill.navBase;
|
|
195
|
+
const textColor =
|
|
196
|
+
isDisabled ? T.text.navDisabled
|
|
197
|
+
: (isActive || isCollapsedActiveGrouper || isExpandedGrouper) ? T.text.navActive
|
|
198
|
+
: hovered ? T.text.navHover
|
|
199
|
+
: T.text.navBase;
|
|
200
|
+
const iconColor =
|
|
201
|
+
isDisabled ? T.icon.navDisabled
|
|
202
|
+
: (isActive || isCollapsedActiveGrouper) ? T.icon.navActive
|
|
203
|
+
: hovered ? T.icon.navHover
|
|
204
|
+
: T.icon.navBase;
|
|
205
|
+
|
|
206
|
+
const handleClick = () => {
|
|
207
|
+
if (isDisabled) return;
|
|
208
|
+
if (hasChildren && !isSidebarCollapsed) onToggle(item.id);
|
|
209
|
+
else onClick(item.id);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div ref={itemRef} style={{ position: "relative" }}>
|
|
214
|
+
<div role="button" tabIndex={isDisabled ? -1 : 0}
|
|
215
|
+
aria-current={isActive ? "page" : undefined}
|
|
216
|
+
aria-disabled={isDisabled ? true : undefined}
|
|
217
|
+
aria-expanded={hasChildren && !isSidebarCollapsed ? isExpanded : undefined}
|
|
218
|
+
onClick={handleClick}
|
|
219
|
+
onKeyDown={e => !isDisabled && (e.key === "Enter" || e.key === " ") && handleClick()}
|
|
220
|
+
onMouseEnter={() => {
|
|
221
|
+
if (isDisabled) return;
|
|
222
|
+
setHovered(true);
|
|
223
|
+
if (isSidebarCollapsed && onPopoverEnter) {
|
|
224
|
+
const rect = itemRef.current ? itemRef.current.getBoundingClientRect() : null;
|
|
225
|
+
onPopoverEnter(item.id, rect);
|
|
226
|
+
}
|
|
227
|
+
}}
|
|
228
|
+
onMouseLeave={() => {
|
|
229
|
+
if (isDisabled) return;
|
|
230
|
+
setHovered(false);
|
|
231
|
+
if (isSidebarCollapsed && onPopoverLeave) onPopoverLeave();
|
|
232
|
+
}}
|
|
233
|
+
style={{ display: "flex", alignItems: "center", minHeight: L.itemH,
|
|
234
|
+
// COLLAPSED IS A SQUARE. Figma's SideNavItem.Collapsed is 44x44 at
|
|
235
|
+
// x=8 inside the 60px column (84px rail minus 12px padding each side),
|
|
236
|
+
// so the fill is inset 8px and reads as a rounded square. Stretching
|
|
237
|
+
// it to the full 60 made it a rectangle, which is what Jo spotted.
|
|
238
|
+
// 44 is L.itemH, so the square is expressed as width = height rather
|
|
239
|
+
// than as a second number that could drift from it.
|
|
240
|
+
width: isSidebarCollapsed ? L.itemH : "100%",
|
|
241
|
+
marginLeft: isSidebarCollapsed ? "auto" : undefined,
|
|
242
|
+
marginRight: isSidebarCollapsed ? "auto" : undefined,
|
|
243
|
+
// Figma's container.rowStart is 40 wide inside the 44 square with
|
|
244
|
+
// padding-right 6 and primary=CENTER, so the icon centres in the 34
|
|
245
|
+
// that is left after the 4px indicator column. See the icon wrap below.
|
|
246
|
+
paddingRight: isSidebarCollapsed ? L.railIconInset : undefined,
|
|
247
|
+
// border-box or the 6px padding is ADDED to the 44, which made the rail
|
|
248
|
+
// item 50 wide and no longer a square.
|
|
249
|
+
boxSizing: "border-box",
|
|
250
|
+
borderRadius: T.radius, backgroundColor: fillBg,
|
|
251
|
+
cursor: isDisabled ? "not-allowed" : "pointer",
|
|
252
|
+
overflow: "hidden", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)", userSelect: "none" }}>
|
|
253
|
+
|
|
254
|
+
<IndicatorStripe visible={showStripe} />
|
|
255
|
+
|
|
256
|
+
{/* Leading icon, Level 0 only */}
|
|
257
|
+
{!isChild && item.icon && (
|
|
258
|
+
<div style={{ width: L.iconWrap, height: L.iconWrap, display: "flex",
|
|
259
|
+
alignItems: "center", justifyContent: "center", flexShrink: 0,
|
|
260
|
+
// COLLAPSED CENTRES, EXPANDED INDENTS. The fixed 12px left margin
|
|
261
|
+
// applied in both states, so on the rail the 24px icon sat at
|
|
262
|
+
// x 16..40 of the 44 square: 6px right of centre, in every state.
|
|
263
|
+
// Reported 2026-10-02. Auto margins hand the free space back to
|
|
264
|
+
// both sides, which with the 6px right padding above reproduces
|
|
265
|
+
// Figma's x=9 exactly.
|
|
266
|
+
marginLeft: isSidebarCollapsed ? "auto" : L.rowPadH,
|
|
267
|
+
marginRight: isSidebarCollapsed ? "auto" : undefined,
|
|
268
|
+
color: iconColor }}>
|
|
269
|
+
{typeof item.icon === "function"
|
|
270
|
+
? item.icon({ size: L.iconInner, color: iconColor })
|
|
271
|
+
: typeof item.icon === "string"
|
|
272
|
+
? <span className="material-symbols-rounded" aria-hidden="true"
|
|
273
|
+
style={{ fontSize: L.iconInner, lineHeight: 1, color: iconColor,
|
|
274
|
+
// display:block so the glyph is laid out as a box rather than
|
|
275
|
+
// on a text baseline: an inline glyph in a flex-centred
|
|
276
|
+
// parent is centred by its line box, not by its own art, so
|
|
277
|
+
// it rode high inside the 24px wrap.
|
|
278
|
+
display: "block",
|
|
279
|
+
// opsz MUST match fontSize. It said 20 against a 16px size,
|
|
280
|
+
// so the glyph was drawn on a 20px grid and scaled down into
|
|
281
|
+
// a 16px box, which shifts the art off centre.
|
|
282
|
+
fontVariationSettings: `'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' ${L.iconInner}` }}>
|
|
283
|
+
{item.icon}
|
|
284
|
+
</span>
|
|
285
|
+
: React.cloneElement(item.icon, { style: { color: iconColor } })}
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
{/* Label */}
|
|
290
|
+
<div style={{
|
|
291
|
+
// Collapsed, the label must take NO part in the flex distribution.
|
|
292
|
+
// With flex:1 it still claimed the free space even though max-width
|
|
293
|
+
// clamped it to zero, so the icon's auto margins had nothing left to
|
|
294
|
+
// centre with and the icon stayed hard left.
|
|
295
|
+
flex: isSidebarCollapsed ? "0 0 0px" : 1,
|
|
296
|
+
minWidth: 0,
|
|
297
|
+
// Collapsed, the padding has to go too. max-width clamps the CONTENT
|
|
298
|
+
// box, so the label still occupied 8px of padding on the rail and ate
|
|
299
|
+
// most of the free space the icon's auto margins needed: the icon
|
|
300
|
+
// landed at x=5 instead of Figma's x=9.
|
|
301
|
+
paddingLeft: isSidebarCollapsed ? 0 : (isChild ? 32 : L.textPad),
|
|
302
|
+
paddingRight: isSidebarCollapsed ? 0 : ((!isChild && hasChildren) ? 0 : L.rowPadH),
|
|
303
|
+
maxWidth: isSidebarCollapsed ? 0 : 200,
|
|
304
|
+
opacity: isSidebarCollapsed ? 0 : 1,
|
|
305
|
+
overflow: "hidden",
|
|
306
|
+
pointerEvents: isSidebarCollapsed ? "none" : "auto",
|
|
307
|
+
transition: "max-width var(--motion-duration-6) var(--motion-easing-emphasized), opacity var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
308
|
+
<p style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 500,
|
|
309
|
+
fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)", color: textColor, margin: 0,
|
|
310
|
+
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
|
311
|
+
transition: "color var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
312
|
+
{item.label}
|
|
313
|
+
</p>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
{/* Chevron, groupers only, and EXPANDED ONLY.
|
|
317
|
+
Figma's SideNavItem.Collapsed is indicator + Container.LeadingIcon
|
|
318
|
+
and nothing else: there is no chevron in the rail. Giving it
|
|
319
|
+
width 0 and max-width 0 was not enough, because the glyph still
|
|
320
|
+
painted outside its collapsed box, so the rail showed a second
|
|
321
|
+
column of 10px chevrons 17px to the right of the icons. Measured
|
|
322
|
+
2026-10-02: two distinct glyph centres, 28.8 and 45.8, where there
|
|
323
|
+
should be one. It is now not rendered at all when collapsed. */}
|
|
324
|
+
{!isChild && hasChildren && !isSidebarCollapsed && (
|
|
325
|
+
<div style={{ width: isSidebarCollapsed ? 0 : 40, height: L.iconWrap, display: "flex",
|
|
326
|
+
alignItems: "center", justifyContent: "center", flexShrink: 0,
|
|
327
|
+
maxWidth: isSidebarCollapsed ? 0 : 40,
|
|
328
|
+
opacity: isSidebarCollapsed ? 0 : 1,
|
|
329
|
+
overflow: "hidden",
|
|
330
|
+
transition: "max-width var(--motion-duration-6) var(--motion-easing-emphasized), opacity var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
331
|
+
{/* Chevron rotation matches accordion timing, --motion-duration-5 + --motion-easing-accordion */}
|
|
332
|
+
<div style={{ transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
|
|
333
|
+
transition: "transform var(--motion-duration-5) var(--motion-easing-accordion)" }}>
|
|
334
|
+
{chevronDown ? chevronDown({ size: 10, color: iconColor }) : <ChevDown size={10} color={iconColor} />}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
{/* Hover-safe bridge */}
|
|
341
|
+
{isSidebarCollapsed && popoverOpen && anchorRect && (
|
|
342
|
+
<div style={{ position: "fixed", left: anchorRect.right, top: anchorRect.top,
|
|
343
|
+
width: 8, height: anchorRect.height, zIndex: 999 }}
|
|
344
|
+
onMouseEnter={() => {
|
|
345
|
+
const rect = itemRef.current ? itemRef.current.getBoundingClientRect() : anchorRect;
|
|
346
|
+
onPopoverEnter && onPopoverEnter(item.id, rect);
|
|
347
|
+
}} />
|
|
348
|
+
)}
|
|
349
|
+
|
|
350
|
+
{/* Collapsed grouper → flyout popover */}
|
|
351
|
+
{isSidebarCollapsed && hasChildren && popoverOpen && anchorRect &&
|
|
352
|
+
<CollapsedPopover item={item} onClick={onClick} anchorRect={anchorRect}
|
|
353
|
+
activeId={activeId}
|
|
354
|
+
onMouseEnter={() => {
|
|
355
|
+
const rect = itemRef.current ? itemRef.current.getBoundingClientRect() : anchorRect;
|
|
356
|
+
onPopoverEnter && onPopoverEnter(item.id, rect);
|
|
357
|
+
}}
|
|
358
|
+
onMouseLeave={onPopoverLeave} />
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
{/* Collapsed destination → tooltip */}
|
|
362
|
+
{isSidebarCollapsed && !hasChildren && popoverOpen && anchorRect &&
|
|
363
|
+
<SideNavTooltip label={item.label} anchorRect={anchorRect}
|
|
364
|
+
onMouseEnter={() => {
|
|
365
|
+
const rect = itemRef.current ? itemRef.current.getBoundingClientRect() : anchorRect;
|
|
366
|
+
onPopoverEnter && onPopoverEnter(item.id, rect);
|
|
367
|
+
}}
|
|
368
|
+
onMouseLeave={onPopoverLeave} />
|
|
369
|
+
}
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─── SideNavTooltip ─────────────────────────────────────────────────────────
|
|
375
|
+
export function SideNavTooltip({ label, anchorRect, onMouseEnter, onMouseLeave }) {
|
|
376
|
+
if (!anchorRect) return null;
|
|
377
|
+
return ReactDOM.createPortal(
|
|
378
|
+
<div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
|
|
379
|
+
style={{ position: "fixed",
|
|
380
|
+
left: anchorRect.right + 8,
|
|
381
|
+
top: anchorRect.top + anchorRect.height / 2,
|
|
382
|
+
zIndex: 1000, backgroundColor: T.surface.navLight,
|
|
383
|
+
border: `0.5px solid ${c("stroke-static-neutral-faint")}`, borderRadius: "var(--semantic-layout-units-cornerradius-base)",
|
|
384
|
+
boxShadow: c2("--elevation-widget"),
|
|
385
|
+
padding: "6px 8px", whiteSpace: "nowrap", pointerEvents: "auto",
|
|
386
|
+
animation: "popoverInCentered var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
|
|
387
|
+
<span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
|
|
388
|
+
fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-wide)", color: T.text.secondary }}>
|
|
389
|
+
{label}
|
|
390
|
+
</span>
|
|
391
|
+
</div>,
|
|
392
|
+
document.body
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ─── SectionLabel ─────────────────────────────────────────────────────────────
|
|
397
|
+
// Figma node 40006794-5977, building block used in CollapsedPopover headers.
|
|
398
|
+
// Tokens: Foreground/Static/Neutral/Base (#313131), Label/Section/Small/Semibold
|
|
399
|
+
// (11px / 600 / 16px / 0.6px letter-spacing, uppercase)
|
|
400
|
+
// Padding: Padding/Tight (12px left), Padding/XTight (8px vertical)
|
|
401
|
+
export function SectionLabel({ label }) {
|
|
402
|
+
return (
|
|
403
|
+
<div style={{ display: "flex", alignItems: "center", width: "100%",
|
|
404
|
+
paddingLeft: "var(--semantic-layout-units-padding-tight)", paddingTop: "var(--semantic-layout-units-padding-xtight)", paddingBottom: "var(--semantic-layout-units-padding-xtight)"}}>
|
|
405
|
+
<span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
|
|
406
|
+
fontSize: "var(--semantic-type-font-size-xxs)", lineHeight: "var(--semantic-type-line-height-xxs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)",
|
|
407
|
+
color: T.text.secondary, textTransform: "uppercase",
|
|
408
|
+
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
409
|
+
{label}
|
|
410
|
+
</span>
|
|
411
|
+
</div>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ─── CollapsedPopover ─────────────────────────────────────────────────────────
|
|
416
|
+
export function CollapsedPopover({ item, onClick, anchorRect, onMouseEnter, onMouseLeave, activeId }) {
|
|
417
|
+
if (!anchorRect) return null;
|
|
418
|
+
return ReactDOM.createPortal(
|
|
419
|
+
<div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
|
|
420
|
+
style={{ position: "fixed", left: anchorRect.right + 8, top: anchorRect.top,
|
|
421
|
+
zIndex: 1000, backgroundColor: T.surface.navElevated,
|
|
422
|
+
border: `0.5px solid ${c("stroke-static-neutral-faint")}`, borderRadius: "var(--semantic-layout-units-cornerradius-base)",
|
|
423
|
+
boxShadow: c2("--elevation-widget"),
|
|
424
|
+
padding: "var(--semantic-layout-units-padding-xxtight)", minWidth: 200, pointerEvents: "auto",
|
|
425
|
+
animation: "popoverIn var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
|
|
426
|
+
{/* Section label, Figma component 40006794-5977 */}
|
|
427
|
+
<div style={{ borderBottom: `0.5px solid ${c("stroke-static-neutral-faint")}` }}>
|
|
428
|
+
<SectionLabel label={item.label} />
|
|
429
|
+
</div>
|
|
430
|
+
{item.children.map(child =>
|
|
431
|
+
<PopoverRow key={child.id} item={child} onClick={onClick} activeId={activeId} />
|
|
432
|
+
)}
|
|
433
|
+
</div>,
|
|
434
|
+
document.body
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ─── PopoverRow ───────────────────────────────────────────────────────────────
|
|
439
|
+
export function PopoverRow({ item, onClick, activeId }) {
|
|
440
|
+
const [h, setH] = useState(false);
|
|
441
|
+
const isActive = item.id === activeId;
|
|
442
|
+
const bg = isActive ? T.fill.navActive : h ? T.fill.navHover : "transparent";
|
|
443
|
+
const color = isActive ? T.text.navActive : h ? T.text.navHover : T.text.navBase;
|
|
444
|
+
return (
|
|
445
|
+
<div onClick={() => onClick(item.id)}
|
|
446
|
+
onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
|
|
447
|
+
style={{ display: "flex", alignItems: "center", minHeight: 40, borderRadius: "var(--semantic-layout-units-cornerradius-base)",
|
|
448
|
+
overflow: "hidden", backgroundColor: bg, cursor: "pointer",
|
|
449
|
+
transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
450
|
+
<div style={{ width: 4, alignSelf: "stretch", borderRadius: "0 4px 4px 0",
|
|
451
|
+
backgroundColor: isActive ? T.indicator : "transparent",
|
|
452
|
+
flexShrink: 0, transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
|
|
453
|
+
<span style={{ padding: "0 8px", fontFamily: "'Red Hat Text',sans-serif",
|
|
454
|
+
fontWeight: 400, fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-wide)",
|
|
455
|
+
color, transition: "color var(--motion-duration-3) var(--motion-easing-standard)", flex: 1 }}>
|
|
456
|
+
{item.label}
|
|
457
|
+
</span>
|
|
458
|
+
</div>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─── CollapseButton ───────────────────────────────────────────────────────────
|
|
463
|
+
// Redesigned in Figma (2026-05-12): now uses Slot.RowStart (Module.Mark icon)
|
|
464
|
+
// + Slot.RowEnd (Action Icon). The action icon uses Foreground/Action/Secondary/Rest
|
|
465
|
+
// (#6b6b6b), different from nav item icons. SVG assets fetched from Figma directly.
|
|
466
|
+
export function CollapseButton({ isSidebarCollapsed, onToggle, collapseIcon, expandIcon }) {
|
|
467
|
+
const [h, setH] = useState(false);
|
|
468
|
+
const labelColor = h ? T.text.navHover : T.text.navBase;
|
|
469
|
+
const actionIconColor = T.icon.actionSecondary; // var(--semantic-color-stroke-action-field-hover), always static, no hover change
|
|
470
|
+
return (
|
|
471
|
+
<div style={{ width: "100%" }}>
|
|
472
|
+
<div style={{ height: 1, backgroundColor: T.fill.infoSubtle, marginBottom: L.collapseGap }} />
|
|
473
|
+
<div onClick={onToggle} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
|
|
474
|
+
aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
|
|
475
|
+
role="button" tabIndex={0}
|
|
476
|
+
onKeyDown={e => (e.key === "Enter" || e.key === " ") && onToggle()}
|
|
477
|
+
style={{ display: "flex", alignItems: "center", minHeight: L.itemH, width: "100%",
|
|
478
|
+
borderRadius: T.radius, backgroundColor: h ? T.fill.navHover : T.fill.navBase,
|
|
479
|
+
cursor: "pointer", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)", overflow: "hidden" }}>
|
|
480
|
+
|
|
481
|
+
{/* Slot.RowStart, 4px stripe column (structural, always present) */}
|
|
482
|
+
<div style={{ width: L.stripeW, alignSelf: "stretch", flexShrink: 0 }} />
|
|
483
|
+
|
|
484
|
+
{/* Container.Main, label (hidden when collapsed) */}
|
|
485
|
+
<div style={{ flex: 1, paddingLeft: L.rowPadH,
|
|
486
|
+
maxWidth: isSidebarCollapsed ? 0 : 200,
|
|
487
|
+
opacity: isSidebarCollapsed ? 0 : 1,
|
|
488
|
+
overflow: "hidden",
|
|
489
|
+
transition: "max-width var(--motion-duration-5) var(--motion-easing-standard), opacity var(--motion-duration-3) var(--motion-easing-standard)",
|
|
490
|
+
pointerEvents: isSidebarCollapsed ? "none" : "auto" }}>
|
|
491
|
+
<span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 500, fontSize: "var(--semantic-type-font-size-s)",
|
|
492
|
+
lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)", color: labelColor,
|
|
493
|
+
transition: "color var(--motion-duration-3) var(--motion-easing-standard)", whiteSpace: "nowrap" }}>
|
|
494
|
+
Collapse
|
|
495
|
+
</span>
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
{/* Slot.RowEnd, Action Icon (right_panel_open / left_panel_open from Figma) */}
|
|
499
|
+
<div style={{ width: 40, height: L.iconWrap, display: "flex",
|
|
500
|
+
alignItems: "center", justifyContent: "center", flexShrink: 0,
|
|
501
|
+
marginLeft: isSidebarCollapsed ? "auto" : 0,
|
|
502
|
+
paddingLeft: isSidebarCollapsed ? 0 : 0 }}>
|
|
503
|
+
{isSidebarCollapsed
|
|
504
|
+
? (expandIcon
|
|
505
|
+
? expandIcon({ size: 12, color: actionIconColor })
|
|
506
|
+
: <LeftPanelOpenIcon color={actionIconColor} />)
|
|
507
|
+
: (collapseIcon
|
|
508
|
+
? collapseIcon({ size: 12, color: actionIconColor })
|
|
509
|
+
: <RightPanelOpenIcon color={actionIconColor} />)}
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
</div>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ─── SideNav (top-level container) ────────────────────────────────────────────
|
|
517
|
+
// ── NavSectionLabel ──────────────────────────────────────────────────────────
|
|
518
|
+
// Optional in-nav section heading. Figma: SideNav.SectionLabel (node 40006794:5975).
|
|
519
|
+
// Tokens: Label/Section/Small/Semibold (11px/600/16px/0.6px), Foreground/Static/Neutral/Base (#606060)
|
|
520
|
+
// Container: h-[40px], pl-[4px], pr-[4px], py-[8px]
|
|
521
|
+
// In collapsed rail: hidden, replaced by a Divider (rendered by SideNav itself, see §2.3)
|
|
522
|
+
// ── NavHeader ────────────────────────────────────────────────────────────────
|
|
523
|
+
// Sits at the TOP of the SideNav. 48px row with the expand/collapse action icon
|
|
524
|
+
// in Slot.RowEnd (expanded) or centered (collapsed). Divider below.
|
|
525
|
+
// Matches Figma "Slot.NavHeader" (node 40007331:7794).
|
|
526
|
+
// Hidden on mobile (<768px), TopNav hamburger is the sole toggle there.
|
|
527
|
+
export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIcon }) {
|
|
528
|
+
const [h, setH] = useState(false);
|
|
529
|
+
const actionIconColor = T.icon.actionSecondary; // var(--semantic-color-stroke-action-field-hover), always static
|
|
530
|
+
return (
|
|
531
|
+
<div style={{ width: "100%", flexShrink: 0 }}>
|
|
532
|
+
<div onClick={onToggle} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
|
|
533
|
+
aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
|
|
534
|
+
role="button" tabIndex={0}
|
|
535
|
+
onKeyDown={e => (e.key === "Enter" || e.key === " ") && onToggle()}
|
|
536
|
+
style={{ display: "flex", alignItems: "center", height: L.itemH, width: "100%",
|
|
537
|
+
borderRadius: T.radius, cursor: "pointer",
|
|
538
|
+
backgroundColor: h ? T.fill.navHover : "transparent",
|
|
539
|
+
transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
540
|
+
{isSidebarCollapsed ? (
|
|
541
|
+
/* Collapsed: centered action icon */
|
|
542
|
+
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
543
|
+
{expandIcon
|
|
544
|
+
? expandIcon({ size: 12, color: actionIconColor })
|
|
545
|
+
: <LeftPanelOpenIcon color={actionIconColor} />}
|
|
546
|
+
</div>
|
|
547
|
+
) : (
|
|
548
|
+
/* Expanded: right-aligned action icon in Slot.RowEnd */
|
|
549
|
+
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "flex-end" }}>
|
|
550
|
+
<div style={{ width: 36, height: 36, display: "flex",
|
|
551
|
+
alignItems: "center", justifyContent: "center" }}>
|
|
552
|
+
{collapseIcon
|
|
553
|
+
? collapseIcon({ size: 12, color: actionIconColor })
|
|
554
|
+
: <RightPanelOpenIcon color={actionIconColor} />}
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
)}
|
|
558
|
+
</div>
|
|
559
|
+
{/* Divider, Stroke/Static/Neutral/Base, py-[2px] in Figma */}
|
|
560
|
+
<div style={{ padding: "2px 0" }}>
|
|
561
|
+
<div style={{ height: 1, backgroundColor: T.fill.infoSubtle }} />
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function NavSectionLabel({ label }) {
|
|
568
|
+
return (
|
|
569
|
+
<div style={{ display: "flex", alignItems: "center", height: 40, width: "100%",
|
|
570
|
+
paddingLeft: "var(--semantic-layout-units-padding-xxxtight)", paddingRight: "var(--semantic-layout-units-padding-xxxtight)", paddingTop: "var(--semantic-layout-units-padding-xtight)", paddingBottom: "var(--semantic-layout-units-padding-xtight)",
|
|
571
|
+
flexShrink: 0 }}>
|
|
572
|
+
<span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
|
|
573
|
+
fontSize: "var(--semantic-type-font-size-xxs)", lineHeight: "var(--semantic-type-line-height-xxs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)",
|
|
574
|
+
textTransform: "uppercase", color: T.text.secondarySubtle,
|
|
575
|
+
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
576
|
+
{label}
|
|
577
|
+
</span>
|
|
578
|
+
</div>
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── BulletDot ────────────────────────────────────────────────────────────────
|
|
583
|
+
// 6px filled circle, leading icon for SideNavListSection's ListItem.
|
|
584
|
+
// Color cycles Base / Hover / Active just like SideNavItem icons.
|
|
585
|
+
export function BulletDot({ color }) {
|
|
586
|
+
return (
|
|
587
|
+
<div style={{ width: 6, height: 6, borderRadius: "50%",
|
|
588
|
+
backgroundColor: color, flexShrink: 0 }} />
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── ListItem ─────────────────────────────────────────────────────────────────
|
|
593
|
+
// Figma: SideNavItemList (node 40007332:6994). Used inside SideNavListSection.
|
|
594
|
+
// 48px min-height, 12px/400/18px/0.6px Text/Body/XSmall/Regular.
|
|
595
|
+
// Same Base/Hover/Active token cycle as SideNavItem destinations.
|
|
596
|
+
export function ListItem({ item, isActive, onClick }) {
|
|
597
|
+
const [hovered, setHovered] = useState(false);
|
|
598
|
+
const bg = isActive ? T.fill.navActive : hovered ? T.fill.navHover : "transparent";
|
|
599
|
+
const color = isActive ? T.text.navActive : hovered ? T.text.navHover : T.text.navBase;
|
|
600
|
+
const dotColor = isActive ? T.icon.navActive : hovered ? T.icon.navHover : T.icon.navBase;
|
|
601
|
+
return (
|
|
602
|
+
<div onClick={() => onClick && onClick(item.id)}
|
|
603
|
+
onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
|
|
604
|
+
role="button" tabIndex={0}
|
|
605
|
+
onKeyDown={e => (e.key === "Enter" || e.key === " ") && onClick && onClick(item.id)}
|
|
606
|
+
style={{ display: "flex", alignItems: "center", minHeight: L.itemH, width: "100%",
|
|
607
|
+
borderRadius: T.radius, backgroundColor: bg, cursor: "pointer",
|
|
608
|
+
overflow: "hidden", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)",
|
|
609
|
+
userSelect: "none" }}>
|
|
610
|
+
<IndicatorStripe visible={isActive} />
|
|
611
|
+
<div style={{ width: L.iconWrap, height: L.iconWrap, display: "flex",
|
|
612
|
+
alignItems: "center", justifyContent: "center", flexShrink: 0,
|
|
613
|
+
marginLeft: L.rowPadH }}>
|
|
614
|
+
<BulletDot color={dotColor} />
|
|
615
|
+
</div>
|
|
616
|
+
<div style={{ flex: 1, minWidth: 0, paddingLeft: L.textPad, paddingRight: L.rowPadH }}>
|
|
617
|
+
<p style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
|
|
618
|
+
fontSize: "var(--semantic-type-font-size-xs)", lineHeight: "var(--semantic-type-line-height-xs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)", color,
|
|
619
|
+
margin: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
|
620
|
+
transition: "color var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
621
|
+
{item.label}
|
|
622
|
+
</p>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ── SideNavListSection ───────────────────────────────────────────────────────
|
|
629
|
+
// Figma: SideNav.ListSection (node 40007332:8034). A labelled grouping of flat
|
|
630
|
+
// nav items: no icons, no children, no expand/collapse. Used for contextual
|
|
631
|
+
// link lists (Recent Content, Pinned, Bookmarks). Only shown in expanded sidebar.
|
|
632
|
+
export function SideNavListSection({ label, items, activeId, onNavigate }) {
|
|
633
|
+
return (
|
|
634
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "var(--semantic-layout-units-gap-xtight)", width: "100%", flexShrink: 0 }}>
|
|
635
|
+
<NavSectionLabel label={label} />
|
|
636
|
+
<div style={{ display: "flex", flexDirection: "column" }}>
|
|
637
|
+
{items.map(item => (
|
|
638
|
+
<ListItem key={item.id} item={item}
|
|
639
|
+
isActive={item.id === activeId}
|
|
640
|
+
onClick={onNavigate} />
|
|
641
|
+
))}
|
|
642
|
+
</div>
|
|
643
|
+
</div>
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function SideNav({
|
|
648
|
+
items,
|
|
649
|
+
sections,
|
|
650
|
+
listSection,
|
|
651
|
+
activeId,
|
|
652
|
+
onNavigate,
|
|
653
|
+
collapsed = false,
|
|
654
|
+
onCollapseChange,
|
|
655
|
+
hideCollapseButton = false,
|
|
656
|
+
defaultExpanded = {},
|
|
657
|
+
className = "",
|
|
658
|
+
// ── SLOTS ─────────────────────────────────────────────────────────────────
|
|
659
|
+
// Three insertion points, mirroring the slot structure in Figma. Added
|
|
660
|
+
// 2026-09-10 at the user's explicit request (which is what the §1.1 Figma
|
|
661
|
+
// exemption requires before SideNav changes).
|
|
662
|
+
//
|
|
663
|
+
// WHY SLOTS RATHER THAN MORE PROPS: the `items` array can express anything
|
|
664
|
+
// item-SHAPED - a label, an icon, nesting, a disabled state. It cannot
|
|
665
|
+
// express a module-specific widget: a support button, a plan-usage meter, a
|
|
666
|
+
// "New" badge component a team already owns. Without a slot those teams have
|
|
667
|
+
// exactly one option, which is to stop using the component and rebuild the
|
|
668
|
+
// nav. That is the drift this component exists to prevent, so the escape
|
|
669
|
+
// hatch has to be inside it.
|
|
670
|
+
//
|
|
671
|
+
// headerEnd after the collapse toggle in the pinned header. This is the
|
|
672
|
+
// common case: a module name, a small action. In Figma the
|
|
673
|
+
// header is a slot that can hold the toggle alone, or the
|
|
674
|
+
// toggle plus a label or button.
|
|
675
|
+
// children inside the scroll region, after the items. Anything that
|
|
676
|
+
// scrolls with the list.
|
|
677
|
+
// footer PINNED below the scroll region, so it stays put while the
|
|
678
|
+
// list scrolls. Buttons live here.
|
|
679
|
+
//
|
|
680
|
+
// Each is null by default and renders NOTHING when null - no wrapper, no
|
|
681
|
+
// padding, no border. An always-rendered empty wrapper would shift the
|
|
682
|
+
// layout of every nav that does not use the slot.
|
|
683
|
+
headerEnd = null,
|
|
684
|
+
children = null,
|
|
685
|
+
footer = null,
|
|
686
|
+
}) {
|
|
687
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
688
|
+
const [popoverId, setPopoverId] = useState(null);
|
|
689
|
+
const [popoverRect, setPopoverRect] = useState(null);
|
|
690
|
+
const timerRef = useRef(null);
|
|
691
|
+
|
|
692
|
+
// Build a single flat list of all nav items for trail computation, regardless of
|
|
693
|
+
// whether the data was passed as flat `items` or as `sections` (which contain items).
|
|
694
|
+
// This keeps the active/trail logic unchanged from the flat-items API.
|
|
695
|
+
const flatItems = sections
|
|
696
|
+
? sections.flatMap(s => s.items)
|
|
697
|
+
: (items || []);
|
|
698
|
+
|
|
699
|
+
const trailParentId = (() => {
|
|
700
|
+
const p = flatItems.find(item => item.children && item.children.some(c => c.id === activeId));
|
|
701
|
+
return p ? p.id : null;
|
|
702
|
+
})();
|
|
703
|
+
|
|
704
|
+
// Accordion behaviour: opening one grouper auto-closes any other that's open.
|
|
705
|
+
// Matches the demo HTML (2026-05-13).
|
|
706
|
+
const toggleExpand = id => setExpanded(prev => {
|
|
707
|
+
const isNowOpen = !prev[id];
|
|
708
|
+
if (isNowOpen) return { [id]: true }; // close all others
|
|
709
|
+
const next = { ...prev }; delete next[id]; return next;
|
|
710
|
+
});
|
|
711
|
+
const onPopoverEnter = (id, rect) => {
|
|
712
|
+
clearTimeout(timerRef.current);
|
|
713
|
+
setPopoverId(id);
|
|
714
|
+
if (rect) setPopoverRect(rect);
|
|
715
|
+
};
|
|
716
|
+
const onPopoverLeave = () => {
|
|
717
|
+
timerRef.current = setTimeout(() => {
|
|
718
|
+
setPopoverId(null);
|
|
719
|
+
setPopoverRect(null);
|
|
720
|
+
}, 300);
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
// Renders one nav item + its animated child list. Shared between flat-items and
|
|
724
|
+
// sectioned renders so behaviour is identical regardless of which API is used.
|
|
725
|
+
const renderItem = (item) => {
|
|
726
|
+
const hasChildren = !!(item.children && item.children.length);
|
|
727
|
+
const isActive = item.id === activeId;
|
|
728
|
+
const isTrail = item.id === trailParentId;
|
|
729
|
+
const isExp = !!expanded[item.id];
|
|
730
|
+
return (
|
|
731
|
+
<div key={item.id}>
|
|
732
|
+
<SideNavItem item={item} isActive={isActive} isTrail={isTrail}
|
|
733
|
+
isExpanded={isExp} isSidebarCollapsed={collapsed}
|
|
734
|
+
isChild={false} onClick={onNavigate} onToggle={toggleExpand}
|
|
735
|
+
popoverOpen={popoverId === item.id}
|
|
736
|
+
anchorRect={popoverId === item.id ? popoverRect : null}
|
|
737
|
+
onPopoverEnter={onPopoverEnter} onPopoverLeave={onPopoverLeave}
|
|
738
|
+
activeId={activeId} />
|
|
739
|
+
{/* Level 1 children, animated accordion. grid-template-rows 0fr→1fr at
|
|
740
|
+
--motion-duration-5 + --motion-easing-accordion, with inner wrapper opacity
|
|
741
|
+
fade --motion-duration-4 (70ms delay on expand, no delay on collapse). */}
|
|
742
|
+
{hasChildren && !collapsed && (
|
|
743
|
+
<div style={{
|
|
744
|
+
display: "grid",
|
|
745
|
+
gridTemplateRows: isExp ? "1fr" : "0fr",
|
|
746
|
+
marginTop: isExp ? L.menuGap : 0,
|
|
747
|
+
transition: "grid-template-rows var(--motion-duration-5) var(--motion-easing-accordion), margin-top var(--motion-duration-5) var(--motion-easing-accordion)" }}>
|
|
748
|
+
<div style={{
|
|
749
|
+
overflow: "hidden",
|
|
750
|
+
display: "flex", flexDirection: "column", gap: L.menuGap,
|
|
751
|
+
opacity: isExp ? 1 : 0,
|
|
752
|
+
transition: isExp
|
|
753
|
+
? "opacity var(--motion-duration-4) var(--motion-easing-standard) 70ms"
|
|
754
|
+
: "opacity var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
755
|
+
{item.children.map(child => (
|
|
756
|
+
<SideNavItem key={child.id} item={child}
|
|
757
|
+
isActive={child.id === activeId} isTrail={false}
|
|
758
|
+
isExpanded={false} isSidebarCollapsed={false}
|
|
759
|
+
isChild={true} onClick={onNavigate} onToggle={() => {}} />
|
|
760
|
+
))}
|
|
761
|
+
</div>
|
|
762
|
+
</div>
|
|
763
|
+
)}
|
|
764
|
+
</div>
|
|
765
|
+
);
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
return (
|
|
769
|
+
<nav className={className} style={{
|
|
770
|
+
width: collapsed ? L.navWcol : L.navW,
|
|
771
|
+
height: "100%",
|
|
772
|
+
backgroundColor: T.surface.navLight,
|
|
773
|
+
display: "flex", flexDirection: "column",
|
|
774
|
+
paddingTop: L.navPadTop,
|
|
775
|
+
paddingBottom: 0,
|
|
776
|
+
paddingLeft: collapsed ? L.navColPadH : L.navPadH,
|
|
777
|
+
paddingRight: collapsed ? L.navColPadH : L.navPadH,
|
|
778
|
+
boxSizing: "border-box", flexShrink: 0,
|
|
779
|
+
// Smooth-spring curve, soft personality, no overshoot.
|
|
780
|
+
transition: "width var(--motion-duration-6) var(--motion-easing-emphasized)",
|
|
781
|
+
borderRight: `0.5px solid ${T.fill.infoSubtle}`,
|
|
782
|
+
overflow: "hidden", // the inner menu scrolls; the nav itself does not
|
|
783
|
+
}}>
|
|
784
|
+
{/* NavHeader, TOP of nav, PINNED (flexShrink:0) so it stays visible while the
|
|
785
|
+
item list scrolls beneath it. Hidden on mobile (<768px). */}
|
|
786
|
+
{(!hideCollapseButton || headerEnd) && (
|
|
787
|
+
<div style={{ flexShrink: 0 }}>
|
|
788
|
+
{!hideCollapseButton && (
|
|
789
|
+
<NavHeader isSidebarCollapsed={collapsed}
|
|
790
|
+
onToggle={() => onCollapseChange && onCollapseChange(!collapsed)} />
|
|
791
|
+
)}
|
|
792
|
+
{/* headerEnd slot. Hidden on the 72px rail: the rail is only wide
|
|
793
|
+
enough for a centred icon, so arbitrary header content would
|
|
794
|
+
either overflow it or force it wider. It fades rather than
|
|
795
|
+
unmounting, on the same curve as the section labels, so the
|
|
796
|
+
header does not jump when the rail toggles. */}
|
|
797
|
+
{headerEnd && (
|
|
798
|
+
<div style={{
|
|
799
|
+
opacity: collapsed ? 0 : 1,
|
|
800
|
+
maxHeight: collapsed ? 0 : 200,
|
|
801
|
+
overflow: "hidden",
|
|
802
|
+
pointerEvents: collapsed ? "none" : "auto",
|
|
803
|
+
padding: collapsed ? 0 : `0 ${L.navPadH}`,
|
|
804
|
+
transition:
|
|
805
|
+
"opacity var(--motion-duration-4) var(--motion-easing-standard), " +
|
|
806
|
+
"max-height var(--motion-duration-5) var(--motion-easing-spring)",
|
|
807
|
+
}}>
|
|
808
|
+
{headerEnd}
|
|
809
|
+
</div>
|
|
810
|
+
)}
|
|
811
|
+
</div>
|
|
812
|
+
)}
|
|
813
|
+
|
|
814
|
+
{/* SideNavMenu: the ONLY scroll region, wrapped in <Scrollable> so the custom
|
|
815
|
+
overlay scrollbar (identical on every OS, never affects layout/padding) handles
|
|
816
|
+
overflow. flex:1 + minHeight:0 lets it shrink below content height; the pinned
|
|
817
|
+
header above never moves. Applies in BOTH expanded (240px) and collapsed (72px) states. */}
|
|
818
|
+
<Scrollable
|
|
819
|
+
style={{ flex: 1, minHeight: 0,
|
|
820
|
+
// Bleed the scroll region out to the nav's TRUE right edge so the overlay thumb
|
|
821
|
+
// hugs the edge in BOTH states (the nav's border-box paddingRight would otherwise
|
|
822
|
+
// inset the thumb by 16px expanded / 12px on the 72px rail, badly off on the rail).
|
|
823
|
+
// negated through calc() because these are var() references, and negating
|
|
824
|
+
// a string would produce NaN
|
|
825
|
+
marginRight: neg(collapsed ? L.navColPadH : L.navPadH) }}
|
|
826
|
+
viewStyle={{ display: "flex", flexDirection: "column", gap: L.menuGap,
|
|
827
|
+
paddingTop: L.menuPadT,
|
|
828
|
+
// Re-add the content's right inset INSIDE the scroll view so items keep their
|
|
829
|
+
// position; only the thumb moves to the edge. (Spec rule: padding on viewStyle,
|
|
830
|
+
// never on the wrapper, keeps the thumb flush on any scroll surface.)
|
|
831
|
+
paddingRight: collapsed ? L.navColPadH : L.navPadH }}
|
|
832
|
+
>
|
|
833
|
+
|
|
834
|
+
{/* Sectioned render: when `sections` prop is provided */}
|
|
835
|
+
{sections && sections.map(({ section, items: secItems }, sIdx) => (
|
|
836
|
+
<div key={section} style={{ display: "flex", flexDirection: "column",
|
|
837
|
+
gap: L.menuGap, flexShrink: 0 }}>
|
|
838
|
+
{/* Expanded: NavSectionLabel; fades to 0 height as rail collapses */}
|
|
839
|
+
<div style={{ opacity: collapsed ? 0 : 1,
|
|
840
|
+
maxHeight: collapsed ? 0 : 40, overflow: "hidden",
|
|
841
|
+
transition: "opacity var(--motion-duration-4) var(--motion-easing-standard), max-height var(--motion-duration-5) var(--motion-easing-spring)",
|
|
842
|
+
flexShrink: 0 }}>
|
|
843
|
+
<NavSectionLabel label={section} />
|
|
844
|
+
</div>
|
|
845
|
+
{/* Collapsed rail: Divider replaces the section label (skip first section) */}
|
|
846
|
+
{sIdx > 0 && (
|
|
847
|
+
<div style={{ opacity: collapsed ? 1 : 0,
|
|
848
|
+
maxHeight: collapsed ? 5 : 0, overflow: "hidden",
|
|
849
|
+
transition: "opacity var(--motion-duration-4) var(--motion-easing-standard), max-height var(--motion-duration-5) var(--motion-easing-spring)",
|
|
850
|
+
padding: "2px 0", flexShrink: 0 }}>
|
|
851
|
+
<div style={{ height: 1, backgroundColor: T.fill.infoSubtle }} />
|
|
852
|
+
</div>
|
|
853
|
+
)}
|
|
854
|
+
{secItems.map(renderItem)}
|
|
855
|
+
</div>
|
|
856
|
+
))}
|
|
857
|
+
|
|
858
|
+
{/* Flat render: when only `items` is provided (backward compatibility) */}
|
|
859
|
+
{!sections && (items || []).map(renderItem)}
|
|
860
|
+
|
|
861
|
+
{/* SideNavListSection, optional. Hidden in collapsed rail. */}
|
|
862
|
+
{listSection && (
|
|
863
|
+
<div style={{ opacity: collapsed ? 0 : 1,
|
|
864
|
+
maxHeight: collapsed ? 0 : 400, overflow: "hidden",
|
|
865
|
+
transition: "opacity var(--motion-duration-4) var(--motion-easing-standard), max-height var(--motion-duration-5) var(--motion-easing-spring)" }}>
|
|
866
|
+
<SideNavListSection label={listSection.label} items={listSection.items}
|
|
867
|
+
activeId={activeId} onNavigate={onNavigate} />
|
|
868
|
+
</div>
|
|
869
|
+
)}
|
|
870
|
+
|
|
871
|
+
{/* Body slot, scrolls with the item list, so it sits INSIDE
|
|
872
|
+
Scrollable and above the spacer. Use this for content that belongs
|
|
873
|
+
with the items; use `footer` for anything that must stay visible. */}
|
|
874
|
+
{children && <div style={{ flexShrink: 0 }}>{children}</div>}
|
|
875
|
+
|
|
876
|
+
{/* Bottom spacer, fills remaining height */}
|
|
877
|
+
<div style={{ flex: 1, minHeight: L.menuPadB }} />
|
|
878
|
+
</Scrollable>
|
|
879
|
+
|
|
880
|
+
{/* Footer slot, OUTSIDE Scrollable and flexShrink:0, so it is pinned to
|
|
881
|
+
the bottom of the nav and stays put while the list scrolls. The
|
|
882
|
+
divider only appears when the slot has content, so a nav without a
|
|
883
|
+
footer is unchanged. Padding drops to the rail's narrower inset when
|
|
884
|
+
collapsed rather than hiding: a footer button is usually an icon
|
|
885
|
+
button that still works at 72px, unlike arbitrary header content. */}
|
|
886
|
+
{footer && (
|
|
887
|
+
<div style={{
|
|
888
|
+
flexShrink: 0,
|
|
889
|
+
borderTop: `0.5px solid ${T.fill.infoSubtle}`,
|
|
890
|
+
padding: collapsed ? `8px ${L.navColPadH}` : `8px ${L.navPadH}`,
|
|
891
|
+
transition: "padding var(--motion-duration-6) var(--motion-easing-emphasized)",
|
|
892
|
+
}}>
|
|
893
|
+
{footer}
|
|
894
|
+
</div>
|
|
895
|
+
)}
|
|
896
|
+
</nav>
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ─── Built-in chevron icons ───────────────────────────────────────────────────
|
|
901
|
+
function ChevDown({ size = 10, color }) {
|
|
902
|
+
return (
|
|
903
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
|
904
|
+
<path d="M3 5.5L8 10.5L13 5.5" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
905
|
+
</svg>
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
function ChevUp({ size = 10, color }) {
|
|
909
|
+
return (
|
|
910
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
|
911
|
+
<path d="M3 10.5L8 5.5L13 10.5" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
912
|
+
</svg>
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ─── Collapse / Expand action icons (fetched from Figma 2026-05-12) ──────────
|
|
917
|
+
//
|
|
918
|
+
// PINNED 2026-09-07: DO NOT RE-SYNC THESE TWO FROM FIGMA.
|
|
919
|
+
//
|
|
920
|
+
// The Figma SideBar Expand/Collapse component was changed after this date to a
|
|
921
|
+
// different icon. Jo has explicitly rejected that change for the repo, for
|
|
922
|
+
// Storybook and for <pathway-sidenav>. The panel-style pair below is the
|
|
923
|
+
// intended design and stays.
|
|
924
|
+
//
|
|
925
|
+
// This is a deliberate divergence from Figma, which normally wins for component
|
|
926
|
+
// visual design (CLAUDE.md §1). So if a reconciliation run reports these as
|
|
927
|
+
// drift, that report is correct and the answer is "leave it". Do not resolve it
|
|
928
|
+
// by pulling Figma, and do not silently re-fetch node 40006793:3783. Ask first
|
|
929
|
+
// if you think it should change.
|
|
930
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
931
|
+
// right_panel_open, used on the collapse button when sidebar is EXPANDED (click to collapse)
|
|
932
|
+
// Token: Foreground/Action/Secondary/Rest (#6b6b6b)
|
|
933
|
+
// Figma component: SideBar Expand/Collapse, Type=Collapse, node 40006793:3783
|
|
934
|
+
function RightPanelOpenIcon({ size = 12, color = T.icon.actionSecondary }) {
|
|
935
|
+
return (
|
|
936
|
+
<svg width={size} height={size} viewBox="0 0 12 12" fill="none">
|
|
937
|
+
<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} />
|
|
938
|
+
</svg>
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// left_panel_open, used on the collapse button when sidebar is COLLAPSED (click to expand)
|
|
943
|
+
// Token: Foreground/Action/Secondary/Rest (#6b6b6b)
|
|
944
|
+
// Figma component: SideBar Expand/Collapse, Type=Expand, node 40006793:3783
|
|
945
|
+
function LeftPanelOpenIcon({ size = 12, color = T.icon.actionSecondary }) {
|
|
946
|
+
return (
|
|
947
|
+
<svg width={size} height={size} viewBox="0 0 12 12" fill="none">
|
|
948
|
+
<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} />
|
|
949
|
+
</svg>
|
|
950
|
+
);
|
|
951
|
+
}
|