Skip to content

Commit bc4c842

Browse files
feat!: Migrate Modal to Svelte 5 (#708)
# Motivation Migrating component `Modal` to Svelte 5. # Breaking Changes The interface changes: - The slots `title`, `headerLeft`, `headerRight`, `subTitle` and `footer` are migrated to `Snippet`. - No event `nnsClose` bubbling up anymore, use the prop `onClose` that receives the callback function. - No event `introend` bubbling up anymore, use the prop `onIntroend` that receives the callback function. - No event `click` bubbling up anymore, use the prop `onClick` that receives the callback function. --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 5fdbec5 commit bc4c842

File tree

9 files changed

+129
-91
lines changed

9 files changed

+129
-91
lines changed
15.5 KB
Loading

src/lib/components/Modal.svelte

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,41 @@
11
<script lang="ts">
22
import { nonNullish } from "@dfinity/utils";
3-
import { createEventDispatcher } from "svelte";
43
import { get } from "svelte/store";
54
import { fade } from "svelte/transition";
65
import Backdrop from "$lib/components/Backdrop.svelte";
76
import IconClose from "$lib/icons/IconClose.svelte";
87
import { busy } from "$lib/stores/busy.store";
98
import { i18n } from "$lib/stores/i18n";
9+
import type { ModalProps } from "$lib/types/modal";
10+
import { stopPropagation } from "$lib/utils/event-modifiers.utils";
1011
import { nextElementId } from "$lib/utils/html.utils";
1112
12-
export let visible = true;
13-
export let role: "dialog" | "alert" = "dialog";
14-
export let testId: string | undefined = undefined;
15-
export let disablePointerEvents = false;
16-
17-
let showHeader: boolean;
18-
$: showHeader = nonNullish($$slots.title);
19-
20-
let showHeaderLeft: boolean;
21-
$: showHeaderLeft = nonNullish($$slots["header-left"]);
13+
let {
14+
visible = true,
15+
role = "dialog",
16+
testId,
17+
disablePointerEvents = false,
18+
title,
19+
headerLeft,
20+
headerRight,
21+
subTitle,
22+
footer,
23+
children,
24+
onClose,
25+
onIntroEnd,
26+
onClick,
27+
}: ModalProps = $props();
28+
29+
let showHeader = $derived(nonNullish(title));
30+
31+
let showHeaderLeft = $derived(nonNullish(headerLeft));
2232
2333
/**
2434
* @deprecated according new design there should be no sticky footer
2535
*/
26-
let showFooterAlert: boolean;
27-
$: showFooterAlert = nonNullish($$slots.footer) && role === "alert";
36+
let showFooterAlert = $derived(nonNullish(footer) && role === "alert");
2837
29-
const dispatch = createEventDispatcher();
30-
const close = () => dispatch("nnsClose");
38+
const close = () => onClose?.();
3139
3240
// A bit faster fade in that backdrop IN, a bit slower on OUT
3341
const FADE_IN_DURATION = 125 as const;
@@ -37,24 +45,24 @@
3745
const modalContentId = nextElementId("modal-content-");
3846
3947
const handleKeyDown = ({ key }: KeyboardEvent) => {
40-
// Check for $busy to mock the same behavior as the close button being covered by the busy overlay
48+
// Check for $busy to mock the same behaviour as the close button being covered by the busy overlay
4149
if (visible && !disablePointerEvents && !get(busy) && key === "Escape") {
4250
close();
4351
}
4452
};
4553
</script>
4654

47-
<svelte:window on:keydown={handleKeyDown} />
55+
<svelte:window onkeydown={handleKeyDown} />
4856

4957
{#if visible}
5058
<div
5159
class="modal"
5260
aria-describedby={modalContentId}
5361
aria-labelledby={showHeader ? modalTitleId : undefined}
5462
data-tid={testId}
63+
onclick={nonNullish(onClick) ? stopPropagation(onClick) : undefined}
64+
onintroend={onIntroEnd}
5565
{role}
56-
on:introend
57-
on:click|stopPropagation
5866
transition:fade|global={{ duration: 25 }}
5967
>
6068
<Backdrop {disablePointerEvents} on:nnsClose />
@@ -67,46 +75,47 @@
6775
<div class="header">
6876
{#if showHeaderLeft}
6977
<div class="header-left">
70-
<slot name="header-left" />
78+
{@render headerLeft?.()}
7179
</div>
7280
{/if}
7381

7482
<h2 id={modalTitleId} data-tid="modal-title">
75-
<slot name="title" />
83+
{@render title?.()}
7684
</h2>
7785

7886
<div class="header-right">
79-
<slot name="header-right" />
87+
{@render headerRight?.()}
8088

8189
{#if !disablePointerEvents}
8290
<button
8391
aria-label={$i18n.core.close}
8492
data-tid="close-modal"
85-
on:click|stopPropagation={close}
86-
><IconClose size="24px" /></button
93+
onclick={stopPropagation(close)}
8794
>
95+
<IconClose size="24px" />
96+
</button>
8897
{/if}
8998
</div>
9099
</div>
91100
{/if}
92101

93102
<div class="container-wrapper">
94-
<slot name="sub-title" />
103+
{@render subTitle?.()}
95104

96105
<div class="container">
97106
<div
98107
id={modalContentId}
99108
class="content"
100109
class:alert={role === "alert"}
101110
>
102-
<slot />
111+
{@render children()}
103112
</div>
104113
</div>
105114
</div>
106115

107116
{#if showFooterAlert}
108117
<div class="footer toolbar">
109-
<slot name="footer" />
118+
{@render footer?.()}
110119
</div>
111120
{/if}
112121
</div>

src/lib/components/WizardModal.svelte

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,7 @@
6262
</script>
6363

6464
{#if visible}
65-
<Modal {disablePointerEvents} {testId} on:nnsClose={close}>
66-
<svelte:fragment slot="title">
67-
{@render title?.()}
68-
</svelte:fragment>
69-
65+
<Modal {disablePointerEvents} onClose={close} {testId} {title}>
7066
<WizardTransition {transition}>
7167
{@render children()}
7268
</WizardTransition>

src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from "./stores/toasts.store";
1010
export * from "./stores/wizard.state";
1111
export type { BusyState } from "./types/busy";
1212
export type { ChipGroupItem } from "./types/chip-group";
13+
export type { ModalProps } from "./types/modal";
1314
export type { ProgressBarSegment } from "./types/progress-bar";
1415
export type { ProgressStep, ProgressStepState } from "./types/progress-step";
1516
export * from "./types/theme";

src/lib/types/modal.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { OnEventCallback } from "$lib/types/event-modifiers";
2+
import type { Snippet } from "svelte";
3+
4+
export interface ModalProps {
5+
visible?: boolean;
6+
role?: "dialog" | "alert";
7+
testId?: string;
8+
disablePointerEvents?: boolean;
9+
title?: Snippet;
10+
headerLeft?: Snippet;
11+
headerRight?: Snippet;
12+
subTitle?: Snippet;
13+
footer?: Snippet;
14+
children: Snippet;
15+
onClose?: () => void;
16+
onIntroEnd?: () => void;
17+
onClick?: OnEventCallback;
18+
}

src/routes/(split)/components/modal/+page.md

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
# Modal
1010

11-
A Modal is a dialog that appears on top of the app's content, and must be dismissed by the app before interaction can resume. It is useful as a select component when there are a lot of options to choose from, or when filtering items in a list, as well as many other use cases.
11+
A Modal is a dialog that appears on top of the app's content and must be dismissed by the app before interaction can resume.
12+
It is useful as a select component when there are a lot of options to choose from,
13+
or when filtering items in a list, as well as many other use cases.
1214

1315
```javascript
1416
<script lang="ts">
@@ -19,38 +21,35 @@ A Modal is a dialog that appears on top of the app's content, and must be dismis
1921
Open modal
2022
</button>
2123

22-
<Modal {visible} on:nnsClose={() => (visible = false)}>
23-
<svelte:fragment slot="title">My title</svelte:fragment>
24+
<Modal {visible} onClose={() => (visible = false)}>
25+
{#snippet title()}My title{/snippet}
2426

2527
<p>My content</p>
2628
</Modal>
2729
```
2830

2931
## Properties
3032

31-
| Property | Description | Type | Default |
32-
| ---------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------- | ----------- |
33-
| `visible` | Display or hide the modal. | `boolean` | `false` |
34-
| `role` | The modal is either a dialog meant for getting work done or an alert that requires immediate attention. | `dialog` or `alert` | `dialog` |
35-
| `testId` | Add a `data-tid` attribute to the DOM, useful for test purpose. | `string` or `undefined` | `undefined` |
36-
| `disablePointerEvents` | Disable interactive elements - close actions - of the modal. | `boolean` | `false` |
37-
38-
## Slots
39-
40-
| Slot name | Description |
41-
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42-
| Default slot | The content of the modal. |
43-
| `title` | The title of the modal. Displayed in a toolbar with a "Close" icon button on the right side. |
44-
| `header-left` | Position content on the left side of the header title, such as additional buttons. |
45-
| `header-right` | Position content on the right side of the header title, such as additional buttons, or replace the close button with a custom button by setting `disablePointerEvents` to `true`. |
46-
| `toolbar` | A sticky toolbar displayed at the bottom of the modal. Available for "alert" only. |
47-
| `sub-title` | A slot below the title but outside of the content card. |
48-
49-
## Events
50-
51-
| Event | Description | Detail |
52-
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
53-
| `nnsClose` | Triggered when a closing interaction element is clicks - close button or backdrop. Note that the modal itself does not update the `visible` property. | No detail |
33+
| Property | Description | Type | Default |
34+
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ----------- |
35+
| `visible` | Display or hide the modal. | `boolean` | `false` |
36+
| `role` | The modal is either a dialog meant for getting work done or an alert that requires immediate attention. | `dialog` or `alert` | `dialog` |
37+
| `testId` | Add a `data-tid` attribute to the DOM, useful for test purpose. | `string` or `undefined` | `undefined` |
38+
| `disablePointerEvents` | Disable interactive elements - close actions - of the modal. | `boolean` | `false` |
39+
| `onClose` | Triggered when a closing interaction element is clicks - close button or backdrop. Note that the modal itself does not update the `visible` property. | `() => void` | `undefined` |
40+
| `onIntroEnd` | Callback triggered when the intro transition ends. | `() => void` | `undefined` |
41+
| `onClick` | Callback triggered when the modal is clicked. | `() => void` | `undefined` |
42+
43+
## Snippets
44+
45+
| Snippet name | Description |
46+
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
47+
| Default snippet | The content of the modal. |
48+
| `title` | The title of the modal. Displayed in a toolbar with a "Close" icon button on the right side. |
49+
| `header-left` | Position content on the left side of the header title, such as additional buttons. |
50+
| `header-right` | Position content on the right side of the header title, such as additional buttons, or replace the close button with a custom button by setting `disablePointerEvents` to `true`. |
51+
| `toolbar` | A sticky toolbar displayed at the bottom of the modal. Available for "alert" only. |
52+
| `sub-title` | A snippet below the title but outside of the content card. |
5453

5554
## Utility Classes
5655

@@ -64,10 +63,10 @@ A Modal is a dialog that appears on top of the app's content, and must be dismis
6463
Open modal
6564
</button>
6665

67-
<Modal {visible} on:nnsClose={() => (visible = false)} {role}>
68-
<svelte:fragment slot="title">My title</svelte:fragment>
66+
<Modal {visible} onClose={() => (visible = false)} {role}>
67+
{#snippet title()}My title{/snippet}
6968

70-
<p slot="sub-title">This is the subtitle</p>
69+
{#snippet subTitle()}<p >This is the subtitle</p>{/snippet}
7170

7271
<DocsLoremIpsum length={role === "alert" ? 1 : 10} />
7372

@@ -79,12 +78,12 @@ Open modal
7978
</div>
8079
{/if}
8180

82-
<svelte:fragment slot="footer">
81+
{#snippet footer()}
8382
{#if role === "alert"}
8483
<button class="secondary">Cancel</button>
8584
<button class="primary">An action</button>
8685
{/if}
87-
</svelte:fragment>
86+
{/snippet}
8887

8988
</Modal>
9089

src/routes/(split)/components/qr-code-reader/+page.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ Open modal
5757

5858
</div>
5959

60-
<Modal {visible} on:nnsClose={close} on:introend={() => renderQRCodeReader = true}>
61-
<svelte:fragment slot="title">Scan QR Code</svelte:fragment>
60+
<Modal {visible} onClose={close} onIntroEnd={() => renderQRCodeReader = true}>
61+
{#snippet title()}Scan QR Code{/snippet}
6262

6363
{#if renderQRCodeReader}
6464
<QRCodeReaderModal on:nnsQRCode={({detail: value}) => {

src/tests/lib/components/Modal.spec.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import { startBusy } from "$lib";
22
import Modal from "$lib/components/Modal.svelte";
3+
import type { ModalProps } from "$lib/types/modal";
34
import { fireEvent } from "@testing-library/svelte";
45
import { render } from "../../utils/render.test-utils";
6+
import { mockSnippet } from "../mocks/snippet.mocks";
57
import ModalTest from "./ModalTest.svelte";
68

79
describe("Modal", () => {
8-
const props: { visible: boolean } = { visible: true };
10+
const props: ModalProps = { visible: true, children: mockSnippet };
911

1012
it("should display modal", async () => {
1113
const { container, rerender } = render(Modal, {
12-
props: { visible: false },
14+
props: { visible: false, children: mockSnippet },
1315
});
1416

1517
expect(container.querySelector("div.modal")).toBeNull();
@@ -21,7 +23,7 @@ describe("Modal", () => {
2123

2224
it("should display an alert modal", () => {
2325
const { container } = render(Modal, {
24-
props: { visible: true, role: "alert" },
26+
props: { visible: true, role: "alert", children: mockSnippet },
2527
});
2628

2729
const alert: HTMLElement | null = container.querySelector('[role="alert"]');
@@ -74,15 +76,15 @@ describe("Modal", () => {
7476
});
7577

7678
it("should render a subtitle", () => {
77-
const subTitle = "My subtitle";
79+
const subTitleString = "My subtitle";
7880
const { getByText } = render(ModalTest, {
7981
props: {
8082
...props,
81-
subTitle,
83+
subTitleString,
8284
},
8385
});
8486

85-
expect(getByText(subTitle)).not.toBeNull();
87+
expect(getByText(subTitleString)).not.toBeNull();
8688
});
8789

8890
it("should render a toolbar", () => {
@@ -125,9 +127,9 @@ describe("Modal", () => {
125127
it("should trigger close modal on Esc", () =>
126128
new Promise<void>((done) => {
127129
const { container } = render(Modal, {
128-
props,
129-
events: {
130-
nnsClose: () => done(),
130+
props: {
131+
...props,
132+
onClose: () => done(),
131133
},
132134
});
133135

@@ -196,16 +198,18 @@ describe("Modal", () => {
196198
it("should trigger close modal on click on close button", () =>
197199
new Promise<void>((done) => {
198200
const { getByTestId } = render(ModalTest, {
199-
props,
200-
// TODO: remove once events is migrated to props
201-
events: {
202-
nnsClose: () => done(),
201+
props: {
202+
...props,
203+
onClose: () => done(),
203204
},
204205
});
205206

206207
const button: HTMLElement | null = getByTestId("close-modal");
207208

208-
button && fireEvent.click(button);
209+
expect(button).not.toBeNull();
210+
expect(button).toBeInTheDocument();
211+
212+
fireEvent.click(button);
209213
}));
210214

211215
it("should not trigger close modal on click on backdrop", () => {

0 commit comments

Comments
 (0)