UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

462 lines (400 loc) 17.3 kB
import type { Constructable } from '@furystack/core' import { Injector } from '@furystack/inject' import { ObservableValue } from '@furystack/utils' import type { ChildrenList, CSSObject, PartialElement, RenderOptions } from './models/index.js' import type { RefObject } from './models/render-options.js' import { LocationService } from './services/location-service.js' import { ResourceManager } from './services/resource-manager.js' import { attachProps, attachStyles, setRenderMode } from './shade-component.js' import { StyleManager } from './style-manager.js' import type { VChild } from './vnode.js' import { patchChildren, toVChildArray } from './vnode.js' export type ShadeOptions<TProps, TElementBase extends HTMLElement> = { /** * The custom element tag name used to register the component. * Must follow the Custom Elements naming convention (lowercase, must contain a hyphen). * * @example 'my-button', 'shade-dialog', 'app-header' */ customElementName: string render: (options: RenderOptions<TProps, TElementBase>) => JSX.Element | string | null /** * Tag name of the base built-in element when extending one (e.g. `'a'`, * `'button'`, `'input'`). Required when {@link ShadeOptions.elementBase} * is set; passed to `customElements.define` as `{ extends }`. */ elementBaseName?: string /** * Constructor of the built-in element to extend (e.g. `HTMLButtonElement`). * Defaults to `HTMLElement`. Pair with {@link ShadeOptions.elementBaseName}. */ elementBase?: Constructable<TElementBase> /** * Inline styles applied to each component instance. * Use for per-instance dynamic overrides. Prefer `css` for component-level defaults. */ style?: Partial<CSSStyleDeclaration> /** * CSS styles injected as a stylesheet during component registration. * Supports pseudo-selectors (`&:hover`, `&:active`) and nested selectors (`& .class`). * * **Best practice:** Prefer `css` over `style` for component defaults -- styles are injected * once per component type (better performance), and support pseudo-selectors and nesting. * * @example * ```typescript * css: { * display: 'flex', * padding: '16px', * '&:hover': { backgroundColor: '#f0f0f0' }, * '& .title': { fontWeight: 'bold' } * } * ``` */ css?: CSSObject } /** * Defines and registers a Shade component as a custom element. Returns a * JSX-callable factory `(props, children?) => JSX.Element`. Throws when a * component with the same {@link ShadeOptions.customElementName} has * already been registered — registration is global and process-wide. * * The returned factory is the entry point for downstream code. The custom * element class itself is installed via `customElements.define` and never * directly exposed. */ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>( o: ShadeOptions<TProps, TElementBase>, ) => { const { customElementName } = o const existing = customElements.get(customElementName) if (!existing) { // Register CSS styles if provided if (o.css) { StyleManager.registerComponentStyles(customElementName, o.css, o.elementBaseName) } const ElementBase = o.elementBase || HTMLElement customElements.define( customElementName, class extends (ElementBase as Constructable<HTMLElement>) implements JSX.Element { private _renderCount = 0 public getRenderCount() { return this._renderCount } public resourceManager = new ResourceManager() /** * Host props collected during the current render pass via `useHostProps`. * Applied to the host element after each render. */ private _pendingHostProps: Array<Record<string, unknown> & { style?: Record<string, string> }> = [] /** * The host props that were applied in the previous render, used for diffing. */ private _prevHostProps: Record<string, unknown> | null = null /** * Cached ref objects keyed by the user-provided key string. */ private _refs = new Map<string, RefObject<Element>>() /** * Set to true once disconnectedCallback fires. Prevents ghost re-renders * triggered by observable changes during async disposal. */ private _disconnected = false public connectedCallback() { this._disconnected = false this._performUpdate() } public async disconnectedCallback() { this._disconnected = true this._refs.clear() this._prevVTree = null this._prevHostProps = null await this.resourceManager[Symbol.asyncDispose]() } public props!: TProps & { children?: JSX.Element[] } & PartialElement<TElementBase> public shadeChildren?: ChildrenList public render = (options: RenderOptions<TProps, TElementBase>) => { this._renderCount++ return o.render(options) } private getRenderOptions = (): RenderOptions<TProps, TElementBase> => { const renderOptions: RenderOptions<TProps, TElementBase> = { props: this.props, injector: this.injector, children: this.shadeChildren, renderCount: this._renderCount, useHostProps: (hostProps) => { this._pendingHostProps.push(hostProps) }, useRef: <T extends Element = HTMLElement>(key: string): RefObject<T> => { const existingRef = this._refs.get(key) as RefObject<T> | undefined if (existingRef) return existingRef const refObject = { current: null } as { current: T | null } this._refs.set(key, refObject) return refObject }, useObservable: (key, observable, options) => { const onChange = options?.onChange || (() => this.updateComponent()) return this.resourceManager.useObservable(key, observable, onChange, options) }, useState: (key, initialValue) => this.resourceManager.useState(key, initialValue, this.updateComponent.bind(this)), useSearchState: (key, initialValue) => this.resourceManager.useObservable( `useSearchState-${key}`, this.injector.get(LocationService).useSearchParam(key, initialValue), () => this.updateComponent(), ), useStoredState: <T>(key: string, initialValue: T, storageArea = localStorage) => { const getFromStorage = () => { const value = storageArea?.getItem(key) return value ? (JSON.parse(value) as T) : initialValue } const setToStorage = (value: T) => { if (JSON.stringify(value) !== storageArea?.getItem(key)) { const newValue = JSON.stringify(value) storageArea?.setItem(key, newValue) } if (JSON.stringify(observable.getValue()) !== JSON.stringify(value)) { observable.setValue(value) } } const observable = this.resourceManager.useDisposable( `useStoredState-${key}`, () => new ObservableValue(getFromStorage()), ) const updateFromStorageEvent = (e: StorageEvent) => { if (e.key === key && e.storageArea === storageArea) { setToStorage((e.newValue && (JSON.parse(e.newValue) as T)) || initialValue) } } this.resourceManager.useDisposable(`useStoredState-${key}-storage-event`, () => { window.addEventListener('storage', updateFromStorageEvent) const channelName = `useStoredState-broadcast-channel` const messageChannel = new BroadcastChannel(channelName) messageChannel.onmessage = (e: MessageEvent<{ key?: string; value: T }>) => { if (e.data.key === key) { setToStorage(e.data.value) } } const subscription = observable.subscribe((value) => { messageChannel.postMessage({ key, value }) }) return { [Symbol.dispose]: () => { window.removeEventListener('storage', updateFromStorageEvent) subscription[Symbol.dispose]() messageChannel.close() }, } }) observable.subscribe(setToStorage) return this.resourceManager.useObservable(`useStoredState-${key}`, observable, () => this.updateComponent(), ) }, useDisposable: this.resourceManager.useDisposable.bind(this.resourceManager), } return renderOptions } private _updateScheduled = false /** * The VChild array from the previous render, with `_el` references * pointing to the real DOM nodes. Used to diff against the next render. */ private _prevVTree: VChild[] | null = null /** * Schedules a component update via microtask. Multiple calls before the microtask * runs are coalesced into a single render pass. */ public updateComponent() { if (this._disconnected) return if (!this._updateScheduled) { this._updateScheduled = true queueMicrotask(() => { if (!this._updateScheduled || this._disconnected) return this._updateScheduled = false this._performUpdate() }) } } /** * Performs a synchronous component update, canceling any pending async update. * Used during parent-to-child reconciliation so the entire subtree settles * in a single call frame rather than cascading across microtask ticks. */ public updateComponentSync() { if (this._disconnected) return this._updateScheduled = false this._performUpdate() } private _performUpdate() { this._pendingHostProps = [] let renderResult: unknown setRenderMode(true) try { renderResult = this.render(this.getRenderOptions()) } finally { setRenderMode(false) } // Apply host props before patching children so that child components // rendered synchronously can discover parent state (e.g. injector) // via getInjectorFromParent(). this._applyHostProps() const newVTree = toVChildArray(renderResult) patchChildren(this, this._prevVTree || [], newVTree) this._prevVTree = newVTree } /** * Merges all pending host props from the render pass and applies them * to the host element, diffing against the previously applied host props. */ private _applyHostProps() { if (this._pendingHostProps.length === 0) { if (this._prevHostProps) { // All host props were removed — clean up for (const key of Object.keys(this._prevHostProps)) { if (key === 'style') continue this.removeAttribute(key) } if (this._prevHostProps.style) { for (const sk of Object.keys(this._prevHostProps.style)) { if (sk.startsWith('--')) { this.style.removeProperty(sk) } else { ;(this.style as unknown as Record<string, string>)[sk] = '' } } } this._prevHostProps = null } return } // Merge all pending host prop calls into a single object const merged: Record<string, unknown> = {} let mergedStyle: Record<string, string> | undefined for (const hp of this._pendingHostProps) { for (const [key, value] of Object.entries(hp)) { if (key === 'style' && typeof value === 'object' && value !== null) { mergedStyle = { ...mergedStyle, ...(value as Record<string, string>) } } else { merged[key] = value } } } if (mergedStyle) { merged.style = mergedStyle } const oldHP = this._prevHostProps || {} const newHP = merged // Remove attributes no longer present for (const key of Object.keys(oldHP)) { if (key === 'style') continue if (!(key in newHP)) { if (key.startsWith('on') && typeof oldHP[key] === 'function') { ;(this as unknown as Record<string, unknown>)[key] = null } else { this.removeAttribute(key) } } } // Apply new/changed attributes for (const [key, value] of Object.entries(newHP)) { if (key === 'style') continue if (oldHP[key] !== value) { if (typeof value === 'function' || (typeof value === 'object' && value !== null)) { ;(this as unknown as Record<string, unknown>)[key] = value } else if (value === null || value === undefined || value === false) { if (key in this) { ;(this as unknown as Record<string, unknown>)[key] = undefined } this.removeAttribute(key) } else { // eslint-disable-next-line @typescript-eslint/no-base-to-string this.setAttribute(key, String(value)) } } } // Diff styles const oldStyle = (oldHP.style as Record<string, string>) || {} const newStyle = (mergedStyle as Record<string, string>) || {} for (const sk of Object.keys(oldStyle)) { if (!(sk in newStyle)) { if (sk.startsWith('--')) { this.style.removeProperty(sk) } else { ;(this.style as unknown as Record<string, string>)[sk] = '' } } } for (const [sk, sv] of Object.entries(newStyle)) { if (oldStyle[sk] !== sv) { if (sk.startsWith('--')) { this.style.setProperty(sk, sv) } else { ;(this.style as unknown as Record<string, string>)[sk] = sv } } } this._prevHostProps = merged } private _injector?: Injector private getInjectorFromParent(): Injector | void { let parent = this.parentElement while (parent) { if ((parent as JSX.Element).injector) { return (parent as JSX.Element).injector } parent = parent.parentElement } } public get injector(): Injector { if (this._injector) { return this._injector } const fromProps = (this.props as { injector?: unknown } | undefined)?.injector if (fromProps instanceof Injector) { return fromProps } const fromParent = this.getInjectorFromParent() if (fromParent) { this._injector = fromParent return fromParent } // Fallback for isolated components (tests and non-DI use cases) that // never reach for any services. Components that do resolve tokens // will fail loudly at resolution time, since this throwaway injector // has no bindings. return new Injector() } public set injector(i: Injector) { this._injector = i } }, o.elementBaseName ? { extends: o.elementBaseName } : undefined, ) } else { throw Error(`A custom shade with name '${o.customElementName}' has already been registered!`) } return (props: TProps & PartialElement<TElementBase>, children?: ChildrenList) => { const ElementType = customElements.get(customElementName) const el = new (ElementType as CustomElementConstructor)({ ...(props as TProps & ElementCreationOptions & PartialElement<TElementBase>), }) as JSX.Element<TProps> el.props = props || ({} as TProps & PartialElement<TElementBase>) el.shadeChildren = children if (o.elementBaseName) { el.setAttribute('is', customElementName) } attachStyles(el, { style: o.style }) attachProps(el, props) return el as JSX.Element } } /** * Awaits the next microtask tick — long enough for `updateComponent`'s * batching microtask to drain. A single `await flushUpdates()` settles the * entire component tree because child reconciliation is synchronous within * the parent's render. Use in tests before asserting on DOM state. */ export const flushUpdates = (): Promise<void> => new Promise<void>((resolve) => queueMicrotask(resolve))