diff --git a/controller/events.ts b/controller/events.ts index fca170bbb1..3911aa487c 100644 --- a/controller/events.ts +++ b/controller/events.ts @@ -40,3 +40,93 @@ export function redispatchEvent(element: Element, event: Event) { return dispatched; } + +/** + * Dispatches a click event to the given element that triggers a native action, + * but is not composed and therefore is not seen outside the element. + * + * This is useful for responding to an external click event on the host element + * that should trigger an internal action like a button click. + * + * Note, a helper is provided because setting this up correctly is a bit tricky. + * In particular, calling `click` on an element creates a composed event, which + * is not desirable, and a manually dispatched event must specifically be a + * `MouseEvent` to trigger a native action. + * + * @example + * hostClickListener = (event: MouseEvent) { + * if (isActivationClick(event)) { + * this.dispatchActivationClick(this.buttonElement); + * } + * } + * + */ +export function dispatchActivationClick(element: HTMLElement) { + const event = new MouseEvent('click', {bubbles: true}); + element.dispatchEvent(event); + return event; +} + +/** + * Returns true if the click event should trigger an activation behavior. The + * behavior is defined by the element and is whatever it should do when + * clicked. + * + * Typically when an element needs to handle a click, the click is generated + * from within the element and an event listener within the element implements + * the needed behavior; however, it's possible to fire a click directly + * at the element that the element should handle. This method helps + * distinguish these "external" clicks. + * + * An "external" click can be triggered in a number of ways: via a click + * on an associated label for a form associated element, calling + * `element.click()`, or calling + * `element.dispatchEvent(new MouseEvent('click', ...))`. + * + * Also works around Firefox issue + * https://bugzilla.mozilla.org/show_bug.cgi?id=1804576 by squelching + * events for a microtask after called. + * + * @example + * hostClickListener = (event: MouseEvent) { + * if (isActivationClick(event)) { + * this.dispatchActivationClick(this.buttonElement); + * } + * } + * + */ +export function isActivationClick(event: Event) { + // Event must start at the event target. + if (event.composedPath()[0] !== event.target) { + return false; + } + // Target must not be disabled; this should only occur for a synthetically + // dispatched click. + if ((event.target as EventTarget & {disabled: boolean}).disabled) { + return false; + } + // This is an activation if the event should not be squelched. + return !squelchEvent(event); +} + +// TODO(https://bugzilla.mozilla.org/show_bug.cgi?id=1804576) +// Remove when Firefox bug is addressed. +function squelchEvent(event: Event) { + const squelched = isSquelchingEvents; + if (squelched) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + squelchEventsForMicrotask(); + return squelched; +} + +// Ignore events for one microtask only. +let isSquelchingEvents = false; +async function squelchEventsForMicrotask() { + isSquelchingEvents = true; + // Need to pause for just one microtask. + // tslint:disable-next-line + await null; + isSquelchingEvents = false; +} diff --git a/controller/events_test.ts b/controller/events_test.ts index 88f4c29445..367ce70ee8 100644 --- a/controller/events_test.ts +++ b/controller/events_test.ts @@ -6,9 +6,9 @@ // import 'jasmine'; (google3-only) -import {redispatchEvent} from './events.js'; +import {dispatchActivationClick, isActivationClick, redispatchEvent} from './events.js'; -describe('redispatchEvent()', () => { +describe('events', () => { let instance: HTMLDivElement; beforeEach(() => { @@ -23,90 +23,134 @@ describe('redispatchEvent()', () => { document.body.removeChild(instance); }); - it('should re-dispatch events', () => { - const event = new Event('foo', {composed: false, bubbles: true}); - const fooHandler = jasmine.createSpy('fooHandler'); - instance.addEventListener('foo', fooHandler); - redispatchEvent(instance, event); - - expect(fooHandler).toHaveBeenCalled(); - const redispatchedEvent = fooHandler.calls.first().args[0] as Event; - expect(redispatchedEvent) - .withContext('redispatched event should be a new instance') - .not.toBe(event); - expect(redispatchedEvent.target) - .withContext( - 'target should be the instance that redispatched the event') - .toBe(instance); - expect(redispatchedEvent.type) - .withContext('should be the same event type') - .toBe(event.type); - expect(redispatchedEvent.composed) - .withContext('should not be composed') - .toBeFalse(); - expect(redispatchedEvent.bubbles) - .withContext('should keep other flags set to true') - .toBeTrue(); - }); + describe('redispatchEvent()', () => { + it('should re-dispatch events', () => { + const event = new Event('foo', {composed: false, bubbles: true}); + const fooHandler = jasmine.createSpy('fooHandler'); + instance.addEventListener('foo', fooHandler); + redispatchEvent(instance, event); - it('should not dispatch multiple events if bubbling and composed', () => { - const event = new Event('foo', {composed: true, bubbles: true}); - const fooHandler = jasmine.createSpy('fooHandler'); - instance.addEventListener('foo', fooHandler); - redispatchEvent(instance, event); + expect(fooHandler).toHaveBeenCalled(); + const redispatchedEvent = fooHandler.calls.first().args[0] as Event; + expect(redispatchedEvent) + .withContext('redispatched event should be a new instance') + .not.toBe(event); + expect(redispatchedEvent.target) + .withContext( + 'target should be the instance that redispatched the event') + .toBe(instance); + expect(redispatchedEvent.type) + .withContext('should be the same event type') + .toBe(event.type); + expect(redispatchedEvent.composed) + .withContext('should not be composed') + .toBeFalse(); + expect(redispatchedEvent.bubbles) + .withContext('should keep other flags set to true') + .toBeTrue(); + }); - expect(fooHandler).toHaveBeenCalledTimes(1); - }); - - it('should not dispatch multiple events if bubbling in light DOM', () => { - const lightDomInstance = document.createElement('div'); - try { - document.body.appendChild(lightDomInstance); + it('should not dispatch multiple events if bubbling and composed', () => { const event = new Event('foo', {composed: true, bubbles: true}); const fooHandler = jasmine.createSpy('fooHandler'); instance.addEventListener('foo', fooHandler); redispatchEvent(instance, event); expect(fooHandler).toHaveBeenCalledTimes(1); - } finally { - document.body.removeChild(lightDomInstance); - } + }); + + it('should not dispatch multiple events if bubbling in light DOM', () => { + const lightDomInstance = document.createElement('div'); + try { + document.body.appendChild(lightDomInstance); + const event = new Event('foo', {composed: true, bubbles: true}); + const fooHandler = jasmine.createSpy('fooHandler'); + instance.addEventListener('foo', fooHandler); + redispatchEvent(instance, event); + + expect(fooHandler).toHaveBeenCalledTimes(1); + } finally { + document.body.removeChild(lightDomInstance); + } + }); + + it('should preventDefault() on the original event if canceled', () => { + const event = new Event('foo', {cancelable: true}); + const fooHandler = + jasmine.createSpy('fooHandler').and.callFake((event: Event) => { + event.preventDefault(); + }); + instance.addEventListener('foo', fooHandler); + const result = redispatchEvent(instance, event); + expect(result) + .withContext('should return false since event was canceled') + .toBeFalse(); + expect(fooHandler).toHaveBeenCalled(); + const redispatchedEvent = fooHandler.calls.first().args[0] as Event; + expect(redispatchedEvent.defaultPrevented) + .withContext('redispatched event should be canceled by handler') + .toBeTrue(); + expect(event.defaultPrevented) + .withContext('original event should be canceled') + .toBeTrue(); + }); + + it('should preserve event instance types', () => { + const event = new CustomEvent('foo', {detail: 'bar'}); + const fooHandler = jasmine.createSpy('fooHandler'); + instance.addEventListener('foo', fooHandler); + redispatchEvent(instance, event); + + expect(fooHandler).toHaveBeenCalled(); + const redispatchedEvent = fooHandler.calls.first().args[0] as CustomEvent; + expect(redispatchedEvent) + .withContext('should create the same instance type') + .toBeInstanceOf(CustomEvent); + expect(redispatchedEvent.detail) + .withContext('should copy event type-specific properties') + .toBe('bar'); + }); }); - it('should preventDefault() on the original event if canceled', () => { - const event = new Event('foo', {cancelable: true}); - const fooHandler = - jasmine.createSpy('fooHandler').and.callFake((event: Event) => { - event.preventDefault(); - }); - instance.addEventListener('foo', fooHandler); - const result = redispatchEvent(instance, event); - expect(result) - .withContext('should return false since event was canceled') - .toBeFalse(); - expect(fooHandler).toHaveBeenCalled(); - const redispatchedEvent = fooHandler.calls.first().args[0] as Event; - expect(redispatchedEvent.defaultPrevented) - .withContext('redispatched event should be canceled by handler') - .toBeTrue(); - expect(event.defaultPrevented) - .withContext('original event should be canceled') - .toBeTrue(); + describe('isActivationClick()', () => { + it('should return true only if the event originated from target', () => { + const listener = jasmine.createSpy('listener', isActivationClick); + listener.and.callThrough(); + instance.addEventListener('click', listener); + instance.dispatchEvent( + new MouseEvent('click', {bubbles: true, composed: true})); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.calls.mostRecent().returnValue).toBe(true); + const innerEl = document.createElement('div'); + instance.shadowRoot!.append(innerEl); + + innerEl.dispatchEvent( + new MouseEvent('click', {bubbles: true, composed: true})); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener.calls.mostRecent().returnValue).toBe(false); + }); }); - it('should preserve event instance types', () => { - const event = new CustomEvent('foo', {detail: 'bar'}); - const fooHandler = jasmine.createSpy('fooHandler'); - instance.addEventListener('foo', fooHandler); - redispatchEvent(instance, event); - - expect(fooHandler).toHaveBeenCalled(); - const redispatchedEvent = fooHandler.calls.first().args[0] as CustomEvent; - expect(redispatchedEvent) - .withContext('should create the same instance type') - .toBeInstanceOf(CustomEvent); - expect(redispatchedEvent.detail) - .withContext('should copy event type-specific properties') - .toBe('bar'); + describe('dispatchActivationClick()', () => { + it('dispatches an event', () => { + const innerEl = document.createElement('div'); + instance.shadowRoot!.append(innerEl); + const listener = jasmine.createSpy('listener'); + innerEl.addEventListener('click', listener); + dispatchActivationClick(innerEl); + expect(listener).toHaveBeenCalledTimes(1); + dispatchActivationClick(innerEl); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('dispatches an event that cannot be heard outside dispatching scope', + () => { + const innerEl = document.createElement('div'); + instance.shadowRoot!.append(innerEl); + const listener = jasmine.createSpy('listener'); + instance.addEventListener('click', listener); + dispatchActivationClick(innerEl); + expect(listener).toHaveBeenCalledTimes(0); + }); }); -}); +}); \ No newline at end of file diff --git a/controller/form-associated.ts b/controller/form-associated.ts new file mode 100644 index 0000000000..1b563bfed2 --- /dev/null +++ b/controller/form-associated.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * FormAssociatedElement interface + */ +export interface FormAssociatedElement extends HTMLElement {} + +declare var FormAssociatedElement: { + new (): FormAssociatedElement; prototype: FormAssociatedElement; + readonly formAssociated?: boolean; +}; + +/** + * Returns true if the element is a form associated custom element (FACE). + */ +export function isFormAssociated(element: FormAssociatedElement) { + return (element.constructor as typeof FormAssociatedElement).formAssociated; +} diff --git a/controller/form-controller.ts b/controller/form-controller.ts index 2dbca0fc7a..ac87b450ad 100644 --- a/controller/form-controller.ts +++ b/controller/form-controller.ts @@ -8,6 +8,9 @@ import {ReactiveController, ReactiveControllerHost} from 'lit'; import {bound} from '../decorators/bound.js'; +import {isFormAssociated} from './form-associated.js'; +import {shimLabelSupport, SUPPORTS_FACE_LABEL} from './shim-label-activation.js'; + declare global { interface Window { ShadyDOM?: {inUse: boolean;}; @@ -48,6 +51,13 @@ export const getFormValue = Symbol('getFormValue'); /** * A `ReactiveController` that adds `