diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe552873..0e82afb70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,15 @@ All notable changes to this project will be documented in this file. The format ### Added -- Add a createElement function ([#548](https://github.com/studiometa/js-toolkit/pull/548), [a6417f2f](https://github.com/studiometa/js-toolkit/commit/a6417f2f)) +- Add support for `Symbol` as service keys ([#559](https://github.com/studiometa/js-toolkit/pull/559), [7ce96a41](https://github.com/studiometa/js-toolkit/commit/7ce96a41)) +- Add a `withMutation` decorator ([#559](https://github.com/studiometa/js-toolkit/pull/559), [fdce27a2](https://github.com/studiometa/js-toolkit/commit/fdce27a2)) +- Add a `useMutation` service ([#559](https://github.com/studiometa/js-toolkit/pull/559), [0c804b79](https://github.com/studiometa/js-toolkit/commit/0c804b79)) +- Add a `cache` utility function ([#559](https://github.com/studiometa/js-toolkit/pull/559), [b3fe8080](https://github.com/studiometa/js-toolkit/commit/b3fe8080)) +- Add a `createElement` function ([#548](https://github.com/studiometa/js-toolkit/pull/548), [a6417f2f](https://github.com/studiometa/js-toolkit/commit/a6417f2f)) ### Changed +- Refactor service instances cache handling ([#559](https://github.com/studiometa/js-toolkit/pull/559), [e87dca16](https://github.com/studiometa/js-toolkit/commit/e87dca16)) - Refactor decorators to not override the name config ([#549](https://github.com/studiometa/js-toolkit/issues/549), [#550](https://github.com/studiometa/js-toolkit/pull/550), [6436ef7d](https://github.com/studiometa/js-toolkit/commit/6436ef7d)) - Refactor all exports to be named exports ([#551](https://github.com/studiometa/js-toolkit/pull/551), [2e046016](https://github.com/studiometa/js-toolkit/commit/2e046016)) diff --git a/packages/docs/.vitepress/config.js b/packages/docs/.vitepress/config.js index 3b5c218b9..0964118b0 100644 --- a/packages/docs/.vitepress/config.js +++ b/packages/docs/.vitepress/config.js @@ -209,6 +209,7 @@ function getServicesSidebar() { { text: 'useDrag', link: '/api/services/useDrag.html' }, { text: 'useKey', link: '/api/services/useKey.html' }, { text: 'useLoad', link: '/api/services/useLoad.html' }, + { text: 'useMutation', link: '/api/services/useMutation.html' }, { text: 'usePointer', link: '/api/services/usePointer.html' }, { text: 'useRaf', link: '/api/services/useRaf.html' }, { text: 'useResize', link: '/api/services/useResize.html' }, @@ -227,6 +228,7 @@ function getDecoratorsSidebar() { { text: 'withMountOnMediaQuery', link: '/api/decorators/withMountOnMediaQuery.html' }, { text: 'withMountWhenInView', link: '/api/decorators/withMountWhenInView.html' }, { text: 'withMountWhenPrefersMotion', link: '/api/decorators/withMountWhenPrefersMotion.html' }, + { text: 'withMutation', link: '/api/decorators/withMutation.html' }, { text: 'withRelativePointer', link: '/api/decorators/withRelativePointer.html' }, { text: 'withResponsiveOptions', link: '/api/decorators/withResponsiveOptions.html' }, { text: 'withScrolledInView', link: '/api/decorators/withScrolledInView.html' }, @@ -256,6 +258,7 @@ function getUtilsSidebar() { link: '/utils/', collapsed: true, items: [ + { text: 'cache', link: '/utils/cache.html' }, { text: 'createElement', link: '/utils/createElement.html' }, { text: 'debounce', link: '/utils/debounce.html' }, { text: 'keyCodes', link: '/utils/keyCodes.html' }, diff --git a/packages/docs/api/decorators/withMutation.md b/packages/docs/api/decorators/withMutation.md new file mode 100644 index 000000000..35b7061d9 --- /dev/null +++ b/packages/docs/api/decorators/withMutation.md @@ -0,0 +1,67 @@ +# withMutation + +Use this decorator to add a `mutated(props)` hook managed by the [mutation](/api/services/useMutation.html) service. + +## Usage + +```js +import { Base, withMutation } from '@studiometa/js-toolkit'; + +export default class Component extends withMutation(Base, { + target: (instance) => instance.$el, +}) { + static config = { + name: 'Component', + }; + + mutated(props) { + for (const mutation of props.mutations) { + console.log(mutation); // MutationRecord + } + } +} +``` + +### Parameters + +- `BaseClass` (`Base`): the class to add mutation observation to +- `options?` (`{ target?: (instance:Base) => Node } & MutationObserverInit`): define which element should be observed (defaults to the component's root element) and any options for the mutation observer + +### Return value + +- `Base`: a new class extending the given class with mutation observability enabled + +## API + +### Class methods + +#### `mutated` + +The `mutated` class method will be triggered when a DOM mutation occurs on the given target. + +**Arguments** + +- `props` (`MutationServiceProps`): the [mutation service props](/api/services/useMutation.md#props) + +## Examples + +### Update a component when its children have changed + +This decorator can be used to update a component when its inner HTML has changed to rebind refs and child components. + +```js +import { Base, withMutation } from '@studiometa/js-toolkit'; + +export default class Component extends withMutation(Base, { + childList: true, + subtree: true, +}) { + static config = { + name: 'Component', + }; + + mutated(props) { + this.$update(); + } +} +``` diff --git a/packages/docs/api/services/useMutation.md b/packages/docs/api/services/useMutation.md new file mode 100644 index 000000000..0719cf1ae --- /dev/null +++ b/packages/docs/api/services/useMutation.md @@ -0,0 +1,62 @@ +# Mutation service + +The mutation service can be used to observe DOM mutations on a component with the MutationObserver API. + +## Usage + +```js +import { useMutation } from '@studiometa/js-toolkit'; + +const { add, remove } = useMutation(); + +// Add a callback +add('custom-id', ({ mutations }) => { + console.log('Some attribute has changed!'); +}); + +// Remove the callback +remove('custom-id'); +``` + +## Parameters + +### `target` + +- Type: `Node` + +The target element to observe, defaults to `document.documentElement`. + +**Example** + +Observe any attribute mutation on the `` element: + +```js +const service = useMutation(document.body); +``` + +### `options` + +- Type: [`MutationObserverInit`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#options) + +Options for the mutation observer, defaults to `{ attributes: true }` only. + +**Example** + +Observe everything on the given element: + +```js +const element = document.querySelector('#element'); +const service = useMutation(element, { + attributes: true, + childList: true, + subtree: true, +}); +``` + +## Props + +### `mutations` + +- Type: `MutationRecord[]` + +The list of `MutationRecord` that triggered the callback. diff --git a/packages/docs/utils/cache.md b/packages/docs/utils/cache.md new file mode 100644 index 000000000..cc8327d75 --- /dev/null +++ b/packages/docs/utils/cache.md @@ -0,0 +1,28 @@ +# cache + +Cache the result of a function with a list of keys. + +## Usage + +```js +import { cache } from '@studiometa/js-toolkit/utils'; + +const keys = [document.body, Symbol('key')]; +const callback = () => performance.now(); + +console.log(cache(keys, callback)); // 100 +console.log(cache(keys, callback) === cache(keys, callback)); // true + +setTimeout(() => { + console.log(cache(keys, callback)); // 100 +}, 100); +``` + +### Parameters + +- `keys` (`Array`): a list of keys to be used to cache the result of the callback +- `callback` (`() => any`): the callback executed to retrieve the value to cache + +### Return value + +The value returned by the `callback` function given as parameter. diff --git a/packages/js-toolkit/decorators/index.ts b/packages/js-toolkit/decorators/index.ts index 28b77ce67..f0923a6e3 100644 --- a/packages/js-toolkit/decorators/index.ts +++ b/packages/js-toolkit/decorators/index.ts @@ -7,6 +7,7 @@ export * from './withIntersectionObserver.js'; export * from './withMountOnMediaQuery.js'; export * from './withMountWhenInView.js'; export * from './withMountWhenPrefersMotion.js'; +export * from './withMutation.js'; export * from './withName.js'; export * from './withRelativePointer.js'; export * from './withResponsiveOptions.js'; diff --git a/packages/js-toolkit/decorators/withMutation.ts b/packages/js-toolkit/decorators/withMutation.ts new file mode 100644 index 000000000..aecfca227 --- /dev/null +++ b/packages/js-toolkit/decorators/withMutation.ts @@ -0,0 +1,55 @@ +import type { BaseDecorator, BaseInterface } from '../Base/types.js'; +import type { Base, BaseProps, BaseConfig } from '../Base/index.js'; +import type { MutationServiceOptions, MutationServiceProps } from '../services/index.js'; +import { useMutation } from '../services/index.js'; + +export type MutationDecoratorOptions = MutationServiceOptions & { + target?: (this: Base, instance: Base) => Node; +}; + +export interface WithMutationInterface extends BaseInterface { + mutated?(props: MutationServiceProps): void; +} + +/** + * Add a mutation observer to a component. + */ +export function withMutation( + BaseClass: typeof Base, + { target = (instance) => instance.$el, ...options }: MutationDecoratorOptions = {}, +): BaseDecorator { + /** + * Class. + */ + class WithMutation extends BaseClass { + /** + * Config. + */ + static config: BaseConfig = { + ...BaseClass.config, + emits: ['mutated'], + }; + + /** + * Class constructor. + */ + constructor(element: HTMLElement) { + super(element); + this.$on('mounted', () => { + this.$services.register( + 'mutated', + useMutation.bind(undefined, target.call(this, this), options), + ); + this.$services.enable('mutated'); + }); + + this.$on('destroyed', () => { + this.$services.disable('mutated'); + this.$services.unregister('mutated'); + }); + } + } + + // @ts-ignore + return WithMutation; +} diff --git a/packages/js-toolkit/services/AbstractService.ts b/packages/js-toolkit/services/AbstractService.ts index 7f3308ddf..714bc243a 100644 --- a/packages/js-toolkit/services/AbstractService.ts +++ b/packages/js-toolkit/services/AbstractService.ts @@ -1,3 +1,5 @@ +import { cache } from '../utils/cache.js'; + export interface ServiceInterface { /** * Remove a function from the resize service by its key. @@ -48,19 +50,17 @@ export class AbstractService { /** * Get a service instance as a singleton based on the given key. */ - static getInstance>(key: any = this, ...args: any[]) { - if (!this.__instances.has(key)) { + static getInstance>(keys: any = [this], ...args: any[]) { + return cache(keys, () => { // @ts-ignore const instance = new this(...args); - this.__instances.set(key, { + return { 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); + } as T; + }); } /** @@ -76,28 +76,28 @@ export class AbstractService { /** * Holds all the callbacks that will be triggered. */ - callbacks: Map unknown> = new Map(); + callbacks: Map unknown> = new Map(); /** * Does the service has the given key? */ - has(key: string): boolean { + has(key: string | symbol): boolean { return this.callbacks.has(key); } /** * Get a service callback by its key. */ - get(key: string): (props: PropsType) => unknown { + get(key: string | symbol): (props: PropsType) => unknown { return this.callbacks.get(key); } /** * Add a callback to the service. */ - add(key: string, callback: (props: PropsType) => unknown) { + add(key: string | symbol, callback: (props: PropsType) => unknown) { if (this.has(key)) { - console.warn(`The key \`${key}\` has already been added.`); + console.warn(`The key \`${String(key)}\` has already been added.`); return; } @@ -113,7 +113,7 @@ export class AbstractService { /** * Remove a callback from the service by its key. */ - remove(key: string) { + remove(key: string | symbol) { this.callbacks.delete(key); // Kill the service when we remove the last callback diff --git a/packages/js-toolkit/services/DragService.ts b/packages/js-toolkit/services/DragService.ts index aef384bc8..d61f0d646 100644 --- a/packages/js-toolkit/services/DragService.ts +++ b/packages/js-toolkit/services/DragService.ts @@ -316,5 +316,9 @@ export class DragService extends AbstractService { * ``` */ export function useDrag(target: HTMLElement, options: DragServiceOptions): DragServiceInterface { - return DragService.getInstance(target, target, options); + return DragService.getInstance( + [target, JSON.stringify(options)], + target, + options, + ); } diff --git a/packages/js-toolkit/services/MutationService.ts b/packages/js-toolkit/services/MutationService.ts new file mode 100644 index 000000000..7dd92f183 --- /dev/null +++ b/packages/js-toolkit/services/MutationService.ts @@ -0,0 +1,52 @@ +import type { ServiceInterface } from './AbstractService.js'; +import { AbstractService } from './AbstractService.js'; +import { isEmpty } from '../utils/is.js'; + +export interface MutationServiceProps { + mutations: MutationRecord[]; +} + +export type MutationServiceInterface = ServiceInterface; + +export type MutationServiceOptions = MutationObserverInit; + +export class MutationService extends AbstractService { + props: MutationServiceProps = { + mutations: [], + }; + + target: Node; + + options: MutationObserverInit; + + observer: MutationObserver; + + constructor(target?: Node, options?: MutationObserverInit) { + super(); + this.target = target ?? document.documentElement; + this.options = options; + } + + init() { + this.observer = new MutationObserver((mutations) => { + this.props.mutations = mutations; + this.trigger(this.props); + }); + this.observer.observe(this.target, isEmpty(this.options) ? { attributes: true } : this.options); + } + + kill() { + this.observer.disconnect(); + this.observer = null; + } +} + +/** + * Use the mutation service. + */ +export function useMutation( + target?: Node, + options?: MutationObserverInit, +): MutationServiceInterface { + return MutationService.getInstance([target, JSON.stringify(options)], target, options); +} diff --git a/packages/js-toolkit/services/PointerService.ts b/packages/js-toolkit/services/PointerService.ts index 8608ada6d..06d1bd9b5 100644 --- a/packages/js-toolkit/services/PointerService.ts +++ b/packages/js-toolkit/services/PointerService.ts @@ -170,5 +170,5 @@ export class PointerService extends AbstractService { * Use the pointer service. */ export function usePointer(target: HTMLElement | Window = window): PointerServiceInterface { - return PointerService.getInstance(target, target); + return PointerService.getInstance([target], target); } diff --git a/packages/js-toolkit/services/ResizeService.ts b/packages/js-toolkit/services/ResizeService.ts index 1f510a757..c01d6fda0 100644 --- a/packages/js-toolkit/services/ResizeService.ts +++ b/packages/js-toolkit/services/ResizeService.ts @@ -96,5 +96,5 @@ export class ResizeService< export function useResize( breakpoints?: T, ): ResizeServiceInterface { - return ResizeService.getInstance(breakpoints, breakpoints); + return ResizeService.getInstance([breakpoints], breakpoints); } diff --git a/packages/js-toolkit/services/index.ts b/packages/js-toolkit/services/index.ts index b1875d6db..5b9eb117f 100644 --- a/packages/js-toolkit/services/index.ts +++ b/packages/js-toolkit/services/index.ts @@ -2,6 +2,7 @@ export * from './AbstractService.js'; export * from './DragService.js'; export * from './KeyService.js'; export * from './LoadService.js'; +export * from './MutationService.js'; export * from './PointerService.js'; export * from './RafService.js'; export * from './ResizeService.js'; diff --git a/packages/js-toolkit/utils/cache.ts b/packages/js-toolkit/utils/cache.ts new file mode 100644 index 000000000..4d3eb097d --- /dev/null +++ b/packages/js-toolkit/utils/cache.ts @@ -0,0 +1,21 @@ +const map = new Map(); + +/** + * Cache the result of a callback in map instances. + */ +export function cache(keys: any[], callback: () => T): T { + let value = map; + let index = 1; + + for (const key of keys) { + if (!value.has(key)) { + const newValue = index === keys.length ? callback() : new Map(); + value.set(key, newValue); + } + + value = value.get(key); + index += 1; + } + + return value as T; +} diff --git a/packages/js-toolkit/utils/index.ts b/packages/js-toolkit/utils/index.ts index c1f74f875..787854ae6 100644 --- a/packages/js-toolkit/utils/index.ts +++ b/packages/js-toolkit/utils/index.ts @@ -29,3 +29,4 @@ export * from './wait.js'; export * from './random.js'; export * from './memo.js'; export * from './loadElement.js'; +export * from './cache.js'; diff --git a/packages/tests/decorators/index.spec.ts b/packages/tests/decorators/index.spec.ts index f1d0cca67..1577d01a4 100644 --- a/packages/tests/decorators/index.spec.ts +++ b/packages/tests/decorators/index.spec.ts @@ -13,6 +13,7 @@ test('decorators exports', () => { "withMountOnMediaQuery", "withMountWhenInView", "withMountWhenPrefersMotion", + "withMutation", "withName", "withRelativePointer", "withResponsiveOptions", diff --git a/packages/tests/decorators/withMutation.spec.ts b/packages/tests/decorators/withMutation.spec.ts new file mode 100644 index 000000000..68b207030 --- /dev/null +++ b/packages/tests/decorators/withMutation.spec.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Base, withMutation } from '@studiometa/js-toolkit'; +import { nextTick } from '@studiometa/js-toolkit/utils'; +import { h } from '#test-utils'; + +describe('The `withMutation` decorator', () => { + it('should add a `mutated` hook', async () => { + const fn = vi.fn(); + + class Foo extends withMutation(Base) { + static config = { + name: 'Foo', + }; + + mutated(props) { + fn(props); + } + } + + const div = h('div'); + const foo = new Foo(div); + + await foo.$mount(); + div.classList.add('foo'); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(1); + + await foo.$destroy(); + div.classList.remove('foo'); + + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts index 1f9305555..7eb38bfb5 100644 --- a/packages/tests/index.spec.ts +++ b/packages/tests/index.spec.ts @@ -10,6 +10,7 @@ describe('The package exports', () => { "DragService", "KeyService", "LoadService", + "MutationService", "PointerService", "RafService", "ResizeService", @@ -28,6 +29,7 @@ describe('The package exports', () => { "useDrag", "useKey", "useLoad", + "useMutation", "usePointer", "useRaf", "useResize", @@ -41,6 +43,7 @@ describe('The package exports', () => { "withMountOnMediaQuery", "withMountWhenInView", "withMountWhenPrefersMotion", + "withMutation", "withName", "withRelativePointer", "withResponsiveOptions", diff --git a/packages/tests/services/AbstractService.spec.ts b/packages/tests/services/AbstractService.spec.ts index 9758aed44..1f0076027 100644 --- a/packages/tests/services/AbstractService.spec.ts +++ b/packages/tests/services/AbstractService.spec.ts @@ -37,6 +37,17 @@ describe('The `Service` class', () => { expect(service.has('key')).toBe(false); }); + it('should accept Symbol as key', () => { + const { service } = getContext(); + const fn = vi.fn(); + const key = Symbol('key'); + service.add(key, fn); + expect(service.has(key)).toBe(true); + expect(service.get(key)).toBe(fn); + service.remove(key); + expect(service.has(key)).toBe(false); + }); + it('should init and kill itself when adding or removing a callback', () => { const { service, fn } = getContext(); service.add('key', () => fn('callback')); diff --git a/packages/tests/services/MutationService.spec.ts b/packages/tests/services/MutationService.spec.ts new file mode 100644 index 000000000..70836644c --- /dev/null +++ b/packages/tests/services/MutationService.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from 'vitest'; +import { useMutation } from '@studiometa/js-toolkit'; +import { nextTick } from '@studiometa/js-toolkit/utils'; +import { h } from '#test-utils'; + +describe('The `useMutation` service', () => { + it('should trigger on attribute change by default', async () => { + const div = h('div'); + const service = useMutation(div); + const fn = vi.fn(); + + service.add('key', fn); + div.classList.add('foo'); + await nextTick(); + + expect(fn).toHaveBeenLastCalledWith(service.props()); + fn.mockRestore(); + + service.remove('key'); + div.classList.add('foo'); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('should accept mutation observer options as second parameter', async () => { + const em = h('em'); + const span = h('span', em); + const div = h('div', span); + const service = useMutation(div, { childList: true, subtree: true }); + const fn = vi.fn(); + + service.add('key', fn); + em.remove(); + await nextTick(); + + expect(fn).toHaveBeenLastCalledWith(service.props()); + expect(service.props().mutations[0].removedNodes[0]).toBe(em); + + service.remove('key'); + }); + + it('should use documentElement as default target', async () => { + const service = useMutation(); + const fn = vi.fn(); + + service.add('key', fn); + document.documentElement.classList.add('foo'); + + await nextTick(); + + expect(fn).toHaveBeenLastCalledWith(service.props()); + service.remove('key'); + }); +}); diff --git a/packages/tests/services/index.spec.ts b/packages/tests/services/index.spec.ts index 6774f65f9..6d19f658b 100644 --- a/packages/tests/services/index.spec.ts +++ b/packages/tests/services/index.spec.ts @@ -8,6 +8,7 @@ test('components exports', () => { "DragService", "KeyService", "LoadService", + "MutationService", "PointerService", "RafService", "ResizeService", @@ -15,6 +16,7 @@ test('components exports', () => { "useDrag", "useKey", "useLoad", + "useMutation", "usePointer", "useRaf", "useResize", diff --git a/packages/tests/utils/cache.spec.ts b/packages/tests/utils/cache.spec.ts new file mode 100644 index 000000000..aeef780f5 --- /dev/null +++ b/packages/tests/utils/cache.spec.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import { cache } from '@studiometa/js-toolkit/utils'; + +describe('cache function', () => { + it('caches values with multiple keys', async () => { + const keys = ['one', 'two', 'three']; + const callback = () => new Map(); + expect(cache(keys, callback)).toBe(cache(keys, callback)) + }); +}); diff --git a/packages/tests/utils/index.spec.ts b/packages/tests/utils/index.spec.ts index 91807be4b..918db16af 100644 --- a/packages/tests/utils/index.spec.ts +++ b/packages/tests/utils/index.spec.ts @@ -11,6 +11,7 @@ describe('@studiometa/js-toolkit/utils exports', () => { "addStyle", "animate", "boundingRectToCircle", + "cache", "camelCase", "clamp", "clamp01",