@breathe-ds/ui 0.6.5 → 0.6.6

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).
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". */
@@ -66,6 +66,12 @@ type Props = {
66
66
  /** Embedded in a `relative` container (demo, phone frame): bar, veil and
67
67
  * panel are `absolute` inside it and show at any width. */
68
68
  embedded?: boolean;
69
+ /** Reading mode: scrolling down shrinks the bar to a slim icon strip at the
70
+ * bottom; scrolling up, tapping it or reaching it by keyboard brings it back.
71
+ * Always full at the top and at the end of the page. Off by default. */
72
+ collapseOnScroll?: boolean;
73
+ /** Element that scrolls, when it isn't the window (e.g. the demo frame). */
74
+ scrollContainer?: RefObject<HTMLElement | null>;
69
75
  };
70
76
 
71
77
  /** Minimum width of a tab, in px: below this the label doesn't fit. */
@@ -93,14 +99,18 @@ const ArrowUpRight = () => (
93
99
  // Tab: icon+label column. The active one gets a SHAPE (light pill), not just color —
94
100
  // readable for people who can't tell colors apart. Shrinks on touch, like an app button.
95
101
  // The view-transition name makes the highlight slide between tabs on page change.
96
- const tab = (active: boolean) =>
102
+ const tab = (active: boolean, collapsed = false) =>
97
103
  [
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",
104
+ "flex w-full flex-col items-center justify-center rounded-[20px] px-1 text-[10.5px] font-medium leading-none tracking-tight",
105
+ collapsed ? "h-9 gap-0" : "h-14 gap-1",
106
+ "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
107
  "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface",
101
108
  active ? "bg-surface text-ink [view-transition-name:aba-ativa]" : "text-surface/85 hover:bg-white/10",
102
109
  ].join(" ");
103
110
 
111
+ // label under the icon: in the slim bar it folds away (still read by screen readers)
112
+ const LABEL = "w-full truncate text-center transition-all duration-200 motion-reduce:transition-none";
113
+
104
114
  const row = (active?: boolean) =>
105
115
  `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
116
  active ? "bg-surface text-ink" : "text-ink"
@@ -121,7 +131,7 @@ const bubble = "flex h-10 w-10 shrink-0 items-center justify-center rounded-full
121
131
  * (demo), it becomes an absolute panel inside the frame, with the same entrance.
122
132
  * - Hidden from `hideFrom` up (md on creeta, lg on ttcab), where the header takes over.
123
133
  */
124
- export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg", labels, embedded = false }: Props) {
134
+ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg", labels, embedded = false, collapseOnScroll = false, scrollContainer }: Props) {
125
135
  const L: ElementType = linkComponent ?? "a";
126
136
  const t = { ...EN, ...labels };
127
137
  const list = useRef<HTMLUListElement>(null);
@@ -131,6 +141,43 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
131
141
  // before measuring: the typical phone case (5 tabs = 4 pages + More)
132
142
  const [visible, setVisible] = useState(() => howManyFit(5 * TAB_MIN_WIDTH, pages.length));
133
143
  const [open, setOpen] = useState(false); // embedded mode only
144
+ const [collapsed, setCollapsed] = useState(false);
145
+
146
+ // Reading mode: direction of the scroll, with a small dead zone so a shaky
147
+ // thumb doesn't make the bar blink. Down 24px → slim; up 12px → full.
148
+ useEffect(() => {
149
+ if (!collapseOnScroll) return setCollapsed(false);
150
+ const el = scrollContainer?.current ?? null;
151
+ const y = () => (el ? el.scrollTop : window.scrollY);
152
+ const atEnd = () =>
153
+ el ? el.scrollTop + el.clientHeight >= el.scrollHeight - 8 : window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 8;
154
+ let last = y();
155
+ let run = 0;
156
+ let raf = 0;
157
+ const onScroll = () => {
158
+ if (raf) return;
159
+ raf = requestAnimationFrame(() => {
160
+ raf = 0;
161
+ const now = y();
162
+ const d = now - last;
163
+ last = now;
164
+ if (now < 80 || atEnd()) {
165
+ run = 0;
166
+ return setCollapsed(false);
167
+ }
168
+ run = Math.sign(d) === Math.sign(run) ? run + d : d;
169
+ if (run > 24) setCollapsed(true);
170
+ else if (run < -12) setCollapsed(false);
171
+ });
172
+ };
173
+ const target: HTMLElement | Window = el ?? window;
174
+ target.addEventListener("scroll", onScroll, { passive: true });
175
+ return () => {
176
+ target.removeEventListener("scroll", onScroll);
177
+ cancelAnimationFrame(raf);
178
+ };
179
+ }, [collapseOnScroll, scrollContainer]);
180
+ const slim = collapsed && !open;
134
181
 
135
182
  useEffect(() => {
136
183
  const ul = list.current;
@@ -235,21 +282,25 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
235
282
  reaching it, instead of showing up clipped by the edges. */}
236
283
  <div
237
284
  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}`}
285
+ 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
286
  />
240
287
  <nav
241
288
  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}`}
289
+ 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
+ // slim: a tap anywhere on the bar only brings it back (doesn't navigate);
291
+ // keyboard focus brings it back too (a mouse/touch focus doesn't, or the tap would navigate)
292
+ onClickCapture={slim ? (e) => { e.preventDefault(); e.stopPropagation(); setCollapsed(false); } : undefined}
293
+ onFocus={(e) => { if ((e.target as Element).matches?.(":focus-visible")) setCollapsed(false); }}
243
294
  >
244
295
  <ul
245
296
  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"
297
+ 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
298
  >
248
299
  {onBar.map(({ href, label, Icon, active }, i) => (
249
300
  <li key={`${i}-${href}`} className="min-w-0 flex-1">
250
- <L href={href} aria-current={active ? "page" : undefined} className={tab(!!active)}>
301
+ <L href={href} aria-current={active ? "page" : undefined} className={tab(!!active, slim)}>
251
302
  <Icon aria-hidden="true" size={20} strokeWidth={1.75} />
252
- <span className="w-full truncate text-center">{label}</span>
303
+ <span className={`${LABEL} ${slim ? "max-h-0 opacity-0" : "max-h-4 opacity-100"}`}>{label}</span>
253
304
  </L>
254
305
  </li>
255
306
  ))}
@@ -258,10 +309,10 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
258
309
  <button
259
310
  type="button"
260
311
  {...(embedded ? { "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen((o) => !o) } : { [POPOVER_TARGET]: panelId })}
261
- className={tab(embedded ? open || moreActive : moreActive)}
312
+ className={tab(embedded ? open || moreActive : moreActive, slim)}
262
313
  >
263
314
  <MenuIcon />
264
- <span className="w-full truncate text-center">{t.more}</span>
315
+ <span className={`${LABEL} ${slim ? "max-h-0 opacity-0" : "max-h-4 opacity-100"}`}>{t.more}</span>
265
316
  <span className="sr-only"> {t.morePagesSr}{moreActive ? ` ${t.currentHere}` : ""}</span>
266
317
  </button>
267
318
  </li>
@@ -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.6",
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",