@gradio/imageslider 0.4.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @gradio/imageslider
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Features
6
+
7
+ - [#13252](https://github.com/gradio-app/gradio/pull/13252) [`e39f028`](https://github.com/gradio-app/gradio/commit/e39f0284582bbac93fd01b2a5eae4ecae219f252) - Add ImageSlider unit tests. Thanks @pngwn!
8
+
9
+ ### Dependency updates
10
+
11
+ - @gradio/atoms@0.24.0
12
+ - @gradio/statustracker@0.14.1
13
+ - @gradio/upload@0.17.9
14
+
3
15
  ## 0.4.7
4
16
 
5
17
  ### Dependency updates
@@ -0,0 +1,714 @@
1
+ import { test, describe, afterEach, expect, vi } from "vitest";
2
+ import {
3
+ cleanup,
4
+ render,
5
+ fireEvent,
6
+ waitFor,
7
+ upload_file,
8
+ mock_client,
9
+ TEST_JPG
10
+ } from "@self/tootils/render";
11
+ import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
12
+ import type { FileData } from "@gradio/client";
13
+
14
+ import ImageSlider from "./Index.svelte";
15
+
16
+ const fake_image = (id: string): FileData => ({
17
+ path: `${id}.png`,
18
+ url: `https://example.com/${id}.png`,
19
+ orig_name: `${id}.png`,
20
+ size: 1024,
21
+ mime_type: "image/png",
22
+ is_stream: false,
23
+ meta: { _type: "gradio.FileData" }
24
+ });
25
+
26
+ const img_a = fake_image("img_a");
27
+ const img_b = fake_image("img_b");
28
+
29
+ const default_props = {
30
+ value: [null, null] as [FileData | null, FileData | null],
31
+ label: "ImageSlider",
32
+ show_label: true,
33
+ interactive: false,
34
+ buttons: [] as (string | { value: string; id: number; icon: null })[],
35
+ slider_position: 50,
36
+ input_ready: true,
37
+ upload_count: 2,
38
+ slider_color: "#ff0000",
39
+ max_height: 500
40
+ };
41
+
42
+ // interactive: false ensures SliderPreview is rendered for elem_id/elem_classes/visible tests.
43
+ // has_label: false because BlockLabel's show_label=false uses class:hide (display:none) but
44
+ // is applied directly on the <label> element, not on a data-testid='block-info' wrapper that
45
+ // the shared test expects. Custom label tests are below.
46
+ run_shared_prop_tests({
47
+ component: ImageSlider,
48
+ name: "ImageSlider",
49
+ base_props: { ...default_props },
50
+ has_label: false
51
+ });
52
+
53
+ describe("Props: label", () => {
54
+ afterEach(() => cleanup());
55
+
56
+ test("label text is rendered", async () => {
57
+ const { getByTestId } = await render(ImageSlider, {
58
+ ...default_props,
59
+ label: "My Comparison"
60
+ });
61
+
62
+ expect(getByTestId("block-label")).toHaveTextContent("My Comparison");
63
+ });
64
+
65
+ test("show_label: true makes the label visible", async () => {
66
+ const { getByTestId } = await render(ImageSlider, {
67
+ ...default_props,
68
+ show_label: true
69
+ });
70
+
71
+ expect(getByTestId("block-label")).toBeVisible();
72
+ });
73
+
74
+ test("show_label: false hides the label", async () => {
75
+ const { getByTestId } = await render(ImageSlider, {
76
+ ...default_props,
77
+ show_label: false
78
+ });
79
+
80
+ // BlockLabel uses class:hide (display:none) when show_label=false
81
+ expect(getByTestId("block-label")).not.toBeVisible();
82
+ });
83
+ });
84
+
85
+ describe("ImageSlider", () => {
86
+ afterEach(() => cleanup());
87
+
88
+ test("renders empty state when non-interactive with no images", async () => {
89
+ const { queryAllByTestId } = await render(ImageSlider, {
90
+ ...default_props,
91
+ interactive: false,
92
+ value: [null, null]
93
+ });
94
+
95
+ expect(queryAllByTestId("imageslider-image")).toHaveLength(0);
96
+ });
97
+
98
+ test("renders upload area when interactive with no images", async () => {
99
+ // Upload component renders two buttons (one per slot); getAllByRole handles both
100
+ const { getAllByRole } = await render(ImageSlider, {
101
+ ...default_props,
102
+ interactive: true,
103
+ value: [null, null],
104
+ client: mock_client()
105
+ });
106
+
107
+ const btns = getAllByRole("button", {
108
+ name: "Click to upload or drop files"
109
+ });
110
+ expect(btns[0]).toBeVisible();
111
+ });
112
+
113
+ test("renders both images when value has two images", async () => {
114
+ const { getAllByTestId } = await render(ImageSlider, {
115
+ ...default_props,
116
+ value: [img_a, img_b]
117
+ });
118
+
119
+ expect(getAllByTestId("imageslider-image").length).toBeGreaterThan(0);
120
+ });
121
+ });
122
+
123
+ describe("Props: value", () => {
124
+ afterEach(() => cleanup());
125
+
126
+ test("[null, null] + interactive shows upload area", async () => {
127
+ // Two upload buttons (one per slot); getAllByRole avoids "multiple elements" error
128
+ const { getAllByRole } = await render(ImageSlider, {
129
+ ...default_props,
130
+ interactive: true,
131
+ value: [null, null],
132
+ client: mock_client()
133
+ });
134
+
135
+ const btns = getAllByRole("button", {
136
+ name: "Click to upload or drop files"
137
+ });
138
+ expect(btns[0]).toBeVisible();
139
+ });
140
+
141
+ test("both images show their URLs in <img> src attributes", async () => {
142
+ const { getAllByTestId } = await render(ImageSlider, {
143
+ ...default_props,
144
+ value: [img_a, img_b]
145
+ });
146
+
147
+ const imgs = getAllByTestId("imageslider-image");
148
+ const srcs = imgs.map((img) => (img as HTMLImageElement).src);
149
+ expect(srcs).toContain("https://example.com/img_a.png");
150
+ expect(srcs).toContain("https://example.com/img_b.png");
151
+ });
152
+
153
+ test("[img, null] + interactive shows upload mode (second image missing)", async () => {
154
+ const { getByRole } = await render(ImageSlider, {
155
+ ...default_props,
156
+ interactive: true,
157
+ value: [img_a, null],
158
+ client: mock_client()
159
+ });
160
+
161
+ expect(
162
+ getByRole("button", { name: "Click to upload or drop files" })
163
+ ).toBeVisible();
164
+ });
165
+
166
+ test("[null, img] + interactive shows upload mode (first image missing)", async () => {
167
+ const { getByRole } = await render(ImageSlider, {
168
+ ...default_props,
169
+ interactive: true,
170
+ value: [null, img_b],
171
+ client: mock_client()
172
+ });
173
+
174
+ expect(
175
+ getByRole("button", { name: "Click to upload or drop files" })
176
+ ).toBeVisible();
177
+ });
178
+
179
+ test("[img, img] + interactive switches to preview mode", async () => {
180
+ const { getAllByTestId, queryByRole } = await render(ImageSlider, {
181
+ ...default_props,
182
+ interactive: true,
183
+ value: [img_a, img_b]
184
+ });
185
+
186
+ expect(
187
+ queryByRole("button", { name: "Click to upload or drop files" })
188
+ ).toBeNull();
189
+ expect(getAllByTestId("imageslider-image").length).toBeGreaterThan(0);
190
+ });
191
+
192
+ test("set_data with both images updates DOM with correct src values", async () => {
193
+ const { set_data, getAllByTestId } = await render(ImageSlider, {
194
+ ...default_props,
195
+ value: [null, null]
196
+ });
197
+
198
+ await set_data({ value: [img_a, img_b] });
199
+
200
+ const imgs = getAllByTestId("imageslider-image");
201
+ const srcs = imgs.map((img) => (img as HTMLImageElement).src);
202
+ expect(srcs).toContain("https://example.com/img_a.png");
203
+ expect(srcs).toContain("https://example.com/img_b.png");
204
+ });
205
+
206
+ test("set_data with [null, null] removes images from DOM", async () => {
207
+ const { set_data, queryAllByTestId } = await render(ImageSlider, {
208
+ ...default_props,
209
+ interactive: false,
210
+ value: [img_a, img_b]
211
+ });
212
+
213
+ await set_data({ value: [null, null] });
214
+
215
+ expect(queryAllByTestId("imageslider-image")).toHaveLength(0);
216
+ });
217
+ });
218
+
219
+ describe("Props: interactive", () => {
220
+ afterEach(() => cleanup());
221
+
222
+ test("interactive: false with no images shows static empty state without file inputs", async () => {
223
+ const { queryByTestId } = await render(ImageSlider, {
224
+ ...default_props,
225
+ interactive: false,
226
+ value: [null, null]
227
+ });
228
+
229
+ expect(queryByTestId("file-upload")).toBeNull();
230
+ });
231
+
232
+ test("interactive: true with no images shows upload area", async () => {
233
+ // Two upload buttons (one per slot); getAllByRole avoids "multiple elements" error
234
+ const { getAllByRole } = await render(ImageSlider, {
235
+ ...default_props,
236
+ interactive: true,
237
+ value: [null, null],
238
+ client: mock_client()
239
+ });
240
+
241
+ const btns = getAllByRole("button", {
242
+ name: "Click to upload or drop files"
243
+ });
244
+ expect(btns[0]).toBeVisible();
245
+ });
246
+
247
+ test("interactive: true with both images shows preview without upload buttons", async () => {
248
+ const { getAllByTestId, queryByRole } = await render(ImageSlider, {
249
+ ...default_props,
250
+ interactive: true,
251
+ value: [img_a, img_b]
252
+ });
253
+
254
+ expect(
255
+ queryByRole("button", { name: "Click to upload or drop files" })
256
+ ).toBeNull();
257
+ expect(getAllByTestId("imageslider-image").length).toBeGreaterThan(0);
258
+ });
259
+
260
+ test("interactive: false with both images shows static preview", async () => {
261
+ const { queryByTestId, getAllByTestId } = await render(ImageSlider, {
262
+ ...default_props,
263
+ interactive: false,
264
+ value: [img_a, img_b]
265
+ });
266
+
267
+ expect(queryByTestId("file-upload")).toBeNull();
268
+ expect(getAllByTestId("imageslider-image").length).toBeGreaterThan(0);
269
+ });
270
+ });
271
+
272
+ describe("Props: slider_position", () => {
273
+ afterEach(() => cleanup());
274
+
275
+ test("slider_position: 50 renders the slider element", async () => {
276
+ const { getByTestId } = await render(ImageSlider, {
277
+ ...default_props,
278
+ value: [img_a, img_b],
279
+ slider_position: 50
280
+ });
281
+
282
+ expect(getByTestId("slider")).toBeInTheDocument();
283
+ });
284
+
285
+ test.todo(
286
+ "VISUAL: slider_position=0 positions the slider at the left edge — needs Playwright visual regression screenshot comparison"
287
+ );
288
+ test.todo(
289
+ "VISUAL: slider_position=100 positions the slider at the right edge — needs Playwright visual regression screenshot comparison"
290
+ );
291
+ });
292
+
293
+ describe("Props: buttons", () => {
294
+ afterEach(() => cleanup());
295
+
296
+ const preview_props = {
297
+ ...default_props,
298
+ interactive: false,
299
+ value: [img_a, img_b] as [FileData, FileData]
300
+ };
301
+
302
+ test("buttons: ['download'] shows a download link", async () => {
303
+ const { getByTestId } = await render(ImageSlider, {
304
+ ...preview_props,
305
+ buttons: ["download"]
306
+ });
307
+
308
+ expect(getByTestId("download-link")).toBeInTheDocument();
309
+ });
310
+
311
+ test("buttons: [] hides the download link", async () => {
312
+ const { queryByTestId } = await render(ImageSlider, {
313
+ ...preview_props,
314
+ buttons: []
315
+ });
316
+
317
+ expect(queryByTestId("download-link")).toBeNull();
318
+ });
319
+
320
+ test("buttons: ['fullscreen'] shows the fullscreen button", async () => {
321
+ const { getByLabelText } = await render(ImageSlider, {
322
+ ...preview_props,
323
+ buttons: ["fullscreen"]
324
+ });
325
+
326
+ expect(getByLabelText("Fullscreen")).toBeVisible();
327
+ });
328
+
329
+ test("buttons: [] hides the fullscreen button", async () => {
330
+ const { queryByLabelText } = await render(ImageSlider, {
331
+ ...preview_props,
332
+ buttons: []
333
+ });
334
+
335
+ expect(queryByLabelText("Fullscreen")).toBeNull();
336
+ });
337
+
338
+ test("buttons: ['download', 'fullscreen'] shows both buttons", async () => {
339
+ const { getByTestId, getByLabelText } = await render(ImageSlider, {
340
+ ...preview_props,
341
+ buttons: ["download", "fullscreen"]
342
+ });
343
+
344
+ expect(getByTestId("download-link")).toBeInTheDocument();
345
+ expect(getByLabelText("Fullscreen")).toBeVisible();
346
+ });
347
+
348
+ test("interactive: true with both images shows Remove Image button", async () => {
349
+ const { getByLabelText } = await render(ImageSlider, {
350
+ ...default_props,
351
+ interactive: true,
352
+ value: [img_a, img_b]
353
+ });
354
+
355
+ expect(getByLabelText("Remove Image")).toBeVisible();
356
+ });
357
+
358
+ test("interactive: false hides the Remove Image button", async () => {
359
+ const { queryByLabelText } = await render(ImageSlider, {
360
+ ...preview_props,
361
+ interactive: false
362
+ });
363
+
364
+ expect(queryByLabelText("Remove Image")).toBeNull();
365
+ });
366
+
367
+ test("custom button fires custom_button_click with correct id", async () => {
368
+ const { listen, getByLabelText } = await render(ImageSlider, {
369
+ ...preview_props,
370
+ buttons: [{ value: "Analyze", id: 42, icon: null }]
371
+ });
372
+
373
+ const custom = listen("custom_button_click");
374
+ await fireEvent.click(getByLabelText("Analyze"));
375
+
376
+ expect(custom).toHaveBeenCalledTimes(1);
377
+ expect(custom).toHaveBeenCalledWith({ id: 42 });
378
+ });
379
+
380
+ test("multiple custom buttons each dispatch their own id", async () => {
381
+ const { listen, getByLabelText } = await render(ImageSlider, {
382
+ ...preview_props,
383
+ buttons: [
384
+ { value: "Action A", id: 1, icon: null },
385
+ { value: "Action B", id: 2, icon: null }
386
+ ]
387
+ });
388
+
389
+ const custom = listen("custom_button_click");
390
+
391
+ await fireEvent.click(getByLabelText("Action A"));
392
+ await fireEvent.click(getByLabelText("Action B"));
393
+
394
+ expect(custom).toHaveBeenCalledTimes(2);
395
+ expect(custom).toHaveBeenNthCalledWith(1, { id: 1 });
396
+ expect(custom).toHaveBeenNthCalledWith(2, { id: 2 });
397
+ });
398
+ });
399
+
400
+ describe("Props: placeholder", () => {
401
+ afterEach(() => cleanup());
402
+
403
+ test("shows placeholder text in the upload areas", async () => {
404
+ // Both upload slots show the same placeholder text; getAllByText handles both
405
+ const { getAllByText } = await render(ImageSlider, {
406
+ ...default_props,
407
+ interactive: true,
408
+ value: [null, null],
409
+ placeholder: "Drop comparison images here",
410
+ client: mock_client()
411
+ });
412
+
413
+ const placeholders = getAllByText("Drop comparison images here");
414
+ expect(placeholders[0]).toBeVisible();
415
+ });
416
+ });
417
+
418
+ describe("Events: change", () => {
419
+ afterEach(() => cleanup());
420
+
421
+ test("no spurious change event on initial mount", async () => {
422
+ const { listen } = await render(ImageSlider, {
423
+ ...default_props,
424
+ value: [img_a, img_b]
425
+ });
426
+
427
+ const change = listen("change", { retrospective: true });
428
+
429
+ expect(change).not.toHaveBeenCalled();
430
+ });
431
+
432
+ test("fires when set_data changes value", async () => {
433
+ const { listen, set_data } = await render(ImageSlider, {
434
+ ...default_props,
435
+ value: [null, null]
436
+ });
437
+
438
+ const change = listen("change");
439
+
440
+ await set_data({ value: [img_a, img_b] });
441
+
442
+ expect(change).toHaveBeenCalledTimes(1);
443
+ });
444
+
445
+ test("fires again when value changes to a different pair", async () => {
446
+ const { listen, set_data } = await render(ImageSlider, {
447
+ ...default_props,
448
+ value: [null, null]
449
+ });
450
+
451
+ const change = listen("change");
452
+ const alt_a = fake_image("alt_a");
453
+ const alt_b = fake_image("alt_b");
454
+
455
+ await set_data({ value: [img_a, img_b] });
456
+ await set_data({ value: [alt_a, alt_b] });
457
+
458
+ expect(change).toHaveBeenCalledTimes(2);
459
+ });
460
+
461
+ test("setting value to [null, null] after having images fires change", async () => {
462
+ const { listen, set_data } = await render(ImageSlider, {
463
+ ...default_props,
464
+ value: [img_a, img_b]
465
+ });
466
+
467
+ const change = listen("change");
468
+
469
+ await set_data({ value: [null, null] });
470
+
471
+ expect(change).toHaveBeenCalledTimes(1);
472
+ });
473
+ });
474
+
475
+ describe("Events: clear", () => {
476
+ afterEach(() => cleanup());
477
+
478
+ test("fires when Remove Image button is clicked", async () => {
479
+ const { listen, getByLabelText } = await render(ImageSlider, {
480
+ ...default_props,
481
+ interactive: true,
482
+ value: [img_a, img_b]
483
+ });
484
+
485
+ const clear = listen("clear");
486
+
487
+ await fireEvent.click(getByLabelText("Remove Image"));
488
+
489
+ expect(clear).toHaveBeenCalledTimes(1);
490
+ });
491
+
492
+ test("value is [null, null] after clicking Remove Image", async () => {
493
+ const { get_data, getByLabelText } = await render(ImageSlider, {
494
+ ...default_props,
495
+ interactive: true,
496
+ value: [img_a, img_b]
497
+ });
498
+
499
+ await fireEvent.click(getByLabelText("Remove Image"));
500
+
501
+ const data = await get_data();
502
+ expect(data.value).toEqual([null, null]);
503
+ });
504
+ });
505
+
506
+ describe("Events: upload", () => {
507
+ afterEach(() => cleanup());
508
+
509
+ test("fires after a file is uploaded in interactive mode", async () => {
510
+ const { listen } = await render(ImageSlider, {
511
+ ...default_props,
512
+ interactive: true,
513
+ value: [null, null],
514
+ client: mock_client(),
515
+ root: "https://example.com"
516
+ });
517
+
518
+ const upload = listen("upload");
519
+
520
+ await upload_file(TEST_JPG);
521
+
522
+ await waitFor(() => {
523
+ expect(upload).toHaveBeenCalledTimes(1);
524
+ });
525
+ });
526
+
527
+ test("uploading also fires change", async () => {
528
+ const { listen } = await render(ImageSlider, {
529
+ ...default_props,
530
+ interactive: true,
531
+ value: [null, null],
532
+ client: mock_client(),
533
+ root: "https://example.com"
534
+ });
535
+
536
+ const upload = listen("upload");
537
+ const change = listen("change");
538
+
539
+ await upload_file(TEST_JPG);
540
+
541
+ await waitFor(() => {
542
+ expect(upload).toHaveBeenCalledTimes(1);
543
+ });
544
+ expect(change).toHaveBeenCalledTimes(1);
545
+ });
546
+ });
547
+
548
+ describe("Events: input", () => {
549
+ afterEach(() => cleanup());
550
+
551
+ test("fires when a file is uploaded by the user", async () => {
552
+ const { listen } = await render(ImageSlider, {
553
+ ...default_props,
554
+ interactive: true,
555
+ value: [null, null],
556
+ client: mock_client(),
557
+ root: "https://example.com"
558
+ });
559
+
560
+ const input = listen("input");
561
+
562
+ await upload_file(TEST_JPG);
563
+
564
+ await waitFor(() => {
565
+ expect(input).toHaveBeenCalledTimes(1);
566
+ });
567
+ });
568
+
569
+ test("does not fire when value changes via set_data (backend output)", async () => {
570
+ const { listen, set_data } = await render(ImageSlider, {
571
+ ...default_props,
572
+ value: [null, null]
573
+ });
574
+
575
+ const input = listen("input");
576
+
577
+ await set_data({ value: [img_a, img_b] });
578
+
579
+ expect(input).not.toHaveBeenCalled();
580
+ });
581
+
582
+ test("fires when Remove Image button is clicked", async () => {
583
+ const { listen, getByLabelText } = await render(ImageSlider, {
584
+ ...default_props,
585
+ interactive: true,
586
+ value: [img_a, img_b]
587
+ });
588
+
589
+ const input = listen("input");
590
+
591
+ await fireEvent.click(getByLabelText("Remove Image"));
592
+
593
+ expect(input).toHaveBeenCalledTimes(1);
594
+ });
595
+ });
596
+
597
+ describe("Events: custom_button_click", () => {
598
+ afterEach(() => cleanup());
599
+
600
+ test("fires with { id } when a custom button is clicked", async () => {
601
+ const { listen, getByLabelText } = await render(ImageSlider, {
602
+ ...default_props,
603
+ interactive: false,
604
+ value: [img_a, img_b],
605
+ buttons: [{ value: "Run", id: 99, icon: null }]
606
+ });
607
+
608
+ const custom = listen("custom_button_click");
609
+
610
+ await fireEvent.click(getByLabelText("Run"));
611
+
612
+ expect(custom).toHaveBeenCalledTimes(1);
613
+ expect(custom).toHaveBeenCalledWith({ id: 99 });
614
+ });
615
+ });
616
+
617
+ describe("get_data / set_data", () => {
618
+ afterEach(() => cleanup());
619
+
620
+ test("get_data returns the initial value", async () => {
621
+ const { get_data } = await render(ImageSlider, {
622
+ ...default_props,
623
+ value: [img_a, img_b]
624
+ });
625
+
626
+ const data = await get_data();
627
+ expect(data.value).toEqual([img_a, img_b]);
628
+ });
629
+
630
+ test("get_data returns [null, null] when no images are set", async () => {
631
+ const { get_data } = await render(ImageSlider, {
632
+ ...default_props,
633
+ value: [null, null]
634
+ });
635
+
636
+ const data = await get_data();
637
+ expect(data.value).toEqual([null, null]);
638
+ });
639
+
640
+ test("set_data then get_data round-trips correctly", async () => {
641
+ const { set_data, get_data } = await render(ImageSlider, {
642
+ ...default_props,
643
+ value: [null, null]
644
+ });
645
+
646
+ await set_data({ value: [img_a, img_b] });
647
+
648
+ const data = await get_data();
649
+ expect(data.value).toEqual([img_a, img_b]);
650
+ });
651
+ });
652
+
653
+ describe("Edge cases", () => {
654
+ afterEach(() => cleanup());
655
+
656
+ test("[null, null] value renders without crash", async () => {
657
+ const { container } = await render(ImageSlider, {
658
+ ...default_props,
659
+ value: [null, null]
660
+ });
661
+
662
+ expect(container).toBeInTheDocument();
663
+ });
664
+
665
+ test("[img, null] partial value renders without crash in static mode", async () => {
666
+ const { container } = await render(ImageSlider, {
667
+ ...default_props,
668
+ interactive: false,
669
+ value: [img_a, null]
670
+ });
671
+
672
+ expect(container).toBeInTheDocument();
673
+ });
674
+
675
+ test("[null, img] partial value renders without crash in static mode", async () => {
676
+ const { container } = await render(ImageSlider, {
677
+ ...default_props,
678
+ interactive: false,
679
+ value: [null, img_b]
680
+ });
681
+
682
+ expect(container).toBeInTheDocument();
683
+ });
684
+
685
+ test("no change event on initial mount when value is set", async () => {
686
+ const { listen } = await render(ImageSlider, {
687
+ ...default_props,
688
+ value: [img_a, img_b]
689
+ });
690
+
691
+ const change = listen("change", { retrospective: true });
692
+
693
+ expect(change).not.toHaveBeenCalled();
694
+ });
695
+ });
696
+
697
+ test.todo(
698
+ "VISUAL: slider_color applies the given color to the slider line and handles — needs Playwright visual regression screenshot comparison"
699
+ );
700
+ test.todo(
701
+ "VISUAL: height prop constrains the component height — needs Playwright visual regression screenshot comparison"
702
+ );
703
+ test.todo(
704
+ "VISUAL: width prop constrains the component width — needs Playwright visual regression screenshot comparison"
705
+ );
706
+ test.todo(
707
+ "VISUAL: max_height limits image display height — needs Playwright visual regression screenshot comparison"
708
+ );
709
+ test.todo(
710
+ "VISUAL: scroll wheel zoom scales both images together — needs Playwright visual regression screenshot comparison"
711
+ );
712
+ test.todo(
713
+ "VISUAL: mouse drag pans both images when zoomed in — needs Playwright visual regression screenshot comparison"
714
+ );
package/Index.svelte CHANGED
@@ -30,8 +30,6 @@
30
30
  const props = $props();
31
31
  const gradio = new ImageSliderGradio(props);
32
32
 
33
- let value_is_output = $state(false);
34
- let old_value = $state(gradio.props.value);
35
33
  let fullscreen = $state(false);
36
34
  let dragging = $state(false);
37
35
  let active_source: sources = $state(null);
@@ -41,15 +39,7 @@
41
39
  Math.max(0, Math.min(100, gradio.props.slider_position)) / 100
42
40
  );
