@gradio/gallery 0.17.5 → 0.17.6

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @gradio/gallery
2
2
 
3
+ ## 0.17.6
4
+
5
+ ### Features
6
+
7
+ - [#13213](https://github.com/gradio-app/gradio/pull/13213) [`457f130`](https://github.com/gradio-app/gradio/commit/457f130b310ac087fdc6ba7dbbc7fa78e0bcccf8) - Gallery Unit Tests. Thanks @freddyaboulton!
8
+
9
+ ### Dependency updates
10
+
11
+ - @gradio/atoms@0.23.1
12
+ - @gradio/statustracker@0.14.0
13
+ - @gradio/client@2.2.0
14
+ - @gradio/image@0.26.2
15
+ - @gradio/file@0.14.7
16
+ - @gradio/video@0.20.7
17
+
3
18
  ## 0.17.5
4
19
 
5
20
  ### Fixes
package/Gallery.test.ts CHANGED
@@ -1,147 +1,1017 @@
1
- import { test, describe, assert, afterEach, vi, beforeEach } from "vitest";
2
- import { cleanup, render } from "@self/tootils/render";
3
- import { setupi18n } from "../core/src/i18n";
1
+ import { test, describe, afterEach, expect, vi } from "vitest";
2
+ import { cleanup, render, fireEvent, waitFor } from "@self/tootils/render";
3
+ import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
4
4
 
5
5
  import Gallery from "./Index.svelte";
6
- import type { LoadingStatus } from "@gradio/statustracker";
7
-
8
- const loading_status: LoadingStatus = {
9
- eta: 0,
10
- queue_position: 1,
11
- queue_size: 1,
12
- status: "complete" as LoadingStatus["status"],
13
- scroll_to_output: false,
14
- visible: true,
15
- fn_index: 0,
16
- show_progress: "full"
6
+
7
+ const fake_image = (id: string) => ({
8
+ path: `${id}.png`,
9
+ url: `https://example.com/${id}.png`,
10
+ orig_name: `${id}.png`,
11
+ size: 1024,
12
+ mime_type: "image/png",
13
+ is_stream: false
14
+ });
15
+
16
+ const fake_video = (id: string) => ({
17
+ path: `${id}.mp4`,
18
+ url: `https://example.com/${id}.mp4`,
19
+ orig_name: `${id}.mp4`,
20
+ size: 2048,
21
+ mime_type: "video/mp4",
22
+ is_stream: false
23
+ });
24
+
25
+ const img = (id: string, caption: string | null = null) => ({
26
+ image: fake_image(id),
27
+ caption
28
+ });
29
+
30
+ const vid = (id: string, caption: string | null = null) => ({
31
+ video: fake_video(id),
32
+ caption
33
+ });
34
+
35
+ const default_props = {
36
+ label: "Gallery",
37
+ show_label: true,
38
+ preview: false,
39
+ allow_preview: true,
40
+ selected_index: null as number | null,
41
+ object_fit: "cover" as const,
42
+ buttons: ["fullscreen"] as (
43
+ | string
44
+ | { value: string; id: number; icon: null }
45
+ )[],
46
+ value: null as any,
47
+ columns: 2,
48
+ rows: undefined as number | undefined,
49
+ height: "auto",
50
+ fit_columns: true,
51
+ interactive: false,
52
+ sources: ["upload"] as ("upload" | "webcam" | "clipboard")[]
17
53
  };
18
54
 
55
+ const three_images = [img("img1"), img("img2"), img("img3")];
56
+
57
+ run_shared_prop_tests({
58
+ component: Gallery,
59
+ name: "Gallery",
60
+ base_props: {
61
+ ...default_props
62
+ },
63
+ has_label: false
64
+ });
65
+
66
+ describe("Props: label", () => {
67
+ afterEach(() => cleanup());
68
+
69
+ test("label text is rendered when show_label=true", async () => {
70
+ const { getByText } = await render(Gallery, {
71
+ ...default_props,
72
+ label: "My Gallery",
73
+ show_label: true,
74
+ value: three_images
75
+ });
76
+
77
+ expect(getByText("My Gallery")).toBeVisible();
78
+ });
79
+
80
+ test("show_label=false removes label from DOM entirely", async () => {
81
+ const { queryByText } = await render(Gallery, {
82
+ ...default_props,
83
+ label: "My Gallery",
84
+ show_label: false,
85
+ value: three_images
86
+ });
87
+
88
+ expect(queryByText("My Gallery")).not.toBeInTheDocument();
89
+ });
90
+ });
91
+
19
92
  describe("Gallery", () => {
20
- afterEach(() => {
21
- cleanup();
22
- vi.useRealTimers();
93
+ afterEach(() => cleanup());
94
+
95
+ test("renders empty state when value is null", async () => {
96
+ const { container } = await render(Gallery, {
97
+ ...default_props,
98
+ value: null
99
+ });
100
+
101
+ // Empty gallery shows an SVG icon placeholder, no thumbnails
102
+ const thumbnails = container.querySelectorAll("[aria-label^='Thumbnail']");
103
+ expect(thumbnails.length).toBe(0);
104
+ });
105
+
106
+ test("renders empty state when value is empty array", async () => {
107
+ const { container } = await render(Gallery, {
108
+ ...default_props,
109
+ value: []
110
+ });
111
+
112
+ const thumbnails = container.querySelectorAll("[aria-label^='Thumbnail']");
113
+ expect(thumbnails.length).toBe(0);
114
+ });
115
+
116
+ test("renders image thumbnails in grid", async () => {
117
+ const { getByRole } = await render(Gallery, {
118
+ ...default_props,
119
+ value: three_images
120
+ });
121
+
122
+ expect(getByRole("button", { name: "Thumbnail 1 of 3" })).toBeVisible();
123
+ expect(getByRole("button", { name: "Thumbnail 2 of 3" })).toBeVisible();
124
+ expect(getByRole("button", { name: "Thumbnail 3 of 3" })).toBeVisible();
125
+ });
126
+
127
+ test("renders video thumbnails in grid", async () => {
128
+ const { getByRole } = await render(Gallery, {
129
+ ...default_props,
130
+ value: [vid("clip1"), vid("clip2")]
131
+ });
132
+
133
+ expect(getByRole("button", { name: "Thumbnail 1 of 2" })).toBeVisible();
134
+ expect(getByRole("button", { name: "Thumbnail 2 of 2" })).toBeVisible();
135
+ });
136
+
137
+ test("renders captions on thumbnails", async () => {
138
+ const { getByText } = await render(Gallery, {
139
+ ...default_props,
140
+ value: [img("cat", "A cute cat"), img("dog", "A good dog")]
141
+ });
142
+
143
+ expect(getByText("A cute cat")).toBeVisible();
144
+ expect(getByText("A good dog")).toBeVisible();
23
145
  });
24
- beforeEach(() => {
25
- setupi18n();
146
+
147
+ test("mixed images and videos render correctly", async () => {
148
+ const { getByRole } = await render(Gallery, {
149
+ ...default_props,
150
+ value: [img("photo"), vid("clip"), img("photo2")]
151
+ });
152
+
153
+ expect(getByRole("button", { name: "Thumbnail 1 of 3" })).toBeVisible();
154
+ expect(getByRole("button", { name: "Thumbnail 2 of 3" })).toBeVisible();
155
+ expect(getByRole("button", { name: "Thumbnail 3 of 3" })).toBeVisible();
26
156
  });
27
- test("renders the image provided", async () => {
157
+ });
158
+
159
+ describe("Props: preview / allow_preview", () => {
160
+ afterEach(() => cleanup());
161
+
162
+ test("preview=true with selected_index shows detailed view", async () => {
163
+ // Gallery.svelte auto-selects index 0 when preview=true and
164
+ // selected_index is null, but the Gradio prop framework doesn't
165
+ // propagate the internal binding back synchronously. Test the
166
+ // explicit selected_index=0 case which is the effective behavior.
28
167
  const { getByTestId } = await render(Gallery, {
29
- show_label: true,
30
- label: "Gallery",
31
- loading_status: loading_status,
168
+ ...default_props,
32
169
  preview: true,
33
- buttons: ["share", "download", "fullscreen"],
34
170
  selected_index: 0,
35
- value: [
36
- {
37
- image: {
38
- path: "https://raw.githubusercontent.com/gradio-app/gradio/main/gradio/demo/gallery_component/files/cheetah.jpg",
39
- url: "https://raw.githubusercontent.com/gradio-app/gradio/main/gradio/demo/gallery_component/files/cheetah.jpg"
40
- },
41
- caption: null
42
- }
43
- ]
44
- });
45
- let item = getByTestId("detailed-image") as HTMLImageElement;
46
- assert.equal(
47
- item.src,
48
- "https://raw.githubusercontent.com/gradio-app/gradio/main/gradio/demo/gallery_component/files/cheetah.jpg"
49
- );
171
+ value: [img("cat"), img("dog")]
172
+ });
173
+
174
+ const detail = getByTestId("detailed-image") as HTMLImageElement;
175
+ expect(detail.src).toBe("https://example.com/cat.png");
176
+ });
177
+
178
+ test("preview=false does not show detailed view", async () => {
179
+ const { queryByTestId } = await render(Gallery, {
180
+ ...default_props,
181
+ preview: false,
182
+ value: three_images
183
+ });
184
+
185
+ expect(queryByTestId("detailed-image")).not.toBeInTheDocument();
186
+ });
187
+
188
+ test("allow_preview=false hides preview even when selected_index is set", async () => {
189
+ const { queryByTestId } = await render(Gallery, {
190
+ ...default_props,
191
+ allow_preview: false,
192
+ selected_index: 0,
193
+ value: three_images
194
+ });
195
+
196
+ expect(queryByTestId("detailed-image")).not.toBeInTheDocument();
197
+ });
198
+
199
+ test("allow_preview=true shows preview when thumbnail clicked", async () => {
200
+ const { getByRole, getByTestId } = await render(Gallery, {
201
+ ...default_props,
202
+ allow_preview: true,
203
+ preview: false,
204
+ value: three_images
205
+ });
206
+
207
+ await fireEvent.click(getByRole("button", { name: "Thumbnail 2 of 3" }));
208
+
209
+ const detail = getByTestId("detailed-image") as HTMLImageElement;
210
+ expect(detail.src).toBe("https://example.com/img2.png");
50
211
  });
51
212
 
52
- test("renders the video provided", async () => {
213
+ test("renders the video provided in preview", async () => {
53
214
  const { getByTestId } = await render(Gallery, {
54
- show_label: true,
55
- label: "Gallery",
56
- loading_status: loading_status,
215
+ ...default_props,
57
216
  preview: true,
58
- buttons: ["share", "download", "fullscreen"],
59
217
  selected_index: 0,
60
- value: [
61
- {
62
- video: {
63
- path: "https://raw.githubusercontent.com/gradio-app/gradio/main/gradio/demo/gallery_component/files/world.mp4",
64
- url: "https://raw.githubusercontent.com/gradio-app/gradio/main/gradio/demo/gallery_component/files/world.mp4"
65
- },
66
- caption: null
67
- }
68
- ]
69
- });
70
- let item = getByTestId("detailed-video") as HTMLVideoElement;
71
- assert.equal(
72
- item.src,
73
- "https://raw.githubusercontent.com/gradio-app/gradio/main/gradio/demo/gallery_component/files/world.mp4"
74
- );
218
+ value: [vid("clip")]
219
+ });
220
+
221
+ const item = getByTestId("detailed-video") as HTMLVideoElement;
222
+ expect(item.src).toBe("https://example.com/clip.mp4");
75
223
  });
224
+ });
76
225
 
77
- test.skip("triggers the change event if and only if the images change", async () => {
78
- // TODO: Fix this test, the test requires prop update using $set which is deprecated in Svelte 5.
79
- const { listen, component } = await render(Gallery, {
80
- show_label: true,
81
- label: "Gallery",
82
- loading_status: loading_status,
226
+ describe("Props: selected_index", () => {
227
+ afterEach(() => cleanup());
228
+
229
+ test("selected_index shows the correct image in preview", async () => {
230
+ const { getByTestId } = await render(Gallery, {
231
+ ...default_props,
83
232
  preview: true,
84
- value: [
85
- {
86
- image: {
87
- path: "https://raw.githubusercontent.com/gradio-app/gradio/main/gradio/demo/gallery_component/files/cheetah.jpg",
88
- url: "https://raw.githubusercontent.com/gradio-app/gradio/main/gradio/demo/gallery_component/files/cheetah.jpg"
89
- },
90
- caption: null
91
- }
92
- ]
93
- });
94
- const change_event = listen("change");
95
-
96
- await component.$set({
97
- value: [
98
- {
99
- image: {
100
- path: "https://raw.githubusercontent.com/gradio-app/gradio/main/gradio/demo/gallery_component/files/cheetah.jpg",
101
- url: "https://raw.githubusercontent.com/gradio-app/gradio/main/gradio/demo/gallery_component/files/cheetah.jpg"
102
- },
103
- caption: null
104
- }
105
- ]
106
- });
107
- assert.equal(change_event.callCount, 0);
108
- });
109
-
110
- test.skip("triggers preview_close event when pressing Escape key", async () => {
111
- const { listen, getByTestId } = await render(Gallery, {
112
- show_label: true,
113
- label: "Gallery",
114
- loading_status: loading_status,
233
+ selected_index: 1,
234
+ allow_preview: true,
235
+ value: three_images
236
+ });
237
+
238
+ const detail = getByTestId("detailed-image") as HTMLImageElement;
239
+ expect(detail.src).toBe("https://example.com/img2.png");
240
+ });
241
+
242
+ test("selected_index=null shows no preview", async () => {
243
+ const { queryByTestId } = await render(Gallery, {
244
+ ...default_props,
245
+ preview: false,
246
+ selected_index: null,
247
+ value: three_images
248
+ });
249
+
250
+ expect(queryByTestId("detailed-image")).not.toBeInTheDocument();
251
+ });
252
+
253
+ test("changing selected_index via set_data updates preview", async () => {
254
+ const { getByTestId, set_data } = await render(Gallery, {
255
+ ...default_props,
115
256
  preview: true,
116
- buttons: ["share", "download", "fullscreen"],
117
257
  selected_index: 0,
118
- value: [
119
- {
120
- image: {
121
- path: "https://raw.githubusercontent.com/gradio-app/gradio/main/gradio/demo/gallery_component/files/cheetah.jpg",
122
- url: "https://raw.githubusercontent.com/gradio-app/gradio/main/gradio/demo/gallery_component/files/cheetah.jpg"
123
- },
124
- caption: null
125
- }
126
- ]
127
- });
128
-
129
- const preview_close_event = listen("preview_close");
130
-
131
- // Find the preview container and dispatch Escape key event
132
- const preview = document.querySelector(".preview");
133
- if (preview) {
134
- const escapeEvent = new KeyboardEvent("keydown", {
135
- code: "Escape",
136
- key: "Escape",
137
- bubbles: true
138
- });
139
- preview.dispatchEvent(escapeEvent);
258
+ value: three_images
259
+ });
260
+
261
+ await set_data({ selected_index: 2 });
262
+
263
+ const detail = getByTestId("detailed-image") as HTMLImageElement;
264
+ expect(detail.src).toBe("https://example.com/img3.png");
265
+ });
266
+ });
267
+
268
+ describe("Props: columns", () => {
269
+ afterEach(() => cleanup());
270
+
271
+ test("columns=3 sets grid to 3 columns", async () => {
272
+ const { container } = await render(Gallery, {
273
+ ...default_props,
274
+ columns: 3,
275
+ fit_columns: false,
276
+ value: three_images
277
+ });
278
+
279
+ // No testid or role available on the grid-container div; querySelector is the only option
280
+ const grid = container.querySelector(".grid-container") as HTMLElement;
281
+ expect(grid).toBeInTheDocument();
282
+ expect(grid.style.getPropertyValue("--grid-cols")).toBe("3");
283
+ });
284
+
285
+ test("columns=2 with 5 images renders all 5 items in a 2-column grid", async () => {
286
+ const five_images = Array.from({ length: 5 }, (_, i) => img(`img${i + 1}`));
287
+
288
+ const { container, getByRole } = await render(Gallery, {
289
+ ...default_props,
290
+ columns: 2,
291
+ fit_columns: false,
292
+ value: five_images
293
+ });
294
+
295
+ // All 5 thumbnails are rendered
296
+ for (let i = 1; i <= 5; i++) {
297
+ expect(
298
+ getByRole("button", { name: `Thumbnail ${i} of 5` })
299
+ ).toBeVisible();
140
300
  }
141
301
 
142
- assert.isTrue(
143
- preview_close_event.callCount >= 1,
144
- "preview_close event should be triggered when pressing Escape"
302
+ // Grid is set to 2 columns (wrapping after 2)
303
+ const grid = container.querySelector(".grid-container") as HTMLElement;
304
+ expect(grid.style.getPropertyValue("--grid-cols")).toBe("2");
305
+ });
306
+
307
+ test("columns as array sets responsive breakpoints", async () => {
308
+ const { container } = await render(Gallery, {
309
+ ...default_props,
310
+ columns: [1, 2, 3],
311
+ fit_columns: false,
312
+ value: three_images
313
+ });
314
+
315
+ const grid = container.querySelector(".grid-container") as HTMLElement;
316
+ expect(grid.style.getPropertyValue("--grid-cols")).toBe("1,2,3");
317
+ });
318
+ });
319
+
320
+ describe("Props: fit_columns", () => {
321
+ afterEach(() => cleanup());
322
+
323
+ test("fit_columns=true with 1 image and columns=3 reduces to 1 column", async () => {
324
+ const { container } = await render(Gallery, {
325
+ ...default_props,
326
+ columns: 3,
327
+ fit_columns: true,
328
+ value: [img("only")]
329
+ });
330
+
331
+ const grid = container.querySelector(".grid-container") as HTMLElement;
332
+ expect(grid.style.getPropertyValue("--grid-cols")).toBe("1");
333
+ });
334
+
335
+ test("fit_columns=false with 1 image and columns=3 keeps 3 columns", async () => {
336
+ const { container } = await render(Gallery, {
337
+ ...default_props,
338
+ columns: 3,
339
+ fit_columns: false,
340
+ value: [img("only")]
341
+ });
342
+
343
+ const grid = container.querySelector(".grid-container") as HTMLElement;
344
+ expect(grid.style.getPropertyValue("--grid-cols")).toBe("3");
345
+ });
346
+
347
+ test("fit_columns=true with items equal to columns keeps columns unchanged", async () => {
348
+ const { container } = await render(Gallery, {
349
+ ...default_props,
350
+ columns: 3,
351
+ fit_columns: true,
352
+ value: three_images
353
+ });
354
+
355
+ const grid = container.querySelector(".grid-container") as HTMLElement;
356
+ expect(grid.style.getPropertyValue("--grid-cols")).toBe("3");
357
+ });
358
+ });
359
+
360
+ describe("Props: buttons", () => {
361
+ afterEach(() => cleanup());
362
+
363
+ const preview_props = {
364
+ ...default_props,
365
+ preview: true,
366
+ selected_index: 0,
367
+ value: [img("cat")]
368
+ };
369
+
370
+ test("buttons=['download'] shows download button in preview", async () => {
371
+ const { getByRole } = await render(Gallery, {
372
+ ...preview_props,
373
+ buttons: ["download"]
374
+ });
375
+
376
+ expect(getByRole("button", { name: "common.download" })).toBeVisible();
377
+ });
378
+
379
+ test("buttons=['fullscreen'] shows fullscreen button in preview", async () => {
380
+ const { getByLabelText } = await render(Gallery, {
381
+ ...preview_props,
382
+ buttons: ["fullscreen"]
383
+ });
384
+
385
+ expect(getByLabelText("Fullscreen")).toBeVisible();
386
+ });
387
+
388
+ test("buttons=[] shows only close button in preview", async () => {
389
+ const { queryByRole, queryByLabelText, getByRole } = await render(Gallery, {
390
+ ...preview_props,
391
+ buttons: []
392
+ });
393
+
394
+ expect(getByRole("button", { name: "Close" })).toBeVisible();
395
+ expect(
396
+ queryByRole("button", { name: "common.download" })
397
+ ).not.toBeInTheDocument();
398
+ expect(queryByLabelText("Fullscreen")).not.toBeInTheDocument();
399
+ });
400
+
401
+ test("custom button renders and dispatches custom_button_click", async () => {
402
+ const { listen, getByLabelText } = await render(Gallery, {
403
+ ...preview_props,
404
+ buttons: [{ value: "Analyze", id: 7, icon: null }]
405
+ });
406
+
407
+ const custom = listen("custom_button_click");
408
+ const btn = getByLabelText("Analyze");
409
+ await fireEvent.click(btn);
410
+
411
+ expect(custom).toHaveBeenCalledTimes(1);
412
+ expect(custom).toHaveBeenCalledWith({ id: 7 });
413
+ });
414
+ });
415
+
416
+ describe("Props: interactive", () => {
417
+ afterEach(() => cleanup());
418
+
419
+ test("interactive=true shows delete buttons on gallery items", async () => {
420
+ const { getAllByRole } = await render(Gallery, {
421
+ ...default_props,
422
+ interactive: true,
423
+ value: three_images
424
+ });
425
+
426
+ const deleteButtons = getAllByRole("button", { name: "Delete image" });
427
+ expect(deleteButtons.length).toBe(3);
428
+ });
429
+
430
+ test("interactive=false hides delete buttons", async () => {
431
+ const { queryByRole } = await render(Gallery, {
432
+ ...default_props,
433
+ interactive: false,
434
+ value: three_images
435
+ });
436
+
437
+ expect(
438
+ queryByRole("button", { name: "Delete image" })
439
+ ).not.toBeInTheDocument();
440
+ });
441
+
442
+ test("interactive=true with no value shows upload area", async () => {
443
+ const { getByTestId } = await render(Gallery, {
444
+ ...default_props,
445
+ interactive: true,
446
+ value: null
447
+ });
448
+
449
+ // The file input itself is display:none (the visible area is a styled overlay),
450
+ // so we check it exists in the DOM rather than visibility.
451
+ expect(getByTestId("file-upload")).toBeInTheDocument();
452
+ });
453
+
454
+ test("interactive=false with no value does not show upload area", async () => {
455
+ const { queryByTestId } = await render(Gallery, {
456
+ ...default_props,
457
+ interactive: false,
458
+ value: null
459
+ });
460
+
461
+ expect(queryByTestId("file-upload")).not.toBeInTheDocument();
462
+ });
463
+ });
464
+
465
+ describe("Props: sources", () => {
466
+ afterEach(() => cleanup());
467
+
468
+ const sources_props = {
469
+ ...default_props,
470
+ interactive: true,
471
+ selected_index: null as number | null,
472
+ value: three_images
473
+ };
474
+
475
+ test("sources=['upload'] shows only upload button", async () => {
476
+ const { getByLabelText, queryByLabelText } = await render(Gallery, {
477
+ ...sources_props,
478
+ sources: ["upload"]
479
+ });
480
+
481
+ expect(getByLabelText("upload_text.click_to_upload")).toBeVisible();
482
+ expect(queryByLabelText("common.webcam")).not.toBeInTheDocument();
483
+ expect(
484
+ queryByLabelText("upload_text.paste_clipboard")
485
+ ).not.toBeInTheDocument();
486
+ });
487
+
488
+ test("sources=['upload', 'webcam'] shows upload and webcam buttons", async () => {
489
+ const { getByLabelText } = await render(Gallery, {
490
+ ...sources_props,
491
+ sources: ["upload", "webcam"]
492
+ });
493
+
494
+ expect(getByLabelText("upload_text.click_to_upload")).toBeVisible();
495
+ expect(getByLabelText("common.webcam")).toBeVisible();
496
+ });
497
+
498
+ test("sources=['upload', 'clipboard'] shows upload and clipboard buttons", async () => {
499
+ const { getByLabelText } = await render(Gallery, {
500
+ ...sources_props,
501
+ sources: ["upload", "clipboard"]
502
+ });
503
+
504
+ expect(getByLabelText("upload_text.click_to_upload")).toBeVisible();
505
+ expect(getByLabelText("upload_text.paste_clipboard")).toBeVisible();
506
+ });
507
+
508
+ test("sources=['upload', 'webcam', 'clipboard'] shows all source buttons", async () => {
509
+ const { getByLabelText } = await render(Gallery, {
510
+ ...sources_props,
511
+ sources: ["upload", "webcam", "clipboard"]
512
+ });
513
+
514
+ expect(getByLabelText("upload_text.click_to_upload")).toBeVisible();
515
+ expect(getByLabelText("common.webcam")).toBeVisible();
516
+ expect(getByLabelText("upload_text.paste_clipboard")).toBeVisible();
517
+ });
518
+
519
+ test("source buttons are hidden when selected_index is set (preview active)", async () => {
520
+ const { queryByLabelText } = await render(Gallery, {
521
+ ...sources_props,
522
+ sources: ["upload", "webcam", "clipboard"],
523
+ selected_index: 0,
524
+ preview: true
525
+ });
526
+
527
+ expect(queryByLabelText("common.webcam")).not.toBeInTheDocument();
528
+ expect(
529
+ queryByLabelText("upload_text.paste_clipboard")
530
+ ).not.toBeInTheDocument();
531
+ });
532
+
533
+ test("source buttons are hidden when interactive=false", async () => {
534
+ const { queryByLabelText } = await render(Gallery, {
535
+ ...sources_props,
536
+ interactive: false,
537
+ sources: ["upload", "webcam", "clipboard"]
538
+ });
539
+
540
+ expect(queryByLabelText("common.webcam")).not.toBeInTheDocument();
541
+ expect(
542
+ queryByLabelText("upload_text.paste_clipboard")
543
+ ).not.toBeInTheDocument();
544
+ });
545
+ });
546
+
547
+ describe("Props: visual-only", () => {
548
+ test.todo(
549
+ "VISUAL: object_fit='contain' vs 'cover' changes image display in thumbnails — needs Playwright visual regression screenshot comparison"
550
+ );
551
+
552
+ test.todo(
553
+ "VISUAL: height prop sets gallery container height — needs Playwright visual regression screenshot comparison"
554
+ );
555
+
556
+ test.todo(
557
+ "VISUAL: rows prop controls grid row count — needs Playwright visual regression screenshot comparison"
558
+ );
559
+ });
560
+
561
+ describe("Events: change", () => {
562
+ afterEach(() => cleanup());
563
+
564
+ test("change: fired when value changes via set_data", async () => {
565
+ const { listen, set_data } = await render(Gallery, {
566
+ ...default_props,
567
+ value: null
568
+ });
569
+
570
+ const change = listen("change");
571
+ await set_data({ value: three_images });
572
+
573
+ expect(change).toHaveBeenCalled();
574
+ });
575
+
576
+ test("change: not fired spuriously on mount", async () => {
577
+ const { listen } = await render(Gallery, {
578
+ ...default_props,
579
+ value: three_images
580
+ });
581
+
582
+ const change = listen("change", { retrospective: true });
583
+ expect(change).not.toHaveBeenCalled();
584
+ });
585
+
586
+ test("change: fired each time value updates", async () => {
587
+ const { listen, set_data } = await render(Gallery, {
588
+ ...default_props,
589
+ value: null
590
+ });
591
+
592
+ const change = listen("change");
593
+
594
+ await set_data({ value: [img("a")] });
595
+ await set_data({ value: [img("a"), img("b")] });
596
+
597
+ expect(change).toHaveBeenCalledTimes(2);
598
+ });
599
+
600
+ test("change: setting value to null after items triggers change", async () => {
601
+ const { listen, set_data } = await render(Gallery, {
602
+ ...default_props,
603
+ value: three_images
604
+ });
605
+
606
+ const change = listen("change");
607
+ await set_data({ value: null });
608
+
609
+ expect(change).toHaveBeenCalled();
610
+ });
611
+ });
612
+
613
+ describe("Events: select", () => {
614
+ afterEach(() => cleanup());
615
+
616
+ test("select: fired when thumbnail is clicked", async () => {
617
+ const { listen, getByRole } = await render(Gallery, {
618
+ ...default_props,
619
+ value: three_images
620
+ });
621
+
622
+ const select = listen("select");
623
+
624
+ await fireEvent.click(getByRole("button", { name: "Thumbnail 2 of 3" }));
625
+
626
+ expect(select).toHaveBeenCalledTimes(1);
627
+ expect(select).toHaveBeenCalledWith(expect.objectContaining({ index: 1 }));
628
+ });
629
+
630
+ test("select: fired when selected_index changes via set_data", async () => {
631
+ const { listen, set_data } = await render(Gallery, {
632
+ ...default_props,
633
+ preview: true,
634
+ selected_index: 0,
635
+ value: three_images
636
+ });
637
+
638
+ const select = listen("select");
639
+
640
+ await set_data({ selected_index: 2 });
641
+
642
+ expect(select).toHaveBeenCalled();
643
+ expect(select).toHaveBeenCalledWith(expect.objectContaining({ index: 2 }));
644
+ });
645
+ });
646
+
647
+ describe("Events: delete", () => {
648
+ afterEach(() => cleanup());
649
+
650
+ test("delete: fired when delete button is clicked", async () => {
651
+ const { listen, getAllByRole } = await render(Gallery, {
652
+ ...default_props,
653
+ interactive: true,
654
+ value: [img("a"), img("b"), img("c")]
655
+ });
656
+
657
+ const del = listen("delete");
658
+
659
+ const deleteButtons = getAllByRole("button", { name: "Delete image" });
660
+ await fireEvent.click(deleteButtons[1]);
661
+
662
+ expect(del).toHaveBeenCalledTimes(1);
663
+ });
664
+
665
+ test("delete: removes item from gallery", async () => {
666
+ const { getAllByRole, queryByRole } = await render(Gallery, {
667
+ ...default_props,
668
+ interactive: true,
669
+ value: [img("a"), img("b")]
670
+ });
671
+
672
+ expect(queryByRole("button", { name: "Thumbnail 2 of 2" })).toBeVisible();
673
+
674
+ const deleteButtons = getAllByRole("button", { name: "Delete image" });
675
+ await fireEvent.click(deleteButtons[0]);
676
+
677
+ await waitFor(() => {
678
+ expect(
679
+ queryByRole("button", { name: "Thumbnail 2 of 2" })
680
+ ).not.toBeInTheDocument();
681
+ });
682
+
683
+ expect(queryByRole("button", { name: "Thumbnail 1 of 1" })).toBeVisible();
684
+ });
685
+ });
686
+
687
+ describe("Events: preview_open / preview_close", () => {
688
+ afterEach(() => cleanup());
689
+
690
+ test("preview_open: fired when clicking thumbnail to enter preview", async () => {
691
+ const { listen, getByRole } = await render(Gallery, {
692
+ ...default_props,
693
+ allow_preview: true,
694
+ preview: false,
695
+ selected_index: null,
696
+ value: three_images
697
+ });
698
+
699
+ const preview_open = listen("preview_open");
700
+
701
+ await fireEvent.click(getByRole("button", { name: "Thumbnail 1 of 3" }));
702
+
703
+ expect(preview_open).toHaveBeenCalledTimes(1);
704
+ });
705
+
706
+ test("preview_close: fired when clicking close button", async () => {
707
+ const { listen, getByRole } = await render(Gallery, {
708
+ ...default_props,
709
+ preview: true,
710
+ selected_index: 0,
711
+ value: three_images
712
+ });
713
+
714
+ const preview_close = listen("preview_close");
715
+
716
+ await fireEvent.click(getByRole("button", { name: "Close" }));
717
+
718
+ expect(preview_close).toHaveBeenCalledTimes(1);
719
+ });
720
+
721
+ test("preview_close: fired when pressing Escape in preview", async () => {
722
+ const { listen, getByRole } = await render(Gallery, {
723
+ ...default_props,
724
+ preview: true,
725
+ selected_index: 0,
726
+ value: three_images
727
+ });
728
+
729
+ const preview_close = listen("preview_close");
730
+ const detail_button = getByRole("button", {
731
+ name: "detailed view of selected image"
732
+ });
733
+
734
+ await fireEvent.keyDown(detail_button, { code: "Escape" });
735
+
736
+ expect(preview_close).toHaveBeenCalledTimes(1);
737
+ });
738
+ });
739
+
740
+ describe("Keyboard navigation", () => {
741
+ afterEach(() => cleanup());
742
+
743
+ const keyboard_props = {
744
+ ...default_props,
745
+ preview: true,
746
+ selected_index: 0,
747
+ value: three_images
748
+ };
749
+
750
+ test("ArrowRight advances to next image", async () => {
751
+ const { getByTestId, getByRole } = await render(Gallery, keyboard_props);
752
+
753
+ const detail_button = getByRole("button", {
754
+ name: "detailed view of selected image"
755
+ });
756
+
757
+ await fireEvent.keyDown(detail_button, { code: "ArrowRight" });
758
+
759
+ const detail = getByTestId("detailed-image") as HTMLImageElement;
760
+ expect(detail.src).toBe("https://example.com/img2.png");
761
+ });
762
+
763
+ test("ArrowLeft goes to previous image (wraps to last)", async () => {
764
+ const { getByTestId, getByRole } = await render(Gallery, keyboard_props);
765
+
766
+ const detail_button = getByRole("button", {
767
+ name: "detailed view of selected image"
768
+ });
769
+
770
+ await fireEvent.keyDown(detail_button, { code: "ArrowLeft" });
771
+
772
+ const detail = getByTestId("detailed-image") as HTMLImageElement;
773
+ expect(detail.src).toBe("https://example.com/img3.png");
774
+ });
775
+
776
+ test("ArrowRight wraps around at end", async () => {
777
+ const { getByTestId, getByRole, set_data } = await render(
778
+ Gallery,
779
+ keyboard_props
145
780
  );
781
+
782
+ await set_data({ selected_index: 2 });
783
+
784
+ const detail_button = getByRole("button", {
785
+ name: "detailed view of selected image"
786
+ });
787
+
788
+ await fireEvent.keyDown(detail_button, { code: "ArrowRight" });
789
+
790
+ const detail = getByTestId("detailed-image") as HTMLImageElement;
791
+ expect(detail.src).toBe("https://example.com/img1.png");
792
+ });
793
+
794
+ test("Escape closes preview", async () => {
795
+ const { queryByTestId, getByRole } = await render(Gallery, keyboard_props);
796
+
797
+ const detail_button = getByRole("button", {
798
+ name: "detailed view of selected image"
799
+ });
800
+
801
+ await fireEvent.keyDown(detail_button, { code: "Escape" });
802
+
803
+ expect(queryByTestId("detailed-image")).not.toBeInTheDocument();
804
+ });
805
+ });
806
+
807
+ describe("get_data / set_data", () => {
808
+ afterEach(() => cleanup());
809
+
810
+ test("get_data returns current value", async () => {
811
+ const { get_data } = await render(Gallery, {
812
+ ...default_props,
813
+ value: three_images
814
+ });
815
+
816
+ const data = await get_data();
817
+ expect(data.value).toEqual(three_images);
818
+ });
819
+
820
+ test("get_data returns null when no value", async () => {
821
+ const { get_data } = await render(Gallery, {
822
+ ...default_props,
823
+ value: null
824
+ });
825
+
826
+ const data = await get_data();
827
+ expect(data.value).toBeNull();
828
+ });
829
+
830
+ test("set_data with new value updates DOM", async () => {
831
+ const { set_data, getByRole, queryByRole } = await render(Gallery, {
832
+ ...default_props,
833
+ value: [img("a")]
834
+ });
835
+
836
+ expect(getByRole("button", { name: "Thumbnail 1 of 1" })).toBeVisible();
837
+
838
+ await set_data({ value: [img("a"), img("b"), img("c")] });
839
+
840
+ expect(getByRole("button", { name: "Thumbnail 1 of 3" })).toBeVisible();
841
+ expect(getByRole("button", { name: "Thumbnail 3 of 3" })).toBeVisible();
842
+ });
843
+
844
+ test("set_data -> get_data round-trip", async () => {
845
+ const { set_data, get_data } = await render(Gallery, {
846
+ ...default_props,
847
+ value: null
848
+ });
849
+
850
+ const new_value = [img("x"), img("y")];
851
+ await set_data({ value: new_value });
852
+
853
+ const data = await get_data();
854
+ expect(data.value).toEqual(new_value);
855
+ });
856
+
857
+ test("set_data with null clears gallery", async () => {
858
+ const { set_data, get_data, queryByRole } = await render(Gallery, {
859
+ ...default_props,
860
+ value: three_images
861
+ });
862
+
863
+ await set_data({ value: null });
864
+
865
+ const data = await get_data();
866
+ expect(data.value).toBeNull();
867
+ expect(
868
+ queryByRole("button", { name: /^Thumbnail/ })
869
+ ).not.toBeInTheDocument();
870
+ });
871
+ });
872
+
873
+ describe("Edge cases", () => {
874
+ afterEach(() => cleanup());
875
+
876
+ test("caption in preview shows caption text", async () => {
877
+ const { getAllByText } = await render(Gallery, {
878
+ ...default_props,
879
+ preview: true,
880
+ selected_index: 0,
881
+ value: [img("cat", "A beautiful cat")]
882
+ });
883
+
884
+ // Caption appears in both the preview caption and the grid thumbnail label
885
+ const matches = getAllByText("A beautiful cat");
886
+ expect(matches.length).toBeGreaterThanOrEqual(1);
887
+ });
888
+
889
+ test("no caption hides caption element in preview", async () => {
890
+ const { queryByText } = await render(Gallery, {
891
+ ...default_props,
892
+ preview: true,
893
+ selected_index: 0,
894
+ value: [img("cat", null)]
895
+ });
896
+
897
+ // The caption element should not be present
898
+ // (there's no static text content to query for a missing caption,
899
+ // so we verify no <caption> tag is rendered)
900
+ const captions = document.querySelectorAll("caption.caption");
901
+ expect(captions.length).toBe(0);
902
+ });
903
+
904
+ test("selected_index clamped when value shrinks", async () => {
905
+ // NOTE: Gallery.svelte has a bug in scroll_to_img where el[index]
906
+ // can become null after value shrinks, causing an unhandled rejection.
907
+ // We test clamping via get_data to avoid triggering the scroll effect.
908
+ const { set_data, get_data } = await render(Gallery, {
909
+ ...default_props,
910
+ preview: true,
911
+ selected_index: 2,
912
+ allow_preview: false,
913
+ value: three_images
914
+ });
915
+
916
+ // Shrink to 2 items — selected_index should clamp from 2 to 1
917
+ await set_data({ value: [img("a"), img("b")] });
918
+
919
+ const data = await get_data();
920
+ expect(data.selected_index).toBe(1);
921
+ });
922
+
923
+ test("clicking close button hides preview and shows grid only", async () => {
924
+ const { getByRole, queryByTestId } = await render(Gallery, {
925
+ ...default_props,
926
+ preview: true,
927
+ selected_index: 0,
928
+ value: three_images
929
+ });
930
+
931
+ expect(queryByTestId("detailed-image")).toBeVisible();
932
+
933
+ await fireEvent.click(getByRole("button", { name: "Close" }));
934
+
935
+ expect(queryByTestId("detailed-image")).not.toBeInTheDocument();
936
+ });
937
+
938
+ test("clicking thumbnail in preview switches selection", async () => {
939
+ const { getByTestId, getAllByRole } = await render(Gallery, {
940
+ ...default_props,
941
+ preview: true,
942
+ selected_index: 0,
943
+ value: three_images
944
+ });
945
+
946
+ // In preview mode, both the preview strip and the grid render buttons
947
+ // with the same aria-label. The preview strip thumbnails appear first
948
+ // in DOM order (inside data-testid="container_el").
949
+ const thumb3_buttons = getAllByRole("button", {
950
+ name: "Thumbnail 3 of 3"
951
+ });
952
+ await fireEvent.click(thumb3_buttons[0]);
953
+
954
+ const detail = getByTestId("detailed-image") as HTMLImageElement;
955
+ expect(detail.src).toBe("https://example.com/img3.png");
956
+ });
957
+ });
958
+
959
+ describe("Regression: #13170 — selected_index points to newly appended image", () => {
960
+ afterEach(() => cleanup());
961
+
962
+ test("selected_index pointing to a newly appended last item shows the correct image", async () => {
963
+ const initial_value = [img("img1"), img("img2")];
964
+
965
+ const { set_data, getByTestId } = await render(Gallery, {
966
+ ...default_props,
967
+ preview: true,
968
+ selected_index: 0,
969
+ value: initial_value
970
+ });
971
+
972
+ const updated_value = [...initial_value, img("img3")];
973
+ await set_data({ value: updated_value, selected_index: 2 });
974
+
975
+ const preview_img = getByTestId("detailed-image") as HTMLImageElement;
976
+ expect(preview_img.src).toBe("https://example.com/img3.png");
977
+ });
978
+
979
+ test("selected_index pointing to newly appended item dispatches select with correct data", async () => {
980
+ const initial_value = [img("a"), img("b")];
981
+
982
+ const { set_data, listen } = await render(Gallery, {
983
+ ...default_props,
984
+ preview: true,
985
+ selected_index: 0,
986
+ value: initial_value
987
+ });
988
+
989
+ const select = listen("select");
990
+
991
+ await set_data({
992
+ value: [...initial_value, img("c")],
993
+ selected_index: 2
994
+ });
995
+
996
+ expect(select).toHaveBeenCalled();
997
+ const last_call = select.mock.calls[select.mock.calls.length - 1][0];
998
+ expect(last_call.index).toBe(2);
999
+ });
1000
+
1001
+ test("rapidly appending images with selected_index tracking last always shows the latest", async () => {
1002
+ const { set_data, getByTestId } = await render(Gallery, {
1003
+ ...default_props,
1004
+ preview: true,
1005
+ selected_index: 0,
1006
+ value: [img("img1")]
1007
+ });
1008
+
1009
+ for (let i = 2; i <= 5; i++) {
1010
+ const images = Array.from({ length: i }, (_, j) => img(`img${j + 1}`));
1011
+ await set_data({ value: images, selected_index: i - 1 });
1012
+ }
1013
+
1014
+ const preview_img = getByTestId("detailed-image") as HTMLImageElement;
1015
+ expect(preview_img.src).toBe("https://example.com/img5.png");
146
1016
  });
147
1017
  });
package/Index.svelte CHANGED
@@ -37,12 +37,10 @@
37
37
 
38
38
  let fullscreen = $state(false);
39
39
 
40
- function handle_delete(
41
- event: CustomEvent<{ file: FileData; index: number }>
42
- ): void {
40
+ function handle_delete(data: { file: FileData; index: number }): void {
43
41
  if (!gradio.props.value) return;
44
- const { index } = event.detail;
45
- gradio.dispatch("delete", event.detail);
42
+ const { index } = data;
43
+ gradio.dispatch("delete", data);
46
44
  gradio.props.value = gradio.props.value.filter((_, i) => i !== index);
47
45
  gradio.dispatch("change", gradio.props.value);
48
46
  }
@@ -309,7 +307,7 @@
309
307
  (btn) => typeof btn === "string" && btn === "fullscreen"
310
308
  )}
311
309
  buttons={gradio.props.buttons}
312
- on_custom_button_click={(id) => {
310
+ oncustom_button_click={(id) => {
313
311
  gradio.dispatch("custom_button_click", { id });
314
312
  }}
315
313
  {fullscreen}
package/dist/Index.svelte CHANGED
@@ -37,12 +37,10 @@
37
37
 
38
38
  let fullscreen = $state(false);
39
39
 
40
- function handle_delete(
41
- event: CustomEvent<{ file: FileData; index: number }>
42
- ): void {
40
+ function handle_delete(data: { file: FileData; index: number }): void {
43
41
  if (!gradio.props.value) return;
44
- const { index } = event.detail;
45
- gradio.dispatch("delete", event.detail);
42
+ const { index } = data;
43
+ gradio.dispatch("delete", data);
46
44
  gradio.props.value = gradio.props.value.filter((_, i) => i !== index);
47
45
  gradio.dispatch("change", gradio.props.value);
48
46
  }
@@ -309,7 +307,7 @@
309
307
  (btn) => typeof btn === "string" && btn === "fullscreen"
310
308
  )}
311
309
  buttons={gradio.props.buttons}
312
- on_custom_button_click={(id) => {
310
+ oncustom_button_click={(id) => {
313
311
  gradio.dispatch("custom_button_click", { id });
314
312
  }}
315
313
  {fullscreen}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradio/gallery",
3
- "version": "0.17.5",
3
+ "version": "0.17.6",
4
4
  "description": "Gradio UI packages",
5
5
  "type": "module",
6
6
  "author": "",
@@ -8,15 +8,15 @@
8
8
  "private": false,
9
9
  "dependencies": {
10
10
  "dequal": "^2.0.3",
11
- "@gradio/atoms": "^0.23.0",
12
- "@gradio/file": "^0.14.6",
13
- "@gradio/client": "^2.1.0",
14
- "@gradio/image": "^0.26.1",
15
- "@gradio/statustracker": "^0.13.1",
11
+ "@gradio/atoms": "^0.23.1",
12
+ "@gradio/file": "^0.14.7",
16
13
  "@gradio/icons": "^0.15.1",
14
+ "@gradio/client": "^2.2.0",
15
+ "@gradio/upload": "^0.17.8",
17
16
  "@gradio/utils": "^0.12.2",
18
- "@gradio/video": "^0.20.6",
19
- "@gradio/upload": "^0.17.8"
17
+ "@gradio/video": "^0.20.7",
18
+ "@gradio/statustracker": "^0.14.0",
19
+ "@gradio/image": "^0.26.2"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@gradio/preview": "^0.16.2"