Skip to content

Instantly share code, notes, and snippets.

@shadowmoose
Last active February 14, 2024 04:10
Show Gist options
  • Save shadowmoose/d52e83476eddfc8009f2c111617acd23 to your computer and use it in GitHub Desktop.
Save shadowmoose/d52e83476eddfc8009f2c111617acd23 to your computer and use it in GitHub Desktop.
Simple, bad, pure-js HTML tree wrapper

This is my rapid prototyping JS "library" for building basic UI.

It is absolutely not viable for a production product. This is - at best - a minimalist tool for exposing UI in contexts where libraries or line count matter.

Personally, I use this in situations where content must be injected from outside into the DOM, as the single entrypoint function makes this entire library trivial to encode. This situation is most commonly encountered in Browser plugins or Userscripts.

There are zero external dependencies, and the entire library is under 100 LoC.

/**
* @param {Object} initialState The initial state of the render tree.
* @param {Object} opts Configuration options.
* @param {string} opts.rootEleType Element type to use for the base element of the tree. Default `div`.
* @param {Function} opts.renderCb A callback, triggered before every rerender. Useful for side effects, debugging, or local state.
* @param {string} opts.cssText CSS text to be injected into the head of the document.
*/
function makeRenderTree(initialState={}, opts = {}) {
const options = { rootEleType: 'div', renderCb: null, cssText: '', ...opts };
let rootCb=null, rootEle=null, focusEle=null, selectedId=null, focusPos=0, cssEle=null, immediate=null;
const state = new Proxy(initialState, {
get: (target, prop) => {
return target[prop];
},
set: (target, prop, value) => {
if (target[prop] !== value) {
target[prop] = value;
rootCb?.();
}
return true;
},
deleteProperty: (target, prop) => {
delete target[prop];
rootCb?.();
return true;
}
});
function ele(tag, props, ...children) {
const e = document.createElement(tag);
if (!props) props = {};
for (const prop in (props || {})) {
const val = props[prop];
if (prop.startsWith('on_')) {
e.addEventListener(prop.slice(3), val);
} else if (prop === 'update') {
e.addEventListener('input', evt => e['value'] = val(evt.target?.type === 'checkbox' ? evt.target.checked : evt.target.value));
} else if (prop === '_focus' && props._focusId === selectedId) {
focusEle = e; focusPos = val;
} else {
e[prop] = typeof val === 'function' ? val() : val;
}
}
const handleFocus = () => {
props._focus = e.type === 'text' ? (e.selectionStart || 0) : (e.value?.length || 0);
selectedId = props._focusId = Date.now();
};
e.addEventListener('focus', handleFocus);
e.addEventListener('input', handleFocus);
e.addEventListener('blur', () => {delete props._focus; delete props._focusId; });
const renderChildren = children.flatMap(c => c.render?.().node || (typeof c === 'function' ? c() : c))
e.append(...renderChildren);
return {
node: e,
render: () => ele(tag, props, ...children),
}
}
function root(props, ...children) {
if (options.cssText && !cssEle) {
cssEle = document.createElement('style');
cssEle.textContent = options.cssText;
(document.head || document.body || document).append(cssEle);
}
rootEle = ele(options.rootEleType, props, ...children);
rootCb = () => {
clearTimeout(immediate);
immediate = setTimeout(() => {
options.renderCb?.();
let newRoot = rootEle.render();
rootEle.node?.replaceWith(newRoot.node);
rootEle = newRoot;
focusEle?.focus();
if (focusEle && focusPos !== undefined && focusEle?.setSelectionRange) {
const ot = focusEle.type;
focusEle.type = 'text';
focusEle?.setSelectionRange(focusPos, focusPos);
focusEle.type = ot;
}
}, 0);
};
return rootEle;
}
return {
ele, root, state,
unmount: () => {rootEle.node?.remove(); cssEle?.remove(); rootEle = null; rootCb = null;}
}
}
<html lang="">
<head><title>JS DOM Element Tree</title></head>
<body></body>
<script>
/**
* @param {Object} initialState The initial state of the render tree.
* @param {Object} opts Configuration options.
* @param {string} opts.rootEleType Element type to use for the base element of the tree. Default `div`.
* @param {Function} opts.renderCb A callback, triggered before every rerender. Useful for side effects, debugging, or local state.
* @param {string} opts.cssText CSS text to be injected into the head of the document.
*/
function makeRenderTree(initialState={}, opts = {}) {
const options = { rootEleType: 'div', renderCb: null, cssText: '', ...opts };
let rootCb=null, rootEle=null, focusEle=null, selectedId=null, focusPos=0, cssEle=null, immediate=null;
const state = new Proxy(initialState, {
get: (target, prop) => {
return target[prop];
},
set: (target, prop, value) => {
if (target[prop] !== value) {
target[prop] = value;
rootCb?.();
}
return true;
},
deleteProperty: (target, prop) => {
delete target[prop];
rootCb?.();
return true;
}
});
function ele(tag, props, ...children) {
const e = document.createElement(tag);
if (!props) props = {};
for (const prop in (props || {})) {
const val = props[prop];
if (prop.startsWith('on_')) {
e.addEventListener(prop.slice(3), val);
} else if (prop === 'update') {
e.addEventListener('input', evt => e['value'] = val(evt.target?.type === 'checkbox' ? evt.target.checked : evt.target.value));
} else if (prop === '_focus' && props._focusId === selectedId) {
focusEle = e; focusPos = val;
} else {
e[prop] = typeof val === 'function' ? val() : val;
}
}
const handleFocus = () => {
props._focus = e.type === 'text' ? (e.selectionStart || 0) : (e.value?.length || 0);
selectedId = props._focusId = Date.now();
};
e.addEventListener('focus', handleFocus);
e.addEventListener('input', handleFocus);
e.addEventListener('blur', () => {delete props._focus; delete props._focusId; });
const renderChildren = children.flatMap(c => c.render?.().node || (typeof c === 'function' ? c() : c))
e.append(...renderChildren);
return {
node: e,
render: () => ele(tag, props, ...children),
}
}
function root(props, ...children) {
if (options.cssText && !cssEle) {
cssEle = document.createElement('style');
cssEle.textContent = options.cssText;
(document.head || document.body || document).append(cssEle);
}
rootEle = ele(options.rootEleType, props, ...children);
rootCb = () => {
clearTimeout(immediate);
immediate = setTimeout(() => {
options.renderCb?.();
let newRoot = rootEle.render();
rootEle.node?.replaceWith(newRoot.node);
rootEle = newRoot;
focusEle?.focus();
if (focusEle && focusPos !== undefined && focusEle?.setSelectionRange) {
const ot = focusEle.type;
focusEle.type = 'text';
focusEle?.setSelectionRange(focusPos, focusPos);
focusEle.type = ot;
}
}, 0);
};
return rootEle;
}
return {
ele, root, state,
unmount: () => {rootEle.node?.remove(); cssEle?.remove(); rootEle = null; rootCb = null;}
}
}
const {ele, root, state, unmount} = makeRenderTree({ count: 1, textVal: 'Pen pineapple', boolVal: false }, {
cssText: `body {background-color: #f0f0f0; font-family: sans-serif;}`,
});
const labeled = (label, inputProps={}, labelProps={}) => {
if (!inputProps?.id) inputProps.id = btoa(label)+'_'+Math.random();
return ele('div', null,
ele('label', { id: inputProps.id+'-lbl', htmlFor: inputProps.id, ...labelProps }, label),
ele('input', { id: inputProps.id, ...inputProps }),
)
}
document.body.append(root(null,
ele('h1', {style: 'color: red'}, 'Hello World'),
ele('button', {on_click: () => state.count++}, 'Increment'),
ele('button', {on_click: () => state.count--}, 'Decrement'),
ele('pre', null, () => `Count: ${state.count} | TextVal: ${state.textVal} | Bool: ${state.boolVal}`),
labeled('Input a number:', { type: 'number', value: () => state.count, on_input: evt => state.count = evt.target.value }),
labeled('Input a string:', { type: 'text', value: () => state.textVal, update: val => state.textVal = val }),
labeled('Check the Box:', { type: 'checkbox', checked: () => !!state.boolVal, update: val => state.boolVal = val }),
).node);
</script>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment