typed-dom
Version:
A value-level and type-level DOM builder.
287 lines (269 loc) • 11.2 kB
text/typescript
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;
}