Skip to content

Commit

Permalink
feat(components): try reactive controller for checkbox-wrapper | bh | #…
Browse files Browse the repository at this point in the history
  • Loading branch information
denyo committed Feb 12, 2024
1 parent 2e0761b commit c5d82cc
Show file tree
Hide file tree
Showing 4 changed files with 205 additions and 14 deletions.
191 changes: 191 additions & 0 deletions packages/components/src/abstract-components/Controllers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { type ComponentInterface, forceUpdate } from '@stencil/core';

// inspired by lit
// https://github.com/lit/lit/blob/59d5ba7649f2957d01996e3115d0d79bdfbf34ac/packages/reactive-element/src/reactive-controller.ts#L11

/**
* An object that can host Reactive Controllers and call their lifecycle
* callbacks.
*/
type ReactiveControllerHost = {
/**
* Adds a controller to the host, which sets up the controller's lifecycle
* methods to be called with the host's lifecycle.
*/
addController(controller: ReactiveController): void;

/**
* Removes a controller from the host.
*/
removeController(controller: ReactiveController): void;

/**
* Requests a host update which is processed asynchronously. The update can
* be waited on via the `updateComplete` property.
*/
requestUpdate(): void;

/**
* Returns a Promise that resolves when the host has completed updating.
* The Promise value is a boolean that is `true` if the element completed the
* update without triggering another update. The Promise result is `false` if
* a property was set inside `updated()`. If the Promise is rejected, an
* exception was thrown during the update.
*
* @return A promise of a boolean that indicates if the update resolved
* without triggering another update.
*/
readonly updateComplete: Promise<boolean>;
};

/**
* A Reactive Controller is an object that enables sub-component code
* organization and reuse by aggregating the state, behavior, and lifecycle
* hooks related to a single feature.
*
* Controllers are added to a host component, or other object that implements
* the `ReactiveControllerHost` interface, via the `addController()` method.
* They can hook their host components's lifecycle by implementing one or more
* of the lifecycle callbacks, or initiate an update of the host component by
* calling `requestUpdate()` on the host.
*/
type ReactiveController = {
/**
* Called when the host is connected to the component tree. For custom
* element hosts, this corresponds to the `connectedCallback()` lifecycle,
* which is only called when the component is connected to the document.
*/
hostConnected?(): void;

/**
* Called when the host is disconnected from the component tree. For custom
* element hosts, this corresponds to the `disconnectedCallback()` lifecycle,
* which is called the host or an ancestor component is disconnected from the
* document.
*/
hostDisconnected?(): void;

/**
* Called during the client-side host update, just before the host calls
* its own update.
*
* Code in `update()` can depend on the DOM as it is not called in
* server-side rendering.
*/
hostWillUpdate?(): void;

/**
* Called after a host update, just before the host calls firstUpdated and
* updated. It is not called in server-side rendering.
*/
hostDidUpdate?(): void;

hostWillLoad?(): void;
};

export class ControllerHost implements ReactiveControllerHost {
constructor(
private host: ComponentInterface,
private controllers: Set<ReactiveController> = new Set<ReactiveController>()
) {
const { connectedCallback, disconnectedCallback, componentWillLoad, componentWillUpdate, componentDidUpdate } =
host;
console.log('ControllerHost constructor', host);

host.connectedCallback = () => {
this.controllers.forEach((ctrl) => ctrl.hostConnected?.());
connectedCallback?.apply(host);
};
host.disconnectedCallback = () => {
this.controllers.forEach((ctrl) => ctrl.hostDisconnected?.());
disconnectedCallback?.apply(host);
};
host.componentWillLoad = () => {
this.controllers.forEach((ctrl) => ctrl.hostWillLoad?.());
componentWillLoad?.apply(host);
};
host.componentWillUpdate = () => {
this.controllers.forEach((ctrl) => ctrl.hostWillUpdate?.());
componentWillUpdate?.apply(host);
};
host.componentDidUpdate = () => {
this.controllers.forEach((ctrl) => ctrl.hostDidUpdate?.());
componentDidUpdate?.apply(host);
};
}

public addController(ctrl: ReactiveController) {
this.controllers.add(ctrl);
}

public removeController(ctrl: ReactiveController) {
this.controllers.delete(ctrl);
}

public requestUpdate() {
forceUpdate(this.host);
}

updateComplete = Promise.resolve(true);
}

