@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.
Files changed (45) hide show
  1. package/README.md +29 -2
  2. package/components/Accordion.tsx +6 -3
  3. package/components/AnchorNav.tsx +10 -5
  4. package/components/Avatar.tsx +1 -1
  5. package/components/BottomNav.tsx +12 -5
  6. package/components/Button.tsx +5 -2
  7. package/components/Byline.tsx +25 -1
  8. package/components/Card.tsx +11 -3
  9. package/components/ContactRow.tsx +112 -0
  10. package/components/CourseCard.tsx +3 -3
  11. package/components/CtaCard.tsx +3 -1
  12. package/components/DateBadge.tsx +1 -1
  13. package/components/EmptyState.tsx +54 -0
  14. package/components/EventFacts.tsx +1 -1
  15. package/components/EventRow.tsx +3 -3
  16. package/components/FactStrip.tsx +1 -1
  17. package/components/FilterBar.tsx +50 -0
  18. package/components/FilterChips.tsx +63 -0
  19. package/components/FilterPill.tsx +35 -0
  20. package/components/Footer.tsx +1 -1
  21. package/components/Header.tsx +61 -33
  22. package/components/Heading.tsx +3 -2
  23. package/components/InfoCard.tsx +7 -2
  24. package/components/List.tsx +1 -1
  25. package/components/PageHero.tsx +74 -0
  26. package/components/PageVideoHero.tsx +15 -5
  27. package/components/PersonChip.tsx +1 -1
  28. package/components/Pill.tsx +1 -1
  29. package/components/ProfessionalCard.tsx +60 -5
  30. package/components/ProfileSpotlight.tsx +94 -0
  31. package/components/PublicationCard.tsx +215 -0
  32. package/components/PullQuote.tsx +87 -0
  33. package/components/RegistrationBox.tsx +1 -1
  34. package/components/SearchField.tsx +85 -0
  35. package/components/SelectField.tsx +75 -0
  36. package/components/SidePanel.tsx +6 -3
  37. package/components/StickyGroupHeading.tsx +38 -0
  38. package/components/TestimonialCard.tsx +179 -23
  39. package/components/Text.tsx +1 -1
  40. package/components/Timeline.tsx +1 -1
  41. package/components/VideoInviteCard.tsx +20 -6
  42. package/package.json +14 -2
  43. package/theme.css +50 -0
  44. package/tokens/creeta.css +1 -0
  45. 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
+ }
@@ -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-ink/[0.08] 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";
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
@@ -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={() => setOpen(true)}
79
- onMouseLeave={() => setOpen(false)}
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={`w-[19rem] list-none rounded-2xl border border-line bg-surface p-2 shadow-[0_18px_40px_-16px_rgba(20,71,83,0.4)] transition duration-150 ease-out motion-reduce:transition-none ${
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.children!.map((c, i) => (
111
- <li key={i}>
112
- <L
113
- href={c.href}
114
- onClick={() => setOpen(false)}
115
- // with a description the icon sits with the label's first line (creeta);
116
- // label only: centred on the bubble, and a two-line label stays tight
117
- className={`flex ${c.description ? "items-start" : "items-center"} gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-ink/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink`}
118
- >
119
- {c.icon && (
120
- <span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-ink/[0.08] text-ink">
121
- {c.icon}
122
- </span>
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="mt-1 border-t border-line pt-1">
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)}>
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
 
3
- type Level = "h1" | "h2" | "h3" | "h4";
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;
@@ -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-ink/[0.12] text-ink">
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
- <ArrowUpRight />
114
+ <ArrowRight />
110
115
  </L>
111
116
  )}
112
117
  </span>
@@ -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-ink/[0.12] text-ink">
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="relative isolate mx-auto flex min-h-[min(calc(100svh-7.5rem),560px)] max-w-[1600px] flex-col justify-end overflow-hidden rounded-[32px] bg-ink text-surface lg:min-h-[min(calc(100svh-7.5rem),820px)]">
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="relative z-10 p-6 md:m-6 md:p-0">
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 ${panel.actions && actions === undefined ? "" : "md:max-w-[calc(100%-16rem)]"}`}>
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="absolute right-4 bottom-4 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:right-6 md:bottom-6"
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-ink/[0.12] text-ink">
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>
@@ -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-ink/[0.12] transition-colors duration-200 group-hover:bg-surface/15 motion-reduce:transition-none"
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-ink/[0.12] font-heading text-xl font-medium text-ink">
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-ink/[0.12] font-heading text-3xl font-medium text-ink">
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-ink/[0.12]"
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>