mirror of
https://github.com/aphrody-labs/aphrody-m3-front.git
synced 2026-10-13 19:54:13 +00:00
chore: migrate package sources from Aphrody
This commit is contained in:
1 parent
dbd4641c40
commit
d97c932687
10 files changed
+531
-2
No files matched your search
@@ -1,2 +1,21 @@
|
|||||||
# aphrody-m3-front
|
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||||
Aphrody package @aphrody/m3-front
|
|
||||||
|
# `@aphrody/m3-front`
|
||||||
|
|
||||||
|
Framework-agnostic front layer over `@aphrody/material-web` and `@aphrody/m3-tokens`. No React, no build step.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import "@aphrody/m3-front"; // defines <m3-theme>
|
||||||
|
import { registerM3, observeWindowClass } from "@aphrody/m3-front";
|
||||||
|
await registerM3("all"); // lazy md-* catalog ("aphrody" for the extensions)
|
||||||
|
observeWindowClass(); // data-m3-width / data-m3-height / data-m3-nav on <html>
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<m3-theme seed="#6750a4" mode="auto" variant="tonalSpot"> ... md-* elements ... </m3-theme>
|
||||||
|
```
|
||||||
|
|
||||||
|
`<m3-theme>` scopes the dynamic colour roles to its subtree, follows `prefers-color-scheme` in `auto`, and
|
||||||
|
fires `m3-theme-change`. `registerM3Component("button/filled-button")` loads one element.
|
||||||
|
|
||||||
|
`bun test test/` (happy-dom), `bun run typecheck`.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["@aphrody/m3-config/test/dom"]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "@aphrody/m3-front",
|
||||||
|
"version": "1.4.4",
|
||||||
|
"description": "Framework-agnostic Material 3 front layer: <m3-theme> web component, lazy material-web registration and window-class observation on top of @aphrody/material-web and @aphrody/m3-tokens.",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"author": "aphrody-code",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/aphrody-labs/aphrody-m3.git",
|
||||||
|
"directory": "packages/m3-front"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": [
|
||||||
|
"./src/theme-element.ts"
|
||||||
|
],
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./theme-element": "./src/theme-element.ts",
|
||||||
|
"./register": "./src/register.ts",
|
||||||
|
"./window-class": "./src/window-class.ts",
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test test/",
|
||||||
|
"typecheck": "bun check -p tsconfig.json --noEmit",
|
||||||
|
"lint": "oxlint src test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aphrody/m3-tokens": "workspace:*",
|
||||||
|
"@aphrody/material-web": "catalog:"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@aphrody/bun": "^3.3.0",
|
||||||
|
"@aphrody/m3-config": "workspace:*",
|
||||||
|
"typescript": "catalog:"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.4.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
export { M3ThemeElement, defineM3Theme, type ThemeMode } from "./theme-element.ts";
|
||||||
|
export { registerM3, registerM3Component, whenDefined, type M3Catalog } from "./register.ts";
|
||||||
|
export { observeWindowClass, windowClassFor, type WindowClassState } from "./window-class.ts";
|
||||||
|
export { cssFromSeed, schemeFromSeed, applyDynamicColor } from "@aphrody/m3-tokens/dynamic-color";
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Lazy registration of the @aphrody/material-web element catalogs. Nothing is imported until asked,
|
||||||
|
// so the front layer stays SSR-safe and tree-shakable.
|
||||||
|
|
||||||
|
export type M3Catalog = "all" | "aphrody";
|
||||||
|
|
||||||
|
const CATALOGS: Record<M3Catalog, () => Promise<unknown>> = {
|
||||||
|
all: () => import("@aphrody/material-web/all.js"),
|
||||||
|
aphrody: () => import("@aphrody/material-web/aphrody-components.js"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Load whole catalogs: `all` (upstream md-*) and `aphrody` (Aphrody extension elements). */
|
||||||
|
export async function registerM3(...catalogs: M3Catalog[]): Promise<void> {
|
||||||
|
for (const catalog of catalogs) {
|
||||||
|
if (!Object.hasOwn(CATALOGS, catalog)) throw new Error(`invalid M3 catalog "${catalog}"`);
|
||||||
|
}
|
||||||
|
await Promise.all(catalogs.map((c) => CATALOGS[c]()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load single components by material-web path, e.g. `"button/filled-button"` or `"switch/switch"`.
|
||||||
|
* Only a-z, 0-9, `-` and one `/` are accepted.
|
||||||
|
*/
|
||||||
|
export async function registerM3Component(...paths: string[]): Promise<void> {
|
||||||
|
for (const path of paths) {
|
||||||
|
if (!/^[a-z0-9-]+\/[a-z0-9-]+$/.test(path)) throw new Error(`invalid component path "${path}"`);
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
paths.map((path) => import(/* @vite-ignore */ `@aphrody/material-web/${path}.js`)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve once every tag is defined. */
|
||||||
|
export async function whenDefined(tags: readonly string[]): Promise<void> {
|
||||||
|
await Promise.all(tags.map((t) => customElements.whenDefined(t)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// <m3-theme seed="#6750a4" mode="auto" variant="tonalSpot" contrast="0"> scopes a dynamic M3 colour
|
||||||
|
// scheme to its subtree (display: contents) with no framework: any <md-*> inside re-themes live.
|
||||||
|
import { schemeFromSeed, type SchemeVariant } from "@aphrody/m3-tokens/dynamic-color";
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark" | "auto";
|
||||||
|
|
||||||
|
// Importing the pure front helpers must also work in server renderers without a DOM.
|
||||||
|
// The browser receives the real HTMLElement; the server never constructs or registers elements.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- The SSR fallback is only a constructor base and never instantiated.
|
||||||
|
const HTMLElementBase = globalThis.HTMLElement ?? (class {} as typeof HTMLElement);
|
||||||
|
|
||||||
|
export class M3ThemeElement extends HTMLElementBase {
|
||||||
|
static observedAttributes = ["seed", "mode", "variant", "contrast"];
|
||||||
|
#applied: string[] = [];
|
||||||
|
#media: MediaQueryList | null = null;
|
||||||
|
#listener = (): void => {
|
||||||
|
if (this.mode === "auto") this.apply();
|
||||||
|
};
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
this.style.display = "contents";
|
||||||
|
this.#media =
|
||||||
|
this.ownerDocument.defaultView?.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
|
||||||
|
this.#media?.addEventListener("change", this.#listener);
|
||||||
|
this.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this.#media?.removeEventListener("change", this.#listener);
|
||||||
|
this.#media = null;
|
||||||
|
this.clear();
|
||||||
|
this.style.removeProperty("color-scheme");
|
||||||
|
delete this.dataset["resolvedMode"];
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(): void {
|
||||||
|
if (this.isConnected) this.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
get seed(): string {
|
||||||
|
return this.getAttribute("seed") ?? "#6750a4";
|
||||||
|
}
|
||||||
|
set seed(v: string) {
|
||||||
|
this.setAttribute("seed", v);
|
||||||
|
}
|
||||||
|
get mode(): ThemeMode {
|
||||||
|
const m = this.getAttribute("mode");
|
||||||
|
return m === "light" || m === "dark" ? m : "auto";
|
||||||
|
}
|
||||||
|
set mode(v: ThemeMode) {
|
||||||
|
this.setAttribute("mode", v);
|
||||||
|
}
|
||||||
|
|
||||||
|
get variant(): SchemeVariant {
|
||||||
|
return (this.getAttribute("variant") ?? "tonalSpot") as SchemeVariant;
|
||||||
|
}
|
||||||
|
set variant(v: SchemeVariant) {
|
||||||
|
this.setAttribute("variant", v);
|
||||||
|
}
|
||||||
|
|
||||||
|
get contrast(): number {
|
||||||
|
return Number(this.getAttribute("contrast") ?? 0);
|
||||||
|
}
|
||||||
|
set contrast(v: number) {
|
||||||
|
this.setAttribute("contrast", String(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mode in effect (auto resolves through prefers-color-scheme). */
|
||||||
|
get resolvedMode(): "light" | "dark" {
|
||||||
|
if (this.mode !== "auto") return this.mode;
|
||||||
|
return this.#media?.matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(): void {
|
||||||
|
const dark = this.resolvedMode === "dark";
|
||||||
|
// Derive before changing styles: rejected inputs keep the last complete scheme.
|
||||||
|
const roles = schemeFromSeed(this.seed, {
|
||||||
|
dark,
|
||||||
|
variant: this.variant,
|
||||||
|
contrastLevel: this.contrast,
|
||||||
|
});
|
||||||
|
this.clear();
|
||||||
|
for (const [k, val] of Object.entries(roles)) {
|
||||||
|
this.style.setProperty(k, val);
|
||||||
|
this.#applied.push(k);
|
||||||
|
}
|
||||||
|
this.style.setProperty("color-scheme", dark ? "dark" : "light");
|
||||||
|
this.dataset["resolvedMode"] = dark ? "dark" : "light";
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("m3-theme-change", {
|
||||||
|
detail: { seed: this.seed, dark, variant: this.variant, contrastLevel: this.contrast },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clear(): void {
|
||||||
|
for (const k of this.#applied) this.style.removeProperty(k);
|
||||||
|
this.#applied = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Define `<m3-theme>` or a custom tag (idempotent and safe to import during SSR). */
|
||||||
|
export function defineM3Theme(tag = "m3-theme"): void {
|
||||||
|
const registry = globalThis.customElements;
|
||||||
|
if (!registry || registry.get(tag)) return;
|
||||||
|
// A CustomElementRegistry cannot associate one constructor with multiple tags.
|
||||||
|
registry.define(tag, tag === "m3-theme" ? M3ThemeElement : class extends M3ThemeElement {});
|
||||||
|
}
|
||||||
|
|
||||||
|
defineM3Theme();
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import {
|
||||||
|
classifyHeight,
|
||||||
|
classifyWidth,
|
||||||
|
navigationFor,
|
||||||
|
panesFor,
|
||||||
|
type NavigationKind,
|
||||||
|
type WindowHeightClass,
|
||||||
|
type WindowSizeClass,
|
||||||
|
} from "@aphrody/m3-tokens/breakpoints";
|
||||||
|
|
||||||
|
export interface WindowClassState {
|
||||||
|
width: WindowSizeClass;
|
||||||
|
height: WindowHeightClass;
|
||||||
|
navigation: NavigationKind;
|
||||||
|
panes: 1 | 2 | 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function windowClassFor(widthDp: number, heightDp: number): WindowClassState {
|
||||||
|
const width = classifyWidth(widthDp);
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height: classifyHeight(heightDp),
|
||||||
|
navigation: navigationFor(width),
|
||||||
|
panes: panesFor(width),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe the window size classes and mirror them on `target` as `data-m3-width`, `data-m3-height`,
|
||||||
|
* `data-m3-nav`. Calls `onChange` when a class changes. Returns a disposer.
|
||||||
|
*/
|
||||||
|
export function observeWindowClass(
|
||||||
|
onChange?: (s: WindowClassState) => void,
|
||||||
|
target: HTMLElement = document.documentElement,
|
||||||
|
win: Window = window,
|
||||||
|
): () => void {
|
||||||
|
let last = "";
|
||||||
|
const update = (): void => {
|
||||||
|
const s = windowClassFor(win.innerWidth, win.innerHeight);
|
||||||
|
target.dataset["m3Width"] = s.width;
|
||||||
|
target.dataset["m3Height"] = s.height;
|
||||||
|
target.dataset["m3Nav"] = s.navigation;
|
||||||
|
const key = `${s.width}/${s.height}`;
|
||||||
|
if (key !== last) {
|
||||||
|
last = key;
|
||||||
|
onChange?.(s);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
win.addEventListener("resize", update);
|
||||||
|
return () => win.removeEventListener("resize", update);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
M3ThemeElement,
|
||||||
|
observeWindowClass,
|
||||||
|
registerM3Component,
|
||||||
|
windowClassFor,
|
||||||
|
} from "../src/index.ts";
|
||||||
|
|
||||||
|
describe("<m3-theme>", () => {
|
||||||
|
test("is registered and scopes the colour roles to its subtree", () => {
|
||||||
|
expect(customElements.get("m3-theme")).toBe(M3ThemeElement);
|
||||||
|
const el = document.createElement("m3-theme") as M3ThemeElement;
|
||||||
|
el.setAttribute("seed", "#6750a4");
|
||||||
|
el.setAttribute("mode", "light");
|
||||||
|
document.body.append(el);
|
||||||
|
expect(el.style.getPropertyValue("--md-sys-color-primary")).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
expect(el.dataset["resolvedMode"]).toBe("light");
|
||||||
|
const lightSurface = el.style.getPropertyValue("--md-sys-color-surface");
|
||||||
|
el.mode = "dark";
|
||||||
|
expect(el.dataset["resolvedMode"]).toBe("dark");
|
||||||
|
expect(el.style.getPropertyValue("--md-sys-color-surface")).not.toBe(lightSurface);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--md-sys-color-primary")).toBe("");
|
||||||
|
el.remove();
|
||||||
|
expect(el.style.getPropertyValue("--md-sys-color-primary")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits m3-theme-change", () => {
|
||||||
|
const el = document.createElement("m3-theme") as M3ThemeElement;
|
||||||
|
let seen = 0;
|
||||||
|
el.addEventListener("m3-theme-change", () => seen++);
|
||||||
|
document.body.append(el);
|
||||||
|
el.seed = "#336699";
|
||||||
|
expect(seen).toBeGreaterThanOrEqual(2);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("window classes", () => {
|
||||||
|
test("classifies width and height", () => {
|
||||||
|
expect(windowClassFor(500, 400)).toEqual({
|
||||||
|
width: "compact",
|
||||||
|
height: "compact",
|
||||||
|
navigation: "bar",
|
||||||
|
panes: 1,
|
||||||
|
});
|
||||||
|
expect(windowClassFor(1300, 950)).toEqual({
|
||||||
|
width: "large",
|
||||||
|
height: "expanded",
|
||||||
|
navigation: "rail-expanded",
|
||||||
|
panes: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mirrors classes on the target and disposes", () => {
|
||||||
|
const target = document.createElement("div");
|
||||||
|
const seen: string[] = [];
|
||||||
|
const dispose = observeWindowClass((s) => seen.push(s.width), target, {
|
||||||
|
innerWidth: 700,
|
||||||
|
innerHeight: 500,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
} as unknown as Window);
|
||||||
|
expect(target.dataset["m3Width"]).toBe("medium");
|
||||||
|
expect(target.dataset["m3Height"]).toBe("medium");
|
||||||
|
expect(target.dataset["m3Nav"]).toBe("rail-collapsed");
|
||||||
|
expect(seen).toEqual(["medium"]);
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("registration", () => {
|
||||||
|
test("rejects unsafe component paths", async () => {
|
||||||
|
await expect(registerM3Component("../evil")).rejects.toThrow("invalid component path");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { schemeFromSeed } from "@aphrody/m3-tokens/dynamic-color";
|
||||||
|
import { defineM3Theme, M3ThemeElement, observeWindowClass, registerM3 } from "../src/index.ts";
|
||||||
|
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
|
});
|
||||||
|
|
||||||
|
function colorPreference(dark = false) {
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const media = {
|
||||||
|
matches: dark,
|
||||||
|
addEventListener(_type: string, listener: () => void) {
|
||||||
|
listeners.add(listener);
|
||||||
|
},
|
||||||
|
removeEventListener(_type: string, listener: () => void) {
|
||||||
|
listeners.delete(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
window.matchMedia = () => media as unknown as MediaQueryList;
|
||||||
|
return {
|
||||||
|
listeners,
|
||||||
|
change(value: boolean) {
|
||||||
|
media.matches = value;
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("front production contract", () => {
|
||||||
|
test("imports all public front helpers in a server without DOM globals", async () => {
|
||||||
|
const child = Bun.spawn(
|
||||||
|
[
|
||||||
|
process.execPath,
|
||||||
|
"-e",
|
||||||
|
'const front = await import("@aphrody/m3-front"); front.defineM3Theme(); console.log(front.windowClassFor(600, 480).width);',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
windowsHide: true,
|
||||||
|
cwd: Bun.fileURLToPath(new URL("..", import.meta.url)),
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [code, stdout, stderr] = await Promise.all([
|
||||||
|
child.exited,
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
new Response(child.stderr).text(),
|
||||||
|
]);
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(stdout.trim()).toBe("medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registers independent custom tag aliases idempotently", () => {
|
||||||
|
defineM3Theme("m3-custom-theme");
|
||||||
|
const ctor = customElements.get("m3-custom-theme");
|
||||||
|
defineM3Theme("m3-custom-theme");
|
||||||
|
expect(customElements.get("m3-custom-theme")).toBe(ctor);
|
||||||
|
const element = document.createElement("m3-custom-theme");
|
||||||
|
expect(element).toBeInstanceOf(M3ThemeElement);
|
||||||
|
document.body.append(element);
|
||||||
|
expect(element.style.getPropertyValue("--md-sys-color-primary")).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reacts to contrast and variant and announces complete roles across shadow boundaries", () => {
|
||||||
|
colorPreference();
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const shadow = host.attachShadow({ mode: "open" });
|
||||||
|
const element = document.createElement("m3-theme") as M3ThemeElement;
|
||||||
|
element.dir = "rtl";
|
||||||
|
const changes: Array<{ variant: string; contrastLevel: number }> = [];
|
||||||
|
const onChange = (event: Event) => changes.push((event as CustomEvent).detail);
|
||||||
|
document.addEventListener("m3-theme-change", onChange);
|
||||||
|
try {
|
||||||
|
shadow.append(element);
|
||||||
|
element.variant = "fidelity";
|
||||||
|
element.contrast = 1;
|
||||||
|
const roles = schemeFromSeed(element.seed, { variant: "fidelity", contrastLevel: 1 });
|
||||||
|
for (const [role, value] of Object.entries(roles)) {
|
||||||
|
expect(element.style.getPropertyValue(role)).toBe(value);
|
||||||
|
}
|
||||||
|
expect(element.dir).toBe("rtl");
|
||||||
|
expect(changes.at(-1)).toMatchObject({ variant: "fidelity", contrastLevel: 1 });
|
||||||
|
} finally {
|
||||||
|
document.removeEventListener("m3-theme-change", onChange);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only auto mode follows system changes and reconnects with one live subscription", () => {
|
||||||
|
const preference = colorPreference();
|
||||||
|
const element = document.createElement("m3-theme") as M3ThemeElement;
|
||||||
|
let events = 0;
|
||||||
|
element.addEventListener("m3-theme-change", () => events++);
|
||||||
|
document.body.append(element);
|
||||||
|
expect(preference.listeners.size).toBe(1);
|
||||||
|
preference.change(true);
|
||||||
|
expect(element.resolvedMode).toBe("dark");
|
||||||
|
element.mode = "light";
|
||||||
|
const beforeChange = events;
|
||||||
|
preference.change(false);
|
||||||
|
expect(events).toBe(beforeChange);
|
||||||
|
element.mode = "auto";
|
||||||
|
element.remove();
|
||||||
|
expect(preference.listeners.size).toBe(0);
|
||||||
|
expect(element.style.getPropertyValue("--md-sys-color-primary")).toBe("");
|
||||||
|
expect(element.style.getPropertyValue("color-scheme")).toBe("");
|
||||||
|
expect(element.dataset["resolvedMode"]).toBeUndefined();
|
||||||
|
document.body.append(element);
|
||||||
|
expect(preference.listeners.size).toBe(1);
|
||||||
|
preference.change(true);
|
||||||
|
expect(element.dataset["resolvedMode"]).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid contrast atomically instead of silently resetting it", () => {
|
||||||
|
const element = document.createElement("m3-theme") as M3ThemeElement;
|
||||||
|
document.body.append(element);
|
||||||
|
const style = element.style.cssText;
|
||||||
|
let events = 0;
|
||||||
|
element.addEventListener("m3-theme-change", () => events++);
|
||||||
|
expect(() => element.setAttribute("contrast", "invalid")).toThrow("contrast");
|
||||||
|
expect(element.style.cssText).toBe(style);
|
||||||
|
expect(events).toBe(0);
|
||||||
|
element.contrast = 0.5;
|
||||||
|
expect(element.contrast).toBe(0.5);
|
||||||
|
expect(events).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adaptive observation publishes changes once and removes its exact listener", () => {
|
||||||
|
const target = document.createElement("div");
|
||||||
|
let width = 599;
|
||||||
|
let resize: (() => void) | undefined;
|
||||||
|
let removed: (() => void) | undefined;
|
||||||
|
const win = {
|
||||||
|
get innerWidth() {
|
||||||
|
return width;
|
||||||
|
},
|
||||||
|
innerHeight: 900,
|
||||||
|
addEventListener(_type: string, listener: () => void) {
|
||||||
|
resize = listener;
|
||||||
|
},
|
||||||
|
removeEventListener(_type: string, listener: () => void) {
|
||||||
|
removed = listener;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const states: string[] = [];
|
||||||
|
const dispose = observeWindowClass(
|
||||||
|
(state) => states.push(state.width),
|
||||||
|
target,
|
||||||
|
win as unknown as Window,
|
||||||
|
);
|
||||||
|
width = 600;
|
||||||
|
resize?.();
|
||||||
|
resize?.();
|
||||||
|
width = 840;
|
||||||
|
resize?.();
|
||||||
|
expect(states).toEqual(["compact", "medium", "expanded"]);
|
||||||
|
expect(target.dataset["m3Nav"]).toBe("rail-collapsed");
|
||||||
|
dispose();
|
||||||
|
expect(removed).toBe(resize);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects prototype names as catalog keys", async () => {
|
||||||
|
await expect(registerM3("__proto__" as "all")).rejects.toThrow("invalid M3 catalog");
|
||||||
|
await expect(registerM3("constructor" as "all")).rejects.toThrow("invalid M3 catalog");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "@aphrody/m3-config/tsconfig.json",
|
||||||
|
"compilerOptions": { "types": ["bun", "node"] },
|
||||||
|
"include": ["src/**/*", "test/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user