@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/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 +26 -4
- package/README.md +0 -3
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TopNav.Global, Pathway Design System
|
|
3
|
+
*
|
|
4
|
+
* Importable React component module. Source of truth for the TopNav.Global
|
|
5
|
+
* implementation; the standalone demo (top-nav.html) and Storybook stories
|
|
6
|
+
* both consume this.
|
|
7
|
+
*
|
|
8
|
+
* Spec: components/top-nav/top-nav-spec.md
|
|
9
|
+
* Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40007067-6508
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
13
|
+
import { ActionIcon } from "../icon/action-icon.jsx";
|
|
14
|
+
import { Icon } from "../icon/icon.jsx";
|
|
15
|
+
|
|
16
|
+
// TopNav.Search is the canonical nested search control. Import it: never
|
|
17
|
+
// reimplement it here. (See CLAUDE.md §10.1 "import, don't reinvent": the prior
|
|
18
|
+
// in-file duplicate drifted to hardcoded hex and the wrong token mode.)
|
|
19
|
+
import { TopNavSearch, SearchInput } from "../search/search.jsx";
|
|
20
|
+
// The OrgSwitcher "Open" dropdown lives in the org-switcher component (Figma
|
|
21
|
+
// node 40007336:9453). TopNav renders it when the org trigger is open.
|
|
22
|
+
import { OrgSwitcherPanel, DEMO_ORGS } from "../org-switcher/org-switcher.jsx";
|
|
23
|
+
import { ModuleIcon, MODULE_LABELS, MODULES } from "../module-icon/module-icon.jsx";
|
|
24
|
+
|
|
25
|
+
// ─── DESIGN TOKENS ─────────────────────────────────────────────────────────────
|
|
26
|
+
// Bound to live semantic CSS variables (CLAUDE.md §6). The TopNav bar is a dark
|
|
27
|
+
// brand-blue surface, so every CONTROL on it resolves through the DARK-MODE token
|
|
28
|
+
// set (tertiary / primaryinverse / mono). The brand bg, the accent avatar, and the
|
|
29
|
+
// white dropdown panels use the LIGHT-MODE set. Each var() carries the previously
|
|
30
|
+
// hand-copied hex as a fallback, so rendering is unchanged if a var is unavailable;
|
|
31
|
+
// values were verified against get_variable_defs on Figma node 40007067:5284.
|
|
32
|
+
// Both helpers emit the MODELESS name. The mode is no longer baked into the
|
|
33
|
+
// property: it comes from the nearest [data-theme] ancestor, which is why the bar
|
|
34
|
+
// carries data-theme="midnight" and the dropdown panels carry data-theme="light".
|
|
35
|
+
// SCD and SCL are kept as separate names purely to document INTENT at each call
|
|
36
|
+
// site: SCD marks "this sits on the dark bar", SCL marks "this sits on a light
|
|
37
|
+
// panel". They resolve identically; the wrapper does the work.
|
|
38
|
+
// MOTION THE COMPONENT OWNS. The dropdown keyframe tnDropIn is defined in the
|
|
39
|
+
// STORY file, not here, which is why the search takeover had no motion at all:
|
|
40
|
+
// nothing had ever defined one for it, and a component cannot rely on its
|
|
41
|
+
// consumer to supply a keyframe it needs. Reported 2026-10-02.
|
|
42
|
+
//
|
|
43
|
+
// The bar EXPANDS from the control it replaces. clip-path reveals it leftward
|
|
44
|
+
// from the right-hand edge, where the collapsed search icon sits, so the field
|
|
45
|
+
// is uncovered rather than slid into place. Nothing inside translates or
|
|
46
|
+
// scales, so the placeholder and the back arrow stay sharp for the whole
|
|
47
|
+
// transition, which a scaleX would smear. The inset starts at the collapsed
|
|
48
|
+
// control's own width, 48, so the reveal begins exactly where the icon was.
|
|
49
|
+
if (typeof document !== "undefined" && !document.getElementById("pds-topnav-motion")) {
|
|
50
|
+
const el = document.createElement("style");
|
|
51
|
+
el.id = "pds-topnav-motion";
|
|
52
|
+
el.textContent =
|
|
53
|
+
"@keyframes tnSearchExpand{" +
|
|
54
|
+
"from{clip-path:inset(0 0 0 calc(100% - 48px));opacity:.6}" +
|
|
55
|
+
"to{clip-path:inset(0 0 0 0);opacity:1}}" +
|
|
56
|
+
"@media (prefers-reduced-motion: reduce){" +
|
|
57
|
+
"@keyframes tnSearchExpand{from{clip-path:inset(0);opacity:1}to{clip-path:inset(0);opacity:1}}}";
|
|
58
|
+
document.head.appendChild(el);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const TOK = (p) => `var(--semantic-color-${p})`;
|
|
62
|
+
const SCD = TOK; // on the dark bar
|
|
63
|
+
const SCL = TOK; // on a light panel
|
|
64
|
+
// Layout helpers. `r` reads Responsive: Layout, the only collection with
|
|
65
|
+
// breakpoint modes: layout-responsive.css emits ONE property name and switches
|
|
66
|
+
// its value by media query, so the chrome's padding no longer needs a JS
|
|
67
|
+
// breakpoint branch. `u` reads the single-valued semantic scale.
|
|
68
|
+
const r = (name) => `var(--responsive-layout-${name})`;
|
|
69
|
+
const u = (name) => `var(--semantic-layout-units-${name})`;
|
|
70
|
+
// Contextual: Layout & Units. TopNav/Height lives HERE, not in Responsive:
|
|
71
|
+
// Layout, because the bar is the same height at every breakpoint, only its
|
|
72
|
+
// padding and gap change. navH used to read the height through the RESPONSIVE
|
|
73
|
+
// helper, asking for a property that no emitted file declares. The two-argument
|
|
74
|
+
// helper form hid it, because the reference checker skips a call it cannot
|
|
75
|
+
// evaluate statically: the height resolved to a hard-coded 60px fallback for as
|
|
76
|
+
// long as that fallback existed, and would have painted nothing the moment it
|
|
77
|
+
// was removed. Corrected 2026-09-30 while removing the fallbacks.
|
|
78
|
+
const c = (name) => `var(--contextual-layout-units-${name})`;
|
|
79
|
+
export const T = {
|
|
80
|
+
navBg: SCL("fill-surface-chrome"),
|
|
81
|
+
// GHOST IS THIS BAR'S FAMILY. Everything that sits on the brand chrome and
|
|
82
|
+
// answers a pointer is Action/Ghost: the org switcher, the module switcher,
|
|
83
|
+
// the collapsed search, the side nav control, the action icons, the avatar
|
|
84
|
+
// button. All of it was Action/Primary/Subtle, which is a family for LIGHT
|
|
85
|
+
// surfaces, so the whole bar was quietly wearing the wrong tier. Reported
|
|
86
|
+
// 2026-10-02. Measured against Figma the same day:
|
|
87
|
+
//
|
|
88
|
+
// Fill/Action/Ghost/Rest #b6c6ec @ 8%
|
|
89
|
+
// Fill/Action/Ghost/Hover #b6c6ec @ 16%
|
|
90
|
+
// Fill/Action/Ghost/Pressed #22386b @ 70%
|
|
91
|
+
// Stroke/Action/Ghost/Rest #b6c6ec @ 36%
|
|
92
|
+
// Stroke/Action/Ghost/Hover #b6c6ec @ 50%
|
|
93
|
+
// Stroke/Action/Ghost/Pressed #b6c6ec @ 70%
|
|
94
|
+
//
|
|
95
|
+
// Ghost is the right family here for the reason it is the WRONG family on a
|
|
96
|
+
// light page: its foreground is near-white in both modes, which is exactly
|
|
97
|
+
// what a control on a permanently dark bar needs.
|
|
98
|
+
//
|
|
99
|
+
// The AVATAR is the single exception and stays Action/Primary/Subtle: it is a
|
|
100
|
+
// filled identity chip, not a ghost control. See avatarBg/avatarText below.
|
|
101
|
+
//
|
|
102
|
+
// THERE IS NO RESTING FILL on the org switcher or the module switcher. Figma's
|
|
103
|
+
// State=Base has no fill on Container.Main at all, for either of them; the
|
|
104
|
+
// fill arrives on Hover and stays through Pressed and Open. The org switcher
|
|
105
|
+
// does carry a resting STROKE; the module switcher carries nothing at rest.
|
|
106
|
+
// The collapsed search is different again: it is the one control with a
|
|
107
|
+
// resting ghost fill, because it reads as a field rather than a label.
|
|
108
|
+
orgStroke: SCD("stroke-action-ghost-rest"),
|
|
109
|
+
orgStrokeHover: SCD("stroke-action-ghost-hover"),
|
|
110
|
+
orgStrokePressed: SCD("stroke-action-ghost-pressed"),
|
|
111
|
+
searchFill: SCD("fill-action-ghost-rest"),
|
|
112
|
+
controlHover: SCD("fill-action-ghost-hover"),
|
|
113
|
+
controlPressed: SCD("fill-action-ghost-pressed"),
|
|
114
|
+
noLogoBg: SCD("fill-action-secondary-rest"),
|
|
115
|
+
// FOREGROUNDS ARE GHOST TOO. Every glyph and label on the bar is
|
|
116
|
+
// Foreground/Action/Ghost: #e2e9f7 rest, #eef2fb hover, #f9fafd pressed in
|
|
117
|
+
// Light, and #e8eafe / #f0f1ff / #f8f9ff in Midnight. Reported 2026-10-02.
|
|
118
|
+
//
|
|
119
|
+
// They were Foreground/Static/Neutral/Base and /Bold. On screen that looked
|
|
120
|
+
// acceptable, which is exactly why it survived: the bar carries
|
|
121
|
+
// data-theme="midnight" permanently, and inside a midnight region the neutral
|
|
122
|
+
// ladder resolves LIGHT (base is #e8eafe), so a near-black Light-mode value
|
|
123
|
+
// came out near-white by accident. The colour was right by side effect and the
|
|
124
|
+
// token was wrong on its face. Ghost names the thing directly: a control
|
|
125
|
+
// foreground for a dark, on-brand surface, near-white in BOTH modes, so it no
|
|
126
|
+
// longer depends on the data-theme trick to be legible.
|
|
127
|
+
//
|
|
128
|
+
// Before that it was foreground-static-neutral-MONO, which is an inversion
|
|
129
|
+
// anchor rather than a rung: #ffffff in Light and #181b2b in Midnight, so the
|
|
130
|
+
// label, the notification icons and the more-actions icon were all painting
|
|
131
|
+
// #181b2b on #2d4889. It went unnoticed because the one element anybody
|
|
132
|
+
// looked at, the Home module mark, was a hand-drawn SVG with fill="white"
|
|
133
|
+
// baked in, so exactly one thing on the bar was legible and it was legible
|
|
134
|
+
// for the wrong reason.
|
|
135
|
+
//
|
|
136
|
+
// Code Connect does not protect against any of this: it publishes a code
|
|
137
|
+
// snippet from this repo into Figma's dev panel, one way, and never compares
|
|
138
|
+
// a token value in the file against a token value in the design.
|
|
139
|
+
monoBase: SCD("foreground-action-ghost-rest"),
|
|
140
|
+
monoHover: SCD("foreground-action-ghost-hover"),
|
|
141
|
+
monoPressed: SCD("foreground-action-ghost-pressed"),
|
|
142
|
+
// Org switcher label and chevron. Ghost has no weight axis, so the label's
|
|
143
|
+
// emphasis comes from font-weight at the call site, not from a bolder colour.
|
|
144
|
+
orgText: SCD("foreground-action-ghost-rest"),
|
|
145
|
+
orgChevron: SCD("foreground-action-ghost-rest"),
|
|
146
|
+
// SubOrgName, the campus line. Figma binds Foreground/Static/Neutral/Mono
|
|
147
|
+
// here rather than a Ghost rung, so it stays put while OrgName tracks hover.
|
|
148
|
+
// Mono is safe on this bar now that the bar renders Light, where it is
|
|
149
|
+
// #ffffff; it would be wrong the moment anything inverted the bar, which is
|
|
150
|
+
// recorded in the manifest as a Figma-side fragility rather than copied as a
|
|
151
|
+
// pattern.
|
|
152
|
+
orgSubText: SCD("foreground-static-neutral-mono"),
|
|
153
|
+
avatarBg: SCD("fill-action-primary-subtle-rest"),
|
|
154
|
+
avatarText: SCD("foreground-action-primary-on-subtle-rest"),
|
|
155
|
+
// White dropdown-menu surface, tracks fill-neutral-light (now warm-neutral-0).
|
|
156
|
+
panelBg: SCL("fill-static-neutral-faint"),
|
|
157
|
+
activeItem: SCL("fill-action-primary-subtle-rest"),
|
|
158
|
+
itemText: SCL("foreground-static-neutral-bold"),
|
|
159
|
+
itemTextBase: SCL("foreground-static-neutral-base"),
|
|
160
|
+
itemMeta: SCL("foreground-static-neutral-base"),
|
|
161
|
+
signOut: SCL("foreground-action-status-negative-on-subtle-rest"),
|
|
162
|
+
// Token gaps (no semantic token exists yet, flagged P2 in the pipeline report):
|
|
163
|
+
panelBorder: SCL("stroke-static-neutral-base"),
|
|
164
|
+
// The dropdown panels are a LIFT, and Elevation already encodes the whole
|
|
165
|
+
// shadow per theme. The two-layer rgba stack it replaces was a light-mode
|
|
166
|
+
// value that stayed light-mode on a dark surface.
|
|
167
|
+
panelShadow: "var(--elevation-lift)",
|
|
168
|
+
panelDivider: SCL("stroke-static-neutral-faint"),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// ─── LAYOUT VALUES ─────────────────────────────────────────────────────────────
|
|
172
|
+
export const L = {
|
|
173
|
+
// TopNav is chrome that DOES change across breakpoints, so its padding, height
|
|
174
|
+
// and gap live in Responsive: Layout and resolve by media query. One name each,
|
|
175
|
+
// no JS branch. Horizontal padding is 16 / 12 / 8 desktop / tablet / mobile,
|
|
176
|
+
// measured from Container.Main in Figma; vertical is 6 and the gap 8 at every
|
|
177
|
+
// breakpoint. Before 2026-09-17 these were six hardcoded numbers here, and the
|
|
178
|
+
// tablet height said 54 while the token said 56.
|
|
179
|
+
navPadH: r("topnav-padding-horizontal"),
|
|
180
|
+
navPadV: r("topnav-padding-vertical"),
|
|
181
|
+
navH: c("topnav-height"),
|
|
182
|
+
navGap: r("topnav-gap-horizontal"),
|
|
183
|
+
deskOrgMax: 316, mobOrgMax: 120,
|
|
184
|
+
touchTarget: 48, modInnerH: 36,
|
|
185
|
+
orgAvatarNav: 20, orgAvatarSm: 24, orgAvatarPanel: 32,
|
|
186
|
+
searchPill: 32, avatarSize: 32,
|
|
187
|
+
// ICON GEOMETRY, read off the leaf nodes of Figma TopNav.Global 40007067:6508
|
|
188
|
+
// on 2026-10-02 rather than inferred from container padding:
|
|
189
|
+
// ModuleSwitcher Container.RowEnd 20x20 box, expand_more 12x12
|
|
190
|
+
// Org Switcher Container.RowEnd 24x24 box, expand_more 12x12
|
|
191
|
+
// TopNav.Actions Container.Icon 28x28 box, glyph 14x14
|
|
192
|
+
// TopNav.Search Container.Icon 32x32 box, search 16x16
|
|
193
|
+
// The chevron glyph is the SAME 12 in both switchers; only the box differs,
|
|
194
|
+
// which is why one constant serves both.
|
|
195
|
+
// Org Switcher Container.Main padding, Figma 6,6,6,12.
|
|
196
|
+
modPadL: u("padding-xxtight"), // 6 ModuleSwitcher Container.Main, left only
|
|
197
|
+
profilePad: u("padding-xxxtight"), // 4 TopNav.Profile
|
|
198
|
+
orgPadV: u("padding-xxtight"), // 6
|
|
199
|
+
orgPadL: u("padding-tight"), // 12
|
|
200
|
+
orgPadMobileH: u("padding-xxxxtight"),// 2
|
|
201
|
+
chevronGlyph: 12,
|
|
202
|
+
modChevronBox: 20,
|
|
203
|
+
orgChevronBox: 24,
|
|
204
|
+
actionIconBox: 28, actionIconGlyph: 14,
|
|
205
|
+
// Figma's Action Icon is 44, not the bar's 48. It is the one control on the
|
|
206
|
+
// bar whose touch target is the AA minimum rather than the nav's own height.
|
|
207
|
+
actionTarget: u("accessibility-touch-target-aa-width"),
|
|
208
|
+
// The search takeover's back arrow. AA touch target (44) rather than the
|
|
209
|
+
// nav's own 48, because it sits inside the bar's padding and 48 would crowd
|
|
210
|
+
// the field; the glyph is sized independently and centred in the target.
|
|
211
|
+
takeoverExit: u("accessibility-touch-target-aa-width"),
|
|
212
|
+
takeoverExitGlyph: 24,
|
|
213
|
+
radius: u("cornerradius-base"),
|
|
214
|
+
radiusSm: u("cornerradius-small"),
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// ─── ABBREVIATION UTILITIES ────────────────────────────────────────────────────
|
|
218
|
+
const SKIP_WORDS = new Set(["the","a","an","of","in","at","for","and","or","but"]);
|
|
219
|
+
|
|
220
|
+
export function abbreviateOrg(name) {
|
|
221
|
+
if (!name) return "???";
|
|
222
|
+
const sig = name.replace(/-/g," ").split(/\s+/).filter(w => !SKIP_WORDS.has(w.toLowerCase()));
|
|
223
|
+
if (sig.length >= 3) return (sig[0][0]+sig[1][0]+sig[2][0]).toUpperCase();
|
|
224
|
+
if (sig.length === 2) return (sig[0][0]+sig[1][0]+sig[1][0]).toUpperCase();
|
|
225
|
+
return sig[0].slice(0,3).toUpperCase();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const PLACE_SUFFIXES = [
|
|
229
|
+
"ville","field","burg","burgh","berg","town","port","ford",
|
|
230
|
+
"wood","land","dale","view","gate","bridge","worth","shire",
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
export function abbreviateCampus(campus) {
|
|
234
|
+
if (!campus) return "";
|
|
235
|
+
const sig = campus.replace(/-/g," ").split(/\s+/).filter(w => !SKIP_WORDS.has(w.toLowerCase()));
|
|
236
|
+
if (!sig.length) return campus.slice(0,2).toUpperCase();
|
|
237
|
+
if (sig.length >= 2) return (sig[0][0]+sig[1][0]).toUpperCase();
|
|
238
|
+
const w = sig[0], wl = w.toLowerCase();
|
|
239
|
+
for (const sfx of PLACE_SUFFIXES) {
|
|
240
|
+
if (wl.endsWith(sfx) && w.length > sfx.length+1) return (w[0]+sfx[0]).toUpperCase();
|
|
241
|
+
}
|
|
242
|
+
return (w[0]+w[w.length-1]).toUpperCase();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function mobileLabel(orgName, campusName) {
|
|
246
|
+
const org = abbreviateOrg(orgName);
|
|
247
|
+
return campusName ? `${org} | ${abbreviateCampus(campusName)}` : org;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── FIGMA CUSTOM SVG ICONS ────────────────────────────────────────────────────
|
|
251
|
+
// These are custom Figma-exported SVG paths. Do NOT replace with Material Symbols.
|
|
252
|
+
// Source: get_design_context on TopNav.Global desktop/tablet/mobile + no-logo variant.
|
|
253
|
+
|
|
254
|
+
// Module=Home, Style=Flat, Color=White, node I40007067:5241;40006803:50605;40006853:34204
|
|
255
|
+
/**
|
|
256
|
+
* @deprecated Use `<ModuleIcon module="home" />`.
|
|
257
|
+
*
|
|
258
|
+
* Was a hand-drawn 44x44 path traced from Module=Home, Style=Flat, Color=White,
|
|
259
|
+
* carried here because the repo had no module artwork. Module.Icon now ships all
|
|
260
|
+
* twelve marks from the Figma component set, so keeping a second copy of one of
|
|
261
|
+
* them would be keeping a copy that drifts. Delegates rather than being deleted
|
|
262
|
+
* outright, because it is an exported name.
|
|
263
|
+
*/
|
|
264
|
+
export function HomeModuleIcon({ size = 22 }) {
|
|
265
|
+
return <ModuleIcon module="home" size={size} color="mono" />;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Org avatar placeholder shown when an org has no logo on file.
|
|
270
|
+
* Figma: church icon, node 40007243:73426, positioned at inset 4.17% 8.33% 8.33% 8.33%
|
|
271
|
+
* within the 16px inner avatar frame.
|
|
272
|
+
*/
|
|
273
|
+
export function OrgAvatarPlaceholder() {
|
|
274
|
+
return (
|
|
275
|
+
<div style={{ position: "absolute", inset: "4.17% 8.33% 8.33% 8.33%" }}>
|
|
276
|
+
<svg
|
|
277
|
+
viewBox="0 0 13.3333 14" width="100%" height="100%"
|
|
278
|
+
fill="none" xmlns="http://www.w3.org/2000/svg"
|
|
279
|
+
aria-hidden="true" style={{ display: "block" }}
|
|
280
|
+
>
|
|
281
|
+
<path d={CHURCH_ICON_PATH} fill="white" fillOpacity="0.7" />
|
|
282
|
+
</svg>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── MATERIAL SYMBOL HELPER ────────────────────────────────────────────────────
|
|
288
|
+
// Used for all icons EXCEPT the custom Figma SVGs above.
|
|
289
|
+
/* Icon is imported, not redeclared, and that fixes a real defect rather than
|
|
290
|
+
just removing duplication.
|
|
291
|
+
The copy that used to live here hardcoded `'opsz' 20` no matter what `size`
|
|
292
|
+
was. This file draws glyphs at 11, 12, 14 and 22, so every one of them
|
|
293
|
+
except the 20s rendered at the wrong optical size: a 12px chevron at opsz 20
|
|
294
|
+
draws visibly too light, which is exactly the defect that shipped in the
|
|
295
|
+
collapsed SideNav rail and was fixed there months ago. The shared component
|
|
296
|
+
tracks opsz to the frame size. */
|
|
297
|
+
|
|
298
|
+
// ─── MODULE DATA ───────────────────────────────────────────────────────────────
|
|
299
|
+
/**
|
|
300
|
+
* The modules, in Figma's Module.Icon variant order.
|
|
301
|
+
*
|
|
302
|
+
* This list used to name five Material Symbols (home, group, volunteer_activism,
|
|
303
|
+
* event, mail) for five entries, one of which ("events") is not an Amplify
|
|
304
|
+
* module at all. So the switcher showed generic glyphs where the design has
|
|
305
|
+
* bespoke marks, and seven modules were missing entirely.
|
|
306
|
+
*
|
|
307
|
+
* There is no `icon` field any more. The mark is the module: ModuleIcon takes
|
|
308
|
+
* the id directly, so there is nothing to keep in step.
|
|
309
|
+
*/
|
|
310
|
+
export const DEFAULT_MODULES = MODULES.map((id) => ({ id, label: MODULE_LABELS[id] }));
|
|
311
|
+
|
|
312
|
+
// ─── TopNavActions ─────────────────────────────────────────────────────────────
|
|
313
|
+
export function TopNavActions({ breakpoint = "desktop", onNotifications, onMore }) {
|
|
314
|
+
const [hov0, setHov0] = useState(false);
|
|
315
|
+
const [hov1, setHov1] = useState(false);
|
|
316
|
+
const [hovMore, setHovMore] = useState(false);
|
|
317
|
+
|
|
318
|
+
// Figma TopNav.Actions, node 40007082:7313, Amplify section, read 2026-10-02:
|
|
319
|
+
//
|
|
320
|
+
// component 132x48 horizontal, gap 0
|
|
321
|
+
// Action Icon 44x44 three of them, 3 x 44 = 132
|
|
322
|
+
// Container.Icon 28x28 r=8 rest no fill, hover Fill/Action/Ghost/Hover,
|
|
323
|
+
// pressed Fill/Action/Ghost/Pressed
|
|
324
|
+
// glyph 14x14 rest Foreground/Action/Ghost/Rest,
|
|
325
|
+
// hover Foreground/Action/Ghost/Hover
|
|
326
|
+
//
|
|
327
|
+
// THE HOVER FILL BELONGS ON THE 28px BOX, not on the touch target. It was on
|
|
328
|
+
// a 48px button, so the hover state painted a box nearly twice the size of
|
|
329
|
+
// the one in the design. The target is also 44 in Figma, not 48.
|
|
330
|
+
const btnStyle = () => ({
|
|
331
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
332
|
+
width: "100%", height: "100%", padding: 0,
|
|
333
|
+
background: "transparent", border: "none", cursor: "pointer",
|
|
334
|
+
});
|
|
335
|
+
// The box that actually carries the state.
|
|
336
|
+
const iconBoxStyle = (hov) => ({
|
|
337
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
338
|
+
width: L.actionIconBox, height: L.actionIconBox, borderRadius: L.radius,
|
|
339
|
+
background: hov ? T.controlHover : "transparent",
|
|
340
|
+
transition: "background var(--motion-duration-2) var(--motion-easing-standard)",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (breakpoint === "desktop") {
|
|
344
|
+
return (
|
|
345
|
+
<div style={{ display: "flex", alignItems: "center" }}>
|
|
346
|
+
{[
|
|
347
|
+
{ hov: hov0, setHov: setHov0, label: "Notifications" },
|
|
348
|
+
{ hov: hov1, setHov: setHov1, label: "Alerts" },
|
|
349
|
+
].map(({ hov, setHov, label }, idx) => (
|
|
350
|
+
<div key={idx} style={{ display: "flex", alignItems: "center", justifyContent: "center",
|
|
351
|
+
minHeight: L.actionTarget, minWidth: L.actionTarget, padding: 0}}>
|
|
352
|
+
<button
|
|
353
|
+
onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
|
|
354
|
+
onClick={onNotifications} aria-label={label}
|
|
355
|
+
style={btnStyle()}
|
|
356
|
+
>
|
|
357
|
+
<span style={iconBoxStyle(hov)}>
|
|
358
|
+
{/* The glyph tracks hover too: Figma moves it from
|
|
359
|
+
Foreground/Action/Ghost/Rest to /Hover. It used to stay put. */}
|
|
360
|
+
<Icon name="notifications" size={L.actionIconGlyph}
|
|
361
|
+
style={{ color: hov ? T.monoHover : T.monoBase,
|
|
362
|
+
transition: "color var(--motion-duration-2) var(--motion-easing-standard)" }} />
|
|
363
|
+
</span>
|
|
364
|
+
</button>
|
|
365
|
+
</div>
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "center",
|
|
373
|
+
minHeight: L.actionTarget, minWidth: L.actionTarget }}>
|
|
374
|
+
<button
|
|
375
|
+
onMouseEnter={() => setHovMore(true)} onMouseLeave={() => setHovMore(false)}
|
|
376
|
+
onClick={onMore} aria-label="More actions"
|
|
377
|
+
style={btnStyle()}
|
|
378
|
+
>
|
|
379
|
+
<span style={iconBoxStyle(hovMore)}>
|
|
380
|
+
<Icon name="more_vert" size={L.actionIconGlyph}
|
|
381
|
+
style={{ color: hovMore ? T.monoHover : T.monoBase,
|
|
382
|
+
transition: "color var(--motion-duration-2) var(--motion-easing-standard)" }} />
|
|
383
|
+
</span>
|
|
384
|
+
</button>
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── TopNavProfile ─────────────────────────────────────────────────────────────
|
|
390
|
+
export function TopNavProfile({ user, open, onToggle, mobile = false }) {
|
|
391
|
+
const [hov, setHov] = useState(false);
|
|
392
|
+
return (
|
|
393
|
+
<div style={{ position: "relative", display: "flex", alignItems: "center",
|
|
394
|
+
justifyContent: "center", minHeight: L.touchTarget, minWidth: L.touchTarget, padding: "var(--semantic-layout-units-padding-xxxxtight)"}}>
|
|
395
|
+
<button
|
|
396
|
+
onClick={onToggle}
|
|
397
|
+
onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
|
|
398
|
+
aria-haspopup="true" aria-expanded={open}
|
|
399
|
+
aria-label={`Account: ${user.name}`}
|
|
400
|
+
style={{
|
|
401
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
402
|
+
// Figma TopNav.Profile is 48x48 with pad 4,4,4,4. This was 44 with 6.
|
|
403
|
+
width: L.touchTarget, height: L.touchTarget, boxSizing: "border-box",
|
|
404
|
+
background: open ? T.controlPressed : hov ? T.controlHover : "transparent",
|
|
405
|
+
border: "none", borderRadius: "50%", cursor: "pointer", padding: L.profilePad,
|
|
406
|
+
transition: "background var(--motion-duration-2) var(--motion-easing-standard)",
|
|
407
|
+
}}
|
|
408
|
+
>
|
|
409
|
+
<div style={{
|
|
410
|
+
width: L.avatarSize, height: L.avatarSize, borderRadius: "50%",
|
|
411
|
+
background: T.avatarBg, display: "flex", alignItems: "center", justifyContent: "center",
|
|
412
|
+
// Desktop/Tablet: Text/Body/Small/Semibold 14px/600
|
|
413
|
+
// Mobile: Text/Supporting/Small/Semibold 11px/600 (Figma)
|
|
414
|
+
fontSize: mobile ? 11 : 14, fontWeight: 600, letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
|
|
415
|
+
color: T.avatarText, lineHeight: 1, flexShrink: 0, overflow: "hidden",
|
|
416
|
+
}}>
|
|
417
|
+
{user.avatarUrl
|
|
418
|
+
? <img src={user.avatarUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
419
|
+
: user.initials
|
|
420
|
+
}
|
|
421
|
+
</div>
|
|
422
|
+
</button>
|
|
423
|
+
</div>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ─── OrgSwitcher ──────────────────────────────────────────────────────────────
|
|
428
|
+
export function OrgSwitcher({ org, open, onToggle, mobile = false }) {
|
|
429
|
+
const [hov, setHov] = useState(false);
|
|
430
|
+
const [imgFailed, setImgFailed] = useState(false);
|
|
431
|
+
|
|
432
|
+
const hasLogo = org.logoUrl && !imgFailed;
|
|
433
|
+
|
|
434
|
+
// FIGMA HAS TWO TEXT NODES, NOT ONE STRING. OrgName takes
|
|
435
|
+
// Foreground/Action/Ghost/* and follows the hover state; SubOrgName takes
|
|
436
|
+
// Foreground/Static/Neutral/Mono and does not. Concatenating them into
|
|
437
|
+
// "name | campus" gave the campus the org name's colour and made it track
|
|
438
|
+
// hover, which the design does not. Read off set 40006819:14583, 2026-10-02.
|
|
439
|
+
//
|
|
440
|
+
// Mobile still abbreviates into one string, because the mobile variant has a
|
|
441
|
+
// single label with no room for two.
|
|
442
|
+
const label = mobile ? mobileLabel(org.name, org.campus) : org.name;
|
|
443
|
+
const subLabel = mobile ? null : (org.campus || null);
|
|
444
|
+
|
|
445
|
+
// Desktop/Tablet: Label/Button/S, 14px/500/20px
|
|
446
|
+
// Mobile: Label/Button/XS, 12px/500/18px (Figma annotation)
|
|
447
|
+
const labelStyle = mobile
|
|
448
|
+
? { fontSize: "var(--semantic-type-font-size-xs)", fontWeight: 500, lineHeight: "var(--semantic-type-line-height-xs-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)"}
|
|
449
|
+
: { fontSize: "var(--semantic-type-font-size-s)", fontWeight: 500, lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)"};
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<div style={{ position: "relative", padding: mobile ? "4px 2px" : 4,
|
|
453
|
+
maxWidth: mobile ? L.mobOrgMax : L.deskOrgMax }}>
|
|
454
|
+
<button
|
|
455
|
+
onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
|
|
456
|
+
onClick={onToggle}
|
|
457
|
+
aria-haspopup="true" aria-expanded={open}
|
|
458
|
+
aria-label={`Switch organisation: ${org.name}${org.campus ? ", "+org.campus : ""}`}
|
|
459
|
+
style={{
|
|
460
|
+
display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)",
|
|
461
|
+
minHeight: 36,
|
|
462
|
+
// Figma OrgSwitcher Container.Main: pl-12 pr-6 py-4 (updated 2026-06-08 from uniform 4px)
|
|
463
|
+
// Figma Org Switcher Container.Main is pad 6,6,6,12 on desktop, read
|
|
464
|
+
// off set 40006819:14583 on 2026-10-02. The vertical was 4 here, so
|
|
465
|
+
// the trigger sat 4px shorter than the design and out of step with
|
|
466
|
+
// the module switcher beside it.
|
|
467
|
+
padding: mobile ? `${L.orgPadV} ${L.orgPadMobileH}` : `${L.orgPadV} ${L.orgPadV} ${L.orgPadV} ${L.orgPadL}`,
|
|
468
|
+
borderRadius: L.radius,
|
|
469
|
+
// Rest is bare: stroke only, no fill. See the Ghost note in T.
|
|
470
|
+
background: open ? T.controlPressed : hov ? T.controlHover : "transparent",
|
|
471
|
+
border: `1px solid ${open ? T.orgStrokePressed : hov ? T.orgStrokeHover : T.orgStroke}`,
|
|
472
|
+
cursor: "pointer", color: T.monoBase, fontFamily: "inherit",
|
|
473
|
+
transition: "background var(--motion-duration-3) var(--motion-easing-standard), border-color var(--motion-duration-3) var(--motion-easing-standard)",
|
|
474
|
+
}}
|
|
475
|
+
>
|
|
476
|
+
{/* RowStart, org name label only. No avatar or logo displayed in the
|
|
477
|
+
nav trigger per Figma (showOrgAvatar = false by default).
|
|
478
|
+
If org.logoUrl is provided it is still used in the org panel below. */}
|
|
479
|
+
<div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)",
|
|
480
|
+
height: 20, padding: "0 2px" }}>
|
|
481
|
+
{/* Logo avatar, only rendered when a URL is explicitly provided */}
|
|
482
|
+
{hasLogo && (
|
|
483
|
+
<div style={{ width: L.orgAvatarSm, height: L.orgAvatarSm, padding: "var(--semantic-layout-units-padding-xxxxtight)",
|
|
484
|
+
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
|
485
|
+
<div style={{
|
|
486
|
+
width: L.orgAvatarNav, height: L.orgAvatarNav, borderRadius: L.radiusSm,
|
|
487
|
+
border: `1px solid ${T.orgStroke}`,
|
|
488
|
+
background: "transparent",
|
|
489
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
490
|
+
overflow: "hidden", position: "relative", flexShrink: 0,
|
|
491
|
+
}}>
|
|
492
|
+
<img
|
|
493
|
+
src={org.logoUrl} alt=""
|
|
494
|
+
onError={() => setImgFailed(true)}
|
|
495
|
+
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
|
|
496
|
+
/>
|
|
497
|
+
</div>
|
|
498
|
+
</div>
|
|
499
|
+
)}
|
|
500
|
+
{/* Label */}
|
|
501
|
+
<span
|
|
502
|
+
aria-hidden={mobile ? "true" : undefined}
|
|
503
|
+
style={{
|
|
504
|
+
...labelStyle,
|
|
505
|
+
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
|
506
|
+
maxWidth: mobile ? 70 : 248, color: T.orgText, paddingRight: "var(--semantic-layout-units-padding-xxxtight)",
|
|
507
|
+
}}
|
|
508
|
+
>
|
|
509
|
+
{label}
|
|
510
|
+
</span>
|
|
511
|
+
{/* SubOrgName. Its own node in Figma with its own token,
|
|
512
|
+
Foreground/Static/Neutral/Mono, which on this bar resolves to
|
|
513
|
+
#ffffff and does NOT follow the hover state the way OrgName does.
|
|
514
|
+
Figma puts it in Container.CityName.Catholic beside the org name
|
|
515
|
+
with gap 8. */}
|
|
516
|
+
{subLabel && (
|
|
517
|
+
<span
|
|
518
|
+
style={{
|
|
519
|
+
...labelStyle,
|
|
520
|
+
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
|
521
|
+
maxWidth: 110, flexShrink: 0,
|
|
522
|
+
color: T.orgSubText,
|
|
523
|
+
marginLeft: "var(--semantic-layout-units-gap-tight)",
|
|
524
|
+
}}
|
|
525
|
+
>
|
|
526
|
+
{subLabel}
|
|
527
|
+
</span>
|
|
528
|
+
)}
|
|
529
|
+
{mobile && (
|
|
530
|
+
<span style={{ position: "absolute", left: -9999, width: 1, height: 1, overflow: "hidden" }}>
|
|
531
|
+
{org.name}{org.campus ? ", "+org.campus : ""}
|
|
532
|
+
</span>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
{/* Chevron */}
|
|
536
|
+
<div style={{
|
|
537
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
538
|
+
width: 16, height: 16, marginRight: 2,
|
|
539
|
+
transform: open ? "rotate(180deg)" : "none",
|
|
540
|
+
transition: "transform var(--motion-duration-4) var(--motion-easing-standard)",
|
|
541
|
+
}}>
|
|
542
|
+
{/* Figma Org Switcher Container.RowEnd is a 24x24 box holding a 12x12
|
|
543
|
+
expand_more. This was a bare 16px glyph with no box, so the
|
|
544
|
+
chevron was both too large and unaligned with the module
|
|
545
|
+
switcher's, which sits in a 20 box. */}
|
|
546
|
+
<span style={{ width: L.orgChevronBox, height: L.orgChevronBox, display: "inline-flex",
|
|
547
|
+
alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
|
548
|
+
<Icon name="expand_more" size={L.chevronGlyph} style={{ color: T.orgChevron }} />
|
|
549
|
+
</span>
|
|
550
|
+
</div>
|
|
551
|
+
</button>
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ─── ModuleSwitcher ────────────────────────────────────────────────────────────
|
|
557
|
+
/**
|
|
558
|
+
* ModuleSwitcher (app switcher).
|
|
559
|
+
*
|
|
560
|
+
* type, "interactive" (default) | "static" (Figma variant property "Type")
|
|
561
|
+
* "interactive": switch-module control, chevron + hover + pressed + button
|
|
562
|
+
* semantics + aria-haspopup. Used in every module.
|
|
563
|
+
* "static": non-interactive current-module label: NO chevron, NO hover,
|
|
564
|
+
* NO pressed, NOT a button. Used on the Amplify Dashboard, where the module
|
|
565
|
+
* switcher only indicates the current location (there is nowhere to switch
|
|
566
|
+
* to from the dashboard).
|
|
567
|
+
* See top-nav-spec.md §ModuleSwitcher properties + usage-by-context.
|
|
568
|
+
*/
|
|
569
|
+
export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint = "desktop", type = "interactive" }) {
|
|
570
|
+
const [hov, setHov] = useState(false);
|
|
571
|
+
const active = modules.find(m => m.id === activeId) || modules[0];
|
|
572
|
+
const showLabel = breakpoint === "desktop";
|
|
573
|
+
const isStatic = type === "static";
|
|
574
|
+
|
|
575
|
+
// Icon + label: identical in both variants.
|
|
576
|
+
const inner = (
|
|
577
|
+
<div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)",
|
|
578
|
+
paddingRight: showLabel && !isStatic ? 2 : 0 }}>
|
|
579
|
+
{/* The module mark. Mono on the bar: the chrome is brand blue, so the
|
|
580
|
+
mark inherits currentColor and reads as near-white, which is what the
|
|
581
|
+
hand-drawn home icon did for one module and nothing did for the rest. */}
|
|
582
|
+
<div style={{ width: 30, height: 30, display: "flex", alignItems: "center",
|
|
583
|
+
justifyContent: "center", flexShrink: 0, color: T.monoBase }}>
|
|
584
|
+
<ModuleIcon module={active.id} size={24} color="mono" />
|
|
585
|
+
</div>
|
|
586
|
+
{/* Label, desktop only */}
|
|
587
|
+
{showLabel && (
|
|
588
|
+
<span style={{ fontSize: "var(--semantic-type-font-size-s)", fontWeight: 500, lineHeight: "var(--semantic-type-line-height-s-single)",
|
|
589
|
+
letterSpacing: "var(--semantic-type-letter-spacing-spacious)", whiteSpace: "nowrap", overflow: "hidden",
|
|
590
|
+
textOverflow: "ellipsis", maxWidth: 160, color: T.monoBase }}>
|
|
591
|
+
{active.label}
|
|
592
|
+
</span>
|
|
593
|
+
)}
|
|
594
|
+
</div>
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
// STATIC, Amplify Dashboard: current-module label, not a control.
|
|
598
|
+
// No chevron, no hover/pressed, no button role/affordance.
|
|
599
|
+
if (isStatic) {
|
|
600
|
+
return (
|
|
601
|
+
<div style={{ position: "relative", padding: "4px 2px" }}>
|
|
602
|
+
<div
|
|
603
|
+
aria-label={`Current module: ${active.label}`}
|
|
604
|
+
style={{
|
|
605
|
+
display: "flex", alignItems: "center",
|
|
606
|
+
maxHeight: L.modInnerH, minHeight: L.modInnerH,
|
|
607
|
+
// Figma ModuleSwitcher Container.Main is pad 0,0,0,6: left only.
|
|
608
|
+
// This was a uniform 4 on all four sides.
|
|
609
|
+
padding: `0 0 0 ${L.modPadL}`, borderRadius: L.radius,
|
|
610
|
+
background: "transparent", border: "1px solid transparent",
|
|
611
|
+
color: T.monoBase, fontFamily: "inherit", cursor: "default",
|
|
612
|
+
}}
|
|
613
|
+
>
|
|
614
|
+
{inner}
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// INTERACTIVE (default), switch-module control.
|
|
621
|
+
return (
|
|
622
|
+
<div style={{ position: "relative", padding: "4px 2px" }}>
|
|
623
|
+
<button
|
|
624
|
+
onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
|
|
625
|
+
onClick={onToggle}
|
|
626
|
+
aria-haspopup="listbox" aria-expanded={open}
|
|
627
|
+
aria-label={`Switch module: ${active.label}`}
|
|
628
|
+
style={{
|
|
629
|
+
display: "flex", alignItems: "center",
|
|
630
|
+
maxHeight: L.modInnerH, minHeight: L.modInnerH,
|
|
631
|
+
// Figma ModuleSwitcher Container.Main is pad 0,0,0,6: left only.
|
|
632
|
+
// This was a uniform 4 on all four sides.
|
|
633
|
+
padding: `0 0 0 ${L.modPadL}`, borderRadius: L.radius,
|
|
634
|
+
background: open ? T.controlPressed : hov ? T.controlHover : "transparent",
|
|
635
|
+
// No stroke in any state, Figma's ModuleSwitcher has none, unlike the
|
|
636
|
+
// org switcher next to it. Transparent rather than none so the two
|
|
637
|
+
// controls keep the same box height.
|
|
638
|
+
border: "1px solid transparent",
|
|
639
|
+
cursor: "pointer", color: T.monoBase, fontFamily: "inherit",
|
|
640
|
+
transition: "background var(--motion-duration-3) var(--motion-easing-standard), border-color var(--motion-duration-3) var(--motion-easing-standard)",
|
|
641
|
+
}}
|
|
642
|
+
>
|
|
643
|
+
{inner}
|
|
644
|
+
{/* Chevron. Figma ModuleSwitcher Container.RowEnd is a 20x20 box holding a
|
|
645
|
+
12x12 expand_more instance, read off node 40007067:6508 on
|
|
646
|
+
2026-10-02. The glyph was right at 12; the box said 16. */}
|
|
647
|
+
<div style={{
|
|
648
|
+
width: L.modChevronBox, height: L.modChevronBox, display: "flex", alignItems: "center",
|
|
649
|
+
justifyContent: "center", marginLeft: 2,
|
|
650
|
+
transform: open ? "rotate(180deg)" : "none",
|
|
651
|
+
transition: "transform var(--motion-duration-4) var(--motion-easing-standard)",
|
|
652
|
+
}}>
|
|
653
|
+
<Icon name="expand_more" size={12} style={{ color: T.monoBase }} />
|
|
654
|
+
</div>
|
|
655
|
+
</button>
|
|
656
|
+
</div>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ─── TopNav ────────────────────────────────────────────────────────────────────
|
|
661
|
+
/**
|
|
662
|
+
* TopNav.Global component.
|
|
663
|
+
*
|
|
664
|
+
* Props:
|
|
665
|
+
* modules, Array<{id, label, icon}> (default: DEFAULT_MODULES)
|
|
666
|
+
* activeModuleId, string
|
|
667
|
+
* org, { id, name, campus?, initials, logoUrl?, bg? }
|
|
668
|
+
* logoUrl absent or undefined → nav trigger shows org name only
|
|
669
|
+
* (no avatar/placeholder). logoUrl, when provided, renders the logo
|
|
670
|
+
* in the nav trigger AND in the org-panel dropdown.
|
|
671
|
+
* user, { name, initials, email, avatarUrl? }
|
|
672
|
+
* breakpoint, "desktop" | "tablet" | "mobile"
|
|
673
|
+
* onModuleSelect, (id: string) => void
|
|
674
|
+
* onOrgSelect, () => void
|
|
675
|
+
* onSearchOpen, () => void
|
|
676
|
+
* onSideNavToggle, () => void (mobile only)
|
|
677
|
+
* onNotifications, () => void
|
|
678
|
+
* onMore, () => void
|
|
679
|
+
* className, string
|
|
680
|
+
*/
|
|
681
|
+
export function TopNav({
|
|
682
|
+
modules = DEFAULT_MODULES,
|
|
683
|
+
activeModuleId = "home",
|
|
684
|
+
org = { id: "shc", name: "Sacred Heart Church-ITD", campus: "Knoxville", initials: "SH" },
|
|
685
|
+
orgs = DEMO_ORGS, // list shown in the OrgSwitcher "Open" dropdown panel
|
|
686
|
+
user = { name: "Jo Lopez", initials: "JL", email: "jo@sacredheart.org" },
|
|
687
|
+
breakpoint = "desktop",
|
|
688
|
+
moduleSwitcherType = "interactive", // "interactive" | "static" (Amplify Dashboard), Figma "Type"
|
|
689
|
+
initialOpenPanel = null, // "module" | "org" | "profile" | null, seeds the open panel (demo/snapshot use)
|
|
690
|
+
onModuleSelect,
|
|
691
|
+
onOrgSelect,
|
|
692
|
+
onSearchOpen,
|
|
693
|
+
onSideNavToggle,
|
|
694
|
+
onNotifications,
|
|
695
|
+
onMore,
|
|
696
|
+
className = "",
|
|
697
|
+
}) {
|
|
698
|
+
const [openPanel, setOpenPanel] = useState(initialOpenPanel); // "module"|"org"|"profile"|null
|
|
699
|
+
const [currentModuleId, setModuleId] = useState(activeModuleId);
|
|
700
|
+
const [searchExpanded, setSearchExpanded] = useState(false);
|
|
701
|
+
const [searchQuery, setSearchQuery] = useState(""); // shared so the value persists across the collapsed icon ↔ takeover
|
|
702
|
+
const navRef = useRef(null);
|
|
703
|
+
|
|
704
|
+
const isMobile = breakpoint === "mobile";
|
|
705
|
+
const isTablet = breakpoint === "tablet";
|
|
706
|
+
|
|
707
|
+
const toggle = (p) => setOpenPanel(x => x === p ? null : p);
|
|
708
|
+
const close = () => setOpenPanel(null);
|
|
709
|
+
|
|
710
|
+
useEffect(() => {
|
|
711
|
+
const h = (e) => { if (!navRef.current?.contains(e.target)) close(); };
|
|
712
|
+
document.addEventListener("mousedown", h);
|
|
713
|
+
return () => document.removeEventListener("mousedown", h);
|
|
714
|
+
}, []);
|
|
715
|
+
|
|
716
|
+
useEffect(() => {
|
|
717
|
+
const h = (e) => { if (e.key === "Escape") { close(); setSearchExpanded(false); } };
|
|
718
|
+
document.addEventListener("keydown", h);
|
|
719
|
+
return () => document.removeEventListener("keydown", h);
|
|
720
|
+
}, []);
|
|
721
|
+
|
|
722
|
+
const handleModuleSelect = (id) => {
|
|
723
|
+
setModuleId(id);
|
|
724
|
+
close();
|
|
725
|
+
onModuleSelect?.(id);
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
// Search always takes over the whole bar when open (all breakpoints), until
|
|
729
|
+
// it is closed. There is no inline-expand state, opening search overlays the
|
|
730
|
+
// entire TopNav so it can never collide with the OrgSwitcher or other controls.
|
|
731
|
+
const useTakeover = searchExpanded;
|
|
732
|
+
|
|
733
|
+
return (
|
|
734
|
+
// TWO ELEMENTS, AND THE SPLIT IS THE POINT.
|
|
735
|
+
//
|
|
736
|
+
// The bar is brand-coloured, not dark-themed. Those are different things and
|
|
737
|
+
// conflating them was a real bug: data-theme="midnight" used to sit on the
|
|
738
|
+
// <nav> itself, and a [data-theme] applies to the element it is on as well as
|
|
739
|
+
// its descendants, so the bar re-resolved its OWN background through Midnight
|
|
740
|
+
// and painted Fill/Surface/Chrome as #152343. In Figma the bar is #2d4889 and
|
|
741
|
+
// carries no mode override at all, so on a Light page the repo was showing a
|
|
742
|
+
// colour the design does not contain.
|
|
743
|
+
//
|
|
744
|
+
// So the background is read OUT here, in the page's own theme, and the dark
|
|
745
|
+
// region starts INSIDE. The nav keeps position:relative, so the absolutely
|
|
746
|
+
// positioned dropdown panels still anchor to it and nothing about layout
|
|
747
|
+
// moves.
|
|
748
|
+
<div
|
|
749
|
+
style={{
|
|
750
|
+
background: "var(--semantic-color-fill-surface-chrome)",
|
|
751
|
+
position: "relative", zIndex: 100,
|
|
752
|
+
}}
|
|
753
|
+
>
|
|
754
|
+
<nav
|
|
755
|
+
ref={navRef}
|
|
756
|
+
// NO data-theme HERE, AND THAT IS THE FIX FOR THE WHOLE BAR.
|
|
757
|
+
//
|
|
758
|
+
// This carried data-theme="midnight", on the reasoning that a dark bar is
|
|
759
|
+
// a dark region. It is not. Figma renders this bar in LIGHT, and the bar
|
|
760
|
+
// is dark because Fill/Surface/Chrome is itself a dark navy in Light, not
|
|
761
|
+
// because the region is inverted. Measured off node 40007067:6508 on
|
|
762
|
+
// 2026-10-02, reading the resolved paints as the file renders them:
|
|
763
|
+
//
|
|
764
|
+
// bar Container.Main #2d4889 Fill/Surface/Chrome, LIGHT
|
|
765
|
+
// avatar Avatar #eef2fb Fill/Action/Primary/Subtle/Rest, LIGHT
|
|
766
|
+
// initials "JL" #345499 Foreground/Action/Primary/On Subtle/Rest, LIGHT
|
|
767
|
+
//
|
|
768
|
+
// Forcing midnight made every token on the bar resolve to its Midnight
|
|
769
|
+
// value while the bar's own background, which sits outside this element,
|
|
770
|
+
// resolved Light. So the avatar came out #22386b instead of #eef2fb, and
|
|
771
|
+
// the initials with it. It stayed hidden on the Ghost tokens only because
|
|
772
|
+
// those are near-white in both modes, which is also why fixing the tier
|
|
773
|
+
// earlier today did not fix the colours: the tier was right and the mode
|
|
774
|
+
// was wrong.
|
|
775
|
+
//
|
|
776
|
+
// The dropdown panels keep their own data-theme="light" and are unaffected,
|
|
777
|
+
// since light is now the ambient mode rather than an opt-out.
|
|
778
|
+
aria-label="Global navigation"
|
|
779
|
+
className={className}
|
|
780
|
+
style={{
|
|
781
|
+
background: "transparent",
|
|
782
|
+
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
783
|
+
// Figma TopNav.Global is 60 TALL, full stop. maxHeight alone let the
|
|
784
|
+
// vertical padding add to the box, so the bar rendered 64. Measured
|
|
785
|
+
// 2026-10-02. height + border-box makes 60 mean 60.
|
|
786
|
+
height: L.navH, maxHeight: L.navH, boxSizing: "border-box",
|
|
787
|
+
padding: `${L.navPadV} ${L.navPadH}`, gap: L.navGap,
|
|
788
|
+
position: "relative", overflow: "visible",
|
|
789
|
+
fontFamily: "'Red Hat Text', sans-serif",
|
|
790
|
+
}}
|
|
791
|
+
>
|
|
792
|
+
{/* ── Slot.RowStart ── */}
|
|
793
|
+
<div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", flexShrink: 0 }}>
|
|
794
|
+
|
|
795
|
+
{/* SideNav.Control, mobile only. Figma set 40007082:7703: three State
|
|
796
|
+
variants wrapping an `Action Icon` at Size=Large, so a 48x48 target,
|
|
797
|
+
a 32 state layer and a 16 glyph. This was a hand-rolled button with
|
|
798
|
+
a 22px glyph and no state layer at all, which is 6px too big with
|
|
799
|
+
nothing to hover. It now uses the shared ActionIcon, so the three
|
|
800
|
+
states come from the one component the design nests. */}
|
|
801
|
+
{isMobile && (
|
|
802
|
+
<ActionIcon
|
|
803
|
+
name="menu"
|
|
804
|
+
size="Large"
|
|
805
|
+
label="Open navigation menu"
|
|
806
|
+
color={T.monoBase}
|
|
807
|
+
onClick={onSideNavToggle}
|
|
808
|
+
/>
|
|
809
|
+
)}
|
|
810
|
+
|
|
811
|
+
{/* ModuleSwitcher */}
|
|
812
|
+
<ModuleSwitcher
|
|
813
|
+
modules={modules}
|
|
814
|
+
activeId={currentModuleId}
|
|
815
|
+
open={openPanel === "module"}
|
|
816
|
+
onToggle={() => toggle("module")}
|
|
817
|
+
breakpoint={breakpoint}
|
|
818
|
+
type={moduleSwitcherType}
|
|
819
|
+
/>
|
|
820
|
+
|
|
821
|
+
{/* Module dropdown: never opens in static mode (non-interactive) */}
|
|
822
|
+
{openPanel === "module" && moduleSwitcherType !== "static" && (
|
|
823
|
+
<ul role="listbox" data-theme="light" aria-label="Switch module"
|
|
824
|
+
style={{
|
|
825
|
+
position: "absolute", top: "calc(100% + 4px)", left: L.navPadH,
|
|
826
|
+
width: 243, background: T.panelBg,
|
|
827
|
+
border: `1px solid ${T.panelBorder}`, borderRadius: L.radius,
|
|
828
|
+
boxShadow: T.panelShadow, padding: "var(--semantic-layout-units-padding-xxxtight)", zIndex: 300,
|
|
829
|
+
margin: 0, listStyle: "none",
|
|
830
|
+
animation: "tnDropIn var(--motion-duration-4) var(--motion-easing-spring) both",
|
|
831
|
+
}}
|
|
832
|
+
>
|
|
833
|
+
{modules.map(m => (
|
|
834
|
+
<li key={m.id} role="option" aria-selected={m.id === currentModuleId}>
|
|
835
|
+
<button
|
|
836
|
+
onClick={() => handleModuleSelect(m.id)}
|
|
837
|
+
style={{
|
|
838
|
+
display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-medium)",
|
|
839
|
+
padding: "", borderRadius: "var(--semantic-layout-units-cornerradius-base)", width: "100%", textAlign: "left",
|
|
840
|
+
color: m.id === currentModuleId ? T.itemText : T.itemTextBase,
|
|
841
|
+
fontSize: "var(--semantic-type-font-size-s)", fontWeight: m.id === currentModuleId ? 500 : 400,
|
|
842
|
+
background: m.id === currentModuleId ? T.activeItem : "transparent",
|
|
843
|
+
border: "none", fontFamily: "inherit", cursor: "pointer",
|
|
844
|
+
}}
|
|
845
|
+
>
|
|
846
|
+
{/* Full colour here: the panel is a light surface and the
|
|
847
|
+
identity colour is what makes a module recognisable. */}
|
|
848
|
+
<ModuleIcon module={m.id} size={18} />
|
|
849
|
+
{m.label}
|
|
850
|
+
</button>
|
|
851
|
+
</li>
|
|
852
|
+
))}
|
|
853
|
+
</ul>
|
|
854
|
+
)}
|
|
855
|
+
|
|
856
|
+
{/* OrgSwitcher */}
|
|
857
|
+
<OrgSwitcher
|
|
858
|
+
org={org}
|
|
859
|
+
open={openPanel === "org"}
|
|
860
|
+
onToggle={() => { toggle("org"); onOrgSelect?.(); }}
|
|
861
|
+
mobile={isMobile}
|
|
862
|
+
/>
|
|
863
|
+
|
|
864
|
+
{/* Org panel: the real OrgSwitcher "Open" dropdown (Figma 40007336:9453) */}
|
|
865
|
+
{openPanel === "org" && (
|
|
866
|
+
<div style={{
|
|
867
|
+
position: "absolute", top: "calc(100% + 4px)",
|
|
868
|
+
left: isMobile ? L.navPadH : `calc(${L.navPadH} + 40px)`, zIndex: 300,
|
|
869
|
+
animation: "tnDropIn var(--motion-duration-4) var(--motion-easing-spring) both",
|
|
870
|
+
}}>
|
|
871
|
+
<OrgSwitcherPanel
|
|
872
|
+
orgs={orgs}
|
|
873
|
+
activeOrgId={org.id}
|
|
874
|
+
onSelect={(o) => { close(); onOrgSelect?.(o); }}
|
|
875
|
+
/>
|
|
876
|
+
</div>
|
|
877
|
+
)}
|
|
878
|
+
</div>
|
|
879
|
+
|
|
880
|
+
{/* ── Slot.RowEnd ── */}
|
|
881
|
+
<div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", flexShrink: 0 }}>
|
|
882
|
+
|
|
883
|
+
{/* Collapsed search pill. Opening it triggers the full-bar takeover
|
|
884
|
+
overlay below (never an inline expand), so it cannot overlap the
|
|
885
|
+
OrgSwitcher. */}
|
|
886
|
+
<TopNavSearch
|
|
887
|
+
expanded={false}
|
|
888
|
+
onExpandChange={setSearchExpanded}
|
|
889
|
+
breakpoint={breakpoint}
|
|
890
|
+
onSearchOpen={onSearchOpen}
|
|
891
|
+
searchProps={{ value: searchQuery, onChange: setSearchQuery, onClear: () => setSearchQuery("") }}
|
|
892
|
+
/>
|
|
893
|
+
|
|
894
|
+
<TopNavActions breakpoint={breakpoint} onNotifications={onNotifications} onMore={onMore} />
|
|
895
|
+
|
|
896
|
+
<TopNavProfile
|
|
897
|
+
user={user}
|
|
898
|
+
open={openPanel === "profile"}
|
|
899
|
+
onToggle={() => toggle("profile")}
|
|
900
|
+
mobile={isMobile}
|
|
901
|
+
/>
|
|
902
|
+
|
|
903
|
+
{/* Profile menu */}
|
|
904
|
+
{openPanel === "profile" && (
|
|
905
|
+
<div role="menu" data-theme="light"
|
|
906
|
+
style={{
|
|
907
|
+
position: "absolute", top: "calc(100% + 4px)", right: L.navPadH,
|
|
908
|
+
width: 200, background: T.panelBg,
|
|
909
|
+
border: `1px solid ${T.panelBorder}`, borderRadius: L.radius,
|
|
910
|
+
boxShadow: T.panelShadow, padding: "var(--semantic-layout-units-padding-xxxtight)", zIndex: 300,
|
|
911
|
+
animation: "tnDropIn var(--motion-duration-4) var(--motion-easing-spring) both",
|
|
912
|
+
}}
|
|
913
|
+
>
|
|
914
|
+
<div style={{ padding: " ",
|
|
915
|
+
borderBottom: `1px solid ${T.panelDivider}`, marginBottom: 4 }}>
|
|
916
|
+
<div style={{ fontSize: "var(--semantic-type-font-size-s)", fontWeight: 600, color: T.itemText }}>{user.name}</div>
|
|
917
|
+
<div style={{ fontSize: "var(--semantic-type-font-size-xxs)", color: T.itemMeta, marginTop: 1 }}>{user.email}</div>
|
|
918
|
+
</div>
|
|
919
|
+
{["Profile settings", "Settings"].map(label => (
|
|
920
|
+
<button key={label} role="menuitem"
|
|
921
|
+
style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "",
|
|
922
|
+
borderRadius: "var(--semantic-layout-units-cornerradius-base)", fontFamily: "inherit", fontSize: "var(--semantic-type-font-size-s)",
|
|
923
|
+
color: T.itemTextBase, background: "transparent",
|
|
924
|
+
border: "none", width: "100%", textAlign: "left", cursor: "pointer" }}>
|
|
925
|
+
{label}
|
|
926
|
+
</button>
|
|
927
|
+
))}
|
|
928
|
+
<button role="menuitem"
|
|
929
|
+
style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "",
|
|
930
|
+
borderRadius: "var(--semantic-layout-units-cornerradius-base)", fontFamily: "inherit", fontSize: "var(--semantic-type-font-size-s)",
|
|
931
|
+
color: T.signOut, background: "transparent",
|
|
932
|
+
border: "none", width: "100%", textAlign: "left", cursor: "pointer" }}>
|
|
933
|
+
Sign out
|
|
934
|
+
</button>
|
|
935
|
+
</div>
|
|
936
|
+
)}
|
|
937
|
+
</div>
|
|
938
|
+
|
|
939
|
+
{/* Full-width search takeover, shown whenever search is open, on every
|
|
940
|
+
breakpoint. Fills the entire bar (absolute inset:0) so it can never
|
|
941
|
+
collide with the OrgSwitcher.
|
|
942
|
+
Exit is a BACK ARROW to the LEFT of the field, outside it. It used to
|
|
943
|
+
be a ✕ to the right, which sat beside the field's own ✕ for clearing
|
|
944
|
+
typed text: two glyphs of the same shape, inches apart, one throwing
|
|
945
|
+
the query away and one throwing the whole search away. The arrow is a
|
|
946
|
+
different shape, on the opposite side, and says "go back" rather than
|
|
947
|
+
"close". The field's leading glyph is now a plain search mark with no
|
|
948
|
+
action, because giving it a second exit was the same confusion again.
|
|
949
|
+
Escape still closes. */}
|
|
950
|
+
{useTakeover && (
|
|
951
|
+
<div
|
|
952
|
+
// data-theme="light" because the takeover IS the bar, and the bar
|
|
953
|
+
// resolves its chrome in light. Without it the takeover sat inside the
|
|
954
|
+
// bar's own data-theme="midnight" region, where Fill/Surface/Chrome is
|
|
955
|
+
// #152343 rather than #2d4889: so opening search visibly darkened the
|
|
956
|
+
// whole bar. Measured 2026-10-02: bar rgb(45,72,137), takeover
|
|
957
|
+
// rgb(21,35,67).
|
|
958
|
+
data-theme="light"
|
|
959
|
+
style={{
|
|
960
|
+
position: "absolute", inset: 0, zIndex: 200,
|
|
961
|
+
background: "var(--semantic-color-fill-surface-chrome)",
|
|
962
|
+
animation: "tnSearchExpand var(--motion-duration-4) var(--motion-easing-emphasized) both",
|
|
963
|
+
display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)",
|
|
964
|
+
padding: `0 ${L.navPadH}`,
|
|
965
|
+
}}
|
|
966
|
+
>
|
|
967
|
+
{/* Exit, to the LEFT and outside the field, so it can never be read
|
|
968
|
+
as the field's own clear control */}
|
|
969
|
+
<button
|
|
970
|
+
type="button"
|
|
971
|
+
aria-label="Back"
|
|
972
|
+
onClick={() => setSearchExpanded(false)}
|
|
973
|
+
style={{
|
|
974
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
975
|
+
width: L.takeoverExit, height: L.takeoverExit, flexShrink: 0,
|
|
976
|
+
background: "transparent", border: "none", cursor: "pointer",
|
|
977
|
+
borderRadius: L.radius, color: T.monoBase,
|
|
978
|
+
}}
|
|
979
|
+
>
|
|
980
|
+
<Icon name="arrow_back" size={L.takeoverExitGlyph} style={{ color: T.monoBase }} />
|
|
981
|
+
</button>
|
|
982
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
983
|
+
<SearchInput
|
|
984
|
+
value={searchQuery}
|
|
985
|
+
onChange={setSearchQuery}
|
|
986
|
+
onClear={() => setSearchQuery("")}
|
|
987
|
+
inputRef={(el) => el && el.focus()}
|
|
988
|
+
placeholder="Search…"
|
|
989
|
+
/>
|
|
990
|
+
</div>
|
|
991
|
+
</div>
|
|
992
|
+
)}
|
|
993
|
+
</nav>
|
|
994
|
+
</div>
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Canonical compound name: TopNav.Search (the nested search control). The bare
|
|
999
|
+
// `TopNavSearch` export is kept for backward-compatible named imports.
|
|
1000
|
+
TopNav.Search = TopNavSearch;
|
|
1001
|
+
export { TopNavSearch };
|
|
1002
|
+
|
|
1003
|
+
export default TopNav;
|