import { css, html } from "lit";
import { property } from "lit/decorators.js";
import { provide } from "@lit/context";
import { BaseElement } from "../../core/base-element.ts";
import {
applyThemeToElement,
type CFTheme,
cfThemeContext,
defaultTheme,
mergeWithDefaultTheme,
} from "../theme-context.ts";
import { type CellHandle, isCellHandle } from "@commonfabric/runtime-client";
export function unwrapThemeCellValues(
value: unknown,
seen = new WeakSet(),
): unknown {
if (isCellHandle(value)) {
return value.get();
}
if (!value || typeof value !== "object") {
return value;
}
if (seen.has(value)) {
return value;
}
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => unwrapThemeCellValues(item, seen));
}
const out: Record = {};
for (const [key, child] of Object.entries(value)) {
out[key] = unwrapThemeCellValues(child, seen);
}
return out;
}
export function subscribeToThemeCellValues(
value: unknown,
onChange: () => void,
seen = new WeakSet(),
): Array<() => void> {
const unsubs: Array<() => void> = [];
const visit = (current: unknown) => {
if (isCellHandle(current)) {
const cellVal = current as CellHandle;
let didReceiveInitialValue = false;
const off = cellVal.subscribe(() => {
if (!didReceiveInitialValue) {
didReceiveInitialValue = true;
return;
}
onChange();
});
unsubs.push(off);
return;
}
if (!current || typeof current !== "object") {
return;
}
if (seen.has(current)) {
return;
}
seen.add(current);
for (const child of Object.values(current)) {
visit(child);
}
};
visit(value);
return unsubs;
}
/**
* cf-theme — Provides a theme to a subtree and applies CSS vars.
*
* Usage:
*
*
* The component unwraps CellHandle values inside a partial theme, merges the
* result with defaults, provides it via context, and sets CSS custom properties
* on the host so descendants pick up tokens.
*
* @element cf-theme
*/
export class CFThemeProvider extends BaseElement {
static override styles = css`
:host {
display: contents; /* do not add extra layout */
}
`;
/** Partial or full theme object (pattern-style supported) */
@property({ attribute: false })
accessor theme: any = {};
/** Computed full theme that is provided to children */
@provide({ context: cfThemeContext })
@property({ attribute: false })
accessor _computedTheme: CFTheme = defaultTheme;
#unsubs: Array<() => void> = [];
private _mediaQuery?: MediaQueryList;
private _onMediaChange = () => this._recomputeAndApply();
private _onThemePreferenceChanged = () => this._recomputeAndApply();
override connectedCallback(): void {
super.connectedCallback();
// Re-resolve when user toggles theme preference (data-theme attribute)
document.addEventListener(
"theme-preference-changed",
this._onThemePreferenceChanged,
);
}
override firstUpdated(changed: Map) {
super.firstUpdated(changed);
this._recomputeAndApply();
}
override updated(changed: Map) {
super.updated(changed);
if (changed.has("theme")) {
this._recomputeAndApply();
}
}
private _recomputeAndApply() {
this._computedTheme = mergeWithDefaultTheme(
unwrapThemeCellValues(this.theme),
);
applyThemeToElement(this, this._computedTheme);
this.#setupSubscriptions();
// Manage the matchMedia listener based on the resolved colorScheme
if (this._computedTheme.colorScheme === "auto") {
if (
!this._mediaQuery && typeof globalThis !== "undefined" &&
globalThis.matchMedia
) {
this._mediaQuery = globalThis.matchMedia(
"(prefers-color-scheme: dark)",
);
this._mediaQuery.addEventListener("change", this._onMediaChange);
}
} else {
if (this._mediaQuery) {
this._mediaQuery.removeEventListener("change", this._onMediaChange);
this._mediaQuery = undefined;
}
}
}
#setupSubscriptions() {
// Clear previous subscriptions
for (const off of this.#unsubs) off();
this.#unsubs = [];
this.#unsubs = subscribeToThemeCellValues(
this.theme,
() => this._recomputeAndApply(),
);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
for (const off of this.#unsubs) off();
this.#unsubs = [];
if (this._mediaQuery) {
this._mediaQuery.removeEventListener("change", this._onMediaChange);
this._mediaQuery = undefined;
}
document.removeEventListener(
"theme-preference-changed",
this._onThemePreferenceChanged,
);
}
override render() {
return html`
`;
}
}