apprun
Version:
JavaScript library that has Elm inspired architecture, event pub-sub and components
270 lines (240 loc) • 8.34 kB
text/typescript
import app, { App } from './app';
import { Reflect } from './decorator'
import { View, Update, ActionDef, ActionOptions, MountOptions } from './types';
import directive from './directive';
const componentCache = {};
app.on('get-components', o => o.components = componentCache);
const REFRESH = state => state;
export class Component<T=any, E=any> {
static __isAppRunComponent = true;
private _app = new App();
private _actions = [];
private _global_events = [];
private _state;
private _history = [];
private _history_idx = -1;
private enable_history;
private global_event;
public element;
public rendered;
public mounted;
public unload;
private tracking_id;
private observer;
private save_vdom;
render(element: HTMLElement, node) {
app.render(element, node, this);
}
private _view(state, p = null) {
if (!this.view) return;
const h = app.createElement;
app.createElement = (tag, props, ...children) => {
props && Object.keys(props).forEach(key => {
if (key.startsWith('$')) {
directive(key, props, tag, this);
delete props[key];
}
});
return h(tag, props, ...children);
}
const html = p ? this.view(state, p) : this.view(state);
app.createElement = h;
return html;
}
private renderState(state: T, vdom = null) {
if (!this.view) return;
let html = vdom || this._view(state);
app['debug'] && app.run('debug', {
component: this,
state,
vdom: html || '[vdom is null - no render]',
});
if (typeof document !== 'object') return;
const el = (typeof this.element === 'string') ?
document.getElementById(this.element) : this.element;
if (el) {
const tracking_attr = '_c';
if (!this.unload) {
el.removeAttribute && el.removeAttribute(tracking_attr);
} else if (el['_component'] !== this || el.getAttribute(tracking_attr) !== this.tracking_id) {
this.tracking_id = new Date().valueOf().toString();
el.setAttribute(tracking_attr, this.tracking_id);
if (typeof MutationObserver !== 'undefined') {
if(!this.observer) this.observer = new MutationObserver(changes => {
if (changes[0].oldValue === this.tracking_id || !document.body.contains(el)) {
this.unload(this.state);
this.observer.disconnect();
this.observer = null;
this.save_vdom = [];
}
});
this.observer.observe(document.body, {
childList: true, subtree: true,
attributes: true, attributeOldValue: true, attributeFilter: [tracking_attr]
});
}
}
el['_component'] = this;
}
if (!vdom) {
this.render(el, html);
}
this.rendered && this.rendered(this.state);
}
public setState(state: T, options: ActionOptions
= { render: true, history: false}) {
if (state instanceof Promise) {
// Promise will not be saved or rendered
// state will be saved and rendered when promise is resolved
state.then(s => {
this.setState(s, options)
}).catch(err => {
console.error(err);
throw err;
});
this._state = state;
} else {
this._state = state;
if (state == null) return;
this.state = state;
if (options.render !== false) this.renderState(state);
if (options.history !== false && this.enable_history) {
this._history = [...this._history, state];
this._history_idx = this._history.length - 1;
}
if (typeof options.callback === 'function') options.callback(this.state);
}
}
private _history_prev = () => {
this._history_idx--;
if (this._history_idx >= 0) {
this.setState(this._history[this._history_idx], { render: true, history: false });
}
else {
this._history_idx = 0;
}
};
private _history_next = () => {
this._history_idx++;
if (this._history_idx < this._history.length) {
this.setState(this._history[this._history_idx], { render: true, history: false });
}
else {
this._history_idx = this._history.length - 1;
}
};
constructor(
protected state?: T,
protected view?: View<T>,
protected update?: Update<T, E>,
protected options?) {
}
start = (element = null, options?: MountOptions): Component<T, E> => {
return this.mount(element, { ...options, render: true });
}
public mount(element = null, options?: MountOptions): Component<T, E> {
console.assert(!this.element, 'Component already mounted.')
this.options = options = { ...this.options, ...options };
this.element = element;
this.global_event = options.global_event;
this.enable_history = !!options.history;
if (this.enable_history) {
this.on(options.history.prev || 'history-prev', this._history_prev);
this.on(options.history.next || 'history-next', this._history_next);
}
if (options.route) {
this.update = this.update || {};
this.update[options.route] = REFRESH;
}
this.add_actions();
this.state = this.state ?? this['model'] ?? {};
if (options.render) {
this.setState(this.state, { render: true, history: true });
} else {
this.setState(this.state, { render: false, history: true });
}
if (app['debug']) {
const id = typeof element === 'string' ? element : element.id;
componentCache[id] = componentCache[id] || [];
componentCache[id].push(this);
}
return this;
}
is_global_event(name: string): boolean {
return name && (
this.global_event ||
this._global_events.indexOf(name) >= 0 ||
name.startsWith('#') || name.startsWith('/') || name.startsWith('@'));
}
add_action(name: string, action, options: ActionOptions = {}) {
if (!action || typeof action !== 'function') return;
if (options.global) this. _global_events.push(name);
this.on(name as any, (...p) => {
const newState = action(this.state, ...p);
app['debug'] && app.run('debug', {
component: this,
'event': name,
e: p,
state: this.state,
newState,
options
})
this.setState(newState, options)
}, options);
}
add_actions() {
const actions = this.update || {};
Reflect.getMetadataKeys(this).forEach(key => {
if (key.startsWith('apprun-update:')) {
const meta = Reflect.getMetadata(key, this)
actions[meta.name] = [this[meta.key].bind(this), meta.options];
}
})
const all = {};
if (Array.isArray(actions)) {
actions.forEach(act => {
const [name, action, opts] = act as ActionDef<T, E>;
const names = name.toString();
names.split(',').forEach(n => all[n.trim()] = [action, opts])
})
} else {
Object.keys(actions).forEach(name => {
const action = actions[name];
if (typeof action === 'function' || Array.isArray(action)) {
name.split(',').forEach(n => all[n.trim()] = action)
}
})
}
if (!all['.']) all['.'] = REFRESH;
Object.keys(all).forEach(name => {
const action = all[name];
if (typeof action === 'function') {
this.add_action(name, action);
} else if (Array.isArray(action)) {
this.add_action(name, action[0], action[1]);
}
});
}
public run(event: E, ...args) {
const name = event.toString();
return this.is_global_event(name) ?
app.run(name, ...args) :
this._app.run(name, ...args);
}
public on(event: E, fn: (...args) => void, options?: any) {
const name = event.toString();
this._actions.push({ name, fn });
return this.is_global_event(name) ?
app.on(name, fn, options) :
this._app.on(name, fn, options);
}
public unmount() {
this.observer?.disconnect();
this._actions.forEach(action => {
const { name, fn } = action;
this.is_global_event(name) ?
app.off(name, fn) :
this._app.off(name, fn);
});
}
}