@breathe-ds/ui 0.6.5 → 0.6.7

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 CHANGED
@@ -246,7 +246,7 @@ from color, not from extra markup.
246
246
  shrinks the tap targets); the rest and the external exits (`extras`, with the domain spelled out
247
247
  and a ↗ arrow) go to the "More" panel, a native popover (Esc, tap outside, no
248
248
  JS). Gradient veil behind the bar. `hideFrom` = `md` (creeta) or `lg`
249
- (ttcab). `labels` for another language. `embedded` for the demo frame.
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). `group` on a page lists it under a section title in "More" (sticky while the list scrolls).
250
250
  - **AnchorNav**: floating capsule of page anchors (scroll-spy + sliding
251
251
  indicator), extracted from ttcab's TreatmentAnchorNav. Target sections need
252
252
  a `scroll-mt-*` compatible with the sticky header. White capsule (`card`);
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { version as REACT_VERSION, 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, type RefObject } from "react";
4
4
 
5
5
  // the popover trigger attribute: camelCase in React 19+, lowercase passthrough in 18
6
6
  const POPOVER_TARGET = parseInt(REACT_VERSION, 10) >= 19 ? "popoverTarget" : "popovertarget";
7
7
 
8
8
  /** Icon in the lucide pattern (the component, not the node): the bar decides the size. */
9
- export type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
9
+ export type IconType = ComponentType<{ size?: number | string; strokeWidth?: number | string; className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
10
10
 
11
11
  /** One page of the site, in order of importance. The first ones stay on the bar;
12
12
  * those that don't fit go into "More". */
@@ -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
@@ -66,6 +69,12 @@ type Props = {
66
69
  /** Embedded in a `relative` container (demo, phone frame): bar, veil and
67
70
  * panel are `absolute` inside it and show at any width. */
68
71
  embedded?: boolean;
72
+ /** Reading mode: scrolling down shrinks the bar to a slim icon strip at the
73
+ * bottom; scrolling up, tapping it or reaching it by keyboard brings it back.
74
+ * Always full at the top and at the end of the page. Off by default. */
75
+ collapseOnScroll?: boolean;
76
+ /** Element that scrolls, when it isn't the window (e.g. the demo frame). */
77
+ scrollContainer?: RefObject<HTMLElement | null>;
69
78
  };
70
79
 
71
80
  /** Minimum width of a tab, in px: below this the label doesn't fit. */
@@ -93,14 +102,21 @@ const ArrowUpRight = () => (
93
102
  // Tab: icon+label column. The active one gets a SHAPE (light pill), not just color —
94
103
  // readable for people who can't tell colors apart. Shrinks on touch, like an app button.
95
104
  // The view-transition name makes the highlight slide between tabs on page change.
96
- const tab = (active: boolean) =>
105
+ const tab = (active: boolean, collapsed = false) =>
97
106
  [
98
- "flex h-14 w-full flex-col items-center justify-center gap-1 rounded-[20px] px-1 text-[10.5px] font-medium leading-none tracking-tight",
99
- "transition duration-150 active:scale-[0.96] motion-reduce:transition-none motion-reduce:active:scale-100",
107
+ "flex w-full flex-col items-center justify-center rounded-[20px] px-1 text-[10.5px] font-medium leading-none tracking-tight",
108
+ collapsed ? "h-9 gap-0" : "h-14 gap-1",
109
+ "transition-all duration-200 ease-[cubic-bezier(.23,1,.32,1)] active:scale-[0.96] motion-reduce:transition-none motion-reduce:active:scale-100",
100
110
  "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface",
101
111
  active ? "bg-surface text-ink [view-transition-name:aba-ativa]" : "text-surface/85 hover:bg-white/10",
102
112
  ].join(" ");
103
113
 
114
+ // label under the icon: in the slim bar it folds away (still read by screen readers)
115
+ const LABEL = "w-full truncate text-center transition-all duration-200 motion-reduce:transition-none";
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
+
104
120
  const row = (active?: boolean) =>
105
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 ${
106
122
  active ? "bg-surface text-ink" : "text-ink"
@@ -121,7 +137,7 @@ const bubble = "flex h-10 w-10 shrink-0 items-center justify-center rounded-full
121
137
  * (demo), it becomes an absolute panel inside the frame, with the same entrance.
122
138
  * - Hidden from `hideFrom` up (md on creeta, lg on ttcab), where the header takes over.
123
139
  */
124
- export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg", labels, embedded = false }: Props) {
140
+ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg", labels, embedded = false, collapseOnScroll = false, scrollContainer }: Props) {
125
141
  const L: ElementType = linkComponent ?? "a";
126
142
  const t = { ...EN, ...labels };
127
143
  const list = useRef<HTMLUListElement>(null);
@@ -131,6 +147,56 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
131
147
  // before measuring: the typical phone case (5 tabs = 4 pages + More)
132
148
  const [visible, setVisible] = useState(() => howManyFit(5 * TAB_MIN_WIDTH, pages.length));
133
149
  const [open, setOpen] = useState(false); // embedded mode only
150
+ const [collapsed, setCollapsed] = useState(false);
151
+
152
+ // Reading mode: direction of the scroll, with a small dead zone so a shaky
153
+ // thumb doesn't make the bar blink. Down 24px → slim; up 12px → full.
154
+ useEffect(() => {
155
+ if (!collapseOnScroll) return setCollapsed(false);
156
+ const el = scrollContainer?.current ?? null;
157
+ const y = () => (el ? el.scrollTop : window.scrollY);
158
+ const atEnd = () =>
159
+ el ? el.scrollTop + el.clientHeight >= el.scrollHeight - 8 : window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 8;
160
+ let last = y();
161
+ let run = 0;
162
+ let raf = 0;
163
+ const onScroll = () => {
164
+ if (raf) return;
165
+ raf = requestAnimationFrame(() => {
166
+ raf = 0;
167
+ const now = y();
168
+ const d = now - last;
169
+ last = now;
170
+ if (now < 80 || atEnd()) {
171
+ run = 0;
172
+ return setCollapsed(false);
173
+ }
174
+ run = Math.sign(d) === Math.sign(run) ? run + d : d;
175
+ if (run > 24) setCollapsed(true);
176
+ else if (run < -12) setCollapsed(false);
177
+ });
178
+ };
179
+ const target: HTMLElement | Window = el ?? window;
180
+ target.addEventListener("scroll", onScroll, { passive: true });
181
+ return () => {
182
+ target.removeEventListener("scroll", onScroll);
183
+ cancelAnimationFrame(raf);
184
+ };
185
+ }, [collapseOnScroll, scrollContainer]);
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;
134
200
 
135
201
  useEffect(() => {
136
202
  const ul = list.current;
@@ -178,6 +244,13 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
178
244
 
179
245
  const onBar = pages.slice(0, visible);
180
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
+ }
181
254
  const moreActive = inMore.some((p) => p.active) || extras.some((x) => x.active);
182
255
  const pos = embedded ? "absolute" : "fixed";
183
256
  // full class names (Tailwind only generates what it finds written in the code)
@@ -186,18 +259,25 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
186
259
  const panelInner = (
187
260
  <nav aria-label={t.morePages}>
188
261
  {/* One item per row: in two columns the panel got confusing. */}
189
- {inMore.length > 0 && (
190
- <ul className="grid list-none gap-1">
191
- {inMore.map(({ href, label, Icon, active }, i) => (
192
- <li key={`${i}-${href}`}>
193
- <L href={href} aria-current={active ? "page" : undefined} onClick={close} className={row(active)}>
194
- <span className={bubble}><Icon aria-hidden="true" size={19} strokeWidth={1.75} /></span>
195
- {label}
196
- </L>
197
- </li>
198
- ))}
199
- </ul>
200
- )}
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
+ })}
201
281
  {extras.length > 0 && (
202
282
  <ul className={`grid list-none gap-1${inMore.length > 0 ? " mt-1 border-t border-ink/10 pt-1" : ""}`}>
203
283
  {extras.map(({ href, label, Icon, description, external, active }, i) => {
@@ -235,21 +315,31 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
235
315
  reaching it, instead of showing up clipped by the edges. */}
236
316
  <div
237
317
  aria-hidden="true"
238
- className={`pointer-events-none ${pos} inset-x-0 bottom-0 z-40 h-28 bg-gradient-to-t from-surface from-35% via-surface/85 via-65% to-transparent [view-transition-name:veu]${hide}`}
318
+ className={`pointer-events-none ${pos} inset-x-0 bottom-0 z-40 ${slim ? "h-14" : "h-28"} transition-[height] duration-200 ease-[cubic-bezier(.23,1,.32,1)] motion-reduce:transition-none bg-gradient-to-t from-surface from-35% via-surface/85 via-65% to-transparent [view-transition-name:veu]${hide}`}
239
319
  />
240
320
  <nav
241
321
  aria-label={t.nav}
242
- className={`${pos} inset-x-3 bottom-3 z-50 pb-[env(safe-area-inset-bottom)] [view-transition-name:barra]${hide}`}
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}`}
323
+ // slim: a tap anywhere on the bar only brings it back (doesn't navigate);
324
+ // keyboard focus brings it back too (a mouse/touch focus doesn't, or the tap would navigate)
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}
332
+ onFocus={(e) => { if ((e.target as Element).matches?.(":focus-visible")) setCollapsed(false); }}
243
333
  >
244
334
  <ul
245
335
  ref={list}
246
- className="flex list-none items-stretch justify-between gap-0.5 rounded-[26px] bg-ink/95 p-1.5 shadow-[0_12px_32px_-12px_rgba(0,0,0,0.5)] backdrop-blur-md"
336
+ className={`flex list-none items-stretch justify-between gap-0.5 rounded-[26px] bg-ink/95 ${slim ? "p-1" : "p-1.5"} transition-[padding] duration-200 motion-reduce:transition-none shadow-[0_12px_32px_-12px_rgba(0,0,0,0.5)] backdrop-blur-md`}
247
337
  >
248
338
  {onBar.map(({ href, label, Icon, active }, i) => (
249
339
  <li key={`${i}-${href}`} className="min-w-0 flex-1">
250
- <L href={href} aria-current={active ? "page" : undefined} className={tab(!!active)}>
340
+ <L href={href} aria-current={active ? "page" : undefined} className={tab(!!active, slim)}>
251
341
  <Icon aria-hidden="true" size={20} strokeWidth={1.75} />
252
- <span className="w-full truncate text-center">{label}</span>
342
+ <span className={`${LABEL} ${slim ? "max-h-0 opacity-0" : "max-h-4 opacity-100"}`}>{label}</span>
253
343
  </L>
254
344
  </li>
255
345
  ))}
@@ -257,11 +347,12 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
257
347
  {/* React 19 knows `popoverTarget`; React 18 only passes the lowercase attribute through as is. */}
258
348
  <button
259
349
  type="button"
350
+ data-more=""
260
351
  {...(embedded ? { "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen((o) => !o) } : { [POPOVER_TARGET]: panelId })}
261
- className={tab(embedded ? open || moreActive : moreActive)}
352
+ className={tab(embedded ? open || moreActive : moreActive, slim)}
262
353
  >
263
354
  <MenuIcon />
264
- <span className="w-full truncate text-center">{t.more}</span>
355
+ <span className={`${LABEL} ${slim ? "max-h-0 opacity-0" : "max-h-4 opacity-100"}`}>{t.more}</span>
265
356
  <span className="sr-only"> {t.morePagesSr}{moreActive ? ` ${t.currentHere}` : ""}</span>
266
357
  </button>
267
358
  </li>
@@ -289,6 +380,10 @@ const CSS = `
289
380
  .cg-more-panel{
290
381
  position:fixed; inset:auto .75rem calc(5.75rem + env(safe-area-inset-bottom)) .75rem;
291
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;
292
387
  background:var(--color-card); color:var(--color-ink);
293
388
  box-shadow:0 20px 48px -16px rgb(0 0 0 / .35), 0 1px 2px rgb(0 0 0 / .1);
294
389
  opacity:0; transform:translateY(8px) scale(.97); transform-origin:88% 100%;
@@ -297,7 +392,7 @@ const CSS = `
297
392
  .cg-more-panel:popover-open, .cg-more-panel--embedded{ opacity:1; transform:none; }
298
393
  @starting-style{ .cg-more-panel:popover-open, .cg-more-panel--embedded{ opacity:0; transform:translateY(8px) scale(.97); } }
299
394
  .cg-more-panel::backdrop{ background:transparent; }
300
- .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); }
301
396
  @media (prefers-reduced-motion:reduce){
302
397
  .cg-more-panel{ transform:none; transition:opacity 120ms ease; }
303
398
  @starting-style{ .cg-more-panel:popover-open, .cg-more-panel--embedded{ transform:none; } }
@@ -1,6 +1,6 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
2
 
3
- type IconType = ComponentType<{ size?: number; strokeWidth?: number; "aria-hidden"?: boolean | "true" }>;
3
+ type IconType = ComponentType<{ size?: number | string; strokeWidth?: number | string; "aria-hidden"?: boolean | "true" | "false" }>;
4
4
 
5
5
  type Props = {
6
6
  title: ReactNode;
@@ -1,6 +1,6 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
2
 
3
- type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
3
+ type IconType = ComponentType<{ size?: number | string; strokeWidth?: number | string; className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
4
4
 
5
5
  export type Fact = {
6
6
  Icon: IconType;
@@ -3,7 +3,7 @@ import { LanguageSwitcher, type Language } from "./LanguageSwitcher";
3
3
  import { List } from "./List";
4
4
 
5
5
  /** lucide-style icon component (the footer sets the size). */
6
- type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
6
+ type IconType = ComponentType<{ size?: number | string; strokeWidth?: number | string; className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
7
7
 
8
8
  export type FooterLink = { href: string; label: ReactNode };
9
9
  /** A titled column of links, for footers with many pages ("Foundations", "Cards…"). */
@@ -1,7 +1,7 @@
1
1
  import type { ComponentType, ElementType, ReactNode } from "react";
2
2
 
3
3
  /** lucide-style icon component (the card sets the size). */
4
- type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
4
+ type IconType = ComponentType<{ size?: number | string; strokeWidth?: number | string; className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
5
5
 
6
6
  export type InfoCardLink = {
7
7
  href: string;
@@ -3,7 +3,7 @@
3
3
  import { useEffect, useRef, useState, type ComponentType, type ElementType, type ReactNode } from "react";
4
4
 
5
5
  /** lucide-style icon component (the badge sets the size). */
6
- type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
6
+ type IconType = ComponentType<{ size?: number | string; strokeWidth?: number | string; className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
7
7
 
8
8
  /** Event the card videos listen to when the person pauses/resumes them all
9
9
  * (dispatch it on `window` after toggling the attribute below). */
@@ -39,7 +39,7 @@ export type ProfessionalModalLabels = {
39
39
 
40
40
  /** Lucide's icon type is wider than MediaCardBadge's `IconType` under strict
41
41
  * TS (propTypes allow a string `size`); this thin wrapper fits the slot. */
42
- const PlayIcon = (p: { size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }) => <Play {...p} />;
42
+ const PlayIcon = (p: { size?: number | string; strokeWidth?: number | string; className?: string; "aria-hidden"?: boolean | "true" | "false" }) => <Play {...p} />;
43
43
 
44
44
  const EN: ProfessionalModalLabels = {
45
45
  presentation: "Presentation",
@@ -21,7 +21,7 @@ type Props = {
21
21
  actions?: ReactNode;
22
22
  /** Social profiles at the foot (Instagram, LinkedIn, Lattes…): round icon
23
23
  * links, each with its name for screen readers, opening in a new tab. */
24
- social?: { label: string; href: string; Icon: ComponentType<{ size?: number; strokeWidth?: number; "aria-hidden"?: boolean | "true" }> }[];
24
+ social?: { label: string; href: string; Icon: ComponentType<{ size?: number | string; strokeWidth?: number | string; "aria-hidden"?: boolean | "true" | "false" }> }[];
25
25
  /** sr-only after each social link. */
26
26
  newTabLabel?: string;
27
27
  /** `card`: on a white card. `plain` (default): straight on the page color. */
@@ -1,6 +1,6 @@
1
1
  import type { ComponentType, ElementType, ReactNode } from "react";
2
2
 
3
- type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
3
+ type IconType = ComponentType<{ size?: number | string; strokeWidth?: number | string; className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
4
4
 
5
5
  export type TimelineStep = {
6
6
  Icon: IconType;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breathe-ds/ui",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
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",