- 51 headless Web Components (45 core + 6 animation) - Shared helpers: emit(), part(), listen(), wireLabel(), initialValue() - Zero `new CustomEvent` or `static #counter` — all use shared utils - Zod schemas for all 44 core components - MCP package with discover, inspect, compose, validate tools - Showcase with Aperture Science theme, M3 Expressive motion - 81 tests passing, TypeScript strict mode clean - Signals (~500B), SPA router (~400B), zero dependencies
92 lines
3.2 KiB
TypeScript
92 lines
3.2 KiB
TypeScript
import { signal } from "../../signals";
|
|
import { emit } from "../../shared/helpers";
|
|
import { uniqueId } from "../../shared/aria";
|
|
|
|
/** PettyNumberField — numeric input with increment/decrement buttons and clamping. */
|
|
export class PettyNumberField extends HTMLElement {
|
|
static observedAttributes = ["min", "max", "step", "value", "disabled", "name"];
|
|
|
|
readonly #value = signal(0);
|
|
|
|
get value(): number { return this.#value.get(); }
|
|
set value(v: number) { this.#applyValue(v); }
|
|
|
|
connectedCallback(): void {
|
|
const input = this.#input();
|
|
const init = this.getAttribute("value");
|
|
if (init !== null) this.#value.set(Number(init));
|
|
this.#syncInput();
|
|
this.#wireLabel();
|
|
|
|
input?.addEventListener("input", this.#onInput);
|
|
input?.addEventListener("keydown", this.#onKeydown);
|
|
this.querySelector("[data-part=increment]")?.addEventListener("click", this.#onIncrement);
|
|
this.querySelector("[data-part=decrement]")?.addEventListener("click", this.#onDecrement);
|
|
}
|
|
|
|
disconnectedCallback(): void {
|
|
const input = this.#input();
|
|
input?.removeEventListener("input", this.#onInput);
|
|
input?.removeEventListener("keydown", this.#onKeydown);
|
|
this.querySelector("[data-part=increment]")?.removeEventListener("click", this.#onIncrement);
|
|
this.querySelector("[data-part=decrement]")?.removeEventListener("click", this.#onDecrement);
|
|
}
|
|
|
|
attributeChangedCallback(name: string, _old: string | null, next: string | null): void {
|
|
if (name === "value" && next !== null) this.#value.set(Number(next));
|
|
this.#syncInput();
|
|
}
|
|
|
|
#input(): HTMLInputElement | null {
|
|
return this.querySelector("input[data-part=control]");
|
|
}
|
|
|
|
#wireLabel(): void {
|
|
const input = this.#input();
|
|
const label = this.querySelector("[data-part=label]");
|
|
if (input && label) {
|
|
if (!input.id) input.id = uniqueId("petty-nf");
|
|
(label as HTMLLabelElement).htmlFor = input.id;
|
|
}
|
|
}
|
|
|
|
#clamp(v: number): number {
|
|
const min = Number(this.getAttribute("min") ?? -Infinity);
|
|
const max = Number(this.getAttribute("max") ?? Infinity);
|
|
return Math.min(Math.max(v, min), max);
|
|
}
|
|
|
|
#step(): number {
|
|
return Number(this.getAttribute("step") ?? 1);
|
|
}
|
|
|
|
#applyValue(v: number): void {
|
|
const clamped = this.#clamp(v);
|
|
this.#value.set(clamped);
|
|
this.#syncInput();
|
|
emit(this, "change", { value: clamped });
|
|
}
|
|
|
|
#syncInput(): void {
|
|
const input = this.#input();
|
|
if (!input) return;
|
|
input.value = String(this.#value.get());
|
|
input.disabled = this.hasAttribute("disabled");
|
|
if (this.hasAttribute("name")) input.name = this.getAttribute("name") ?? "";
|
|
}
|
|
|
|
#onInput = (): void => {
|
|
const raw = Number(this.#input()?.value ?? 0);
|
|
if (!Number.isNaN(raw)) this.#applyValue(raw);
|
|
};
|
|
|
|
#onKeydown = (e: KeyboardEvent): void => {
|
|
if (e.key === "ArrowUp") { e.preventDefault(); this.#applyValue(this.#value.get() + this.#step()); }
|
|
if (e.key === "ArrowDown") { e.preventDefault(); this.#applyValue(this.#value.get() - this.#step()); }
|
|
};
|
|
|
|
#onIncrement = (): void => { if (!this.hasAttribute("disabled")) this.#applyValue(this.#value.get() + this.#step()); };
|
|
#onDecrement = (): void => { if (!this.hasAttribute("disabled")) this.#applyValue(this.#value.get() - this.#step()); };
|
|
|
|
}
|