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 `
` support to an element. + * + * Elements should also set `static formAssociated = true` which + * provides platform support for forms. When an element is form associated, + * it can be activated via clicks on associated label elements. It is the + * responsibility of the element to process this click and perform any necessary + * activation tasks, for example focusing and clicking on an internal element. + * */ export class FormController implements ReactiveController { private form?: HTMLFormElement|null; @@ -71,6 +81,12 @@ export class FormController implements ReactiveController { // null if the child was removed. this.form = this.element.form; this.form?.addEventListener('formdata', this.formDataListener); + + // TODO(b/261871554) Label activation shim is currently only needed for + // Safari. Remove it when no longer needed. + if (isFormAssociated(this.element) && !SUPPORTS_FACE_LABEL) { + shimLabelSupport(this.element.getRootNode() as Document | ShadowRoot); + } } hostDisconnected() { diff --git a/controller/form-controller_test.ts b/controller/form-controller_test.ts index b78405622e..c33165535a 100644 --- a/controller/form-controller_test.ts +++ b/controller/form-controller_test.ts @@ -7,10 +7,12 @@ // import 'jasmine'; (google3-only) import {html, LitElement, TemplateResult} from 'lit'; -import {customElement, property} from 'lit/decorators.js'; +import {customElement, property, query} from 'lit/decorators.js'; import {Environment} from '../testing/environment.js'; +import {Harness} from '../testing/harness.js'; +import {dispatchActivationClick, isActivationClick} from './events.js'; import {FormController, FormElement, getFormValue} from './form-controller.js'; function submitForm(form: HTMLFormElement) { @@ -32,6 +34,8 @@ declare global { interface HTMLElementTagNameMap { 'my-form-element': MyFormElement; 'my-form-data-element': MyFormDataElement; + 'my-checked-form-element': MyCheckedFormElement; + 'my-checked-form-associated-element': MyCheckedFormAssociatedElement; } } @@ -64,6 +68,55 @@ class MyFormDataElement extends MyFormElement { } } +@customElement('my-checked-form-element') +class MyCheckedFormElement extends MyFormElement { + @property({type: Boolean}) checked = false; + + @query('#checked') checkedEl!: HTMLDivElement; + + constructor() { + super(); + this.setupActivationClickHandler(); + } + + setupActivationClickHandler() { + this.addEventListener('click', (event: MouseEvent) => { + if (!isActivationClick(event)) { + return; + } + this.checkedEl.focus(); + dispatchActivationClick(this.checkedEl); + }); + } + + // Note, due to the following Firefox issue, it's important that the + // element contain native "interactive content" like a button or input, + // see https://bugzilla.mozilla.org/show_bug.cgi?id=1804576. + // + override render() { + return html``; + } + + override focus() { + this.checkedEl.focus(); + } +} + +@customElement('my-checked-form-associated-element') +class MyCheckedFormAssociatedElement extends MyCheckedFormElement { + static formAssociated = true; +} + +class CheckedFormElementHarness extends Harness { + protected override async getInteractiveElement() { + await this.element.updateComplete; + return this.element.checkedEl; + } +} + describe('FormController', () => { const env = new Environment(); @@ -90,6 +143,21 @@ describe('FormController', () => { .toBe('foo'); }); + it('should add form associated element\'s name/value pair to the form', + async () => { + const form = await setupTest(html` + + `); + + const data = await submitForm(form); + expect(data.has('element')) + .withContext('should add name to data') + .toBeTrue(); + expect(data.get('element')) + .withContext('should add value to data') + .toBe('foo'); + }); + it('should not add data when disconnected', async () => { const form = await setupTest(html` @@ -144,4 +212,114 @@ describe('FormController', () => { .withContext('element-foo should match "foo"') .toBe('foo'); }); -}); + + describe('label activation', () => { + const setupLabelTest = async ( + template: TemplateResult, + harnessTag = 'my-checked-form-associated-element') => { + const form = await setupTest(template); + const label = new Harness(form.querySelector('label')!); + const face = new CheckedFormElementHarness( + form.querySelector(harnessTag)!); + return {label, face}; + }; + + it('should activate via click event', async () => { + const {face} = await setupLabelTest(html` + + `); + expect(face.element.checked).toBeFalse(); + await face.clickWithMouse(); + expect(face.element.checked).toBeTrue(); + await face.clickWithMouse(); + expect(face.element.checked).toBeFalse(); + }); + + it('should activate via click method', async () => { + const {face} = await setupLabelTest(html` + + `); + expect(face.element.checked).toBeFalse(); + face.element.click(); + await env.waitForStability(); + expect(face.element.checked).toBeTrue(); + face.element.click(); + await env.waitForStability(); + expect(face.element.checked).toBeFalse(); + }); + + it('should activate form associated elements via surrounding label', + async () => { + const {label, face} = await setupLabelTest(html` + + `); + expect(face.element.checked).toBeFalse(); + await face.clickWithMouse(); + expect(face.element.checked).toBeTrue(); + await label.clickWithMouse(); + expect(face.element.checked).toBeFalse(); + await label.clickWithMouse(); + expect(face.element.checked).toBeTrue(); + }); + + it('should not generate extra clicks when activated', async () => { + const {label, face} = await setupLabelTest(html` + + `); + expect(face.element.checked).toBeFalse(); + const clickListener = jasmine.createSpy('clickListener'); + face.element.addEventListener('click', clickListener); + await face.clickWithMouse(); + expect(clickListener).toHaveBeenCalledTimes(1); + face.element.click(); + await env.waitForStability(); + expect(clickListener).toHaveBeenCalledTimes(2); + await label.clickWithMouse(); + expect(clickListener).toHaveBeenCalledTimes(3); + }); + + it('should activate form associated elements via label with matching `for`', + async () => { + const {label, face} = await setupLabelTest(html` + + + `); + expect(face.element.checked).toBeFalse(); + await face.clickWithMouse(); + expect(face.element.checked).toBeTrue(); + await label.clickWithMouse(); + expect(face.element.checked).toBeFalse(); + await label.clickWithMouse(); + expect(face.element.checked).toBeTrue(); + + // Disconnect `for` and check that face is not activated. + label.element.setAttribute('for', ''); + await label.clickWithMouse(); + expect(face.element.checked).toBeTrue(); + }); + + it('should *not* activate disabled form associated elements via label', + async () => { + const {label, face} = await setupLabelTest(html` + + `); + expect(face.element.checked).toBeFalse(); + await label.clickWithMouse(); + expect(face.element.checked).toBeFalse(); + }); + + it('should *not* activate non-form associated elements via label', + async () => { + const {label, face} = await setupLabelTest( + html` + + `, + `my-checked-form-element`); + expect(face.element.checked).toBeFalse(); + await face.clickWithMouse(); + expect(face.element.checked).toBeTrue(); + await label.clickWithMouse(); + expect(face.element.checked).toBeTrue(); + }); + }); +}); \ No newline at end of file diff --git a/controller/shim-label-activation.ts b/controller/shim-label-activation.ts new file mode 100644 index 0000000000..f7835e6e68 --- /dev/null +++ b/controller/shim-label-activation.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {isFormAssociated} from './form-associated.js'; + +// TODO Label activation shim is currently only needed for Safari. Remove it +// when no longer needed, see b/261871554. + +/** + * Returns true if labeling is supported for form associated custom elemeents. + * Chrome and Firefox currently do and Safari support appears to be in progress, + * see https://bugs.webkit.org/show_bug.cgi?id=197960. + */ +export const SUPPORTS_FACE_LABEL = + 'labels' in (globalThis?.ElementInternals?.prototype ?? {}); + +function isCustomElement(element: HTMLElement) { + return element.localName.match('-'); +} + +// Elements that can be associated with a