element-vir
Version:
Heroic. Reactive. Declarative. Type safe. Web components without compromise.
266 lines (265 loc) • 10.8 kB
JavaScript
/* 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;
}