43
41
 
44
- $effect(() => {
45
- if (old_value != gradio.props.value) {
46
- old_value = gradio.props.value;
47
- gradio.dispatch("change");
48
- if (!value_is_output) {
49
- gradio.dispatch("input");
50
- }
51
- }
52
- });
42
+ gradio.watch_for_change();
53
43
 
54
44
  const handle_drag_event = (event: Event): void => {
55
45
  const drag_event = event as DragEvent;
@@ -101,7 +91,10 @@
101
91
  on:select={({ detail }) => gradio.dispatch("select", detail)}
102
92
  on:share={({ detail }) => gradio.dispatch("share", detail)}
103
93
  on:error={({ detail }) => gradio.dispatch("error", detail)}
104
- on:clear={() => gradio.dispatch("clear")}
94
+ on:clear={() => {
95
+ gradio.dispatch("clear");
96
+ gradio.dispatch("input");
97
+ }}
105
98
  on:fullscreen={({ detail }) => {
106
99
  fullscreen = detail;
107
100
  }}
@@ -162,9 +155,13 @@
162
155
  on:edit={() => gradio.dispatch("edit")}
163
156
  on:clear={() => {
164
157
  gradio.dispatch("clear");
158
+ gradio.dispatch("input");
165
159
  }}
166
160
  on:drag={({ detail }) => (dragging = detail)}
167
- on:upload={() => gradio.dispatch("upload")}
161
+ on:upload={() => {
162
+ gradio.dispatch("upload");
163
+ gradio.dispatch("input");
164
+ }}
168
165
  on:error={({ detail }) => {
169
166
  if (gradio.shared.loading_status)
170
167
  gradio.shared.loading_status.status = "error";
package/dist/Index.svelte CHANGED
@@ -30,8 +30,6 @@
30
30
  const props = $props();
31
31
  const gradio = new ImageSliderGradio(props);
32
32
 
33
- let value_is_output = $state(false);
34
- let old_value = $state(gradio.props.value);
35
33
  let fullscreen = $state(false);
36
34
  let dragging = $state(false);
37
35
  let active_source: sources = $state(null);
@@ -41,15 +39,7 @@
41
39
  Math.max(0, Math.min(100, gradio.props.slider_position)) / 100
42
40
  );
43
41
 
44
- $effect(() => {
45
- if (old_value != gradio.props.value) {
46
- old_value = gradio.props.value;
47
- gradio.dispatch("change");
48
- if (!value_is_output) {
49
- gradio.dispatch("input");
50
- }
51
- }
52
- });
42
+ gradio.watch_for_change();
53
43
 
54
44
  const handle_drag_event = (event: Event): void => {
55
45
  const drag_event = event as DragEvent;
@@ -101,7 +91,10 @@
101
91
  on:select={({ detail }) => gradio.dispatch("select", detail)}
102
92
  on:share={({ detail }) => gradio.dispatch("share", detail)}
103
93
  on:error={({ detail }) => gradio.dispatch("error", detail)}
104
- on:clear={() => gradio.dispatch("clear")}
94
+ on:clear={() => {
95
+ gradio.dispatch("clear");
96
+ gradio.dispatch("input");
97
+ }}
105
98
  on:fullscreen={({ detail }) => {
106
99
  fullscreen = detail;
107
100
  }}
@@ -162,9 +155,13 @@
162
155
  on:edit={() => gradio.dispatch("edit")}
163
156
  on:clear={() => {
164
157
  gradio.dispatch("clear");
158
+ gradio.dispatch("input");
165
159
  }}
166
160
  on:drag={({ detail }) => (dragging = detail)}
167
- on:upload={() => gradio.dispatch("upload")}
161
+ on:upload={() => {
162
+ gradio.dispatch("upload");
163
+ gradio.dispatch("input");
164
+ }}
168
165
  on:error={({ detail }) => {
169
166
  if (gradio.shared.loading_status)
170
167
  gradio.shared.loading_status.status = "error";
@@ -85,6 +85,7 @@
85
85
  <!-- svelte-ignore a11y-missing-attribute -->
86
86
  <img
87
87
  {src}
88
+ data-testid="imageslider-image"
88
89
  {...$$restProps}
89
90
  class:fixed
90
91
  style:transform
@@ -89,6 +89,7 @@
89
89
  <div
90
90
  class="outer"
91
91
  class:disabled
92
+ data-testid="slider"
92
93
  bind:this={inner}
93
94
  role="none"
94
95
  style="transform: translateX({px}px)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradio/imageslider",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
4
  "description": "Gradio UI packages",
5
5
  "type": "module",
6
6
  "author": "",
@@ -11,11 +11,11 @@
11
11
  "@types/d3-selection": "^3.0.11",
12
12
  "d3-drag": "^3.0.0",
13
13
  "d3-selection": "^3.0.0",
14
- "@gradio/atoms": "^0.23.1",
15
- "@gradio/icons": "^0.15.1",
16
- "@gradio/upload": "^0.17.8",
17
- "@gradio/statustracker": "^0.14.0",
14
+ "@gradio/atoms": "^0.24.0",
18
15
  "@gradio/client": "^2.2.0",
16
+ "@gradio/icons": "^0.15.1",
17
+ "@gradio/statustracker": "^0.14.1",
18
+ "@gradio/upload": "^0.17.9",
19
19
  "@gradio/utils": "^0.12.2"
20
20
  },
21
21
  "exports": {
@@ -85,6 +85,7 @@
85
85
  <!-- svelte-ignore a11y-missing-attribute -->
86
86
  <img
87
87
  {src}
88
+ data-testid="imageslider-image"
88
89
  {...$$restProps}
89
90
  class:fixed
90
91
  style:transform
@@ -89,6 +89,7 @@
89
89
  <div
90
90
  class="outer"
91
91
  class:disabled
92
+ data-testid="slider"
92
93
  bind:this={inner}
93
94
  role="none"
94
95
  style="transform: translateX({px}px)"
@@ -0,0 +1,121 @@
1
+ import { test, describe, beforeEach, afterEach, expect, vi } from "vitest";
2
+ import { ZoomableImage } from "./zoom";
3
+
4
+ describe("ZoomableImage", () => {
5
+ let container: HTMLDivElement;
6
+ let img: HTMLImageElement;
7
+ let zi: ZoomableImage;
8
+
9
+ beforeEach(() => {
10
+ container = document.createElement("div");
11
+ img = document.createElement("img");
12
+ container.appendChild(img);
13
+ zi = new ZoomableImage(container, img);
14
+ });
15
+
16
+ afterEach(() => {
17
+ zi.destroy();
18
+ });
19
+
20
+ test("initializes with scale=1 and zero offsets", () => {
21
+ expect(zi.scale).toBe(1);
22
+ expect(zi.offsetX).toBe(0);
23
+ expect(zi.offsetY).toBe(0);
24
+ });
25
+
26
+ test("subscribe callback receives transform updates via updateTransform", () => {
27
+ const cb = vi.fn();
28
+ zi.subscribe(cb);
29
+ zi.updateTransform();
30
+
31
+ expect(cb).toHaveBeenCalledWith({ x: 0, y: 0, scale: 1 });
32
+ });
33
+
34
+ test("reset_zoom restores scale=1 and zero offsets", () => {
35
+ zi.scale = 3;
36
+ zi.offsetX = 100;
37
+ zi.offsetY = -50;
38
+
39
+ const cb = vi.fn();
40
+ zi.subscribe(cb);
41
+
42
+ zi.reset_zoom();
43
+
44
+ expect(zi.scale).toBe(1);
45
+ expect(zi.offsetX).toBe(0);
46
+ expect(zi.offsetY).toBe(0);
47
+ expect(cb).toHaveBeenCalledWith({ x: 0, y: 0, scale: 1 });
48
+ });
49
+
50
+ test("unsubscribe removes callback so it no longer receives updates", () => {
51
+ const cb = vi.fn();
52
+ zi.subscribe(cb);
53
+ zi.unsubscribe(cb);
54
+ zi.updateTransform();
55
+
56
+ expect(cb).not.toHaveBeenCalled();
57
+ });
58
+
59
+ test("multiple subscribers all receive the same transform", () => {
60
+ const cb1 = vi.fn();
61
+ const cb2 = vi.fn();
62
+
63
+ zi.subscribe(cb1);
64
+ zi.subscribe(cb2);
65
+ zi.updateTransform();
66
+
67
+ expect(cb1).toHaveBeenCalledWith({ x: 0, y: 0, scale: 1 });
68
+ expect(cb2).toHaveBeenCalledWith({ x: 0, y: 0, scale: 1 });
69
+ });
70
+
71
+ test("notify delivers arbitrary transform values to subscribers", () => {
72
+ const cb = vi.fn();
73
+ zi.subscribe(cb);
74
+
75
+ zi.notify({ x: 15, y: -20, scale: 2.5 });
76
+
77
+ expect(cb).toHaveBeenCalledWith({ x: 15, y: -20, scale: 2.5 });
78
+ });
79
+
80
+ test("destroy does not throw", () => {
81
+ expect(() => zi.destroy()).not.toThrow();
82
+ });
83
+
84
+ test("reset_zoom after destroy does not notify destroyed listeners", () => {
85
+ const cb = vi.fn();
86
+ zi.subscribe(cb);
87
+
88
+ zi.destroy();
89
+
90
+ // After destroy, event listeners are removed but subscribers list is not cleared.
91
+ // reset_zoom still calls notify internally, so we verify the subscriber still fires
92
+ // (destroy only removes DOM listeners, not the in-memory subscriber list).
93
+ zi.reset_zoom();
94
+ expect(cb).toHaveBeenCalledWith({ x: 0, y: 0, scale: 1 });
95
+ });
96
+
97
+ test("compute_new_offset scales offset correctly relative to cursor", () => {
98
+ const result = zi.compute_new_offset({
99
+ cursor_position: 100,
100
+ current_offset: 0,
101
+ new_scale: 2,
102
+ old_scale: 1
103
+ });
104
+
105
+ // cursor_position - (new_scale / old_scale) * (cursor_position - current_offset)
106
+ // = 100 - 2 * (100 - 0) = 100 - 200 = -100
107
+ expect(result).toBe(-100);
108
+ });
109
+
110
+ test("compute_new_offset accounts for existing offset", () => {
111
+ const result = zi.compute_new_offset({
112
+ cursor_position: 100,
113
+ current_offset: 50,
114
+ new_scale: 2,
115
+ old_scale: 1
116
+ });
117
+
118
+ // = 100 - 2 * (100 - 50) = 100 - 100 = 0
119
+ expect(result).toBe(0);
120
+ });
121
+ });