Skip to content

Instantly share code, notes, and snippets.

@theacodes
Created April 22, 2024 17:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save theacodes/a5ee193d7ca7a80d54a58114e1f5a6f7 to your computer and use it in GitHub Desktop.
Save theacodes/a5ee193d7ca7a80d54a58114e1f5a6f7 to your computer and use it in GitHub Desktop.
Lit @change decorator
/**
* Helper for watching reactive properties (@property) or internal state (@state)
* and calling a callback.
*
* Adapted from Shoelace:
* - https://github.com/shoelace-style/shoelace/blob/64996b2d3512a13d2ec68146fb92164d03e07e6a/src/internal/watch.ts
*/
import type { PropertyValues, ReactiveElement } from "lit";
type MixinBase<ExpectedBase extends ReactiveElement = ReactiveElement> = new (...args: any[]) => ExpectedBase;
type MixinReturn<MixinBase, MixinClass = object> = (new (...args: any[]) => MixinClass) & MixinBase;
type UpdateHandler = (prev?: any, next?: any) => void;
type NonUndefined<A> = A extends undefined ? never : A;
type UpdateHandlerFunctionKeys<T extends object> = {
[K in keyof T]-?: NonUndefined<T[K]> extends UpdateHandler ? K : never;
}[keyof T];
interface WatchOptions {
afterFirstUpdate?: boolean;
when?: "before" | "after";
}
type Watcher = Required<WatchOptions> & {
callback: UpdateHandler;
};
const watchers = Symbol("watchers");
const addWatcher = Symbol("addWatcher");
const notifyWatchers = Symbol("notifyWatchers");
interface IIsWatchable {
[watchers]?: Map<string, Watcher>;
[addWatcher](propertyName: PropertyKey, watcher: Watcher): void;
[notifyWatchers](when: "before" | "after", changedProperties: PropertyValues): void;
}
/**
* Runs when observed properties (@property, @state) change.
*
* This runs *before* but before the component updates, so it's useful for
* *reacting* to property changes, for example, setting multiple attributes
* based on the value of a single one.
*
* The class must have the IsWatchable mixin
*
* To wait for an update to complete after a change occurs, pass in
* `{when: "after"}`
*
* To start watching after the initial render, pass in `{ afterFirstUpdate: true }`
* or use `this.hasUpdated` in the handler.
*
* Example:
*
* @watch('propName')
* handlePropChange(oldValue, newValue) {
* ...
* }
*/
export function watch<T extends IIsWatchable & ReactiveElement, TKey extends keyof T>(
propertyName: TKey,
options?: WatchOptions,
) {
return (proto: T, callbackName: UpdateHandlerFunctionKeys<T>): any => {
(proto.constructor as typeof ReactiveElement).addInitializer((element: ReactiveElement): void => {
const e = element as T;
e[addWatcher](propertyName, {
callback: e[callbackName] as UpdateHandler,
afterFirstUpdate: false,
when: "before",
...options,
});
});
};
}
export const IsWatchable = <T extends MixinBase>(superClass: T) => {
class IsWatchable extends superClass implements IIsWatchable {
[watchers]?: Map<string, Watcher>;
[addWatcher](propertyName: string, watcher: Watcher): void {
if (this[watchers] === undefined) {
this[watchers] = new Map();
}
this[watchers].set(propertyName, watcher);
}
[notifyWatchers](when: "before" | "after", changedProperties: PropertyValues) {
if (!this[watchers]) {
return;
}
for (const [key, watcher] of this[watchers]) {
if (watcher.when !== when) {
continue;
}
if (watcher.afterFirstUpdate && !this.hasUpdated) {
continue;
}
if (!changedProperties.has(key)) {
continue;
}
const oldValue: unknown = changedProperties.get(key);
const newValue: unknown = (this as Record<string, unknown>)[key];
watcher.callback.call(this, oldValue, newValue);
}
}
protected override update(changedProperties: PropertyValues): void {
this[notifyWatchers]("before", changedProperties);
super.update(changedProperties);
this[notifyWatchers]("after", changedProperties);
}
}
return IsWatchable as MixinReturn<T, IIsWatchable>;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment