@breathe-ds/ui 0.5.4 → 0.6.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 +29 -2
- package/components/Accordion.tsx +6 -3
- package/components/AnchorNav.tsx +10 -5
- package/components/Avatar.tsx +1 -1
- package/components/BottomNav.tsx +12 -5
- package/components/Button.tsx +5 -2
- package/components/Byline.tsx +25 -1
- package/components/Card.tsx +11 -3
- package/components/ContactRow.tsx +112 -0
- package/components/CourseCard.tsx +3 -3
- package/components/CtaCard.tsx +3 -1
- package/components/DateBadge.tsx +1 -1
- package/components/EmptyState.tsx +54 -0
- package/components/EventFacts.tsx +1 -1
- package/components/EventRow.tsx +3 -3
- package/components/FactStrip.tsx +1 -1
- package/components/FilterBar.tsx +50 -0
- package/components/FilterChips.tsx +63 -0
- package/components/FilterPill.tsx +35 -0
- package/components/Footer.tsx +1 -1
- package/components/Header.tsx +61 -33
- package/components/Heading.tsx +3 -2
- package/components/InfoCard.tsx +7 -2
- package/components/List.tsx +1 -1
- package/components/PageHero.tsx +74 -0
- package/components/PageVideoHero.tsx +15 -5
- package/components/PersonChip.tsx +1 -1
- package/components/Pill.tsx +1 -1
- package/components/ProfessionalCard.tsx +60 -5
- package/components/ProfileSpotlight.tsx +94 -0
- package/components/PublicationCard.tsx +215 -0
- package/components/PullQuote.tsx +87 -0
- package/components/RegistrationBox.tsx +1 -1
- package/components/SearchField.tsx +85 -0
- package/components/SelectField.tsx +75 -0
- package/components/SidePanel.tsx +6 -3
- package/components/StickyGroupHeading.tsx +38 -0
- package/components/TestimonialCard.tsx +179 -23
- package/components/Text.tsx +1 -1
- package/components/Timeline.tsx +1 -1
- package/components/VideoInviteCard.tsx +20 -6
- package/package.json +14 -2
- package/theme.css +50 -0
- package/tokens/creeta.css +1 -0
- package/tokens/ttcab.css +6 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import { LivingPortrait, type ProfessionalLiving } from "./ProfessionalCard";
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
name: string;
|
|
6
|
+
/** Small line above the name ("Idealizadora", "Course coordinator"). */
|
|
7
|
+
eyebrow?: ReactNode;
|
|
8
|
+
/** What the person does, under the name, one tone below. */
|
|
9
|
+
role?: ReactNode;
|
|
10
|
+
/** Council registration (CREFITO): the family rule for health professionals. */
|
|
11
|
+
registration?: ReactNode;
|
|
12
|
+
/** The bio: a string (one paragraph) or your own paragraphs. */
|
|
13
|
+
bio?: ReactNode;
|
|
14
|
+
/** The still portrait (4:5). With `living`, it is the fallback if the clip fails. */
|
|
15
|
+
photo: { src: string; alt?: string };
|
|
16
|
+
/** Living portrait: the clip plays muted while the mouse is on the block. */
|
|
17
|
+
living?: ProfessionalLiving;
|
|
18
|
+
/** Your own image element (e.g. next/image) in place of the photo; must fill the box. */
|
|
19
|
+
media?: ReactNode;
|
|
20
|
+
/** Links or buttons at the foot ("Watch the presentation", "Curriculum ↗"). */
|
|
21
|
+
actions?: ReactNode;
|
|
22
|
+
/** Social profiles at the foot (Instagram, LinkedIn, Lattes…): round icon
|
|
23
|
+
* links, each with its name for screen readers, opening in a new tab. */
|
|
24
|
+
social?: { label: string; href: string; Icon: ComponentType<{ size?: number; strokeWidth?: number; "aria-hidden"?: boolean | "true" }> }[];
|
|
25
|
+
/** sr-only after each social link. */
|
|
26
|
+
newTabLabel?: string;
|
|
27
|
+
/** `card`: on a white card. `plain` (default): straight on the page color. */
|
|
28
|
+
variant?: "plain" | "card";
|
|
29
|
+
/** h2 for a page section (default), h3 inside a section that already has one. */
|
|
30
|
+
headingLevel?: "h2" | "h3";
|
|
31
|
+
id?: string;
|
|
32
|
+
className?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Profile spotlight of the Cuidado Global family: one person in focus — the
|
|
37
|
+
* founder, a course coordinator, a guest. Portrait on the left (photo or the
|
|
38
|
+
* living portrait of the team pages), then eyebrow, name, role, registration,
|
|
39
|
+
* bio and actions. Stacks on phones (portrait on top). The whole block is the
|
|
40
|
+
* hover target of the living portrait.
|
|
41
|
+
*/
|
|
42
|
+
export function ProfileSpotlight({ name, eyebrow, role, registration, bio, photo, living, media, actions, social, newTabLabel = "(opens in a new tab)", variant = "plain", headingLevel = "h2", id, className = "" }: Props) {
|
|
43
|
+
const H = headingLevel;
|
|
44
|
+
const card = variant === "card";
|
|
45
|
+
return (
|
|
46
|
+
<article
|
|
47
|
+
id={id}
|
|
48
|
+
data-cg-professional-card
|
|
49
|
+
className={`grid items-center gap-8 md:grid-cols-[minmax(220px,360px)_1fr] md:gap-[clamp(32px,6vw,88px)] ${card ? "rounded-[28px] bg-card p-5 md:p-8" : ""} ${id ? "scroll-mt-24" : ""} ${className}`}
|
|
50
|
+
>
|
|
51
|
+
<div className="overflow-hidden rounded-2xl bg-tint">
|
|
52
|
+
{living ? (
|
|
53
|
+
<LivingPortrait src={living.src} poster={living.poster} photo={photo} media={media} alt={photo.alt ?? name} decorative className="aspect-[4/5]" />
|
|
54
|
+
) : (
|
|
55
|
+
media ?? (
|
|
56
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
57
|
+
<img src={photo.src} alt="" loading="lazy" decoding="async" className="aspect-[4/5] w-full object-cover" />
|
|
58
|
+
)
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
<div className="min-w-0">
|
|
62
|
+
{eyebrow && <p className="m-0 mb-3 font-heading text-sm font-semibold text-ink-soft">{eyebrow}</p>}
|
|
63
|
+
<H className="m-0 font-heading text-[clamp(1.9rem,3.6vw,2.75rem)] leading-[1.1] font-medium tracking-[-0.015em] text-ink">{name}</H>
|
|
64
|
+
{role && <p className="mt-4 mb-0 max-w-[52ch] text-lg leading-snug text-ink-body">{role}</p>}
|
|
65
|
+
{registration && <p className="mt-2 mb-0 text-sm text-ink-soft">{registration}</p>}
|
|
66
|
+
{bio && (
|
|
67
|
+
<div className="mt-6 flex max-w-[60ch] flex-col gap-4 leading-relaxed text-ink-body [&>p]:m-0">
|
|
68
|
+
{typeof bio === "string" ? <p>{bio}</p> : bio}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
{actions && <div className="mt-7 flex flex-wrap items-center gap-3">{actions}</div>}
|
|
72
|
+
{social?.length ? (
|
|
73
|
+
<ul className={`m-0 flex list-none flex-wrap gap-2 p-0 ${actions ? "mt-5" : "mt-7"}`}>
|
|
74
|
+
{social.map(({ label, href, Icon }) => (
|
|
75
|
+
<li key={href}>
|
|
76
|
+
<a
|
|
77
|
+
href={href}
|
|
78
|
+
target="_blank"
|
|
79
|
+
rel="noopener noreferrer"
|
|
80
|
+
className="flex h-11 w-11 items-center justify-center rounded-full bg-tint text-ink transition-colors duration-200 hover:bg-ink hover:text-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none"
|
|
81
|
+
>
|
|
82
|
+
<Icon size={19} strokeWidth={1.75} aria-hidden="true" />
|
|
83
|
+
<span className="sr-only">
|
|
84
|
+
{label} {newTabLabel}
|
|
85
|
+
</span>
|
|
86
|
+
</a>
|
|
87
|
+
</li>
|
|
88
|
+
))}
|
|
89
|
+
</ul>
|
|
90
|
+
) : null}
|
|
91
|
+
</div>
|
|
92
|
+
</article>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useId, useState, type ElementType, type ReactNode } from "react";
|
|
4
|
+
import { ArrowRight, ChevronDown, FileText } from "lucide-react";
|
|
5
|
+
import { FADE_IN } from "./Rows";
|
|
6
|
+
|
|
7
|
+
export type PublicationCardLabels = {
|
|
8
|
+
/** The toggle ("Resumo"). */
|
|
9
|
+
abstract: string;
|
|
10
|
+
/** sr-only word before the title after the toggle and the PDF link ("de" → "Resumo de {title}").
|
|
11
|
+
* A plain string so a server component can pass it. */
|
|
12
|
+
of: string;
|
|
13
|
+
pdf: string;
|
|
14
|
+
newTab: string;
|
|
15
|
+
/** Footer link of the compact card ("Ler o artigo"). */
|
|
16
|
+
read: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const EN: PublicationCardLabels = {
|
|
20
|
+
abstract: "Abstract",
|
|
21
|
+
of: "of",
|
|
22
|
+
pdf: "PDF",
|
|
23
|
+
newTab: "(opens in a new tab)",
|
|
24
|
+
read: "Read the article",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Props = {
|
|
28
|
+
/** Plain title (sr-only labels). */
|
|
29
|
+
title: string;
|
|
30
|
+
/** Language of the title and abstract when it differs from the page ("en"). */
|
|
31
|
+
lang?: string;
|
|
32
|
+
/** The study type, shown as a small tag ("Ensaio clínico randomizado"). */
|
|
33
|
+
kind?: ReactNode;
|
|
34
|
+
/** Topics, in a lighter line beside the tag. */
|
|
35
|
+
topics?: ReactNode[];
|
|
36
|
+
/** Authors, already shortened ("Ana Souza e mais 3 autores"). */
|
|
37
|
+
byline?: ReactNode;
|
|
38
|
+
/** The publication's page on the site. */
|
|
39
|
+
href?: string;
|
|
40
|
+
/** The full PDF, opened in a new tab. */
|
|
41
|
+
pdf?: string;
|
|
42
|
+
/** Abstract paragraphs (structured abstracts keep Background / Methods… apart). */
|
|
43
|
+
abstract?: string[];
|
|
44
|
+
/** Language of the abstract if it differs from the title's. */
|
|
45
|
+
abstractLang?: string;
|
|
46
|
+
headingLevel?: "h2" | "h3";
|
|
47
|
+
/** `full` (default): the library row, abstract opening in place.
|
|
48
|
+
* `compact`: the "related articles" tile — same height as its neighbours,
|
|
49
|
+
* tag and topics on two fixed lines, and a footer pinned to the bottom with
|
|
50
|
+
* "read the article" on the left and the PDF on the right. */
|
|
51
|
+
variant?: "full" | "compact";
|
|
52
|
+
/** Link component of the app (e.g. next-intl Link). Default: <a>. */
|
|
53
|
+
linkComponent?: ElementType;
|
|
54
|
+
labels?: Partial<PublicationCardLabels>;
|
|
55
|
+
className?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** "Background: text" -> the label in bold, so a structured abstract can be scanned. */
|
|
59
|
+
export function abstractLabel(p: string): { label?: string; text: string } {
|
|
60
|
+
const m = p.match(/^([A-Z][A-Za-z ]{2,22}|[A-ZÀ-Ú ]{3,22}):\s*(.+)$/);
|
|
61
|
+
return m ? { label: m[1].replace(/\s+/g, " ").trim(), text: m[2] } : { text: p };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The paragraphs of an abstract, labels in bold. */
|
|
65
|
+
export function AbstractText({ paragraphs, lang, className = "" }: { paragraphs: string[]; lang?: string; className?: string }) {
|
|
66
|
+
return (
|
|
67
|
+
<div lang={lang} className={`flex max-w-[68ch] flex-col gap-3 leading-relaxed text-ink-body ${className}`}>
|
|
68
|
+
{paragraphs.map((p, i) => {
|
|
69
|
+
const { label, text } = abstractLabel(p);
|
|
70
|
+
return (
|
|
71
|
+
<p key={i} className="m-0">
|
|
72
|
+
{label && <strong className="font-semibold text-ink">{label}. </strong>}
|
|
73
|
+
{text}
|
|
74
|
+
</p>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ACTION =
|
|
82
|
+
"inline-flex min-h-11 items-center gap-1.5 rounded-[2px] text-sm font-semibold text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* PublicationCard — one study in a library of scientific articles (ttcab's
|
|
86
|
+
* Artigos). Built on proximity and common region: the white card holds the
|
|
87
|
+
* whole item, the tag and topics sit tight over the title, the authors right
|
|
88
|
+
* under it, and the two actions (open the abstract here, open the PDF) share
|
|
89
|
+
* one row under the authors — never pushed to the far edge of the page.
|
|
90
|
+
*
|
|
91
|
+
* The abstract opens in place (a button with `aria-expanded` controlling the
|
|
92
|
+
* region below, so the PDF link can sit beside the toggle); it fades in with
|
|
93
|
+
* `@starting-style`, no JavaScript animation.
|
|
94
|
+
*/
|
|
95
|
+
export function PublicationCard({
|
|
96
|
+
title,
|
|
97
|
+
lang,
|
|
98
|
+
kind,
|
|
99
|
+
topics,
|
|
100
|
+
byline,
|
|
101
|
+
href,
|
|
102
|
+
pdf,
|
|
103
|
+
abstract,
|
|
104
|
+
abstractLang,
|
|
105
|
+
headingLevel: H = "h2",
|
|
106
|
+
variant = "full",
|
|
107
|
+
linkComponent,
|
|
108
|
+
labels,
|
|
109
|
+
className = "",
|
|
110
|
+
}: Props) {
|
|
111
|
+
const t = { ...EN, ...labels };
|
|
112
|
+
const L: ElementType = linkComponent ?? "a";
|
|
113
|
+
const [open, setOpen] = useState(false);
|
|
114
|
+
const regionId = useId();
|
|
115
|
+
const hasAbstract = Boolean(abstract && abstract.length);
|
|
116
|
+
|
|
117
|
+
if (variant === "compact") {
|
|
118
|
+
return (
|
|
119
|
+
<article className={`flex h-full flex-col rounded-[22px] bg-card ${className}`}>
|
|
120
|
+
<div className="flex flex-1 flex-col px-6 pt-6 pb-5">
|
|
121
|
+
{kind && (
|
|
122
|
+
<p className="m-0">
|
|
123
|
+
<span className="inline-block rounded-full bg-tint px-2.5 py-1 text-xs font-semibold text-ink">{kind}</span>
|
|
124
|
+
</p>
|
|
125
|
+
)}
|
|
126
|
+
{topics && topics.length > 0 && <p className="m-0 mt-2 text-sm text-ink-soft">{topics.join(" · ")}</p>}
|
|
127
|
+
<H lang={lang} className="mt-3 font-heading text-[1.15rem] leading-snug font-medium text-balance text-ink">
|
|
128
|
+
{href ? (
|
|
129
|
+
<L href={href} className="rounded-[2px] underline decoration-transparent decoration-[1.5px] underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none">
|
|
130
|
+
{title}
|
|
131
|
+
</L>
|
|
132
|
+
) : (
|
|
133
|
+
title
|
|
134
|
+
)}
|
|
135
|
+
</H>
|
|
136
|
+
{byline && <p className="m-0 mt-2 text-sm text-ink-soft">{byline}</p>}
|
|
137
|
+
</div>
|
|
138
|
+
{(href || pdf) && (
|
|
139
|
+
<footer className="flex min-h-14 items-center justify-between gap-4 rounded-b-[22px] border-t border-line bg-surface-alt/60 px-6">
|
|
140
|
+
{href ? (
|
|
141
|
+
<L href={href} tabIndex={-1} aria-hidden="true" className="group/read inline-flex min-h-11 items-center gap-1.5 text-sm font-semibold text-ink">
|
|
142
|
+
{t.read}
|
|
143
|
+
<ArrowRight aria-hidden="true" size={16} className="transition-transform duration-200 group-hover/read:translate-x-0.5 motion-reduce:transform-none" />
|
|
144
|
+
</L>
|
|
145
|
+
) : (
|
|
146
|
+
<span />
|
|
147
|
+
)}
|
|
148
|
+
{pdf && (
|
|
149
|
+
<a href={pdf} target="_blank" rel="noopener noreferrer" className={`${ACTION} underline decoration-ink/35 decoration-[1.5px] underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink motion-reduce:transition-none`}>
|
|
150
|
+
<FileText aria-hidden="true" size={16} className="text-ink-soft" />
|
|
151
|
+
{t.pdf}
|
|
152
|
+
<span className="sr-only">{` ${t.of} ${title} ${t.newTab}`}</span>
|
|
153
|
+
</a>
|
|
154
|
+
)}
|
|
155
|
+
</footer>
|
|
156
|
+
)}
|
|
157
|
+
</article>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<article className={`rounded-[22px] bg-card p-[clamp(20px,3vw,28px)] ${className}`}>
|
|
163
|
+
{(kind || (topics && topics.length > 0)) && (
|
|
164
|
+
<p className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-ink-soft">
|
|
165
|
+
{kind && <span className="rounded-full bg-tint px-2.5 py-1 text-xs font-semibold text-ink">{kind}</span>}
|
|
166
|
+
{topics && topics.length > 0 && <span>{topics.join(" · ")}</span>}
|
|
167
|
+
</p>
|
|
168
|
+
)}
|
|
169
|
+
<H lang={lang} className="mt-3 max-w-[64ch] font-heading text-[1.2rem] leading-snug font-medium text-balance text-ink md:text-[1.3rem]">
|
|
170
|
+
{href ? (
|
|
171
|
+
<L
|
|
172
|
+
href={href}
|
|
173
|
+
className="rounded-[2px] underline decoration-transparent decoration-[1.5px] underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none"
|
|
174
|
+
>
|
|
175
|
+
{title}
|
|
176
|
+
</L>
|
|
177
|
+
) : (
|
|
178
|
+
title
|
|
179
|
+
)}
|
|
180
|
+
</H>
|
|
181
|
+
{byline && <p className="mt-1.5 text-sm text-ink-soft">{byline}</p>}
|
|
182
|
+
|
|
183
|
+
{(hasAbstract || pdf) && (
|
|
184
|
+
<div className="mt-3 flex flex-wrap items-center gap-x-6 border-t border-line pt-1">
|
|
185
|
+
{hasAbstract && (
|
|
186
|
+
<button type="button" aria-expanded={open} aria-controls={regionId} onClick={() => setOpen((v) => !v)} className={`group/abs ${ACTION}`}>
|
|
187
|
+
{t.abstract}
|
|
188
|
+
<ChevronDown
|
|
189
|
+
aria-hidden="true"
|
|
190
|
+
size={16}
|
|
191
|
+
className={`text-ink-soft transition-transform duration-200 motion-reduce:transition-none ${open ? "rotate-180" : "group-hover/abs:translate-y-0.5"}`}
|
|
192
|
+
/>
|
|
193
|
+
<span className="sr-only">{` ${t.of} ${title}`}</span>
|
|
194
|
+
</button>
|
|
195
|
+
)}
|
|
196
|
+
{pdf && (
|
|
197
|
+
<a href={pdf} target="_blank" rel="noopener noreferrer" className={`${ACTION} underline decoration-ink/35 decoration-[1.5px] underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink motion-reduce:transition-none`}>
|
|
198
|
+
<FileText aria-hidden="true" size={16} className="text-ink-soft" />
|
|
199
|
+
{t.pdf}
|
|
200
|
+
<span className="sr-only">
|
|
201
|
+
{` ${t.of} ${title} ${t.newTab}`}
|
|
202
|
+
</span>
|
|
203
|
+
</a>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{hasAbstract && (
|
|
209
|
+
<div id={regionId} hidden={!open} className={`${FADE_IN} mt-2`}>
|
|
210
|
+
<AbstractText paragraphs={abstract!} lang={abstractLang ?? lang} />
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</article>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
import { Byline, type Person } from "./Byline";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The hand-drawn underline in the action color, under the key words of a line
|
|
6
|
+
* ("de forma integrada"). Decorative: the words stay plain text for screen
|
|
7
|
+
* readers. Keep it to a few words — it does not wrap.
|
|
8
|
+
*/
|
|
9
|
+
export function Highlight({ children }: { children: ReactNode }) {
|
|
10
|
+
return (
|
|
11
|
+
<span className="relative whitespace-nowrap">
|
|
12
|
+
{children}
|
|
13
|
+
<svg
|
|
14
|
+
aria-hidden="true"
|
|
15
|
+
viewBox="0 0 200 14"
|
|
16
|
+
preserveAspectRatio="none"
|
|
17
|
+
className="pointer-events-none absolute inset-x-0 -bottom-[0.16em] h-[0.3em] w-full"
|
|
18
|
+
>
|
|
19
|
+
<path
|
|
20
|
+
d="M3 9 C 40 3, 80 12, 118 7 C 150 3, 178 8, 197 6"
|
|
21
|
+
fill="none"
|
|
22
|
+
strokeWidth="3"
|
|
23
|
+
strokeLinecap="round"
|
|
24
|
+
className="stroke-action-line"
|
|
25
|
+
/>
|
|
26
|
+
</svg>
|
|
27
|
+
</span>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Props = {
|
|
32
|
+
/** The line itself. Use <Highlight> on the few words that carry it. */
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
/** Who said it (portrait + name + role under the line). Omit for a brand
|
|
35
|
+
* statement — then the quote marks stay, without an author. */
|
|
36
|
+
cite?: { people: Person[]; role?: ReactNode };
|
|
37
|
+
/** Small line above ("O que nos guia"). */
|
|
38
|
+
eyebrow?: ReactNode;
|
|
39
|
+
/** `center` (default) for a statement between sections; `start` beside other content. */
|
|
40
|
+
align?: "center" | "start";
|
|
41
|
+
/** `lg` (default) between sections; `md` inside a column or a card. */
|
|
42
|
+
size?: "md" | "lg";
|
|
43
|
+
/** Big opening quote mark in the action color. Default: true. */
|
|
44
|
+
mark?: boolean;
|
|
45
|
+
as?: ElementType;
|
|
46
|
+
className?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const SIZE = {
|
|
50
|
+
lg: "text-[clamp(1.75rem,4.2vw,3.25rem)] leading-[1.14]",
|
|
51
|
+
md: "text-[clamp(1.4rem,2.6vw,2rem)] leading-[1.2]",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pull quote of the Cuidado Global family: one line that matters, set large,
|
|
56
|
+
* with the opening mark in the action color and, optionally, the person who
|
|
57
|
+
* said it (the family's Byline). Between sections it breathes on its own; the
|
|
58
|
+
* line is balanced (text-balance) so it never ends on a lonely word.
|
|
59
|
+
*/
|
|
60
|
+
export function PullQuote({ children, cite, eyebrow, align = "center", size = "lg", mark = true, as: Root = "figure", className = "" }: Props) {
|
|
61
|
+
const center = align === "center";
|
|
62
|
+
return (
|
|
63
|
+
<Root className={`${center ? "mx-auto text-center" : ""} max-w-4xl ${className}`}>
|
|
64
|
+
{mark && (
|
|
65
|
+
// drawn mark: the font's own “ is tiny in Quicksand, it would not hold the line
|
|
66
|
+
<svg
|
|
67
|
+
aria-hidden="true"
|
|
68
|
+
viewBox="0 0 48 36"
|
|
69
|
+
className={`mb-6 block h-9 w-12 fill-action-line md:mb-8 md:h-12 md:w-16 ${center ? "mx-auto" : ""}`}
|
|
70
|
+
>
|
|
71
|
+
<path d="M0 36V22C0 10.5 6 2.6 18.2 0l2.3 5.2C14.6 7.4 11.4 11 11 16h8.6v20H0Zm27.4 0V22c0-11.5 6-19.4 18.2-22l2.3 5.2C42 7.4 38.8 11 38.4 16H47v20H27.4Z" />
|
|
72
|
+
</svg>
|
|
73
|
+
)}
|
|
74
|
+
{eyebrow && (
|
|
75
|
+
<p className="mb-4 font-heading text-[0.82rem] font-bold tracking-[0.14em] text-ink-soft uppercase">{eyebrow}</p>
|
|
76
|
+
)}
|
|
77
|
+
<blockquote className="m-0">
|
|
78
|
+
<p className={`m-0 font-heading font-medium tracking-[-0.015em] text-balance text-ink ${SIZE[size]}`}>{children}</p>
|
|
79
|
+
</blockquote>
|
|
80
|
+
{cite && (
|
|
81
|
+
<figcaption className={`mt-8 flex ${center ? "justify-center" : ""}`}>
|
|
82
|
+
<Byline cite people={cite.people} subtitle={cite.role} className="text-left" />
|
|
83
|
+
</figcaption>
|
|
84
|
+
)}
|
|
85
|
+
</Root>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -66,7 +66,7 @@ export function RegistrationBox({ title, intro, options, value, onChange, action
|
|
|
66
66
|
</span>
|
|
67
67
|
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-2 font-semibold text-ink">
|
|
68
68
|
{o.label}
|
|
69
|
-
{o.remaining && <span className="rounded-pill bg-
|
|
69
|
+
{o.remaining && <span className="rounded-pill bg-tint px-2 py-0.5 text-xs font-semibold text-ink-soft">{o.remaining}</span>}
|
|
70
70
|
</span>
|
|
71
71
|
<span className="shrink-0 font-semibold text-ink">{o.price}</span>
|
|
72
72
|
</label>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, type InputHTMLAttributes, type ReactNode } from "react";
|
|
4
|
+
import { Search, X } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
/** The family's field: a white pill on the page colour, warm hairline, the
|
|
7
|
+
* card's soft shadow. 56px tall, so it reads as the page's main control. */
|
|
8
|
+
export const FIELD =
|
|
9
|
+
"w-full min-h-14 rounded-pill border border-line bg-card text-base text-ink shadow-[0_2px_12px_color-mix(in_srgb,var(--color-shadow)_6%,transparent)] transition-[border-color,box-shadow] duration-150 outline-none hover:border-ink/35 focus-visible:border-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink disabled:cursor-not-allowed disabled:opacity-60 motion-reduce:transition-none";
|
|
10
|
+
|
|
11
|
+
/** Label of a field: Figtree semibold in `ink`, sentence case (no uppercase
|
|
12
|
+
* tracking: the label is read, not scanned). */
|
|
13
|
+
export const FIELD_LABEL = "mb-2 block text-sm font-semibold text-ink";
|
|
14
|
+
|
|
15
|
+
type Props = Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "type" | "children"> & {
|
|
16
|
+
id: string;
|
|
17
|
+
label: ReactNode;
|
|
18
|
+
value: string;
|
|
19
|
+
onValueChange: (value: string) => void;
|
|
20
|
+
/** aria-label of the clear button ("Limpar busca"). */
|
|
21
|
+
clearLabel?: string;
|
|
22
|
+
/** Short help under the field, linked by aria-describedby. */
|
|
23
|
+
hint?: ReactNode;
|
|
24
|
+
/** Keep the label for screen readers only (when a heading right above already names the field). */
|
|
25
|
+
hideLabel?: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* SearchField — the search box of a list page (the professionals directory,
|
|
31
|
+
* a blog). A native `type="search"` input (Esc clears it, the phone keyboard
|
|
32
|
+
* shows "Search"), with the lucide magnifier on the left and a clear button
|
|
33
|
+
* on the right once there is text; clearing puts the focus back in the field.
|
|
34
|
+
* The browser's own cancel glyph is hidden so there is only one ✕.
|
|
35
|
+
*
|
|
36
|
+
* Placeholder in `ink-soft` (#54707a on white: 5.3:1 on ttcab, passes AA):
|
|
37
|
+
* it carries an example, so it has to be legible.
|
|
38
|
+
*/
|
|
39
|
+
export function SearchField({ id, label, value, onValueChange, clearLabel = "Clear search", hint, hideLabel = false, className = "", ...rest }: Props) {
|
|
40
|
+
const input = useRef<HTMLInputElement>(null);
|
|
41
|
+
const hintId = hint ? `${id}-hint` : undefined;
|
|
42
|
+
return (
|
|
43
|
+
<div className={className}>
|
|
44
|
+
<label htmlFor={id} className={hideLabel ? "sr-only" : FIELD_LABEL}>
|
|
45
|
+
{label}
|
|
46
|
+
</label>
|
|
47
|
+
<div className="relative">
|
|
48
|
+
<Search aria-hidden="true" size={20} className="pointer-events-none absolute top-1/2 left-5 -translate-y-1/2 text-ink-soft" />
|
|
49
|
+
<input
|
|
50
|
+
ref={input}
|
|
51
|
+
id={id}
|
|
52
|
+
type="search"
|
|
53
|
+
value={value}
|
|
54
|
+
onChange={(e) => onValueChange(e.target.value)}
|
|
55
|
+
autoComplete="off"
|
|
56
|
+
autoCorrect="off"
|
|
57
|
+
autoCapitalize="none"
|
|
58
|
+
spellCheck={false}
|
|
59
|
+
enterKeyHint="search"
|
|
60
|
+
aria-describedby={hintId}
|
|
61
|
+
className={`${FIELD} pr-14 pl-13 placeholder:text-ink-soft [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none`}
|
|
62
|
+
{...rest}
|
|
63
|
+
/>
|
|
64
|
+
{value && (
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
aria-label={clearLabel}
|
|
68
|
+
onClick={() => {
|
|
69
|
+
onValueChange("");
|
|
70
|
+
input.current?.focus();
|
|
71
|
+
}}
|
|
72
|
+
className="absolute top-1/2 right-2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full text-ink-soft transition-colors duration-150 hover:bg-tint hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-ink motion-reduce:transition-none"
|
|
73
|
+
>
|
|
74
|
+
<X aria-hidden="true" size={18} />
|
|
75
|
+
</button>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
{hint && (
|
|
79
|
+
<p id={hintId} className="mt-2 px-5 text-sm text-ink-soft">
|
|
80
|
+
{hint}
|
|
81
|
+
</p>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ReactNode, SelectHTMLAttributes } from "react";
|
|
2
|
+
import { ChevronDown } from "lucide-react";
|
|
3
|
+
import { FIELD, FIELD_LABEL } from "./SearchField";
|
|
4
|
+
|
|
5
|
+
export type SelectOption = {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
/** Count shown after the label ("São Paulo (51)"). Native options only take text. */
|
|
9
|
+
count?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type SelectGroup = { label: string; options: SelectOption[] };
|
|
13
|
+
|
|
14
|
+
type Props = Omit<SelectHTMLAttributes<HTMLSelectElement>, "value" | "onChange" | "children"> & {
|
|
15
|
+
id: string;
|
|
16
|
+
label: ReactNode;
|
|
17
|
+
value: string;
|
|
18
|
+
onValueChange: (value: string) => void;
|
|
19
|
+
/** The first option, value "": "Todos os estados". */
|
|
20
|
+
placeholder: string;
|
|
21
|
+
/** Flat options, or groups (`<optgroup>`): pass one or the other. */
|
|
22
|
+
options?: SelectOption[];
|
|
23
|
+
groups?: SelectGroup[];
|
|
24
|
+
/** Keep the label for screen readers only, when a group label above already says
|
|
25
|
+
* what the fields are (and the placeholder names the field: "Todos os estados"). */
|
|
26
|
+
hideLabel?: boolean;
|
|
27
|
+
/** `md` (default): the 56px field. `sm`: a 40px pill for a filter bar (Apple-like).
|
|
28
|
+
* (Not `size`: that is the native <select> attribute.) */
|
|
29
|
+
density?: "md" | "sm";
|
|
30
|
+
className?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const text = (o: SelectOption) => (o.count === undefined ? o.label : `${o.label} (${o.count})`);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* SelectField — a native `<select>` dressed as the family's field (same pill,
|
|
37
|
+
* hairline and shadow as SearchField), with a lucide chevron. Native on
|
|
38
|
+
* purpose: on phones it opens the system picker, which is faster and more
|
|
39
|
+
* accessible than any custom listbox for a list of states or cities.
|
|
40
|
+
*/
|
|
41
|
+
export function SelectField({ id, label, value, onValueChange, placeholder, options, groups, hideLabel = false, density = "md", className = "", ...rest }: Props) {
|
|
42
|
+
return (
|
|
43
|
+
<div className={className}>
|
|
44
|
+
<label htmlFor={id} className={hideLabel ? "sr-only" : FIELD_LABEL}>
|
|
45
|
+
{label}
|
|
46
|
+
</label>
|
|
47
|
+
<div className="relative">
|
|
48
|
+
<select
|
|
49
|
+
id={id}
|
|
50
|
+
value={value}
|
|
51
|
+
onChange={(e) => onValueChange(e.target.value)}
|
|
52
|
+
className={`${FIELD} cursor-pointer appearance-none truncate ${density === "sm" ? "!min-h-10 py-2 pr-9 pl-4 text-sm font-medium" : "pr-12 pl-5"}`}
|
|
53
|
+
{...rest}
|
|
54
|
+
>
|
|
55
|
+
<option value="">{placeholder}</option>
|
|
56
|
+
{options?.map((o) => (
|
|
57
|
+
<option key={o.value} value={o.value}>
|
|
58
|
+
{text(o)}
|
|
59
|
+
</option>
|
|
60
|
+
))}
|
|
61
|
+
{groups?.map((g) => (
|
|
62
|
+
<optgroup key={g.label} label={g.label}>
|
|
63
|
+
{g.options.map((o) => (
|
|
64
|
+
<option key={o.value} value={o.value}>
|
|
65
|
+
{text(o)}
|
|
66
|
+
</option>
|
|
67
|
+
))}
|
|
68
|
+
</optgroup>
|
|
69
|
+
))}
|
|
70
|
+
</select>
|
|
71
|
+
<ChevronDown aria-hidden="true" size={density === "sm" ? 16 : 18} className={`pointer-events-none absolute top-1/2 -translate-y-1/2 text-ink-soft ${density === "sm" ? "right-3.5" : "right-5"}`} />
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
package/components/SidePanel.tsx
CHANGED
|
@@ -34,7 +34,10 @@ export function SidePanel({ open, onClose, title, actions, children, labels, cla
|
|
|
34
34
|
const box = useRef<HTMLDialogElement>(null);
|
|
35
35
|
const headingId = useId();
|
|
36
36
|
|
|
37
|
-
// open/close follow the prop; the way out waits for the slide to finish
|
|
37
|
+
// open/close follow the prop; the way out waits for the slide to finish.
|
|
38
|
+
// Closed, the dialog is display:none (hidden open:flex): a plain `flex` would
|
|
39
|
+
// keep it rendered off-screen, its shadow bleeding into the right edge and its
|
|
40
|
+
// content reachable.
|
|
38
41
|
useEffect(() => {
|
|
39
42
|
const d = box.current;
|
|
40
43
|
if (!d) return;
|
|
@@ -76,14 +79,14 @@ export function SidePanel({ open, onClose, title, actions, children, labels, cla
|
|
|
76
79
|
<dialog
|
|
77
80
|
ref={box}
|
|
78
81
|
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}`}
|
|
82
|
+
className={`fixed inset-y-0 right-0 left-auto m-0 hidden open: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
83
|
>
|
|
81
84
|
<h2 id={headingId} className="sr-only">{title}</h2>
|
|
82
85
|
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-ink/10 px-4 py-3">
|
|
83
86
|
<button
|
|
84
87
|
type="button"
|
|
85
88
|
onClick={onClose}
|
|
86
|
-
className="flex h-11 w-11 items-center justify-center rounded-full text-ink transition-colors duration-150 hover:bg-
|
|
89
|
+
className="flex h-11 w-11 items-center justify-center rounded-full text-ink transition-colors duration-150 hover:bg-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none"
|
|
87
90
|
>
|
|
88
91
|
<X aria-hidden="true" size={20} strokeWidth={2} />
|
|
89
92
|
<span className="sr-only">{t.close}</span>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
/** Lighter info after the title, e.g. the count ("51 profissionais"). */
|
|
6
|
+
meta?: ReactNode;
|
|
7
|
+
id?: string;
|
|
8
|
+
/** h2 by default; h3 inside a section that already has an h2. */
|
|
9
|
+
as?: "h2" | "h3";
|
|
10
|
+
/** Where it sticks: below the site's sticky header. Pass a literal Tailwind
|
|
11
|
+
* class written in your source (`top-20` = an 80px header), so it gets generated. */
|
|
12
|
+
stickyTop?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* StickyGroupHeading — the title of a group in a long list that stays at the
|
|
18
|
+
* top while its items scroll by, and is pushed away by the next group's title
|
|
19
|
+
* (the section headers of Apple's lists, Contacts A–Z). Used on ttcab's
|
|
20
|
+
* professionals directory, one group per state.
|
|
21
|
+
*
|
|
22
|
+
* It sticks inside its own group: put it as the first child of the group's
|
|
23
|
+
* wrapper (a <section>), so the next wrapper pushes it out. The band bleeds
|
|
24
|
+
* 16/24px past the content so the hairline and the blurred page colour cover
|
|
25
|
+
* the cards passing under it; `surface` at 90% keeps the title at full
|
|
26
|
+
* contrast (ink on surface, 9.41:1 on ttcab) even without backdrop-filter.
|
|
27
|
+
*/
|
|
28
|
+
export function StickyGroupHeading({ children, meta, id, as: Tag = "h2", stickyTop = "top-20", className = "" }: Props) {
|
|
29
|
+
return (
|
|
30
|
+
<Tag
|
|
31
|
+
id={id}
|
|
32
|
+
className={`sticky ${stickyTop} z-10 -mx-4 flex items-baseline gap-3 border-b border-line bg-surface/90 px-4 py-3 font-heading text-xl font-medium text-ink backdrop-blur-md md:-mx-6 md:px-6 ${className}`}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
{meta && <span className="text-sm font-normal text-ink-soft tabular-nums">{meta}</span>}
|
|
36
|
+
</Tag>
|
|
37
|
+
);
|
|
38
|
+
}
|