@webmcp-auto-ui/ui 2.5.32 → 2.5.34

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 (81) hide show
  1. package/package.json +16 -2
  2. package/src/agent/DiagnosticModal.svelte +126 -50
  3. package/src/agent/EphemeralBubble.svelte +13 -3
  4. package/src/agent/MCPserversList.svelte +147 -0
  5. package/src/agent/McpConnector.svelte +10 -1
  6. package/src/agent/RecipeBrowser.svelte +384 -0
  7. package/src/agent/RemoteMCPserversDemo.svelte +5 -121
  8. package/src/agent/ToolBrowser.svelte +133 -0
  9. package/src/agent/WebMCPserversList.svelte +2 -0
  10. package/src/agent/useAgentLoop.svelte.ts +396 -0
  11. package/src/base/chat-inline.svelte +68 -0
  12. package/src/base/dialog-content.svelte +3 -1
  13. package/src/base/dialog-trigger.svelte +3 -2
  14. package/src/components/HeaderControls.svelte +78 -0
  15. package/src/index.ts +13 -35
  16. package/src/stores/canvas.svelte.ts +0 -6
  17. package/src/widgets/SafeImage.svelte +67 -0
  18. package/src/widgets/WidgetRenderer.svelte +153 -78
  19. package/src/widgets/notebook/executors/index.ts +0 -1
  20. package/src/widgets/notebook/executors/sql.ts +32 -182
  21. package/src/widgets/notebook/import-modal-api.ts +237 -0
  22. package/src/widgets/notebook/import-modal.svelte +738 -0
  23. package/src/widgets/notebook/left-pane.ts +1 -1
  24. package/src/widgets/notebook/notebook.svelte +75 -0
  25. package/src/widgets/notebook/notebook.ts +38 -73
  26. package/src/widgets/notebook/prose.ts +6 -3
  27. package/src/widgets/notebook/shared.ts +68 -49
  28. package/src/widgets/rich/cards.svelte +74 -0
  29. package/src/widgets/rich/carousel.svelte +126 -0
  30. package/src/widgets/rich/chart-rich.svelte +221 -0
  31. package/src/widgets/rich/chat-input.svelte +51 -0
  32. package/src/widgets/rich/data-table.svelte +132 -0
  33. package/src/widgets/rich/gallery.svelte +115 -0
  34. package/src/widgets/rich/grid-data.svelte +85 -0
  35. package/src/widgets/rich/hemicycle.svelte +95 -0
  36. package/src/widgets/rich/js-sandbox.svelte +67 -0
  37. package/src/widgets/rich/json-viewer.svelte +82 -0
  38. package/src/widgets/rich/log.svelte +62 -0
  39. package/src/widgets/rich/profile.svelte +91 -0
  40. package/src/widgets/rich/sankey.svelte +73 -0
  41. package/src/widgets/rich/stat-card.svelte +60 -0
  42. package/src/widgets/rich/timeline.svelte +95 -0
  43. package/src/widgets/rich/trombinoscope.svelte +87 -0
  44. package/src/widgets/simple/actions.svelte +36 -0
  45. package/src/widgets/simple/alert.svelte +52 -0
  46. package/src/widgets/simple/chart.svelte +38 -0
  47. package/src/widgets/simple/code.svelte +30 -0
  48. package/src/widgets/simple/kv.svelte +31 -0
  49. package/src/widgets/simple/list.svelte +35 -0
  50. package/src/widgets/simple/stat.svelte +36 -0
  51. package/src/widgets/simple/tags.svelte +34 -0
  52. package/src/widgets/simple/text.svelte +130 -0
  53. package/src/widgets/helpers/safe-image.ts +0 -78
  54. package/src/widgets/notebook/import-modals.ts +0 -560
  55. package/src/widgets/notebook/recipe-browser.ts +0 -350
  56. package/src/widgets/rich/cards.ts +0 -181
  57. package/src/widgets/rich/carousel.ts +0 -319
  58. package/src/widgets/rich/chart-rich.ts +0 -386
  59. package/src/widgets/rich/d3.ts +0 -503
  60. package/src/widgets/rich/data-table.ts +0 -342
  61. package/src/widgets/rich/gallery.ts +0 -350
  62. package/src/widgets/rich/grid-data.ts +0 -173
  63. package/src/widgets/rich/hemicycle.ts +0 -313
  64. package/src/widgets/rich/js-sandbox.ts +0 -122
  65. package/src/widgets/rich/json-viewer.ts +0 -202
  66. package/src/widgets/rich/log.ts +0 -143
  67. package/src/widgets/rich/map.ts +0 -218
  68. package/src/widgets/rich/profile.ts +0 -256
  69. package/src/widgets/rich/sankey.ts +0 -257
  70. package/src/widgets/rich/stat-card.ts +0 -125
  71. package/src/widgets/rich/timeline.ts +0 -179
  72. package/src/widgets/rich/trombinoscope.ts +0 -246
  73. package/src/widgets/simple/actions.ts +0 -89
  74. package/src/widgets/simple/alert.ts +0 -100
  75. package/src/widgets/simple/chart.ts +0 -189
  76. package/src/widgets/simple/code.ts +0 -79
  77. package/src/widgets/simple/kv.ts +0 -68
  78. package/src/widgets/simple/list.ts +0 -89
  79. package/src/widgets/simple/stat.ts +0 -58
  80. package/src/widgets/simple/tags.ts +0 -125
  81. package/src/widgets/simple/text.ts +0 -198
