bd-core
Version:
a JavaScript library for building browser-hosted user interfaces
941 lines (836 loc) • 31.8 kB
JavaScript
import {adviseGlobal} from './global.js';
import {getAttr, setAttr, getStyle, getStyles, setStyle, setPosit, getPosit, create, insert, connect} from './dom.js';
import {getPostProcessingFunction} from './postProcessingCatalog.js';
import {Element} from './element.js';
import {eventHub} from './eventHub.js';
import {WatchHub, withWatchables} from './watchUtils.js';
import {Destroyable} from './Destroyable.js';
let document = 0;
adviseGlobal(window => {
document = window.document;
});
function cleanClassName(s) {
return s.replace(/\s{2,}/g, ' ').trim();
}
function conditionClassNameArgs(args) {
return args.reduce((acc, item) => {
if (item instanceof RegExp) {
acc.push(item);
} else if (item) {
acc = acc.concat(cleanClassName(item).split(' '));
}
return acc;
}, []);
}
function classValueToRegExp(v, args) {
return v instanceof RegExp ? v : RegExp(` ${v} `, args);
}
function calcDomClassName(component) {
const staticClassName = component.staticClassName;
const className = component.bdClassName;
return staticClassName && className ? (`${staticClassName} ${className}`) : (staticClassName || className);
}
function addChildToDomNode(parent, domNode, child, childIsComponent) {
if (childIsComponent) {
const childDomRoot = child.bdDom.root;
if (Array.isArray(childDomRoot)) {
childDomRoot.forEach(node => insert(node, domNode));
} else {
insert(childDomRoot, domNode);
}
parent.bdAdopt(child);
} else {
insert(child, domNode);
}
}
function validateElements(elements) {
if (Array.isArray(elements)) {
elements.forEach(validateElements);
} else if (elements.isComponentType) {
throw new Error('Illegal: root element(s) for a Component cannot be Components');
}
}
function postProcess(ppFuncs, owner, target) {
Reflect.ownKeys(ppFuncs).forEach(ppf => {
const args = ppFuncs[ppf];
if (Array.isArray(args)) {
getPostProcessingFunction(ppf)(owner, target, ...args);
} else {
getPostProcessingFunction(ppf)(owner, target, args);
}
});
}
function noop() {
// do nothing
}
function pushHandles(dest, ...handles) {
handles.forEach(h => {
if (Array.isArray(h)) {
pushHandles(dest, ...h);
} else if (h) {
const destroy = h.destroy.bind(h);
h.destroy = () => {
destroy();
const index = dest.indexOf(h);
if (index !== -1) {
dest.splice(index, 1);
}
h.destroy = noop;
};
dest.push(h);
}
});
}
const ownedHandlesCatalog = new WeakMap();
const domNodeToComponent = new Map();
export class Component extends eventHub(WatchHub) {
constructor(kwargs = {}) {
// notice that this class requires only the per-instance data actually used by its subclass/instance
super();
if (!this.constructor.noKwargs) {
this.kwargs = kwargs;
}
// id, if provided, is read-only
if (kwargs.id) {
Object.defineProperty(this, 'id', { value: `${kwargs.id}`, enumerable: true });
}
if (kwargs.className) {
Array.isArray(kwargs.className) ? this.addClassName(...kwargs.className) : this.addClassName(kwargs.className);
}
if (kwargs.tabIndex !== undefined) {
this.tabIndex = kwargs.tabIndex;
}
if (kwargs.title) {
this.title = kwargs.title;
}
if (kwargs.disabled || (kwargs.enabled !== undefined && !kwargs.enabled)) {
this.disabled = true;
}
if (kwargs.visible !== undefined) {
this.visible = kwargs.visible;
}
if (kwargs.elements) {
if (typeof kwargs.elements === 'function') {
this.bdElements = kwargs.elements;
} else {
this.bdElements = () => kwargs.elements;
}
}
if (kwargs.postRender) {
this.postRender = kwargs.postRender;
}
if (kwargs.mix) {
Reflect.ownKeys(kwargs.mix).forEach(p => (this[p] = kwargs.mix[p]));
}
if (kwargs.callbacks) {
const events = this.constructor.events;
Reflect.ownKeys(kwargs.callbacks).forEach(key => {
if (events.indexOf(key) !== -1) {
this.advise(key, kwargs.callbacks[key]);
} else {
this.watch(key, kwargs.callbacks[key]);
}
});
}
}
destroy() {
if (!this.destroyed) {
this.destroyed = 'in-prog';
this.unrender();
const handles = ownedHandlesCatalog.get(this);
if (handles) {
Destroyable.destroyAll(handles);
ownedHandlesCatalog.delete(this);
}
this.destroyWatch();
this.destroyAdvise();
delete this.kwargs;
this.destroyed = true;
}
}
render(
proc // [function, optional] called after this class's render work is done, called in context of this
) {
if (!this.bdDom) {
const dom = this.bdDom = this._dom = {};
const elements = this.bdElements();
validateElements(elements);
const root = dom.root = this.constructor.renderElements(this, elements);
if (Array.isArray(root)) {
root.forEach(node => domNodeToComponent.set(node, this));
} else {
domNodeToComponent.set(root, this);
if (this.id) {
root.id = this.id;
}
this.addClassName(root.getAttribute('class') || '');
const className = calcDomClassName(this);
if (className) {
root.setAttribute('class', className);
}
if (this.bdDom.tabIndexNode) {
if (this.bdTabIndex === undefined) {
this.bdTabIndex = this.bdDom.tabIndexNode.tabIndex;
} else {
this.bdDom.tabIndexNode.tabIndex = this.bdTabIndex;
}
} else if (this.bdTabIndex !== undefined) {
(this.bdDom.tabIndexNode || this.bdDom.root).tabIndex = this.bdTabIndex;
}
if (this.bdTitle !== undefined) {
(this.bdDom.titleNode || this.bdDom.root).title = this.bdTitle;
}
this[this.bdDisabled ? 'addClassName' : 'removeClassName']('bd-disabled');
if (!this.visible) {
this._hiddenDisplayStyle = root.style.display;
root.style.display = 'none';
}
}
this.ownWhileRendered(this.postRender());
proc && proc.call(this);
this.bdMutateNotify('rendered', true, false);
}
return this.bdDom.root;
}
postRender() {
// no-op
}
bdElements() {
return new Element('div', {});
}
unrender() {
if (this.rendered) {
if (this.bdParent) {
this.bdParent.delChild(this, true);
}
if (this.children) {
this.children.slice().forEach(child => {
child.destroy();
});
}
delete this.children;
const root = this.bdDom.root;
if (Array.isArray(root)) {
root.forEach(node => {
domNodeToComponent.delete(node);
node.parentNode && node.parentNode.removeChild(node);
});
} else {
domNodeToComponent.delete(root);
root.parentNode && root.parentNode.removeChild(root);
}
Destroyable.destroyAll(this.bdDom.handles);
delete this.bdDom;
delete this._dom;
delete this._hiddenDisplayStyle;
this.bdAttachToDoc(false);
this.bdMutateNotify('rendered', false, true);
}
}
get rendered() {
return !!(this.bdDom && this.bdDom.root);
}
own(...handles) {
let _handles = ownedHandlesCatalog.get(this);
if (!_handles) {
ownedHandlesCatalog.set(this, (_handles = []));
}
pushHandles(_handles, ...handles);
}
ownWhileRendered(...handles) {
pushHandles(this.bdDom.handles || (this.bdDom.handles = []), ...handles);
}
get parent() {
return this.bdParent;
}
bdAdopt(child) {
if (child.bdParent) {
throw new Error('unexpected');
}
(this.children || (this.children = [])).push(child);
child.bdMutate('parent', 'bdParent', this);
child.bdAttachToDoc(this.bdAttachedToDoc);
}
bdAttachToDoc(value) {
if (this.bdMutate('attachedToDoc', 'bdAttachedToDoc', !!value)) {
if (value && this.resize) {
this.resize();
}
this.children && this.children.forEach(child => child.bdAttachToDoc(value));
return true;
} else {
return false;
}
}
get attachedToDoc() {
return !!this.bdAttachedToDoc;
}
insChild(...args) {
if (!this.rendered) {
throw new Error('parent component must be rendered before explicitly inserting a child');
}
let { src, attachPoint, position } = decodeRender(args);
let child;
if (src instanceof Component) {
child = src;
if (child.parent) {
child.parent.delChild(child, true);
}
child.render();
} else { // child instanceof Element
if (!src.isComponentType) {
src = new Element(Component, { elements: src });
}
child = this.constructor.renderElements(this, src);
}
if (/before|after|replace|only|first|last/.test(attachPoint) || typeof attachPoint === 'number') {
position = attachPoint;
attachPoint = 0;
}
if (attachPoint) {
if (attachPoint in this) {
// node reference
attachPoint = this[attachPoint];
} else if (typeof attachPoint === 'string') {
attachPoint = document.getElementById(attachPoint);
if (!attachPoint) {
throw new Error('unexpected');
}
} else if (position !== undefined) {
// attachPoint must be a child Component
const index = this.children ? this.children.indexOf(attachPoint) : -1;
if (index !== -1) {
// attachPoint is a child
attachPoint = attachPoint.bdDom.root;
if (Array.isArray(attachPoint)) {
switch (position) {
case 'replace':
case 'only':
case 'before':
attachPoint = attachPoint[0];
break;
case 'after':
attachPoint = attachPoint[attachPoint.length - 1];
break;
default:
throw new Error('unexpected');
}
}
} else {
throw new Error('unexpected');
}
} else {
// attachPoint without a position must give a node reference
throw new Error('unexpected');
}
} else if (child.bdParentAttachPoint) {
// child is telling the parent where it wants to go; this is more specific than pChildrenAttachPoint
if (child.bdParentAttachPoint in this) {
attachPoint = this[child.bdParentAttachPoint];
} else {
throw new Error('unexpected');
}
} else {
attachPoint = this.bdChildrenAttachPoint || this.bdDom.root;
if (Array.isArray(attachPoint)) {
throw new Error('unexpected');
}
}
const childRoot = child.bdDom.root;
if (Array.isArray(childRoot)) {
const firstChildNode = childRoot[0];
unrender(insert(firstChildNode, attachPoint, position));
childRoot.slice(1).reduce((prevNode, node) => {
insert(node, prevNode, 'after');
return node;
}, firstChildNode);
} else {
unrender(insert(childRoot, attachPoint, position));
}
this.bdAdopt(child);
return child;
}
delChild(child, preserve) {
const index = this.children ? this.children.indexOf(child) : -1;
if (index !== -1) {
const root = child.bdDom && child.bdDom.root;
const removeNode = node => {
node.parentNode && node.parentNode.removeChild(node);
};
Array.isArray(root) ? root.forEach(removeNode) : removeNode(root);
child.bdMutate('parent', 'bdParent', null);
child.bdAttachToDoc(false);
this.children.splice(index, 1);
if (!preserve) {
child.destroy();
child = false;
} else if (preserve === 'unrender') {
child.unrender();
}
return child;
}
return false;
}
delChildren(preserve) {
return this.children.slice().map(child => this.delChild(child, preserve));
}
reorderChildren(children) {
if (children === this.children) {
children = this.children.slice();
}
const thisChildren = this.children;
if (thisChildren && thisChildren.length) {
const node = children.bdDom.root.parentNode;
children.forEach((child, i) => {
if (thisChildren[i] !== child) {
const index = thisChildren.indexOf(child, i + 1);
thisChildren.splice(index, 1);
node.insertBefore(child.bdDom.root, thisChildren[i].bdDom.root);
thisChildren.splice(i, 0, child);
}
});
}
}
get staticClassName() {
return this.kwargs.hasOwnProperty('staticClassName') ?
this.kwargs.staticClassName : (this.constructor.className || '');
}
get className() {
// WARNING: if a staticClassName was given as a constructor argument, then that part of node.className is NOT returned
if (this.rendered) {
// if rendered, then look at what's actually in the document...maybe client code _improperly_ manipulated directly
let root = this.bdDom.root;
if (Array.isArray(root)) {
root = root[0];
}
let className = root.className;
const staticClassName = this.staticClassName;
if (staticClassName) {
staticClassName.split(' ').forEach(s => (className = className.replace(s, '')));
}
return cleanClassName(className);
} else {
return this.bdClassName || '';
}
}
set className(value) {
// WARNING: if a staticClassName was given as a constructor argument, then that part of node.className is NOT affected
// clean up any space sloppiness, sometimes caused by client-code algorithms that manipulate className
value = cleanClassName(value);
if (!this.bdClassName) {
this.bdSetClassName(value, '');
} else if (!value) {
this.bdSetClassName('', this.bdClassName);
} else if (value !== this.bdClassName) {
this.bdSetClassName(value, this.bdClassName);
}
}
containsClassName(value) {
// WARNING: if a staticClassName was given as a constructor argument, then that part of node.className is NOT considered
value = cleanClassName(value);
return (` ${this.bdClassName || ''} `).indexOf(value) !== -1;
}
addClassName(...values) {
const current = this.bdClassName || '';
this.bdSetClassName(conditionClassNameArgs(values).reduce((className, value) => {
return classValueToRegExp(value).test(className) ? className : `${className + value} `;
}, ` ${current} `).trim(), current);
return this;
}
removeClassName(...values) {
// WARNING: if a staticClassName was given as a constructor argument, then that part of node.className is NOT considered
const current = this.bdClassName || '';
this.bdSetClassName(conditionClassNameArgs(values).reduce((className, value) => {
return className.replace(classValueToRegExp(value, 'g'), ' ');
}, ` ${current} `).trim(), current);
return this;
}
toggleClassName(...values) {
// WARNING: if a staticClassName was given as a constructor argument, then that part of node.className is NOT considered
const current = this.bdClassName || '';
this.bdSetClassName(conditionClassNameArgs(values).reduce((className, value) => {
if (classValueToRegExp(value).test(className)) {
return className.replace(classValueToRegExp(value, 'g'), ' ');
} else {
return `${className + value} `;
}
}, ` ${current} `).trim(), current);
return this;
}
get classList() {
if (!this._classList) {
const self = this;
this._classList = {
get() {
return self.className;
},
set(value) {
return (self.className = value);
},
add(...values) {
return self.addClassName(...values);
},
ins(...values) {
return self.addClassName(...values);
},
remove(...values) {
return self.removeClassName(...values);
},
del(...values) {
return self.removeClassName(...values);
},
toggle(...values) {
return self.toggleClassName(...values);
},
contains(...values) {
return self.containsClassName(...values);
},
has(...values) {
return self.containsClassName(...values);
}
};
}
return this._classList;
}
bdSetClassName(newValue, oldValue) {
if (newValue !== oldValue) {
this.bdClassName = newValue;
if (this.rendered) {
this.bdDom.root.setAttribute('class', calcDomClassName(this));
}
if (this.rendered && !Array.isArray(this.bdDom.root)) {
this.bdDom.root.setAttribute('class', calcDomClassName(this));
}
this.bdMutateNotify('className', newValue, oldValue);
const oldVisibleValue = oldValue ? oldValue.indexOf('bd-hidden') === -1 : true,
newVisibleValue = newValue ? newValue.indexOf('bd-hidden') === -1 : true;
if (oldVisibleValue !== newVisibleValue) {
this.bdMutateNotify('visible', newVisibleValue, oldVisibleValue);
}
}
}
bdOnFocus() {
this.addClassName('bd-focused');
this.bdMutate('hasFocus', 'bdHasFocus', true);
}
bdOnBlur() {
this.removeClassName('bd-focused');
this.bdMutate('hasFocus', 'bdHasFocus', false);
}
get hasFocus() {
return !!this.bdHasFocus;
}
focus() {
if (this.bdDom) {
(this.bdDom.tabIndexNode || this.bdDom.root).focus();
}
}
setItem(...args) {
let data = this.bdData || {};
let i = 0;
const end = args.length - 2;
while (i < end) {
data = data[args[i]] || (data[args[i]] = {});
i++;
}
data[args[i]] = args[i + 1];
}
getItem(...args) {
let data = this.bdData;
for (let i = 0, end = args.length; data !== undefined && i < end;) {
data = data[args[i++]];
}
return data;
}
removeItem(...args) {
let data = this.bdData;
const i = 0;
for (const end = args.length - 1; data !== undefined && i < end;) {
data = data[args[i]++];
}
if (data) {
const result = data[args[i]];
delete data[args[i]];
return result;
} else {
return undefined;
}
}
getAttr(name) {
return getAttr(this.bdDom.root, name);
}
setAttr(name, value) {
return setAttr(this.bdDom.root, name, value);
}
getStyle(property) {
return getStyle(this.bdDom.root, property);
}
getStyles(...styleNames) {
return getStyles(this.bdDom.root, styleNames);
}
setStyle(property, value) {
return setStyle(this.bdDom.root, property, value);
}
animate(className, onComplete) {
if (this.rendered) {
const h = connect(this.bdDom.root, 'animationend', e => {
if (e.animationName === className) {
h.destroy();
!this.destroyed && this.removeClassName(className);
if (onComplete) {
onComplete.destroy ? onComplete.destroy() : onComplete();
}
}
});
if (!this.containsClassName(className)) {
this.addClassName(className);
}
}
}
getPosit() {
return getPosit(this.bdDom.root);
}
setPosit(posit) {
setPosit(this.bdDom.root, posit);
}
get uid() {
return this.bdUid || (this.bdUid = Symbol('component-instance-uid'));
}
get tabIndex() {
if (this.rendered) {
// unconditionally make sure this.bdTabIndex and the dom is synchronized on each get
return (this.bdTabIndex = (this.bdDom.tabIndexNode || this.bdDom.root).tabIndex);
} else {
return this.bdTabIndex;
}
}
set tabIndex(value) {
if (!value && value !== 0) {
value = '';
}
if (value !== this.bdTabIndex) {
this.rendered && ((this.bdDom.tabIndexNode || this.bdDom.root).tabIndex = value);
this.bdMutate('tabIndex', 'bdTabIndex', value);
}
}
get enabled() {
return !this.bdDisabled;
}
set enabled(value) {
this.disabled = !value;
}
get disabled() {
return !!this.bdDisabled;
}
set disabled(value) {
value = !!value;
if (this.bdDisabled !== value) {
this.bdDisabled = value;
this.bdMutateNotify([['disabled', value, !value], ['enabled', !value, value]]);
this[value ? 'addClassName' : 'removeClassName']('bd-disabled');
}
}
get visible() {
return !this.containsClassName('bd-hidden');
}
set visible(value) {
value = !!value;
if (value !== !this.containsClassName('bd-hidden')) {
if (value) {
this.removeClassName('bd-hidden');
const node = this.bdDom && this.bdDom.root;
if (this._hiddenDisplayStyle !== undefined) {
node && (node.style.display = this._hiddenDisplayStyle);
delete this._hiddenDisplayStyle;
}
this.resize && this.resize();
} else {
this.addClassName('bd-hidden');
const node = this.bdDom && this.bdDom.root;
if (node) {
this._hiddenDisplayStyle = node.style.display;
node.style.display = 'none';
}
}
this.bdMutateNotify('visible', value, !value);
}
}
get title() {
if (this.rendered) {
return (this.bdDom.titleNode || this.bdDom.root).title;
} else {
return this.bdTitle;
}
}
set title(value) {
if (this.bdMutate('title', 'bdTitle', value)) {
this.rendered && ((this.bdDom.titleNode || this.bdDom.root).title = value);
}
}
static get(domNode) {
return domNodeToComponent.get(domNode);
}
static renderElements(owner, e) {
if (Array.isArray(e)) {
// eslint-disable-next-line no-shadow
return e.map(e => Component.renderElements(owner, e));
} else if (e instanceof Element) {
const { type, ctorProps, ppFuncs, children } = e;
let result;
if (e.isComponentType) {
// eslint-disable-next-line new-cap
const componentInstance = result = new type(ctorProps);
componentInstance.render();
ppFuncs && postProcess(ppFuncs, owner, componentInstance);
if (children) {
const renderedChildren = Component.renderElements(owner, children);
if (Array.isArray(renderedChildren)) {
renderedChildren.forEach(child => result.insChild(child));
} else {
result.insChild(renderedChildren);
}
}
} else {
const domNode = result = create(type, ctorProps);
if ('tabIndex' in ctorProps && ctorProps.tabIndex !== false) {
owner.bdDom.tabIndexNode = domNode;
}
ppFuncs && postProcess(ppFuncs, owner, domNode);
if (children) {
const renderedChildren = Component.renderElements(owner, children);
if (Array.isArray(renderedChildren)) {
renderedChildren.forEach(
(child, i) => addChildToDomNode(owner, domNode, child, children[i].isComponentType)
);
} else {
addChildToDomNode(owner, domNode, renderedChildren, children.isComponentType);
}
}
}
return result;
} else {
// e must be convertible to a string
return document.createTextNode(e);
}
}
}
Component.watchables = ['rendered', 'parent', 'attachedToDoc', 'className', 'hasFocus', 'tabIndex', 'enabled', 'visible', 'title'];
Component.events = [];
Component.withWatchables = (...args) => withWatchables(Component, ...args);
function isComponentDerivedCtor(f) {
return f === Component || (f && isComponentDerivedCtor(Object.getPrototypeOf(f)));
}
const prototypeOfObject = Object.getPrototypeOf({});
function decodeRender(args) {
// eight signatures...
// Signatures 1-2 render an element, 3-6 render a Component, 7-8 render an instance of a Component
//
// Each of the above groups may or may not have the args node:domNode[, position:Position="last"]
// which indicate where to attach the rendered Component instance (or not).
//
// when this decode routine is used by Component::insertChild, then node can be a string | symbol, indicating
// an instance property that holds the node
//
// 1. render(e:Element)
// => isComponentDerivedCtor(e.type), then render e.type(e.props); otherwise, render Component({elements:e})
//
// 2. render(e:Element, node:domNode[, position:Position="last"])
// => [1] with attach information
//
// 3. render(C:Component)
// => render(C, {})
//
// 4. render(C:Component, args:kwargs)
// => render(C, args)
// // note: args is kwargs for C's constructor; therefore, postprocessing instructions are meaningless unless C's
// // construction defines some usage for them (atypical)
//
// 5. render(C:Component, node:domNode[, position:Position="last"])
// => [3] with attach information
//
// 6. render(C:Component, args:kwargs, node:domNode[, position:Position="last"])
// => [4] with attach information
//
// 7. render(c:instanceof Component)
// => c.render()
//
// 8. render(c:instanceof Component, node:domNode[, position:Position="last"])
// => [7] with attach information
//
// Position one of "first", "last", "before", "after", "replace", "only"; see dom::insert
//
// returns {
// src: instanceof Component | Element
// attachPoint: node | string | undefined
// position: string | undefined
// }
//
// for signatures 3-6, an Element is manufactured given the arguments
const [arg1, arg2, arg3, arg4] = args;
if (arg1 instanceof Element || arg1 instanceof Component) {
// [1] or [2] || [7] or [8]
return { src: arg1, attachPoint: arg2, position: arg3 };
} else {
if (!isComponentDerivedCtor(arg1)) {
throw new Error('first argument must be an Element, Component, or a class derived from Component');
}
if (args.length === 1) {
// [3]
return { src: new Element(arg1) };
} else {
// more than one argument; the second argument is either props or not
// eslint-disable-next-line no-lonely-if
if (Object.getPrototypeOf(arg2) === prototypeOfObject) {
// [4] or [6]
// WARNING: this signature requires kwargs to be a plain Javascript Object (which is should be!)
return { src: new Element(arg1, arg2), attachPoint: arg3, position: arg4 };
} else {
// [5]
return { src: new Element(arg1), attachPoint: arg2, position: arg3 };
}
}
}
}
function unrender(node) {
function unrender_(n) {
const component = domNodeToComponent.get(n);
if (component) {
component.destroy();
}
}
Array.isArray(node) ? node.forEach(unrender_) : (node && unrender_(node));
}
export function render(...args) {
let result;
let { src, attachPoint, position } = decodeRender(args);
if (src instanceof Element) {
if (src.isComponentType) {
// eslint-disable-next-line new-cap
result = new src.type(src.ctorProps);
} else {
result = new Component({ elements: src });
}
result.render();
} else { // src instanceof Component
result = src;
result.render();
}
if (typeof attachPoint === 'string') {
attachPoint = document.getElementById(attachPoint);
}
if (attachPoint) {
const root = result.bdDom.root;
if (Array.isArray(root)) {
const firstChildNode = root[0];
unrender(insert(firstChildNode, attachPoint, position));
root.slice(1).reduce((prevNode, node) => {
insert(node, prevNode, 'after');
return node;
}, firstChildNode);
} else {
unrender(insert(root, attachPoint, position));
}
result.bdAttachToDoc(document.body.contains(attachPoint));
}
return result;
}