@workflow/web-shared 4.1.0-beta.62 → 4.1.0-beta.64

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 (70) hide show
  1. package/README.md +4 -0
  2. package/dist/components/event-list-view.d.ts +9 -3
  3. package/dist/components/event-list-view.d.ts.map +1 -1
  4. package/dist/components/event-list-view.js +222 -98
  5. package/dist/components/event-list-view.js.map +1 -1
  6. package/dist/components/index.d.ts +1 -0
  7. package/dist/components/index.d.ts.map +1 -1
  8. package/dist/components/index.js +1 -0
  9. package/dist/components/index.js.map +1 -1
  10. package/dist/components/run-trace-view.d.ts +1 -3
  11. package/dist/components/run-trace-view.d.ts.map +1 -1
  12. package/dist/components/run-trace-view.js +2 -2
  13. package/dist/components/run-trace-view.js.map +1 -1
  14. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  15. package/dist/components/sidebar/attribute-panel.js +11 -1
  16. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  17. package/dist/components/sidebar/detail-card.d.ts.map +1 -1
  18. package/dist/components/sidebar/detail-card.js +4 -2
  19. package/dist/components/sidebar/detail-card.js.map +1 -1
  20. package/dist/components/sidebar/entity-detail-panel.d.ts +3 -3
  21. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  22. package/dist/components/sidebar/entity-detail-panel.js +43 -26
  23. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  24. package/dist/components/trace-viewer/trace-viewer.d.ts +7 -1
  25. package/dist/components/trace-viewer/trace-viewer.d.ts.map +1 -1
  26. package/dist/components/trace-viewer/trace-viewer.js +36 -11
  27. package/dist/components/trace-viewer/trace-viewer.js.map +1 -1
  28. package/dist/components/ui/error-stack-block.d.ts +3 -4
  29. package/dist/components/ui/error-stack-block.d.ts.map +1 -1
  30. package/dist/components/ui/error-stack-block.js +18 -9
  31. package/dist/components/ui/error-stack-block.js.map +1 -1
  32. package/dist/components/ui/menu-dropdown.d.ts +16 -0
  33. package/dist/components/ui/menu-dropdown.d.ts.map +1 -0
  34. package/dist/components/ui/menu-dropdown.js +50 -0
  35. package/dist/components/ui/menu-dropdown.js.map +1 -0
  36. package/dist/components/workflow-trace-view.d.ts +3 -3
  37. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  38. package/dist/components/workflow-trace-view.js +31 -129
  39. package/dist/components/workflow-trace-view.js.map +1 -1
  40. package/dist/components/workflow-traces/trace-span-construction.d.ts +18 -5
  41. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  42. package/dist/components/workflow-traces/trace-span-construction.js +65 -18
  43. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  44. package/dist/index.d.ts +3 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/lib/event-materialization.d.ts +72 -0
  49. package/dist/lib/event-materialization.d.ts.map +1 -0
  50. package/dist/lib/event-materialization.js +171 -0
  51. package/dist/lib/event-materialization.js.map +1 -0
  52. package/dist/lib/trace-builder.d.ts +32 -0
  53. package/dist/lib/trace-builder.d.ts.map +1 -0
  54. package/dist/lib/trace-builder.js +129 -0
  55. package/dist/lib/trace-builder.js.map +1 -0
  56. package/package.json +3 -3
  57. package/src/components/event-list-view.tsx +324 -103
  58. package/src/components/index.ts +1 -0
  59. package/src/components/run-trace-view.tsx +0 -6
  60. package/src/components/sidebar/attribute-panel.tsx +17 -2
  61. package/src/components/sidebar/detail-card.tsx +10 -2
  62. package/src/components/sidebar/entity-detail-panel.tsx +59 -21
  63. package/src/components/trace-viewer/trace-viewer.tsx +47 -2
  64. package/src/components/ui/error-stack-block.tsx +26 -16
  65. package/src/components/ui/menu-dropdown.tsx +114 -0
  66. package/src/components/workflow-trace-view.tsx +95 -195
  67. package/src/components/workflow-traces/trace-span-construction.ts +85 -32
  68. package/src/index.ts +13 -0
  69. package/src/lib/event-materialization.ts +243 -0
  70. package/src/lib/trace-builder.ts +201 -0
