@workflow/web-shared 5.0.0-beta.10 → 5.0.0-beta.11
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/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +2 -1
- package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/event-list.js +9 -4
- package/dist/components/new-trace-viewer/components/split-pane.d.ts +3 -2
- package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/split-pane.js +10 -4
- 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 +6 -8
- package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts +2 -0
- package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +19 -0
- package/dist/components/new-trace-viewer/components/use-row-window.d.ts +20 -0
- package/dist/components/new-trace-viewer/components/use-row-window.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/use-row-window.js +69 -0
- package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/context.js +2 -2
- package/dist/components/new-trace-viewer/trace-viewer.d.ts +4 -1
- package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/trace-viewer.js +19 -6
- package/dist/components/trace-viewer-new.d.ts +5 -1
- package/dist/components/trace-viewer-new.d.ts.map +1 -1
- package/dist/components/trace-viewer-new.js +7 -8
- package/dist/components/ui/icon-button.d.ts +8 -2
- package/dist/components/ui/icon-button.d.ts.map +1 -1
- package/dist/components/ui/icon-button.js +4 -4
- package/dist/components/ui/kbd.d.ts +6 -0
- package/dist/components/ui/kbd.d.ts.map +1 -0
- package/dist/components/ui/kbd.js +6 -0
- package/dist/components/ui/tooltip.d.ts +10 -0
- package/dist/components/ui/tooltip.d.ts.map +1 -0
- package/dist/components/ui/tooltip.js +12 -0
- package/dist/hooks/use-intersection-observer.d.ts +17 -0
- package/dist/hooks/use-intersection-observer.d.ts.map +1 -0
- package/dist/hooks/use-intersection-observer.js +29 -0
- package/dist/hooks/use-load-more-on-scroll.d.ts +21 -0
- package/dist/hooks/use-load-more-on-scroll.d.ts.map +1 -0
- package/dist/hooks/use-load-more-on-scroll.js +28 -0
- package/package.json +3 -3
- package/src/components/index.ts +1 -0
- package/src/components/new-trace-viewer/components/event-list.tsx +19 -11
- package/src/components/new-trace-viewer/components/split-pane.tsx +15 -2
- package/src/components/new-trace-viewer/components/timeline.tsx +27 -19
- package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +94 -0
- package/src/components/new-trace-viewer/components/use-row-window.ts +93 -0
- package/src/components/new-trace-viewer/context.tsx +1 -3
- package/src/components/new-trace-viewer/trace-viewer.tsx +89 -21
- package/src/components/trace-viewer-new.tsx +20 -12
- package/src/components/ui/icon-button.tsx +7 -11
- package/src/components/ui/kbd.tsx +21 -0
- package/src/components/ui/tooltip.tsx +47 -0
- package/src/hooks/use-intersection-observer.ts +57 -0
- package/src/hooks/use-load-more-on-scroll.ts +49 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type RefObject, useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/** Rows rendered on the first paint, before the container has been measured. */
|
|
6
|
+
const INITIAL_VISIBLE_ROWS = 60;
|
|
7
|
+
|
|
8
|
+
export interface RowWindow {
|
|
9
|
+
/** First visible row index (inclusive), already padded by overscan. */
|
|
10
|
+
start: number;
|
|
11
|
+
/** Last visible row index (exclusive), already padded by overscan. */
|
|
12
|
+
end: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Nearest scrollable ancestor of `el` (the shared `SplitPane` container). */
|
|
16
|
+
function getScrollParent(el: HTMLElement | null): HTMLElement | null {
|
|
17
|
+
let node = el?.parentElement ?? null;
|
|
18
|
+
while (node) {
|
|
19
|
+
const overflowY = getComputedStyle(node).overflowY;
|
|
20
|
+
if (overflowY === 'auto' || overflowY === 'scroll') return node;
|
|
21
|
+
node = node.parentElement;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fixed-height windowing over a shared scroll container.
|
|
28
|
+
*
|
|
29
|
+
* The events list and timeline both render one `rowHeight`-tall row per span
|
|
30
|
+
* and scroll together inside the same `SplitPane`. Each caller passes a ref to
|
|
31
|
+
* its own root; the hook walks up to the shared scrollable ancestor, so the
|
|
32
|
+
* scroll container doesn't have to be threaded through props. Same scroll
|
|
33
|
+
* position + same row height means both panes window to the same range.
|
|
34
|
+
*/
|
|
35
|
+
export function useRowWindow(
|
|
36
|
+
ref: RefObject<HTMLElement | null>,
|
|
37
|
+
rowCount: number,
|
|
38
|
+
rowHeight: number,
|
|
39
|
+
overscan = 12
|
|
40
|
+
): RowWindow {
|
|
41
|
+
// Bound the pre-measurement window so the first paint of a large trace
|
|
42
|
+
// doesn't render every row before the effect narrows it down.
|
|
43
|
+
const [range, setRange] = useState<RowWindow>(() => ({
|
|
44
|
+
start: 0,
|
|
45
|
+
end: Math.min(rowCount, INITIAL_VISIBLE_ROWS),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const self = ref.current;
|
|
50
|
+
const el = getScrollParent(self);
|
|
51
|
+
if (!self || !el) return;
|
|
52
|
+
|
|
53
|
+
let raf = 0;
|
|
54
|
+
const measure = () => {
|
|
55
|
+
raf = 0;
|
|
56
|
+
// Offset of the list's top within the scroll container's content, so the
|
|
57
|
+
// window stays correct even if the list isn't flush with the top.
|
|
58
|
+
const offset =
|
|
59
|
+
self.getBoundingClientRect().top -
|
|
60
|
+
el.getBoundingClientRect().top +
|
|
61
|
+
el.scrollTop;
|
|
62
|
+
const top = el.scrollTop - offset;
|
|
63
|
+
const start = Math.max(0, Math.floor(top / rowHeight) - overscan);
|
|
64
|
+
const end = Math.min(
|
|
65
|
+
rowCount,
|
|
66
|
+
Math.ceil((top + el.clientHeight) / rowHeight) + overscan
|
|
67
|
+
);
|
|
68
|
+
setRange((prev) =>
|
|
69
|
+
prev.start === start && prev.end === end ? prev : { start, end }
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const onScroll = () => {
|
|
74
|
+
if (!raf) raf = requestAnimationFrame(measure);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
measure();
|
|
78
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
79
|
+
const ro = new ResizeObserver(measure);
|
|
80
|
+
ro.observe(el);
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
el.removeEventListener('scroll', onScroll);
|
|
84
|
+
ro.disconnect();
|
|
85
|
+
if (raf) cancelAnimationFrame(raf);
|
|
86
|
+
};
|
|
87
|
+
}, [ref, rowCount, rowHeight, overscan]);
|
|
88
|
+
|
|
89
|
+
return range;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Row height (px) shared by the events list and timeline rows (`h-10`). */
|
|
93
|
+
export const ROW_HEIGHT_PX = 40;
|
|
@@ -28,9 +28,7 @@ export function ActiveSpanProvider({
|
|
|
28
28
|
spans: Span[];
|
|
29
29
|
children: ReactNode;
|
|
30
30
|
}) {
|
|
31
|
-
const [activeSpanId, setActiveSpanId] = useState<string | null>(
|
|
32
|
-
spans[0]?.spanId ?? null
|
|
33
|
-
);
|
|
31
|
+
const [activeSpanId, setActiveSpanId] = useState<string | null>(null);
|
|
34
32
|
|
|
35
33
|
useEffect(() => {
|
|
36
34
|
setActiveSpanId((currentSpanId) => {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
useRef,
|
|
20
20
|
useState,
|
|
21
21
|
} from 'react';
|
|
22
|
+
import { useLoadMoreOnScroll } from '../../hooks/use-load-more-on-scroll';
|
|
22
23
|
import { useReducedMotion } from '../../hooks/use-reduced-motion';
|
|
23
24
|
import { ErrorBoundary } from '../error-boundary';
|
|
24
25
|
import {
|
|
@@ -29,6 +30,8 @@ import { useSidebarDataOptional } from '../sidebar/sidebar-data-context';
|
|
|
29
30
|
import type { Trace } from '../trace-viewer/types';
|
|
30
31
|
import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
|
|
31
32
|
import { IconButton } from '../ui/icon-button';
|
|
33
|
+
import { Spinner } from '../ui/spinner';
|
|
34
|
+
import { Kbd } from '../ui/kbd';
|
|
32
35
|
import EventList from './components/event-list';
|
|
33
36
|
import { SplitPane } from './components/split-pane';
|
|
34
37
|
import {
|
|
@@ -36,6 +39,12 @@ import {
|
|
|
36
39
|
Timeline,
|
|
37
40
|
TimelineHeader,
|
|
38
41
|
} from './components/timeline';
|
|
42
|
+
import {
|
|
43
|
+
Tooltip,
|
|
44
|
+
TooltipContent,
|
|
45
|
+
TooltipProvider,
|
|
46
|
+
TooltipTrigger,
|
|
47
|
+
} from '../ui/tooltip';
|
|
39
48
|
import { ActiveSpanProvider, useActiveSpan } from './context';
|
|
40
49
|
import { DetailPanel } from './detail-panel';
|
|
41
50
|
import { searchSpans } from './search';
|
|
@@ -43,6 +52,9 @@ import { computeRootBounds, computeTimeMarkers } from './utils';
|
|
|
43
52
|
|
|
44
53
|
interface NewTraceViewerProps {
|
|
45
54
|
trace: Trace;
|
|
55
|
+
onLoadMore?: () => void | Promise<void>;
|
|
56
|
+
hasMore?: boolean;
|
|
57
|
+
isLoadingMore?: boolean;
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
const MIN_VIEWPORT_MS = 0.001;
|
|
@@ -152,15 +164,32 @@ function useSelectedSpanInfo(): SelectedSpanInfo | null {
|
|
|
152
164
|
// Root component
|
|
153
165
|
// ---------------------------------------------------------------------------
|
|
154
166
|
|
|
155
|
-
export function NewTraceViewer({
|
|
167
|
+
export function NewTraceViewer({
|
|
168
|
+
trace,
|
|
169
|
+
onLoadMore,
|
|
170
|
+
hasMore,
|
|
171
|
+
isLoadingMore,
|
|
172
|
+
}: NewTraceViewerProps): ReactNode {
|
|
156
173
|
return (
|
|
157
|
-
<
|
|
158
|
-
<
|
|
159
|
-
|
|
174
|
+
<TooltipProvider delayDuration={300}>
|
|
175
|
+
<ActiveSpanProvider spans={trace.spans}>
|
|
176
|
+
<NewTraceViewerContent
|
|
177
|
+
trace={trace}
|
|
178
|
+
onLoadMore={onLoadMore}
|
|
179
|
+
hasMore={hasMore}
|
|
180
|
+
isLoadingMore={isLoadingMore}
|
|
181
|
+
/>
|
|
182
|
+
</ActiveSpanProvider>
|
|
183
|
+
</TooltipProvider>
|
|
160
184
|
);
|
|
161
185
|
}
|
|
162
186
|
|
|
163
|
-
function NewTraceViewerContent({
|
|
187
|
+
function NewTraceViewerContent({
|
|
188
|
+
trace,
|
|
189
|
+
onLoadMore,
|
|
190
|
+
hasMore,
|
|
191
|
+
isLoadingMore,
|
|
192
|
+
}: NewTraceViewerProps): ReactNode {
|
|
164
193
|
const { activeSpan, activeSpanId, setActiveSpan, clearActiveSpan } =
|
|
165
194
|
useActiveSpan();
|
|
166
195
|
|
|
@@ -177,6 +206,16 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
|
|
|
177
206
|
|
|
178
207
|
const root = useMemo(() => computeRootBounds(trace.spans), [trace.spans]);
|
|
179
208
|
|
|
209
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
210
|
+
const loadMore = useCallback(() => {
|
|
211
|
+
void onLoadMore?.();
|
|
212
|
+
}, [onLoadMore]);
|
|
213
|
+
const loadMoreSentinelRef = useLoadMoreOnScroll(loadMore, {
|
|
214
|
+
hasMore: Boolean(onLoadMore && hasMore),
|
|
215
|
+
isLoadingMore: Boolean(isLoadingMore),
|
|
216
|
+
rootRef: scrollContainerRef,
|
|
217
|
+
});
|
|
218
|
+
|
|
180
219
|
const { viewport, setViewport, animateTo } = useAnimatedViewport({
|
|
181
220
|
start: root.startTime,
|
|
182
221
|
end: root.startTime + root.duration,
|
|
@@ -530,6 +569,7 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
|
|
|
530
569
|
className="grid grid-rows-[1fr] h-full min-h-0 overflow-hidden relative bg-background-100"
|
|
531
570
|
>
|
|
532
571
|
<SplitPane
|
|
572
|
+
scrollContainerRef={scrollContainerRef}
|
|
533
573
|
startHeader={
|
|
534
574
|
<div className="bg-background-100 border-b border-gray-alpha-400 h-10 min-h-10 flex items-center pl-4 pr-2 gap-1.5">
|
|
535
575
|
<Search className="w-3.5 h-3.5 shrink-0 text-gray-800" />
|
|
@@ -575,6 +615,14 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
|
|
|
575
615
|
searchResult={searchResult}
|
|
576
616
|
onSelectSpan={handleSelectSpan}
|
|
577
617
|
/>
|
|
618
|
+
<div ref={loadMoreSentinelRef} className="flex justify-center">
|
|
619
|
+
{isLoadingMore ? (
|
|
620
|
+
<div className="flex items-center justify-center gap-2 py-3 text-sm text-gray-800">
|
|
621
|
+
<Spinner size={14} />
|
|
622
|
+
<span>Loading spans…</span>
|
|
623
|
+
</div>
|
|
624
|
+
) : null}
|
|
625
|
+
</div>
|
|
578
626
|
</div>
|
|
579
627
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: timeline hover and wheel gestures are pointer-only annotations */}
|
|
580
628
|
<div
|
|
@@ -634,22 +682,42 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
|
|
|
634
682
|
{selectedSpanName}
|
|
635
683
|
</span>
|
|
636
684
|
<div className="flex items-center gap-0.5 shrink-0">
|
|
637
|
-
<
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
685
|
+
<Tooltip>
|
|
686
|
+
<TooltipTrigger asChild>
|
|
687
|
+
<IconButton
|
|
688
|
+
aria-label="Navigate up"
|
|
689
|
+
aria-keyshortcuts="K"
|
|
690
|
+
onClick={handleSelectPrevSpan}
|
|
691
|
+
disabled={!prevSpanId}
|
|
692
|
+
>
|
|
693
|
+
<ChevronUp className="w-4 h-4" />
|
|
694
|
+
</IconButton>
|
|
695
|
+
</TooltipTrigger>
|
|
696
|
+
{prevSpanId ? (
|
|
697
|
+
<TooltipContent>
|
|
698
|
+
Navigate up
|
|
699
|
+
<Kbd>K</Kbd>
|
|
700
|
+
</TooltipContent>
|
|
701
|
+
) : null}
|
|
702
|
+
</Tooltip>
|
|
703
|
+
<Tooltip>
|
|
704
|
+
<TooltipTrigger asChild>
|
|
705
|
+
<IconButton
|
|
706
|
+
aria-label="Navigate down"
|
|
707
|
+
aria-keyshortcuts="J"
|
|
708
|
+
onClick={handleSelectNextSpan}
|
|
709
|
+
disabled={!nextSpanId}
|
|
710
|
+
>
|
|
711
|
+
<ChevronDown className="w-4 h-4" />
|
|
712
|
+
</IconButton>
|
|
713
|
+
</TooltipTrigger>
|
|
714
|
+
{nextSpanId ? (
|
|
715
|
+
<TooltipContent>
|
|
716
|
+
Navigate down
|
|
717
|
+
<Kbd>J</Kbd>
|
|
718
|
+
</TooltipContent>
|
|
719
|
+
) : null}
|
|
720
|
+
</Tooltip>
|
|
653
721
|
<div aria-hidden className="w-px h-4 bg-gray-alpha-400 mx-1" />
|
|
654
722
|
<IconButton
|
|
655
723
|
aria-label="Close span details"
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { useMemo } from 'react';
|
|
2
1
|
import type { WorkflowRun } from '@workflow/core/runtime';
|
|
3
2
|
import type { Event } from '@workflow/world';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
4
|
import { buildTrace, type TraceWithMeta } from '../lib/trace-builder';
|
|
5
|
+
import { TraceViewerSkeleton } from './new-trace-viewer/components/trace-viewer-skeleton';
|
|
5
6
|
import { NewTraceViewer as NewTraceViewerComponent } from './new-trace-viewer/trace-viewer';
|
|
6
7
|
import {
|
|
7
|
-
SidebarDataProvider,
|
|
8
8
|
type SidebarDataContextValue,
|
|
9
|
+
SidebarDataProvider,
|
|
9
10
|
} from './sidebar/sidebar-data-context';
|
|
10
11
|
import type { Trace } from './trace-viewer/types';
|
|
11
12
|
|
|
@@ -13,34 +14,41 @@ const NewTraceViewer = ({
|
|
|
13
14
|
run,
|
|
14
15
|
events,
|
|
15
16
|
sidebarData,
|
|
17
|
+
onLoadMore,
|
|
18
|
+
hasMore,
|
|
19
|
+
isLoadingMore,
|
|
20
|
+
loading = false,
|
|
16
21
|
}: {
|
|
17
22
|
run: WorkflowRun;
|
|
18
23
|
events: Event[];
|
|
19
24
|
sidebarData: SidebarDataContextValue;
|
|
25
|
+
onLoadMore?: () => void | Promise<void>;
|
|
26
|
+
hasMore?: boolean;
|
|
27
|
+
isLoadingMore?: boolean;
|
|
28
|
+
loading?: boolean;
|
|
20
29
|
}) => {
|
|
21
|
-
// Build trace only when actual data changes — no timer-driven rebuilds.
|
|
22
|
-
// Active span widths are animated imperatively by useLiveTick at 60fps.
|
|
23
30
|
const traceWithMeta: TraceWithMeta | undefined = useMemo(() => {
|
|
24
31
|
if (!run?.runId) {
|
|
25
32
|
return undefined;
|
|
26
33
|
}
|
|
27
34
|
return buildTrace(run, events, new Date());
|
|
28
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep
|
|
35
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep
|
|
29
36
|
}, [run, events]);
|
|
30
37
|
const trace = traceWithMeta;
|
|
31
38
|
|
|
32
|
-
if (!trace) {
|
|
33
|
-
return
|
|
34
|
-
<div className="relative w-full h-full flex items-center justify-center">
|
|
35
|
-
<div className="text-gray-500 text-sm">Loading trace…</div>
|
|
36
|
-
</div>
|
|
37
|
-
);
|
|
39
|
+
if (!trace || (loading && events.length === 0)) {
|
|
40
|
+
return <TraceViewerSkeleton />;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
return (
|
|
41
44
|
<SidebarDataProvider value={sidebarData}>
|
|
42
45
|
<div className="relative w-full h-full flex">
|
|
43
|
-
<NewTraceViewerComponent
|
|
46
|
+
<NewTraceViewerComponent
|
|
47
|
+
trace={trace as Trace}
|
|
48
|
+
onLoadMore={onLoadMore}
|
|
49
|
+
hasMore={hasMore}
|
|
50
|
+
isLoadingMore={isLoadingMore}
|
|
51
|
+
/>
|
|
44
52
|
</div>
|
|
45
53
|
</SidebarDataProvider>
|
|
46
54
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
2
|
-
import
|
|
2
|
+
import * as React from 'react';
|
|
3
3
|
import { cn } from '../../lib/utils';
|
|
4
4
|
|
|
5
5
|
const iconButtonVariants = cva(
|
|
@@ -38,20 +38,16 @@ export type IconButtonProps = Omit<
|
|
|
38
38
|
type?: 'button' | 'submit' | 'reset';
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
export
|
|
42
|
-
className,
|
|
43
|
-
variant,
|
|
44
|
-
size,
|
|
45
|
-
type = 'button',
|
|
46
|
-
...props
|
|
47
|
-
}: IconButtonProps) {
|
|
48
|
-
return (
|
|
41
|
+
export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
42
|
+
({ className, variant, size, type = 'button', ...props }, ref) => (
|
|
49
43
|
<button
|
|
44
|
+
ref={ref}
|
|
50
45
|
type={type}
|
|
51
46
|
className={cn(iconButtonVariants({ variant, size, className }))}
|
|
52
47
|
{...props}
|
|
53
48
|
/>
|
|
54
|
-
)
|
|
55
|
-
|
|
49
|
+
)
|
|
50
|
+
);
|
|
51
|
+
IconButton.displayName = 'IconButton';
|
|
56
52
|
|
|
57
53
|
export { iconButtonVariants };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { cn } from '../../lib/utils';
|
|
3
|
+
|
|
4
|
+
export function Kbd({
|
|
5
|
+
children,
|
|
6
|
+
className,
|
|
7
|
+
}: {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
}): ReactNode {
|
|
11
|
+
return (
|
|
12
|
+
<kbd
|
|
13
|
+
className={cn(
|
|
14
|
+
'inline-flex h-5 min-w-5 items-center justify-center rounded bg-background-100/20 px-1 text-center font-sans text-xs leading-none',
|
|
15
|
+
className
|
|
16
|
+
)}
|
|
17
|
+
>
|
|
18
|
+
{children}
|
|
19
|
+
</kbd>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
import { cn } from '../../lib/utils';
|
|
6
|
+
|
|
7
|
+
const TooltipProvider = TooltipPrimitive.Provider;
|
|
8
|
+
|
|
9
|
+
const Tooltip = TooltipPrimitive.Root;
|
|
10
|
+
|
|
11
|
+
const TooltipTrigger = TooltipPrimitive.Trigger;
|
|
12
|
+
|
|
13
|
+
const TooltipContent = React.forwardRef<
|
|
14
|
+
React.ElementRef<typeof TooltipPrimitive.Content>,
|
|
15
|
+
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
|
16
|
+
showArrow?: boolean;
|
|
17
|
+
}
|
|
18
|
+
>(
|
|
19
|
+
(
|
|
20
|
+
{ className, sideOffset = 8, showArrow = true, children, ...props },
|
|
21
|
+
ref
|
|
22
|
+
) => (
|
|
23
|
+
<TooltipPrimitive.Portal>
|
|
24
|
+
<TooltipPrimitive.Content
|
|
25
|
+
ref={ref}
|
|
26
|
+
sideOffset={sideOffset}
|
|
27
|
+
className={cn(
|
|
28
|
+
'z-[99999] flex items-center gap-1 rounded-[10px] bg-gray-1000 px-2 py-1.5 !text-label-13 text-background-100 shadow-md select-none',
|
|
29
|
+
className
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
33
|
+
{children}
|
|
34
|
+
{showArrow ? (
|
|
35
|
+
<TooltipPrimitive.Arrow
|
|
36
|
+
width={11}
|
|
37
|
+
height={5}
|
|
38
|
+
className="fill-gray-1000"
|
|
39
|
+
/>
|
|
40
|
+
) : null}
|
|
41
|
+
</TooltipPrimitive.Content>
|
|
42
|
+
</TooltipPrimitive.Portal>
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
|
46
|
+
|
|
47
|
+
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { RefObject } from 'react';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
interface UseIntersectionObserverArgs
|
|
7
|
+
extends Omit<IntersectionObserverInit, 'root'> {
|
|
8
|
+
/**
|
|
9
|
+
* Scroll container the target is observed within. Defaults to the viewport.
|
|
10
|
+
* Passed as a ref so it resolves after commit (when the node exists).
|
|
11
|
+
*/
|
|
12
|
+
rootRef?: RefObject<Element | null>;
|
|
13
|
+
freezeOnceVisible?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the latest IntersectionObserverEntry for `elementRef`.
|
|
18
|
+
* Ported from vercel/front's `@vercel/hooks` `useIntersectionObserver`, with
|
|
19
|
+
* `root` threaded as a ref so we can observe inside a custom scroll container.
|
|
20
|
+
*/
|
|
21
|
+
export function useIntersectionObserver(
|
|
22
|
+
elementRef: RefObject<Element | null>,
|
|
23
|
+
{
|
|
24
|
+
threshold = 0,
|
|
25
|
+
rootRef,
|
|
26
|
+
rootMargin = '0%',
|
|
27
|
+
freezeOnceVisible = false,
|
|
28
|
+
}: UseIntersectionObserverArgs = {}
|
|
29
|
+
): IntersectionObserverEntry | undefined {
|
|
30
|
+
const [entry, setEntry] = useState<IntersectionObserverEntry>();
|
|
31
|
+
|
|
32
|
+
const frozen = entry?.isIntersecting && freezeOnceVisible;
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const node = elementRef?.current;
|
|
36
|
+
|
|
37
|
+
const hasIOSupport = !!window.IntersectionObserver;
|
|
38
|
+
|
|
39
|
+
if (!hasIOSupport || frozen || !node) return;
|
|
40
|
+
|
|
41
|
+
const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
|
|
42
|
+
setEntry(entry);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const observer = new IntersectionObserver(updateEntry, {
|
|
46
|
+
threshold,
|
|
47
|
+
root: rootRef?.current ?? null,
|
|
48
|
+
rootMargin,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
observer.observe(node);
|
|
52
|
+
|
|
53
|
+
return () => observer.disconnect();
|
|
54
|
+
}, [elementRef, threshold, rootRef, rootMargin, frozen]);
|
|
55
|
+
|
|
56
|
+
return entry;
|
|
57
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { RefObject } from 'react';
|
|
4
|
+
import { useEffect, useRef } from 'react';
|
|
5
|
+
import { useIntersectionObserver } from './use-intersection-observer';
|
|
6
|
+
|
|
7
|
+
interface UseLoadMoreOnScrollOptions {
|
|
8
|
+
hasMore: boolean;
|
|
9
|
+
isLoadingMore: boolean;
|
|
10
|
+
/** Scroll container the sentinel lives in. Defaults to the viewport. */
|
|
11
|
+
rootRef?: RefObject<Element | null>;
|
|
12
|
+
rootMargin?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Triggers `loadMore` when a sentinel element scrolls into view.
|
|
17
|
+
* Returns a ref to attach to an invisible sentinel `<div>` placed after the
|
|
18
|
+
* list content. Ported from vercel/front's `@vercel/hooks`
|
|
19
|
+
* `useLoadMoreOnScroll`.
|
|
20
|
+
*
|
|
21
|
+
* Because it keys off `isIntersecting`/`isLoadingMore`, it self-continues:
|
|
22
|
+
* if the sentinel is still in view once a page finishes loading, the next
|
|
23
|
+
* page is requested automatically.
|
|
24
|
+
*/
|
|
25
|
+
export function useLoadMoreOnScroll(
|
|
26
|
+
loadMore: () => void,
|
|
27
|
+
{
|
|
28
|
+
hasMore,
|
|
29
|
+
isLoadingMore,
|
|
30
|
+
rootRef,
|
|
31
|
+
rootMargin = '400px',
|
|
32
|
+
}: UseLoadMoreOnScrollOptions
|
|
33
|
+
) {
|
|
34
|
+
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
const entry = useIntersectionObserver(sentinelRef, { rootRef, rootMargin });
|
|
36
|
+
|
|
37
|
+
const loadMoreRef = useRef(loadMore);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
loadMoreRef.current = loadMore;
|
|
40
|
+
}, [loadMore]);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (entry?.isIntersecting && hasMore && !isLoadingMore) {
|
|
44
|
+
loadMoreRef.current();
|
|
45
|
+
}
|
|
46
|
+
}, [entry?.isIntersecting, hasMore, isLoadingMore]);
|
|
47
|
+
|
|
48
|
+
return sentinelRef;
|
|
49
|
+
}
|