@breathe-ds/ui 0.1.1 → 0.3.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/LICENSE +21 -0
- package/README.md +219 -142
- package/components/Accordion.tsx +17 -14
- package/components/AnchorNav.tsx +33 -21
- package/components/Avatar.tsx +37 -0
- package/components/BackgroundVideo.tsx +14 -14
- package/components/BottomNav.tsx +70 -69
- package/components/Breadcrumb.tsx +45 -0
- package/components/BreatheMark.tsx +12 -24
- package/components/Button.tsx +12 -12
- package/components/Byline.tsx +103 -0
- package/components/CaptionBand.tsx +74 -0
- package/components/Card.tsx +5 -5
- package/components/Container.tsx +4 -4
- package/components/CtaCard.tsx +52 -0
- package/components/Curtain.tsx +152 -0
- package/components/FactStrip.tsx +55 -0
- package/components/Footer.tsx +169 -77
- package/components/Header.tsx +32 -17
- package/components/Heading.tsx +86 -0
- package/components/InfoCard.tsx +116 -0
- package/components/LanguageSwitcher.tsx +139 -0
- package/components/List.tsx +81 -0
- package/components/Lockup.tsx +38 -55
- package/components/MapCard.tsx +158 -0
- package/components/MediaCard.tsx +266 -0
- package/components/PageVideoHero.tsx +53 -53
- package/components/PersonChip.tsx +49 -0
- package/components/Pill.tsx +42 -0
- package/components/PillList.tsx +21 -48
- package/components/ProfessionalCard.tsx +341 -0
- package/components/ProfessionalModal.tsx +243 -0
- package/components/Rows.tsx +131 -0
- package/components/SectionKicker.tsx +3 -3
- package/components/TestimonialCard.tsx +138 -0
- package/components/Text.tsx +31 -0
- package/components/Timeline.tsx +69 -0
- package/components/VideoInviteCard.tsx +168 -0
- package/components/VideoStoryCard.tsx +382 -0
- package/components/icons.tsx +6 -6
- package/components/marks/creeta.tsx +22 -22
- package/components/marks/ttcab.tsx +13 -13
- package/package.json +28 -5
- package/theme.css +88 -22
- package/tokens/creeta.css +14 -12
- package/tokens/neutral.css +16 -10
- package/tokens/ttcab.css +9 -8
- package/components/Cortina.tsx +0 -152
package/components/AnchorNav.tsx
CHANGED
|
@@ -5,30 +5,42 @@ import { Container } from "./Container";
|
|
|
5
5
|
|
|
6
6
|
export type AnchorItem = { id: string; label: string };
|
|
7
7
|
|
|
8
|
+
type Labels = {
|
|
9
|
+
/** aria-label of the anchor nav */
|
|
10
|
+
nav: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const EN: Labels = {
|
|
14
|
+
nav: "On this page",
|
|
15
|
+
};
|
|
16
|
+
|
|
8
17
|
type Props = {
|
|
9
18
|
items: AnchorItem[];
|
|
10
|
-
/**
|
|
19
|
+
/** Sticky offset class (header height). Default `top-20` (80px header). */
|
|
11
20
|
stickyTop?: string;
|
|
12
|
-
/**
|
|
21
|
+
/** The app's link component (e.g. next-intl Link). Default: <a>. */
|
|
13
22
|
linkComponent?: ElementType;
|
|
14
23
|
className?: string;
|
|
24
|
+
/** Strings (English defaults); the app passes the ones for its locale. */
|
|
25
|
+
labels?: Partial<Labels>;
|
|
15
26
|
};
|
|
16
27
|
|
|
17
28
|
/**
|
|
18
|
-
*
|
|
19
|
-
* (
|
|
20
|
-
* 1. Scroll-spy:
|
|
21
|
-
*
|
|
22
|
-
* 2.
|
|
23
|
-
* Width,
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* `scroll-mt-*`
|
|
29
|
+
* Contextual anchor navigation — floating capsule that follows the scroll
|
|
30
|
+
* (extracted from ttcab's TreatmentAnchorNav). Two behaviors:
|
|
31
|
+
* 1. Scroll-spy: an IntersectionObserver marks as active the section that
|
|
32
|
+
* crosses the band at ~35% from the top; the matching pill lights up.
|
|
33
|
+
* 2. Sliding indicator: an absolute <span> measures the active pill (offsetLeft/
|
|
34
|
+
* Width, immune to the capsule's horizontal scroll) and slides to it on the
|
|
35
|
+
* family's curve. On narrow screens the capsule scrolls and centers the active one.
|
|
36
|
+
* The active signal = the indicator (ink/12 fill + outline), NOT text color:
|
|
37
|
+
* the text is always `ink` on `surface`, with no low-contrast frame.
|
|
38
|
+
* prefers-reduced-motion removes the slide. Target sections need a
|
|
39
|
+
* `scroll-mt-*` compatible with the sticky offset (e.g. `scroll-mt-40`).
|
|
29
40
|
*/
|
|
30
|
-
export function AnchorNav({ items, stickyTop = "top-20", linkComponent, className = "" }: Props) {
|
|
41
|
+
export function AnchorNav({ items, stickyTop = "top-20", linkComponent, className = "", labels }: Props) {
|
|
31
42
|
const L: ElementType = linkComponent ?? "a";
|
|
43
|
+
const t = { ...EN, ...labels };
|
|
32
44
|
const [active, setActive] = useState(items[0]?.id ?? "");
|
|
33
45
|
const listRef = useRef<HTMLUListElement>(null);
|
|
34
46
|
const linkRefs = useRef<Record<string, HTMLAnchorElement | null>>({});
|
|
@@ -53,9 +65,9 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
|
|
|
53
65
|
return () => io.disconnect();
|
|
54
66
|
}, [items]);
|
|
55
67
|
|
|
56
|
-
//
|
|
57
|
-
// load
|
|
58
|
-
//
|
|
68
|
+
// Arriving with a #hash via client-side navigation: scrolls to the anchor (on
|
|
69
|
+
// a full load the browser already does it; here it's idempotent). Reapplies a
|
|
70
|
+
// few times to beat the router's late scroll reset.
|
|
59
71
|
useEffect(() => {
|
|
60
72
|
const hash = window.location.hash.slice(1);
|
|
61
73
|
if (!hash || !items.some((it) => it.id === hash)) return;
|
|
@@ -76,7 +88,7 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
|
|
|
76
88
|
setPill({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight, ready: true });
|
|
77
89
|
};
|
|
78
90
|
|
|
79
|
-
//
|
|
91
|
+
// Measures the active pill + reveals it in the capsule on narrow screens
|
|
80
92
|
useLayoutEffect(() => {
|
|
81
93
|
measure();
|
|
82
94
|
const el = linkRefs.current[active];
|
|
@@ -88,7 +100,7 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
|
|
|
88
100
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
89
101
|
}, [active]);
|
|
90
102
|
|
|
91
|
-
//
|
|
103
|
+
// Recalculates on resize (line breaks/fonts change the width)
|
|
92
104
|
useEffect(() => {
|
|
93
105
|
window.addEventListener("resize", measure);
|
|
94
106
|
return () => window.removeEventListener("resize", measure);
|
|
@@ -96,14 +108,14 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
|
|
|
96
108
|
}, [active]);
|
|
97
109
|
|
|
98
110
|
return (
|
|
99
|
-
<nav aria-label=
|
|
111
|
+
<nav aria-label={t.nav} className={`sticky ${stickyTop} z-30 py-3 ${className}`}>
|
|
100
112
|
<Container>
|
|
101
113
|
<div className="flex justify-center">
|
|
102
114
|
<ul
|
|
103
115
|
ref={listRef}
|
|
104
116
|
className="relative flex max-w-full list-none gap-1 overflow-x-auto rounded-pill border border-line bg-surface/95 p-1.5 shadow-[0_10px_28px_-14px_rgba(0,0,0,0.35)] backdrop-blur [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
105
117
|
>
|
|
106
|
-
{/*
|
|
118
|
+
{/* Sliding indicator */}
|
|
107
119
|
<span
|
|
108
120
|
aria-hidden="true"
|
|
109
121
|
className="pointer-events-none absolute top-0 left-0 rounded-pill bg-ink/[0.12] shadow-[inset_0_0_0_1px_var(--color-line)] transition-[transform,width,height,opacity] duration-[320ms] ease-cg motion-reduce:transition-none"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
/** Person's name: the initials fallback and nothing else (the image is decorative). */
|
|
5
|
+
name: string;
|
|
6
|
+
/** Photo src. Without it, the initials in a tinted bubble (as the team cards of novo.creeta). */
|
|
7
|
+
photo?: string;
|
|
8
|
+
/** Your own image element (e.g. next/image), replaces `photo`. */
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
/** Side in px. */
|
|
11
|
+
size?: number;
|
|
12
|
+
/** White ring, for stacked groups. */
|
|
13
|
+
ring?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const initials = (name: string) =>
|
|
18
|
+
name
|
|
19
|
+
.trim()
|
|
20
|
+
.split(/\s+/)
|
|
21
|
+
.filter((w) => w.length > 2 || w === name)
|
|
22
|
+
.slice(0, 2)
|
|
23
|
+
.map((w) => w[0]?.toUpperCase() ?? "")
|
|
24
|
+
.join("");
|
|
25
|
+
|
|
26
|
+
/** Round portrait, decorative (the name is written next to it). */
|
|
27
|
+
export function Avatar({ name, photo, children, size = 40, ring = false, className = "" }: Props) {
|
|
28
|
+
const style = { width: size, height: size, fontSize: Math.round(size * 0.36) };
|
|
29
|
+
const base = `inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full ${ring ? "ring-2 ring-card" : ""} ${className}`;
|
|
30
|
+
if (children) return <span aria-hidden="true" className={base} style={style}>{children}</span>;
|
|
31
|
+
if (photo) return <img src={photo} alt="" loading="lazy" decoding="async" className={`${base} object-cover`} style={style} />;
|
|
32
|
+
return (
|
|
33
|
+
<span aria-hidden="true" className={`${base} bg-ink/[0.12] font-heading font-bold text-ink`} style={style}>
|
|
34
|
+
{initials(name)}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -3,31 +3,31 @@
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
|
|
5
5
|
type Props = {
|
|
6
|
-
/**
|
|
6
|
+
/** Short, muted, looping clip (file hosted by the site itself). */
|
|
7
7
|
src: string;
|
|
8
|
-
/**
|
|
8
|
+
/** Larger version, downloaded only on wide screens (from `from` up). */
|
|
9
9
|
srcLarge?: string;
|
|
10
|
-
/**
|
|
10
|
+
/** Minimum width (px) for the larger version. Defaults to 1024. */
|
|
11
11
|
from?: 768 | 1024;
|
|
12
|
-
/**
|
|
12
|
+
/** Still frame: shown before the video loads, without JS, and with reduced motion. */
|
|
13
13
|
poster: string;
|
|
14
|
-
/**
|
|
14
|
+
/** Still frame of the larger version, when it is a different video (e.g. vertical on mobile, landscape on desktop). */
|
|
15
15
|
posterLarge?: string;
|
|
16
|
-
/**
|
|
16
|
+
/** Zooms the video in to crop edges baked into the file. */
|
|
17
17
|
trimEdges?: boolean;
|
|
18
|
-
/** object-position
|
|
18
|
+
/** object-position of the video (e.g. "center 20%" to keep the face in a vertical clip). */
|
|
19
19
|
objectPosition?: string;
|
|
20
|
-
/**
|
|
20
|
+
/** Pause button labels (injectable for i18n). */
|
|
21
21
|
labels?: { pause?: string; play?: string };
|
|
22
22
|
className?: string;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* (save-data).
|
|
26
|
+
* Background video of the Cuidado Global family — mirrors creeta's `VideoFundo`
|
|
27
|
+
* with the Breathe tokens (ink/surface). Muted looping clip, purely decorative:
|
|
28
|
+
* hidden from screen readers (aria-hidden). Does not use the `autoplay`
|
|
29
|
+
* attribute: it only starts after checking prefers-reduced-motion and data
|
|
30
|
+
* saver (save-data). Has a pause button (WCAG 2.2.2) in the top-right corner.
|
|
31
31
|
*/
|
|
32
32
|
export function BackgroundVideo({
|
|
33
33
|
src,
|
|
@@ -93,7 +93,7 @@ export function BackgroundVideo({
|
|
|
93
93
|
<button
|
|
94
94
|
type="button"
|
|
95
95
|
onClick={toggle}
|
|
96
|
-
aria-label={running ? labels?.pause ?? "
|
|
96
|
+
aria-label={running ? labels?.pause ?? "Pause video" : labels?.play ?? "Play video"}
|
|
97
97
|
className="absolute top-4 right-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-ink/85 text-surface backdrop-blur-md transition-colors duration-200 hover:bg-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface motion-reduce:transition-none"
|
|
98
98
|
>
|
|
99
99
|
{running ? (
|
package/components/BottomNav.tsx
CHANGED
|
@@ -2,75 +2,75 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useId, useRef, useState, type ComponentType, type ElementType, type ReactNode } from "react";
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/** Icon in the lucide pattern (the component, not the node): the bar decides the size. */
|
|
6
6
|
export type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
*
|
|
8
|
+
/** One page of the site, in order of importance. The first ones stay on the bar;
|
|
9
|
+
* those that don't fit go into "More". */
|
|
10
10
|
export type BottomPage = {
|
|
11
11
|
href: string;
|
|
12
12
|
label: ReactNode;
|
|
13
13
|
Icon: IconType;
|
|
14
|
-
/**
|
|
14
|
+
/** Is this the current page? (the app decides: route/locale are its own) */
|
|
15
15
|
active?: boolean;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
18
|
+
/** Extra exit in the "More" panel (e.g. courses on another site): address written
|
|
19
|
+
* underneath and an outward arrow, as on novo.creeta. */
|
|
20
20
|
export type BottomExtra = {
|
|
21
21
|
href: string;
|
|
22
22
|
label: ReactNode;
|
|
23
23
|
Icon: IconType;
|
|
24
|
-
/**
|
|
24
|
+
/** Smaller line under the label (e.g. the domain). With `external` and without this, shows the href's domain. */
|
|
25
25
|
description?: ReactNode;
|
|
26
26
|
external?: boolean;
|
|
27
27
|
active?: boolean;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
type Labels = {
|
|
31
|
-
/** aria-label
|
|
31
|
+
/** aria-label of the bar */
|
|
32
32
|
nav: string;
|
|
33
|
-
/**
|
|
33
|
+
/** label of the "More" tab */
|
|
34
34
|
more: string;
|
|
35
|
-
/** aria-label
|
|
35
|
+
/** aria-label of the panel */
|
|
36
36
|
morePages: string;
|
|
37
|
-
/** sr-only
|
|
37
|
+
/** sr-only after "More": "more pages" */
|
|
38
38
|
morePagesSr: string;
|
|
39
|
-
/** sr-only
|
|
39
|
+
/** sr-only when the current page is inside "More" */
|
|
40
40
|
currentHere: string;
|
|
41
|
-
/** sr-only
|
|
41
|
+
/** sr-only on an external link */
|
|
42
42
|
newTab: string;
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
-
const
|
|
46
|
-
nav: "
|
|
47
|
-
more: "
|
|
48
|
-
morePages: "
|
|
49
|
-
morePagesSr: "
|
|
50
|
-
currentHere: "(
|
|
51
|
-
newTab: "(
|
|
45
|
+
const EN: Labels = {
|
|
46
|
+
nav: "Main navigation",
|
|
47
|
+
more: "More",
|
|
48
|
+
morePages: "More pages",
|
|
49
|
+
morePagesSr: "more pages",
|
|
50
|
+
currentHere: "(the current page is here)",
|
|
51
|
+
newTab: "(opens in a new tab)",
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
type Props = {
|
|
55
55
|
pages: BottomPage[];
|
|
56
56
|
extras?: BottomExtra[];
|
|
57
|
-
/**
|
|
57
|
+
/** The app's link component (e.g. next-intl Link). Default: <a>. */
|
|
58
58
|
linkComponent?: ElementType;
|
|
59
|
-
/**
|
|
59
|
+
/** From which width the header takes over and the bar disappears. creeta: `md`; ttcab: `lg`. */
|
|
60
60
|
hideFrom?: "md" | "lg";
|
|
61
|
-
/**
|
|
61
|
+
/** Strings (English defaults); the app passes the ones for its locale. */
|
|
62
62
|
labels?: Partial<Labels>;
|
|
63
|
-
/**
|
|
64
|
-
*
|
|
63
|
+
/** Embedded in a `relative` container (demo, phone frame): bar, veil and
|
|
64
|
+
* panel are `absolute` inside it and show at any width. */
|
|
65
65
|
embedded?: boolean;
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
/**
|
|
68
|
+
/** Minimum width of a tab, in px: below this the label doesn't fit. */
|
|
69
69
|
export const TAB_MIN_WIDTH = 64;
|
|
70
70
|
|
|
71
|
-
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
71
|
+
/** How many pages show directly on the bar, for a given usable width. The last
|
|
72
|
+
* tab is always "More" (that's where the pages that didn't fit and the extra
|
|
73
|
+
* exits live), so one page fewer than the number of tabs fits. */
|
|
74
74
|
export function howManyFit(width: number, total: number): number {
|
|
75
75
|
const tabs = Math.floor(width / TAB_MIN_WIDTH);
|
|
76
76
|
return Math.max(2, Math.min(total, tabs - 1));
|
|
@@ -83,9 +83,9 @@ const ArrowUpRight = () => (
|
|
|
83
83
|
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-[18px] w-[18px] shrink-0" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
84
84
|
);
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
86
|
+
// Tab: icon+label column. The active one gets a SHAPE (light pill), not just color —
|
|
87
|
+
// readable for people who can't tell colors apart. Shrinks on touch, like an app button.
|
|
88
|
+
// The view-transition name makes the highlight slide between tabs on page change.
|
|
89
89
|
const tab = (active: boolean) =>
|
|
90
90
|
[
|
|
91
91
|
"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",
|
|
@@ -101,29 +101,29 @@ const row = (active?: boolean) =>
|
|
|
101
101
|
const bubble = "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-ink/[0.12] text-ink";
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
104
|
+
* Main navigation on mobile: floating bar within thumb reach, app-like. It is
|
|
105
|
+
* the code of novo.creeta's BarraNavegacao (and ttcab's BottomNav), made
|
|
106
|
+
* neutral through tokens: `ink` on the bar, `surface` on the active tab.
|
|
107
107
|
*
|
|
108
|
-
* -
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* -
|
|
112
|
-
* -
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* -
|
|
108
|
+
* - Dynamic: shows as many pages as fit the width (ResizeObserver, 64px per
|
|
109
|
+
* tab). The last tab is always "More", holding the rest and the extra
|
|
110
|
+
* exits. On a typical phone that's four pages plus "More".
|
|
111
|
+
* - Veil behind the bar: content fades out gradually before reaching it.
|
|
112
|
+
* - The "More" panel is a native popover: closes on Esc and on tap outside,
|
|
113
|
+
* returns focus to the button and opens even without JavaScript. Embedded
|
|
114
|
+
* (demo), it becomes an absolute panel inside the frame, with the same entrance.
|
|
115
|
+
* - Hidden from `hideFrom` up (md on creeta, lg on ttcab), where the header takes over.
|
|
116
116
|
*/
|
|
117
117
|
export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg", labels, embedded = false }: Props) {
|
|
118
118
|
const L: ElementType = linkComponent ?? "a";
|
|
119
|
-
const t = { ...
|
|
119
|
+
const t = { ...EN, ...labels };
|
|
120
120
|
const list = useRef<HTMLUListElement>(null);
|
|
121
121
|
const panel = useRef<HTMLDivElement>(null);
|
|
122
122
|
const wrap = useRef<HTMLDivElement>(null);
|
|
123
123
|
const panelId = useId();
|
|
124
|
-
//
|
|
124
|
+
// before measuring: the typical phone case (5 tabs = 4 pages + More)
|
|
125
125
|
const [visible, setVisible] = useState(() => howManyFit(5 * TAB_MIN_WIDTH, pages.length));
|
|
126
|
-
const [open, setOpen] = useState(false); //
|
|
126
|
+
const [open, setOpen] = useState(false); // embedded mode only
|
|
127
127
|
|
|
128
128
|
useEffect(() => {
|
|
129
129
|
const ul = list.current;
|
|
@@ -145,15 +145,15 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
145
145
|
try {
|
|
146
146
|
if (el?.matches(":popover-open")) el.hidePopover?.();
|
|
147
147
|
} catch {
|
|
148
|
-
/*
|
|
148
|
+
/* browser without popover support: nothing to close */
|
|
149
149
|
}
|
|
150
150
|
};
|
|
151
151
|
|
|
152
|
-
//
|
|
152
|
+
// Page changed: the panel closes.
|
|
153
153
|
const activeHref = pages.find((p) => p.active)?.href;
|
|
154
154
|
useEffect(close, [activeHref]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
155
155
|
|
|
156
|
-
//
|
|
156
|
+
// Embedded: closes on Esc and on tap outside (the native popover does this by itself).
|
|
157
157
|
useEffect(() => {
|
|
158
158
|
if (!embedded || !open) return;
|
|
159
159
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
|
@@ -173,11 +173,12 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
173
173
|
const inMore = pages.slice(visible);
|
|
174
174
|
const moreActive = inMore.some((p) => p.active) || extras.some((x) => x.active);
|
|
175
175
|
const pos = embedded ? "absolute" : "fixed";
|
|
176
|
-
|
|
176
|
+
// full class names (Tailwind only generates what it finds written in the code)
|
|
177
|
+
const hide = embedded ? "" : hideFrom === "md" ? " md:hidden" : " lg:hidden";
|
|
177
178
|
|
|
178
179
|
const panelInner = (
|
|
179
180
|
<nav aria-label={t.morePages}>
|
|
180
|
-
{/*
|
|
181
|
+
{/* One item per row: in two columns the panel got confusing. */}
|
|
181
182
|
{inMore.length > 0 && (
|
|
182
183
|
<ul className="grid list-none gap-1">
|
|
183
184
|
{inMore.map(({ href, label, Icon, active }, i) => (
|
|
@@ -223,8 +224,8 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
223
224
|
return (
|
|
224
225
|
<div ref={wrap} className={embedded ? "contents" : undefined}>
|
|
225
226
|
<style>{CSS}</style>
|
|
226
|
-
{/*
|
|
227
|
-
|
|
227
|
+
{/* Veil behind the bar: scrolling content fades out gradually before
|
|
228
|
+
reaching it, instead of showing up clipped by the edges. */}
|
|
228
229
|
<div
|
|
229
230
|
aria-hidden="true"
|
|
230
231
|
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}`}
|
|
@@ -246,7 +247,7 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
246
247
|
</li>
|
|
247
248
|
))}
|
|
248
249
|
<li className="min-w-0 flex-1">
|
|
249
|
-
{/* popovertarget
|
|
250
|
+
{/* popovertarget in lowercase: React 18 doesn't know the attribute and passes it through as is. */}
|
|
250
251
|
<button
|
|
251
252
|
type="button"
|
|
252
253
|
{...(embedded ? { "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen((o) => !o) } : { popovertarget: panelId })}
|
|
@@ -262,12 +263,12 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
262
263
|
|
|
263
264
|
{embedded ? (
|
|
264
265
|
open && (
|
|
265
|
-
<div id={panelId} className="cg-
|
|
266
|
+
<div id={panelId} className="cg-more-panel cg-more-panel--embedded">
|
|
266
267
|
{panelInner}
|
|
267
268
|
</div>
|
|
268
269
|
)
|
|
269
270
|
) : (
|
|
270
|
-
<div ref={panel} id={panelId} {...{ popover: "auto" }} className={`cg-
|
|
271
|
+
<div ref={panel} id={panelId} {...{ popover: "auto" }} className={`cg-more-panel cg-more-panel--${hideFrom}`}>
|
|
271
272
|
{panelInner}
|
|
272
273
|
</div>
|
|
273
274
|
)}
|
|
@@ -275,26 +276,26 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
275
276
|
);
|
|
276
277
|
}
|
|
277
278
|
|
|
278
|
-
/*
|
|
279
|
-
|
|
279
|
+
/* "More" panel: right above the bar; enters growing from the corner of the
|
|
280
|
+
"More" button, in 0.18s (same as novo.creeta's .mais-painel). */
|
|
280
281
|
const CSS = `
|
|
281
|
-
.cg-
|
|
282
|
+
.cg-more-panel{
|
|
282
283
|
position:fixed; inset:auto .75rem calc(5.75rem + env(safe-area-inset-bottom)) .75rem;
|
|
283
284
|
width:auto; margin:0; border:0; border-radius:26px; padding:.5rem;
|
|
284
|
-
background
|
|
285
|
+
background:var(--color-card); color:var(--color-ink);
|
|
285
286
|
box-shadow:0 20px 48px -16px rgb(0 0 0 / .35), 0 1px 2px rgb(0 0 0 / .1);
|
|
286
287
|
opacity:0; transform:translateY(8px) scale(.97); transform-origin:88% 100%;
|
|
287
288
|
transition:opacity 180ms cubic-bezier(.23,1,.32,1), transform 180ms cubic-bezier(.23,1,.32,1), overlay 180ms allow-discrete, display 180ms allow-discrete;
|
|
288
289
|
}
|
|
289
|
-
.cg-
|
|
290
|
-
@starting-style{ .cg-
|
|
291
|
-
.cg-
|
|
292
|
-
.cg-
|
|
290
|
+
.cg-more-panel:popover-open, .cg-more-panel--embedded{ opacity:1; transform:none; }
|
|
291
|
+
@starting-style{ .cg-more-panel:popover-open, .cg-more-panel--embedded{ opacity:0; transform:translateY(8px) scale(.97); } }
|
|
292
|
+
.cg-more-panel::backdrop{ background:transparent; }
|
|
293
|
+
.cg-more-panel--embedded{ position:absolute; z-index:50; }
|
|
293
294
|
@media (prefers-reduced-motion:reduce){
|
|
294
|
-
.cg-
|
|
295
|
-
@starting-style{ .cg-
|
|
295
|
+
.cg-more-panel{ transform:none; transition:opacity 120ms ease; }
|
|
296
|
+
@starting-style{ .cg-more-panel:popover-open, .cg-more-panel--embedded{ transform:none; } }
|
|
296
297
|
}
|
|
297
|
-
/*
|
|
298
|
-
@media (min-width:768px){ .cg-
|
|
299
|
-
@media (min-width:1024px){ .cg-
|
|
298
|
+
/* From the breakpoint up the bar doesn't exist: neither does the panel. */
|
|
299
|
+
@media (min-width:768px){ .cg-more-panel--md{ display:none !important; } }
|
|
300
|
+
@media (min-width:1024px){ .cg-more-panel--lg{ display:none !important; } }
|
|
300
301
|
`;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type Crumb = { label: ReactNode; href: string };
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
/** From the section the page belongs to down to the parent of the current page. */
|
|
7
|
+
items: Crumb[];
|
|
8
|
+
linkComponent?: ElementType;
|
|
9
|
+
/** aria-label of the nav. */
|
|
10
|
+
label?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// trail link: no resting underline (two underlines side by side weighed too much);
|
|
15
|
+
// it shows on hover and on focus
|
|
16
|
+
const link =
|
|
17
|
+
"inline-flex min-h-11 items-center text-ink underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:decoration-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Breadcrumb — the trail of a novo.creeta article: "← Section › Parent", so
|
|
21
|
+
* the text starts right away. The first crumb carries the back arrow; the
|
|
22
|
+
* current page is not listed (its title follows).
|
|
23
|
+
*/
|
|
24
|
+
export function Breadcrumb({ items, linkComponent, label = "Trail", className = "" }: Props) {
|
|
25
|
+
const L: ElementType = linkComponent ?? "a";
|
|
26
|
+
return (
|
|
27
|
+
<nav aria-label={label} className={className}>
|
|
28
|
+
<ol className="m-0 flex list-none flex-wrap items-center gap-x-2 p-0 text-sm font-semibold text-ink/80">
|
|
29
|
+
{items.map((c, i) => (
|
|
30
|
+
<li key={`${i}-${c.href}`} className="flex items-center gap-x-2">
|
|
31
|
+
{i > 0 && (
|
|
32
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={14} height={14} className="text-ink/50" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m9 18 6-6-6-6" /></svg>
|
|
33
|
+
)}
|
|
34
|
+
<L href={c.href} className={`${link} ${i === 0 ? "gap-1.5" : ""}`}>
|
|
35
|
+
{i === 0 && (
|
|
36
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12 19-7-7 7-7M19 12H5" /></svg>
|
|
37
|
+
)}
|
|
38
|
+
{c.label}
|
|
39
|
+
</L>
|
|
40
|
+
</li>
|
|
41
|
+
))}
|
|
42
|
+
</ol>
|
|
43
|
+
</nav>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
type Props = {
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
/** Side in px (square). This is the size of the visible glyph: the SVG box
|
|
3
|
+
* is tight to the ring + dot, with no inner padding (the space between
|
|
4
|
+
* symbol and name is up to the layout). */
|
|
5
5
|
size?: number;
|
|
6
|
-
/**
|
|
6
|
+
/** Seconds of one full breathing cycle (inhale + exhale). */
|
|
7
7
|
duration?: number;
|
|
8
|
-
/**
|
|
8
|
+
/** Animate? (still respects prefers-reduced-motion). */
|
|
9
9
|
animated?: boolean;
|
|
10
10
|
className?: string;
|
|
11
11
|
title?: string;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* "
|
|
15
|
+
* Living symbol of Breathe: an open ring (a fluid, continuous gesture) with a
|
|
16
|
+
* dot at the breath gap, an abstraction of the creeta logo (round shape + dot).
|
|
17
|
+
* NEUTRAL: uses currentColor and inherits the color from context (the palette
|
|
18
|
+
* comes from each app). It "breathes" with a soft scale pulse; respects
|
|
19
|
+
* prefers-reduced-motion.
|
|
19
20
|
*/
|
|
20
21
|
export function BreatheMark({
|
|
21
22
|
size = 96,
|
|
@@ -26,19 +27,6 @@ export function BreatheMark({
|
|
|
26
27
|
}: Props) {
|
|
27
28
|
return (
|
|
28
29
|
<span className={className} style={{ display: "inline-flex", lineHeight: 0 }}>
|
|
29
|
-
<style>{`
|
|
30
|
-
@keyframes breathe-mark-inhale {
|
|
31
|
-
0%, 100% { transform: scale(0.93); }
|
|
32
|
-
50% { transform: scale(1.05); }
|
|
33
|
-
}
|
|
34
|
-
.breathe-mark__g {
|
|
35
|
-
transform-box: fill-box;
|
|
36
|
-
transform-origin: center;
|
|
37
|
-
animation: breathe-mark-inhale var(--breathe-dur, 6s) cubic-bezier(0.37, 0, 0.63, 1) infinite;
|
|
38
|
-
}
|
|
39
|
-
.breathe-mark--static .breathe-mark__g { animation: none; }
|
|
40
|
-
@media (prefers-reduced-motion: reduce) { .breathe-mark__g { animation: none; } }
|
|
41
|
-
`}</style>
|
|
42
30
|
<svg
|
|
43
31
|
width={size}
|
|
44
32
|
height={size}
|
|
@@ -50,7 +38,7 @@ export function BreatheMark({
|
|
|
50
38
|
style={{ ["--breathe-dur" as string]: `${duration}s` }}
|
|
51
39
|
>
|
|
52
40
|
<g className="breathe-mark__g">
|
|
53
|
-
{/*
|
|
41
|
+
{/* open ring: a continuous gesture with a breath gap at the top */}
|
|
54
42
|
<path
|
|
55
43
|
d="M 42 17.5 A 17 17 0 1 1 22 17.5"
|
|
56
44
|
fill="none"
|
|
@@ -58,7 +46,7 @@ export function BreatheMark({
|
|
|
58
46
|
strokeWidth="8.5"
|
|
59
47
|
strokeLinecap="round"
|
|
60
48
|
/>
|
|
61
|
-
{/*
|
|
49
|
+
{/* the breath dot */}
|
|
62
50
|
<circle cx="32" cy="14.5" r="4.4" fill="currentColor" />
|
|
63
51
|
</g>
|
|
64
52
|
</svg>
|