UNPKG

@furystack/shades

Version:

Google Authentication Provider for FuryStack

294 lines (249 loc) 10.3 kB
import type { Constructable } from '@furystack/inject' import { hasInjectorReference, Injector } from '@furystack/inject' import { ObservableValue } from '@furystack/utils' import type { ChildrenList, PartialElement, RenderOptions } from './models/index.js' import { LocationService } from './services/location-service.js' import { ResourceManager } from './services/resource-manager.js' import { attachProps, attachStyles } from './shade-component.js' export type ShadeOptions<TProps, TElementBase extends HTMLElement> = { /** * Explicit shadow dom name. Will fall back to 'shade-{guid}' if not provided */ shadowDomName: string /** * Render hook, this method will be executed on each and every render. */ render: (options: RenderOptions<TProps, TElementBase>) => JSX.Element | string | null /** * Construct hook. Will be executed once when the element has been constructed and initialized */ constructed?: ( options: RenderOptions<TProps, TElementBase>, ) => void | undefined | (() => void) | Promise<void | undefined | (() => void)> /** * Will be executed when the element is attached to the DOM. */ onAttach?: (options: RenderOptions<TProps, TElementBase>) => void /** * Will be executed when the element is detached from the DOM. */ onDetach?: (options: RenderOptions<TProps, TElementBase>) => void /** * Name of the HTML Element's base class. Needs to be defined if the elementBase is set. E.g.: 'div', 'button', 'input' */ elementBaseName?: string /** * Base class for the custom element. Defaults to HTMLElement. E.g. HTMLButtonElement */ elementBase?: Constructable<TElementBase> /** * A default style that will be applied to the element. Can be overridden by external styles. */ style?: Partial<CSSStyleDeclaration> } /** * Factory method for creating Shade components * @param o for component creation * @returns the JSX element */ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>( o: ShadeOptions<TProps, TElementBase>, ) => { // register shadow-dom element const customElementName = o.shadowDomName const existing = customElements.get(customElementName) if (!existing) { const ElementBase = o.elementBase || HTMLElement customElements.define( customElementName, class extends (ElementBase as Constructable<HTMLElement>) implements JSX.Element { private _renderCount = 0 /** * @returns the current render count */ public getRenderCount() { return this._renderCount } public resourceManager = new ResourceManager() public connectedCallback() { o.onAttach?.(this.getRenderOptions()) this.callConstructed() } public async disconnectedCallback() { o.onDetach?.(this.getRenderOptions()) await this.resourceManager[Symbol.asyncDispose]() this.cleanup?.() } /** * Will be triggered when updating the external props object */ public props!: TProps & { children?: JSX.Element[] } & PartialElement<TElementBase> /** * Will be updated when on children change */ public shadeChildren?: ChildrenList /** * @param options Options for rendering the component * @returns the JSX element */ public render = (options: RenderOptions<TProps, TElementBase>) => { this._renderCount++ return o.render(options) } /** * @returns values for the current render options */ private getRenderOptions = (): RenderOptions<TProps, TElementBase> => { const renderOptions: RenderOptions<TProps, TElementBase> = { props: this.props, injector: this.injector, children: this.shadeChildren, element: this, renderCount: this._renderCount, useObservable: (key, obesrvable, options) => { const onChange = options?.onChange || (() => this.updateComponent()) return this.resourceManager.useObservable(key, obesrvable, onChange, options) }, useState: (key, initialValue) => this.resourceManager.useState(key, initialValue, this.updateComponent.bind(this)), useSearchState: (key, initialValue) => this.resourceManager.useObservable( `useSearchState-${key}`, this.injector.getInstance(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 } /** * Updates the component in the DOM. */ public updateComponent() { const renderResult = this.render(this.getRenderOptions()) if (renderResult === null || renderResult === undefined) { this.innerHTML = '' } if (typeof renderResult === 'string' || typeof renderResult === 'number') { this.innerHTML = renderResult } if (renderResult instanceof HTMLElement) { this.replaceChildren(renderResult) } if (renderResult instanceof DocumentFragment) { this.replaceChildren(renderResult) } } /** * Finialize the component initialization after it gets the Props. Called by the framework internally */ public callConstructed() { this.updateComponent() const cleanupResult = o.constructed && o.constructed(this.getRenderOptions()) if (cleanupResult instanceof Promise) { cleanupResult .then((cleanup) => (this.cleanup = cleanup)) .catch(() => { /** */ }) } else { // construct is not async this.cleanup = cleanupResult } } private cleanup: void | (() => void) = undefined 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 = hasInjectorReference(this.props) && this.props.injector if (fromProps && fromProps instanceof Injector) { return fromProps } const fromParent = this.getInjectorFromParent() if (fromParent) { this._injector = fromParent return fromParent } // Injector not set explicitly and not found on parents! return new Injector() } public set injector(i: Injector) { this._injector = i } }, o.elementBaseName ? { extends: o.elementBaseName } : undefined, ) } else { throw Error(`A custom shade with shadow DOM name '${o.shadowDomName}' 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 attachStyles(el, { style: o.style }) attachProps(el, props) return el as JSX.Element } }