Skip to content

Instantly share code, notes, and snippets.

@rodydavis
Last active January 16, 2025 06:46
Show Gist options
  • Save rodydavis/af36c05040998da280d89389d7422345 to your computer and use it in GitHub Desktop.
Save rodydavis/af36c05040998da280d89389d7422345 to your computer and use it in GitHub Desktop.
Lit + Signals
import { computed } from "@preact/signals-core";
import { css } from "lit";
import { html } from "@lit-labs/preact-signals";
import { WithShadowRoot, WithLitTemplate } from "./utils.js";
class Counter extends WithShadowRoot(HTMLElement) {
count = this.attr("count", "0");
countInt = computed(() => parseInt(this.count.value));
private increment() {
this.count.value = (this.countInt.value + 1).toString();
}
private decrement() {
this.count.value = (this.countInt.value - 1).toString();
}
private reset() {
this.count.value = "0";
}
override styles = computed(
() => css`
.actions {
button {
border-radius: 10px;
}
}
`
);
override builder = computed(
() => html`
<span>Count: ${this.count}</span>
<div class="actions">
<button @click=${this.decrement.bind(this)}>-</button>
<button @click=${this.increment.bind(this)}>+</button>
<button @click=${this.reset.bind(this)} ?disabled=${this.countInt.value === 0}>Reset</button>
</div>
`
);
}
export default Counter;
customElements.define("x-counter", Counter);
<!DOCTYPE html>
<head>
<script type="module" src="./counter.js"></script>
</head>
<body>
<x-counter count="1"></x-counter>
</body>
import {
ReadonlySignal,
Signal,
computed,
effect,
signal,
} from "@preact/signals-core";
import { CSSResult, render, TemplateResult, unsafeCSS, html } from "lit";
export class LitSignals extends HTMLElement {
cleanup: (() => void)[] = [];
builder: ReadonlySignal<TemplateResult> = signal(html``);
attrs = new Map<string, Signal>();
constructor() {
super();
}
getRoot(): HTMLElement | ShadowRoot {
return this;
}
connectedCallback() {
this.cleanup.push(
effect(() => {
render(this.builder.value, this.getRoot());
})
);
}
disconnectedCallback() {
this.cleanup.forEach((cleanup) => cleanup());
}
attr(key: string, fallback?: string, reflect?: boolean): Signal<string> {
const val = this.getAttribute(key);
const s = signal(val ?? fallback ?? "");
this.attrs.set(key, s);
if (reflect === true) {
this.cleanup.push(
effect(() => {
this.setAttribute(key, s.value);
})
);
}
return s;
}
attributeChangedCallback(
_name: string,
_olvValue: string,
_newValue: string
) {
if (this.attrs.has(_name)) {
const s = this.attrs.get(_name)!;
if (s.value !== _newValue) s.value = _newValue;
}
}
}
export type Style = string | CSSResult | CSSStyleSheet;
type Constructor<T = LitSignals> = new (...args: any[]) => T;
export function WithFormInternals<TBase extends Constructor>(Base: TBase) {
return class extends Base {
static formAssociated = true;
constructor(...args: any[]) {
super(...args);
// @ts-ignore
this.internals_ = this.attachInternals();
}
};
}
export function WithShadowRoot<TBase extends Constructor>(Base: TBase) {
return class extends Base {
styles: ReadonlySignal<Style | Array<Style>> = computed(() => []);
sheets = computed(() => {
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 result =
typeof style == "string" //
? unsafeCSS(style)
: (style as CSSResult);
const sheet = result.styleSheet;
if (sheet) {
styles.push(sheet);
} else {
const sheet = new CSSStyleSheet();
sheet.replaceSync(result.cssText);
styles.push(sheet);
}
}
}
return styles;
});
override getRoot(): ShadowRoot {
let root = this.shadowRoot;
if (!root) {
root = this.attachShadow({ mode: "open" });
}
return root;
}
connectedCallback() {
super.connectedCallback();
this.cleanup.push(
effect(() => {
const root = this.getRoot();
root.adoptedStyleSheets = this.sheets.value;
})
);
}
};
}
{
"dependencies": {
"lit": "^3.0.0",
"@preact/signals-core": "^1.8.0",
"@lit-labs/preact-signals": "^1.0.2"
}
}
import { html, render, unsafeCSS, type CSSResult, type SVGTemplateResult, type TemplateResult } from "lit";
import { computed, effect, signal, type ReadonlySignal, type Signal } from "@preact/signals-core";
export type Style = string | CSSResult | CSSStyleSheet;
type Constructor<T extends HTMLElement = HTMLElement> = new (
...args: any[]
) => T;
export function WithCleanup<TBase extends Constructor>(Base: TBase) {
return class extends Base {
cleanup: (() => void)[] = [];
disconnectedCallback() {
this.cleanup.forEach((cleanup) => cleanup());
}
};
}
export function WithReactiveAttributes<TBase extends Constructor>(Base: TBase) {
return class extends WithCleanup(Base) {
attrs = new Map<string, Signal>();
constructor(...args: any[]) {
super(...args);
}
attr(key: string, fallback?: string, reflect?: boolean): Signal<string> {
const val = this.getAttribute(key);
const s = signal(val ?? fallback ?? "");
this.attrs.set(key, s);
if (reflect === true) {
this.cleanup.push(
effect(() => {
this.setAttribute(key, s.value);
})
);
}
return s;
}
attributeChangedCallback(
_name: string,
_olvValue: string,
_newValue: string
) {
if (this.attrs.has(_name)) {
const s = this.attrs.get(_name)!;
if (s.value !== _newValue) s.value = _newValue;
}
}
};
}
export function WithFormInternals<TBase extends Constructor>(Base: TBase) {
return class extends Base {
static formAssociated = true;
constructor(...args: any[]) {
super(...args);
// @ts-ignore
this.internals_ = this.attachInternals();
}
};
}
export function WithLitTemplate<TBase extends Constructor>(Base: TBase) {
return class extends WithReactiveAttributes(WithCleanup(Base)) {
builder: ReadonlySignal<TemplateResult | SVGTemplateResult> = signal(
html``
);
getRoot(): HTMLElement | ShadowRoot {
return this;
}
initRender() {
this.cleanup.push(
effect(() => {
render(this.builder.value, this.getRoot());
})
);
}
connectedCallback() {
this.initRender();
}
};
}
export function WithShadowRoot<TBase extends Constructor>(Base: TBase) {
return class extends WithLitTemplate(Base) {
styles: ReadonlySignal<Style | Array<Style>> = computed(() => []);
sheets = computed(() => {
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 result =
typeof style == "string" //
? unsafeCSS(style)
: (style as CSSResult);
const sheet = result.styleSheet;
if (sheet) {
styles.push(sheet);
} else {
const sheet = new CSSStyleSheet();
sheet.replaceSync(result.cssText);
styles.push(sheet);
}
}
}
return styles;
});
getRoot(): ShadowRoot {
let root = this.shadowRoot;
if (!root) {
root = this.attachShadow({ mode: "open" });
}
return root;
}
connectedCallback() {
this.initRender();
this.cleanup.push(
effect(() => {
const root = this.getRoot();
root.adoptedStyleSheets = this.sheets.value;
})
);
}
};
}
{
"files": {
"index.html": {
"position": 0
},
"package.json": {
"position": 1,
"hidden": true
},
"counter.ts": {
"position": 2
},
"lit-signals.ts": {
"position": 3
},
"utils.ts": {
"position": 4
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment