From e201c8572aecb6b39160b736ea1f7f6ad32914e3 Mon Sep 17 00:00:00 2001 From: Kitty Giraudel <1889710+KittyGiraudel@users.noreply.github.com> Date: Fri, 9 Aug 2024 10:34:11 +0200 Subject: [PATCH] Enable canceling events to prevent intended behavior --- cypress/e2e/instance.cy.ts | 104 ++++++++++++++++++++++++++++++------- src/a11y-dialog.ts | 47 ++++++++++------- 2 files changed, 114 insertions(+), 37 deletions(-) diff --git a/cypress/e2e/instance.cy.ts b/cypress/e2e/instance.cy.ts index 03777263..3b01ba64 100644 --- a/cypress/e2e/instance.cy.ts +++ b/cypress/e2e/instance.cy.ts @@ -61,6 +61,7 @@ describe('Instance', { testIsolation: false }, () => { ).to.eq('my-dialog') expect(event.target.id).to.eq('my-dialog') }, + showPrevented: event => event.preventDefault(), hide: event => { // When programmatically hiding the dialog, event details are not set. expect(event.detail).to.eq(null) @@ -72,52 +73,117 @@ describe('Instance', { testIsolation: false }, () => { expect(event.detail.target.hasAttribute('data-a11y-dialog-hide')) expect(event.target.id).to.eq('my-dialog') }, + hidePrevented: event => event.preventDefault(), + hideConditionallyPrevented: event => { + if (event.detail?.key === 'Escape') event.preventDefault() + }, destroy: event => { expect(event.target.id).to.eq('my-dialog') }, } - // Spy on handlers to know whether they’ve been called + cy.log('Spy on handlers to know whether they’ve been called') cy.spy(handlers, 'show').as('show') cy.spy(handlers, 'hide').as('hide') cy.spy(handlers, 'showManual').as('showManual') cy.spy(handlers, 'hideManual').as('hideManual') + cy.spy(handlers, 'showPrevented').as('showPrevented') + cy.spy(handlers, 'hidePrevented').as('hidePrevented') + cy.spy(handlers, 'hideConditionallyPrevented').as( + 'hideConditionallyPrevented' + ) cy.spy(handlers, 'destroy').as('destroy') - // Register event listeners on show, hide and destroy events - cy.window() - .its('instance') - .invoke('on', 'show', handlers.show) - .invoke('on', 'hide', handlers.hide) - .invoke('on', 'destroy', handlers.destroy) - - // Programmatically show the dialog and ensure the show handler has been - // called + cy.log( + 'Programmatically show the dialog and ensure the show handler has been called' + ) + cy.window().its('instance').invoke('on', 'show', handlers.show) cy.window().its('instance').invoke('show') cy.get('@show').should('have.been.called') + cy.get('.dialog').then(shouldBeVisible) + cy.window().its('instance').invoke('off', 'show', handlers.show) - // Programmatically hide the dialog and ensure the hide handler has been - // called + cy.log( + 'Programmatically hide the dialog and ensure the hide handler has been called' + ) + cy.window().its('instance').invoke('on', 'hide', handlers.hide) cy.window().its('instance').invoke('hide') cy.get('@hide').should('have.been.called') + cy.get('.dialog').then(shouldBeHidden) + cy.window().its('instance').invoke('off', 'hide', handlers.hide) - // Replace the show handler with one that test for a manual action, and - // manually show the dialog to ensure it was called properly - cy.window().its('instance').invoke('off', 'show', handlers.show) + cy.log( + 'Replace the show handler with one that test for a manual action, and manually show the dialog to ensure it was called properly' + ) cy.window().its('instance').invoke('on', 'show', handlers.showManual) cy.get('[data-a11y-dialog-show="my-dialog"]').click() cy.get('@showManual').should('have.been.called') + cy.get('.dialog').then(shouldBeVisible) + cy.window().its('instance').invoke('off', 'show', handlers.showManual) - // Replace the hide handler with one that test for a manual action, and - // manually hide the dialog to ensure it was called properly - cy.window().its('instance').invoke('off', 'hide', handlers.hide) + cy.log( + 'Replace the hide handler with one that test for a manual action, and manually hide the dialog to ensure it was called properly' + ) cy.window().its('instance').invoke('on', 'hide', handlers.hideManual) cy.get('[data-a11y-dialog-hide').last().click() cy.get('@hideManual').should('have.been.called') + cy.get('.dialog').then(shouldBeHidden) + cy.window().its('instance').invoke('off', 'hide', handlers.hideManual) + + cy.log( + 'Replace the show handler with one that prevents the action, and attempt to show the dialog to ensure it was called properly' + ) + cy.window().its('instance').invoke('on', 'show', handlers.showPrevented) + cy.get('[data-a11y-dialog-show="my-dialog"]').click() + cy.get('@showPrevented').should('have.been.called') + cy.get('.dialog').then(shouldBeHidden) + cy.window().its('instance').invoke('off', 'show', handlers.showPrevented) + + cy.log( + 'Replace the hide handler with one that prevents the action, and attempt to hide the dialog to ensure it was called properly' + ) + cy.window().its('instance').invoke('on', 'hide', handlers.hidePrevented) + cy.get('[data-a11y-dialog-show="my-dialog"]').click() + cy.get('.dialog').then(shouldBeVisible) + cy.get('[data-a11y-dialog-hide').last().click() + cy.get('@hidePrevented').should('have.been.called') + cy.get('.dialog').then(shouldBeVisible) + cy.window().its('instance').invoke('off', 'hide', handlers.hidePrevented) + cy.get('[data-a11y-dialog-hide').last().click() + cy.get('.dialog').then(shouldBeHidden) + + cy.log( + 'Replace the hide handler with one that conditionally prevents the action, and attempt to hide the dialog to ensure it was called properly' + ) + cy.window() + .its('instance') + .invoke('on', 'hide', handlers.hideConditionallyPrevented) + // Open the dialog and expect it to be visible + cy.get('[data-a11y-dialog-show="my-dialog"]').click() + cy.get('.dialog').then(shouldBeVisible) + // Close it with a button and expect it to be hidden + cy.get('[data-a11y-dialog-hide').last().click() + cy.get('@hideConditionallyPrevented').should('have.been.called') + cy.get('.dialog').then(shouldBeHidden) + // Open the dialog and expect it to be visible + cy.get('[data-a11y-dialog-show="my-dialog"]').click() + cy.get('.dialog').then(shouldBeVisible) + // Close it with ESC and expect it to still be visible + cy.realPress('Escape') + cy.get('@hideConditionallyPrevented').should('have.been.called') + cy.get('.dialog').then(shouldBeVisible) + // Remove the event prevention, close it with ESC and expect it to be hidden + cy.window() + .its('instance') + .invoke('off', 'hide', handlers.hideConditionallyPrevented) + cy.realPress('Escape') + cy.get('.dialog').then(shouldBeHidden) - // Destroy the dialog and ensure the destroy handler has been called + cy.log('Destroy the dialog and ensure the destroy handler has been called') + cy.window().its('instance').invoke('on', 'destroy', handlers.destroy) cy.window().its('instance').invoke('destroy') cy.get('@destroy').should('have.been.called') + cy.window().its('instance').invoke('off', 'destroy', handlers.destroy) }) it('should be possible to handle dialog destroy', () => { diff --git a/src/a11y-dialog.ts b/src/a11y-dialog.ts index 6998da12..ebea51e3 100644 --- a/src/a11y-dialog.ts +++ b/src/a11y-dialog.ts @@ -44,6 +44,12 @@ export default class A11yDialog { * and remove all associated listeners from dialog openers and closers */ public destroy(): A11yDialogInstance { + // Dispatch a `destroy` event + const destroyEvent = this.fire('destroy') + + // If the event was prevented, do not continue with the normal behavior + if (destroyEvent.defaultPrevented) return this + // Hide the dialog to avoid destroying an open instance this.hide() @@ -54,9 +60,6 @@ export default class A11yDialog { // event listeners that the author might not have cleaned up. this.$el.replaceWith(this.$el.cloneNode(true)) - // Dispatch a `destroy` event - this.fire('destroy') - return this } @@ -68,6 +71,12 @@ export default class A11yDialog { // If the dialog is already open, abort if (this.shown) return this + // Dispatch a `show` event + const showEvent = this.fire('show', event) + + // If the event was prevented, do not continue with the normal behavior + if (showEvent.defaultPrevented) return this + // Keep a reference to the currently focused element to be able to restore // it later this.shown = true @@ -99,9 +108,6 @@ export default class A11yDialog { document.body.addEventListener('focus', this.maintainFocus, true) this.$el.addEventListener('keydown', this.bindKeypress, true) - // Dispatch a `show` event - this.fire('show', event) - return this } @@ -114,6 +120,12 @@ export default class A11yDialog { // If the dialog is already closed, abort if (!this.shown) return this + // Dispatch a `hide` event + const hideEvent = this.fire('hide', event) + + // If the event was prevented, do not continue with the normal behavior + if (hideEvent.defaultPrevented) return this + this.shown = false this.$el.setAttribute('aria-hidden', 'true') this.previouslyFocused?.focus?.() @@ -123,9 +135,6 @@ export default class A11yDialog { document.body.removeEventListener('focus', this.maintainFocus, true) this.$el.removeEventListener('keydown', this.bindKeypress, true) - // Dispatch a `hide` event - this.fire('hide', event) - return this } @@ -156,17 +165,19 @@ export default class A11yDialog { } /** - * Dispatch a custom event from the DOM element associated with this dialog. - * This allows authors to listen for and respond to the events in their own - * code + * Dispatch and return a custom event from the DOM element associated with + * this dialog; this allows authors to listen for and respond to the events + * in their own code */ private fire(type: A11yDialogEvent, event?: Event) { - this.$el.dispatchEvent( - new CustomEvent(type, { - detail: event, - cancelable: true, - }) - ) + const customEvent = new CustomEvent(type, { + detail: event, + cancelable: true, + }) + + this.$el.dispatchEvent(customEvent) + + return customEvent } /**