UNPKG

typed-dom

Version:

A value-level and type-level DOM builder.

287 lines (269 loc) 11.2 kB
import { isArray, hasOwnProperty } from 'spica/alias'; import { memoize } from 'spica/memoize'; declare global { interface ShadowHostHTMLElementTagNameMap { 'article': HTMLElement; 'aside': HTMLElement; 'blockquote': HTMLQuoteElement; 'body': HTMLBodyElement; 'div': HTMLDivElement; 'footer': HTMLElement; 'h1': HTMLHeadingElement; 'h2': HTMLHeadingElement; 'h3': HTMLHeadingElement; 'h4': HTMLHeadingElement; 'h5': HTMLHeadingElement; 'h6': HTMLHeadingElement; 'header': HTMLElement; 'main': HTMLElement; 'nav': HTMLElement; 'p': HTMLParagraphElement; 'section': HTMLElement; 'span': HTMLSpanElement; } interface HTMLElementTagNameMap extends ShadowHostHTMLElementTagNameMap { } } export const enum NS { HTML = 'HTML', SVG = 'SVG', Math = 'MathML', } // {HTML,SVG,}ElementEventMapを使用しないがEvent型しか使われてないので問題ない interface NodeEvent<N extends Node> extends Event { readonly target: Node; readonly currentTarget: N; } type NodeEventListener<N extends Node> = (ev: NodeEvent<N>) => void; export type TagNameMap = object; export type Attrs<E extends Element = Element> = Record<string, string | NodeEventListener<E> | null | undefined>; export type Children = Iterable<string | Node> | string | undefined; export interface Factory<M extends TagNameMap> { <T extends keyof M & string>(tag: T, children?: Children): M[T]; <T extends keyof M & string>(tag: T, attrs: Attrs<Extract<M[T], Element>> | undefined, children?: Children): M[T]; } namespace caches { // Closed only. export const shadows = new WeakMap<Element, ShadowRoot>(); export const shadow = memoize((el: Element, opts: ShadowRootInit) => el.attachShadow(opts), shadows); export const fragment = document.createDocumentFragment(); } export function shadow<M extends ShadowHostHTMLElementTagNameMap>(el: keyof M & string | Extract<M[keyof M & string], Element>, factory?: Factory<M>): ShadowRoot; export function shadow<M extends ShadowHostHTMLElementTagNameMap>(el: keyof M & string | Extract<M[keyof M & string], Element>, children?: Children, factory?: Factory<M>): ShadowRoot; export function shadow<M extends ShadowHostHTMLElementTagNameMap>(el: keyof M & string | Extract<M[keyof M & string], Element>, opts?: ShadowRootInit, factory?: Factory<M>): ShadowRoot; export function shadow<M extends ShadowHostHTMLElementTagNameMap>(el: keyof M & string | Extract<M[keyof M & string], Element>, opts?: ShadowRootInit, children?: Children, factory?: Factory<M>): ShadowRoot; export function shadow<M extends ShadowHostHTMLElementTagNameMap>(el: keyof M & string | Extract<M[keyof M & string], Element>, opts?: ShadowRootInit | Factory<M> | Children, children?: Factory<M> | Children, factory: Factory<M> = html as unknown as Factory<M>): ShadowRoot { if (typeof el === 'string') return shadow(factory(el) as Extract<M[keyof M & string], Element>, opts as ShadowRootInit, children as Children, factory); if (typeof opts === 'function') return shadow(el, undefined, children as Children, opts); if (typeof children === 'function') return shadow(el, opts as ShadowRootInit, undefined, children); if (isChildren(opts)) return shadow(el, undefined, opts, factory); return defineChildren( !opts ? el.shadowRoot ?? caches.shadows.get(el) ?? el.attachShadow({ mode: 'open' }) : opts.mode === 'open' ? el.shadowRoot ?? el.attachShadow(opts) : caches.shadow(el, opts), children); } export function frag(children?: Children): DocumentFragment { return defineChildren(caches.fragment.cloneNode(true) as DocumentFragment, children); } export const html = element<HTMLElementTagNameMap>(document, NS.HTML); export const svg = element<SVGElementTagNameMap>(document, NS.SVG); export const math = element<MathMLElementTagNameMap>(document, NS.Math); export function text(source: string): Text { return document.createTextNode(source); } export function element<M extends HTMLElementTagNameMap>(context: Document | ShadowRoot, ns: NS.HTML): Factory<M>; export function element<M extends SVGElementTagNameMap>(context: Document | ShadowRoot, ns: NS.SVG): Factory<M>; export function element<M extends MathMLElementTagNameMap>(context: Document | ShadowRoot, ns: NS.Math): Factory<M>; export function element<M extends TagNameMap>(context: Document | ShadowRoot, ns: NS): Factory<M> { return (tag: string, attrs?: Attrs | Children, children?: Children) => { return !attrs || isChildren(attrs) ? defineChildren(elem(context, ns, tag, {}), attrs ?? children) : defineChildren(defineAttrs(elem(context, ns, tag, attrs), attrs), children); }; } function elem(context: Document | ShadowRoot, ns: NS, tag: string, attrs: Attrs): Element { if (!('createElement' in context)) throw new Error(`Typed-DOM: Scoped custom elements are not supported on this browser`); const opts = 'is' in attrs ? { is: attrs['is'] as string } : undefined; switch (ns) { case NS.HTML: return context.createElement(tag, opts); case NS.SVG: return context.createElementNS('http://www.w3.org/2000/svg', tag, opts); case NS.Math: return context.createElementNS('http://www.w3.org/1998/Math/MathML', tag, opts); } } export function define<E extends Element>(el: E, attrs?: Attrs<E>, children?: Children): E; export function define<E extends Element | DocumentFragment | ShadowRoot>(node: E, children?: Children): E; export function define<E extends Element | DocumentFragment | ShadowRoot>(node: E, attrs?: Attrs | Children, children?: Children): E { // Bug: TypeScript // Need the next type assertions to suppress an impossible type error on dependent projects. // Probably caused by typed-query-selector. // // typed-dom/dom.ts(113,3): Error TS2322: Type 'ParentNode & Node' is not assignable to type 'E'. // 'E' could be instantiated with an arbitrary type which could be unrelated to 'ParentNode & Node'. // return !attrs || isChildren(attrs) ? defineChildren(node, attrs ?? children) as E : defineChildren(defineAttrs(node as Element, attrs), children) as E; } function defineAttrs<E extends Element>(el: E, attrs: Attrs): E { for (const name of Object.keys(attrs)) { switch (name) { case 'is': continue; } const value = attrs[name]; switch (typeof value) { case 'string': el.setAttribute(name, value); if (name.startsWith('on')) { const type = name.slice(2).toLowerCase(); switch (type) { case 'mutate': case 'connect': case 'disconnect': const prop = `on${type}`; el[prop] ?? Object.defineProperty(el, prop, { configurable: true, enumerable: false, writable: true, value: prop in el && !hasOwnProperty(el, prop) ? (ev: Event) => ev.returnValue : '', }); assert.deepStrictEqual({ ...el }, {}); } } continue; case 'function': if (name.length < 3) throw new Error(`Typed-DOM: Attribute names for event listeners must have an event name but got "${name}"`); const names = name.split(/\s+/); for (const name of names) { if (!name.startsWith('on')) throw new Error(`Typed-DOM: Attribute names for event listeners must start with "on" but got "${name}"`); const type = name.slice(2).toLowerCase(); el.addEventListener(type, value, { passive: [ 'wheel', 'mousewheel', 'touchstart', 'touchmove', 'touchend', 'touchcancel', ].includes(type), }); switch (type) { case 'mutate': case 'connect': case 'disconnect': const prop = `on${type}`; el[prop] ?? Object.defineProperty(el, prop, { configurable: true, enumerable: false, writable: true, value: prop in el && !hasOwnProperty(el, prop) ? (ev: Event) => ev.returnValue : '', }); assert.deepStrictEqual({ ...el }, {}); } } continue; case 'object': assert(value === null); el.removeAttribute(name); continue; default: continue; } } return el; } function defineChildren<N extends ParentNode & Node>(node: N, children: Children | readonly (string | Node)[]): N { if (children === undefined) return node; if (typeof children === 'string') { node.textContent = children; } else if ((isArray(children) || !(Symbol.iterator in children)) && !node.firstChild) { for (let i = 0; i < children.length; ++i) { const child = children[i]; typeof child === 'object' ? node.appendChild(child) : node.append(child); } } else { node.replaceChildren(...children); } return node; } export function isChildren(value: Attrs | Children | ShadowRootInit): value is NonNullable<Children> { return value?.[Symbol.iterator] !== undefined; } export function append<N extends ParentNode & Node>(node: N, children: Children): N { if (children === undefined) return node; if (typeof children === 'string') { node.append(children); } else if (isArray(children) || !(Symbol.iterator in children)) { for (let i = 0; i < children.length; ++i) { const child = children[i]; typeof child === 'object' ? node.appendChild(child) : node.append(child); } } else { for (const child of children) { typeof child === 'object' ? node.appendChild(child) : node.append(child); } } return node; } export function prepend<N extends ParentNode & Node>(node: N, children: Children): N { if (children === undefined) return node; if (typeof children === 'string') { node.prepend(children); } else if (isArray(children) || !(Symbol.iterator in children)) { for (let i = 0; i < children.length; ++i) { const child = children[i]; typeof child === 'object' ? node.insertBefore(child, null) : node.prepend(child); } } else { for (const child of children) { typeof child === 'object' ? node.insertBefore(child, null) : node.prepend(child); } } return node; } export function defrag<N extends Node | string>(nodes: ArrayLike<N>): N[]; export function defrag(nodes: ArrayLike<Node | string>): (Node | string)[] { assert(Array.from(nodes).every(n => typeof n === 'string' || n instanceof Node)); const acc: (Node | string)[] = []; let appendable = false; for (let i = 0, len = nodes.length; i < len; ++i) { const node = nodes[i]; if (typeof node === 'object') { acc.push(node); appendable = false; } else if (node !== '') { appendable ? acc[acc.length - 1] += node : acc.push(node); appendable = true; } } return acc; }