@zeix/ui-element
Version:
UIElement - minimal reactive framework based on Web Components
828 lines (816 loc) • 23.9 kB
JavaScript
// node_modules/@zeix/cause-effect/lib/util.ts
var isFunction = (value) => typeof value === "function";
var isObjectOfType = (value, type) => Object.prototype.toString.call(value) === `[object ${type}]`;
var isInstanceOf = (type) => (value) => value instanceof type;
var isError = /* @__PURE__ */ isInstanceOf(Error);
var isPromise = /* @__PURE__ */ isInstanceOf(Promise);
var toError = (value) => isError(value) ? value : new Error(String(value));
// node_modules/@zeix/cause-effect/lib/scheduler.ts
var active;
var pending = new Set;
var batchDepth = 0;
var updateMap = new Map;
var requestId;
var updateDOM = () => {
requestId = undefined;
const updates = Array.from(updateMap.values());
updateMap.clear();
for (const fn of updates) {
fn();
}
};
var requestTick = () => {
if (requestId)
cancelAnimationFrame(requestId);
requestId = requestAnimationFrame(updateDOM);
};
queueMicrotask(updateDOM);
var subscribe = (watchers) => {
if (active && !watchers.includes(active)) {
watchers.push(active);
}
};
var notify = (watchers) => {
for (const mark of watchers) {
if (batchDepth)
pending.add(mark);
else
mark();
}
};
var flush = () => {
while (pending.size) {
const watchers = Array.from(pending);
pending.clear();
for (const mark of watchers) {
mark();
}
}
};
var batch = (fn) => {
batchDepth++;
try {
fn();
} finally {
flush();
batchDepth--;
}
};
var watch = (run, mark) => {
const prev = active;
active = mark;
try {
run();
} finally {
active = prev;
}
};
var enqueue = (fn, dedupe) => new Promise((resolve, reject) => {
const wrappedCallback = () => {
try {
resolve(fn());
} catch (error) {
reject(error);
}
};
if (dedupe) {
updateMap.set(dedupe, wrappedCallback);
}
requestTick();
});
// node_modules/@zeix/cause-effect/lib/effect.ts
function effect(cb, ...maybeSignals) {
let running = false;
const run = () => watch(() => {
if (running)
throw new Error("Circular dependency in effect detected");
running = true;
const result = resolve(maybeSignals, cb);
if (isError(result))
console.error("Unhandled error in effect:", result);
running = false;
}, run);
run();
}
// node_modules/@zeix/cause-effect/lib/computed.ts
var TYPE_COMPUTED = "Computed";
var isEquivalentError = (error1, error2) => {
if (!error2)
return false;
return error1.name === error2.name && error1.message === error2.message;
};
var computed = (cb, ...maybeSignals) => {
const watchers = [];
let value = UNSET;
let error;
let dirty = true;
let unchanged = false;
let computing = false;
const ok = (v) => {
if (!Object.is(v, value)) {
value = v;
dirty = false;
error = undefined;
unchanged = false;
}
};
const nil = () => {
unchanged = UNSET === value;
value = UNSET;
error = undefined;
};
const err = (e) => {
const newError = toError(e);
unchanged = isEquivalentError(newError, error);
value = UNSET;
error = newError;
};
const mark = () => {
dirty = true;
if (!unchanged)
notify(watchers);
};
const compute = () => watch(() => {
if (computing)
throw new Error("Circular dependency in computed detected");
unchanged = true;
computing = true;
const result = resolve(maybeSignals, cb);
if (isPromise(result)) {
nil();
result.then((v) => {
ok(v);
notify(watchers);
}).catch(err);
} else if (result == null || UNSET === result)
nil();
else if (isError(result))
err(result);
else
ok(result);
computing = false;
}, mark);
const c = {
[Symbol.toStringTag]: TYPE_COMPUTED,
get: () => {
subscribe(watchers);
flush();
if (dirty)
compute();
if (error)
throw error;
return value;
},
map: (cb2) => computed(cb2, c),
match: (cb2) => {
effect(cb2, c);
return c;
}
};
return c;
};
var isComputed = (value) => isObjectOfType(value, TYPE_COMPUTED);
// node_modules/@zeix/cause-effect/lib/state.ts
var TYPE_STATE = "State";
var state = (initialValue) => {
const watchers = [];
let value = initialValue;
const s = {
[Symbol.toStringTag]: TYPE_STATE,
get: () => {
subscribe(watchers);
return value;
},
set: (v) => {
if (Object.is(value, v))
return;
value = v;
notify(watchers);
if (UNSET === value)
watchers.length = 0;
},
update: (fn) => {
s.set(fn(value));
},
map: (cb) => computed(cb, s),
match: (cb) => {
effect(cb, s);
return s;
}
};
return s;
};
var isState = (value) => isObjectOfType(value, TYPE_STATE);
// node_modules/@zeix/cause-effect/lib/signal.ts
var UNSET = Symbol();
var isSignal = (value) => isState(value) || isComputed(value);
var isComputedCallbacks = (value) => isFunction(value) && !value.length || typeof value === "object" && value !== null && ("ok" in value) && isFunction(value.ok);
var toSignal = (value) => isSignal(value) ? value : isComputedCallbacks(value) ? computed(value) : state(value);
var resolve = (maybeSignals, cb) => {
const { ok, nil, err } = isFunction(cb) ? { ok: cb } : cb;
const values = [];
const errors = [];
let hasUnset = false;
for (let i = 0;i < maybeSignals.length; i++) {
const s = maybeSignals[i];
try {
const value = s.get();
if (value === UNSET)
hasUnset = true;
values[i] = value;
} catch (e) {
errors.push(toError(e));
}
}
let result = undefined;
try {
if (hasUnset && nil)
result = nil();
else if (errors.length)
result = err ? err(...errors) : errors[0];
else if (!hasUnset)
result = ok(...values);
} catch (e) {
result = toError(e);
if (err)
result = err(result);
}
return result;
};
// src/core/util.ts
var isFunction2 = (value) => typeof value === "function";
var isDefinedObject = (value) => !!value && typeof value === "object";
var isString = (value) => typeof value === "string";
// src/core/log.ts
var DEV_MODE = true;
var LOG_DEBUG = "debug";
var LOG_INFO = "info";
var LOG_WARN = "warn";
var LOG_ERROR = "error";
var idString = (id) => id ? `#${id}` : "";
var classString = (classList) => classList.length ? `.${Array.from(classList).join(".")}` : "";
var elementName = (el) => `<${el.localName}${idString(el.id)}${classString(el.classList)}>`;
var valueString = (value) => isString(value) ? `"${value}"` : isDefinedObject(value) ? JSON.stringify(value) : String(value);
var typeString = (value) => {
if (value === null)
return "null";
if (typeof value !== "object")
return typeof value;
if (Array.isArray(value))
return "Array";
if (Symbol.toStringTag in Object(value)) {
return value[Symbol.toStringTag];
}
return value.constructor?.name || "Object";
};
var log = (value, msg, level = LOG_DEBUG) => {
if (DEV_MODE || [LOG_ERROR, LOG_WARN].includes(level))
console[level](msg, value);
return value;
};
// src/core/ui.ts
var ui = (host, targets = [host]) => {
const u = {
host,
targets,
on: (type, listenerOrProvider) => {
targets.forEach((target, index) => {
let listener;
if (isFunction2(listenerOrProvider)) {
listener = listenerOrProvider.length === 2 ? listenerOrProvider(target, index) : listenerOrProvider;
} else if (isDefinedObject(listenerOrProvider) && isFunction2(listenerOrProvider.handleEvent)) {
listener = listenerOrProvider;
} else {
log(listenerOrProvider, `Invalid listener provided for ${type} event on element ${elementName(target)}`, LOG_ERROR);
return;
}
target.addEventListener(type, listener);
host.cleanup.push(() => target.removeEventListener(type, listener));
});
return u;
},
emit: (type, detail) => {
targets.forEach((target) => {
target.dispatchEvent(new CustomEvent(type, {
detail,
bubbles: true
}));
});
return u;
},
pass: (passedSignalsOrProvider) => {
targets.forEach(async (target, index) => {
await UIElement.registry.whenDefined(target.localName);
if (target instanceof UIElement) {
let passedSignals;
if (isFunction2(passedSignalsOrProvider) && passedSignalsOrProvider.length === 2) {
passedSignals = passedSignalsOrProvider(target, index);
} else if (isDefinedObject(passedSignalsOrProvider)) {
passedSignals = passedSignalsOrProvider;
} else {
log(passedSignalsOrProvider, `Invalid passed signals provided`, LOG_ERROR);
return;
}
Object.entries(passedSignals).forEach(([key, source]) => {
if (isString(source)) {
if (source in host.signals) {
target.set(key, host.signals[source]);
} else {
log(source, `Invalid string key "${source}" for state ${valueString(key)}`, LOG_WARN);
}
} else {
try {
target.set(key, toSignal(source));
} catch (error) {
log(error, `Invalid source for state ${valueString(key)}`, LOG_WARN);
}
}
});
} else {
log(target, `Target is not a UIElement`, LOG_ERROR);
}
});
return u;
},
sync: (...fns) => {
targets.forEach((target, index) => fns.forEach((fn) => fn(host, target, index)));
return u;
}
};
return u;
};
// src/core/context.ts
var CONTEXT_REQUEST = "context-request";
class ContextRequestEvent extends Event {
context;
callback;
subscribe;
constructor(context, callback, subscribe2 = false) {
super(CONTEXT_REQUEST, {
bubbles: true,
composed: true
});
this.context = context;
this.callback = callback;
this.subscribe = subscribe2;
}
}
var useContext = (host) => {
const proto = host.constructor;
const consumed = proto.consumedContexts || [];
queueMicrotask(() => {
for (const context of consumed)
host.dispatchEvent(new ContextRequestEvent(context, (value) => host.set(String(context), value ?? RESET)));
});
const provided = proto.providedContexts || [];
if (!provided.length)
return false;
host.addEventListener(CONTEXT_REQUEST, (e) => {
const { context, callback } = e;
if (!provided.includes(context) || !isFunction2(callback))
return;
e.stopPropagation();
callback(host.signals[String(context)]);
});
return true;
};
// src/ui-element.ts
var RESET = Symbol();
var isAttributeParser = (value) => isFunction2(value) && !!value.length;
var isStateUpdater = (value) => isFunction2(value) && !!value.length;
var unwrap = (v) => isFunction2(v) ? unwrap(v()) : isSignal(v) ? unwrap(v.get()) : v;
var parse = (host, key, value, old) => {
const parser = host.init[key];
return isAttributeParser(parser) ? parser(value, host, old) : value ?? undefined;
};
class UIElement extends HTMLElement {
static registry = customElements;
static localName;
static observedAttributes;
static consumedContexts;
static providedContexts;
static define(name = this.localName) {
try {
this.registry.define(name, this);
if (DEV_MODE)
log(name, "Registered custom element");
} catch (error) {
log(error, `Failed to register custom element ${name}`, LOG_ERROR);
}
return this;
}
init = {};
signals = {};
cleanup = [];
self = ui(this);
get root() {
return this.shadowRoot || this;
}
debug = false;
attributeChangedCallback(name, old, value) {
if (value === old || isComputed(this.signals[name]))
return;
const parsed = parse(this, name, value, old);
if (DEV_MODE && this.debug)
log(value, `Attribute "${name}" of ${elementName(this)} changed from ${valueString(old)} to ${valueString(value)}, parsed as <${typeString(parsed)}> ${valueString(parsed)}`);
this.set(name, parsed ?? RESET);
}
connectedCallback() {
if (DEV_MODE) {
this.debug = this.hasAttribute("debug");
if (this.debug)
log(this, "Connected");
}
for (const [key, init] of Object.entries(this.init)) {
if (this.constructor.observedAttributes?.includes(key))
continue;
const result = isAttributeParser(init) ? init(this.getAttribute(key), this) : isComputedCallbacks(init) ? computed(init) : init;
this.set(key, result ?? RESET, false);
}
useContext(this);
}
disconnectedCallback() {
this.cleanup.forEach((off) => off());
this.cleanup = [];
if (DEV_MODE && this.debug)
log(this, "Disconnected");
}
adoptedCallback() {
if (DEV_MODE && this.debug)
log(this, "Adopted");
}
has(key) {
return key in this.signals;
}
get(key) {
const value = unwrap(this.signals[key]);
if (DEV_MODE && this.debug)
log(value, `Get current value of Signal ${valueString(key)} in ${elementName(this)}`);
return value;
}
set(key, value, update = true) {
if (value == null) {
log(value, `Attempt to set State ${valueString(key)} to null or undefined in ${elementName(this)}`, LOG_ERROR);
return;
}
let op;
const s = this.signals[key];
const old = s?.get();
if (!(key in this.signals)) {
if (isStateUpdater(value)) {
log(value, `Cannot use updater function to create a Computed in ${elementName(this)}`, LOG_ERROR);
return;
}
if (DEV_MODE && this.debug)
op = "Create Signal of type";
this.signals[key] = toSignal(value);
} else if (update || old === UNSET || old === RESET) {
if (isComputedCallbacks(value)) {
log(value, `Cannot use computed callbacks to update Signal ${valueString(key)} in ${elementName(this)}`, LOG_ERROR);
return;
}
if (isSignal(value)) {
if (DEV_MODE && this.debug)
op = "Replace";
this.signals[key] = value;
if (isState(s))
s.set(UNSET);
} else {
if (isState(s)) {
if (DEV_MODE && this.debug)
op = "Update State of type";
s.set(isStateUpdater(value) ? value(old) : value);
} else {
log(value, `Computed ${valueString(key)} in ${elementName(this)} cannot be set`, LOG_WARN);
return;
}
}
} else
return;
if (DEV_MODE && this.debug)
log(value, `${op} ${typeString(value)} ${valueString(key)} in ${elementName(this)}`);
}
delete(key) {
if (DEV_MODE && this.debug)
log(key, `Delete Signal ${valueString(key)} from ${elementName(this)}`);
return delete this.signals[key];
}
first(selector) {
let element = this.root.querySelector(selector);
if (this.shadowRoot && !element)
element = this.querySelector(selector);
return ui(this, element ? [element] : []);
}
all(selector) {
let elements = this.root.querySelectorAll(selector);
if (this.shadowRoot && !elements.length)
elements = this.querySelectorAll(selector);
return ui(this, Array.from(elements));
}
}
// src/lib/parsers.ts
var parseNumber = (parseFn, value) => {
if (value == null)
return;
const parsed = parseFn(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
var getFallback = (value) => Array.isArray(value) && value[0] ? value[0] : value;
var asBoolean = (value) => value !== "false" && value != null;
var asInteger = (fallback = 0) => (value) => parseNumber(parseInt, value) ?? fallback;
var asNumber = (fallback = 0) => (value) => parseNumber(parseFloat, value) ?? fallback;
var asString = (fallback = "") => (value) => value ?? fallback;
var asEnum = (valid) => (value) => value != null && valid.includes(value.toLowerCase()) ? value : getFallback(valid);
var asJSON = (fallback) => (value) => {
if (value == null)
return fallback;
let result;
try {
result = JSON.parse(value);
} catch (error) {
log(error, "Failed to parse JSON", LOG_ERROR);
}
return result ?? fallback;
};
// src/lib/effects.ts
var ops = {
a: "attribute ",
c: "class ",
h: "inner HTML",
p: "property ",
s: "style property ",
t: "text content"
};
var resolveSignalLike = (s, host, target, index) => isString(s) ? host.get(s) : isSignal(s) ? s.get() : isFunction2(s) ? s(target, index) : RESET;
var isSafeURL = (value) => {
if (/^(mailto|tel):/i.test(value))
return true;
if (value.includes("://")) {
try {
const url = new URL(value, window.location.origin);
return ["http:", "https:", "ftp:"].includes(url.protocol);
} catch (error) {
return false;
}
}
return true;
};
var safeSetAttribute = (element, attr, value) => {
if (/^on/i.test(attr))
throw new Error(`Unsafe attribute: ${attr}`);
value = String(value).trim();
if (!isSafeURL(value))
throw new Error(`Unsafe URL for ${attr}: ${value}`);
element.setAttribute(attr, value);
};
var updateElement = (s, updater) => (host, target, index) => {
const { op, read, update } = updater;
const fallback = read(target);
if (isString(s) && !isComputed(host.signals[s])) {
const value = isString(fallback) ? parse(host, s, fallback) : fallback;
if (value != null)
host.set(s, value, false);
}
const err = (error, verb, prop = "element") => log(error, `Failed to ${verb} ${prop} ${elementName(target)} in ${elementName(host)}`, LOG_ERROR);
effect(() => {
let value = RESET;
try {
value = resolveSignalLike(s, host, target, index);
} catch (error) {
err(error, "update");
return;
}
if (value === RESET)
value = fallback;
if (value === UNSET)
value = updater.delete ? null : fallback;
if (updater.delete && value === null) {
let name = "";
enqueue(() => {
name = updater.delete(target);
return true;
}, [target, op]).then(() => {
log(target, `Deleted ${ops[op] + name} of ${elementName(target)} in ${elementName(host)}`);
}).catch((error) => {
err(error, "delete", `${ops[op] + name} of`);
});
} else if (value != null) {
const current = read(target);
if (Object.is(value, current))
return;
let name = "";
enqueue(() => {
name = update(target, value);
return true;
}, [target, op]).then(() => {
log(target, `Updated ${ops[op] + name} of ${elementName(target)} in ${elementName(host)}`);
}).catch((error) => {
err(error, "update", `${ops[op] + name} of`);
});
}
});
};
var insertNode = (s, { type, where, create }) => (host, target, index) => {
const methods = {
beforebegin: "before",
afterbegin: "prepend",
beforeend: "append",
afterend: "after"
};
if (!isFunction2(target[methods[where]])) {
log(`Invalid insertPosition ${valueString(where)} for ${elementName(host)}:`, LOG_ERROR);
return;
}
const err = (error) => log(error, `Failed to insert ${type} into ${elementName(host)}:`, LOG_ERROR);
effect(() => {
let really = false;
try {
really = resolveSignalLike(s, host, target, index);
} catch (error) {
err(error);
return;
}
if (!really)
return;
enqueue(() => {
const node = create(host);
if (!node)
return;
target[methods[where]](node);
}, [target, "i"]).then(() => {
const maybeSignal = isString(s) ? host.signals[s] : s;
if (isState(maybeSignal))
maybeSignal.set(false);
log(target, `Inserted ${type} into ${elementName(host)}`);
}).catch((error) => {
err(error);
});
});
};
var setText = (s) => updateElement(s, {
op: "t",
read: (el) => el.textContent,
update: (el, value) => {
Array.from(el.childNodes).filter((node) => node.nodeType !== Node.COMMENT_NODE).forEach((node) => node.remove());
el.append(document.createTextNode(value));
return "";
}
});
var setProperty = (key, s = key) => updateElement(s, {
op: "p",
read: (el) => (key in el) ? el[key] : UNSET,
update: (el, value) => {
el[key] = value;
return String(key);
}
});
var setAttribute = (name, s = name) => updateElement(s, {
op: "a",
read: (el) => el.getAttribute(name),
update: (el, value) => {
safeSetAttribute(el, name, value);
return name;
},
delete: (el) => {
el.removeAttribute(name);
return name;
}
});
var toggleAttribute = (name, s = name) => updateElement(s, {
op: "a",
read: (el) => el.hasAttribute(name),
update: (el, value) => {
el.toggleAttribute(name, value);
return name;
}
});
var toggleClass = (token, s = token) => updateElement(s, {
op: "c",
read: (el) => el.classList.contains(token),
update: (el, value) => {
el.classList.toggle(token, value);
return token;
}
});
var setStyle = (prop, s = prop) => updateElement(s, {
op: "s",
read: (el) => el.style.getPropertyValue(prop),
update: (el, value) => {
el.style.setProperty(prop, value);
return prop;
},
delete: (el) => {
el.style.removeProperty(prop);
return prop;
}
});
var dangerouslySetInnerHTML = (s, attachShadow, allowScripts) => updateElement(s, {
op: "h",
read: (el) => (el.shadowRoot || !attachShadow ? el : null)?.innerHTML ?? "",
update: (el, html) => {
if (!html) {
if (el.shadowRoot)
el.shadowRoot.innerHTML = "<slot></slot>";
return "";
}
if (attachShadow && !el.shadowRoot)
el.attachShadow({ mode: attachShadow });
const target = el.shadowRoot || el;
target.innerHTML = html;
if (!allowScripts)
return "";
target.querySelectorAll("script").forEach((script) => {
const newScript = document.createElement("script");
newScript.appendChild(document.createTextNode(script.textContent ?? ""));
target.appendChild(newScript);
script.remove();
});
return " with scripts";
}
});
var insertTemplate = (template, s, where = "beforeend") => insertNode(s, {
type: "template content",
where,
create: (host) => {
if (!(template instanceof HTMLTemplateElement)) {
log(`Invalid template to insert into ${elementName(host)}:`, LOG_ERROR);
return;
}
const clone = host.shadowRoot ? document.importNode(template.content, true) : template.content.cloneNode(true);
return clone;
}
});
var createElement = (tag, s, where = "beforeend", attributes = {}, text) => insertNode(s, {
type: "new element",
where,
create: () => {
const child = document.createElement(tag);
for (const [key, value] of Object.entries(attributes))
safeSetAttribute(child, key, value);
if (text)
child.textContent = text;
return child;
}
});
var removeElement = (s) => (host, target, index) => {
const err = (error) => log(error, `Failed to delete ${elementName(target)} from ${elementName(host)}:`, LOG_ERROR);
effect(() => {
let really = false;
try {
really = resolveSignalLike(s, host, target, index);
} catch (error) {
err(error);
return;
}
if (!really)
return;
enqueue(() => {
target.remove();
return true;
}, [target, "r"]).then(() => {
log(target, `Deleted ${elementName(target)} into ${elementName(host)}`);
}).catch((error) => {
err(error);
});
});
};
export {
watch,
useContext,
updateElement,
toggleClass,
toggleAttribute,
toSignal,
state,
setText,
setStyle,
setProperty,
setAttribute,
removeElement,
parse,
log,
isState,
isSignal,
isComputed,
insertTemplate,
insertNode,
enqueue,
effect,
dangerouslySetInnerHTML,
createElement,
computed,
batch,
asString,
asNumber,
asJSON,
asInteger,
asEnum,
asBoolean,
UNSET,
UIElement,
RESET,
LOG_WARN,
LOG_INFO,
LOG_ERROR,
LOG_DEBUG
};