Use listen() helper across more components

This commit is contained in:
Mats Bosson 2026-04-01 01:42:10 +07:00
parent dec5f8f1d2
commit 929a6585f0
26 changed files with 419 additions and 339 deletions

View File

@ -3,43 +3,43 @@
.petty-stagger-hidden { opacity: 0; } .petty-stagger-hidden { opacity: 0; }
.petty-stagger-fade-up { .petty-stagger-fade-up {
animation: pettyFadeUp 0.5s ease both; animation: pettyFadeUp 0.5s cubic-bezier(0.2, 0, 0, 1) both;
} }
.petty-stagger-fade-down { .petty-stagger-fade-down {
animation: pettyFadeDown 0.5s ease both; animation: pettyFadeDown 0.5s cubic-bezier(0.2, 0, 0, 1) both;
} }
.petty-stagger-fade-left { .petty-stagger-fade-left {
animation: pettyFadeLeft 0.5s ease both; animation: pettyFadeLeft 0.5s cubic-bezier(0.2, 0, 0, 1) both;
} }
.petty-stagger-fade-right { .petty-stagger-fade-right {
animation: pettyFadeRight 0.5s ease both; animation: pettyFadeRight 0.5s cubic-bezier(0.2, 0, 0, 1) both;
} }
.petty-stagger-scale { .petty-stagger-scale {
animation: pettyScale 0.5s ease both; animation: pettyScale 0.5s cubic-bezier(0.2, 0, 0, 1) both;
} }
.petty-stagger-blur { .petty-stagger-blur {
animation: pettyBlur 0.6s ease both; animation: pettyBlur 0.5s cubic-bezier(0.2, 0, 0, 1) both;
} }
.petty-reveal-hidden { opacity: 0; } .petty-reveal-hidden { opacity: 0; }
.petty-reveal-fade-up { .petty-reveal-fade-up {
animation: pettyFadeUp 0.6s ease both; animation: pettyFadeUp 0.5s cubic-bezier(0.2, 0, 0, 1) both;
} }
.petty-reveal-fade-down { .petty-reveal-fade-down {
animation: pettyFadeDown 0.6s ease both; animation: pettyFadeDown 0.5s cubic-bezier(0.2, 0, 0, 1) both;
} }
.petty-reveal-fade-left { .petty-reveal-fade-left {
animation: pettyFadeLeft 0.6s ease both; animation: pettyFadeLeft 0.5s cubic-bezier(0.2, 0, 0, 1) both;
} }
.petty-reveal-fade-right { .petty-reveal-fade-right {
animation: pettyFadeRight 0.6s ease both; animation: pettyFadeRight 0.5s cubic-bezier(0.2, 0, 0, 1) both;
} }
.petty-reveal-scale { .petty-reveal-scale {
animation: pettyScale 0.6s ease both; animation: pettyScale 0.5s cubic-bezier(0.2, 0, 0, 1) both;
} }
.petty-reveal-blur { .petty-reveal-blur {
animation: pettyBlur 0.7s ease both; animation: pettyBlur 0.5s cubic-bezier(0.2, 0, 0, 1) both;
} }
petty-typewriter.petty-typewriter-cursor::after { petty-typewriter.petty-typewriter-cursor::after {
@ -82,7 +82,7 @@ petty-counter { font-variant-numeric: tabular-nums; }
} }
dialog[open] { dialog[open] {
animation: pettyDialogIn 0.2s ease; animation: pettyDialogIn 0.2s cubic-bezier(0.2, 0, 0, 1);
} }
@keyframes pettyDialogIn { @keyframes pettyDialogIn {
from { opacity: 0; transform: translateY(8px) scale(0.98); } from { opacity: 0; transform: translateY(8px) scale(0.98); }
@ -90,7 +90,7 @@ dialog[open] {
} }
[popover]:popover-open { [popover]:popover-open {
animation: pettyPopoverIn 0.15s ease; animation: pettyPopoverIn 0.15s cubic-bezier(0.2, 0, 0, 1);
} }
@keyframes pettyPopoverIn { @keyframes pettyPopoverIn {
from { opacity: 0; transform: translateY(-4px); } from { opacity: 0; transform: translateY(-4px); }
@ -98,7 +98,7 @@ dialog[open] {
} }
[data-part="toast"] { [data-part="toast"] {
animation: pettySlideIn 0.3s ease; animation: pettySlideIn 0.3s cubic-bezier(0.2, 0, 0, 1);
} }
@keyframes pettySlideIn { @keyframes pettySlideIn {
from { opacity: 0; transform: translateX(100%); } from { opacity: 0; transform: translateX(100%); }
@ -113,25 +113,10 @@ petty-loading-indicator [data-part="indicator"] {
border: 3px solid rgba(54, 192, 241, 0.12); border: 3px solid rgba(54, 192, 241, 0.12);
border-top-color: #36c0f1; border-top-color: #36c0f1;
border-right-color: rgba(54, 192, 241, 0.4); border-right-color: rgba(54, 192, 241, 0.4);
animation: pettyLoadSpin 1.1s linear infinite, pettyLoadPulse 2.2s ease-in-out infinite; animation: pettyLoadSpin 0.9s linear infinite;
}
petty-loading-indicator [data-part="container"]::before {
content: "";
position: absolute;
inset: -2px;
border-radius: 50%;
border: 2px solid transparent;
border-bottom-color: rgba(54, 192, 241, 0.2);
border-left-color: rgba(54, 192, 241, 0.1);
animation: pettyLoadSpin 2.4s linear infinite reverse;
} }
@keyframes pettyLoadSpin { @keyframes pettyLoadSpin {
to { rotate: 360deg; } to { rotate: 360deg; }
} }
@keyframes pettyLoadPulse {
0%, 100% { box-shadow: 0 0 8px rgba(54, 192, 241, 0.15); }
50% { box-shadow: 0 0 20px rgba(54, 192, 241, 0.35); }
}

View File

@ -1,3 +1,5 @@
import { listen } from "../../shared/helpers";
/** /**
* PettyAccordionItem wraps a single `<details>` element within a PettyAccordion. * PettyAccordionItem wraps a single `<details>` element within a PettyAccordion.
* *
@ -34,20 +36,18 @@ export class PettyAccordionItem extends HTMLElement {
return this.querySelector("summary")?.textContent?.trim() ?? ""; return this.querySelector("summary")?.textContent?.trim() ?? "";
} }
#cleanup = (): void => {};
/** @internal */ /** @internal */
connectedCallback(): void { connectedCallback(): void {
this.#syncState(); this.#syncState();
this.#applyDisabled(); this.#applyDisabled();
const details = this.detailsElement; this.#cleanup = listen(this.detailsElement, [["toggle", this.#handleToggle]]);
if (details) {
details.addEventListener("toggle", this.#handleToggle);
}
} }
/** @internal */ /** @internal */
disconnectedCallback(): void { disconnectedCallback(): void {
const details = this.detailsElement; this.#cleanup();
details?.removeEventListener("toggle", this.#handleToggle);
} }
/** @internal */ /** @internal */

View File

@ -1,3 +1,5 @@
import { listen } from "../../shared/helpers";
/** PettyAvatar — image with automatic fallback on load error. */ /** PettyAvatar — image with automatic fallback on load error. */
export class PettyAvatar extends HTMLElement { export class PettyAvatar extends HTMLElement {
connectedCallback(): void { connectedCallback(): void {

View File

@ -1,5 +1,5 @@
import { signal, effect } from "../../signals"; import { signal, effect } from "../../signals";
import { emit } from "../../shared/helpers"; import { emit, listen } from "../../shared/helpers";
/** PettyCalendar — month grid with day selection and month navigation. */ /** PettyCalendar — month grid with day selection and month navigation. */
export class PettyCalendar extends HTMLElement { export class PettyCalendar extends HTMLElement {

View File

@ -1,5 +1,5 @@
import { uniqueId } from "../../shared/aria"; import { uniqueId } from "../../shared/aria";
import { emit } from "../../shared/helpers"; import { emit, listen } from "../../shared/helpers";
/** PettyCombobox — searchable select with popover listbox and keyboard nav. */ /** PettyCombobox — searchable select with popover listbox and keyboard nav. */
export class PettyCombobox extends HTMLElement { export class PettyCombobox extends HTMLElement {

View File

@ -1,4 +1,4 @@
import { emit } from "../../shared/helpers"; import { emit, listen } from "../../shared/helpers";
/** PettyCommandPalette — search-driven command menu using native dialog. */ /** PettyCommandPalette — search-driven command menu using native dialog. */
export class PettyCommandPalette extends HTMLElement { export class PettyCommandPalette extends HTMLElement {

View File

@ -1,4 +1,4 @@
import { emit } from "../../shared/helpers"; import { emit, listen } from "../../shared/helpers";
/** PettyContextMenu — right-click menu using Popover API with keyboard nav. */ /** PettyContextMenu — right-click menu using Popover API with keyboard nav. */
export class PettyContextMenu extends HTMLElement { export class PettyContextMenu extends HTMLElement {

View File

@ -1,4 +1,4 @@
import { emit } from "../../shared/helpers"; import { emit, listen } from "../../shared/helpers";
/** PettyDatePicker — date input with calendar popover integration. */ /** PettyDatePicker — date input with calendar popover integration. */
export class PettyDatePicker extends HTMLElement { export class PettyDatePicker extends HTMLElement {

View File

@ -1,4 +1,4 @@
import { emit } from "../../shared/helpers"; import { emit, listen } from "../../shared/helpers";
/** PettyDropdownMenu — action menu built on the Popover API. */ /** PettyDropdownMenu — action menu built on the Popover API. */
export class PettyDropdownMenu extends HTMLElement { export class PettyDropdownMenu extends HTMLElement {

View File

@ -1,4 +1,4 @@
import { emit } from "../../shared/helpers"; import { emit, listen } from "../../shared/helpers";
interface SchemaLike { interface SchemaLike {
safeParse: (data: unknown) => { safeParse: (data: unknown) => {
@ -37,18 +37,19 @@ export class PettyForm extends HTMLElement {
this.#schema = schema; this.#schema = schema;
} }
#cleanup = (): void => {};
/** @internal */ /** @internal */
connectedCallback(): void { connectedCallback(): void {
const form = this.querySelector("form"); const form = this.querySelector("form");
if (!form) return; if (!form) return;
form.addEventListener("submit", this.#handleSubmit);
form.setAttribute("novalidate", ""); form.setAttribute("novalidate", "");
this.#cleanup = listen(form, [["submit", this.#handleSubmit]]);
} }
/** @internal */ /** @internal */
disconnectedCallback(): void { disconnectedCallback(): void {
const form = this.querySelector("form"); this.#cleanup();
form?.removeEventListener("submit", this.#handleSubmit);
} }
#handleSubmit = (e: Event): void => { #handleSubmit = (e: Event): void => {

View File

@ -1,3 +1,5 @@
import { listen } from "../../shared/helpers";
/** PettyImage — image element with fallback display on load failure. */ /** PettyImage — image element with fallback display on load failure. */
export class PettyImage extends HTMLElement { export class PettyImage extends HTMLElement {
connectedCallback(): void { connectedCallback(): void {

View File

@ -1,3 +1,5 @@
import { listen } from "../../shared/helpers";
/** PettyLink — headless anchor wrapper with disabled and external support. */ /** PettyLink — headless anchor wrapper with disabled and external support. */
export class PettyLink extends HTMLElement { export class PettyLink extends HTMLElement {
static observedAttributes = ["disabled", "external"]; static observedAttributes = ["disabled", "external"];
@ -6,13 +8,15 @@ export class PettyLink extends HTMLElement {
return this.querySelector("a"); return this.querySelector("a");
} }
#cleanup = (): void => {};
connectedCallback(): void { connectedCallback(): void {
this.#sync(); this.#sync();
this.addEventListener("click", this.#handleClick); this.#cleanup = listen(this, [["click", this.#handleClick]]);
} }
disconnectedCallback(): void { disconnectedCallback(): void {
this.removeEventListener("click", this.#handleClick); this.#cleanup();
} }
attributeChangedCallback(): void { attributeChangedCallback(): void {

View File

@ -1,5 +1,5 @@
import { signal, effect } from "../../signals"; import { signal, effect } from "../../signals";
import { emit, initialValue } from "../../shared/helpers"; import { emit, initialValue, listen } from "../../shared/helpers";
/** PettyListbox — inline selectable list with single or multiple selection. */ /** PettyListbox — inline selectable list with single or multiple selection. */
export class PettyListbox extends HTMLElement { export class PettyListbox extends HTMLElement {
@ -27,18 +27,18 @@ export class PettyListbox extends HTMLElement {
emit(this, "change", { value: this.#value.get() }); emit(this, "change", { value: this.#value.get() });
} }
#cleanup = (): void => {};
connectedCallback(): void { connectedCallback(): void {
const init = initialValue(this); const init = initialValue(this);
if (init) this.#value.set(init); if (init) this.#value.set(init);
this.#stopEffect = effect(() => this.#syncChildren()); this.#stopEffect = effect(() => this.#syncChildren());
this.addEventListener("keydown", this.#onKeydown); this.#cleanup = listen(this, [["keydown", this.#onKeydown], ["click", this.#onClick]]);
this.addEventListener("click", this.#onClick);
} }
disconnectedCallback(): void { disconnectedCallback(): void {
this.#stopEffect = null; this.#stopEffect = null;
this.removeEventListener("keydown", this.#onKeydown); this.#cleanup();
this.removeEventListener("click", this.#onClick);
} }
attributeChangedCallback(name: string, _old: string | null, next: string | null): void { attributeChangedCallback(name: string, _old: string | null, next: string | null): void {
@ -59,18 +59,19 @@ export class PettyListbox extends HTMLElement {
} }
} }
#onClick = (e: MouseEvent): void => { #onClick = (e: Event): void => {
const opt = (e.target as HTMLElement).closest("petty-listbox-option"); const opt = (e.target as HTMLElement).closest("petty-listbox-option");
if (!opt || opt.hasAttribute("disabled")) return; if (!opt || opt.hasAttribute("disabled")) return;
this.selectValue(opt.getAttribute("value") ?? ""); this.selectValue(opt.getAttribute("value") ?? "");
}; };
#onKeydown = (e: KeyboardEvent): void => { #onKeydown = (e: Event): void => {
const ke = e as KeyboardEvent;
const items = this.#options(); const items = this.#options();
const active = document.activeElement as HTMLElement; const active = document.activeElement as HTMLElement;
const idx = items.indexOf(active); const idx = items.indexOf(active);
if (e.key === "ArrowDown") { e.preventDefault(); items[(idx + 1) % items.length]?.focus(); } if (ke.key === "ArrowDown") { ke.preventDefault(); items[(idx + 1) % items.length]?.focus(); }
else if (e.key === "ArrowUp") { e.preventDefault(); items[(idx - 1 + items.length) % items.length]?.focus(); } else if (ke.key === "ArrowUp") { ke.preventDefault(); items[(idx - 1 + items.length) % items.length]?.focus(); }
else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (active) this.selectValue(active.getAttribute("value") ?? ""); } else if (ke.key === "Enter" || ke.key === " ") { ke.preventDefault(); if (active) this.selectValue(active.getAttribute("value") ?? ""); }
}; };
} }

View File

@ -1,3 +1,5 @@
import { listen } from "../../shared/helpers";
/** PettyNavigationMenuItem — nav item with optional popover content on hover. */ /** PettyNavigationMenuItem — nav item with optional popover content on hover. */
export class PettyNavigationMenuItem extends HTMLElement { export class PettyNavigationMenuItem extends HTMLElement {
#showTimer: ReturnType<typeof setTimeout> | null = null; #showTimer: ReturnType<typeof setTimeout> | null = null;

View File

@ -1,5 +1,5 @@
import { signal } from "../../signals"; import { signal } from "../../signals";
import { emit } from "../../shared/helpers"; import { emit, listen, part } from "../../shared/helpers";
import { uniqueId } from "../../shared/aria"; import { uniqueId } from "../../shared/aria";
/** PettyNumberField — numeric input with increment/decrement buttons and clamping. */ /** PettyNumberField — numeric input with increment/decrement buttons and clamping. */
@ -7,6 +7,7 @@ export class PettyNumberField extends HTMLElement {
static observedAttributes = ["min", "max", "step", "value", "disabled", "name"]; static observedAttributes = ["min", "max", "step", "value", "disabled", "name"];
readonly #value = signal(0); readonly #value = signal(0);
#cleanup = (): void => {};
get value(): number { return this.#value.get(); } get value(): number { return this.#value.get(); }
set value(v: number) { this.#applyValue(v); } set value(v: number) { this.#applyValue(v); }
@ -18,18 +19,14 @@ export class PettyNumberField extends HTMLElement {
this.#syncInput(); this.#syncInput();
this.#wireLabel(); this.#wireLabel();
input?.addEventListener("input", this.#onInput); const c1 = listen(input, [["input", this.#onInput], ["keydown", this.#onKeydown as EventListener]]);
input?.addEventListener("keydown", this.#onKeydown); const c2 = listen(part(this, "increment"), [["click", this.#onIncrement]]);
this.querySelector("[data-part=increment]")?.addEventListener("click", this.#onIncrement); const c3 = listen(part(this, "decrement"), [["click", this.#onDecrement]]);
this.querySelector("[data-part=decrement]")?.addEventListener("click", this.#onDecrement); this.#cleanup = () => { c1(); c2(); c3(); };
} }
disconnectedCallback(): void { disconnectedCallback(): void {
const input = this.#input(); this.#cleanup();
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 { attributeChangedCallback(name: string, _old: string | null, next: string | null): void {

View File

@ -1,17 +1,19 @@
import { listen } from "../../shared/helpers";
/** PettyPaginationItem — single page button within a pagination component. */ /** PettyPaginationItem — single page button within a pagination component. */
export class PettyPaginationItem extends HTMLElement { export class PettyPaginationItem extends HTMLElement {
static observedAttributes = ["value", "type", "disabled"]; static observedAttributes = ["value", "type", "disabled"];
#cleanup = (): void => {};
connectedCallback(): void { connectedCallback(): void {
this.setAttribute("role", "button"); this.setAttribute("role", "button");
this.setAttribute("tabindex", "0"); this.setAttribute("tabindex", "0");
this.addEventListener("click", this.#handleClick); this.#cleanup = listen(this, [["click", this.#handleClick], ["keydown", this.#handleKeydown]]);
this.addEventListener("keydown", this.#handleKeydown);
} }
disconnectedCallback(): void { disconnectedCallback(): void {
this.removeEventListener("click", this.#handleClick); this.#cleanup();
this.removeEventListener("keydown", this.#handleKeydown);
} }
attributeChangedCallback(name: string): void { attributeChangedCallback(name: string): void {
@ -35,7 +37,8 @@ export class PettyPaginationItem extends HTMLElement {
} }
#handleClick = (): void => { this.#activate(); }; #handleClick = (): void => { this.#activate(); };
#handleKeydown = (e: KeyboardEvent): void => { #handleKeydown = (e: Event): void => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); this.#activate(); } const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") { ke.preventDefault(); this.#activate(); }
}; };
} }

View File

@ -1,4 +1,5 @@
import { wrapIndex } from "../../shared/keyboard"; import { wrapIndex } from "../../shared/keyboard";
import { listen } from "../../shared/helpers";
/** PettyRadioItem — single radio option within a petty-radio-group. */ /** PettyRadioItem — single radio option within a petty-radio-group. */
export class PettyRadioItem extends HTMLElement { export class PettyRadioItem extends HTMLElement {
@ -7,17 +8,17 @@ export class PettyRadioItem extends HTMLElement {
get value(): string { return this.getAttribute("value") ?? ""; } get value(): string { return this.getAttribute("value") ?? ""; }
get disabled(): boolean { return this.hasAttribute("disabled"); } get disabled(): boolean { return this.hasAttribute("disabled"); }
#cleanup = (): void => {};
connectedCallback(): void { connectedCallback(): void {
this.setAttribute("role", "radio"); this.setAttribute("role", "radio");
this.setAttribute("tabindex", "-1"); this.setAttribute("tabindex", "-1");
if (this.disabled) this.setAttribute("aria-disabled", "true"); if (this.disabled) this.setAttribute("aria-disabled", "true");
this.addEventListener("click", this.#handleClick); this.#cleanup = listen(this, [["click", this.#handleClick], ["keydown", this.#handleKeydown]]);
this.addEventListener("keydown", this.#handleKeydown);
} }
disconnectedCallback(): void { disconnectedCallback(): void {
this.removeEventListener("click", this.#handleClick); this.#cleanup();
this.removeEventListener("keydown", this.#handleKeydown);
} }
attributeChangedCallback(name: string): void { attributeChangedCallback(name: string): void {
@ -42,17 +43,18 @@ export class PettyRadioItem extends HTMLElement {
this.focus(); this.focus();
}; };
#handleKeydown = (e: KeyboardEvent): void => { #handleKeydown = (e: Event): void => {
const ke = e as KeyboardEvent;
const isVertical = this.closest("petty-radio-group")?.getAttribute("orientation") !== "horizontal"; const isVertical = this.closest("petty-radio-group")?.getAttribute("orientation") !== "horizontal";
const prev = isVertical ? "ArrowUp" : "ArrowLeft"; const prev = isVertical ? "ArrowUp" : "ArrowLeft";
const next = isVertical ? "ArrowDown" : "ArrowRight"; const next = isVertical ? "ArrowDown" : "ArrowRight";
if (e.key !== prev && e.key !== next && e.key !== " ") return; if (ke.key !== prev && ke.key !== next && ke.key !== " ") return;
e.preventDefault(); ke.preventDefault();
if (e.key === " ") { this.#handleClick(); return; } if (ke.key === " ") { this.#handleClick(); return; }
const items = this.#siblings(); const items = this.#siblings();
const idx = items.indexOf(this); const idx = items.indexOf(this);
if (idx === -1) return; if (idx === -1) return;
const delta = e.key === next ? 1 : -1; const delta = ke.key === next ? 1 : -1;
const target = items[wrapIndex(idx, delta, items.length)]; const target = items[wrapIndex(idx, delta, items.length)];
if (target) { if (target) {
this.#group()?.selectValue(target.value); this.#group()?.selectValue(target.value);

View File

@ -1,10 +1,11 @@
import { emit } from "../../shared/helpers"; import { emit, listen, part, wireLabel } from "../../shared/helpers";
import { uniqueId } from "../../shared/aria";
/** PettySlider — range input wrapper with label, output, and change events. */ /** PettySlider — range input wrapper with label, output, and change events. */
export class PettySlider extends HTMLElement { export class PettySlider extends HTMLElement {
static observedAttributes = ["min", "max", "step", "value", "disabled", "name", "orientation"]; static observedAttributes = ["min", "max", "step", "value", "disabled", "name", "orientation"];
#cleanup = (): void => {};
get value(): number { get value(): number {
return Number(this.#input()?.value ?? 0); return Number(this.#input()?.value ?? 0);
} }
@ -18,13 +19,13 @@ export class PettySlider extends HTMLElement {
const input = this.#input(); const input = this.#input();
if (!input) return; if (!input) return;
this.#syncAttrs(); this.#syncAttrs();
this.#wireLabel(); wireLabel(input, part(this, "label"), "petty-slider");
this.#syncOutput(); this.#syncOutput();
input.addEventListener("input", this.#handleInput); this.#cleanup = listen(input, [["input", this.#handleInput]]);
} }
disconnectedCallback(): void { disconnectedCallback(): void {
this.#input()?.removeEventListener("input", this.#handleInput); this.#cleanup();
} }
attributeChangedCallback(): void { attributeChangedCallback(): void {
@ -36,15 +37,6 @@ export class PettySlider extends HTMLElement {
return this.querySelector("input[data-part=control]"); 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-slider");
(label as HTMLLabelElement).htmlFor = input.id;
}
}
#syncAttrs(): void { #syncAttrs(): void {
const input = this.#input(); const input = this.#input();
if (!input) return; if (!input) return;

View File

@ -1,3 +1,5 @@
import { listen } from "../../shared/helpers";
/** /**
* PettyTab individual tab button within a petty-tabs component. * PettyTab individual tab button within a petty-tabs component.
* *
@ -19,17 +21,17 @@ export class PettyTab extends HTMLElement {
} }
/** @internal */ /** @internal */
#cleanup = (): void => {};
connectedCallback(): void { connectedCallback(): void {
this.setAttribute("role", "tab"); this.setAttribute("role", "tab");
this.setAttribute("tabindex", "-1"); this.setAttribute("tabindex", "-1");
this.addEventListener("click", this.#handleClick); this.#cleanup = listen(this, [["click", this.#handleClick], ["keydown", this.#handleKeydown]]);
this.addEventListener("keydown", this.#handleKeydown);
} }
/** @internal */ /** @internal */
disconnectedCallback(): void { disconnectedCallback(): void {
this.removeEventListener("click", this.#handleClick); this.#cleanup();
this.removeEventListener("keydown", this.#handleKeydown);
} }
/** @internal */ /** @internal */
@ -48,12 +50,13 @@ export class PettyTab extends HTMLElement {
active?.focus(); active?.focus();
}; };
#handleKeydown = (e: KeyboardEvent): void => { #handleKeydown = (e: Event): void => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; const ke = e as KeyboardEvent;
if (ke.key !== "ArrowLeft" && ke.key !== "ArrowRight") return;
const tabs = this.#siblingTabs().filter(t => !t.disabled); const tabs = this.#siblingTabs().filter(t => !t.disabled);
const idx = tabs.indexOf(this); const idx = tabs.indexOf(this);
if (idx === -1) return; if (idx === -1) return;
const nextIdx = e.key === "ArrowRight" const nextIdx = ke.key === "ArrowRight"
? (idx + 1) % tabs.length ? (idx + 1) % tabs.length
: (idx - 1 + tabs.length) % tabs.length; : (idx - 1 + tabs.length) % tabs.length;
const next = tabs[nextIdx]; const next = tabs[nextIdx];

View File

@ -1,5 +1,5 @@
import { signal, effect } from "../../signals"; import { signal, effect } from "../../signals";
import { emit } from "../../shared/helpers"; import { emit, listen } from "../../shared/helpers";
/** PettyTagsInput — multi-value text input for tags, emails, or tokens. */ /** PettyTagsInput — multi-value text input for tags, emails, or tokens. */
export class PettyTagsInput extends HTMLElement { export class PettyTagsInput extends HTMLElement {
@ -41,18 +41,21 @@ export class PettyTagsInput extends HTMLElement {
this.#dispatch(); this.#dispatch();
} }
#cleanupInput = (): void => {};
#cleanupSelf = (): void => {};
connectedCallback(): void { connectedCallback(): void {
const init = this.getAttribute("value"); const init = this.getAttribute("value");
if (init) this.#tags.set(init.split(",").map(s => s.trim()).filter(Boolean)); if (init) this.#tags.set(init.split(",").map(s => s.trim()).filter(Boolean));
this.#stopEffect = effect(() => this.#render()); this.#stopEffect = effect(() => this.#render());
this.#input()?.addEventListener("keydown", this.#onKeydown); this.#cleanupInput = listen(this.#input(), [["keydown", this.#onKeydown]]);
this.addEventListener("click", this.#onTagRemove); this.#cleanupSelf = listen(this, [["click", this.#onTagRemove]]);
} }
disconnectedCallback(): void { disconnectedCallback(): void {
this.#stopEffect = null; this.#stopEffect = null;
this.#input()?.removeEventListener("keydown", this.#onKeydown); this.#cleanupInput();
this.removeEventListener("click", this.#onTagRemove); this.#cleanupSelf();
} }
attributeChangedCallback(name: string, _old: string | null, next: string | null): void { attributeChangedCallback(name: string, _old: string | null, next: string | null): void {
@ -73,14 +76,15 @@ export class PettyTagsInput extends HTMLElement {
if (hidden instanceof HTMLInputElement) hidden.value = this.#tags.get().join(","); if (hidden instanceof HTMLInputElement) hidden.value = this.#tags.get().join(",");
} }
#onKeydown = (e: KeyboardEvent): void => { #onKeydown = (e: Event): void => {
const ke = e as KeyboardEvent;
if (this.hasAttribute("disabled")) return; if (this.hasAttribute("disabled")) return;
const input = this.#input(); const input = this.#input();
if (!input) return; if (!input) return;
if (e.key === "Enter" || e.key === ",") { if (ke.key === "Enter" || ke.key === ",") {
e.preventDefault(); ke.preventDefault();
if (this.addTag(input.value)) input.value = ""; if (this.addTag(input.value)) input.value = "";
} else if (e.key === "Backspace" && input.value === "") { } else if (ke.key === "Backspace" && input.value === "") {
const tags = this.#tags.get(); const tags = this.#tags.get();
const last = tags[tags.length - 1]; const last = tags[tags.length - 1];
if (last) this.removeTag(last); if (last) this.removeTag(last);

View File

@ -1,20 +1,22 @@
import { uniqueId } from "../../shared/aria"; import { uniqueId } from "../../shared/aria";
import { emit } from "../../shared/helpers"; import { emit, listen, part } from "../../shared/helpers";
/** PettyTextField — labeled text input with description and error wiring. */ /** PettyTextField — labeled text input with description and error wiring. */
export class PettyTextField extends HTMLElement { export class PettyTextField extends HTMLElement {
static observedAttributes = ["name", "disabled", "required"]; static observedAttributes = ["name", "disabled", "required"];
#cleanup = (): void => {};
connectedCallback(): void { connectedCallback(): void {
const name = this.getAttribute("name") ?? ""; const name = this.getAttribute("name") ?? "";
const controlId = uniqueId(`petty-tf-${name}`); const controlId = uniqueId(`petty-tf-${name}`);
const errorId = `${controlId}-error`; const errorId = `${controlId}-error`;
const descId = `${controlId}-desc`; const descId = `${controlId}-desc`;
const label = this.querySelector("[data-part=label]"); const label = part<HTMLLabelElement>(this, "label");
const control = this.#control(); const control = this.#control();
const desc = this.querySelector("[data-part=description]"); const desc = part(this, "description");
const error = this.querySelector("[data-part=error]"); const error = part(this, "error");
if (control) { if (control) {
control.id = controlId; control.id = controlId;
@ -22,16 +24,16 @@ export class PettyTextField extends HTMLElement {
const describedBy = [desc ? descId : "", error ? errorId : ""].filter(Boolean).join(" "); const describedBy = [desc ? descId : "", error ? errorId : ""].filter(Boolean).join(" ");
if (describedBy) control.setAttribute("aria-describedby", describedBy); if (describedBy) control.setAttribute("aria-describedby", describedBy);
} }
if (label && control) (label as HTMLLabelElement).htmlFor = controlId; if (label && control) label.htmlFor = controlId;
if (desc) desc.id = descId; if (desc) desc.id = descId;
if (error) error.id = errorId; if (error) error.id = errorId;
this.#syncAttrs(); this.#syncAttrs();
control?.addEventListener("input", this.#handleInput); this.#cleanup = listen(control, [["input", this.#handleInput]]);
} }
disconnectedCallback(): void { disconnectedCallback(): void {
this.#control()?.removeEventListener("input", this.#handleInput); this.#cleanup();
} }
attributeChangedCallback(): void { attributeChangedCallback(): void {
@ -40,7 +42,7 @@ export class PettyTextField extends HTMLElement {
/** Display an error message on this field. */ /** Display an error message on this field. */
setError(message: string): void { setError(message: string): void {
const error = this.querySelector("[data-part=error]"); const error = part(this, "error");
const control = this.#control(); const control = this.#control();
if (error) error.textContent = message; if (error) error.textContent = message;
if (control) control.setAttribute("aria-invalid", "true"); if (control) control.setAttribute("aria-invalid", "true");
@ -48,7 +50,7 @@ export class PettyTextField extends HTMLElement {
/** Clear the error message on this field. */ /** Clear the error message on this field. */
clearError(): void { clearError(): void {
const error = this.querySelector("[data-part=error]"); const error = part(this, "error");
const control = this.#control(); const control = this.#control();
if (error) error.textContent = ""; if (error) error.textContent = "";
if (control) control.removeAttribute("aria-invalid"); if (control) control.removeAttribute("aria-invalid");

View File

@ -1,4 +1,5 @@
import { wrapIndex } from "../../shared/keyboard"; import { wrapIndex } from "../../shared/keyboard";
import { listen } from "../../shared/helpers";
/** PettyToggleGroupItem — single item within a toggle group. */ /** PettyToggleGroupItem — single item within a toggle group. */
export class PettyToggleGroupItem extends HTMLElement { export class PettyToggleGroupItem extends HTMLElement {
@ -7,17 +8,17 @@ export class PettyToggleGroupItem extends HTMLElement {
get value(): string { return this.getAttribute("value") ?? ""; } get value(): string { return this.getAttribute("value") ?? ""; }
get disabled(): boolean { return this.hasAttribute("disabled"); } get disabled(): boolean { return this.hasAttribute("disabled"); }
#cleanup = (): void => {};
connectedCallback(): void { connectedCallback(): void {
this.setAttribute("role", "button"); this.setAttribute("role", "button");
this.setAttribute("tabindex", "0"); this.setAttribute("tabindex", "0");
if (this.disabled) this.setAttribute("aria-disabled", "true"); if (this.disabled) this.setAttribute("aria-disabled", "true");
this.addEventListener("click", this.#handleClick); this.#cleanup = listen(this, [["click", this.#handleClick], ["keydown", this.#handleKeydown]]);
this.addEventListener("keydown", this.#handleKeydown);
} }
disconnectedCallback(): void { disconnectedCallback(): void {
this.removeEventListener("click", this.#handleClick); this.#cleanup();
this.removeEventListener("keydown", this.#handleKeydown);
} }
attributeChangedCallback(name: string): void { attributeChangedCallback(name: string): void {
@ -39,21 +40,22 @@ export class PettyToggleGroupItem extends HTMLElement {
this.#group()?.toggleValue(this.value); this.#group()?.toggleValue(this.value);
}; };
#handleKeydown = (e: KeyboardEvent): void => { #handleKeydown = (e: Event): void => {
if (e.key === " " || e.key === "Enter") { const ke = e as KeyboardEvent;
e.preventDefault(); if (ke.key === " " || ke.key === "Enter") {
ke.preventDefault();
this.#handleClick(); this.#handleClick();
return; return;
} }
const isHorizontal = this.closest("petty-toggle-group")?.getAttribute("orientation") !== "vertical"; const isHorizontal = this.closest("petty-toggle-group")?.getAttribute("orientation") !== "vertical";
const prev = isHorizontal ? "ArrowLeft" : "ArrowUp"; const prev = isHorizontal ? "ArrowLeft" : "ArrowUp";
const next = isHorizontal ? "ArrowRight" : "ArrowDown"; const next = isHorizontal ? "ArrowRight" : "ArrowDown";
if (e.key !== prev && e.key !== next) return; if (ke.key !== prev && ke.key !== next) return;
e.preventDefault(); ke.preventDefault();
const items = this.#siblings(); const items = this.#siblings();
const idx = items.indexOf(this); const idx = items.indexOf(this);
if (idx === -1) return; if (idx === -1) return;
const delta = e.key === next ? 1 : -1; const delta = ke.key === next ? 1 : -1;
items[wrapIndex(idx, delta, items.length)]?.focus(); items[wrapIndex(idx, delta, items.length)]?.focus();
}; };
} }

View File

@ -1,4 +1,4 @@
import { emit } from "../../shared/helpers"; import { emit, listen } from "../../shared/helpers";
/** PettyVirtualList — windowed scroll rendering only visible items plus overscan. */ /** PettyVirtualList — windowed scroll rendering only visible items plus overscan. */
export class PettyVirtualList extends HTMLElement { export class PettyVirtualList extends HTMLElement {

View File

@ -46,7 +46,6 @@ dialog {
dialog::backdrop { dialog::backdrop {
background: rgba(0 0 0 / 0.4); background: rgba(0 0 0 / 0.4);
backdrop-filter: blur(2px);
} }
/* ── Select ─────────────────────────────────────────────────────────────── */ /* ── Select ─────────────────────────────────────────────────────────────── */
@ -234,7 +233,13 @@ petty-toast-region {
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
line-height: 1; line-height: 1;
padding: 0.125rem 0.25rem; padding: 0.5rem;
min-width: 2.75rem;
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--petty-radius);
} }
[data-part="toast-close"]:hover { color: var(--petty-text); } [data-part="toast-close"]:hover { color: var(--petty-text); }
@ -265,10 +270,11 @@ petty-form-field {
width: 100%; width: 100%;
} }
[data-part="control"]:focus { [data-part="control"]:focus-visible {
border-color: var(--petty-border-focus); border-color: var(--petty-border-focus);
box-shadow: 0 0 0 3px rgba(37 99 235 / 0.15); box-shadow: 0 0 0 3px rgba(37 99 235 / 0.15);
outline: none; outline: 2px solid var(--petty-border-focus);
outline-offset: 2px;
} }
[data-part="control"][aria-invalid="true"] { [data-part="control"][aria-invalid="true"] {

View File

@ -7,7 +7,7 @@
<title>PettyUI — Component Showcase</title> <title>PettyUI — Component Showcase</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script type="module"> <script type="module">
import "pettyui/button"; import "pettyui/link"; import "pettyui/separator"; import "pettyui/button"; import "pettyui/link"; import "pettyui/separator";
import "pettyui/badge"; import "pettyui/skeleton"; import "pettyui/avatar"; import "pettyui/badge"; import "pettyui/skeleton"; import "pettyui/avatar";
@ -88,7 +88,7 @@
<h3>Text Field</h3> <h3>Text Field</h3>
<petty-text-field name="email"> <petty-text-field name="email">
<label data-part="label">Email address</label> <label data-part="label">Email address</label>
<input data-part="control" type="email" placeholder="you@example.com" /> <input data-part="control" type="email" placeholder="you@example.com" aria-label="Email address" />
<span data-part="description">We will never share your email.</span> <span data-part="description">We will never share your email.</span>
<span data-part="error"></span> <span data-part="error"></span>
</petty-text-field> </petty-text-field>

File diff suppressed because it is too large Load Diff