@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,63 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type FilterChip = {
|
|
4
|
+
value: string;
|
|
5
|
+
label: ReactNode;
|
|
6
|
+
/** Shown after the label, lighter ("Tratamento 8"). */
|
|
7
|
+
count?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type Props = {
|
|
11
|
+
/** Names the group for screen readers ("Filtrar por tema"). */
|
|
12
|
+
label: string;
|
|
13
|
+
chips: FilterChip[];
|
|
14
|
+
/** The selected value; "" is the first chip ("Todos"). */
|
|
15
|
+
value: string;
|
|
16
|
+
onValueChange: (value: string) => void;
|
|
17
|
+
/** `wrap` (default): the chips wrap to new lines. `scroll`: on phones they
|
|
18
|
+
* stay on one line and scroll sideways (bleeding to the screen edge), so a
|
|
19
|
+
* long topic list does not push the content down; from `sm` they wrap. */
|
|
20
|
+
layout?: "wrap" | "scroll";
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* FilterChips — one-of-many filter over a list (the topics of a library, the
|
|
26
|
+
* categories of a blog). Toggle buttons with `aria-pressed` in a labelled
|
|
27
|
+
* group: they change what is shown, they do not navigate, so they are not
|
|
28
|
+
* links and not tabs. 44px tall for the finger.
|
|
29
|
+
*
|
|
30
|
+
* Off: the white pill of the family (`card`, soft shadow, `ink` text). On:
|
|
31
|
+
* filled `ink` with `surface` text (9.41:1 on ttcab). A small control never
|
|
32
|
+
* uses the action colour as its fill (see the Action dose rule).
|
|
33
|
+
*/
|
|
34
|
+
const SCROLL =
|
|
35
|
+
"max-sm:-mx-5 max-sm:flex-nowrap max-sm:overflow-x-auto max-sm:px-5 max-sm:py-1 max-sm:[scrollbar-width:none] max-sm:[&::-webkit-scrollbar]:hidden max-sm:snap-x max-sm:scroll-px-5 [&>button]:shrink-0 [&>button]:snap-start";
|
|
36
|
+
|
|
37
|
+
export function FilterChips({ label, chips, value, onValueChange, layout = "wrap", className = "" }: Props) {
|
|
38
|
+
return (
|
|
39
|
+
<div role="group" aria-label={label} className={`flex flex-wrap gap-2 ${layout === "scroll" ? SCROLL : ""} ${className}`}>
|
|
40
|
+
{chips.map((c) => {
|
|
41
|
+
const on = c.value === value;
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
key={c.value || "all"}
|
|
45
|
+
type="button"
|
|
46
|
+
aria-pressed={on}
|
|
47
|
+
onClick={() => onValueChange(c.value)}
|
|
48
|
+
className={`inline-flex min-h-11 items-center gap-2 rounded-full px-4 text-sm font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:scale-100 ${
|
|
49
|
+
on
|
|
50
|
+
? "bg-ink text-surface"
|
|
51
|
+
: "bg-card text-ink shadow-[0_1px_2px_color-mix(in_srgb,var(--color-shadow)_18%,transparent)] hover:bg-tint"
|
|
52
|
+
}`}
|
|
53
|
+
>
|
|
54
|
+
{c.label}
|
|
55
|
+
{c.count !== undefined && (
|
|
56
|
+
<span className={`font-normal tabular-nums ${on ? "text-surface/80" : "text-ink-soft"}`}>{c.count}</span>
|
|
57
|
+
)}
|
|
58
|
+
</button>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
/** What is filtered: "Santa Catarina". */
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
onRemove: () => void;
|
|
8
|
+
/** aria-label of the pill ("Remover filtro Santa Catarina"). */
|
|
9
|
+
removeLabel: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* FilterPill — one active filter, removable (the token in a search bar:
|
|
15
|
+
* "Santa Catarina ✕"). The whole pill is the button: a bigger target than a
|
|
16
|
+
* lone ✕, 36px tall inside a 44px row. `ink` fill with `surface` text, the
|
|
17
|
+
* same "on" state as FilterChips (9.41:1 on ttcab), so active filters read the
|
|
18
|
+
* same everywhere. Pair it with a "Clear filters" link when there can be more
|
|
19
|
+
* than one.
|
|
20
|
+
*/
|
|
21
|
+
export function FilterPill({ children, onRemove, removeLabel, className = "" }: Props) {
|
|
22
|
+
return (
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
onClick={onRemove}
|
|
26
|
+
aria-label={removeLabel}
|
|
27
|
+
className={`group/pill inline-flex min-h-9 items-center gap-1.5 rounded-full bg-ink py-1 pr-2 pl-3.5 text-sm font-semibold text-surface transition-colors duration-150 hover:bg-ink-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:scale-100 ${className}`}
|
|
28
|
+
>
|
|
29
|
+
<span aria-hidden="true">{children}</span>
|
|
30
|
+
<span aria-hidden="true" className="flex h-6 w-6 items-center justify-center rounded-full bg-surface/15 transition-colors duration-150 group-hover/pill:bg-surface/25">
|
|
31
|
+
<X size={14} strokeWidth={2.25} />
|
|
32
|
+
</span>
|
|
33
|
+
</button>
|
|
34
|
+
);
|
|
35
|
+
}
|
package/components/Footer.tsx
CHANGED
|
@@ -77,7 +77,7 @@ const link =
|
|
|
77
77
|
const strongLink =
|
|
78
78
|
"inline-flex min-h-11 items-center gap-1.5 font-semibold text-ink underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
|
|
79
79
|
const chip =
|
|
80
|
-
"inline-flex min-h-11 items-center gap-2 rounded-full bg-
|
|
80
|
+
"inline-flex min-h-11 items-center gap-2 rounded-full bg-tint px-4 text-sm font-medium text-ink transition-colors duration-200 hover:bg-ink hover:text-surface motion-reduce:transition-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* Footer of the Cuidado Global family — the site-footer of novo.creeta, by
|
package/components/Header.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, type ReactNode, type ElementType } from "react";
|
|
3
|
+
import { useId, useRef, useState, type ReactNode, type ElementType } from "react";
|
|
4
4
|
|
|
5
5
|
export type NavChild = {
|
|
6
6
|
label: ReactNode;
|
|
@@ -20,6 +20,12 @@ export type NavItem = {
|
|
|
20
20
|
/** Final link of the dropdown. Arrow → (goes to another page of the site); with
|
|
21
21
|
* `external` it becomes ↗ (leaves to another site), as in the other components. */
|
|
22
22
|
seeAll?: { label: ReactNode; href: string; external?: boolean };
|
|
23
|
+
/** Instead of `children`: labelled groups of links (e.g. "Navigation", "Content"). */
|
|
24
|
+
groups?: { label: ReactNode; children: NavChild[] }[];
|
|
25
|
+
/** Long dropdowns: 2 columns of compact rows (max 2, as in the rest of the DS). */
|
|
26
|
+
columns?: 1 | 2;
|
|
27
|
+
/** Which edge of the trigger the dropdown aligns to; "end" keeps a wide menu inside the viewport. */
|
|
28
|
+
align?: "start" | "end";
|
|
23
29
|
};
|
|
24
30
|
|
|
25
31
|
type Labels = {
|
|
@@ -72,11 +78,44 @@ const linkCls = (active?: boolean) =>
|
|
|
72
78
|
|
|
73
79
|
function Dropdown({ item, L, t }: { item: NavItem; L: ElementType; t: Labels }) {
|
|
74
80
|
const [open, setOpen] = useState(false);
|
|
81
|
+
const two = item.columns === 2;
|
|
82
|
+
const gid = useId();
|
|
83
|
+
// a mouse that hovered already opened the menu: its click on the chevron must not close it
|
|
84
|
+
const hovering = useRef(false);
|
|
85
|
+
const renderChild = (c: NavChild) => (
|
|
86
|
+
<L
|
|
87
|
+
href={c.href}
|
|
88
|
+
onClick={() => setOpen(false)}
|
|
89
|
+
// with a description the icon sits with the label's first line (creeta);
|
|
90
|
+
// label only: centred on the bubble, and a two-line label stays tight
|
|
91
|
+
className={`flex ${c.description ? "items-start" : "items-center"} gap-3 rounded-xl px-2.5 ${item.groups ? "py-1" : two ? "py-1.5" : "py-2"} transition-colors hover:bg-ink/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink`}
|
|
92
|
+
>
|
|
93
|
+
{c.icon && (
|
|
94
|
+
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-tint text-ink">
|
|
95
|
+
{c.icon}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
<span>
|
|
99
|
+
<span className={`block font-heading ${two ? "text-[0.9rem]" : "text-[0.95rem]"} leading-snug font-semibold text-ink`}>
|
|
100
|
+
{c.label}
|
|
101
|
+
</span>
|
|
102
|
+
{c.description && (
|
|
103
|
+
<span className="mt-0.5 block text-sm text-ink-soft">{c.description}</span>
|
|
104
|
+
)}
|
|
105
|
+
</span>
|
|
106
|
+
</L>
|
|
107
|
+
);
|
|
75
108
|
return (
|
|
76
109
|
<div
|
|
77
110
|
className="relative"
|
|
78
|
-
onMouseEnter={() =>
|
|
79
|
-
|
|
111
|
+
onMouseEnter={() => {
|
|
112
|
+
hovering.current = true;
|
|
113
|
+
setOpen(true);
|
|
114
|
+
}}
|
|
115
|
+
onMouseLeave={() => {
|
|
116
|
+
hovering.current = false;
|
|
117
|
+
setOpen(false);
|
|
118
|
+
}}
|
|
80
119
|
onBlur={(e) => {
|
|
81
120
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false);
|
|
82
121
|
}}
|
|
@@ -94,46 +133,35 @@ function Dropdown({ item, L, t }: { item: NavItem; L: ElementType; t: Labels })
|
|
|
94
133
|
type="button"
|
|
95
134
|
aria-expanded={open}
|
|
96
135
|
aria-label={t.openSubmenu}
|
|
97
|
-
onClick={() => setOpen((o) => !o)}
|
|
136
|
+
onClick={() => setOpen((o) => (hovering.current ? true : !o))}
|
|
98
137
|
className="flex min-h-11 items-center rounded-md px-0.5 text-ink/70 transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
|
|
99
138
|
>
|
|
100
139
|
<Chevron open={open} />
|
|
101
140
|
</button>
|
|
102
141
|
</span>
|
|
103
142
|
|
|
104
|
-
<div className={`absolute top-full left-0 z-50 pt-2 ${open ? "" : "pointer-events-none"}`}>
|
|
143
|
+
<div className={`absolute top-full ${item.align === "end" ? "right-0" : "left-0"} z-50 pt-2 ${open ? "" : "pointer-events-none"}`}>
|
|
105
144
|
<ul
|
|
106
|
-
className={
|
|
145
|
+
className={`${two ? (item.groups ? "w-[34rem] columns-2 gap-x-4" : "grid w-[34rem] grid-cols-2 gap-x-1") : "w-[19rem]"} list-none rounded-2xl border border-line bg-surface p-2 shadow-[0_18px_40px_-16px_color-mix(in_srgb,var(--color-shadow)_40%,transparent)] transition duration-150 ease-out motion-reduce:transition-none ${
|
|
107
146
|
open ? "visible scale-100 opacity-100" : "invisible -translate-y-1 scale-[0.98] opacity-0"
|
|
108
147
|
}`}
|
|
109
148
|
>
|
|
110
|
-
{item.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<span>
|
|
125
|
-
<span className="block font-heading text-[0.95rem] leading-snug font-semibold text-ink">
|
|
126
|
-
{c.label}
|
|
127
|
-
</span>
|
|
128
|
-
{c.description && (
|
|
129
|
-
<span className="mt-0.5 block text-sm text-ink-soft">{c.description}</span>
|
|
130
|
-
)}
|
|
131
|
-
</span>
|
|
132
|
-
</L>
|
|
133
|
-
</li>
|
|
134
|
-
))}
|
|
149
|
+
{item.groups
|
|
150
|
+
? item.groups.map((g, gi) => (
|
|
151
|
+
<li key={gi} className="break-inside-avoid pb-5 last:pb-1">
|
|
152
|
+
<p id={`${gid}-${gi}`} className="px-2.5 pt-2 pb-0.5 font-heading text-[0.7rem] font-bold tracking-[0.12em] text-ink-soft uppercase">
|
|
153
|
+
{g.label}
|
|
154
|
+
</p>
|
|
155
|
+
<ul aria-labelledby={`${gid}-${gi}`} className="list-none">
|
|
156
|
+
{g.children.map((c, i) => (
|
|
157
|
+
<li key={i}>{renderChild(c)}</li>
|
|
158
|
+
))}
|
|
159
|
+
</ul>
|
|
160
|
+
</li>
|
|
161
|
+
))
|
|
162
|
+
: item.children!.map((c, i) => <li key={i}>{renderChild(c)}</li>)}
|
|
135
163
|
{item.seeAll && (
|
|
136
|
-
<li className=
|
|
164
|
+
<li className={`mt-1 border-t border-line pt-1 ${two ? "col-span-2 [column-span:all]" : ""}`}>
|
|
137
165
|
<L
|
|
138
166
|
href={item.seeAll.href}
|
|
139
167
|
onClick={() => setOpen(false)}
|
|
@@ -168,7 +196,7 @@ export function Header({ logo, items, linkComponent, cta, localeSwitcher, labels
|
|
|
168
196
|
|
|
169
197
|
<nav aria-label={t.nav} className="hidden items-center gap-6 lg:flex xl:gap-7">
|
|
170
198
|
{items.map((item, i) =>
|
|
171
|
-
item.children?.length ? (
|
|
199
|
+
item.children?.length || item.groups?.length ? (
|
|
172
200
|
<Dropdown key={i} item={item} L={L} t={t} />
|
|
173
201
|
) : (
|
|
174
202
|
<L key={i} href={item.href} aria-current={item.active ? "page" : undefined} className={linkCls(item.active)}>
|
package/components/Heading.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// "p": heading look without being a heading (a pull quote, a display line)
|
|
4
|
+
type Level = "h1" | "h2" | "h3" | "h4" | "p";
|
|
4
5
|
/** The family's heading scale (novo.creeta):
|
|
5
6
|
* display — page title (SectionHeading as h1) · hero — title over a full-bleed photo/video
|
|
6
7
|
* section — section title (h2) · card — title inside a card · small — h3 */
|
|
@@ -13,7 +14,7 @@ export const HEADING_SIZE: Record<HeadingSize, string> = {
|
|
|
13
14
|
card: "text-[1.375rem] leading-snug md:text-2xl",
|
|
14
15
|
small: "text-xl leading-snug",
|
|
15
16
|
};
|
|
16
|
-
const DEFAULT_SIZE: Record<Level, HeadingSize> = { h1: "display", h2: "section", h3: "small", h4: "small" };
|
|
17
|
+
const DEFAULT_SIZE: Record<Level, HeadingSize> = { h1: "display", h2: "section", h3: "small", h4: "small", p: "section" };
|
|
17
18
|
|
|
18
19
|
type HeadingProps = {
|
|
19
20
|
as?: Level;
|
package/components/InfoCard.tsx
CHANGED
|
@@ -40,6 +40,11 @@ const ArrowUpRight = () => (
|
|
|
40
40
|
<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>
|
|
41
41
|
);
|
|
42
42
|
|
|
43
|
+
// internal page link: arrow forward (the ↗ is reserved for leaving the site, as in the other components)
|
|
44
|
+
const ArrowRight = () => (
|
|
45
|
+
<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="M5 12h14M13 6l6 6-6 6" /></svg>
|
|
46
|
+
);
|
|
47
|
+
|
|
43
48
|
const body = "mt-2 leading-relaxed text-ink/80";
|
|
44
49
|
const link =
|
|
45
50
|
"mt-4 inline-flex min-h-11 items-center gap-1.5 font-semibold text-ink underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
|
|
@@ -71,7 +76,7 @@ export function InfoCard({
|
|
|
71
76
|
|
|
72
77
|
return (
|
|
73
78
|
<Tag className={`rounded-2xl bg-card p-6 md:p-8 ${className}`}>
|
|
74
|
-
<span aria-hidden="true" className="flex h-11 w-11 items-center justify-center rounded-full bg-
|
|
79
|
+
<span aria-hidden="true" className="flex h-11 w-11 items-center justify-center rounded-full bg-tint text-ink">
|
|
75
80
|
<Icon size={20} strokeWidth={1.75} />
|
|
76
81
|
</span>
|
|
77
82
|
<Heading className="mt-4 font-heading text-xl font-medium text-ink">{title}</Heading>
|
|
@@ -106,7 +111,7 @@ export function InfoCard({
|
|
|
106
111
|
) : (
|
|
107
112
|
<L href={l.href} className={link}>
|
|
108
113
|
{l.label}
|
|
109
|
-
<
|
|
114
|
+
<ArrowRight />
|
|
110
115
|
</L>
|
|
111
116
|
)}
|
|
112
117
|
</span>
|
package/components/List.tsx
CHANGED
|
@@ -43,7 +43,7 @@ const ArrowUpRight = () => (
|
|
|
43
43
|
export function List({ items, marker = "check", linkComponent, newTabLabel = "(opens in a new tab)", className = "" }: Props) {
|
|
44
44
|
const L: ElementType = linkComponent ?? "a";
|
|
45
45
|
const bubble = (
|
|
46
|
-
<span aria-hidden="true" className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-
|
|
46
|
+
<span aria-hidden="true" className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-tint text-ink">
|
|
47
47
|
<Check />
|
|
48
48
|
</span>
|
|
49
49
|
);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Heading } from "./Heading";
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
title: ReactNode;
|
|
6
|
+
/** Supporting paragraph under the title. */
|
|
7
|
+
lead?: ReactNode;
|
|
8
|
+
/** A smaller line under the lead ("Os artigos estão no idioma original…"). */
|
|
9
|
+
note?: ReactNode;
|
|
10
|
+
/** Above the title: a Breadcrumb. */
|
|
11
|
+
breadcrumb?: ReactNode;
|
|
12
|
+
/** The page's main controls (search, filters), inside the panel: the hero
|
|
13
|
+
* and the tools that act on the page read as one region. */
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
/** The site's graphic device, low in the corner (e.g. the brand symbol at
|
|
16
|
+
* 6% in `ink`). Decorative: rendered aria-hidden. */
|
|
17
|
+
watermark?: ReactNode;
|
|
18
|
+
/** `alt` (default): the section band colour (`surface-alt`), light, ink
|
|
19
|
+
* text at full contrast (8.85:1 on ttcab). `card`: white. */
|
|
20
|
+
tone?: "alt" | "card";
|
|
21
|
+
/** `panel` (default): the rounded band with the tools inside. `centered`: the
|
|
22
|
+
* Apple search page — no box, title and lead centred, one search below on the
|
|
23
|
+
* page colour; refining filters live outside, after the results begin. */
|
|
24
|
+
variant?: "panel" | "centered";
|
|
25
|
+
className?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* PageHero — the opening panel of an inner page (a library, a directory).
|
|
30
|
+
* Wide, in novo.creeta's proportion (up to 1600px, 16/24px from the screen
|
|
31
|
+
* edge), rounded 28/32px, with the content aligned to the site's column
|
|
32
|
+
* (max-w-6xl) so the title lines up with the list below.
|
|
33
|
+
*
|
|
34
|
+
* Light on purpose: the white search field and chips stand out on the band,
|
|
35
|
+
* and the panel groups title, explanation and controls (common region)
|
|
36
|
+
* without the weight of a dark panel.
|
|
37
|
+
*/
|
|
38
|
+
export function PageHero({ title, lead, note, breadcrumb, children, watermark, tone = "alt", variant = "panel", className = "" }: Props) {
|
|
39
|
+
if (variant === "centered") {
|
|
40
|
+
return (
|
|
41
|
+
<section className={`px-4 pt-[clamp(40px,7vw,96px)] pb-[clamp(28px,4vw,48px)] text-center md:px-6 ${className}`}>
|
|
42
|
+
<div className="mx-auto w-full max-w-3xl">
|
|
43
|
+
{breadcrumb && <div className="mb-6 flex justify-center">{breadcrumb}</div>}
|
|
44
|
+
<Heading as="h1" size="display" className="text-balance">
|
|
45
|
+
{title}
|
|
46
|
+
</Heading>
|
|
47
|
+
{lead && <p className="mx-auto m-0 mt-4 max-w-xl text-lg leading-relaxed text-balance text-ink-body">{lead}</p>}
|
|
48
|
+
{note && <p className="mx-auto m-0 mt-3 max-w-xl text-sm leading-relaxed text-ink-soft">{note}</p>}
|
|
49
|
+
{children && <div className="mx-auto mt-[clamp(24px,4vw,40px)] max-w-2xl text-left">{children}</div>}
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return (
|
|
55
|
+
<div className={`px-4 pt-2 md:px-6 md:pt-4 ${className}`}>
|
|
56
|
+
<section className={`relative isolate mx-auto max-w-[1600px] overflow-hidden rounded-[28px] md:rounded-[32px] ${tone === "card" ? "bg-card" : "bg-surface-alt"}`}>
|
|
57
|
+
{watermark && (
|
|
58
|
+
<span aria-hidden="true" className="pointer-events-none absolute -right-10 -bottom-16 -z-10 hidden w-[min(34vw,420px)] md:block">
|
|
59
|
+
{watermark}
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
<div className="mx-auto w-full max-w-6xl px-5 py-[clamp(36px,6vw,72px)] sm:px-6 lg:px-8">
|
|
63
|
+
{breadcrumb && <div className="mb-6">{breadcrumb}</div>}
|
|
64
|
+
<Heading as="h1" size="display" className="text-balance">
|
|
65
|
+
{title}
|
|
66
|
+
</Heading>
|
|
67
|
+
{lead && <p className="m-0 mt-4 max-w-xl text-lg leading-relaxed text-ink-body">{lead}</p>}
|
|
68
|
+
{note && <p className="m-0 mt-3 max-w-xl text-sm leading-relaxed text-ink-soft">{note}</p>}
|
|
69
|
+
{children && <div className="mt-[clamp(24px,4vw,40px)]">{children}</div>}
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -67,6 +67,9 @@ type Props = {
|
|
|
67
67
|
/** Labels of the background pause button (player mode, muted preview). */
|
|
68
68
|
previewLabels?: { pause?: string; play?: string };
|
|
69
69
|
videoTitle?: string;
|
|
70
|
+
/** The page's AnchorNav comes right after the hero: the hero gives up the
|
|
71
|
+
* capsule's height so both fit in the first fold. */
|
|
72
|
+
withAnchorNav?: boolean;
|
|
70
73
|
};
|
|
71
74
|
|
|
72
75
|
function PlayGlyph({ size = 20 }: { size?: number }) {
|
|
@@ -175,8 +178,14 @@ export function PageVideoHero({
|
|
|
175
178
|
closeLabel = "Close video",
|
|
176
179
|
previewLabels,
|
|
177
180
|
videoTitle,
|
|
181
|
+
withAnchorNav = false,
|
|
178
182
|
}: Props) {
|
|
179
183
|
const isPlayer = video.mode === "player";
|
|
184
|
+
// something sits in the bottom-right corner: the panel leaves room for it (md+)
|
|
185
|
+
const corner = actions === null ? false : actions ? true : isPlayer;
|
|
186
|
+
// phones: with a panel, the default watch button moves to the top-left corner
|
|
187
|
+
// and the panel keeps room for it (pt-24), so the two never overlap
|
|
188
|
+
const watchOnTop = Boolean(panel) && !topLeft && actions === undefined && isPlayer;
|
|
180
189
|
const [playing, setPlaying] = useState(false);
|
|
181
190
|
const watchRef = useRef<HTMLButtonElement>(null);
|
|
182
191
|
const playedOnce = useRef(false);
|
|
@@ -191,7 +200,7 @@ export function PageVideoHero({
|
|
|
191
200
|
|
|
192
201
|
return (
|
|
193
202
|
<div className="px-4 pt-2 md:px-6 md:pt-6">
|
|
194
|
-
<section className=
|
|
203
|
+
<section className={`relative isolate mx-auto flex max-w-[1600px] flex-col justify-end overflow-hidden rounded-[32px] bg-ink text-surface ${withAnchorNav ? "min-h-[min(calc(100svh-13.5rem),560px)] lg:min-h-[min(calc(100svh-13.5rem),820px)]" : "min-h-[min(calc(100svh-7.5rem),560px)] lg:min-h-[min(calc(100svh-7.5rem),820px)]"}`}>
|
|
195
204
|
{/* media — background (preview) or, when playing, the full video with sound */}
|
|
196
205
|
<div className={`absolute inset-x-0 top-0 ${playing ? "bottom-[4.5rem]" : "bottom-0 -z-10"}`}>
|
|
197
206
|
{playing ? (
|
|
@@ -267,9 +276,9 @@ export function PageVideoHero({
|
|
|
267
276
|
{topLeft && <div className="absolute top-4 left-4 z-10">{topLeft}</div>}
|
|
268
277
|
|
|
269
278
|
{/* panel / caption — in flow (justify-end), m-6 */}
|
|
270
|
-
<div className=
|
|
279
|
+
<div className={`relative z-10 p-6 md:m-6 md:p-0 ${watchOnTop ? "pt-24" : ""}`}>
|
|
271
280
|
{panel ? (
|
|
272
|
-
<div className={`inline-flex max-w-full items-center rounded-[24px] bg-ink/75 px-6 py-4 backdrop-blur-md lg:px-7 lg:py-5 ${
|
|
281
|
+
<div className={`inline-flex max-w-full items-center rounded-[24px] bg-ink/75 px-6 py-4 backdrop-blur-md lg:px-7 lg:py-5 ${corner ? "md:max-w-[calc(100%-16rem)]" : ""}`}>
|
|
273
282
|
<div>
|
|
274
283
|
{panel.kicker && (
|
|
275
284
|
<p className="mb-3 font-heading text-[0.82rem] font-bold tracking-[0.1em] text-surface/80 uppercase">{panel.kicker}</p>
|
|
@@ -286,7 +295,8 @@ export function PageVideoHero({
|
|
|
286
295
|
) : null}
|
|
287
296
|
</div>
|
|
288
297
|
|
|
289
|
-
{/* bottom-right action
|
|
298
|
+
{/* bottom-right action (on phones, with a panel, the watch button goes to the
|
|
299
|
+
free top-left corner so it never covers the panel's buttons) */}
|
|
290
300
|
{actions === null ? null : actions ? (
|
|
291
301
|
<div className="absolute right-4 bottom-4 z-10 flex flex-wrap items-center justify-end gap-2 md:right-6 md:bottom-6">{actions}</div>
|
|
292
302
|
) : isPlayer ? (
|
|
@@ -294,7 +304,7 @@ export function PageVideoHero({
|
|
|
294
304
|
ref={watchRef}
|
|
295
305
|
type="button"
|
|
296
306
|
onClick={() => setPlaying(true)}
|
|
297
|
-
className=
|
|
307
|
+
className={`absolute z-10 flex items-center gap-3 rounded-full border border-surface/15 bg-ink/80 py-2 pr-7 pl-2 font-body font-semibold text-surface backdrop-blur-md transition-colors duration-200 hover:bg-ink/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface motion-reduce:transition-none md:top-auto md:left-auto md:right-6 md:bottom-6 ${watchOnTop ? "top-4 left-4" : "right-4 bottom-4"}`}
|
|
298
308
|
>
|
|
299
309
|
<span className="relative flex h-12 w-12 items-center justify-center rounded-full bg-surface text-ink">
|
|
300
310
|
<PlayGlyph size={20} />
|
|
@@ -40,7 +40,7 @@ export function PersonChip({ name, description, photo, avatar, href, linkCompone
|
|
|
40
40
|
className={`${box} pr-1.5 transition-transform duration-150 ease-out active:scale-[0.98] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none motion-reduce:active:scale-100 ${className}`}
|
|
41
41
|
>
|
|
42
42
|
{inner}
|
|
43
|
-
<span aria-hidden="true" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-
|
|
43
|
+
<span aria-hidden="true" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-tint text-ink">
|
|
44
44
|
<svg viewBox="0 0 24 24" width={15} height={15} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
45
45
|
</span>
|
|
46
46
|
</L>
|
package/components/Pill.tsx
CHANGED
|
@@ -29,7 +29,7 @@ export function Pill({ href, children, meta, linkComponent, className = "" }: Pr
|
|
|
29
29
|
<span
|
|
30
30
|
aria-hidden="true"
|
|
31
31
|
data-arrow={onPage ? "down" : "other-page"}
|
|
32
|
-
className="ml-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-
|
|
32
|
+
className="ml-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-tint transition-colors duration-200 group-hover:bg-surface/15 motion-reduce:transition-none"
|
|
33
33
|
>
|
|
34
34
|
{onPage ? (
|
|
35
35
|
<svg viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-down" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M19 12l-7 7-7-7" /></svg>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
|
|
4
|
-
import { initials } from "./Avatar";
|
|
4
|
+
import { Avatar, initials } from "./Avatar";
|
|
5
5
|
import { ProfessionalModal, type ProfessionalModalLabels, type ProfessionalPresentation } from "./ProfessionalModal";
|
|
6
6
|
import { CollapseRow, LinkRow, RowList, type RowLink } from "./Rows";
|
|
7
7
|
|
|
@@ -56,6 +56,19 @@ export type ProfessionalCardRow =
|
|
|
56
56
|
icon?: ReactNode;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
/** One contact line of the `listing` card: a LinkRow with the lucide glyph on
|
|
60
|
+
* the right (phone, mail, WhatsApp). The label is the visible number/address;
|
|
61
|
+
* `srLabel` says who it reaches ("Ligar para Ana Souza"). */
|
|
62
|
+
export type ProfessionalContact = {
|
|
63
|
+
label: ReactNode;
|
|
64
|
+
href: string;
|
|
65
|
+
icon?: ReactNode;
|
|
66
|
+
srLabel?: ReactNode;
|
|
67
|
+
external?: boolean;
|
|
68
|
+
/** Long unbroken text (an e-mail) may wrap anywhere, only when it does not fit. */
|
|
69
|
+
breakAll?: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
59
72
|
export type ProfessionalCardLabels = ProfessionalModalLabels & {
|
|
60
73
|
/** alt of the portrait. */
|
|
61
74
|
portrait: (name: string) => string;
|
|
@@ -263,7 +276,12 @@ type Props = {
|
|
|
263
276
|
* (living portrait and "Meet" badge on hover). `micro`: the "who treats"
|
|
264
277
|
* row — 72px photo, name and role, also a link. The small sizes render the
|
|
265
278
|
* link as the root; wrap them in your <li> (RailItem). */
|
|
266
|
-
size?: "full" | "medium" | "micro";
|
|
279
|
+
size?: "full" | "medium" | "micro" | "listing";
|
|
280
|
+
/** `listing` only: the contact rows (phone, e-mail). */
|
|
281
|
+
contacts?: ProfessionalContact[];
|
|
282
|
+
/** `listing` only: the name as rendered, e.g. with the search match marked.
|
|
283
|
+
* `name` stays the plain text (initials, alt). */
|
|
284
|
+
nameDisplay?: ReactNode;
|
|
267
285
|
/** Where the small sizes lead: the full card on the team page ("/team/#slug"). */
|
|
268
286
|
href?: string;
|
|
269
287
|
/** Name shown on the medium card (the site uses the first two words). */
|
|
@@ -304,6 +322,8 @@ export function ProfessionalCard({
|
|
|
304
322
|
rows,
|
|
305
323
|
id,
|
|
306
324
|
size = "full",
|
|
325
|
+
contacts,
|
|
326
|
+
nameDisplay,
|
|
307
327
|
href = "#",
|
|
308
328
|
shortName,
|
|
309
329
|
headingLevel: Heading = "h2",
|
|
@@ -317,6 +337,41 @@ export function ProfessionalCard({
|
|
|
317
337
|
const alt = photo?.alt ?? t.portrait(name);
|
|
318
338
|
const hasPortrait = Boolean(photo || media || living);
|
|
319
339
|
|
|
340
|
+
if (size === "listing") {
|
|
341
|
+
// the directory card (ttcab "Encontre um profissional"): many people, no
|
|
342
|
+
// photos. The family's solid white card, the initials in a tint bubble,
|
|
343
|
+
// name, place (in `role`) and the registration, then the contacts as
|
|
344
|
+
// 48px rows. The card itself is not a link: the rows are the actions.
|
|
345
|
+
return (
|
|
346
|
+
<As id={id} data-cg-professional-card="" className={`flex h-full flex-col rounded-[22px] bg-card px-5 pt-5 pb-1.5 ${className}`}>
|
|
347
|
+
<div className="flex items-start gap-4">
|
|
348
|
+
{photo ? <Avatar name={name} photo={photo.src} size={48} /> : <Avatar name={name} size={48} />}
|
|
349
|
+
<div className="min-w-0 pt-0.5">
|
|
350
|
+
<Heading className="font-heading text-lg leading-snug font-medium text-pretty text-ink">{nameDisplay ?? name}</Heading>
|
|
351
|
+
{role && <p className="mt-1 text-sm text-ink-soft">{role}</p>}
|
|
352
|
+
{registration && <p className="mt-1 text-xs text-ink/80">{registration}</p>}
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
{contacts && contacts.length > 0 && (
|
|
356
|
+
<div className="mt-auto pt-5">
|
|
357
|
+
<RowList>
|
|
358
|
+
{contacts.map((c, i) => (
|
|
359
|
+
<LinkRow
|
|
360
|
+
key={i}
|
|
361
|
+
label={<span className={c.breakAll ? "[overflow-wrap:anywhere]" : undefined}>{c.label}</span>}
|
|
362
|
+
srLabel={c.srLabel}
|
|
363
|
+
href={c.href}
|
|
364
|
+
icon={c.icon}
|
|
365
|
+
external={c.external}
|
|
366
|
+
/>
|
|
367
|
+
))}
|
|
368
|
+
</RowList>
|
|
369
|
+
</div>
|
|
370
|
+
)}
|
|
371
|
+
</As>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
320
375
|
if (size === "micro") {
|
|
321
376
|
// the "who treats" row of a treatment page (creeta telas/tratamento.tsx):
|
|
322
377
|
// the text beside says who it is, so the photo is decoration
|
|
@@ -331,7 +386,7 @@ export function ProfessionalCard({
|
|
|
331
386
|
) : media ? (
|
|
332
387
|
<span className="block h-[72px] w-[72px] shrink-0 overflow-hidden rounded-2xl">{media}</span>
|
|
333
388
|
) : (
|
|
334
|
-
<span aria-hidden="true" className="flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-2xl bg-
|
|
389
|
+
<span aria-hidden="true" className="flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-2xl bg-tint font-heading text-xl font-medium text-ink">
|
|
335
390
|
{initials(name)}
|
|
336
391
|
</span>
|
|
337
392
|
)}
|
|
@@ -359,7 +414,7 @@ export function ProfessionalCard({
|
|
|
359
414
|
{hasPortrait ? (
|
|
360
415
|
<Portrait name={name} photo={photo ? { ...photo, sizes: photo.sizes ?? "(min-width: 768px) 16vw, 42vw" } : undefined} media={media} living={living} alt={alt} decorative className={ZOOM_MEDIUM} />
|
|
361
416
|
) : (
|
|
362
|
-
<span aria-hidden="true" className="flex aspect-[3/4] w-full items-center justify-center bg-
|
|
417
|
+
<span aria-hidden="true" className="flex aspect-[3/4] w-full items-center justify-center bg-tint font-heading text-3xl font-medium text-ink">
|
|
363
418
|
{initials(name)}
|
|
364
419
|
</span>
|
|
365
420
|
)}
|
|
@@ -401,7 +456,7 @@ export function ProfessionalCard({
|
|
|
401
456
|
<div
|
|
402
457
|
data-testid="initials"
|
|
403
458
|
aria-hidden="true"
|
|
404
|
-
className="flex aspect-[3/4] w-full items-center justify-center rounded-[20px] bg-
|
|
459
|
+
className="flex aspect-[3/4] w-full items-center justify-center rounded-[20px] bg-tint"
|
|
405
460
|
>
|
|
406
461
|
<span className="font-heading text-4xl font-bold text-ink">{initials(name)}</span>
|
|
407
462
|
</div>
|