@helloimjolopez-pathway/pathway-components 0.1.1 → 0.2.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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Tooltip's standalone demo. Imports the real module, so there is nothing here
3
+ * that can fall out of step with the shipped component: see
4
+ * components/_demo/harness.jsx for why the newer demos are generated.
5
+ */
6
+ import React, { useState } from "react";
7
+ import { DemoChrome, mount } from "../_demo/harness.jsx";
8
+ import { Tooltip, TooltipProvider } from "./tooltip.jsx";
9
+ import { Button } from "../button/button.jsx";
10
+
11
+ const U = (n) => `var(--semantic-layout-units-${n})`;
12
+ const C = (n) => `var(--semantic-color-${n})`;
13
+ const Y = (n) => `var(--semantic-type-${n})`;
14
+
15
+ const SIDES = ["top", "bottom", "left", "right"];
16
+ const ALIGNS = ["start", "center", "end"];
17
+ const LONG = "Weekly totals aren't available because budgets are set by month. Switch to Month to compare.";
18
+
19
+ const label = { fontSize: Y("font-size-xs"), lineHeight: Y("line-height-xs-single"), color: C("foreground-static-neutral-subtle") };
20
+ const card = {
21
+ background: C("fill-surface-elevated"), border: `1px solid ${C("stroke-static-neutral-base")}`,
22
+ borderRadius: U("cornerradius-large"), padding: U("padding-relaxed"),
23
+ display: "flex", flexDirection: "column", gap: U("gap-base"), minWidth: 0,
24
+ };
25
+ const h2 = { margin: 0, fontSize: Y("font-size-m"), lineHeight: Y("line-height-m-single"), fontWeight: Y("weight-semibold"), color: C("foreground-static-neutral-bold") };
26
+
27
+ /* An unavailable option stays focusable so it can say why, which a native
28
+ disabled button cannot. This stands in for a Toggle Button option. */
29
+ const Unavailable = React.forwardRef(function Unavailable({ children, ...rest }, ref) {
30
+ return (
31
+ <button ref={ref} type="button" aria-disabled="true" {...rest}
32
+ style={{ minWidth: U("accessibility-touch-target-aa-width"), minHeight: U("accessibility-touch-target-aa-height"),
33
+ padding: `0 ${U("padding-tight")}`, border: 0, borderRadius: U("cornerradius-base"), cursor: "not-allowed",
34
+ background: C("fill-action-disabled"), color: C("foreground-action-disabled"),
35
+ fontFamily: Y("family-brand"), fontSize: Y("font-size-s"), fontWeight: Y("weight-medium") }}>
36
+ {children}
37
+ </button>
38
+ );
39
+ });
40
+
41
+ function Playground() {
42
+ const [side, setSide] = useState("top");
43
+ const [align, setAlign] = useState("center");
44
+ const [arrow, setArrow] = useState(true);
45
+ const [text, setText] = useState("Bar chart");
46
+ const pick = (list, v, set) => (
47
+ <div style={{ display: "flex", gap: U("gap-xtight"), flexWrap: "wrap" }}>
48
+ {list.map((x) => <Button key={x} size="S" buttonStyle={x === v ? "Fill" : "Outlined"} type="Secondary" text={x} onClick={() => set(x)} />)}
49
+ </div>
50
+ );
51
+ return (
52
+ <section style={card}>
53
+ <h2 style={h2}>Playground</h2>
54
+ <div style={{ display: "flex", gap: U("gap-relaxed"), flexWrap: "wrap" }}>
55
+ <div><div style={label}>Side</div>{pick(SIDES, side, setSide)}</div>
56
+ <div><div style={label}>Align</div>{pick(ALIGNS, align, setAlign)}</div>
57
+ <div><div style={label}>Arrow</div>{pick(["on", "off"], arrow ? "on" : "off", (x) => setArrow(x === "on"))}</div>
58
+ </div>
59
+ <label style={{ ...label, display: "flex", flexDirection: "column", gap: U("gap-xxtight"), maxWidth: 420 }}>
60
+ Content
61
+ <input id="tooltip-content" value={text} onChange={(e) => setText(e.target.value)}
62
+ style={{ font: "inherit", fontSize: Y("font-size-s"), padding: U("padding-xtight"), borderRadius: U("cornerradius-small"),
63
+ border: `1px solid ${C("stroke-static-neutral-bold")}`, background: C("fill-surface-elevated"), color: C("foreground-static-neutral-base") }} />
64
+ </label>
65
+ <div style={{ minHeight: 160, display: "flex", alignItems: "center", justifyContent: "center" }}>
66
+ <Tooltip content={text} side={side} align={align} arrow={arrow} open purpose="description">
67
+ <button type="button" style={{ width: 120, height: 44, border: `1px dashed ${C("stroke-static-neutral-bold")}`,
68
+ borderRadius: U("cornerradius-base"), background: "transparent", color: C("foreground-static-neutral-subtle"), font: "inherit" }}>
69
+ Trigger
70
+ </button>
71
+ </Tooltip>
72
+ </div>
73
+ </section>
74
+ );
75
+ }
76
+
77
+ function Uses() {
78
+ return (
79
+ <section style={card}>
80
+ <h2 style={h2}>Try it: hover, Tab, tap, Esc</h2>
81
+ <div style={{ display: "flex", gap: U("gap-wide"), flexWrap: "wrap", alignItems: "flex-end" }}>
82
+ <div style={{ display: "flex", flexDirection: "column", gap: U("gap-tight") }}>
83
+ <span style={label}>Names icon-only controls</span>
84
+ <div style={{ display: "flex", gap: U("gap-xxtight") }}>
85
+ {[["bar_chart", "Bar chart"], ["show_chart", "Line chart"], ["table_rows", "Table"]].map(([icon, name]) => (
86
+ <Tooltip key={icon} content={name} purpose="label">
87
+ <Button buttonStyle="Naked" type="Secondary" size="M" showText={false} text={name}
88
+ leadingIcon={icon} showLeadingIcon />
89
+ </Tooltip>
90
+ ))}
91
+ </div>
92
+ </div>
93
+ <div style={{ display: "flex", flexDirection: "column", gap: U("gap-tight") }}>
94
+ <span style={label}>Says why an option is unavailable</span>
95
+ <Tooltip content="Weekly totals aren't available. Budgets are set by month." persistOnPress>
96
+ <Unavailable>Week</Unavailable>
97
+ </Tooltip>
98
+ </div>
99
+ <div style={{ display: "flex", flexDirection: "column", gap: U("gap-tight") }}>
100
+ <span style={label}>Adds a short hint</span>
101
+ <Tooltip content="Includes payments that haven't cleared yet.">
102
+ <Button buttonStyle="Naked" type="Secondary" size="M" showText={false} text="About this balance"
103
+ leadingIcon="info" showLeadingIcon />
104
+ </Tooltip>
105
+ </div>
106
+ </div>
107
+ </section>
108
+ );
109
+ }
110
+
111
+ function Grid() {
112
+ return (
113
+ <section style={card}>
114
+ <h2 style={h2}>Sides and alignment</h2>
115
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: U("gap-base") }}>
116
+ {SIDES.flatMap((s) => ALIGNS.map((a) => (
117
+ <div key={s + a} style={{ minHeight: 150, display: "flex", flexDirection: "column", gap: U("gap-tight"),
118
+ border: `1px dashed ${C("stroke-static-neutral-base")}`, borderRadius: U("cornerradius-base"), padding: U("padding-tight") }}>
119
+ <span style={label}>{s}, {a}</span>
120
+ <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
121
+ <Tooltip content={a === "center" ? "Bar chart" : LONG.slice(0, 44)} side={s} align={a} open>
122
+ <span tabIndex={-1} style={{ width: 28, height: 28, borderRadius: U("cornerradius-small"), background: C("fill-static-neutral-base") }} />
123
+ </Tooltip>
124
+ </div>
125
+ </div>
126
+ )))}
127
+ </div>
128
+ </section>
129
+ );
130
+ }
131
+
132
+ function App() {
133
+ return (
134
+ <DemoChrome title="Tooltip"
135
+ blurb={"A short, plain-text label beside a control. It is always the opposite mode to the page: " +
136
+ "switch to Midnight to see it turn light. Hover waits 300ms, keyboard focus opens it at once, " +
137
+ "a tap shows it for 2.6 seconds, Esc closes it."}>
138
+ <TooltipProvider>
139
+ <div style={{ display: "flex", flexDirection: "column", gap: U("gap-relaxed") }}>
140
+ <Uses />
141
+ <Playground />
142
+ <Grid />
143
+ </div>
144
+ </TooltipProvider>
145
+ </DemoChrome>
146
+ );
147
+ }
148
+
149
+ mount(<App />);
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Tooltip, Pathway Design System
3
+ *
4
+ * Importable React component module. Source of truth for the Tooltip
5
+ * implementation; the standalone demo (tooltip.html), the Storybook stories
6
+ * and the <pathway-tooltip> web component all consume this.
7
+ *
8
+ * A short, plain-text label beside a control. It names an icon-only control,
9
+ * says why an option is unavailable, or shows text that has been cut off.
10
+ * Never interactive: anything with a title, link or button is a Popover.
11
+ *
12
+ * Follows the WAI-ARIA tooltip pattern and the Radix Tooltip anatomy
13
+ * (Provider, Trigger, Portal, Content, Arrow), hand-built so the package keeps
14
+ * zero runtime dependencies (pipeline decision G-A, 2026-10-29).
15
+ *
16
+ * Spec: components/tooltip/tooltip-spec.md
17
+ * Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40018655-12004
18
+ */
19
+
20
+ import React, { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
21
+ import { createPortal } from "react-dom";
22
+ import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
23
+
24
+ // ─── TOKEN HELPERS ────────────────────────────────────────────────────────────
25
+ // SC = semantic-color (themes/*.css), SL = semantic-layout-units (layout.css),
26
+ // ST = semantic-type (type.css), M = motion (motion.css). Every name is
27
+ // modeless: the inverse surface comes from a data-theme on the container, not
28
+ // from a separate token family (spec §3.0).
29
+ const SC = (p) => `var(--semantic-color-${p})`;
30
+ const SL = (p) => `var(--semantic-layout-units-${p})`;
31
+ const ST = (p) => `var(--semantic-type-${p})`;
32
+ const M = (p) => `var(--motion-${p})`;
33
+
34
+ // ─── COLOUR TOKENS ────────────────────────────────────────────────────────────
35
+ // Static and Surface tokens: a tooltip is not interactive, so nothing here has
36
+ // a rest/hover/pressed set. They resolve in the INVERSE mode.
37
+ export const T = {
38
+ fill: SC("fill-surface-elevated"),
39
+ stroke: SC("stroke-static-neutral-base"),
40
+ fg: SC("foreground-static-neutral-base"),
41
+ shadow: "var(--elevation-widget)",
42
+ };
43
+
44
+ // ─── LAYOUT ───────────────────────────────────────────────────────────────────
45
+ // Max width and the arrow are measurements, not tokens (CLAUDE.md §5).
46
+ export const L = {
47
+ padY: SL("padding-xxtight"), // 6px
48
+ padX: SL("padding-xtight"), // 8px
49
+ radius: SL("cornerradius-small"), // 4px
50
+ stroke: SL("borderwidth-base"), // 1px
51
+ offset: "--semantic-layout-units-gap-xxtight", // 4px, read at runtime
52
+ edge: "--semantic-layout-units-padding-xtight", // 8px, read at runtime
53
+ maxW: 240,
54
+ arrowW: 12,
55
+ arrowH: 6,
56
+ arrowInset: 8, // arrow edge to container corner, minimum
57
+ bridgeMs: 120, // pointer grace between trigger and tooltip
58
+ tapMs: 2600, // tap and press hold
59
+ };
60
+
61
+ const TYPE = {
62
+ fontFamily: ST("family-brand"),
63
+ fontSize: ST("font-size-s"),
64
+ fontWeight: ST("weight-regular"),
65
+ lineHeight: ST("line-height-s-single"),
66
+ letterSpacing: ST("letter-spacing-wide"),
67
+ };
68
+
69
+ // The theme the tooltip takes, for each page theme (spec §3.0).
70
+ const INVERSE = {
71
+ "amplify-light": "amplify-dark", light: "dark",
72
+ "amplify-dark": "amplify-light", dark: "light", midnight: "light",
73
+ };
74
+
75
+ const CSS = `
76
+ .pw-tooltip{position:fixed;left:0;top:0;z-index:1000;box-sizing:border-box;width:max-content;
77
+ max-width:min(${L.maxW}px, calc(100vw - 2 * var(${L.edge})));
78
+ padding:${L.padY} ${L.padX};border-radius:${L.radius};background:${T.fill};color:${T.fg};
79
+ box-shadow:inset 0 0 0 ${L.stroke} ${T.stroke},${T.shadow};
80
+ font-family:${TYPE.fontFamily},sans-serif;font-size:${TYPE.fontSize};font-weight:${TYPE.fontWeight};
81
+ line-height:${TYPE.lineHeight};letter-spacing:${TYPE.letterSpacing};
82
+ overflow-wrap:anywhere;text-align:start;pointer-events:auto;user-select:none}
83
+ .pw-tooltip[data-state=open]{animation:pw-tooltip-in ${M("duration-2")} ${M("easing-decelerate")}}
84
+ .pw-tooltip[data-state=closed]{animation:pw-tooltip-out ${M("duration-1")} ${M("easing-accelerate")} forwards}
85
+ .pw-tooltip[data-side=top]{--pw-tooltip-dx:0;--pw-tooltip-dy:${SL("gap-xxtight")}}
86
+ .pw-tooltip[data-side=bottom]{--pw-tooltip-dx:0;--pw-tooltip-dy:calc(-1 * ${SL("gap-xxtight")})}
87
+ .pw-tooltip[data-side=left]{--pw-tooltip-dx:${SL("gap-xxtight")};--pw-tooltip-dy:0}
88
+ .pw-tooltip[data-side=right]{--pw-tooltip-dx:calc(-1 * ${SL("gap-xxtight")});--pw-tooltip-dy:0}
89
+ .pw-tooltip[data-reduced][data-state=open]{animation-name:pw-tooltip-fade}
90
+ @keyframes pw-tooltip-in{from{opacity:0;transform:translate(var(--pw-tooltip-dx),var(--pw-tooltip-dy))}to{opacity:1;transform:none}}
91
+ @keyframes pw-tooltip-fade{from{opacity:0}to{opacity:1}}
92
+ @keyframes pw-tooltip-out{from{opacity:1}to{opacity:0}}
93
+ .pw-tooltip__arrow{position:absolute;display:block;width:${L.arrowW}px;height:calc(${L.arrowH}px + ${L.stroke});overflow:hidden;transform-origin:0 0;pointer-events:none}
94
+ .pw-tooltip__arrow::before{content:"";position:absolute;left:50%;top:0;width:8px;height:8px;box-sizing:border-box;
95
+ transform:translate(-50%,-50%) rotate(45deg);background:${T.fill};box-shadow:inset 0 0 0 ${L.stroke} ${T.stroke}}
96
+ .pw-tooltip__sr{display:none}
97
+ .pw-tooltip__trigger{display:inline-flex}
98
+ `;
99
+
100
+ let injected = false;
101
+ function useStyles() {
102
+ useEffect(() => {
103
+ if (injected || typeof document === "undefined" || document.getElementById("pw-tooltip-css")) { injected = true; return; }
104
+ const el = document.createElement("style");
105
+ el.id = "pw-tooltip-css";
106
+ el.textContent = CSS;
107
+ document.head.appendChild(el);
108
+ injected = true;
109
+ }, []);
110
+ }
111
+
112
+ /* Radix-style timing needs numbers, so read the token values at runtime. */
113
+ function tokenPx(name, fallback) {
114
+ if (typeof document === "undefined") return fallback;
115
+ const v = parseFloat(getComputedStyle(document.documentElement).getPropertyValue(name));
116
+ return Number.isFinite(v) ? v : fallback;
117
+ }
118
+ const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), Math.max(lo, hi));
119
+
120
+ /* Nearest ancestor with the attribute, crossing shadow roots, so a trigger
121
+ inside <pathway-tooltip> still finds the page's theme. */
122
+ function closestAcross(el, sel) {
123
+ let n = el;
124
+ while (n) {
125
+ const hit = n.closest?.(sel);
126
+ if (hit) return hit;
127
+ n = n.getRootNode?.().host || null;
128
+ }
129
+ return null;
130
+ }
131
+ function themeOf(el) {
132
+ const host = closestAcross(el, "[data-theme]") || document.documentElement;
133
+ const brandHost = closestAcross(el, "[data-brand]") || document.documentElement;
134
+ return { theme: host.getAttribute("data-theme") || "light", brand: brandHost.getAttribute("data-brand") };
135
+ }
136
+
137
+ // One shared timestamp gives the skip delay: a new hover soon after a tooltip
138
+ // closed opens at once.
139
+ let lastClosedAt = 0;
140
+
141
+ const TimingContext = createContext(null);
142
+
143
+ /** App-wide defaults. Each Tooltip prop overrides its default. */
144
+ export const TooltipDefaults = createContext({ arrow: true });
145
+
146
+ /**
147
+ * Optional, once per app.
148
+ * @param {number} delay hover delay in ms (default --motion-duration-4)
149
+ * @param {number} skipDelay ms after a tooltip closes during which the next opens at once
150
+ */
151
+ export function TooltipProvider({ children, delay, skipDelay }) {
152
+ return <TimingContext.Provider value={{ delay, skipDelay }}>{children}</TimingContext.Provider>;
153
+ }
154
+
155
+ /* Where the tooltip goes: preferred side and alignment, flipped and shifted to
156
+ stay inside the boundary, with the arrow kept on the trigger. */
157
+ function computePosition(trigger, tip, { side, align, arrow, boundary }) {
158
+ const tr = trigger.getBoundingClientRect();
159
+ const w = tip.offsetWidth, h = tip.offsetHeight;
160
+ const b = boundary ? boundary.getBoundingClientRect() : { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
161
+ const edge = tokenPx(L.edge, 8);
162
+ const gap = tokenPx(L.offset, 4) + (arrow ? L.arrowH : 0);
163
+ const fits = {
164
+ top: tr.top - gap - h >= b.top + edge,
165
+ bottom: tr.bottom + gap + h <= b.bottom - edge,
166
+ left: tr.left - gap - w >= b.left + edge,
167
+ right: tr.right + gap + w <= b.right - edge,
168
+ };
169
+ const opposite = { top: "bottom", bottom: "top", left: "right", right: "left" };
170
+ const s = !fits[side] && fits[opposite[side]] ? opposite[side] : side;
171
+ const vertical = s === "top" || s === "bottom";
172
+ let x, y;
173
+ if (vertical) {
174
+ y = s === "top" ? tr.top - gap - h : tr.bottom + gap;
175
+ x = align === "start" ? tr.left : align === "end" ? tr.right - w : tr.left + tr.width / 2 - w / 2;
176
+ } else {
177
+ x = s === "left" ? tr.left - gap - w : tr.right + gap;
178
+ y = align === "start" ? tr.top : align === "end" ? tr.bottom - h : tr.top + tr.height / 2 - h / 2;
179
+ }
180
+ // Shift to stay inside the boundary, but never so far that the arrow can no
181
+ // longer reach the trigger: a tooltip whose trigger scrolls out of view goes
182
+ // with it instead of sticking to the window edge.
183
+ const min = L.arrowInset + L.arrowW / 2;
184
+ let a = align;
185
+ if (vertical) {
186
+ const c = tr.left + tr.width / 2;
187
+ const nx = clamp(clamp(x, b.left + edge, b.right - edge - w), c - (w - min), c - min);
188
+ if (Math.abs(nx - x) > 0.5) a = nx > x ? "start" : "end";
189
+ x = nx;
190
+ } else {
191
+ const c = tr.top + tr.height / 2;
192
+ const ny = clamp(clamp(y, b.top + edge, b.bottom - edge - h), c - (h - min), c - min);
193
+ if (Math.abs(ny - y) > 0.5) a = ny > y ? "start" : "end";
194
+ y = ny;
195
+ }
196
+ const along = vertical ? clamp(tr.left + tr.width / 2 - x, min, w - min) : clamp(tr.top + tr.height / 2 - y, min, h - min);
197
+ return { x, y, side: s, align: a, along };
198
+ }
199
+
200
+ /* The arrow is drawn pointing down, attached by its top edge, then rotated
201
+ about its top-left corner for the other sides (spec §7). */
202
+ function arrowStyle(side, along) {
203
+ const k = L.stroke;
204
+ switch (side) {
205
+ case "bottom": return { top: k, left: along + L.arrowW / 2, transform: "rotate(180deg)" };
206
+ case "left": return { left: `calc(100% - ${k})`, top: along + L.arrowW / 2, transform: "rotate(-90deg)" };
207
+ case "right": return { left: k, top: along - L.arrowW / 2, transform: "rotate(90deg)" };
208
+ default: return { top: `calc(100% - ${k})`, left: along - L.arrowW / 2 };
209
+ }
210
+ }
211
+
212
+ /**
213
+ * @param {React.ReactElement} children the trigger: one focusable element that forwards refs
214
+ * @param {string} content the text, plain, one or two short sentences
215
+ * @param {string} describe what aria-describedby reads, when it differs from content
216
+ * @param {"label"|"description"} purpose label: the text is the trigger's name; description: a reason or hint
217
+ * @param {"top"|"bottom"|"left"|"right"} side preferred side, flips when there is no room
218
+ * @param {"start"|"center"|"end"} align
219
+ * @param {boolean} arrow point at the trigger
220
+ * @param {boolean} disabled render the trigger alone (for example, text that is not cut off)
221
+ * @param {boolean} persistOnPress a press shows it for 2.6s instead of closing it
222
+ * @param {Element} boundary keep it inside this element instead of the window
223
+ * @param {boolean} open controlled open; documentation and stories
224
+ * @param {Function} onOpenChange (open) => void
225
+ * @param {boolean} manageAria false when the caller names or describes the trigger itself (the web component)
226
+ */
227
+ export function Tooltip({
228
+ children, content, describe, purpose = "description", side = "top", align = "center", arrow: arrowProp,
229
+ disabled = false, persistOnPress = false, boundary, open: forced, onOpenChange, manageAria = true,
230
+ }) {
231
+ useStyles();
232
+ const defaults = useContext(TooltipDefaults);
233
+ const timing = useContext(TimingContext);
234
+ const arrow = arrowProp ?? defaults.arrow ?? true;
235
+ const reduced = useReducedMotion();
236
+ const descId = useId();
237
+ const triggerRef = useRef(null);
238
+ const tipRef = useRef(null);
239
+ const [hover, setHover] = useState(false);
240
+ const [focusOpen, setFocusOpen] = useState(false);
241
+ const [tapped, setTapped] = useState(false);
242
+ const [dismissed, setDismissed] = useState(false);
243
+ const [mounted, setMounted] = useState(false);
244
+ const [pos, setPos] = useState(null);
245
+ const [island, setIsland] = useState(null);
246
+ const openT = useRef(null), closeT = useRef(null), tapT = useRef(null), unmountT = useRef(null);
247
+ useEffect(() => () => [openT, closeT, tapT, unmountT].forEach((t) => clearTimeout(t.current)), []);
248
+
249
+ const isOpen = !disabled && !!content && (forced ?? (tapped || (!dismissed && (hover || focusOpen))));
250
+
251
+ // Keep the tooltip mounted through its exit animation.
252
+ const wasOpen = useRef(false);
253
+ useEffect(() => {
254
+ clearTimeout(unmountT.current);
255
+ if (isOpen) setMounted(true);
256
+ else if (wasOpen.current) {
257
+ lastClosedAt = Date.now();
258
+ unmountT.current = setTimeout(() => { setMounted(false); setPos(null); }, reduced ? 0 : tokenPx("--motion-duration-1", 100));
259
+ }
260
+ if (wasOpen.current !== isOpen) onOpenChange?.(isOpen);
261
+ wasOpen.current = isOpen;
262
+ }, [isOpen]);
263
+
264
+ // Resolve the inverse theme each time it opens, so a mode switch is picked up.
265
+ useEffect(() => {
266
+ if (!isOpen) return;
267
+ const resolve = () => {
268
+ const { theme, brand } = themeOf(triggerRef.current);
269
+ setIsland({ theme: INVERSE[theme] || "dark", brand });
270
+ };
271
+ resolve();
272
+ // A page that switches mode while a tooltip is held open (Storybook, the
273
+ // demo) updates it too.
274
+ const mo = typeof MutationObserver !== "undefined" ? new MutationObserver(resolve) : null;
275
+ mo?.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-brand"] });
276
+ return () => mo?.disconnect();
277
+ }, [isOpen]);
278
+
279
+ const place = useCallback(() => {
280
+ if (!triggerRef.current || !tipRef.current) return;
281
+ setPos(computePosition(triggerRef.current, tipRef.current, { side, align, arrow, boundary }));
282
+ }, [side, align, arrow, boundary]);
283
+
284
+ useLayoutEffect(() => {
285
+ if (!mounted) return;
286
+ place();
287
+ const onMove = () => place();
288
+ window.addEventListener("scroll", onMove, true);
289
+ window.addEventListener("resize", onMove);
290
+ const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(onMove) : null;
291
+ if (ro && tipRef.current) ro.observe(tipRef.current);
292
+ // The trigger can also move without any scroll or resize (content above it
293
+ // loads, a panel opens), so follow its box while the tooltip is shown.
294
+ let raf = 0, last = "";
295
+ const follow = () => {
296
+ const r = triggerRef.current?.getBoundingClientRect();
297
+ const key = r ? `${r.left},${r.top},${r.width},${r.height}` : "";
298
+ if (key !== last) { last = key; place(); }
299
+ raf = requestAnimationFrame(follow);
300
+ };
301
+ raf = requestAnimationFrame(follow);
302
+ return () => { window.removeEventListener("scroll", onMove, true); window.removeEventListener("resize", onMove); ro?.disconnect(); cancelAnimationFrame(raf); };
303
+ }, [mounted, place, content, island]);
304
+
305
+ // Esc closes it wherever focus is; a press outside closes it too.
306
+ useEffect(() => {
307
+ if (!isOpen) return;
308
+ const onKey = (e) => { if (e.key === "Escape") { setDismissed(true); setTapped(false); } };
309
+ const onDown = (e) => { if (!triggerRef.current?.contains(e.target) && !tipRef.current?.contains(e.target)) { setHover(false); setTapped(false); } };
310
+ document.addEventListener("keydown", onKey);
311
+ document.addEventListener("pointerdown", onDown);
312
+ return () => { document.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onDown); };
313
+ }, [isOpen]);
314
+
315
+ if (disabled || !content) return children;
316
+
317
+ const child = React.Children.only(children);
318
+ // A child that can take a ref (a DOM element, forwardRef or class) becomes
319
+ // the trigger itself. One that cannot, such as Pathway Button, is wrapped in
320
+ // an inline-flex span that carries the handlers and the position; the ARIA
321
+ // still goes onto the child, which spreads it onto its own element.
322
+ const canRef = typeof child.type === "string" || child.type?.$$typeof === Symbol.for("react.forward_ref")
323
+ || !!child.type?.prototype?.isReactComponent;
324
+ // A wrapped child keeps its own handlers, so only a cloned one needs them forwarded.
325
+ const call = (name, e) => { if (canRef) child.props[name]?.(e); };
326
+ const delay = timing?.delay ?? tokenPx("--motion-duration-4", 300);
327
+ const skip = timing?.skipDelay ?? delay;
328
+
329
+ const enter = (e) => {
330
+ call("onPointerEnter", e);
331
+ if (e.pointerType === "touch") return;
332
+ clearTimeout(closeT.current);
333
+ setDismissed(false);
334
+ if (hover) return;
335
+ if (Date.now() - lastClosedAt < skip) setHover(true);
336
+ else { clearTimeout(openT.current); openT.current = setTimeout(() => setHover(true), delay); }
337
+ };
338
+ const leave = (e) => {
339
+ call("onPointerLeave", e);
340
+ clearTimeout(openT.current);
341
+ clearTimeout(closeT.current);
342
+ // The grace period lets the pointer cross onto the tooltip (WCAG 1.4.13).
343
+ closeT.current = setTimeout(() => setHover(false), L.bridgeMs);
344
+ };
345
+ const keepOpen = () => clearTimeout(closeT.current);
346
+ const leaveTip = () => { clearTimeout(closeT.current); closeT.current = setTimeout(() => setHover(false), L.bridgeMs); };
347
+ const onFocus = (e) => {
348
+ call("onFocus", e);
349
+ let visible = true;
350
+ try { visible = e.target.matches(":focus-visible"); } catch { /* older browsers: treat as keyboard */ }
351
+ if (visible) { setDismissed(false); setFocusOpen(true); }
352
+ };
353
+ const onBlur = (e) => { call("onBlur", e); setFocusOpen(false); setTapped(false); };
354
+ // A tap shows it for 2.6s; persistOnPress does the same for a mouse press.
355
+ // Otherwise a press closes it, so it never covers what the press changed.
356
+ const onPointerDown = (e) => {
357
+ call("onPointerDown", e);
358
+ clearTimeout(openT.current);
359
+ if (e.pointerType === "touch" || persistOnPress) {
360
+ setTapped(true);
361
+ clearTimeout(tapT.current);
362
+ tapT.current = setTimeout(() => setTapped(false), L.tapMs);
363
+ } else { setHover(false); setDismissed(true); }
364
+ };
365
+
366
+ // The description is always in the page, so a screen reader reads it as soon
367
+ // as the trigger takes focus, not only once the tooltip has opened.
368
+ const describedBy = purpose === "description"
369
+ ? [child.props["aria-describedby"], descId].filter(Boolean).join(" ")
370
+ : child.props["aria-describedby"];
371
+
372
+ const setRef = (node) => {
373
+ triggerRef.current = node;
374
+ const r = child.ref;
375
+ if (typeof r === "function") r(node); else if (r && typeof r === "object") r.current = node;
376
+ };
377
+ const handlers = { onPointerEnter: enter, onPointerLeave: leave, onPointerDown, onFocus, onBlur };
378
+ const aria = manageAria ? {
379
+ "aria-label": purpose === "label" && typeof content === "string" ? content : child.props["aria-label"],
380
+ "aria-describedby": describedBy || undefined,
381
+ } : {};
382
+ const trigger = canRef
383
+ ? React.cloneElement(child, { ref: setRef, ...handlers, ...aria })
384
+ : <span ref={(n) => { triggerRef.current = n; }} className="pw-tooltip__trigger" {...handlers}>{React.cloneElement(child, aria)}</span>;
385
+
386
+ const tip = mounted && typeof document !== "undefined" ? createPortal(
387
+ <div
388
+ ref={tipRef}
389
+ className="pw-tooltip"
390
+ role="tooltip"
391
+ aria-hidden="true"
392
+ data-state={isOpen ? "open" : "closed"}
393
+ data-side={pos?.side ?? side}
394
+ data-align={pos?.align ?? align}
395
+ data-theme={island?.theme}
396
+ data-brand={island?.brand || undefined}
397
+ data-reduced={reduced || undefined}
398
+ onPointerEnter={keepOpen}
399
+ onPointerLeave={leaveTip}
400
+ style={{ left: pos ? pos.x : 0, top: pos ? pos.y : 0, visibility: pos ? "visible" : "hidden" }}>
401
+ {content}
402
+ {arrow && pos && <span className="pw-tooltip__arrow" style={arrowStyle(pos.side, pos.along)} />}
403
+ </div>,
404
+ document.body,
405
+ ) : null;
406
+
407
+ return (
408
+ <>
409
+ {trigger}
410
+ {manageAria && purpose === "description" && <span id={descId} className="pw-tooltip__sr" hidden>{describe ?? content}</span>}
411
+ {tip}
412
+ </>
413
+ );
414
+ }
415
+
416
+ export default Tooltip;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloimjolopez-pathway/pathway-components",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Pathway Design System React components",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -16,7 +16,8 @@
16
16
  "./top-nav": "./components/top-nav/top-nav.jsx",
17
17
  "./sidenav": "./components/sidenav/sidenav.jsx",
18
18
  "./nav-shell": "./components/nav-shell/nav-shell.jsx",
19
- "./page-template": "./components/page-template/page-template.jsx"
19
+ "./page-template": "./components/page-template/page-template.jsx",
20
+ "./tooltip": "./components/tooltip/tooltip.jsx"
20
21
  },
21
22
  "peerDependencies": {
22
23
  "react": ">=17",