// see lit docs:
// - https://lit.dev/docs/composition/overview/
// - https://lit.dev/docs/composition/mixins/
// - https://lit.dev/docs/composition/controllers/
export class LoadingController implements ReactiveController {
public initialLoading: boolean = false;

constructor(
private host: ReactiveControllerHost,
private cmp: ComponentInterface & { loading?: boolean }
) {
this.host.addController(this);
}

hostConnected() {
console.log('hostConnected', this.cmp.loading);
this.initialLoading = this.cmp.loading;
}

hostWillLoad() {
console.log('hostWillLoad', this.cmp.loading);
this.initialLoading = this.cmp.loading;
}

hostWillUpdate() {
console.log('hostWillUpdate', this.cmp.loading);
if (this.cmp.loading) {
this.initialLoading = true;
}
}
}

// NOTE: maybe useful for closing popovers, select dropdowns, etc.
export class DismissController implements ReactiveController {
// host: ReactiveControllerHost;

constructor(
host: ReactiveControllerHost,
private onClose: () => void
) {
// this.host = host;
host.addController(this);
}

hostConnected() {
window.addEventListener('click', this.onClose);
window.addEventListener('keyup', this.handleKeyUp);
}

hostDisconnected() {
window.removeEventListener('click', this.onClose);
window.removeEventListener('keyup', this.handleKeyUp);
}

private handleKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
this.onClose();
}
};
}
1 change: 1 addition & 0 deletions packages/components/src/abstract-components/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './Controllers';
export * from './LoadingBaseComponent';
export * from './LoadingComponent';
export * from './LoadingMixin';
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, Element, forceUpdate, h, type JSX, Listen, Prop, Watch } from '@stencil/core';
import { Component, Element, forceUpdate, h, type JSX, Listen, Prop } from '@stencil/core';
import {
addChangeListener,
AllowedTypes,
Expand All @@ -20,6 +20,7 @@ import { type CheckboxWrapperState } from './checkbox-wrapper-utils';
import { StateMessage } from '../common/state-message/state-message';
import { Label } from '../common/label/label';
import { LoadingMessage } from '../common/loading-message/loading-message';
import { ControllerHost, LoadingController } from '../../abstract-components';

const propTypes: PropTypes<typeof CheckboxWrapper> = {
label: AllowedTypes.string,
Expand Down Expand Up @@ -56,7 +57,9 @@ export class CheckboxWrapper {
@Prop() public theme?: Theme = 'light';

private input: HTMLInputElement;
private initialLoading: boolean = false;

private controllerHost = new ControllerHost(this);
private loadingCtrl = new LoadingController(this.controllerHost, this);

@Listen('keydown')
public onKeydown(e: KeyboardEvent): void {
Expand All @@ -66,24 +69,14 @@ export class CheckboxWrapper {
}
}

@Watch('loading')
public loadingChanged(newVal: boolean): void {
if (newVal) {
// don't reset initialLoading to false
this.initialLoading = newVal;
}
}

public connectedCallback(): void {
this.observeAttributes(); // on every reconnect
this.initialLoading = this.loading;
}

public componentWillLoad(): void {
this.input = getOnlyChildOfKindHTMLElementOrThrow(this.host, 'input[type=checkbox]');
addChangeListener(this.input);
this.observeAttributes(); // once initially
this.initialLoading = this.loading;
}

public componentShouldUpdate(newVal: unknown, oldVal: unknown): boolean {
Expand Down Expand Up @@ -132,7 +125,7 @@ export class CheckboxWrapper {
)}
</div>
<StateMessage state={this.state} message={this.message} theme={this.theme} host={this.host} />
<LoadingMessage loading={this.loading} initialLoading={this.initialLoading} />
<LoadingMessage loading={this.loading} initialLoading={this.loadingCtrl.initialLoading} />
</div>
);
}
Expand Down
8 changes: 7 additions & 1 deletion packages/components/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,16 @@ <h2>@use mixin composition works</h2>
<h2>@use stencil component composition partially works</h2>
<p-switch>Hi</p-switch>
<p-switch loading>Ho</p-switch>

<hr />

<h2>reactive controller works</h2>
<p-checkbox-wrapper label="Hi"><input type="checkbox" /></p-checkbox-wrapper>
<p-checkbox-wrapper label="Ho" loading><input type="checkbox" /></p-checkbox-wrapper>
</main>

<script>
document.querySelectorAll('p-button,p-button-pure,p-switch').forEach((el) =>
document.querySelectorAll('p-button,p-button-pure,p-switch,p-checkbox-wrapper').forEach((el) =>
el.addEventListener('click', ({ target }) => {
target.loading = !target.loading;
setTimeout(() => {
Expand Down

0 comments on commit c5d82cc

Please sign in to comment.