Skip to content

Commit 8e2b930

Browse files
committed
feat(composed-modal): support cancelable close event
Closes #1549
1 parent 5030657 commit 8e2b930

File tree

6 files changed

+122
-73
lines changed

6 files changed

+122
-73
lines changed

COMPONENT_INDEX.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -731,18 +731,18 @@ export interface ComboBoxItem {
731731

732732
### Events
733733

734-
| Event name | Type | Detail |
735-
| :-------------------- | :--------- | :------------------------------ |
736-
| transitionend | dispatched | <code>{ open: boolean; }</code> |
737-
| keydown | forwarded | -- |
738-
| click | forwarded | -- |
739-
| mouseover | forwarded | -- |
740-
| mouseenter | forwarded | -- |
741-
| mouseleave | forwarded | -- |
742-
| submit | dispatched | <code>null</code> |
743-
| click:button--primary | dispatched | <code>null</code> |
744-
| close | dispatched | <code>null</code> |
745-
| open | dispatched | <code>null</code> |
734+
| Event name | Type | Detail |
735+
| :-------------------- | :--------- | :---------------------------------------------------------------------------------- |
736+
| close | dispatched | <code>{ trigger: "escape-key" &#124; "outside-click" &#124; "close-button" }</code> |
737+
| transitionend | dispatched | <code>{ open: boolean; }</code> |
738+
| keydown | forwarded | -- |
739+
| click | forwarded | -- |
740+
| mouseover | forwarded | -- |
741+
| mouseenter | forwarded | -- |
742+
| mouseleave | forwarded | -- |
743+
| submit | dispatched | <code>null</code> |
744+
| click:button--primary | dispatched | <code>null</code> |
745+
| open | dispatched | <code>null</code> |
746746

747747
## `Content`
748748

docs/src/COMPONENT_API.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2254,6 +2254,11 @@
22542254
}
22552255
],
22562256
"events": [
2257+
{
2258+
"type": "dispatched",
2259+
"name": "close",
2260+
"detail": "{\n trigger:\n | \"escape-key\"\n | \"outside-click\"\n | \"close-button\";\n}"
2261+
},
22572262
{
22582263
"type": "dispatched",
22592264
"name": "transitionend",
@@ -2294,11 +2299,6 @@
22942299
"name": "click:button--primary",
22952300
"detail": "null"
22962301
},
2297-
{
2298-
"type": "dispatched",
2299-
"name": "close",
2300-
"detail": "null"
2301-
},
23022302
{
23032303
"type": "dispatched",
23042304
"name": "open",

src/ComposedModal/ComposedModal.svelte

Lines changed: 21 additions & 4 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
*/
56
@@ -46,10 +47,21 @@
4647
let buttonRef = null;
4748
let innerModal = null;
4849
let didClickInnerModal = false;
50+
let closeDispatched = false;
51+
52+
function close(trigger) {
53+
closeDispatched = true;
54+
const shouldContinue = dispatch("close", { trigger }, { cancelable: true });
55+
if (shouldContinue) {
56+
open = false;
57+
} else {
58+
closeDispatched = false;
59+
}
60+
}
4961
5062
setContext("ComposedModal", {
5163
closeModal: () => {
52-
open = false;
64+
close("close-button");
5365
},
5466
submit: () => {
5567
dispatch("submit");
@@ -87,7 +99,10 @@
8799
if (opened) {
88100
if (!open) {
89101
opened = false;
90-
dispatch("close");
102+
if (!closeDispatched) {
103+
dispatch("close");
104+
}
105+
closeDispatched = false;
91106
}
92107
} else if (open) {
93108
opened = true;
@@ -108,7 +123,7 @@
108123
on:keydown={(e) => {
109124
if (open) {
110125
if (e.key === "Escape") {
111-
open = false;
126+
close("escape-key");
112127
} else if (e.key === "Tab") {
113128
// taken from github.com/carbon-design-system/carbon/packages/react/src/internal/keyboard/navigation.js
114129
const selectorTabbable = `
@@ -133,7 +148,9 @@
133148
}}
134149
on:click
135150
on:click={() => {
136-
if (!didClickInnerModal && !preventCloseOnClickOutside) open = false;
151+
if (!didClickInnerModal && !preventCloseOnClickOutside) {
152+
close("outside-click");
153+
}
137154
didClickInnerModal = false;
138155
}}
139156
on:mouseover

tests/ComposedModal/ComposedModal.test.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
{preventCloseOnClickOutside}
3434
{containerClass}
3535
{selectorPrimaryFocus}
36-
on:open={() => console.log("open")}
37-
on:close={() => console.log("close")}
36+
on:open
37+
on:close
3838
on:submit={() => console.log("submit")}
3939
on:click:button--primary={() => console.log("click:button--primary")}
4040
on:transitionend={(e) => console.log("transitionend", e.detail)}

tests/ComposedModal/ComposedModal.test.ts

Lines changed: 79 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,26 @@ describe("ComposedModal", () => {
3636
});
3737

3838
it("should handle open state", async () => {
39-
const consoleLog = vi.spyOn(console, "log");
4039
const { component } = render(ComposedModalTest, {
4140
props: {
4241
open: false,
4342
headerTitle: "Test Modal",
4443
},
4544
});
4645

46+
const openHandler = vi.fn();
47+
const closeHandler = vi.fn();
48+
component.$on("open", openHandler);
49+
component.$on("close", closeHandler);
50+
4751
component.$set({ open: true });
4852
await tick();
4953
expect(screen.getByRole("dialog")).toBeInTheDocument();
50-
expect(consoleLog).toHaveBeenCalledWith("open");
54+
expect(openHandler).toHaveBeenCalledTimes(1);
5155

5256
component.$set({ open: false });
5357
await tick();
54-
expect(consoleLog).toHaveBeenCalledWith("close");
58+
expect(closeHandler).toHaveBeenCalledTimes(1);
5559
});
5660

5761
it("should handle size variants", () => {
@@ -106,21 +110,6 @@ describe("ComposedModal", () => {
106110
expect(modalWrapper).not.toHaveClass("is-visible");
107111
});
108112

109-
it("should close on outside click", async () => {
110-
const consoleLog = vi.spyOn(console, "log");
111-
const { container } = render(ComposedModalTest, {
112-
props: {
113-
open: true,
114-
headerTitle: "Test Modal",
115-
},
116-
});
117-
118-
const modalWrapper = container.querySelector(".bx--modal");
119-
assert(modalWrapper);
120-
await user.click(modalWrapper);
121-
expect(consoleLog).toHaveBeenCalledWith("close");
122-
});
123-
124113
it("should not close on inside click", async () => {
125114
const consoleLog = vi.spyOn(console, "log");
126115
render(ComposedModalTest, {
@@ -151,20 +140,6 @@ describe("ComposedModal", () => {
151140
expect(consoleLog).not.toHaveBeenCalledWith("close");
152141
});
153142

154-
it("should handle close button click", async () => {
155-
const consoleLog = vi.spyOn(console, "log");
156-
render(ComposedModalTest, {
157-
props: {
158-
open: true,
159-
headerTitle: "Test Modal",
160-
},
161-
});
162-
163-
const closeButton = screen.getByRole("button", { name: "Close" });
164-
await user.click(closeButton);
165-
expect(consoleLog).toHaveBeenCalledWith("close");
166-
});
167-
168143
it("should render header with title and label", () => {
169144
render(ComposedModalTest, {
170145
props: {
@@ -207,23 +182,6 @@ describe("ComposedModal", () => {
207182
expect(consoleLog).toHaveBeenCalledWith("click:button--primary");
208183
});
209184

210-
it("should handle secondary button click", async () => {
211-
const consoleLog = vi.spyOn(console, "log");
212-
render(ComposedModalTest, {
213-
props: {
214-
open: true,
215-
headerTitle: "Test Modal",
216-
footerSecondaryButtonText: "Cancel",
217-
},
218-
});
219-
220-
await user.click(screen.getByRole("button", { name: "Cancel" }));
221-
expect(consoleLog).toHaveBeenCalledWith("click:button--secondary", {
222-
text: "Cancel",
223-
});
224-
expect(consoleLog).toHaveBeenCalledWith("close");
225-
});
226-
227185
it("should disable primary button when configured", () => {
228186
render(ComposedModalTest, {
229187
props: {
@@ -343,4 +301,76 @@ describe("ComposedModal", () => {
343301
const modalWrapper = container.querySelector(".bx--modal");
344302
expect(modalWrapper).toHaveClass("is-visible");
345303
});
304+
305+
it("dispatches close event with outside-click trigger", async () => {
306+
const { container, component } = render(ComposedModalTest, {
307+
props: {
308+
open: true,
309+
headerTitle: "Outside Click Test",
310+
},
311+
});
312+
313+
const closeHandler = vi.fn();
314+
component.$on("close", closeHandler);
315+
316+
const modalOverlay = container.querySelector(".bx--modal");
317+
assert(modalOverlay);
318+
await user.click(modalOverlay);
319+
await tick();
320+
321+
expect(closeHandler).toHaveBeenCalledTimes(1);
322+
expect(closeHandler.mock.calls[0][0].detail).toEqual({
323+
trigger: "outside-click",
324+
});
325+
});
326+
327+
it("dispatches close event with close-button trigger", async () => {
328+
const { component } = render(ComposedModalTest, {
329+
props: {
330+
open: true,
331+
headerTitle: "Close Button Test",
332+
},
333+
});
334+
335+
const closeHandler = vi.fn();
336+
component.$on("close", closeHandler);
337+
338+
const closeButton = screen.getByLabelText("Close");
339+
await user.click(closeButton);
340+
await tick();
341+
342+
expect(closeHandler).toHaveBeenCalledTimes(1);
343+
expect(closeHandler.mock.calls[0][0].detail).toEqual({
344+
trigger: "close-button",
345+
});
346+
});
347+
348+
it("prevents closing when preventDefault is called on close event", async () => {
349+
const { container, component } = render(ComposedModalTest, {
350+
props: {
351+
open: true,
352+
headerTitle: "Prevent Close Test",
353+
},
354+
});
355+
356+
const closeHandler = vi.fn((e) => {
357+
e.preventDefault();
358+
});
359+
component.$on("close", closeHandler);
360+
361+
// Close via outside click.
362+
const modalOverlay = container.querySelector(".bx--modal");
363+
assert(modalOverlay);
364+
await user.click(modalOverlay);
365+
await tick();
366+
expect(closeHandler).toHaveBeenCalledTimes(1);
367+
expect(screen.getByRole("dialog")).toBeInTheDocument();
368+
369+
// Close via close button.
370+
const closeButton = screen.getByLabelText("Close");
371+
await user.click(closeButton);
372+
await tick();
373+
expect(closeHandler).toHaveBeenCalledTimes(2);
374+
expect(screen.getByRole("dialog")).toBeInTheDocument();
375+
});
346376
});

types/ComposedModal/ComposedModal.svelte.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export type ComposedModalProps = Omit<$RestProps, keyof $Props> & $Props;
5454
export default class ComposedModal extends SvelteComponentTyped<
5555
ComposedModalProps,
5656
{
57+
close: CustomEvent<{
58+
trigger: "escape-key" | "outside-click" | "close-button";
59+
}>;
5760
transitionend: CustomEvent<{ open: boolean }>;
5861
keydown: WindowEventMap["keydown"];
5962
click: WindowEventMap["click"];
@@ -62,7 +65,6 @@ export default class ComposedModal extends SvelteComponentTyped<
6265
mouseleave: WindowEventMap["mouseleave"];
6366
submit: CustomEvent<null>;
6467
["click:button--primary"]: CustomEvent<null>;
65-
close: CustomEvent<null>;
6668
open: CustomEvent<null>;
6769
},
6870
{ default: {} }

0 commit comments

Comments
 (0)