@seed-ship/mcp-ui-solid 2.1.2 → 2.2.3

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 (62) hide show
  1. package/dist/components/ChartJSRenderer.cjs +79 -36
  2. package/dist/components/ChartJSRenderer.cjs.map +1 -1
  3. package/dist/components/ChartJSRenderer.d.ts.map +1 -1
  4. package/dist/components/ChartJSRenderer.js +80 -37
  5. package/dist/components/ChartJSRenderer.js.map +1 -1
  6. package/dist/components/CodeBlockRenderer.cjs +79 -56
  7. package/dist/components/CodeBlockRenderer.cjs.map +1 -1
  8. package/dist/components/CodeBlockRenderer.d.ts.map +1 -1
  9. package/dist/components/CodeBlockRenderer.js +80 -57
  10. package/dist/components/CodeBlockRenderer.js.map +1 -1
  11. package/dist/components/ExpandableWrapper.cjs +136 -0
  12. package/dist/components/ExpandableWrapper.cjs.map +1 -0
  13. package/dist/components/ExpandableWrapper.d.ts +31 -0
  14. package/dist/components/ExpandableWrapper.d.ts.map +1 -0
  15. package/dist/components/ExpandableWrapper.js +136 -0
  16. package/dist/components/ExpandableWrapper.js.map +1 -0
  17. package/dist/components/UIResourceRenderer.cjs +369 -242
  18. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  19. package/dist/components/UIResourceRenderer.d.ts +4 -0
  20. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  21. package/dist/components/UIResourceRenderer.js +370 -243
  22. package/dist/components/UIResourceRenderer.js.map +1 -1
  23. package/dist/index.cjs +3 -0
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +2 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/node_modules/.pnpm/{dompurify@3.3.0 → dompurify@3.3.3}/node_modules/dompurify/dist/purify.es.cjs +19 -4
  31. package/dist/node_modules/.pnpm/dompurify@3.3.3/node_modules/dompurify/dist/purify.es.cjs.map +1 -0
  32. package/dist/node_modules/.pnpm/{dompurify@3.3.0 → dompurify@3.3.3}/node_modules/dompurify/dist/purify.es.js +19 -4
  33. package/dist/node_modules/.pnpm/dompurify@3.3.3/node_modules/dompurify/dist/purify.es.js.map +1 -0
  34. package/dist/services/component-registry.cjs.map +1 -1
  35. package/dist/services/component-registry.d.ts +1 -0
  36. package/dist/services/component-registry.d.ts.map +1 -1
  37. package/dist/services/component-registry.js.map +1 -1
  38. package/dist/services/validation.cjs +29 -5
  39. package/dist/services/validation.cjs.map +1 -1
  40. package/dist/services/validation.d.ts.map +1 -1
  41. package/dist/services/validation.js +29 -5
  42. package/dist/services/validation.js.map +1 -1
  43. package/dist/types/index.d.ts +17 -0
  44. package/dist/types/index.d.ts.map +1 -1
  45. package/dist/types.d.cts +17 -0
  46. package/dist/types.d.ts +17 -0
  47. package/package.json +4 -4
  48. package/src/components/ChartJSRenderer.tsx +71 -42
  49. package/src/components/CodeBlockRenderer.tsx +33 -14
  50. package/src/components/ExpandableWrapper.test.tsx +229 -0
  51. package/src/components/ExpandableWrapper.tsx +201 -0
  52. package/src/components/UIResourceRenderer.tsx +165 -62
  53. package/src/components/renderCellValue.test.ts +122 -0
  54. package/src/index.ts +2 -0
  55. package/src/services/component-registry.test.ts +81 -0
  56. package/src/services/component-registry.ts +3 -2
  57. package/src/services/validation.test.ts +134 -0
  58. package/src/services/validation.ts +21 -5
  59. package/src/types/index.ts +17 -0
  60. package/tsconfig.tsbuildinfo +1 -1
  61. package/dist/node_modules/.pnpm/dompurify@3.3.0/node_modules/dompurify/dist/purify.es.cjs.map +0 -1
  62. package/dist/node_modules/.pnpm/dompurify@3.3.0/node_modules/dompurify/dist/purify.es.js.map +0 -1
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { Component, createEffect, onCleanup, createSignal, Show } from 'solid-js'
12
12
  import type { UIComponent, ChartComponentParams } from '../types'
13
+ import { ExpandableWrapper } from './ExpandableWrapper'
13
14
 
14
15
  // Lazy load Chart.js to avoid bundling if not used
15
16
  let ChartJS: any = null
@@ -87,6 +88,16 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
87
88
 
88
89
  const params = () => props.component.params as ChartComponentParams
89
90
 
91
+ // Chart PNG export
92
+ const handleExportPNG = () => {
93
+ if (!canvasRef) return
94
+ const url = canvasRef.toDataURL('image/png')
95
+ const a = document.createElement('a')
96
+ a.href = url
97
+ a.download = `${(params().title || 'chart').replace(/\s+/g, '-').toLowerCase()}.png`
98
+ a.click()
99
+ }
100
+
90
101
  // Create/update chart when params change
91
102
  createEffect(async () => {
92
103
  if (!canvasRef) return
@@ -143,52 +154,70 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
143
154
  })
144
155
 
145
156
  return (
146
- <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 p-4">
147
- <Show when={params().title}>
148
- <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
149
- {params().title}
150
- </h3>
151
- </Show>
152
-
153
- <Show when={isLoading()}>
154
- <div class="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80">
155
- <div class="flex flex-col items-center gap-2">
156
- <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
157
- <span class="text-sm text-gray-500 dark:text-gray-400">Loading chart...</span>
158
- </div>
159
- </div>
160
- </Show>
161
-
162
- <Show when={error()}>
163
- <div class="absolute inset-0 flex items-center justify-center p-4 bg-white dark:bg-gray-800">
164
- <div class="text-center">
165
- <div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/20 mb-3">
166
- <svg
167
- class="w-6 h-6 text-red-600 dark:text-red-400"
168
- fill="none"
169
- viewBox="0 0 24 24"
170
- stroke="currentColor"
157
+ <ExpandableWrapper title={params().title || 'Chart'}>
158
+ <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 p-4 group">
159
+ <Show when={params().title || params().exportable}>
160
+ <div class="flex items-center justify-between mb-3">
161
+ <Show when={params().title}>
162
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-white">
163
+ {params().title}
164
+ </h3>
165
+ </Show>
166
+ <Show when={params().exportable}>
167
+ <button
168
+ onClick={handleExportPNG}
169
+ class="opacity-0 group-hover: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"
170
+ title="Download PNG"
171
+ aria-label="Download chart as PNG"
171
172
  >
172
- <path
173
- stroke-linecap="round"
174
- stroke-linejoin="round"
175
- stroke-width="2"
176
- d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
177
- />
178
- </svg>
173
+ <svg class="w-3 h-3 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
174
+ <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" />
175
+ </svg>
176
+ </button>
177
+ </Show>
178
+ </div>
179
+ </Show>
180
+
181
+ <Show when={isLoading()}>
182
+ <div class="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80">
183
+ <div class="flex flex-col items-center gap-2">
184
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
185
+ <span class="text-sm text-gray-500 dark:text-gray-400">Loading chart...</span>
179
186
  </div>
180
- <p class="text-red-600 dark:text-red-400 text-sm font-medium">Chart Error</p>
181
- <p class="text-gray-600 dark:text-gray-400 text-xs mt-1 max-w-xs">{error()}</p>
182
187
  </div>
183
- </div>
184
- </Show>
188
+ </Show>
189
+
190
+ <Show when={error()}>
191
+ <div class="absolute inset-0 flex items-center justify-center p-4 bg-white dark:bg-gray-800">
192
+ <div class="text-center">
193
+ <div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/20 mb-3">
194
+ <svg
195
+ class="w-6 h-6 text-red-600 dark:text-red-400"
196
+ fill="none"
197
+ viewBox="0 0 24 24"
198
+ stroke="currentColor"
199
+ >
200
+ <path
201
+ stroke-linecap="round"
202
+ stroke-linejoin="round"
203
+ stroke-width="2"
204
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
205
+ />
206
+ </svg>
207
+ </div>
208
+ <p class="text-red-600 dark:text-red-400 text-sm font-medium">Chart Error</p>
209
+ <p class="text-gray-600 dark:text-gray-400 text-xs mt-1 max-w-xs">{error()}</p>
210
+ </div>
211
+ </div>
212
+ </Show>
185
213
 
186
- <div
187
- class="w-full h-[250px]"
188
- style={{ display: error() ? 'none' : 'block' }}
189
- >
190
- <canvas ref={canvasRef} />
214
+ <div
215
+ class="w-full"
216
+ style={{ height: params().height || '250px', display: error() ? 'none' : 'block' }}
217
+ >
218
+ <canvas ref={canvasRef} />
219
+ </div>
191
220
  </div>
192
- </div>
221
+ </ExpandableWrapper>
193
222
  )
194
223
  }
@@ -7,6 +7,7 @@
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
11
 
11
12
  // Lazy load highlight.js
12
13
  let hljs: any = null
@@ -30,6 +31,7 @@ export const CodeBlockRenderer: Component<CodeBlockRendererProps> = (props) => {
30
31
  const [isCopied, setIsCopied] = createSignal(false)
31
32
  const [isHljsLoaded, setIsHljsLoaded] = createSignal(false)
32
33
  const [activeTheme, setActiveTheme] = createSignal<'light' | 'dark'>('dark')
34
+ const [wordWrap, setWordWrap] = createSignal(false)
33
35
 
34
36
  const params = () => props.params || (props.component?.params as CodeComponentParams)
35
37
 
@@ -143,28 +145,43 @@ export const CodeBlockRenderer: Component<CodeBlockRendererProps> = (props) => {
143
145
  }
144
146
 
145
147
  return (
148
+ <ExpandableWrapper title={params()?.filename || params()?.language || 'Code'} copyData={params()?.code} copyLabel="Copy code">
146
149
  <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">
147
150
  {/* Header */}
148
151
  <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">
149
152
  <div class="font-mono text-xs text-gray-600 dark:text-gray-400">
150
153
  {params()?.filename || params()?.language || 'Code'}
151
154
  </div>
152
- <button
153
- onClick={handleCopy}
154
- class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none transition-colors"
155
- aria-label="Copy code"
156
- title="Copy code"
157
- >
158
- <Show when={isCopied()} fallback={
155
+ <div class="flex items-center gap-2">
156
+ {/* Word wrap toggle */}
157
+ <button
158
+ onClick={() => setWordWrap(!wordWrap())}
159
+ class={`focus:outline-none transition-colors ${wordWrap() ? 'text-blue-500 dark:text-blue-400' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}`}
160
+ aria-label="Toggle word wrap"
161
+ title={wordWrap() ? 'Disable word wrap' : 'Enable word wrap'}
162
+ >
159
163
  <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
160
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
161
- </svg>
162
- }>
163
- <svg class="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
164
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
164
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a4 4 0 010 8H9m4 0l-3-3m3 3l-3 3M3 6h18M3 14h4" />
165
165
  </svg>
166
- </Show>
167
- </button>
166
+ </button>
167
+ {/* Copy button */}
168
+ <button
169
+ onClick={handleCopy}
170
+ class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none transition-colors"
171
+ aria-label="Copy code"
172
+ title="Copy code"
173
+ >
174
+ <Show when={isCopied()} fallback={
175
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
176
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
177
+ </svg>
178
+ }>
179
+ <svg class="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
180
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
181
+ </svg>
182
+ </Show>
183
+ </button>
184
+ </div>
168
185
  </div>
169
186
 
170
187
  {/* Code Area */}
@@ -184,6 +201,7 @@ export const CodeBlockRenderer: Component<CodeBlockRendererProps> = (props) => {
184
201
  {/* Code Content - Sprint Ultimate U.1: data-theme for reactive theming */}
185
202
  <pre
186
203
  class="flex-1 m-0 p-4 font-mono text-gray-800 dark:text-gray-100 bg-transparent leading-5"
204
+ style={wordWrap() ? { 'white-space': 'pre-wrap', 'word-break': 'break-all' } : {}}
187
205
  data-theme={activeTheme()}
188
206
  >
189
207
  <code
@@ -193,5 +211,6 @@ export const CodeBlockRenderer: Component<CodeBlockRendererProps> = (props) => {
193
211
  </pre>
194
212
  </div>
195
213
  </div>
214
+ </ExpandableWrapper>
196
215
  )
197
216
  }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Tests for ExpandableWrapper component
3
+ * P1: Expand/fullscreen for tables, charts, code
4
+ */
5
+
6
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
7
+ import { render, fireEvent, cleanup } from '@solidjs/testing-library'
8
+ import { ExpandableWrapper } from './ExpandableWrapper'
9
+
10
+ // Mock Portal to render inline for testing
11
+ vi.mock('solid-js/web', async () => {
12
+ const actual = await vi.importActual('solid-js/web') as any
13
+ return {
14
+ ...actual,
15
+ Portal: (props: any) => props.children,
16
+ }
17
+ })
18
+
19
+ describe('ExpandableWrapper', () => {
20
+ beforeEach(() => {
21
+ cleanup()
22
+ })
23
+
24
+ it('renders children inline', () => {
25
+ const { getByText } = render(() => (
26
+ <ExpandableWrapper title="Test">
27
+ <div>Hello World</div>
28
+ </ExpandableWrapper>
29
+ ))
30
+
31
+ expect(getByText('Hello World')).toBeDefined()
32
+ })
33
+
34
+ it('shows expand button on render', () => {
35
+ const { getByLabelText } = render(() => (
36
+ <ExpandableWrapper title="Test">
37
+ <div>Content</div>
38
+ </ExpandableWrapper>
39
+ ))
40
+
41
+ expect(getByLabelText('Expand to fullscreen')).toBeDefined()
42
+ })
43
+
44
+ it('opens modal when expand button is clicked', async () => {
45
+ const { getByLabelText, getByRole } = render(() => (
46
+ <ExpandableWrapper title="Test Title">
47
+ <div>Content</div>
48
+ </ExpandableWrapper>
49
+ ))
50
+
51
+ const expandBtn = getByLabelText('Expand to fullscreen')
52
+ fireEvent.click(expandBtn)
53
+
54
+ // Modal dialog should appear
55
+ const dialog = getByRole('dialog')
56
+ expect(dialog).toBeDefined()
57
+ })
58
+
59
+ it('displays title in expanded modal header', async () => {
60
+ const { getByLabelText, getByText } = render(() => (
61
+ <ExpandableWrapper title="Sales Data">
62
+ <div>Table content</div>
63
+ </ExpandableWrapper>
64
+ ))
65
+
66
+ fireEvent.click(getByLabelText('Expand to fullscreen'))
67
+
68
+ // Title should be visible in the modal header
69
+ expect(getByText('Sales Data')).toBeDefined()
70
+ })
71
+
72
+ it('closes modal when close button is clicked', async () => {
73
+ const { getByLabelText, queryByRole } = render(() => (
74
+ <ExpandableWrapper title="Test">
75
+ <div>Content</div>
76
+ </ExpandableWrapper>
77
+ ))
78
+
79
+ // Open
80
+ fireEvent.click(getByLabelText('Expand to fullscreen'))
81
+ expect(queryByRole('dialog')).not.toBeNull()
82
+
83
+ // Close
84
+ fireEvent.click(getByLabelText('Close expanded view'))
85
+
86
+ // Dialog should be gone
87
+ expect(queryByRole('dialog')).toBeNull()
88
+ })
89
+
90
+ it('closes modal on Escape key', async () => {
91
+ const { getByLabelText, queryByRole } = render(() => (
92
+ <ExpandableWrapper title="Test">
93
+ <div>Content</div>
94
+ </ExpandableWrapper>
95
+ ))
96
+
97
+ fireEvent.click(getByLabelText('Expand to fullscreen'))
98
+ expect(queryByRole('dialog')).not.toBeNull()
99
+
100
+ fireEvent.keyDown(document, { key: 'Escape' })
101
+
102
+ expect(queryByRole('dialog')).toBeNull()
103
+ })
104
+
105
+ it('shows copy button when copyData is provided', async () => {
106
+ const { getByLabelText } = render(() => (
107
+ <ExpandableWrapper title="Test" copyData="some data" copyLabel="Copy TSV">
108
+ <div>Content</div>
109
+ </ExpandableWrapper>
110
+ ))
111
+
112
+ fireEvent.click(getByLabelText('Expand to fullscreen'))
113
+
114
+ expect(getByLabelText('Copy TSV')).toBeDefined()
115
+ })
116
+
117
+ it('does not show copy button when no copyData', async () => {
118
+ const { getByLabelText, queryByLabelText } = render(() => (
119
+ <ExpandableWrapper title="Test">
120
+ <div>Content</div>
121
+ </ExpandableWrapper>
122
+ ))
123
+
124
+ fireEvent.click(getByLabelText('Expand to fullscreen'))
125
+
126
+ expect(queryByLabelText('Copy to clipboard')).toBeNull()
127
+ })
128
+
129
+ it('copies data to clipboard when copy button is clicked', async () => {
130
+ const writeText = vi.fn().mockResolvedValue(undefined)
131
+ Object.assign(navigator, { clipboard: { writeText } })
132
+
133
+ const testData = `col1\tcol2\nval1\tval2`
134
+ const { getByLabelText } = render(() => (
135
+ <ExpandableWrapper title="Test" copyData={testData}>
136
+ <div>Content</div>
137
+ </ExpandableWrapper>
138
+ ))
139
+
140
+ fireEvent.click(getByLabelText('Expand to fullscreen'))
141
+ fireEvent.click(getByLabelText('Copy to clipboard'))
142
+
143
+ expect(writeText).toHaveBeenCalledWith(testData)
144
+ })
145
+
146
+ it('uses default title "Expanded View" when no title provided', async () => {
147
+ const { getByLabelText, getByText } = render(() => (
148
+ <ExpandableWrapper>
149
+ <div>Content</div>
150
+ </ExpandableWrapper>
151
+ ))
152
+
153
+ fireEvent.click(getByLabelText('Expand to fullscreen'))
154
+
155
+ expect(getByText('Expanded View')).toBeDefined()
156
+ })
157
+
158
+ it('expanded content area is scrollable (overflow-auto)', async () => {
159
+ const { getByLabelText, getByRole } = render(() => (
160
+ <ExpandableWrapper title="Scrollable Table">
161
+ <div style={{ height: '2000px' }}>Tall content</div>
162
+ </ExpandableWrapper>
163
+ ))
164
+
165
+ fireEvent.click(getByLabelText('Expand to fullscreen'))
166
+
167
+ const dialog = getByRole('dialog')
168
+ // Content area inside the modal panel should have overflow-auto
169
+ const contentArea = dialog.querySelector('.overflow-auto')
170
+ expect(contentArea).not.toBeNull()
171
+ expect(contentArea!.classList.contains('overflow-auto')).toBe(true)
172
+ })
173
+
174
+ it('closes modal on backdrop click', async () => {
175
+ const { getByLabelText, getByRole, queryByRole } = render(() => (
176
+ <ExpandableWrapper title="Test">
177
+ <div>Content</div>
178
+ </ExpandableWrapper>
179
+ ))
180
+
181
+ fireEvent.click(getByLabelText('Expand to fullscreen'))
182
+ const dialog = getByRole('dialog')
183
+ expect(dialog).toBeDefined()
184
+
185
+ // Click directly on the backdrop (the dialog element itself, not the inner panel)
186
+ fireEvent.click(dialog)
187
+
188
+ expect(queryByRole('dialog')).toBeNull()
189
+ })
190
+
191
+ it('has dark theme classes on modal elements', async () => {
192
+ const { getByLabelText, getByRole } = render(() => (
193
+ <ExpandableWrapper title="Dark Theme Test">
194
+ <div>Content</div>
195
+ </ExpandableWrapper>
196
+ ))
197
+
198
+ fireEvent.click(getByLabelText('Expand to fullscreen'))
199
+
200
+ const dialog = getByRole('dialog')
201
+ // Modal panel should have dark mode background class
202
+ const panel = dialog.querySelector('.dark\\:bg-gray-800')
203
+ expect(panel).not.toBeNull()
204
+
205
+ // Header title should have dark mode text class
206
+ const title = dialog.querySelector('.dark\\:text-white')
207
+ expect(title).not.toBeNull()
208
+
209
+ // Header border should have dark mode class
210
+ const header = dialog.querySelector('.dark\\:border-gray-700')
211
+ expect(header).not.toBeNull()
212
+ })
213
+
214
+ it('prevents body scroll when expanded', async () => {
215
+ const { getByLabelText } = render(() => (
216
+ <ExpandableWrapper title="Test">
217
+ <div>Content</div>
218
+ </ExpandableWrapper>
219
+ ))
220
+
221
+ const originalOverflow = document.body.style.overflow
222
+
223
+ fireEvent.click(getByLabelText('Expand to fullscreen'))
224
+ expect(document.body.style.overflow).toBe('hidden')
225
+
226
+ fireEvent.keyDown(document, { key: 'Escape' })
227
+ expect(document.body.style.overflow).toBe(originalOverflow)
228
+ })
229
+ })
@@ -0,0 +1,201 @@
1
+ /**
2
+ * ExpandableWrapper - Generic expand/fullscreen wrapper for components
3
+ * v2.2.0: Reusable wrapper that adds expand button + fullscreen modal
4
+ *
5
+ * Uses DOM reparenting to avoid rendering children twice — critical for
6
+ * imperative components like ChartJS that bind instances to DOM nodes.
7
+ */
8
+
9
+ import { Component, Show, createSignal, createEffect, onCleanup, JSX } from 'solid-js'
10
+ import { Portal } from 'solid-js/web'
11
+
12
+ export interface ExpandableWrapperProps {
13
+ /** Content to render inline (and in expanded view) */
14
+ children: JSX.Element
15
+ /** Title shown in the expanded modal header */
16
+ title?: string
17
+ /** Data string for copy-to-clipboard in expanded view */
18
+ copyData?: string
19
+ /** Label for copy button tooltip */
20
+ copyLabel?: string
21
+ }
22
+
23
+ /**
24
+ * Wraps any component with an expand button (top-right corner).
25
+ * Opens a fullscreen Portal modal. The children's DOM is physically
26
+ * reparented into the modal (not duplicated), so imperative bindings
27
+ * like Chart.js canvas refs stay intact.
28
+ *
29
+ * @example
30
+ * <ExpandableWrapper title="Sales Data" copyData={tsvData}>
31
+ * <TableRenderer ... />
32
+ * </ExpandableWrapper>
33
+ */
34
+ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
35
+ const [isExpanded, setIsExpanded] = createSignal(false)
36
+ const [copied, setCopied] = createSignal(false)
37
+ let dialogRef: HTMLDivElement | undefined
38
+ let contentRef: HTMLDivElement | undefined
39
+ let inlineSlotRef: HTMLDivElement | undefined
40
+ let modalSlotRef: HTMLDivElement | undefined
41
+
42
+ const handleOpen = () => setIsExpanded(true)
43
+ const handleClose = () => setIsExpanded(false)
44
+
45
+ // Reparent content DOM between inline and modal slots
46
+ createEffect(() => {
47
+ if (!contentRef) return
48
+
49
+ if (isExpanded()) {
50
+ // Move content into modal
51
+ modalSlotRef?.appendChild(contentRef)
52
+ } else {
53
+ // Move content back to inline
54
+ inlineSlotRef?.appendChild(contentRef)
55
+ }
56
+ })
57
+
58
+ // Keyboard: Escape to close
59
+ createEffect(() => {
60
+ if (!isExpanded()) return
61
+
62
+ const onKeyDown = (e: KeyboardEvent) => {
63
+ if (e.key === 'Escape') {
64
+ e.preventDefault()
65
+ handleClose()
66
+ }
67
+ }
68
+
69
+ document.addEventListener('keydown', onKeyDown)
70
+ onCleanup(() => document.removeEventListener('keydown', onKeyDown))
71
+ })
72
+
73
+ // Prevent body scroll when expanded
74
+ createEffect(() => {
75
+ if (isExpanded()) {
76
+ const prev = document.body.style.overflow
77
+ document.body.style.overflow = 'hidden'
78
+ // Focus the dialog
79
+ setTimeout(() => dialogRef?.focus(), 10)
80
+ onCleanup(() => {
81
+ document.body.style.overflow = prev
82
+ })
83
+ }
84
+ })
85
+
86
+ const handleBackdropClick = (e: MouseEvent) => {
87
+ if (e.target === e.currentTarget) handleClose()
88
+ }
89
+
90
+ const handleCopy = async () => {
91
+ if (!props.copyData) return
92
+ try {
93
+ await navigator.clipboard.writeText(props.copyData)
94
+ setCopied(true)
95
+ setTimeout(() => setCopied(false), 2000)
96
+ } catch (err) {
97
+ console.error('Failed to copy:', err)
98
+ }
99
+ }
100
+
101
+ return (
102
+ <div class="relative group">
103
+ {/* Inline slot — content lives here when not expanded */}
104
+ <div ref={inlineSlotRef}>
105
+ <div ref={contentRef}>
106
+ {props.children}
107
+ </div>
108
+ </div>
109
+
110
+ {/* Expand button — visible on hover */}
111
+ <button
112
+ onClick={handleOpen}
113
+ 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"
114
+ title="Expand"
115
+ aria-label="Expand to fullscreen"
116
+ >
117
+ <svg class="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
118
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
119
+ </svg>
120
+ </button>
121
+
122
+ {/* Fullscreen modal via Portal */}
123
+ <Show when={isExpanded()}>
124
+ <Portal>
125
+ <div
126
+ class="fixed inset-0 z-50 flex flex-col bg-black/50 backdrop-blur-sm"
127
+ style={{ animation: 'expandable-fade-in 0.15s ease-out' }}
128
+ onClick={handleBackdropClick}
129
+ role="dialog"
130
+ aria-modal="true"
131
+ aria-label={props.title || 'Expanded view'}
132
+ tabIndex={-1}
133
+ ref={dialogRef}
134
+ >
135
+ {/* Modal panel */}
136
+ <div
137
+ class="relative flex flex-col m-4 flex-1 bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden"
138
+ style={{ animation: 'expandable-scale-in 0.15s ease-out' }}
139
+ onClick={(e) => e.stopPropagation()}
140
+ >
141
+ {/* Header */}
142
+ <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
143
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-white truncate">
144
+ {props.title || 'Expanded View'}
145
+ </h2>
146
+ <div class="flex items-center gap-2">
147
+ {/* Copy button */}
148
+ <Show when={props.copyData}>
149
+ <button
150
+ onClick={handleCopy}
151
+ class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
152
+ title={props.copyLabel || 'Copy to clipboard'}
153
+ aria-label={props.copyLabel || 'Copy to clipboard'}
154
+ >
155
+ <Show
156
+ when={!copied()}
157
+ fallback={
158
+ <svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
159
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
160
+ </svg>
161
+ }
162
+ >
163
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
164
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
165
+ </svg>
166
+ </Show>
167
+ </button>
168
+ </Show>
169
+ {/* Close button */}
170
+ <button
171
+ onClick={handleClose}
172
+ class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
173
+ aria-label="Close expanded view"
174
+ >
175
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
176
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
177
+ </svg>
178
+ </button>
179
+ </div>
180
+ </div>
181
+
182
+ {/* Modal slot — content is reparented here when expanded */}
183
+ <div class="flex-1 overflow-auto p-4" ref={modalSlotRef} />
184
+ </div>
185
+ </div>
186
+
187
+ <style>{`
188
+ @keyframes expandable-fade-in {
189
+ from { opacity: 0; }
190
+ to { opacity: 1; }
191
+ }
192
+ @keyframes expandable-scale-in {
193
+ from { opacity: 0; transform: scale(0.97); }
194
+ to { opacity: 1; transform: scale(1); }
195
+ }
196
+ `}</style>
197
+ </Portal>
198
+ </Show>
199
+ </div>
200
+ )
201
+ }