@breathe-ds/ui 0.5.1 → 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 +33 -5
- 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 +20 -8
- 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 +4 -2
- package/components/PageHero.tsx +74 -0
- package/components/PageVideoHero.tsx +21 -6
- 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
package/README.md
CHANGED
|
@@ -192,21 +192,48 @@ from color, not from extra markup.
|
|
|
192
192
|
|
|
193
193
|
## Navigation
|
|
194
194
|
|
|
195
|
+
- **ProfileSpotlight**: one person in focus (founder, course coordinator, guest) —
|
|
196
|
+
portrait on the left (photo or `living`, the team pages' living portrait), then
|
|
197
|
+
eyebrow, name, role, registration, bio and `actions`. `variant` `plain` (on the page
|
|
198
|
+
color) or `card` (white card). Stacks on phones.
|
|
199
|
+
- **Texture & rhythm (opt-in)**: `.cg-grain` adds a barely-there paper grain (noise in
|
|
200
|
+
the ink color) to any background — the body, a band. `bg-surface-alt` (token
|
|
201
|
+
`--cg-surface-alt`, falls back to `surface`) is a band one step deeper than the page,
|
|
202
|
+
for alternating sections; TTCAB `#f6eedd` (ink-soft 4.58:1), Creeta `#e4ecee`.
|
|
203
|
+
Neutral fills and shadows are tokens too: `bg-tint` (`--cg-tint`, icon bubbles, pills,
|
|
204
|
+
placeholders; default ink at 8%) and `--cg-shadow` (the hue of soft shadows). TTCAB
|
|
205
|
+
derives them from the logo's orange (warm neutrals, never grey).
|
|
206
|
+
`.cg-dots` (dot grid, 16px) or `.cg-dots-edges` (only on the side gutters, fading
|
|
207
|
+
out before the content column — the Cloudflare touch) layer dots over a background.
|
|
208
|
+
- **Search page, Apple-style**: `PageHero variant="centered"` (no box, title and one search
|
|
209
|
+
centred) + **FilterBar** (thin sticky bar of compact filters — `SelectField density="sm"` —
|
|
210
|
+
with the count and "clear" on the right; it publishes its height as `--cg-filterbar-h`, so
|
|
211
|
+
`StickyGroupHeading stickyTop="top-[calc(80px+var(--cg-filterbar-h,0px))]"` sticks right below).
|
|
212
|
+
- **PullQuote** + **Highlight**: one line set large between sections — drawn quote
|
|
213
|
+
mark in the action color, balanced lines, hand-drawn underline (`Highlight`) on the
|
|
214
|
+
key words. `cite` adds the person (Byline with role); without it, a brand statement.
|
|
195
215
|
- **Header**: brand (the app passes the link to the home page) · items · item with dropdown
|
|
196
216
|
(icons on sub-items; final link with a → arrow, or ↗ if `external`) · language · CTA.
|
|
197
217
|
Site convention: "Home" is just the house (`icon` + `iconOnly`), with a label for
|
|
198
218
|
screen readers.
|
|
219
|
+
Long dropdowns: `columns: 2` (compact rows, max 2 columns) and `align: "end"` to keep
|
|
220
|
+
the wider menu inside the viewport; `groups` (in place of `children`) adds labelled
|
|
221
|
+
groups, e.g. `[{ label: "Navigation", children: [...] }]`.
|
|
199
222
|
- **BottomNav**: on phone/tablet, navigation leaves the header and becomes the
|
|
200
223
|
floating bottom bar. It is the code of novo.creeta's BarraNavegacao (= ttcab's
|
|
201
224
|
BottomNav), made neutral through tokens: it receives ALL pages in order of importance
|
|
202
225
|
(`pages`, with the lucide `Icon`) and shows as many as fit (64px per tab,
|
|
203
|
-
ResizeObserver)
|
|
226
|
+
ResizeObserver), **at most 5 tabs counting "More"** (4 pages + More, as the iOS and
|
|
227
|
+
Android tab bars; family rule of 2026-10-30: more than that cuts the labels and
|
|
228
|
+
shrinks the tap targets); the rest and the external exits (`extras`, with the domain spelled out
|
|
204
229
|
and a ↗ arrow) go to the "More" panel, a native popover (Esc, tap outside, no
|
|
205
230
|
JS). Gradient veil behind the bar. `hideFrom` = `md` (creeta) or `lg`
|
|
206
231
|
(ttcab). `labels` for another language. `embedded` for the demo frame.
|
|
207
232
|
- **AnchorNav**: floating capsule of page anchors (scroll-spy + sliding
|
|
208
233
|
indicator), extracted from ttcab's TreatmentAnchorNav. Target sections need
|
|
209
|
-
a `scroll-mt-*` compatible with the sticky header.
|
|
234
|
+
a `scroll-mt-*` compatible with the sticky header. White capsule (`card`);
|
|
235
|
+
`tone="surface"` (default: active pill in the page color with a hairline) or
|
|
236
|
+
`tone="accent"` (action tint, active label in action-text — more emphasis).
|
|
210
237
|
- **LanguageSwitcher**: the SeletorDeIdioma of novo.creeta — `menu` (button with
|
|
211
238
|
the code, opens the three languages; header) or `list` (plain links; the Footer
|
|
212
239
|
uses it). Same page in the other language, each language written in itself,
|
|
@@ -304,8 +331,9 @@ from color, not from extra markup.
|
|
|
304
331
|
keep working) to open its details. By tone: no border, no shadow, no lift.
|
|
305
332
|
- **Events** (after Luma and Meetup, for the school's classes): **EventList**
|
|
306
333
|
(days down a dotted rail, each with its rows), **EventRow** (time with an
|
|
307
|
-
accented end, title as the stretched link,
|
|
308
|
-
|
|
334
|
+
accented end, title as the stretched link, or a button with `onSelect` to
|
|
335
|
+
open the details in a SidePanel, organizers, location, who is going with
|
|
336
|
+
"+N", square cover, an optional badge), **EventFacts** (date on a
|
|
309
337
|
calendar tile, venue on a pin tile with its link), **DateBadge** and
|
|
310
338
|
**RegistrationBox** (a strip title, ticket options as a radio group with the
|
|
311
339
|
remaining pill and the price, the one button at the foot).
|
|
@@ -345,7 +373,7 @@ from color, not from extra markup.
|
|
|
345
373
|
**Data rule:** every professional MUST have a **CREFITO** attached (COFFITO requirement).
|
|
346
374
|
- [x] **Pill / PillList** — the Atalho of novo.creeta (white 44px pill, arrow bubble: ↓ anchor, ↗ other page, optional meta); wrapping row + "See all"
|
|
347
375
|
- [x] **AnchorNav** (floating capsule of anchors, scroll-spy + sliding indicator) — extracted from ttcab (10/29)
|
|
348
|
-
- [x] **Footer** · **PageVideoHero** (loop, and player from a YouTube id or the site's own mp4) · **Header** (dropdown, house) · **BottomNav** · **Curtain** · **Lockup** (5 arrangements) · **BreatheMark**
|
|
376
|
+
- [x] **Footer** · **PageVideoHero** (loop, and player from a YouTube id or the site's own mp4; `panel.actions` puts two or more calls to action inside the panel) · **Header** (dropdown, house) · **BottomNav** · **Curtain** · **Lockup** (5 arrangements) · **BreatheMark**
|
|
349
377
|
- [x] **Site brand marks** (`components/marks/`): real symbol + wordmark for TTCAB and Creeta (10/29)
|
|
350
378
|
- [ ] **ThreadRail** (side graphic device — a thread running down the left linking sections) — approved,
|
|
351
379
|
**already exists in ttcab** → extract.
|
package/components/Accordion.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useId, useState, type ReactNode } from "react";
|
|
3
|
+
import { version as REACT_VERSION, useId, useState, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
const INERT_ON = parseInt(REACT_VERSION, 10) >= 19 ? true : "";
|
|
4
6
|
|
|
5
7
|
export type AccordionItem = {
|
|
6
8
|
id: string;
|
|
@@ -85,8 +87,9 @@ export function Accordion({ items, defaultOpenId, single = true }: Props) {
|
|
|
85
87
|
id={panelId}
|
|
86
88
|
role="region"
|
|
87
89
|
aria-labelledby={btnId}
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
+
// closed panel out of the tab order: React 19 takes `inert` as a boolean (an empty
|
|
91
|
+
// string there means false); React 18 does not know it and needs the empty string
|
|
92
|
+
{...({ inert: isOpen ? undefined : INERT_ON } as Record<string, unknown>)}
|
|
90
93
|
className={`grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
|
|
91
94
|
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
|
92
95
|
}`}
|
package/components/AnchorNav.tsx
CHANGED
|
@@ -21,6 +21,10 @@ type Props = {
|
|
|
21
21
|
/** The app's link component (e.g. next-intl Link). Default: <a>. */
|
|
22
22
|
linkComponent?: ElementType;
|
|
23
23
|
className?: string;
|
|
24
|
+
/** Tone of the active and hover pill. `surface` (default): the page color on the
|
|
25
|
+
* white capsule, with a hairline. `accent`: the light action tint, the active
|
|
26
|
+
* label in action-text (more emphasis). */
|
|
27
|
+
tone?: "surface" | "accent";
|
|
24
28
|
/** Strings (English defaults); the app passes the ones for its locale. */
|
|
25
29
|
labels?: Partial<Labels>;
|
|
26
30
|
};
|
|
@@ -39,7 +43,8 @@ type Props = {
|
|
|
39
43
|
* `scroll-mt-*` that, added to the theme's `scroll-padding-top` (96px), clears
|
|
40
44
|
* the header plus the capsule (e.g. `scroll-mt-20` with an 80px header).
|
|
41
45
|
*/
|
|
42
|
-
export function AnchorNav({ items, stickyTop = "top-20", linkComponent, className = "", labels }: Props) {
|
|
46
|
+
export function AnchorNav({ items, stickyTop = "top-20", linkComponent, className = "", tone = "surface", labels }: Props) {
|
|
47
|
+
const accent = tone === "accent";
|
|
43
48
|
const L: ElementType = linkComponent ?? "a";
|
|
44
49
|
const t = { ...EN, ...labels };
|
|
45
50
|
const [active, setActive] = useState(items[0]?.id ?? "");
|
|
@@ -121,12 +126,12 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
|
|
|
121
126
|
<div className="flex justify-center">
|
|
122
127
|
<ul
|
|
123
128
|
ref={listRef}
|
|
124
|
-
className="relative flex max-w-full list-none gap-1 overflow-x-auto rounded-pill border border-line bg-
|
|
129
|
+
className="relative flex max-w-full list-none gap-1 overflow-x-auto rounded-pill border border-line bg-card p-1.5 shadow-[0_10px_28px_-14px_color-mix(in_srgb,var(--color-shadow)_35%,transparent)] backdrop-blur [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
125
130
|
>
|
|
126
131
|
{/* Sliding indicator */}
|
|
127
132
|
<span
|
|
128
133
|
aria-hidden="true"
|
|
129
|
-
className=
|
|
134
|
+
className={`pointer-events-none absolute top-0 left-0 rounded-pill ${accent ? "bg-action-tint" : "bg-surface shadow-[inset_0_0_0_1px_var(--color-line)]"} transition-[transform,width,height,opacity] duration-[320ms] ease-cg motion-reduce:transition-none`}
|
|
130
135
|
style={{ width: pill.w, height: pill.h, opacity: pill.ready ? 1 : 0, transform: `translate(${pill.x}px, ${pill.y}px)` }}
|
|
131
136
|
/>
|
|
132
137
|
{items.map((n) => {
|
|
@@ -140,8 +145,8 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
|
|
|
140
145
|
href={`#${n.id}`}
|
|
141
146
|
aria-current={isActive ? "true" : undefined}
|
|
142
147
|
onClick={() => setActive(n.id)}
|
|
143
|
-
className={`relative z-10 inline-block whitespace-nowrap rounded-pill border border-transparent px-3.5 py-2 font-heading text-[0.92rem] font-semibold text-ink transition-[background-color,border-color] duration-200 ease-cg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none ${
|
|
144
|
-
isActive ? "" : "hover:
|
|
148
|
+
className={`relative z-10 inline-block whitespace-nowrap rounded-pill border border-transparent px-3.5 py-2 font-heading text-[0.92rem] font-semibold ${isActive && accent ? "text-action-text" : "text-ink"} transition-[background-color,border-color,color] duration-200 ease-cg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none ${
|
|
149
|
+
isActive ? "" : accent ? "hover:bg-action-tint/60" : "hover:bg-surface"
|
|
145
150
|
}`}
|
|
146
151
|
>
|
|
147
152
|
{n.label}
|
package/components/Avatar.tsx
CHANGED
|
@@ -30,7 +30,7 @@ export function Avatar({ name, photo, children, size = 40, ring = false, classNa
|
|
|
30
30
|
if (children) return <span aria-hidden="true" className={base} style={style}>{children}</span>;
|
|
31
31
|
if (photo) return <img src={photo} alt="" loading="lazy" decoding="async" className={`${base} object-cover`} style={style} />;
|
|
32
32
|
return (
|
|
33
|
-
<span aria-hidden="true" className={`${base} bg-
|
|
33
|
+
<span aria-hidden="true" className={`${base} bg-tint font-heading font-bold text-ink`} style={style}>
|
|
34
34
|
{initials(name)}
|
|
35
35
|
</span>
|
|
36
36
|
);
|
package/components/BottomNav.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useId, useRef, useState, type ComponentType, type ElementType, type ReactNode } from "react";
|
|
3
|
+
import { version as REACT_VERSION, useEffect, useId, useRef, useState, type ComponentType, type ElementType, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
// the popover trigger attribute: camelCase in React 19+, lowercase passthrough in 18
|
|
6
|
+
const POPOVER_TARGET = parseInt(REACT_VERSION, 10) >= 19 ? "popoverTarget" : "popovertarget";
|
|
4
7
|
|
|
5
8
|
/** Icon in the lucide pattern (the component, not the node): the bar decides the size. */
|
|
6
9
|
export type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
|
|
@@ -71,8 +74,12 @@ export const TAB_MIN_WIDTH = 64;
|
|
|
71
74
|
/** How many pages show directly on the bar, for a given usable width. The last
|
|
72
75
|
* tab is always "More" (that's where the pages that didn't fit and the extra
|
|
73
76
|
* exits live), so one page fewer than the number of tabs fits. */
|
|
77
|
+
/** At most 5 tabs (4 pages + More), as the iOS and Android tab bars: on a wider
|
|
78
|
+
* bar each tab gets room for its label instead of more tabs squeezing in. */
|
|
79
|
+
export const MAX_TABS = 5;
|
|
80
|
+
|
|
74
81
|
export function howManyFit(width: number, total: number): number {
|
|
75
|
-
const tabs = Math.floor(width / TAB_MIN_WIDTH);
|
|
82
|
+
const tabs = Math.min(MAX_TABS, Math.floor(width / TAB_MIN_WIDTH));
|
|
76
83
|
return Math.max(2, Math.min(total, tabs - 1));
|
|
77
84
|
}
|
|
78
85
|
|
|
@@ -98,7 +105,7 @@ const row = (active?: boolean) =>
|
|
|
98
105
|
`flex min-h-14 items-center gap-3 rounded-[18px] px-3 font-medium transition-colors duration-150 active:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none ${
|
|
99
106
|
active ? "bg-surface text-ink" : "text-ink"
|
|
100
107
|
}`;
|
|
101
|
-
const bubble = "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-
|
|
108
|
+
const bubble = "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-tint text-ink";
|
|
102
109
|
|
|
103
110
|
/**
|
|
104
111
|
* Main navigation on mobile: floating bar within thumb reach, app-like. It is
|
|
@@ -247,10 +254,10 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
247
254
|
</li>
|
|
248
255
|
))}
|
|
249
256
|
<li className="min-w-0 flex-1">
|
|
250
|
-
{/*
|
|
257
|
+
{/* React 19 knows `popoverTarget`; React 18 only passes the lowercase attribute through as is. */}
|
|
251
258
|
<button
|
|
252
259
|
type="button"
|
|
253
|
-
{...(embedded ? { "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen((o) => !o) } : {
|
|
260
|
+
{...(embedded ? { "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen((o) => !o) } : { [POPOVER_TARGET]: panelId })}
|
|
254
261
|
className={tab(embedded ? open || moreActive : moreActive)}
|
|
255
262
|
>
|
|
256
263
|
<MenuIcon />
|
package/components/Button.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactNode, ButtonHTMLAttributes, AnchorHTMLAttributes } from "react";
|
|
2
2
|
|
|
3
|
-
type Variant = "solid" | "accent" | "outline" | "ghost" | "link";
|
|
3
|
+
type Variant = "solid" | "accent" | "outline" | "ghost" | "link" | "outline-inverse";
|
|
4
4
|
type Size = "sm" | "md" | "lg";
|
|
5
5
|
|
|
6
6
|
const pillBase =
|
|
@@ -27,7 +27,10 @@ const variants: Record<Exclude<Variant, "link">, string> = {
|
|
|
27
27
|
// the 4.5:1 of normal text. Keep the accent for ONE large button per block.
|
|
28
28
|
accent: "bg-action text-on-action hover:brightness-[0.92]",
|
|
29
29
|
outline: "border border-line text-ink hover:bg-ink/[0.05]",
|
|
30
|
-
ghost: "text-ink hover:bg-
|
|
30
|
+
ghost: "text-ink hover:bg-tint",
|
|
31
|
+
// secondary on a dark panel (hero panel, StoryPanel, surface-panel): light outline,
|
|
32
|
+
// surface text (cream on petrol 9.41:1 on ttcab)
|
|
33
|
+
"outline-inverse": "border border-surface/45 text-surface hover:bg-surface/10",
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
const sizes: Record<Size, string> = {
|
package/components/Byline.tsx
CHANGED
|
@@ -9,6 +9,11 @@ type Props = {
|
|
|
9
9
|
* and the names go on their own line; without it, one inline line:
|
|
10
10
|
* avatar · name · action. */
|
|
11
11
|
eyebrow?: ReactNode;
|
|
12
|
+
/** Hide the portraits (the card already shows a big photo of the person). */
|
|
13
|
+
hideAvatars?: boolean;
|
|
14
|
+
/** Small line under the name(s), aligned with them ("Physiotherapist").
|
|
15
|
+
* Ignored when there is an `eyebrow`. */
|
|
16
|
+
subtitle?: ReactNode;
|
|
12
17
|
/** Link after the name(s) ("Watch the video ↗"). */
|
|
13
18
|
action?: { label: ReactNode; href: string; external?: boolean };
|
|
14
19
|
/** Joiners between names: ", " and " and ". */
|
|
@@ -35,7 +40,7 @@ const nameLink =
|
|
|
35
40
|
* - inline (under a topic card): portrait, name and "Watch the video ↗".
|
|
36
41
|
* Portraits are decorative: the names carry the information.
|
|
37
42
|
*/
|
|
38
|
-
export function Byline({ people, eyebrow, action, joiners = { sep: ", ", last: " and " }, cite = false, newTabLabel = "(opens in a new tab)", linkComponent, className = "" }: Props) {
|
|
43
|
+
export function Byline({ people, eyebrow, subtitle, hideAvatars = false, action, joiners = { sep: ", ", last: " and " }, cite = false, newTabLabel = "(opens in a new tab)", linkComponent, className = "" }: Props) {
|
|
39
44
|
const L: ElementType = linkComponent ?? "a";
|
|
40
45
|
// portraits stack with a ring (as on the site); initials would lose their letters, so they sit side by side
|
|
41
46
|
const stack = people.length > 1 && people.every((p) => p.photo || p.avatar);
|
|
@@ -65,6 +70,25 @@ export function Byline({ people, eyebrow, action, joiners = { sep: ", ", last: "
|
|
|
65
70
|
</L>
|
|
66
71
|
) : null;
|
|
67
72
|
|
|
73
|
+
if (subtitle && !eyebrow) {
|
|
74
|
+
return (
|
|
75
|
+
<div className={`flex items-center gap-3 ${className}`}>
|
|
76
|
+
<span className={hideAvatars ? "hidden" : row}>
|
|
77
|
+
{people.map((p) => (
|
|
78
|
+
<Avatar key={p.name} name={p.name} photo={p.photo} size={people.length === 1 ? 40 : 36} ring={stack}>{p.avatar}</Avatar>
|
|
79
|
+
))}
|
|
80
|
+
</span>
|
|
81
|
+
<div className="leading-snug">
|
|
82
|
+
<p className="m-0 text-sm font-semibold text-ink">
|
|
83
|
+
<Names className="not-italic">{names}</Names>
|
|
84
|
+
{actionEl && <> · {actionEl}</>}
|
|
85
|
+
</p>
|
|
86
|
+
<p className="m-0 mt-0.5 text-sm text-ink-soft">{subtitle}</p>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
if (eyebrow) {
|
|
69
93
|
return (
|
|
70
94
|
<div className={`flex items-center gap-3 ${className}`}>
|
package/components/Card.tsx
CHANGED
|
@@ -6,6 +6,10 @@ type Props = {
|
|
|
6
6
|
href?: string;
|
|
7
7
|
/** Lifts on hover (for clickable cards). */
|
|
8
8
|
interactive?: boolean;
|
|
9
|
+
/** `outline` (default): page color with a hairline and a soft shadow.
|
|
10
|
+
* `solid`: white card, no border, 28px corners — the family's testimonial
|
|
11
|
+
* and profile cards. */
|
|
12
|
+
variant?: "outline" | "solid";
|
|
9
13
|
className?: string;
|
|
10
14
|
};
|
|
11
15
|
|
|
@@ -14,10 +18,14 @@ type Props = {
|
|
|
14
18
|
* shadow**, 2xl radius (28px), surface background. With `href` it becomes a
|
|
15
19
|
* link; with `interactive` it lifts on hover. No strong borders or heavy shadows.
|
|
16
20
|
*/
|
|
17
|
-
export function Card({ children, href, interactive, className = "" }: Props) {
|
|
18
|
-
const
|
|
21
|
+
export function Card({ children, href, interactive, variant = "outline", className = "" }: Props) {
|
|
22
|
+
const look =
|
|
23
|
+
variant === "solid"
|
|
24
|
+
? "rounded-[28px] bg-card p-5 md:p-8"
|
|
25
|
+
: "rounded-2xl border border-line bg-surface p-[clamp(20px,3vw,32px)] shadow-[0_2px_12px_color-mix(in_srgb,var(--color-shadow)_6%,transparent)]";
|
|
26
|
+
const cls = `block ${look} ${
|
|
19
27
|
interactive || href
|
|
20
|
-
? "transition duration-200 hover:-translate-y-0.5 hover:shadow-[
|
|
28
|
+
? "transition duration-200 hover:-translate-y-0.5 hover:shadow-[0_14px_34px_color-mix(in_srgb,var(--color-shadow)_12%,transparent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none motion-reduce:hover:translate-y-0"
|
|
21
29
|
: ""
|
|
22
30
|
} ${className}`;
|
|
23
31
|
if (href) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useId, useState, type ReactNode } from "react";
|
|
4
|
+
import { ChevronDown } from "lucide-react";
|
|
5
|
+
import { Avatar } from "./Avatar";
|
|
6
|
+
import { FADE_IN } from "./Rows";
|
|
7
|
+
|
|
8
|
+
export type ContactAction = {
|
|
9
|
+
/** What the button does ("Ligar", "E-mail"). */
|
|
10
|
+
label: ReactNode;
|
|
11
|
+
/** The value shown under it: the number, the address. */
|
|
12
|
+
value: ReactNode;
|
|
13
|
+
href: string;
|
|
14
|
+
icon?: ReactNode;
|
|
15
|
+
/** sr-only after the label: who it reaches (" para Ana Souza"). */
|
|
16
|
+
srLabel?: ReactNode;
|
|
17
|
+
/** Long unbroken value (an e-mail) may wrap, only when it does not fit. */
|
|
18
|
+
breakAll?: boolean;
|
|
19
|
+
/** Opens in a new tab (WhatsApp). Say so in `srLabel`. */
|
|
20
|
+
external?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Props = {
|
|
24
|
+
/** Plain name: the initials. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** The name as rendered (e.g. with the search match marked). */
|
|
27
|
+
nameDisplay?: ReactNode;
|
|
28
|
+
/** One quiet line under the name: the city. */
|
|
29
|
+
meta?: ReactNode;
|
|
30
|
+
/** Revealed when the row opens. */
|
|
31
|
+
actions: ContactAction[];
|
|
32
|
+
/** Start open (e.g. the only result of a search). */
|
|
33
|
+
defaultOpen?: boolean;
|
|
34
|
+
headingLevel?: "h3" | "h4";
|
|
35
|
+
className?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ContactRow — one person in a long directory, as in the iPhone's Contacts:
|
|
40
|
+
* the list shows only what you scan by (initials, name, city); the ways to
|
|
41
|
+
* reach the person appear when you tap the row. 140 people with phone and
|
|
42
|
+
* e-mail always visible was a wall of data; this is a list of names.
|
|
43
|
+
*
|
|
44
|
+
* Put rows in a `ContactList` (the white inset-grouped panel with hairlines).
|
|
45
|
+
* The row is a button with `aria-expanded`; the actions are real links
|
|
46
|
+
* (wa.me, tel:, mailto:) written as plain text — icon + the value, 44px tall,
|
|
47
|
+
* underlined on hover — so the number can also be read and copied. No hover
|
|
48
|
+
* fill on the row (only a light press tint): open, row and actions share one
|
|
49
|
+
* white surface.
|
|
50
|
+
*/
|
|
51
|
+
export function ContactRow({ name, nameDisplay, meta, actions, defaultOpen = false, headingLevel: H = "h3", className = "" }: Props) {
|
|
52
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
53
|
+
const panel = useId();
|
|
54
|
+
return (
|
|
55
|
+
<li className={`relative list-none ${className}`}>
|
|
56
|
+
<H className="m-0 font-normal">
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
aria-expanded={open}
|
|
60
|
+
aria-controls={panel}
|
|
61
|
+
onClick={() => setOpen((v) => !v)}
|
|
62
|
+
className="group/row flex min-h-16 w-full items-center gap-3.5 px-4 py-2.5 text-left transition-colors duration-150 active:bg-tint/50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none md:px-5"
|
|
63
|
+
>
|
|
64
|
+
<Avatar name={name} size={40} />
|
|
65
|
+
<span className="min-w-0 flex-1">
|
|
66
|
+
<span className="line-clamp-2 block font-heading text-[1.0625rem] leading-snug font-medium text-ink">{nameDisplay ?? name}</span>
|
|
67
|
+
{meta && <span className="mt-0.5 block truncate text-sm text-ink-soft">{meta}</span>}
|
|
68
|
+
</span>
|
|
69
|
+
<ChevronDown
|
|
70
|
+
aria-hidden="true"
|
|
71
|
+
size={18}
|
|
72
|
+
className={`shrink-0 text-ink-soft transition-transform duration-200 motion-reduce:transition-none ${open ? "rotate-180" : "group-hover/row:translate-y-0.5"}`}
|
|
73
|
+
/>
|
|
74
|
+
</button>
|
|
75
|
+
</H>
|
|
76
|
+
<div id={panel} hidden={!open} className={`${FADE_IN} pr-4 pb-4 pl-[4.375rem] md:pr-5 md:pl-[4.625rem]`}>
|
|
77
|
+
{/* minimalista: ícone + valor, texto em ink sublinhado no hover; o nome da
|
|
78
|
+
ação ("WhatsApp", "E-mail") fica para o leitor de tela */}
|
|
79
|
+
<ul className="m-0 flex list-none flex-col gap-0.5 p-0 sm:flex-row sm:flex-wrap sm:gap-x-6">
|
|
80
|
+
{actions.map((a, i) => (
|
|
81
|
+
<li key={i}>
|
|
82
|
+
<a
|
|
83
|
+
href={a.href}
|
|
84
|
+
{...(a.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
85
|
+
className="group/act inline-flex min-h-11 max-w-full items-center gap-2 rounded-[2px] text-[0.9375rem] text-ink 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"
|
|
86
|
+
>
|
|
87
|
+
{a.icon && <span className="flex shrink-0 text-ink-soft group-hover/act:text-ink">{a.icon}</span>}
|
|
88
|
+
<span className="sr-only">{a.label}</span>
|
|
89
|
+
<span className={a.breakAll ? "[overflow-wrap:anywhere]" : undefined}>{a.value}</span>
|
|
90
|
+
{a.srLabel && <span className="sr-only">{a.srLabel}</span>}
|
|
91
|
+
</a>
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
</ul>
|
|
95
|
+
</div>
|
|
96
|
+
</li>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The white inset-grouped panel of a section (iOS lists): rounded corners on
|
|
101
|
+
* the group, rows separated by a hairline that starts at the name (after the
|
|
102
|
+
* 40px avatar + its padding: 70px, 74px from md), drawn as a ::before so the
|
|
103
|
+
* row itself never moves. */
|
|
104
|
+
export function ContactList({ children, className = "" }: { children: ReactNode; className?: string }) {
|
|
105
|
+
return (
|
|
106
|
+
<ul
|
|
107
|
+
className={`m-0 list-none overflow-hidden rounded-[22px] bg-card p-0 [&>li+li]:before:absolute [&>li+li]:before:top-0 [&>li+li]:before:right-0 [&>li+li]:before:left-[4.375rem] [&>li+li]:before:h-px [&>li+li]:before:bg-line [&>li+li]:before:content-[''] md:[&>li+li]:before:left-[4.625rem] ${className}`}
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
</ul>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -50,7 +50,7 @@ type Props = {
|
|
|
50
50
|
className?: string;
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
-
const PILL = "inline-flex items-center rounded-pill bg-
|
|
53
|
+
const PILL = "inline-flex items-center rounded-pill bg-tint px-2.5 py-0.5 text-xs font-semibold text-ink";
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* Course card of the Cuidado Global family — the class card (ttcab's
|
|
@@ -68,7 +68,7 @@ export function CourseCard({ cover, course, title, start, when, where, workload,
|
|
|
68
68
|
if (workload)
|
|
69
69
|
facts.push({
|
|
70
70
|
tile: (
|
|
71
|
-
<span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-
|
|
71
|
+
<span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-tint text-ink">
|
|
72
72
|
<Clock size={20} strokeWidth={2} />
|
|
73
73
|
</span>
|
|
74
74
|
),
|
|
@@ -77,7 +77,7 @@ export function CourseCard({ cover, course, title, start, when, where, workload,
|
|
|
77
77
|
const pill = soldOut ? t.soldOut : seats;
|
|
78
78
|
|
|
79
79
|
const foot = soldOut ? (
|
|
80
|
-
<span className="inline-flex min-h-11 items-center rounded-pill bg-
|
|
80
|
+
<span className="inline-flex min-h-11 items-center rounded-pill bg-tint px-5 font-heading font-semibold text-ink">{t.soldOut}</span>
|
|
81
81
|
) : (
|
|
82
82
|
action
|
|
83
83
|
);
|
package/components/CtaCard.tsx
CHANGED
|
@@ -10,7 +10,9 @@ type Props = {
|
|
|
10
10
|
action: ReactNode;
|
|
11
11
|
/** Side card with the speaker's video (VideoInviteCard props). Omit for the text card alone. */
|
|
12
12
|
video?: Omit<VideoInviteCardProps, "className">;
|
|
13
|
-
/** Any other side media (e.g. a still photo), placed in the same frame as the video.
|
|
13
|
+
/** Any other side media (e.g. a still photo), placed in the same frame as the video.
|
|
14
|
+
* It must fill the frame (`absolute inset-0 h-full w-full object-cover`): the frame
|
|
15
|
+
* takes the height of the text card, not the photo's. */
|
|
14
16
|
media?: ReactNode;
|
|
15
17
|
className?: string;
|
|
16
18
|
};
|
package/components/DateBadge.tsx
CHANGED
|
@@ -16,7 +16,7 @@ type Props = {
|
|
|
16
16
|
export function DateBadge({ month, day, size = "md", className = "" }: Props) {
|
|
17
17
|
const box = size === "lg" ? "h-14 w-14 rounded-2xl" : "h-12 w-12 rounded-xl";
|
|
18
18
|
return (
|
|
19
|
-
<span aria-hidden="true" className={`flex ${box} shrink-0 flex-col items-center justify-center bg-
|
|
19
|
+
<span aria-hidden="true" className={`flex ${box} shrink-0 flex-col items-center justify-center bg-tint text-ink ${className}`}>
|
|
20
20
|
<span className="text-[0.6rem] font-bold uppercase leading-none tracking-[0.12em] text-ink-soft">{month}</span>
|
|
21
21
|
<span className={`mt-1 font-heading font-bold leading-none ${size === "lg" ? "text-2xl" : "text-lg"}`}>{day}</span>
|
|
22
22
|
</span>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { SearchX } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
/** What happened, in the product's words: "Ninguém encontrado para “Kirs”". */
|
|
6
|
+
title: ReactNode;
|
|
7
|
+
/** One sentence on why, or what the list contains. */
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
/** The fastest way out first: a "did you mean" line, a "widen the search" button. */
|
|
10
|
+
recovery?: ReactNode;
|
|
11
|
+
/** Short tips (spelling, fewer words). Rendered as a list. */
|
|
12
|
+
tips?: ReactNode[];
|
|
13
|
+
/** Actions at the foot: clear the filters, talk to the team. */
|
|
14
|
+
actions?: ReactNode;
|
|
15
|
+
/** Replaces the magnifier (an icon at 22px). */
|
|
16
|
+
icon?: ReactNode;
|
|
17
|
+
/** h2 by default; h3 inside a section that already has an h2. */
|
|
18
|
+
headingLevel?: "h2" | "h3";
|
|
19
|
+
className?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* EmptyState — what a list shows when nothing matches. Never a dead end: it
|
|
24
|
+
* names what was searched, offers the nearest way out (a corrected spelling,
|
|
25
|
+
* a wider filter), then tips, then a person to ask. A white card (the family's
|
|
26
|
+
* solid card) so the place of the results does not go blank; left aligned,
|
|
27
|
+
* because it is read, not admired.
|
|
28
|
+
*
|
|
29
|
+
* Render it inside an `aria-live="polite"` region (or announce the count
|
|
30
|
+
* elsewhere) so a screen reader hears that the results changed.
|
|
31
|
+
*/
|
|
32
|
+
export function EmptyState({ title, children, recovery, tips, actions, icon, headingLevel: H = "h2", className = "" }: Props) {
|
|
33
|
+
return (
|
|
34
|
+
<div className={`rounded-[28px] bg-card p-[clamp(24px,4vw,44px)] ${className}`}>
|
|
35
|
+
<span aria-hidden="true" className="flex h-12 w-12 items-center justify-center rounded-full bg-tint text-ink">
|
|
36
|
+
{icon ?? <SearchX size={22} strokeWidth={1.75} />}
|
|
37
|
+
</span>
|
|
38
|
+
<H className="mt-5 font-heading text-[1.375rem] leading-snug font-medium text-balance text-ink md:text-2xl">{title}</H>
|
|
39
|
+
{children && <div className="mt-2 max-w-[60ch] leading-relaxed text-ink-body">{children}</div>}
|
|
40
|
+
{recovery && <div className="mt-5">{recovery}</div>}
|
|
41
|
+
{tips && tips.length > 0 && (
|
|
42
|
+
<ul className="mt-6 max-w-[60ch] list-none space-y-2 border-t border-line pt-5">
|
|
43
|
+
{tips.map((tip, i) => (
|
|
44
|
+
<li key={i} className="relative pl-5 text-sm leading-relaxed text-ink-soft">
|
|
45
|
+
<span aria-hidden="true" className="absolute top-[0.6em] left-0 h-1.5 w-1.5 rounded-full bg-ink-soft/60" />
|
|
46
|
+
{tip}
|
|
47
|
+
</li>
|
|
48
|
+
))}
|
|
49
|
+
</ul>
|
|
50
|
+
)}
|
|
51
|
+
{actions && <div className="mt-7 flex flex-wrap items-center gap-3">{actions}</div>}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -33,7 +33,7 @@ export function EventFacts({ facts, linkComponent, newTabLabel = "(opens in a ne
|
|
|
33
33
|
{facts.map((f, i) => (
|
|
34
34
|
<li key={i} className="flex items-center gap-4">
|
|
35
35
|
{f.tile === "pin" ? (
|
|
36
|
-
<span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-
|
|
36
|
+
<span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-tint text-ink">
|
|
37
37
|
<MapPin size={20} strokeWidth={2} />
|
|
38
38
|
</span>
|
|
39
39
|
) : (
|
package/components/EventRow.tsx
CHANGED
|
@@ -11,6 +11,9 @@ type Labels = {
|
|
|
11
11
|
};
|
|
12
12
|
const EN: Labels = { more: (n) => `+${n}`, by: "By" };
|
|
13
13
|
|
|
14
|
+
// the hit area of the row: the title's link or button, stretched over it
|
|
15
|
+
const STRETCH = "after:absolute after:inset-0 after:rounded-[24px] focus-visible:outline-none focus-visible:after:outline focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-ink";
|
|
16
|
+
|
|
14
17
|
export type EventRowProps = {
|
|
15
18
|
/** The time line ("09:00 · 12:00 GMT+0", or "3 to 9 Nov · 08:30–17:30"). */
|
|
16
19
|
time: ReactNode;
|
|
@@ -18,7 +21,10 @@ export type EventRowProps = {
|
|
|
18
21
|
* time this way). Optional. */
|
|
19
22
|
timeAccent?: ReactNode;
|
|
20
23
|
title: ReactNode;
|
|
21
|
-
|
|
24
|
+
/** The event's page. With `onSelect` instead, the row opens its details in place. */
|
|
25
|
+
href?: string;
|
|
26
|
+
/** Makes the title a button stretched over the row (a SidePanel opens, say). */
|
|
27
|
+
onSelect?: () => void;
|
|
22
28
|
/** Who organizes or teaches: small portrait(s) and the name(s). */
|
|
23
29
|
organizers?: Person[];
|
|
24
30
|
location?: ReactNode;
|
|
@@ -41,7 +47,7 @@ export type EventRowProps = {
|
|
|
41
47
|
* where, who is going, and the square cover on the right. Lives in an
|
|
42
48
|
* EventList, under a date heading.
|
|
43
49
|
*/
|
|
44
|
-
export function EventRow({ time, timeAccent, title, href, organizers, location, people, peopleMore, cover, badge, linkComponent, labels, className = "" }: EventRowProps) {
|
|
50
|
+
export function EventRow({ time, timeAccent, title, href, onSelect, organizers, location, people, peopleMore, cover, badge, linkComponent, labels, className = "" }: EventRowProps) {
|
|
45
51
|
const L: ElementType = linkComponent ?? "a";
|
|
46
52
|
const t = { ...EN, ...labels };
|
|
47
53
|
const shown = people?.slice(0, 4) ?? [];
|
|
@@ -58,10 +64,16 @@ export function EventRow({ time, timeAccent, title, href, organizers, location,
|
|
|
58
64
|
)}
|
|
59
65
|
</p>
|
|
60
66
|
<h3 className="mt-1.5 font-heading text-xl font-medium leading-snug text-ink md:text-[1.375rem]">
|
|
61
|
-
|
|
62
|
-
{
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
{onSelect ? (
|
|
68
|
+
<button type="button" onClick={onSelect} className={`text-left ${STRETCH}`}>
|
|
69
|
+
{title}
|
|
70
|
+
</button>
|
|
71
|
+
) : (
|
|
72
|
+
<L href={href} className={STRETCH}>
|
|
73
|
+
{title}
|
|
74
|
+
</L>
|
|
75
|
+
)}
|
|
76
|
+
{badge && <span className="ml-2 inline-flex translate-y-[-2px] items-center rounded-pill bg-tint px-2.5 py-0.5 align-middle text-xs font-semibold text-ink">{badge}</span>}
|
|
65
77
|
</h3>
|
|
66
78
|
{organizers && organizers.length > 0 && (
|
|
67
79
|
<p className="mt-2 flex items-center gap-2 text-sm text-ink-body">
|
|
@@ -86,13 +98,13 @@ export function EventRow({ time, timeAccent, title, href, organizers, location,
|
|
|
86
98
|
<Avatar key={p.name} name={p.name} photo={p.photo} size={28} ring>{p.avatar}</Avatar>
|
|
87
99
|
))}
|
|
88
100
|
</span>
|
|
89
|
-
{peopleMore ? <span className="rounded-pill bg-
|
|
101
|
+
{peopleMore ? <span className="rounded-pill bg-tint px-2 py-0.5 text-xs font-semibold text-ink">{t.more(peopleMore)}</span> : null}
|
|
90
102
|
<span className="sr-only">{shown.map((p) => p.name).join(", ")}</span>
|
|
91
103
|
</p>
|
|
92
104
|
)}
|
|
93
105
|
</div>
|
|
94
106
|
{cover && (
|
|
95
|
-
<div className="relative h-24 w-24 shrink-0 overflow-hidden rounded-2xl bg-
|
|
107
|
+
<div className="relative h-24 w-24 shrink-0 overflow-hidden rounded-2xl bg-tint md:h-36 md:w-36">
|
|
96
108
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
97
109
|
<img src={cover.src} alt={cover.alt ?? ""} loading="lazy" decoding="async" className="absolute inset-0 h-full w-full object-cover" />
|
|
98
110
|
</div>
|
package/components/FactStrip.tsx
CHANGED
|
@@ -36,7 +36,7 @@ export function FactStrip({ facts, label, className = "" }: Props) {
|
|
|
36
36
|
>
|
|
37
37
|
{facts.map(({ Icon, title, text, separator = ", " }, i) => (
|
|
38
38
|
<li key={i} className="flex items-center gap-4 py-5 md:px-8 md:py-0 lg:px-10">
|
|
39
|
-
<span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-
|
|
39
|
+
<span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-tint text-ink">
|
|
40
40
|
<Icon size={22} strokeWidth={1.75} />
|
|
41
41
|
</span>
|
|
42
42
|
<p className="m-0 leading-snug">
|