From bc2efb2e9bc71143dcac848a0fd366c0a8646bd8 Mon Sep 17 00:00:00 2001 From: a11delavar Date: Sat, 30 Mar 2024 04:53:01 +0100 Subject: [PATCH] feat(Lit): @eventListener decorators are not supported in Controller classes. Fixes #11 feat(Lit): Add initializers to Controllers similar to Components' initializers --- package-lock.json | 3 +- packages/Lit/Controller/Controller.test.ts | 35 ++- packages/Lit/Controller/Controller.ts | 10 +- .../EventListenerController.test.ts | 5 +- .../eventListener/EventListenerController.ts | 39 +-- .../Lit/eventListener/EventListenerTarget.ts | 4 - .../Lit/eventListener/eventListener.test.ts | 268 +++++++++++++----- packages/Lit/eventListener/eventListener.ts | 30 +- .../eventListener/extractEventTargets.test.ts | 62 ++++ .../Lit/eventListener/extractEventTargets.ts | 32 +++ packages/Lit/eventListener/index.ts | 2 +- packages/Lit/package.json | 2 +- 12 files changed, 368 insertions(+), 124 deletions(-) delete mode 100644 packages/Lit/eventListener/EventListenerTarget.ts create mode 100644 packages/Lit/eventListener/extractEventTargets.test.ts create mode 100644 packages/Lit/eventListener/extractEventTargets.ts diff --git a/package-lock.json b/package-lock.json index f1ce81c..5b0e5ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,5 @@ { "name": "lit", - "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { @@ -8386,7 +8385,7 @@ }, "packages/Lit": { "name": "@a11d/lit", - "version": "0.8.2", + "version": "0.9.1", "license": "MIT", "dependencies": { "@a11d/constructor": "x", diff --git a/packages/Lit/Controller/Controller.test.ts b/packages/Lit/Controller/Controller.test.ts index 7e3753a..0cd1e66 100644 --- a/packages/Lit/Controller/Controller.test.ts +++ b/packages/Lit/Controller/Controller.test.ts @@ -13,12 +13,12 @@ class ControllerTestComponent extends Component { } customElements.define('controller-test-component', ControllerTestComponent) -class TestController extends Controller { } - describe('Controller', () => { const fixture = new ComponentTestFixture('controller-test-component') it('should add itself as a controller', () => { + class TestController extends Controller { } + expect(fixture.component.controllers.size).toBe(0) const controller = new TestController(fixture.component) @@ -26,4 +26,35 @@ describe('Controller', () => { expect(fixture.component.controllers.size).toBe(1) expect(fixture.component.controllers.has(controller)).toBeTrue() }) + + describe('initializers', () => { + it('should be called when a controller is constructed', () => { + class TestController extends Controller { } + const spy = jasmine.createSpy('initializer') + TestController.addInitializer(spy) + + expect(spy).not.toHaveBeenCalled() + + const controller = new TestController(fixture.component) + + expect(spy).toHaveBeenCalledOnceWith(controller) + }) + + it('should inherit initializers from parent classes', () => { + const spy1 = jasmine.createSpy('initializer1') + const spy2 = jasmine.createSpy('initializer2') + class TestController1 extends Controller { } + TestController1.addInitializer(spy1) + class TestController2 extends TestController1 { } + TestController2.addInitializer(spy2) + + expect(spy1).not.toHaveBeenCalled() + expect(spy2).not.toHaveBeenCalled() + + const controller = new TestController2(fixture.component) + + expect(spy1).toHaveBeenCalledOnceWith(controller) + expect(spy2).toHaveBeenCalledOnceWith(controller) + }) + }) }) \ No newline at end of file diff --git a/packages/Lit/Controller/Controller.ts b/packages/Lit/Controller/Controller.ts index 433dedb..a6ba04f 100644 --- a/packages/Lit/Controller/Controller.ts +++ b/packages/Lit/Controller/Controller.ts @@ -1,8 +1,16 @@ import { ReactiveController, ReactiveControllerHost } from 'lit' +type Initializer = (controller: Controller) => void + export abstract class Controller implements ReactiveController { + private static _initializers?: Set + static addInitializer(initializer: Initializer) { + (this._initializers ??= new Set(Object.getPrototypeOf(this).initializers ?? [])).add(initializer) + } + constructor(protected readonly host: ReactiveControllerHost) { - this.host.addController(this) + this.host.addController(this); + (this.constructor as typeof Controller)._initializers?.forEach(initializer => initializer(this)) } hostConnected?(): void diff --git a/packages/Lit/eventListener/EventListenerController.test.ts b/packages/Lit/eventListener/EventListenerController.test.ts index f3aad63..3300163 100644 --- a/packages/Lit/eventListener/EventListenerController.test.ts +++ b/packages/Lit/eventListener/EventListenerController.test.ts @@ -1,6 +1,7 @@ import { ComponentTestFixture } from '@a11d/lit-testing' import { component, Component, EventListenerTarget, html, queryAsync } from '../index.js' -import { EventListenerController, extractEventTargets } from './EventListenerController.js' +import { EventListenerController } from './EventListenerController.js' +import { extractEventTargets } from './extractEventTargets.js' abstract class EventListenerControllerTestComponent extends Component { readonly fakeCall = jasmine.createSpy('fakeCall') @@ -124,7 +125,7 @@ describe('EventListenerController', () => { const event = specs.event ?? new PointerEvent('click') const dispatchEvent = async () => { - const targets = await extractEventTargets.call(specs.fixture.component, specs.target) + const targets = await extractEventTargets(specs.fixture.component, specs.target) targets.forEach(t => t.dispatchEvent(event)) return targets.length } diff --git a/packages/Lit/eventListener/EventListenerController.ts b/packages/Lit/eventListener/EventListenerController.ts index 7d9fd6c..6c779da 100644 --- a/packages/Lit/eventListener/EventListenerController.ts +++ b/packages/Lit/eventListener/EventListenerController.ts @@ -1,34 +1,7 @@ import { ReactiveElement } from 'lit' import { Controller } from '../Controller/Controller.js' -import type { EventListenerTarget, EventTargets } from './EventListenerTarget.js' - -export async function extractEventTargets(this: any, target: EventListenerTarget | undefined) { - const handle = (value: EventTargets) => Symbol.iterator in value ? [...value] : [value] - - if (target === undefined) { - return handle(this) - } - - if (typeof target === 'function') { - let eventTarget = (target as (this: any) => EventTargets | Promise).call(this) - - if (eventTarget instanceof Promise) { - eventTarget = await eventTarget - } - - if (eventTarget instanceof EventTarget) { - return handle(eventTarget) - } - - if (Symbol.iterator in eventTarget && [...eventTarget].every(t => t instanceof EventTarget)) { - return handle(eventTarget) - } - - throw new TypeError(`${this.constructor}.target is not an EventTarget`) - } - - return handle(target ?? this as EventTarget) -} +import { type EventListenerTarget } from './extractEventTargets.js' +import { extractEventTargets } from './extractEventTargets.js' type Listener = EventListenerObject | ((e: any) => void) @@ -64,15 +37,19 @@ export class EventListenerController extends Controller { this.options = extractOptions(options) } + protected get context(): object { + return this.host + } + async subscribe() { - const targets = await extractEventTargets.call(this.host, this.options.target) + const targets = await extractEventTargets.call(this.context, this.host, this.options.target) for (const target of targets) { target.addEventListener(this.options.type, this.options.listener, this.options.options) } } async unsubscribe() { - const targets = await extractEventTargets.call(this.host, this.options.target) + const targets = await extractEventTargets.call(this.context, this.host, this.options.target) for (const target of targets) { target?.removeEventListener(this.options.type, this.options.listener, this.options.options) } diff --git a/packages/Lit/eventListener/EventListenerTarget.ts b/packages/Lit/eventListener/EventListenerTarget.ts deleted file mode 100644 index 0d0b95b..0000000 --- a/packages/Lit/eventListener/EventListenerTarget.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type EventTargets = EventTarget | Iterable -export type EventTargetsResolver = (this: any) => EventTargets -export type EventTargetsAsyncResolver = (this: any) => Promise -export type EventListenerTarget = EventTargets | EventTargetsResolver | EventTargetsAsyncResolver \ No newline at end of file diff --git a/packages/Lit/eventListener/eventListener.test.ts b/packages/Lit/eventListener/eventListener.test.ts index 6a1ccf4..0d6bb17 100644 --- a/packages/Lit/eventListener/eventListener.test.ts +++ b/packages/Lit/eventListener/eventListener.test.ts @@ -1,6 +1,6 @@ -import { eventListener, Component, component, html, EventListenerTarget, queryAsync } from '../index.js' +import { eventListener, Component, component, html, EventListenerTarget, queryAsync, Controller } from '../index.js' import { ComponentTestFixture } from '@a11d/lit-testing' -import { extractEventTargets } from './EventListenerController.js' +import { extractEventTargets } from './extractEventTargets.js' abstract class EventListenerTestComponent extends Component { readonly fakeCall = jasmine.createSpy('fakeCall') @@ -26,103 +26,235 @@ abstract class EventListenerTestComponent extends Component { } describe(eventListener.name, () => { - describe('used as method', () => { - @component('lit-test-event-listener-used-as-method') - class TestComponent extends EventListenerTestComponent { - @eventListener('click') - protected handleClick(e: Event) { - super.handleEvent(e) + describe('on Component class', () => { + describe('used as method', () => { + @component('lit-test-event-listener-used-as-method') + class TestComponent extends EventListenerTestComponent { + @eventListener('click') + protected handleClick(e: Event) { + super.handleEvent(e) + } } - } - const fixture = new ComponentTestFixture(() => new TestComponent()) - test({ fixture }) - }) + const fixture = new ComponentTestFixture(() => new TestComponent()) + test({ fixture }) + }) - describe('used as arrow function', () => { - @component('lit-test-event-listener-used-as-arrow-function') - class TestComponent extends EventListenerTestComponent { - @eventListener('click') - protected handleClick = (e: Event) => super.handleEvent(e) - } - const fixture = new ComponentTestFixture(() => new TestComponent()) - test({ fixture }) - }) + describe('used as arrow function', () => { + @component('lit-test-event-listener-used-as-arrow-function') + class TestComponent extends EventListenerTestComponent { + @eventListener('click') + protected handleClick = (e: Event) => super.handleEvent(e) + } + const fixture = new ComponentTestFixture(() => new TestComponent()) + test({ fixture }) + }) - describe('used on custom target', () => { - const target = window + describe('used on custom target', () => { + const target = window - @component('lit-test-event-listener-used-on-custom-target') - class TestComponent extends EventListenerTestComponent { - @eventListener({ target, type: 'click' }) - protected handleClick(e: Event) { - super.handleEvent(e) + @component('lit-test-event-listener-used-on-custom-target') + class TestComponent extends EventListenerTestComponent { + @eventListener({ target, type: 'click' }) + protected handleClick(e: Event) { + super.handleEvent(e) + } } - } - const fixture = new ComponentTestFixture(() => new TestComponent()) - test({ fixture, target }) - }) + const fixture = new ComponentTestFixture(() => new TestComponent()) + test({ fixture, target }) + }) - describe('used on custom target getter', () => { - function target(this: EventListenerTestComponent) { - return this.ul - } + describe('used on custom target getter', () => { + function target(this: EventListenerTestComponent) { + return this.ul + } - @component('lit-test-event-listener-used-on-custom-target-getter') - class TestComponent extends EventListenerTestComponent { - @eventListener({ target, type: 'click' }) - protected handleClick(e: Event) { - super.handleEvent(e) + @component('lit-test-event-listener-used-on-custom-target-getter') + class TestComponent extends EventListenerTestComponent { + @eventListener({ target, type: 'click' }) + protected handleClick(e: Event) { + super.handleEvent(e) + } } - } - const fixture = new ComponentTestFixture(() => new TestComponent()) - test({ fixture, target }) - }) + const fixture = new ComponentTestFixture(() => new TestComponent()) + test({ fixture, target }) + }) - describe('used on custom iterable target', () => { - const target = [document, window] + describe('used on custom iterable target', () => { + const target = [document, window] - @component('lit-test-event-listener-used-on-custom-iterable-target') - class TestComponent extends EventListenerTestComponent { - @eventListener({ target, type: 'click' }) - protected handleClick(e: Event) { - super.handleEvent(e) + @component('lit-test-event-listener-used-on-custom-iterable-target') + class TestComponent extends EventListenerTestComponent { + @eventListener({ target, type: 'click' }) + protected handleClick(e: Event) { + super.handleEvent(e) + } } - } - const fixture = new ComponentTestFixture(() => new TestComponent()) - test({ fixture, target }) + const fixture = new ComponentTestFixture(() => new TestComponent()) + test({ fixture, target }) + }) + + describe('used on custom iterable target getter', () => { + async function target(this: EventListenerTestComponent) { + const e = await this.ul + return e.querySelectorAll('li') + } + + @component('lit-test-event-listener-used-on-custom-iterable-target-getter') + class TestComponent extends EventListenerTestComponent { + @eventListener({ target, type: 'click' }) + protected handleClick(e: Event) { + super.handleEvent(e) + } + } + + const fixture = new ComponentTestFixture(() => new TestComponent()) + test({ fixture, target }) + }) }) - describe('used on custom iterable target getter', () => { - async function target(this: EventListenerTestComponent) { - const e = await this.ul - return e.querySelectorAll('li') + describe('on Controller class', () => { + abstract class EventListenerTestController extends Controller { + constructor(readonly host: EventListenerTestComponent) { + super(host) + } } - @component('lit-test-event-listener-used-on-custom-iterable-target-getter') - class TestComponent extends EventListenerTestComponent { - @eventListener({ target, type: 'click' }) - protected handleClick(e: Event) { - super.handleEvent(e) + describe('used as method', () => { + class SampleController extends Controller { + constructor(readonly host: ComponentWithControllerComponent) { + super(host) + } + + @eventListener('click') + protected handleClick(e: Event) { + this.host.handleEvent(e) + } + } + + @component('lit-test-event-listener-used-on-controller-class') + class ComponentWithControllerComponent extends EventListenerTestComponent { + readonly controller = new SampleController(this) + } + + const fixture = new ComponentTestFixture(() => new ComponentWithControllerComponent()) + test({ fixture, context: () => fixture.component.controller }) + }) + + describe('used as arrow function', () => { + class SampleController extends Controller { + constructor(readonly host: ComponentWithControllerComponent) { + super(host) + } + + @eventListener('click') + protected handleClick = (e: Event) => this.host.handleEvent(e) } - } - const fixture = new ComponentTestFixture(() => new TestComponent()) - test({ fixture, target }) + @component('lit-test-event-listener-used-on-controller-arrow-function') + class ComponentWithControllerComponent extends EventListenerTestComponent { + readonly controller = new SampleController(this) + } + + const fixture = new ComponentTestFixture(() => new ComponentWithControllerComponent()) + test({ fixture, context: () => fixture.component.controller }) + }) + + describe('used on custom target', () => { + const target = window + + class SampleController extends EventListenerTestController { + @eventListener({ target, type: 'click' }) + protected handleClick(e: Event) { + this.host.handleEvent(e) + } + } + + @component('lit-test-event-listener-used-on-controller-custom-target') + class ComponentWithControllerComponent extends EventListenerTestComponent { + readonly controller = new SampleController(this) + } + + const fixture = new ComponentTestFixture(() => new ComponentWithControllerComponent()) + test({ fixture, target, context: () => fixture.component.controller }) + }) + + describe('used on custom target getter', () => { + function target(this: SampleController) { + return this.host.ul + } + + class SampleController extends EventListenerTestController { + @eventListener({ target, type: 'click' }) + protected handleClick(e: Event) { + this.host.handleEvent(e) + } + } + + @component('lit-test-event-listener-used-on-controller-custom-target-getter') + class ComponentWithControllerComponent extends EventListenerTestComponent { + readonly controller = new SampleController(this) + } + + const fixture = new ComponentTestFixture(() => new ComponentWithControllerComponent()) + test({ fixture, target, context: () => fixture.component.controller }) + }) + + describe('used on custom iterable target', () => { + const target = [document, window] + + class SampleController extends EventListenerTestController { + @eventListener({ target, type: 'click' }) + protected handleClick(e: Event) { + this.host.handleEvent(e) + } + } + + @component('lit-test-event-listener-used-on-controller-custom-iterable-target') + class ComponentWithControllerComponent extends EventListenerTestComponent { + readonly controller = new SampleController(this) + } + + const fixture = new ComponentTestFixture(() => new ComponentWithControllerComponent()) + test({ fixture, target, context: () => fixture.component.controller }) + }) + + describe('used on custom iterable target getter', () => { + async function target(this: SampleController) { + const e = await this.host.ul + return e.querySelectorAll('li') + } + + class SampleController extends EventListenerTestController { + @eventListener({ target, type: 'click' }) + protected handleClick(e: Event) { + this.host.handleEvent(e) + } + } + + @component('lit-test-event-listener-used-on-controller-custom-iterable-target-getter') + class ComponentWithControllerComponent extends EventListenerTestComponent { + readonly controller = new SampleController(this) + } + + const fixture = new ComponentTestFixture(() => new ComponentWithControllerComponent()) + test({ fixture, target, context: () => fixture.component.controller }) + }) }) function test(specs: { fixture: ComponentTestFixture event?: Event target?: EventListenerTarget + context?: () => object }) { const event = specs.event ?? new PointerEvent('click') const dispatchEvent = async () => { - const targets = await extractEventTargets.call(specs.fixture.component, specs.target) + const targets = await extractEventTargets.call(specs.context?.() ?? specs.fixture.component, specs.fixture.component, specs.target) targets.forEach(t => t.dispatchEvent(event)) return targets.length } diff --git a/packages/Lit/eventListener/eventListener.ts b/packages/Lit/eventListener/eventListener.ts index 48ec5f2..ada2038 100644 --- a/packages/Lit/eventListener/eventListener.ts +++ b/packages/Lit/eventListener/eventListener.ts @@ -1,6 +1,7 @@ +import { ReactiveElement } from 'lit' import { EventListenerController } from './EventListenerController.js' -import type { ReactiveElement } from 'lit' -import type { EventListenerTarget } from './EventListenerTarget.js' +import type { EventListenerTarget } from './extractEventTargets.js' +import type { Controller } from '../Controller/Controller.js' type ShorthandEventListenerDecoratorOptions = [type: string, options?: EventListenerOptions | boolean] @@ -22,25 +23,30 @@ export function extractOptions(args: EventListenerDecoratorOptions): FullEventLi } export const eventListener = (...eventListenerOptions: EventListenerDecoratorOptions) => { - return (prototype: ReactiveElement, propertyKey: string, descriptor?: PropertyDescriptor) => { - const Constructor = prototype.constructor as typeof ReactiveElement - Constructor.addInitializer(element => { - element.addController(new class extends EventListenerController { + return (prototype: ReactiveElement | Controller, propertyKey: string, descriptor?: PropertyDescriptor) => { + const Constructor = prototype.constructor as typeof ReactiveElement | typeof Controller + Constructor.addInitializer(context => { + const element = context instanceof ReactiveElement ? context : context['host'] as ReactiveElement + new class extends EventListenerController { constructor() { const { type, target, options } = extractOptions(eventListenerOptions) super(element, { type, target, options, listener: undefined! }) + this.options.listener = this } - override hostConnected() { - this.options.listener ??= (!descriptor - ? Object.getOwnPropertyDescriptor(element, propertyKey)!.value + protected override get context() { + return context + } + + handleEvent(event: Event) { + (!descriptor + ? Object.getOwnPropertyDescriptor(context, propertyKey)!.value : typeof descriptor.get === 'function' ? descriptor.get : descriptor.value - ).bind(element) - return super.hostConnected() + ).call(context, event) } - }) + } }) } } \ No newline at end of file diff --git a/packages/Lit/eventListener/extractEventTargets.test.ts b/packages/Lit/eventListener/extractEventTargets.test.ts new file mode 100644 index 0000000..016e336 --- /dev/null +++ b/packages/Lit/eventListener/extractEventTargets.test.ts @@ -0,0 +1,62 @@ +import { extractEventTargets } from './extractEventTargets.js' +import { ComponentTestFixture } from '@a11d/lit-testing' + +describe('extractEventTargets', () => { + const fixture = new ComponentTestFixture('div') + + it('should return the host if no target is provided', async () => { + const host = fixture.component + const result = await extractEventTargets(host, undefined) + expect(result).toEqual([host]) + }) + + it('should return the host if the target is the host', async () => { + const host = fixture.component + const result = await extractEventTargets(host, host) + expect(result).toEqual([host]) + }) + + it('should return the target if the target is a global EventTarget', async () => { + const host = fixture.component + const result = await extractEventTargets(host, window) + expect(result).toEqual([window]) + }) + + it('should return the targetsif the target is an array of global EventTargets', async () => { + const host = fixture.component + const result = await extractEventTargets(host, [window, document]) + expect(result).toEqual([window, document]) + }) + + it('should return the host if the target is a function that returns the host', async () => { + const host = fixture.component + const result = await extractEventTargets(host, () => host) + expect(result).toEqual([host]) + }) + + it('should return the host if the target is a function that returns a promise that resolves to the host', async () => { + const host = fixture.component + const result = await extractEventTargets(host, () => Promise.resolve(host)) + expect(result).toEqual([host]) + }) + + it('should return the host if the target is a function that returns an array containing the host', async () => { + const host = fixture.component + const result = await extractEventTargets(host, () => [host]) + expect(result).toEqual([host]) + }) + + it('should return the host if the target is a function that returns a promise that resolves to an array containing the host', async () => { + const host = fixture.component + const result = await extractEventTargets(host, () => Promise.resolve([host])) + expect(result).toEqual([host]) + }) + + it('should return the host if the target is a function that returns an array containing the host and another EventTarget', async () => { + const host = fixture.component + const target = document.createElement('div') + host.appendChild(target) + const result = await extractEventTargets(host, function (this: any) { return [this, this.querySelector('div')] }) + expect(result).toEqual([host, target]) + }) +}) \ No newline at end of file diff --git a/packages/Lit/eventListener/extractEventTargets.ts b/packages/Lit/eventListener/extractEventTargets.ts new file mode 100644 index 0000000..ebb71ac --- /dev/null +++ b/packages/Lit/eventListener/extractEventTargets.ts @@ -0,0 +1,32 @@ +export type EventTargets = EventTarget | Iterable +export type EventTargetsResolver = (this: any) => EventTargets +export type EventTargetsAsyncResolver = (this: any) => Promise +export type EventListenerTarget = EventTargets | EventTargetsResolver | EventTargetsAsyncResolver + +export async function extractEventTargets(this: any, host: any, target: EventListenerTarget | undefined) { + const handle = (value: EventTargets) => Symbol.iterator in value ? [...value] : [value] + + if (target === undefined) { + return handle(host) + } + + if (typeof target === 'function') { + let eventTarget = (target as (context: any) => EventTargets | Promise).call(this ?? host, host) + + if (eventTarget instanceof Promise) { + eventTarget = await eventTarget + } + + if (eventTarget instanceof EventTarget) { + return handle(eventTarget) + } + + if (Symbol.iterator in eventTarget && [...eventTarget].every(t => t instanceof EventTarget)) { + return handle(eventTarget) + } + + throw new TypeError('Target is not a valid EventTarget.') + } + + return handle(target ?? host as EventTarget) +} \ No newline at end of file diff --git a/packages/Lit/eventListener/index.ts b/packages/Lit/eventListener/index.ts index e4f7085..dcb1f7b 100644 --- a/packages/Lit/eventListener/index.ts +++ b/packages/Lit/eventListener/index.ts @@ -1,4 +1,4 @@ -export * from './EventListenerTarget.js' +export * from './extractEventTargets.js' export * from './eventListener.js' export * from './extractEventHandler.js' export * from './EventListenerController.js' \ No newline at end of file diff --git a/packages/Lit/package.json b/packages/Lit/package.json index a89987f..8d123da 100644 --- a/packages/Lit/package.json +++ b/packages/Lit/package.json @@ -1,6 +1,6 @@ { "name": "@a11d/lit", - "version": "0.8.2", + "version": "0.9.1", "description": "A thin wrapper around the Lit library", "repository": { "type": "git",