-
Notifications
You must be signed in to change notification settings - Fork 904
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: add event hooks for default prevention behavior
PiperOrigin-RevId: 592375327
- Loading branch information
1 parent
eca1357
commit d06a3e7
Showing
2 changed files
with
356 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
/** | ||
* @license | ||
* Copyright 2023 Google LLC | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
/** | ||
* A symbol used to access dispatch hooks on an event. | ||
*/ | ||
const dispatchHooks = Symbol('dispatchHooks'); | ||
|
||
/** | ||
* An `Event` with additional symbols for dispatch hooks. | ||
*/ | ||
interface EventWithDispatchHooks extends Event { | ||
[dispatchHooks]: EventTarget; | ||
} | ||
|
||
/** | ||
* Add a hook for an event that is called after the event is dispatched and | ||
* propagates to other event listeners. | ||
* | ||
* This is useful for behaviors that need to check if an event is canceled. | ||
* | ||
* The callback is invoked synchronously, which allows for better integration | ||
* with synchronous platform APIs (like `<form>` or `<label>` clicking). | ||
* | ||
* Note: `setupDispatchHooks()` must be called on the element before adding any | ||
* other event listeners. Call it in the constructor of an element or | ||
* controller. | ||
* | ||
* @example | ||
* ```ts | ||
* class MyControl extends LitElement { | ||
* constructor() { | ||
* super(); | ||
* setupDispatchHooks(this, 'click'); | ||
* this.addEventListener('click', event => { | ||
* afterDispatch(event, () => { | ||
* if (event.defaultPrevented) { | ||
* return | ||
* } | ||
* | ||
* // ... perform logic | ||
* }); | ||
* }); | ||
* } | ||
* } | ||
* ``` | ||
* | ||
* @example | ||
* ```ts | ||
* class MyController implements ReactiveController { | ||
* constructor(host: ReactiveElement) { | ||
* // setupDispatchHooks() may be called multiple times for the same | ||
* // element and events, making it safe for multiple controllers to use it. | ||
* setupDispatchHooks(host, 'click'); | ||
* host.addEventListener('click', event => { | ||
* afterDispatch(event, () => { | ||
* if (event.defaultPrevented) { | ||
* return; | ||
* } | ||
* | ||
* // ... perform logic | ||
* }); | ||
* }); | ||
* } | ||
* } | ||
* ``` | ||
* | ||
* @param event The event to add a hook to. | ||
* @param callback A hook that is called after the event finishes dispatching. | ||
*/ | ||
export function afterDispatch(event: Event, callback: () => void) { | ||
const hooks = (event as EventWithDispatchHooks)[dispatchHooks]; | ||
if (!hooks) { | ||
throw new Error(`'${event.type}' event needs setupDispatchHooks().`); | ||
} | ||
|
||
hooks.addEventListener('after', callback); | ||
} | ||
|
||
/** | ||
* A lookup map of elements and event types that have a dispatch hook listener | ||
* set up. Used to ensure we don't set up multiple hook listeners on the same | ||
* element for the same event. | ||
*/ | ||
const ELEMENT_DISPATCH_HOOK_TYPES = new WeakMap<Element, Set<string>>(); | ||
|
||
/** | ||
* Sets up an element to add dispatch hooks to given event types. This must be | ||
* called before adding any event listeners that need to use dispatch hooks | ||
* like `afterDispatch()`. | ||
* | ||
* This function is safe to call multiple times with the same element or event | ||
* types. Call it in the constructor of elements, mixins, and controllers to | ||
* ensure it is set up before external listeners. | ||
* | ||
* @example | ||
* ```ts | ||
* class MyControl extends LitElement { | ||
* constructor() { | ||
* super(); | ||
* setupDispatchHooks(this, 'click'); | ||
* this.addEventListener('click', this.listenerUsingAfterDispatch); | ||
* } | ||
* } | ||
* ``` | ||
* | ||
* @param element The element to set up event dispatch hooks for. | ||
* @param eventTypes The event types to add dispatch hooks to. | ||
*/ | ||
export function setupDispatchHooks( | ||
element: Element, | ||
...eventTypes: [string, ...string[]] | ||
) { | ||
let typesAlreadySetUp = ELEMENT_DISPATCH_HOOK_TYPES.get(element); | ||
if (!typesAlreadySetUp) { | ||
typesAlreadySetUp = new Set(); | ||
ELEMENT_DISPATCH_HOOK_TYPES.set(element, typesAlreadySetUp); | ||
} | ||
|
||
for (const eventType of eventTypes) { | ||
// Don't register multiple dispatch hook listeners. A second registration | ||
// would lead to the second listener re-dispatching a re-dispatched event, | ||
// which can cause an infinite loop inside the other one. | ||
if (typesAlreadySetUp.has(eventType)) { | ||
continue; | ||
} | ||
|
||
// When we re-dispatch the event, it's going to immediately trigger this | ||
// listener again. Use a flag to ignore it. | ||
let isRedispatching = false; | ||
element.addEventListener( | ||
eventType, | ||
(event: Event) => { | ||
if (isRedispatching) { | ||
return; | ||
} | ||
|
||
// Do not let the event propagate to any other listener (not just | ||
// bubbling listeners with `stopPropagation()`). | ||
event.stopImmediatePropagation(); | ||
// Make a copy. | ||
const eventCopy = Reflect.construct(event.constructor, [ | ||
event.type, | ||
event, | ||
]); | ||
|
||
// Add hooks onto the event. | ||
const hooks = new EventTarget(); | ||
(eventCopy as EventWithDispatchHooks)[dispatchHooks] = hooks; | ||
|
||
// Re-dispatch the event. We can't reuse `redispatchEvent()` since we | ||
// need to add the hooks to the copy before it's dispatched. | ||
isRedispatching = true; | ||
const dispatched = element.dispatchEvent(eventCopy); | ||
isRedispatching = false; | ||
if (!dispatched) { | ||
event.preventDefault(); | ||
} | ||
|
||
// Synchronously call afterDispatch() hooks. | ||
hooks.dispatchEvent(new Event('after')); | ||
}, | ||
{ | ||
// Ensure this listener runs before other listeners. | ||
// `setupDispatchHooks()` should be called in constructors to also | ||
// ensure they run before any other externally-added capture listeners. | ||
capture: true, | ||
}, | ||
); | ||
|
||
typesAlreadySetUp.add(eventType); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,180 @@ | ||
/** | ||
* @license | ||
* Copyright 2023 Google LLC | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
// import 'jasmine'; (google3-only) | ||
|
||
import {afterDispatch, setupDispatchHooks} from './dispatch-hooks.js'; | ||
|
||
describe('dispatch hooks', () => { | ||
let element: HTMLDivElement; | ||
|
||
beforeEach(() => { | ||
element = document.createElement('div'); | ||
document.body.appendChild(element); | ||
}); | ||
|
||
afterEach(() => { | ||
document.body.removeChild(element); | ||
}); | ||
|
||
describe('setupDispatchHooks()', () => { | ||
it('does not add more than one setup listener for an event type', () => { | ||
spyOn(element, 'addEventListener').and.callThrough(); | ||
setupDispatchHooks(element, 'foo'); | ||
setupDispatchHooks(element, 'foo'); | ||
|
||
expect(element.addEventListener) | ||
.withContext('element.addEventListener') | ||
.toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('can add setup listeners for multiple event types', () => { | ||
spyOn(element, 'addEventListener').and.callThrough(); | ||
|
||
setupDispatchHooks(element, 'foo', 'bar', 'baz'); | ||
expect(element.addEventListener) | ||
.withContext('element.addEventListener') | ||
.toHaveBeenCalledTimes(3); | ||
}); | ||
}); | ||
|
||
describe('afterDispatch()', () => { | ||
it('resolves synchronously after the event is finished dispatching', () => { | ||
setupDispatchHooks(element, 'click'); | ||
|
||
const afterDispatchCallback = jasmine.createSpy('afterDispatchCallback'); | ||
const clickListener = jasmine | ||
.createSpy('clickListener') | ||
.and.callFake((event: Event) => { | ||
afterDispatch(event, afterDispatchCallback); | ||
}); | ||
|
||
element.addEventListener('click', clickListener); | ||
element.click(); | ||
|
||
expect(clickListener) | ||
.withContext('clickListener') | ||
.toHaveBeenCalledTimes(1); | ||
expect(afterDispatchCallback) | ||
.withContext('afterDispatch() callback') | ||
.toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('supports multiple afterDispatch listeners', () => { | ||
setupDispatchHooks(element, 'click'); | ||
|
||
const firstAfterDispatchCallback = jasmine.createSpy( | ||
'firstAfterDispatchCallback', | ||
); | ||
element.addEventListener('click', (event) => { | ||
afterDispatch(event, firstAfterDispatchCallback); | ||
}); | ||
|
||
const secondAfterDispatchCallback = jasmine.createSpy( | ||
'secondAfterDispatchCallback', | ||
); | ||
element.addEventListener('click', (event) => { | ||
afterDispatch(event, secondAfterDispatchCallback); | ||
}); | ||
|
||
element.click(); | ||
|
||
expect(firstAfterDispatchCallback) | ||
.withContext('afterDispatch() first callback') | ||
.toHaveBeenCalledTimes(1); | ||
expect(secondAfterDispatchCallback) | ||
.withContext('afterDispatch() second callback') | ||
.toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('resolves synchronously after the event is finished dispatching', () => { | ||
setupDispatchHooks(element, 'click'); | ||
|
||
const afterDispatchCallback = jasmine.createSpy('afterDispatchCallback'); | ||
const clickListener = jasmine | ||
.createSpy('clickListener') | ||
.and.callFake((event: Event) => { | ||
afterDispatch(event, afterDispatchCallback); | ||
}); | ||
|
||
element.addEventListener('click', clickListener); | ||
element.click(); | ||
|
||
expect(clickListener) | ||
.withContext('clickListener') | ||
.toHaveBeenCalledTimes(1); | ||
expect(afterDispatchCallback) | ||
.withContext('afterDispatch() callback') | ||
.toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('can be used to synchronously detect if event was canceled', () => { | ||
setupDispatchHooks(element, 'click'); | ||
|
||
// element listener | ||
let eventDefaultPreventedInAfterDispatch: boolean | null = null; | ||
element.addEventListener('click', (event) => { | ||
afterDispatch(event, () => { | ||
eventDefaultPreventedInAfterDispatch = event.defaultPrevented; | ||
}); | ||
}); | ||
|
||
// client listener | ||
element.addEventListener('click', (event) => { | ||
event.preventDefault(); | ||
}); | ||
|
||
element.click(); | ||
|
||
expect(eventDefaultPreventedInAfterDispatch) | ||
.withContext('event.defaultPrevented() in afterDispatch() callback') | ||
.toBeTrue(); | ||
}); | ||
|
||
it('throws if setupDispatchHooks() was not called for the event type', () => { | ||
// Do not set up hooks | ||
let errorThrown: unknown; | ||
element.addEventListener('click', (event) => { | ||
try { | ||
afterDispatch(event, () => {}); | ||
} catch (error) { | ||
errorThrown = error; | ||
} | ||
}); | ||
|
||
element.click(); | ||
expect(errorThrown) | ||
.withContext('error thrown calling afterDispatch()') | ||
.toBeInstanceOf(Error); | ||
|
||
expect((errorThrown as Error).message) | ||
.withContext('errorThrown.message') | ||
.toMatch('setupDispatchHooks'); | ||
}); | ||
|
||
it('does not fire multiple times if setupDispatchHooks() is called multiple times for the same element', () => { | ||
setupDispatchHooks(element, 'click'); | ||
setupDispatchHooks(element, 'click'); | ||
|
||
const afterDispatchCallback = jasmine.createSpy('afterDispatchCallback'); | ||
const clickListener = jasmine | ||
.createSpy('clickListener') | ||
.and.callFake((event: Event) => { | ||
afterDispatch(event, afterDispatchCallback); | ||
}); | ||
|
||
element.addEventListener('click', clickListener); | ||
element.click(); | ||
|
||
expect(clickListener) | ||
.withContext('clickListener') | ||
.toHaveBeenCalledTimes(1); | ||
expect(afterDispatchCallback) | ||
.withContext('afterDispatch() callback') | ||
.toHaveBeenCalledTimes(1); | ||
}); | ||
}); | ||
}); |