import { html, svg, render } from 'lit-html' import { Signal } from 'signal-polyfill' export { html, svg } export type RenderFunction = () => ReturnType export type CleanupFunction = () => void export type EffectFunction = () => CleanupFunction | void export type StylePropsFunction = ( props: Record ) => void interface EffectEntry { fn: EffectFunction computed?: Signal.Computed watcher?: any cleanup?: CleanupFunction | void } type AttributeSignals = { [K in Attrs]: Signal.State } export type ComponentContext = AttributeSignals & { internals: ElementInternals effect: (fn: EffectFunction) => void styleProps: StylePropsFunction } export type FunctionalComponent = (context: ComponentContext) => RenderFunction export interface DefineOptions { setup: FunctionalComponent tagName?: string attributes?: Attrs[] useShadow?: boolean formAssociated?: boolean styles?: string } const RESERVED_KEYS = new Set(['internals', 'effect', 'styleProps']) // Convert camelCase property key to kebab-case CSS custom property name. // `hueShift` -> `--hue-shift`, `hue` -> `--hue` function toCustomProperty(key: string): string { const kebab = key .replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2') .replaceAll(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') .toLowerCase() return `--${kebab}` } function lightElement(fn: FunctionalComponent): void function lightElement(styles: string, fn: FunctionalComponent): void function lightElement( observedAttributes: Attrs[], fn: FunctionalComponent ): void function lightElement( observedAttributes: Attrs[], styles: string, fn: FunctionalComponent ): void function lightElement( a: FunctionalComponent | Attrs[] | string, b?: FunctionalComponent | string, c?: FunctionalComponent ): void { const { setup, attributes, styles } = resolveOverload(a, b, c) define({ setup, attributes, styles, useShadow: false }) } function shadowElement(fn: FunctionalComponent): void function shadowElement(styles: string, fn: FunctionalComponent): void function shadowElement( observedAttributes: Attrs[], fn: FunctionalComponent ): void function shadowElement( observedAttributes: Attrs[], styles: string, fn: FunctionalComponent ): void function shadowElement( a: FunctionalComponent | Attrs[] | string, b?: FunctionalComponent | string, c?: FunctionalComponent ): void { const { setup, attributes, styles } = resolveOverload(a, b, c) define({ setup, attributes, styles, useShadow: true }) } function resolveOverload( a: FunctionalComponent | Attrs[] | string, b?: FunctionalComponent | string, c?: FunctionalComponent ): { setup: FunctionalComponent; attributes?: Attrs[]; styles?: string } { if (typeof a === 'function') { return { setup: a as FunctionalComponent } } if (typeof a === 'string') { return { styles: a, setup: b as FunctionalComponent } } if (typeof b === 'function') { return { attributes: a, setup: b } } return { attributes: a, styles: b, setup: c! } } const state = (value: T) => new Signal.State(value) const computed = (fn: () => T) => new Signal.Computed(fn) export { state, computed, lightElement, shadowElement } export function define(options: DefineOptions) { const { setup, tagName, attributes = [] as unknown as Attrs[], useShadow = true, formAssociated = false, styles } = options const elementName = (tagName ?? setup.name .replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2') .replaceAll(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') .toLowerCase()) if (!elementName.includes('-')) { throw new Error(`Function ${setup.name} must include at least one capital letter to be converted to a valid custom element name`) } if (customElements.get(elementName)) { throw new Error(`Custom element with name ${elementName} already defined`) } attributes.forEach(attr => { if (RESERVED_KEYS.has(attr)) { throw new Error(`Attribute name "${attr}" conflicts with a reserved context property.`) } }) // One constructed stylesheet per element type, shared across all instances. // For shadow DOM, it's adopted into each shadowRoot. // For light DOM, it's wrapped in @scope and adopted into the document once. let stylesheet: CSSStyleSheet | undefined if (styles !== undefined) { stylesheet = new CSSStyleSheet() if (useShadow) { stylesheet.replaceSync(styles) } else { stylesheet.replaceSync(`@scope (${elementName}) { ${styles} }`) document.adoptedStyleSheets = [ ...document.adoptedStyleSheets, stylesheet ] } } customElements.define(elementName, class extends HTMLElement { #template: Signal.Computed> | undefined #watcher: any #renderTemplate: (() => void) | undefined #effects: EffectEntry[] = [] #attributeSignals: Map> = new Map() static get observedAttributes() { return attributes } static get formAssociated() { return formAssociated } constructor() { super() if (useShadow) this.attachShadow({ mode: 'open' }) if (stylesheet && this.shadowRoot) { this.shadowRoot.adoptedStyleSheets = [ ...this.shadowRoot.adoptedStyleSheets, stylesheet ] } const context = { internals: this.attachInternals(), effect: (fn: EffectFunction) => this.#effects.push({ fn }), styleProps: (props: Record) => { Object.keys(props).forEach(key => { const value = props[key] const name = toCustomProperty(key) if (value === null) { this.style.removeProperty(name) } else { this.style.setProperty(name, String(value)) } }) } } as ComponentContext attributes.forEach(attr => { const signal = new Signal.State(this.getAttribute(attr), { equals: (prev, next) => prev === next }) this.#attributeSignals.set(attr, signal) ;(context as Record)[attr] = signal Object.defineProperty(this, attr, { get() { return signal.get() }, set(value: unknown) { if (value !== null && typeof value !== 'string') { throw new TypeError(`Attribute "${attr}" only accepts strings or null, got ${typeof value}.`) } signal.set(value) }, enumerable: true, configurable: true }) const watcher = new Signal.subtle.Watcher(() => { // Microtask required: setAttribute triggers attributeChangedCallback // which calls signal.set(), and writing to a signal inside a watcher // notify callback is not allowed. queueMicrotask(() => { const value = signal.get() if (value === null) { this.removeAttribute(attr) } else { this.setAttribute(attr, value) } watcher.watch() }) }) watcher.watch(signal) }) const setupResult = setup.call(this, context) const target = useShadow ? this.shadowRoot! : this if (typeof setupResult === 'function') { this.#template = new Signal.Computed(() => setupResult()) this.#renderTemplate = () => { const result = this.#template!.get() if (typeof result === 'string') { target.innerHTML = result } else { render(result, target) } } let renderPending = false this.#watcher = new Signal.subtle.Watcher(() => { if (renderPending) return renderPending = true queueMicrotask(() => { renderPending = false try { this.#renderTemplate!() } catch (error) { console.error( `Error in render function for <${elementName}> fun element: `, error, ) } this.#watcher.watch() }) }) } else if (typeof setupResult === 'string') { target.innerHTML = setupResult } else if (typeof setupResult === 'object' && '_$litType$' in (setupResult as object)) { render(setupResult, target) } else if (setupResult === undefined) { return } else { console.error( `Setup function for <${elementName}> returned an unexpected value. ` + `Expected a render function, a template (html\`...\`), a string, or nothing. ` + `Got: ${typeof setupResult}` ) } } connectedCallback() { if (this.#watcher && this.#template) { this.#watcher.watch(this.#template) this.#renderTemplate?.() } this.#effects.forEach(entry => { try { entry.computed = new Signal.Computed(() => entry.fn()) entry.watcher = new Signal.subtle.Watcher(() => { queueMicrotask(() => { if (typeof entry.cleanup === 'function') { entry.cleanup() } entry.computed = new Signal.Computed(() => entry.fn()) try { entry.cleanup = entry.computed.get() } catch (error) { console.error( `Error in effect for <${elementName}> fun element: `, error ) } entry.watcher.watch(entry.computed) }) }) entry.cleanup = entry.computed.get() entry.watcher.watch(entry.computed) } catch (error) { console.error( `Error in effect for <${elementName}> fun element: `, error ) } }) } disconnectedCallback() { if (this.#watcher && this.#template) { this.#watcher.unwatch(this.#template) } // Clean up all effects and stop watching this.#effects.forEach(effectEntry => { effectEntry.watcher.unwatch(effectEntry.computed) if (typeof effectEntry.cleanup === 'function') { effectEntry.cleanup() } effectEntry.cleanup = undefined }) } attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { this.#attributeSignals.get(name)?.set(newValue) } }) }