@workflow/web-shared 4.1.0-beta.63 → 4.1.0-beta.65

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.
Files changed (52) hide show
  1. package/README.md +4 -0
  2. package/dist/components/event-list-view.d.ts +12 -1
  3. package/dist/components/event-list-view.d.ts.map +1 -1
  4. package/dist/components/event-list-view.js +233 -91
  5. package/dist/components/event-list-view.js.map +1 -1
  6. package/dist/components/index.d.ts +4 -0
  7. package/dist/components/index.d.ts.map +1 -1
  8. package/dist/components/index.js +4 -0
  9. package/dist/components/index.js.map +1 -1
  10. package/dist/components/sidebar/entity-detail-panel.d.ts +3 -1
  11. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  12. package/dist/components/sidebar/entity-detail-panel.js +4 -14
  13. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  14. package/dist/components/stream-viewer.d.ts +3 -1
  15. package/dist/components/stream-viewer.d.ts.map +1 -1
  16. package/dist/components/stream-viewer.js +23 -28
  17. package/dist/components/stream-viewer.js.map +1 -1
  18. package/dist/components/ui/decrypt-button.d.ts +15 -0
  19. package/dist/components/ui/decrypt-button.d.ts.map +1 -0
  20. package/dist/components/ui/decrypt-button.js +12 -0
  21. package/dist/components/ui/decrypt-button.js.map +1 -0
  22. package/dist/components/ui/error-stack-block.d.ts +3 -4
  23. package/dist/components/ui/error-stack-block.d.ts.map +1 -1
  24. package/dist/components/ui/error-stack-block.js +18 -9
  25. package/dist/components/ui/error-stack-block.js.map +1 -1
  26. package/dist/components/ui/load-more-button.d.ts +13 -0
  27. package/dist/components/ui/load-more-button.d.ts.map +1 -0
  28. package/dist/components/ui/load-more-button.js +12 -0
  29. package/dist/components/ui/load-more-button.js.map +1 -0
  30. package/dist/components/ui/menu-dropdown.d.ts +16 -0
  31. package/dist/components/ui/menu-dropdown.d.ts.map +1 -0
  32. package/dist/components/ui/menu-dropdown.js +46 -0
  33. package/dist/components/ui/menu-dropdown.js.map +1 -0
  34. package/dist/components/ui/spinner.d.ts +9 -0
  35. package/dist/components/ui/spinner.d.ts.map +1 -0
  36. package/dist/components/ui/spinner.js +57 -0
  37. package/dist/components/ui/spinner.js.map +1 -0
  38. package/dist/components/workflow-trace-view.d.ts +3 -1
  39. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  40. package/dist/components/workflow-trace-view.js +7 -6
  41. package/dist/components/workflow-trace-view.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/components/event-list-view.tsx +398 -141
  44. package/src/components/index.ts +4 -0
  45. package/src/components/sidebar/entity-detail-panel.tsx +9 -25
  46. package/src/components/stream-viewer.tsx +52 -63
  47. package/src/components/ui/decrypt-button.tsx +69 -0
  48. package/src/components/ui/error-stack-block.tsx +26 -16
  49. package/src/components/ui/load-more-button.tsx +38 -0
  50. package/src/components/ui/menu-dropdown.tsx +111 -0
  51. package/src/components/ui/spinner.tsx +76 -0
  52. package/src/components/workflow-trace-view.tsx +15 -22
