@breathe-ds/ui 0.6.6 → 0.6.8
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 +9 -1
- package/components/BottomNav.tsx +59 -15
- package/components/Curtain.tsx +4 -9
- package/components/TestimonialCard.tsx +20 -3
- package/components/curtainHeadScript.ts +13 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -162,6 +162,8 @@ files (`public/brand`) in the repositories, split into two components so the
|
|
|
162
162
|
|
|
163
163
|
```tsx
|
|
164
164
|
<Curtain logo={<TtcabSymbol />} name={<TtcabWordmark />} wordmarkRatio={TTCAB_LOCKUP.wordmarkPerSymbol} tagline="…" />
|
|
165
|
+
// once per session: the head script, from its own module (callable in a server layout, 0.6.8)
|
|
166
|
+
// import { curtainHeadScript } from "@breathe-ds/ui/components/curtainHeadScript";
|
|
165
167
|
<Lockup variant="horizontal" size="md" logo={<CreetaSymbol />} wordmark={<CreetaWordmark />} wordmarkRatio={CREETA_LOCKUP.wordmarkPerSymbol} name="creeta" />
|
|
166
168
|
```
|
|
167
169
|
|
|
@@ -246,7 +248,7 @@ from color, not from extra markup.
|
|
|
246
248
|
shrinks the tap targets); the rest and the external exits (`extras`, with the domain spelled out
|
|
247
249
|
and a ↗ arrow) go to the "More" panel, a native popover (Esc, tap outside, no
|
|
248
250
|
JS). Gradient veil behind the bar. `hideFrom` = `md` (creeta) or `lg`
|
|
249
|
-
(ttcab). `labels` for another language. `embedded` for the demo frame. `collapseOnScroll` (reading mode): scrolling down shrinks it to a slim icon strip; scrolling up, a tap or keyboard focus brings it back; full at the top and the end (`scrollContainer` when the window is not what scrolls).
|
|
251
|
+
(ttcab). `labels` for another language. `embedded` for the demo frame. `collapseOnScroll` (reading mode): scrolling down shrinks it to a slim icon strip; scrolling up, a tap or keyboard focus brings it back; full at the top and the end (`scrollContainer` when the window is not what scrolls). `group` on a page lists it under a section title in "More" (sticky while the list scrolls).
|
|
250
252
|
- **AnchorNav**: floating capsule of page anchors (scroll-spy + sliding
|
|
251
253
|
indicator), extracted from ttcab's TreatmentAnchorNav. Target sections need
|
|
252
254
|
a `scroll-mt-*` compatible with the sticky header. White capsule (`card`);
|
|
@@ -306,6 +308,12 @@ from color, not from extra markup.
|
|
|
306
308
|
the page-level pause contract (`CARD_VIDEOS_PAUSE_EVENT` / `CARD_VIDEOS_PAUSED_ATTR`).
|
|
307
309
|
- **TestimonialCard**: clip with a play button on the left, quote + author +
|
|
308
310
|
outbound link on the right ("Famílias contam").
|
|
311
|
+
**Rule (0.6.8): in a grid of testimonials with `panel`, the card is ONLY a quote**
|
|
312
|
+
— a short excerpt in the person's own words, large, with the drawn quote mark;
|
|
313
|
+
the whole testimonial (and its video or photo) opens in the panel on click. Pass
|
|
314
|
+
the `excerpt` the client chose (`excerptMarks={false}` for a context line that is
|
|
315
|
+
not the person's speech); without one, the card takes the first sentence.
|
|
316
|
+
Never show the long text clamped on the card.
|
|
309
317
|
- **MapCard**: the Mapa of novo.creeta — a still preview fading into the card,
|
|
310
318
|
the address, "View the map" and the outbound "Directions" link; Google is only
|
|
311
319
|
called after the tap (no consent banner, lighter page). Open, the map fills
|
package/components/BottomNav.tsx
CHANGED
|
@@ -16,6 +16,9 @@ export type BottomPage = {
|
|
|
16
16
|
Icon: IconType;
|
|
17
17
|
/** Is this the current page? (the app decides: route/locale are its own) */
|
|
18
18
|
active?: boolean;
|
|
19
|
+
/** Section title in the "More" panel. Pages with the same group are listed
|
|
20
|
+
* together under it, in order of first appearance. Without it: one plain list. */
|
|
21
|
+
group?: string;
|
|
19
22
|
};
|
|
20
23
|
|
|
21
24
|
/** Extra exit in the "More" panel (e.g. courses on another site): address written
|
|
@@ -111,6 +114,9 @@ const tab = (active: boolean, collapsed = false) =>
|
|
|
111
114
|
// label under the icon: in the slim bar it folds away (still read by screen readers)
|
|
112
115
|
const LABEL = "w-full truncate text-center transition-all duration-200 motion-reduce:transition-none";
|
|
113
116
|
|
|
117
|
+
const GROUP_TITLE =
|
|
118
|
+
"sticky -top-2 z-10 -mx-2 bg-card px-5 pt-3 pb-1.5 font-heading text-[0.7rem] font-semibold tracking-[0.12em] text-ink-soft uppercase";
|
|
119
|
+
|
|
114
120
|
const row = (active?: boolean) =>
|
|
115
121
|
`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 ${
|
|
116
122
|
active ? "bg-surface text-ink" : "text-ink"
|
|
@@ -177,7 +183,20 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
177
183
|
cancelAnimationFrame(raf);
|
|
178
184
|
};
|
|
179
185
|
}, [collapseOnScroll, scrollContainer]);
|
|
180
|
-
|
|
186
|
+
// native popover (site mode): the panel's real state, so the bar stays full while it's open
|
|
187
|
+
const [popOpen, setPopOpen] = useState(false);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const el = panel.current;
|
|
190
|
+
if (!el) return;
|
|
191
|
+
const onToggle = (e: Event) => {
|
|
192
|
+
const isOpen = (e as Event & { newState?: string }).newState === "open";
|
|
193
|
+
setPopOpen(isOpen);
|
|
194
|
+
if (isOpen) setCollapsed(false);
|
|
195
|
+
};
|
|
196
|
+
el.addEventListener("toggle", onToggle);
|
|
197
|
+
return () => el.removeEventListener("toggle", onToggle);
|
|
198
|
+
}, []);
|
|
199
|
+
const slim = collapsed && !open && !popOpen;
|
|
181
200
|
|
|
182
201
|
useEffect(() => {
|
|
183
202
|
const ul = list.current;
|
|
@@ -225,6 +244,13 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
225
244
|
|
|
226
245
|
const onBar = pages.slice(0, visible);
|
|
227
246
|
const inMore = pages.slice(visible);
|
|
247
|
+
// "More" pages by group (order of first appearance); ungrouped ones form a group without title
|
|
248
|
+
const groups: { title?: string; items: BottomPage[] }[] = [];
|
|
249
|
+
for (const p of inMore) {
|
|
250
|
+
const g = groups.find((x) => x.title === p.group);
|
|
251
|
+
if (g) g.items.push(p);
|
|
252
|
+
else groups.push({ title: p.group, items: [p] });
|
|
253
|
+
}
|
|
228
254
|
const moreActive = inMore.some((p) => p.active) || extras.some((x) => x.active);
|
|
229
255
|
const pos = embedded ? "absolute" : "fixed";
|
|
230
256
|
// full class names (Tailwind only generates what it finds written in the code)
|
|
@@ -233,18 +259,25 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
233
259
|
const panelInner = (
|
|
234
260
|
<nav aria-label={t.morePages}>
|
|
235
261
|
{/* One item per row: in two columns the panel got confusing. */}
|
|
236
|
-
{
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
262
|
+
{groups.map(({ title, items }, gi) => {
|
|
263
|
+
const headId = `${panelId}-g${gi}`;
|
|
264
|
+
return (
|
|
265
|
+
<div key={gi} className={gi > 0 ? "mt-1 border-t border-ink/10 pt-1" : undefined}>
|
|
266
|
+
{/* title sticks to the top of the panel while its list scrolls by */}
|
|
267
|
+
{title && <p id={headId} className={GROUP_TITLE}>{title}</p>}
|
|
268
|
+
<ul className="grid list-none gap-1" aria-labelledby={title ? headId : undefined}>
|
|
269
|
+
{items.map(({ href, label, Icon, active }, i) => (
|
|
270
|
+
<li key={`${i}-${href}`}>
|
|
271
|
+
<L href={href} aria-current={active ? "page" : undefined} onClick={close} className={row(active)}>
|
|
272
|
+
<span className={bubble}><Icon aria-hidden="true" size={19} strokeWidth={1.75} /></span>
|
|
273
|
+
{label}
|
|
274
|
+
</L>
|
|
275
|
+
</li>
|
|
276
|
+
))}
|
|
277
|
+
</ul>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
})}
|
|
248
281
|
{extras.length > 0 && (
|
|
249
282
|
<ul className={`grid list-none gap-1${inMore.length > 0 ? " mt-1 border-t border-ink/10 pt-1" : ""}`}>
|
|
250
283
|
{extras.map(({ href, label, Icon, description, external, active }, i) => {
|
|
@@ -289,7 +322,13 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
289
322
|
className={`${pos} inset-x-3 ${slim ? "bottom-1.5" : "bottom-3"} z-50 pb-[env(safe-area-inset-bottom)] transition-[bottom] duration-200 ease-[cubic-bezier(.23,1,.32,1)] motion-reduce:transition-none [view-transition-name:barra]${hide}`}
|
|
290
323
|
// slim: a tap anywhere on the bar only brings it back (doesn't navigate);
|
|
291
324
|
// keyboard focus brings it back too (a mouse/touch focus doesn't, or the tap would navigate)
|
|
292
|
-
|
|
325
|
+
// …except "More": the hamburger is a clear intent, it expands AND opens the panel in one tap
|
|
326
|
+
onClickCapture={slim ? (e) => {
|
|
327
|
+
setCollapsed(false);
|
|
328
|
+
if ((e.target as Element).closest?.("[data-more]")) return;
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
e.stopPropagation();
|
|
331
|
+
} : undefined}
|
|
293
332
|
onFocus={(e) => { if ((e.target as Element).matches?.(":focus-visible")) setCollapsed(false); }}
|
|
294
333
|
>
|
|
295
334
|
<ul
|
|
@@ -308,6 +347,7 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
308
347
|
{/* React 19 knows `popoverTarget`; React 18 only passes the lowercase attribute through as is. */}
|
|
309
348
|
<button
|
|
310
349
|
type="button"
|
|
350
|
+
data-more=""
|
|
311
351
|
{...(embedded ? { "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen((o) => !o) } : { [POPOVER_TARGET]: panelId })}
|
|
312
352
|
className={tab(embedded ? open || moreActive : moreActive, slim)}
|
|
313
353
|
>
|
|
@@ -340,6 +380,10 @@ const CSS = `
|
|
|
340
380
|
.cg-more-panel{
|
|
341
381
|
position:fixed; inset:auto .75rem calc(5.75rem + env(safe-area-inset-bottom)) .75rem;
|
|
342
382
|
width:auto; margin:0; border:0; border-radius:26px; padding:.5rem;
|
|
383
|
+
/* long list (many pages): the panel stops below the top of the screen and
|
|
384
|
+
scrolls inside, instead of running off the top with the first items lost */
|
|
385
|
+
max-height:calc(100dvh - 5.75rem - env(safe-area-inset-bottom) - .75rem);
|
|
386
|
+
overflow-y:auto; overscroll-behavior:contain; scrollbar-width:thin;
|
|
343
387
|
background:var(--color-card); color:var(--color-ink);
|
|
344
388
|
box-shadow:0 20px 48px -16px rgb(0 0 0 / .35), 0 1px 2px rgb(0 0 0 / .1);
|
|
345
389
|
opacity:0; transform:translateY(8px) scale(.97); transform-origin:88% 100%;
|
|
@@ -348,7 +392,7 @@ const CSS = `
|
|
|
348
392
|
.cg-more-panel:popover-open, .cg-more-panel--embedded{ opacity:1; transform:none; }
|
|
349
393
|
@starting-style{ .cg-more-panel:popover-open, .cg-more-panel--embedded{ opacity:0; transform:translateY(8px) scale(.97); } }
|
|
350
394
|
.cg-more-panel::backdrop{ background:transparent; }
|
|
351
|
-
.cg-more-panel--embedded{ position:absolute; z-index:50; }
|
|
395
|
+
.cg-more-panel--embedded{ position:absolute; z-index:50; max-height:calc(100% - 6.5rem); }
|
|
352
396
|
@media (prefers-reduced-motion:reduce){
|
|
353
397
|
.cg-more-panel{ transform:none; transition:opacity 120ms ease; }
|
|
354
398
|
@starting-style{ .cg-more-panel:popover-open, .cg-more-panel--embedded{ transform:none; } }
|
package/components/Curtain.tsx
CHANGED
|
@@ -98,16 +98,11 @@ export function Curtain({ name, tagline, logo, wordmarkRatio, symbolSide = "left
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/**
|
|
101
|
-
* Script for the <head
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* // Next (app/layout):
|
|
105
|
-
* <script dangerouslySetInnerHTML={{ __html: curtainHeadScript("cg-curtain") }} />
|
|
101
|
+
* Script for the <head>: moved to `components/curtainHeadScript.ts` (2026-11-05)
|
|
102
|
+
* so a server layout can call it (this file is "use client"). Kept here for old
|
|
103
|
+
* imports from client code.
|
|
106
104
|
*/
|
|
107
|
-
export
|
|
108
|
-
const k = JSON.stringify(storageKey);
|
|
109
|
-
return `try{var d=document.documentElement;if(sessionStorage.getItem(${k})){d.setAttribute('data-cg-curtain','seen')}else{sessionStorage.setItem(${k},'1')}}catch(e){}`;
|
|
110
|
-
}
|
|
105
|
+
export { curtainHeadScript } from "./curtainHeadScript";
|
|
111
106
|
|
|
112
107
|
const CURTAIN_CSS = `
|
|
113
108
|
.cg-curtain{
|
|
@@ -79,7 +79,11 @@ type Props = {
|
|
|
79
79
|
* or only in the panel — the card stays a clean quote with the avatar. */
|
|
80
80
|
mediaPlacement?: "card" | "panel";
|
|
81
81
|
/** The card shows only this excerpt, large, as a pull quote (the person's own
|
|
82
|
-
* words, never rewritten); the whole `quote` goes to the panel.
|
|
82
|
+
* words, never rewritten); the whole `quote` goes to the panel.
|
|
83
|
+
* RULE (2026-11-05, Felipe): with `panel`, the card is ONLY a quote — never
|
|
84
|
+
* the long text clamped. Pass the excerpt the client chose; without one, the
|
|
85
|
+
* card takes the first sentence of a string `quote` (cut at a word, with "…",
|
|
86
|
+
* when it runs long). The whole testimonial always opens in the panel. */
|
|
83
87
|
excerpt?: ReactNode;
|
|
84
88
|
/** Quote marks around the excerpt. false for a context line that is not the
|
|
85
89
|
* person's speech (e.g. the description of a video). Default: true. */
|
|
@@ -179,8 +183,21 @@ function TapVideo({ src, youtubeId, poster: posterProp, description, withSound =
|
|
|
179
183
|
// whole class names, so Tailwind generates them
|
|
180
184
|
const CLAMP: Record<number, string> = { 3: "line-clamp-3", 4: "line-clamp-4", 5: "line-clamp-5", 6: "line-clamp-6", 8: "line-clamp-8" };
|
|
181
185
|
|
|
186
|
+
/** First sentence of a testimonial, for the card when no excerpt was chosen.
|
|
187
|
+
* Long sentences are cut at a word (≤ 140 characters) with "…". */
|
|
188
|
+
export function firstSentence(text: string, max = 140): string {
|
|
189
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
190
|
+
const m = /^(.+?[.!?])(\s|$)/.exec(clean);
|
|
191
|
+
const s = m ? m[1] : clean;
|
|
192
|
+
if (s.length <= max) return s;
|
|
193
|
+
const cut = s.slice(0, max);
|
|
194
|
+
return `${cut.slice(0, cut.lastIndexOf(" ")).replace(/[,;:\s]+$/, "")}…`;
|
|
195
|
+
}
|
|
196
|
+
|
|
182
197
|
export function TestimonialCard({ quote, author, authorPhoto, authorAvatar, authors, link, video, photo, mediaPlacement = "card", role, joiners, panel, excerpt, excerptMarks = true, clampLines, as: Root = "div", labels, className = "" }: Props) {
|
|
183
198
|
const t = { ...EN, ...labels };
|
|
199
|
+
// with a panel the card is only a quote: the given excerpt, or the first sentence
|
|
200
|
+
const shownExcerpt = excerpt ?? (panel && typeof quote === "string" ? firstSentence(quote) : undefined);
|
|
184
201
|
const watch = (d: string) => (typeof t.watch === "function" ? t.watch(d) : t.watch.replace("{description}", d));
|
|
185
202
|
const people = authors ?? [{ name: author ?? "", photo: authorPhoto, avatar: authorAvatar }];
|
|
186
203
|
const cardPhoto = mediaPlacement === "card" ? photo : undefined;
|
|
@@ -227,7 +244,7 @@ export function TestimonialCard({ quote, author, authorPhoto, authorAvatar, auth
|
|
|
227
244
|
) : null}
|
|
228
245
|
<figure className={`flex flex-1 flex-col justify-between gap-5 ${cardVideo || cardPhoto ? "p-5 md:p-7" : "p-6 md:p-8"}`}>
|
|
229
246
|
<div>
|
|
230
|
-
{
|
|
247
|
+
{shownExcerpt ? (
|
|
231
248
|
<blockquote className="m-0">
|
|
232
249
|
{/* the family's drawn mark (same as PullQuote) above the line; the line itself stays clean */}
|
|
233
250
|
{excerptMarks && (
|
|
@@ -235,7 +252,7 @@ export function TestimonialCard({ quote, author, authorPhoto, authorAvatar, auth
|
|
|
235
252
|
<path d="M0 36V22C0 10.5 6 2.6 18.2 0l2.3 5.2C14.6 7.4 11.4 11 11 16h8.6v20H0Zm27.4 0V22c0-11.5 6-19.4 18.2-22l2.3 5.2C42 7.4 38.8 11 38.4 16H47v20H27.4Z" />
|
|
236
253
|
</svg>
|
|
237
254
|
)}
|
|
238
|
-
<p className="m-0 font-heading text-[1.3rem] leading-snug font-semibold text-balance text-ink md:text-[1.5rem]">{
|
|
255
|
+
<p className="m-0 font-heading text-[1.3rem] leading-snug font-semibold text-balance text-ink md:text-[1.5rem]">{shownExcerpt}</p>
|
|
239
256
|
</blockquote>
|
|
240
257
|
) : (
|
|
241
258
|
<blockquote
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Script for the <head> (before paint) of the Curtain: marks the curtain as seen
|
|
3
|
+
* in this session, so it does not repeat on navigation or reload. Without it, the
|
|
4
|
+
* curtain shows on every load. Plain module (no "use client"): a server layout
|
|
5
|
+
* can call it.
|
|
6
|
+
* // Next (app/layout):
|
|
7
|
+
* import { curtainHeadScript } from "@breathe-ds/ui/components/curtainHeadScript";
|
|
8
|
+
* <script dangerouslySetInnerHTML={{ __html: curtainHeadScript("cg-curtain") }} />
|
|
9
|
+
*/
|
|
10
|
+
export function curtainHeadScript(storageKey = "cg-curtain") {
|
|
11
|
+
const k = JSON.stringify(storageKey);
|
|
12
|
+
return `try{var d=document.documentElement;if(sessionStorage.getItem(${k})){d.setAttribute('data-cg-curtain','seen')}else{sessionStorage.setItem(${k},'1')}}catch(e){}`;
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@breathe-ds/ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "Breathe DS — the design system of the Cuidado Global family of sites (creeta, ttcab, metodorta). Themeable tokens + React components.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"./components/BottomNav": "./components/BottomNav.tsx",
|
|
51
51
|
"./components/BreatheMark": "./components/BreatheMark.tsx",
|
|
52
52
|
"./components/Curtain": "./components/Curtain.tsx",
|
|
53
|
+
"./components/curtainHeadScript": "./components/curtainHeadScript.ts",
|
|
53
54
|
"./components/Lockup": "./components/Lockup.tsx",
|
|
54
55
|
"./tokens/neutral.css": {
|
|
55
56
|
"style": "./tokens/neutral.css",
|