DarkWiiPlayer
3f838821e4
Function arguments to skooma generators are now only treated as initialisers if they take at least one named argument. Otherwise they are called and their return value is evaluated as their replacement. The process is recursive and can happen repeatedly for functions that themselves return functions.
224 lines
6.6 KiB
JavaScript
224 lines
6.6 KiB
JavaScript
/*
|
|
A functional HTML generation library.
|
|
|
|
Example:
|
|
html.label(
|
|
html.span("Delete everything", {class: ["warning", "important"]}),
|
|
html.button("Click", {onClick: e => document.body.innerHTML=""}),
|
|
)
|
|
or
|
|
html.ul([1, 2, 3, 4, 5].map(x => html.li(x)), {class: "numbers"})
|
|
*/
|
|
|
|
// Keep a referee alive until a referrer is collected
|
|
const weakReferences = new WeakMap()
|
|
const untilDeathDoThemPart = (referrer, reference) => {
|
|
if (!weakReferences.has(referrer)) {
|
|
weakReferences.set(referrer, new Set())
|
|
}
|
|
weakReferences.get(referrer).add(reference)
|
|
}
|
|
|
|
export const empty = Symbol("Explicit empty argument for Skooma")
|
|
|
|
const keyToPropName = key => key.replace(/^[A-Z]/, a => "-"+a).replace(/[A-Z]/g, a => '-'+a.toLowerCase())
|
|
|
|
const insertStyles = (rule, styles) => {
|
|
for (const [key, value] of Object.entries(styles))
|
|
if (typeof value == "undefined")
|
|
rule.removeProperty(keyToPropName(key))
|
|
else
|
|
rule.setProperty(keyToPropName(key), value.toString())
|
|
}
|
|
|
|
const parseAttribute = (attribute) => {
|
|
if (typeof attribute == "string" || typeof attribute == "number")
|
|
return attribute
|
|
else if (attribute && "join" in attribute)
|
|
return attribute.join(" ")
|
|
else
|
|
return JSON.stringify(attribute)
|
|
}
|
|
|
|
const defined = (value, fallback) => typeof value != "undefined" ? value : fallback
|
|
const getCustom = args => args.reduce(
|
|
(current, argument) => Array.isArray(argument)
|
|
? defined(getCustom(argument), current)
|
|
: (argument && typeof argument == "object")
|
|
? defined(argument.is, current)
|
|
: current
|
|
,undefined
|
|
)
|
|
|
|
const isElement = object => HTMLElement.prototype.isPrototypeOf(object)
|
|
|
|
export const isReactive = object => !(object instanceof HTMLElement)
|
|
&& (object instanceof EventTarget)
|
|
&& ("value" in object)
|
|
|
|
const toChild = arg => {
|
|
if (typeof arg == "string" || typeof arg == "number") {
|
|
return document.createTextNode(arg)
|
|
} else if (isElement(arg)) {
|
|
return arg
|
|
} else if (isReactive(arg)) {
|
|
return reactiveChild(arg)
|
|
} else {
|
|
return document.createComment("Placeholder for reactive content")
|
|
}
|
|
}
|
|
|
|
const reactiveChild = reactive => {
|
|
const ref = new WeakRef(toChild(reactive.value))
|
|
reactive.addEventListener("change", () => {
|
|
const value = ref.deref()
|
|
if (value)
|
|
value.replaceWith(reactiveChild(reactive))
|
|
}, {once: true})
|
|
untilDeathDoThemPart(ref.deref(), reactive)
|
|
return ref.deref()
|
|
}
|
|
|
|
const specialAttributes = {
|
|
value: {
|
|
get: element => element.value,
|
|
set: (element, value) => {
|
|
element.setAttribute("value", value)
|
|
element.value = value
|
|
},
|
|
hook: (element, callback) => { element.addEventListener("input", callback) }
|
|
},
|
|
style: {
|
|
set: (element, value) => { insertStyles(element.style, value) }
|
|
},
|
|
dataset: {
|
|
set: (element, value) => {
|
|
for (const [attribute2, value2] of Object.entries(value)) {
|
|
element.dataset[attribute2] = parseAttribute(value2)
|
|
}
|
|
}
|
|
},
|
|
shadowRoot: {
|
|
set: (element, value) => {
|
|
parseArgs((element.shadowRoot || element.attachShadow({mode: "open"})), value)
|
|
}
|
|
}
|
|
}
|
|
|
|
const setAttribute = (element, attribute, value, cleanupSignal) => {
|
|
const special = specialAttributes[attribute]
|
|
if (isReactive(value))
|
|
setReactiveAttribute(element, attribute, value)
|
|
else if (typeof value === "function")
|
|
element.addEventListener(attribute.replace(/^on[A-Z]/, x => x.charAt(x.length-1).toLowerCase()), value, {signal: cleanupSignal})
|
|
else if (special) {
|
|
special.set(element, value)
|
|
}
|
|
else if (value === true)
|
|
{if (!element.hasAttribute(attribute)) element.setAttribute(attribute, '')}
|
|
else if (value === false)
|
|
element.removeAttribute(attribute)
|
|
else {
|
|
element.setAttribute(attribute, parseAttribute(value))
|
|
}
|
|
}
|
|
|
|
const setReactiveAttribute = (element, attribute, reactive, abortController) => {
|
|
untilDeathDoThemPart(element, reactive)
|
|
|
|
if (abortController) abortController.abort()
|
|
abortController = new AbortController()
|
|
|
|
const ref = new WeakRef(element)
|
|
setAttribute(element, attribute, reactive.value, abortController.signal)
|
|
|
|
reactive.addEventListener("change", () => {
|
|
const element = ref.deref()
|
|
if (element)
|
|
setReactiveAttribute(element, attribute, reactive, abortController)
|
|
}, {once: true})
|
|
|
|
const special = specialAttributes[attribute]
|
|
if (special?.hook) {
|
|
special.hook(element, () => {
|
|
const value = special.get(element, attribute)
|
|
if (value != reactive.value) reactive.value = value
|
|
})
|
|
}
|
|
}
|
|
|
|
const parseArgs = (element, ...args) => {
|
|
if (element.content) element = element.content
|
|
for (const arg of args) if (arg !== empty) {
|
|
if (arg instanceof Array) {
|
|
parseArgs(element, ...arg)
|
|
} else {
|
|
const child = toChild(arg)
|
|
if (child)
|
|
element.append(child)
|
|
else if (arg === undefined || arg == null)
|
|
console.warn(`An argument of type ${typeof arg} has been ignored`, element)
|
|
else if (typeof arg == "function" && arg.length == 0)
|
|
parseArgs(element, arg())
|
|
else if (typeof arg == "function")
|
|
arg(element)
|
|
else
|
|
for (const key in arg)
|
|
setAttribute(element, key, arg[key])
|
|
}
|
|
}
|
|
}
|
|
|
|
const node = (name, args, options) => {
|
|
let element
|
|
const custom = getCustom(args)
|
|
const opts = custom && {is: String(custom)}
|
|
|
|
if ("nameFilter" in options) name = options.nameFilter(name)
|
|
if (options.xmlns)
|
|
element = document.createElementNS(options.xmlns, name, opts)
|
|
else
|
|
element = document.createElement(name, opts)
|
|
parseArgs(element, args)
|
|
return element
|
|
}
|
|
|
|
const nameSpacedProxy = (options={}) => new Proxy(Window, {
|
|
get: (_target, prop, _receiver) => { return (...args) => node(prop, args, options) },
|
|
has: (_target, _prop) => true,
|
|
})
|
|
|
|
export const html = nameSpacedProxy({nameFilter: name => name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()})
|
|
export const svg = nameSpacedProxy({xmlns: "http://www.w3.org/2000/svg"})
|
|
export default html
|
|
|
|
// Other utility exports
|
|
|
|
// Wraps an event handler in a function that calls preventDefault on the event
|
|
export const handle = fn => event => { event.preventDefault(); return fn(event) }
|
|
|
|
// Wraps a list of elements in a document fragment
|
|
export const fragment = (...elements) => {
|
|
const fragment = new DocumentFragment()
|
|
for (const element of elements)
|
|
fragment.append(element)
|
|
return fragment
|
|
}
|
|
|
|
// Turns a template literal into document fragment.
|
|
// Strings are returned as text nodes.
|
|
// Elements are inserted in between.
|
|
const textFromTemplate = (literals, items) => {
|
|
const fragment = new DocumentFragment()
|
|
for (const key in items) {
|
|
fragment.append(document.createTextNode(literals[key]))
|
|
fragment.append(items[key])
|
|
}
|
|
fragment.append(document.createTextNode(literals.at(-1)))
|
|
return fragment
|
|
}
|
|
|
|
export const text = (data="", ...items) =>
|
|
typeof data == "object" && "at" in data
|
|
? textFromTemplate(data, items)
|
|
: document.createTextNode(data)
|