Mats Bosson 168b5642d0 All components, schemas, tests, MCP, and showcase
- 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
2026-03-31 21:42:29 +07:00

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()); };
}