@breathe-ds/ui 0.3.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -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/CourseCard.tsx +139 -0
- package/components/DateBadge.tsx +24 -0
- package/components/EventFacts.tsx +62 -0
- package/components/EventList.tsx +49 -0
- package/components/EventRow.tsx +102 -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/RegistrationBox.tsx +82 -0
- package/components/Rows.tsx +9 -3
- package/components/SidePanel.tsx +96 -0
- package/components/StoryPanel.tsx +107 -0
- package/components/useOnAppear.ts +39 -0
- package/package.json +36 -6
- package/theme.css +140 -0
- package/tokens/creeta.css +2 -0
- package/tokens/neutral.css +2 -0
- package/tokens/ttcab.css +13 -5
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import { Check } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
export type TicketOption = {
|
|
7
|
+
id: string;
|
|
8
|
+
label: ReactNode;
|
|
9
|
+
/** "6 left", as a small pill after the label. */
|
|
10
|
+
remaining?: ReactNode;
|
|
11
|
+
/** "Free", "R$ 1.900". */
|
|
12
|
+
price: ReactNode;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
/** The strip at the top ("Registration"). */
|
|
18
|
+
title: ReactNode;
|
|
19
|
+
/** The sentence above the options ("Hi! Choose your ticket:"). */
|
|
20
|
+
intro?: ReactNode;
|
|
21
|
+
options: TicketOption[];
|
|
22
|
+
/** Selected option id (controlled); without it, the first enabled one. */
|
|
23
|
+
value?: string;
|
|
24
|
+
onChange?: (id: string) => void;
|
|
25
|
+
/** The button at the foot (a Button `accent`, or `solid`); receives nothing: read the selection through onChange. */
|
|
26
|
+
action: ReactNode;
|
|
27
|
+
/** Small line under the button ("Only physiotherapists can attend."). */
|
|
28
|
+
footnote?: ReactNode;
|
|
29
|
+
/** Name of the radio group, for the form. */
|
|
30
|
+
name?: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Registration box of an event (Luma): a strip with the title, a sentence,
|
|
36
|
+
* the ticket options as a radio group (the chosen one gets the ring and the
|
|
37
|
+
* check), the price on the right, and the one button at the foot. By tone:
|
|
38
|
+
* the box is a surface card inside the page's card.
|
|
39
|
+
*/
|
|
40
|
+
export function RegistrationBox({ title, intro, options, value, onChange, action, footnote, name = "ticket", className = "" }: Props) {
|
|
41
|
+
const first = options.find((o) => !o.disabled)?.id;
|
|
42
|
+
const [inner, setInner] = useState(first);
|
|
43
|
+
const selected = value ?? inner;
|
|
44
|
+
const choose = (id: string) => {
|
|
45
|
+
setInner(id);
|
|
46
|
+
onChange?.(id);
|
|
47
|
+
};
|
|
48
|
+
return (
|
|
49
|
+
<section className={`overflow-hidden rounded-[24px] bg-surface ${className}`}>
|
|
50
|
+
<h2 className="border-b border-ink/10 px-5 py-3 text-sm font-semibold text-ink-soft">{title}</h2>
|
|
51
|
+
<div className="p-5">
|
|
52
|
+
{intro && <p className="mb-4 text-ink">{intro}</p>}
|
|
53
|
+
<fieldset className="m-0 border-0 p-0">
|
|
54
|
+
<legend className="sr-only">{title}</legend>
|
|
55
|
+
<div className="flex flex-col gap-2">
|
|
56
|
+
{options.map((o) => {
|
|
57
|
+
const on = o.id === selected;
|
|
58
|
+
return (
|
|
59
|
+
<label
|
|
60
|
+
key={o.id}
|
|
61
|
+
className={`flex min-h-12 cursor-pointer items-center gap-3 rounded-2xl px-4 py-2.5 transition-colors duration-150 motion-reduce:transition-none ${on ? "bg-card shadow-[inset_0_0_0_2px_var(--color-ink)]" : "bg-card/60 hover:bg-card"} ${o.disabled ? "cursor-not-allowed opacity-50" : ""}`}
|
|
62
|
+
>
|
|
63
|
+
<input type="radio" name={name} value={o.id} checked={on} disabled={o.disabled} onChange={() => choose(o.id)} className="sr-only" />
|
|
64
|
+
<span aria-hidden="true" className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${on ? "bg-ink text-surface" : "border-2 border-ink/25"}`}>
|
|
65
|
+
{on && <Check size={12} strokeWidth={3} />}
|
|
66
|
+
</span>
|
|
67
|
+
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-2 font-semibold text-ink">
|
|
68
|
+
{o.label}
|
|
69
|
+
{o.remaining && <span className="rounded-pill bg-ink/[0.08] px-2 py-0.5 text-xs font-semibold text-ink-soft">{o.remaining}</span>}
|
|
70
|
+
</span>
|
|
71
|
+
<span className="shrink-0 font-semibold text-ink">{o.price}</span>
|
|
72
|
+
</label>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</div>
|
|
76
|
+
</fieldset>
|
|
77
|
+
<div className="mt-4 [&>*]:w-full [&>*]:justify-center">{action}</div>
|
|
78
|
+
{footnote && <p className="mt-3 text-sm text-ink-soft">{footnote}</p>}
|
|
79
|
+
</div>
|
|
80
|
+
</section>
|
|
81
|
+
);
|
|
82
|
+
}
|
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,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useRef, type ReactNode } from "react";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
type Labels = {
|
|
7
|
+
close: string;
|
|
8
|
+
};
|
|
9
|
+
const EN: Labels = { close: "Close" };
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
open: boolean;
|
|
13
|
+
/** Called when the person closes it (Esc, the backdrop, the close button). */
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
/** Accessible name of the panel (sr-only unless you render it in `children`). */
|
|
16
|
+
title: string;
|
|
17
|
+
/** Small actions on the top bar, beside the close button ("Copy link", "Event page ↗"). */
|
|
18
|
+
actions?: ReactNode;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
labels?: Partial<Labels>;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Side panel of the Cuidado Global family (Luma's event drawer): a native
|
|
26
|
+
* <dialog> that slides in from the right, full height, up to 32rem wide (full
|
|
27
|
+
* width on the phone), with a top bar (close on the left, actions on the
|
|
28
|
+
* right) and a scrolling body. Focus is trapped by the dialog, Esc and the
|
|
29
|
+
* backdrop close it, focus goes back to what opened it. The content is
|
|
30
|
+
* whatever you pass: a course, an event, a person.
|
|
31
|
+
*/
|
|
32
|
+
export function SidePanel({ open, onClose, title, actions, children, labels, className = "" }: Props) {
|
|
33
|
+
const t = { ...EN, ...labels };
|
|
34
|
+
const box = useRef<HTMLDialogElement>(null);
|
|
35
|
+
const headingId = useId();
|
|
36
|
+
|
|
37
|
+
// open/close follow the prop; the way out waits for the slide to finish
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const d = box.current;
|
|
40
|
+
if (!d) return;
|
|
41
|
+
if (open) {
|
|
42
|
+
if (!d.open) d.showModal();
|
|
43
|
+
// paint the closed state first (the dialog was display:none), then open:
|
|
44
|
+
// a forced reflow plus a frame, or the slide would not play
|
|
45
|
+
void d.getBoundingClientRect();
|
|
46
|
+
const frame = requestAnimationFrame(() => d.setAttribute("data-open", ""));
|
|
47
|
+
return () => cancelAnimationFrame(frame);
|
|
48
|
+
}
|
|
49
|
+
if (!d.open) return;
|
|
50
|
+
d.removeAttribute("data-open");
|
|
51
|
+
const wait = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? 0 : 260;
|
|
52
|
+
const timer = window.setTimeout(() => d.close(), wait);
|
|
53
|
+
return () => window.clearTimeout(timer);
|
|
54
|
+
}, [open]);
|
|
55
|
+
|
|
56
|
+
// Esc (cancel) and click on the backdrop → the parent closes it
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const d = box.current;
|
|
59
|
+
if (!d) return;
|
|
60
|
+
const onCancel = (e: Event) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
onClose();
|
|
63
|
+
};
|
|
64
|
+
const onClick = (e: MouseEvent) => {
|
|
65
|
+
if (e.target === d) onClose();
|
|
66
|
+
};
|
|
67
|
+
d.addEventListener("cancel", onCancel);
|
|
68
|
+
d.addEventListener("click", onClick);
|
|
69
|
+
return () => {
|
|
70
|
+
d.removeEventListener("cancel", onCancel);
|
|
71
|
+
d.removeEventListener("click", onClick);
|
|
72
|
+
};
|
|
73
|
+
}, [onClose]);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<dialog
|
|
77
|
+
ref={box}
|
|
78
|
+
aria-labelledby={headingId}
|
|
79
|
+
className={`fixed inset-y-0 right-0 left-auto m-0 flex h-dvh max-h-none w-[min(100vw,32rem)] max-w-none translate-x-full flex-col overflow-hidden bg-card p-0 text-ink shadow-2xl [transition:translate_280ms_var(--ease-cg)] data-open:translate-x-0 motion-reduce:transition-none backdrop:bg-ink/50 backdrop:backdrop-blur-sm backdrop:opacity-0 backdrop:[transition:opacity_240ms_ease-out] [&[data-open]::backdrop]:opacity-100 ${className}`}
|
|
80
|
+
>
|
|
81
|
+
<h2 id={headingId} className="sr-only">{title}</h2>
|
|
82
|
+
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-ink/10 px-4 py-3">
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={onClose}
|
|
86
|
+
className="flex h-11 w-11 items-center justify-center rounded-full text-ink transition-colors duration-150 hover:bg-ink/[0.06] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none"
|
|
87
|
+
>
|
|
88
|
+
<X aria-hidden="true" size={20} strokeWidth={2} />
|
|
89
|
+
<span className="sr-only">{t.close}</span>
|
|
90
|
+
</button>
|
|
91
|
+
{actions && <div className="flex flex-wrap items-center justify-end gap-2">{actions}</div>}
|
|
92
|
+
</div>
|
|
93
|
+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">{children}</div>
|
|
94
|
+
</dialog>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -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.5.1",
|
|
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",
|
|
@@ -15,9 +15,18 @@
|
|
|
15
15
|
"LICENSE"
|
|
16
16
|
],
|
|
17
17
|
"exports": {
|
|
18
|
-
"./theme.css":
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
"./theme.css": {
|
|
19
|
+
"style": "./theme.css",
|
|
20
|
+
"default": "./theme.css"
|
|
21
|
+
},
|
|
22
|
+
"./tokens/ttcab.css": {
|
|
23
|
+
"style": "./tokens/ttcab.css",
|
|
24
|
+
"default": "./tokens/ttcab.css"
|
|
25
|
+
},
|
|
26
|
+
"./tokens/creeta.css": {
|
|
27
|
+
"style": "./tokens/creeta.css",
|
|
28
|
+
"default": "./tokens/creeta.css"
|
|
29
|
+
},
|
|
21
30
|
"./components/Accordion": "./components/Accordion.tsx",
|
|
22
31
|
"./components/PageVideoHero": "./components/PageVideoHero.tsx",
|
|
23
32
|
"./components/Footer": "./components/Footer.tsx",
|
|
@@ -42,12 +51,16 @@
|
|
|
42
51
|
"./components/BreatheMark": "./components/BreatheMark.tsx",
|
|
43
52
|
"./components/Curtain": "./components/Curtain.tsx",
|
|
44
53
|
"./components/Lockup": "./components/Lockup.tsx",
|
|
45
|
-
"./tokens/neutral.css":
|
|
54
|
+
"./tokens/neutral.css": {
|
|
55
|
+
"style": "./tokens/neutral.css",
|
|
56
|
+
"default": "./tokens/neutral.css"
|
|
57
|
+
},
|
|
46
58
|
"./components/icons": "./components/icons.tsx",
|
|
47
59
|
"./components/AnchorNav": "./components/AnchorNav.tsx",
|
|
48
60
|
"./components/LanguageSwitcher": "./components/LanguageSwitcher.tsx",
|
|
49
61
|
"./components/List": "./components/List.tsx",
|
|
50
62
|
"./components/Rows": "./components/Rows.tsx",
|
|
63
|
+
"./components/Rail": "./components/Rail.tsx",
|
|
51
64
|
"./components/Avatar": "./components/Avatar.tsx",
|
|
52
65
|
"./components/Byline": "./components/Byline.tsx",
|
|
53
66
|
"./components/ProfessionalCard": "./components/ProfessionalCard.tsx",
|
|
@@ -57,7 +70,24 @@
|
|
|
57
70
|
"./components/Breadcrumb": "./components/Breadcrumb.tsx",
|
|
58
71
|
"./components/Timeline": "./components/Timeline.tsx",
|
|
59
72
|
"./components/marks/ttcab": "./components/marks/ttcab.tsx",
|
|
60
|
-
"./components/marks/creeta": "./components/marks/creeta.tsx"
|
|
73
|
+
"./components/marks/creeta": "./components/marks/creeta.tsx",
|
|
74
|
+
"./components/CareSteps": "./components/CareSteps.tsx",
|
|
75
|
+
"./components/ChatPreview": "./components/ChatPreview.tsx",
|
|
76
|
+
"./components/PlanTimeline": "./components/PlanTimeline.tsx",
|
|
77
|
+
"./components/PhotoSequence": "./components/PhotoSequence.tsx",
|
|
78
|
+
"./components/PauseButton": "./components/PauseButton.tsx",
|
|
79
|
+
"./components/useOnAppear": "./components/useOnAppear.ts",
|
|
80
|
+
"./components/StoryPanel": "./components/StoryPanel.tsx",
|
|
81
|
+
"./components/PostCard": "./components/PostCard.tsx",
|
|
82
|
+
"./components/FeaturedPosts": "./components/FeaturedPosts.tsx",
|
|
83
|
+
"./components/PostArticle": "./components/PostArticle.tsx",
|
|
84
|
+
"./components/CourseCard": "./components/CourseCard.tsx",
|
|
85
|
+
"./components/DateBadge": "./components/DateBadge.tsx",
|
|
86
|
+
"./components/EventRow": "./components/EventRow.tsx",
|
|
87
|
+
"./components/EventList": "./components/EventList.tsx",
|
|
88
|
+
"./components/EventFacts": "./components/EventFacts.tsx",
|
|
89
|
+
"./components/RegistrationBox": "./components/RegistrationBox.tsx",
|
|
90
|
+
"./components/SidePanel": "./components/SidePanel.tsx"
|
|
61
91
|
},
|
|
62
92
|
"publishConfig": {
|
|
63
93
|
"access": "public"
|