Skip to content

Commit 17fbc40

Browse files
authored
Fix/2059 date field clicking a date in a dialog can close the dialog (#2331)
* story with bug reproduction, trying to fix the issue * trying to fix the issue * have the dialog working when clicking in the calendar, still a problem with using escape * trying to solve the issue with an escape key * investigating the issue with popover closing and dialog at once * fixing using escape key in the date field (when closing calendar), problems with unit tests in the dialog * fixing dialog unit tests, date field in a dialog story changes * cleanup * cleanup * changeset * Trying to fix the dialog closing when the date field (popover) is opened - not working yet * example of a dialog with multiple overlay components in it * Make it working with using escape key in the select and combobox and only partially working in the popover * Make it working with using escape key in the popover as well, story changes * Make it working with using escape key in the menu/menu-button * trying to block the dialog closing when the date-field is opened * trying to block the dialog closing when the date-field is opened and also for the select component * trying to block the dialog closing when child elements are opened * removing some unnecessary changes, more description to `disableCancel` added. * removing some unnecessary changes * cleanup * cleanup * trying to fix tests using different approach for events * fixing tests * improved example * changeset and small descriptions changes in tests * changes after review * popover example in the dialog improved
1 parent 91197da commit 17fbc40

File tree

16 files changed

+276
-19
lines changed

16 files changed

+276
-19
lines changed

.changeset/chatty-places-throw.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sl-design-system/combobox': patch
3+
---
4+
5+
Fixes the issue where pressing the `Escape` key inside the combobox closes parent containers (such as dialogs).

.changeset/chubby-ravens-decide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sl-design-system/menu': patch
3+
---
4+
5+
Fixes the issue where pressing the `Escape` key inside the menu closes parent containers (such as dialogs).

.changeset/itchy-onions-lead.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@sl-design-system/dialog': patch
3+
---
4+
5+
Fixes closing the dialog when clicking the backdrop.
6+
The dialog should close only when the dialog element itself is clicked, not when a child of the dialog is clicked.

.changeset/mighty-schools-itch.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@sl-design-system/date-field': patch
3+
---
4+
5+
Fixes the issue where pressing the `Escape` key inside the date picker (calendar) closes parent containers (such as dialogs).
6+
Prevents the Escape key event from bubbling up, so pressing Escape inside the date field does not close the dialog (or other parent container).

.changeset/poor-moose-hang.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sl-design-system/select': patch
3+
---
4+
5+
Fixes the issue where pressing the `Escape` key inside the select closes parent containers (such as dialogs).
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@sl-design-system/popover': patch
3+
'@sl-design-system/shared': patch
4+
---
5+
6+
Fixes the issue where pressing the `Escape` key inside the popover closes parent containers (such as dialogs).

packages/components/combobox/src/combobox.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ export class Combobox<T = any, U = T> extends FormControlMixin(ScopedElementsMix
443443
})}
444444
@beforetoggle=${this.#onBeforeToggle}
445445
@click=${this.#onOptionClick}
446+
@keydown=${this.#onKeydown}
446447
@slotchange=${() => this.#onSlotChange()}
447448
@toggle=${this.#onToggle}
448449
part="wrapper"
@@ -625,6 +626,10 @@ export class Combobox<T = any, U = T> extends FormControlMixin(ScopedElementsMix
625626
index = (index + delta + items.length) % items.length;
626627

627628
this.#updateCurrent(items[index]);
629+
} else if (event.key === 'Escape') {
630+
// Prevents the Escape key event from bubbling up, so that pressing 'Escape' inside the combobox
631+
// does not close parent containers (such as dialogs).
632+
event.stopPropagation();
628633
}
629634
}
630635

packages/components/date-field/src/date-field.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export class DateField extends LocaleMixin(FormControlMixin(ScopedElementsMixin(
118118

119119
/**
120120
* Whether the component is select only. This means you cannot type in the text field,
121-
* but you can still pick a date via de popover.
121+
* but you can still pick a date via the popover.
122122
* @default false
123123
*/
124124
@property({ type: Boolean, reflect: true, attribute: 'select-only' }) selectOnly?: boolean;
@@ -231,6 +231,7 @@ export class DateField extends LocaleMixin(FormControlMixin(ScopedElementsMixin(
231231
})}
232232
@beforetoggle=${this.#onBeforeToggle}
233233
@toggle=${this.#onToggle}
234+
@keydown=${this.#onKeydown}
234235
name="calendar"
235236
part="wrapper"
236237
popover
@@ -336,4 +337,12 @@ export class DateField extends LocaleMixin(FormControlMixin(ScopedElementsMixin(
336337
// Trigger a rerender so the calendar will be rendered
337338
this.requestUpdate();
338339
}
340+
341+
#onKeydown(event: KeyboardEvent): void {
342+
if (event.key === 'Escape') {
343+
// Prevents the Escape key event from bubbling up, so that pressing 'Escape' inside the date field
344+
// does not close parent containers (such as dialogs).
345+
event.stopPropagation();
346+
}
347+
}
339348
}

packages/components/dialog/src/dialog.spec.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, fixture } from '@open-wc/testing';
1+
import { expect, fixture, oneEvent } from '@open-wc/testing';
22
import { type Button } from '@sl-design-system/button';
33
import '@sl-design-system/button/register.js';
44
import { sendKeys } from '@web/test-runner-commands';
@@ -134,7 +134,6 @@ describe('sl-dialog', () => {
134134
const onCancel = spy();
135135
el.addEventListener('sl-cancel', onCancel);
136136

137-
// Mock the click event
138137
const clickEvent = new PointerEvent('click');
139138
stub(clickEvent, 'clientX').value(100);
140139
stub(clickEvent, 'clientY').value(100);
@@ -143,14 +142,56 @@ describe('sl-dialog', () => {
143142
expect(onCancel).to.have.been.calledOnce;
144143
});
145144

145+
it('should only handle backdrop clicks when event.target is a dialog', async () => {
146+
stub(dialog, 'getBoundingClientRect').returns({
147+
top: 400,
148+
right: 1400,
149+
bottom: 900,
150+
left: 700
151+
} as DOMRect);
152+
153+
const onCancel = spy();
154+
el.addEventListener('sl-cancel', onCancel);
155+
156+
const clickEvent = new PointerEvent('click');
157+
stub(clickEvent, 'clientX').value(100);
158+
stub(clickEvent, 'clientY').value(100);
159+
dialog.dispatchEvent(clickEvent);
160+
161+
// Wait for the event to be emitted
162+
await new Promise(resolve => setTimeout(resolve));
163+
164+
expect(onCancel).to.have.been.calledOnce;
165+
166+
onCancel.resetHistory();
167+
168+
// Simulate a click inside the dialog (not a backdrop click)
169+
const button = document.createElement('sl-button');
170+
button.innerHTML = 'Click me';
171+
dialog.appendChild(button);
172+
173+
await el.updateComplete;
174+
175+
button.click();
176+
await el.updateComplete;
177+
178+
// Wait for the event to be emitted
179+
await new Promise(resolve => setTimeout(resolve));
180+
181+
expect(onCancel).not.to.have.been.called;
182+
});
183+
146184
it('should emit an sl-close event when calling close()', async () => {
147185
const onClose = spy();
148186

149187
el.addEventListener('sl-close', onClose);
150188
el.close();
151189

152-
// Wait for the event to be emitted
153-
await new Promise(resolve => setTimeout(resolve, 50));
190+
// Using `oneEvent` https://open-wc.org/docs/testing/helpers/#testing-events
191+
// instead of `await new Promise(resolve => setTimeout(resolve))`
192+
// ensures the test waits for the actual 'sl-close' event to be emitted by the component, rather than relying on a timeout.
193+
await oneEvent(el, 'sl-close', false);
194+
await el.updateComplete;
154195

155196
expect(onClose).to.have.been.calledOnce;
156197
});
@@ -172,8 +213,9 @@ describe('sl-dialog', () => {
172213
stub(clickEvent, 'clientY').value(100);
173214
dialog.dispatchEvent(clickEvent);
174215

175-
// Wait for the event to be emitted
176-
await new Promise(resolve => setTimeout(resolve, 50));
216+
// ensures the test waits for the actual 'sl-close' event to be emitted by the component, rather than relying on a timeout.
217+
await oneEvent(el, 'sl-close', false);
218+
await el.updateComplete;
177219

178220
expect(onClose).to.have.been.calledOnce;
179221
});
@@ -184,8 +226,10 @@ describe('sl-dialog', () => {
184226
el.addEventListener('sl-close', onClose);
185227
el.renderRoot.querySelector<Button>('sl-button[aria-label="Close"]')?.click();
186228

187-
// Wait for the event to be emitted
188-
await new Promise(resolve => setTimeout(resolve, 50));
229+
// ensures the test waits for the actual 'sl-close' event to be emitted by the component, rather than relying on a timeout.
230+
await oneEvent(el, 'sl-close', false);
231+
232+
await el.updateComplete;
189233

190234
expect(onClose).to.have.been.calledOnce;
191235
});
@@ -196,8 +240,9 @@ describe('sl-dialog', () => {
196240
el.addEventListener('sl-close', onClose);
197241
el.querySelector('sl-button')?.click();
198242

199-
// Wait for the event to be emitted
200-
await new Promise(resolve => setTimeout(resolve, 50));
243+
// ensures the test waits for the actual 'sl-close' event to be emitted by the component, rather than relying on a timeout.
244+
await oneEvent(el, 'sl-close', false);
245+
await el.updateComplete;
201246

202247
expect(onClose).to.have.been.calledOnce;
203248
});

packages/components/dialog/src/dialog.stories.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { faBurst } from '@fortawesome/pro-regular-svg-icons';
22
import '@sl-design-system/button/register.js';
33
import '@sl-design-system/button-bar/register.js';
4+
import '@sl-design-system/combobox/register.js';
5+
import '@sl-design-system/date-field/register.js';
46
import '@sl-design-system/form/register.js';
57
import { Icon } from '@sl-design-system/icon';
68
import '@sl-design-system/icon/register.js';
9+
import '@sl-design-system/listbox/register.js';
710
import { FormInDialog } from '@sl-design-system/lit-examples';
11+
import '@sl-design-system/popover/register.js';
12+
import '@sl-design-system/select/register.js';
813
import '@sl-design-system/text-area/register.js';
914
import '@sl-design-system/text-field/register.js';
1015
import { type Meta, type StoryObj } from '@storybook/web-components-vite';
@@ -230,3 +235,107 @@ export const All: Story = {
230235
`;
231236
}
232237
};
238+
239+
export const DialogWithOverlayComponents: Story = {
240+
render: () => {
241+
const onClick = async (event: Event & { target: HTMLElement }) => {
242+
const dialog = document.createElement('sl-dialog');
243+
244+
const onClickPopover = (): void => {
245+
const popover = document.getElementById('popover-example') as HTMLElement & { togglePopover(): void };
246+
popover?.togglePopover();
247+
};
248+
249+
const hidePopover = (): void => {
250+
const popover = document.getElementById('popover-example') as HTMLElement & { togglePopover(): void };
251+
popover?.hidePopover();
252+
};
253+
254+
dialog.innerHTML = `
255+
<span slot="title">Dialog with overlay components</span>
256+
<div class="container">
257+
This dialog should not close when any overlay component is closed using the Escape key.
258+
259+
<sl-date-field autofocus select-only placeholder="Choose a date" style="width: fit-content"> </sl-date-field>
260+
261+
<sl-select placeholder="Select an option">
262+
<sl-option value="1">Option 1</sl-option>
263+
<sl-option value="2">Option 2</sl-option>
264+
<sl-option value="3">Option 3</sl-option>
265+
<sl-option value="3">Option 4</sl-option>
266+
<sl-option value="3">Option 5</sl-option>
267+
</sl-select>
268+
269+
<sl-combobox multiple value='["0","2"]'>
270+
<sl-listbox>
271+
<sl-option value="0">Mathematics</sl-option>
272+
<sl-option value="1">Geography</sl-option>
273+
<sl-option value="2">Physics</sl-option>
274+
<sl-option value="3">History</sl-option>
275+
<sl-option value="4">Biology</sl-option>
276+
<sl-option value="4">Art</sl-option>
277+
</sl-listbox>
278+
</sl-combobox>
279+
280+
<sl-button id="anchor" variant="primary">Show definition</sl-button>
281+
<sl-popover id="popover-example" anchor="anchor">
282+
<header style="font-size: 1.1em; padding-block-end: 1rem;">Word Definition</header>
283+
<section style="padding-block-end: 1rem;">
284+
<strong>Photosynthesis</strong> is the process by which green plants and some other organisms <br/>
285+
use sunlight to synthesize foods from carbon dioxide and water.
286+
</section>
287+
<footer>
288+
<sl-button id="closeButton" size="sm" variant="primary">Got it</sl-button>
289+
</footer>
290+
</sl-popover>
291+
292+
<sl-menu-button position="bottom">
293+
<span slot="button">Actions</span>
294+
<sl-menu-item><sl-icon name="smile"></sl-icon>Profile</sl-menu-item>
295+
<sl-menu-item><sl-icon name="calendar"></sl-icon>Settings</sl-menu-item>
296+
<sl-menu-item><sl-icon name="far-trash"></sl-icon>Remove</sl-menu-item>
297+
</sl-menu-button>
298+
</div>
299+
<sl-button slot="primary-actions" sl-dialog-close variant="primary">Close</sl-button>
300+
`;
301+
302+
dialog.closeButton = true;
303+
304+
dialog.addEventListener('sl-close', () => {
305+
dialog.remove();
306+
});
307+
308+
event.target.insertAdjacentElement('afterend', dialog);
309+
310+
dialog.querySelector('#anchor')?.addEventListener('click', onClickPopover);
311+
312+
dialog.querySelector('#closeButton')?.addEventListener('click', hidePopover);
313+
314+
await dialog.updateComplete;
315+
316+
dialog.showModal();
317+
};
318+
319+
return html`
320+
<style>
321+
.container {
322+
display: flex;
323+
flex-direction: column;
324+
gap: 0.8rem;
325+
margin-block-end: 0.5rem;
326+
}
327+
328+
section {
329+
margin-block-end: 1rem;
330+
}
331+
</style>
332+
<section>
333+
This example shows a dialog with overlay components (such as date fields, selects, comboboxes, popovers, and
334+
menu buttons). <br />
335+
The main purpose is to verify that closing any of these overlay components (for example, by pressing the
336+
<code>Escape</code> key) does not accidentally close the parent dialog.
337+
</section>
338+
<sl-button @click=${onClick}>Open dialog</sl-button>
339+
`;
340+
}
341+
};

0 commit comments

Comments
 (0)