@workflow/web-shared 5.0.0-beta.15 → 5.0.0-beta.17
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/dist/components/event-list-view.d.ts +1 -1
- package/dist/components/event-list-view.d.ts.map +1 -1
- package/dist/components/event-list-view.js +20 -3
- package/dist/components/new-trace-viewer/components/timeline.d.ts +1 -1
- package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/timeline.js +40 -4
- package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/trace-viewer.js +8 -9
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +26 -32
- package/dist/components/sidebar/attributes-block.d.ts +28 -0
- package/dist/components/sidebar/attributes-block.d.ts.map +1 -0
- package/dist/components/sidebar/attributes-block.js +83 -0
- package/dist/components/sidebar/entity-detail-panel.js +2 -2
- package/dist/components/sidebar/events-list.d.ts.map +1 -1
- package/dist/components/sidebar/events-list.js +8 -1
- package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/node.js +2 -1
- package/dist/components/ui/context-card.d.ts +47 -0
- package/dist/components/ui/context-card.d.ts.map +1 -0
- package/dist/components/ui/context-card.js +471 -0
- package/dist/components/ui/timestamp-tooltip.d.ts +34 -1
- package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -1
- package/dist/components/ui/timestamp-tooltip.js +80 -132
- package/dist/components/workflow-trace-view.d.ts.map +1 -1
- package/dist/components/workflow-trace-view.js +8 -10
- package/dist/components/workflow-traces/event-colors.d.ts +1 -0
- package/dist/components/workflow-traces/event-colors.d.ts.map +1 -1
- package/dist/components/workflow-traces/event-colors.js +12 -1
- package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
- package/dist/components/workflow-traces/trace-span-construction.js +2 -1
- package/dist/lib/trace-builder.d.ts +15 -0
- package/dist/lib/trace-builder.d.ts.map +1 -1
- package/dist/lib/trace-builder.js +27 -2
- package/dist/styles.css +9 -0
- package/package.json +7 -5
- package/src/components/event-list-view.tsx +25 -2
- package/src/components/new-trace-viewer/components/timeline.tsx +82 -2
- package/src/components/new-trace-viewer/trace-viewer.tsx +17 -14
- package/src/components/sidebar/attribute-panel.tsx +117 -119
- package/src/components/sidebar/attributes-block.tsx +182 -0
- package/src/components/sidebar/entity-detail-panel.tsx +1 -1
- package/src/components/sidebar/events-list.tsx +8 -0
- package/src/components/trace-viewer/components/node.tsx +3 -2
- package/src/components/ui/context-card.tsx +784 -0
- package/src/components/ui/timestamp-tooltip.tsx +132 -194
- package/src/components/workflow-trace-view.tsx +18 -10
- package/src/components/workflow-traces/event-colors.ts +12 -0
- package/src/components/workflow-traces/trace-span-construction.ts +1 -0
- package/src/lib/trace-builder.ts +32 -1
- package/src/styles.css +9 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Root as SlotRoot, Slottable } from '@radix-ui/react-slot';
|
|
4
|
+
import type { JSX, MutableRefObject, ReactNode, RefObject } from 'react';
|
|
5
|
+
import {
|
|
6
|
+
createContext,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useId,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from 'react';
|
|
14
|
+
import { createPortal } from 'react-dom';
|
|
15
|
+
import useMeasure from 'react-use-measure';
|
|
16
|
+
import { useReducedMotion } from '../../hooks/use-reduced-motion';
|
|
17
|
+
import { cn } from '../../lib/utils';
|
|
18
|
+
|
|
19
|
+
const INACTIVE_TIMEOUT_MS = 250;
|
|
20
|
+
const ENTER_DELAY_MS = 0;
|
|
21
|
+
const OPEN_DELAY_MS = 150;
|
|
22
|
+
const MAX_TRANSITION_DISTANCE_PX = 150;
|
|
23
|
+
const MAX_OVERLAP_FOR_TRANSITION_PERCENT = 100;
|
|
24
|
+
const PERSIST_TIMEOUT_MS = 1000;
|
|
25
|
+
|
|
26
|
+
interface Point {
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ContextCardData {
|
|
32
|
+
id: string;
|
|
33
|
+
origin: Point;
|
|
34
|
+
contentSize: { width: number; height: number };
|
|
35
|
+
side: Side;
|
|
36
|
+
arrowOffset: Point;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type NullString = string | null;
|
|
40
|
+
|
|
41
|
+
interface ContextCardContextType {
|
|
42
|
+
rootOrigin: Point;
|
|
43
|
+
rootBounds: { width: number; height: number };
|
|
44
|
+
portalRef: RefObject<HTMLDivElement | null>;
|
|
45
|
+
activeId: RefObject<NullString | null>;
|
|
46
|
+
setActiveId: (id: NullString) => void;
|
|
47
|
+
hoveredId: NullString;
|
|
48
|
+
setHoveredId: (id: NullString) => void;
|
|
49
|
+
updateActiveContextCard: (update: ContextCardData | null) => void;
|
|
50
|
+
skipTransition: boolean;
|
|
51
|
+
rootVisible: boolean;
|
|
52
|
+
distanceFromLast: number;
|
|
53
|
+
lastOrigin: Point | null;
|
|
54
|
+
/** Whether a real {@link ContextCardProvider} is mounted above this consumer. */
|
|
55
|
+
isProvided: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ContextCardContext = createContext<ContextCardContextType>({
|
|
59
|
+
rootOrigin: { x: 0, y: 0 },
|
|
60
|
+
rootBounds: { width: 0, height: 0 },
|
|
61
|
+
portalRef: null as unknown as RefObject<HTMLDivElement | null>,
|
|
62
|
+
activeId: null as unknown as MutableRefObject<string | null>,
|
|
63
|
+
setActiveId: () => void 0,
|
|
64
|
+
hoveredId: null,
|
|
65
|
+
setHoveredId: () => void 0,
|
|
66
|
+
updateActiveContextCard: () => void 0,
|
|
67
|
+
skipTransition: false,
|
|
68
|
+
rootVisible: false,
|
|
69
|
+
distanceFromLast: 0,
|
|
70
|
+
lastOrigin: null,
|
|
71
|
+
isProvided: false,
|
|
72
|
+
});
|
|
73
|
+
ContextCardContext.displayName = 'ContextCardContext';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Returns whether the current subtree is wrapped in a {@link ContextCardProvider}.
|
|
77
|
+
*/
|
|
78
|
+
export function useHasContextCardProvider(): boolean {
|
|
79
|
+
return useContext(ContextCardContext).isProvided;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function usePrevious<T>(value: T): T | null {
|
|
83
|
+
const ref = useRef<T | null>(null);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
ref.current = value;
|
|
86
|
+
});
|
|
87
|
+
return ref.current;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isRefObject(
|
|
91
|
+
value: HTMLElement | RefObject<HTMLElement | null> | null | undefined
|
|
92
|
+
): value is RefObject<HTMLElement | null> {
|
|
93
|
+
return value !== null && value !== undefined && 'current' in value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Provides shared state and a portal container for {@link ContextCardTrigger}
|
|
98
|
+
* components. Must wrap any tree containing triggers; manages positioning,
|
|
99
|
+
* visibility transitions, and the animated arrow tip across all cards.
|
|
100
|
+
*/
|
|
101
|
+
export function ContextCardProvider({
|
|
102
|
+
children,
|
|
103
|
+
portalTarget,
|
|
104
|
+
}: {
|
|
105
|
+
children: ReactNode;
|
|
106
|
+
/** Custom container for the portal. Accepts either an element or a ref. */
|
|
107
|
+
portalTarget?: HTMLElement | RefObject<HTMLElement | null> | null;
|
|
108
|
+
}): ReactNode {
|
|
109
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
110
|
+
useEffect(() => setIsMounted(true), []);
|
|
111
|
+
const resolvedPortalTarget = isRefObject(portalTarget)
|
|
112
|
+
? portalTarget.current
|
|
113
|
+
: portalTarget;
|
|
114
|
+
const [ref, bounds] = useMeasure();
|
|
115
|
+
const portalRef = useRef<HTMLDivElement | null>(null);
|
|
116
|
+
const [activeContextCard, setActiveContextCard] =
|
|
117
|
+
useState<ContextCardData | null>(null);
|
|
118
|
+
const [visible, setVisible] = useState(false);
|
|
119
|
+
const [hoveredId, setHoveredId] = useState<NullString>(null);
|
|
120
|
+
const activeId = useRef<NullString>(null);
|
|
121
|
+
|
|
122
|
+
function setActiveId(id: NullString): void {
|
|
123
|
+
activeId.current = id;
|
|
124
|
+
setVisible(id !== null);
|
|
125
|
+
}
|
|
126
|
+
const updateActiveContextCard = (update: ContextCardData | null): void => {
|
|
127
|
+
if (update?.id !== activeId.current && update !== null) return;
|
|
128
|
+
setActiveContextCard(update);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const BORDER_WIDTH = 1;
|
|
132
|
+
|
|
133
|
+
const activeBounds = useMemo(() => {
|
|
134
|
+
if (!activeContextCard)
|
|
135
|
+
return {
|
|
136
|
+
width: 0,
|
|
137
|
+
height: 0,
|
|
138
|
+
x: 0,
|
|
139
|
+
y: 0,
|
|
140
|
+
side: 'top' as Side,
|
|
141
|
+
arrowOffset: { x: 0, y: 0 },
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
width: activeContextCard.contentSize.width + BORDER_WIDTH * 2,
|
|
145
|
+
height: activeContextCard.contentSize.height + BORDER_WIDTH * 2,
|
|
146
|
+
x: activeContextCard.origin.x,
|
|
147
|
+
y: activeContextCard.origin.y,
|
|
148
|
+
side: activeContextCard.side,
|
|
149
|
+
arrowOffset: activeContextCard.arrowOffset,
|
|
150
|
+
};
|
|
151
|
+
}, [activeContextCard]);
|
|
152
|
+
|
|
153
|
+
const lastBounds = usePrevious(activeBounds);
|
|
154
|
+
const lastId = usePrevious(activeId.current);
|
|
155
|
+
|
|
156
|
+
const percentOverlapFromLast = useMemo(() => {
|
|
157
|
+
if (!lastBounds) return 0;
|
|
158
|
+
|
|
159
|
+
const overlapLeft = Math.max(activeBounds.x, lastBounds.x);
|
|
160
|
+
const overlapRight = Math.min(
|
|
161
|
+
activeBounds.x + activeBounds.width,
|
|
162
|
+
lastBounds.x + lastBounds.width
|
|
163
|
+
);
|
|
164
|
+
const overlapTop = Math.max(activeBounds.y, lastBounds.y);
|
|
165
|
+
const overlapBottom = Math.min(
|
|
166
|
+
activeBounds.y + activeBounds.height,
|
|
167
|
+
lastBounds.y + lastBounds.height
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (overlapLeft < overlapRight && overlapTop < overlapBottom) {
|
|
171
|
+
const overlapArea =
|
|
172
|
+
(overlapRight - overlapLeft) * (overlapBottom - overlapTop);
|
|
173
|
+
|
|
174
|
+
const activeBoundsArea = activeBounds.width * activeBounds.height;
|
|
175
|
+
|
|
176
|
+
if (activeBoundsArea === 0) return 0;
|
|
177
|
+
|
|
178
|
+
return (overlapArea / activeBoundsArea) * 100;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return 0;
|
|
182
|
+
}, [activeBounds, lastBounds]);
|
|
183
|
+
|
|
184
|
+
const distanceFromLast = useMemo(
|
|
185
|
+
() =>
|
|
186
|
+
Math.sqrt(
|
|
187
|
+
((lastBounds?.x ?? 0) - activeBounds.x) ** 2 +
|
|
188
|
+
((lastBounds?.y ?? 0) - activeBounds.y) ** 2
|
|
189
|
+
),
|
|
190
|
+
[activeBounds.x, activeBounds.y, lastBounds?.x, lastBounds?.y]
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const lastOrigin = useMemo(() => {
|
|
194
|
+
if (!lastBounds) return null;
|
|
195
|
+
return {
|
|
196
|
+
x: lastBounds.x - activeBounds.x,
|
|
197
|
+
y: lastBounds.y - activeBounds.y,
|
|
198
|
+
};
|
|
199
|
+
}, [activeBounds, lastBounds]);
|
|
200
|
+
|
|
201
|
+
const [isScrolling, setIsScrolling] = useState(false);
|
|
202
|
+
const skipTransition = useMemo(
|
|
203
|
+
() =>
|
|
204
|
+
distanceFromLast > MAX_TRANSITION_DISTANCE_PX ||
|
|
205
|
+
percentOverlapFromLast > MAX_OVERLAP_FOR_TRANSITION_PERCENT ||
|
|
206
|
+
lastId === null ||
|
|
207
|
+
isScrolling,
|
|
208
|
+
[distanceFromLast, isScrolling, lastId, percentOverlapFromLast]
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
let isScrollingTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
213
|
+
|
|
214
|
+
const onScroll = (): void => {
|
|
215
|
+
if (isScrollingTimeout) window.clearTimeout(isScrollingTimeout);
|
|
216
|
+
|
|
217
|
+
isScrollingTimeout = setTimeout(() => {
|
|
218
|
+
setIsScrolling(false);
|
|
219
|
+
}, 66);
|
|
220
|
+
|
|
221
|
+
if (!isScrolling) {
|
|
222
|
+
setIsScrolling(true);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
document.addEventListener('scroll', onScroll, true);
|
|
227
|
+
|
|
228
|
+
return () => {
|
|
229
|
+
document.removeEventListener('scroll', onScroll, true);
|
|
230
|
+
};
|
|
231
|
+
}, [isScrolling]);
|
|
232
|
+
|
|
233
|
+
const contextValue = useMemo(
|
|
234
|
+
() => ({
|
|
235
|
+
updateActiveContextCard,
|
|
236
|
+
rootOrigin: { ...activeBounds },
|
|
237
|
+
rootBounds: bounds,
|
|
238
|
+
portalRef,
|
|
239
|
+
setActiveId,
|
|
240
|
+
activeId,
|
|
241
|
+
setHoveredId,
|
|
242
|
+
hoveredId,
|
|
243
|
+
skipTransition,
|
|
244
|
+
rootVisible: visible,
|
|
245
|
+
distanceFromLast,
|
|
246
|
+
lastOrigin,
|
|
247
|
+
isProvided: true,
|
|
248
|
+
}),
|
|
249
|
+
[
|
|
250
|
+
activeBounds,
|
|
251
|
+
bounds,
|
|
252
|
+
hoveredId,
|
|
253
|
+
skipTransition,
|
|
254
|
+
visible,
|
|
255
|
+
distanceFromLast,
|
|
256
|
+
lastOrigin,
|
|
257
|
+
]
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<ContextCardContext.Provider value={contextValue}>
|
|
262
|
+
{children}
|
|
263
|
+
{isMounted
|
|
264
|
+
? createPortal(
|
|
265
|
+
<div
|
|
266
|
+
className="w-full h-full inset-0 fixed pointer-events-none z-[100000]"
|
|
267
|
+
ref={ref}
|
|
268
|
+
>
|
|
269
|
+
<div
|
|
270
|
+
className="min-w-max transition-all duration-150 ease-[easing-function:cubic-bezier(0.3,_0.57,_0.07,_0.95)] will-change-[transform,width,height] motion-reduce:!transition-none"
|
|
271
|
+
style={{
|
|
272
|
+
opacity: visible ? 1 : 0,
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
<div
|
|
276
|
+
className={cn(
|
|
277
|
+
'absolute bg-background-100 bg-clip-padding rounded-[6px] overflow-visible pointer-events-none z-[1000000] shadow-[var(--ds-shadow-tooltip),0_0_0_1px_var(--ds-background-100)] w-fit [--context-card-tip-stroke:#DBDBDB] dark-theme:[--context-card-tip-stroke:#252525]',
|
|
278
|
+
skipTransition
|
|
279
|
+
? '!transition-none'
|
|
280
|
+
: 'transition-[transform,width,height] duration-250 ease-[easing-function:cubic-bezier(0.29,0.31,0.05,0.96)] will-change-[transform,width,height] motion-reduce:!transition-none'
|
|
281
|
+
)}
|
|
282
|
+
data-skip-transition={skipTransition}
|
|
283
|
+
style={{
|
|
284
|
+
transform: `translate(${activeBounds.x}px,${activeBounds.y}px)`,
|
|
285
|
+
width: activeBounds.width,
|
|
286
|
+
height: activeBounds.height,
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
<div
|
|
290
|
+
className={cn(
|
|
291
|
+
'w-[14px] h-[7px] absolute grid place-content-center z-[999999999] origin-center',
|
|
292
|
+
{
|
|
293
|
+
'top-full -translate-x-1/2 rotate-0':
|
|
294
|
+
activeBounds.side === 'top',
|
|
295
|
+
'right-full -translate-y-1/2 rotate-90 -mr-[3.5px]':
|
|
296
|
+
activeBounds.side === 'right',
|
|
297
|
+
'bottom-full -translate-x-1/2 rotate-180':
|
|
298
|
+
activeBounds.side === 'bottom',
|
|
299
|
+
'left-full -translate-y-1/2 rotate-[270deg] -ml-[3.5px]':
|
|
300
|
+
activeBounds.side === 'left',
|
|
301
|
+
},
|
|
302
|
+
skipTransition
|
|
303
|
+
? '!transition-none'
|
|
304
|
+
: 'transition-[transform,width,height] duration-250 ease-[easing-function:cubic-bezier(0.29,0.31,0.05,0.96)] will-change-[transform,width,height] motion-reduce:!transition-none'
|
|
305
|
+
)}
|
|
306
|
+
style={{
|
|
307
|
+
...(activeBounds.side === 'top' ||
|
|
308
|
+
activeBounds.side === 'bottom'
|
|
309
|
+
? {
|
|
310
|
+
left: `calc(50% + ${activeBounds.arrowOffset.x}px)`,
|
|
311
|
+
}
|
|
312
|
+
: {
|
|
313
|
+
top: `calc(50% + ${activeBounds.arrowOffset.y}px)`,
|
|
314
|
+
}),
|
|
315
|
+
}}
|
|
316
|
+
data-skip-transition={skipTransition}
|
|
317
|
+
data-side={activeBounds.side}
|
|
318
|
+
>
|
|
319
|
+
<svg
|
|
320
|
+
width="14"
|
|
321
|
+
height="7"
|
|
322
|
+
viewBox="0 0 14 7"
|
|
323
|
+
fill="none"
|
|
324
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
325
|
+
aria-hidden="true"
|
|
326
|
+
>
|
|
327
|
+
<g clipPath="url(#context-card-tip-clip)">
|
|
328
|
+
<path
|
|
329
|
+
d="M15 -0.5V0.5H12.9834L12.8184 0.508789C12.4377 0.550822 12.0853 0.738056 11.8359 1.03418L8.53027 4.95996C7.73114 5.90893 6.26886 5.90892 5.46973 4.95996L2.16406 1.03418C1.87905 0.695733 1.45907 0.5 1.0166 0.5H-1V-0.5H15Z"
|
|
330
|
+
fill="var(--ds-background-100)"
|
|
331
|
+
style={{
|
|
332
|
+
fill: 'var(--ds-background-100)',
|
|
333
|
+
fillOpacity: 1,
|
|
334
|
+
stroke: 'var(--context-card-tip-stroke)',
|
|
335
|
+
strokeOpacity: 1,
|
|
336
|
+
}}
|
|
337
|
+
/>
|
|
338
|
+
</g>
|
|
339
|
+
<defs>
|
|
340
|
+
<clipPath id="context-card-tip-clip">
|
|
341
|
+
<rect
|
|
342
|
+
width="14"
|
|
343
|
+
height="7"
|
|
344
|
+
fill="white"
|
|
345
|
+
style={{ fill: 'white', fillOpacity: 1 }}
|
|
346
|
+
/>
|
|
347
|
+
</clipPath>
|
|
348
|
+
</defs>
|
|
349
|
+
</svg>
|
|
350
|
+
</div>
|
|
351
|
+
<div ref={portalRef} />
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>,
|
|
355
|
+
resolvedPortalTarget ?? document.body
|
|
356
|
+
)
|
|
357
|
+
: null}
|
|
358
|
+
</ContextCardContext.Provider>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export type Side = 'top' | 'bottom' | 'left' | 'right';
|
|
363
|
+
export type Align = 'start' | 'center' | 'end';
|
|
364
|
+
|
|
365
|
+
/** Props for the {@link ContextCardTrigger} component. */
|
|
366
|
+
export interface ContextCardTriggerProps
|
|
367
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'content'> {
|
|
368
|
+
/** The content rendered inside the context card popup. */
|
|
369
|
+
content: ReactNode;
|
|
370
|
+
/** Which side of the trigger to display the card. Defaults to `"right"`. */
|
|
371
|
+
side?: Side;
|
|
372
|
+
/** Alignment of the card relative to the trigger. Defaults to `"center"`. */
|
|
373
|
+
align?: Align;
|
|
374
|
+
/** Distance in pixels between the trigger and the card. Defaults to `16`. */
|
|
375
|
+
sideOffset?: number;
|
|
376
|
+
/** Offset in pixels along the alignment axis. Defaults to `0`. */
|
|
377
|
+
alignOffset?: number;
|
|
378
|
+
/** Whether to disable pointer events on the card content. */
|
|
379
|
+
ignoreCardPointerEvents?: boolean;
|
|
380
|
+
/** Whether to remove default padding from the card content. */
|
|
381
|
+
noPadding?: boolean;
|
|
382
|
+
/** Whether the trigger uses the Slot pattern to merge props onto its child. */
|
|
383
|
+
asChild?: boolean;
|
|
384
|
+
/** Can be used to hide the card when needed. */
|
|
385
|
+
hide?: boolean;
|
|
386
|
+
/** Time in milliseconds before the card is dismissed after mouse leaves. */
|
|
387
|
+
inactiveTimeoutMs?: number;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Displays a floating context card on hover with viewport-aware collision
|
|
392
|
+
* detection and animated position transitions between triggers in the same
|
|
393
|
+
* {@link ContextCardProvider}. Persists briefly after mouse leave.
|
|
394
|
+
*/
|
|
395
|
+
export function ContextCardTrigger({
|
|
396
|
+
content,
|
|
397
|
+
children,
|
|
398
|
+
side = 'right',
|
|
399
|
+
align = 'center',
|
|
400
|
+
sideOffset = 16,
|
|
401
|
+
alignOffset = 0,
|
|
402
|
+
ignoreCardPointerEvents = false,
|
|
403
|
+
noPadding,
|
|
404
|
+
asChild,
|
|
405
|
+
className,
|
|
406
|
+
hide,
|
|
407
|
+
inactiveTimeoutMs = INACTIVE_TIMEOUT_MS,
|
|
408
|
+
}: ContextCardTriggerProps): JSX.Element {
|
|
409
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
410
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
411
|
+
const [contentMeasureRef, contentMeasureBounds] = useMeasure();
|
|
412
|
+
const {
|
|
413
|
+
portalRef,
|
|
414
|
+
updateActiveContextCard,
|
|
415
|
+
activeId,
|
|
416
|
+
setActiveId,
|
|
417
|
+
hoveredId,
|
|
418
|
+
setHoveredId,
|
|
419
|
+
skipTransition,
|
|
420
|
+
rootVisible,
|
|
421
|
+
} = useContext(ContextCardContext);
|
|
422
|
+
const id = useId();
|
|
423
|
+
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
return () => {
|
|
426
|
+
// Only clear the shared active card if this trigger was the active one;
|
|
427
|
+
// otherwise an unmounting inactive trigger would hide another trigger's card.
|
|
428
|
+
if (activeId.current === id) {
|
|
429
|
+
setActiveId(null);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
}, []);
|
|
433
|
+
|
|
434
|
+
const reduceMotion = useReducedMotion();
|
|
435
|
+
|
|
436
|
+
const [localHover, setLocalHover] = useState(false);
|
|
437
|
+
const [persisting, setPersisting] = useState(false);
|
|
438
|
+
const [visible, setVisible] = useState(false);
|
|
439
|
+
|
|
440
|
+
const activeTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
441
|
+
const persistingTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
442
|
+
const openTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
443
|
+
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
if (
|
|
446
|
+
localHover &&
|
|
447
|
+
contentMeasureBounds.width > 0 &&
|
|
448
|
+
contentMeasureBounds.height > 0
|
|
449
|
+
) {
|
|
450
|
+
if (openTimeout.current) clearTimeout(openTimeout.current);
|
|
451
|
+
openTimeout.current = setTimeout(
|
|
452
|
+
() => {
|
|
453
|
+
setActiveId(id);
|
|
454
|
+
updateBounds();
|
|
455
|
+
},
|
|
456
|
+
rootVisible ? 0 : OPEN_DELAY_MS
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
return () => {
|
|
460
|
+
if (openTimeout.current) clearTimeout(openTimeout.current);
|
|
461
|
+
};
|
|
462
|
+
}, [localHover, contentMeasureBounds]);
|
|
463
|
+
|
|
464
|
+
const enterTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
465
|
+
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
if (enterTimeout.current) clearTimeout(enterTimeout.current);
|
|
468
|
+
if (hoveredId !== id) {
|
|
469
|
+
// Exits are instant
|
|
470
|
+
setVisible(false);
|
|
471
|
+
} else {
|
|
472
|
+
// Enters can be delayed unless we wish to script the transition
|
|
473
|
+
enterTimeout.current = setTimeout(
|
|
474
|
+
() => {
|
|
475
|
+
setVisible(true);
|
|
476
|
+
},
|
|
477
|
+
reduceMotion || skipTransition ? 0 : ENTER_DELAY_MS
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
}, [hoveredId, id, reduceMotion, skipTransition]);
|
|
481
|
+
|
|
482
|
+
function updateBounds(): void {
|
|
483
|
+
if (!contentRef.current) return;
|
|
484
|
+
const bounds = computeBounds();
|
|
485
|
+
updateActiveContextCard({ id, ...bounds });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function computeBounds(sideOverride?: Side): {
|
|
489
|
+
origin: Point;
|
|
490
|
+
contentSize: { width: number; height: number };
|
|
491
|
+
side: Side;
|
|
492
|
+
arrowOffset: Point;
|
|
493
|
+
} {
|
|
494
|
+
if (!triggerRef.current) throw new Error('Trigger not found');
|
|
495
|
+
if (!contentRef.current) throw new Error('Content not found');
|
|
496
|
+
const appliedSide = sideOverride ?? side;
|
|
497
|
+
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
498
|
+
const contentRect = contentRef.current.getBoundingClientRect();
|
|
499
|
+
contentRef.current.style.width = 'max-content';
|
|
500
|
+
contentRef.current.style.position = 'absolute';
|
|
501
|
+
const contentSize = {
|
|
502
|
+
width: Math.max(contentRect.width, contentRef.current.offsetWidth),
|
|
503
|
+
height: contentRect.height,
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
let origin: Point;
|
|
507
|
+
|
|
508
|
+
const arrowWidth = 14;
|
|
509
|
+
const halfArrowWidth = arrowWidth / 2;
|
|
510
|
+
const halfTriggerWidth = triggerRect.width / 2;
|
|
511
|
+
const halfTriggerHeight = triggerRect.height / 2;
|
|
512
|
+
const halfContentWidth = contentSize.width / 2;
|
|
513
|
+
const halfContentHeight = contentSize.height / 2;
|
|
514
|
+
|
|
515
|
+
const top = triggerRect.top;
|
|
516
|
+
const left = triggerRect.left;
|
|
517
|
+
|
|
518
|
+
let alignX = left + halfTriggerWidth - halfContentWidth + alignOffset;
|
|
519
|
+
if (align === 'start') alignX = left + alignOffset;
|
|
520
|
+
if (align === 'end')
|
|
521
|
+
alignX = left + triggerRect.width - contentRect.width + alignOffset;
|
|
522
|
+
|
|
523
|
+
let alignY = top + halfTriggerHeight - halfContentHeight + alignOffset;
|
|
524
|
+
if (align === 'start') alignY = top + alignOffset;
|
|
525
|
+
if (align === 'end')
|
|
526
|
+
alignY = top + triggerRect.height - contentRect.height + alignOffset;
|
|
527
|
+
|
|
528
|
+
switch (appliedSide) {
|
|
529
|
+
case 'top':
|
|
530
|
+
origin = {
|
|
531
|
+
x: alignX,
|
|
532
|
+
y: top - contentRect.height - sideOffset,
|
|
533
|
+
};
|
|
534
|
+
break;
|
|
535
|
+
case 'right':
|
|
536
|
+
origin = {
|
|
537
|
+
x: left + triggerRect.width + sideOffset,
|
|
538
|
+
y: alignY,
|
|
539
|
+
};
|
|
540
|
+
break;
|
|
541
|
+
case 'bottom':
|
|
542
|
+
origin = {
|
|
543
|
+
x: alignX,
|
|
544
|
+
y: top + triggerRect.height + sideOffset,
|
|
545
|
+
};
|
|
546
|
+
break;
|
|
547
|
+
case 'left':
|
|
548
|
+
origin = {
|
|
549
|
+
x: left - contentRect.width - sideOffset,
|
|
550
|
+
y: alignY,
|
|
551
|
+
};
|
|
552
|
+
break;
|
|
553
|
+
default: {
|
|
554
|
+
const _exhaustive: never = appliedSide;
|
|
555
|
+
throw new Error(`Unknown side: ${String(_exhaustive)}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
560
|
+
const viewportHeight = document.documentElement.clientHeight;
|
|
561
|
+
|
|
562
|
+
const isOffscreenLeft = origin.x < 0;
|
|
563
|
+
const isOffscreenRight = origin.x + contentRect.width > viewportWidth;
|
|
564
|
+
const isOffscreenTop = origin.y < 0;
|
|
565
|
+
const isOffscreenBottom = origin.y + contentRect.height > viewportHeight;
|
|
566
|
+
|
|
567
|
+
if (!sideOverride) {
|
|
568
|
+
const spaceAbove = top;
|
|
569
|
+
const spaceBelow = viewportHeight - (top + triggerRect.height);
|
|
570
|
+
const spaceLeft = left;
|
|
571
|
+
const spaceRight = viewportWidth - (left + triggerRect.width);
|
|
572
|
+
|
|
573
|
+
if (side === 'top' && isOffscreenTop && spaceBelow > spaceAbove) {
|
|
574
|
+
return computeBounds('bottom');
|
|
575
|
+
}
|
|
576
|
+
if (side === 'bottom' && isOffscreenBottom && spaceAbove > spaceBelow) {
|
|
577
|
+
return computeBounds('top');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (side === 'right' && isOffscreenRight && spaceLeft > spaceRight) {
|
|
581
|
+
return computeBounds('left');
|
|
582
|
+
}
|
|
583
|
+
if (side === 'left' && isOffscreenLeft && spaceRight > spaceLeft) {
|
|
584
|
+
return computeBounds('right');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (
|
|
588
|
+
(side === 'top' || side === 'bottom') &&
|
|
589
|
+
isOffscreenLeft &&
|
|
590
|
+
spaceRight > contentRect.width
|
|
591
|
+
) {
|
|
592
|
+
return computeBounds('right');
|
|
593
|
+
}
|
|
594
|
+
if (
|
|
595
|
+
(side === 'top' || side === 'bottom') &&
|
|
596
|
+
isOffscreenRight &&
|
|
597
|
+
spaceLeft > contentRect.width
|
|
598
|
+
) {
|
|
599
|
+
return computeBounds('left');
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (
|
|
603
|
+
(side === 'left' || side === 'right') &&
|
|
604
|
+
isOffscreenTop &&
|
|
605
|
+
spaceBelow > contentRect.height
|
|
606
|
+
) {
|
|
607
|
+
return computeBounds('bottom');
|
|
608
|
+
}
|
|
609
|
+
if (
|
|
610
|
+
(side === 'left' || side === 'right') &&
|
|
611
|
+
isOffscreenBottom &&
|
|
612
|
+
spaceAbove > contentRect.height
|
|
613
|
+
) {
|
|
614
|
+
return computeBounds('top');
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const viewportPadding = 8;
|
|
619
|
+
if (appliedSide === 'left' || appliedSide === 'right') {
|
|
620
|
+
origin.y = Math.max(
|
|
621
|
+
viewportPadding,
|
|
622
|
+
Math.min(
|
|
623
|
+
origin.y,
|
|
624
|
+
viewportHeight - contentSize.height - viewportPadding
|
|
625
|
+
)
|
|
626
|
+
);
|
|
627
|
+
} else {
|
|
628
|
+
origin.x = Math.max(
|
|
629
|
+
viewportPadding,
|
|
630
|
+
Math.min(origin.x, viewportWidth - contentSize.width - viewportPadding)
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Always constrain Y position within viewport bounds
|
|
635
|
+
origin.y = Math.max(
|
|
636
|
+
viewportPadding,
|
|
637
|
+
Math.min(origin.y, viewportHeight - contentSize.height - viewportPadding)
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
const triggerCenterX = left + halfTriggerWidth;
|
|
641
|
+
const triggerCenterY = top + halfTriggerHeight;
|
|
642
|
+
const cardCenterX = origin.x + contentSize.width / 2;
|
|
643
|
+
const cardCenterY = origin.y + contentSize.height / 2;
|
|
644
|
+
|
|
645
|
+
let arrowOffsetX = triggerCenterX - cardCenterX;
|
|
646
|
+
let arrowOffsetY = triggerCenterY - cardCenterY;
|
|
647
|
+
|
|
648
|
+
// Clamp arrow offset to ensure it stays within card boundaries.
|
|
649
|
+
// Arrow should be at least halfArrowWidth (7px) from the card edges.
|
|
650
|
+
const maxArrowOffsetX = halfContentWidth - halfArrowWidth;
|
|
651
|
+
const maxArrowOffsetY = halfContentHeight - halfArrowWidth;
|
|
652
|
+
|
|
653
|
+
if (appliedSide === 'top' || appliedSide === 'bottom') {
|
|
654
|
+
arrowOffsetX = Math.max(
|
|
655
|
+
-maxArrowOffsetX,
|
|
656
|
+
Math.min(arrowOffsetX, maxArrowOffsetX)
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (appliedSide === 'left' || appliedSide === 'right') {
|
|
661
|
+
arrowOffsetY = Math.max(
|
|
662
|
+
-maxArrowOffsetY,
|
|
663
|
+
Math.min(arrowOffsetY, maxArrowOffsetY)
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const arrowOffset = {
|
|
668
|
+
x: arrowOffsetX,
|
|
669
|
+
y: arrowOffsetY,
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
return { origin, contentSize, side: appliedSide, arrowOffset };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
useEffect(() => {
|
|
676
|
+
document.addEventListener('scroll', updateBounds, true);
|
|
677
|
+
window.addEventListener('resize', updateBounds);
|
|
678
|
+
return () => {
|
|
679
|
+
document.removeEventListener('scroll', updateBounds, true);
|
|
680
|
+
window.removeEventListener('resize', updateBounds);
|
|
681
|
+
};
|
|
682
|
+
}, []);
|
|
683
|
+
|
|
684
|
+
function resetDestructiveTimeouts(): void {
|
|
685
|
+
if (activeTimeout.current) clearTimeout(activeTimeout.current);
|
|
686
|
+
if (persistingTimeout.current) clearTimeout(persistingTimeout.current);
|
|
687
|
+
if (openTimeout.current) clearTimeout(openTimeout.current);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function attemptDeactivate(): void {
|
|
691
|
+
if (activeTimeout.current) clearTimeout(activeTimeout.current);
|
|
692
|
+
activeTimeout.current = setTimeout(() => {
|
|
693
|
+
if (activeId.current !== id) return;
|
|
694
|
+
setActiveId(null);
|
|
695
|
+
}, inactiveTimeoutMs);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function attemptPersist(): void {
|
|
699
|
+
setPersisting(true);
|
|
700
|
+
if (persistingTimeout.current) clearTimeout(persistingTimeout.current);
|
|
701
|
+
persistingTimeout.current = setTimeout(() => {
|
|
702
|
+
setPersisting(false);
|
|
703
|
+
}, PERSIST_TIMEOUT_MS);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function onMouseEnter(): void {
|
|
707
|
+
resetDestructiveTimeouts();
|
|
708
|
+
setHoveredId(id);
|
|
709
|
+
setLocalHover(true);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function onMouseLeave(): void {
|
|
713
|
+
setLocalHover(false);
|
|
714
|
+
attemptDeactivate();
|
|
715
|
+
attemptPersist();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
useEffect(() => {
|
|
719
|
+
// close instantly
|
|
720
|
+
if (hide) {
|
|
721
|
+
setLocalHover(false);
|
|
722
|
+
setActiveId(null);
|
|
723
|
+
}
|
|
724
|
+
}, [hide, setActiveId]);
|
|
725
|
+
|
|
726
|
+
function handleClick(e: React.MouseEvent): void {
|
|
727
|
+
// Close if clicking a link. We assume we want the card to disappear if navigating.
|
|
728
|
+
if (e.target instanceof HTMLElement && e.target.closest('a[href]')) {
|
|
729
|
+
onMouseLeave();
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const Comp = asChild ? SlotRoot : 'div';
|
|
734
|
+
|
|
735
|
+
return (
|
|
736
|
+
<Comp
|
|
737
|
+
className={cn(
|
|
738
|
+
'inline-flex cursor-pointer flex-[0_1_auto] overflow-hidden',
|
|
739
|
+
className
|
|
740
|
+
)}
|
|
741
|
+
onMouseEnter={onMouseEnter}
|
|
742
|
+
onMouseLeave={onMouseLeave}
|
|
743
|
+
onClick={handleClick}
|
|
744
|
+
ref={triggerRef}
|
|
745
|
+
>
|
|
746
|
+
<Slottable>{children}</Slottable>
|
|
747
|
+
{portalRef?.current && (localHover || persisting)
|
|
748
|
+
? createPortal(
|
|
749
|
+
<div
|
|
750
|
+
className={cn(
|
|
751
|
+
'absolute top-0 p-3 left-0 max-w-max transition-[transform,width,height] duration-250 ease-[easing-function:cubic-bezier(0.29,0.31,0.05,0.96)] will-change-[transform,width,height] motion-reduce:!transition-none',
|
|
752
|
+
skipTransition && '!transition-none'
|
|
753
|
+
)}
|
|
754
|
+
key={id}
|
|
755
|
+
ref={contentRef}
|
|
756
|
+
style={{
|
|
757
|
+
...(noPadding ? { padding: 0 } : {}),
|
|
758
|
+
pointerEvents:
|
|
759
|
+
rootVisible && visible && !ignoreCardPointerEvents
|
|
760
|
+
? 'all'
|
|
761
|
+
: 'none',
|
|
762
|
+
}}
|
|
763
|
+
>
|
|
764
|
+
<div
|
|
765
|
+
className={cn(
|
|
766
|
+
'min-w-max transition-all duration-150 ease-[easing-function:cubic-bezier(0.3,_0.57,_0.07,_0.95)] will-change-[transform,width,height] motion-reduce:!transition-none',
|
|
767
|
+
skipTransition
|
|
768
|
+
? '!transition-none'
|
|
769
|
+
: 'transition-[transform,width,height] duration-250 ease-[easing-function:cubic-bezier(0.29,0.31,0.05,0.96)] will-change-[transform,width,height] motion-reduce:!transition-none'
|
|
770
|
+
)}
|
|
771
|
+
ref={contentMeasureRef}
|
|
772
|
+
style={{
|
|
773
|
+
opacity: Number(visible),
|
|
774
|
+
}}
|
|
775
|
+
>
|
|
776
|
+
{content}
|
|
777
|
+
</div>
|
|
778
|
+
</div>,
|
|
779
|
+
portalRef.current
|
|
780
|
+
)
|
|
781
|
+
: null}
|
|
782
|
+
</Comp>
|
|
783
|
+
);
|
|
784
|
+
}
|