Skip to content

Commit

Permalink
Merge pull request #559 from studiometa/feature/mutation-observer
Browse files Browse the repository at this point in the history
[Feature] Add mutation observer service and decorator
  • Loading branch information
titouanmathis authored Dec 4, 2024
2 parents a86be1d + af35c96 commit ab50f10
Show file tree
Hide file tree
Showing 23 changed files with 433 additions and 17 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
3 changes: 3 additions & 0 deletions packages/docs/.vitepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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' },
Expand Down Expand Up @@ -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' },
Expand Down
67 changes: 67 additions & 0 deletions packages/docs/api/decorators/withMutation.md
Original file line number Diff line number Diff line change
@@ -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();
}
}
```
62 changes: 62 additions & 0 deletions packages/docs/api/services/useMutation.md
Original file line number Diff line number Diff line change
@@ -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 `<body>` 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.
28 changes: 28 additions & 0 deletions packages/docs/utils/cache.md
Original file line number Diff line number Diff line change
@@ -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<any>`): 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.
1 change: 1 addition & 0 deletions packages/js-toolkit/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
55 changes: 55 additions & 0 deletions packages/js-toolkit/decorators/withMutation.ts
Original file line number Diff line number Diff line change
@@ -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<S extends Base>(
BaseClass: typeof Base,
{ target = (instance) => instance.$el, ...options }: MutationDecoratorOptions = {},
): BaseDecorator<BaseInterface, S> {
/**
* Class.
*/
class WithMutation<T extends BaseProps = BaseProps> extends BaseClass<T & WithMutationInterface> {
/**
* 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;
}
26 changes: 13 additions & 13 deletions packages/js-toolkit/services/AbstractService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { cache } from '../utils/cache.js';

export interface ServiceInterface<T> {
/**
* Remove a function from the resize service by its key.
Expand Down Expand Up @@ -48,19 +50,17 @@ export class AbstractService<PropsType = any> {
/**
* Get a service instance as a singleton based on the given key.
*/
static getInstance<T extends ServiceInterface<any>>(key: any = this, ...args: any[]) {
if (!this.__instances.has(key)) {
static getInstance<T extends ServiceInterface<any>>(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;
});
}

/**
Expand All @@ -76,28 +76,28 @@ export class AbstractService<PropsType = any> {
/**
* Holds all the callbacks that will be triggered.
*/
callbacks: Map<string, (props: PropsType) => unknown> = new Map();
callbacks: Map<string | symbol, (props: PropsType) => 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;
}

Expand All @@ -113,7 +113,7 @@ export class AbstractService<PropsType = any> {
/**
* 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
Expand Down
6 changes: 5 additions & 1 deletion packages/js-toolkit/services/DragService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,5 +316,9 @@ export class DragService extends AbstractService<DragServiceProps> {
* ```
*/
export function useDrag(target: HTMLElement, options: DragServiceOptions): DragServiceInterface {
return DragService.getInstance<DragServiceInterface>(target, target, options);
return DragService.getInstance<DragServiceInterface>(
[target, JSON.stringify(options)],
target,
options,
);
}
Loading

0 comments on commit ab50f10

Please sign in to comment.