@seed-ship/mcp-ui-solid 6.1.0 → 6.3.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 (58) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/dist/components/CarouselRenderer.cjs +41 -30
  3. package/dist/components/CarouselRenderer.cjs.map +1 -1
  4. package/dist/components/CarouselRenderer.d.ts.map +1 -1
  5. package/dist/components/CarouselRenderer.js +42 -31
  6. package/dist/components/CarouselRenderer.js.map +1 -1
  7. package/dist/components/CodeBlockRenderer.cjs +88 -25
  8. package/dist/components/CodeBlockRenderer.cjs.map +1 -1
  9. package/dist/components/CodeBlockRenderer.d.ts.map +1 -1
  10. package/dist/components/CodeBlockRenderer.js +89 -26
  11. package/dist/components/CodeBlockRenderer.js.map +1 -1
  12. package/dist/components/ExpandableWrapper.cjs +2 -1
  13. package/dist/components/ExpandableWrapper.cjs.map +1 -1
  14. package/dist/components/ExpandableWrapper.d.ts +10 -0
  15. package/dist/components/ExpandableWrapper.d.ts.map +1 -1
  16. package/dist/components/ExpandableWrapper.js +3 -2
  17. package/dist/components/ExpandableWrapper.js.map +1 -1
  18. package/dist/components/ImageGalleryRenderer.cjs +101 -77
  19. package/dist/components/ImageGalleryRenderer.cjs.map +1 -1
  20. package/dist/components/ImageGalleryRenderer.d.ts.map +1 -1
  21. package/dist/components/ImageGalleryRenderer.js +102 -78
  22. package/dist/components/ImageGalleryRenderer.js.map +1 -1
  23. package/dist/components/MapRenderer.cjs +94 -34
  24. package/dist/components/MapRenderer.cjs.map +1 -1
  25. package/dist/components/MapRenderer.d.ts.map +1 -1
  26. package/dist/components/MapRenderer.js +107 -47
  27. package/dist/components/MapRenderer.js.map +1 -1
  28. package/dist/components/UIResourceRenderer.cjs +27 -12
  29. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  30. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  31. package/dist/components/UIResourceRenderer.js +27 -12
  32. package/dist/components/UIResourceRenderer.js.map +1 -1
  33. package/dist/components/VideoRenderer.cjs +95 -74
  34. package/dist/components/VideoRenderer.cjs.map +1 -1
  35. package/dist/components/VideoRenderer.d.ts.map +1 -1
  36. package/dist/components/VideoRenderer.js +96 -75
  37. package/dist/components/VideoRenderer.js.map +1 -1
  38. package/dist/index.cjs +3 -3
  39. package/dist/index.js +1 -1
  40. package/dist/mcp-ui-spec/dist/schemas.cjs +8 -1
  41. package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
  42. package/dist/mcp-ui-spec/dist/schemas.js +8 -1
  43. package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
  44. package/dist/types/index.d.ts +11 -0
  45. package/dist/types/index.d.ts.map +1 -1
  46. package/dist/types.d.cts +11 -0
  47. package/dist/types.d.ts +11 -0
  48. package/package.json +2 -2
  49. package/src/components/CarouselRenderer.tsx +9 -1
  50. package/src/components/CodeBlockRenderer.tsx +65 -5
  51. package/src/components/ExpandableWrapper.tsx +16 -2
  52. package/src/components/ImageGalleryRenderer.test.tsx +18 -7
  53. package/src/components/ImageGalleryRenderer.tsx +22 -3
  54. package/src/components/MapRenderer.tsx +68 -14
  55. package/src/components/UIResourceRenderer.tsx +19 -10
  56. package/src/components/VideoRenderer.tsx +14 -4
  57. package/src/types/index.ts +11 -0
  58. package/tsconfig.tsbuildinfo +1 -1
@@ -7,7 +7,18 @@
7
7
  import { Component, createEffect, onCleanup, createSignal, Show, For } from 'solid-js'
8
8
  import { isServer } from 'solid-js/web'
9
9
  import type { UIComponent, CodeComponentParams } from '../types'
