@breathe-ds/ui 0.6.6 → 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. `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).
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`);
@@ -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
- const slim = collapsed && !open;
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
- {inMore.length > 0 && (
237
- <ul className="grid list-none gap-1">
238
- {inMore.map(({ href, label, Icon, active }, i) => (
239
- <li key={`${i}-${href}`}>
240
- <L href={href} aria-current={active ? "page" : undefined} onClick={close} className={row(active)}>
241
- <span className={bubble}><Icon aria-hidden="true" size={19} strokeWidth={1.75} /></span>
242
- {label}
243
- </L>
244
- </li>
245
- ))}
246
- </ul>
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
- onClickCapture={slim ? (e) => { e.preventDefault(); e.stopPropagation(); setCollapsed(false); } : undefined}
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breathe-ds/ui",
3
- "version": "0.6.6",
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",