From 7f5e45d528c5177b57bc6293dd8ea3ff5203974d Mon Sep 17 00:00:00 2001 From: thedhanawada <13751641+thedhanawada@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:32:50 +1000 Subject: [PATCH] fix: preserve editor interactions across shadow boundaries --- src/components/EventForm.js | 16 ++++++- tests/unit/EventForm.test.js | 84 ++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/components/EventForm.js b/src/components/EventForm.js index 0e1e726..b288825 100644 --- a/src/components/EventForm.js +++ b/src/components/EventForm.js @@ -69,6 +69,8 @@ export class EventForm extends BaseComponent { background: var(--fc-background); width: 400px; max-width: 90vw; + max-height: 90vh; + overflow: hidden; border-radius: var(--fc-border-radius-lg); box-shadow: var(--fc-shadow-lg); border: 1px solid var(--fc-border-color); @@ -78,6 +80,7 @@ export class EventForm extends BaseComponent { } .modal-header { + flex-shrink: 0; padding: var(--fc-spacing-lg); border-bottom: 1px solid var(--fc-border-color); display: flex; @@ -109,6 +112,8 @@ export class EventForm extends BaseComponent { } .modal-body { + min-height: 0; + overflow-y: auto; padding: var(--fc-spacing-lg); display: flex; flex-direction: column; @@ -116,6 +121,7 @@ export class EventForm extends BaseComponent { } .form-group { + min-width: 0; display: flex; flex-direction: column; gap: 4px; @@ -130,6 +136,8 @@ export class EventForm extends BaseComponent { input[type="text"], input[type="datetime-local"], select { + width: 100%; + min-width: 0; padding: 8px 12px; border: 1px solid var(--fc-border-color); border-radius: var(--fc-border-radius); @@ -149,6 +157,7 @@ export class EventForm extends BaseComponent { .row { display: flex; + flex-direction: column; gap: var(--fc-spacing-md); } @@ -157,6 +166,7 @@ export class EventForm extends BaseComponent { } .modal-footer { + flex-shrink: 0; padding: var(--fc-spacing-lg); border-top: 1px solid var(--fc-border-color); display: flex; @@ -170,6 +180,7 @@ export class EventForm extends BaseComponent { /* Color picker style */ .color-options { display: flex; + flex-wrap: wrap; gap: 8px; margin-top: 4px; } @@ -299,9 +310,10 @@ export class EventForm extends BaseComponent { }); }); - // Close on backdrop click + // Shadow DOM retargets inside clicks to the host. Only dismiss when the + // composed path does not pass through the dialog, including nested shadows. this.addListener(this, 'click', e => { - if (e.target === this) this.close(); + if (!e.composedPath().includes(this.modalContent)) this.close(); }); // Close on Escape key - only add once to prevent memory leaks diff --git a/tests/unit/EventForm.test.js b/tests/unit/EventForm.test.js index 2c357be..463e32d 100644 --- a/tests/unit/EventForm.test.js +++ b/tests/unit/EventForm.test.js @@ -69,4 +69,88 @@ describe('EventForm', () => { form.save(); }); + + describe('composed click interactions', () => { + const click = element => element.dispatchEvent(new MouseEvent('click', { + bubbles: true, + composed: true + })); + + test.each(['#event-title', '#event-start', '#event-end', '.modal-content']) ( + 'keeps the dialog open when clicking %s across the shadow boundary', selector => { + form.open(); + const received = jest.fn(); + form.addEventListener('click', received); + + click(form.$(selector)); + + // A real composed event is retargeted to the host, even for an inside click. + expect(received).toHaveBeenCalledTimes(1); + expect(received.mock.calls[0][0].target).toBe(form); + expect(form.hasAttribute('open')).toBe(true); + } + ); + + test('selects a color without dismissing or saving, then saves that color', () => { + form.open(); + form.titleInput.value = 'Release planning'; + const onSave = jest.fn(); + form.addEventListener('save', onSave); + const purple = form.$('[aria-label="Purple"]'); + + click(purple); + + expect(form.hasAttribute('open')).toBe(true); + expect(purple.getAttribute('aria-checked')).toBe('true'); + expect(form.titleInput.value).toBe('Release planning'); + expect(onSave).not.toHaveBeenCalled(); + + click(form.$('#save-btn')); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave.mock.calls[0][0].detail.backgroundColor).toBe(purple.dataset.color); + expect(form.hasAttribute('open')).toBe(false); + }); + + test.each(['title', 'date range'])('keeps invalid %s errors visible after Save', invalidField => { + form.open(); + form.titleInput.value = invalidField === 'title' ? '' : 'Release planning'; + if (invalidField === 'date range') form.endInput.value = form.startInput.value; + const onSave = jest.fn(); + form.addEventListener('save', onSave); + + click(form.$('#save-btn')); + + expect(form.hasAttribute('open')).toBe(true); + expect(form.$(invalidField === 'title' ? '#title-group' : '#end-group') + .classList.contains('has-error')).toBe(true); + expect(onSave).not.toHaveBeenCalled(); + }); + + test.each(['#cancel-btn', '#close-x', '#close-x path'])('still dismisses with %s', selector => { + form.open(); + click(form.$(selector)); + expect(form.hasAttribute('open')).toBe(false); + }); + + test('closes only on the backdrop after repeated opening inside another shadow root', () => { + const outer = document.createElement('div'); + const root = outer.attachShadow({ mode: 'open' }); + document.body.appendChild(outer); + root.appendChild(form); + try { + for (let attempt = 0; attempt < 2; attempt++) { + form.open(); + click(form.$('[aria-label="Purple"]')); + expect(form.hasAttribute('open')).toBe(true); + click(form); + expect(form.hasAttribute('open')).toBe(false); + } + } finally { + document.body.appendChild(form); + outer.remove(); + } + }); + }); + }); \ No newline at end of file