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