@helloimjolopez-pathway/pathway-components 0.1.0 → 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,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavShell, Pathway Design System
|
|
3
|
+
*
|
|
4
|
+
* Composes TopNav + SideNav + Shell.Main into the application frame that wraps
|
|
5
|
+
* every Amplify page. Not a Figma component set (the shell has no Figma node of
|
|
6
|
+
* its own); it is a layout composition of TopNav, SideNav, and PageTemplate.
|
|
7
|
+
*
|
|
8
|
+
* Spec: components/nav-shell/nav-shell-spec.md
|
|
9
|
+
* Figma: TopNav 40007067:6508, SideNav.Container 40004059:1375,
|
|
10
|
+
* ScreenTemplate (set) 40009709:26016
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
14
|
+
import TopNav, { DEFAULT_MODULES } from "../top-nav/top-nav.jsx";
|
|
15
|
+
import { SideNav } from "../sidenav/sidenav.jsx";
|
|
16
|
+
|
|
17
|
+
// ─── CONSTANTS ────────────────────────────────────────────────────────────────
|
|
18
|
+
const TOPNAV_H = 60; // px — spec §9.3 rule 1
|
|
19
|
+
|
|
20
|
+
// SideNav widths via contextual tokens (same source as sidenav.jsx L.navW/L.navWcol)
|
|
21
|
+
const CL = (p) => `var(--contextual-layout-units-${p})`;
|
|
22
|
+
const SIDENAV_W_EXP = CL("sidenav-width-expanded");
|
|
23
|
+
const SIDENAV_W_COL = CL("sidenav-width-collapsed");
|
|
24
|
+
|
|
25
|
+
// ─── HOOK: responsive breakpoint ─────────────────────────────────────────────
|
|
26
|
+
function useBreakpoint() {
|
|
27
|
+
const get = () => {
|
|
28
|
+
if (typeof window === "undefined") return "desktop";
|
|
29
|
+
if (window.innerWidth < 768) return "mobile";
|
|
30
|
+
if (window.innerWidth < 1024) return "tablet";
|
|
31
|
+
return "desktop";
|
|
32
|
+
};
|
|
33
|
+
const [bp, setBp] = useState(get);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const h = () => setBp(get());
|
|
36
|
+
window.addEventListener("resize", h, { passive: true });
|
|
37
|
+
return () => window.removeEventListener("resize", h);
|
|
38
|
+
}, []);
|
|
39
|
+
return bp;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── COMPONENT ────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* NavShell — the fixed application frame.
|
|
46
|
+
*
|
|
47
|
+
* Layout zones (always present):
|
|
48
|
+
* TopNav fixed, full width, 60px tall, z-index 100
|
|
49
|
+
* SideNav fixed, left edge, top 60px → bottom, push layout
|
|
50
|
+
* Shell.Main scrollable content area, margin-left = SideNav width
|
|
51
|
+
*
|
|
52
|
+
* Desktop: SideNav 240px expanded | 84px collapsed, user toggles
|
|
53
|
+
* Tablet: SideNav always 84px rail, toggle locked
|
|
54
|
+
* Mobile: SideNav hidden, slides in as 240px overlay on hamburger tap
|
|
55
|
+
*
|
|
56
|
+
* Props (TopNav pass-through):
|
|
57
|
+
* modules Array<{id, label, icon}> — all modules (default: DEFAULT_MODULES)
|
|
58
|
+
* activeModuleId string — which module is active
|
|
59
|
+
* org {id, name, campus, initials, logoUrl?}
|
|
60
|
+
* orgs Array<org> — OrgSwitcher dropdown list
|
|
61
|
+
* user {name, initials, email}
|
|
62
|
+
* onModuleSelect (id) => void
|
|
63
|
+
* onOrgSelect (id) => void
|
|
64
|
+
*
|
|
65
|
+
* Props (SideNav pass-through):
|
|
66
|
+
* navItems Array<SideNavItem> — flat nav items (use this OR navSections)
|
|
67
|
+
* navSections Array<{label, items}> — sectioned nav items
|
|
68
|
+
* activeNavId string — current page id
|
|
69
|
+
* onNavigate (id) => void
|
|
70
|
+
* navHeaderEnd ReactNode — slot after collapse toggle in nav header
|
|
71
|
+
* navChildren ReactNode — slot inside scroll area, after items
|
|
72
|
+
* navFooter ReactNode — slot pinned below scroll area
|
|
73
|
+
*
|
|
74
|
+
* Props (shell):
|
|
75
|
+
* defaultCollapsed boolean — initial desktop collapse state (default false)
|
|
76
|
+
* children ReactNode — page content (renders inside Shell.Main)
|
|
77
|
+
*/
|
|
78
|
+
export function NavShell({
|
|
79
|
+
// TopNav
|
|
80
|
+
modules = DEFAULT_MODULES,
|
|
81
|
+
activeModuleId = "home",
|
|
82
|
+
org,
|
|
83
|
+
orgs,
|
|
84
|
+
user,
|
|
85
|
+
onModuleSelect,
|
|
86
|
+
onOrgSelect,
|
|
87
|
+
|
|
88
|
+
// SideNav
|
|
89
|
+
navItems,
|
|
90
|
+
navSections,
|
|
91
|
+
activeNavId,
|
|
92
|
+
onNavigate,
|
|
93
|
+
navHeaderEnd = null,
|
|
94
|
+
navChildren = null,
|
|
95
|
+
navFooter = null,
|
|
96
|
+
|
|
97
|
+
// Shell
|
|
98
|
+
defaultCollapsed = false,
|
|
99
|
+
children,
|
|
100
|
+
}) {
|
|
101
|
+
const breakpoint = useBreakpoint();
|
|
102
|
+
const isMobile = breakpoint === "mobile";
|
|
103
|
+
const isTablet = breakpoint === "tablet";
|
|
104
|
+
|
|
105
|
+
// Desktop collapse state (controlled within NavShell)
|
|
106
|
+
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
|
107
|
+
// Mobile overlay open state
|
|
108
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
109
|
+
|
|
110
|
+
// Tablet: rail (84px) is fixed, no user toggle
|
|
111
|
+
// Mobile: always "collapsed" visually (but managed separately via mobileOpen)
|
|
112
|
+
// Desktop: user-controlled
|
|
113
|
+
const effectiveCollapsed = isTablet || isMobile ? true : collapsed;
|
|
114
|
+
|
|
115
|
+
// Shell.Main margin-left: matches current SideNav width for push layout.
|
|
116
|
+
// Mobile: no margin (SideNav is an overlay).
|
|
117
|
+
const mainMarginLeft = isMobile
|
|
118
|
+
? 0
|
|
119
|
+
: effectiveCollapsed ? SIDENAV_W_COL : SIDENAV_W_EXP;
|
|
120
|
+
|
|
121
|
+
const handleCollapseChange = useCallback((next) => {
|
|
122
|
+
if (!isTablet && !isMobile) setCollapsed(next);
|
|
123
|
+
}, [isTablet, isMobile]);
|
|
124
|
+
|
|
125
|
+
// TopNav calls this when hamburger (mobile) or collapse button (desktop) is tapped
|
|
126
|
+
const handleSideNavToggle = useCallback(() => {
|
|
127
|
+
if (isMobile) setMobileOpen(o => !o);
|
|
128
|
+
else if (!isTablet) setCollapsed(c => !c);
|
|
129
|
+
}, [isMobile, isTablet]);
|
|
130
|
+
|
|
131
|
+
// Close mobile overlay on Escape — spec §8 mobile hamburger rules
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (!mobileOpen) return;
|
|
134
|
+
const h = (e) => { if (e.key === "Escape") setMobileOpen(false); };
|
|
135
|
+
document.addEventListener("keydown", h);
|
|
136
|
+
return () => document.removeEventListener("keydown", h);
|
|
137
|
+
}, [mobileOpen]);
|
|
138
|
+
|
|
139
|
+
// Also close mobile overlay when breakpoint changes away from mobile
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!isMobile) setMobileOpen(false);
|
|
142
|
+
}, [isMobile]);
|
|
143
|
+
|
|
144
|
+
// ── Render ─────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<>
|
|
148
|
+
{/* Skip navigation — first focusable element, WCAG 2.4.1 */}
|
|
149
|
+
<a
|
|
150
|
+
href="#main-content"
|
|
151
|
+
style={{
|
|
152
|
+
position: "absolute", top: -48, left: 8, zIndex: 300,
|
|
153
|
+
background: "var(--semantic-color-fill-action-primary-strong-rest)",
|
|
154
|
+
color: "var(--semantic-color-foreground-action-ghost-rest)",
|
|
155
|
+
padding: "8px 12px", borderRadius: 6,
|
|
156
|
+
fontSize: 14, fontWeight: 500, textDecoration: "none",
|
|
157
|
+
transition: "top var(--motion-duration-2) var(--motion-easing-standard)",
|
|
158
|
+
}}
|
|
159
|
+
onFocus={(e) => { e.currentTarget.style.top = "8px"; }}
|
|
160
|
+
onBlur={(e) => { e.currentTarget.style.top = "-48px"; }}
|
|
161
|
+
>
|
|
162
|
+
Skip to content
|
|
163
|
+
</a>
|
|
164
|
+
|
|
165
|
+
{/* ── TopNav: position fixed, full width, 60px ─────────────────────── */}
|
|
166
|
+
<TopNav
|
|
167
|
+
modules={modules}
|
|
168
|
+
activeModuleId={activeModuleId}
|
|
169
|
+
org={org}
|
|
170
|
+
orgs={orgs}
|
|
171
|
+
user={user}
|
|
172
|
+
breakpoint={breakpoint}
|
|
173
|
+
onModuleSelect={onModuleSelect}
|
|
174
|
+
onOrgSelect={onOrgSelect}
|
|
175
|
+
onSideNavToggle={handleSideNavToggle}
|
|
176
|
+
/>
|
|
177
|
+
|
|
178
|
+
{/* ── Mobile scrim — darkens content while SideNav overlay is open ─── */}
|
|
179
|
+
{isMobile && mobileOpen && (
|
|
180
|
+
<div
|
|
181
|
+
aria-hidden="true"
|
|
182
|
+
onClick={() => setMobileOpen(false)}
|
|
183
|
+
style={{
|
|
184
|
+
position: "fixed", inset: 0, top: TOPNAV_H,
|
|
185
|
+
background: "rgba(0,0,0,0.32)",
|
|
186
|
+
zIndex: 49,
|
|
187
|
+
}}
|
|
188
|
+
/>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{/* ── SideNav container ────────────────────────────────────────────── */}
|
|
192
|
+
{/* Position: fixed so it stays below TopNav while the content scrolls.
|
|
193
|
+
On mobile: width controls the overlay reveal (0 → 240px); the SideNav
|
|
194
|
+
itself renders inside a clipping container and slides in from the left.
|
|
195
|
+
On desktop/tablet: overflow hidden so the SideNav's own width transition
|
|
196
|
+
stays within the container; no width needed here. */}
|
|
197
|
+
<div
|
|
198
|
+
aria-hidden={isMobile && !mobileOpen}
|
|
199
|
+
style={{
|
|
200
|
+
position: "fixed",
|
|
201
|
+
top: TOPNAV_H,
|
|
202
|
+
left: 0,
|
|
203
|
+
bottom: 0,
|
|
204
|
+
zIndex: isMobile ? 51 : 10,
|
|
205
|
+
overflow: "hidden",
|
|
206
|
+
// Mobile: clipping container, SideNav slides in via its own width
|
|
207
|
+
...(isMobile && {
|
|
208
|
+
width: mobileOpen
|
|
209
|
+
? SIDENAV_W_EXP
|
|
210
|
+
: 0,
|
|
211
|
+
transition: `width var(--motion-duration-6) var(--motion-easing-emphasized)`,
|
|
212
|
+
}),
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<SideNav
|
|
216
|
+
items={navItems}
|
|
217
|
+
sections={navSections}
|
|
218
|
+
activeId={activeNavId}
|
|
219
|
+
onNavigate={onNavigate}
|
|
220
|
+
collapsed={effectiveCollapsed}
|
|
221
|
+
onCollapseChange={handleCollapseChange}
|
|
222
|
+
hideCollapseButton={isTablet}
|
|
223
|
+
headerEnd={navHeaderEnd}
|
|
224
|
+
footer={navFooter}
|
|
225
|
+
>
|
|
226
|
+
{navChildren}
|
|
227
|
+
</SideNav>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{/* ── Shell.Main: scrollable content area ─────────────────────────── */}
|
|
231
|
+
{/* margin-left matches the SideNav width and transitions simultaneously.
|
|
232
|
+
spec §9.3 rule 3: never overflow hidden here. */}
|
|
233
|
+
<main
|
|
234
|
+
id="main-content"
|
|
235
|
+
role="main"
|
|
236
|
+
style={{
|
|
237
|
+
marginTop: TOPNAV_H,
|
|
238
|
+
marginLeft: mainMarginLeft,
|
|
239
|
+
minHeight: `calc(100vh - ${TOPNAV_H}px)`,
|
|
240
|
+
overflowY: "auto",
|
|
241
|
+
overflowX: "hidden",
|
|
242
|
+
background: "var(--semantic-color-fill-surface-canvas)",
|
|
243
|
+
transition: `margin-left var(--motion-duration-6) var(--motion-easing-emphasized)`,
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
{children}
|
|
247
|
+
</main>
|
|
248
|
+
</>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export default NavShell;
|
|
@@ -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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pathway Design System React components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"./button": "./components/button/button.jsx",
|
|
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",
|
|
20
|
+
"./tooltip": "./components/tooltip/tooltip.jsx"
|
|
19
21
|
},
|
|
20
22
|
"peerDependencies": {
|
|
21
23
|
"react": ">=17",
|