@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
|
@@ -17,20 +17,33 @@ type LoopPreview = {
|
|
|
17
17
|
|
|
18
18
|
/** Two kinds of video hero:
|
|
19
19
|
* - loop: a local clip on repeat (no player). Background = BackgroundVideo.
|
|
20
|
-
* - player: the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
20
|
+
* - player: the video ITSELF plays muted, on repeat, as the background.
|
|
21
|
+
* Turning the sound on remounts it from zero — with audio and controls —
|
|
22
|
+
* and the close bar appears at the bottom. Keeps things consistent: the
|
|
23
|
+
* preview and the video are the same footage. The source is either a
|
|
24
|
+
* YouTube id (16:9 iframe covering the card, no false crop/zoom) or an
|
|
25
|
+
* mp4 hosted by the site (`src` + `poster`; the sound version plays in
|
|
26
|
+
* the browser's own player, letterboxed like the iframe). */
|
|
25
27
|
type VideoLoop = { mode: "loop"; preview: LoopPreview };
|
|
26
|
-
type
|
|
27
|
-
|
|
28
|
+
type PlayerCommon = { mode: "player"; playerCaption?: ReactNode };
|
|
29
|
+
type VideoPlayerYouTube = PlayerCommon & {
|
|
28
30
|
youtubeId: string;
|
|
29
31
|
/** Still frame (reduced motion / data saver). Without it, uses YouTube's own
|
|
30
32
|
* thumbnail (maxresdefault). */
|
|
31
33
|
poster?: string;
|
|
32
|
-
playerCaption?: ReactNode;
|
|
33
34
|
};
|
|
35
|
+
type VideoPlayerFile = PlayerCommon & {
|
|
36
|
+
/** The mp4 itself: muted and looping as the background, with sound after the tap. */
|
|
37
|
+
src: string;
|
|
38
|
+
/** Larger file for wide screens (background only). */
|
|
39
|
+
srcLarge?: string;
|
|
40
|
+
/** Still frame (reduced motion / data saver, and the <video> poster). */
|
|
41
|
+
poster: string;
|
|
42
|
+
posterLarge?: string;
|
|
43
|
+
objectPosition?: string;
|
|
44
|
+
};
|
|
45
|
+
type VideoPlayer = VideoPlayerYouTube | VideoPlayerFile;
|
|
46
|
+
const isFile = (v: VideoPlayer): v is VideoPlayerFile => "src" in v;
|
|
34
47
|
|
|
35
48
|
type Props = {
|
|
36
49
|
/** Video background — defines the hero kind (loop or player). */
|
|
@@ -144,10 +157,10 @@ function YouTubeBackground({
|
|
|
144
157
|
* in the Breathe language (tokens: ink/surface).
|
|
145
158
|
*
|
|
146
159
|
* - loop: the background is a local clip on repeat (BackgroundVideo).
|
|
147
|
-
* - player: the background is the
|
|
148
|
-
* Turning the sound on remounts it from zero
|
|
149
|
-
* inside the card and opens the close bar at
|
|
150
|
-
* the left, "Close" on the right).
|
|
160
|
+
* - player: the background is the video ITSELF (YouTube id or the site's mp4)
|
|
161
|
+
* playing muted, on repeat. Turning the sound on remounts it from zero
|
|
162
|
+
* (with audio and controls) inside the card and opens the close bar at
|
|
163
|
+
* the bottom (caption on the left, "Close" on the right).
|
|
151
164
|
*/
|
|
152
165
|
export function PageVideoHero({
|
|
153
166
|
video,
|
|
@@ -179,20 +192,49 @@ export function PageVideoHero({
|
|
|
179
192
|
{/* media — background (preview) or, when playing, the full video with sound */}
|
|
180
193
|
<div className={`absolute inset-x-0 top-0 ${playing ? "bottom-[4.5rem]" : "bottom-0 -z-10"}`}>
|
|
181
194
|
{playing ? (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
195
|
+
isFile(video as VideoPlayer) ? (
|
|
196
|
+
// the site's own mp4, with sound and the browser's controls; the
|
|
197
|
+
// click came from the person, so autoplay with audio is allowed
|
|
198
|
+
<video
|
|
199
|
+
key="cg-hero-sound"
|
|
200
|
+
src={(video as VideoPlayerFile).src}
|
|
201
|
+
poster={(video as VideoPlayerFile).poster}
|
|
202
|
+
controls
|
|
203
|
+
autoPlay
|
|
204
|
+
playsInline
|
|
205
|
+
aria-label={videoTitle ?? "Video"}
|
|
206
|
+
onEnded={() => setPlaying(false)}
|
|
207
|
+
className="absolute inset-0 h-full w-full bg-black object-contain"
|
|
208
|
+
/>
|
|
209
|
+
) : (
|
|
210
|
+
<iframe
|
|
211
|
+
key="cg-hero-sound"
|
|
212
|
+
title={videoTitle ?? "Video"}
|
|
213
|
+
src={`https://www.youtube-nocookie.com/embed/${(video as VideoPlayerYouTube).youtubeId}?autoplay=1&mute=0&start=0&rel=0&modestbranding=1&playsinline=1&iv_load_policy=3`}
|
|
214
|
+
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
|
215
|
+
allowFullScreen
|
|
216
|
+
className="absolute inset-0 h-full w-full border-0 bg-black"
|
|
217
|
+
/>
|
|
218
|
+
)
|
|
190
219
|
) : (
|
|
191
220
|
<>
|
|
192
|
-
{isPlayer ? (
|
|
221
|
+
{isPlayer && isFile(video as VideoPlayer) ? (
|
|
222
|
+
// the mp4 itself, muted and on repeat (BackgroundVideo: reduced
|
|
223
|
+
// motion, data saver and the pause button come with it)
|
|
224
|
+
<BackgroundVideo
|
|
225
|
+
key={(video as VideoPlayerFile).src}
|
|
226
|
+
src={(video as VideoPlayerFile).src}
|
|
227
|
+
srcLarge={(video as VideoPlayerFile).srcLarge}
|
|
228
|
+
poster={(video as VideoPlayerFile).poster}
|
|
229
|
+
posterLarge={(video as VideoPlayerFile).posterLarge}
|
|
230
|
+
objectPosition={(video as VideoPlayerFile).objectPosition}
|
|
231
|
+
labels={previewLabels}
|
|
232
|
+
className="absolute inset-0"
|
|
233
|
+
/>
|
|
234
|
+
) : isPlayer ? (
|
|
193
235
|
<YouTubeBackground
|
|
194
|
-
id={(video as
|
|
195
|
-
poster={(video as
|
|
236
|
+
id={(video as VideoPlayerYouTube).youtubeId}
|
|
237
|
+
poster={(video as VideoPlayerYouTube).poster}
|
|
196
238
|
title={videoTitle}
|
|
197
239
|
labels={previewLabels}
|
|
198
240
|
/>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Pause, Play } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
type Labels = {
|
|
4
|
+
/** sr-only name: "Pause {what}" / "Resume {what}". */
|
|
5
|
+
pause: (what: string) => string;
|
|
6
|
+
resume: (what: string) => string;
|
|
7
|
+
};
|
|
8
|
+
const EN: Labels = { pause: (what) => `Pause ${what}`, resume: (what) => `Resume ${what}` };
|
|
9
|
+
|
|
10
|
+
type Props = {
|
|
11
|
+
paused: boolean;
|
|
12
|
+
onToggle: () => void;
|
|
13
|
+
/** What it pauses, in the page's language: "the sample conversation". */
|
|
14
|
+
what: string;
|
|
15
|
+
/** Colour of the circle: card (white) over the surface, surface over a card. */
|
|
16
|
+
bubble?: "card" | "surface";
|
|
17
|
+
/** Position (absolute) inside the drawing: "right-0 top-0". */
|
|
18
|
+
className?: string;
|
|
19
|
+
labels?: Partial<Labels>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Pause/resume button of the looping drawings (creeta's `Pausa`). What moves
|
|
23
|
+
* by itself for more than 5s must be pausable (WCAG 2.2.2). 44px tap area,
|
|
24
|
+
* 32px circle. */
|
|
25
|
+
export function PauseButton({ paused, onToggle, what, bubble = "card", className = "", labels }: Props) {
|
|
26
|
+
const t = { ...EN, ...labels };
|
|
27
|
+
const Icon = paused ? Play : Pause;
|
|
28
|
+
return (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
onClick={onToggle}
|
|
32
|
+
aria-pressed={paused}
|
|
33
|
+
className={`absolute flex h-11 w-11 items-center justify-center rounded-full text-ink transition-transform duration-150 ease-out active:scale-[0.94] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ink motion-reduce:transition-none motion-reduce:active:scale-100 ${className}`}
|
|
34
|
+
>
|
|
35
|
+
<span className={`flex h-8 w-8 items-center justify-center rounded-full ${bubble === "card" ? "bg-card" : "bg-surface"}`}>
|
|
36
|
+
<Icon aria-hidden="true" size={14} strokeWidth={2} className={paused ? "ml-0.5" : ""} />
|
|
37
|
+
</span>
|
|
38
|
+
<span className="sr-only">{paused ? t.resume(what) : t.pause(what)}</span>
|
|
39
|
+
</button>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { Pause, Play } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
export type SequencePhoto = { src: string; alt: string; srcSet?: string; sizes?: string };
|
|
5
|
+
|
|
6
|
+
type Labels = {
|
|
7
|
+
/** sr-only of the pause control. */
|
|
8
|
+
pause: string;
|
|
9
|
+
};
|
|
10
|
+
const EN: Labels = { pause: "Pause the photos" };
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
photos: SequencePhoto[];
|
|
14
|
+
/** Unique in the page: ties the pause control to the sequence. */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Seconds each photo stays, not counting the fade. */
|
|
17
|
+
seconds?: number;
|
|
18
|
+
labels?: Partial<Labels>;
|
|
19
|
+
className?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Seconds of fade between one photo and the next. */
|
|
23
|
+
const FADE = 1;
|
|
24
|
+
/** Ken Burns: how much the photo grows while it shows. */
|
|
25
|
+
const ZOOM = 1.08;
|
|
26
|
+
const CURVE = "cubic-bezier(0.77, 0, 0.175, 1)";
|
|
27
|
+
const pct = (n: number) => `${Number(n.toFixed(3))}%`;
|
|
28
|
+
|
|
29
|
+
/** Keyframes for a sequence of `n` photos. The cycle lasts n × seconds and
|
|
30
|
+
* each photo owns its slice: middle ones fade in, stay and vanish at once
|
|
31
|
+
* when the next has covered them; the last fades out revealing the first;
|
|
32
|
+
* the zoom grows while the photo shows and resets while hidden. */
|
|
33
|
+
export function sequenceKeyframes(n: number, seconds: number): string {
|
|
34
|
+
const cycle = n * seconds;
|
|
35
|
+
const enter = (FADE / cycle) * 100;
|
|
36
|
+
const stay = (seconds / cycle) * 100;
|
|
37
|
+
const leave = ((seconds + FADE) / cycle) * 100;
|
|
38
|
+
return [
|
|
39
|
+
`@keyframes cg-seq-mid-${n}{0%{opacity:0}${pct(enter)}{opacity:1}${pct(leave)}{opacity:1}${pct(leave + 0.01)},100%{opacity:0}}`,
|
|
40
|
+
`@keyframes cg-seq-end-${n}{0%{opacity:0}${pct(enter)}{opacity:1}${pct(stay)}{opacity:1}${pct(leave)},100%{opacity:0}}`,
|
|
41
|
+
`@keyframes cg-seq-zoom-${n}{0%{transform:scale(1)}${pct(leave)}{transform:scale(${ZOOM})}${pct(leave + 0.01)},100%{transform:scale(1)}}`,
|
|
42
|
+
].join("");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Photos that alternate with a fade and slowly zoom in, no JavaScript
|
|
46
|
+
* (creeta's `SequenciaFotos`). Only opacity and transform. Content that moves
|
|
47
|
+
* by itself for more than 5s must be pausable (WCAG 2.2.2): the hidden
|
|
48
|
+
* checkbox does it through CSS (theme.css). Reduced motion: first photo, still. */
|
|
49
|
+
export function PhotoSequence({ photos, id, seconds = 4, labels, className = "" }: Props) {
|
|
50
|
+
const t = { ...EN, ...labels };
|
|
51
|
+
const n = photos.length;
|
|
52
|
+
const cycle = n * seconds;
|
|
53
|
+
|
|
54
|
+
const animation = (i: number): CSSProperties => {
|
|
55
|
+
const zoom = `cg-seq-zoom-${n} ${cycle}s linear infinite`;
|
|
56
|
+
if (i === 0) return { animation: zoom };
|
|
57
|
+
const fade = `${i === n - 1 ? `cg-seq-end-${n}` : `cg-seq-mid-${n}`} ${cycle}s ${CURVE} infinite`;
|
|
58
|
+
return { animation: `${fade}, ${zoom}`, animationDelay: `${i * seconds}s` };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// position and radius are defaults: a className with `absolute` or `rounded-*`
|
|
62
|
+
// (filling a CareSteps frame) takes their place instead of fighting them
|
|
63
|
+
const position = /\babsolute\b|\bfixed\b/.test(className) ? "" : "relative";
|
|
64
|
+
const radius = /\brounded/.test(className) ? "" : "rounded-[28px]";
|
|
65
|
+
return (
|
|
66
|
+
<div className={`${position} ${radius} overflow-hidden bg-ink ${className}`}>
|
|
67
|
+
{n > 1 && <style dangerouslySetInnerHTML={{ __html: sequenceKeyframes(n, seconds) }} />}
|
|
68
|
+
<input id={id} type="checkbox" className="cg-seq-pause peer sr-only" />
|
|
69
|
+
|
|
70
|
+
<div className="cg-seq absolute inset-0">
|
|
71
|
+
{photos.map((p, i) => (
|
|
72
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
73
|
+
<img
|
|
74
|
+
key={p.src}
|
|
75
|
+
src={p.src}
|
|
76
|
+
srcSet={p.srcSet}
|
|
77
|
+
sizes={p.sizes}
|
|
78
|
+
alt={p.alt}
|
|
79
|
+
loading="lazy"
|
|
80
|
+
decoding="async"
|
|
81
|
+
style={n > 1 ? animation(i) : undefined}
|
|
82
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
83
|
+
/>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{n > 1 && (
|
|
88
|
+
<label
|
|
89
|
+
htmlFor={id}
|
|
90
|
+
className="cg-seq-control absolute bottom-3 right-3 flex h-11 w-11 cursor-pointer items-center justify-center rounded-full bg-ink/85 text-surface backdrop-blur-md peer-checked:[&_.cg-on-pause]:block peer-checked:[&_.cg-on-play]:hidden peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-surface"
|
|
91
|
+
>
|
|
92
|
+
<Pause aria-hidden="true" size={18} strokeWidth={2} className="cg-on-play" />
|
|
93
|
+
<Play aria-hidden="true" size={18} strokeWidth={2} className="cg-on-pause ml-0.5 hidden" />
|
|
94
|
+
<span className="sr-only">{t.pause}</span>
|
|
95
|
+
</label>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useOnAppear } from "./useOnAppear";
|
|
5
|
+
import { PauseButton } from "./PauseButton";
|
|
6
|
+
|
|
7
|
+
type Labels = {
|
|
8
|
+
/** Completes the pause button's name: "Pause the plan animation". */
|
|
9
|
+
pause: string;
|
|
10
|
+
};
|
|
11
|
+
const EN: Labels = { pause: "the plan animation" };
|
|
12
|
+
|
|
13
|
+
type Props = {
|
|
14
|
+
/** The three stages, short ("Short", "Medium", "Long"). Three: the keyframes
|
|
15
|
+
* in theme.css light one dot at a time, and there are three sets. */
|
|
16
|
+
names: [string, string, string];
|
|
17
|
+
/** Only from md, glued to the name: " term" → "Short term". */
|
|
18
|
+
suffix?: string;
|
|
19
|
+
labels?: Partial<Labels>;
|
|
20
|
+
className?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Step 3 of the care flow (creeta's `Prazos`): the three stages of the plan.
|
|
25
|
+
* The line comes down and each dot lights up when it reaches it; after a rest
|
|
26
|
+
* everything goes off and restarts (6s loop, keyframes in theme.css).
|
|
27
|
+
* - Starts when on screen and only runs while on screen.
|
|
28
|
+
* - Moves by itself for more than 5s: pause button (WCAG 2.2.2).
|
|
29
|
+
* - Without JavaScript or with reduced motion: still, the three dots lit, no button.
|
|
30
|
+
* Fills its box (absolute inset-0): put it in a frame with a height.
|
|
31
|
+
*/
|
|
32
|
+
export function PlanTimeline({ names, suffix, labels, className = "" }: Props) {
|
|
33
|
+
const t = { ...EN, ...labels };
|
|
34
|
+
const { ref, state, onScreen } = useOnAppear<HTMLDivElement>();
|
|
35
|
+
const [paused, setPaused] = useState(false);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div ref={ref} data-state={state} data-stopped={paused || !onScreen ? "" : undefined} className={`cg-plan absolute inset-0 ${className}`}>
|
|
39
|
+
<div aria-hidden="true" className="absolute inset-y-3 left-3 right-0 flex flex-col justify-center rounded-l-2xl bg-card pl-3 md:inset-y-5 md:left-5 md:pl-5">
|
|
40
|
+
{/* the track, light, and over it the line that comes down */}
|
|
41
|
+
<span className="absolute bottom-[22%] left-[17.5px] top-[22%] w-px bg-ink-soft/25 md:left-[25.5px]" />
|
|
42
|
+
<span className="cg-plan-line absolute bottom-[22%] left-[17.5px] top-[22%] w-px origin-top bg-ink-soft md:left-[25.5px]" />
|
|
43
|
+
{names.map((name, i) => (
|
|
44
|
+
<span
|
|
45
|
+
key={name}
|
|
46
|
+
data-n={i + 1}
|
|
47
|
+
className={`cg-plan-step flex flex-1 items-center gap-2.5 text-[0.8125rem] font-medium text-ink md:gap-3.5 md:text-[0.9375rem] ${i > 0 ? "border-t border-ink/10" : ""}`}
|
|
48
|
+
>
|
|
49
|
+
<span className="relative h-3 w-3 shrink-0 rounded-full border-2 border-ink-soft bg-card">
|
|
50
|
+
<span className="cg-plan-dot absolute -inset-0.5 rounded-full bg-ink-soft" />
|
|
51
|
+
</span>
|
|
52
|
+
{/* on the phone the frame is 134px and the pause button takes the corner: name only */}
|
|
53
|
+
<span className="cg-plan-name">
|
|
54
|
+
{name}
|
|
55
|
+
{suffix && <span className="max-md:hidden">{suffix}</span>}
|
|
56
|
+
</span>
|
|
57
|
+
</span>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{state !== "whole" && (
|
|
62
|
+
<PauseButton paused={paused} onToggle={() => setPaused((p) => !p)} what={t.pause} bubble="surface" className="bottom-3 right-0 md:bottom-5 md:right-1" />
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { PostDate } from "./PostCard";
|
|
3
|
+
|
|
4
|
+
type Labels = {
|
|
5
|
+
/** sr-only before the date. */
|
|
6
|
+
publishedOn: string;
|
|
7
|
+
/** sr-only heading of the aside (the video). */
|
|
8
|
+
aside: string;
|
|
9
|
+
};
|
|
10
|
+
const EN: Labels = { publishedOn: "Published on", aside: "Watch" };
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
/** The trail above the title (a Breadcrumb). */
|
|
14
|
+
trail?: ReactNode;
|
|
15
|
+
title: ReactNode;
|
|
16
|
+
/** Who signs (a PersonChip). */
|
|
17
|
+
signature?: ReactNode;
|
|
18
|
+
date?: PostDate;
|
|
19
|
+
/** The text, one entry per paragraph: the first one is the lead, larger. */
|
|
20
|
+
paragraphs: ReactNode[];
|
|
21
|
+
/** Small line under a hairline at the end of the text ("This text guides, but…"). */
|
|
22
|
+
notice?: ReactNode;
|
|
23
|
+
/** Right column from md, sticky: the video the text came from (a VideoStoryCard). */
|
|
24
|
+
aside?: ReactNode;
|
|
25
|
+
/** The video's speech, in full, open: here it is the body of the page, not an extra. */
|
|
26
|
+
transcript?: { title: ReactNode; note?: ReactNode; paragraphs: ReactNode[]; lang?: string };
|
|
27
|
+
/** After the article: "other posts" (a heading and a grid of PostCard), the final call… */
|
|
28
|
+
children?: ReactNode;
|
|
29
|
+
labels?: Partial<Labels>;
|
|
30
|
+
className?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Post page of the Cuidado Global family — creeta's `OrientacaoTela`, by
|
|
35
|
+
* tokens. Order: the trail, the title, who signs and when; the text, which
|
|
36
|
+
* answers at once; the video the text came from and, in full, what is said in
|
|
37
|
+
* it. On the phone they come in reading order (text, video, transcript); from
|
|
38
|
+
* md the video goes to the right column, sticky, and follows the reading.
|
|
39
|
+
*/
|
|
40
|
+
export function PostArticle({ trail, title, signature, date, paragraphs, notice, aside, transcript, children, labels, className = "" }: Props) {
|
|
41
|
+
const t = { ...EN, ...labels };
|
|
42
|
+
return (
|
|
43
|
+
<article className={className}>
|
|
44
|
+
{trail}
|
|
45
|
+
|
|
46
|
+
<header className="mt-3 max-w-3xl md:mt-5">
|
|
47
|
+
<h1 className="font-heading text-[2rem] font-medium leading-[1.08] tracking-[-0.02em] text-ink md:text-5xl">{title}</h1>
|
|
48
|
+
{(signature || date) && (
|
|
49
|
+
<div className="mt-6 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-ink-body">
|
|
50
|
+
{signature}
|
|
51
|
+
{date && (
|
|
52
|
+
<time dateTime={date.iso}>
|
|
53
|
+
<span className="sr-only">{t.publishedOn} </span>
|
|
54
|
+
{date.label}
|
|
55
|
+
</time>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
</header>
|
|
60
|
+
|
|
61
|
+
{/* Three pieces in a grid. On the phone in reading order: the text (the
|
|
62
|
+
answer), the video, the transcript. From md the video takes the right
|
|
63
|
+
column and follows the reading of the other two. The two text pieces
|
|
64
|
+
share one column (`contents` on the phone, a stack from md): in two
|
|
65
|
+
grid rows the taller video stretched the first row and opened a gap
|
|
66
|
+
above the transcript (2026-10-29). */}
|
|
67
|
+
<div className="mt-8 grid gap-3 md:mt-10 md:grid-cols-[minmax(0,7fr)_minmax(0,4fr)] md:items-start md:gap-5">
|
|
68
|
+
<div className="contents md:col-start-1 md:row-start-1 md:flex md:flex-col md:gap-5">
|
|
69
|
+
<section className="rounded-[28px] bg-card p-6 md:p-10">
|
|
70
|
+
{paragraphs.map((p, i) => (
|
|
71
|
+
<p key={i} className={i === 0 ? "text-lg leading-relaxed text-ink md:text-xl" : "mt-4 leading-relaxed text-ink-body"}>
|
|
72
|
+
{p}
|
|
73
|
+
</p>
|
|
74
|
+
))}
|
|
75
|
+
{notice && <p className="mt-6 border-t border-ink/10 pt-4 text-sm text-ink-body">{notice}</p>}
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
{transcript && transcript.paragraphs.length > 0 && (
|
|
79
|
+
<section className="rounded-[28px] bg-card p-6 max-md:order-2 md:p-10">
|
|
80
|
+
<h2 className="font-heading text-[1.375rem] font-medium leading-snug text-ink md:text-2xl">{transcript.title}</h2>
|
|
81
|
+
{transcript.note && <p className="mt-2 text-sm font-medium text-ink">{transcript.note}</p>}
|
|
82
|
+
<div lang={transcript.lang} className="mt-4 space-y-4 leading-relaxed text-ink-body">
|
|
83
|
+
{transcript.paragraphs.map((p, i) => (
|
|
84
|
+
<p key={i}>{p}</p>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
</section>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{aside && (
|
|
92
|
+
<aside className="max-md:order-1 md:sticky md:top-28 md:col-start-2 md:row-start-1">
|
|
93
|
+
<h2 className="sr-only">{t.aside}</h2>
|
|
94
|
+
{aside}
|
|
95
|
+
</aside>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{children}
|
|
100
|
+
</article>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type PostCover = { src: string; srcSet?: string; sizes?: string };
|
|
4
|
+
export type PostDate = { iso: string; label: ReactNode };
|
|
5
|
+
|
|
6
|
+
type Labels = {
|
|
7
|
+
/** Decorative line at the foot ("Read the guidance"); the link is the title. */
|
|
8
|
+
read: ReactNode;
|
|
9
|
+
};
|
|
10
|
+
const EN: Labels = { read: "Read more" };
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
/** 4:3 at the top, decorative (the title says the subject). */
|
|
14
|
+
cover: PostCover;
|
|
15
|
+
/** Small line above the title ("Respiratory treatment"). */
|
|
16
|
+
category?: ReactNode;
|
|
17
|
+
title: ReactNode;
|
|
18
|
+
href: string;
|
|
19
|
+
/** The first paragraph, clamped to three lines. */
|
|
20
|
+
excerpt: ReactNode;
|
|
21
|
+
author: ReactNode;
|
|
22
|
+
date: PostDate;
|
|
23
|
+
headingLevel?: "h2" | "h3";
|
|
24
|
+
linkComponent?: ElementType;
|
|
25
|
+
labels?: Partial<Labels>;
|
|
26
|
+
className?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ArrowUpRight = () => (
|
|
30
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Post card of the Cuidado Global family — creeta's `OrientacaoCartao`, by
|
|
35
|
+
* tokens: the card of one article in the list and in "other posts". The whole
|
|
36
|
+
* card is clickable (the title's link stretches over it) but the link's name
|
|
37
|
+
* stays the title: that is what the screen reader hears.
|
|
38
|
+
*/
|
|
39
|
+
export function PostCard({ cover, category, title, href, excerpt, author, date, headingLevel: Title = "h2", linkComponent, labels, className = "" }: Props) {
|
|
40
|
+
const L: ElementType = linkComponent ?? "a";
|
|
41
|
+
const t = { ...EN, ...labels };
|
|
42
|
+
return (
|
|
43
|
+
<article className={`group relative flex h-full flex-col overflow-hidden rounded-[28px] bg-card transition-transform duration-200 ease-out active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 ${className}`}>
|
|
44
|
+
<div className="relative aspect-[4/3] overflow-hidden">
|
|
45
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
46
|
+
<img src={cover.src} srcSet={cover.srcSet} sizes={cover.sizes} alt="" loading="lazy" decoding="async" className="absolute inset-0 h-full w-full object-cover object-[50%_30%]" />
|
|
47
|
+
</div>
|
|
48
|
+
<div className="flex flex-1 flex-col p-6 md:p-7">
|
|
49
|
+
{category && <p className="mb-2 text-sm font-semibold text-ink-soft">{category}</p>}
|
|
50
|
+
<Title className="font-heading text-xl font-medium leading-snug text-ink md:text-[1.375rem]">
|
|
51
|
+
<L href={href} className="after:absolute after:inset-0 after:rounded-[28px] focus-visible:outline-none focus-visible:after:outline focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-ink">
|
|
52
|
+
{title}
|
|
53
|
+
</L>
|
|
54
|
+
</Title>
|
|
55
|
+
<p className="mt-3 line-clamp-3 leading-relaxed text-ink-body">{excerpt}</p>
|
|
56
|
+
<p className="mt-auto pt-5 text-sm text-ink-body">
|
|
57
|
+
<span className="font-semibold text-ink">{author}</span>
|
|
58
|
+
<span aria-hidden="true"> · </span>
|
|
59
|
+
<time dateTime={date.iso}>{date.label}</time>
|
|
60
|
+
</p>
|
|
61
|
+
{/* decoration: the link is the title */}
|
|
62
|
+
<p aria-hidden="true" className="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-ink-soft">
|
|
63
|
+
{t.read}
|
|
64
|
+
<ArrowUpRight />
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
</article>
|
|
68
|
+
);
|
|
69
|
+
}
|