UNPKG

jsx-view

Version:

Minimal JSX for HTML DOM tightly integrated with RxJS. TypeScript definitions, and attributes can be assigned to observables.

447 lines 17.3 kB
import { isObservable } from "rxjs"; import { distinctUntilChanged } from "rxjs/operators"; import { __isDOMSpecElement } from "./jsxSpec"; import { map$Class } from "./rxjs-helpers"; import { subscribeState } from "./subscribeState"; import { isObservableUnchecked } from "./isObservableUnchecked"; import { St } from "./stack"; import { _devctx, _devctxglobal } from "./addJSXDev"; /** * Render out an Element which can be appended to another Node in the DOM. * * TypeScript: Defaults to assuming the return is an {@link HTMLElement}, but it can be customized using a type parameter. */ export function renderSpec(parentSub, structure) { // must wrap top-level observable in an element, or the Element returned will not update // if it's detached from the DOM (which is very confusing) if (isObservable(structure)) throw new Error("Cannot render an Observable root"); // DOMOutputSpec must result in an Element return renderSpecDoc(document, parentSub, structure); } const globalContextStack = new St(0); /** * Pull a context from the current render function's execution frame. * * @example * import {createContext, useContext} from "jsx-view" * * const themeContext = createContext({ * textColor: "cornflowerblue" * }) * * function MyComponent(props, children) { * const theme = useContext(themeContext) * return <p style={`color: ${theme.textColor}`}> * Styled text * </p> * } */ export function useContext(context) { if (globalContextStack.s < 1) throw new ContextAccessError("useContext"); const curr = globalContextStack.get(); for (let i = curr.length - 1; i >= 0; i--) { if (curr[i].c === context) return curr[i].v; } return context.defaultValue; } /** * Add a value for the context to the current render scope which will be passed down to child dom components. * * @example * import {createContext, useContext, addContext} from "jsx-view" * * const themeContext = createContext({ * textColor: "cornflowerblue" * }) * * function MyParent(props, children) { * const theme = useContext(themeContext) // pull default / parent provided context * * addContext(themeContext, {...theme, textColor: "dodgerblue"}) // Makes MyComponent style with dodgerblue * * return <p style={`color: ${theme.textColor}`}> * Styled cornflowerblue * <MyComponent/> * </p> * } * * function MyComponent(props, children) { * const theme = useContext(themeContext) * return <p style={`color: ${theme.textColor}`}> * Styled dodgerblue * </p> * } */ export function addContext(context, value) { if (globalContextStack.s < 2) throw new ContextAccessError("addContext"); globalContextStack.get().push({ c: context, v: value }); return value; } export class ContextAccessError extends Error { constructor(intent) { super(`Cannot ${intent} outside of a jsx component's function call frame.`); } } /** Examples: `<input disabled/>`, `<script defer .../>`, etc. */ const booleanProps = new Set([ "async", "autofocus", "autoplay", "checked", "controls", "default", "defer", "disabled", "draggable", "hidden", "loop", "multiple", "novalidate", "open", "readonly", "required", "reversed", "scoped", "selected", "spellcheck", "wrap", ]); /** * Props that must have their values assigned like `elt[prop] = value` (as opposed to `elt.setAttribute(prop, value)`). */ function isDirectAssignProp(prop) { return ( // According to the HTML spec, all attributes starting with "on" are event listeners (accepting assignment to functions) prop.startsWith("on") || // boolean brops show up like `<input disabled/>` where there isn't an actual value needed booleanProps.has(prop) || // "value" must be directly assigned to notify HTMLInputElement of change prop === "value"); } /** * :: (dom.Document, DOMOutputSpec) → {dom: dom.Node, subscription: rxjs.Subscription} * Render an [output spec](#model.DOMOutputSpec) to a DOM node. * * * **Modified to work with event listeners see `else if (name.startsWith("on"))`** * * **Modified to work with observables on attribute setters see `else if (name.startsWith("on"))`** * * **Modified to work with `ref` attributes** */ function renderSpecDoc(doc, parentSub, structure_, scope = [], xmlNS = null, devRenderInfo = undefined) { let structure = structure_; let _dev; if (__isDOMSpecElement(structure)) { _dev = structure._dev; structure = structure.spec; } // passed down to children let childRenderInfo = { ...devRenderInfo, // clear direct render info for children directParentComponent: undefined, directParentComponentProps: undefined, }; if (devRenderInfo?.directParentComponent) { childRenderInfo.parentComponent = devRenderInfo.directParentComponent; childRenderInfo.parentComponentProps = devRenderInfo?.directParentComponentProps; } if (typeof structure === "string") return doc.createTextNode(structure); if (structure == null || structure === false) return doc.createTextNode(""); if (isObservableUnchecked(structure)) { let obsNode = doc.createElement("jsx-view-observable"); // temporary until the first is rendered subscribeState(parentSub, structure, (spec, whileSpec) => { const oldNode = obsNode; obsNode = renderSpecDoc(doc, whileSpec, spec == null || spec === false ? createEmptyNode(doc) : __isDOMSpecElement(spec) || Array.isArray(spec) ? // will have a valid Element container spec : // might not have a container ["jsx-view-observable", null, spec], scope, xmlNS, devRenderInfo); oldNode.replaceWith(obsNode); }); return obsNode; } if (structure["nodeType"] != null) return structure; if (!Array.isArray(structure)) return doc.createTextNode(String(structure)); let tagName = structure[0]; if (typeof tagName === "function") { scope = scope.slice(0); // clone scope so it can be pushed to globalContextStack.push(scope); globalContextStack.s = 2; const props = structure[1]; structure = tagName(props); if (devRenderInfo) { devRenderInfo.directParentComponent = tagName; devRenderInfo.directParentComponentProps = props; } const res = renderSpecDoc(doc, parentSub, // Hmm: Do we need to check if it has a proper container like with the observable one above? structure, scope, xmlNS, devRenderInfo); globalContextStack.pop(); globalContextStack.s = 0; return res; } if (typeof tagName !== "string") { const err = new Error(`Expected string tagName, but found ${tagName}`); console.error(err, { given: structure_ }); throw err; } if (tagName.indexOf(" ") > 0) { throw new RangeError(`Unexpected space in tagName ("${tagName}")`); } const attrs = structure[1]; if (devRenderInfo) devRenderInfo.intrinsicProps = attrs; let ref = undefined; let classAttrHandled = 0; tagName = attrs?.is ?? tagName; if (tagName === "svg") xmlNS = "http://www.w3.org/2000/svg"; const dom = (xmlNS ? doc.createElementNS(xmlNS, tagName) : doc.createElement(tagName)); if (attrs != null) { for (let name in attrs) { if (name === "is") continue; // handled above const attrVal = attrs[name]; if (attrVal != null) { if (name === "$class" || name === "class" || name === "tags") { if (classAttrHandled) continue; // already performed classAttrHandled = 1; const classNamesList = attrs.class ? [attrs.class] : []; // works because `attrs.class` is `StringValue` const val$classes = attrs.$class; if (Array.isArray(val$classes)) { classNamesList.push(...val$classes); } else if (val$classes != null) { classNamesList.push(val$classes); } const valClass = attrs.class; if (valClass != null) { classNamesList.push(valClass); } const valTag = attrs.tags; if (valTag != null) { classNamesList.push(isObservableUnchecked(valTag) ? valTag.pipe(map$Class(mapTagsToClassNames)) : mapTagsToClassNames(valTag)); if (isObservableUnchecked(valTag)) { parentSub.add(valTag.subscribe((a) => dom.setAttribute("data-tags", a?.join(",") ?? ""))); } else { dom.setAttribute("data-tags", valTag.join(",")); } } for (let i = 0; i < classNamesList.length; i++) { const classItem = classNamesList[i]; if (isObservableUnchecked(classItem)) { let previousClasses = []; parentSub.add(classItem.subscribe((a) => { dom.classList.remove(...previousClasses); if (!a) { // skip previousClasses = []; } else if (typeof a === "string") previousClasses = a.split(/\s+/g).filter(Boolean); else if (Array.isArray(a)) previousClasses = a.filter(Boolean); else { previousClasses = []; for (const className in a) { // just check if truthy if (a[className]) { previousClasses.push(className); } } } dom.classList.add(...previousClasses); })); } else { let classesToAdd = []; if (!classItem) { // skip } else if (typeof classItem === "string") classesToAdd = classItem.split(/\s+/g).filter(Boolean); else { for (const className in classItem) { const classVal = classItem[className]; // just check if truthy if (classVal) { if (isObservableUnchecked(classVal)) { parentSub.add(classVal.pipe(distinctUntilChanged()).subscribe((shouldAdd) => { dom.classList.toggle(className, !!shouldAdd); })); } else { classesToAdd.push(className); } } } } dom.classList.add(...classesToAdd); } } } else if (isObservable(attrVal)) { if (name === "$style") { if (isObservable(attrs.style)) throw new RangeError("Cannot combine $style property with an Observable [style] property."); subAssign$Style(parentSub, attrVal, dom); } else if (isDirectAssignProp(name)) { parentSub.add(attrVal.subscribe((value) => { if (dom[name] !== value) dom[name] = value; })); } else parentSub.add(attrVal.subscribe((value) => { if (value == null) dom.removeAttribute(name); else dom.setAttribute(name, String(value)); })); } else { // enable event listeners and boolean props if (isDirectAssignProp(name)) { ; dom[name] = attrVal; } else if (name === "ref") { ref = attrVal; } else if (name === "style") { subAssignStyle(parentSub, attrVal, dom); } else if (name === "$style") { for (const key in attrVal) { ; dom.style[key] = attrVal[key]; } } else dom.setAttribute(name, attrVal); } } } } // render children // @ts-ignore for (let i = 2; i < structure.length; i++) { let child = structure[i]; const inner = renderSpecDoc(doc, parentSub, child, scope, xmlNS, childRenderInfo); dom.appendChild(inner); } // reach directly into scope to find the _devctx quickly let foundDevFn = _devctxglobal[0]; for (let i = scope.length - 1; i >= 0; i--) { const si = scope[i]; if (si.c !== _devctx) continue; foundDevFn = si.v; break; } if (foundDevFn != null) { const options = { ..._dev, ...devRenderInfo }; try { foundDevFn(dom, options, parentSub); } catch (err) { console.warn("Error thrown running the configured addJSXDev(fn) function", { error: err, dom, options, fn: foundDevFn, }); } } if (ref) { globalContextStack.push(scope); globalContextStack.s = 1; if (typeof ref === "function") { // call ref after the inner contents are created ref(dom, parentSub); } else { ref.next({ dom, sub: parentSub }); } globalContextStack.pop(); globalContextStack.s = 0; } return dom; } function subAssign$Style(parentSub, attrVal, dom) { parentSub.add(attrVal.subscribe((value) => { for (const rule in value) { if (rule.startsWith("--")) { dom.style.setProperty(rule, value[rule] ?? null); } else { dom.style[rule] = value[rule] ?? ""; } } })); } function subAssignStyle(parentSub, value, dom) { if (!value) return; if (isObservable(value)) { parentSub.add(value.subscribe((styleStringValue) => { if (styleStringValue) { dom.setAttribute("style", styleStringValue); } else { dom.removeAttribute("style"); } })); } else if (typeof value === "object") { for (const rule in value) { const ruleVal = value[rule]; if (rule.startsWith("--")) { if (isObservable(ruleVal)) parentSub.add(ruleVal.subscribe((a) => dom.style.setProperty(rule, a ?? null))); else dom.style.setProperty(rule, ruleVal ?? null); } else { if (isObservable(ruleVal)) parentSub.add(ruleVal.subscribe((a) => (dom.style[rule] = a ?? ""))); else dom.style[rule] = ruleVal ?? ""; } } } else if (typeof value === "string") { if (value) { dom.setAttribute("style", value); } else { dom.removeAttribute("style"); } } else { const err = new TypeError("Unexpected type for style=..."); console.error(err, { found: value }); throw err; } } function createEmptyNode(document) { return document.createElement("jsx-view-empty"); } function mapTagsToClassNames(tags) { return (tags ?? []).map((tag) => `tag-${tag}`).join(" "); } //# sourceMappingURL=renderSpec.js.map