@@ -1,342 +0,0 @@
1
- /**
2
- * data-table — vanilla renderer for DataTable widget.
3
- *
4
- * Contract:
5
- * render(container, data) -> dispose()
6
- *
7
- * The `data` argument is the full object passed by the dispatcher:
8
- * { spec: Partial<DataTableSpec>, data?: unknown, onrowclick?: (row) => void }
9
- *
10
- * Rows resolution: spec.rows first, else top-level `data` (if array), else [].
11
- * Columns: spec.columns if non-empty, else inferred from first row's keys.
12
- *
13
- * Local state (closure): sortCol, sortAsc. Clicking a <th> toggles
14
- * null -> asc -> desc -> null (off) — matches the 3-state a11y pattern.
15
- * (Svelte version only toggled asc/desc; we extend with an "off" state so
16
- * aria-sort can correctly report "none"; set ENABLE_SORT_OFF=false to
17
- * revert to strict parity.)
18
- *
19
- * Events: on row dblclick (when onrowclick is provided OR unconditionally),
20
- * dispatches a bubbling CustomEvent 'widget:interact'
21
- * with detail { action: 'rowclick', payload: row }.
22
- * Also calls onrowclick(row) if provided (parity with Svelte).
23
- *
24
- * XSS: all values are inserted via textContent; the only exception is the
25
- * anchor href for `type: 'link'` string values (href is an attribute, not
26
- * innerHTML, so no script injection — but caller-supplied URLs are still
27
- * rendered as-is, same as the Svelte version).
28
- */
29
-
30
- export interface DataTableColumn {
31
- key: string;
32
- label: string;
33
- align?: 'left' | 'center' | 'right';
34
- type?: 'text' | 'number' | 'boolean' | 'link';
35
- }
36
-
37
- export interface DataTableSpec {
38
- title?: string;
39
- columns?: DataTableColumn[];
40
- rows?: Record<string, unknown>[];
41
- compact?: boolean;
42
- striped?: boolean;
43
- emptyMessage?: string;
44
- }
45
-
46
- interface DataTableProps {
47
- spec?: Partial<DataTableSpec>;
48
- data?: unknown;
49
- onrowclick?: (row: Record<string, unknown>) => void;
50
- }
51
-
52
- const MAX = 200;
53
- const STRICT_SVELTE_PARITY = true; // true = 2-state sort (asc/desc), false = 3-state (asc/desc/off)
54
-
55
- function dv(v: unknown): string {
56
- if (v == null) return '';
57
- if (typeof v === 'object') return JSON.stringify(v);
58
- return String(v);
59
- }
60
-
61
- function resolveRows(props: DataTableProps): Record<string, unknown>[] {
62
- const specRows = props.spec?.rows;
63
- if (Array.isArray(specRows) && specRows.length) return specRows as Record<string, unknown>[];
64
- if (Array.isArray(props.data)) return props.data as Record<string, unknown>[];
65
- return [];
66
- }
67
-
68
- function resolveColumns(spec: Partial<DataTableSpec> | undefined, rows: Record<string, unknown>[]): DataTableColumn[] {
69
- const specCols = spec?.columns;
70
- if (Array.isArray(specCols) && specCols.length) return specCols as DataTableColumn[];
71
- if (rows.length > 0) return Object.keys(rows[0] as object).map((k) => ({ key: k, label: k }));
72
- return [];
73
- }
74
-
75
- function sortRows(rows: Record<string, unknown>[], col: string | null, asc: boolean): Record<string, unknown>[] {
76
- if (!col) return rows;
77
- return [...rows].sort((a, b) => {
78
- const av = a[col];
79
- const bv = b[col];
80
- const an = Number(av);
81
- const bn = Number(bv);
82
- const c = !isNaN(an) && !isNaN(bn) ? an - bn : String(av ?? '').localeCompare(String(bv ?? ''), 'fr');
83
- return asc ? c : -c;
84
- });
85
- }
86
-
87
- function alignClass(align?: 'left' | 'center' | 'right'): string {
88
- if (align === 'center') return 'text-center';
89
- if (align === 'right') return 'text-right';
90
- return '';
91
- }
92
-
93
- export function render(container: HTMLElement, data: unknown): () => void {
94
- const props = (data ?? {}) as DataTableProps;
95
- const spec: Partial<DataTableSpec> = props.spec ?? {};
96
- const onrowclick = typeof props.onrowclick === 'function' ? props.onrowclick : undefined;
97
- const fmt = new Intl.NumberFormat('fr-FR');
98
-
99
- const rows = resolveRows(props);
100
- const columns = resolveColumns(spec, rows);
101
- const compact = spec.compact === true;
102
- const striped = spec.striped !== false;
103
-
104
- // Local sort state (closure).
105
- let sortCol: string | null = null;
106
- let sortAsc = true;
107
-
108
- // Cleanup registry.
109
- const cleanups: Array<() => void> = [];
110
- const on = <K extends keyof HTMLElementEventMap>(
111
- el: HTMLElement,
112
- type: K,
113
- handler: (ev: HTMLElementEventMap[K]) => void
114
- ) => {
115
- el.addEventListener(type, handler as EventListener);
116
- cleanups.push(() => el.removeEventListener(type, handler as EventListener));
117
- };
118
-
119
- // Root.
120
- const root = document.createElement('div');
121
- root.className = 'bg-surface border border-border rounded-lg p-3 md:p-4 font-sans';
122
-
123
- if (spec.title) {
124
- const h3 = document.createElement('h3');
125
- h3.className = 'text-sm font-semibold text-text1 mb-3';
126
- h3.textContent = spec.title;
127
- root.appendChild(h3);
128
- }
129
-
130
- // Empty state.
131
- if (columns.length === 0 && rows.length === 0) {
132
- const p = document.createElement('p');
133
- p.className = 'text-text2 text-sm';
134
- p.textContent = spec.emptyMessage ?? 'No data';
135
- root.appendChild(p);
136
- container.innerHTML = '';
137
- container.appendChild(root);
138
- return () => {
139
- cleanups.forEach((fn) => fn());
140
- cleanups.length = 0;
141
- container.innerHTML = '';
142
- };
143
- }
144
-
145
- // Scroll wrapper.
146
- const scroll = document.createElement('div');
147
- scroll.className = 'overflow-auto max-h-[480px] rounded border border-border';
148
-
149
- const table = document.createElement('table');
150
- table.className = 'w-full border-collapse';
151
-
152
- // THEAD.
153
- const thead = document.createElement('thead');
154
- const headRow = document.createElement('tr');
155
-
156
- // tbody created early so header click can re-render it.
157
- const tbody = document.createElement('tbody');
158
-
159
- function renderHeaders(): void {
160
- headRow.innerHTML = '';
161
- for (const col of columns) {
162
- const th = document.createElement('th');
163
- th.className =
164
- 'sticky top-0 bg-surface2 border-b-2 border-border2 px-3 py-2 text-left text-xs font-mono font-medium text-text2 uppercase tracking-wider whitespace-nowrap cursor-pointer select-none hover:text-text1 transition-colors ' +
165
- alignClass(col.align);
166
- th.setAttribute('role', 'button');
167
- th.setAttribute('tabindex', '0');
168
- const ariaSort = sortCol === col.key ? (sortAsc ? 'ascending' : 'descending') : 'none';
169
- th.setAttribute('aria-sort', ariaSort);
170
-
171
- const labelSpan = document.createElement('span');
172
- labelSpan.textContent = col.label;
173
- th.appendChild(labelSpan);
174
-
175
- if (sortCol === col.key) {
176
- const arrow = document.createElement('span');
177
- arrow.className = 'ml-1 text-accent';
178
- arrow.textContent = sortAsc ? '↑' : '↓';
179
- th.appendChild(arrow);
180
- }
181
-
182
- const toggle = () => {
183
- if (sortCol === col.key) {
184
- if (STRICT_SVELTE_PARITY) {
185
- sortAsc = !sortAsc;
186
- } else {
187
- if (sortAsc) {
188
- sortAsc = false;
189
- } else {
190
- sortCol = null;
191
- sortAsc = true;
192
- }
193
- }
194
- } else {
195
- sortCol = col.key;
196
- sortAsc = true;
197
- }
198
- renderHeaders();
199
- renderBody();
200
- };
201
-
202
- on(th, 'click', toggle);
203
- on(th, 'keydown', (ev) => {
204
- const ke = ev as KeyboardEvent;
205
- if (ke.key === 'Enter' || ke.key === ' ') {
206
- ke.preventDefault();
207
- toggle();
208
- }
209
- });
210
-
211
- headRow.appendChild(th);
212
- }
213
- }
214
-
215
- function renderBody(): void {
216
- // Remove previous listeners attached to tbody descendants.
217
- // We can't selectively remove; we rebuild the row listeners each time.
218
- // (Strategy: keep a per-body cleanups list.)
219
- bodyCleanups.forEach((fn) => fn());
220
- bodyCleanups.length = 0;
221
-
222
- tbody.innerHTML = '';
223
-
224
- const sorted = sortRows(rows, sortCol, sortAsc);
225
- const displayed = sorted.slice(0, MAX);
226
- const overflow = rows.length > MAX ? rows.length - MAX : 0;
227
-
228
- displayed.forEach((row, i) => {
229
- const tr = document.createElement('tr');
230
- const stripedClass = striped && i % 2 === 1 ? 'bg-white/[0.02]' : '';
231
- const cursorClass = onrowclick ? 'cursor-pointer' : '';
232
- tr.className = `hover:bg-surface2 transition-colors ${stripedClass} ${cursorClass}`.trim();
233
- if (onrowclick) tr.title = 'Double-cliquez pour interagir';
234
-
235
- const handler = () => {
236
- try {
237
- onrowclick?.(row);
238
- } catch {
239
- /* swallow */
240
- }
241
- container.dispatchEvent(
242
- new CustomEvent('widget:interact', {
243
- detail: { action: 'rowclick', payload: row },
244
- bubbles: true,
245
- })
246
- );
247
- };
248
- tr.addEventListener('dblclick', handler);
249
- bodyCleanups.push(() => tr.removeEventListener('dblclick', handler));
250
-
251
- for (const col of columns) {
252
- const td = document.createElement('td');
253
- const sizeClass = compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm';
254
- td.className =
255
- 'border-b border-border text-text1 overflow-hidden text-ellipsis whitespace-nowrap max-w-[280px] ' +
256
- sizeClass +
257
- ' ' +
258
- alignClass(col.align);
259
-
260
- const val = row[col.key];
261
-
262
- if (val == null) {
263
- const span = document.createElement('span');
264
- span.className = 'text-text2';
265
- span.textContent = '—';
266
- td.appendChild(span);
267
- } else if (col.type === 'boolean') {
268
- const span = document.createElement('span');
269
- span.textContent = val ? '✓' : '✗';
270
- td.appendChild(span);
271
- } else if (col.type === 'link' && typeof val === 'string') {
272
- const a = document.createElement('a');
273
- a.href = val;
274
- a.className = 'text-accent hover:underline';
275
- a.textContent = val;
276
- td.appendChild(a);
277
- } else if (typeof val === 'object') {
278
- const code = document.createElement('code');
279
- code.className = 'text-xs bg-surface2 px-1 py-0.5 rounded';
280
- code.textContent = JSON.stringify(val);
281
- td.appendChild(code);
282
- } else if (typeof val === 'number') {
283
- const span = document.createElement('span');
284
- span.title = String(val);
285
- span.textContent = fmt.format(val);
286
- td.appendChild(span);
287
- } else {
288
- const s = String(val);
289
- const span = document.createElement('span');
290
- span.title = s;
291
- span.textContent = s.length > 80 ? s.slice(0, 77) + '…' : s;
292
- td.appendChild(span);
293
- }
294
-
295
- tr.appendChild(td);
296
- }
297
-
298
- tbody.appendChild(tr);
299
- });
300
-
301
- if (overflow > 0) {
302
- const tr = document.createElement('tr');
303
- const td = document.createElement('td');
304
- td.colSpan = columns.length;
305
- td.className = 'text-center text-text2 text-xs py-2 px-3';
306
- td.textContent = `… ${overflow} more rows`;
307
- tr.appendChild(td);
308
- tbody.appendChild(tr);
309
- }
310
- }
311
-
312
- const bodyCleanups: Array<() => void> = [];
313
-
314
- thead.appendChild(headRow);
315
- table.appendChild(thead);
316
- table.appendChild(tbody);
317
- scroll.appendChild(table);
318
- root.appendChild(scroll);
319
-
320
- // Footer: row count.
321
- const footer = document.createElement('div');
322
- footer.className = 'mt-2 text-text2 text-xs';
323
- footer.textContent = `${rows.length} ligne${rows.length !== 1 ? 's' : ''}`;
324
- root.appendChild(footer);
325
-
326
- renderHeaders();
327
- renderBody();
328
-
329
- container.innerHTML = '';
330
- container.appendChild(root);
331
-
332
- // silence "unused" for helper
333
- void dv;
334
-
335
- return () => {
336
- cleanups.forEach((fn) => fn());
337
- cleanups.length = 0;
338
- bodyCleanups.forEach((fn) => fn());
339
- bodyCleanups.length = 0;
340
- container.innerHTML = '';
341
- };
342
- }
@@ -1,350 +0,0 @@
1
- /**
2
- * Gallery (vanilla) — grid of images with lightbox modal, prev/next nav, ESC to close.
3
- * Mirrors Gallery.svelte behavior (open on click, dblclick to dispatch imageclick,
4
- * keyboard nav in lightbox). Uses createSafeImage for URL validation + error fallback.
5
- */
6
-
7
- import { createSafeImage } from '../helpers/safe-image.js';
8
-
9
- export interface GalleryImage {
10
- src: string;
11
- alt?: string;
12
- caption?: string;
13
- href?: string;
14
- }
15
-
16
- export interface GallerySpec {
17
- title?: string;
18
- images?: GalleryImage[];
19
- columns?: number;
20
- gap?: string;
21
- emptyMessage?: string;
22
- }
23
-
24
- interface GalleryData {
25
- spec?: Partial<GallerySpec>;
26
- data?: unknown;
27
- // Back-compat: allow passing a bare array or bare spec.
28
- [k: string]: unknown;
29
- }
30
-
31
- function resolveImages(spec: Partial<GallerySpec>, data: unknown): GalleryImage[] {
32
- if (Array.isArray(spec.images) && spec.images.length) return spec.images;
33
- if (Array.isArray(data)) return data as GalleryImage[];
34
- return [];
35
- }
36
-
37
- function resolveSpec(input: unknown): { spec: Partial<GallerySpec>; data: unknown } {
38
- if (!input || typeof input !== 'object') return { spec: {}, data: undefined };
39
- const obj = input as GalleryData;
40
- // If object has spec/data shape, use it.
41
- if ('spec' in obj || 'data' in obj) {
42
- return { spec: (obj.spec as Partial<GallerySpec>) ?? {}, data: obj.data };
43
- }
44
- // If it's a bare array, treat as data.
45
- if (Array.isArray(input)) return { spec: {}, data: input };
46
- // Else treat the object itself as the spec.
47
- return { spec: obj as Partial<GallerySpec>, data: undefined };
48
- }
49
-
50
- function dispatchInteract(container: HTMLElement, image: GalleryImage, index: number): void {
51
- container.dispatchEvent(
52
- new CustomEvent('widget:interact', {
53
- detail: { action: 'imageclick', payload: { image, index } },
54
- bubbles: true,
55
- }),
56
- );
57
- }
58
-
59
- export function render(container: HTMLElement, data: any): () => void {
60
- const { spec, data: rawData } = resolveSpec(data);
61
- const images = resolveImages(spec, rawData);
62
-
63
- // --- Cleanup bookkeeping -------------------------------------------------
64
- const cleanups: Array<() => void> = [];
65
- let lightboxEl: HTMLElement | null = null;
66
- let lightboxIdx: number | null = null;
67
- let previouslyFocused: HTMLElement | null = null;
68
-
69
- // Root
70
- container.innerHTML = '';
71
- const root = document.createElement('div');
72
- root.className = 'bg-surface border border-border rounded-lg p-3 md:p-4 font-sans';
73
- container.appendChild(root);
74
-
75
- // Title
76
- if (spec.title) {
77
- const h = document.createElement('h3');
78
- h.className = 'text-sm font-semibold text-text1 mb-3';
79
- h.textContent = spec.title;
80
- root.appendChild(h);
81
- }
82
-
83
- // --- Lightbox ------------------------------------------------------------
84
- function closeLightbox(): void {
85
- if (!lightboxEl) return;
86
- lightboxEl.remove();
87
- lightboxEl = null;
88
- lightboxIdx = null;
89
- if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
90
- try {
91
- previouslyFocused.focus();
92
- } catch {
93
- /* no-op */
94
- }
95
- }
96
- previouslyFocused = null;
97
- }
98
-
99
- function renderLightboxContent(): void {
100
- if (!lightboxEl || lightboxIdx === null) return;
101
- const img = images[lightboxIdx];
102
- if (!img) return;
103
-
104
- const inner = lightboxEl.querySelector('[data-lightbox-inner]') as HTMLElement | null;
105
- if (!inner) return;
106
- inner.innerHTML = '';
107
-
108
- const safe = createSafeImage({
109
- src: img.src,
110
- alt: img.alt ?? '',
111
- className: 'max-w-full max-h-[85vh] object-contain rounded',
112
- loading: 'eager',
113
- });
114
- inner.appendChild(safe);
115
-
116
- if (img.caption) {
117
- const cap = document.createElement('div');
118
- cap.className = 'text-center text-white text-sm mt-2';
119
- cap.textContent = img.caption;
120
- inner.appendChild(cap);
121
- }
122
-
123
- // Close button
124
- const closeBtn = document.createElement('button');
125
- closeBtn.type = 'button';
126
- closeBtn.setAttribute('aria-label', 'Fermer');
127
- closeBtn.className =
128
- 'absolute top-2 right-2 w-8 h-8 rounded-full bg-black/50 text-white flex items-center justify-center text-lg hover:bg-black/70';
129
- closeBtn.innerHTML = '&times;';
130
- closeBtn.addEventListener('click', (e) => {
131
- e.stopPropagation();
132
- closeLightbox();
133
- });
134
- inner.appendChild(closeBtn);
135
-
136
- // Prev
137
- if (lightboxIdx > 0) {
138
- const prev = document.createElement('button');
139
- prev.type = 'button';
140
- prev.setAttribute('aria-label', 'Image précédente');
141
- prev.className =
142
- 'absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70';
143
- prev.innerHTML = '&lsaquo;';
144
- prev.addEventListener('click', (e) => {
145
- e.stopPropagation();
146
- goPrev();
147
- });
148
- inner.appendChild(prev);
149
- }
150
-
151
- // Next
152
- if (lightboxIdx < images.length - 1) {
153
- const next = document.createElement('button');
154
- next.type = 'button';
155
- next.setAttribute('aria-label', 'Image suivante');
156
- next.className =
157
- 'absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70';
158
- next.innerHTML = '&rsaquo;';
159
- next.addEventListener('click', (e) => {
160
- e.stopPropagation();
161
- goNext();
162
- });
163
- inner.appendChild(next);
164
- }
165
-
166
- // Focus close for a11y
167
- try {
168
- closeBtn.focus();
169
- } catch {
170
- /* no-op */
171
- }
172
- }
173
-
174
- function goPrev(): void {
175
- if (lightboxIdx !== null && lightboxIdx > 0) {
176
- lightboxIdx--;
177
- renderLightboxContent();
178
- }
179
- }
180
- function goNext(): void {
181
- if (lightboxIdx !== null && lightboxIdx < images.length - 1) {
182
- lightboxIdx++;
183
- renderLightboxContent();
184
- }
185
- }
186
-
187
- function openLightbox(i: number): void {
188
- if (i < 0 || i >= images.length) return;
189
- if (lightboxEl) closeLightbox();
190
- previouslyFocused = (document.activeElement as HTMLElement) ?? null;
191
- lightboxIdx = i;
192
-
193
- const overlay = document.createElement('div');
194
- overlay.className = 'fixed inset-0 z-50 bg-black/80 flex items-center justify-center';
195
- overlay.setAttribute('role', 'dialog');
196
- overlay.setAttribute('aria-modal', 'true');
197
- overlay.setAttribute('aria-label', 'Image en grand');
198
- overlay.addEventListener('click', closeLightbox);
199
-
200
- const inner = document.createElement('div');
201
- inner.className = 'relative max-w-[90vw] max-h-[90vh]';
202
- inner.setAttribute('data-lightbox-inner', '');
203
- inner.addEventListener('click', (e) => e.stopPropagation());
204
-
205
- overlay.appendChild(inner);
206
- document.body.appendChild(overlay);
207
- lightboxEl = overlay;
208
-
209
- renderLightboxContent();
210
- }
211
-
212
- // Global keydown — ESC / arrows when lightbox open.
213
- function onKey(e: KeyboardEvent): void {
214
- if (lightboxIdx === null) return;
215
- if (e.key === 'Escape') {
216
- e.preventDefault();
217
- closeLightbox();
218
- } else if (e.key === 'ArrowLeft') {
219
- e.preventDefault();
220
- goPrev();
221
- } else if (e.key === 'ArrowRight') {
222
- e.preventDefault();
223
- goNext();
224
- } else if (e.key === 'Tab') {
225
- // Simple focus trap: keep focus inside the lightbox overlay.
226
- if (!lightboxEl) return;
227
- const focusables = lightboxEl.querySelectorAll<HTMLElement>(
228
- 'button, [href], [tabindex]:not([tabindex="-1"])',
229
- );
230
- if (!focusables.length) return;
231
- const first = focusables[0];
232
- const last = focusables[focusables.length - 1];
233
- const active = document.activeElement as HTMLElement | null;
234
- if (e.shiftKey && active === first) {
235
- e.preventDefault();
236
- last.focus();
237
- } else if (!e.shiftKey && active === last) {
238
- e.preventDefault();
239
- first.focus();
240
- }
241
- }
242
- }
243
- window.addEventListener('keydown', onKey);
244
- cleanups.push(() => window.removeEventListener('keydown', onKey));
245
-
246
- // --- Body ----------------------------------------------------------------
247
- if (images.length === 0) {
248
- const p = document.createElement('p');
249
- p.className = 'text-text2 text-sm';
250
- p.textContent = spec.emptyMessage ?? 'Aucune image';
251
- root.appendChild(p);
252
- } else if (images.length === 1) {
253
- const img = images[0];
254
- const btn = document.createElement('button');
255
- btn.type = 'button';
256
- btn.className =
257
- 'relative overflow-hidden rounded-lg border border-border hover:border-border2 transition-all cursor-pointer bg-transparent p-0 group w-full';
258
- btn.addEventListener('click', () => openLightbox(0));
259
- btn.addEventListener('dblclick', () => dispatchInteract(container, img, 0));
260
-
261
- const safe = createSafeImage({
262
- src: img.src,
263
- alt: img.alt ?? '',
264
- className: 'w-full max-h-[400px] object-contain rounded-lg',
265
- loading: 'eager',
266
- });
267
- btn.appendChild(safe);
268
-
269
- if (img.caption || img.alt) {
270
- const cap = document.createElement('div');
271
- cap.className = 'mt-2 text-center text-xs text-text2';
272
- cap.textContent = img.caption ?? img.alt ?? '';
273
- btn.appendChild(cap);
274
- }
275
- root.appendChild(btn);
276
- } else {
277
- const cols = spec.columns ?? 3;
278
- const grid = document.createElement('div');
279
- grid.className = 'grid gap-2 responsive-gallery';
280
- grid.setAttribute('role', 'list');
281
- grid.setAttribute('style', `--gallery-cols: repeat(${cols}, minmax(0, 1fr));`);
282
- // Inline the responsive rule as a <style> scope is awkward in vanilla;
283
- // we rely on the .responsive-gallery global rule from the Svelte version
284
- // being present, but also set a sensible default inline for robustness.
285
- // Fallback default (mobile): 2 columns.
286
- // The matching CSS (below) ensures desktop uses --gallery-cols.
287
- grid.style.gridTemplateColumns = 'repeat(2, minmax(0, 1fr))';
288
-
289
- images.forEach((img, i) => {
290
- const btn = document.createElement('button');
291
- btn.type = 'button';
292
- btn.setAttribute('role', 'listitem');
293
- btn.className =
294
- 'relative overflow-hidden rounded-lg border border-border hover:border-border2 transition-all cursor-pointer bg-transparent p-0 group';
295
- btn.title = 'Double-cliquez pour interagir';
296
- btn.addEventListener('click', () => openLightbox(i));
297
- btn.addEventListener('dblclick', () => dispatchInteract(container, img, i));
298
-
299
- const safe = createSafeImage({
300
- src: img.src,
301
- alt: img.alt ?? '',
302
- className: 'w-full h-32 sm:h-40 object-cover transition-transform group-hover:scale-105',
303
- loading: 'lazy',
304
- });
305
- btn.appendChild(safe);
306
-
307
- if (img.caption) {
308
- const overlay = document.createElement('div');
309
- overlay.className =
310
- 'absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5';
311
- const span = document.createElement('span');
312
- span.className = 'text-white text-xs';
313
- span.textContent = img.caption;
314
- overlay.appendChild(span);
315
- btn.appendChild(overlay);
316
- }
317
-
318
- grid.appendChild(btn);
319
- });
320
- root.appendChild(grid);
321
-
322
- // Media query for desktop columns: inject once per render (scoped via data-attr).
323
- const styleId = 'autoui-gallery-responsive-style';
324
- if (!document.getElementById(styleId)) {
325
- const style = document.createElement('style');
326
- style.id = styleId;
327
- style.textContent =
328
- '@media (min-width: 768px){.responsive-gallery{grid-template-columns: var(--gallery-cols) !important;}}';
329
- document.head.appendChild(style);
330
- }
331
-
332
- const count = document.createElement('div');
333
- count.className = 'mt-2 text-xs text-text2';
334
- count.textContent = `${images.length} image${images.length !== 1 ? 's' : ''}`;
335
- root.appendChild(count);
336
- }
337
-
338
- // --- Cleanup -------------------------------------------------------------
339
- return () => {
340
- closeLightbox();
341
- for (const fn of cleanups) {
342
- try {
343
- fn();
344
- } catch {
345
- /* no-op */
346
- }
347
- }
348
- container.innerHTML = '';
349
- };
350
- }