@breathe-ds/ui 0.2.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.
Files changed (49) hide show
  1. package/README.md +118 -13
  2. package/components/Accordion.tsx +6 -4
  3. package/components/AnchorNav.tsx +12 -4
  4. package/components/Avatar.tsx +37 -0
  5. package/components/BottomNav.tsx +1 -1
  6. package/components/Breadcrumb.tsx +45 -0
  7. package/components/BreatheMark.tsx +0 -13
  8. package/components/Button.tsx +11 -4
  9. package/components/Byline.tsx +105 -0
  10. package/components/CaptionBand.tsx +74 -0
  11. package/components/CareSteps.tsx +86 -0
  12. package/components/ChatPreview.tsx +138 -0
  13. package/components/CtaCard.tsx +52 -0
  14. package/components/FactStrip.tsx +55 -0
  15. package/components/FeaturedPosts.tsx +80 -0
  16. package/components/Footer.tsx +50 -30
  17. package/components/Header.tsx +4 -2
  18. package/components/Heading.tsx +86 -0
  19. package/components/InfoCard.tsx +116 -0
  20. package/components/LanguageSwitcher.tsx +1 -1
  21. package/components/List.tsx +81 -0
  22. package/components/Lockup.tsx +0 -18
  23. package/components/MapCard.tsx +158 -0
  24. package/components/MediaCard.tsx +266 -0
  25. package/components/PageVideoHero.tsx +65 -23
  26. package/components/PauseButton.tsx +41 -0
  27. package/components/PersonChip.tsx +49 -0
  28. package/components/PhotoSequence.tsx +99 -0
  29. package/components/Pill.tsx +42 -0
  30. package/components/PillList.tsx +20 -47
  31. package/components/PlanTimeline.tsx +66 -0
  32. package/components/PostArticle.tsx +102 -0
  33. package/components/PostCard.tsx +69 -0
  34. package/components/ProfessionalCard.tsx +446 -0
  35. package/components/ProfessionalModal.tsx +243 -0
  36. package/components/Rail.tsx +94 -0
  37. package/components/Rows.tsx +137 -0
  38. package/components/StoryPanel.tsx +107 -0
  39. package/components/TestimonialCard.tsx +138 -0
  40. package/components/Text.tsx +31 -0
  41. package/components/Timeline.tsx +69 -0
  42. package/components/VideoInviteCard.tsx +168 -0
  43. package/components/VideoStoryCard.tsx +382 -0
  44. package/components/useOnAppear.ts +39 -0
  45. package/package.json +34 -2
  46. package/theme.css +206 -0
  47. package/tokens/creeta.css +7 -2
  48. package/tokens/neutral.css +12 -4
  49. package/tokens/ttcab.css +14 -5
@@ -1,38 +1,29 @@
1
- import type { ReactNode } from "react";
1
+ import type { ElementType, ReactNode } from "react";
2
2
  import { Button } from "./Button";
3
+ import { Pill } from "./Pill";
3
4
 
4
- export type Pill = { label: ReactNode; href?: string };
5
+ export type PillItem = { label: ReactNode; href: string; meta?: ReactNode };
6
+ /** @deprecated use PillItem */
7
+ export type Pill_ = PillItem;
5
8
 
6
9
  type Props = {
7
10
  title?: ReactNode;
8
- items: Pill[];
11
+ /** Each item is a Pill: `#anchor` hrefs get the down arrow, page links the diagonal one. */
12
+ items: PillItem[];
9
13
  /** Optional "See all" link. */
10
14
  seeAll?: { label: ReactNode; href?: string };
15
+ linkComponent?: ElementType;
11
16
  };
12
17
 
13
- function ArrowUpRight() {
14
- return (
15
- <svg
16
- aria-hidden="true"
17
- viewBox="0 0 24 24"
18
- className="h-4 w-4 flex-none"
19
- fill="none"
20
- stroke="currentColor"
21
- strokeWidth="2.25"
22
- strokeLinecap="round"
23
- strokeLinejoin="round"
24
- >
25
- <path d="M7 17 17 7M9 7h8v8" />
26
- </svg>
27
- );
28
- }
18
+ const ArrowUpRight = () => (
19
+ <svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4 flex-none" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
20
+ );
29
21
 
30
22
  /**
31
- * Pill list (e.g. "Conditions we treat"): white chips with label + arrow, in
32
- * a wrapping grid, plus a "See all" link. A clickable chip renders as `<a>`.
33
- * The DS's subtle card style (soft border + shadow), pill radius.
23
+ * Pill list: a wrapping row of Pills ("Conditions we treat", the video groups
24
+ * of a page with "11 videos" as meta), plus an optional "See all" link.
34
25
  */
35
- export function PillList({ title, items, seeAll }: Props) {
26
+ export function PillList({ title, items, seeAll, linkComponent }: Props) {
36
27
  return (
37
28
  <div>
38
29
  {title && (
@@ -40,30 +31,12 @@ export function PillList({ title, items, seeAll }: Props) {
40
31
  {title}
41
32
  </h2>
42
33
  )}
43
- <ul className="flex flex-wrap gap-2.5">
44
- {items.map((p, i) => {
45
- const inner = (
46
- <>
47
- <span>{p.label}</span>
48
- <span className="grid h-7 w-7 flex-none place-items-center rounded-full bg-ink/[0.06] text-ink-soft transition-colors group-hover:bg-ink/[0.1] group-hover:text-ink">
49
- <ArrowUpRight />
50
- </span>
51
- </>
52
- );
53
- const cls =
54
- "group inline-flex items-center gap-3 rounded-pill border border-line bg-surface py-2 pr-2 pl-5 font-heading text-[0.98rem] font-semibold text-ink shadow-[0_2px_10px_rgba(20,71,83,0.05)] transition hover:-translate-y-0.5 hover:shadow-[0_10px_24px_rgba(20,71,83,0.1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
55
- return (
56
- <li key={i}>
57
- {p.href ? (
58
- <a href={p.href} className={cls}>
59
- {inner}
60
- </a>
61
- ) : (
62
- <span className={cls}>{inner}</span>
63
- )}
64
- </li>
65
- );
66
- })}
34
+ <ul className="flex list-none flex-wrap gap-2.5">
35
+ {items.map((p, i) => (
36
+ <li key={`${i}-${p.href}`}>
37
+ <Pill href={p.href} meta={p.meta} linkComponent={linkComponent}>{p.label}</Pill>
38
+ </li>
39
+ ))}
67
40
  </ul>
68
41
  {seeAll && (
69
42
  <div className="mt-6">
@@ -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
+ }