Skip to content

Instantly share code, notes, and snippets.

@rodydavis
Last active January 15, 2025 17:12
Show Gist options
  • Save rodydavis/0f5a10361b96a2bf9b8bc4add8b9ac35 to your computer and use it in GitHub Desktop.
Save rodydavis/0f5a10361b96a2bf9b8bc4add8b9ac35 to your computer and use it in GitHub Desktop.
Signals + Web Components
import { computed, signal } from "@preact/signals-core";
import { type SignalsTemplate, render } from "./signals-template";
import { SignalsWebComponent } from "./signals-web-component";
const tagName = "x-counter";
class Counter extends SignalsWebComponent {
counter = signal(0);
counterStr = computed(() => this.counter.value.toString());
styles = computed(
() => /* css */ `
.counter {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
.stepper {
display: flex;
flex-direction: row;
gap: 10px;
}
}
`
);
template = signal<SignalsTemplate>({
tag: "div",
attributes: {
class: "counter",
},
children: [
{
tag: "h1",
children: [
"Count: ",
{
tag: "span",
children: [this.counterStr],
},
],
},
{
tag: "input",
properties: {
value: this.counterStr,
readOnly: true,
},
attributes: {
type: "text",
// value: this.counterStr,
},
},
{
tag: "div",
attributes: {
class: "stepper",
},
children: [
{
tag: "button",
events: {
click: () => {
this.counter.value--;
console.log("dec", this.counter.value);
},
},
children: ["-"],
},
{
tag: "button",
events: {
click: () => {
this.counter.value++;
console.log("inc", this.counter.value);
},
},
children: ["+"],
},
{
tag: "button",
events: {
click: () => {
this.counter.value = 0;
console.log("reset", this.counter.value);
},
},
children: ["Reset"],
},
],
},
],
});
builder = signal(render(this.template.value, this.cleanup));
}
export default Counter;
customElements.define(tagName, Counter);
declare global {
interface HTMLElementTagNameMap {
[tagName]: Counter;
}
}
import { ReadonlySignal } from "@preact/signals-core";
import { isSignal } from "./signals-web-component";
export interface SignalsTemplate {
tag: string;
options?: ElementCreationOptions;
attributes?: {
[key: string]: string | ReadonlySignal<string>;
};
properties?: {
[key: string]: any | ReadonlySignal<any>;
};
events?: {
[key: string]: (event?: Event) => void;
};
children?: (SignalsTemplate | string | ReadonlySignal<string>)[];
}
export function render(options: SignalsTemplate, cleanup: (() => void)[] = []) {
const { tag, attributes, properties, events, children } = options;
const element = document.createElement(tag, options.options);
if (attributes) {
for (const [key, value] of Object.entries(attributes)) {
if (isSignal(value)) {
cleanup.push(
value.subscribe((newValue) => {
element.setAttribute(key, newValue);
})
);
} else {
element.setAttribute(key, value);
}
}
}
if (properties) {
for (const [key, value] of Object.entries(properties)) {
if (isSignal(value)) {
cleanup.push(
value.subscribe((newValue) => {
// @ts-ignore
element[key] = newValue;
})
);
} else {
if (key in element) {
// @ts-ignore
element[key] = value;
}
}
}
}
if (events) {
for (const [key, value] of Object.entries(events)) {
element.addEventListener(key, value);
cleanup.push(() => {
element.removeEventListener(key, value);
});
}
}
if (children) {
for (const child of children) {
if (typeof child === "string") {
element.appendChild(document.createTextNode(child));
} else {
if (isSignal(child)) {
const node = document.createTextNode(child.value);
element.appendChild(node);
cleanup.push(
child.subscribe((newValue) => {
node.nodeValue = newValue;
})
);
} else {
const template = render(child, cleanup);
element.appendChild(template);
}
}
}
}
return element;
}
import { effect, computed, ReadonlySignal } from "@preact/signals-core";
export type Style = CSSStyleSheet | string;
export abstract class SignalsWebComponent extends HTMLElement {
abstract builder: ReadonlySignal<string | HTMLElement>;
styles: ReadonlySignal<Style | Array<Style>> = computed(() => []);
shouldRenderInShadowRoot = true;
cleanup: (() => void)[] = [];
connectedCallback() {
let root = this.shouldRenderInShadowRoot ? this.shadowRoot : this;
if (this.shouldRenderInShadowRoot && !root) {
root = this.attachShadow({ mode: "open" });
}
this.cleanup.push(
effect(() => {
const value = this.builder.value;
if (typeof value === "string") {
root!.innerHTML = value;
} else if (value instanceof HTMLTemplateElement) {
const node = value.content.cloneNode(true);
root!.innerHTML = "";
root!.appendChild(node);
} else if (value instanceof HTMLElement) {
root!.innerHTML = "";
root!.appendChild(value);
}
})
);
this.cleanup.push(
effect(() => {
const value = this.styles.value;
const styles: CSSStyleSheet[] = [];
const array = Array.isArray(value) ? value : [value];
for (const style of array) {
if (style instanceof CSSStyleSheet) {
styles.push(style);
} else {
const sheet = new CSSStyleSheet();
sheet.replaceSync(style);
styles.push(sheet);
}
}
if (root instanceof ShadowRoot) {
root.adoptedStyleSheets = styles;
} else {
document.adoptedStyleSheets = styles;
}
})
);
}
disconnectedCallback() {
this.cleanup.forEach((cleanup) => cleanup());
}
}
export function isSignal(value: any): value is ReadonlySignal<any> {
return (
value &&
typeof value === "object" &&
"brand" in value &&
value.brand === Symbol.for("preact-signals")
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment