Skip to content
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
63 changes: 36 additions & 27 deletions src/lib/components/Modal.svelte
Original file line number Diff line number Diff line change
@@ -1,33 +1,41 @@
<script lang="ts">
import { nonNullish } from "@dfinity/utils";
import { createEventDispatcher } from "svelte";
import { get } from "svelte/store";
import { fade } from "svelte/transition";
import Backdrop from "$lib/components/Backdrop.svelte";
import IconClose from "$lib/icons/IconClose.svelte";
import { busy } from "$lib/stores/busy.store";
import { i18n } from "$lib/stores/i18n";
import type { ModalProps } from "$lib/types/modal";
import { stopPropagation } from "$lib/utils/event-modifiers.utils";
import { nextElementId } from "$lib/utils/html.utils";

export let visible = true;
export let role: "dialog" | "alert" = "dialog";
export let testId: string | undefined = undefined;
export let disablePointerEvents = false;

let showHeader: boolean;
$: showHeader = nonNullish($$slots.title);

let showHeaderLeft: boolean;
$: showHeaderLeft = nonNullish($$slots["header-left"]);
let {
visible = true,
role = "dialog",
testId,
disablePointerEvents = false,
title,
headerLeft,
headerRight,
subTitle,
footer,
children,
onClose,
onIntroEnd,
onClick,
}: ModalProps = $props();

let showHeader = $derived(nonNullish(title));

let showHeaderLeft = $derived(nonNullish(headerLeft));

/**
* @deprecated according new design there should be no sticky footer
*/
let showFooterAlert: boolean;
$: showFooterAlert = nonNullish($$slots.footer) && role === "alert";
let showFooterAlert = $derived(nonNullish(footer) && role === "alert");

const dispatch = createEventDispatcher();
const close = () => dispatch("nnsClose");
const close = () => onClose?.();

// A bit faster fade in that backdrop IN, a bit slower on OUT
const FADE_IN_DURATION = 125 as const;
Expand All @@ -37,24 +45,24 @@
const modalContentId = nextElementId("modal-content-");

const handleKeyDown = ({ key }: KeyboardEvent) => {
// Check for $busy to mock the same behavior as the close button being covered by the busy overlay
// Check for $busy to mock the same behaviour as the close button being covered by the busy overlay
if (visible && !disablePointerEvents && !get(busy) && key === "Escape") {
close();
}
};
</script>

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

