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