Skip to content

Commit

Permalink
feat(controller): add label activation support to FormController
Browse files Browse the repository at this point in the history
Extends `FormController` to provide label activation via form associated custom elements via a shim where not supported (currently just Safari).

Performing label activation is the responsibility of form associated elements.

Helpers are provided to facilitate this, including `isActivationClick` and `dispatchActivationClick`. Note, any element that should perform an action via a click, could use these helpers to help distinguish external "activation" clicks.

PiperOrigin-RevId: 495689270
  • Loading branch information
material-web-copybara authored and copybara-github committed Dec 15, 2022
1 parent 2ce167f commit 4e3054b
Show file tree
Hide file tree
Showing 6 changed files with 510 additions and 78 deletions.
90 changes: 90 additions & 0 deletions controller/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
196 changes: 120 additions & 76 deletions controller/events_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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);
});
});
});
});
22 changes: 22 additions & 0 deletions controller/form-associated.ts
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 16 additions & 0 deletions controller/form-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;};
Expand Down Expand Up @@ -48,6 +51,13 @@ export const getFormValue = Symbol('getFormValue');

/**
* A `ReactiveController` that adds `<form>` 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;
Expand All @@ -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() {
Expand Down
Loading

0 comments on commit 4e3054b

Please sign in to comment.