@breathe-ds/ui 0.3.0 → 0.4.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 +50 -7
- package/components/AnchorNav.tsx +12 -4
- package/components/Button.tsx +11 -4
- package/components/Byline.tsx +3 -1
- package/components/CareSteps.tsx +86 -0
- package/components/ChatPreview.tsx +138 -0
- package/components/FeaturedPosts.tsx +80 -0
- package/components/Footer.tsx +47 -10
- package/components/Header.tsx +4 -2
- package/components/PageVideoHero.tsx +65 -23
- package/components/PauseButton.tsx +41 -0
- package/components/PhotoSequence.tsx +99 -0
- package/components/PlanTimeline.tsx +66 -0
- package/components/PostArticle.tsx +102 -0
- package/components/PostCard.tsx +69 -0
- package/components/ProfessionalCard.tsx +155 -50
- package/components/Rail.tsx +94 -0
- package/components/Rows.tsx +9 -3
- package/components/StoryPanel.tsx +107 -0
- package/components/useOnAppear.ts +39 -0
- package/package.json +13 -2
- package/theme.css +140 -0
- package/tokens/creeta.css +2 -0
- package/tokens/neutral.css +2 -0
- package/tokens/ttcab.css +13 -5
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
|
|
4
4
|
import { initials } from "./Avatar";
|
|
5
5
|
import { ProfessionalModal, type ProfessionalModalLabels, type ProfessionalPresentation } from "./ProfessionalModal";
|
|
6
|
-
import { CollapseRow, LinkRow, RowList } from "./Rows";
|
|
6
|
+
import { CollapseRow, LinkRow, RowList, type RowLink } from "./Rows";
|
|
7
7
|
|
|
8
8
|
export type ProfessionalPhoto = {
|
|
9
9
|
src: string;
|
|
@@ -22,28 +22,50 @@ export type ProfessionalLiving = {
|
|
|
22
22
|
poster: string;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* One row at the foot of the full card (RowList). Generic: the site decides
|
|
27
|
+
* what the rows are. On novo.creeta they are "Trajetória" (collapse with the
|
|
28
|
+
* bio), "Orientações" (collapse with the links the person signs, with
|
|
29
|
+
* `openOnArrival`) and "Instagram" (link with icon, new tab).
|
|
30
|
+
*/
|
|
31
|
+
export type ProfessionalCardRow =
|
|
32
|
+
| {
|
|
33
|
+
kind: "collapse";
|
|
34
|
+
label: ReactNode;
|
|
35
|
+
/** sr-only right after the label, e.g. " of Person name". */
|
|
36
|
+
srLabel?: ReactNode;
|
|
37
|
+
/** Text content (a string becomes one paragraph). */
|
|
38
|
+
content?: ReactNode;
|
|
39
|
+
/** Or a list of links, with the count beside the label. */
|
|
40
|
+
links?: RowLink[];
|
|
41
|
+
count?: number;
|
|
42
|
+
/** Marks the row with `data-cg-open-on-arrival`: the site opens it when
|
|
43
|
+
* the page is reached by the card's anchor (creeta's `abre-ao-chegar`). */
|
|
44
|
+
openOnArrival?: boolean;
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
kind: "link";
|
|
48
|
+
label: ReactNode;
|
|
49
|
+
href: string;
|
|
50
|
+
srLabel?: ReactNode;
|
|
51
|
+
/** ↗ another page (default) or ↓ an anchor on this page. */
|
|
52
|
+
arrow?: "go" | "down";
|
|
53
|
+
/** Opens in a new tab. */
|
|
54
|
+
external?: boolean;
|
|
55
|
+
/** Replaces the arrow (an 18px glyph): the Instagram icon, for instance. */
|
|
56
|
+
icon?: ReactNode;
|
|
57
|
+
};
|
|
26
58
|
|
|
27
59
|
export type ProfessionalCardLabels = ProfessionalModalLabels & {
|
|
28
60
|
/** alt of the portrait. */
|
|
29
61
|
portrait: (name: string) => string;
|
|
30
|
-
|
|
31
|
-
|
|
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;
|
|
62
|
+
/** The small badge on the medium card's photo, on hover ("Conhecer"). */
|
|
63
|
+
meet: string;
|
|
38
64
|
};
|
|
39
65
|
|
|
40
66
|
const EN: Omit<ProfessionalCardLabels, keyof ProfessionalModalLabels> = {
|
|
41
67
|
portrait: (name) => `Portrait of ${name}`,
|
|
42
|
-
|
|
43
|
-
guidance: "Guidance",
|
|
44
|
-
of: (name) => ` of ${name}`,
|
|
45
|
-
instagram: "Instagram",
|
|
46
|
-
instagramOf: (name) => ` of ${name} (opens in a new tab)`,
|
|
68
|
+
meet: "Meet",
|
|
47
69
|
};
|
|
48
70
|
|
|
49
71
|
const SIZES = "(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 92vw";
|
|
@@ -52,6 +74,9 @@ const SIZES = "(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 92vw";
|
|
|
52
74
|
const ZOOM =
|
|
53
75
|
"aspect-[4/5] h-auto w-full object-cover transition-transform duration-500 ease-[var(--ease-cg)] motion-reduce:transition-none";
|
|
54
76
|
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`;
|
|
77
|
+
/* medium card: the 3:4 portrait, zoom with the mouse over the link */
|
|
78
|
+
const ZOOM_MEDIUM =
|
|
79
|
+
"aspect-[3/4] h-auto w-full object-cover transition-transform duration-500 ease-[var(--ease-cg)] motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-[1.04] motion-reduce:[@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-100";
|
|
55
80
|
/* inside the modal trigger only the photo button is the hover target */
|
|
56
81
|
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
82
|
|
|
@@ -224,15 +249,25 @@ type Props = {
|
|
|
224
249
|
living?: ProfessionalLiving;
|
|
225
250
|
/** With a presentation video the portrait opens the modal (ProfessionalModal). */
|
|
226
251
|
presentation?: ProfessionalPresentation;
|
|
227
|
-
/** The bio: a string (one paragraph)
|
|
228
|
-
*
|
|
252
|
+
/** The full bio, shown in the presentation modal: a string (one paragraph)
|
|
253
|
+
* or your own paragraphs. To show it on the card too, put it in a
|
|
254
|
+
* `rows` collapse. */
|
|
229
255
|
bio?: ReactNode;
|
|
230
|
-
/** The
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
/** Anchor of the card (the authorship link of a guidance page lands here). */
|
|
256
|
+
/** The rows at the foot of the card (full size): collapses with text or
|
|
257
|
+
* links, links with an arrow or an icon. See ProfessionalCardRow. */
|
|
258
|
+
rows?: ProfessionalCardRow[];
|
|
259
|
+
/** Anchor of the card (a link elsewhere on the site lands here). */
|
|
235
260
|
id?: string;
|
|
261
|
+
/** `full` (default): the team-page card. `medium`: the home's tile — a
|
|
262
|
+
* white card with the 3:4 portrait and the name, a link to the full card
|
|
263
|
+
* (living portrait and "Meet" badge on hover). `micro`: the "who treats"
|
|
264
|
+
* row — 72px photo, name and role, also a link. The small sizes render the
|
|
265
|
+
* link as the root; wrap them in your <li> (RailItem). */
|
|
266
|
+
size?: "full" | "medium" | "micro";
|
|
267
|
+
/** Where the small sizes lead: the full card on the team page ("/team/#slug"). */
|
|
268
|
+
href?: string;
|
|
269
|
+
/** Name shown on the medium card (the site uses the first two words). */
|
|
270
|
+
shortName?: string;
|
|
236
271
|
/** h2 on the team page (default), h3 inside a section that already has an h2. */
|
|
237
272
|
headingLevel?: "h2" | "h3";
|
|
238
273
|
/** Root element. Default <article>. */
|
|
@@ -248,13 +283,14 @@ type Props = {
|
|
|
248
283
|
* (components/professional-card.tsx) with the Breathe tokens. A `card`
|
|
249
284
|
* surface with the portrait on top (plain, living, or opening the video
|
|
250
285
|
* presentation), the name, role and registration, then one list of equal
|
|
251
|
-
* rows separated by hairlines:
|
|
252
|
-
*
|
|
253
|
-
*
|
|
286
|
+
* rows separated by hairlines (`rows`): collapses that open in place (a text,
|
|
287
|
+
* or a list of links with the count) and links (arrow or icon). On creeta:
|
|
288
|
+
* Trajectory, Guidance and Instagram. Six open bios used to make the page
|
|
289
|
+
* 6,700px tall on a phone, hence the collapses.
|
|
254
290
|
*
|
|
255
|
-
*
|
|
256
|
-
* the page is reached by the card's anchor (creeta's
|
|
257
|
-
* not part of the DS).
|
|
291
|
+
* A collapse with `openOnArrival` carries `data-cg-open-on-arrival`: the site
|
|
292
|
+
* opens it when the page is reached by the card's anchor (creeta's
|
|
293
|
+
* `abre-ao-chegar.tsx`, not part of the DS).
|
|
258
294
|
*/
|
|
259
295
|
export function ProfessionalCard({
|
|
260
296
|
name,
|
|
@@ -265,9 +301,11 @@ export function ProfessionalCard({
|
|
|
265
301
|
living,
|
|
266
302
|
presentation,
|
|
267
303
|
bio,
|
|
268
|
-
|
|
269
|
-
instagram,
|
|
304
|
+
rows,
|
|
270
305
|
id,
|
|
306
|
+
size = "full",
|
|
307
|
+
href = "#",
|
|
308
|
+
shortName,
|
|
271
309
|
headingLevel: Heading = "h2",
|
|
272
310
|
as: As = "article",
|
|
273
311
|
linkComponent,
|
|
@@ -279,6 +317,69 @@ export function ProfessionalCard({
|
|
|
279
317
|
const alt = photo?.alt ?? t.portrait(name);
|
|
280
318
|
const hasPortrait = Boolean(photo || media || living);
|
|
281
319
|
|
|
320
|
+
if (size === "micro") {
|
|
321
|
+
// the "who treats" row of a treatment page (creeta telas/tratamento.tsx):
|
|
322
|
+
// the text beside says who it is, so the photo is decoration
|
|
323
|
+
return (
|
|
324
|
+
<L
|
|
325
|
+
href={href}
|
|
326
|
+
id={id}
|
|
327
|
+
className={`flex items-center gap-4 rounded-[22px] bg-card p-3 pr-5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink ${className}`}
|
|
328
|
+
>
|
|
329
|
+
{photo ? (
|
|
330
|
+
<img src={photo.src} srcSet={photo.srcSet} sizes="72px" alt="" loading="lazy" decoding="async" className="h-[72px] w-[72px] shrink-0 rounded-2xl object-cover" />
|
|
331
|
+
) : media ? (
|
|
332
|
+
<span className="block h-[72px] w-[72px] shrink-0 overflow-hidden rounded-2xl">{media}</span>
|
|
333
|
+
) : (
|
|
334
|
+
<span aria-hidden="true" className="flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-2xl bg-ink/[0.12] font-heading text-xl font-medium text-ink">
|
|
335
|
+
{initials(name)}
|
|
336
|
+
</span>
|
|
337
|
+
)}
|
|
338
|
+
<span>
|
|
339
|
+
<span className="block font-heading font-medium leading-snug text-ink">{name}</span>
|
|
340
|
+
{role && <span className="mt-0.5 block text-sm text-ink-soft">{role}</span>}
|
|
341
|
+
</span>
|
|
342
|
+
</L>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (size === "medium") {
|
|
347
|
+
// the home's team tile (creeta telas/home.tsx): a white card with the
|
|
348
|
+
// photo and the name inside — without the card the name sat loose under
|
|
349
|
+
// the photo, with no "close". Mouse over: the clip plays, the photo zooms
|
|
350
|
+
// a little and a discreet "Meet" shows in the corner; on tap the card shrinks.
|
|
351
|
+
return (
|
|
352
|
+
<L
|
|
353
|
+
href={href}
|
|
354
|
+
id={id}
|
|
355
|
+
data-cg-professional-card=""
|
|
356
|
+
className={`group block h-full rounded-[26px] bg-card p-2 pb-4 transition-transform duration-200 ease-out active:scale-[0.98] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none motion-reduce:active:scale-100 ${className}`}
|
|
357
|
+
>
|
|
358
|
+
<span className="relative block overflow-hidden rounded-[20px]">
|
|
359
|
+
{hasPortrait ? (
|
|
360
|
+
<Portrait name={name} photo={photo ? { ...photo, sizes: photo.sizes ?? "(min-width: 768px) 16vw, 42vw" } : undefined} media={media} living={living} alt={alt} decorative className={ZOOM_MEDIUM} />
|
|
361
|
+
) : (
|
|
362
|
+
<span aria-hidden="true" className="flex aspect-[3/4] w-full items-center justify-center bg-ink/[0.12] font-heading text-3xl font-medium text-ink">
|
|
363
|
+
{initials(name)}
|
|
364
|
+
</span>
|
|
365
|
+
)}
|
|
366
|
+
{/* only where a mouse exists, and with keyboard focus; the link
|
|
367
|
+
already has its name, so it is decoration */}
|
|
368
|
+
<span
|
|
369
|
+
aria-hidden="true"
|
|
370
|
+
className="pointer-events-none absolute bottom-2 left-2 inline-flex translate-y-1 items-center gap-1.5 rounded-full border border-white/15 bg-ink/80 px-3 py-2 text-xs font-medium text-white opacity-0 backdrop-blur-md transition duration-200 ease-out group-focus-visible:translate-y-0 group-focus-visible:opacity-100 motion-reduce:translate-y-0 motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:group-hover:translate-y-0 [@media(hover:hover)_and_(pointer:fine)]:group-hover:opacity-100"
|
|
371
|
+
>
|
|
372
|
+
{t.meet}
|
|
373
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={14} height={14} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
374
|
+
</span>
|
|
375
|
+
</span>
|
|
376
|
+
<span className="mt-3 block px-1.5 font-heading text-sm font-medium leading-snug text-ink transition-colors duration-150 group-hover:text-ink-soft motion-reduce:transition-none">
|
|
377
|
+
{shortName ?? name}
|
|
378
|
+
</span>
|
|
379
|
+
</L>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
282
383
|
return (
|
|
283
384
|
<As id={id} data-cg-professional-card="" className={`group/card scroll-mt-24 rounded-2xl bg-card p-3 pb-6 ${className}`}>
|
|
284
385
|
{hasPortrait && presentation ? (
|
|
@@ -315,26 +416,30 @@ export function ProfessionalCard({
|
|
|
315
416
|
<p className="mt-1 text-xs text-ink/80">{registration}</p>
|
|
316
417
|
</>
|
|
317
418
|
)}
|
|
318
|
-
{/*
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
419
|
+
{/* One list of equal rows. On creeta they used to be three things
|
|
420
|
+
with three designs (an open button, a loose list of links and a
|
|
421
|
+
magenta link at the end); the collapses open in place. */}
|
|
422
|
+
{rows && rows.length > 0 && (
|
|
423
|
+
<RowList className="mt-4">
|
|
424
|
+
{rows.map((row, i) =>
|
|
425
|
+
row.kind === "collapse" ? (
|
|
426
|
+
<CollapseRow
|
|
427
|
+
key={i}
|
|
428
|
+
label={row.label}
|
|
429
|
+
srLabel={row.srLabel}
|
|
430
|
+
count={row.count}
|
|
431
|
+
links={row.links}
|
|
432
|
+
linkComponent={L}
|
|
433
|
+
{...(row.openOnArrival ? { "data-cg-open-on-arrival": "" } : {})}
|
|
434
|
+
>
|
|
435
|
+
{row.content}
|
|
436
|
+
</CollapseRow>
|
|
437
|
+
) : (
|
|
438
|
+
<LinkRow key={i} label={row.label} srLabel={row.srLabel} href={row.href} arrow={row.arrow} external={row.external} icon={row.icon} linkComponent={L} />
|
|
439
|
+
),
|
|
440
|
+
)}
|
|
441
|
+
</RowList>
|
|
442
|
+
)}
|
|
338
443
|
</div>
|
|
339
444
|
</As>
|
|
340
445
|
);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
type RailProps = {
|
|
6
|
+
/** Name of the list for screen readers: "Treatments", "Team". */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Grid classes from md up, written in full (Tailwind only generates whole
|
|
9
|
+
* classes). Default: three columns. */
|
|
10
|
+
grid?: string;
|
|
11
|
+
/** <li> children, usually RailItem. */
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Rail — novo.creeta's `Trilho` (components/trilho.tsx) with the Breathe
|
|
18
|
+
* tokens. On a phone it is a horizontal rail with snap, and the next card
|
|
19
|
+
* shows by half to say there is more. From md it becomes a grid.
|
|
20
|
+
*
|
|
21
|
+
* Under the rail, a small bar follows the finger: it starts the size of what
|
|
22
|
+
* is already in view and fills to the end — it says how much is left. Shown
|
|
23
|
+
* only when the rail really scrolls, decoration for screen readers. The fill
|
|
24
|
+
* is written straight to the style, without re-rendering on every scroll frame.
|
|
25
|
+
*/
|
|
26
|
+
export function Rail({ label, grid = "md:grid-cols-3", children, className = "" }: RailProps) {
|
|
27
|
+
const list = useRef<HTMLUListElement>(null);
|
|
28
|
+
const bar = useRef<HTMLSpanElement>(null);
|
|
29
|
+
const [scrolls, setScrolls] = useState(false);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const ul = list.current;
|
|
33
|
+
if (!ul) return;
|
|
34
|
+
let frame = 0;
|
|
35
|
+
const measure = () => {
|
|
36
|
+
frame = 0;
|
|
37
|
+
const overflow = ul.scrollWidth - ul.clientWidth;
|
|
38
|
+
setScrolls(overflow > 8);
|
|
39
|
+
if (bar.current && ul.scrollWidth > 0) {
|
|
40
|
+
const seen = Math.min(1, (ul.scrollLeft + ul.clientWidth) / ul.scrollWidth);
|
|
41
|
+
bar.current.style.transform = `scaleX(${seen})`;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const ask = () => {
|
|
45
|
+
if (!frame) frame = requestAnimationFrame(measure);
|
|
46
|
+
};
|
|
47
|
+
measure();
|
|
48
|
+
ul.addEventListener("scroll", ask, { passive: true });
|
|
49
|
+
window.addEventListener("resize", ask);
|
|
50
|
+
return () => {
|
|
51
|
+
ul.removeEventListener("scroll", ask);
|
|
52
|
+
window.removeEventListener("resize", ask);
|
|
53
|
+
if (frame) cancelAnimationFrame(frame);
|
|
54
|
+
};
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<ul
|
|
60
|
+
ref={list}
|
|
61
|
+
aria-label={label}
|
|
62
|
+
className={`cg-no-scrollbar -mx-4 flex snap-x snap-mandatory scroll-px-4 gap-3 overflow-x-auto px-4 pb-1 md:mx-0 md:grid md:snap-none md:gap-5 md:overflow-visible md:px-0 ${grid} ${className}`}
|
|
63
|
+
>
|
|
64
|
+
{children}
|
|
65
|
+
</ul>
|
|
66
|
+
<div
|
|
67
|
+
aria-hidden="true"
|
|
68
|
+
data-cg-rail-progress=""
|
|
69
|
+
className={`mx-auto mt-4 h-1 w-14 overflow-hidden rounded-full bg-ink/15 md:hidden ${scrolls ? "" : "hidden"}`}
|
|
70
|
+
>
|
|
71
|
+
<span ref={bar} className="block h-full w-full origin-left rounded-full bg-ink-soft" />
|
|
72
|
+
</div>
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Widths of one item on the rail (phone / sm); from md the grid decides.
|
|
78
|
+
* The next card shows by half: that is what says "there is more". */
|
|
79
|
+
export const RAIL_ITEM = {
|
|
80
|
+
/** Treatments, videos: one card almost full width. */
|
|
81
|
+
wide: "w-[78%] shrink-0 snap-start sm:w-[46%] md:w-auto",
|
|
82
|
+
/** Team (medium ProfessionalCard): two cards and a bit. */
|
|
83
|
+
narrow: "w-[46%] shrink-0 snap-start sm:w-[30%] md:w-auto",
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
type RailItemProps = ComponentPropsWithoutRef<"li"> & {
|
|
87
|
+
/** `wide` (default) or `narrow`. */
|
|
88
|
+
width?: keyof typeof RAIL_ITEM;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** One <li> of the Rail, with the phone width preset. */
|
|
92
|
+
export function RailItem({ width = "wide", className = "", ...rest }: RailItemProps) {
|
|
93
|
+
return <li className={`${RAIL_ITEM[width]} ${className}`} {...rest} />;
|
|
94
|
+
}
|
package/components/Rows.tsx
CHANGED
|
@@ -18,8 +18,14 @@ import { ArrowDown, ArrowUpRight, ChevronDown } from "lucide-react";
|
|
|
18
18
|
export const ROW =
|
|
19
19
|
"flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 text-sm font-semibold text-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink [&::-webkit-details-marker]:hidden";
|
|
20
20
|
|
|
21
|
+
/** Hover tells the two rows apart (2026-10-29): a link row underlines its label
|
|
22
|
+
* (the family's sign for "goes somewhere", as the Footer and the Byline) and
|
|
23
|
+
* its arrow takes the step; a collapse row never underlines — it goes nowhere —
|
|
24
|
+
* its chevron dips 2px, "opens down here", and turns up when open. */
|
|
25
|
+
const ROW_LINK = `${ROW} underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink motion-reduce:transition-none`;
|
|
26
|
+
|
|
21
27
|
const OPEN_CHEVRON =
|
|
22
|
-
"shrink-0 text-ink-soft transition-transform duration-200 group-open:rotate-180 motion-reduce:transition-none";
|
|
28
|
+
"shrink-0 text-ink-soft transition-[transform,translate] duration-200 group-hover/row:translate-y-0.5 group-open/row:rotate-180 group-open/row:group-hover/row:translate-y-0 motion-reduce:transition-none";
|
|
23
29
|
|
|
24
30
|
/** What appears out of nowhere (the content of a <details>) fades in briefly
|
|
25
31
|
* instead of jumping on screen: `@starting-style` via Tailwind's `starting:`
|
|
@@ -72,7 +78,7 @@ export function CollapseRow({ label, srLabel, count, links, linkComponent, child
|
|
|
72
78
|
const L: ElementType = linkComponent ?? "a";
|
|
73
79
|
const n = count ?? links?.length;
|
|
74
80
|
return (
|
|
75
|
-
<details className={`group ${className}`} {...rest}>
|
|
81
|
+
<details className={`group/row ${className}`} {...rest}>
|
|
76
82
|
<summary className={ROW}>
|
|
77
83
|
<span>
|
|
78
84
|
{label}
|
|
@@ -120,7 +126,7 @@ export type LinkRowProps = Omit<ComponentPropsWithoutRef<"a">, "children"> & {
|
|
|
120
126
|
export function LinkRow({ label, href, srLabel, arrow, external = false, icon, linkComponent, className = "", ...rest }: LinkRowProps) {
|
|
121
127
|
const L: ElementType = linkComponent ?? "a";
|
|
122
128
|
return (
|
|
123
|
-
<L href={href} className={`${
|
|
129
|
+
<L href={href} className={`${ROW_LINK} ${className}`} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} {...rest}>
|
|
124
130
|
<span>
|
|
125
131
|
{label}
|
|
126
132
|
{srLabel && <span className="sr-only">{srLabel}</span>}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type StoryPillar = { title: ReactNode; text: ReactNode };
|
|
4
|
+
export type StoryLink = { label: ReactNode; href: string; external?: boolean };
|
|
5
|
+
|
|
6
|
+
type Labels = {
|
|
7
|
+
/** sr-only after an external link. */
|
|
8
|
+
newTab: string;
|
|
9
|
+
};
|
|
10
|
+
const EN: Labels = { newTab: "(opens in a new tab)" };
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
/** What fills the media box at the top: a BackgroundVideo, an <img>
|
|
14
|
+
* (absolute inset-0, object-cover). The panel's film fades it into the panel. */
|
|
15
|
+
media: ReactNode;
|
|
16
|
+
/** Box of the media: 4:3 on the phone, 21:8 from md (creeta). */
|
|
17
|
+
mediaAspect?: string;
|
|
18
|
+
/** Logo for the dark background, at the base of the media where the film is already full. */
|
|
19
|
+
logo?: ReactNode;
|
|
20
|
+
title: ReactNode;
|
|
21
|
+
lead?: ReactNode;
|
|
22
|
+
/** Three columns with a hairline on top: a short title and a paragraph. */
|
|
23
|
+
pillars: StoryPillar[];
|
|
24
|
+
/** Outbound links at the end ("Learn about the method ↗"). */
|
|
25
|
+
links?: StoryLink[];
|
|
26
|
+
headingLevel?: "h2" | "h3";
|
|
27
|
+
/** Colour of the pillar titles. Default `text-surface`; creeta passes its light teal. */
|
|
28
|
+
pillarTitleClassName?: string;
|
|
29
|
+
linkComponent?: ElementType;
|
|
30
|
+
labels?: Partial<Labels>;
|
|
31
|
+
className?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const ArrowUpRight = () => (
|
|
35
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={18} height={18} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const link =
|
|
39
|
+
"inline-flex min-h-11 items-center gap-1.5 font-semibold underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Story panel of the Cuidado Global family — creeta's `OrigemRta`, by tokens:
|
|
43
|
+
* a dark panel (surface-panel) that groups a story in one piece. Media at the
|
|
44
|
+
* top with a film that fades into the panel, the logo at its base, a title and
|
|
45
|
+
* a lead, three pillars in columns with a hairline above each, and the outbound
|
|
46
|
+
* links. Good for "who we are" blocks: one rounded card, everything inside.
|
|
47
|
+
*/
|
|
48
|
+
export function StoryPanel({
|
|
49
|
+
media,
|
|
50
|
+
mediaAspect = "aspect-[4/3] md:aspect-[21/8]",
|
|
51
|
+
logo,
|
|
52
|
+
title,
|
|
53
|
+
lead,
|
|
54
|
+
pillars,
|
|
55
|
+
links,
|
|
56
|
+
headingLevel: Title = "h2",
|
|
57
|
+
pillarTitleClassName = "text-surface",
|
|
58
|
+
linkComponent,
|
|
59
|
+
labels,
|
|
60
|
+
className = "",
|
|
61
|
+
}: Props) {
|
|
62
|
+
const L: ElementType = linkComponent ?? "a";
|
|
63
|
+
const t = { ...EN, ...labels };
|
|
64
|
+
return (
|
|
65
|
+
<section className={`overflow-hidden rounded-[28px] bg-surface-panel text-surface ${className}`}>
|
|
66
|
+
<div className={`relative ${mediaAspect}`}>
|
|
67
|
+
{media}
|
|
68
|
+
{/* -bottom-px: the box's height is fractional and a light half pixel showed under the film */}
|
|
69
|
+
<span aria-hidden="true" className="pointer-events-none absolute inset-0 -bottom-px bg-gradient-to-t from-surface-panel from-10% via-surface-panel/70 via-40% to-surface-panel/10" />
|
|
70
|
+
{logo && <div className="absolute bottom-2 left-6 md:bottom-4 md:left-12">{logo}</div>}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div className="px-6 pb-9 pt-7 md:px-12 md:pb-14 md:pt-9">
|
|
74
|
+
<Title className="max-w-3xl font-heading text-[1.75rem] font-medium leading-[1.12] tracking-[-0.01em] md:text-5xl">{title}</Title>
|
|
75
|
+
{lead && <p className="mt-4 max-w-2xl text-lg leading-relaxed text-surface/85">{lead}</p>}
|
|
76
|
+
|
|
77
|
+
<ul className="mt-8 grid list-none gap-6 md:mt-12 md:grid-cols-3 md:gap-10">
|
|
78
|
+
{pillars.map((p, i) => (
|
|
79
|
+
<li key={i} className="border-t border-surface/25 pt-5">
|
|
80
|
+
<p className={`font-heading text-2xl font-medium ${pillarTitleClassName}`}>{p.title}</p>
|
|
81
|
+
<p className="mt-2 leading-relaxed text-surface/85">{p.text}</p>
|
|
82
|
+
</li>
|
|
83
|
+
))}
|
|
84
|
+
</ul>
|
|
85
|
+
|
|
86
|
+
{links && links.length > 0 && (
|
|
87
|
+
<div className="mt-8 flex flex-wrap gap-x-8 gap-y-1 md:mt-10">
|
|
88
|
+
{links.map((l, i) =>
|
|
89
|
+
l.external ? (
|
|
90
|
+
<a key={i} href={l.href} target="_blank" rel="noopener noreferrer" className={link}>
|
|
91
|
+
{l.label}
|
|
92
|
+
<ArrowUpRight />
|
|
93
|
+
<span className="sr-only"> {t.newTab}</span>
|
|
94
|
+
</a>
|
|
95
|
+
) : (
|
|
96
|
+
<L key={i} href={l.href} className={link}>
|
|
97
|
+
{l.label}
|
|
98
|
+
<ArrowUpRight />
|
|
99
|
+
</L>
|
|
100
|
+
),
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
</section>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/** whole: the drawing finished, not animating (no JavaScript, no
|
|
6
|
+
* IntersectionObserver, or reduced motion).
|
|
7
|
+
* waiting: hidden, until it shows up on screen.
|
|
8
|
+
* playing: it showed up; the animation runs. */
|
|
9
|
+
export type AppearState = "whole" | "waiting" | "playing";
|
|
10
|
+
|
|
11
|
+
export const reducedMotion = () =>
|
|
12
|
+
typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
13
|
+
|
|
14
|
+
/** For drawings that animate once half of them is on screen (creeta's
|
|
15
|
+
* `useAoAparecer`). The default is the finished drawing: it only hides to
|
|
16
|
+
* animate when it can animate. `onScreen` follows the drawing the whole time:
|
|
17
|
+
* a looping animation only runs while someone can see it. */
|
|
18
|
+
export function useOnAppear<T extends HTMLElement>() {
|
|
19
|
+
const ref = useRef<T>(null);
|
|
20
|
+
const [state, setState] = useState<AppearState>("whole");
|
|
21
|
+
const [onScreen, setOnScreen] = useState(false);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const el = ref.current;
|
|
25
|
+
if (!el || typeof IntersectionObserver === "undefined" || reducedMotion()) return;
|
|
26
|
+
setState("waiting");
|
|
27
|
+
const eye = new IntersectionObserver(
|
|
28
|
+
([e]) => {
|
|
29
|
+
setOnScreen(!!e.isIntersecting);
|
|
30
|
+
if (e.isIntersecting) setState("playing");
|
|
31
|
+
},
|
|
32
|
+
{ threshold: 0.5 },
|
|
33
|
+
);
|
|
34
|
+
eye.observe(el);
|
|
35
|
+
return () => eye.disconnect();
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
return { ref, state, onScreen };
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@breathe-ds/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Breathe DS — the design system of the Cuidado Global family of sites (creeta, ttcab, metodorta). Themeable tokens + React components.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"./components/LanguageSwitcher": "./components/LanguageSwitcher.tsx",
|
|
49
49
|
"./components/List": "./components/List.tsx",
|
|
50
50
|
"./components/Rows": "./components/Rows.tsx",
|
|
51
|
+
"./components/Rail": "./components/Rail.tsx",
|
|
51
52
|
"./components/Avatar": "./components/Avatar.tsx",
|
|
52
53
|
"./components/Byline": "./components/Byline.tsx",
|
|
53
54
|
"./components/ProfessionalCard": "./components/ProfessionalCard.tsx",
|
|
@@ -57,7 +58,17 @@
|
|
|
57
58
|
"./components/Breadcrumb": "./components/Breadcrumb.tsx",
|
|
58
59
|
"./components/Timeline": "./components/Timeline.tsx",
|
|
59
60
|
"./components/marks/ttcab": "./components/marks/ttcab.tsx",
|
|
60
|
-
"./components/marks/creeta": "./components/marks/creeta.tsx"
|
|
61
|
+
"./components/marks/creeta": "./components/marks/creeta.tsx",
|
|
62
|
+
"./components/CareSteps": "./components/CareSteps.tsx",
|
|
63
|
+
"./components/ChatPreview": "./components/ChatPreview.tsx",
|
|
64
|
+
"./components/PlanTimeline": "./components/PlanTimeline.tsx",
|
|
65
|
+
"./components/PhotoSequence": "./components/PhotoSequence.tsx",
|
|
66
|
+
"./components/PauseButton": "./components/PauseButton.tsx",
|
|
67
|
+
"./components/useOnAppear": "./components/useOnAppear.ts",
|
|
68
|
+
"./components/StoryPanel": "./components/StoryPanel.tsx",
|
|
69
|
+
"./components/PostCard": "./components/PostCard.tsx",
|
|
70
|
+
"./components/FeaturedPosts": "./components/FeaturedPosts.tsx",
|
|
71
|
+
"./components/PostArticle": "./components/PostArticle.tsx"
|
|
61
72
|
},
|
|
62
73
|
"publishConfig": {
|
|
63
74
|
"access": "public"
|