lume
Version:
185 lines (158 loc) • 6.56 kB
text/typescript
import 'element-behaviors'
import {element} from '@lume/element'
import {PropReceiver} from './PropReceiver.js'
import type {Element as LumeElement} from '@lume/element'
import type {ElementWithBehaviors, PossibleBehaviorConstructor, PossibleBehaviorInstance} from 'element-behaviors'
import type {AnyConstructor} from 'lowclass/dist/Constructor.js'
/**
* Alias of the `@element` decorator used on custom elements for use on Behavior
* classes. If a name is passed in, it defines an element behavior instead of a
* custom element.
*
* Besides defining an element behavior instead of a custom element, it re-uses
* the `@element` implementation: sets up `observedAttributes`,
* `attributeChangedCallback`, and makes properties be Solid signals by
* composing `@reactive` and `@signal` decorators).
*
* Example:
*
* ```js
* @behavior('my-behavior')
* class MyBehavior extends Behavior {
* @numberAttribute foo = 123
* }
* ```
*/
export function behavior(
name: string,
): <T extends AnyConstructor<PossibleBehaviorInstance>>(Class: T, context?: ClassDecoratorContext) => T
export function behavior<T extends AnyConstructor<PossibleBehaviorInstance>>(
Class: T,
context?: ClassDecoratorContext,
): T
export function behavior(
nameOrClass?: string | AnyConstructor<PossibleBehaviorInstance>,
context?: ClassDecoratorContext,
) {
if (typeof nameOrClass === 'string' && context == null) {
return (Class: AnyConstructor<PossibleBehaviorInstance>, context: ClassDecoratorContext) =>
elementBehaviors.define(
nameOrClass,
element(Class as AnyConstructor<HTMLElement>, context) as PossibleBehaviorConstructor,
)
} else if (context && context.kind === 'class') {
return element(nameOrClass as AnyConstructor<HTMLElement>, context)
} else {
throw new TypeError(
'Invalid decorator usage. Call with a string, or as a plain decorator with, only on a class meant to be used as an element behavior.',
)
}
}
/**
* @class Behavior
* Base class for all LUME behaviors.
*
* Features:
* - Sets `static awaitElementDefined` to `true`, which causes `elementBehaviors` to wait until the behavior's host element is upgraded if it might be a custom element (i.e. when the host element has a hyphen in its name).
* - Assigns the host element onto `this.element` for convenience.
* - Calls a subclass's `requiredElementType` method which should return the type (constructor) of allowed elements that the behavior can be hosted on. If the element is not instanceof the `requiredElementType()`, then an error is shown in console. For TypeScript users, it enforces the type of `.element` in subclass code.
* - Forwards the properties specified in `receivedProperties` from `observedObject` to `this` any time `receivedProperties` on `observedObject` change. Useful for forwarding JS properties from the host element to the behavior. This functionality comes from the [`PropReceiver`](./PropReceiver) class.
*
* @extends PropReceiver
*/
export abstract class Behavior extends PropReceiver() {
// If true, elementBehaviors will wait for a custom element to be defined
// before running "connectedCallback" or "disconnectedCallback" on the
// behavior. This guarantees that the host element is already upgraded
// before the life cycle hooks run.
static awaitElementDefined = true
element: Element
constructor(element: ElementWithBehaviors) {
super()
// Ensure this.element is the type specified by a subclass's requiredElementType.
// @prod-prune
this.#checkElementIsLibraryElement(element)
this.element = element
}
/**
* @method requiredElementType - A subclass can override this method in
* order to enforce that the behavior can be applied only on certain types
* of elements by returning an array of constructors. An error will be
* thrown if `this.element` is not an instanceof one of the constructors.
*
* If the element's tag name has a hyphen in it, the logic will consider it
* to possibly be a custom element and will wait for it to be upgraded
* before performing the check; if the custom element is not upgraded within
* a second, an error is thrown.
*
* @returns {[typeof Element]}
*/
requiredElementType() {
return [Element]
}
// used by PropReceiver. See PropReceiver.ts
override get observedObject() {
return this.element
}
// a promise resolved when an element is upgraded
#whenDefined: Promise<unknown> = null! as Promise<unknown>
#elementDefined = false
override receiveInitialValues() {
super.receiveInitialValues()
this.#fowardPreUpgradeValues()
}
#preUpgradeValuesHandled = false
// TODO Write a test to ensure that pre-upgrade values are handled.
#fowardPreUpgradeValues() {
if (this.#preUpgradeValuesHandled) return
const el = this.observedObject
if (!isLumeElement(el)) return
this.#preUpgradeValuesHandled = true
for (const prop of this.receivedProperties ?? []) {
// prettier-ignore
const value = el.
// @ts-expect-error protected access is ok here
_preUpgradeValues
.get(prop)
if (value !== undefined) this[prop as keyof this]
}
}
// TODO add a test to make sure this check works
// @prod-prune
async #checkElementIsLibraryElement(element: Element) {
const classes = this.requiredElementType()
if (element.nodeName.includes('-')) {
this.#whenDefined = customElements.whenDefined(element.nodeName.toLowerCase())
this.#whenDefined.then(() => {
this.#elementDefined = classes.some(Class => element instanceof Class)
this.#fowardPreUpgradeValues()
})
await Promise.race([this.#whenDefined, new Promise(r => setTimeout(r, 1000))])
if (!this.#elementDefined) {
const errorMessage = `
Either the element you're using the behavior on
(<${element.tagName.toLowerCase()}>) is not an instance of one
of the allowed classes, or there was a 1-second timeout waiting
for the element to be defined. Please make sure all elements
you intend to use are defined. The allowed classes are:
`
queueMicrotask(() => console.error(errorMessage, classes))
throw new Error(`${errorMessage}
${classes}
`)
}
} else {
const errorMessage = `
The element you're using the mesh behavior on (<${element.tagName.toLowerCase()}>)
is not an instance of one of the following classes:
`
queueMicrotask(() => console.error(errorMessage, classes))
throw new Error(`${errorMessage}
${classes}
`)
}
}
}
function isLumeElement(el: Element): el is LumeElement {
return '_preUpgradeValues' in el
}