@helloimjolopez-pathway/pathway-components 0.2.0 → 0.2.2
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.
|
@@ -398,8 +398,8 @@ export function Button({
|
|
|
398
398
|
};
|
|
399
399
|
|
|
400
400
|
// Icon slot wrapper, iconWrap = outer slot size, iconInner = rendered glyph size.
|
|
401
|
-
//
|
|
402
|
-
//
|
|
401
|
+
// opsz 24, as Figma draws its icons, so the glyph fills the slot in Figma's
|
|
402
|
+
// proportion (changed 2026-11-03; opsz 20 drew it about 6% small).
|
|
403
403
|
const iconWrapStyle = {
|
|
404
404
|
display: "inline-flex",
|
|
405
405
|
alignItems: "center",
|
|
@@ -410,7 +410,7 @@ export function Button({
|
|
|
410
410
|
fontSize: sz.iconInner,
|
|
411
411
|
color: iconColor,
|
|
412
412
|
transition: "color var(--motion-duration-2) var(--motion-easing-standard)",
|
|
413
|
-
fontVariationSettings: "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz'
|
|
413
|
+
fontVariationSettings: "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24",
|
|
414
414
|
};
|
|
415
415
|
|
|
416
416
|
// Outer button element, handles the touch-target zone
|
package/components/icon/icon.jsx
CHANGED
|
@@ -34,9 +34,12 @@
|
|
|
34
34
|
* SideNav is FILL 1 throughout; the Widget header is FILL 0.
|
|
35
35
|
* wght 400 everywhere in Pathway.
|
|
36
36
|
* GRAD 0 everywhere in Pathway.
|
|
37
|
-
* opsz
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* opsz 24 everywhere in Pathway (changed 2026-11-03). Figma's icons are the
|
|
38
|
+
* font's opsz-24 artwork scaled to each frame, so the glyph fills its
|
|
39
|
+
* box in the same proportion at every size. Tracking opsz to the frame
|
|
40
|
+
* (which the font clamps to 20 below 20px) redrew each glyph about 6%
|
|
41
|
+
* smaller inside the same box: a 16 help icon drew 12.5 against
|
|
42
|
+
* Figma's 13.33.
|
|
40
43
|
*
|
|
41
44
|
* SIZE IS THE FRAME, NEVER THE VECTOR. Figma nests a wrapper (24), an icon
|
|
42
45
|
* frame (16) and the drawn vector (~12), because the Material grid has about
|
|
@@ -58,8 +61,8 @@ export const ICON_SOURCES = {
|
|
|
58
61
|
webSymbols: "https://github.com/google/material-design-icons/tree/master/symbols/web",
|
|
59
62
|
};
|
|
60
63
|
|
|
61
|
-
/** Pathway's fixed axis values. Only FILL
|
|
62
|
-
export const AXES = { wght: 400, GRAD: 0 };
|
|
64
|
+
/** Pathway's fixed axis values. Only FILL ever varies. */
|
|
65
|
+
export const AXES = { wght: 400, GRAD: 0, opsz: 24 };
|
|
63
66
|
|
|
64
67
|
/**
|
|
65
68
|
* The per-size table from docs/design-system-spec.md §7.2. `wrapper` is the
|
|
@@ -109,10 +112,9 @@ export function Icon({
|
|
|
109
112
|
display: "block",
|
|
110
113
|
flexShrink: 0,
|
|
111
114
|
color,
|
|
112
|
-
// opsz
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
fontVariationSettings: `'FILL' ${fill}, 'wght' ${AXES.wght}, 'GRAD' ${AXES.GRAD}, 'opsz' ${frame}`,
|
|
115
|
+
// opsz is fixed at 24, Figma's artwork, so the glyph fills the frame
|
|
116
|
+
// exactly as it does in Figma. See AXES.
|
|
117
|
+
fontVariationSettings: `'FILL' ${fill}, 'wght' ${AXES.wght}, 'GRAD' ${AXES.GRAD}, 'opsz' ${AXES.opsz}`,
|
|
116
118
|
...style,
|
|
117
119
|
}}
|
|
118
120
|
{...rest}
|
|
@@ -276,10 +276,9 @@ export function SideNavItem({
|
|
|
276
276
|
// parent is centred by its line box, not by its own art, so
|
|
277
277
|
// it rode high inside the 24px wrap.
|
|
278
278
|
display: "block",
|
|
279
|
-
// opsz
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
fontVariationSettings: `'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' ${L.iconInner}` }}>
|
|
279
|
+
// opsz 24, as Figma draws its icons, so the glyph fills the
|
|
280
|
+
// box in Figma's proportion (changed 2026-11-03).
|
|
281
|
+
fontVariationSettings: `'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24` }}>
|
|
283
282
|
{item.icon}
|
|
284
283
|
</span>
|
|
285
284
|
: React.cloneElement(item.icon, { style: { color: iconColor } })}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TopNav's standalone demo. Imports the real module, so there is nothing here
|
|
3
|
+
* that can fall out of step with the shipped component: see
|
|
4
|
+
* components/_demo/harness.jsx for why the newer demos are generated.
|
|
5
|
+
*
|
|
6
|
+
* Replaced the hand-written top-nav.html on 2026-10-30. That page carried its
|
|
7
|
+
* own copy of TopNav, and the copy had drifted from top-nav.jsx: it still
|
|
8
|
+
* abbreviated the mobile org label, still drew the campus line in Mono, and
|
|
9
|
+
* still sized the module mark at 24.
|
|
10
|
+
*/
|
|
11
|
+
import React, { useState } from "react";
|
|
12
|
+
import { DemoChrome, mount } from "../_demo/harness.jsx";
|
|
13
|
+
import { TopNav } from "./top-nav.jsx";
|
|
14
|
+
|
|
15
|
+
const U = (n) => `var(--semantic-layout-units-${n})`;
|
|
16
|
+
const C = (n) => `var(--semantic-color-${n})`;
|
|
17
|
+
const Y = (n) => `var(--semantic-type-${n})`;
|
|
18
|
+
|
|
19
|
+
const BARS = [
|
|
20
|
+
{ id: "desktop", label: "Desktop", width: 1200 },
|
|
21
|
+
{ id: "tablet", label: "Tablet", width: 768 },
|
|
22
|
+
{ id: "mobile", label: "Mobile", width: 393 },
|
|
23
|
+
];
|
|
24
|
+
const ORG = { id: "gcc", name: "Grace Community Church", shortName: "Grace Comm", campus: "Knoxville", initials: "GC" };
|
|
25
|
+
const USER = { name: "Jo Lopez", initials: "JL", email: "jo@gracecommunity.org" };
|
|
26
|
+
|
|
27
|
+
const label = { fontSize: Y("font-size-xs"), lineHeight: Y("line-height-xs-single"), color: C("foreground-static-neutral-subtle") };
|
|
28
|
+
const card = {
|
|
29
|
+
background: C("fill-surface-elevated"), border: `1px solid ${C("stroke-static-neutral-base")}`,
|
|
30
|
+
borderRadius: U("cornerradius-large"), padding: U("padding-relaxed"),
|
|
31
|
+
display: "flex", flexDirection: "column", gap: U("gap-base"), minWidth: 0,
|
|
32
|
+
};
|
|
33
|
+
const h2 = { margin: 0, fontSize: Y("font-size-m"), lineHeight: Y("line-height-m-single"), fontWeight: Y("weight-semibold"), color: C("foreground-static-neutral-bold") };
|
|
34
|
+
|
|
35
|
+
function App() {
|
|
36
|
+
const [showCampus, setShowCampus] = useState(false);
|
|
37
|
+
const [module, setModule] = useState("people");
|
|
38
|
+
const [brand, setBrand] = useState("amplify");
|
|
39
|
+
return (
|
|
40
|
+
<DemoChrome title="TopNav"
|
|
41
|
+
blurb={"The global bar across the top of every Pathway screen. Hover and open the module and org switchers. " +
|
|
42
|
+
"The campus line is off by default, as in Figma; turn it on below."}>
|
|
43
|
+
<div style={{ display: "flex", flexDirection: "column", gap: U("gap-relaxed") }}>
|
|
44
|
+
<section style={card}>
|
|
45
|
+
<h2 style={h2}>Options</h2>
|
|
46
|
+
<label style={{ ...label, display: "flex", alignItems: "center", gap: U("gap-xtight") }}>
|
|
47
|
+
<input id="topnav-show-campus" type="checkbox" checked={showCampus} onChange={(e) => setShowCampus(e.target.checked)} />
|
|
48
|
+
Show campus line (Figma "Show CityName")
|
|
49
|
+
</label>
|
|
50
|
+
<label style={{ ...label, display: "flex", alignItems: "center", gap: U("gap-xtight") }}>
|
|
51
|
+
Brand layout
|
|
52
|
+
<select id="topnav-brand" value={brand} onChange={(e) => setBrand(e.target.value)}>
|
|
53
|
+
<option value="amplify">Amplify</option>
|
|
54
|
+
<option value="newco">NewCo</option>
|
|
55
|
+
</select>
|
|
56
|
+
</label>
|
|
57
|
+
<label style={{ ...label, display: "flex", alignItems: "center", gap: U("gap-xtight") }}>
|
|
58
|
+
Module
|
|
59
|
+
<select id="topnav-module" value={module} onChange={(e) => setModule(e.target.value)}>
|
|
60
|
+
{["home", "people", "giving", "accounting", "communications"].map((m) => <option key={m} value={m}>{m}</option>)}
|
|
61
|
+
</select>
|
|
62
|
+
</label>
|
|
63
|
+
</section>
|
|
64
|
+
{BARS.map((b) => (
|
|
65
|
+
<section key={b.id} style={card}>
|
|
66
|
+
<h2 style={h2}>{b.label}</h2>
|
|
67
|
+
<div style={{ overflowX: "auto" }}>
|
|
68
|
+
<div style={{ width: b.width }}>
|
|
69
|
+
<TopNav key={module + brand} brand={brand} breakpoint={b.id} activeModuleId={module} org={ORG} user={USER} showCampus={showCampus} />
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</section>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
</DemoChrome>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
mount(<App />);
|
|
@@ -143,13 +143,12 @@ export const T = {
|
|
|
143
143
|
// emphasis comes from font-weight at the call site, not from a bolder colour.
|
|
144
144
|
orgText: SCD("foreground-action-ghost-rest"),
|
|
145
145
|
orgChevron: SCD("foreground-action-ghost-rest"),
|
|
146
|
-
// SubOrgName, the campus line.
|
|
147
|
-
//
|
|
148
|
-
// Mono
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
orgSubText: SCD("foreground-static-neutral-mono"),
|
|
146
|
+
// SubOrgName, the campus line. Same Ghost rung as OrgName in every state,
|
|
147
|
+
// tracking hover and pressed with it. Figma bound it to
|
|
148
|
+
// Foreground/Static/Neutral/Mono until 2026-10-30, which inverts to a
|
|
149
|
+
// near-black in every dark mode and made the campus unreadable on
|
|
150
|
+
// the dark bar; Jo had Figma rebound to the OrgName token that day.
|
|
151
|
+
orgSubText: SCD("foreground-action-ghost-rest"),
|
|
153
152
|
avatarBg: SCD("fill-action-primary-subtle-rest"),
|
|
154
153
|
avatarText: SCD("foreground-action-primary-on-subtle-rest"),
|
|
155
154
|
// White dropdown-menu surface, tracks fill-neutral-light (now warm-neutral-0).
|
|
@@ -180,7 +179,6 @@ export const L = {
|
|
|
180
179
|
navPadV: r("topnav-padding-vertical"),
|
|
181
180
|
navH: c("topnav-height"),
|
|
182
181
|
navGap: r("topnav-gap-horizontal"),
|
|
183
|
-
deskOrgMax: 316, mobOrgMax: 120,
|
|
184
182
|
touchTarget: 48, modInnerH: 36,
|
|
185
183
|
orgAvatarNav: 20, orgAvatarSm: 24, orgAvatarPanel: 32,
|
|
186
184
|
searchPill: 32, avatarSize: 32,
|
|
@@ -192,16 +190,33 @@ export const L = {
|
|
|
192
190
|
// TopNav.Search Container.Icon 32x32 box, search 16x16
|
|
193
191
|
// The chevron glyph is the SAME 12 in both switchers; only the box differs,
|
|
194
192
|
// which is why one constant serves both.
|
|
195
|
-
// Org Switcher Container.Main padding, Figma 6,6,6,12.
|
|
193
|
+
// Org Switcher Container.Main padding, Figma 6,6,6,12; mobile 6,4,6,8.
|
|
196
194
|
modPadL: u("padding-xxtight"), // 6 ModuleSwitcher Container.Main, left only
|
|
197
195
|
profilePad: u("padding-xxxtight"), // 4 TopNav.Profile
|
|
198
196
|
orgPadV: u("padding-xxtight"), // 6
|
|
199
197
|
orgPadL: u("padding-tight"), // 12
|
|
200
|
-
|
|
198
|
+
orgPadMobileR: u("padding-xxxtight"), // 4
|
|
199
|
+
orgPadMobileL: u("padding-xtight"), // 8
|
|
200
|
+
// The 48 box around ModuleSwitcher and Org Switcher: Padding/XXTight top and
|
|
201
|
+
// bottom; ModuleSwitcher and the mobile Org Switcher take Padding/XXXTight at
|
|
202
|
+
// the sides, the desktop Org Switcher Padding/XXTight. Read 2026-10-30; these
|
|
203
|
+
// were raw 4px and 4px 2px, so both switchers sat 4px short of the design.
|
|
204
|
+
slotPadV: u("padding-xxtight"), // 6
|
|
205
|
+
slotPadNarrow: u("padding-xxxtight"), // 4
|
|
206
|
+
// Container.Main max width, Figma 308 desktop, 102 mobile. OrgName truncates
|
|
207
|
+
// at 200, the campus line at 72.
|
|
208
|
+
orgMainMax: 308, orgMainMaxMobile: 102, orgNameMax: 200, orgCampusMax: 72,
|
|
209
|
+
// The mobile org label is a 52-wide text box in Figma (both brands), so the
|
|
210
|
+
// short name truncates at 52, not at the trigger's 102.
|
|
211
|
+
orgNameMaxMobile: 52,
|
|
212
|
+
// Module.Icon is 16 inside a 20 Container.IconLeading. It was 24 in a 30.
|
|
213
|
+
moduleMark: 16, moduleMarkBox: 20,
|
|
201
214
|
chevronGlyph: 12,
|
|
202
215
|
modChevronBox: 20,
|
|
203
216
|
orgChevronBox: 24,
|
|
204
|
-
|
|
217
|
+
// Action Icon Size=Base: 16 glyph in a 28 Container.Icon. The glyph was 14,
|
|
218
|
+
// which also put it below the icon font's smallest optical size.
|
|
219
|
+
actionIconBox: 28, actionIconGlyph: 16,
|
|
205
220
|
// Figma's Action Icon is 44, not the bar's 48. It is the one control on the
|
|
206
221
|
// bar whose touch target is the AA minimum rather than the nav's own height.
|
|
207
222
|
actionTarget: u("accessibility-touch-target-aa-width"),
|
|
@@ -310,78 +325,70 @@ export function OrgAvatarPlaceholder() {
|
|
|
310
325
|
export const DEFAULT_MODULES = MODULES.map((id) => ({ id, label: MODULE_LABELS[id] }));
|
|
311
326
|
|
|
312
327
|
// ─── TopNavActions ─────────────────────────────────────────────────────────────
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
328
|
+
/**
|
|
329
|
+
* TopNav.Actions, Figma 40007082:7313. A SLOT (Figma property "Slot.Actions"),
|
|
330
|
+
* not a fixed pair of bells: a product puts whatever action icons it needs in
|
|
331
|
+
* it, one bell, or a bell, a cog and a help icon. Each item renders as Figma's
|
|
332
|
+
* nested Action Icon at Size=Base.
|
|
333
|
+
*
|
|
334
|
+
* actions Array<{ icon, label, onClick }>. `icon` is a Material Symbols
|
|
335
|
+
* ligature, `label` the accessible name. Defaults to the two
|
|
336
|
+
* notification bells the bar has always shipped with.
|
|
337
|
+
*
|
|
338
|
+
* MOBILE FOLDS. On mobile, more than one action folds into a single more_vert
|
|
339
|
+
* ("More actions"), which is what Figma's mobile variant shows. One action on
|
|
340
|
+
* its own stays as itself.
|
|
341
|
+
*
|
|
342
|
+
* Action Icon Size=Base, read 2026-10-30 and 2026-11-02:
|
|
343
|
+
* target accessibility-touch-target-aa (44)
|
|
344
|
+
* Container.Icon 28, cornerradius-base: rest none, hover
|
|
345
|
+
* Fill/Action/Ghost/Hover, pressed Fill/Action/Ghost/Pressed
|
|
346
|
+
* glyph 16: Foreground/Action/Ghost/Rest, /Hover, /Pressed
|
|
347
|
+
*
|
|
348
|
+
* THE STATE FILL BELONGS ON THE 28 BOX, not on the touch target.
|
|
349
|
+
*/
|
|
350
|
+
export const DEFAULT_ACTIONS = [
|
|
351
|
+
{ icon: "notifications", label: "Notifications" },
|
|
352
|
+
{ icon: "notifications", label: "Alerts" },
|
|
353
|
+
];
|
|
317
354
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
355
|
+
function TopNavActionIcon({ icon, label, onClick }) {
|
|
356
|
+
const [hov, setHov] = useState(false);
|
|
357
|
+
const [press, setPress] = useState(false);
|
|
358
|
+
const fill = press ? T.controlPressed : hov ? T.controlHover : "transparent";
|
|
359
|
+
const fg = press ? T.monoPressed : hov ? T.monoHover : T.monoBase;
|
|
360
|
+
return (
|
|
361
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "center",
|
|
362
|
+
minHeight: L.actionTarget, minWidth: L.actionTarget }}>
|
|
363
|
+
<button type="button" aria-label={label} onClick={onClick}
|
|
364
|
+
onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
|
|
365
|
+
onMouseDown={() => setPress(true)} onMouseUp={() => setPress(false)} onBlur={() => setPress(false)}
|
|
366
|
+
style={{ display: "flex", alignItems: "center", justifyContent: "center",
|
|
367
|
+
width: "100%", height: "100%", padding: 0,
|
|
368
|
+
background: "transparent", border: "none", cursor: "pointer" }}>
|
|
369
|
+
<span style={{ display: "flex", alignItems: "center", justifyContent: "center",
|
|
370
|
+
width: L.actionIconBox, height: L.actionIconBox, borderRadius: L.radius, background: fill,
|
|
371
|
+
transition: "background var(--motion-duration-2) var(--motion-easing-standard)" }}>
|
|
372
|
+
<Icon name={icon} size={L.actionIconGlyph}
|
|
373
|
+
style={{ color: fg, transition: "color var(--motion-duration-2) var(--motion-easing-standard)" }} />
|
|
374
|
+
</span>
|
|
375
|
+
</button>
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function TopNavActions({ breakpoint = "desktop", actions, onNotifications, onMore }) {
|
|
381
|
+
const items = (actions || DEFAULT_ACTIONS).map((a) => ({ ...a, onClick: a.onClick || onNotifications }));
|
|
382
|
+
if (breakpoint === "mobile" && items.length > 1) {
|
|
344
383
|
return (
|
|
345
384
|
<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
|
-
))}
|
|
385
|
+
<TopNavActionIcon icon="more_vert" label="More actions" onClick={onMore} />
|
|
367
386
|
</div>
|
|
368
387
|
);
|
|
369
388
|
}
|
|
370
|
-
|
|
371
389
|
return (
|
|
372
|
-
<div style={{ display: "flex", alignItems: "center"
|
|
373
|
-
|
|
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>
|
|
390
|
+
<div style={{ display: "flex", alignItems: "center" }}>
|
|
391
|
+
{items.map((a, i) => <TopNavActionIcon key={`${a.icon}-${i}`} {...a} />)}
|
|
385
392
|
</div>
|
|
386
393
|
);
|
|
387
394
|
}
|
|
@@ -390,8 +397,10 @@ export function TopNavActions({ breakpoint = "desktop", onNotifications, onMore
|
|
|
390
397
|
export function TopNavProfile({ user, open, onToggle, mobile = false }) {
|
|
391
398
|
const [hov, setHov] = useState(false);
|
|
392
399
|
return (
|
|
400
|
+
// Figma TopNav.Profile is the 48 box itself. This wrapper added
|
|
401
|
+
// Padding/XXXXTight around the 48 button, so the profile took 52.
|
|
393
402
|
<div style={{ position: "relative", display: "flex", alignItems: "center",
|
|
394
|
-
justifyContent: "center", minHeight: L.touchTarget, minWidth: L.touchTarget
|
|
403
|
+
justifyContent: "center", minHeight: L.touchTarget, minWidth: L.touchTarget }}>
|
|
395
404
|
<button
|
|
396
405
|
onClick={onToggle}
|
|
397
406
|
onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
|
|
@@ -409,10 +418,13 @@ export function TopNavProfile({ user, open, onToggle, mobile = false }) {
|
|
|
409
418
|
<div style={{
|
|
410
419
|
width: L.avatarSize, height: L.avatarSize, borderRadius: "50%",
|
|
411
420
|
background: T.avatarBg, display: "flex", alignItems: "center", justifyContent: "center",
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
421
|
+
// Text/Body/Small/Semibold at EVERY breakpoint, read 2026-10-30. The
|
|
422
|
+
// mobile 11px this used to drop to is not in Figma. Figma names the
|
|
423
|
+
// 0.3 tracking Letter Spacing/Wide; the CSS emits 0.3 as -spacious.
|
|
424
|
+
fontFamily: "var(--semantic-type-family-brand)",
|
|
425
|
+
fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-semibold)",
|
|
426
|
+
lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
|
|
427
|
+
color: T.avatarText, flexShrink: 0, overflow: "hidden",
|
|
416
428
|
}}>
|
|
417
429
|
{user.avatarUrl
|
|
418
430
|
? <img src={user.avatarUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
@@ -425,60 +437,73 @@ export function TopNavProfile({ user, open, onToggle, mobile = false }) {
|
|
|
425
437
|
}
|
|
426
438
|
|
|
427
439
|
// ─── OrgSwitcher ──────────────────────────────────────────────────────────────
|
|
428
|
-
|
|
440
|
+
/**
|
|
441
|
+
* Org Switcher, Figma set 40006819:14583, read 2026-10-30.
|
|
442
|
+
*
|
|
443
|
+
* showCampus Figma "Show CityName (Catholic)", BOOLEAN, default false. The
|
|
444
|
+
* campus line is off unless a product turns it on. It was shown
|
|
445
|
+
* whenever an org had a campus, so every demo carried one.
|
|
446
|
+
* org.shortName Figma "Label-Mobile", TEXT. The mobile trigger shows this
|
|
447
|
+
* short name, truncated. Without one it falls back to the
|
|
448
|
+
* spec's initials rule (§10.2), with the campus initials only
|
|
449
|
+
* when showCampus is on.
|
|
450
|
+
*
|
|
451
|
+
* OrgName, the campus line and the trigger all follow the state together:
|
|
452
|
+
* Foreground/Action/Ghost/Rest, Hover, then Pressed for Pressed and Open. The
|
|
453
|
+
* chevron stays on Rest in every state, as in Figma.
|
|
454
|
+
*/
|
|
455
|
+
export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus = false }) {
|
|
429
456
|
const [hov, setHov] = useState(false);
|
|
430
457
|
const [imgFailed, setImgFailed] = useState(false);
|
|
431
458
|
|
|
432
459
|
const hasLogo = org.logoUrl && !imgFailed;
|
|
460
|
+
const label = mobile
|
|
461
|
+
? (org.shortName || mobileLabel(org.name, showCampus ? org.campus : null))
|
|
462
|
+
: org.name;
|
|
463
|
+
const campus = !mobile && showCampus && org.campus ? org.campus : null;
|
|
464
|
+
const fg = open ? T.monoPressed : hov ? T.monoHover : T.monoBase;
|
|
433
465
|
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
|
|
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)"};
|
|
466
|
+
// Label/Button/Small at every breakpoint, mobile included (read 2026-11-02;
|
|
467
|
+
// the 12px Label/Button/XS mobile size is not in Figma). Figma binds its
|
|
468
|
+
// tracking to the PRIMITIVE Letter Spacing/Wide (0.3); the semantic CSS
|
|
469
|
+
// name for 0.3 is -spacious.
|
|
470
|
+
const labelStyle = { fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-medium)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)"};
|
|
471
|
+
const truncate = { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", minWidth: 0 };
|
|
450
472
|
|
|
451
473
|
return (
|
|
452
|
-
|
|
453
|
-
|
|
474
|
+
// The 48 slot: Padding/XXTight all round on desktop, XXTight by XXXTight on mobile.
|
|
475
|
+
<div style={{ position: "relative", minWidth: 0,
|
|
476
|
+
padding: mobile ? `${L.slotPadV} ${L.slotPadNarrow}` : L.slotPadV }}>
|
|
454
477
|
<button
|
|
455
478
|
onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
|
|
456
479
|
onClick={onToggle}
|
|
457
480
|
aria-haspopup="true" aria-expanded={open}
|
|
458
481
|
aria-label={`Switch organisation: ${org.name}${org.campus ? ", "+org.campus : ""}`}
|
|
459
482
|
style={{
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
483
|
+
// Container.Main: gap Gap/XXXTight between RowStart and RowEnd,
|
|
484
|
+
// padding 6,6,6,12 desktop and 6,4,6,8 mobile, 36 tall.
|
|
485
|
+
display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxxtight)",
|
|
486
|
+
minHeight: L.modInnerH, boxSizing: "border-box", minWidth: 0,
|
|
487
|
+
maxWidth: mobile ? L.orgMainMaxMobile : L.orgMainMax,
|
|
488
|
+
padding: mobile
|
|
489
|
+
? `${L.orgPadV} ${L.orgPadMobileR} ${L.orgPadV} ${L.orgPadMobileL}`
|
|
490
|
+
: `${L.orgPadV} ${L.orgPadV} ${L.orgPadV} ${L.orgPadL}`,
|
|
468
491
|
borderRadius: L.radius,
|
|
469
492
|
// Rest is bare: stroke only, no fill. See the Ghost note in T.
|
|
470
493
|
background: open ? T.controlPressed : hov ? T.controlHover : "transparent",
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
494
|
+
// Button/BorderWidth/Rest, as Figma binds it. This was a raw 1px.
|
|
495
|
+
// Drawn as an inset shadow because Figma strokes INSIDE the frame:
|
|
496
|
+
// a CSS border adds to the box and made Container.Main 37.5, not 36.
|
|
497
|
+
border: "none",
|
|
498
|
+
boxShadow: `inset 0 0 0 var(--contextual-layout-units-button-borderwidth-rest) ${open ? T.orgStrokePressed : hov ? T.orgStrokeHover : T.orgStroke}`,
|
|
499
|
+
cursor: "pointer", color: fg, fontFamily: "inherit",
|
|
500
|
+
transition: "background var(--motion-duration-3) var(--motion-easing-standard), box-shadow var(--motion-duration-3) var(--motion-easing-standard)",
|
|
474
501
|
}}
|
|
475
502
|
>
|
|
476
|
-
{/* RowStart
|
|
477
|
-
nav trigger per Figma (showOrgAvatar = false by default).
|
|
478
|
-
If org.logoUrl is provided it is still used in the org panel below. */}
|
|
503
|
+
{/* Container.RowStart: 24 tall desktop, 20 mobile, gap Gap/XXTight. */}
|
|
479
504
|
<div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)",
|
|
480
|
-
height: 20,
|
|
481
|
-
{/* Logo avatar, only
|
|
505
|
+
height: mobile ? 20 : 24, minWidth: 0 }}>
|
|
506
|
+
{/* Logo avatar, only when a URL is provided (Figma "Show Org Avatar", default false) */}
|
|
482
507
|
{hasLogo && (
|
|
483
508
|
<div style={{ width: L.orgAvatarSm, height: L.orgAvatarSm, padding: "var(--semantic-layout-units-padding-xxxxtight)",
|
|
484
509
|
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
|
@@ -497,57 +522,30 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false }) {
|
|
|
497
522
|
</div>
|
|
498
523
|
</div>
|
|
499
524
|
)}
|
|
500
|
-
{/*
|
|
501
|
-
<
|
|
502
|
-
aria-hidden=
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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}
|
|
525
|
+
{/* Container.OrgName: OrgName and the campus line, gap Gap/Tight. */}
|
|
526
|
+
<div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", minWidth: 0 }}>
|
|
527
|
+
<span aria-hidden="true"
|
|
528
|
+
style={{ ...labelStyle, ...truncate, maxWidth: mobile ? L.orgNameMaxMobile : L.orgNameMax,
|
|
529
|
+
color: fg, transition: "color var(--motion-duration-2) var(--motion-easing-standard)" }}>
|
|
530
|
+
{label}
|
|
527
531
|
</span>
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
532
|
+
{campus && (
|
|
533
|
+
<span aria-hidden="true"
|
|
534
|
+
style={{ ...labelStyle, ...truncate, maxWidth: L.orgCampusMax, flexShrink: 0,
|
|
535
|
+
color: open ? T.monoPressed : hov ? T.monoHover : T.orgSubText,
|
|
536
|
+
transition: "color var(--motion-duration-2) var(--motion-easing-standard)" }}>
|
|
537
|
+
{`| ${campus}`}
|
|
538
|
+
</span>
|
|
539
|
+
)}
|
|
540
|
+
</div>
|
|
534
541
|
</div>
|
|
535
|
-
{/*
|
|
536
|
-
<
|
|
537
|
-
display: "flex", alignItems: "center", justifyContent: "center",
|
|
538
|
-
width: 16, height: 16, marginRight: 2,
|
|
542
|
+
{/* Container.RowEnd: 24 box desktop, 20 mobile, 12 expand_more on Ghost/Rest. */}
|
|
543
|
+
<span style={{ width: mobile ? L.modChevronBox : L.orgChevronBox, height: mobile ? L.modChevronBox : L.orgChevronBox,
|
|
544
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
|
539
545
|
transform: open ? "rotate(180deg)" : "none",
|
|
540
|
-
transition: "transform var(--motion-duration-4) var(--motion-easing-standard)"
|
|
541
|
-
|
|
542
|
-
|
|
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>
|
|
546
|
+
transition: "transform var(--motion-duration-4) var(--motion-easing-standard)" }}>
|
|
547
|
+
<Icon name="expand_more" size={L.chevronGlyph} style={{ color: T.orgChevron }} />
|
|
548
|
+
</span>
|
|
551
549
|
</button>
|
|
552
550
|
</div>
|
|
553
551
|
);
|
|
@@ -571,23 +569,26 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
|
|
|
571
569
|
const active = modules.find(m => m.id === activeId) || modules[0];
|
|
572
570
|
const showLabel = breakpoint === "desktop";
|
|
573
571
|
const isStatic = type === "static";
|
|
572
|
+
// The label follows the state (Ghost Rest, Hover, Pressed for Pressed and
|
|
573
|
+
// 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;
|
|
574
575
|
|
|
575
|
-
// Icon + label: identical in both variants.
|
|
576
|
+
// Icon + label: identical in both variants. Container.RowStart, gap Gap/XXTight.
|
|
576
577
|
const inner = (
|
|
577
|
-
<div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)"
|
|
578
|
-
paddingRight: showLabel && !isStatic ? 2 : 0 }}>
|
|
578
|
+
<div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)" }}>
|
|
579
579
|
{/* The module mark. Mono on the bar: the chrome is brand blue, so the
|
|
580
580
|
mark inherits currentColor and reads as near-white, which is what the
|
|
581
581
|
hand-drawn home icon did for one module and nothing did for the rest. */}
|
|
582
|
-
<div style={{ width:
|
|
582
|
+
<div style={{ width: L.moduleMarkBox, height: L.moduleMarkBox, display: "flex", alignItems: "center",
|
|
583
583
|
justifyContent: "center", flexShrink: 0, color: T.monoBase }}>
|
|
584
|
-
<ModuleIcon module={active.id} size={
|
|
584
|
+
<ModuleIcon module={active.id} size={L.moduleMark} color="mono" />
|
|
585
585
|
</div>
|
|
586
586
|
{/* Label, desktop only */}
|
|
587
587
|
{showLabel && (
|
|
588
|
-
<span style={{ fontSize: "var(--semantic-type-font-size-s)", fontWeight:
|
|
588
|
+
<span style={{ fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-medium)", lineHeight: "var(--semantic-type-line-height-s-single)",
|
|
589
589
|
letterSpacing: "var(--semantic-type-letter-spacing-spacious)", whiteSpace: "nowrap", overflow: "hidden",
|
|
590
|
-
textOverflow: "ellipsis", maxWidth: 160, color:
|
|
590
|
+
textOverflow: "ellipsis", maxWidth: 160, color: labelFg,
|
|
591
|
+
transition: "color var(--motion-duration-2) var(--motion-easing-standard)" }}>
|
|
591
592
|
{active.label}
|
|
592
593
|
</span>
|
|
593
594
|
)}
|
|
@@ -598,7 +599,7 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
|
|
|
598
599
|
// No chevron, no hover/pressed, no button role/affordance.
|
|
599
600
|
if (isStatic) {
|
|
600
601
|
return (
|
|
601
|
-
<div style={{ position: "relative", padding:
|
|
602
|
+
<div style={{ position: "relative", padding: `${L.slotPadV} ${L.slotPadNarrow}` }}>
|
|
602
603
|
<div
|
|
603
604
|
aria-label={`Current module: ${active.label}`}
|
|
604
605
|
style={{
|
|
@@ -619,14 +620,16 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
|
|
|
619
620
|
|
|
620
621
|
// INTERACTIVE (default), switch-module control.
|
|
621
622
|
return (
|
|
622
|
-
|
|
623
|
+
// The 48 slot, Padding/XXTight by Padding/XXXTight. This was a raw 4px 2px.
|
|
624
|
+
<div style={{ position: "relative", padding: `${L.slotPadV} ${L.slotPadNarrow}` }}>
|
|
623
625
|
<button
|
|
624
626
|
onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
|
|
625
627
|
onClick={onToggle}
|
|
626
628
|
aria-haspopup="listbox" aria-expanded={open}
|
|
627
629
|
aria-label={`Switch module: ${active.label}`}
|
|
628
630
|
style={{
|
|
629
|
-
|
|
631
|
+
// Container.Main: gap Gap/XXXTight between RowStart and the chevron.
|
|
632
|
+
display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxxtight)",
|
|
630
633
|
maxHeight: L.modInnerH, minHeight: L.modInnerH,
|
|
631
634
|
// Figma ModuleSwitcher Container.Main is pad 0,0,0,6: left only.
|
|
632
635
|
// This was a uniform 4 on all four sides.
|
|
@@ -646,17 +649,312 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
|
|
|
646
649
|
2026-10-02. The glyph was right at 12; the box said 16. */}
|
|
647
650
|
<div style={{
|
|
648
651
|
width: L.modChevronBox, height: L.modChevronBox, display: "flex", alignItems: "center",
|
|
649
|
-
justifyContent: "center",
|
|
652
|
+
justifyContent: "center",
|
|
650
653
|
transform: open ? "rotate(180deg)" : "none",
|
|
651
654
|
transition: "transform var(--motion-duration-4) var(--motion-easing-standard)",
|
|
652
655
|
}}>
|
|
653
|
-
<Icon name="expand_more" size={
|
|
656
|
+
<Icon name="expand_more" size={L.chevronGlyph} style={{ color: T.monoBase }} />
|
|
654
657
|
</div>
|
|
655
658
|
</button>
|
|
656
659
|
</div>
|
|
657
660
|
);
|
|
658
661
|
}
|
|
659
662
|
|
|
663
|
+
// ─── TopNavProfileMenu ─────────────────────────────────────────────────────────
|
|
664
|
+
// The open profile menu. A separate pattern from the bar (see the spec's scope
|
|
665
|
+
// note); shared by both brand layouts so there is one copy of it.
|
|
666
|
+
function TopNavProfileMenu({ user }) {
|
|
667
|
+
return (
|
|
668
|
+
<div role="menu" data-theme="light"
|
|
669
|
+
style={{
|
|
670
|
+
position: "absolute", top: "calc(100% + 4px)", right: L.navPadH,
|
|
671
|
+
width: 200, background: T.panelBg,
|
|
672
|
+
border: `1px solid ${T.panelBorder}`, borderRadius: L.radius,
|
|
673
|
+
boxShadow: T.panelShadow, padding: "var(--semantic-layout-units-padding-xxxtight)", zIndex: 300,
|
|
674
|
+
animation: "tnDropIn var(--motion-duration-4) var(--motion-easing-spring) both",
|
|
675
|
+
}}
|
|
676
|
+
>
|
|
677
|
+
<div style={{ padding: " ",
|
|
678
|
+
borderBottom: `1px solid ${T.panelDivider}`, marginBottom: 4 }}>
|
|
679
|
+
<div style={{ fontSize: "var(--semantic-type-font-size-s)", fontWeight: 600, color: T.itemText }}>{user.name}</div>
|
|
680
|
+
<div style={{ fontSize: "var(--semantic-type-font-size-xxs)", color: T.itemMeta, marginTop: 1 }}>{user.email}</div>
|
|
681
|
+
</div>
|
|
682
|
+
{["Profile settings", "Settings"].map(label => (
|
|
683
|
+
<button key={label} role="menuitem"
|
|
684
|
+
style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "",
|
|
685
|
+
borderRadius: "var(--semantic-layout-units-cornerradius-base)", fontFamily: "inherit", fontSize: "var(--semantic-type-font-size-s)",
|
|
686
|
+
color: T.itemTextBase, background: "transparent",
|
|
687
|
+
border: "none", width: "100%", textAlign: "left", cursor: "pointer" }}>
|
|
688
|
+
{label}
|
|
689
|
+
</button>
|
|
690
|
+
))}
|
|
691
|
+
<button role="menuitem"
|
|
692
|
+
style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "",
|
|
693
|
+
borderRadius: "var(--semantic-layout-units-cornerradius-base)", fontFamily: "inherit", fontSize: "var(--semantic-type-font-size-s)",
|
|
694
|
+
color: T.signOut, background: "transparent",
|
|
695
|
+
border: "none", width: "100%", textAlign: "left", cursor: "pointer" }}>
|
|
696
|
+
Sign out
|
|
697
|
+
</button>
|
|
698
|
+
</div>
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ─── Brand=NewCo layout ────────────────────────────────────────────────────────
|
|
703
|
+
/**
|
|
704
|
+
* Figma TopNav.Global, Brand=NewCo (40018199:57659 desktop, 40018199:59368
|
|
705
|
+
* tablet, 40018199:59418 mobile), read 2026-11-03. A different layout from
|
|
706
|
+
* Amplify, not a recolour of it:
|
|
707
|
+
*
|
|
708
|
+
* desktop logo, divider, Pillar (brand tab + module tabs) | Org Switcher,
|
|
709
|
+
* search, help, settings, notifications, profile
|
|
710
|
+
* tablet the same RowStart | Org Switcher (mobile type), search,
|
|
711
|
+
* notifications, profile
|
|
712
|
+
* mobile SideNav.Control, Pillar trigger, Org Switcher (mobile type) |
|
|
713
|
+
* more_vert, profile
|
|
714
|
+
*
|
|
715
|
+
* The action icons are NewCo's own line icons (Figma icon/search, icon/help,
|
|
716
|
+
* icon/gear, icon/bell), not Material Symbols. Their paths are the Figma
|
|
717
|
+
* exports, drawn at 16 with a 1.44 stroke, as the instances are.
|
|
718
|
+
*/
|
|
719
|
+
const NC_ICONS = {
|
|
720
|
+
search: ["M11 17.5C14.5899 17.5 17.5 14.5899 17.5 11C17.5 7.41015 14.5899 4.5 11 4.5C7.41015 4.5 4.5 7.41015 4.5 11C4.5 14.5899 7.41015 17.5 11 17.5Z", "M20 20L15.8 15.8"],
|
|
721
|
+
help: ["M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z", "M9.5 9.49999C9.50157 9.04964 9.62476 8.60808 9.85656 8.22196C10.0884 7.83584 10.4202 7.5195 10.8169 7.30638C11.2136 7.09325 11.6606 6.99125 12.1105 7.01116C12.5604 7.03106 12.9966 7.17212 13.3729 7.41945C13.7493 7.66677 14.0519 8.01117 14.2487 8.41625C14.4455 8.82133 14.5292 9.27205 14.491 9.72078C14.4528 10.1695 14.2941 10.5996 14.0316 10.9656C13.7692 11.3316 13.4128 11.6199 13 11.8C12.3 12.1 12 12.7 12 13.5M12 17H12.01"],
|
|
722
|
+
gear: ["M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z", "M19.4 15C19.2737 15.2975 19.2349 15.6249 19.288 15.9437C19.3411 16.2625 19.4841 16.5595 19.7 16.8L19.8 16.9C20.0292 17.0719 20.2189 17.2911 20.356 17.5427C20.4932 17.7943 20.5747 18.0724 20.595 18.3582C20.6153 18.644 20.5739 18.9309 20.4737 19.1994C20.3735 19.4678 20.2168 19.7116 20.0142 19.9142C19.8116 20.1168 19.5678 20.2735 19.2994 20.3737C19.0309 20.4739 18.744 20.5153 18.4582 20.495C18.1724 20.4747 17.8943 20.3932 17.6427 20.256C17.3911 20.1189 17.1719 19.9292 17 19.7L16.9 19.6C16.6595 19.3841 16.3625 19.2411 16.0437 19.188C15.7249 19.1349 15.3975 19.1737 15.1 19.3C14.8094 19.4311 14.5616 19.6413 14.3848 19.9065C14.208 20.1718 14.1093 20.4814 14.1 20.8V21C14.1 21.5304 13.8893 22.0391 13.5142 22.4142C13.1391 22.7893 12.6304 23 12.1 23C11.5696 23 11.0609 22.7893 10.6858 22.4142C10.3107 22.0391 10.1 21.5304 10.1 21V20.9C10.0822 20.5701 9.96871 20.2526 9.77335 19.9862C9.578 19.7198 9.30927 19.5161 9 19.4C8.70249 19.2737 8.37514 19.2349 8.05634 19.288C7.73753 19.3411 7.44048 19.4841 7.2 19.7L7.1 19.8C6.92807 20.0292 6.7089 20.2189 6.45731 20.356C6.20572 20.4932 5.9276 20.5747 5.64178 20.595C5.35595 20.6153 5.0691 20.5739 4.80065 20.4737C4.53219 20.3735 4.2884 20.2168 4.08579 20.0142C3.88317 19.8116 3.72646 19.5678 3.62626 19.2994C3.52606 19.0309 3.48472 18.744 3.50503 18.4582C3.52534 18.1724 3.60684 17.8943 3.74399 17.6427C3.88115 17.3911 4.07076 17.1719 4.3 17L4.4 16.9C4.61594 16.6595 4.75885 16.3625 4.81199 16.0437C4.86512 15.7249 4.82627 15.3975 4.7 15.1C4.56888 14.8094 4.3587 14.5616 4.09347 14.3848C3.82824 14.208 3.51863 14.1093 3.2 14.1H3C2.46957 14.1 1.96086 13.8893 1.58579 13.5142C1.21071 13.1391 1 12.6304 1 12.1C1 11.5696 1.21071 11.0609 1.58579 10.6858C1.96086 10.3107 2.46957 10.1 3 10.1H3.1C3.42986 10.0822 3.7474 9.96871 4.01379 9.77335C4.28018 9.578 4.48389 9.30927 4.6 9C4.72627 8.70249 4.76512 8.37514 4.71199 8.05634C4.65885 7.73753 4.51594 7.44048 4.3 7.2L4.2 7.1C3.97076 6.92807 3.78115 6.7089 3.64399 6.45731C3.50684 6.20572 3.42534 5.9276 3.40503 5.64178C3.38472 5.35595 3.42606 5.0691 3.52626 4.80065C3.62646 4.53219 3.78317 4.2884 3.98579 4.08579C4.1884 3.88317 4.43219 3.72646 4.70065 3.62626C4.9691 3.52606 5.25595 3.48472 5.54178 3.50503C5.8276 3.52534 6.10572 3.60684 6.35731 3.74399C6.6089 3.88115 6.82807 4.07076 7 4.3L7.1 4.4C7.34048 4.61594 7.63753 4.75885 7.95634 4.81199C8.27514 4.86512 8.60249 4.82627 8.9 4.7H9C9.29055 4.56888 9.53839 4.3587 9.71521 4.09347C9.89203 3.82824 9.99071 3.51863 10 3.2V3C10 2.46957 10.2107 1.96086 10.5858 1.58579C10.9609 1.21071 11.4696 1 12 1C12.5304 1 13.0391 1.21071 13.4142 1.58579C13.7893 1.96086 14 2.46957 14 3V3.1C14.0093 3.41863 14.108 3.72824 14.2848 3.99347C14.4616 4.2587 14.7094 4.46888 15 4.6C15.2975 4.72627 15.6249 4.76512 15.9437 4.71199C16.2625 4.65885 16.5595 4.51594 16.8 4.3L16.9 4.2C17.0719 3.97076 17.2911 3.78115 17.5427 3.64399C17.7943 3.50684 18.0724 3.42534 18.3582 3.40503C18.644 3.38472 18.9309 3.42606 19.1994 3.52626C19.4678 3.62646 19.7116 3.78317 19.9142 3.98579C20.1168 4.1884 20.2735 4.43219 20.3737 4.70065C20.4739 4.9691 20.5153 5.25595 20.495 5.54178C20.4747 5.8276 20.3932 6.10572 20.256 6.35731C20.1189 6.6089 19.9292 6.82807 19.7 7L19.6 7.1C19.3841 7.34048 19.2411 7.63753 19.188 7.95634C19.1349 8.27514 19.1737 8.60249 19.3 8.9V9C19.4311 9.29055 19.6413 9.53839 19.9065 9.71521C20.1718 9.89203 20.4814 9.99071 20.8 10H21C21.5304 10 22.0391 10.2107 22.4142 10.5858C22.7893 10.9609 23 11.4696 23 12C23 12.5304 22.7893 13.0391 22.4142 13.4142C22.0391 13.7893 21.5304 14 21 14H20.9C20.5814 14.0093 20.2718 14.108 20.0065 14.2848C19.7413 14.4616 19.5311 14.7094 19.4 15Z"],
|
|
723
|
+
bell: ["M10.3 21C10.4577 21.3159 10.7003 21.5816 11.0006 21.7674C11.3008 21.9531 11.6469 22.0515 12 22.0515C12.3531 22.0515 12.6992 21.9531 12.9994 21.7674C13.2997 21.5816 13.5423 21.3159 13.7 21M6 8C6 6.4087 6.63214 4.88258 7.75736 3.75736C8.88258 2.63214 10.4087 2 12 2C13.5913 2 15.1174 2.63214 16.2426 3.75736C17.3679 4.88258 18 6.4087 18 8C18 15 21 17 21 17H3C3 17 6 15 6 8Z"],
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
function NcIcon({ name }) {
|
|
727
|
+
// 24 viewBox drawn at 16. Stroke 2.16 in viewBox units renders 1.44, the
|
|
728
|
+
// instances' stroke weight.
|
|
729
|
+
return (
|
|
730
|
+
<svg width={L.actionIconGlyph} height={L.actionIconGlyph} viewBox="0 0 24 24" fill="none" aria-hidden="true"
|
|
731
|
+
stroke="currentColor" strokeWidth="2.16" strokeLinecap="round" strokeLinejoin="round" style={{ display: "block" }}>
|
|
732
|
+
{NC_ICONS[name].map((d, i) => <path key={i} d={d} />)}
|
|
733
|
+
</svg>
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** NewCo.Logo / Mark / Reversed, 27x32, painted in Foreground/Action/Ghost/Rest. */
|
|
738
|
+
function NewCoMark() {
|
|
739
|
+
return (
|
|
740
|
+
<svg width="27" height="32" viewBox="0 0 27 32" fill="currentColor" aria-hidden="true" style={{ display: "block" }}>
|
|
741
|
+
<path d="M2.29958 25.2142C1.02842 25.2142 0 24.3456 0 23.277C0 22.2765 0.900517 21.4519 2.05683 21.3508L22.8862 19.5081C23.0036 19.4971 23.1237 19.4927 23.2438 19.4927C25.1205 19.4927 26.6422 20.7747 26.6422 22.3556C26.6422 23.9366 25.1205 25.2186 23.2438 25.2186H2.29958V25.2142Z" />
|
|
742
|
+
<path d="M22.2387 5.52125C23.6274 4.83081 25.418 5.22221 26.2376 6.392C27.0572 7.56179 26.5925 9.07021 25.2039 9.76065C25.0369 9.84201 24.8672 9.91018 24.6923 9.96295L15.2956 12.7643C14.429 13.0238 13.4398 12.7379 12.9543 12.0453C12.4061 11.2603 12.7168 10.2488 13.6486 9.78484L22.2413 5.52125H22.2387Z" />
|
|
743
|
+
<path d="M8.7547 19.5479C7.74194 19.6754 6.74224 19.1631 6.43424 18.3121C6.09491 17.3754 6.71875 16.3837 7.83069 16.0979L22.453 12.3334C24.1653 11.8936 25.9767 12.705 26.4988 14.1453C27.0208 15.5877 26.0577 17.1137 24.348 17.5535C24.1914 17.5931 24.0322 17.6239 23.8755 17.6437L8.7547 19.5457V19.5479Z" />
|
|
744
|
+
</svg>
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Type styles the NewCo bar uses, composed from the scale. Figma binds the
|
|
749
|
+
// tracking to the primitive tier; these are the semantic names for the same values.
|
|
750
|
+
const NC_TYPE = {
|
|
751
|
+
menuBase: { fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-medium)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)" }, // Label/Menu/Base/Medium
|
|
752
|
+
menuLarge: { fontSize: "var(--semantic-type-font-size-r)", fontWeight: "var(--semantic-type-weight-medium)", lineHeight: "var(--semantic-type-line-height-r-single)", letterSpacing: "var(--semantic-type-letter-spacing-wide)" }, // Label/Menu/Large/Medium
|
|
753
|
+
sectionSm: { fontSize: "var(--semantic-type-font-size-r)", fontWeight: "var(--semantic-type-weight-semibold)", lineHeight: "var(--semantic-type-line-height-r-single)", letterSpacing: "var(--semantic-type-letter-spacing-wide)" }, // Heading/Section/Small/Semibold
|
|
754
|
+
localBase: { fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-semibold)", lineHeight: "var(--semantic-type-line-height-s-tight)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)" }, // Heading/Local/Base/Semibold
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// Hover, press and focus as one hook, so every NewCo control reads its state the same way.
|
|
758
|
+
function usePress() {
|
|
759
|
+
const [hov, setHov] = useState(false);
|
|
760
|
+
const [press, setPress] = useState(false);
|
|
761
|
+
return { hov, press, handlers: {
|
|
762
|
+
onMouseEnter: () => setHov(true), onMouseLeave: () => { setHov(false); setPress(false); },
|
|
763
|
+
onMouseDown: () => setPress(true), onMouseUp: () => setPress(false), onBlur: () => setPress(false),
|
|
764
|
+
} };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Pillar.Item, Figma set 40018002:15993. Type=Brand is the first tab (the
|
|
769
|
+
* product name); Type=Default the module tabs. 36 tall inside a 44 target,
|
|
770
|
+
* CornerRadius/Full, padding Padding/Base (desktop) or Padding/Tight (tablet).
|
|
771
|
+
*
|
|
772
|
+
* Base no fill, Foreground/Action/Ghost/Rest
|
|
773
|
+
* Hover Fill/Action/Ghost/Rest, Foreground/Action/Ghost/Hover
|
|
774
|
+
* Pressed Fill/Action/Ghost/Pressed, Foreground/Action/Ghost/Pressed
|
|
775
|
+
* Active Fill/Action/Ghost/Hover, Foreground/Action/Ghost/Pressed, semibold style
|
|
776
|
+
*/
|
|
777
|
+
function PillarItem({ label, active, brandItem, tablet, onClick }) {
|
|
778
|
+
const { hov, press, handlers } = usePress();
|
|
779
|
+
const fill = active ? T.controlHover : press ? T.controlPressed : hov ? T.searchFill : "transparent";
|
|
780
|
+
const fg = active || press ? T.monoPressed : hov ? T.monoHover : T.monoBase;
|
|
781
|
+
const type = brandItem ? (active ? NC_TYPE.sectionSm : NC_TYPE.menuLarge) : (active ? NC_TYPE.localBase : NC_TYPE.menuBase);
|
|
782
|
+
return (
|
|
783
|
+
<button type="button" aria-current={active ? "page" : undefined} onClick={onClick} {...handlers}
|
|
784
|
+
style={{ display: "flex", alignItems: "center", minWidth: L.actionTarget, minHeight: L.actionTarget,
|
|
785
|
+
padding: 0, border: "none", background: "transparent", cursor: "pointer", fontFamily: "inherit" }}>
|
|
786
|
+
<span style={{ display: "flex", alignItems: "center", height: L.modInnerH, boxSizing: "border-box",
|
|
787
|
+
padding: `0 ${tablet ? u("padding-tight") : u("padding-base")}`, borderRadius: u("cornerradius-full"),
|
|
788
|
+
background: fill, color: fg, whiteSpace: "nowrap", ...type,
|
|
789
|
+
transition: "background var(--motion-duration-2) var(--motion-easing-standard), color var(--motion-duration-2) var(--motion-easing-standard)" }}>
|
|
790
|
+
{label}
|
|
791
|
+
</span>
|
|
792
|
+
</button>
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Org Switcher, Brand=NewCo (set 40006819:14583). Pill-shaped (CornerRadius/Full)
|
|
798
|
+
* with a resting Stroke/Action/Ghost/Rest at Button/BorderWidth/Rest that stays
|
|
799
|
+
* in every state; the fill follows the state, the label stays on Ghost/Rest.
|
|
800
|
+
* Type=Desktop: padding 6,6,6,12, gap Gap/XXTight, chevron box 24.
|
|
801
|
+
* Type=Mobile (tablet and mobile): padding 8,4,8,8, gap Gap/XXXTight, chevron box 20,
|
|
802
|
+
* label from org.shortName (Figma Label-Mobile).
|
|
803
|
+
*/
|
|
804
|
+
function NewCoOrgSwitcher({ org, open, onToggle, compact }) {
|
|
805
|
+
const { hov, press, handlers } = usePress();
|
|
806
|
+
const fill = open || press ? T.controlPressed : hov ? T.controlHover : "transparent";
|
|
807
|
+
return (
|
|
808
|
+
<div style={{ position: "relative", padding: L.slotPadV, minWidth: 0 }}>
|
|
809
|
+
<button type="button" onClick={onToggle} {...handlers}
|
|
810
|
+
aria-haspopup="true" aria-expanded={open}
|
|
811
|
+
aria-label={`Switch organisation: ${org.name}${org.campus ? ", " + org.campus : ""}`}
|
|
812
|
+
style={{ display: "flex", alignItems: "center", boxSizing: "border-box", minHeight: L.modInnerH, minWidth: 0,
|
|
813
|
+
maxWidth: compact ? L.orgMainMaxMobile : L.orgMainMax,
|
|
814
|
+
gap: compact ? "var(--semantic-layout-units-gap-xxxtight)" : "var(--semantic-layout-units-gap-xxtight)",
|
|
815
|
+
padding: compact ? `${u("padding-xtight")} ${u("padding-xxxtight")} ${u("padding-xtight")} ${u("padding-xtight")}`
|
|
816
|
+
: `${L.orgPadV} ${L.orgPadV} ${L.orgPadV} ${L.orgPadL}`,
|
|
817
|
+
borderRadius: u("cornerradius-full"), border: "none", background: fill, cursor: "pointer", fontFamily: "inherit",
|
|
818
|
+
boxShadow: `inset 0 0 0 var(--contextual-layout-units-button-borderwidth-rest) ${T.orgStroke}`,
|
|
819
|
+
transition: "background var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
820
|
+
<span aria-hidden="true" style={{ ...NC_TYPE.menuBase, color: T.monoBase, whiteSpace: "nowrap", overflow: "hidden",
|
|
821
|
+
textOverflow: "ellipsis", minWidth: 0, maxWidth: compact ? L.orgNameMaxMobile : L.orgNameMax }}>
|
|
822
|
+
{compact ? (org.shortName || org.name) : org.name}
|
|
823
|
+
</span>
|
|
824
|
+
<span style={{ width: compact ? L.modChevronBox : L.orgChevronBox, height: compact ? L.modChevronBox : L.orgChevronBox,
|
|
825
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
|
826
|
+
transform: open ? "rotate(180deg)" : "none", transition: "transform var(--motion-duration-4) var(--motion-easing-standard)" }}>
|
|
827
|
+
<Icon name="expand_more" size={L.chevronGlyph} style={{ color: T.orgChevron }} />
|
|
828
|
+
</span>
|
|
829
|
+
</button>
|
|
830
|
+
</div>
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Pillar trigger, mobile: the product name as a pill dropdown, 36 inside a 48 slot. */
|
|
835
|
+
function PillarTrigger({ label, open, onToggle }) {
|
|
836
|
+
const { hov, press, handlers } = usePress();
|
|
837
|
+
const fill = open || press ? T.controlPressed : hov ? T.controlHover : "transparent";
|
|
838
|
+
return (
|
|
839
|
+
<div style={{ padding: `${L.slotPadV} ${L.slotPadNarrow}` }}>
|
|
840
|
+
<button type="button" onClick={onToggle} aria-haspopup="true" aria-expanded={open} {...handlers}
|
|
841
|
+
style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxxtight)", height: L.modInnerH,
|
|
842
|
+
boxSizing: "border-box", padding: `0 10px 0 ${u("padding-medium")}`, /* pathway-allow: Figma Pillar.Trigger right padding 10 has no token (gap logged in the spec) */
|
|
843
|
+
borderRadius: u("cornerradius-full"), border: "none", background: fill, cursor: "pointer", fontFamily: "inherit",
|
|
844
|
+
boxShadow: `inset 0 0 0 var(--contextual-layout-units-button-borderwidth-rest) ${T.orgStroke}`, color: T.monoBase }}>
|
|
845
|
+
<span style={{ ...NC_TYPE.sectionSm, whiteSpace: "nowrap" }}>{label}</span>
|
|
846
|
+
<span style={{ width: L.modChevronBox, height: L.modChevronBox, display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
|
|
847
|
+
<Icon name="expand_more" size={L.chevronGlyph} style={{ color: T.monoBase }} />
|
|
848
|
+
</span>
|
|
849
|
+
</button>
|
|
850
|
+
</div>
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** One NewCo action: a 44 target, CornerRadius/Base, a 16 line icon, optional Badge. */
|
|
855
|
+
function NewCoAction({ icon, label, badge, onClick }) {
|
|
856
|
+
const { hov, press, handlers } = usePress();
|
|
857
|
+
return (
|
|
858
|
+
<button type="button" aria-label={label} onClick={onClick} {...handlers}
|
|
859
|
+
style={{ position: "relative", display: "flex", alignItems: "center", justifyContent: "center",
|
|
860
|
+
width: L.actionTarget, height: L.actionTarget, padding: 0, border: "none", cursor: "pointer",
|
|
861
|
+
borderRadius: L.radius, background: press ? T.controlPressed : hov ? T.controlHover : "transparent",
|
|
862
|
+
color: press ? T.monoPressed : hov ? T.monoHover : T.monoBase,
|
|
863
|
+
transition: "background var(--motion-duration-2) var(--motion-easing-standard), color var(--motion-duration-2) var(--motion-easing-standard)" }}>
|
|
864
|
+
<NcIcon name={icon} />
|
|
865
|
+
{/* Badge: 8 circle at 26,10, Fill/Static/Attention/Strong with a 1.5 ring in
|
|
866
|
+
Fill/Surface/Chrome drawn INSIDE the 8, as Figma strokes it. */}
|
|
867
|
+
{badge && <span aria-hidden="true" style={{ position: "absolute", left: 26, top: 10, width: 8, height: 8,
|
|
868
|
+
boxSizing: "border-box", borderRadius: "50%", background: SCD("fill-static-attention-strong"),
|
|
869
|
+
boxShadow: `inset 0 0 0 var(--semantic-layout-units-borderwidth-medium) ${T.navBg}` }} />}
|
|
870
|
+
</button>
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export const NEWCO_PILLARS = [
|
|
875
|
+
{ id: "newco", label: "NewCo", brand: true },
|
|
876
|
+
{ id: "people", label: "People" },
|
|
877
|
+
{ id: "giving", label: "Giving" },
|
|
878
|
+
{ id: "financials", label: "Financials" },
|
|
879
|
+
{ id: "digital", label: "Digital" },
|
|
880
|
+
];
|
|
881
|
+
|
|
882
|
+
export const NEWCO_ACTIONS = [
|
|
883
|
+
{ icon: "search", label: "Search" },
|
|
884
|
+
{ icon: "help", label: "Help" },
|
|
885
|
+
{ icon: "gear", label: "Settings" },
|
|
886
|
+
{ icon: "bell", label: "Notifications", badge: true },
|
|
887
|
+
];
|
|
888
|
+
|
|
889
|
+
function TopNavNewCo({ navRef, className, breakpoint, org, user, openPanel, toggle, close, orgs, onOrgSelect,
|
|
890
|
+
pillars, activePillarId, onPillarSelect, ncActions, onSideNavToggle, onMore }) {
|
|
891
|
+
const isMobile = breakpoint === "mobile", isTablet = breakpoint === "tablet";
|
|
892
|
+
const brandItem = pillars.find((x) => x.brand) || pillars[0];
|
|
893
|
+
// Tablet keeps search and notifications; mobile folds the actions into more_vert.
|
|
894
|
+
const shown = isTablet ? ncActions.filter((a) => a.icon === "search" || a.icon === "bell") : ncActions;
|
|
895
|
+
const org_ = <NewCoOrgSwitcher org={org} open={openPanel === "org"} onToggle={() => { toggle("org"); onOrgSelect?.(); }} compact={isMobile || isTablet} />;
|
|
896
|
+
return (
|
|
897
|
+
<div style={{ background: "var(--semantic-color-fill-surface-chrome)", position: "relative", zIndex: 100 }}>
|
|
898
|
+
<nav ref={navRef} aria-label="Global navigation" className={className}
|
|
899
|
+
style={{ display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
900
|
+
height: L.navH, maxHeight: L.navH, boxSizing: "border-box",
|
|
901
|
+
// Container.Main padding 6 by 16 desktop and tablet, 6 by 8 mobile.
|
|
902
|
+
padding: `${L.navPadV} ${isMobile ? u("padding-xtight") : u("padding-base")}`, gap: L.navGap,
|
|
903
|
+
position: "relative", fontFamily: "'Red Hat Text', sans-serif",
|
|
904
|
+
WebkitFontSmoothing: "antialiased", MozOsxFontSmoothing: "grayscale" }}>
|
|
905
|
+
<div style={{ display: "flex", alignItems: "center", flexShrink: 0, minWidth: 0,
|
|
906
|
+
gap: isMobile ? "var(--semantic-layout-units-gap-xxtight)" : "var(--semantic-layout-units-gap-tight)" }}>
|
|
907
|
+
{isMobile ? (
|
|
908
|
+
<>
|
|
909
|
+
<ActionIcon name="menu" size="Large" label="Open navigation menu" color={T.monoBase} onClick={onSideNavToggle} />
|
|
910
|
+
<PillarTrigger label={brandItem.label} open={openPanel === "pillar"} onToggle={() => toggle("pillar")} />
|
|
911
|
+
{org_}
|
|
912
|
+
</>
|
|
913
|
+
) : (
|
|
914
|
+
<>
|
|
915
|
+
{/* Logo.Home: 48 tall, padding 0 by Padding/XTight, CornerRadius/Base */}
|
|
916
|
+
<a href="#" aria-label="Home" onClick={(e) => e.preventDefault()}
|
|
917
|
+
style={{ display: "flex", alignItems: "center", height: L.touchTarget, padding: `0 ${u("padding-xtight")}`,
|
|
918
|
+
borderRadius: L.radius, color: T.monoBase }}>
|
|
919
|
+
<NewCoMark />
|
|
920
|
+
</a>
|
|
921
|
+
{/* Divider.Logo: 1 by 28, Fill/Action/Ghost/Pressed */}
|
|
922
|
+
<span aria-hidden="true" style={{ width: 1, height: 28, borderRadius: 1, background: T.controlPressed, flexShrink: 0 }} />
|
|
923
|
+
<div role="navigation" aria-label="Products"
|
|
924
|
+
style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)", paddingLeft: u("padding-xtight") }}>
|
|
925
|
+
{pillars.map((p) => (
|
|
926
|
+
<PillarItem key={p.id} label={p.label} brandItem={!!p.brand} tablet={isTablet}
|
|
927
|
+
active={p.brand ? activePillarId === p.id || !activePillarId : activePillarId === p.id}
|
|
928
|
+
onClick={() => onPillarSelect?.(p.id)} />
|
|
929
|
+
))}
|
|
930
|
+
</div>
|
|
931
|
+
</>
|
|
932
|
+
)}
|
|
933
|
+
</div>
|
|
934
|
+
<div style={{ display: "flex", alignItems: "center", flexShrink: 0,
|
|
935
|
+
gap: isMobile || isTablet ? "var(--semantic-layout-units-gap-xxtight)" : "var(--semantic-layout-units-gap-tight)" }}>
|
|
936
|
+
{!isMobile && org_}
|
|
937
|
+
{isMobile ? (
|
|
938
|
+
<TopNavActionIcon icon="more_vert" label="More actions" onClick={onMore} />
|
|
939
|
+
) : (
|
|
940
|
+
<div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)" }}>
|
|
941
|
+
{shown.map((a) => <NewCoAction key={a.label} {...a} />)}
|
|
942
|
+
</div>
|
|
943
|
+
)}
|
|
944
|
+
<TopNavProfile user={user} open={openPanel === "profile"} onToggle={() => toggle("profile")} mobile={isMobile} />
|
|
945
|
+
{openPanel === "profile" && <TopNavProfileMenu user={user} />}
|
|
946
|
+
</div>
|
|
947
|
+
{openPanel === "org" && (
|
|
948
|
+
<div style={{ position: "absolute", top: "calc(100% + 4px)", right: L.navPadH, zIndex: 300,
|
|
949
|
+
animation: "tnDropIn var(--motion-duration-4) var(--motion-easing-spring) both" }}>
|
|
950
|
+
<OrgSwitcherPanel orgs={orgs} activeOrgId={org.id} onSelect={(o) => { close(); onOrgSelect?.(o); }} />
|
|
951
|
+
</div>
|
|
952
|
+
)}
|
|
953
|
+
</nav>
|
|
954
|
+
</div>
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
660
958
|
// ─── TopNav ────────────────────────────────────────────────────────────────────
|
|
661
959
|
/**
|
|
662
960
|
* TopNav.Global component.
|
|
@@ -669,11 +967,19 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
|
|
|
669
967
|
* (no avatar/placeholder). logoUrl, when provided, renders the logo
|
|
670
968
|
* in the nav trigger AND in the org-panel dropdown.
|
|
671
969
|
* user, { name, initials, email, avatarUrl? }
|
|
970
|
+
* showCampus, boolean, default false. Figma Org Switcher "Show CityName
|
|
971
|
+
* (Catholic)": shows org.campus beside the org name.
|
|
972
|
+
* brand, "amplify" | "newco" Figma Brand. NewCo is its own layout: logo,
|
|
973
|
+
* Pillar tabs, pill org switcher, line-icon actions.
|
|
974
|
+
* pillars, activePillarId, onPillarSelect NewCo Pillar tabs (default NEWCO_PILLARS)
|
|
975
|
+
* newcoActions NewCo action icons (default NEWCO_ACTIONS)
|
|
672
976
|
* breakpoint, "desktop" | "tablet" | "mobile"
|
|
673
977
|
* onModuleSelect, (id: string) => void
|
|
674
978
|
* onOrgSelect, () => void
|
|
675
979
|
* onSearchOpen, () => void
|
|
676
980
|
* onSideNavToggle, () => void (mobile only)
|
|
981
|
+
* actions, Array<{icon, label, onClick}> Figma TopNav.Actions slot (default: two bells).
|
|
982
|
+
* On mobile, more than one folds into more_vert.
|
|
677
983
|
* onNotifications, () => void
|
|
678
984
|
* onMore, () => void
|
|
679
985
|
* className, string
|
|
@@ -685,12 +991,19 @@ export function TopNav({
|
|
|
685
991
|
orgs = DEMO_ORGS, // list shown in the OrgSwitcher "Open" dropdown panel
|
|
686
992
|
user = { name: "Jo Lopez", initials: "JL", email: "jo@sacredheart.org" },
|
|
687
993
|
breakpoint = "desktop",
|
|
994
|
+
brand = "amplify", // Figma "Brand": "amplify" | "newco"
|
|
995
|
+
pillars = NEWCO_PILLARS,
|
|
996
|
+
activePillarId,
|
|
997
|
+
onPillarSelect,
|
|
998
|
+
newcoActions = NEWCO_ACTIONS,
|
|
999
|
+
showCampus = false, // Figma Org Switcher "Show CityName (Catholic)", default off
|
|
688
1000
|
moduleSwitcherType = "interactive", // "interactive" | "static" (Amplify Dashboard), Figma "Type"
|
|
689
1001
|
initialOpenPanel = null, // "module" | "org" | "profile" | null, seeds the open panel (demo/snapshot use)
|
|
690
1002
|
onModuleSelect,
|
|
691
1003
|
onOrgSelect,
|
|
692
1004
|
onSearchOpen,
|
|
693
1005
|
onSideNavToggle,
|
|
1006
|
+
actions,
|
|
694
1007
|
onNotifications,
|
|
695
1008
|
onMore,
|
|
696
1009
|
className = "",
|
|
@@ -730,6 +1043,13 @@ export function TopNav({
|
|
|
730
1043
|
// entire TopNav so it can never collide with the OrgSwitcher or other controls.
|
|
731
1044
|
const useTakeover = searchExpanded;
|
|
732
1045
|
|
|
1046
|
+
if (brand === "newco") {
|
|
1047
|
+
return <TopNavNewCo navRef={navRef} className={className} breakpoint={breakpoint} org={org} user={user} orgs={orgs}
|
|
1048
|
+
openPanel={openPanel} toggle={toggle} close={close} onOrgSelect={onOrgSelect}
|
|
1049
|
+
pillars={pillars} activePillarId={activePillarId} onPillarSelect={onPillarSelect}
|
|
1050
|
+
ncActions={newcoActions} onSideNavToggle={onSideNavToggle} onMore={onMore} />;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
733
1053
|
return (
|
|
734
1054
|
// TWO ELEMENTS, AND THE SPLIT IS THE POINT.
|
|
735
1055
|
//
|
|
@@ -787,10 +1107,14 @@ export function TopNav({
|
|
|
787
1107
|
padding: `${L.navPadV} ${L.navPadH}`, gap: L.navGap,
|
|
788
1108
|
position: "relative", overflow: "visible",
|
|
789
1109
|
fontFamily: "'Red Hat Text', sans-serif",
|
|
1110
|
+
// Grayscale smoothing, as Figma renders. macOS subpixel smoothing
|
|
1111
|
+
// draws light text on a dark bar visibly bolder, which made every
|
|
1112
|
+
// label and the avatar initials look a weight heavier than Figma.
|
|
1113
|
+
WebkitFontSmoothing: "antialiased", MozOsxFontSmoothing: "grayscale",
|
|
790
1114
|
}}
|
|
791
1115
|
>
|
|
792
|
-
{/* ── Slot.RowStart ── */}
|
|
793
|
-
<div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", flexShrink: 0 }}>
|
|
1116
|
+
{/* ── Slot.RowStart ── gap Gap/Tight; 0 on mobile (Figma Slot.RowStart.Mobile) */}
|
|
1117
|
+
<div style={{ display: "flex", alignItems: "center", gap: isMobile ? 0 : "var(--semantic-layout-units-gap-tight)", flexShrink: 0 }}>
|
|
794
1118
|
|
|
795
1119
|
{/* SideNav.Control, mobile only. Figma set 40007082:7703: three State
|
|
796
1120
|
variants wrapping an `Action Icon` at Size=Large, so a 48x48 target,
|
|
@@ -859,6 +1183,7 @@ export function TopNav({
|
|
|
859
1183
|
open={openPanel === "org"}
|
|
860
1184
|
onToggle={() => { toggle("org"); onOrgSelect?.(); }}
|
|
861
1185
|
mobile={isMobile}
|
|
1186
|
+
showCampus={showCampus}
|
|
862
1187
|
/>
|
|
863
1188
|
|
|
864
1189
|
{/* Org panel: the real OrgSwitcher "Open" dropdown (Figma 40007336:9453) */}
|
|
@@ -877,8 +1202,8 @@ export function TopNav({
|
|
|
877
1202
|
)}
|
|
878
1203
|
</div>
|
|
879
1204
|
|
|
880
|
-
{/* ── Slot.RowEnd ── */}
|
|
881
|
-
<div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", flexShrink: 0 }}>
|
|
1205
|
+
{/* ── Slot.RowEnd ── gap Gap/Tight; 0 on mobile (Figma Slot.RowEnd.Mobile) */}
|
|
1206
|
+
<div style={{ display: "flex", alignItems: "center", gap: isMobile ? 0 : "var(--semantic-layout-units-gap-tight)", flexShrink: 0 }}>
|
|
882
1207
|
|
|
883
1208
|
{/* Collapsed search pill. Opening it triggers the full-bar takeover
|
|
884
1209
|
overlay below (never an inline expand), so it cannot overlap the
|
|
@@ -891,7 +1216,7 @@ export function TopNav({
|
|
|
891
1216
|
searchProps={{ value: searchQuery, onChange: setSearchQuery, onClear: () => setSearchQuery("") }}
|
|
892
1217
|
/>
|
|
893
1218
|
|
|
894
|
-
<TopNavActions breakpoint={breakpoint} onNotifications={onNotifications} onMore={onMore} />
|
|
1219
|
+
<TopNavActions breakpoint={breakpoint} actions={actions} onNotifications={onNotifications} onMore={onMore} />
|
|
895
1220
|
|
|
896
1221
|
<TopNavProfile
|
|
897
1222
|
user={user}
|
|
@@ -901,39 +1226,7 @@ export function TopNav({
|
|
|
901
1226
|
/>
|
|
902
1227
|
|
|
903
1228
|
{/* 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
|
-
)}
|
|
1229
|
+
{openPanel === "profile" && <TopNavProfileMenu user={user} />}
|
|
937
1230
|
</div>
|
|
938
1231
|
|
|
939
1232
|
{/* Full-width search takeover, shown whenever search is open, on every
|
|
@@ -949,13 +1242,12 @@ export function TopNav({
|
|
|
949
1242
|
Escape still closes. */}
|
|
950
1243
|
{useTakeover && (
|
|
951
1244
|
<div
|
|
952
|
-
// data-theme
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
data-theme="light"
|
|
1245
|
+
// NO data-theme. The takeover IS the bar, so it paints
|
|
1246
|
+
// Fill/Surface/Chrome in the page's own mode, exactly as the bar does.
|
|
1247
|
+
// It used to force data-theme="light", left over from when the bar
|
|
1248
|
+
// itself carried "midnight"; once the bar followed the page, that
|
|
1249
|
+
// override made opening search switch the bar to the Light chrome on
|
|
1250
|
+
// any dark page. Removed 2026-11-02.
|
|
959
1251
|
style={{
|
|
960
1252
|
position: "absolute", inset: 0, zIndex: 200,
|
|
961
1253
|
background: "var(--semantic-color-fill-surface-chrome)",
|