@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,604 @@
1
+ /**
2
+ * OrgSwitcher, Pathway Design System (v1, trigger only)
3
+ *
4
+ * Trigger button that lives in the brand-blue top nav and shows the active
5
+ * organisation. Catholic orgs render a second container after the org name
6
+ * (city/diocese). Protestant orgs render the org name on its own.
7
+ *
8
+ * Spec: components/org-switcher/org-switcher-spec.md
9
+ * Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40006819-14583
10
+ *
11
+ * Variants on Figma (all read by the pipeline skill):
12
+ * Base Desktop 40006819:14581
13
+ * Hover Desktop 40006819:14582
14
+ * Pressed Desktop 40006933:15754
15
+ * Open Desktop 40007336:9453 (Open variant, used only for the chevron rotation)
16
+ * Base Mobile 40006820:14757
17
+ * Hover Mobile 40006820:14772
18
+ * Pressed Mobile 40006933:15766
19
+ * Open Mobile 40007336:17470
20
+ *
21
+ * Figma annotations on the desktop label:
22
+ * • Container.OrgLabel, "Text truncates if going beyond 248pt"
23
+ * • Container.OrgName, "Text Truncates if frame going beyond 170pt"
24
+ * • Container.CityName.Catholic, "Catholic orgs only. NOT a suborg name."
25
+ *
26
+ * The placeholder icon (when no org logo on file) is the church/building SVG
27
+ * at Figma node 40007243:73426. Never replace with text initials.
28
+ *
29
+ * v1 scope (per spec §0): TRIGGER ONLY. The dropdown panel is a separate
30
+ * design pass; this file does not implement one. The `open` prop flips the
31
+ * chevron rotation and pressed-state styling on the trigger itself, that's
32
+ * all. The caller renders whatever (if any) panel they want above/below.
33
+ */
34
+
35
+ import React, { useState, useEffect } from "react";
36
+ import { Icon } from "../icon/icon.jsx";
37
+ import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
38
+
39
+ // ─── CHURCH PLACEHOLDER ICON ──────────────────────────────────────────────────
40
+ // Figma node 40007243:73426. Used when the org has no logo image on file.
41
+ const CHURCH_ICON_PATH =
42
+ "M0 12.6667V9.53333C0 9.26667 0.0722222 9.025 0.216667 8.80833C0.361111 8.59167 0.555556 8.42778 0.8 8.31667L2.66667 7.48333V6.15C2.66667 5.89444 2.73333 5.66389 2.86667 5.45833C3 5.25278 3.17778 5.08889 3.4 4.96667L6 3.66667V2.66667H5.33333C5.14444 2.66667 4.98611 2.60278 4.85833 2.475C4.73056 2.34722 4.66667 2.18889 4.66667 2C4.66667 1.81111 4.73056 1.65278 4.85833 1.525C4.98611 1.39722 5.14444 1.33333 5.33333 1.33333H6V0.666667C6 0.477778 6.06389 0.319444 6.19167 0.191667C6.31944 0.0638889 6.47778 0 6.66667 0C6.85556 0 7.01389 0.0638889 7.14167 0.191667C7.26944 0.319444 7.33333 0.477778 7.33333 0.666667V1.33333H8C8.18889 1.33333 8.34722 1.39722 8.475 1.525C8.60278 1.65278 8.66667 1.81111 8.66667 2C8.66667 2.18889 8.60278 2.34722 8.475 2.475C8.34722 2.60278 8.18889 2.66667 8 2.66667H7.33333V3.66667L9.93333 4.96667C10.1556 5.08889 10.3333 5.25278 10.4667 5.45833C10.6 5.66389 10.6667 5.89444 10.6667 6.15V7.48333L12.5333 8.31667C12.7778 8.42778 12.9722 8.59167 13.1167 8.80833C13.2611 9.025 13.3333 9.26667 13.3333 9.53333V12.6667C13.3333 13.0333 13.2028 13.3472 12.9417 13.6083C12.6806 13.8694 12.3667 14 12 14H8.66667C8.47778 14 8.31944 13.9361 8.19167 13.8083C8.06389 13.6806 8 13.5222 8 13.3333V12C8 11.6333 7.86944 11.3194 7.60833 11.0583C7.34722 10.7972 7.03333 10.6667 6.66667 10.6667C6.3 10.6667 5.98611 10.7972 5.725 11.0583C5.46389 11.3194 5.33333 11.6333 5.33333 12V13.3333C5.33333 13.5222 5.26944 13.6806 5.14167 13.8083C5.01389 13.9361 4.85556 14 4.66667 14H1.33333C0.966667 14 0.652778 13.8694 0.391667 13.6083C0.130556 13.3472 0 13.0333 0 12.6667ZM6.66667 8.33333C6.94444 8.33333 7.18056 8.23611 7.375 8.04167C7.56944 7.84722 7.66667 7.61111 7.66667 7.33333C7.66667 7.05556 7.56944 6.81944 7.375 6.625C7.18056 6.43056 6.94444 6.33333 6.66667 6.33333C6.38889 6.33333 6.15278 6.43056 5.95833 6.625C5.76389 6.81944 5.66667 7.05556 5.66667 7.33333C5.66667 7.61111 5.76389 7.84722 5.95833 8.04167C6.15278 8.23611 6.38889 8.33333 6.66667 8.33333Z";
43
+
44
+ // ─── DESIGN TOKENS (dark mode) ────────────────────────────────────────────────
45
+ // Every value is a semantic token name. Fallbacks are the resolved Figma values
46
+ //: they're never used at runtime when Storybook loads the token contract, but they
47
+ // keep the file readable on its own.
48
+ const T = {
49
+ // ACTION/GHOST, the whole way through. This component sits on the brand bar,
50
+ // and Ghost is the family for a control on a dark on-brand surface: near-white
51
+ // foreground in both modes, translucent brand fill, translucent brand stroke.
52
+ // It was Action/Primary/Subtle for the fills and Action/Primary/Strong for the
53
+ // strokes, both LIGHT-surface families, with Static/Neutral foregrounds that
54
+ // only read correctly because the bar carries data-theme="midnight".
55
+ // Reported 2026-10-02 and measured against Figma node 40006819:14583.
56
+ //
57
+ // THE REST STATE HAS NO FILL. Figma's State=Base, Type=Desktop has no fill on
58
+ // Container.Main at all, only the stroke. The fill appears on Hover
59
+ // (#b6c6ec @16%) and deepens on Pressed and Open (#22386b @70%). fillBase is
60
+ // kept as a name so the call site still reads in three states, but it is
61
+ // transparent on purpose.
62
+ fillBase: "transparent",
63
+ fillHover: "var(--semantic-color-fill-action-ghost-hover)",
64
+ fillPressed: "var(--semantic-color-fill-action-ghost-pressed)",
65
+
66
+ // Avatar placeholder background (when no logo on file). The avatar is the one
67
+ // element on this bar that is NOT ghost, it is a filled identity chip, so it
68
+ // takes Action/Primary/Subtle.
69
+ fillAvatarPlaceholder: "var(--semantic-color-fill-action-primary-subtle-rest)",
70
+
71
+ // Borders, Stroke/Action/Ghost at 36 / 50 / 70 percent.
72
+ strokeBase: "var(--semantic-color-stroke-action-ghost-rest)",
73
+ strokeHover: "var(--semantic-color-stroke-action-ghost-hover)",
74
+ strokePressed: "var(--semantic-color-stroke-action-ghost-pressed)",
75
+
76
+ // Text, org name. Ghost has no weight axis, so emphasis is font-weight at the
77
+ // call site rather than a bolder colour token.
78
+ textBase: "var(--semantic-color-foreground-action-ghost-rest)",
79
+ textHover: "var(--semantic-color-foreground-action-ghost-hover)",
80
+ textPressed: "var(--semantic-color-foreground-action-ghost-pressed)",
81
+
82
+ // Chevron icon.
83
+ iconBase: "var(--semantic-color-foreground-action-ghost-rest)",
84
+ iconHover: "var(--semantic-color-foreground-action-ghost-hover)",
85
+ iconPressed: "var(--semantic-color-foreground-action-ghost-pressed)",
86
+
87
+ // Geometry
88
+ radiusMedium: "var(--semantic-layout-units-cornerradius-base)",
89
+ radiusSmall: "var(--semantic-layout-units-cornerradius-small)",
90
+ borderWidth: "var(--semantic-layout-units-borderwidth-base)",
91
+ pXxtight: "var(--semantic-layout-units-padding-xxtight)",
92
+ pXxxtight: "var(--semantic-layout-units-padding-xxxtight)",
93
+ gapXxtight: "var(--semantic-layout-units-gap-xxtight)",
94
+ };
95
+
96
+ // ─── ICON HELPER ──────────────────────────────────────────────────────────────
97
+ // Material Symbols Rounded. Pathway uses Material Symbols for all icons
98
+ // (CLAUDE.md icon rules). The font is loaded by the Storybook preview-head
99
+ // and by the standalone HTML demo.
100
+ /* Icon is imported, not redeclared. The copy that used to live here set NO
101
+ fontVariationSettings at all, so every glyph in this component rendered at
102
+ the font's default axes rather than Pathway's FILL 0 / wght 400 / GRAD 0 with
103
+ opsz matched to the size. The shared component sets them. */
104
+
105
+ // ─── CHURCH PLACEHOLDER (no-logo state) ───────────────────────────────────────
106
+ // Renders inside the avatar frame at Figma's specified inset (4.17% top,
107
+ // 8.33% sides, 8.33% bottom). White at 70% opacity.
108
+ function ChurchPlaceholderIcon() {
109
+ return (
110
+ <div style={{ position: "absolute", inset: "4.17% 8.33% 8.33% 8.33%" }}>
111
+ <svg
112
+ viewBox="0 0 13.3333 14"
113
+ width="100%" height="100%"
114
+ fill="none"
115
+ xmlns="http://www.w3.org/2000/svg"
116
+ aria-hidden="true"
117
+ style={{ display: "block" }}
118
+ >
119
+ <path d={CHURCH_ICON_PATH} fill="white" fillOpacity="0.7" />
120
+ </svg>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ // ─── AVATAR ───────────────────────────────────────────────────────────────────
126
+ // Outer Container.Avatar: declared size×size with 2px padding (xxxtight).
127
+ // box-sizing:border-box keeps padding INSIDE the declared dimensions so the
128
+ // avatar doesn't overflow the 24-tall RowStart.
129
+ // Inner Avatar div: 1px border, rounded-4, holds either the logo <img> or
130
+ // the church placeholder SVG.
131
+ function TriggerAvatar({ logoUrl, size, borderColor }) {
132
+ return (
133
+ <div style={{
134
+ boxSizing: "border-box",
135
+ width: size, height: size,
136
+ padding: T.pXxxtight,
137
+ display: "flex", alignItems: "center", justifyContent: "center",
138
+ flexShrink: 0,
139
+ }}>
140
+ <div style={{
141
+ boxSizing: "border-box",
142
+ flex: "1 0 0", height: "100%",
143
+ border: `${T.borderWidth} solid ${borderColor}`,
144
+ borderRadius: T.radiusSmall,
145
+ overflow: "hidden",
146
+ position: "relative",
147
+ background: logoUrl ? "transparent" : T.fillAvatarPlaceholder,
148
+ }}>
149
+ {logoUrl ? (
150
+ <img
151
+ src={logoUrl}
152
+ alt=""
153
+ aria-hidden="true"
154
+ style={{
155
+ position: "absolute", inset: 0,
156
+ width: "100%", height: "100%",
157
+ objectFit: "cover",
158
+ display: "block",
159
+ }}
160
+ />
161
+ ) : (
162
+ <ChurchPlaceholderIcon />
163
+ )}
164
+ </div>
165
+ </div>
166
+ );
167
+ }
168
+
169
+ // ─── TYPOGRAPHY ──────────────────────────────────────────────────────────────
170
+ // Label/Button/S, used on both desktop and mobile per Figma. The primitive
171
+ // values in the raw token JSON are unitless (e.g. lineheight=20, not 20px), which
172
+ // breaks CSS inline `lineHeight: var(...)` because 20 is interpreted as a
173
+ // multiplier. So raw px values are used here, with the token names captured
174
+ // in comments. If/when the primitive values get units, swap these to vars.
175
+ const TYPE_S = {
176
+ // --semantic-type-desktop-label-button-s-*
177
+ fontFamily: "'Red Hat Text', sans-serif",
178
+ fontWeight: 500,
179
+ fontSize: "var(--semantic-type-font-size-s)",
180
+ lineHeight: "var(--semantic-type-line-height-s-single)",
181
+ letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
182
+ WebkitFontSmoothing: "antialiased",
183
+ MozOsxFontSmoothing: "grayscale",
184
+ textAlign: "left",
185
+ };
186
+
187
+ // ═════════════════════════════════════════════════════════════════════════════
188
+ // ORG SWITCHER, TRIGGER (v1)
189
+ // ═════════════════════════════════════════════════════════════════════════════
190
+ //
191
+ // Props
192
+ // orgName, full organisation name. Truncates with ellipsis at 180px
193
+ // desktop / 50px mobile.
194
+ // orgType, "protestant" (default) | "catholic". Catholic-only gate
195
+ // for the CityName container (spec §0.1). Even if a cityName
196
+ // string is supplied, Protestant orgs render no city container.
197
+ // cityName, city/diocese name. Catholic orgs only.
198
+ // logoUrl, org logo image URL. When provided the logo avatar renders.
199
+ // When absent no avatar is shown, org name only (Figma default:
200
+ // showOrgAvatar = false). The church placeholder is NOT shown.
201
+ // open, controlled open state. Flips the chevron rotation and applies
202
+ // pressed-state styling. The caller renders any panel above.
203
+ // onClick, () => void, fired when the trigger is activated.
204
+ // disabled, true for single-org users. Renders inert at 50% opacity.
205
+ // mobile, force the mobile compact display regardless of viewport.
206
+
207
+ export function OrgSwitcher({
208
+ orgName = "Organisation",
209
+ orgType = "protestant",
210
+ cityName = "",
211
+ logoUrl,
212
+ open = false,
213
+ onClick,
214
+ disabled = false,
215
+ mobile, // tristate: true=force mobile, false=force desktop, undefined=auto-detect
216
+ className,
217
+ }) {
218
+ const noMotion = useReducedMotion();
219
+ const [pressed, setPressed] = useState(false);
220
+ const [hovered, setHovered] = useState(false);
221
+ const [isMobile, setIsMobile] = useState(mobile === true);
222
+
223
+ // Viewport detection. When `mobile` is explicitly true or false, that value
224
+ // wins, useful for Storybook stories where the iframe width is artificial.
225
+ // When `mobile` is undefined, fall back to (max-width: 767px) media query.
226
+ useEffect(() => {
227
+ if (mobile === true) { setIsMobile(true); return; }
228
+ if (mobile === false) { setIsMobile(false); return; }
229
+ const mq = window.matchMedia("(max-width: 767px)");
230
+ setIsMobile(mq.matches);
231
+ const h = e => setIsMobile(e.matches);
232
+ mq.addEventListener("change", h);
233
+ return () => mq.removeEventListener("change", h);
234
+ }, [mobile]);
235
+
236
+ // State → token mapping
237
+ const isActive = open || pressed;
238
+ const fill = isActive ? T.fillPressed : hovered ? T.fillHover : T.fillBase;
239
+ const stroke = isActive ? T.strokePressed : hovered ? T.strokeHover : T.strokeBase;
240
+ const text = isActive ? T.textPressed : hovered ? T.textHover : T.textBase;
241
+ const icon = isActive ? T.iconPressed : hovered ? T.iconHover : T.iconBase;
242
+
243
+ // CityName visibility, Catholic orgs ONLY (spec §0.1).
244
+ const showCityName = orgType === "catholic" && Boolean(cityName);
245
+
246
+ const ariaLabel = showCityName
247
+ ? `Current organisation: ${orgName}, ${cityName}. Activate to switch.`
248
+ : `Current organisation: ${orgName}. Activate to switch.`;
249
+
250
+ // Avatar size from Figma, Container.Avatar size-24 desktop, h-full (=20) mobile
251
+ const AVATAR_SZ = isMobile ? 20 : 24;
252
+
253
+ return (
254
+ <div
255
+ className={className}
256
+ style={{
257
+ // Outer wrapper. min-w/h 48 (touch target). Mobile gets a fixed
258
+ // w-108 per Figma 40006820:14757. Desktop is content-sized.
259
+ boxSizing: "border-box",
260
+ minHeight: 48,
261
+ minWidth: 48,
262
+ ...(isMobile
263
+ ? { width: 108 }
264
+ : {}),
265
+ // Mobile asymmetric padding (px-2 py-4) vs desktop uniform (p-4)
266
+ ...(isMobile
267
+ ? { paddingLeft: T.pXxxtight, paddingRight: T.pXxxtight, paddingTop: T.pXxtight, paddingBottom: T.pXxtight }
268
+ : { padding: T.pXxtight }),
269
+ display: "flex",
270
+ flexDirection: "column",
271
+ alignItems: "flex-start",
272
+ justifyContent: "center",
273
+ position: "relative",
274
+ width: isMobile ? 108 : "fit-content",
275
+ }}
276
+ >
277
+ {/* Container.Main: the pill button */}
278
+ <button
279
+ type="button"
280
+ disabled={disabled}
281
+ aria-haspopup="true"
282
+ aria-expanded={open}
283
+ aria-label={ariaLabel}
284
+ onClick={onClick}
285
+ onMouseEnter={() => !disabled && setHovered(true)}
286
+ onMouseLeave={() => { setHovered(false); setPressed(false); }}
287
+ onMouseDown={() => !disabled && setPressed(true)}
288
+ onMouseUp={() => setPressed(false)}
289
+ style={{
290
+ boxSizing: "border-box",
291
+ display: "flex",
292
+ alignItems: "center",
293
+ height: 36, maxHeight: 36, minHeight: 36,
294
+ maxWidth: isMobile ? 102 : 308,
295
+ borderRadius: T.radiusMedium,
296
+ border: `${T.borderWidth} solid ${stroke}`,
297
+ background: disabled ? T.fillBase : fill,
298
+ opacity: disabled ? 0.5 : 1,
299
+ cursor: disabled ? "not-allowed" : "pointer",
300
+ // Figma: Container.Main, pl-12px pr-6px py-4px (Padding/Medium, XTight, XXTight)
301
+ paddingLeft: "var(--semantic-layout-units-padding-tight)",
302
+ paddingRight: "var(--semantic-layout-units-padding-xxtight)",
303
+ paddingTop: T.pXxtight,
304
+ paddingBottom: T.pXxtight,
305
+ // Desktop: 2px gap between RowStart and RowEnd. Mobile: no gap.
306
+ ...(isMobile ? {} : { gap: T.pXxxtight }),
307
+ outline: "none",
308
+ transition: noMotion ? "none" : "background var(--motion-duration-2) var(--motion-easing-standard), border-color var(--motion-duration-2) var(--motion-easing-standard)",
309
+ }}
310
+ onFocus={e => {
311
+ // Only show focus ring on keyboard navigation, not on mouse click
312
+ if (e.target.matches?.(':focus-visible')) {
313
+ e.currentTarget.style.outline = `2px solid ${T.iconHover}`;
314
+ e.currentTarget.style.outlineOffset = "2px";
315
+ }
316
+ }}
317
+ onBlur={e => (e.currentTarget.style.outline = "none")}
318
+ >
319
+ {/* Intermediate wrapper around RowStart, `flex flex-row items-center
320
+ self-stretch` per Figma desktop. Lets the row vertically fill the
321
+ button content area on desktop. */}
322
+ <div style={{ display: "flex", flexDirection: "row", alignItems: "center", alignSelf: "stretch", flexShrink: 0 }}>
323
+ {/* Container.RowStart */}
324
+ <div style={{
325
+ display: "flex",
326
+ alignItems: "center",
327
+ flexShrink: 0,
328
+ gap: T.gapXxtight,
329
+ ...(isMobile
330
+ ? { height: 20, maxWidth: 74 }
331
+ : { height: "100%" }),
332
+ }}>
333
+ {/* Avatar only rendered when a logo URL is explicitly provided.
334
+ The church placeholder is no longer shown by default, per Figma
335
+ the OrgSwitcher shows org name only (showOrgAvatar = false). */}
336
+ {logoUrl && <TriggerAvatar logoUrl={logoUrl} size={AVATAR_SZ} borderColor={stroke} />}
337
+
338
+ {/* Container.OrgLabel, DESKTOP ONLY (annotation: truncates at 248pt) */}
339
+ {!isMobile && (
340
+ <div style={{
341
+ display: "flex", alignItems: "center", height: "100%",
342
+ maxWidth: 248, flexShrink: 0, minWidth: 0,
343
+ }}>
344
+ {/* Container.OrgName, content-sized, max-w 180.
345
+ Annotation: "Text Truncates if frame going beyond 170pt"
346
+ Figma autolayout class is max-w-[180px] (treat 180 as the
347
+ cap; "170pt" annotation is the design intent).
348
+ The inner <p> has max-w-[200px] per Figma autolayout. */}
349
+ <div style={{
350
+ display: "flex", alignItems: "center", height: "100%",
351
+ maxWidth: 180, flexShrink: 1, minWidth: 0,
352
+ }}>
353
+ <p style={{
354
+ ...TYPE_S,
355
+ color: text,
356
+ margin: 0,
357
+ maxWidth: 200,
358
+ whiteSpace: "nowrap",
359
+ overflow: "hidden",
360
+ textOverflow: "ellipsis",
361
+ }}>
362
+ {orgName}
363
+ </p>
364
+ </div>
365
+
366
+ {/* Container.CityName.Catholic, Catholic orgs ONLY (spec §0.1).
367
+ Figma annotation: "Catholic orgs only. NOT a suborg name."
368
+ The 6px left padding gives the pipe visual breathing room
369
+ from the OrgName, Figma renders this gap via the
370
+ OrgLabel autolayout, but with overflow:hidden + nowrap on
371
+ the inner <p>, leading whitespace inside the <p> is
372
+ unreliable across browsers. Use padding instead. */}
373
+ {showCityName && (
374
+ <div style={{
375
+ display: "flex", alignItems: "center", height: "100%",
376
+ maxWidth: 78, flexShrink: 0, minWidth: 0,
377
+ paddingLeft: "var(--semantic-layout-units-padding-xxtight)",
378
+ }}>
379
+ <p style={{
380
+ ...TYPE_S,
381
+ color: text,
382
+ margin: 0,
383
+ maxWidth: 72,
384
+ whiteSpace: "nowrap",
385
+ overflow: "hidden",
386
+ textOverflow: "ellipsis",
387
+ }}>
388
+ {`| ${cityName}`}
389
+ </p>
390
+ </div>
391
+ )}
392
+ </div>
393
+ )}
394
+
395
+ {/* Container.Label, MOBILE ONLY (Figma 40007067:13273).
396
+ Fixed w-50, holds the truncated full org name. Typography is
397
+ Label/Button/S: same scale as desktop. */}
398
+ {isMobile && (
399
+ <div style={{
400
+ display: "flex", alignItems: "center", height: "100%",
401
+ width: 50, flexShrink: 0, minWidth: 0,
402
+ }}>
403
+ <p style={{
404
+ ...TYPE_S,
405
+ color: text,
406
+ margin: 0,
407
+ maxWidth: 50,
408
+ whiteSpace: "nowrap",
409
+ overflow: "hidden",
410
+ textOverflow: "ellipsis",
411
+ }}>
412
+ {orgName}
413
+ </p>
414
+ </div>
415
+ )}
416
+ </div>
417
+ </div>
418
+
419
+ {/* Container.RowEnd, p-2 (xxxtight) around the trailing icon */}
420
+ <div style={{
421
+ display: "flex", alignItems: "center", justifyContent: "center",
422
+ padding: T.pXxxtight,
423
+ flexShrink: 0,
424
+ }}>
425
+ {/* Container.IconTrailing, size-16, p-2, holds the chevron */}
426
+ <div style={{
427
+ boxSizing: "border-box",
428
+ display: "flex", alignItems: "center", justifyContent: "center",
429
+ width: 16, height: 16,
430
+ padding: T.pXxxtight,
431
+ transform: open ? "rotate(180deg)" : "none",
432
+ transition: noMotion ? "none" : "transform var(--motion-duration-4) var(--motion-easing-standard)",
433
+ }}>
434
+ <Icon name="expand_more" size={12} style={{ color: icon }} />
435
+ </div>
436
+ </div>
437
+ </button>
438
+ </div>
439
+ );
440
+ }
441
+
442
+ // ═══════════════════════════════════════════════════════════════════════════════
443
+ // ORG SWITCHER PANEL (the "Open" state, dropdown, Figma node 40007336:9453)
444
+ // ═══════════════════════════════════════════════════════════════════════════════
445
+ // Implemented from the Figma Open/Desktop variant. Two deliberate deviations from
446
+ // the raw Figma export, both Figma-side artifacts:
447
+ // 1. Font is Red Hat Text (the DS font, CLAUDE.md §6), Figma reported
448
+ // "Google Sans Flex", which is not a Pathway font.
449
+ // 2. Panel text/border/icon use LIGHT-mode tokens: the Figma node bound
450
+ // DARK-mode tokens onto a white surface (e.g. a search border of
451
+ // rgba(255,255,255,0.16) that is invisible on white). Corrected for contrast.
452
+ // Icons are Material Symbols Rounded (Figma used Font Awesome for the chevron).
453
+
454
+ // Ministry Brands module colours (Figma "Module Colors/*"). Not yet tokenised, // flagged as a token gap in org-switcher-spec §Gaps.
455
+ export const ORG_MODULES = [
456
+ { key: "people", color: "#877EC8", icon: "group" },
457
+ { key: "giving", color: "#4BA8CB", icon: "volunteer_activism" },
458
+ { key: "mobile-app", color: "#6FCEB7", icon: "smartphone" },
459
+ { key: "website", color: "#4B6EC3", icon: "language" },
460
+ { key: "streaming", color: "#FF894A", icon: "live_tv" },
461
+ { key: "content", color: "#1E99AE", icon: "description" },
462
+ { key: "communications", color: "#1FA79B", icon: "mail" },
463
+ { key: "protections", color: "#E97272", icon: "shield" },
464
+ { key: "events", color: "#7996FC", icon: "event" },
465
+ { key: "accounting", color: "#F8C84F", icon: "account_balance" },
466
+ ];
467
+
468
+ // Demo organisations for the panel (mirrors the orgs shown in the Figma Open state).
469
+ export const DEMO_ORGS = [
470
+ { id: "grace", name: "Grace Church", initials: "GC" },
471
+ { id: "cityhope", name: "City Hope Church", initials: "CH" },
472
+ { id: "nkbc", name: "Northern Kentucky Baptist Church", initials: "NK" },
473
+ { id: "crosspoint", name: "Cross Point", initials: "CP" },
474
+ { id: "cornerstone", name: "Cornerstone", initials: "CO" },
475
+ { id: "elevate", name: "Elevate Life Church", initials: "EL" },
476
+ { id: "crossbridge", name: "Crossbridge", initials: "CB" },
477
+ ];
478
+
479
+ const PANEL_T = {
480
+ bg: "var(--semantic-color-fill-static-neutral-faint)",
481
+ header: "var(--semantic-color-foreground-static-neutral-base)",
482
+ border: "var(--semantic-color-stroke-static-neutral-faint)",
483
+ name: "var(--semantic-color-foreground-static-neutral-bold)",
484
+ icon: "var(--semantic-color-foreground-static-neutral-base)",
485
+ logoBg: "var(--semantic-color-fill-surface-chrome)",
486
+ rowActive: "var(--semantic-color-fill-action-primary-subtle-rest)",
487
+ };
488
+
489
+ function ModuleCluster() {
490
+ return (
491
+ <div style={{ display: "flex", alignItems: "center" }} aria-hidden="true">
492
+ {ORG_MODULES.map((m) => (
493
+ <span key={m.key} style={{
494
+ width: 18, height: 18, borderRadius: "50%", background: m.color,
495
+ marginRight: -4, border: "1.5px solid var(--semantic-color-fill-static-neutral-mono)", flexShrink: 0,
496
+ display: "flex", alignItems: "center", justifyContent: "center",
497
+ }}>
498
+ <Icon name={m.icon} size={11} style={{ color: "var(--semantic-color-foreground-static-neutral-mono)" }} />
499
+ </span>
500
+ ))}
501
+ </div>
502
+ );
503
+ }
504
+
505
+ function OrgRow({ org, active, onSelect }) {
506
+ const noMotion = useReducedMotion();
507
+ const [hov, setHov] = useState(false);
508
+ return (
509
+ <button
510
+ type="button"
511
+ onClick={() => onSelect && onSelect(org)}
512
+ onMouseEnter={() => setHov(true)}
513
+ onMouseLeave={() => setHov(false)}
514
+ aria-current={active ? "true" : undefined}
515
+ style={{
516
+ display: "flex", alignItems: "center", gap: 12, width: "100%", textAlign: "left",
517
+ padding: "8px 8px", border: "none", cursor: "pointer", borderRadius: 8,
518
+ fontFamily: "inherit",
519
+ background: active || hov ? PANEL_T.rowActive : "transparent",
520
+ transition: noMotion ? "none" : "background var(--motion-duration-2) var(--motion-easing-standard)",
521
+ }}
522
+ >
523
+ {/* Logo tile, org initials on brand fill (falls back from a logo image) */}
524
+ <div style={{
525
+ width: 44, height: 44, borderRadius: 6, flexShrink: 0, background: PANEL_T.logoBg,
526
+ display: "flex", alignItems: "center", justifyContent: "center", overflow: "hidden",
527
+ }}>
528
+ {org.logoUrl
529
+ ? <img src={org.logoUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />
530
+ : <span style={{ fontSize: 13, fontWeight: 700, color: "var(--semantic-color-foreground-static-neutral-mono)" }}>{org.initials}</span>}
531
+ </div>
532
+ {/* Name + module cluster */}
533
+ <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 6 }}>
534
+ <div style={{
535
+ fontSize: 14, fontWeight: 600, lineHeight: "18px", color: PANEL_T.name,
536
+ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
537
+ }}>{org.name}</div>
538
+ <ModuleCluster />
539
+ </div>
540
+ {/* Trailing chevron */}
541
+ <Icon name="chevron_right" size={20} style={{ color: PANEL_T.icon, flexShrink: 0 }} />
542
+ </button>
543
+ );
544
+ }
545
+
546
+ /**
547
+ * OrgSwitcherPanel: the dropdown shown when the OrgSwitcher trigger is open.
548
+ * Caller positions it (e.g. absolutely under the trigger). Trigger-only
549
+ * OrgSwitcher stays separate; compose the two per the Figma Open variant.
550
+ *
551
+ * Props:
552
+ * orgs, [{ id, name, initials, logoUrl? }] (default DEMO_ORGS)
553
+ * activeOrgId, id of the current org (highlighted row)
554
+ * query, controlled search string
555
+ * onQueryChange, onSearch, onSelect, callbacks
556
+ * style, extra styles on the panel (positioning)
557
+ */
558
+ export function OrgSwitcherPanel({
559
+ orgs = DEMO_ORGS, activeOrgId, query = "", onQueryChange, onSearch, onSelect, style,
560
+ }) {
561
+ return (
562
+ <div
563
+ role="dialog"
564
+ aria-label="Switch organisation"
565
+ style={{
566
+ width: 360, boxSizing: "border-box", background: PANEL_T.bg,
567
+ borderRadius: "var(--semantic-layout-units-cornerradius-base)", boxShadow: "var(--elevation-lift)",
568
+ padding: "18px 18px 24px", display: "flex", flexDirection: "column", gap: "var(--semantic-layout-units-gap-medium)",
569
+ fontFamily: "'Red Hat Text', sans-serif", ...style,
570
+ }}
571
+ >
572
+ <div style={{ padding: "4px 10px", fontSize: "var(--semantic-type-font-size-s)", fontWeight: 600, lineHeight: "var(--semantic-type-line-height-s-single)", color: PANEL_T.header }}>
573
+ My Organizations
574
+ </div>
575
+ {/* Search */}
576
+ <div style={{
577
+ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "var(--semantic-layout-units-padding-xtight)", borderRadius: "var(--semantic-layout-units-cornerradius-base)",
578
+ border: `1px solid ${PANEL_T.border}`,
579
+ }}>
580
+ <Icon name="search" size={20} style={{ color: PANEL_T.icon }} />
581
+ <input
582
+ type="text"
583
+ value={query}
584
+ placeholder="Search"
585
+ aria-label="Search organisations"
586
+ onChange={(e) => onQueryChange && onQueryChange(e.target.value)}
587
+ onKeyDown={(e) => { if (e.key === "Enter" && onSearch) onSearch(query); }}
588
+ style={{
589
+ flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent",
590
+ fontFamily: "inherit", fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", color: PANEL_T.name,
591
+ }}
592
+ />
593
+ </div>
594
+ {/* Org list */}
595
+ <div style={{ display: "flex", flexDirection: "column", gap: "var(--semantic-layout-units-gap-xxxtight)", maxHeight: 320, overflowY: "auto", overflowX: "hidden" }}>
596
+ {orgs.map((o) => (
597
+ <OrgRow key={o.id} org={o} active={o.id === activeOrgId} onSelect={onSelect} />
598
+ ))}
599
+ </div>
600
+ </div>
601
+ );
602
+ }
603
+
604
+ export default OrgSwitcher;