@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/_shared/use-reduced-motion.jsx +49 -0
- package/components/button/button.jsx +512 -0
- package/components/icon/action-icon.jsx +155 -0
- package/components/icon/display-icon.jsx +137 -0
- package/components/icon/featured-icon.jsx +162 -0
- package/components/icon/icon.jsx +154 -0
- package/components/module-icon/module-icon.jsx +387 -0
- package/components/org-switcher/org-switcher.jsx +604 -0
- package/components/page-template/page-template.jsx +147 -0
- package/components/scrollbar/scrollbar.jsx +232 -0
- package/components/search/search.jsx +689 -0
- package/components/sidenav/sidenav.jsx +951 -0
- package/components/top-nav/top-nav.jsx +1003 -0
- package/package.json +26 -4
- package/README.md +0 -3
|
@@ -0,0 +1,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;
|