10
- import { ExpandableWrapper } from './ExpandableWrapper'
10
+ import { ExpandableWrapper, useExpanded } from './ExpandableWrapper'
11
+ import { highlightQuery } from './UIResourceRenderer'
12
+
13
+ /** Map of `params.language` → file extension for the v6.2.0 download button. */
14
+ const LANGUAGE_EXTENSIONS: Record<string, string> = {
15
+ typescript: 'ts', tsx: 'tsx', javascript: 'js', jsx: 'jsx',
16
+ python: 'py', ruby: 'rb', go: 'go', rust: 'rs', java: 'java',
17
+ kotlin: 'kt', swift: 'swift', php: 'php', csharp: 'cs', cpp: 'cpp',
18
+ c: 'c', sql: 'sql', json: 'json', yaml: 'yml', toml: 'toml',
19
+ bash: 'sh', shell: 'sh', html: 'html', css: 'css', scss: 'scss',
20
+ markdown: 'md', xml: 'xml', graphql: 'graphql',
21
+ }
11
22
 
12
23
  // Lazy load highlight.js
13
24
  let hljs: any = null
@@ -34,6 +45,35 @@ export const CodeBlockRenderer: Component<CodeBlockRendererProps> = (props) => {
34
45
  const [wordWrap, setWordWrap] = createSignal(false)
35
46
 
36
47
  const params = () => props.params || (props.component?.params as CodeComponentParams)
48
+ const isExpanded = useExpanded()
49
+ const [searchQuery, setSearchQuery] = createSignal('')
50
+
51
+ // v6.2.0 — search highlight: re-wraps `<mark>` around matches in the
52
+ // already-highlighted (hljs) HTML output. `highlightQuery` is the same
53
+ // helper TableRenderer uses (only wraps text outside of HTML tags so
54
+ // syntax span colors stay intact).
55
+ const displayedHTML = () => {
56
+ const q = searchQuery().trim()
57
+ return q ? highlightQuery(highlightedCode(), q) : highlightedCode()
58
+ }
59
+
60
+ const handleDownload = () => {
61
+ const code = params()?.code
62
+ if (!code) return
63
+ const lang = (params()?.language || '').toLowerCase()
64
+ const ext = LANGUAGE_EXTENSIONS[lang] || 'txt'
65
+ const stem = (params()?.filename || `code-${Date.now()}`).replace(/\.[^.]+$/, '')
66
+ const filename = stem.endsWith(`.${ext}`) ? stem : `${stem}.${ext}`
67
+ const blob = new Blob([code], { type: 'text/plain' })
68
+ const url = URL.createObjectURL(blob)
69
+ const a = document.createElement('a')
70
+ a.href = url
71
+ a.download = filename
72
+ document.body.appendChild(a)
73
+ a.click()
74
+ document.body.removeChild(a)
75
+ URL.revokeObjectURL(url)
76
+ }
37
77
 
38
78
  // Load highlight.js on mount
39
79
  createEffect(async () => {
@@ -152,13 +192,33 @@ export const CodeBlockRenderer: Component<CodeBlockRendererProps> = (props) => {
152
192
 
153
193
  return (
154
194
  <ExpandableWrapper title={params()?.filename || params()?.language || 'Code'} copyData={params()?.code} copyLabel="Copy code">
155
- <div class="w-full bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden text-sm flex flex-col">
195
+ <div class={`w-full bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden text-sm flex flex-col ${isExpanded() ? 'flex-1 min-h-0' : ''}`}>
156
196
  {/* Header */}
157
197
  <div class="flex items-center justify-between px-4 py-2 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shrink-0">
158
198
  <div class="font-mono text-xs text-gray-600 dark:text-gray-400">
159
199
  {params()?.filename || params()?.language || 'Code'}
160
200
  </div>
161
201
  <div class="flex items-center gap-2">
202
+ {/* Search input (v6.2.0) */}
203
+ <input
204
+ type="text"
205
+ value={searchQuery()}
206
+ onInput={(e) => setSearchQuery(e.currentTarget.value)}
207
+ placeholder="Search…"
208
+ class="px-2 py-0.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-400 outline-none w-32"
209
+ aria-label="Search in code"
210
+ />
211
+ {/* Download button (v6.2.0) */}
212
+ <button
213
+ onClick={handleDownload}
214
+ class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none transition-colors"
215
+ aria-label="Download code as file"
216
+ title="Download code"
217
+ >
218
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
219
+ <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" />
220
+ </svg>
221
+ </button>
162
222
  {/* Word wrap toggle */}
163
223
  <button
164
224
  onClick={() => setWordWrap(!wordWrap())}
@@ -192,8 +252,8 @@ export const CodeBlockRenderer: Component<CodeBlockRendererProps> = (props) => {
192
252
 
193
253
  {/* Code Area */}
194
254
  <div
195
- class="relative overflow-auto flex"
196
- style={params()?.maxHeight ? { 'max-height': params()?.maxHeight } : {}}
255
+ class={`relative overflow-auto flex ${isExpanded() ? 'flex-1 min-h-0' : ''}`}
256
+ style={!isExpanded() && params()?.maxHeight ? { 'max-height': params()?.maxHeight } : {}}
197
257
  >
198
258
  {/* Line Numbers */}
199
259
  <Show when={params()?.showLineNumbers !== false}>
@@ -212,7 +272,7 @@ export const CodeBlockRenderer: Component<CodeBlockRendererProps> = (props) => {
212
272
  >
213
273
  <code
214
274
  class={`hljs ${params()?.language ? `language-${params()?.language}` : ''}`}
215
- innerHTML={highlightedCode()}
275
+ innerHTML={displayedHTML()}
216
276
  />
217
277
  </pre>
218
278
  </div>
@@ -24,6 +24,16 @@ export interface ExpandableWrapperProps {
24
24
  copyData?: string
25
25
  /** Label for copy button tooltip */
26
26
  copyLabel?: string
27
+ /**
28
+ * Visibility behavior of the inline expand button (v6.3.0 — axe 4 deposium handoff).
29
+ * - `'hover'` (default) : opacity 0, fades to 0.7 on parent group hover.
30
+ * Backwards-compatible — pre-v6.3.0 behavior.
31
+ * - `'always-visible'` : opacity 0.6 permanent, 1 on hover. Use this when
32
+ * the inline button needs to be discoverable without hovering — esp.
33
+ * on touch surfaces and consumer themes where the hover-only pattern
34
+ * hides the affordance.
35
+ */
36
+ toolbarVariant?: 'hover' | 'always-visible'
27
37
  }
28
38
 
29
39
  /**
@@ -115,10 +125,14 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
115
125
  </div>
116
126
  </div>
117
127
 
118
- {/* Expand button — visible on hover */}
128
+ {/* Expand button — visibility per `toolbarVariant` (default 'hover') */}
119
129
  <button
120
130
  onClick={handleOpen}
121
- class="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-70 hover:!opacity-100 p-1.5 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"
131
+ class={`absolute top-2 right-2 z-10 ${
132
+ props.toolbarVariant === 'always-visible'
133
+ ? 'opacity-60 hover:opacity-100'
134
+ : 'opacity-0 group-hover:opacity-70 hover:!opacity-100'
135
+ } p-1.5 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`}
122
136
  title="Expand"
123
137
  aria-label="Expand to fullscreen"
124
138
  >
@@ -120,10 +120,16 @@ describe('ImageGalleryRenderer', () => {
120
120
  })
121
121
 
122
122
  it('renders buttons for images when lightbox is enabled', () => {
123
- render(() => <ImageGalleryRenderer params={defaultParams} />)
124
-
125
- const buttons = screen.getAllByRole('button')
126
- // Should have one button per image (for opening lightbox)
123
+ const { container } = render(() => <ImageGalleryRenderer params={defaultParams} />)
124
+
125
+ // Filter to image-trigger buttons (have aria-label "View image …"). The
126
+ // ExpandableWrapper expand button (added in v6.2.0) is excluded.
127
+ // Image-trigger buttons have a tailwind `relative overflow-hidden` group
128
+ // class. The ExpandableWrapper expand button (added v6.2.0) has a
129
+ // different `absolute top-2 right-2` class — this filter excludes it.
130
+ const buttons = Array.from(container.querySelectorAll('button')).filter(
131
+ (b) => b.className.includes('relative overflow-hidden')
132
+ )
127
133
  expect(buttons.length).toBe(3)
128
134
  })
129
135
 
@@ -133,10 +139,15 @@ describe('ImageGalleryRenderer', () => {
133
139
  lightbox: false,
134
140
  }
135
141
 
136
- render(() => <ImageGalleryRenderer params={paramsNoLightbox} />)
142
+ const { container } = render(() => <ImageGalleryRenderer params={paramsNoLightbox} />)
137
143
 
138
- // Should still render buttons (for accessibility) but not open lightbox
139
- const buttons = screen.getAllByRole('button')
144
+ // Same filter as above ignore the ExpandableWrapper expand button (v6.2.0).
145
+ // Image-trigger buttons have a tailwind `relative overflow-hidden` group
146
+ // class. The ExpandableWrapper expand button (added v6.2.0) has a
147
+ // different `absolute top-2 right-2` class — this filter excludes it.
148
+ const buttons = Array.from(container.querySelectorAll('button')).filter(
149
+ (b) => b.className.includes('relative overflow-hidden')
150
+ )
140
151
  expect(buttons.length).toBe(3)
141
152
  })
142
153
 
@@ -6,6 +6,7 @@
6
6
  import { Component, createSignal, For, Show } from 'solid-js'
7
7
  import type { UIComponent, ImageGalleryParams } from '../types'
8
8
  import { LightboxOverlay } from './LightboxOverlay'
9
+ import { ExpandableWrapper, useExpanded } from './ExpandableWrapper'
9
10
 
10
11
  export interface ImageGalleryRendererProps {
11
12
  /**
@@ -19,8 +20,18 @@ export interface ImageGalleryRendererProps {
19
20
  params?: ImageGalleryParams
20
21
  }
21
22
 
23
+ /** Build a newline-separated list of image URLs (with captions when present)
24
+ * for the ExpandableWrapper copy button. v6.2.0. */
25
+ function imagesToTextList(p: ImageGalleryParams | undefined): string {
26
+ if (!p) return ''
27
+ return (p.images ?? [])
28
+ .map((img) => (img.caption ? `${img.url}\t${img.caption}` : img.url))
29
+ .join('\n')
30
+ }
31
+
22
32
  export const ImageGalleryRenderer: Component<ImageGalleryRendererProps> = (props) => {
23
33
  const [selectedIndex, setSelectedIndex] = createSignal<number | null>(null)
34
+ const isExpanded = useExpanded()
24
35
 
25
36
  const params = () => props.params || (props.component?.params as ImageGalleryParams)
26
37
 
@@ -72,16 +83,23 @@ export const ImageGalleryRenderer: Component<ImageGalleryRendererProps> = (props
72
83
  }
73
84
 
74
85
  return (
75
- <div class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
86
+ <ExpandableWrapper
87
+ title={params()?.title || 'Gallery'}
88
+ copyData={imagesToTextList(params())}
89
+ copyLabel="Copy image URLs"
90
+ >
91
+ <div class={`w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden ${
92
+ isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
93
+ }`}>
76
94
  {/* Title */}
77
95
  <Show when={params()?.title}>
78
- <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
96
+ <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
79
97
  <h3 class="text-sm font-semibold text-gray-900 dark:text-white">{params()!.title}</h3>
80
98
  </div>
81
99
  </Show>
82
100
 
83
101
  {/* Gallery Grid */}
84
- <div class={`grid ${columnsClass()} ${gapClass()} p-4`}>
102
+ <div class={`grid ${columnsClass()} ${gapClass()} p-4 ${isExpanded() ? 'flex-1 min-h-0 overflow-auto' : ''}`}>
85
103
  <For each={params()?.images}>
86
104
  {(image, index) => (
87
105
  <button
@@ -142,5 +160,6 @@ export const ImageGalleryRenderer: Component<ImageGalleryRendererProps> = (props
142
160
  onNavigate={setSelectedIndex}
143
161
  />
144
162
  </div>
163
+ </ExpandableWrapper>
145
164
  )
146
165
  }
@@ -7,6 +7,7 @@
7
7
  import { Component, createEffect, onCleanup, createSignal, Show } from 'solid-js'
8
8
  import { isServer } from 'solid-js/web'
9
9
  import type { UIComponent, MapComponentParams, MapClusterOptions, MapGeoJSONStyle, MapPopupConfig, MapLayer, MapPMTilesConfig } from '../types'
10
+ import { ExpandableWrapper, useExpanded } from './ExpandableWrapper'
10
11
 
11
12
  // Lazy load leaflet (it doesn't support SSR well)
12
13
  let L: any = null
@@ -165,14 +166,55 @@ function addGeoJSONLayer(
165
166
 
166
167
  // ─── Component ──────────────────────────────────────────────
167
168
 
169
+ /**
170
+ * Build a GeoJSON FeatureCollection from the map's `markers` (and any
171
+ * inlined GeoJSON layers, when present). Used by the "Copy data" button
172
+ * shipped via `<ExpandableWrapper>` (v6.2.0). Best-effort — clusters,
173
+ * tile layers, and choropleth-only data don't get round-tripped.
174
+ */
175
+ function mapToGeoJSON(p: MapComponentParams | undefined): string {
176
+ if (!p) return '{"type":"FeatureCollection","features":[]}'
177
+ const features: any[] = []
178
+ for (const marker of p.markers ?? []) {
179
+ const pos: any = marker.position as any
180
+ // Accept both [lat, lng] tuple and {lat, lng} object shapes (v5.0.2 spec)
181
+ const lat = Array.isArray(pos) ? pos[0] : pos?.lat
182
+ const lng = Array.isArray(pos) ? pos[1] : pos?.lng
183
+ if (typeof lat !== 'number' || typeof lng !== 'number') continue
184
+ features.push({
185
+ type: 'Feature',
186
+ geometry: { type: 'Point', coordinates: [lng, lat] },
187
+ properties: {
188
+ ...(marker.tooltip ? { tooltip: marker.tooltip } : {}),
189
+ ...(marker.popup ? { popup: marker.popup } : {}),
190
+ },
191
+ })
192
+ }
193
+ return JSON.stringify({ type: 'FeatureCollection', features }, null, 2)
194
+ }
195
+
168
196
  export const MapRenderer: Component<MapRendererProps> = (props) => {
169
197
  let mapContainer: HTMLDivElement | undefined
170
198
  let mapInstance: any = null
171
199
  const [isLeafletLoaded, setIsLeafletLoaded] = createSignal(false)
172
200
  const [error, setError] = createSignal<string | null>(null)
201
+ const isExpanded = useExpanded()
173
202
 
174
203
  const params = () => props.params || (props.component?.params as MapComponentParams)
175
204
 
205
+ // v6.2.0 — Leaflet has to be told to re-measure when its container
206
+ // resizes (e.g. transitioning to fullscreen via ExpandableWrapper).
207
+ // We give the DOM a tick to settle the new dimensions, then ask
208
+ // Leaflet to reflow tiles.
209
+ createEffect(() => {
210
+ const expanded = isExpanded()
211
+ if (!mapInstance) return
212
+ // Read the signal so the effect re-runs on toggle ; the value is
213
+ // observed for its side effects on layout.
214
+ void expanded
215
+ setTimeout(() => mapInstance?.invalidateSize?.(), 100)
216
+ })
217
+
176
218
  // Initialize Map
177
219
  createEffect(async () => {
178
220
  if (isServer) return // Don't run on server
@@ -384,19 +426,31 @@ export const MapRenderer: Component<MapRendererProps> = (props) => {
384
426
  })
385
427
 
386
428
  return (
387
- <div class={`w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden ${params()?.className || ''}`}>
388
- <Show when={error()}>
389
- <div class="p-4 text-red-500 bg-red-50 dark:bg-red-900/20 text-center">
390
- {error()}
391
- </div>
392
- </Show>
393
- <Show when={!error()}>
394
- <div
395
- ref={mapContainer}
396
- style={{ height: params()?.height || '400px', width: '100%', "z-index": 0 }}
397
- class="relative z-0"
398
- />
399
- </Show>
400
- </div>
429
+ <ExpandableWrapper
430
+ title={'Map'}
431
+ copyData={mapToGeoJSON(params())}
432
+ copyLabel="Copy markers as GeoJSON"
433
+ >
434
+ <div class={`w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden ${params()?.className || ''} ${
435
+ isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
436
+ }`}>
437
+ <Show when={error()}>
438
+ <div class="p-4 text-red-500 bg-red-50 dark:bg-red-900/20 text-center">
439
+ {error()}
440
+ </div>
441
+ </Show>
442
+ <Show when={!error()}>
443
+ <div
444
+ ref={mapContainer}
445
+ style={
446
+ isExpanded()
447
+ ? { height: '100%', width: '100%', 'z-index': 0 }
448
+ : { height: params()?.height || '400px', width: '100%', 'z-index': 0 }
449
+ }
450
+ class={`relative z-0 ${isExpanded() ? 'flex-1 min-h-0' : ''}`}
451
+ />
452
+ </Show>
453
+ </div>
454
+ </ExpandableWrapper>
401
455
  )
402
456
  }
@@ -891,16 +891,25 @@ function TableRenderer(props: {
891
891
  class={`overflow-x-auto ${isExpanded() ? 'flex-1 min-h-0' : ''}`}
892
892
  style={
893
893
  // v6.1.0 — when expanded, the scroll container fills the
894
- // remaining vertical space (flex-1 + min-h-0 above) and
895
- // scrolls internally instead of the modal scrolling. Inline
896
- // mode keeps the previous max-height heuristic.
897
- isExpanded()
898
- ? { 'overflow-y': 'auto' }
899
- : isVirtualizing()
900
- ? { 'max-height': '500px', 'overflow-y': 'auto' }
901
- : clientVisibleRows().length > 8
902
- ? { 'max-height': '400px', 'overflow-y': 'auto' }
903
- : {}
894
+ // remaining vertical space and scrolls internally.
895
+ // v6.3.0 `params.maxHeight` opt-out (axe 1 deposium handoff)
896
+ // - 'auto' no cap, parent handles overflow
897
+ // - number → `${n}px`, string → CSS as-is
898
+ // - undefined existing 400/500px heuristic
899
+ (() => {
900
+ if (isExpanded()) return { 'overflow-y': 'auto' }
901
+ const mh = tableParams.maxHeight
902
+ if (mh === 'auto') return {}
903
+ if (mh !== undefined) {
904
+ return {
905
+ 'max-height': typeof mh === 'number' ? `${mh}px` : mh,
906
+ 'overflow-y': 'auto',
907
+ }
908
+ }
909
+ if (isVirtualizing()) return { 'max-height': '500px', 'overflow-y': 'auto' }
910
+ if (clientVisibleRows().length > 8) return { 'max-height': '400px', 'overflow-y': 'auto' }
911
+ return {}
912
+ })()
904
913
  }
905
914
  role="region"
906
915
  aria-label={tableParams.title || 'Data table'}
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { Component, createMemo, Show } from 'solid-js'
9
9
  import type { UIComponent, VideoComponentParams } from '../types'
10
+ import { ExpandableWrapper, useExpanded } from './ExpandableWrapper'
10
11
 
11
12
  export interface VideoRendererProps {
12
13
  /**
@@ -68,6 +69,7 @@ function parseVideoUrl(url: string): VideoInfo {
68
69
 
69
70
  export const VideoRenderer: Component<VideoRendererProps> = (props) => {
70
71
  const params = () => props.params || (props.component?.params as VideoComponentParams)
72
+ const isExpanded = useExpanded()
71
73
 
72
74
  const videoInfo = createMemo(() => parseVideoUrl(params()?.url || ''))
73
75
 
@@ -126,7 +128,14 @@ export const VideoRenderer: Component<VideoRendererProps> = (props) => {
126
128
  }
127
129
 
128
130
  return (
129
- <div class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
131
+ <ExpandableWrapper
132
+ title={params()?.title || 'Video'}
133
+ copyData={params()?.url || ''}
134
+ copyLabel="Copy video URL"
135
+ >
136
+ <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
+ isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
138
+ }`}>
130
139
  {/* Title */}
131
140
  <Show when={params()?.title}>
132
141
  <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
@@ -134,8 +143,8 @@ export const VideoRenderer: Component<VideoRendererProps> = (props) => {
134
143
  </div>
135
144
  </Show>
136
145
 
137
- {/* Video Container */}
138
- <div class={`relative ${aspectClass()} bg-black`}>
146
+ {/* Video Container — when expanded, fill remaining space (override aspect ratio) */}
147
+ <div class={`relative bg-black ${isExpanded() ? 'flex-1 min-h-0' : aspectClass()}`}>
139
148
  <Show
140
149
  when={embedUrl()}
141
150
  fallback={
@@ -170,11 +179,12 @@ export const VideoRenderer: Component<VideoRendererProps> = (props) => {
170
179
 
171
180
  {/* Caption */}
172
181
  <Show when={params()?.caption}>
173
- <div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
182
+ <div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
174
183
  <p class="text-sm text-gray-600 dark:text-gray-400">{params()!.caption}</p>
175
184
  </div>
176
185
  </Show>
177
186
  </div>
187
+ </ExpandableWrapper>
178
188
  )
179
189
  }
180
190
 
@@ -242,6 +242,17 @@ export interface TableComponentParams {
242
242
  id: number,
243
243
  mapping: CitationEntry | undefined
244
244
  ) => string
245
+ /**
246
+ * Opt-out for the inline-mode max-height cap (v6.3.0).
247
+ * - `'auto'` → no cap, parent container handles overflow
248
+ * - number → `${n}px`
249
+ * - string → CSS length as-is
250
+ * - undefined → existing behavior (400px when > 8 rows, 500px virtualizing)
251
+ *
252
+ * Ignored in expanded (fullscreen) mode — the modal uses
253
+ * `flex-1 min-h-0` regardless.
254
+ */
255
+ maxHeight?: 'auto' | number | string
245
256
  }
246
257
 
247
258
  /**