UNPKG

element-vir

Version:

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

266 lines (265 loc) 10.8 kB
/* eslint-disable @typescript-eslint/no-empty-object-type */ import { assert, check } from '@augment-vir/assert'; import { StringCase, 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 { createEventDescriptorMap, } from './properties/element-events.js'; import { createHostClassNamesMap } from './properties/host-classes.js'; import { bindReactiveProperty, createElementPropertyProxy } from './properties/property-proxy.js'; import { assertValidStringNames, createSlotNamesMap, createStringNameMap, } from './properties/string-names.js'; import { applyHostClasses, createStylesCallbackInput } from './properties/styles.js'; import { createRenderParams } from './render-callback.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: ${String(init)}`); } return internalDefineElement({ ...init, options: { ...init.options, }, }); }; } function internalDefineElement(init) { if (!check.isObject(init)) { throw new TypeError(`Cannot define element with non-object init: ${String(init)}`); } else 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) { assertValidStringNames(init.tagName, Object.keys(init.hostClasses)); } if (init.cssVars) { assertValidStringNames(init.tagName, Object.keys(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.tagName, init.slotNames); const testIdsMap = createStringNameMap(init.tagName, 'test-id', init.testIds); const calculatedStyles = typeof init.styles === 'function' ? init.styles(createStylesCallbackInput({ hostClassNames, cssVars, slotNamesMap, })) : 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, testIdsMap, }); } static assign = typedAssignCallback; static events = eventsMap; static render = typedRenderCallback; static hostClasses = hostClassNames; static cssVars = cssVars; static init = init; static slotNames = slotNamesMap; static testIds = testIdsMap; 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('state 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; void elementOptions.errorHandler?.(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(); } }); this._initCalled = false; this._stateCalled = false; } disconnectedCallback() { super.disconnectedCallback(); /** * Always reset bookkeeping and run `destroy()`, even when `cleanup` throws — otherwise * a throwing cleanup leaves `_initCalled` / `_stateCalled` stuck and corrupts the next * mount. */ try { if (init.cleanup && this._stateCalled) { const renderParams = this.createRenderParams(); if (init.cleanup(renderParams) instanceof Promise) { throw new TypeError(`cleanup in '${init.tagName}' cannot be asynchronous`); } } } finally { this.destroy(); } } // 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, { firstLetterCase: StringCase.Upper, }), writable: true, }, }); /** * `window` will be `undefined` in Node.js and we want to be able to import these files into * Node.js. */ if (globalThis.window) { if (globalThis.window.customElements.get(init.tagName)) { console.warn(`Tried to define custom element '${init.tagName}' but it is already defined.`); } else { globalThis.window.customElements.define(init.tagName, anonymousClass); } } return anonymousClass; }