import { Cleanup, MaybeSignal, Signal, useEffect, useSubscope, useSignal, SignalLike, } from "./scope.js"; import type { DomEventProps, DomProps } from "./dom.js"; import { runWithRenderer } from "./renderer.js"; import { camelCaseToKebabCase, EventNameToJsxProp, JsxPropNameToEventName, jsxPropNameToEventName, } from "./utils.js"; import { useScope } from "./scope.js"; import { Context, isContext, provideContext } from "./context.js"; import { Template, TemplateNodes } from "./template.js"; interface Tagged { _tag: T; } type OmitNever = Omit< T, { [K in keyof T]: T[K] extends never ? K : never }[keyof T] >; type PartialRequire = Omit & Required>; /** @ignore */ export interface PropMeta extends PropOptions, Tagged<"p"> { _type?: [T]; _defaultOrContext: T; } export interface AttributeOptions { /** * The name of the attribute to observe. * * Defaults to the kebab-case version of the prop. */ name?: string; /** * A function to transform the attribute value to the prop value. */ transform: (value: string) => T; /** * Set to `true` to not observe the attribute for changes. * * @default false */ static?: boolean; } type PartialPartial = Omit & Partial>; export interface PropOptions { attribute?: | ((value: string) => T) | (string extends T ? PartialPartial, "transform"> : AttributeOptions); } type Props = OmitNever<{ readonly [K in keyof M]: M[K] extends PropMeta ? Signal : never; }>; export type EventConstructor = new ( name: string, arg: T, ) => E; /** @ignore */ export interface EventMeta extends Tagged<"e"> { _event: E; } type Events = OmitNever< Omit< { readonly [K in keyof M]: K extends `on${string}` ? M[K] extends EventMeta ? E : never : never; }, `on${Lowercase}` > >; type GeneralJsxProps = Partial< OmitNever<{ [K in keyof T]: K extends | typeof jsxPropsSym | keyof DomProps | `on${string}` // Readonly HTMLElement properties | `${Uppercase}${string}` | "accessKeyLabel" | "offsetHeight" | "offsetLeft" | "offsetParent" | "offsetTop" | "offsetWidth" | "attributes" | "classList" | "clientHeight" | "clientLeft" | "clientTop" | "clientWidth" | "localName" | "namespaceURI" | "ownerDocument" | "part" | "prefix" | "scrollHeight" | "scrollWidth" | "shadowRoot" | "tagName" | "baseURI" | "childNodes" | "firstChild" | "isConnected" | "lastChild" | "nextSibling" | "nodeName" | "nodeType" | "parentElement" | "parentNode" | "previousSibling" | "nextElementSibling" | "previousElementSibling" | "childElementCount" | "firstElementChild" | "lastElementChild" | "assignedSlot" | "attributeStyleMap" | "isContentEditable" | "dataset" ? never : T[K] extends Function ? never : MaybeSignal; }> > & DomProps & DomEventProps & // Allow other HTMLElement attributes Record; export type JsxProps = typeof jsxPropsSym extends keyof T ? NonNullable : any; type ComponentJsxProps = Partial< OmitNever<{ [K in keyof Props]: Props[K] extends Signal ? MaybeSignal : never; }> & { [K in keyof Events]: (evt: InstanceType[K]>) => void; } > & GeneralJsxProps; type EventEmitters = OmitNever< Omit< { [K in keyof Events]: Events[K] extends EventConstructor ? undefined extends E ? (arg?: E) => boolean : (arg: E) => boolean : never; }, `on${Lowercase}` > >; /** * Defines a property in your component metadata that can be set from outside * of the component. * * Make sure to avoid conflicts with native `HTMLElement` properties. * * You can get properties by accessing the {@link Signal} in `this.props`. * It's also possible to set the properties directly on the component instance. * * It's also possible to define an attribute for the property by setting the * `attribute` option. By default, the attribute name is the kebab-case version * of the property name. The attribute will be observed and the signal updated * on changes. You can also provide a custom name and a transform function to * convert the attribute value to the property value. * * @example * ```tsx * class App extends Component("x-app", { * greetingMessage: prop("Hello, world!", { * attribute: { * name: "greeting", * } * }), * }) { * render() { * return

{this.props.greetingMessage}

; * } * } * * defineComponents(App); * * const app = new App(); * app.greetingMessage = "Hello, universe!"; * ``` */ export const prop: (( context: Context, opts?: PropOptions, ) => PropMeta) & ((defaultValue: T, opts?: PropOptions) => PropMeta) & (( defaultValue?: T, opts?: PropOptions, ) => PropMeta) = ( defaultOrContext?: Context | T, opts?: PropOptions, ): PropMeta => ({ _tag: "p", _defaultOrContext: defaultOrContext, ...opts, }); // CustomEvent has a flaw in its constructor signature since it allows // `detail` to be optional. This is a workaround to make it required unless // `undefined` can be assigned to `T`. type _CustomEventContructor = undefined extends T ? typeof CustomEvent : EventConstructor< PartialRequire, "detail">, CustomEvent >; /** * Defines an event in your component metadata that can be dispatched by * the component. * * Make sure your event name starts with `on` and to avoid conflicts with * native `HTMLElement` events. The event name will be converted to kebab-case. * * You can dispatch events either using `HTMLElement.dispatchEvent` or by * calling the event emitter function in `this.events` inside the `render` * function of a component. * * @example * ```tsx * class App extends Component("x-app", { * onSomethingHappen: event(), * // Event name will be `something-happen` * }) { * render() { * // … * this.events.onSomethingHappen({ detail: "Something happened! "}); * } * } * * const app = new App(); * app.addEventListener("something-happen", (evt) => { * // `evt` is `CustomEvent` * console.log(evt.detail); * }); * ``` * * You can also provide a custom event constructor: * * @example * ```tsx * class App extends Component("x-app", { * onSomethingClick: event(() => MouseEvent), * }) { * render() { * return ( * * ); * } * } * ``` */ export const event: (() => EventMeta<_CustomEventContructor>) & (() => EventMeta<_CustomEventContructor>) & ((eventConstructor: E) => EventMeta) = (( eventConstructor: EventConstructor = CustomEvent, ): EventMeta => ({ _tag: "e", _event: eventConstructor, })) as any; export type Metadata = { // Forbid all library properties [K in keyof _ComponentInner | "props" | "events"]?: never; } & { // Forbid all dom props [K in keyof DomProps]?: never; } & { // Forbid all HTMLElement props [K in keyof HTMLElement]?: never; } & { [name: string]: PropMeta | EventMeta; }; export const componentSym = Symbol("Component"); export declare const jsxPropsSym: unique symbol; export declare abstract class _ComponentInner { protected props: Props; protected events: EventEmitters; readonly [jsxPropsSym]?: ComponentJsxProps; readonly [componentSym]: { _scope?: ReturnType; _destroy?: (() => void) | void; }; connectedCallback(): void; disconnectedCallback(): void; attributeChangedCallback( name: string, oldValue: string | null, value: string | null, ): void; addEventListener & string>>( type: K, listener: ( event: InstanceType< Events[Extract, keyof Events>] >, ) => void, options?: boolean | AddEventListenerOptions, ): void; removeEventListener< K extends JsxPropNameToEventName & string>, >( type: K, listener: ( event: InstanceType< Events[Extract, keyof Events>] >, ) => void, options?: boolean | EventListenerOptions, ): void; abstract render(): Template; } export type Component = { -readonly [K in keyof Props]: Props[K] extends Signal ? T | undefined : never; } & _ComponentInner & HTMLElement; export interface ComponentConstructor { /** @ignore */ readonly [componentSym]: { readonly _tagName: string | null; }; readonly observedAttributes: readonly string[]; new (): Component; } export interface ComponentOptions { /** * Shadow DOM options. Set to `false` to disable shadow DOM. * * @default { mode: "open" } */ shadow?: ShadowRootInit | false; } let mountEffects: | [fn: () => Cleanup, deps?: SignalLike[]][] | undefined; /** * Creates an effect which will rerun when any accessed signal changes. * * If used inside of a component and the component is not yet mounted, the * effect will run only after the component is mounted. Otherwise, the effect * will run immediately. * * @param fn The function to run; it may return a cleanup function. */ export const useMountEffect = ( fn: () => Cleanup, deps?: SignalLike[], ): void => { if (mountEffects) { mountEffects.push([fn, deps]); } else { useEffect(fn, deps); } }; /** * Creates a new web component class. * * Specify props and events using the `metadata` parameter. * * @example * ```tsx * class MyComponent extends Component({ * myProp: prop("Hello, world!"), * onMyEvent: event(), * }) { * render() { * return ( * <> *

{this.props.myProp}

* * * ); * }, * } * * defineComponents(MyComponent); * ``` */ export const Component: ((tagName?: string) => ComponentConstructor<{}>) & (( tagName: string, metadata: M, opts?: ComponentOptions, ) => ComponentConstructor) & (( metadata: M, opts?: ComponentOptions, ) => ComponentConstructor) = (( tagNameOrMetadata?: string | Metadata, metadataOrOpts?: Metadata | ComponentOptions, optsParam?: ComponentOptions, ): ComponentConstructor => { const tagName = typeof tagNameOrMetadata === "string" ? tagNameOrMetadata : null; const metadata = typeof tagNameOrMetadata === "string" ? (metadataOrOpts as Metadata) : tagNameOrMetadata; const opts = (typeof tagNameOrMetadata === "string" ? optsParam : (metadataOrOpts as ComponentOptions)) ?? {}; // Extract attribute information const observedAttributes: string[] = []; const attributePropMap = new Map< string, { name: string; meta: PropMeta & { attribute: Required< NonNullable["attribute"], boolean | Function>> >; }; } >(); for (const name in metadata) { const meta = metadata[name] as PropMeta | EventMeta; if (meta._tag == "p" && meta.attribute) { if (typeof meta.attribute == "function") { meta.attribute = { transform: meta.attribute }; } const attribute: AttributeOptions = (meta.attribute = { name: camelCaseToKebabCase(name), static: false, transform: (x) => x, ...meta.attribute, }); attributePropMap.set(attribute.name!, { name, meta: meta as any, }); if (!attribute.static) { observedAttributes.push(attribute.name!); } } } // Create base class opts.shadow ??= { mode: "open" }; const getRenderParent = (component: _Component) => opts.shadow ? (component.shadowRoot ?? component.attachShadow(opts.shadow)) : component; abstract class _Component extends HTMLElement { static readonly [componentSym]: ComponentConstructor[typeof componentSym] = { _tagName: tagName, }; static readonly observedAttributes: readonly string[] = observedAttributes; protected props: Record> = {}; protected events: Record any> = {}; readonly [componentSym]: _ComponentInner[typeof componentSym] = {}; constructor() { super(); for (const name in metadata) { const meta = metadata[name]; if (meta._tag == "p") { const context = isContext(meta._defaultOrContext) ? meta._defaultOrContext : null; const [getter, setter] = useSignal( context ? undefined : meta._defaultOrContext, ); this.props[name] = getter; if (context) { provideContext(context, this, getter); } Object.defineProperty(this, name, { get: getter.peek, set: (value) => setter( () => !context && value === undefined ? meta._defaultOrContext : value, { force: true }, ), }); } else if (meta._tag == "e" && name.startsWith("on")) { const eventName = jsxPropNameToEventName(name as `on${string}`); this.events[name] = (arg: unknown) => this.dispatchEvent(new meta._event(eventName, arg)); } } } connectedCallback(): void { const renderParent = getRenderParent(this); this[componentSym]._destroy = useSubscope(() => runWithRenderer( { _svg: false, _component: this as any, _nodes: renderParent.childNodes.values(), }, () => { this[componentSym]._scope = useScope(); // Render const prevMountEffects = mountEffects; mountEffects = []; try { TemplateNodes.forEach(this.render().build(), (node) => { renderParent.append(node); }); // Run mount effects mountEffects.forEach(([fn, opts]) => useEffect(fn, opts)); } finally { mountEffects = prevMountEffects; } }, ), )[1]; } disconnectedCallback(): void { this[componentSym]._destroy?.(); } attributeChangedCallback( name: string, _: string | null, value: string | null, ): void { const prop = attributePropMap.get(name); if (prop) { this[prop.name as keyof this] = value != null ? prop.meta.attribute.transform.call(this, value) : undefined; } } abstract render(): Template; } return _Component as any; }) as any; /** * Determines whether the given value is a component created by * extending {@link ComponentConstructor}. */ export const isComponent = ( value: any, ): value is ComponentConstructor | Component => !!value?.[componentSym]; /** * Represents a functional component. * * @example * ```tsx * const MyComponent: FunctionalComponent<{ * name: MaybeSignal; * }> = ({ name }) => { * return

Hello, {name}!

; * }; * ``` */ export interface FunctionalComponent { (props: P): Template; } /** * Defines a set of components with the given prefix. */ export const defineComponents: (( ...components: ComponentConstructor[] ) => void) & ((prefix: string, ...components: ComponentConstructor[]) => void) = ( ...args: [string | ComponentConstructor, ...ComponentConstructor[]] ) => { const [prefix, components] = typeof args[0] == "string" ? [args[0], args.slice(1) as ComponentConstructor[]] : ["", args as ComponentConstructor[]]; for (const component of components) { customElements.define( prefix + (component[componentSym]._tagName ?? camelCaseToKebabCase(component.name)), component, ); } };