import { DataObject, JsxChildren, VNode } from 'dom-renderer';
import { autorun, IReactionDisposer, IReactionPublic, reaction as watch } from 'mobx';
import {
CustomElement,
isHTMLElementClass,
parseJSON,
toCamelCase,
toHyphenCase
} from 'web-utility';
import { FunctionCell } from './Async';
import { getMobxData } from './utility';
import { ClassComponent, WebCell } from './WebCell';
export type PropsWithChildren
= P & {
children?: JsxChildren;
};
export type FunctionComponent
= (props: P) => VNode;
export type AsyncFunctionComponent
= (props: P) => Promise;
export type FC = FunctionComponent
;
export type AFC
= AsyncFunctionComponent
;
interface ReactionItem {
expression: ReactionExpression;
effect: (...data: any[]) => any;
}
const reactionMap = new WeakMap();
function wrapClass(Component: T) {
class ObserverComponent extends (Component as ClassComponent) implements CustomElement {
static observedAttributes = [];
protected disposers: IReactionDisposer[] = [];
get props() {
return getMobxData(this);
}
constructor() {
super();
Promise.resolve().then(() => this.#boot());
}
update = () => {
const { update } = Object.getPrototypeOf(this);
return new Promise(resolve =>
this.disposers.push(autorun(() => update.call(this).then(resolve)))
);
};
#boot() {
const names: string[] = this.constructor['observedAttributes'] || [],
reactions = reactionMap.get(this) || [];
this.disposers.push(
...names.map(name => autorun(() => this.syncPropAttr(name))),
...reactions.map(({ expression, effect }) =>
watch(reaction => expression(this, reaction), effect.bind(this))
)
);
}
disconnectedCallback() {
for (const disposer of this.disposers) disposer();
this.disposers.length = 0;
super['disconnectedCallback']?.();
}
setAttribute(name: string, value: string) {
const old = super.getAttribute(name),
names: string[] = this.constructor['observedAttributes'];
super.setAttribute(name, value);
if (names.includes(name)) this.attributeChangedCallback(name, old, value);
}
attributeChangedCallback(name: string, old: string, value: string) {
this[toCamelCase(name)] = parseJSON(value);
super['attributeChangedCallback']?.(name, old, value);
}
syncPropAttr(name: string) {
let value = this[toCamelCase(name)];
if (!(value != null) || value === false) return this.removeAttribute(name);
value = value === true ? name : value;
if (typeof value === 'object') {
value = value.toJSON?.();
value = typeof value === 'object' ? JSON.stringify(value) : value;
}
super.setAttribute(name, value);
}
}
return ObserverComponent as unknown as T;
}
export type WebCellComponent = FunctionComponent | ClassComponent;
export type ComponentType = FC
| (WebCell
& ClassComponent);
export type ComponentProps =
C extends keyof JSX.IntrinsicElements
? JSX.IntrinsicElements[C]
: C extends FC
? P
: C extends WebCell & ClassComponent
? P
: never;
export type ObservableComponent = WebCellComponent | AsyncFunctionComponent;
export type AwaitedComponent = T extends (
props: infer P
) => Promise
? (props: P) => R
: T;
/**
* `class` decorator of Web components for MobX
*/
export function observer(
func: T,
_: ClassDecoratorContext
): AwaitedComponent;
export function observer(func: T): AwaitedComponent;
export function observer(func: T, _?: ClassDecoratorContext) {
return isHTMLElementClass(func)
? wrapClass(func)
: (props: object) => func(props)) as FC | AFC} />;
}
/**
* `accessor` decorator of MobX `@observable` for HTML attributes
*/
export function attribute(
_: ClassAccessorDecoratorTarget,
{ name, addInitializer }: ClassAccessorDecoratorContext
) {
addInitializer(function () {
const names: string[] = this.constructor['observedAttributes'],
attribute = toHyphenCase(name.toString());
if (!names.includes(attribute)) names.push(attribute);
});
}
export type ReactionExpression = (data: I, reaction: IReactionPublic) => O;
export type ReactionEffect = (newValue: V, oldValue: V, reaction: IReactionPublic) => any;
/**
* Method decorator of [MobX `reaction()`](https://mobx.js.org/reactions.html#reaction)
*
* @example
* ```tsx
* import { observable } from 'mobx';
* import { component, observer, reaction } from 'web-cell';
*
* @component({ tagName: 'my-tag' })
* @observer
* export class MyTag extends HTMLElement {
* @observable
* accessor count = 0;
*
* @reaction(({ count }) => count)
* handleCountChange(newValue: number, oldValue: number) {
* console.log(`Count changed from ${oldValue} to ${newValue}`);
* }
*
* render() {
* return (
*
* );
* }
* }
* ```
*/
export const reaction =
(expression: ReactionExpression) =>
(effect: ReactionEffect, { addInitializer }: ClassMethodDecoratorContext) =>
addInitializer(function () {
const reactions = reactionMap.get(this) || [];
reactions.push({ expression, effect });
reactionMap.set(this, reactions);
});