@gradio/tabs 0.5.10 → 0.6.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/tabs
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Features
6
+
7
+ - [#13467](https://github.com/gradio-app/gradio/pull/13467) [`feba2e1`](https://github.com/gradio-app/gradio/commit/feba2e1e41c68f4bbdacf4c963d8d9689d4b3346) - `Tab`, `TabItem`, `Plot` Unit tests. Thanks @dawoodkhan82!
8
+
9
+ ## 0.5.11
10
+
11
+ ### Fixes
12
+
13
+ - [#13240](https://github.com/gradio-app/gradio/pull/13240) [`0d670ad`](https://github.com/gradio-app/gradio/commit/0d670adf41a0b510f7fd745495dce1664d38f0e5) - Fix browser freeze when a dataframe's value is set (e.g. via a tab select event), and only dispatch the tabs select event when the selected tab actually changes. Thanks @freddyaboulton!
14
+
3
15
  ## 0.5.10
4
16
 
5
17
  ### Fixes
package/Index.svelte CHANGED
@@ -7,24 +7,26 @@
7
7
  import Tabs from "./shared/Tabs.svelte";
8
8
  import Walkthrough from "./shared/Walkthrough.svelte";
9
9
  import type { TabsProps, TabsEvents } from "./types";
10
- import { untrack } from "svelte";
11
10
 
12
11
  let props = $props();
13
12
  const gradio = new Gradio<TabsEvents, TabsProps>(props);
14
13
 
14
+ let old_selected = gradio.props.selected;
15
+
15
16
  $effect(() => {
16
- if (gradio.props.selected) {
17
- untrack(() => {
18
- const i = gradio.props.initial_tabs.findIndex(
19
- (t) => t.id === gradio.props.selected
20
- );
17
+ if (old_selected !== gradio.props.selected) {
18
+ const i = gradio.props.initial_tabs.findIndex(
19
+ (t) => t.id === gradio.props.selected
20
+ );
21
+ if (i >= 0) {
21
22
  gradio.dispatch("gradio_tab_select", {
22
23
  value: gradio.props.initial_tabs[i].label,
23
24
  index: i,
24
25
  id: gradio.props.initial_tabs[i].id,
25
26
  component_id: gradio.props.initial_tabs[i].component_id
26
27
  });
27
- });
28
+ }
29
+ old_selected = gradio.props.selected;
28
30
  }
29
31
  });
30
32
  </script>
package/Tabs.test.ts ADDED
@@ -0,0 +1,269 @@
1
+ import { test, describe, afterEach, expect } from "vitest";
2
+ import { cleanup, render, fireEvent } from "@self/tootils/render";
3
+ import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
4
+ import type { Tab } from "./shared/Tabs.svelte";
5
+
6
+ import Tabs from "./Index.svelte";
7
+ import TabsWithChild from "./WithChild.svelte";
8
+
9
+ const make_tab = (over: Partial<Tab>): Tab => ({
10
+ label: "Tab",
11
+ id: "t1",
12
+ elem_id: undefined,
13
+ visible: true,
14
+ interactive: true,
15
+ scale: null,
16
+ component_id: 1,
17
+ ...over
18
+ });
19
+
20
+ const tabs: Tab[] = [
21
+ make_tab({ label: "First", id: "t1", component_id: 1 }),
22
+ make_tab({ label: "Second", id: "t2", component_id: 2 }),
23
+ make_tab({ label: "Third", id: "t3", component_id: 3 })
24
+ ];
25
+
26
+ const default_props = {
27
+ initial_tabs: tabs,
28
+ selected: "t1",
29
+ name: "tabs" as const,
30
+ visible: true
31
+ };
32
+
33
+ run_shared_prop_tests({
34
+ component: Tabs,
35
+ name: "Tabs",
36
+ base_props: { initial_tabs: tabs, selected: "t1", name: "tabs" },
37
+ has_label: false,
38
+ has_validation_error: false,
39
+ has_block_wrapper: false,
40
+ visible_false_hides: true
41
+ });
42
+
43
+ describe("Tabs", () => {
44
+ afterEach(() => cleanup());
45
+
46
+ test("renders a tab button for each visible tab", async () => {
47
+ const { getByRole } = await render(Tabs, default_props);
48
+
49
+ expect(getByRole("tab", { name: "First" })).toBeInTheDocument();
50
+ expect(getByRole("tab", { name: "Second" })).toBeInTheDocument();
51
+ expect(getByRole("tab", { name: "Third" })).toBeInTheDocument();
52
+ });
53
+
54
+ test("does not render a button for a tab with visible: false", async () => {
55
+ const { queryByRole } = await render(Tabs, {
56
+ ...default_props,
57
+ initial_tabs: [
58
+ make_tab({ label: "Shown", id: "t1", component_id: 1 }),
59
+ make_tab({ label: "Gone", id: "t2", visible: false, component_id: 2 })
60
+ ]
61
+ });
62
+
63
+ expect(queryByRole("tab", { name: "Gone" })).toBeNull();
64
+ expect(queryByRole("tab", { name: "Shown" })).toBeInTheDocument();
65
+ });
66
+
67
+ test("the selected tab is marked aria-selected", async () => {
68
+ const { getByRole } = await render(Tabs, {
69
+ ...default_props,
70
+ selected: "t2"
71
+ });
72
+
73
+ expect(getByRole("tab", { name: "Second" })).toHaveAttribute(
74
+ "aria-selected",
75
+ "true"
76
+ );
77
+ expect(getByRole("tab", { name: "First" })).toHaveAttribute(
78
+ "aria-selected",
79
+ "false"
80
+ );
81
+ });
82
+ });
83
+
84
+ describe("Props: interactive", () => {
85
+ afterEach(() => cleanup());
86
+
87
+ test("a non-interactive tab is disabled", async () => {
88
+ const { getByRole } = await render(Tabs, {
89
+ ...default_props,
90
+ initial_tabs: [
91
+ make_tab({ label: "Active", id: "t1", component_id: 1 }),
92
+ make_tab({
93
+ label: "Locked",
94
+ id: "t2",
95
+ interactive: false,
96
+ component_id: 2
97
+ })
98
+ ]
99
+ });
100
+
101
+ expect(getByRole("tab", { name: "Locked" })).toBeDisabled();
102
+ expect(getByRole("tab", { name: "Active" })).toBeEnabled();
103
+ });
104
+ });
105
+
106
+ describe("Events: select", () => {
107
+ afterEach(() => cleanup());
108
+
109
+ test("clicking a tab dispatches select with its details", async () => {
110
+ const { listen, getByRole } = await render(Tabs, default_props);
111
+
112
+ const select = listen("select");
113
+
114
+ await fireEvent.click(getByRole("tab", { name: "Second" }));
115
+
116
+ expect(select).toHaveBeenCalledTimes(1);
117
+ expect(select).toHaveBeenCalledWith({
118
+ value: "Second",
119
+ index: 1,
120
+ id: "t2",
121
+ component_id: 2
122
+ });
123
+ });
124
+
125
+ test("clicking the already-selected tab does not dispatch select", async () => {
126
+ const { listen, getByRole } = await render(Tabs, default_props);
127
+
128
+ const select = listen("select");
129
+
130
+ await fireEvent.click(getByRole("tab", { name: "First" }));
131
+
132
+ expect(select).not.toHaveBeenCalled();
133
+ });
134
+
135
+ test("clicking a disabled tab does not make it the selected tab", async () => {
136
+ const { listen, get_data, getByRole } = await render(Tabs, {
137
+ ...default_props,
138
+ selected: "t1",
139
+ initial_tabs: [
140
+ make_tab({ label: "Active", id: "t1", component_id: 1 }),
141
+ make_tab({
142
+ label: "Locked",
143
+ id: "t2",
144
+ interactive: false,
145
+ component_id: 2
146
+ })
147
+ ]
148
+ });
149
+
150
+ const change = listen("change");
151
+
152
+ await fireEvent.click(getByRole("tab", { name: "Locked" }));
153
+
154
+ expect(change).not.toHaveBeenCalled();
155
+ expect(getByRole("tab", { name: "Locked" })).toHaveAttribute(
156
+ "aria-selected",
157
+ "false"
158
+ );
159
+ expect((await get_data()).selected).toBe("t1");
160
+ });
161
+ });
162
+
163
+ describe("Events: change", () => {
164
+ afterEach(() => cleanup());
165
+
166
+ test("clicking a different tab dispatches change once", async () => {
167
+ const { listen, getByRole } = await render(Tabs, default_props);
168
+
169
+ const change = listen("change");
170
+
171
+ await fireEvent.click(getByRole("tab", { name: "Third" }));
172
+
173
+ expect(change).toHaveBeenCalledTimes(1);
174
+ });
175
+
176
+ test("clicking the already-selected tab does not dispatch change", async () => {
177
+ const { listen, getByRole } = await render(Tabs, default_props);
178
+
179
+ const change = listen("change");
180
+
181
+ await fireEvent.click(getByRole("tab", { name: "First" }));
182
+
183
+ expect(change).not.toHaveBeenCalled();
184
+ });
185
+ });
186
+
187
+ describe("Events: gradio_tab_select", () => {
188
+ afterEach(() => cleanup());
189
+
190
+ test("fires when the selected tab changes via set_data", async () => {
191
+ const { listen, set_data } = await render(Tabs, default_props);
192
+
193
+ const gradio_tab_select = listen("gradio_tab_select");
194
+
195
+ await set_data({ selected: "t3" });
196
+
197
+ expect(gradio_tab_select).toHaveBeenCalledTimes(1);
198
+ expect(gradio_tab_select).toHaveBeenCalledWith({
199
+ value: "Third",
200
+ index: 2,
201
+ id: "t3",
202
+ component_id: 3
203
+ });
204
+ });
205
+ });
206
+
207
+ describe("get_data / set_data", () => {
208
+ afterEach(() => cleanup());
209
+
210
+ test("get_data returns the initial selection", async () => {
211
+ const { get_data } = await render(Tabs, default_props);
212
+
213
+ const data = await get_data();
214
+ expect(data.selected).toBe("t1");
215
+ });
216
+
217
+ test("clicking a tab updates the selection returned by get_data", async () => {
218
+ const { get_data, getByRole } = await render(Tabs, default_props);
219
+
220
+ await fireEvent.click(getByRole("tab", { name: "Second" }));
221
+
222
+ const data = await get_data();
223
+ expect(data.selected).toBe("t2");
224
+ });
225
+
226
+ test("set_data updates which tab is marked aria-selected", async () => {
227
+ const { set_data, getByRole } = await render(Tabs, default_props);
228
+
229
+ await set_data({ selected: "t3" });
230
+
231
+ expect(getByRole("tab", { name: "Third" })).toHaveAttribute(
232
+ "aria-selected",
233
+ "true"
234
+ );
235
+ });
236
+
237
+ test("set_data then get_data round-trips the selection", async () => {
238
+ const { set_data, get_data } = await render(Tabs, default_props);
239
+
240
+ await set_data({ selected: "t2" });
241
+
242
+ const data = await get_data();
243
+ expect(data.selected).toBe("t2");
244
+ });
245
+ });
246
+
247
+ describe("Children / slot", () => {
248
+ afterEach(() => cleanup());
249
+
250
+ test("renders slot children inside the tabs container", async () => {
251
+ const { getByTestId } = await render(TabsWithChild, default_props);
252
+
253
+ expect(getByTestId("slot-content")).toBeVisible();
254
+ });
255
+ });
256
+
257
+ describe("Edge cases", () => {
258
+ afterEach(() => cleanup());
259
+
260
+ test("no select or change events fire on initial mount", async () => {
261
+ const { listen } = await render(Tabs, default_props);
262
+
263
+ const select = listen("select", { retrospective: true });
264
+ const change = listen("change", { retrospective: true });
265
+
266
+ expect(select).not.toHaveBeenCalled();
267
+ expect(change).not.toHaveBeenCalled();
268
+ });
269
+ });
@@ -0,0 +1,8 @@
1
+ <script lang="ts">
2
+ import Tabs from "./Index.svelte";
3
+ let props = $props();
4
+ </script>
5
+
6
+ <Tabs {...props}>
7
+ <div data-testid="slot-content">tab panel content</div>
8
+ </Tabs>
package/dist/Index.svelte CHANGED
@@ -7,24 +7,26 @@
7
7
  import Tabs from "./shared/Tabs.svelte";
8
8
  import Walkthrough from "./shared/Walkthrough.svelte";
9
9
  import type { TabsProps, TabsEvents } from "./types";
10
- import { untrack } from "svelte";
11
10
 
12
11
  let props = $props();
13
12
  const gradio = new Gradio<TabsEvents, TabsProps>(props);
14
13
 
14
+ let old_selected = gradio.props.selected;
15
+
15
16
  $effect(() => {
16
- if (gradio.props.selected) {
17
- untrack(() => {
18
- const i = gradio.props.initial_tabs.findIndex(
19
- (t) => t.id === gradio.props.selected
20
- );
17
+ if (old_selected !== gradio.props.selected) {
18
+ const i = gradio.props.initial_tabs.findIndex(
19
+ (t) => t.id === gradio.props.selected
20
+ );
21
+ if (i >= 0) {
21
22
  gradio.dispatch("gradio_tab_select", {
22
23
  value: gradio.props.initial_tabs[i].label,
23
24
  index: i,
24
25
  id: gradio.props.initial_tabs[i].id,
25
26
  component_id: gradio.props.initial_tabs[i].component_id
26
27
  });
27
- });
28
+ }
29
+ old_selected = gradio.props.selected;
28
30
  }
29
31
  });
30
32
  </script>
@@ -0,0 +1,8 @@
1
+ <script lang="ts">
2
+ import Tabs from "./Index.svelte";
3
+ let props = $props();
4
+ </script>
5
+
6
+ <Tabs {...props}>
7
+ <div data-testid="slot-content">tab panel content</div>
8
+ </Tabs>
@@ -0,0 +1,3 @@
1
+ declare const WithChild: import("svelte").Component<$$ComponentProps, {}, "">;
2
+ type WithChild = ReturnType<typeof WithChild>;
3
+ export default WithChild;
@@ -37,16 +37,20 @@
37
37
 
38
38
  // When initial_tabs changes (e.g. a non-mounted tab's props were updated),
39
39
  // sync the internal tabs array so the tab buttons reflect the new state.
40
- // Using a function call so the $: dependency is only on initial_tabs,
41
- // not on tabs (which would cause a loop with register_tab).
40
+ // The tabs mutation is deferred via tick() because in Svelte 5 legacy mode
41
+ // $: effects track all reads inside called functions writing tabs[i]
42
+ // through the $state proxy would track `tabs` as a dependency, creating
43
+ // an infinite self-triggering loop.
42
44
  $: _sync_tabs(initial_tabs);
43
45
 
44
46
  function _sync_tabs(new_tabs: Tab[]): void {
45
- for (let i = 0; i < new_tabs.length; i++) {
46
- if (new_tabs[i] && !mounted_tab_orders.has(i)) {
47
- tabs[i] = new_tabs[i];
47
+ tick().then(() => {
48
+ for (let i = 0; i < new_tabs.length; i++) {
49
+ if (new_tabs[i] && !mounted_tab_orders.has(i)) {
50
+ tabs[i] = new_tabs[i];
51
+ }
48
52
  }
49
- }
53
+ });
50
54
  }
51
55
 
52
56
  $: has_tabs = tabs.length > 0;
@@ -138,6 +142,8 @@
138
142
  await tick();
139
143
  await new Promise((r) => requestAnimationFrame(r));
140
144
 
145
+ if (!tab_nav_el) return;
146
+
141
147
  const available = tab_nav_el.clientWidth;
142
148
 
143
149
  let cumulative = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradio/tabs",
3
- "version": "0.5.10",
3
+ "version": "0.6.0",
4
4
  "description": "Gradio UI packages",
5
5
  "type": "module",
6
6
  "author": "",
@@ -37,16 +37,20 @@
37
37
 
38
38
  // When initial_tabs changes (e.g. a non-mounted tab's props were updated),
39
39
  // sync the internal tabs array so the tab buttons reflect the new state.
40
- // Using a function call so the $: dependency is only on initial_tabs,
41
- // not on tabs (which would cause a loop with register_tab).
40
+ // The tabs mutation is deferred via tick() because in Svelte 5 legacy mode
41
+ // $: effects track all reads inside called functions writing tabs[i]
42
+ // through the $state proxy would track `tabs` as a dependency, creating
43
+ // an infinite self-triggering loop.
42
44
  $: _sync_tabs(initial_tabs);
43
45
 
44
46
  function _sync_tabs(new_tabs: Tab[]): void {
45
- for (let i = 0; i < new_tabs.length; i++) {
46
- if (new_tabs[i] && !mounted_tab_orders.has(i)) {
47
- tabs[i] = new_tabs[i];
47
+ tick().then(() => {
48
+ for (let i = 0; i < new_tabs.length; i++) {
49
+ if (new_tabs[i] && !mounted_tab_orders.has(i)) {
50
+ tabs[i] = new_tabs[i];
51
+ }
48
52
  }
49
- }
53
+ });
50
54
  }
51
55
 
52
56
  $: has_tabs = tabs.length > 0;
@@ -138,6 +142,8 @@
138
142
  await tick();
139
143
  await new Promise((r) => requestAnimationFrame(r));
140
144
 
145
+ if (!tab_nav_el) return;
146
+
141
147
  const available = tab_nav_el.clientWidth;
142
148
 
143
149
  let cumulative = 0;