@@ -22,4 +22,8 @@ export type {
22
22
  export { type StreamChunk, StreamViewer } from './stream-viewer';
23
23
  export type { Span, SpanEvent } from './trace-viewer/types';
24
24
  export { DataInspector, type DataInspectorProps } from './ui/data-inspector';
25
+ export { DecryptButton } from './ui/decrypt-button';
26
+ export { LoadMoreButton } from './ui/load-more-button';
27
+ export { MenuDropdown, type MenuDropdownOption } from './ui/menu-dropdown';
28
+ export { Spinner } from './ui/spinner';
25
29
  export { WorkflowTraceViewer } from './workflow-trace-view';
@@ -2,10 +2,11 @@
2
2
 
3
3
  import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
4
4
  import clsx from 'clsx';
5
- import { Lock, Send, Unlock, Zap } from 'lucide-react';
5
+ import { Send, Zap } from 'lucide-react';
6
6
  import { useCallback, useEffect, useMemo, useState } from 'react';
7
7
  import { toast } from 'sonner';
8
8
  import { isEncryptedMarker } from '../../lib/hydration';
9
+ import { DecryptButton } from '../ui/decrypt-button';
9
10
  import { AttributePanel } from './attribute-panel';
10
11
  import { EventsList } from './events-list';
11
12
  import { ResolveHookModal } from './resolve-hook-modal';
@@ -64,6 +65,7 @@ export function EntityDetailPanel({
64
65
  onResolveHook,
65
66
  encryptionKey,
66
67
  onDecrypt,
68
+ isDecrypting = false,
67
69
  selectedSpan,
68
70
  }: {
69
71
  run: WorkflowRun;
@@ -97,6 +99,8 @@ export function EntityDetailPanel({
97
99
  encryptionKey?: Uint8Array;
98
100
  /** Callback to initiate decryption of encrypted run data */
99
101
  onDecrypt?: () => void;
102
+ /** Whether the encryption key is currently being fetched */
103
+ isDecrypting?: boolean;
100
104
  /** Info about the currently selected span from the trace viewer */
101
105
  selectedSpan: SelectedSpanInfo | null;
102
106
  }): React.JSX.Element | null {
@@ -381,31 +385,11 @@ export function EntityDetailPanel({
381
385
  </p>
382
386
  </div>
383
387
  {(hasEncryptedFields || encryptionKey) && onDecrypt && (
384
- <button
385
- type="button"
388
+ <DecryptButton
389
+ decrypted={!!encryptionKey}
390
+ loading={isDecrypting}
386
391
  onClick={onDecrypt}
387
- disabled={!!encryptionKey}
388
- className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors flex-shrink-0"
389
- style={{
390
- borderColor: encryptionKey
391
- ? 'var(--ds-green-400)'
392
- : 'var(--ds-gray-300)',
393
- color: encryptionKey
394
- ? 'var(--ds-green-900)'
395
- : 'var(--ds-gray-900)',
396
- backgroundColor: encryptionKey
397
- ? 'var(--ds-green-100)'
398
- : 'var(--ds-background-100)',
399
- cursor: encryptionKey ? 'default' : 'pointer',
400
- }}
401
- >
402
- {encryptionKey ? (
403
- <Unlock className="h-3 w-3" />
404
- ) : (
405
- <Lock className="h-3 w-3" />
406
- )}
407
- {encryptionKey ? 'Decrypted' : 'Decrypt'}
408
- </button>
392
+ />
409
393
  )}
410
394
  </div>
411
395
  </div>
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import React, { useCallback, useEffect, useRef, useState } from 'react';
3
+ import React, { useEffect, useRef } from 'react';
4
+ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
4
5
  import { DataInspector } from './ui/data-inspector';
5
6
  import { Skeleton } from './ui/skeleton';
6
7
 
@@ -46,6 +47,8 @@ interface StreamViewerProps {
46
47
  error?: string | null;
47
48
  /** True while the initial stream connection is being established */
48
49
  isLoading?: boolean;
50
+ /** Called when the user scrolls near the bottom, for triggering pagination */
51
+ onScrollEnd?: () => void;
49
52
  }
50
53
 
51
54
  // ──────────────────────────────────────────────────────────────────────────
@@ -114,45 +117,41 @@ function StreamSkeleton() {
114
117
  * of complex types (Map, Set, Date, custom classes, etc.).
115
118
  */
116
119
  export function StreamViewer({
117
- streamId,
120
+ streamId: _streamId,
118
121
  chunks,
119
122
  isLive,
120
123
  error,
121
124
  isLoading,
125
+ onScrollEnd,
122
126
  }: StreamViewerProps) {
123
- const [hasMoreBelow, setHasMoreBelow] = useState(false);
124
- const scrollRef = useRef<HTMLDivElement>(null);
125
-
126
- const checkScrollPosition = useCallback(() => {
127
- if (scrollRef.current) {
128
- const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
129
- const isAtBottom = scrollHeight - scrollTop - clientHeight < 10;
130
- setHasMoreBelow(!isAtBottom && scrollHeight > clientHeight);
131
- }
132
- }, []);
127
+ const virtuosoRef = useRef<VirtuosoHandle>(null);
128
+ const prevChunkCountRef = useRef(0);
133
129
 
134
- // biome-ignore lint/correctness/useExhaustiveDependencies: chunks.length triggers scroll on new chunks
130
+ // Auto-scroll to bottom when new chunks arrive (live streaming)
135
131
  useEffect(() => {
136
- if (scrollRef.current) {
137
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
132
+ if (chunks.length > prevChunkCountRef.current && chunks.length > 0) {
133
+ virtuosoRef.current?.scrollToIndex({
134
+ index: chunks.length - 1,
135
+ align: 'end',
136
+ });
138
137
  }
139
- checkScrollPosition();
140
- }, [chunks.length, checkScrollPosition]);
138
+ prevChunkCountRef.current = chunks.length;
139
+ }, [chunks.length]);
141
140
 
142
141
  // Show skeleton when loading and no chunks have arrived yet
143
142
  if (isLoading && chunks.length === 0) {
144
143
  return (
145
- <div className="flex flex-col h-full pb-4">
144
+ <div className="flex flex-col h-full">
146
145
  <StreamSkeleton />
147
146
  </div>
148
147
  );
149
148
  }
150
149
 
151
150
  return (
152
- <div className="flex flex-col h-full pb-4">
151
+ <div className="flex flex-col h-full">
153
152
  {/* Live indicator */}
154
153
  {isLive && (
155
- <div className="flex items-center gap-1.5 mb-3 px-1">
154
+ <div className="flex items-center gap-1.5 mb-2 px-1">
156
155
  <span
157
156
  className="inline-block w-2 h-2 rounded-full"
158
157
  style={{ backgroundColor: 'var(--ds-green-600)' }}
@@ -182,52 +181,42 @@ export function StreamViewer({
182
181
  )}
183
182
 
184
183
  {/* Content */}
185
- <div className="relative flex-1 min-h-[200px]">
186
- <div
187
- ref={scrollRef}
188
- onScroll={checkScrollPosition}
189
- className="absolute inset-0 overflow-auto flex flex-col gap-2"
190
- >
191
- {error ? (
192
- <div
193
- className="text-[11px] rounded-md border p-3"
194
- style={{
195
- borderColor: 'var(--ds-red-300)',
196
- backgroundColor: 'var(--ds-red-100)',
197
- color: 'var(--ds-red-700)',
198
- }}
199
- >
200
- <div>Error reading stream:</div>
201
- <div>{error}</div>
202
- </div>
203
- ) : chunks.length === 0 ? (
204
- <div
205
- className="text-[11px] rounded-md border p-3"
206
- style={{
207
- borderColor: 'var(--ds-gray-300)',
208
- backgroundColor: 'var(--ds-gray-100)',
209
- color: 'var(--ds-gray-600)',
210
- }}
211
- >
212
- {isLive ? 'Waiting for stream data...' : 'Stream is empty'}
213
- </div>
214
- ) : (
215
- chunks.map((chunk, index) => (
216
- <ChunkRow
217
- key={`${streamId}-chunk-${chunk.id}`}
218
- chunk={chunk}
219
- index={index}
220
- />
221
- ))
222
- )}
223
- </div>
224
- {hasMoreBelow && (
184
+ <div className="flex-1 min-h-0">
185
+ {error ? (
186
+ <div
187
+ className="text-[11px] rounded-md border p-3"
188
+ style={{
189
+ borderColor: 'var(--ds-red-300)',
190
+ backgroundColor: 'var(--ds-red-100)',
191
+ color: 'var(--ds-red-700)',
192
+ }}
193
+ >
194
+ <div>Error reading stream:</div>
195
+ <div>{error}</div>
196
+ </div>
197
+ ) : chunks.length === 0 ? (
225
198
  <div
226
- className="absolute bottom-0 left-0 right-0 h-8 pointer-events-none"
199
+ className="text-[11px] rounded-md border p-3"
227
200
  style={{
228
- background:
229
- 'linear-gradient(to top, var(--ds-background-100), transparent)',
201
+ borderColor: 'var(--ds-gray-300)',
202
+ backgroundColor: 'var(--ds-gray-100)',
203
+ color: 'var(--ds-gray-600)',
230
204
  }}
205
+ >
206
+ {isLive ? 'Waiting for stream data...' : 'Stream is empty'}
207
+ </div>
208
+ ) : (
209
+ <Virtuoso
210
+ ref={virtuosoRef}
211
+ totalCount={chunks.length}
212
+ overscan={10}
213
+ endReached={() => onScrollEnd?.()}
214
+ itemContent={(index) => (
215
+ <div style={{ paddingBottom: 8 }}>
216
+ <ChunkRow chunk={chunks[index]} index={index} />
217
+ </div>
218
+ )}
219
+ style={{ flex: 1, minHeight: 0 }}
231
220
  />
232
221
  )}
233
222
  </div>
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { Spinner } from './spinner';
4
+
5
+ const STYLES = `.wf-decrypt-btn{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 12px;border-radius:6px;font-size:14px;font-weight:500;line-height:20px;cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-decrypt-idle{color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400)}.wf-decrypt-idle:hover{background:var(--ds-gray-alpha-200)}.wf-decrypt-done{color:var(--ds-green-900);background:var(--ds-green-100);box-shadow:0 0 0 1px var(--ds-green-400);cursor:default}`;
6
+
7
+ interface DecryptButtonProps {
8
+ /** Whether an encryption key has been obtained (decryption is active). */
9
+ decrypted?: boolean;
10
+ /** Whether the key is currently being fetched. */
11
+ loading?: boolean;
12
+ /** Called when the user clicks to initiate decryption. */
13
+ onClick?: () => void;
14
+ }
15
+
16
+ /**
17
+ * Decrypt/Decrypted button using Geist secondary style.
18
+ * Three states: idle (secondary gray), decrypting (spinner), decrypted (green success).
19
+ */
20
+ export function DecryptButton({
21
+ decrypted = false,
22
+ loading = false,
23
+ onClick,
24
+ }: DecryptButtonProps) {
25
+ return (
26
+ <>
27
+ <style dangerouslySetInnerHTML={{ __html: STYLES }} />
28
+ <button
29
+ type="button"
30
+ onClick={decrypted ? undefined : onClick}
31
+ disabled={decrypted || loading}
32
+ className={`wf-decrypt-btn ${decrypted ? 'wf-decrypt-done' : 'wf-decrypt-idle'}`}
33
+ >
34
+ {loading ? (
35
+ <Spinner size={14} />
36
+ ) : decrypted ? (
37
+ <svg
38
+ width={14}
39
+ height={14}
40
+ viewBox="0 0 24 24"
41
+ fill="none"
42
+ stroke="currentColor"
43
+ strokeWidth={2}
44
+ strokeLinecap="round"
45
+ strokeLinejoin="round"
46
+ >
47
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
48
+ <path d="M7 11V7a5 5 0 0 1 9.9-1" />
49
+ </svg>
50
+ ) : (
51
+ <svg
52
+ width={14}
53
+ height={14}
54
+ viewBox="0 0 24 24"
55
+ fill="none"
56
+ stroke="currentColor"
57
+ strokeWidth={2}
58
+ strokeLinecap="round"
59
+ strokeLinejoin="round"
60
+ >
61
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
62
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
63
+ </svg>
64
+ )}
65
+ {loading ? 'Decrypting…' : decrypted ? 'Decrypted' : 'Decrypt'}
66
+ </button>
67
+ </>
68
+ );
69
+ }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { Copy } from 'lucide-react';
3
+ import { AlertCircle, Copy } from 'lucide-react';
4
4
  import { toast } from 'sonner';
5
5
 
6
6
  /**
@@ -19,10 +19,9 @@ export function isStructuredErrorWithStack(
19
19
  }
20
20
 
21
21
  /**
22
- * Renders an error with a `stack` field as readable pre-formatted text,
23
- * styled to match the CopyableDataBlock component. The error message is
24
- * displayed at the top with a visual separator from the stack trace.
25
- * The entire block is copyable via a copy button.
22
+ * Renders an error with a `stack` field as a visually distinct error block.
23
+ * Shows the error message with an alert icon at the top, separated from
24
+ * the stack trace below.
26
25
  */
27
26
  export function ErrorStackBlock({
28
27
  value,
@@ -35,15 +34,22 @@ export function ErrorStackBlock({
35
34
 
36
35
  return (
37
36
  <div
38
- className="relative overflow-x-auto rounded-md border p-3 pt-9"
39
- style={{ borderColor: 'var(--ds-gray-300)' }}
37
+ className="relative overflow-hidden rounded-md border"
38
+ style={{
39
+ borderColor: 'var(--ds-red-400)',
40
+ background: 'var(--ds-red-100)',
41
+ }}
40
42
  >
41
43
  <button
42
44
  type="button"
43
45
  aria-label="Copy error"
44
46
  title="Copy"
45
- className="!absolute !right-2 !top-2 !flex !h-6 !w-6 !items-center !justify-center !rounded-md !border !bg-[var(--ds-background-100)] !text-[var(--ds-gray-800)] transition-transform transition-colors duration-100 hover:!bg-[var(--ds-gray-alpha-200)] active:!scale-95 active:!bg-[var(--ds-gray-alpha-300)]"
46
- style={{ borderColor: 'var(--ds-gray-300)' }}
47
+ className="!absolute !right-2 !top-2 !flex !h-6 !w-6 !items-center !justify-center !rounded-md !border transition-transform transition-colors duration-100 hover:!bg-[var(--ds-red-200)] active:!scale-95"
48
+ style={{
49
+ borderColor: 'var(--ds-red-400)',
50
+ background: 'var(--ds-red-100)',
51
+ color: 'var(--ds-red-900)',
52
+ }}
47
53
  onClick={() => {
48
54
  navigator.clipboard
49
55
  .writeText(copyText)
@@ -59,19 +65,23 @@ export function ErrorStackBlock({
59
65
  </button>
60
66
 
61
67
  {message && (
62
- <p
63
- className="pb-2 mb-2 text-xs font-semibold font-mono"
68
+ <div
69
+ className="flex items-start gap-2 px-3 py-2.5 pr-10"
64
70
  style={{
65
71
  color: 'var(--ds-red-900)',
66
- borderBottom: '1px solid var(--ds-gray-300)',
72
+ borderBottom: '1px solid var(--ds-red-400)',
67
73
  }}
68
74
  >
69
- {message}
70
- </p>
75
+ <AlertCircle className="h-4 w-4 shrink-0" style={{ marginTop: 1 }} />
76
+ <p className="text-xs font-semibold m-0 break-words">{message}</p>
77
+ </div>
71
78
  )}
72
79
  <pre
73
- className="text-xs font-mono whitespace-pre-wrap break-words overflow-auto m-0"
74
- style={{ color: 'var(--ds-gray-1000)' }}
80
+ className="px-3 py-2.5 text-xs font-mono whitespace-pre-wrap break-words overflow-auto m-0"
81
+ style={{
82
+ color: 'var(--ds-red-900)',
83
+ background: 'var(--ds-red-200)',
84
+ }}
75
85
  >
76
86
  {stack}
77
87
  </pre>
@@ -0,0 +1,38 @@
1
+ 'use client';
2
+
3
+ import { Spinner } from './spinner';
4
+
5
+ const STYLES = `.wf-load-more{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:32px;padding:0 12px;border-radius:6px;font-size:13px;font-weight:500;line-height:20px;color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400);cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-load-more:hover{background:var(--ds-gray-alpha-200)}.wf-load-more:disabled{opacity:.6;cursor:default}.wf-load-more:disabled:hover{background:var(--ds-background-100)}`;
6
+
7
+ interface LoadMoreButtonProps {
8
+ loading?: boolean;
9
+ onClick?: () => void;
10
+ label?: string;
11
+ loadingLabel?: string;
12
+ }
13
+
14
+ /**
15
+ * A "Load more" button matching Geist's Button type="secondary" size="small"
16
+ * with a spinner prefix when loading.
17
+ */
18
+ export function LoadMoreButton({
19
+ loading = false,
20
+ onClick,
21
+ label = 'Load more',
22
+ loadingLabel = 'Loading...',
23
+ }: LoadMoreButtonProps) {
24
+ return (
25
+ <>
26
+ <style dangerouslySetInnerHTML={{ __html: STYLES }} />
27
+ <button
28
+ type="button"
29
+ onClick={onClick}
30
+ disabled={loading}
31
+ className="wf-load-more"
32
+ >
33
+ {loading && <Spinner size={14} />}
34
+ {loading ? loadingLabel : label}
35
+ </button>
36
+ </>
37
+ );
38
+ }
@@ -0,0 +1,111 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ const STYLES = `.wf-menu-btn{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 12px;border-radius:6px;font-size:14px;font-weight:500;line-height:20px;color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400);cursor:pointer;white-space:nowrap;transition:background 150ms}.wf-menu-btn:hover{background:var(--ds-gray-alpha-200)}.wf-menu-item{appearance:none;-webkit-appearance:none;border:none;display:flex;align-items:center;width:100%;height:40px;padding:0 8px;border-radius:6px;font-size:14px;color:var(--ds-gray-1000);background:transparent;cursor:pointer;transition:background 150ms}.wf-menu-item:hover{background:var(--ds-gray-alpha-100)}`;
6
+
7
+ export interface MenuDropdownOption<T extends string = string> {
8
+ value: T;
9
+ label: string;
10
+ }
11
+
12
+ interface MenuDropdownProps<T extends string = string> {
13
+ options: MenuDropdownOption<T>[];
14
+ value: T;
15
+ onChange: (value: T) => void;
16
+ }
17
+
18
+ /**
19
+ * A dropdown menu that matches Geist's MenuButton (secondary) + Menu styling.
20
+ * Uses CSS classes with proper :hover specificity (no inline background).
21
+ */
22
+ export function MenuDropdown<T extends string = string>({
23
+ options,
24
+ value,
25
+ onChange,
26
+ }: MenuDropdownProps<T>) {
27
+ const [open, setOpen] = useState(false);
28
+ const ref = useRef<HTMLDivElement>(null);
29
+ const label =
30
+ options.find((o) => o.value === value)?.label ?? options[0]?.label ?? '';
31
+
32
+ useEffect(() => {
33
+ if (!open) return;
34
+ function handleClickOutside(e: MouseEvent) {
35
+ if (ref.current && !ref.current.contains(e.target as Node)) {
36
+ setOpen(false);
37
+ }
38
+ }
39
+ document.addEventListener('mousedown', handleClickOutside);
40
+ return () => document.removeEventListener('mousedown', handleClickOutside);
41
+ }, [open]);
42
+
43
+ return (
44
+ <div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
45
+ <style dangerouslySetInnerHTML={{ __html: STYLES }} />
46
+
47
+ <button
48
+ type="button"
49
+ className="wf-menu-btn"
50
+ onClick={() => setOpen(!open)}
51
+ >
52
+ <span>{label}</span>
53
+ <svg
54
+ width={16}
55
+ height={16}
56
+ viewBox="0 0 16 16"
57
+ fill="none"
58
+ style={{
59
+ marginLeft: 16,
60
+ marginRight: -4,
61
+ color: 'var(--ds-gray-900)',
62
+ }}
63
+ >
64
+ <path
65
+ d="M4.5 6L8 9.5L11.5 6"
66
+ stroke="currentColor"
67
+ strokeWidth="1.5"
68
+ strokeLinecap="round"
69
+ strokeLinejoin="round"
70
+ />
71
+ </svg>
72
+ </button>
73
+
74
+ {open && (
75
+ <div
76
+ style={{
77
+ position: 'absolute',
78
+ right: 0,
79
+ top: '100%',
80
+ marginTop: 4,
81
+ minWidth: 140,
82
+ padding: 4,
83
+ borderRadius: 12,
84
+ background: 'var(--ds-background-100)',
85
+ boxShadow: 'var(--ds-shadow-menu, var(--ds-shadow-medium))',
86
+ zIndex: 2001,
87
+ }}
88
+ role="menu"
89
+ >
90
+ {options.map((option) => (
91
+ <button
92
+ key={option.value}
93
+ type="button"
94
+ role="menuitem"
95
+ className="wf-menu-item"
96
+ style={{
97
+ fontWeight: option.value === value ? 500 : 400,
98
+ }}
99
+ onClick={() => {
100
+ onChange(option.value);
101
+ setOpen(false);
102
+ }}
103
+ >
104
+ {option.label}
105
+ </button>
106
+ ))}
107
+ </div>
108
+ )}
109
+ </div>
110
+ );
111
+ }
@@ -0,0 +1,76 @@
1
+ const KEYFRAMES = `@keyframes wf-spinner-fade{0%{opacity:1}100%{opacity:.15}}`;
2
+
3
+ /**
4
+ * Spinner matching Geist's multi-line fade spinner.
5
+ * At size ≤12: 8 lines, ≤16: 10 lines, else: 12 lines.
6
+ */
7
+ export function Spinner({
8
+ size = 14,
9
+ color,
10
+ }: {
11
+ size?: number;
12
+ color?: string;
13
+ }) {
14
+ const config =
15
+ size <= 12
16
+ ? {
17
+ count: 8,
18
+ angle: 45,
19
+ delays: [-875, -750, -625, -500, -375, -250, -125, 0],
20
+ duration: 1000,
21
+ lineW: 3,
22
+ lineH: 1.5,
23
+ }
24
+ : size <= 16
25
+ ? {
26
+ count: 10,
27
+ angle: 36,
28
+ delays: [-900, -800, -700, -600, -500, -400, -300, -200, -100, 0],
29
+ duration: 1000,
30
+ lineW: 4,
31
+ lineH: 1.5,
32
+ }
33
+ : {
34
+ count: 12,
35
+ angle: 30,
36
+ delays: [
37
+ -1100, -1000, -900, -800, -700, -600, -500, -400, -300, -200,
38
+ -100, 0,
39
+ ],
40
+ duration: 1200,
41
+ lineW: size * 0.24,
42
+ lineH: size * 0.08,
43
+ };
44
+
45
+ return (
46
+ <span
47
+ style={{
48
+ display: 'inline-flex',
49
+ position: 'relative',
50
+ width: size,
51
+ height: size,
52
+ }}
53
+ >
54
+ <style dangerouslySetInnerHTML={{ __html: KEYFRAMES }} />
55
+ {config.delays.map((delay, i) => (
56
+ <span
57
+ key={delay}
58
+ style={{
59
+ position: 'absolute',
60
+ left: '50%',
61
+ top: '50%',
62
+ width: config.lineW,
63
+ height: config.lineH,
64
+ marginLeft: -config.lineW / 2,
65
+ marginTop: -config.lineH / 2,
66
+ borderRadius: 1,
67
+ backgroundColor: color ?? 'var(--ds-gray-700)',
68
+ transform: `rotate(${i * config.angle}deg) translate(${size * 0.36}px)`,
69
+ animation: `wf-spinner-fade ${config.duration}ms linear infinite`,
70
+ animationDelay: `${delay}ms`,
71
+ }}
72
+ />
73
+ ))}
74
+ </span>
75
+ );
76
+ }