UNPKG

element-vir

Version:

Heroic. Reactive. Declarative. Type safe. Web components without compromise.

235 lines (234 loc) 9.75 kB
/* eslint-disable @typescript-eslint/no-empty-object-type */ import { assert, check } from '@augment-vir/assert'; import { ensureErrorAndPrependMessage, extractErrorMessage, getObjectTypedKeys, kebabCaseToCamelCase, } from '@augment-vir/common'; import { defineCssVars } from 'lit-css-vars'; import { css } from '../template-transforms/vir-css/vir-css.js'; import { DeclarativeElement, } from './declarative-element.js'; import { defaultDeclarativeElementDefinitionOptions, } from './definition-options.js'; import { assignInputs } from './properties/assign-inputs.js'; import { assertValidCssProperties } from './properties/css-properties.js'; import { createEventDescriptorMap, } from './properties/element-events.js'; import { createHostClassNamesMap } from './properties/host-classes.js'; import { bindReactiveProperty, createElementPropertyProxy } from './properties/property-proxy.js'; import { applyHostClasses, createStylesCallbackInput } from './properties/styles.js'; import { createRenderParams } from './render-callback.js'; import { createSlotNamesMap } from './slot-names.js'; /** * Defines an element with inputs. Note that this function must be called twice, due to TypeScript * type inference limitations. * * @category Element Definition * @example * * ```ts * import {defineElement, html} from 'element-vir'; * * const MyElement = defineElement<{username: string}>()({ * tagName: 'my-element', * render({inputs}) { * return html` * <p>hi: ${inputs.username}</p> * `; * }, * }); * ``` */ export function defineElement( /** * These `errorParams` is present when there are problems with the `Inputs` type. If it is * present, the error should be fixed. This should always be empty. */ ...errorParams) { assert.isEmpty(errorParams); return (initInput) => { const init = initInput; if (!check.isObject(init)) { throw new TypeError('Cannot define element with non-object init: ${init}'); } return internalDefineElement({ ...init, options: { ...init.options, }, }); }; } function internalDefineElement(init) { if (!check.isObject(init)) { throw new TypeError('Cannot define element with non-object init: ${init}'); } if (!check.isString(init.tagName)) { throw new TypeError('Missing valid tagName (expected a string).'); } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!init.render || typeof init.render === 'string') { throw new Error(`Failed to define element '${init.tagName}': render is not a function`); } const elementOptions = { ...defaultDeclarativeElementDefinitionOptions, ...init.options, }; const eventsMap = createEventDescriptorMap(init.tagName, init.events); const hostClassNames = createHostClassNamesMap(init.hostClasses); if (init.hostClasses) { assertValidCssProperties(init.tagName, init.hostClasses); } if (init.cssVars) { assertValidCssProperties(init.tagName, init.cssVars); } /** * As casts here are to prevent defineCssVars from complaining that our CSS var names are too * generic or the names not being in kebab-case. (Which, in this line of code, are indeed true * errors. However, this is for internal types only and the user will actually see much more * specific types externally.) */ const cssVars = (init.cssVars ? defineCssVars(init.cssVars) : {}); const slotNamesMap = createSlotNamesMap(init.slotNames); const calculatedStyles = typeof init.styles === 'function' ? init.styles(createStylesCallbackInput({ hostClassNames, cssVars })) : init.styles || css ``; const typedRenderCallback = init.render; function typedAssignCallback(...[inputs]) { const wrappedDefinition = { _elementVirIsMinimalDefinitionWithInputs: true, definition: anonymousClass, inputs, }; return wrappedDefinition; } const anonymousClass = class extends DeclarativeElement { static elementOptions = elementOptions; static tagName = init.tagName; static styles = calculatedStyles; _lastRenderError = undefined; _internalRenderCount = 0; createRenderParams() { return createRenderParams({ element: this, eventsMap, cssVars, slotNamesMap }); } static assign = typedAssignCallback; static events = eventsMap; static render = typedRenderCallback; static hostClasses = hostClassNames; static cssVars = cssVars; static init = init; static slotNames = slotNamesMap; get InstanceType() { throw new Error(`'InstanceType' was called on ${init.tagName} as a value but it is only a type.`); } static get InputsType() { throw new Error(`'InputsType' was called on ${init.tagName} as a value but it is only a type.`); } static get StateType() { throw new Error(`'StateType' was called on ${init.tagName} as a value but it is only a type.`); } static get UpdateStateType() { throw new Error(`'UpdateStateType' was called on ${init.tagName} as a value but it is only a type.`); } _initCalled = false; _stateCalled = false; _hasRendered = false; _lastRenderedProps = undefined; render() { this._internalRenderCount++; try { this._hasRendered = true; const renderParams = this.createRenderParams(); if (!this._stateCalled && init.state) { this._stateCalled = true; const stateInit = init.state(renderParams); if (stateInit instanceof Promise) { throw new TypeError('init cannot be asynchronous'); } getObjectTypedKeys(stateInit).forEach((stateKey) => { bindReactiveProperty(this, stateKey); this.instanceState[stateKey] = stateInit[stateKey]; }); } if (!this._initCalled && init.init) { this._initCalled = true; if (init.init(renderParams) instanceof Promise) { throw new TypeError('init cannot be asynchronous'); } } const renderResult = typedRenderCallback(renderParams); if (renderResult instanceof Promise) { throw new TypeError('render cannot be asynchronous'); } applyHostClasses({ host: renderParams.host, hostClassesInit: init.hostClasses, hostClassNames, state: renderParams.state, inputs: renderParams.inputs, }); this._lastRenderedProps = { inputs: { ...renderParams.inputs }, state: { ...renderParams.state }, }; return renderResult; } catch (caught) { const error = ensureErrorAndPrependMessage(caught, `Failed to render ${init.tagName}`); console.error(error); this._lastRenderError = error; return extractErrorMessage(error); } } connectedCallback() { super.connectedCallback(); if (this._hasRendered && !this._initCalled && init.init) { this._initCalled = true; const renderParams = this.createRenderParams(); if (init.init(renderParams) instanceof Promise) { throw new TypeError(`init in '${init.tagName}' cannot be asynchronous`); } } } destroy() { Object.values(this.instanceState).forEach((stateValue) => { if (check.hasKey(stateValue, 'destroy') && check.isFunction(stateValue.destroy)) { stateValue.destroy(); } }); } disconnectedCallback() { super.disconnectedCallback(); if (init.cleanup) { const renderParams = this.createRenderParams(); if (init.cleanup(renderParams) instanceof Promise) { throw new TypeError(`cleanup in '${init.tagName}' cannot be asynchronous`); } } this.destroy(); this._initCalled = false; this._stateCalled = false; } // this is set below in Object.defineProperties definition = {}; assignInputs(inputs) { assignInputs(this, inputs); } observablePropertyListenerMap = {}; instanceInputs = createElementPropertyProxy(this, false); instanceState = createElementPropertyProxy(this, !elementOptions.allowPolymorphicState); constructor() { super(); this.definition = anonymousClass; } }; Object.defineProperties(anonymousClass, { name: { value: kebabCaseToCamelCase(init.tagName, { capitalizeFirstLetter: true, }), writable: true, }, }); if (window.customElements.get(init.tagName)) { console.warn(`Tried to define custom element '${init.tagName}' but it is already defined.`); } else { window.customElements.define(init.tagName, anonymousClass); } return anonymousClass; }