@seed-ship/mcp-ui-solid 6.3.0 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/dist/components/CarouselRenderer.cjs +3 -0
  3. package/dist/components/CarouselRenderer.cjs.map +1 -1
  4. package/dist/components/CarouselRenderer.d.ts +5 -0
  5. package/dist/components/CarouselRenderer.d.ts.map +1 -1
  6. package/dist/components/CarouselRenderer.js +3 -0
  7. package/dist/components/CarouselRenderer.js.map +1 -1
  8. package/dist/components/ChartJSRenderer.cjs +3 -0
  9. package/dist/components/ChartJSRenderer.cjs.map +1 -1
  10. package/dist/components/ChartJSRenderer.d.ts +5 -0
  11. package/dist/components/ChartJSRenderer.d.ts.map +1 -1
  12. package/dist/components/ChartJSRenderer.js +3 -0
  13. package/dist/components/ChartJSRenderer.js.map +1 -1
  14. package/dist/components/CodeBlockRenderer.cjs +3 -0
  15. package/dist/components/CodeBlockRenderer.cjs.map +1 -1
  16. package/dist/components/CodeBlockRenderer.d.ts +5 -0
  17. package/dist/components/CodeBlockRenderer.d.ts.map +1 -1
  18. package/dist/components/CodeBlockRenderer.js +3 -0
  19. package/dist/components/CodeBlockRenderer.js.map +1 -1
  20. package/dist/components/GraphRenderer.cjs +33 -24
  21. package/dist/components/GraphRenderer.cjs.map +1 -1
  22. package/dist/components/GraphRenderer.d.ts +8 -2
  23. package/dist/components/GraphRenderer.d.ts.map +1 -1
  24. package/dist/components/GraphRenderer.js +33 -24
  25. package/dist/components/GraphRenderer.js.map +1 -1
  26. package/dist/components/ImageGalleryRenderer.cjs +3 -0
  27. package/dist/components/ImageGalleryRenderer.cjs.map +1 -1
  28. package/dist/components/ImageGalleryRenderer.d.ts +5 -0
  29. package/dist/components/ImageGalleryRenderer.d.ts.map +1 -1
  30. package/dist/components/ImageGalleryRenderer.js +3 -0
  31. package/dist/components/ImageGalleryRenderer.js.map +1 -1
  32. package/dist/components/MapRenderer.cjs +3 -0
  33. package/dist/components/MapRenderer.cjs.map +1 -1
  34. package/dist/components/MapRenderer.d.ts +5 -0
  35. package/dist/components/MapRenderer.d.ts.map +1 -1
  36. package/dist/components/MapRenderer.js +3 -0
  37. package/dist/components/MapRenderer.js.map +1 -1
  38. package/dist/components/PortalDropdownMenu.cjs +82 -0
  39. package/dist/components/PortalDropdownMenu.cjs.map +1 -0
  40. package/dist/components/PortalDropdownMenu.d.ts +56 -0
  41. package/dist/components/PortalDropdownMenu.d.ts.map +1 -0
  42. package/dist/components/PortalDropdownMenu.js +82 -0
  43. package/dist/components/PortalDropdownMenu.js.map +1 -0
  44. package/dist/components/UIResourceRenderer.cjs +296 -256
  45. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  46. package/dist/components/UIResourceRenderer.d.ts +12 -0
  47. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  48. package/dist/components/UIResourceRenderer.js +296 -256
  49. package/dist/components/UIResourceRenderer.js.map +1 -1
  50. package/dist/components/VideoRenderer.cjs +3 -0
  51. package/dist/components/VideoRenderer.cjs.map +1 -1
  52. package/dist/components/VideoRenderer.d.ts +5 -0
  53. package/dist/components/VideoRenderer.d.ts.map +1 -1
  54. package/dist/components/VideoRenderer.js +3 -0
  55. package/dist/components/VideoRenderer.js.map +1 -1
  56. package/dist/components/index.d.ts +2 -0
  57. package/dist/components/index.d.ts.map +1 -1
  58. package/dist/components.cjs +2 -0
  59. package/dist/components.cjs.map +1 -1
  60. package/dist/components.d.cts +2 -0
  61. package/dist/components.d.ts +2 -0
  62. package/dist/components.js +2 -0
  63. package/dist/components.js.map +1 -1
  64. package/dist/index.cjs +2 -0
  65. package/dist/index.cjs.map +1 -1
  66. package/dist/index.d.cts +2 -0
  67. package/dist/index.d.ts +2 -0
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +2 -0
  70. package/dist/index.js.map +1 -1
  71. package/package.json +1 -1
  72. package/src/components/CarouselRenderer.tsx +6 -0
  73. package/src/components/ChartJSRenderer.tsx +7 -0
  74. package/src/components/CodeBlockRenderer.tsx +7 -1
  75. package/src/components/GraphRenderer.tsx +40 -21
  76. package/src/components/ImageGalleryRenderer.tsx +7 -0
  77. package/src/components/MapRenderer.tsx +7 -0
  78. package/src/components/PortalDropdownMenu.test.tsx +113 -0
  79. package/src/components/PortalDropdownMenu.tsx +130 -0
  80. package/src/components/UIResourceRenderer.fluidity.test.tsx +51 -0
  81. package/src/components/UIResourceRenderer.tsx +50 -24
  82. package/src/components/VideoRenderer.tsx +7 -0
  83. package/src/components/index.ts +4 -0
  84. package/src/index.ts +2 -0
  85. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,113 @@