{#if visible}
<div
class="modal"
aria-describedby={modalContentId}
aria-labelledby={showHeader ? modalTitleId : undefined}
data-tid={testId}
onclick={nonNullish(onClick) ? stopPropagation(onClick) : undefined}
onintroend={onIntroEnd}
{role}
on:introend
on:click|stopPropagation
transition:fade|global={{ duration: 25 }}
>
<Backdrop {disablePointerEvents} on:nnsClose />
Expand All @@ -67,46 +75,47 @@
<div class="header">
{#if showHeaderLeft}
<div class="header-left">
<slot name="header-left" />
{@render headerLeft?.()}
</div>
{/if}

<h2 id={modalTitleId} data-tid="modal-title">
<slot name="title" />
{@render title?.()}
</h2>

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

{#if !disablePointerEvents}
<button
aria-label={$i18n.core.close}
data-tid="close-modal"
on:click|stopPropagation={close}
><IconClose size="24px" /></button
onclick={stopPropagation(close)}
>
<IconClose size="24px" />
</button>
{/if}
</div>
</div>
{/if}

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

<div class="container">
<div
id={modalContentId}
class="content"
class:alert={role === "alert"}
>
<slot />
{@render children()}
</div>
</div>
</div>

{#if showFooterAlert}
<div class="footer toolbar">
<slot name="footer" />
{@render footer?.()}
</div>
{/if}
</div>
Expand Down
6 changes: 1 addition & 5 deletions src/lib/components/WizardModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,7 @@
</script>

{#if visible}
<Modal {disablePointerEvents} {testId} on:nnsClose={close}>
<svelte:fragment slot="title">
{@render title?.()}
</svelte:fragment>

<Modal {disablePointerEvents} onClose={close} {testId} {title}>
<WizardTransition {transition}>
{@render children()}
</WizardTransition>
Expand Down
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from "./stores/toasts.store";
export * from "./stores/wizard.state";
export type { BusyState } from "./types/busy";
export type { ChipGroupItem } from "./types/chip-group";
export type { ModalProps } from "./types/modal";
export type { ProgressBarSegment } from "./types/progress-bar";
export type { ProgressStep, ProgressStepState } from "./types/progress-step";
export * from "./types/theme";
Expand Down
18 changes: 18 additions & 0 deletions src/lib/types/modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { OnEventCallback } from "$lib/types/event-modifiers";
import type { Snippet } from "svelte";

export interface ModalProps {
visible?: boolean;
role?: "dialog" | "alert";
testId?: string;
disablePointerEvents?: boolean;
title?: Snippet;
headerLeft?: Snippet;
headerRight?: Snippet;
subTitle?: Snippet;
footer?: Snippet;
children: Snippet;
onClose?: () => void;
onIntroEnd?: () => void;
onClick?: OnEventCallback;
}
61 changes: 30 additions & 31 deletions src/routes/(split)/components/modal/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@

# Modal

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.
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.

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

<Modal {visible} on:nnsClose={() => (visible = false)}>
<svelte:fragment slot="title">My title</svelte:fragment>
<Modal {visible} onClose={() => (visible = false)}>
{#snippet title()}My title{/snippet}

<p>My content</p>
</Modal>
```

## Properties

| Property | Description | Type | Default |
| ---------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------- | ----------- |
| `visible` | Display or hide the modal. | `boolean` | `false` |
| `role` | The modal is either a dialog meant for getting work done or an alert that requires immediate attention. | `dialog` or `alert` | `dialog` |
| `testId` | Add a `data-tid` attribute to the DOM, useful for test purpose. | `string` or `undefined` | `undefined` |
| `disablePointerEvents` | Disable interactive elements - close actions - of the modal. | `boolean` | `false` |

## Slots

| Slot name | Description |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Default slot | The content of the modal. |
| `title` | The title of the modal. Displayed in a toolbar with a "Close" icon button on the right side. |
| `header-left` | Position content on the left side of the header title, such as additional buttons. |
| `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`. |
| `toolbar` | A sticky toolbar displayed at the bottom of the modal. Available for "alert" only. |
| `sub-title` | A slot below the title but outside of the content card. |

## Events

| Event | Description | Detail |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
| `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 |
| Property | Description | Type | Default |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ----------- |
| `visible` | Display or hide the modal. | `boolean` | `false` |
| `role` | The modal is either a dialog meant for getting work done or an alert that requires immediate attention. | `dialog` or `alert` | `dialog` |
| `testId` | Add a `data-tid` attribute to the DOM, useful for test purpose. | `string` or `undefined` | `undefined` |
| `disablePointerEvents` | Disable interactive elements - close actions - of the modal. | `boolean` | `false` |
| `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` |
| `onIntroEnd` | Callback triggered when the intro transition ends. | `() => void` | `undefined` |
| `onClick` | Callback triggered when the modal is clicked. | `() => void` | `undefined` |

## Snippets

| Snippet name | Description |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Default snippet | The content of the modal. |
| `title` | The title of the modal. Displayed in a toolbar with a "Close" icon button on the right side. |
| `header-left` | Position content on the left side of the header title, such as additional buttons. |
| `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`. |
| `toolbar` | A sticky toolbar displayed at the bottom of the modal. Available for "alert" only. |
| `sub-title` | A snippet below the title but outside of the content card. |

## Utility Classes

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

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

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

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

Expand All @@ -79,12 +78,12 @@ Open modal
</div>
{/if}

<svelte:fragment slot="footer">
{#snippet footer()}
{#if role === "alert"}
<button class="secondary">Cancel</button>
<button class="primary">An action</button>
{/if}
</svelte:fragment>
{/snippet}

</Modal>

Expand Down
4 changes: 2 additions & 2 deletions src/routes/(split)/components/qr-code-reader/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ Open modal

</div>

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

{#if renderQRCodeReader}
<QRCodeReaderModal on:nnsQRCode={({detail: value}) => {
Expand Down
32 changes: 18 additions & 14 deletions src/tests/lib/components/Modal.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { startBusy } from "$lib";
import Modal from "$lib/components/Modal.svelte";
import type { ModalProps } from "$lib/types/modal";
import { fireEvent } from "@testing-library/svelte";
import { render } from "../../utils/render.test-utils";
import { mockSnippet } from "../mocks/snippet.mocks";
import ModalTest from "./ModalTest.svelte";

describe("Modal", () => {
const props: { visible: boolean } = { visible: true };
const props: ModalProps = { visible: true, children: mockSnippet };

it("should display modal", async () => {
const { container, rerender } = render(Modal, {
props: { visible: false },
props: { visible: false, children: mockSnippet },
});

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

it("should display an alert modal", () => {
const { container } = render(Modal, {
props: { visible: true, role: "alert" },
props: { visible: true, role: "alert", children: mockSnippet },
});

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

it("should render a subtitle", () => {
const subTitle = "My subtitle";
const subTitleString = "My subtitle";
const { getByText } = render(ModalTest, {
props: {
...props,
subTitle,
subTitleString,
},
});

expect(getByText(subTitle)).not.toBeNull();
expect(getByText(subTitleString)).not.toBeNull();
});

it("should render a toolbar", () => {
Expand Down Expand Up @@ -125,9 +127,9 @@ describe("Modal", () => {
it("should trigger close modal on Esc", () =>
new Promise<void>((done) => {
const { container } = render(Modal, {
props,
events: {
nnsClose: () => done(),
props: {
...props,
onClose: () => done(),
},
});

Expand Down Expand Up @@ -196,16 +198,18 @@ describe("Modal", () => {
it("should trigger close modal on click on close button", () =>
new Promise<void>((done) => {
const { getByTestId } = render(ModalTest, {
props,
// TODO: remove once events is migrated to props
events: {
nnsClose: () => done(),
props: {
...props,
onClose: () => done(),
},
});

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

button && fireEvent.click(button);
expect(button).not.toBeNull();
expect(button).toBeInTheDocument();

fireEvent.click(button);
}));

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