@helloimjolopez-pathway/pathway-components 0.2.2 → 0.2.4
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/icon/action-icon.jsx +11 -2
- package/components/nav-shell/nav-shell-demo.jsx +46 -0
- package/components/nav-shell/nav-shell.jsx +19 -8
- package/components/org-switcher/org-switcher.jsx +16 -2
- package/components/search/search.jsx +7 -5
- package/components/sidenav/sidenav.jsx +147 -85
- package/components/top-nav/top-nav.jsx +65 -24
- package/package.json +1 -1
|
@@ -90,6 +90,13 @@ export const T = {
|
|
|
90
90
|
* @param {string} color Override the resting colour. Omit on a light surface;
|
|
91
91
|
* pass one on a dark or on-brand surface, where Ghost
|
|
92
92
|
* is the right family.
|
|
93
|
+
* @param {object} states Override the hover and pressed tokens as
|
|
94
|
+
* { hover, pressed, layerHover, layerPress }. A dark or
|
|
95
|
+
* on-brand surface needs this as well as `color`: the
|
|
96
|
+
* default ladder paints a light Primary/Subtle layer and a
|
|
97
|
+
* dark Secondary glyph, which is right on a light page and
|
|
98
|
+
* wrong on the navy TopNav. SideNav.Control (40007082:7703)
|
|
99
|
+
* binds the Ghost ladder instead.
|
|
93
100
|
*/
|
|
94
101
|
export function ActionIcon({
|
|
95
102
|
name,
|
|
@@ -97,6 +104,7 @@ export function ActionIcon({
|
|
|
97
104
|
label,
|
|
98
105
|
fill = 0,
|
|
99
106
|
color,
|
|
107
|
+
states,
|
|
100
108
|
onClick,
|
|
101
109
|
disabled,
|
|
102
110
|
className = "",
|
|
@@ -107,7 +115,8 @@ export function ActionIcon({
|
|
|
107
115
|
const [hov, setHov] = useState(false);
|
|
108
116
|
const [press, setPress] = useState(false);
|
|
109
117
|
const s = ACTION_ICON_SIZES[size] || ACTION_ICON_SIZES.Small;
|
|
110
|
-
const
|
|
118
|
+
const st = { ...T, ...states };
|
|
119
|
+
const fg = disabled ? T.rest : press ? st.pressed : hov ? st.hover : (color || T.rest);
|
|
111
120
|
|
|
112
121
|
return (
|
|
113
122
|
<button
|
|
@@ -141,7 +150,7 @@ export function ActionIcon({
|
|
|
141
150
|
<span style={{
|
|
142
151
|
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
143
152
|
width: s.layer, height: s.layer, borderRadius: U("cornerradius-base"),
|
|
144
|
-
background: press ?
|
|
153
|
+
background: press ? st.layerPress : hov ? st.layerHover : "transparent",
|
|
145
154
|
transition: noMotion ? "none" : `background ${M("duration-2")} ${M("easing-standard")}`,
|
|
146
155
|
}}>
|
|
147
156
|
<Icon name={name} size={s.glyph} fill={fill} style={{
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavShell's standalone demo. Imports the real NavShell, which composes the
|
|
3
|
+
* real TopNav and SideNav, so nothing here can fall out of step with them: see
|
|
4
|
+
* components/_demo/harness.jsx for why the newer demos are generated.
|
|
5
|
+
*
|
|
6
|
+
* Replaced the hand-written nav-shell.html on 2026-11-03. That page carried its
|
|
7
|
+
* own copies of TopNav and SideNav, both from before the Figma parity passes:
|
|
8
|
+
* a 240px nav, no item radius, a 4px stripe rounded at 4, a 20px glyph, a 22px
|
|
9
|
+
* module mark, raw rgba hover fills, and a 56px NavHeader Figma does not draw.
|
|
10
|
+
*/
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { DemoChrome, mount } from "../_demo/harness.jsx";
|
|
13
|
+
import { NavShell } from "./nav-shell.jsx";
|
|
14
|
+
import { PageTemplate } from "../page-template/page-template.jsx";
|
|
15
|
+
import { NAV_SECTIONS } from "../../src/stories/Library/SideNav/sidenavDemoData.jsx";
|
|
16
|
+
|
|
17
|
+
const C = (n) => `var(--semantic-color-${n})`;
|
|
18
|
+
const Y = (n) => `var(--semantic-type-${n})`;
|
|
19
|
+
|
|
20
|
+
// NavShell is fixed to the viewport, so every width, Desktop included, renders
|
|
21
|
+
// in an inner frame: in this document it would sit on top of the demo chrome.
|
|
22
|
+
const WIDTHS = { Desktop: 1440, Tablet: 900, Mobile: 390 };
|
|
23
|
+
|
|
24
|
+
const ORG = { id: "gcc", name: "Grace Community Church", campus: "Knoxville", initials: "GC" };
|
|
25
|
+
const USER = { name: "Jo Lopez", initials: "JL", email: "jo@gracecommunity.org" };
|
|
26
|
+
|
|
27
|
+
function App() {
|
|
28
|
+
return (
|
|
29
|
+
<DemoChrome title="NavShell" widths={WIDTHS}
|
|
30
|
+
blurb={"TopNav, SideNav and the page area composed into one application frame. " +
|
|
31
|
+
"Desktop pushes the page with a collapsible nav, Tablet locks the 72px rail, " +
|
|
32
|
+
"Mobile opens the nav as an overlay from the hamburger."}>
|
|
33
|
+
<NavShell activeModuleId="accounting" org={ORG} user={USER}
|
|
34
|
+
navSections={NAV_SECTIONS} activeNavId="balance_sheet" onNavigate={() => {}}>
|
|
35
|
+
<PageTemplate>
|
|
36
|
+
<h1 style={{ margin: 0, fontSize: Y("font-size-xl"), lineHeight: Y("line-height-xl-single"),
|
|
37
|
+
fontWeight: Y("weight-semibold"), color: C("foreground-static-neutral-bold") }}>
|
|
38
|
+
Balance Sheet
|
|
39
|
+
</h1>
|
|
40
|
+
</PageTemplate>
|
|
41
|
+
</NavShell>
|
|
42
|
+
</DemoChrome>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
mount(<App />);
|
|
@@ -49,9 +49,9 @@ function useBreakpoint() {
|
|
|
49
49
|
* SideNav fixed, left edge, top 60px → bottom, push layout
|
|
50
50
|
* Shell.Main scrollable content area, margin-left = SideNav width
|
|
51
51
|
*
|
|
52
|
-
* Desktop: SideNav
|
|
53
|
-
* Tablet: SideNav always
|
|
54
|
-
* Mobile: SideNav hidden, slides in as
|
|
52
|
+
* Desktop: SideNav 250px expanded | 72px collapsed, user toggles
|
|
53
|
+
* Tablet: SideNav always 72px rail, toggle locked
|
|
54
|
+
* Mobile: SideNav hidden, slides in as 250px overlay on hamburger tap
|
|
55
55
|
*
|
|
56
56
|
* Props (TopNav pass-through):
|
|
57
57
|
* modules Array<{id, label, icon}> — all modules (default: DEFAULT_MODULES)
|
|
@@ -107,10 +107,13 @@ export function NavShell({
|
|
|
107
107
|
// Mobile overlay open state
|
|
108
108
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
109
109
|
|
|
110
|
-
// Tablet: rail (
|
|
111
|
-
// Mobile:
|
|
110
|
+
// Tablet: rail (72px) is fixed, no user toggle
|
|
111
|
+
// Mobile: expanded inside the overlay, opened and closed via mobileOpen
|
|
112
112
|
// Desktop: user-controlled
|
|
113
|
-
|
|
113
|
+
// Mobile is EXPANDED: the overlay is the full 250 nav, as spec §8 and the
|
|
114
|
+
// NavShell story both have it. This forced it collapsed, so the 250 overlay
|
|
115
|
+
// opened with a 72 rail inside it and 178px of blank.
|
|
116
|
+
const effectiveCollapsed = isTablet ? true : isMobile ? false : collapsed;
|
|
114
117
|
|
|
115
118
|
// Shell.Main margin-left: matches current SideNav width for push layout.
|
|
116
119
|
// Mobile: no margin (SideNav is an overlay).
|
|
@@ -163,6 +166,12 @@ export function NavShell({
|
|
|
163
166
|
</a>
|
|
164
167
|
|
|
165
168
|
{/* ── TopNav: position fixed, full width, 60px ─────────────────────── */}
|
|
169
|
+
{/* The comment above was all there was: TopNav renders a plain in-flow
|
|
170
|
+
<nav>, so it sat in the document flow while Shell.Main ALSO took a
|
|
171
|
+
60px top margin, putting a blank 60px band under the bar, and the
|
|
172
|
+
fixed SideNav sat under any wrapper padding. The wrapper makes it
|
|
173
|
+
fixed, as spec §9.3 says. */}
|
|
174
|
+
<div style={{ position: "fixed", top: 0, left: 0, right: 0, zIndex: 100 }}>
|
|
166
175
|
<TopNav
|
|
167
176
|
modules={modules}
|
|
168
177
|
activeModuleId={activeModuleId}
|
|
@@ -174,6 +183,7 @@ export function NavShell({
|
|
|
174
183
|
onOrgSelect={onOrgSelect}
|
|
175
184
|
onSideNavToggle={handleSideNavToggle}
|
|
176
185
|
/>
|
|
186
|
+
</div>
|
|
177
187
|
|
|
178
188
|
{/* ── Mobile scrim — darkens content while SideNav overlay is open ─── */}
|
|
179
189
|
{isMobile && mobileOpen && (
|
|
@@ -190,7 +200,7 @@ export function NavShell({
|
|
|
190
200
|
|
|
191
201
|
{/* ── SideNav container ────────────────────────────────────────────── */}
|
|
192
202
|
{/* Position: fixed so it stays below TopNav while the content scrolls.
|
|
193
|
-
On mobile: width controls the overlay reveal (0 →
|
|
203
|
+
On mobile: width controls the overlay reveal (0 → 250px); the SideNav
|
|
194
204
|
itself renders inside a clipping container and slides in from the left.
|
|
195
205
|
On desktop/tablet: overflow hidden so the SideNav's own width transition
|
|
196
206
|
stays within the container; no width needed here. */}
|
|
@@ -219,7 +229,8 @@ export function NavShell({
|
|
|
219
229
|
onNavigate={onNavigate}
|
|
220
230
|
collapsed={effectiveCollapsed}
|
|
221
231
|
onCollapseChange={handleCollapseChange}
|
|
222
|
-
|
|
232
|
+
// Mobile: the TopNav hamburger is the only toggle (NavHeader spec).
|
|
233
|
+
hideCollapseButton={isTablet || isMobile}
|
|
223
234
|
headerEnd={navHeaderEnd}
|
|
224
235
|
footer={navFooter}
|
|
225
236
|
>
|
|
@@ -513,6 +513,7 @@ function OrgRow({ org, active, onSelect }) {
|
|
|
513
513
|
onMouseLeave={() => setHov(false)}
|
|
514
514
|
aria-current={active ? "true" : undefined}
|
|
515
515
|
style={{
|
|
516
|
+
position: "relative",
|
|
516
517
|
display: "flex", alignItems: "center", gap: 12, width: "100%", textAlign: "left",
|
|
517
518
|
padding: "8px 8px", border: "none", cursor: "pointer", borderRadius: 8,
|
|
518
519
|
fontFamily: "inherit",
|
|
@@ -520,6 +521,17 @@ function OrgRow({ org, active, onSelect }) {
|
|
|
520
521
|
transition: noMotion ? "none" : "background var(--motion-duration-2) var(--motion-easing-standard)",
|
|
521
522
|
}}
|
|
522
523
|
>
|
|
524
|
+
{/* indicator.stripe on the selected row, as OrgSwitcher.Row State=Selected
|
|
525
|
+
draws it (40018011:16090): 4 wide, 44 tall, right corners rounded 8,
|
|
526
|
+
Fill/Action/Selection/Indicator. The same stripe as the side nav. */}
|
|
527
|
+
{active && (
|
|
528
|
+
<span aria-hidden="true" style={{
|
|
529
|
+
position: "absolute", left: 0, top: "50%", transform: "translateY(-50%)",
|
|
530
|
+
width: 4, height: 44,
|
|
531
|
+
borderRadius: "0 var(--semantic-layout-units-cornerradius-base) var(--semantic-layout-units-cornerradius-base) 0",
|
|
532
|
+
background: "var(--semantic-color-fill-action-selection-indicator)",
|
|
533
|
+
}} />
|
|
534
|
+
)}
|
|
523
535
|
{/* Logo tile, org initials on brand fill (falls back from a logo image) */}
|
|
524
536
|
<div style={{
|
|
525
537
|
width: 44, height: 44, borderRadius: 6, flexShrink: 0, background: PANEL_T.logoBg,
|
|
@@ -538,7 +550,8 @@ function OrgRow({ org, active, onSelect }) {
|
|
|
538
550
|
<ModuleCluster />
|
|
539
551
|
</div>
|
|
540
552
|
{/* Trailing chevron */}
|
|
541
|
-
|
|
553
|
+
{/* 12, as OrgSwitcher.Row's trailing chevron in Figma. Was 20. */}
|
|
554
|
+
<Icon name="chevron_right" size={12} style={{ color: PANEL_T.icon, flexShrink: 0 }} />
|
|
542
555
|
</button>
|
|
543
556
|
);
|
|
544
557
|
}
|
|
@@ -577,7 +590,8 @@ export function OrgSwitcherPanel({
|
|
|
577
590
|
display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "var(--semantic-layout-units-padding-xtight)", borderRadius: "var(--semantic-layout-units-cornerradius-base)",
|
|
578
591
|
border: `1px solid ${PANEL_T.border}`,
|
|
579
592
|
}}>
|
|
580
|
-
|
|
593
|
+
{/* 18, as OrgSwitcher.Panel's Field.Search icon in Figma. Was 20. */}
|
|
594
|
+
<Icon name="search" size={18} style={{ color: PANEL_T.icon }} />
|
|
581
595
|
<input
|
|
582
596
|
type="text"
|
|
583
597
|
value={query}
|
|
@@ -598,17 +598,19 @@ export function TopNavSearch({
|
|
|
598
598
|
flexShrink: 0,
|
|
599
599
|
}}
|
|
600
600
|
>
|
|
601
|
-
{/*
|
|
601
|
+
{/* Container.Icon: 36x36 with CornerRadius/Full and a Button/BorderWidth/Rest
|
|
602
|
+
(0.75) stroke, read from TopNav.Global 2026-11-03. Was 32 with 0.5. */}
|
|
602
603
|
<div
|
|
603
604
|
style={{
|
|
604
605
|
display: "flex",
|
|
605
606
|
alignItems: "center",
|
|
606
607
|
justifyContent: "center",
|
|
607
|
-
width:
|
|
608
|
-
height:
|
|
609
|
-
|
|
608
|
+
width: 36,
|
|
609
|
+
height: 36,
|
|
610
|
+
boxSizing: "border-box",
|
|
611
|
+
borderRadius: "var(--semantic-layout-units-cornerradius-full)",
|
|
610
612
|
background: hov ? T.collapsedBtnHover : T.collapsedBtnFill,
|
|
611
|
-
border: `
|
|
613
|
+
border: `var(--contextual-layout-units-button-borderwidth-rest) solid ${hov ? T.collapsedBtnBorderHov : T.collapsedBtnBorder}`,
|
|
612
614
|
transition: "background var(--motion-duration-2) var(--motion-easing-standard), border-color var(--motion-duration-2) var(--motion-easing-standard)",
|
|
613
615
|
}}
|
|
614
616
|
>
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
* accurate; the correct action is still to leave this file alone, name the
|
|
13
13
|
* drift, and wait. Do not resolve it by fetching Figma. See CLAUDE.md 1.1.
|
|
14
14
|
*
|
|
15
|
+
* 2026-11-03: Jo explicitly asked for SideNav to be brought in line with Figma
|
|
16
|
+
* (stripe height and the rest). Pulled from nodes 40003954:284, 40005607:25240,
|
|
17
|
+
* 40004035:27057, 40006794:5975, 40007332:6995 and 40007332:8034. The collapse
|
|
18
|
+
* and expand glyphs stay pinned regardless; see the note at the bottom.
|
|
19
|
+
*
|
|
15
20
|
* Spec: components/sidenav/sidenav-spec.md
|
|
16
21
|
* Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40004059-1375
|
|
17
22
|
* SideNav.Container, a COMPONENT_SET. The id cited here until
|
|
@@ -74,7 +79,10 @@ export const T = {
|
|
|
74
79
|
navHover: c("fill-action-selection-hover"),
|
|
75
80
|
navActive: c("fill-action-selection-selected"),
|
|
76
81
|
navTrail: c("fill-action-selection-trail"),
|
|
77
|
-
|
|
82
|
+
// Container right border (Mode=Stroked) and every divider: Figma binds
|
|
83
|
+
// Stroke/Static/Neutral/Faint on both (40004183:6254, 40006793:3783).
|
|
84
|
+
// Was stroke-static-neutral-base, a rung darker.
|
|
85
|
+
infoSubtle: c("stroke-static-neutral-faint"),
|
|
78
86
|
},
|
|
79
87
|
surface: {
|
|
80
88
|
// Figma binds Fill/Surface/Elevated on SideNav.Container (node 40004059:1375).
|
|
@@ -96,8 +104,9 @@ export const T = {
|
|
|
96
104
|
navHover: c("foreground-action-secondary-hover"),
|
|
97
105
|
navActive: c("foreground-action-secondary-pressed"),
|
|
98
106
|
navDisabled: c("foreground-action-disabled"),
|
|
99
|
-
secondary: c("foreground-static-neutral-
|
|
100
|
-
secondarySubtle: c("foreground-static-neutral-
|
|
107
|
+
secondary: c("foreground-static-neutral-subtle"), // PopoverMenu.SectionHeader
|
|
108
|
+
secondarySubtle: c("foreground-static-neutral-faint"), // NavSectionLabel
|
|
109
|
+
strong: c("foreground-static-neutral-strong"), // PopoverMenu.Item, tooltip
|
|
101
110
|
},
|
|
102
111
|
icon: {
|
|
103
112
|
navBase: c("foreground-action-secondary-rest"),
|
|
@@ -106,10 +115,12 @@ export const T = {
|
|
|
106
115
|
navDisabled: c("foreground-action-disabled"),
|
|
107
116
|
actionSecondary: c("foreground-action-secondary-rest"), // CollapseButton
|
|
108
117
|
},
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
// Figma binds Fill/Action/Selection/Indicator on indicator.stripe (node
|
|
119
|
+
// 40004035:27051). It was fill-action-primary-strong-pressed, which happens
|
|
120
|
+
// to resolve to the same brand-550 today but is not the bound token.
|
|
121
|
+
indicator: c("fill-action-selection-indicator"),
|
|
122
|
+
// Figma binds NavItem/CornerRadius (Contextual) on every SideNavItem variant.
|
|
123
|
+
radius: x("navitem-cornerradius"),
|
|
113
124
|
};
|
|
114
125
|
|
|
115
126
|
/*
|
|
@@ -148,19 +159,43 @@ export const L = {
|
|
|
148
159
|
textPad: u("padding-xtight"),
|
|
149
160
|
childIndent: 24, // no token in Figma, level-1 left indent
|
|
150
161
|
stripeW: 4, // no token in Figma, indicator stripe width
|
|
162
|
+
// Indicator stripe insets, read from Figma 2026-11-03. On a 44px item the
|
|
163
|
+
// stripe sits inside container.indicator (padding 4 top/bottom) and then
|
|
164
|
+
// indicator.stripe (padding 6 top/bottom), so it paints 44 - 8 - 12 = 24px
|
|
165
|
+
// tall. The repo had only the outer 4px, which made it 36px.
|
|
166
|
+
stripeOuterPadV: u("padding-xxxtight"),
|
|
167
|
+
stripeInnerPadV: u("padding-xxtight"),
|
|
168
|
+
stripeRadius: u("cornerradius-base"), // Figma Border Radius/S, 8
|
|
169
|
+
// Expanded rows: container.rowStart padding. Level 0 is 6 (Padding/XXTight),
|
|
170
|
+
// so the leading icon starts at x=10. It was 12, which put it at x=16.
|
|
171
|
+
itemPadH: u("padding-xxtight"),
|
|
172
|
+
// Container.HasChildren: 2px padding around a 16px frame holding a 12px
|
|
173
|
+
// expand_more glyph, 20px wide in total. Was a 40px box with a 10px glyph.
|
|
174
|
+
chevPad: u("padding-xxxxtight"),
|
|
175
|
+
chevFrame: 16, // no token in Figma, icon frame
|
|
176
|
+
chevGlyph: 12, // no token in Figma, expand_more glyph
|
|
177
|
+
// Level 1 (nesting) rows: rowStart 12 + container.main 24 + label 8, so the
|
|
178
|
+
// label starts at x=48 after the 4px indicator column. Was 32 (x=36).
|
|
179
|
+
childRowPadL: u("padding-tight"),
|
|
180
|
+
childMainPadL: u("padding-relaxed"),
|
|
151
181
|
colPadL: u("padding-medium"),
|
|
152
182
|
colPadR: u("padding-tight"),
|
|
153
183
|
collapseGap: u("padding-xxtight"),
|
|
154
184
|
};
|
|
155
185
|
|
|
156
186
|
// ─── IndicatorStripe ──────────────────────────────────────────────────────────
|
|
157
|
-
|
|
187
|
+
// Figma: container.indicator (padding 4 vertical) > indicator.stripe (padding 6
|
|
188
|
+
// vertical) > the 4px bar. `inset` overrides the total vertical inset for the
|
|
189
|
+
// rows where Figma places the stripe differently (SideNavItem.List: 6 only).
|
|
190
|
+
export function IndicatorStripe({ visible, inset }) {
|
|
158
191
|
return (
|
|
159
192
|
<div style={{ width: L.stripeW, alignSelf: "stretch", display: "flex",
|
|
160
193
|
flexDirection: "column", alignItems: "flex-start",
|
|
161
|
-
justifyContent: "center",
|
|
194
|
+
justifyContent: "center",
|
|
195
|
+
padding: inset ? `${inset} 0` : `calc(${L.stripeOuterPadV} + ${L.stripeInnerPadV}) 0`,
|
|
196
|
+
boxSizing: "border-box", flexShrink: 0 }}>
|
|
162
197
|
<div style={{ flex: "1 0 0", width: L.stripeW, minHeight: 1,
|
|
163
|
-
borderRadius:
|
|
198
|
+
borderRadius: `0 ${L.stripeRadius} ${L.stripeRadius} 0`,
|
|
164
199
|
backgroundColor: visible ? T.indicator : "transparent",
|
|
165
200
|
transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
|
|
166
201
|
</div>
|
|
@@ -263,7 +298,7 @@ export function SideNavItem({
|
|
|
263
298
|
// Reported 2026-10-02. Auto margins hand the free space back to
|
|
264
299
|
// both sides, which with the 6px right padding above reproduces
|
|
265
300
|
// Figma's x=9 exactly.
|
|
266
|
-
marginLeft: isSidebarCollapsed ? "auto" : L.
|
|
301
|
+
marginLeft: isSidebarCollapsed ? "auto" : L.itemPadH,
|
|
267
302
|
marginRight: isSidebarCollapsed ? "auto" : undefined,
|
|
268
303
|
color: iconColor }}>
|
|
269
304
|
{typeof item.icon === "function"
|
|
@@ -297,8 +332,10 @@ export function SideNavItem({
|
|
|
297
332
|
// box, so the label still occupied 8px of padding on the rail and ate
|
|
298
333
|
// most of the free space the icon's auto margins needed: the icon
|
|
299
334
|
// landed at x=5 instead of Figma's x=9.
|
|
300
|
-
paddingLeft: isSidebarCollapsed ? 0
|
|
301
|
-
|
|
335
|
+
paddingLeft: isSidebarCollapsed ? 0
|
|
336
|
+
: (isChild ? `calc(${L.childRowPadL} + ${L.childMainPadL} + ${L.textPad})` : L.textPad),
|
|
337
|
+
// container.rowStart's right padding: 6 on Level 0, 12 on Level 1.
|
|
338
|
+
paddingRight: isSidebarCollapsed ? 0 : (isChild ? L.childRowPadL : L.itemPadH),
|
|
302
339
|
maxWidth: isSidebarCollapsed ? 0 : 200,
|
|
303
340
|
opacity: isSidebarCollapsed ? 0 : 1,
|
|
304
341
|
overflow: "hidden",
|
|
@@ -321,16 +358,15 @@ export function SideNavItem({
|
|
|
321
358
|
2026-10-02: two distinct glyph centres, 28.8 and 45.8, where there
|
|
322
359
|
should be one. It is now not rendered at all when collapsed. */}
|
|
323
360
|
{!isChild && hasChildren && !isSidebarCollapsed && (
|
|
324
|
-
<div style={{ width:
|
|
361
|
+
<div style={{ width: `calc(${L.chevFrame}px + 2 * ${L.chevPad})`, height: L.iconWrap, display: "flex",
|
|
325
362
|
alignItems: "center", justifyContent: "center", flexShrink: 0,
|
|
326
|
-
maxWidth: isSidebarCollapsed ? 0 : 40,
|
|
327
363
|
opacity: isSidebarCollapsed ? 0 : 1,
|
|
328
364
|
overflow: "hidden",
|
|
329
365
|
transition: "max-width var(--motion-duration-6) var(--motion-easing-emphasized), opacity var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
330
366
|
{/* Chevron rotation matches accordion timing, --motion-duration-5 + --motion-easing-accordion */}
|
|
331
367
|
<div style={{ transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
|
|
332
368
|
transition: "transform var(--motion-duration-5) var(--motion-easing-accordion)" }}>
|
|
333
|
-
{chevronDown ? chevronDown({ size:
|
|
369
|
+
{chevronDown ? chevronDown({ size: L.chevGlyph, color: iconColor }) : <ChevDown size={L.chevGlyph} color={iconColor} />}
|
|
334
370
|
</div>
|
|
335
371
|
</div>
|
|
336
372
|
)}
|
|
@@ -380,11 +416,18 @@ export function SideNavTooltip({ label, anchorRect, onMouseEnter, onMouseLeave }
|
|
|
380
416
|
top: anchorRect.top + anchorRect.height / 2,
|
|
381
417
|
zIndex: 1000, backgroundColor: T.surface.navLight,
|
|
382
418
|
border: `0.5px solid ${c("stroke-static-neutral-faint")}`, borderRadius: "var(--semantic-layout-units-cornerradius-base)",
|
|
419
|
+
// Figma binds Elevation/Card/Rest here, which has no CSS token yet.
|
|
420
|
+
// Widget is the nearest existing elevation.
|
|
383
421
|
boxShadow: c2("--elevation-widget"),
|
|
384
|
-
padding: "
|
|
422
|
+
padding: "var(--semantic-layout-units-padding-xxtight) var(--semantic-layout-units-padding-xtight)",
|
|
423
|
+
whiteSpace: "nowrap", pointerEvents: "auto",
|
|
385
424
|
animation: "popoverInCentered var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
|
|
425
|
+
{/* Text/Body/Small/Regular, Foreground/Static/Neutral/Strong (Figma
|
|
426
|
+
"Text Container", node 40005607:25190). Letter spacing is Figma's
|
|
427
|
+
Wide, 0.3px, which is the repo's `spacious` token: the repo's `wide`
|
|
428
|
+
is 0.1px. */}
|
|
386
429
|
<span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
|
|
387
|
-
fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-
|
|
430
|
+
fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)", color: T.text.strong }}>
|
|
388
431
|
{label}
|
|
389
432
|
</span>
|
|
390
433
|
</div>,
|
|
@@ -393,17 +436,19 @@ export function SideNavTooltip({ label, anchorRect, onMouseEnter, onMouseLeave }
|
|
|
393
436
|
}
|
|
394
437
|
|
|
395
438
|
// ─── SectionLabel ─────────────────────────────────────────────────────────────
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
439
|
+
// The flyout's header is PopoverMenu.SectionHeader (node 40018992:3361), read
|
|
440
|
+
// from SideNavItem.Collapsed on 2026-11-03: Text/Supporting/Base/Semibold
|
|
441
|
+
// (12px / 600 / 18px / 0.6px), Foreground/Static/Neutral/Subtle, sentence case,
|
|
442
|
+
// padding 6 vertical, 12 left, 8 right. It was the 11px uppercase nav section
|
|
443
|
+
// label in neutral-base with a divider under it, which Figma does not have.
|
|
400
444
|
export function SectionLabel({ label }) {
|
|
401
445
|
return (
|
|
402
|
-
<div style={{ display: "flex", alignItems: "center", width: "100%",
|
|
403
|
-
paddingLeft: "var(--semantic-layout-units-padding-tight)",
|
|
446
|
+
<div style={{ display: "flex", alignItems: "center", width: "100%", boxSizing: "border-box",
|
|
447
|
+
paddingLeft: "var(--semantic-layout-units-padding-tight)", paddingRight: "var(--semantic-layout-units-padding-xtight)",
|
|
448
|
+
paddingTop: "var(--semantic-layout-units-padding-xxtight)", paddingBottom: "var(--semantic-layout-units-padding-xxtight)" }}>
|
|
404
449
|
<span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
|
|
405
|
-
fontSize: "var(--semantic-type-font-size-
|
|
406
|
-
color: T.text.secondary,
|
|
450
|
+
fontSize: "var(--semantic-type-font-size-xs)", lineHeight: "var(--semantic-type-line-height-xs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)",
|
|
451
|
+
color: T.text.secondary,
|
|
407
452
|
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
408
453
|
{label}
|
|
409
454
|
</span>
|
|
@@ -418,14 +463,15 @@ export function CollapsedPopover({ item, onClick, anchorRect, onMouseEnter, onMo
|
|
|
418
463
|
<div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
|
|
419
464
|
style={{ position: "fixed", left: anchorRect.right + 8, top: anchorRect.top,
|
|
420
465
|
zIndex: 1000, backgroundColor: T.surface.navElevated,
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
466
|
+
// Figma "Popover Menu" in SideNavItem.Collapsed: radius Unit/10, Elevation/Lift,
|
|
467
|
+
// 220 wide (220 to 360), Items slot padding 8 vertical, 6 horizontal, gap 2.
|
|
468
|
+
border: `0.5px solid ${c("stroke-static-neutral-faint")}`, borderRadius: "var(--primitive-unit-unit-10)",
|
|
469
|
+
boxShadow: c2("--elevation-lift"),
|
|
470
|
+
padding: "var(--semantic-layout-units-padding-xtight) var(--semantic-layout-units-padding-xxtight)",
|
|
471
|
+
display: "flex", flexDirection: "column", gap: "var(--semantic-layout-units-gap-xxxtight)",
|
|
472
|
+
boxSizing: "border-box", minWidth: 220, maxWidth: 360, pointerEvents: "auto",
|
|
424
473
|
animation: "popoverIn var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
|
|
425
|
-
|
|
426
|
-
<div style={{ borderBottom: `0.5px solid ${c("stroke-static-neutral-faint")}` }}>
|
|
427
|
-
<SectionLabel label={item.label} />
|
|
428
|
-
</div>
|
|
474
|
+
<SectionLabel label={item.label} />
|
|
429
475
|
{item.children.map(child =>
|
|
430
476
|
<PopoverRow key={child.id} item={child} onClick={onClick} activeId={activeId} />
|
|
431
477
|
)}
|
|
@@ -439,19 +485,31 @@ export function PopoverRow({ item, onClick, activeId }) {
|
|
|
439
485
|
const [h, setH] = useState(false);
|
|
440
486
|
const isActive = item.id === activeId;
|
|
441
487
|
const bg = isActive ? T.fill.navActive : h ? T.fill.navHover : "transparent";
|
|
442
|
-
|
|
488
|
+
// PopoverMenu.Item label is Foreground/Static/Neutral/Strong in every state;
|
|
489
|
+
// the selected row is marked by its fill and the stripe, never by the text.
|
|
490
|
+
const color = T.text.strong;
|
|
443
491
|
return (
|
|
444
492
|
<div onClick={() => onClick(item.id)}
|
|
445
493
|
onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
|
|
446
|
-
|
|
494
|
+
// PopoverMenu.Item: min 44, padding 12 all round, radius CornerRadius/Base.
|
|
495
|
+
style={{ position: "relative", display: "flex", alignItems: "center", minHeight: L.itemH,
|
|
496
|
+
boxSizing: "border-box", padding: "var(--semantic-layout-units-padding-tight)",
|
|
497
|
+
borderRadius: "var(--semantic-layout-units-cornerradius-base)",
|
|
447
498
|
overflow: "hidden", backgroundColor: bg, cursor: "pointer",
|
|
448
499
|
transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
500
|
+
{/* Stripe: absolutely placed, inset 8 top and bottom, so 28px on a 44px row. */}
|
|
501
|
+
<div style={{ position: "absolute", left: 0, top: "var(--semantic-layout-units-padding-xtight)",
|
|
502
|
+
bottom: "var(--semantic-layout-units-padding-xtight)", width: L.stripeW,
|
|
503
|
+
borderRadius: `0 ${L.stripeRadius} ${L.stripeRadius} 0`,
|
|
504
|
+
// PopoverMenu.Item's Stripe binds Fill/Action/Primary/Strong/Pressed in
|
|
505
|
+
// Figma (40018914:3149), not the nav's Selection/Indicator. Same value
|
|
506
|
+
// in Light; in Midnight they differ, so this follows Figma.
|
|
507
|
+
backgroundColor: isActive ? c("fill-action-primary-strong-pressed") : "transparent",
|
|
508
|
+
transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
|
|
509
|
+
{/* Label/Menu/Base/Medium: 14 / 500 / 20 / 0.3 (the repo's `spacious`). */}
|
|
510
|
+
<span style={{ fontFamily: "'Red Hat Text',sans-serif",
|
|
511
|
+
fontWeight: 500, fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
|
|
512
|
+
color, flex: 1 }}>
|
|
455
513
|
{item.label}
|
|
456
514
|
</span>
|
|
457
515
|
</div>
|
|
@@ -515,8 +573,9 @@ export function CollapseButton({ isSidebarCollapsed, onToggle, collapseIcon, exp
|
|
|
515
573
|
// ─── SideNav (top-level container) ────────────────────────────────────────────
|
|
516
574
|
// ── NavSectionLabel ──────────────────────────────────────────────────────────
|
|
517
575
|
// Optional in-nav section heading. Figma: SideNav.SectionLabel (node 40006794:5975).
|
|
518
|
-
// Tokens: Label/Section/Small/Semibold (11px/600/16px/0.6px), Foreground/Static/Neutral/
|
|
519
|
-
// Container: h
|
|
576
|
+
// Tokens: Label/Section/Small/Semibold (11px/600/16px/0.6px), Foreground/Static/Neutral/Faint
|
|
577
|
+
// Container: h 40, padding 6 left, 6 right, 12 vertical (read 2026-11-03; was
|
|
578
|
+
// 4 / 4 / 8 in neutral-base)
|
|
520
579
|
// In collapsed rail: hidden, replaced by a Divider (rendered by SideNav itself, see §2.3)
|
|
521
580
|
// ── NavHeader ────────────────────────────────────────────────────────────────
|
|
522
581
|
// Sits at the TOP of the SideNav. 48px row with the expand/collapse action icon
|
|
@@ -532,31 +591,30 @@ export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIc
|
|
|
532
591
|
aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
|
|
533
592
|
role="button" tabIndex={0}
|
|
534
593
|
onKeyDown={e => (e.key === "Enter" || e.key === " ") && onToggle()}
|
|
594
|
+
// Figma SideBar Expand/Collapse: Container.Main 44 tall, the Action.Icon
|
|
595
|
+
// is a 44 touch target holding a 28px Container.Icon (radius Unit/10),
|
|
596
|
+
// which is what takes the hover fill. The whole row used to fill, and
|
|
597
|
+
// the icon box was 36. The glyphs themselves stay pinned (see below).
|
|
535
598
|
style={{ display: "flex", alignItems: "center", height: L.itemH, width: "100%",
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
alignItems: "center", justifyContent: "center" }}>
|
|
551
|
-
{collapseIcon
|
|
552
|
-
? collapseIcon({ size: 12, color: actionIconColor })
|
|
553
|
-
: <RightPanelOpenIcon color={actionIconColor} />}
|
|
554
|
-
</div>
|
|
599
|
+
justifyContent: isSidebarCollapsed ? "center" : "flex-end", cursor: "pointer" }}>
|
|
600
|
+
<div style={{ width: L.itemH, height: L.itemH, display: "flex",
|
|
601
|
+
alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
|
602
|
+
<div style={{ width: 28, height: 28, display: "flex", alignItems: "center",
|
|
603
|
+
justifyContent: "center", borderRadius: "var(--primitive-unit-unit-10)",
|
|
604
|
+
backgroundColor: h ? T.fill.navHover : "transparent",
|
|
605
|
+
transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
606
|
+
{isSidebarCollapsed
|
|
607
|
+
? (expandIcon
|
|
608
|
+
? expandIcon({ size: 12, color: actionIconColor })
|
|
609
|
+
: <LeftPanelOpenIcon color={actionIconColor} />)
|
|
610
|
+
: (collapseIcon
|
|
611
|
+
? collapseIcon({ size: 12, color: actionIconColor })
|
|
612
|
+
: <RightPanelOpenIcon color={actionIconColor} />)}
|
|
555
613
|
</div>
|
|
556
|
-
|
|
614
|
+
</div>
|
|
557
615
|
</div>
|
|
558
|
-
{/* Divider,
|
|
559
|
-
<div style={{ padding: "
|
|
616
|
+
{/* Divider, padding 4 vertical in Figma (was 2) */}
|
|
617
|
+
<div style={{ padding: "var(--semantic-layout-units-padding-xxxtight) 0" }}>
|
|
560
618
|
<div style={{ height: 1, backgroundColor: T.fill.infoSubtle }} />
|
|
561
619
|
</div>
|
|
562
620
|
</div>
|
|
@@ -565,8 +623,8 @@ export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIc
|
|
|
565
623
|
|
|
566
624
|
export function NavSectionLabel({ label }) {
|
|
567
625
|
return (
|
|
568
|
-
<div style={{ display: "flex", alignItems: "center", height: 40, width: "100%",
|
|
569
|
-
paddingLeft: "var(--semantic-layout-units-padding-
|
|
626
|
+
<div style={{ display: "flex", alignItems: "center", height: 40, width: "100%", boxSizing: "border-box",
|
|
627
|
+
paddingLeft: "var(--semantic-layout-units-padding-xxtight)", paddingRight: "var(--semantic-layout-units-padding-xxtight)", paddingTop: "var(--semantic-layout-units-padding-tight)", paddingBottom: "var(--semantic-layout-units-padding-tight)",
|
|
570
628
|
flexShrink: 0 }}>
|
|
571
629
|
<span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
|
|
572
630
|
fontSize: "var(--semantic-type-font-size-xxs)", lineHeight: "var(--semantic-type-line-height-xxs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)",
|
|
@@ -579,12 +637,15 @@ export function NavSectionLabel({ label }) {
|
|
|
579
637
|
}
|
|
580
638
|
|
|
581
639
|
// ── BulletDot ────────────────────────────────────────────────────────────────
|
|
582
|
-
//
|
|
583
|
-
//
|
|
640
|
+
// Leading icon for SideNavListSection's ListItem: Figma's Icon.Leading there is
|
|
641
|
+
// the Material "circle" glyph in a 16px frame (a 13.3px disc), node 40007332:6989.
|
|
642
|
+
// It was a 6px dot. Color cycles Base / Hover / Active just like SideNavItem icons.
|
|
584
643
|
export function BulletDot({ color }) {
|
|
585
644
|
return (
|
|
586
|
-
<
|
|
587
|
-
|
|
645
|
+
<svg width={L.iconInner} height={L.iconInner} viewBox="0 0 16 16" aria-hidden="true"
|
|
646
|
+
style={{ display: "block", flexShrink: 0 }}>
|
|
647
|
+
<circle cx="8" cy="8" r="6.6667" fill={color} />
|
|
648
|
+
</svg>
|
|
588
649
|
);
|
|
589
650
|
}
|
|
590
651
|
|
|
@@ -602,17 +663,21 @@ export function ListItem({ item, isActive, onClick }) {
|
|
|
602
663
|
onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
|
|
603
664
|
role="button" tabIndex={0}
|
|
604
665
|
onKeyDown={e => (e.key === "Enter" || e.key === " ") && onClick && onClick(item.id)}
|
|
605
|
-
|
|
666
|
+
// SideNavItem.List is a fixed 48 tall (was min 44).
|
|
667
|
+
style={{ display: "flex", alignItems: "center", height: 48, minHeight: L.itemH, width: "100%",
|
|
606
668
|
borderRadius: T.radius, backgroundColor: bg, cursor: "pointer",
|
|
607
669
|
overflow: "hidden", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)",
|
|
608
670
|
userSelect: "none" }}>
|
|
609
|
-
|
|
671
|
+
{/* Figma places this stripe with indicator.stripe's 6px inset only, so it
|
|
672
|
+
is 36px on the 48px row. */}
|
|
673
|
+
<IndicatorStripe visible={isActive} inset={L.stripeInnerPadV} />
|
|
610
674
|
<div style={{ width: L.iconWrap, height: L.iconWrap, display: "flex",
|
|
611
675
|
alignItems: "center", justifyContent: "center", flexShrink: 0,
|
|
612
676
|
marginLeft: L.rowPadH }}>
|
|
613
677
|
<BulletDot color={dotColor} />
|
|
614
678
|
</div>
|
|
615
|
-
|
|
679
|
+
{/* text.label padding 8 both sides, then container.rowStart's 12. */}
|
|
680
|
+
<div style={{ flex: 1, minWidth: 0, paddingLeft: L.textPad, paddingRight: `calc(${L.textPad} + ${L.rowPadH})` }}>
|
|
616
681
|
<p style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
|
|
617
682
|
fontSize: "var(--semantic-type-font-size-xs)", lineHeight: "var(--semantic-type-line-height-xs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)", color,
|
|
618
683
|
margin: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
|
@@ -777,7 +842,7 @@ export function SideNav({
|
|
|
777
842
|
boxSizing: "border-box", flexShrink: 0,
|
|
778
843
|
// Smooth-spring curve, soft personality, no overshoot.
|
|
779
844
|
transition: "width var(--motion-duration-6) var(--motion-easing-emphasized)",
|
|
780
|
-
borderRight: `
|
|
845
|
+
borderRight: `1px solid ${T.fill.infoSubtle}`,
|
|
781
846
|
overflow: "hidden", // the inner menu scrolls; the nav itself does not
|
|
782
847
|
}}>
|
|
783
848
|
{/* NavHeader, TOP of nav, PINNED (flexShrink:0) so it stays visible while the
|
|
@@ -897,17 +962,14 @@ export function SideNav({
|
|
|
897
962
|
}
|
|
898
963
|
|
|
899
964
|
// ─── Built-in chevron icons ───────────────────────────────────────────────────
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
);
|
|
906
|
-
}
|
|
907
|
-
function ChevUp({ size = 10, color }) {
|
|
965
|
+
// Material Symbols Rounded expand_more, the exact glyph Figma places in
|
|
966
|
+
// Container.HasChildren (node 40014002:8313), 12px box. The hand-drawn stroke
|
|
967
|
+
// chevron it replaces was heavier and 10px. Up is the same glyph rotated, which
|
|
968
|
+
// is what the item already does when it opens.
|
|
969
|
+
function ChevDown({ size = 12, color }) {
|
|
908
970
|
return (
|
|
909
|
-
<svg width={size} height={size} viewBox="0 0
|
|
910
|
-
<path d="
|
|
971
|
+
<svg width={size} height={size} viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
972
|
+
<path d="M6 7.475C5.93333 7.475 5.87083 7.46458 5.8125 7.44375C5.75417 7.42292 5.7 7.3875 5.65 7.3375L3.35 5.0375C3.25833 4.94583 3.2125 4.82917 3.2125 4.6875C3.2125 4.54583 3.25833 4.42917 3.35 4.3375C3.44167 4.24583 3.55833 4.2 3.7 4.2C3.84167 4.2 3.95833 4.24583 4.05 4.3375L6 6.2875L7.95 4.3375C8.04167 4.24583 8.15833 4.2 8.3 4.2C8.44167 4.2 8.55833 4.24583 8.65 4.3375C8.74167 4.42917 8.7875 4.54583 8.7875 4.6875C8.7875 4.82917 8.74167 4.94583 8.65 5.0375L6.35 7.3375C6.3 7.3875 6.24583 7.42292 6.1875 7.44375C6.12917 7.46458 6.06667 7.475 6 7.475Z" fill={color} />
|
|
911
973
|
</svg>
|
|
912
974
|
);
|
|
913
975
|
}
|
|
@@ -150,7 +150,11 @@ export const T = {
|
|
|
150
150
|
// the dark bar; Jo had Figma rebound to the OrgName token that day.
|
|
151
151
|
orgSubText: SCD("foreground-action-ghost-rest"),
|
|
152
152
|
avatarBg: SCD("fill-action-primary-subtle-rest"),
|
|
153
|
+
avatarBgHover: SCD("fill-action-primary-subtle-hover"),
|
|
154
|
+
avatarBgPressed: SCD("fill-action-primary-subtle-pressed"),
|
|
153
155
|
avatarText: SCD("foreground-action-primary-on-subtle-rest"),
|
|
156
|
+
avatarTextHover: SCD("foreground-action-primary-on-subtle-hover"),
|
|
157
|
+
avatarTextPressed: SCD("foreground-action-primary-on-subtle-pressed"),
|
|
154
158
|
// White dropdown-menu surface, tracks fill-neutral-light (now warm-neutral-0).
|
|
155
159
|
panelBg: SCL("fill-static-neutral-faint"),
|
|
156
160
|
activeItem: SCL("fill-action-primary-subtle-rest"),
|
|
@@ -180,7 +184,7 @@ export const L = {
|
|
|
180
184
|
navH: c("topnav-height"),
|
|
181
185
|
navGap: r("topnav-gap-horizontal"),
|
|
182
186
|
touchTarget: 48, modInnerH: 36,
|
|
183
|
-
orgAvatarNav:
|
|
187
|
+
orgAvatarNav: 16, orgAvatarSm: 24, orgAvatarPanel: 32,
|
|
184
188
|
searchPill: 32, avatarSize: 32,
|
|
185
189
|
// ICON GEOMETRY, read off the leaf nodes of Figma TopNav.Global 40007067:6508
|
|
186
190
|
// on 2026-10-02 rather than inferred from container padding:
|
|
@@ -347,9 +351,12 @@ export const DEFAULT_MODULES = MODULES.map((id) => ({ id, label: MODULE_LABELS[i
|
|
|
347
351
|
*
|
|
348
352
|
* THE STATE FILL BELONGS ON THE 28 BOX, not on the touch target.
|
|
349
353
|
*/
|
|
354
|
+
// TopNav.Actions in Figma (40007082:7313): help, settings, notifications_unread,
|
|
355
|
+
// each a 16 glyph in a 28 layer in a 44 target. This drew "notifications" twice.
|
|
350
356
|
export const DEFAULT_ACTIONS = [
|
|
351
|
-
{ icon: "
|
|
352
|
-
{ icon: "
|
|
357
|
+
{ icon: "help", label: "Help" },
|
|
358
|
+
{ icon: "settings", label: "Settings" },
|
|
359
|
+
{ icon: "notifications_unread", label: "Notifications" },
|
|
353
360
|
];
|
|
354
361
|
|
|
355
362
|
function TopNavActionIcon({ icon, label, onClick }) {
|
|
@@ -358,8 +365,11 @@ function TopNavActionIcon({ icon, label, onClick }) {
|
|
|
358
365
|
const fill = press ? T.controlPressed : hov ? T.controlHover : "transparent";
|
|
359
366
|
const fg = press ? T.monoPressed : hov ? T.monoHover : T.monoBase;
|
|
360
367
|
return (
|
|
368
|
+
// The 44 target is the button itself. It used to be a min-size wrapper
|
|
369
|
+
// with a height:100% button, which a min-height parent does not resolve,
|
|
370
|
+
// so the clickable box collapsed to the 28 layer.
|
|
361
371
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center",
|
|
362
|
-
|
|
372
|
+
width: L.actionTarget, height: L.actionTarget, flexShrink: 0 }}>
|
|
363
373
|
<button type="button" aria-label={label} onClick={onClick}
|
|
364
374
|
onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
|
|
365
375
|
onMouseDown={() => setPress(true)} onMouseUp={() => setPress(false)} onBlur={() => setPress(false)}
|
|
@@ -378,7 +388,8 @@ function TopNavActionIcon({ icon, label, onClick }) {
|
|
|
378
388
|
}
|
|
379
389
|
|
|
380
390
|
export function TopNavActions({ breakpoint = "desktop", actions, onNotifications, onMore }) {
|
|
381
|
-
const items = (actions || DEFAULT_ACTIONS).map((a) => ({
|
|
391
|
+
const items = (actions || DEFAULT_ACTIONS).map((a) => ({
|
|
392
|
+
...a, onClick: a.onClick || (a.icon === "notifications_unread" ? onNotifications : undefined) }));
|
|
382
393
|
if (breakpoint === "mobile" && items.length > 1) {
|
|
383
394
|
return (
|
|
384
395
|
<div style={{ display: "flex", alignItems: "center" }}>
|
|
@@ -393,6 +404,14 @@ export function TopNavActions({ breakpoint = "desktop", actions, onNotifications
|
|
|
393
404
|
);
|
|
394
405
|
}
|
|
395
406
|
|
|
407
|
+
// SideNav.Control's hover and pressed (Figma 40007082:7703): Fill/Action/Ghost on
|
|
408
|
+
// the 32 layer and a Foreground/Action/Ghost glyph. ActionIcon's own ladder is
|
|
409
|
+
// for light pages and painted a pale layer under a dark glyph on the navy bar.
|
|
410
|
+
const GHOST_ACTION_STATES = {
|
|
411
|
+
hover: T.monoHover, pressed: T.monoPressed,
|
|
412
|
+
layerHover: T.controlHover, layerPress: T.controlPressed,
|
|
413
|
+
};
|
|
414
|
+
|
|
396
415
|
// ─── TopNavProfile ─────────────────────────────────────────────────────────────
|
|
397
416
|
export function TopNavProfile({ user, open, onToggle, mobile = false }) {
|
|
398
417
|
const [hov, setHov] = useState(false);
|
|
@@ -410,21 +429,27 @@ export function TopNavProfile({ user, open, onToggle, mobile = false }) {
|
|
|
410
429
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
411
430
|
// Figma TopNav.Profile is 48x48 with pad 4,4,4,4. This was 44 with 6.
|
|
412
431
|
width: L.touchTarget, height: L.touchTarget, boxSizing: "border-box",
|
|
413
|
-
|
|
432
|
+
// The 48 box takes no fill in any state (Figma TopNav.Profile,
|
|
433
|
+
// 40016590:37966): hover and pressed land on the avatar itself, on
|
|
434
|
+
// the Primary/Subtle ladder. This used to paint a Ghost circle
|
|
435
|
+
// behind an avatar that never changed.
|
|
436
|
+
background: "transparent",
|
|
414
437
|
border: "none", borderRadius: "50%", cursor: "pointer", padding: L.profilePad,
|
|
415
438
|
transition: "background var(--motion-duration-2) var(--motion-easing-standard)",
|
|
416
439
|
}}
|
|
417
440
|
>
|
|
418
441
|
<div style={{
|
|
419
442
|
width: L.avatarSize, height: L.avatarSize, borderRadius: "50%",
|
|
420
|
-
background: T.
|
|
443
|
+
background: open ? T.avatarBgPressed : hov ? T.avatarBgHover : T.avatarBg,
|
|
444
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
445
|
+
transition: "background var(--motion-duration-2) var(--motion-easing-standard), color var(--motion-duration-2) var(--motion-easing-standard)",
|
|
421
446
|
// Text/Body/Small/Semibold at EVERY breakpoint, read 2026-10-30. The
|
|
422
447
|
// mobile 11px this used to drop to is not in Figma. Figma names the
|
|
423
448
|
// 0.3 tracking Letter Spacing/Wide; the CSS emits 0.3 as -spacious.
|
|
424
449
|
fontFamily: "var(--semantic-type-family-brand)",
|
|
425
450
|
fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-semibold)",
|
|
426
451
|
lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
|
|
427
|
-
color: T.avatarText, flexShrink: 0, overflow: "hidden",
|
|
452
|
+
color: open ? T.avatarTextPressed : hov ? T.avatarTextHover : T.avatarText, flexShrink: 0, overflow: "hidden",
|
|
428
453
|
}}>
|
|
429
454
|
{user.avatarUrl
|
|
430
455
|
? <img src={user.avatarUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
@@ -482,7 +507,10 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
|
|
|
482
507
|
style={{
|
|
483
508
|
// Container.Main: gap Gap/XXXTight between RowStart and RowEnd,
|
|
484
509
|
// padding 6,6,6,12 desktop and 6,4,6,8 mobile, 36 tall.
|
|
485
|
-
|
|
510
|
+
// Desktop places the chevron INSIDE Container.RowStart, whose gap is
|
|
511
|
+
// Gap/XXTight (4); mobile keeps it at Main level on Gap/XXXTight (2).
|
|
512
|
+
display: "flex", alignItems: "center",
|
|
513
|
+
gap: mobile ? "var(--semantic-layout-units-gap-xxxtight)" : "var(--semantic-layout-units-gap-xxtight)",
|
|
486
514
|
minHeight: L.modInnerH, boxSizing: "border-box", minWidth: 0,
|
|
487
515
|
maxWidth: mobile ? L.orgMainMaxMobile : L.orgMainMax,
|
|
488
516
|
padding: mobile
|
|
@@ -495,7 +523,9 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
|
|
|
495
523
|
// Drawn as an inset shadow because Figma strokes INSIDE the frame:
|
|
496
524
|
// a CSS border adds to the box and made Container.Main 37.5, not 36.
|
|
497
525
|
border: "none",
|
|
498
|
-
|
|
526
|
+
// Hover and pressed thicken to Button/BorderWidth/Selected (1px), as
|
|
527
|
+
// Figma binds them on 40006819:14582 and 40006933:15754.
|
|
528
|
+
boxShadow: `inset 0 0 0 ${open || hov ? "var(--contextual-layout-units-button-borderwidth-selected)" : "var(--contextual-layout-units-button-borderwidth-rest)"} ${open ? T.orgStrokePressed : hov ? T.orgStrokeHover : T.orgStroke}`,
|
|
499
529
|
cursor: "pointer", color: fg, fontFamily: "inherit",
|
|
500
530
|
transition: "background var(--motion-duration-3) var(--motion-easing-standard), box-shadow var(--motion-duration-3) var(--motion-easing-standard)",
|
|
501
531
|
}}
|
|
@@ -505,11 +535,16 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
|
|
|
505
535
|
height: mobile ? 20 : 24, minWidth: 0 }}>
|
|
506
536
|
{/* Logo avatar, only when a URL is provided (Figma "Show Org Avatar", default false) */}
|
|
507
537
|
{hasLogo && (
|
|
508
|
-
|
|
538
|
+
// Figma "Show Org Avatar": a 24 box with Padding/XXXTight, so a 16
|
|
539
|
+
// avatar with a 1px Stroke/Action/Primary/Strong/Rest ring. It was
|
|
540
|
+
// padded 2, a 20 avatar, ringed in Ghost.
|
|
541
|
+
<div style={{ width: L.orgAvatarSm, height: L.orgAvatarSm, padding: "var(--semantic-layout-units-padding-xxxtight)",
|
|
542
|
+
boxSizing: "border-box",
|
|
509
543
|
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
|
510
544
|
<div style={{
|
|
511
545
|
width: L.orgAvatarNav, height: L.orgAvatarNav, borderRadius: L.radiusSm,
|
|
512
|
-
border: `1px solid ${
|
|
546
|
+
border: `1px solid ${SCD("stroke-action-primary-strong-rest")}`,
|
|
547
|
+
boxSizing: "border-box",
|
|
513
548
|
background: "transparent",
|
|
514
549
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
515
550
|
overflow: "hidden", position: "relative", flexShrink: 0,
|
|
@@ -566,12 +601,13 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
|
|
|
566
601
|
*/
|
|
567
602
|
export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint = "desktop", type = "interactive" }) {
|
|
568
603
|
const [hov, setHov] = useState(false);
|
|
604
|
+
const [press, setPress] = useState(false);
|
|
569
605
|
const active = modules.find(m => m.id === activeId) || modules[0];
|
|
570
606
|
const showLabel = breakpoint === "desktop";
|
|
571
607
|
const isStatic = type === "static";
|
|
572
608
|
// The label follows the state (Ghost Rest, Hover, Pressed for Pressed and
|
|
573
609
|
// Open); the mark and the chevron stay on Rest, as in Figma.
|
|
574
|
-
const labelFg = isStatic ? T.monoBase : open ? T.monoPressed : hov ? T.monoHover : T.monoBase;
|
|
610
|
+
const labelFg = isStatic ? T.monoBase : open || press ? T.monoPressed : hov ? T.monoHover : T.monoBase;
|
|
575
611
|
|
|
576
612
|
// Icon + label: identical in both variants. Container.RowStart, gap Gap/XXTight.
|
|
577
613
|
const inner = (
|
|
@@ -608,7 +644,7 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
|
|
|
608
644
|
// Figma ModuleSwitcher Container.Main is pad 0,0,0,6: left only.
|
|
609
645
|
// This was a uniform 4 on all four sides.
|
|
610
646
|
padding: `0 0 0 ${L.modPadL}`, borderRadius: L.radius,
|
|
611
|
-
background: "transparent", border: "
|
|
647
|
+
background: "transparent", border: "none", boxSizing: "border-box",
|
|
612
648
|
color: T.monoBase, fontFamily: "inherit", cursor: "default",
|
|
613
649
|
}}
|
|
614
650
|
>
|
|
@@ -623,24 +659,28 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
|
|
|
623
659
|
// The 48 slot, Padding/XXTight by Padding/XXXTight. This was a raw 4px 2px.
|
|
624
660
|
<div style={{ position: "relative", padding: `${L.slotPadV} ${L.slotPadNarrow}` }}>
|
|
625
661
|
<button
|
|
626
|
-
onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
|
|
662
|
+
onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
|
|
663
|
+
onMouseDown={() => setPress(true)} onMouseUp={() => setPress(false)} onBlur={() => setPress(false)}
|
|
627
664
|
onClick={onToggle}
|
|
628
665
|
aria-haspopup="listbox" aria-expanded={open}
|
|
629
666
|
aria-label={`Switch module: ${active.label}`}
|
|
630
667
|
style={{
|
|
631
|
-
// Container.Main: gap Gap/XXXTight between RowStart and the chevron
|
|
632
|
-
|
|
668
|
+
// Container.Main: gap Gap/XXXTight between RowStart and the chevron
|
|
669
|
+
// on desktop. With the label hidden (tablet, mobile) Figma has no gap:
|
|
670
|
+
// the mark and the chevron touch.
|
|
671
|
+
display: "flex", alignItems: "center", gap: showLabel ? "var(--semantic-layout-units-gap-xxxtight)" : 0,
|
|
633
672
|
maxHeight: L.modInnerH, minHeight: L.modInnerH,
|
|
634
673
|
// Figma ModuleSwitcher Container.Main is pad 0,0,0,6: left only.
|
|
635
674
|
// This was a uniform 4 on all four sides.
|
|
636
675
|
padding: `0 0 0 ${L.modPadL}`, borderRadius: L.radius,
|
|
637
|
-
background: open ? T.controlPressed : hov ? T.controlHover : "transparent",
|
|
638
|
-
// No stroke in any state
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
|
|
676
|
+
background: open || press ? T.controlPressed : hov ? T.controlHover : "transparent",
|
|
677
|
+
// No stroke in any state: Figma's ModuleSwitcher has none. This was a
|
|
678
|
+
// 1px transparent border, which inset the content by 1px (the mark sat
|
|
679
|
+
// at x=7, not 6). The org switcher draws its stroke as an inset
|
|
680
|
+
// shadow, so the two still share the 36 box height.
|
|
681
|
+
border: "none", boxSizing: "border-box",
|
|
642
682
|
cursor: "pointer", color: T.monoBase, fontFamily: "inherit",
|
|
643
|
-
transition: "background var(--motion-duration-3) var(--motion-easing-standard)
|
|
683
|
+
transition: "background var(--motion-duration-3) var(--motion-easing-standard)",
|
|
644
684
|
}}
|
|
645
685
|
>
|
|
646
686
|
{inner}
|
|
@@ -906,7 +946,7 @@ function TopNavNewCo({ navRef, className, breakpoint, org, user, openPanel, togg
|
|
|
906
946
|
gap: isMobile ? "var(--semantic-layout-units-gap-xxtight)" : "var(--semantic-layout-units-gap-tight)" }}>
|
|
907
947
|
{isMobile ? (
|
|
908
948
|
<>
|
|
909
|
-
<ActionIcon name="menu" size="Large" label="Open navigation menu" color={T.monoBase} onClick={onSideNavToggle} />
|
|
949
|
+
<ActionIcon name="menu" size="Large" label="Open navigation menu" color={T.monoBase} states={GHOST_ACTION_STATES} onClick={onSideNavToggle} />
|
|
910
950
|
<PillarTrigger label={brandItem.label} open={openPanel === "pillar"} onToggle={() => toggle("pillar")} />
|
|
911
951
|
{org_}
|
|
912
952
|
</>
|
|
@@ -1128,6 +1168,7 @@ export function TopNav({
|
|
|
1128
1168
|
size="Large"
|
|
1129
1169
|
label="Open navigation menu"
|
|
1130
1170
|
color={T.monoBase}
|
|
1171
|
+
states={GHOST_ACTION_STATES}
|
|
1131
1172
|
onClick={onSideNavToggle}
|
|
1132
1173
|
/>
|
|
1133
1174
|
)}
|