Skip to content

Commit 067d647

Browse files
authored
chore(modal): update to svelte 5 runes apis (#2017)
1 parent 6712bc2 commit 067d647

File tree

4 files changed

+136
-107
lines changed

4 files changed

+136
-107
lines changed

.changeset/tiny-deer-travel.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@stackoverflow/stacks-svelte": minor
3+
---
4+
5+
Migrate `Modal` component to use Svelte 5 runes API.
6+
7+
BREAKING CHANGES:
8+
- Named slots (`header`, `body`, `footer`) are replaced by snippet props. Use `{#snippet header()}...{/snippet}` instead of `<svelte:fragment slot="header">...</svelte:fragment>`.
9+
- `on:close` event is replaced by `onclose` callback prop.
10+

packages/stacks-svelte/src/components/Modal/Modal.stories.svelte

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
</script>
1919

2020
<script lang="ts">
21-
let visible = false;
22-
let state: State = "";
21+
let mstate = $state<State>("");
22+
let visible = $state(false);
2323
</script>
2424

2525
<Story name="Base" args={{ id: "base-modal" }}>
@@ -38,19 +38,24 @@
3838
visible={visible || args.visible}
3939
state={args.state}
4040
class={args.class}
41-
on:close={() => (visible = false)}
41+
onclose={() => (visible = false)}
4242
preventCloseOnClickOutside={args.preventCloseOnClickOutside}
4343
hideCloseButton={args.hideCloseButton}
4444
>
45-
<svelte:fragment slot="header">Modal Header</svelte:fragment>
46-
<p slot="body">
47-
Nullam ornare lectus vitae lacus sagittis, at sodales leo
48-
viverra. Suspendisse nec dignissim elit varius tempus. Cras
49-
viverra neque at imperdiet vehicula. Curabitur condimentum id
50-
dolor vitae ultrices. Pellentesque scelerisque nunc sit amet leo
51-
fringilla. Etiam feugiat imperdiet mi, eu blandit arcu cursus a.
52-
</p>
53-
<svelte:fragment slot="footer">
45+
{#snippet header()}
46+
Modal Header
47+
{/snippet}
48+
{#snippet body()}
49+
<p>
50+
Nullam ornare lectus vitae lacus sagittis, at sodales leo
51+
viverra. Suspendisse nec dignissim elit varius tempus. Cras
52+
viverra neque at imperdiet vehicula. Curabitur condimentum
53+
id dolor vitae ultrices. Pellentesque scelerisque nunc sit
54+
amet leo fringilla. Etiam feugiat imperdiet mi, eu blandit
55+
arcu cursus a.
56+
</p>
57+
{/snippet}
58+
{#snippet footer()}
5459
<Button
5560
variant={args.state === "danger" ? "danger" : ""}
5661
weight="filled"
@@ -63,7 +68,7 @@
6368
>
6469
Cancel
6570
</Button>
66-
</svelte:fragment>
71+
{/snippet}
6772
</Modal>
6873
{/snippet}
6974
</Story>
@@ -75,7 +80,7 @@
7580
weight="filled"
7681
onclick={() => {
7782
visible = true;
78-
state = "";
83+
mstate = "";
7984
}}
8085
>
8186
Default
@@ -85,7 +90,7 @@
8590
weight="filled"
8691
onclick={() => {
8792
visible = true;
88-
state = "danger";
93+
mstate = "danger";
8994
}}
9095
>
9196
Danger
@@ -94,36 +99,45 @@
9499
weight="outlined"
95100
onclick={() => {
96101
visible = true;
97-
state = "celebration";
102+
mstate = "celebration";
98103
}}
99104
>
100105
Celebration
101106
</Button>
102107
</div>
103108
</div>
104109

105-
<Modal id="states" {visible} {state} on:close={() => (visible = false)}>
106-
<svelte:fragment slot="header">Modal Header</svelte:fragment>
107-
<p slot="body">
108-
Nullam ornare lectus vitae lacus sagittis, at sodales leo viverra.
109-
Suspendisse nec dignissim elit varius tempus. Cras viverra neque at
110-
imperdiet vehicula. Curabitur condimentum id dolor vitae ultrices.
111-
Pellentesque scelerisque nunc sit amet leo fringilla. Etiam feugiat
112-
imperdiet mi, eu blandit arcu cursus a.
113-
</p>
114-
<svelte:fragment slot="footer">
110+
<Modal
111+
id="states"
112+
{visible}
113+
state={mstate}
114+
onclose={() => (visible = false)}
115+
>
116+
{#snippet header()}
117+
Modal Header
118+
{/snippet}
119+
{#snippet body()}
120+
<p>
121+
Nullam ornare lectus vitae lacus sagittis, at sodales leo
122+
viverra. Suspendisse nec dignissim elit varius tempus. Cras
123+
viverra neque at imperdiet vehicula. Curabitur condimentum id
124+
dolor vitae ultrices. Pellentesque scelerisque nunc sit amet leo
125+
fringilla. Etiam feugiat imperdiet mi, eu blandit arcu cursus a.
126+
</p>
127+
{/snippet}
128+
{#snippet footer()}
115129
<Button
116-
variant={state === "danger" ? "danger" : ""}
130+
variant={mstate === "danger" ? "danger" : ""}
117131
weight="filled"
118132
>
119133
Save changes
120134
</Button>
121135
<Button
122-
variant={state === "danger" ? "muted" : ""}
136+
variant={mstate === "danger" ? "muted" : ""}
123137
onclick={() => (visible = false)}
124138
>
125139
Cancel
126140
</Button>
127-
</svelte:fragment>
141+
{/snippet}
128142
</Modal>
129143
</Story>

packages/stacks-svelte/src/components/Modal/Modal.svelte

Lines changed: 70 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,53 +3,74 @@
33
</script>
44

55
<script lang="ts">
6-
import { createEventDispatcher } from "svelte";
6+
import type { Snippet } from "svelte";
77
import { IconClear } from "@stackoverflow/stacks-icons/icons";
88
99
import { Button, Icon } from "../../components";
1010
import { clickOutside, focusTrap } from "../../actions";
1111
12-
/**
13-
* The html id attribute for the modal (required)
14-
* @type {string}
15-
*/
16-
export let id: string;
17-
18-
/**
19-
* Boolean controlling the visibility of the modal
20-
*/
21-
export let visible = false;
22-
23-
/**
24-
* The state of the modal
25-
* @type {"" | "danger" | "celebration"}
26-
*/
27-
export let state: State = "";
28-
29-
/**
30-
* Additional CSS classes added to the element
31-
*/
32-
let className = "";
33-
export { className as class };
34-
35-
/**
36-
* Localized translation for the close button aria label
37-
*/
38-
export let i18nCloseButtonLabel = "Close";
39-
40-
/**
41-
* Boolean controlling whether or not the modal should close when the user clicks outside.
42-
* This is an escape hatch, please consider whether use of this prop is absolutely necessary!
43-
*/
44-
export let preventCloseOnClickOutside = false;
45-
46-
/**
47-
* Boolean controlling display of the modal close button.
48-
* This is an escape hatch, please consider whether use of this prop is absolutely necessary!
49-
*/
50-
export let hideCloseButton = false;
51-
52-
$: modalClasses = getClasses(className);
12+
interface Props {
13+
/**
14+
* The html id attribute for the modal (required)
15+
*/
16+
id: string;
17+
/**
18+
* Boolean controlling the visibility of the modal
19+
*/
20+
visible?: boolean;
21+
/**
22+
* The state of the modal
23+
*/
24+
state?: State;
25+
/**
26+
* Additional CSS classes added to the element
27+
*/
28+
class?: string;
29+
/**
30+
* Localized translation for the close button aria label
31+
*/
32+
i18nCloseButtonLabel?: string;
33+
/**
34+
* Boolean controlling whether or not the modal should close when the user clicks outside.
35+
* This is an escape hatch, please consider whether use of this prop is absolutely necessary!
36+
*/
37+
preventCloseOnClickOutside?: boolean;
38+
/**
39+
* Boolean controlling display of the modal close button.
40+
* This is an escape hatch, please consider whether use of this prop is absolutely necessary!
41+
*/
42+
hideCloseButton?: boolean;
43+
/**
44+
* Callback fired when the modal is closed
45+
*/
46+
onclose?: () => void;
47+
/**
48+
* Content rendered in the modal header section
49+
*/
50+
header?: Snippet;
51+
/**
52+
* Content rendered in the modal body section
53+
*/
54+
body?: Snippet;
55+
/**
56+
* Content rendered in the modal footer section
57+
*/
58+
footer?: Snippet;
59+
}
60+
61+
let {
62+
id,
63+
visible = false,
64+
state = "",
65+
class: className = "",
66+
i18nCloseButtonLabel = "Close",
67+
preventCloseOnClickOutside = false,
68+
hideCloseButton = false,
69+
onclose,
70+
header,
71+
body,
72+
footer,
73+
}: Props = $props();
5374
5475
const getClasses = (className: string) => {
5576
let classes = "s-modal--dialog";
@@ -60,13 +81,12 @@
6081
6182
return classes;
6283
};
63-
64-
const dispatch = createEventDispatcher<{ close: void }>();
84+
const modalClasses = $derived(getClasses(className));
6585
6686
const close = () => {
6787
if (visible) {
6888
visible = false;
69-
dispatch("close");
89+
onclose?.();
7090
}
7191
};
7292
@@ -77,7 +97,7 @@
7797
};
7898
</script>
7999

80-
<svelte:window on:keydown={keyPress} />
100+
<svelte:window onkeydown={keyPress} />
81101

82102
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
83103
<aside
@@ -97,17 +117,14 @@
97117
onoutclick={() => !preventCloseOnClickOutside && close()}
98118
>
99119
<h1 id={`${id}-title`} class="s-modal--header">
100-
<!-- Content slotted in the Modal header section -->
101-
<slot name="header" />
120+
{@render header?.()}
102121
</h1>
103122
<div id={`${id}-description`} class="s-modal--body">
104-
<!-- Content slotted in the Modal body section -->
105-
<slot name="body" />
123+
{@render body?.()}
106124
</div>
107-
{#if $$slots.footer}
125+
{#if footer}
108126
<div class="d-flex g8 s-modal--footer">
109-
<!-- Content slotted in the Modal footer section -->
110-
<slot name="footer" />
127+
{@render footer?.()}
111128
</div>
112129
{/if}
113130
{#if !hideCloseButton}

packages/stacks-svelte/src/components/Modal/Modal.test.ts

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ describe("Modal", () => {
2424
render(Modal, {
2525
id: "test-modal",
2626
visible: true,
27-
// @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax
28-
$$slots: { header: snippet },
27+
header: snippet,
2928
});
3029
const dialog = screen.getByRole("dialog", {
3130
name: "test snippet", // header content is used as label for the dialog
@@ -38,8 +37,7 @@ describe("Modal", () => {
3837
render(Modal, {
3938
id: "test-modal",
4039
visible: true,
41-
// @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax
42-
$$slots: { body: snippet },
40+
body: snippet,
4341
});
4442
const dialog = screen.getByRole("dialog", {
4543
description: "test snippet", // body content is used as description for the dialog
@@ -52,8 +50,7 @@ describe("Modal", () => {
5250
render(Modal, {
5351
id: "test-modal",
5452
visible: true,
55-
// @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax
56-
$$slots: { footer: snippet },
53+
footer: snippet,
5754
});
5855
expectModalVisible(true);
5956
expect(screen.getByText("test snippet")).to.exist;
@@ -154,10 +151,7 @@ describe("Modal", () => {
154151
render(Modal, {
155152
id: "test-modal",
156153
visible: true,
157-
// @ts-expect-error events are not yet typed in the component
158-
$$events: {
159-
close: onClose,
160-
},
154+
onclose: onClose,
161155
});
162156

163157
const closeButton = await screen.getByRole("button");
@@ -171,10 +165,7 @@ describe("Modal", () => {
171165
render(Modal, {
172166
id: "test-modal",
173167
visible: false,
174-
// @ts-expect-error events are not yet typed in the component
175-
$$events: {
176-
close: onClose,
177-
},
168+
onclose: onClose,
178169
});
179170

180171
const outsideModal = document.body;
@@ -206,17 +197,14 @@ describe("Modal", () => {
206197
const { rerender } = render(Modal, {
207198
id: "test-modal",
208199
visible: false,
209-
// @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax
210-
$$slots: {
211-
body: createRawSnippet(() => ({
212-
render: () => `
213-
<span>
214-
<button>inside modal first</button>
215-
<button>inside modal second</button>
216-
</span>
217-
`,
218-
})),
219-
},
200+
body: createRawSnippet(() => ({
201+
render: () => `
202+
<span>
203+
<button>inside modal first</button>
204+
<button>inside modal second</button>
205+
</span>
206+
`,
207+
})),
220208
});
221209

222210
expectModalVisible(false);

0 commit comments

Comments
 (0)