@breathe-ds/ui 0.2.0 → 0.3.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/README.md +71 -9
- package/components/Accordion.tsx +6 -4
- package/components/Avatar.tsx +37 -0
- package/components/BottomNav.tsx +1 -1
- package/components/Breadcrumb.tsx +45 -0
- package/components/BreatheMark.tsx +0 -13
- package/components/Byline.tsx +103 -0
- package/components/CaptionBand.tsx +74 -0
- package/components/CtaCard.tsx +52 -0
- package/components/FactStrip.tsx +55 -0
- package/components/Footer.tsx +3 -20
- package/components/Heading.tsx +86 -0
- package/components/InfoCard.tsx +116 -0
- package/components/LanguageSwitcher.tsx +1 -1
- package/components/List.tsx +81 -0
- package/components/Lockup.tsx +0 -18
- package/components/MapCard.tsx +158 -0
- package/components/MediaCard.tsx +266 -0
- package/components/PersonChip.tsx +49 -0
- package/components/Pill.tsx +42 -0
- package/components/PillList.tsx +20 -47
- package/components/ProfessionalCard.tsx +341 -0
- package/components/ProfessionalModal.tsx +243 -0
- package/components/Rows.tsx +131 -0
- package/components/TestimonialCard.tsx +138 -0
- package/components/Text.tsx +31 -0
- package/components/Timeline.tsx +69 -0
- package/components/VideoInviteCard.tsx +168 -0
- package/components/VideoStoryCard.tsx +382 -0
- package/package.json +22 -1
- package/theme.css +66 -0
- package/tokens/creeta.css +5 -2
- package/tokens/neutral.css +10 -4
- package/tokens/ttcab.css +1 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
|
|
4
|
+
import { initials } from "./Avatar";
|
|
5
|
+
import { ProfessionalModal, type ProfessionalModalLabels, type ProfessionalPresentation } from "./ProfessionalModal";
|
|
6
|
+
import { CollapseRow, LinkRow, RowList } from "./Rows";
|
|
7
|
+
|
|
8
|
+
export type ProfessionalPhoto = {
|
|
9
|
+
src: string;
|
|
10
|
+
/** Default: `labels.portrait(name)` ("Portrait of {name}"). */
|
|
11
|
+
alt?: string;
|
|
12
|
+
srcSet?: string;
|
|
13
|
+
/** Default: one of three columns from lg, two from sm, full width on phones. */
|
|
14
|
+
sizes?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ProfessionalLiving = {
|
|
18
|
+
/** Short muted clip, back-and-forth (first frame == last frame). */
|
|
19
|
+
src: string;
|
|
20
|
+
/** The clip's first frame: it is the still portrait. Same frame the clip
|
|
21
|
+
* starts and ends on, so there is no jump when it plays or stops. */
|
|
22
|
+
poster: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ProfessionalGuidance = { label: ReactNode; href: string };
|
|
26
|
+
|
|
27
|
+
export type ProfessionalCardLabels = ProfessionalModalLabels & {
|
|
28
|
+
/** alt of the portrait. */
|
|
29
|
+
portrait: (name: string) => string;
|
|
30
|
+
trajectory: string;
|
|
31
|
+
/** The row of the guidance the person signs; the count goes beside it. */
|
|
32
|
+
guidance: string;
|
|
33
|
+
/** sr-only, right after "Trajectory" and "Guidance": " of {name}". */
|
|
34
|
+
of: (name: string) => string;
|
|
35
|
+
instagram: string;
|
|
36
|
+
/** sr-only, right after "Instagram". */
|
|
37
|
+
instagramOf: (name: string) => string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const EN: Omit<ProfessionalCardLabels, keyof ProfessionalModalLabels> = {
|
|
41
|
+
portrait: (name) => `Portrait of ${name}`,
|
|
42
|
+
trajectory: "Trajectory",
|
|
43
|
+
guidance: "Guidance",
|
|
44
|
+
of: (name) => ` of ${name}`,
|
|
45
|
+
instagram: "Instagram",
|
|
46
|
+
instagramOf: (name) => ` of ${name} (opens in a new tab)`,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const SIZES = "(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 92vw";
|
|
50
|
+
|
|
51
|
+
/** The photo zooms in a little with the mouse over the card, as on the home. */
|
|
52
|
+
const ZOOM =
|
|
53
|
+
"aspect-[4/5] h-auto w-full object-cover transition-transform duration-500 ease-[var(--ease-cg)] motion-reduce:transition-none";
|
|
54
|
+
const ZOOM_CARD = `${ZOOM} [@media(hover:hover)_and_(pointer:fine)]:group-hover/card:scale-[1.03] motion-reduce:[@media(hover:hover)_and_(pointer:fine)]:group-hover/card:scale-100`;
|
|
55
|
+
/* inside the modal trigger only the photo button is the hover target */
|
|
56
|
+
const ZOOM_PHOTO = `${ZOOM} [@media(hover:hover)_and_(pointer:fine)]:group-hover/photo:scale-[1.03] motion-reduce:[@media(hover:hover)_and_(pointer:fine)]:group-hover/photo:scale-100`;
|
|
57
|
+
|
|
58
|
+
type LivingProps = ProfessionalLiving & {
|
|
59
|
+
/** The usual photo. Only shows if the video fails. */
|
|
60
|
+
photo: ProfessionalPhoto;
|
|
61
|
+
/** Replaces the fallback <img> (e.g. next/image). */
|
|
62
|
+
media?: ReactNode;
|
|
63
|
+
/** alt text read when the clip is decoration beside the name (sr-only). */
|
|
64
|
+
alt: string;
|
|
65
|
+
/** Empty alt when the text beside already says who it is. */
|
|
66
|
+
decorative?: boolean;
|
|
67
|
+
className?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Portrait that comes alive with the mouse over the card — novo.creeta's
|
|
72
|
+
* `FotoViva` (components/foto-viva.tsx).
|
|
73
|
+
*
|
|
74
|
+
* What is seen still is the poster, the clip's own first frame. With the
|
|
75
|
+
* mouse over, the clip loops. On leave it does not cut: it finishes the way
|
|
76
|
+
* back to the first frame. The clip goes there and back, so the frame at t is
|
|
77
|
+
* the one at (duration − t): if the mouse leaves on the way out, it jumps to
|
|
78
|
+
* the mirrored point (same image) and plays only the way back.
|
|
79
|
+
*
|
|
80
|
+
* Only with a fine pointer (desktop); on touch the poster stays, because the
|
|
81
|
+
* tap on the photo already has a job (opens the presentation of who has one).
|
|
82
|
+
* People who asked for less motion also keep the poster. The clip only
|
|
83
|
+
* downloads on the first hover. If the video fails, the photo takes over.
|
|
84
|
+
*
|
|
85
|
+
* The hover target is the whole card: the closest `[data-cg-professional-card]`,
|
|
86
|
+
* <article> or <a>, else the parent.
|
|
87
|
+
*/
|
|
88
|
+
export function LivingPortrait({ src, poster, photo, media, alt, decorative = false, className = "" }: LivingProps) {
|
|
89
|
+
const video = useRef<HTMLVideoElement>(null);
|
|
90
|
+
const [failed, setFailed] = useState(false);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const v = video.current;
|
|
94
|
+
if (!v) return;
|
|
95
|
+
const card = v.closest("[data-cg-professional-card], article, a") ?? v.parentElement;
|
|
96
|
+
if (!card) return;
|
|
97
|
+
const canAnimate = () =>
|
|
98
|
+
typeof window.matchMedia === "function" &&
|
|
99
|
+
window.matchMedia("(hover: hover) and (pointer: fine)").matches &&
|
|
100
|
+
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
101
|
+
const enter = () => {
|
|
102
|
+
if (!canAnimate()) return;
|
|
103
|
+
v.loop = true;
|
|
104
|
+
v.play().catch(() => {});
|
|
105
|
+
};
|
|
106
|
+
const leave = () => {
|
|
107
|
+
const d = v.duration;
|
|
108
|
+
if (!Number.isFinite(d) || d === 0) {
|
|
109
|
+
// no data yet: nothing to finish
|
|
110
|
+
v.pause();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (v.paused) return;
|
|
114
|
+
if (v.currentTime < d / 2) v.currentTime = d - v.currentTime;
|
|
115
|
+
v.loop = false;
|
|
116
|
+
};
|
|
117
|
+
// reached the end of the way back: the last frame equals the first
|
|
118
|
+
const onEnded = () => {
|
|
119
|
+
v.currentTime = 0;
|
|
120
|
+
v.loop = true;
|
|
121
|
+
};
|
|
122
|
+
card.addEventListener("pointerenter", enter);
|
|
123
|
+
card.addEventListener("pointerleave", leave);
|
|
124
|
+
v.addEventListener("ended", onEnded);
|
|
125
|
+
return () => {
|
|
126
|
+
card.removeEventListener("pointerenter", enter);
|
|
127
|
+
card.removeEventListener("pointerleave", leave);
|
|
128
|
+
v.removeEventListener("ended", onEnded);
|
|
129
|
+
};
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
if (failed) {
|
|
133
|
+
return (
|
|
134
|
+
<span data-portrait="living" className={`relative block ${className}`}>
|
|
135
|
+
{media ?? (
|
|
136
|
+
<img
|
|
137
|
+
src={photo.src}
|
|
138
|
+
srcSet={photo.srcSet}
|
|
139
|
+
sizes={photo.sizes ?? SIZES}
|
|
140
|
+
alt={decorative ? "" : alt}
|
|
141
|
+
loading="lazy"
|
|
142
|
+
decoding="async"
|
|
143
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
144
|
+
/>
|
|
145
|
+
)}
|
|
146
|
+
</span>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<span data-portrait="living" className={`relative block ${className}`}>
|
|
152
|
+
<video
|
|
153
|
+
ref={video}
|
|
154
|
+
src={src}
|
|
155
|
+
poster={poster}
|
|
156
|
+
muted
|
|
157
|
+
loop
|
|
158
|
+
playsInline
|
|
159
|
+
preload="none"
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
tabIndex={-1}
|
|
162
|
+
onError={() => setFailed(true)}
|
|
163
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
164
|
+
/>
|
|
165
|
+
{!decorative && <span className="sr-only">{alt}</span>}
|
|
166
|
+
</span>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type PortraitProps = {
|
|
171
|
+
name: string;
|
|
172
|
+
photo?: ProfessionalPhoto;
|
|
173
|
+
media?: ReactNode;
|
|
174
|
+
living?: ProfessionalLiving;
|
|
175
|
+
alt: string;
|
|
176
|
+
decorative?: boolean;
|
|
177
|
+
className: string;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** The card's portrait: living clip, custom media, or the plain <img>. */
|
|
181
|
+
function Portrait({ photo, media, living, alt, decorative = false, className }: PortraitProps) {
|
|
182
|
+
if (living) {
|
|
183
|
+
return (
|
|
184
|
+
<LivingPortrait
|
|
185
|
+
src={living.src}
|
|
186
|
+
poster={living.poster}
|
|
187
|
+
photo={photo ?? { src: living.poster }}
|
|
188
|
+
media={media}
|
|
189
|
+
alt={alt}
|
|
190
|
+
decorative={decorative}
|
|
191
|
+
className={className}
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (media) return <>{media}</>;
|
|
196
|
+
if (!photo) return null;
|
|
197
|
+
return (
|
|
198
|
+
<img
|
|
199
|
+
src={photo.src}
|
|
200
|
+
srcSet={photo.srcSet}
|
|
201
|
+
sizes={photo.sizes ?? SIZES}
|
|
202
|
+
alt={decorative ? "" : alt}
|
|
203
|
+
loading="lazy"
|
|
204
|
+
decoding="async"
|
|
205
|
+
className={className}
|
|
206
|
+
/>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type Props = {
|
|
211
|
+
name: string;
|
|
212
|
+
/** Second line under the name (the profession), in `ink-soft`. */
|
|
213
|
+
role?: ReactNode;
|
|
214
|
+
/** Registration line (CREFITO). The family rule: a health professional
|
|
215
|
+
* always shows the council number. */
|
|
216
|
+
registration?: ReactNode;
|
|
217
|
+
/** The still portrait (3:4 source, shown 4:5). Without it (and without
|
|
218
|
+
* `media`/`living`), the initials in a tinted bubble. */
|
|
219
|
+
photo?: ProfessionalPhoto;
|
|
220
|
+
/** Your own image element (e.g. next/image) in place of `photo`. Give it
|
|
221
|
+
* the box yourself (`aspect-[4/5] w-full object-cover`). */
|
|
222
|
+
media?: ReactNode;
|
|
223
|
+
/** Living portrait: the clip plays muted on hover; `photo` is the fallback. */
|
|
224
|
+
living?: ProfessionalLiving;
|
|
225
|
+
/** With a presentation video the portrait opens the modal (ProfessionalModal). */
|
|
226
|
+
presentation?: ProfessionalPresentation;
|
|
227
|
+
/** The bio: a string (one paragraph) or your own paragraphs. Opens in place
|
|
228
|
+
* under "Trajectory". */
|
|
229
|
+
bio?: ReactNode;
|
|
230
|
+
/** The guidance pages the person signs: the row lists them, with the count. */
|
|
231
|
+
guidance?: ProfessionalGuidance[];
|
|
232
|
+
/** Instagram profile URL (outbound row). */
|
|
233
|
+
instagram?: string;
|
|
234
|
+
/** Anchor of the card (the authorship link of a guidance page lands here). */
|
|
235
|
+
id?: string;
|
|
236
|
+
/** h2 on the team page (default), h3 inside a section that already has an h2. */
|
|
237
|
+
headingLevel?: "h2" | "h3";
|
|
238
|
+
/** Root element. Default <article>. */
|
|
239
|
+
as?: ElementType;
|
|
240
|
+
/** Link component of the app for the guidance links (e.g. next-intl Link). Default: <a>. */
|
|
241
|
+
linkComponent?: ElementType;
|
|
242
|
+
labels?: Partial<ProfessionalCardLabels>;
|
|
243
|
+
className?: string;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Team card of the Cuidado Global family — novo.creeta's `ProfessionalCard`
|
|
248
|
+
* (components/professional-card.tsx) with the Breathe tokens. A `card`
|
|
249
|
+
* surface with the portrait on top (plain, living, or opening the video
|
|
250
|
+
* presentation), the name, role and registration, then one list of equal
|
|
251
|
+
* rows separated by hairlines: Trajectory (opens the bio in place), Guidance
|
|
252
|
+
* (opens the links in place) and Instagram (outbound). Six open bios used to
|
|
253
|
+
* make the page 6,700px tall on a phone, hence the rows.
|
|
254
|
+
*
|
|
255
|
+
* The Guidance row carries `data-cg-open-on-arrival`: the site opens it when
|
|
256
|
+
* the page is reached by the card's anchor (creeta's `abre-ao-chegar.tsx`,
|
|
257
|
+
* not part of the DS).
|
|
258
|
+
*/
|
|
259
|
+
export function ProfessionalCard({
|
|
260
|
+
name,
|
|
261
|
+
role,
|
|
262
|
+
registration,
|
|
263
|
+
photo,
|
|
264
|
+
media,
|
|
265
|
+
living,
|
|
266
|
+
presentation,
|
|
267
|
+
bio,
|
|
268
|
+
guidance,
|
|
269
|
+
instagram,
|
|
270
|
+
id,
|
|
271
|
+
headingLevel: Heading = "h2",
|
|
272
|
+
as: As = "article",
|
|
273
|
+
linkComponent,
|
|
274
|
+
labels,
|
|
275
|
+
className = "",
|
|
276
|
+
}: Props) {
|
|
277
|
+
const t = { ...EN, ...labels };
|
|
278
|
+
const L: ElementType = linkComponent ?? "a";
|
|
279
|
+
const alt = photo?.alt ?? t.portrait(name);
|
|
280
|
+
const hasPortrait = Boolean(photo || media || living);
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<As id={id} data-cg-professional-card="" className={`group/card scroll-mt-24 rounded-2xl bg-card p-3 pb-6 ${className}`}>
|
|
284
|
+
{hasPortrait && presentation ? (
|
|
285
|
+
// with a presentation video, the photo opens the modal
|
|
286
|
+
<ProfessionalModal
|
|
287
|
+
name={name}
|
|
288
|
+
role={role}
|
|
289
|
+
registration={registration}
|
|
290
|
+
bio={bio}
|
|
291
|
+
presentation={presentation}
|
|
292
|
+
labels={labels}
|
|
293
|
+
portrait={<Portrait name={name} photo={photo} media={media} living={living} alt={alt} decorative className={ZOOM_PHOTO} />}
|
|
294
|
+
/>
|
|
295
|
+
) : hasPortrait ? (
|
|
296
|
+
<span className="block overflow-hidden rounded-[20px]">
|
|
297
|
+
<Portrait name={name} photo={photo} media={media} living={living} alt={alt} className={ZOOM_CARD} />
|
|
298
|
+
</span>
|
|
299
|
+
) : (
|
|
300
|
+
<div
|
|
301
|
+
data-testid="initials"
|
|
302
|
+
aria-hidden="true"
|
|
303
|
+
className="flex aspect-[3/4] w-full items-center justify-center rounded-[20px] bg-ink/[0.12]"
|
|
304
|
+
>
|
|
305
|
+
<span className="font-heading text-4xl font-bold text-ink">{initials(name)}</span>
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
<div className="px-3 pt-5">
|
|
309
|
+
<Heading className="font-heading text-xl font-medium leading-snug text-ink">{name}</Heading>
|
|
310
|
+
{role && <p className="mt-1 text-sm font-semibold text-ink-soft">{role}</p>}
|
|
311
|
+
{registration && (
|
|
312
|
+
<>
|
|
313
|
+
{/* /80 = 5.78:1 on white (creeta) — the CREFITO has a legal
|
|
314
|
+
visibility duty (COFFITO 414/2012); /60 gave 3.37:1 and failed AA. */}
|
|
315
|
+
<p className="mt-1 text-xs text-ink/80">{registration}</p>
|
|
316
|
+
</>
|
|
317
|
+
)}
|
|
318
|
+
{/* Trajectory, guidance and Instagram: one list, of equal rows. They
|
|
319
|
+
used to be three things with three designs (an open button, a loose
|
|
320
|
+
list of links and a magenta link at the end). The first two open in
|
|
321
|
+
place: six open bios made 6,700px of page on a phone. */}
|
|
322
|
+
<RowList className="mt-4">
|
|
323
|
+
{bio && (
|
|
324
|
+
<CollapseRow label={t.trajectory} srLabel={t.of(name)}>
|
|
325
|
+
{bio}
|
|
326
|
+
</CollapseRow>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
{/* What the person signs: it is what makes this card the destination
|
|
330
|
+
of the authorship link of the guidance pages. Whoever arrives by
|
|
331
|
+
that link finds this block open (site-side, data-cg-open-on-arrival). */}
|
|
332
|
+
{guidance && guidance.length > 0 && (
|
|
333
|
+
<CollapseRow label={t.guidance} srLabel={t.of(name)} links={guidance} linkComponent={L} data-cg-open-on-arrival="" />
|
|
334
|
+
)}
|
|
335
|
+
|
|
336
|
+
{instagram && <LinkRow label={t.instagram} srLabel={t.instagramOf(name)} href={instagram} external />}
|
|
337
|
+
</RowList>
|
|
338
|
+
</div>
|
|
339
|
+
</As>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import { Play, X } from "lucide-react";
|
|
5
|
+
import { MediaCardBadge } from "./MediaCard";
|
|
6
|
+
import { CaptionBand, useCaption } from "./CaptionBand";
|
|
7
|
+
import { formatDuration } from "./VideoStoryCard";
|
|
8
|
+
|
|
9
|
+
export type ProfessionalCaptions = {
|
|
10
|
+
/** WebVTT file in the page's language. With it, the current line is shown in
|
|
11
|
+
* the CaptionBand at the top of the text column (never over the picture). */
|
|
12
|
+
src: string;
|
|
13
|
+
/** BCP 47 tag of the captions ("en", "pt-BR"). */
|
|
14
|
+
srcLang: string;
|
|
15
|
+
/** Name of the language, for the browser's captions menu. */
|
|
16
|
+
label?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ProfessionalPresentation = {
|
|
20
|
+
/** The presentation video, recorded for the clinic's Instagram. Downloaded
|
|
21
|
+
* only when the modal opens; plays with sound (it was a tap by the person). */
|
|
22
|
+
src: string;
|
|
23
|
+
poster?: string;
|
|
24
|
+
captions?: ProfessionalCaptions;
|
|
25
|
+
/** Seconds (formatted as "1 min 15 s"), or an already formatted string for
|
|
26
|
+
* other languages. Goes into the sr-only text of the trigger. */
|
|
27
|
+
duration?: number | string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ProfessionalModalLabels = {
|
|
31
|
+
/** The chip over the portrait: says the photo opens a video. */
|
|
32
|
+
presentation: string;
|
|
33
|
+
/** sr-only text of the trigger. `duration` is undefined when none was given. */
|
|
34
|
+
watch: (firstName: string, duration?: string) => string;
|
|
35
|
+
/** aria-label of the <video>. */
|
|
36
|
+
video: (firstName: string) => string;
|
|
37
|
+
close: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Lucide's icon type is wider than MediaCardBadge's `IconType` under strict
|
|
41
|
+
* TS (propTypes allow a string `size`); this thin wrapper fits the slot. */
|
|
42
|
+
const PlayIcon = (p: { size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }) => <Play {...p} />;
|
|
43
|
+
|
|
44
|
+
const EN: ProfessionalModalLabels = {
|
|
45
|
+
presentation: "Presentation",
|
|
46
|
+
watch: (firstName, duration) => `Watch ${firstName}'s video presentation${duration ? ` (${duration})` : ""}`,
|
|
47
|
+
video: (firstName) => `${firstName}'s video presentation`,
|
|
48
|
+
close: "Close",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Props = {
|
|
52
|
+
name: string;
|
|
53
|
+
/** Second line under the name (the profession). */
|
|
54
|
+
role?: ReactNode;
|
|
55
|
+
/** Registration line (CREFITO). Kept at /80: it has a legal visibility duty
|
|
56
|
+
* (COFFITO 414/2012). */
|
|
57
|
+
registration?: ReactNode;
|
|
58
|
+
/** Full bio: a string (one paragraph) or your own paragraphs. */
|
|
59
|
+
bio?: ReactNode;
|
|
60
|
+
presentation: ProfessionalPresentation;
|
|
61
|
+
/** The portrait the trigger shows (plain photo or living portrait), rendered
|
|
62
|
+
* by the card so both share one media contract. Decorative: the sr-only
|
|
63
|
+
* text of the button already says who it is. */
|
|
64
|
+
portrait: ReactNode;
|
|
65
|
+
labels?: Partial<ProfessionalModalLabels>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Team portrait that opens the video presentation in a modal — novo.creeta's
|
|
70
|
+
* `Apresentacao` (components/apresentacao.tsx) with the Breathe tokens.
|
|
71
|
+
*
|
|
72
|
+
* - The portrait is a button with a small play chip in the corner. On tap, the
|
|
73
|
+
* modal grows out of the photo's place (FLIP: starts with the photo's scale
|
|
74
|
+
* and position, transform and opacity only) up to the centre. On close it
|
|
75
|
+
* goes back the same way.
|
|
76
|
+
* - It is a native <dialog>: focus trapped, Esc closes, focus returns to the
|
|
77
|
+
* button, dark blurred backdrop. Body scroll is locked by the browser's
|
|
78
|
+
* modal layer (`showModal()`), nothing else to do.
|
|
79
|
+
* - The video is only downloaded when the modal opens, and plays with sound
|
|
80
|
+
* (it was a tap by the person). Under reduced motion it just appears.
|
|
81
|
+
* - With `captions`, the current line is shown in the CaptionBand that opens
|
|
82
|
+
* the text column: under the video on phones, beside it from `md`.
|
|
83
|
+
*/
|
|
84
|
+
export function ProfessionalModal({ name, role, registration, bio, presentation, portrait, labels }: Props) {
|
|
85
|
+
const t = { ...EN, ...labels };
|
|
86
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
87
|
+
const box = useRef<HTMLDialogElement>(null);
|
|
88
|
+
const video = useRef<HTMLVideoElement>(null);
|
|
89
|
+
const [open, setOpen] = useState(false);
|
|
90
|
+
const headingId = useId();
|
|
91
|
+
const captions = presentation.captions;
|
|
92
|
+
const { line, attach } = useCaption();
|
|
93
|
+
const firstName = name.split(" ")[0];
|
|
94
|
+
const duration =
|
|
95
|
+
typeof presentation.duration === "number" ? formatDuration(presentation.duration) : presentation.duration;
|
|
96
|
+
|
|
97
|
+
// Where the modal is born from: the difference between the photo and the box, as a transform.
|
|
98
|
+
function origin() {
|
|
99
|
+
const d = box.current;
|
|
100
|
+
const f = trigger.current;
|
|
101
|
+
if (!d || !f) return;
|
|
102
|
+
const a = f.getBoundingClientRect();
|
|
103
|
+
const b = d.getBoundingClientRect();
|
|
104
|
+
d.style.setProperty("--dx", `${a.left + a.width / 2 - (b.left + b.width / 2)}px`);
|
|
105
|
+
d.style.setProperty("--dy", `${a.top + a.height / 2 - (b.top + b.height / 2)}px`);
|
|
106
|
+
d.style.setProperty("--sx", `${a.width / b.width}`);
|
|
107
|
+
d.style.setProperty("--sy", `${a.height / b.height}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function show() {
|
|
111
|
+
const d = box.current;
|
|
112
|
+
if (!d) return;
|
|
113
|
+
setOpen(true);
|
|
114
|
+
d.showModal();
|
|
115
|
+
// origin() reads the layout, which flushes the closed styles; the attribute
|
|
116
|
+
// set right after is what the transition runs from.
|
|
117
|
+
origin();
|
|
118
|
+
d.setAttribute("data-open", "");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The <video> is only mounted while the modal is open: this effect presses
|
|
122
|
+
// play, once it exists. The tap on the photo counts as the person's
|
|
123
|
+
// gesture, so it plays with sound.
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!open) return;
|
|
126
|
+
const v = video.current;
|
|
127
|
+
if (!v) return;
|
|
128
|
+
v.play().catch(() => {});
|
|
129
|
+
}, [open]);
|
|
130
|
+
|
|
131
|
+
function close() {
|
|
132
|
+
const d = box.current;
|
|
133
|
+
if (!d) return;
|
|
134
|
+
video.current?.pause();
|
|
135
|
+
origin();
|
|
136
|
+
d.removeAttribute("data-open");
|
|
137
|
+
// wait for the way back to finish before taking it off the screen
|
|
138
|
+
const wait = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? 0 : 260;
|
|
139
|
+
window.setTimeout(() => {
|
|
140
|
+
d.close();
|
|
141
|
+
setOpen(false);
|
|
142
|
+
}, wait);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Esc and click on the backdrop: close with the same animation.
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const d = box.current;
|
|
148
|
+
if (!d) return;
|
|
149
|
+
const onCancel = (e: Event) => {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
close();
|
|
152
|
+
};
|
|
153
|
+
const onClick = (e: MouseEvent) => {
|
|
154
|
+
if (e.target === d) close();
|
|
155
|
+
};
|
|
156
|
+
d.addEventListener("cancel", onCancel);
|
|
157
|
+
d.addEventListener("click", onClick);
|
|
158
|
+
return () => {
|
|
159
|
+
d.removeEventListener("cancel", onCancel);
|
|
160
|
+
d.removeEventListener("click", onClick);
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
<button
|
|
167
|
+
ref={trigger}
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={show}
|
|
170
|
+
className="group/photo relative block w-full overflow-hidden rounded-[20px] text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
|
|
171
|
+
>
|
|
172
|
+
{portrait}
|
|
173
|
+
{/* the play chip in the corner: says the photo opens a video */}
|
|
174
|
+
<span
|
|
175
|
+
aria-hidden="true"
|
|
176
|
+
className="absolute bottom-3 right-3 transition-transform duration-200 ease-out group-active/photo:scale-95 motion-reduce:transition-none"
|
|
177
|
+
>
|
|
178
|
+
<MediaCardBadge Icon={PlayIcon}>{t.presentation}</MediaCardBadge>
|
|
179
|
+
</span>
|
|
180
|
+
<span className="sr-only">{t.watch(firstName, duration)}</span>
|
|
181
|
+
</button>
|
|
182
|
+
|
|
183
|
+
{/* The <dialog> is born from the photo's place: --dx/--dy/--sx/--sy are
|
|
184
|
+
the photo's position and scale relative to the box, computed on open.
|
|
185
|
+
Transform and opacity only; `data-open` runs the transition, closing
|
|
186
|
+
runs it back. Under reduced motion: a short fade, no transform. */}
|
|
187
|
+
<dialog
|
|
188
|
+
ref={box}
|
|
189
|
+
aria-labelledby={headingId}
|
|
190
|
+
className="m-auto max-h-[calc(100dvh-1.5rem)] w-[min(100vw-1.5rem,64rem)] overflow-y-auto rounded-[32px] bg-card p-0 text-ink shadow-2xl md:rounded-[40px] opacity-0 [transform:translate(var(--dx,0),var(--dy,0))_scale(var(--sx,0.9),var(--sy,0.9))] [transition:transform_320ms_var(--ease-cg),opacity_200ms_ease-out] data-open:opacity-100 data-open:transform-none motion-reduce:transform-none motion-reduce:[transition:opacity_150ms_ease] backdrop:bg-ink/60 backdrop:backdrop-blur-md backdrop:opacity-0 backdrop:[transition:opacity_240ms_ease-out] [&[data-open]::backdrop]:opacity-100"
|
|
191
|
+
>
|
|
192
|
+
<div className="grid md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
|
|
193
|
+
{/* on phones the box is shorter than 9:16: the video fits whole
|
|
194
|
+
(object-contain), without cutting the head */}
|
|
195
|
+
<div className="relative aspect-[9/16] max-h-[55dvh] w-full bg-ink md:max-h-none">
|
|
196
|
+
{open && (
|
|
197
|
+
<video
|
|
198
|
+
ref={video}
|
|
199
|
+
src={presentation.src}
|
|
200
|
+
poster={presentation.poster}
|
|
201
|
+
controls
|
|
202
|
+
playsInline
|
|
203
|
+
aria-label={t.video(firstName)}
|
|
204
|
+
className="absolute inset-0 h-full w-full object-contain md:object-cover"
|
|
205
|
+
>
|
|
206
|
+
{captions && (
|
|
207
|
+
<track ref={attach} kind="subtitles" src={captions.src} srcLang={captions.srcLang} label={captions.label} default />
|
|
208
|
+
)}
|
|
209
|
+
</video>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
<div className="flex flex-col p-6 md:p-10">
|
|
213
|
+
{/* the spoken line, in the page's language: opens the text column,
|
|
214
|
+
which sits right under the video on phones and beside it on desktop */}
|
|
215
|
+
{open && captions && (
|
|
216
|
+
<div className="-mt-2 mb-5 md:mr-12">
|
|
217
|
+
<CaptionBand text={line} lang={captions.srcLang} />
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
<h2 id={headingId} className="font-heading text-2xl font-medium leading-snug md:text-[2rem]">
|
|
221
|
+
{name}
|
|
222
|
+
</h2>
|
|
223
|
+
{role && <p className="mt-1 text-sm font-semibold text-ink-soft">{role}</p>}
|
|
224
|
+
{registration && <p className="mt-1 text-xs text-ink/80">{registration}</p>}
|
|
225
|
+
{typeof bio === "string" ? (
|
|
226
|
+
<p className="mt-5 leading-relaxed text-ink/80">{bio}</p>
|
|
227
|
+
) : (
|
|
228
|
+
bio && <div className="mt-5 space-y-3 leading-relaxed text-ink/80">{bio}</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
onClick={close}
|
|
235
|
+
className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center rounded-full bg-ink text-white transition-transform duration-150 ease-out hover:bg-ink/90 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none"
|
|
236
|
+
>
|
|
237
|
+
<X aria-hidden="true" size={20} strokeWidth={2} />
|
|
238
|
+
<span className="sr-only">{t.close}</span>
|
|
239
|
+
</button>
|
|
240
|
+
</dialog>
|
|
241
|
+
</>
|
|
242
|
+
);
|
|
243
|
+
}
|