import { h, mount, render } from 'lithent'; import type { Props, TagFunction } from 'lithent'; /** * Prop declarations for `defineElement`. Each key is a camelCase prop name * observed as its kebab-case attribute; the constructor picks the attribute * conversion (DESIGN §4.1). */ export type PropSpec = Record< string, StringConstructor | NumberConstructor | BooleanConstructor | ObjectConstructor >; /** The prop type a constructor declares (DESIGN §8). */ type PropType = C extends NumberConstructor ? number : C extends BooleanConstructor ? boolean : C extends StringConstructor ? string : unknown; /** * Props a declaration produces. Every prop can be absent (no attribute, no * property yet) except Boolean ones, which start as `false` (DESIGN §4.1). */ export type PropsOf = { [K in keyof S as S[K] extends BooleanConstructor ? K : never]: boolean; } & { [K in keyof S as S[K] extends BooleanConstructor ? never : K]?: PropType< S[K] >; }; /** What the inner component receives: the declared props and `host`. */ export type ElementProps = PropsOf & { host: HTMLElement; }; /** The element `defineElement` registers, with a property per prop. */ export type LithentElementOf = HTMLElement & PropsOf; export type DefineElementOptions = { props?: S; shadow?: boolean | 'open' | 'closed'; styles?: string[]; }; /** What an element keeps between callbacks. */ type State = { /** Current props, owned by the element. */ p: Props; /** The render root, created on first connect. */ root?: ShadowRoot | HTMLElement; /** `destroy` returned by `render`, set while mounted. */ d?: () => void; /** The Host component's `renew`, set while mounted. */ r?: () => void; }; /** * Element state, outside the element: fields on the instance would collide * with props of the same name (`p`, `d`, ...), and a closed shadow root must * not leak through a property. (A module WeakMap instead of `#private` * fields: those compile to helpers that cost more bytes than this whole * file — RC-3.) */ const states = new WeakMap(); const stateOf = (el: HTMLElement) => states.get(el)!; /** Set a prop and re-render if mounted (renew is batched per microtask). */ const update = (el: HTMLElement, key: string, value: unknown) => { const state = stateOf(el); state.p[key] = value; // Before the first connect there is no renew: the first render reads p. if (state.r) state.r(); }; /** * Attribute string -> prop value (DESIGN §4.1). `null` means the attribute is * absent. Invalid numbers and JSON become `undefined` without a warning: the * runtime packages have no dev-only build to keep warnings out of production. */ const convert = (type: PropSpec[string], value: string | null): unknown => { if (type === Boolean) return value !== null; if (value === null) return undefined; if (type === Number) return isNaN(+value) ? undefined : +value; if (type === Object) { try { return JSON.parse(value); } catch { return undefined; } } return value; }; /** * Dispatch a DOM event from the element (DC-5). It is dispatched on the * element itself, so it bubbles to the host page; `composed` lets it also * leave an outer shadow root when the element is nested in another * component. It is cancelable: returns `false` when a listener called * `preventDefault()`. Pass `init` to change `bubbles`, `composed` or * `cancelable`. * * ```ts * const PayButton = mount<{ amount: number; host: HTMLElement }>( * (_renew, props) => () => * h('button', { onClick: () => emit(props.host, 'pay', props.amount) }, 'Pay') * ); * ``` */ export const emit = ( host: Element, name: string, detail?: unknown, // Overrides the defaults, e.g. { bubbles: false } for a noisy event that // only listeners on the element itself should hear. `detail` always wins. init?: EventInit ) => host.dispatchEvent( new CustomEvent(name, { bubbles: true, composed: true, cancelable: true, ...init, detail, }) ); /** * Register a lithent component as a Custom Element. * * The element owns the props and renders a small Host component that holds * `renew`; the user's component sits under it unchanged (DESIGN §2). * Returns `undefined` where Custom Elements do not exist (SSR, Node), and the * existing constructor when the name is already taken (DC-7). */ export const defineElement = < // `host` is reserved (DC-5): declaring it is a type error as well. S extends PropSpec & { host?: never } = Record, >( name: `${string}-${string}`, // `S` is inferred from `props` only: it sits in a mapped type here, which // TypeScript does not infer from (checked in Phase 7; no NoInfer needed). component: (props: ElementProps, children?: never) => unknown, options: DefineElementOptions = {} ): (new () => LithentElementOf) | undefined => { if (typeof customElements === 'undefined') return undefined; const existing = customElements.get(name); if (existing) return existing as new () => LithentElementOf; const { shadow = true, props: spec = {} as S, styles } = options; // DC-5: `host` is the prop that carries the element itself. if ('host' in spec) throw Error('"host" is reserved'); // FR-6: styles apply only inside a shadow root (DESIGN §5); one sheet per // definition, shared by every instance and created on first use. const css = shadow && styles ? styles.join('\n') : ''; let sheet: CSSStyleSheet | undefined; // FR-3: kebab-case attribute -> camelCase prop key. const keyOf: Record = {}; const attrOf: Record = {}; for (const key in spec) { keyOf[(attrOf[key] = key.replace(/[A-Z]/g, c => '-' + c.toLowerCase()))] = key; } const observed = Object.keys(keyOf); class LithentElement extends HTMLElement { // A getter rather than a static field: static fields compile to helpers. static get observedAttributes() { return observed; } constructor() { super(); const state: State = { p: {} }; states.set(this, state); // An absent Boolean attribute is `false`, before any attribute callback. for (const key in spec) if (spec[key] === Boolean) state.p[key] = false; } connectedCallback() { const state = stateOf(this); // Still mounted: this is the reconnect half of a move (DC-4). if (state.d) return; // FR-4: a value assigned before the element was defined is an own // property that hides the prototype accessor (DESIGN §4.2). Take it over // here rather than in the constructor: an upgrade runs the attribute // callbacks after the constructor and before this, and the property is // the richer value. lithent sets both on an element it renders before // the definition (B-3), so an object or `false` must not be turned back // into the attribute string. for (const key in spec) { // Object.hasOwn, not this.hasOwnProperty: a prop may be named that. if (Object.hasOwn(this, key)) { const value = (this as unknown as Props)[key]; delete (this as unknown as Props)[key]; // Through the accessor, so `undefined` falls back to the attribute // (or `false`) as everywhere else. (this as unknown as Props)[key] = value; } } // DC-8: open shadow root by default; `shadow: false` renders into the // element itself. A shadow root survives disconnects, so reuse it. let root = state.root; if (!root) { root = state.root = shadow ? this.attachShadow({ mode: shadow === 'closed' ? 'closed' : 'open' }) : this; // Styles go in once per root: the root, and a fallback