Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] withKeepUnmounted decorator #433

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. The format
### Added

- Add shorthand props on the scroll service for easier destructuring ([#432](https://github.com/studiometa/js-toolkit/pull/432))
- Add a `withKeepUnmounted` decorator ([#433](https://github.com/studiometa/js-toolkit/pull/433))

## [v3.0.0-alpha.3](https://github.com/studiometa/js-toolkit/compare/3.0.0-alpha.2..3.0.0-alpha.3) (2023-04-17)

Expand Down
1 change: 1 addition & 0 deletions packages/docs/.vitepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ function getDecoratorsSidebar() {
{ text: 'withExtraConfig', link: '/api/decorators/withExtraConfig.html' },
{ text: 'withFreezedOptions', link: '/api/decorators/withFreezedOptions.html' },
{ text: 'withIntersectionObserver', link: '/api/decorators/withIntersectionObserver.html' },
{ text: 'withKeepUnmounted', link: '/api/decorators/withKeepUnmounted.html' },
{ text: 'withMountOnMediaQuery', link: '/api/decorators/withMountOnMediaQuery.html' },
{ text: 'withMountWhenInView', link: '/api/decorators/withMountWhenInView.html' },
{ text: 'withMountWhenPrefersMotion', link: '/api/decorators/withMountWhenPrefersMotion.html' },
Expand Down
6 changes: 5 additions & 1 deletion packages/docs/api/decorators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Decorators are used to add extra functionalities to a Base class. The following
- [withExtraConfig](./withExtraConfig.html)
- [withFreezedOptions](./withFreezedOptions.html)
- [withIntersectionObserver](./withIntersectionObserver.html)
- [withMountWhenInView](./withMountWhenInView.html)
- [withKeepUnmounted](./withKeepUnmounted.html)
- [withMountOnMediaQuery](./withMountOnMediaQuery.html)
- [withMountWhenInView](./withMountWhenInView.html)
- [withMountWhenPrefersMotion](./withMountWhenPrefersMotion.html)
- [withRelativePointer](./withRelativePointer.html)
- [withResponsiveOptions](./withResponsiveOptions.html)
- [withScrolledInView](./withScrolledInView.html)
66 changes: 66 additions & 0 deletions packages/docs/api/decorators/withKeepUnmounted.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# withKeepUnmounted

Use this decorator to create a component which will mount only if the option `enabled` is active.

## Usage

```js
import { Base, withKeepUnmounted } from '@studiometa/js-toolkit';
import Component from './Component.js';

export default withKeepUnmounted(Component);
```

### Parameters

- `Base` (`BaseConstructor`): The `Base` class or a class extending it.
- `autoMount` (`boolean`): Watch the `enabled` option to mount automatically. Default to `true`.

### Return value

- `BaseConstructor`: A child class of the given class which will be mounted when the media query matches.

## API

This decorator does not expose a specific API.

## Examples

### Simple usage

```js{1, 3}
import { Base, withKeepUnmounted } from '@studiometa/js-toolkit';

export default class UnmountedComponent extends withKeepUnmounted(Base) {
static config = {
name: 'UnmountedComponent',
log: true,
};

mounted() {
this.$log('Enabled and mounted.');
}
}
```

```js{2, 8, 15}
import { Base, createApp } from '@studiometa/js-toolkit';
import UnmountedComponent from './UnmountedComponent.js';

class App extends Base {
static config = {
name: 'App',
components: {
UnmountedComponent,
},
refs: ['btn'],
};

onBtnClick() {
// Set the enabled option to `true` will mount the component
this.$children.UnmountedComponent[0].$options.enabled = true;
}
}

createApp(App);
```
1 change: 1 addition & 0 deletions packages/js-toolkit/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './withDrag.js';
export * from './withExtraConfig.js';
export * from './withFreezedOptions.js';
export * from './withIntersectionObserver.js';
export * from './withKeepUnmounted.js';
export * from './withMountOnMediaQuery.js';
export * from './withMountWhenInView.js';
export * from './withMountWhenPrefersMotion.js';
Expand Down
90 changes: 90 additions & 0 deletions packages/js-toolkit/decorators/withKeepUnmounted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { BaseInterface, BaseDecorator } from '../Base/types.js';
import type { Base, BaseProps, BaseConfig } from '../Base/index.js';

export interface withKeepUnmountedProps extends BaseProps {
$options: {
enabled: boolean;
};
}

/**
* Keep unmounted decoration.
*/
export function withKeepUnmounted<S extends Base = Base>(
BaseClass: typeof Base,
autoMount = true,
): BaseDecorator<BaseInterface, S, withKeepUnmountedProps> {
/**
* Class.
*/
class WithKeepUnmounted<T extends BaseProps = BaseProps> extends BaseClass<
T & withKeepUnmountedProps
> {
/**
* Config.
*/
static config: BaseConfig = {
...BaseClass.config,
name: `${BaseClass.config.name}withKeepUnmounted`,
options: {
...BaseClass.config?.options,
enabled: Boolean,
},
};

/**
* Listen for enabled option changes when the class in instantiated.
*
* @param {HTMLElement} element The component's root element.
*/
constructor(element: HTMLElement) {
super(element);

if (!autoMount) {
return;
}

// Watch change on the `data-options-enabled` attribute to trigger $mount.
const mutationObserver = new MutationObserver(([mutation]) => {
if (
mutation.type !== 'attributes' ||
(mutation.attributeName !== 'data-options' &&
mutation.attributeName !== 'data-option-enabled')
) {
return;
}

this.$mount();
});

mutationObserver.observe(this.$el, { attributes: true });

this.$on('terminated', () => {
mutationObserver.disconnect();
});

this.$on('mounted', () => {
mutationObserver.disconnect();
});

this.$on('destroyed', () => {
mutationObserver.disconnect();
mutationObserver.observe(this.$el, { attributes: true });
});
}

/**
* Override the mounting of the component.
*/
$mount() {
if (!this.$options.enabled) {
return this;
}

return super.$mount();
}
}

// @ts-ignore
return WithKeepUnmounted;
}
1 change: 1 addition & 0 deletions packages/tests/__snapshots__/index.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ exports[`The package exports should export helpers and the Base class 1`] = `
"withExtraConfig",
"withFreezedOptions",
"withIntersectionObserver",
"withKeepUnmounted",
"withMountOnMediaQuery",
"withMountWhenInView",
"withMountWhenPrefersMotion",
Expand Down
78 changes: 78 additions & 0 deletions packages/tests/decorators/withKeepUnmounted.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { Base, BaseConfig, withKeepUnmounted } from '@studiometa/js-toolkit';
import { advanceTimersByTimeAsync, h, useFakeTimers, useRealTimers } from '#test-utils';

async function getContext(options?: { template?: string; autoMount?: boolean }) {
const div = h('div');

div.innerHTML = options?.template ?? '<div data-component="Component"></div>';

class Component extends withKeepUnmounted(Base, options?.autoMount ?? true) {
static config = {
name: 'Component',
};
}

type AppProps = {
$children: { Component: Component[] };
};

class App extends Base<AppProps> {
static config: BaseConfig = {
name: 'App',
components: {
Component,
},
};
}

const app = new App(div);
app.$mount();
await advanceTimersByTimeAsync(100);
const [component] = app.$children.Component;
return { app, div, component };
}

beforeEach(() => {
useFakeTimers();
});

afterEach(() => {
useRealTimers();
});

describe('The withKeepUnmounted decorator', () => {
it('should not mount the component if it is not enabled', async () => {
const { component } = await getContext();
expect(component.$isMounted).toBe(false);
});

it('should mount the component if it is enabled', async () => {
const { component } = await getContext({
template: '<div data-component="Component" data-option-enabled></div>',
});
expect(component.$isMounted).toBe(true);
});

it('should mount the component automatically after enabling', async () => {
const { component } = await getContext();
expect(component.$isMounted).toBe(false);
component.$options.enabled = true;
await advanceTimersByTimeAsync(100);
expect(component.$isMounted).toBe(true);
});

it('should not mount the component automatically after enabling if autoMount is not active', async () => {
const { component } = await getContext({
autoMount: false,
});

expect(component.$isMounted).toBe(false);
component.$options.enabled = true;
await advanceTimersByTimeAsync(100);
expect(component.$isMounted).toBe(false);
component.$mount();
await advanceTimersByTimeAsync(100);
expect(component.$isMounted).toBe(true);
});
});
Loading