diff --git a/CHANGELOG.md b/CHANGELOG.md index a6ecff74..8e3e583c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. The format ### Changed -- Refactor services to classes ([#535](https://github.com/studiometa/js-toolkit/pull/535), [258bb590](https://github.com/studiometa/js-toolkit/commit/258bb590)) +- Refactor services to classes ([#535](https://github.com/studiometa/js-toolkit/pull/535), [#537](https://github.com/studiometa/js-toolkit/pull/537)) ## [v3.0.0-alpha.12](https://github.com/studiometa/js-toolkit/compare/3.0.0-alpha.11..3.0.0-alpha.12) (2024-11-15) diff --git a/packages/js-toolkit/services/AbstractService.ts b/packages/js-toolkit/services/AbstractService.ts index d9954b9f..538b6944 100644 --- a/packages/js-toolkit/services/AbstractService.ts +++ b/packages/js-toolkit/services/AbstractService.ts @@ -1,3 +1,5 @@ +import { isFunction } from '../utils/index.js'; + export interface ServiceInterface { /** * Remove a function from the resize service by its key. @@ -17,10 +19,65 @@ export interface ServiceInterface { props(): T; } -export class AbstractService { - isInit = false; +/** + * Service configuration of events to be attached to targets. + */ +export type ServiceConfig = [ + EventTarget | ((instance: AbstractService) => EventTarget), + [string, AddEventListenerOptions?][], +][]; + +/** + * AbstractService class. + */ +export class AbstractService { + /** + * Used to type `this.constructor` correctly + * @see https://github.com/microsoft/TypeScript/issues/3841#issuecomment-2381594311 + */ + declare ['constructor']: typeof AbstractService; + + /** + * Service configuration. + */ + static config: ServiceConfig = []; + + /** + * Cache for the created instances. + */ + static __instances = new Map(); + + /** + * Get a service instance as a singleton based on the given key. + */ + static getInstance>(key: any = this, ...args: any[]) { + if (!this.__instances.has(key)) { + // @ts-ignore + const instance = new this(...args); + this.__instances.set(key, { + add: (key, callback) => instance.add(key, callback), + remove: (key) => instance.remove(key), + has: (key) => instance.has(key), + props: () => instance.props, + } as T); + } + + return this.__instances.get(key); + } + + /** + * Is the service active or not? + */ + __isInit = false; + + /** + * Props for the service. + */ props = {}; + /** + * Holds all the callbacks that will be triggered. + */ callbacks: Map unknown> = new Map(); /** @@ -47,9 +104,9 @@ export class AbstractService { } // Initialize the service when we add the first callback - if (this.callbacks.size === 0 && !this.isInit) { + if (this.callbacks.size === 0 && !this.__isInit) { this.init(); - this.isInit = true; + this.__isInit = true; } this.callbacks.set(key, callback); @@ -62,9 +119,9 @@ export class AbstractService { this.callbacks.delete(key); // Kill the service when we remove the last callback - if (this.callbacks.size === 0 && this.isInit) { + if (this.callbacks.size === 0 && this.__isInit) { this.kill(); - this.isInit = false; + this.__isInit = false; } } @@ -77,31 +134,36 @@ export class AbstractService { } } - init() { - throw new Error('The init method must be implemented.'); + /** + * Implements the EventListenerObject interface. + */ + handleEvent(event: Event) { + // Should be implemented. } - kill() { - throw new Error('The kill method must be implemented'); + /** + * Add or remove event listeners based on the static `config` property. + */ + __manageEvents(mode: 'add' | 'remove') { + for (const [target, events] of this.constructor.config) { + const resolvedTarget = isFunction(target) ? target(this) : target; + for (const [type, options] of events) { + resolvedTarget[`${mode}EventListener`](type, this, options); + } + } } - static __instances = new Map(); - /** - * Get a service instance as a singleton based on the given key. + * Triggered when the service is initialized. */ - static getInstance>(key: any = this, ...args: any[]) { - if (!this.__instances.has(key)) { - // @ts-ignore - const instance = new this(...args); - this.__instances.set(key, { - add: (key, callback) => instance.add(key, callback), - remove: (key) => instance.remove(key), - has: (key) => instance.has(key), - props: () => instance.props, - } as T); - } + init() { + this.__manageEvents('add'); + } - return this.__instances.get(key); + /** + * Triggered when the service is killed. + */ + kill() { + this.__manageEvents('remove'); } } diff --git a/packages/js-toolkit/services/DragService.ts b/packages/js-toolkit/services/DragService.ts index 5b235991..7880d0e4 100644 --- a/packages/js-toolkit/services/DragService.ts +++ b/packages/js-toolkit/services/DragService.ts @@ -1,7 +1,8 @@ -import type { ServiceInterface } from './AbstractService.js'; +import type { ServiceConfig, ServiceInterface } from './AbstractService.js'; import { AbstractService } from './AbstractService.js'; import { useRaf } from './RafService.js'; import { isDefined, inertiaFinalValue } from '../utils/index.js'; +import { PASSIVE_EVENT_OPTIONS, CAPTURE_EVENT_OPTIONS } from './utils.js'; type DragLifecycle = 'start' | 'drag' | 'drop' | 'inertia' | 'stop'; @@ -58,9 +59,6 @@ export interface DragServiceOptions { let count = 0; export class DragService extends AbstractService { - static targetEvents = ['pointerdown']; - static windowEvents = ['pointerup', 'touchend']; - static passiveEventOptions = { passive: true }; static MODES: Record, DragLifecycle> = { START: 'start', DRAG: 'drag', @@ -124,31 +122,23 @@ export class DragService extends AbstractService { this.props.target = target; } - init() { - const { target } = this.props; - - for (const event of DragService.targetEvents) { - target.addEventListener(event, this, DragService.passiveEventOptions); - } - for (const event of DragService.windowEvents) { - window.addEventListener(event, this, DragService.passiveEventOptions); - } - target.addEventListener('dragstart', this, { capture: true }); - target.addEventListener('click', this, { capture: true }); - } - - kill() { - const { target } = this.props; - - for (const event of DragService.targetEvents) { - target.removeEventListener(event, this); - } - for (const event of DragService.windowEvents) { - window.removeEventListener(event, this); - } - target.removeEventListener('dragstart', this); - target.removeEventListener('click', this); - } + static config: ServiceConfig = [ + [ + (instance) => (instance as DragService).props.target, + [ + ['dragstart', CAPTURE_EVENT_OPTIONS], + ['click', CAPTURE_EVENT_OPTIONS], + ['pointerdown', PASSIVE_EVENT_OPTIONS], + ], + ], + [ + window, + [ + ['pointerup', PASSIVE_EVENT_OPTIONS], + ['touchend', PASSIVE_EVENT_OPTIONS], + ], + ], + ]; /** * Get the client value for the given axis. @@ -194,8 +184,8 @@ export class DragService extends AbstractService { this.trigger(props); - document.addEventListener('touchmove', this, DragService.passiveEventOptions); - document.addEventListener('mousemove', this, DragService.passiveEventOptions); + document.addEventListener('touchmove', this, PASSIVE_EVENT_OPTIONS); + document.addEventListener('mousemove', this, PASSIVE_EVENT_OPTIONS); } /** @@ -203,8 +193,10 @@ export class DragService extends AbstractService { */ drop() { const { props, dampFactor, id } = this; + document.removeEventListener('touchmove', this); document.removeEventListener('mousemove', this); + props.isGrabbing = false; props.mode = DragService.MODES.DROP; diff --git a/packages/js-toolkit/services/KeyService.ts b/packages/js-toolkit/services/KeyService.ts index 03a56a88..8b409062 100644 --- a/packages/js-toolkit/services/KeyService.ts +++ b/packages/js-toolkit/services/KeyService.ts @@ -1,4 +1,4 @@ -import type { ServiceInterface } from './AbstractService.js'; +import type { ServiceConfig, ServiceInterface } from './AbstractService.js'; import { AbstractService } from './AbstractService.js'; import keyCodes from '../utils/keyCodes.js'; @@ -28,16 +28,18 @@ export interface KeyServiceProps { export type KeyServiceInterface = ServiceInterface; export class KeyService extends AbstractService { + static config: ServiceConfig = [[document, [['keydown'], ['keyup']]]]; + previousEvent: Event | null = null; - props = { + props: KeyServiceProps = { event: null, triggered: 0, isUp: false, isDown: false, direction: 'none', ...getInitialKeyCodes(), - } as KeyServiceProps; + }; updateProps(event: KeyboardEvent): KeyServiceProps { const { props } = this; @@ -70,16 +72,6 @@ export class KeyService extends AbstractService { handleEvent(event: KeyboardEvent) { this.trigger(this.updateProps(event)); } - - init() { - document.addEventListener('keydown', this); - document.addEventListener('keyup', this); - } - - kill() { - document.removeEventListener('keydown', this); - document.removeEventListener('keyup', this); - } } /** diff --git a/packages/js-toolkit/services/LoadService.ts b/packages/js-toolkit/services/LoadService.ts index 4c828f3f..3a268be1 100644 --- a/packages/js-toolkit/services/LoadService.ts +++ b/packages/js-toolkit/services/LoadService.ts @@ -1,4 +1,4 @@ -import type { ServiceInterface } from './AbstractService.js'; +import type { ServiceInterface, ServiceConfig } from './AbstractService.js'; import { AbstractService } from './AbstractService.js'; export interface LoadServiceProps { @@ -8,22 +8,16 @@ export interface LoadServiceProps { export type LoadServiceInterface = ServiceInterface; export class LoadService extends AbstractService { - props = { + static config: ServiceConfig = [[window, [['load']]]]; + + props: LoadServiceProps = { time: performance.now(), - } as LoadServiceProps; + }; handleEvent() { this.props.time = window.performance.now(); this.trigger(this.props); } - - init() { - window.addEventListener('load', this); - } - - kill() { - window.removeEventListener('load', this); - } } /** diff --git a/packages/js-toolkit/services/PointerService.ts b/packages/js-toolkit/services/PointerService.ts index a879e069..3999c1a2 100644 --- a/packages/js-toolkit/services/PointerService.ts +++ b/packages/js-toolkit/services/PointerService.ts @@ -1,5 +1,6 @@ -import type { ServiceInterface } from './AbstractService.js'; +import type { ServiceConfig, ServiceInterface } from './AbstractService.js'; import { AbstractService } from './AbstractService.js'; +import { ONCE_CAPTURE_EVENT_OPTIONS, PASSIVE_CAPTURE_EVENT_OPTIONS } from './utils.js'; export interface PointerServiceProps { event: MouseEvent | TouchEvent; @@ -16,11 +17,24 @@ export interface PointerServiceProps { export type PointerServiceInterface = ServiceInterface; export class PointerService extends AbstractService { - static events = ['mousemove', 'touchmove', 'mousedown', 'touchstart', 'mouseup', 'touchend']; + static config: ServiceConfig = [ + [ + document, + [ + ['mouseenter', ONCE_CAPTURE_EVENT_OPTIONS], + ['mousemove', PASSIVE_CAPTURE_EVENT_OPTIONS], + ['touchmove', PASSIVE_CAPTURE_EVENT_OPTIONS], + ['mousedown', PASSIVE_CAPTURE_EVENT_OPTIONS], + ['touchstart', PASSIVE_CAPTURE_EVENT_OPTIONS], + ['mouseup', PASSIVE_CAPTURE_EVENT_OPTIONS], + ['touchend', PASSIVE_CAPTURE_EVENT_OPTIONS], + ], + ], + ]; target: HTMLElement | Window | undefined; - props = { + props: PointerServiceProps = { event: null, isDown: false, x: 0, @@ -45,7 +59,7 @@ export class PointerService extends AbstractService { x: 0, y: 0, }, - } as PointerServiceProps; + }; constructor(target: HTMLElement | undefined) { super(); @@ -150,24 +164,6 @@ export class PointerService extends AbstractService { break; } } - - init() { - document.documentElement.addEventListener('mouseenter', this, { - once: true, - capture: true, - }); - - const options = { passive: true, capture: true }; - for (const event of PointerService.events) { - document.addEventListener(event, this, options); - } - } - - kill() { - for (const event of PointerService.events) { - document.removeEventListener(event, this); - } - } } /** diff --git a/packages/js-toolkit/services/ResizeService.ts b/packages/js-toolkit/services/ResizeService.ts index 07a543d9..fe98ca32 100644 --- a/packages/js-toolkit/services/ResizeService.ts +++ b/packages/js-toolkit/services/ResizeService.ts @@ -1,4 +1,4 @@ -import type { ServiceInterface } from './AbstractService.js'; +import type { ServiceConfig, ServiceInterface } from './AbstractService.js'; import { AbstractService } from './AbstractService.js'; import type { Features } from '../Base/features.js'; import { features } from '../Base/features.js'; @@ -20,6 +20,8 @@ export type ResizeServiceInterface extends AbstractService { + static config: ServiceConfig = [[window, [['resize']]]]; + breakpoints: T; props: ResizeServiceProps = { @@ -86,14 +88,6 @@ export class ResizeService< handleEvent() { this.onResizeDebounce(); } - - init() { - window.addEventListener('resize', this); - } - - kill() { - window.removeEventListener('resize', this); - } } /** diff --git a/packages/js-toolkit/services/ScrollService.ts b/packages/js-toolkit/services/ScrollService.ts index 1c087ba3..0c2a8b93 100644 --- a/packages/js-toolkit/services/ScrollService.ts +++ b/packages/js-toolkit/services/ScrollService.ts @@ -1,6 +1,7 @@ -import type { ServiceInterface } from './AbstractService.js'; +import type { ServiceConfig, ServiceInterface } from './AbstractService.js'; import { AbstractService } from './AbstractService.js'; import debounce from '../utils/debounce.js'; +import { PASSIVE_CAPTURE_EVENT_OPTIONS } from './utils.js'; export interface ScrollServiceProps { x: number; @@ -30,6 +31,8 @@ export interface ScrollServiceProps { export type ScrollServiceInterface = ServiceInterface; export class ScrollService extends AbstractService { + static config: ServiceConfig = [[document, [['scroll', PASSIVE_CAPTURE_EVENT_OPTIONS]]]]; + props: ScrollServiceProps = { x: window.scrollX, y: window.scrollY, @@ -113,13 +116,6 @@ export class ScrollService extends AbstractService { this.trigger(this.updateProps()); this.onScrollDebounced(); } - - init() { - document.addEventListener('scroll', this, { passive: true, capture: true }); - } - kill() { - document.removeEventListener('scroll', this); - } } /** diff --git a/packages/js-toolkit/services/utils.ts b/packages/js-toolkit/services/utils.ts new file mode 100644 index 00000000..b235a51e --- /dev/null +++ b/packages/js-toolkit/services/utils.ts @@ -0,0 +1,4 @@ +export const ONCE_CAPTURE_EVENT_OPTIONS = { once: true, capture: true }; +export const PASSIVE_CAPTURE_EVENT_OPTIONS = { passive: true, capture: true }; +export const PASSIVE_EVENT_OPTIONS = { passive: true }; +export const CAPTURE_EVENT_OPTIONS = { capture: true }; diff --git a/packages/tests/services/AbstractService.spec.ts b/packages/tests/services/AbstractService.spec.ts index c79af648..9758aed4 100644 --- a/packages/tests/services/AbstractService.spec.ts +++ b/packages/tests/services/AbstractService.spec.ts @@ -27,12 +27,6 @@ function getContext() { } describe('The `Service` class', () => { - it('should throw if the init or kill method are not implemented', () => { - const service = new AbstractService(); - expect(() => service.init()).toThrow(); - expect(() => service.kill()).toThrow(); - }); - it('should implement `add`, `has`, `get` and `remove` methods', () => { const { service } = getContext(); const fn = vi.fn(); @@ -46,10 +40,10 @@ describe('The `Service` class', () => { it('should init and kill itself when adding or removing a callback', () => { const { service, fn } = getContext(); service.add('key', () => fn('callback')); - expect(service.isInit).toBe(true); + expect(service.__isInit).toBe(true); expect(fn).toHaveBeenLastCalledWith('init'); service.remove('key'); - expect(service.isInit).toBe(false); + expect(service.__isInit).toBe(false); expect(fn).toHaveBeenLastCalledWith('kill'); });