Skip to content

Commit

Permalink
Enable canceling events to prevent intended behavior
Browse files Browse the repository at this point in the history
  • Loading branch information
KittyGiraudel committed Aug 9, 2024
1 parent 1c45455 commit bf5b79f
Show file tree
Hide file tree
Showing 2 changed files with 114 additions and 37 deletions.
104 changes: 85 additions & 19 deletions cypress/e2e/instance.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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)
Expand All @@ -71,52 +72,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', () => {
Expand Down
47 changes: 29 additions & 18 deletions src/a11y-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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()

Expand All @@ -49,9 +55,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
}

Expand All @@ -63,6 +66,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
Expand Down Expand Up @@ -94,9 +103,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
}

Expand All @@ -109,6 +115,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?.()
Expand All @@ -118,9 +130,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
}

Expand Down Expand Up @@ -151,17 +160,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 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
}

/**
Expand Down

0 comments on commit bf5b79f

Please sign in to comment.