Skip to content

Commit da534b8

Browse files
committed
feat(modal): support cancelable close event
Closes #1549
1 parent 416eabf commit da534b8

File tree

6 files changed

+134
-43
lines changed

6 files changed

+134
-43
lines changed

COMPONENT_INDEX.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2266,19 +2266,19 @@ None.
22662266

22672267
### Events
22682268

2269-
| Event name | Type | Detail |
2270-
| :---------------------- | :--------- | :------------------------------ |
2271-
| transitionend | dispatched | <code>{ open: boolean; }</code> |
2272-
| click:button--secondary | dispatched | <code>{ text: string; }</code> |
2273-
| keydown | forwarded | -- |
2274-
| click | forwarded | -- |
2275-
| mouseover | forwarded | -- |
2276-
| mouseenter | forwarded | -- |
2277-
| mouseleave | forwarded | -- |
2278-
| submit | dispatched | <code>null</code> |
2279-
| click:button--primary | dispatched | <code>null</code> |
2280-
| close | dispatched | <code>null</code> |
2281-
| open | dispatched | <code>null</code> |
2269+
| Event name | Type | Detail |
2270+
| :---------------------- | :--------- | :---------------------------------------------------------------------------------- |
2271+
| close | dispatched | <code>{ trigger: "escape-key" &#124; "outside-click" &#124; "close-button" }</code> |
2272+
| transitionend | dispatched | <code>{ open: boolean; }</code> |
2273+
| click:button--secondary | dispatched | <code>{ text: string; }</code> |
2274+
| keydown | forwarded | -- |
2275+
| click | forwarded | -- |
2276+
| mouseover | forwarded | -- |
2277+
| mouseenter | forwarded | -- |
2278+
| mouseleave | forwarded | -- |
2279+
| submit | dispatched | <code>null</code> |
2280+
| click:button--primary | dispatched | <code>null</code> |
2281+
| open | dispatched | <code>null</code> |
22822282

22832283
## `ModalBody`
22842284

docs/src/COMPONENT_API.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8187,6 +8187,11 @@
81878187
}
81888188
],
81898189
"events": [
8190+
{
8191+
"type": "dispatched",
8192+
"name": "close",
8193+
"detail": "{\n trigger:\n | \"escape-key\"\n | \"outside-click\"\n | \"close-button\";\n}"
8194+
},
81908195
{
81918196
"type": "dispatched",
81928197
"name": "transitionend",
@@ -8232,11 +8237,6 @@
82328237
"name": "click:button--primary",
82338238
"detail": "null"
82348239
},
8235-
{
8236-
"type": "dispatched",
8237-
"name": "close",
8238-
"detail": "null"
8239-
},
82408240
{
82418241
"type": "dispatched",
82428242
"name": "open",

src/Modal/Modal.svelte

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script>
22
/**
3+
* @event {{ trigger: "escape-key" | "outside-click" | "close-button" }} close
34
* @event {{ open: boolean; }} transitionend
45
* @event {{ text: string; }} click:button--secondary
56
*/
@@ -101,13 +102,24 @@
101102
let innerModal = null;
102103
let opened = false;
103104
let didClickInnerModal = false;
105+
let closeDispatched = false;
104106
105107
function focus(element) {
106108
const node =
107109
(element || innerModal).querySelector(selectorPrimaryFocus) || buttonRef;
108110
node.focus();
109111
}
110112
113+
function close(trigger) {
114+
closeDispatched = true;
115+
const shouldContinue = dispatch("close", { trigger }, { cancelable: true });
116+
if (shouldContinue) {
117+
open = false;
118+
} else {
119+
closeDispatched = false;
120+
}
121+
}
122+
111123
const openStore = writable(open);
112124
$: $openStore = open;
113125
trackModal(openStore);
@@ -116,7 +128,10 @@
116128
if (opened) {
117129
if (!open) {
118130
opened = false;
119-
dispatch("close");
131+
if (!closeDispatched) {
132+
dispatch("close");
133+
}
134+
closeDispatched = false;
120135
}
121136
} else if (open) {
122137
opened = true;
@@ -146,7 +161,7 @@
146161
on:keydown={(e) => {
147162
if (open) {
148163
if (e.key === "Escape") {
149-
open = false;
164+
close("escape-key");
150165
} else if (e.key === "Tab") {
151166
// trap focus
152167

@@ -180,7 +195,9 @@
180195
}}
181196
on:click
182197
on:click={() => {
183-
if (!didClickInnerModal && !preventCloseOnClickOutside) open = false;
198+
if (!didClickInnerModal && !preventCloseOnClickOutside) {
199+
close("outside-click");
200+
}
184201
didClickInnerModal = false;
185202
}}
186203
on:mouseover
@@ -215,7 +232,7 @@
215232
aria-label={iconDescription}
216233
class:bx--modal-close={true}
217234
on:click={() => {
218-
open = false;
235+
close("close-button");
219236
}}
220237
>
221238
<Close size={20} class="bx--modal-close__icon" aria-hidden="true" />
@@ -236,7 +253,7 @@
236253
aria-label={iconDescription}
237254
class:bx--modal-close={true}
238255
on:click={() => {
239-
open = false;
256+
close("close-button");
240257
}}
241258
>
242259
<Close size={20} class="bx--modal-close__icon" aria-hidden="true" />

tests/Modal/Modal.test.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
</script>
2626

2727
<Modal
28-
{open}
28+
bind:open
2929
{modalHeading}
3030
{modalLabel}
3131
{modalAriaLabel}
@@ -44,8 +44,8 @@
4444
{danger}
4545
{alert}
4646
{passiveModal}
47-
on:open={() => console.log("open")}
48-
on:close={() => console.log("close")}
47+
on:open
48+
on:close
4949
on:submit={() => console.log("submit")}
5050
on:click:button--primary={() => console.log("click:button--primary")}
5151
on:click:button--secondary={(e) =>

tests/Modal/Modal.test.ts

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,24 +58,28 @@ describe("Modal", () => {
5858
});
5959

6060
it("opens and closes properly", async () => {
61-
const consoleLog = vi.spyOn(console, "log");
6261
const { component } = render(ModalTest, {
6362
props: {
6463
open: false,
6564
modalHeading: "Test Modal",
6665
},
6766
});
6867

68+
const openHandler = vi.fn();
69+
const closeHandler = vi.fn();
70+
component.$on("open", openHandler);
71+
component.$on("close", closeHandler);
72+
6973
// Open the modal
7074
component.$set({ open: true });
7175
await tick();
7276
expect(screen.getByRole("dialog")).toBeInTheDocument();
73-
expect(consoleLog).toHaveBeenCalledWith("open");
77+
expect(openHandler).toHaveBeenCalledTimes(1);
7478

7579
// Close the modal
7680
component.$set({ open: false });
7781
await tick();
78-
expect(consoleLog).toHaveBeenCalledWith("close");
82+
expect(closeHandler).toHaveBeenCalledTimes(1);
7983
});
8084

8185
it("handles form submission", async () => {
@@ -251,34 +255,102 @@ describe("Modal", () => {
251255
).toBeInTheDocument();
252256
});
253257

254-
it("handles closing through various methods", async () => {
255-
const consoleLog = vi.spyOn(console, "log");
258+
it("dispatches close event with escape-key trigger", async () => {
256259
const { component } = render(ModalTest, {
257260
props: {
258261
open: true,
259-
modalHeading: "Close Test Modal",
262+
modalHeading: "Escape Key Test",
260263
},
261264
});
262265

263-
// Close via escape key
266+
const closeHandler = vi.fn();
267+
component.$on("close", closeHandler);
268+
264269
await user.keyboard("{Escape}");
265-
expect(consoleLog).toHaveBeenCalledWith("close");
270+
await tick();
266271

267-
component.$set({ open: true });
272+
expect(closeHandler).toHaveBeenCalledTimes(1);
273+
expect(closeHandler.mock.calls[0][0].detail).toEqual({
274+
trigger: "escape-key",
275+
});
276+
});
277+
278+
it("dispatches close event with outside-click trigger", async () => {
279+
const { container, component } = render(ModalTest, {
280+
props: {
281+
open: true,
282+
modalHeading: "Outside Click Test",
283+
},
284+
});
285+
286+
const closeHandler = vi.fn();
287+
component.$on("close", closeHandler);
288+
289+
// Click on the modal overlay
290+
const modalOverlay = container.querySelector(".bx--modal");
291+
assert(modalOverlay);
292+
await user.click(modalOverlay);
268293
await tick();
269294

270-
expect(consoleLog).toHaveBeenCalledWith("open");
295+
expect(closeHandler).toHaveBeenCalledTimes(1);
296+
expect(closeHandler.mock.calls[0][0].detail).toEqual({
297+
trigger: "outside-click",
298+
});
299+
});
271300

272-
// Close via clicking outside
273-
await user.click(document.body);
274-
expect(consoleLog).toHaveBeenCalledWith("close");
301+
it("dispatches close event with close-button trigger", async () => {
302+
const { component } = render(ModalTest, {
303+
props: {
304+
open: true,
305+
modalHeading: "Close Button Test",
306+
},
307+
});
275308

276-
component.$set({ open: true });
309+
const closeHandler = vi.fn();
310+
component.$on("close", closeHandler);
311+
312+
const closeButton = screen.getByLabelText("Close the modal");
313+
await user.click(closeButton);
314+
await tick();
315+
316+
expect(closeHandler).toHaveBeenCalledTimes(1);
317+
expect(closeHandler.mock.calls[0][0].detail).toEqual({
318+
trigger: "close-button",
319+
});
320+
});
321+
322+
it("prevents closing when preventDefault is called on close event", async () => {
323+
const { container, component } = render(ModalTest, {
324+
props: {
325+
open: true,
326+
modalHeading: "Prevent Close Test",
327+
},
328+
});
329+
330+
const closeHandler = vi.fn((e) => {
331+
e.preventDefault();
332+
});
333+
component.$on("close", closeHandler);
334+
335+
// Close via escape key.
336+
await user.keyboard("{Escape}");
277337
await tick();
338+
expect(closeHandler).toHaveBeenCalledTimes(1);
339+
expect(screen.getByRole("dialog")).toBeInTheDocument();
278340

279-
// Close via close button
341+
// Close via outside click.
342+
const modalOverlay = container.querySelector(".bx--modal");
343+
assert(modalOverlay);
344+
await user.click(modalOverlay);
345+
await tick();
346+
expect(closeHandler).toHaveBeenCalledTimes(2);
347+
expect(screen.getByRole("dialog")).toBeInTheDocument();
348+
349+
// Close via close button.
280350
const closeButton = screen.getByLabelText("Close the modal");
281351
await user.click(closeButton);
282-
expect(consoleLog).toHaveBeenCalledWith("close");
352+
await tick();
353+
expect(closeHandler).toHaveBeenCalledTimes(3);
354+
expect(screen.getByRole("dialog")).toBeInTheDocument();
283355
});
284356
});

types/Modal/Modal.svelte.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ export type ModalProps = Omit<$RestProps, keyof $Props> & $Props;
140140
export default class Modal extends SvelteComponentTyped<
141141
ModalProps,
142142
{
143+
close: CustomEvent<{
144+
trigger: "escape-key" | "outside-click" | "close-button";
145+
}>;
143146
transitionend: CustomEvent<{ open: boolean }>;
144147
["click:button--secondary"]: CustomEvent<{ text: string }>;
145148
keydown: WindowEventMap["keydown"];
@@ -149,7 +152,6 @@ export default class Modal extends SvelteComponentTyped<
149152
mouseleave: WindowEventMap["mouseleave"];
150153
submit: CustomEvent<null>;
151154
["click:button--primary"]: CustomEvent<null>;
152-
close: CustomEvent<null>;
153155
open: CustomEvent<null>;
154156
},
155157
{ default: {}; heading: {}; label: {} }

0 commit comments

Comments
 (0)