Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/tiny-deer-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@stackoverflow/stacks-svelte": minor
---

Migrate `Modal` component to use Svelte 5 runes API.

BREAKING CHANGES:
- Named slots (`header`, `body`, `footer`) are replaced by snippet props. Use `{#snippet header()}...{/snippet}` instead of `<svelte:fragment slot="header">...</svelte:fragment>`.
- `on:close` event is replaced by `onclose` callback prop.

72 changes: 43 additions & 29 deletions packages/stacks-svelte/src/components/Modal/Modal.stories.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
</script>

<script lang="ts">
let visible = false;
let state: State = "";
let mstate = $state<State>("");
let visible = $state(false);
</script>

<Story name="Base" args={{ id: "base-modal" }}>
Expand All @@ -38,19 +38,24 @@
visible={visible || args.visible}
state={args.state}
class={args.class}
on:close={() => (visible = false)}
onclose={() => (visible = false)}
preventCloseOnClickOutside={args.preventCloseOnClickOutside}
hideCloseButton={args.hideCloseButton}
>
<svelte:fragment slot="header">Modal Header</svelte:fragment>
<p slot="body">
Nullam ornare lectus vitae lacus sagittis, at sodales leo
viverra. Suspendisse nec dignissim elit varius tempus. Cras
viverra neque at imperdiet vehicula. Curabitur condimentum id
dolor vitae ultrices. Pellentesque scelerisque nunc sit amet leo
fringilla. Etiam feugiat imperdiet mi, eu blandit arcu cursus a.
</p>
<svelte:fragment slot="footer">
{#snippet header()}
Modal Header
{/snippet}
{#snippet body()}
<p>
Nullam ornare lectus vitae lacus sagittis, at sodales leo
viverra. Suspendisse nec dignissim elit varius tempus. Cras
viverra neque at imperdiet vehicula. Curabitur condimentum
id dolor vitae ultrices. Pellentesque scelerisque nunc sit
amet leo fringilla. Etiam feugiat imperdiet mi, eu blandit
arcu cursus a.
</p>
{/snippet}
{#snippet footer()}
<Button
variant={args.state === "danger" ? "danger" : ""}
weight="filled"
Expand All @@ -63,7 +68,7 @@
>
Cancel
</Button>
</svelte:fragment>
{/snippet}
</Modal>
{/snippet}
</Story>
Expand All @@ -75,7 +80,7 @@
weight="filled"
onclick={() => {
visible = true;
state = "";
mstate = "";
}}
>
Default
Expand All @@ -85,7 +90,7 @@
weight="filled"
onclick={() => {
visible = true;
state = "danger";
mstate = "danger";
}}
>
Danger
Expand All @@ -94,36 +99,45 @@
weight="outlined"
onclick={() => {
visible = true;
state = "celebration";
mstate = "celebration";
}}
>
Celebration
</Button>
</div>
</div>

<Modal id="states" {visible} {state} on:close={() => (visible = false)}>
<svelte:fragment slot="header">Modal Header</svelte:fragment>
<p slot="body">
Nullam ornare lectus vitae lacus sagittis, at sodales leo viverra.
Suspendisse nec dignissim elit varius tempus. Cras viverra neque at
imperdiet vehicula. Curabitur condimentum id dolor vitae ultrices.
Pellentesque scelerisque nunc sit amet leo fringilla. Etiam feugiat
imperdiet mi, eu blandit arcu cursus a.
</p>
<svelte:fragment slot="footer">
<Modal
id="states"
{visible}
state={mstate}
onclose={() => (visible = false)}
>
{#snippet header()}
Modal Header
{/snippet}
{#snippet body()}
<p>
Nullam ornare lectus vitae lacus sagittis, at sodales leo
viverra. Suspendisse nec dignissim elit varius tempus. Cras
viverra neque at imperdiet vehicula. Curabitur condimentum id
dolor vitae ultrices. Pellentesque scelerisque nunc sit amet leo
fringilla. Etiam feugiat imperdiet mi, eu blandit arcu cursus a.
</p>
{/snippet}
{#snippet footer()}
<Button
variant={state === "danger" ? "danger" : ""}
variant={mstate === "danger" ? "danger" : ""}
weight="filled"
>
Save changes
</Button>
<Button
variant={state === "danger" ? "muted" : ""}
variant={mstate === "danger" ? "muted" : ""}
onclick={() => (visible = false)}
>
Cancel
</Button>
</svelte:fragment>
{/snippet}
</Modal>
</Story>
123 changes: 70 additions & 53 deletions packages/stacks-svelte/src/components/Modal/Modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,74 @@
</script>

<script lang="ts">
import { createEventDispatcher } from "svelte";
import type { Snippet } from "svelte";
import { IconClear } from "@stackoverflow/stacks-icons/icons";

import { Button, Icon } from "../../components";
import { clickOutside, focusTrap } from "../../actions";

/**
* The html id attribute for the modal (required)
* @type {string}
*/
export let id: string;

/**
* Boolean controlling the visibility of the modal
*/
export let visible = false;

/**
* The state of the modal
* @type {"" | "danger" | "celebration"}
*/
export let state: State = "";

/**
* Additional CSS classes added to the element
*/
let className = "";
export { className as class };

/**
* Localized translation for the close button aria label
*/
export let i18nCloseButtonLabel = "Close";

/**
* Boolean controlling whether or not the modal should close when the user clicks outside.
* This is an escape hatch, please consider whether use of this prop is absolutely necessary!
*/
export let preventCloseOnClickOutside = false;

/**
* Boolean controlling display of the modal close button.
* This is an escape hatch, please consider whether use of this prop is absolutely necessary!
*/
export let hideCloseButton = false;

$: modalClasses = getClasses(className);
interface Props {
/**
* The html id attribute for the modal (required)
*/
id: string;
/**
* Boolean controlling the visibility of the modal
*/
visible?: boolean;
/**
* The state of the modal
*/
state?: State;
/**
* Additional CSS classes added to the element
*/
class?: string;
/**
* Localized translation for the close button aria label
*/
i18nCloseButtonLabel?: string;
/**
* Boolean controlling whether or not the modal should close when the user clicks outside.
* This is an escape hatch, please consider whether use of this prop is absolutely necessary!
*/
preventCloseOnClickOutside?: boolean;
/**
* Boolean controlling display of the modal close button.
* This is an escape hatch, please consider whether use of this prop is absolutely necessary!
*/
hideCloseButton?: boolean;
/**
* Callback fired when the modal is closed
*/
onclose?: () => void;
/**
* Content rendered in the modal header section
*/
header?: Snippet;
/**
* Content rendered in the modal body section
*/
body?: Snippet;
/**
* Content rendered in the modal footer section
*/
footer?: Snippet;
}

let {
id,
visible = false,
state = "",
class: className = "",
i18nCloseButtonLabel = "Close",
preventCloseOnClickOutside = false,
hideCloseButton = false,
onclose,
header,
body,
footer,
}: Props = $props();

const getClasses = (className: string) => {
let classes = "s-modal--dialog";
Expand All @@ -60,13 +81,12 @@

return classes;
};

const dispatch = createEventDispatcher<{ close: void }>();
const modalClasses = $derived(getClasses(className));

const close = () => {
if (visible) {
visible = false;
dispatch("close");
onclose?.();
}
};

Expand All @@ -77,7 +97,7 @@
};
</script>

<svelte:window on:keydown={keyPress} />
<svelte:window onkeydown={keyPress} />

<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<aside
Expand All @@ -97,17 +117,14 @@
onoutclick={() => !preventCloseOnClickOutside && close()}
>
<h1 id={`${id}-title`} class="s-modal--header">
<!-- Content slotted in the Modal header section -->
<slot name="header" />
{@render header?.()}
</h1>
<div id={`${id}-description`} class="s-modal--body">
<!-- Content slotted in the Modal body section -->
<slot name="body" />
{@render body?.()}
</div>
{#if $$slots.footer}
{#if footer}
<div class="d-flex g8 s-modal--footer">
<!-- Content slotted in the Modal footer section -->
<slot name="footer" />
{@render footer?.()}
</div>
{/if}
{#if !hideCloseButton}
Expand Down
38 changes: 13 additions & 25 deletions packages/stacks-svelte/src/components/Modal/Modal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ describe("Modal", () => {
render(Modal, {
id: "test-modal",
visible: true,
// @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax
$$slots: { header: snippet },
header: snippet,
});
const dialog = screen.getByRole("dialog", {
name: "test snippet", // header content is used as label for the dialog
Expand All @@ -38,8 +37,7 @@ describe("Modal", () => {
render(Modal, {
id: "test-modal",
visible: true,
// @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax
$$slots: { body: snippet },
body: snippet,
});
const dialog = screen.getByRole("dialog", {
description: "test snippet", // body content is used as description for the dialog
Expand All @@ -52,8 +50,7 @@ describe("Modal", () => {
render(Modal, {
id: "test-modal",
visible: true,
// @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax
$$slots: { footer: snippet },
footer: snippet,
});
expectModalVisible(true);
expect(screen.getByText("test snippet")).to.exist;
Expand Down Expand Up @@ -154,10 +151,7 @@ describe("Modal", () => {
render(Modal, {
id: "test-modal",
visible: true,
// @ts-expect-error events are not yet typed in the component
$$events: {
close: onClose,
},
onclose: onClose,
});

const closeButton = await screen.getByRole("button");
Expand All @@ -171,10 +165,7 @@ describe("Modal", () => {
render(Modal, {
id: "test-modal",
visible: false,
// @ts-expect-error events are not yet typed in the component
$$events: {
close: onClose,
},
onclose: onClose,
});

const outsideModal = document.body;
Expand Down Expand Up @@ -206,17 +197,14 @@ describe("Modal", () => {
const { rerender } = render(Modal, {
id: "test-modal",
visible: false,
// @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax
$$slots: {
body: createRawSnippet(() => ({
render: () => `
<span>
<button>inside modal first</button>
<button>inside modal second</button>
</span>
`,
})),
},
body: createRawSnippet(() => ({
render: () => `
<span>
<button>inside modal first</button>
<button>inside modal second</button>
</span>
`,
})),
});

expectModalVisible(false);
Expand Down