jsx-view
Version:
Minimal JSX for HTML DOM tightly integrated with RxJS. TypeScript definitions, and attributes can be assigned to observables.
468 lines (444 loc) • 15.9 kB
text/typescript
import { isObservable, Observable, Subscription } from "rxjs"
import { distinctUntilChanged, map } from "rxjs/operators"
import type { DOMOutputSpec } from "./DOMOutputSpec"
import { __isDOMSpecElement } from "./jsxSpec"
import { map$Class } from "./rxjs-helpers"
import { subscribeState } from "./subscribeState"
import { isObservableUnchecked } from "./isObservableUnchecked"
import { St } from "./stack"
import type { Context } from "./Context"
import { JSXDevInfo, JSXViewDevRenderInfo, _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<T extends Element = HTMLElement>(parentSub: Subscription, structure: DOMOutputSpec): T {
// 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) as T
}
type CtxScope = StackItem<unknown>[]
const globalContextStack = new St<CtxScope, 0 | 1 | 2>(0)
type StackItem<T> = Readonly<{
/** key */
c: Context<T>
/** value */
v: T
}>
/**
* 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<T>(context: Context<T>): T {
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 as T
}
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<T>(context: Context<T>, value: T): T {
if (globalContextStack.s < 2) throw new ContextAccessError("addContext")
globalContextStack.get().push({ c: context, v: value })
return value
}
export class ContextAccessError extends Error {
constructor(intent: string) {
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: string): boolean {
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: Document,
parentSub: Subscription,
structure_: DOMOutputSpec,
scope: CtxScope = [],
xmlNS: string | null = null,
devRenderInfo: JSXViewDevRenderInfo | undefined = undefined,
): Node | Text {
let structure = structure_
let _dev: JSXDevInfo | undefined
if (__isDOMSpecElement(structure)) {
_dev = structure._dev
structure = structure.spec
}
// passed down to children
let childRenderInfo: JSXViewDevRenderInfo = {
...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<DOMOutputSpec>(structure)) {
let obsNode: Element = 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 as DOMOutputSpec)
: // might not have a container
["jsx-view-observable", null, spec],
scope,
xmlNS,
devRenderInfo,
) as Element
oldNode.replaceWith(obsNode)
})
return obsNode
}
if ((structure as any)["nodeType"] != null) return structure as Node
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,
) as Element
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: JSX.RefValue | undefined = undefined
let classAttrHandled: 0 | 1 = 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)) as Element
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: JSX.ClassNames[] = attrs.class ? [attrs.class] : [] // works because `attrs.class` is `StringValue`
const val$classes = attrs.$class as JSX.$ClassValue | undefined
if (Array.isArray(val$classes)) {
classNamesList.push(...val$classes)
} else if (val$classes != null) {
classNamesList.push(val$classes)
}
const valClass = attrs.class as JSX.StringValue | undefined
if (valClass != null) {
classNamesList.push(valClass)
}
const valTag = attrs.tags as JSX.Value<string[]> | undefined
if (valTag != null) {
classNamesList.push(
isObservableUnchecked<string[] | undefined>(valTag)
? valTag.pipe(map$Class(mapTagsToClassNames))
: mapTagsToClassNames(valTag),
)
if (isObservableUnchecked<string[] | undefined>(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<string | string[] | Record<string, any> | undefined | null | false>(classItem)) {
let previousClasses: string[] = []
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: string[] = []
if (!classItem) {
// skip
} else if (typeof classItem === "string") classesToAdd = classItem.split(/\s+/g).filter(Boolean)
else {
for (const className in classItem) {
const classVal = (classItem as any)[className]
// just check if truthy
if (classVal) {
if (isObservableUnchecked<boolean | undefined | null>(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 as Observable<Partial<CSSStyleDeclaration>>, dom as HTMLElement)
} else if (isDirectAssignProp(name)) {
parentSub.add(
attrVal.subscribe((value) => {
if ((dom as any)[name] !== value) (dom as any)[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 as any)[name] = attrVal
} else if (name === "ref") {
ref = attrVal
} else if (name === "style") {
subAssignStyle(parentSub, attrVal, dom as HTMLElement)
} else if (name === "$style") {
for (const key in attrVal as any) {
;(dom as HTMLElement).style[key as any] = (attrVal as any)[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 as typeof foundDevFn
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: Subscription, attrVal: Observable<Partial<CSSStyleDeclaration>>, dom: HTMLElement) {
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: Subscription,
value: JSX.StyleValueObject | JSX.StringValue | undefined,
dom: HTMLElement,
) {
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: Document): Element {
return document.createElement("jsx-view-empty")
}
function mapTagsToClassNames(tags: string[] | undefined): string {
return (tags ?? []).map((tag) => `tag-${tag}`).join(" ")
}