1
+ /**
2
+ * v6.4.0 — Portal-mounted dropdown for table + graph Export menus.
3
+ *
4
+ * Coverage targets :
5
+ * 1. Menu mounts via <Portal> on document.body (not in trigger's parent)
6
+ * 2. Menu position is computed from the trigger's getBoundingClientRect
7
+ * 3. Click outside the menu closes it
8
+ * 4. Escape key closes it
9
+ * 5. Click on the trigger itself does not close (parent owns toggle)
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
13
+ import { render, fireEvent, cleanup } from '@solidjs/testing-library'
14
+ import { createSignal } from 'solid-js'
15
+ import { PortalDropdownMenu } from './PortalDropdownMenu'
16
+
17
+ describe('PortalDropdownMenu (v6.4.0)', () => {
18
+ beforeEach(() => {
19
+ cleanup()
20
+ })
21
+
22
+ function harness(initiallyOpen = true) {
23
+ const [open, setOpen] = createSignal(initiallyOpen)
24
+ let triggerRef: HTMLButtonElement | undefined
25
+ const result = render(() => (
26
+ <div id="harness-root">
27
+ <button ref={triggerRef} onClick={() => setOpen((v) => !v)}>
28
+ Open
29
+ </button>
30
+ <PortalDropdownMenu
31
+ open={open()}
32
+ onClose={() => setOpen(false)}
33
+ trigger={triggerRef}
34
+ width={144}
35
+ >
36
+ <button data-testid="menu-item">Item 1</button>
37
+ </PortalDropdownMenu>
38
+ </div>
39
+ ))
40
+ return { ...result, getOpen: open, setOpen, getTrigger: () => triggerRef }
41
+ }
42
+
43
+ it('mounts the menu on document.body, NOT inside the trigger parent', () => {
44
+ const { container } = harness(true)
45
+ // Menu should NOT be inside the harness root (it's portaled to body)
46
+ const harnessRoot = container.querySelector('#harness-root')
47
+ expect(harnessRoot).toBeTruthy()
48
+ expect(harnessRoot!.querySelector('[data-testid="menu-item"]')).toBeNull()
49
+ // But it IS in document.body
50
+ const menuItem = document.body.querySelector('[data-testid="menu-item"]')
51
+ expect(menuItem).toBeTruthy()
52
+ })
53
+
54
+ it('does not render the menu when open=false', () => {
55
+ harness(false)
56
+ expect(document.body.querySelector('[data-testid="menu-item"]')).toBeNull()
57
+ })
58
+
59
+ it('positions the menu using the trigger getBoundingClientRect (right-aligned, 4px below)', () => {
60
+ // Stub the trigger rect so we get deterministic coords in jsdom
61
+ const fakeRect = {
62
+ top: 100, bottom: 130, left: 200, right: 350,
63
+ width: 150, height: 30, x: 200, y: 100, toJSON: () => ({}),
64
+ } as DOMRect
65
+ // Patch getBoundingClientRect on HTMLElement.prototype just for this test
66
+ const orig = HTMLElement.prototype.getBoundingClientRect
67
+ HTMLElement.prototype.getBoundingClientRect = vi.fn(() => fakeRect)
68
+ try {
69
+ harness(true)
70
+ const menu = document.body.querySelector('[role="menu"]') as HTMLElement
71
+ expect(menu).toBeTruthy()
72
+ // Position: top = bottom + 4 = 134, left = right - width = 350 - 144 = 206
73
+ expect(menu.style.top).toBe('134px')
74
+ expect(menu.style.left).toBe('206px')
75
+ expect(menu.style.position).toBe('fixed')
76
+ } finally {
77
+ HTMLElement.prototype.getBoundingClientRect = orig
78
+ }
79
+ })
80
+
81
+ it('closes on outside click (mousedown on document.body outside menu)', () => {
82
+ const { getOpen } = harness(true)
83
+ expect(getOpen()).toBe(true)
84
+ // Click on a brand-new element appended to body, outside the menu
85
+ const stranger = document.createElement('div')
86
+ document.body.appendChild(stranger)
87
+ fireEvent.mouseDown(stranger)
88
+ expect(getOpen()).toBe(false)
89
+ document.body.removeChild(stranger)
90
+ })
91
+
92
+ it('closes on Escape', () => {
93
+ const { getOpen } = harness(true)
94
+ expect(getOpen()).toBe(true)
95
+ fireEvent.keyDown(document, { key: 'Escape' })
96
+ expect(getOpen()).toBe(false)
97
+ })
98
+
99
+ it('does not close when mousedown lands on the trigger (parent owns toggle)', () => {
100
+ const { getOpen, getTrigger } = harness(true)
101
+ expect(getOpen()).toBe(true)
102
+ fireEvent.mouseDown(getTrigger()!)
103
+ // Menu should still be open — trigger toggle is parent's responsibility
104
+ expect(getOpen()).toBe(true)
105
+ })
106
+
107
+ it('does not close when mousedown lands inside the menu', () => {
108
+ const { getOpen } = harness(true)
109
+ const item = document.body.querySelector('[data-testid="menu-item"]') as HTMLElement
110
+ fireEvent.mouseDown(item)
111
+ expect(getOpen()).toBe(true)
112
+ })
113
+ })
@@ -0,0 +1,130 @@
1
+ /**
2
+ * PortalDropdownMenu (v6.4.0) — generic dropdown that mounts via
3
+ * `<Portal>` on `document.body` instead of an in-tree `position: absolute`
4
+ * sibling. Eliminates two pain points around the legacy in-tree pattern :
5
+ *
6
+ * 1. **`overflow: hidden` clipping** — when the trigger lives inside a
7
+ * chat bubble or a card with `overflow: hidden`, an absolutely
8
+ * positioned menu sibling gets clipped at the ancestor's boundary.
9
+ * Mounting on `document.body` escapes the clip stack entirely.
10
+ * 2. **`z-index` wars** — chat surfaces stack composer / message rails
11
+ * above the message list, and ancestor `z-index` creates a new
12
+ * stacking context that captures the in-tree menu. A portal is a
13
+ * sibling of the document, so a single `z-index: 9999` wins.
14
+ *
15
+ * The menu is positioned with `position: fixed` from the trigger's
16
+ * `getBoundingClientRect()`. We re-measure on `scroll` (capture phase, so
17
+ * nested scrollables also fire) and `resize` to keep the menu pinned
18
+ * while the user interacts with surrounding chrome.
19
+ *
20
+ * Close affordances : click outside, Escape, programmatic via `onClose`.
21
+ */
22
+
23
+ import { Component, JSX, Show, createSignal, createEffect, onCleanup } from 'solid-js'
24
+ import { Portal } from 'solid-js/web'
25
+
26
+ export interface PortalDropdownMenuProps {
27
+ /**
28
+ * Whether the menu is currently open. Controlled by the parent.
29
+ */
30
+ open: boolean
31
+
32
+ /**
33
+ * Called when the menu wants to close (outside click / Escape / item
34
+ * click — it's the parent's job to actually flip `open` to false).
35
+ */
36
+ onClose: () => void
37
+
38
+ /**
39
+ * Trigger element used as the positioning anchor. The menu's right
40
+ * edge is aligned to the trigger's right edge, top to its bottom + 4px.
41
+ */
42
+ trigger: HTMLElement | undefined
43
+
44
+ /**
45
+ * Menu width in pixels. Used to compute the left coordinate so the menu's
46
+ * right edge aligns with the trigger's right edge. Default : `144` (the
47
+ * legacy table menu width — `w-36`).
48
+ */
49
+ width?: number
50
+
51
+ /**
52
+ * Menu content. Wrapped in the rounded / shadowed container — keep
53
+ * children minimal (just the items).
54
+ */
55
+ children: JSX.Element
56
+
57
+ /**
58
+ * Optional additional class names for the menu container, appended after
59
+ * the default Tailwind classes. Useful to override width or padding.
60
+ */
61
+ class?: string
62
+ }
63
+
64
+ export const PortalDropdownMenu: Component<PortalDropdownMenuProps> = (props) => {
65
+ const [position, setPosition] = createSignal({ top: 0, left: 0 })
66
+ let menuRef: HTMLDivElement | undefined
67
+
68
+ const updatePosition = () => {
69
+ const t = props.trigger
70
+ if (!t) return
71
+ const rect = t.getBoundingClientRect()
72
+ const w = props.width ?? 144
73
+ setPosition({
74
+ top: rect.bottom + 4,
75
+ left: rect.right - w,
76
+ })
77
+ }
78
+
79
+ // Measure once when the menu opens, then react to viewport changes.
80
+ createEffect(() => {
81
+ if (!props.open) return
82
+ updatePosition()
83
+
84
+ const onDown = (e: MouseEvent) => {
85
+ const target = e.target as Node
86
+ if (menuRef?.contains(target) || props.trigger?.contains(target)) return
87
+ props.onClose()
88
+ }
89
+ const onKey = (e: KeyboardEvent) => {
90
+ if (e.key === 'Escape') props.onClose()
91
+ }
92
+ const onScrollOrResize = () => updatePosition()
93
+
94
+ document.addEventListener('mousedown', onDown)
95
+ document.addEventListener('keydown', onKey)
96
+ // Capture phase so scrolls inside nested containers (e.g. chat virtual
97
+ // list) also re-position the menu. `passive: true` because we never
98
+ // preventDefault here.
99
+ window.addEventListener('scroll', onScrollOrResize, { capture: true, passive: true })
100
+ window.addEventListener('resize', onScrollOrResize)
101
+
102
+ onCleanup(() => {
103
+ document.removeEventListener('mousedown', onDown)
104
+ document.removeEventListener('keydown', onKey)
105
+ window.removeEventListener('scroll', onScrollOrResize, { capture: true })
106
+ window.removeEventListener('resize', onScrollOrResize)
107
+ })
108
+ })
109
+
110
+ return (
111
+ <Show when={props.open}>
112
+ <Portal>
113
+ <div
114
+ ref={menuRef}
115
+ role="menu"
116
+ style={{
117
+ position: 'fixed',
118
+ top: `${position().top}px`,
119
+ left: `${position().left}px`,
120
+ 'z-index': 9999,
121
+ width: `${props.width ?? 144}px`,
122
+ }}
123
+ class={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 text-sm ${props.class ?? ''}`}
124
+ >
125
+ {props.children}
126
+ </div>
127
+ </Portal>
128
+ </Show>
129
+ )
130
+ }
@@ -99,3 +99,54 @@ describe('Chart (iframe path) — exportable default (v6.1.0)', () => {
99
99
  ).not.toThrow()
100
100
  })
101
101
  })
102
+
103
+ describe('UIResourceRenderer.toolbarVariant — forwarding to ExpandableWrapper (v6.3.1)', () => {
104
+ beforeEach(() => {
105
+ cleanup()
106
+ })
107
+
108
+ function tableComponent(): UIComponent {
109
+ return {
110
+ id: 'tbl',
111
+ type: 'table',
112
+ position: { colStart: 1, colSpan: 12 },
113
+ params: {
114
+ columns: [
115
+ { key: 'name', label: 'Name' },
116
+ { key: 'value', label: 'Value' },
117
+ ],
118
+ rows: [{ name: 'a', value: 1 }],
119
+ } as any,
120
+ }
121
+ }
122
+
123
+ it('default (toolbarVariant undefined) → expand button uses opacity-0 hover-only classes', () => {
124
+ const { container } = render(() => <UIResourceRenderer content={tableComponent()} />)
125
+ const btn = container.querySelector('button[aria-label="Expand to fullscreen"]')
126
+ expect(btn).toBeTruthy()
127
+ expect(btn!.className).toContain('opacity-0')
128
+ expect(btn!.className).toContain('group-hover:opacity-70')
129
+ expect(btn!.className).not.toContain('opacity-60')
130
+ })
131
+
132
+ it('toolbarVariant="always-visible" → expand button uses opacity-60 (no group-hover gate)', () => {
133
+ const { container } = render(() => (
134
+ <UIResourceRenderer content={tableComponent()} toolbarVariant="always-visible" />
135
+ ))
136
+ const btn = container.querySelector('button[aria-label="Expand to fullscreen"]')
137
+ expect(btn).toBeTruthy()
138
+ expect(btn!.className).toContain('opacity-60')
139
+ expect(btn!.className).not.toContain('opacity-0')
140
+ expect(btn!.className).not.toContain('group-hover:opacity-70')
141
+ })
142
+
143
+ it('toolbarVariant="hover" (explicit) → matches default behavior', () => {
144
+ const { container } = render(() => (
145
+ <UIResourceRenderer content={tableComponent()} toolbarVariant="hover" />
146
+ ))
147
+ const btn = container.querySelector('button[aria-label="Expand to fullscreen"]')
148
+ expect(btn).toBeTruthy()
149
+ expect(btn!.className).toContain('opacity-0')
150
+ expect(btn!.className).toContain('group-hover:opacity-70')
151
+ })
152
+ })
@@ -41,6 +41,7 @@ import { CodeBlockRenderer } from './CodeBlockRenderer'
41
41
  import { MapRenderer } from './MapRenderer'
42
42
  import { GraphRenderer } from './GraphRenderer'
43
43
  import { ExpandableWrapper, useExpanded } from './ExpandableWrapper'
44
+ import { PortalDropdownMenu } from './PortalDropdownMenu'
44
45
  import { RenderProvider } from './RenderContext'
45
46
  import { useAction } from '../hooks/useAction'
46
47
  import { marked } from 'marked'
@@ -121,6 +122,19 @@ export interface UIResourceRendererProps {
121
122
  * @see ValidationErrorMode
122
123
  */
123
124
  errorMode?: ValidationErrorMode
125
+
126
+ /**
127
+ * Visibility behavior of the inline expand button shipped by every
128
+ * renderer wrapped in `<ExpandableWrapper>` (v6.3.1 — closes axe 4 of
129
+ * the deposium handoff).
130
+ * - `'hover'` (default) : button fades in on hover. Backwards-compatible.
131
+ * - `'always-visible'` : button visible at rest. Use this on chat
132
+ * surfaces / touch surfaces where the hover affordance is hidden.
133
+ *
134
+ * Forwarded to all internal renderers : table, chart (Chart.js path),
135
+ * graph, map, video, carousel, image-gallery, code.
136
+ */
137
+ toolbarVariant?: 'hover' | 'always-visible'
124
138
  }
125
139
 
126
140
  /**
@@ -133,6 +147,7 @@ export interface UIResourceRendererProps {
133
147
  function ChartRenderer(props: {
134
148
  component: UIComponent
135
149
  onError?: (error: RendererError) => void
150
+ toolbarVariant?: 'hover' | 'always-visible'
136
151
  }) {
137
152
  const [useNative, setUseNative] = createSignal(false)
138
153
  const [iframeUrl, setIframeUrl] = createSignal<string>()
@@ -256,6 +271,7 @@ function ChartRenderer(props: {
256
271
  >
257
272
  <ChartJSRenderer
258
273
  component={props.component}
274
+ toolbarVariant={props.toolbarVariant}
259
275
  onError={(err) => props.onError?.({
260
276
  type: 'render',
261
277
  message: err.message,
@@ -486,6 +502,7 @@ export function renderCellValue(value: any, citationCtx?: CitationCtx): string {
486
502
  function TableRenderer(props: {
487
503
  component: UIComponent
488
504
  onError?: (error: RendererError) => void
505
+ toolbarVariant?: 'hover' | 'always-visible'
489
506
  }) {
490
507
  const tableParams = props.component.params as any
491
508
  let scrollContainerRef: HTMLDivElement | undefined
@@ -739,6 +756,8 @@ function TableRenderer(props: {
739
756
 
740
757
  // Export dropdown state
741
758
  const [showExportMenu, setShowExportMenu] = createSignal(false)
759
+ // v6.4.0 — trigger ref consumed by <PortalDropdownMenu> for positioning
760
+ let exportTriggerRef: HTMLButtonElement | undefined
742
761
 
743
762
  const handleExport = (format: string) => {
744
763
  setShowExportMenu(false)
@@ -818,35 +837,41 @@ function TableRenderer(props: {
818
837
  }
819
838
 
820
839
  return (
821
- <ExpandableWrapper title={tableParams.title || 'Table'} copyData={getTableCSV()} copyLabel="Copy table (CSV)">
840
+ <ExpandableWrapper title={tableParams.title || 'Table'} copyData={getTableCSV()} copyLabel="Copy table (CSV)" toolbarVariant={props.toolbarVariant}>
822
841
  <div class={`relative w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden group ${
823
842
  isExpanded() ? 'flex-1 min-h-0 flex flex-col' : 'h-full'
824
843
  }`}>
825
844
  <Show when={exportable} fallback={<CopyButton getText={getTableCSV} title="Copy table (CSV)" position="top-right" />}>
826
845
  <div class="absolute right-10 top-2 z-10">
827
846
  <button
847
+ ref={exportTriggerRef}
828
848
  onClick={() => setShowExportMenu(!showExportMenu())}
829
849
  class="opacity-60 hover:opacity-100 px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm"
830
850
  title="Export table"
831
851
  aria-label="Export table"
852
+ aria-haspopup="menu"
853
+ aria-expanded={showExportMenu()}
832
854
  >
833
855
  <svg class="w-3 h-3 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
834
856
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
835
857
  </svg>
836
858
  </button>
837
- <Show when={showExportMenu()}>
838
- <div class="absolute right-0 mt-1 w-36 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 text-sm">
839
- <Show when={(exportFormats as string[]).includes('tsv')}>
840
- <button onClick={() => handleExport('tsv')} class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">Copy TSV</button>
841
- </Show>
842
- <Show when={(exportFormats as string[]).includes('csv')}>
843
- <button onClick={() => handleExport('csv')} class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">Download CSV</button>
844
- </Show>
845
- <Show when={(exportFormats as string[]).includes('json')}>
846
- <button onClick={() => handleExport('json')} class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">Download JSON</button>
847
- </Show>
848
- </div>
849
- </Show>
859
+ <PortalDropdownMenu
860
+ open={showExportMenu()}
861
+ onClose={() => setShowExportMenu(false)}
862
+ trigger={exportTriggerRef}
863
+ width={144}
864
+ >
865
+ <Show when={(exportFormats as string[]).includes('tsv')}>
866
+ <button onClick={() => handleExport('tsv')} class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">Copy TSV</button>
867
+ </Show>
868
+ <Show when={(exportFormats as string[]).includes('csv')}>
869
+ <button onClick={() => handleExport('csv')} class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">Download CSV</button>
870
+ </Show>
871
+ <Show when={(exportFormats as string[]).includes('json')}>
872
+ <button onClick={() => handleExport('json')} class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">Download JSON</button>
873
+ </Show>
874
+ </PortalDropdownMenu>
850
875
  </div>
851
876
  </Show>
852
877
  <div class={`p-4 ${isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''}`}>
@@ -1275,6 +1300,7 @@ function ComponentRenderer(props: {
1275
1300
  component: UIComponent
1276
1301
  onError?: (error: RendererError) => void
1277
1302
  errorMode?: ValidationErrorMode
1303
+ toolbarVariant?: 'hover' | 'always-visible'
1278
1304
  }) {
1279
1305
  // Performance marks — visible in Chrome DevTools "Performance" panel under
1280
1306
  // user timings. Always-on, SSR-safe (see utils/perf.ts).
@@ -1403,10 +1429,10 @@ function ComponentRenderer(props: {
1403
1429
  allowRetry={true}
1404
1430
  >
1405
1431
  <Show when={props.component.type === 'chart'}>
1406
- <ChartRenderer component={props.component} onError={props.onError} />
1432
+ <ChartRenderer component={props.component} onError={props.onError} toolbarVariant={props.toolbarVariant} />
1407
1433
  </Show>
1408
1434
  <Show when={props.component.type === 'table'}>
1409
- <TableRenderer component={props.component} onError={props.onError} />
1435
+ <TableRenderer component={props.component} onError={props.onError} toolbarVariant={props.toolbarVariant} />
1410
1436
  </Show>
1411
1437
  <Show when={props.component.type === 'metric'}>
1412
1438
  <MetricRenderer component={props.component} />
@@ -1430,7 +1456,7 @@ function ComponentRenderer(props: {
1430
1456
  <GridRenderer component={props.component} onError={props.onError} />
1431
1457
  </Show>
1432
1458
  <Show when={props.component.type === 'carousel'}>
1433
- <CarouselRenderer items={(props.component.params as any)?.items || []} height={(props.component.params as any)?.height} />
1459
+ <CarouselRenderer items={(props.component.params as any)?.items || []} height={(props.component.params as any)?.height} toolbarVariant={props.toolbarVariant} />
1434
1460
  </Show>
1435
1461
  <Show when={props.component.type === 'artifact'}>
1436
1462
  <ArtifactRenderer params={props.component.params as any} />
@@ -1445,19 +1471,19 @@ function ComponentRenderer(props: {
1445
1471
  <ActionGroupRenderer component={props.component} />
1446
1472
  </Show>
1447
1473
  <Show when={props.component.type === 'image-gallery'}>
1448
- <ImageGalleryRenderer component={props.component} />
1474
+ <ImageGalleryRenderer component={props.component} toolbarVariant={props.toolbarVariant} />
1449
1475
  </Show>
1450
1476
  <Show when={props.component.type === 'video'}>
1451
- <VideoRenderer component={props.component} />
1477
+ <VideoRenderer component={props.component} toolbarVariant={props.toolbarVariant} />
1452
1478
  </Show>
1453
1479
  <Show when={props.component.type === 'code'}>
1454
- <CodeBlockRenderer component={props.component} />
1480
+ <CodeBlockRenderer component={props.component} toolbarVariant={props.toolbarVariant} />
1455
1481
  </Show>
1456
1482
  <Show when={props.component.type === 'map'}>
1457
- <MapRenderer component={props.component} />
1483
+ <MapRenderer component={props.component} toolbarVariant={props.toolbarVariant} />
1458
1484
  </Show>
1459
1485
  <Show when={props.component.type === 'graph'}>
1460
- <GraphRenderer component={props.component} />
1486
+ <GraphRenderer component={props.component} toolbarVariant={props.toolbarVariant} />
1461
1487
  </Show>
1462
1488
  </GenerativeUIErrorBoundary>
1463
1489
  )
@@ -1742,7 +1768,7 @@ export const UIResourceRenderer: Component<UIResourceRendererProps> = (props) =>
1742
1768
 
1743
1769
  // Wrapper function for RenderContext (breaks circular dependency)
1744
1770
  const renderComponent = (component: UIComponent, onError?: (error: RendererError) => void) => (
1745
- <ComponentRenderer component={component} onError={onError} errorMode={props.errorMode} />
1771
+ <ComponentRenderer component={component} onError={onError} errorMode={props.errorMode} toolbarVariant={props.toolbarVariant} />
1746
1772
  )
1747
1773
 
1748
1774
  return (
@@ -1752,7 +1778,7 @@ export const UIResourceRenderer: Component<UIResourceRendererProps> = (props) =>
1752
1778
  <For each={layoutData.components}>
1753
1779
  {(component) => (
1754
1780
  <div style={getGridStyleString(component)}>
1755
- <ComponentRenderer component={component} onError={props.onError} errorMode={props.errorMode} />
1781
+ <ComponentRenderer component={component} onError={props.onError} errorMode={props.errorMode} toolbarVariant={props.toolbarVariant} />
1756
1782
  </div>
1757
1783
  )}
1758
1784
  </For>
@@ -24,6 +24,12 @@ export interface VideoRendererProps {
24
24
  * Error callback
25
25
  */
26
26
  onError?: (error: Error) => void
27
+
28
+ /**
29
+ * Forwarded to the underlying `<ExpandableWrapper>` (v6.3.1).
30
+ * @see ExpandableWrapperProps.toolbarVariant
31
+ */
32
+ toolbarVariant?: 'hover' | 'always-visible'
27
33
  }
28
34
 
29
35
  /**
@@ -132,6 +138,7 @@ export const VideoRenderer: Component<VideoRendererProps> = (props) => {
132
138
  title={params()?.title || 'Video'}
133
139
  copyData={params()?.url || ''}
134
140
  copyLabel="Copy video URL"
141
+ toolbarVariant={props.toolbarVariant}
135
142
  >
136
143
  <div class={`w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden ${
137
144
  isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
@@ -92,5 +92,9 @@ export type { RenderContextValue, RenderComponentFn } from './RenderContext'
92
92
  export { ElicitationForm } from './ElicitationForm'
93
93
  export type { ElicitationFormProps } from './ElicitationForm'
94
94
 
95
+ // v6.4.0 — Portal-mounted dropdown (used by table + graph Export menus)
96
+ export { PortalDropdownMenu } from './PortalDropdownMenu'
97
+ export type { PortalDropdownMenuProps } from './PortalDropdownMenu'
98
+
95
99
  // Default exports for lazy loading compatibility
96
100
  export { UIResourceRenderer as default } from './UIResourceRenderer'
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export type { FeedbackInlineProps, FeedbackInlineContext } from './components/Fe
44
44
  export { ChatBusProvider, useChatBus } from './hooks/useChatBus'
45
45
  export { ChatPrompt } from './components/ChatPrompt'
46
46
  export { ElicitationForm } from './components/ElicitationForm'
47
+ export { PortalDropdownMenu } from './components/PortalDropdownMenu'
47
48
  export { ScratchpadPanel } from './components/ScratchpadPanel'
48
49
  export {
49
50
  dispatchScratchpad,
@@ -126,6 +127,7 @@ export type { ExpandableWrapperProps } from './components/ExpandableWrapper'
126
127
  export type { ComponentToolbarProps, ToolbarAction, ToolbarIcon } from './components/ComponentToolbar'
127
128
  export type { ChatPromptProps } from './components/ChatPrompt'
128
129
  export type { ElicitationFormProps } from './components/ElicitationForm'
130
+ export type { PortalDropdownMenuProps } from './components/PortalDropdownMenu'
129
131
  export type { ScratchpadPanelProps } from './components/ScratchpadPanel'
130
132
  export type { VerifiedTextProps } from './components/VerifiedText'
131
133
  export type { DataPreviewSectionProps } from './components/DataPreviewSection'