@@ -22,4 +22,5 @@ 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 { MenuDropdown, type MenuDropdownOption } from './ui/menu-dropdown';
25
26
  export { WorkflowTraceViewer } from './workflow-trace-view';
@@ -7,8 +7,6 @@ import { WorkflowTraceViewer } from './workflow-trace-view';
7
7
 
8
8
  interface RunTraceViewProps {
9
9
  run: WorkflowRun;
10
- steps: Step[];
11
- hooks: Hook[];
12
10
  events: Event[];
13
11
  isLoading?: boolean;
14
12
  error?: Error | null;
@@ -34,8 +32,6 @@ interface RunTraceViewProps {
34
32
 
35
33
  export function RunTraceView({
36
34
  run,
37
- steps,
38
- hooks,
39
35
  events,
40
36
  isLoading,
41
37
  error,
@@ -65,9 +61,7 @@ export function RunTraceView({
65
61
  <div className="w-full h-full relative">
66
62
  <WorkflowTraceViewer
67
63
  error={error}
68
- steps={steps}
69
64
  events={events}
70
- hooks={hooks}
71
65
  run={run}
72
66
  isLoading={isLoading}
73
67
  spanDetailData={spanDetailData}
@@ -215,7 +215,10 @@ type AttributeKey =
215
215
  | 'eventData'
216
216
  | 'resumeAt'
217
217
  | 'expiredAt'
218
- | 'workflowCoreVersion';
218
+ | 'workflowCoreVersion'
219
+ | 'receivedCount'
220
+ | 'lastReceivedAt'
221
+ | 'disposedAt';
219
222
 
220
223
  const attributeOrder: AttributeKey[] = [
221
224
  'workflowName',
@@ -228,6 +231,9 @@ const attributeOrder: AttributeKey[] = [
228
231
  'runId',
229
232
  'attempt',
230
233
  'token',
234
+ 'receivedCount',
235
+ 'lastReceivedAt',
236
+ 'disposedAt',
231
237
  'correlationId',
232
238
  'eventType',
233
239
  'deploymentId',
@@ -262,6 +268,7 @@ const sortByAttributeOrder = (a: string, b: string): number => {
262
268
  */
263
269
  const attributeDisplayNames: Partial<Record<AttributeKey, string>> = {
264
270
  workflowCoreVersion: '@workflow/core version',
271
+ receivedCount: 'times resolved',
265
272
  };
266
273
 
267
274
  /**
@@ -353,6 +360,9 @@ const attributeToDisplayFn: Record<
353
360
  // Hook details
354
361
  token: (value: unknown) => String(value),
355
362
  isWebhook: (value: unknown) => String(value),
363
+ receivedCount: (value: unknown) => String(value),
364
+ lastReceivedAt: localMillisecondTimeOrNull,
365
+ disposedAt: localMillisecondTimeOrNull,
356
366
  // Event details
357
367
  eventType: (value: unknown) => String(value),
358
368
  correlationId: (value: unknown) => String(value),
@@ -773,13 +783,18 @@ export const AttributePanel = ({
773
783
  typeof displayValue === 'string'
774
784
  ? displayValue
775
785
  : String(displayValue ?? displayData.moduleSpecifier ?? '');
786
+ const shouldCapitalizeLabel = attribute !== 'workflowCoreVersion';
776
787
  const showDivider = index < orderedBasicAttributes.length - 1;
777
788
 
778
789
  return (
779
790
  <div key={attribute} className="py-1">
780
791
  <div className="flex min-h-[32px] items-center justify-between gap-4 rounded-sm px-2.5 py-1">
781
792
  <span
782
- className="text-[14px] first-letter:uppercase"
793
+ className={
794
+ shouldCapitalizeLabel
795
+ ? 'text-[14px] first-letter:uppercase'
796
+ : 'text-[14px]'
797
+ }
783
798
  style={{ color: 'var(--ds-gray-700)' }}
784
799
  >
785
800
  {getAttributeDisplayName(attribute)}
@@ -1,3 +1,4 @@
1
+ import { ChevronRight } from 'lucide-react';
1
2
  import type { ReactNode } from 'react';
2
3
 
3
4
  export function DetailCard({
@@ -42,14 +43,21 @@ export function DetailCard({
42
43
  onToggle={(e) => onToggle?.((e.target as HTMLDetailsElement).open)}
43
44
  >
44
45
  <summary
45
- className={`cursor-pointer rounded-md border px-2.5 py-1.5 text-xs hover:brightness-95 ${summaryClassName ?? ''}`}
46
+ className={`cursor-pointer rounded-md border px-2.5 py-1.5 text-xs hover:brightness-95 [&::-webkit-details-marker]:hidden ${summaryClassName ?? ''}`}
46
47
  style={{
47
48
  borderColor: 'var(--ds-gray-300)',
48
49
  backgroundColor: 'var(--ds-gray-100)',
49
50
  color: 'var(--ds-gray-900)',
51
+ listStyle: 'none',
50
52
  }}
51
53
  >
52
- {summary}
54
+ <span className="flex items-center gap-1.5">
55
+ <ChevronRight
56
+ size={14}
57
+ className="shrink-0 transition-transform group-open:rotate-90"
58
+ />
59
+ {summary}
60
+ </span>
53
61
  </summary>
54
62
  {/* Expanded content with connecting line */}
55
63
  <div className={`relative pl-6 mt-3 ${contentClassName ?? ''}`}>
@@ -2,9 +2,10 @@
2
2
 
3
3
  import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
4
4
  import clsx from 'clsx';
5
- import { Send, Zap } from 'lucide-react';
5
+ import { Lock, Send, Unlock, Zap } from 'lucide-react';
6
6
  import { useCallback, useEffect, useMemo, useState } from 'react';
7
7
  import { toast } from 'sonner';
8
+ import { isEncryptedMarker } from '../../lib/hydration';
8
9
  import { AttributePanel } from './attribute-panel';
9
10
  import { EventsList } from './events-list';
10
11
  import { ResolveHookModal } from './resolve-hook-modal';
@@ -53,7 +54,6 @@ export interface SelectedSpanInfo {
53
54
  */
54
55
  export function EntityDetailPanel({
55
56
  run,
56
- hooks,
57
57
  onStreamClick,
58
58
  spanDetailData,
59
59
  spanDetailError,
@@ -63,11 +63,10 @@ export function EntityDetailPanel({
63
63
  onLoadEventData,
64
64
  onResolveHook,
65
65
  encryptionKey,
66
+ onDecrypt,
66
67
  selectedSpan,
67
68
  }: {
68
69
  run: WorkflowRun;
69
- /** All hooks for the current run (used as fallback for token lookup). */
70
- hooks?: Hook[];
71
70
  /** Callback when a stream reference is clicked */
72
71
  onStreamClick?: (streamId: string) => void;
73
72
  /** Pre-fetched span detail data for the selected span. */
@@ -96,6 +95,8 @@ export function EntityDetailPanel({
96
95
  ) => Promise<void>;
97
96
  /** Encryption key (available after Decrypt is clicked), used to re-load event data */
98
97
  encryptionKey?: Uint8Array;
98
+ /** Callback to initiate decryption of encrypted run data */
99
+ onDecrypt?: () => void;
99
100
  /** Info about the currently selected span from the trace viewer */
100
101
  selectedSpan: SelectedSpanInfo | null;
101
102
  }): React.JSX.Element | null {
@@ -129,10 +130,11 @@ export function EntityDetailPanel({
129
130
  return { resource: 'hook', resourceId: data.hookId, runId: undefined };
130
131
  }
131
132
  if (res === 'sleep') {
133
+ const waitData = data as { runId?: string } | undefined;
132
134
  return {
133
135
  resource: 'sleep',
134
136
  resourceId: selectedSpan.spanId,
135
- runId: undefined,
137
+ runId: waitData?.runId,
136
138
  };
137
139
  }
138
140
  return { resource: undefined, resourceId: undefined, runId: undefined };
@@ -196,6 +198,17 @@ export function EntityDetailPanel({
196
198
  const error = spanDetailError ?? undefined;
197
199
  const loading = spanDetailLoading ?? false;
198
200
 
201
+ const hasEncryptedFields = useMemo(() => {
202
+ if (!spanDetailData) return false;
203
+ const d = spanDetailData as Record<string, unknown>;
204
+ return (
205
+ isEncryptedMarker(d.input) ||
206
+ isEncryptedMarker(d.output) ||
207
+ isEncryptedMarker(d.error) ||
208
+ isEncryptedMarker(d.metadata)
209
+ );
210
+ }, [spanDetailData]);
211
+
199
212
  // Get the hook token for resolving (prefer fetched data, then hooks array fallback)
200
213
  const hookToken = useMemo(() => {
201
214
  if (resource !== 'hook' || !resourceId) return undefined;
@@ -203,17 +216,12 @@ export function EntityDetailPanel({
203
216
  if (isHook(spanDetailData) && spanDetailData.token) {
204
217
  return spanDetailData.token;
205
218
  }
206
- // 2. Try the hooks array (always has tokens)
207
- const hookFromArray = hooks?.find((h) => h.hookId === resourceId);
208
- if (hookFromArray?.token) {
209
- return hookFromArray.token;
210
- }
211
- // 3. Try the span's inline data (partial hook from events - may lack token)
219
+ // 2. Try the span's inline data (reconstructed from hook_created event)
212
220
  if (isHook(data) && (data as Hook).token) {
213
221
  return (data as Hook).token;
214
222
  }
215
223
  return undefined;
216
- }, [resource, resourceId, spanDetailData, data, hooks]);
224
+ }, [resource, resourceId, spanDetailData, data]);
217
225
 
218
226
  useEffect(() => {
219
227
  if (error && selectedSpan && resource) {
@@ -299,16 +307,15 @@ export function EntityDetailPanel({
299
307
  [onResolveHook, hookToken, resolvingHook, spanDetailData, data]
300
308
  );
301
309
 
302
- if (!selectedSpan || !resource || !resourceId) {
303
- return null;
304
- }
310
+ // Prefer externally-fetched details when available. For sleep spans, the
311
+ // host fetches full correlated events (withData=true) and materializes a wait
312
+ // entity, so this includes resumeAt/completedAt without bloating trace payloads.
313
+ const displayData = (spanDetailData ?? data) as
314
+ | WorkflowRun
315
+ | Step
316
+ | Hook
317
+ | Event;
305
318
 
306
- // For sleep spans, spanDetailData from the host is typically an events array
307
- // (not a single entity), so always prefer the inline wait entity from span
308
- // attributes which contains waitId, runId, createdAt, resumeAt, completedAt.
309
- const displayData = (
310
- resource === 'sleep' ? data : (spanDetailData ?? data)
311
- ) as WorkflowRun | Step | Hook | Event;
312
319
  const moduleSpecifier = useMemo(() => {
313
320
  const displayRecord = displayData as Record<string, unknown>;
314
321
  const displayStepName = displayRecord.stepName;
@@ -325,6 +332,10 @@ export function EntityDetailPanel({
325
332
  return undefined;
326
333
  }, [displayData, run.workflowName]);
327
334
 
335
+ if (!selectedSpan || !resource || !resourceId) {
336
+ return null;
337
+ }
338
+
328
339
  const resourceLabel = resource.charAt(0).toUpperCase() + resource.slice(1);
329
340
  const hasPendingActions =
330
341
  (resource === 'sleep' && canWakeUp) ||
@@ -369,6 +380,33 @@ export function EntityDetailPanel({
369
380
  {resourceId}
370
381
  </p>
371
382
  </div>
383
+ {(hasEncryptedFields || encryptionKey) && onDecrypt && (
384
+ <button
385
+ type="button"
386
+ 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>
409
+ )}
372
410
  </div>
373
411
  </div>
374
412
 
@@ -103,7 +103,16 @@ export function TraceViewerTimeline({
103
103
  highlightedSpans,
104
104
  eagerRender = false,
105
105
  isLive = false,
106
- }: Omit<TraceViewerProps, 'getQuickLinks'>): ReactNode {
106
+ footer,
107
+ knownDurationMs,
108
+ hasMoreData = false,
109
+ }: Omit<TraceViewerProps, 'getQuickLinks'> & {
110
+ footer?: ReactNode;
111
+ /** Duration in ms from trace start to the latest known event. Used to render the unknown-time overlay. */
112
+ knownDurationMs?: number;
113
+ /** Whether more data pages are expected. Controls the unknown-data overlay visibility. */
114
+ hasMoreData?: boolean;
115
+ }): ReactNode {
107
116
  const isSkeleton = trace === skeletonTrace;
108
117
  const { state, dispatch } = useTraceViewer();
109
118
  const { timelineRef, scrollSnapshotRef } = state;
@@ -450,7 +459,7 @@ export function TraceViewerTimeline({
450
459
  style={{
451
460
  position: 'relative',
452
461
  width: state.timelineWidth,
453
- height: state.timelineHeight - TIMELINE_PADDING * 2,
462
+ minHeight: state.timelineHeight - TIMELINE_PADDING * 2,
454
463
  padding: TIMELINE_PADDING,
455
464
  paddingBottom: 0,
456
465
  }}
@@ -487,8 +496,44 @@ export function TraceViewerTimeline({
487
496
  scrollSnapshotRef={scrollSnapshotRef}
488
497
  spans={spans}
489
498
  />
499
+ {/* Horizontal "unknown time" overlay — covers the region to the
500
+ right of the latest known event, indicating data beyond this
501
+ point hasn't been loaded yet. */}
502
+ {knownDurationMs != null &&
503
+ knownDurationMs > 0 &&
504
+ (hasMoreData || isLive) &&
505
+ state.root.duration > 0 &&
506
+ (() => {
507
+ const knownPx = knownDurationMs * scale;
508
+ const totalPx = state.root.duration * scale;
509
+ const unknownWidth = totalPx - knownPx;
510
+ // Only show if the unknown region is meaningfully wide
511
+ if (unknownWidth < 4) return null;
512
+ // Offset ~5% into the unknown region so it doesn't touch spans
513
+ const insetPx = Math.min(unknownWidth * 0.05, 20);
514
+ return (
515
+ <div
516
+ style={{
517
+ position: 'absolute',
518
+ top: 0,
519
+ left: knownPx + insetPx,
520
+ width: unknownWidth - insetPx,
521
+ height: '100%',
522
+ pointerEvents: 'none',
523
+ zIndex: 1,
524
+ maskImage:
525
+ 'linear-gradient(to right, transparent 1%, black 3%)',
526
+ WebkitMaskImage:
527
+ 'linear-gradient(to right, transparent 1%, black 3%)',
528
+ background:
529
+ 'repeating-linear-gradient(-45deg, var(--ds-background-200) 0, var(--ds-background-200) 11px, var(--ds-gray-200) 11px, var(--ds-gray-200) 12px)',
530
+ }}
531
+ />
532
+ );
533
+ })()}
490
534
  </div>
491
535
  </div>
536
+ {footer}
492
537
  </div>
493
538
  <div className={styles.zoomButtonTraceViewer}>
494
539
  <ZoomButton />
@@ -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,114 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ export interface MenuDropdownOption<T extends string = string> {
6
+ value: T;
7
+ label: string;
8
+ }
9
+
10
+ interface MenuDropdownProps<T extends string = string> {
11
+ options: MenuDropdownOption<T>[];
12
+ value: T;
13
+ onChange: (value: T) => void;
14
+ }
15
+
16
+ /**
17
+ * A dropdown menu that matches Geist's MenuButton (secondary) + Menu styling.
18
+ * Uses CSS classes with proper :hover specificity (no inline background).
19
+ */
20
+ export function MenuDropdown<T extends string = string>({
21
+ options,
22
+ value,
23
+ onChange,
24
+ }: MenuDropdownProps<T>) {
25
+ const [open, setOpen] = useState(false);
26
+ const ref = useRef<HTMLDivElement>(null);
27
+ const label =
28
+ options.find((o) => o.value === value)?.label ?? options[0]?.label ?? '';
29
+
30
+ useEffect(() => {
31
+ if (!open) return;
32
+ function handleClickOutside(e: MouseEvent) {
33
+ if (ref.current && !ref.current.contains(e.target as Node)) {
34
+ setOpen(false);
35
+ }
36
+ }
37
+ document.addEventListener('mousedown', handleClickOutside);
38
+ return () => document.removeEventListener('mousedown', handleClickOutside);
39
+ }, [open]);
40
+
41
+ return (
42
+ <div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
43
+ <style>{`
44
+ .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}
45
+ .wf-menu-btn:hover{background:var(--ds-gray-alpha-200)}
46
+ .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}
47
+ .wf-menu-item:hover{background:var(--ds-gray-alpha-100)}
48
+ `}</style>
49
+
50
+ <button
51
+ type="button"
52
+ className="wf-menu-btn"
53
+ onClick={() => setOpen(!open)}
54
+ >
55
+ <span>{label}</span>
56
+ <svg
57
+ width={16}
58
+ height={16}
59
+ viewBox="0 0 16 16"
60
+ fill="none"
61
+ style={{
62
+ marginLeft: 16,
63
+ marginRight: -4,
64
+ color: 'var(--ds-gray-900)',
65
+ }}
66
+ >
67
+ <path
68
+ d="M4.5 6L8 9.5L11.5 6"
69
+ stroke="currentColor"
70
+ strokeWidth="1.5"
71
+ strokeLinecap="round"
72
+ strokeLinejoin="round"
73
+ />
74
+ </svg>
75
+ </button>
76
+
77
+ {open && (
78
+ <div
79
+ style={{
80
+ position: 'absolute',
81
+ right: 0,
82
+ top: '100%',
83
+ marginTop: 4,
84
+ minWidth: 140,
85
+ padding: 4,
86
+ borderRadius: 12,
87
+ background: 'var(--ds-background-100)',
88
+ boxShadow: 'var(--ds-shadow-menu, var(--ds-shadow-medium))',
89
+ zIndex: 2001,
90
+ }}
91
+ role="menu"
92
+ >
93
+ {options.map((option) => (
94
+ <button
95
+ key={option.value}
96
+ type="button"
97
+ role="menuitem"
98
+ className="wf-menu-item"
99
+ style={{
100
+ fontWeight: option.value === value ? 500 : 400,
101
+ }}
102
+ onClick={() => {
103
+ onChange(option.value);
104
+ setOpen(false);
105
+ }}
106
+ >
107
+ {option.label}
108
+ </button>
109
+ ))}
110
+ </div>
111
+ )}
112
+ </div>
113
+ );
114
+ }