|
<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> |