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
This commit is contained in:
Mats Bosson 2026-03-31 20:21:41 +07:00
parent 3768af1369
commit 168b5642d0
207 changed files with 7866 additions and 985 deletions

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 StayThree
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -7,14 +7,58 @@
"./signals": "./src/signals.ts",
"./router": "./src/router.ts",
"./theme": "./src/theme.css",
"./dialog": "./src/components/dialog/index.ts",
"./select": "./src/components/select/index.ts",
"./tabs": "./src/components/tabs/index.ts",
"./animations": "./src/animations.css",
"./counter": "./src/components/counter/index.ts",
"./accordion": "./src/components/accordion/index.ts",
"./form": "./src/components/form/index.ts",
"./toast": "./src/components/toast/index.ts",
"./alert": "./src/components/alert/index.ts",
"./alert-dialog": "./src/components/alert-dialog/index.ts",
"./avatar": "./src/components/avatar/index.ts",
"./badge": "./src/components/badge/index.ts",
"./breadcrumbs": "./src/components/breadcrumbs/index.ts",
"./button": "./src/components/button/index.ts",
"./calendar": "./src/components/calendar/index.ts",
"./card": "./src/components/card/index.ts",
"./checkbox": "./src/components/checkbox/index.ts",
"./collapsible": "./src/components/collapsible/index.ts",
"./combobox": "./src/components/combobox/index.ts",
"./command-palette": "./src/components/command-palette/index.ts",
"./context-menu": "./src/components/context-menu/index.ts",
"./data-table": "./src/components/data-table/index.ts",
"./date-picker": "./src/components/date-picker/index.ts",
"./dialog": "./src/components/dialog/index.ts",
"./drawer": "./src/components/drawer/index.ts",
"./dropdown-menu": "./src/components/dropdown-menu/index.ts",
"./popover": "./src/components/popover/index.ts"
"./form": "./src/components/form/index.ts",
"./hover-card": "./src/components/hover-card/index.ts",
"./image": "./src/components/image/index.ts",
"./link": "./src/components/link/index.ts",
"./loading-indicator": "./src/components/loading-indicator/index.ts",
"./listbox": "./src/components/listbox/index.ts",
"./meter": "./src/components/meter/index.ts",
"./navigation-menu": "./src/components/navigation-menu/index.ts",
"./number-field": "./src/components/number-field/index.ts",
"./pagination": "./src/components/pagination/index.ts",
"./popover": "./src/components/popover/index.ts",
"./progress": "./src/components/progress/index.ts",
"./radio-group": "./src/components/radio-group/index.ts",
"./select": "./src/components/select/index.ts",
"./separator": "./src/components/separator/index.ts",
"./skeleton": "./src/components/skeleton/index.ts",
"./slider": "./src/components/slider/index.ts",
"./switch": "./src/components/switch/index.ts",
"./tabs": "./src/components/tabs/index.ts",
"./text-field": "./src/components/text-field/index.ts",
"./toast": "./src/components/toast/index.ts",
"./toggle": "./src/components/toggle/index.ts",
"./toggle-group": "./src/components/toggle-group/index.ts",
"./tooltip": "./src/components/tooltip/index.ts",
"./virtual-list": "./src/components/virtual-list/index.ts",
"./tags-input": "./src/components/tags-input/index.ts",
"./typewriter": "./src/components/typewriter/index.ts",
"./stagger": "./src/components/stagger/index.ts",
"./reveal": "./src/components/reveal/index.ts",
"./parallax": "./src/components/parallax/index.ts",
"./wizard": "./src/components/wizard/index.ts"
},
"scripts": {
"build": "tsdown",

View File

@ -0,0 +1,137 @@
/* PettyUI Animations — stagger, reveal, and transition CSS */
.petty-stagger-hidden { opacity: 0; }
.petty-stagger-fade-up {
animation: pettyFadeUp 0.5s ease both;
}
.petty-stagger-fade-down {
animation: pettyFadeDown 0.5s ease both;
}
.petty-stagger-fade-left {
animation: pettyFadeLeft 0.5s ease both;
}
.petty-stagger-fade-right {
animation: pettyFadeRight 0.5s ease both;
}
.petty-stagger-scale {
animation: pettyScale 0.5s ease both;
}
.petty-stagger-blur {
animation: pettyBlur 0.6s ease both;
}
.petty-reveal-hidden { opacity: 0; }
.petty-reveal-fade-up {
animation: pettyFadeUp 0.6s ease both;
}
.petty-reveal-fade-down {
animation: pettyFadeDown 0.6s ease both;
}
.petty-reveal-fade-left {
animation: pettyFadeLeft 0.6s ease both;
}
.petty-reveal-fade-right {
animation: pettyFadeRight 0.6s ease both;
}
.petty-reveal-scale {
animation: pettyScale 0.6s ease both;
}
.petty-reveal-blur {
animation: pettyBlur 0.7s ease both;
}
petty-typewriter.petty-typewriter-cursor::after {
content: "|";
animation: pettyCursorBlink 0.8s step-end infinite;
}
petty-typewriter[data-state="done"].petty-typewriter-cursor::after {
animation: pettyCursorBlink 0.8s step-end 3;
}
petty-counter { font-variant-numeric: tabular-nums; }
@keyframes pettyFadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pettyFadeDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pettyFadeLeft {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes pettyFadeRight {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes pettyScale {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
@keyframes pettyBlur {
from { opacity: 0; filter: blur(8px); transform: translateY(10px); }
to { opacity: 1; filter: blur(0); transform: translateY(0); }
}
@keyframes pettyCursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
dialog[open] {
animation: pettyDialogIn 0.2s ease;
}
@keyframes pettyDialogIn {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
[popover]:popover-open {
animation: pettyPopoverIn 0.15s ease;
}
@keyframes pettyPopoverIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
[data-part="toast"] {
animation: pettySlideIn 0.3s ease;
}
@keyframes pettySlideIn {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
petty-loading-indicator { display: inline-flex; align-items: center; justify-content: center; position: relative; }
petty-loading-indicator [data-part="container"] { display: flex; align-items: center; justify-content: center; position: relative; }
petty-loading-indicator [data-part="indicator"] {
border-radius: 50%;
border: 3px solid rgba(54, 192, 241, 0.12);
border-top-color: #36c0f1;
border-right-color: rgba(54, 192, 241, 0.4);
animation: pettyLoadSpin 1.1s linear infinite, pettyLoadPulse 2.2s ease-in-out 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 {
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

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-accordion", description: "Headless accordion built on native <details> elements with single/multiple mode", tier: 1, attributes: [{ name: "type", type: "string", default: "single", description: "Accordion mode: single closes others on open, multiple allows many open" }], parts: [{ name: "content", element: "div", description: "Content area inside each details element" }], events: [{ name: "petty-change", detail: "{ value: string[] }", description: "Fires when open items change, detail contains array of open values" }], example: `<petty-accordion><petty-accordion-item value="one"><details><summary>Section 1</summary><div data-part="content">Content 1</div></details></petty-accordion-item></petty-accordion>` };

View File

@ -1,3 +1,5 @@
import { emit, listen } from "../../shared/helpers";
/**
* PettyAccordion headless accordion built on native `<details>` elements.
*
@ -63,10 +65,7 @@ export class PettyAccordion extends HTMLElement {
#dispatchChange(): void {
const openValues = this.#collectOpenValues();
this.dispatchEvent(new CustomEvent("petty-change", {
bubbles: true,
detail: { value: openValues },
}));
emit(this, "change", { value: openValues });
}
#collectOpenValues(): string[] {

View File

@ -1,5 +1,6 @@
export { PettyAccordion } from "./accordion";
export { PettyAccordionItem } from "./accordion-item";
import { PettyAccordion } from "./accordion";
import { PettyAccordionItem } from "./accordion-item";
export { PettyAccordion, PettyAccordionItem };
if (!customElements.get("petty-accordion")) {
customElements.define("petty-accordion", PettyAccordion);

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-alert-dialog", description: "Confirmation dialog with role=alertdialog on native dialog element", tier: 3, attributes: [], parts: [], events: [{ name: "petty-close", detail: "{ value: string }", description: "Fires when dialog closes, detail contains the return value" }], example: `<petty-alert-dialog><dialog><h2>Confirm</h2><p>Are you sure?</p><button value="cancel">Cancel</button><button value="confirm">Confirm</button></dialog></petty-alert-dialog>` };

View File

@ -0,0 +1,59 @@
import { emit, listen } from "../../shared/helpers";
import { uniqueId } from "../../shared/aria";
/** PettyAlertDialog — confirmation dialog with role="alertdialog" on native dialog. */
export class PettyAlertDialog extends HTMLElement {
#cleanup: (() => void) | null = null;
get dialogElement(): HTMLDialogElement | null {
return this.querySelector("dialog");
}
get isOpen(): boolean {
return this.dialogElement?.open ?? false;
}
/** Opens the alert dialog as a modal. */
open(): void {
const dlg = this.dialogElement;
if (dlg && !dlg.open) dlg.showModal();
}
/** Closes the alert dialog with an optional return value. */
close(returnValue?: string): void {
const dlg = this.dialogElement;
if (dlg?.open) dlg.close(returnValue);
}
connectedCallback(): void {
const dlg = this.dialogElement;
if (!dlg) return;
dlg.setAttribute("role", "alertdialog");
dlg.setAttribute("aria-modal", "true");
this.#linkAria(dlg);
this.#cleanup = listen(dlg, [["close", this.#handleClose]]);
}
disconnectedCallback(): void {
this.#cleanup?.();
this.#cleanup = null;
}
#handleClose = (): void => {
const dlg = this.dialogElement;
emit(this, "close", { value: dlg?.returnValue ?? "" });
};
#linkAria(dlg: HTMLDialogElement): void {
const heading = dlg.querySelector("h1, h2, h3, h4, h5, h6");
if (heading) {
if (!heading.id) heading.id = uniqueId("petty-adlg-title");
dlg.setAttribute("aria-labelledby", heading.id);
}
const desc = dlg.querySelector("p");
if (desc) {
if (!desc.id) desc.id = uniqueId("petty-adlg-desc");
dlg.setAttribute("aria-describedby", desc.id);
}
}
}

View File

@ -0,0 +1,6 @@
import { PettyAlertDialog } from "./alert-dialog";
export { PettyAlertDialog };
if (!customElements.get("petty-alert-dialog")) {
customElements.define("petty-alert-dialog", PettyAlertDialog);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-alert", description: "Inline status message with variant-driven ARIA role", tier: 1, attributes: [{ name: "variant", type: "string", default: "default", description: "Alert variant: default, error, warning, success, info. Error/warning sets role=alert" }], parts: [], events: [], example: `<petty-alert variant="error"><p>Something went wrong.</p></petty-alert>` };

View File

@ -0,0 +1,23 @@
/** PettyAlert — inline status message with variant-driven ARIA role. */
export class PettyAlert extends HTMLElement {
static observedAttributes = ["variant"];
get variant(): string {
return this.getAttribute("variant") ?? "default";
}
connectedCallback(): void {
this.#sync();
}
attributeChangedCallback(): void {
this.#sync();
}
#sync(): void {
const v = this.variant;
this.dataset.variant = v;
const isUrgent = v === "error" || v === "warning";
this.setAttribute("role", isUrgent ? "alert" : "status");
}
}

View File

@ -0,0 +1,6 @@
import { PettyAlert } from "./alert";
export { PettyAlert };
if (!customElements.get("petty-alert")) {
customElements.define("petty-alert", PettyAlert);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-avatar", description: "Image with automatic fallback display on load error", tier: 3, attributes: [], parts: [{ name: "fallback", element: "span", description: "Fallback content shown when image fails to load" }], events: [], example: `<petty-avatar><img src="/photo.jpg" alt="User" /><span data-part="fallback">AB</span></petty-avatar>` };

View File

@ -0,0 +1,38 @@
/** PettyAvatar — image with automatic fallback on load error. */
export class PettyAvatar extends HTMLElement {
connectedCallback(): void {
this.dataset.state = "loading";
const img = this.querySelector("img");
if (!img) { this.#showFallback(); return; }
img.addEventListener("load", this.#onLoad);
img.addEventListener("error", this.#onError);
if (img.complete && img.naturalWidth > 0) this.#onLoad();
else if (img.complete) this.#onError();
}
disconnectedCallback(): void {
const img = this.querySelector("img");
img?.removeEventListener("load", this.#onLoad);
img?.removeEventListener("error", this.#onError);
}
#onLoad = (): void => {
this.dataset.state = "loaded";
const img = this.querySelector("img");
const fallback = this.querySelector("[data-part=fallback]") as HTMLElement | null;
if (img) img.style.display = "";
if (fallback) fallback.style.display = "none";
};
#onError = (): void => {
this.dataset.state = "error";
this.#showFallback();
};
#showFallback(): void {
const img = this.querySelector("img");
const fallback = this.querySelector("[data-part=fallback]") as HTMLElement | null;
if (img) img.style.display = "none";
if (fallback) fallback.style.display = "";
}
}

View File

@ -0,0 +1,6 @@
import { PettyAvatar } from "./avatar";
export { PettyAvatar };
if (!customElements.get("petty-avatar")) {
customElements.define("petty-avatar", PettyAvatar);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-badge", description: "Display-only status indicator with variant support", tier: 3, attributes: [{ name: "variant", type: "string", default: "default", description: "Visual variant for styling" }], parts: [{ name: "badge", element: "span", description: "The badge element itself (set automatically)" }], events: [], example: `<petty-badge variant="success">Active</petty-badge>` };

View File

@ -0,0 +1,17 @@
/** PettyBadge — display-only status indicator with variant support. */
export class PettyBadge extends HTMLElement {
static observedAttributes = ["variant"];
get variant(): string {
return this.getAttribute("variant") ?? "default";
}
connectedCallback(): void {
this.dataset.variant = this.variant;
this.dataset.part = "badge";
}
attributeChangedCallback(): void {
this.dataset.variant = this.variant;
}
}

View File

@ -0,0 +1,6 @@
import { PettyBadge } from "./badge";
export { PettyBadge };
if (!customElements.get("petty-badge")) {
customElements.define("petty-badge", PettyBadge);
}

View File

@ -0,0 +1,24 @@
/** PettyBreadcrumbItem — single breadcrumb with current-page detection. */
export class PettyBreadcrumbItem extends HTMLElement {
static observedAttributes = ["current"];
connectedCallback(): void {
this.dataset.part = "item";
this.#sync();
}
attributeChangedCallback(): void {
this.#sync();
}
#sync(): void {
const isCurrent = this.hasAttribute("current");
const target = this.querySelector("a") ?? this;
if (isCurrent) {
target.setAttribute("aria-current", "page");
} else {
target.removeAttribute("aria-current");
}
this.dataset.state = isCurrent ? "current" : "default";
}
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-breadcrumbs", description: "Navigation breadcrumb trail with ARIA landmarks", tier: 3, attributes: [], parts: [{ name: "item", element: "li", description: "Individual breadcrumb item (on petty-breadcrumb-item)" }], events: [], example: `<petty-breadcrumbs><ol><petty-breadcrumb-item><a href="/">Home</a></petty-breadcrumb-item><petty-breadcrumb-item current><a href="/docs">Docs</a></petty-breadcrumb-item></ol></petty-breadcrumbs>` };

View File

@ -0,0 +1,11 @@
/** PettyBreadcrumbs — navigation breadcrumb trail with ARIA landmarks. */
export class PettyBreadcrumbs extends HTMLElement {
connectedCallback(): void {
if (!this.querySelector("nav")) {
this.setAttribute("role", "navigation");
}
this.setAttribute("aria-label", this.getAttribute("aria-label") ?? "Breadcrumb");
const list = this.querySelector("ol, ul");
if (list) list.setAttribute("role", "list");
}
}

View File

@ -0,0 +1,8 @@
import { PettyBreadcrumbs } from "./breadcrumbs";
import { PettyBreadcrumbItem } from "./breadcrumb-item";
export { PettyBreadcrumbs, PettyBreadcrumbItem };
if (!customElements.get("petty-breadcrumbs")) {
customElements.define("petty-breadcrumbs", PettyBreadcrumbs);
customElements.define("petty-breadcrumb-item", PettyBreadcrumbItem);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-button", description: "Headless button wrapper with loading and disabled states", tier: 3, attributes: [{ name: "disabled", type: "boolean", description: "Disables the button" }, { name: "loading", type: "boolean", description: "Shows loading state, disables interaction" }], parts: [], events: [], example: `<petty-button><button>Click me</button></petty-button>` };

View File

@ -0,0 +1,53 @@
/**
* PettyButton headless button wrapper with loading and disabled states.
*
* Usage:
* ```html
* <petty-button>
* <button>Click me</button>
* </petty-button>
* ```
*/
export class PettyButton extends HTMLElement {
static observedAttributes = ["disabled", "loading"];
/** The child `<button>` element. */
get buttonElement(): HTMLButtonElement | null {
return this.querySelector("button");
}
/** @internal */
connectedCallback(): void {
this.#sync();
}
/** @internal */
attributeChangedCallback(): void {
this.#sync();
}
#sync(): void {
const btn = this.buttonElement;
if (!btn) return;
const disabled = this.hasAttribute("disabled");
const loading = this.hasAttribute("loading");
btn.disabled = disabled || loading;
btn.setAttribute("aria-disabled", String(disabled || loading));
if (loading) {
btn.setAttribute("aria-busy", "true");
this.dataset.state = "loading";
} else {
btn.removeAttribute("aria-busy");
this.dataset.state = disabled ? "disabled" : "idle";
}
if (disabled) {
this.dataset.disabled = "";
} else {
delete this.dataset.disabled;
}
}
}

View File

@ -0,0 +1,6 @@
import { PettyButton } from "./button";
export { PettyButton };
if (!customElements.get("petty-button")) {
customElements.define("petty-button", PettyButton);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-calendar", description: "Month grid with day selection and month navigation", tier: 3, attributes: [{ name: "value", type: "string", description: "Selected date in ISO format (YYYY-MM-DD)" }, { name: "min", type: "string", description: "Minimum selectable date in ISO format" }, { name: "max", type: "string", description: "Maximum selectable date in ISO format" }], parts: [{ name: "title", element: "div", description: "Displays the current month and year" }, { name: "prev-month", element: "button", description: "Navigate to previous month" }, { name: "next-month", element: "button", description: "Navigate to next month" }, { name: "body", element: "tbody", description: "Table body where day cells are rendered" }, { name: "day", element: "button", description: "Individual day button with data-date attribute" }], events: [{ name: "petty-change", detail: "{ value: string }", description: "Fires when a day is selected with ISO date" }], example: `<petty-calendar value="2025-01-15"><header><button data-part="prev-month">&lt;</button><span data-part="title"></span><button data-part="next-month">&gt;</button></header><table><tbody data-part="body"></tbody></table></petty-calendar>` };

View File

@ -0,0 +1,93 @@
import { signal, effect } from "../../signals";
import { emit } from "../../shared/helpers";
/** PettyCalendar — month grid with day selection and month navigation. */
export class PettyCalendar extends HTMLElement {
static observedAttributes = ["value", "min", "max"];
readonly #month = signal(new Date().getMonth());
readonly #year = signal(new Date().getFullYear());
readonly #selected = signal("");
#stopEffect: (() => void) | null = null;
get value(): string { return this.#selected.get(); }
set value(v: string) { this.#selected.set(v); }
connectedCallback(): void {
const init = this.getAttribute("value") ?? "";
if (init) { this.#selected.set(init); this.#parseMonth(init); }
this.#stopEffect = effect(() => this.#render());
this.querySelector("[data-part=prev-month]")?.addEventListener("click", this.#onPrev);
this.querySelector("[data-part=next-month]")?.addEventListener("click", this.#onNext);
this.addEventListener("click", this.#onDayClick);
}
disconnectedCallback(): void {
this.#stopEffect = null;
this.querySelector("[data-part=prev-month]")?.removeEventListener("click", this.#onPrev);
this.querySelector("[data-part=next-month]")?.removeEventListener("click", this.#onNext);
this.removeEventListener("click", this.#onDayClick);
}
attributeChangedCallback(name: string, _old: string | null, next: string | null): void {
if (name === "value" && next) { this.#selected.set(next); this.#parseMonth(next); }
}
#parseMonth(dateStr: string): void {
const d = new Date(dateStr);
if (!Number.isNaN(d.getTime())) { this.#month.set(d.getMonth()); this.#year.set(d.getFullYear()); }
}
#onPrev = (): void => {
if (this.#month.get() === 0) { this.#month.set(11); this.#year.set(this.#year.get() - 1); }
else this.#month.set(this.#month.get() - 1);
};
#onNext = (): void => {
if (this.#month.get() === 11) { this.#month.set(0); this.#year.set(this.#year.get() + 1); }
else this.#month.set(this.#month.get() + 1);
};
#onDayClick = (e: Event): void => {
const btn = (e.target as HTMLElement).closest("[data-date]");
if (!btn || btn.hasAttribute("data-disabled")) return;
const date = (btn as HTMLElement).dataset.date ?? "";
this.#selected.set(date);
emit(this, "change", { value: date });
};
#createDayCell(day: number, iso: string, sel: string, today: string): HTMLTableCellElement {
const td = document.createElement("td");
const btn = document.createElement("button");
btn.dataset.date = iso;
btn.dataset.part = "day";
btn.setAttribute("role", "gridcell");
btn.textContent = String(day);
if (iso === sel) btn.dataset.state = "selected";
if (iso === today) btn.dataset.state = btn.dataset.state ? `${btn.dataset.state} today` : "today";
td.appendChild(btn);
return td;
}
#render(): void {
const m = this.#month.get();
const y = this.#year.get();
const sel = this.#selected.get();
const title = this.querySelector("[data-part=title]");
if (title) title.textContent = `${new Date(y, m).toLocaleString("default", { month: "long" })} ${y}`;
const body = this.querySelector("[data-part=body]");
if (!body) return;
const today = new Date().toISOString().slice(0, 10);
const firstDay = new Date(y, m, 1).getDay();
const daysInMonth = new Date(y, m + 1, 0).getDate();
body.replaceChildren();
let row = document.createElement("tr");
for (let i = 0; i < firstDay; i++) row.appendChild(document.createElement("td"));
for (let d = 1; d <= daysInMonth; d++) {
const iso = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
row.appendChild(this.#createDayCell(d, iso, sel, today));
if ((firstDay + d) % 7 === 0) { body.appendChild(row); row = document.createElement("tr"); }
}
if (row.children.length > 0) body.appendChild(row);
}
}

View File

@ -0,0 +1,6 @@
import { PettyCalendar } from "./calendar";
export { PettyCalendar };
if (!customElements.get("petty-calendar")) {
customElements.define("petty-calendar", PettyCalendar);
}

View File

@ -0,0 +1,6 @@
/** PettyCardContent — structural body section within a card. */
export class PettyCardContent extends HTMLElement {
connectedCallback(): void {
this.dataset.part = "content";
}
}

View File

@ -0,0 +1,6 @@
/** PettyCardFooter — structural footer section within a card. */
export class PettyCardFooter extends HTMLElement {
connectedCallback(): void {
this.dataset.part = "footer";
}
}

View File

@ -0,0 +1,6 @@
/** PettyCardHeader — structural header section within a card. */
export class PettyCardHeader extends HTMLElement {
connectedCallback(): void {
this.dataset.part = "header";
}
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-card", description: "Structural container with optional heading-based ARIA labelling", tier: 3, attributes: [], parts: [{ name: "header", element: "div", description: "Card header section (petty-card-header)" }, { name: "content", element: "div", description: "Card body section (petty-card-content)" }, { name: "footer", element: "div", description: "Card footer section (petty-card-footer)" }], events: [], example: `<petty-card><petty-card-header><h3>Title</h3></petty-card-header><petty-card-content><p>Body text</p></petty-card-content><petty-card-footer><button>Action</button></petty-card-footer></petty-card>` };

View File

@ -0,0 +1,13 @@
import { uniqueId } from "../../shared/aria";
/** PettyCard — structural container with optional heading-based labelling. */
export class PettyCard extends HTMLElement {
connectedCallback(): void {
const heading = this.querySelector("h1, h2, h3, h4, h5, h6");
if (heading) {
this.setAttribute("role", "article");
if (!heading.id) heading.id = uniqueId("petty-card-title");
this.setAttribute("aria-labelledby", heading.id);
}
}
}

View File

@ -0,0 +1,12 @@
import { PettyCard } from "./card";
import { PettyCardHeader } from "./card-header";
import { PettyCardContent } from "./card-content";
import { PettyCardFooter } from "./card-footer";
export { PettyCard, PettyCardHeader, PettyCardContent, PettyCardFooter };
if (!customElements.get("petty-card")) {
customElements.define("petty-card", PettyCard);
customElements.define("petty-card-header", PettyCardHeader);
customElements.define("petty-card-content", PettyCardContent);
customElements.define("petty-card-footer", PettyCardFooter);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-checkbox", description: "Tri-state checkbox with label wiring and change events", tier: 3, attributes: [{ name: "checked", type: "boolean", description: "Whether the checkbox is checked" }, { name: "indeterminate", type: "boolean", description: "Whether the checkbox is in indeterminate state" }, { name: "disabled", type: "boolean", description: "Disables the checkbox" }, { name: "name", type: "string", description: "Form field name" }, { name: "value", type: "string", default: "on", description: "Value submitted when checked" }], parts: [{ name: "control", element: "input", description: "The native checkbox input element" }, { name: "label", element: "label", description: "Label element auto-linked to the control" }], events: [{ name: "petty-change", detail: "{ checked: boolean, indeterminate: boolean }", description: "Fires when checked state changes" }], example: `<petty-checkbox name="agree"><input data-part="control" type="checkbox" /><label data-part="label">I agree</label></petty-checkbox>` };

View File

@ -0,0 +1,62 @@
import { signal } from "../../signals";
import { emit, listen, part, wireLabel } from "../../shared/helpers";
/** PettyCheckbox — tri-state checkbox with label wiring and change events. */
export class PettyCheckbox extends HTMLElement {
static observedAttributes = ["checked", "indeterminate", "disabled", "name", "value"];
readonly #checked = signal(false);
readonly #indeterminate = signal(false);
#cleanup = (): void => {};
get checked(): boolean { return this.#checked.get(); }
set checked(v: boolean) { this.#checked.set(v); this.#sync(); }
get indeterminate(): boolean { return this.#indeterminate.get(); }
set indeterminate(v: boolean) { this.#indeterminate.set(v); this.#sync(); }
connectedCallback(): void {
const input = this.#input();
if (!input) return;
if (this.hasAttribute("checked")) this.#checked.set(true);
if (this.hasAttribute("indeterminate")) this.#indeterminate.set(true);
wireLabel(input, part(this, "label"), "petty-cb");
this.#sync();
this.#cleanup = listen(input, [["change", this.#handleChange]]);
}
disconnectedCallback(): void {
this.#cleanup();
}
attributeChangedCallback(name: string, _old: string | null, next: string | null): void {
if (name === "checked") this.#checked.set(next !== null);
if (name === "indeterminate") this.#indeterminate.set(next !== null);
this.#sync();
}
#input(): HTMLInputElement | null {
return part<HTMLInputElement>(this, "control");
}
#sync(): void {
const input = this.#input();
if (!input) return;
input.checked = this.#checked.get();
input.indeterminate = this.#indeterminate.get();
input.disabled = this.hasAttribute("disabled");
if (this.hasAttribute("name")) input.name = this.getAttribute("name") ?? "";
if (this.hasAttribute("value")) input.value = this.getAttribute("value") ?? "on";
const state = this.#indeterminate.get() ? "indeterminate" : this.#checked.get() ? "checked" : "unchecked";
this.dataset.state = state;
}
#handleChange = (): void => {
const input = this.#input();
if (!input) return;
this.#checked.set(input.checked);
this.#indeterminate.set(false);
this.#sync();
emit(this, "change", { checked: input.checked, indeterminate: false });
};
}

View File

@ -0,0 +1,6 @@
import { PettyCheckbox } from "./checkbox";
export { PettyCheckbox };
if (!customElements.get("petty-checkbox")) {
customElements.define("petty-checkbox", PettyCheckbox);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-collapsible", description: "Single disclosure wrapper on native details element", tier: 1, attributes: [{ name: "disabled", type: "boolean", description: "Prevents opening the collapsible" }], parts: [], events: [{ name: "petty-toggle", detail: "{ open: boolean }", description: "Fires when the collapsible opens or closes" }], example: `<petty-collapsible><details><summary>Toggle</summary><div data-part="content">Hidden content</div></details></petty-collapsible>` };

View File

@ -0,0 +1,77 @@
import { emit, listen } from "../../shared/helpers";
/** PettyCollapsible — single disclosure wrapper on native details element. */
export class PettyCollapsible extends HTMLElement {
static observedAttributes = ["disabled"];
#cleanup = (): void => {};
get detailsElement(): HTMLDetailsElement | null {
return this.querySelector("details");
}
get isOpen(): boolean {
return this.detailsElement?.open ?? false;
}
/** Opens the collapsible. */
open(): void {
const d = this.detailsElement;
if (d && !this.hasAttribute("disabled")) d.open = true;
}
/** Closes the collapsible. */
close(): void {
const d = this.detailsElement;
if (d) d.open = false;
}
/** Toggles the collapsible open/closed state. */
toggle(): void {
if (this.isOpen) this.close();
else this.open();
}
connectedCallback(): void {
const d = this.detailsElement;
if (!d) return;
this.#syncState();
this.#applyDisabled();
this.#cleanup = listen(d, [["toggle", this.#handleToggle]]);
}
disconnectedCallback(): void {
this.#cleanup();
}
attributeChangedCallback(name: string): void {
if (name === "disabled") this.#applyDisabled();
}
#handleToggle = (): void => {
this.#syncState();
emit(this, "toggle", { open: this.isOpen });
};
#syncState(): void {
const d = this.detailsElement;
this.dataset.state = d?.open ? "open" : "closed";
const summary = d?.querySelector("summary");
if (summary) summary.setAttribute("aria-expanded", String(d?.open ?? false));
}
#applyDisabled(): void {
const disabled = this.hasAttribute("disabled");
const summary = this.detailsElement?.querySelector("summary");
if (!summary) return;
if (disabled) {
summary.setAttribute("aria-disabled", "true");
summary.addEventListener("click", this.#preventToggle);
} else {
summary.removeAttribute("aria-disabled");
summary.removeEventListener("click", this.#preventToggle);
}
}
#preventToggle = (e: Event): void => { e.preventDefault(); };
}

View File

@ -0,0 +1,6 @@
import { PettyCollapsible } from "./collapsible";
export { PettyCollapsible };
if (!customElements.get("petty-collapsible")) {
customElements.define("petty-collapsible", PettyCollapsible);
}

View File

@ -0,0 +1,18 @@
/** PettyComboboxOption — single option within a combobox listbox. */
export class PettyComboboxOption extends HTMLElement {
static observedAttributes = ["value", "disabled"];
get value(): string { return this.getAttribute("value") ?? this.textContent?.trim() ?? ""; }
get disabled(): boolean { return this.hasAttribute("disabled"); }
connectedCallback(): void {
this.setAttribute("role", "option");
this.setAttribute("tabindex", "-1");
this.setAttribute("aria-selected", "false");
if (this.disabled) this.setAttribute("aria-disabled", "true");
}
attributeChangedCallback(name: string): void {
if (name === "disabled") this.setAttribute("aria-disabled", String(this.disabled));
}
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-combobox", description: "Searchable select with popover listbox and keyboard navigation", tier: 2, attributes: [{ name: "value", type: "string", description: "Currently selected value" }, { name: "placeholder", type: "string", description: "Placeholder text for the input" }, { name: "disabled", type: "boolean", description: "Disables the combobox" }], parts: [{ name: "input", element: "input", description: "Text input for search filtering" }, { name: "listbox", element: "div", description: "Popover container for options" }], events: [{ name: "petty-change", detail: "{ value: string }", description: "Fires when an option is selected" }], example: `<petty-combobox><input data-part="input" /><div data-part="listbox" popover><petty-combobox-option value="one">One</petty-combobox-option></div></petty-combobox>` };

View File

@ -0,0 +1,83 @@
import { uniqueId } from "../../shared/aria";
import { emit } from "../../shared/helpers";
/** PettyCombobox — searchable select with popover listbox and keyboard nav. */
export class PettyCombobox extends HTMLElement {
static observedAttributes = ["value", "placeholder", "disabled"];
#highlightIndex = -1;
connectedCallback(): void {
const input = this.#input();
const lb = this.#listbox();
if (!input || !lb) return;
if (!lb.id) lb.id = uniqueId("petty-combo-lb");
input.setAttribute("aria-controls", lb.id);
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-autocomplete", "list");
input.addEventListener("input", this.#onInput);
input.addEventListener("keydown", this.#onKeydown);
lb.addEventListener("click", this.#onClick);
}
disconnectedCallback(): void {
this.#input()?.removeEventListener("input", this.#onInput);
this.#input()?.removeEventListener("keydown", this.#onKeydown);
this.#listbox()?.removeEventListener("click", this.#onClick);
}
#input(): HTMLInputElement | null { return this.querySelector("input[data-part=input]"); }
#listbox(): HTMLElement | null { return this.querySelector("[data-part=listbox]"); }
#options(): HTMLElement[] {
return Array.from(this.querySelectorAll("petty-combobox-option:not([disabled]):not([hidden])"));
}
#onInput = (): void => {
const input = this.#input();
const lb = this.#listbox();
if (!input || !lb) return;
const query = input.value.toLowerCase();
const all = this.querySelectorAll<HTMLElement>("petty-combobox-option");
for (const opt of all) {
const match = (opt.textContent ?? "").toLowerCase().includes(query);
opt.toggleAttribute("hidden", !match);
}
if (!lb.matches(":popover-open")) lb.showPopover();
input.setAttribute("aria-expanded", "true");
this.#highlightIndex = -1;
};
#onKeydown = (e: KeyboardEvent): void => {
const items = this.#options();
if (e.key === "ArrowDown") { e.preventDefault(); this.#highlight(Math.min(this.#highlightIndex + 1, items.length - 1), items); }
else if (e.key === "ArrowUp") { e.preventDefault(); this.#highlight(Math.max(this.#highlightIndex - 1, 0), items); }
else if (e.key === "Enter") { e.preventDefault(); this.#selectHighlighted(items); }
else if (e.key === "Escape") { this.#listbox()?.hidePopover(); this.#input()?.setAttribute("aria-expanded", "false"); }
};
#highlight(idx: number, items: HTMLElement[]): void {
for (const opt of items) opt.removeAttribute("data-highlighted");
if (items[idx]) { items[idx].setAttribute("data-highlighted", ""); this.#input()?.setAttribute("aria-activedescendant", items[idx].id || ""); }
this.#highlightIndex = idx;
}
#selectHighlighted(items: HTMLElement[]): void {
const opt = items[this.#highlightIndex];
if (opt) this.#select(opt);
}
#onClick = (e: MouseEvent): void => {
const opt = (e.target as HTMLElement).closest("petty-combobox-option");
if (opt && !opt.hasAttribute("disabled")) this.#select(opt as HTMLElement);
};
#select(opt: HTMLElement): void {
const val = opt.getAttribute("value") ?? opt.textContent?.trim() ?? "";
const input = this.#input();
if (input) input.value = opt.textContent?.trim() ?? val;
this.#listbox()?.hidePopover();
input?.setAttribute("aria-expanded", "false");
emit(this, "change", { value: val });
}
}

View File

@ -0,0 +1,8 @@
import { PettyCombobox } from "./combobox";
import { PettyComboboxOption } from "./combobox-option";
export { PettyCombobox, PettyComboboxOption };
if (!customElements.get("petty-combobox")) {
customElements.define("petty-combobox", PettyCombobox);
customElements.define("petty-combobox-option", PettyComboboxOption);
}

View File

@ -0,0 +1,17 @@
/** PettyCommandPaletteItem — single command option within the palette. */
export class PettyCommandPaletteItem extends HTMLElement {
static observedAttributes = ["value", "disabled"];
get value(): string { return this.getAttribute("value") ?? this.textContent?.trim() ?? ""; }
get disabled(): boolean { return this.hasAttribute("disabled"); }
connectedCallback(): void {
this.setAttribute("role", "option");
this.setAttribute("tabindex", "-1");
if (this.disabled) this.setAttribute("aria-disabled", "true");
}
attributeChangedCallback(name: string): void {
if (name === "disabled") this.setAttribute("aria-disabled", String(this.disabled));
}
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-command-palette", description: "Search-driven command menu using native dialog, opened with Cmd+K", tier: 2, attributes: [], parts: [{ name: "search", element: "input", description: "Search input for filtering commands" }, { name: "list", element: "div", description: "Container for command items" }], events: [{ name: "petty-select", detail: "{ value: string }", description: "Fires when a command item is selected" }], example: `<petty-command-palette><dialog><input data-part="search" placeholder="Search..." /><div data-part="list"><petty-command-palette-item value="save">Save</petty-command-palette-item></div></dialog></petty-command-palette>` };

View File

@ -0,0 +1,79 @@
import { emit } from "../../shared/helpers";
/** PettyCommandPalette — search-driven command menu using native dialog. */
export class PettyCommandPalette extends HTMLElement {
#highlightIndex = -1;
/** Opens the command palette dialog. */
open(): void {
const dlg = this.querySelector("dialog");
if (dlg && !dlg.open) { dlg.showModal(); this.#search()?.focus(); }
}
/** Closes the command palette dialog. */
close(): void {
const dlg = this.querySelector("dialog");
if (dlg?.open) dlg.close();
}
connectedCallback(): void {
document.addEventListener("keydown", this.#onGlobalKey);
this.#search()?.addEventListener("input", this.#onSearch);
this.#list()?.addEventListener("click", this.#onClick);
this.#list()?.addEventListener("keydown", this.#onListKey);
}
disconnectedCallback(): void {
document.removeEventListener("keydown", this.#onGlobalKey);
this.#search()?.removeEventListener("input", this.#onSearch);
this.#list()?.removeEventListener("click", this.#onClick);
this.#list()?.removeEventListener("keydown", this.#onListKey);
}
#search(): HTMLInputElement | null { return this.querySelector("input[data-part=search]"); }
#list(): HTMLElement | null { return this.querySelector("[data-part=list]"); }
#visibleItems(): HTMLElement[] {
return Array.from(this.querySelectorAll("petty-command-palette-item:not([hidden]):not([disabled])"));
}
#onGlobalKey = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); this.open(); }
};
#onSearch = (): void => {
const query = (this.#search()?.value ?? "").toLowerCase();
const all = this.querySelectorAll<HTMLElement>("petty-command-palette-item");
for (const item of all) {
const match = (item.textContent ?? "").toLowerCase().includes(query);
item.toggleAttribute("hidden", !match);
}
this.#highlightIndex = -1;
};
#onListKey = (e: Event): void => {
const ke = e as KeyboardEvent;
const items = this.#visibleItems();
if (ke.key === "ArrowDown") { ke.preventDefault(); this.#highlight(Math.min(this.#highlightIndex + 1, items.length - 1), items); }
else if (ke.key === "ArrowUp") { ke.preventDefault(); this.#highlight(Math.max(this.#highlightIndex - 1, 0), items); }
else if (ke.key === "Enter") { ke.preventDefault(); this.#selectItem(items[this.#highlightIndex]); }
};
#highlight(idx: number, items: HTMLElement[]): void {
for (const item of items) item.removeAttribute("data-highlighted");
if (items[idx]) { items[idx].setAttribute("data-highlighted", ""); items[idx].focus(); }
this.#highlightIndex = idx;
}
#onClick = (e: Event): void => {
const target = (e.target as HTMLElement).closest("petty-command-palette-item");
if (target && !target.hasAttribute("disabled")) this.#selectItem(target as HTMLElement);
};
#selectItem(item: HTMLElement | undefined): void {
if (!item) return;
const val = item.getAttribute("value") ?? item.textContent?.trim() ?? "";
emit(this, "select", { value: val });
this.close();
}
}

View File

@ -0,0 +1,8 @@
import { PettyCommandPalette } from "./command-palette";
import { PettyCommandPaletteItem } from "./command-palette-item";
export { PettyCommandPalette, PettyCommandPaletteItem };
if (!customElements.get("petty-command-palette")) {
customElements.define("petty-command-palette", PettyCommandPalette);
customElements.define("petty-command-palette-item", PettyCommandPaletteItem);
}

View File

@ -0,0 +1,16 @@
/** PettyContextMenuItem — single item within a context menu. */
export class PettyContextMenuItem extends HTMLElement {
static observedAttributes = ["disabled"];
connectedCallback(): void {
this.setAttribute("role", "menuitem");
this.setAttribute("tabindex", "-1");
if (this.hasAttribute("disabled")) this.setAttribute("aria-disabled", "true");
}
attributeChangedCallback(name: string): void {
if (name === "disabled") {
this.setAttribute("aria-disabled", String(this.hasAttribute("disabled")));
}
}
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-context-menu", description: "Right-click menu using Popover API with keyboard navigation", tier: 2, attributes: [], parts: [{ name: "trigger", element: "div", description: "Element that triggers the context menu on right-click" }, { name: "content", element: "div", description: "Popover container for menu items" }], events: [{ name: "petty-select", detail: "{ value: string }", description: "Fires when a menu item is selected" }], example: `<petty-context-menu><div data-part="trigger">Right-click here</div><div data-part="content" popover><petty-context-menu-item value="copy">Copy</petty-context-menu-item></div></petty-context-menu>` };

View File

@ -0,0 +1,63 @@
import { emit } from "../../shared/helpers";
/** PettyContextMenu — right-click menu using Popover API with keyboard nav. */
export class PettyContextMenu extends HTMLElement {
connectedCallback(): void {
const trigger = this.querySelector("[data-part=trigger]");
const content = this.#content();
if (!trigger || !content) return;
trigger.addEventListener("contextmenu", this.#onContext);
content.addEventListener("keydown", this.#onKeydown);
content.addEventListener("click", this.#onClick);
}
disconnectedCallback(): void {
const trigger = this.querySelector("[data-part=trigger]");
const content = this.#content();
trigger?.removeEventListener("contextmenu", this.#onContext);
content?.removeEventListener("keydown", this.#onKeydown);
content?.removeEventListener("click", this.#onClick);
}
#content(): HTMLElement | null { return this.querySelector("[data-part=content]"); }
#items(): HTMLElement[] {
return Array.from(this.querySelectorAll("petty-context-menu-item:not([disabled])"));
}
#onContext = (e: Event): void => {
e.preventDefault();
const me = e as MouseEvent;
const content = this.#content();
if (!content) return;
content.style.setProperty("--petty-ctx-x", `${me.clientX}px`);
content.style.setProperty("--petty-ctx-y", `${me.clientY}px`);
content.showPopover();
this.#items()[0]?.focus();
};
#onKeydown = (e: KeyboardEvent): void => {
const items = this.#items();
const active = document.activeElement as HTMLElement;
const idx = items.indexOf(active);
if (e.key === "ArrowDown") {
e.preventDefault();
items[(idx + 1) % items.length]?.focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
items[(idx - 1 + items.length) % items.length]?.focus();
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
active?.click();
} else if (e.key === "Escape") {
this.#content()?.hidePopover();
}
};
#onClick = (e: MouseEvent): void => {
const item = (e.target as HTMLElement).closest("petty-context-menu-item");
if (!item || item.hasAttribute("disabled")) return;
emit(this, "select", { value: item.getAttribute("value") ?? item.textContent?.trim() ?? "" });
this.#content()?.hidePopover();
};
}

View File

@ -0,0 +1,8 @@
import { PettyContextMenu } from "./context-menu";
import { PettyContextMenuItem } from "./context-menu-item";
export { PettyContextMenu, PettyContextMenuItem };
if (!customElements.get("petty-context-menu")) {
customElements.define("petty-context-menu", PettyContextMenu);
customElements.define("petty-context-menu-item", PettyContextMenuItem);
}

View File

@ -0,0 +1,74 @@
import { emit } from "../../shared/helpers";
/** PettyCounter — animates a number from start to end with easing. */
export class PettyCounter extends HTMLElement {
static observedAttributes = ["from", "to", "duration", "delay", "decimals", "prefix", "suffix"];
#frame: ReturnType<typeof requestAnimationFrame> | null = null;
#startTime = 0;
#timer: ReturnType<typeof setTimeout> | null = null;
get from(): number { return Number(this.getAttribute("from") ?? 0); }
get to(): number { return Number(this.getAttribute("to") ?? 100); }
get duration(): number { return Number(this.getAttribute("duration") ?? 2000); }
get delay(): number { return Number(this.getAttribute("delay") ?? 0); }
get decimals(): number { return Number(this.getAttribute("decimals") ?? 0); }
get prefix(): string { return this.getAttribute("prefix") ?? ""; }
get suffix(): string { return this.getAttribute("suffix") ?? ""; }
/** Starts the counter animation. */
start(): void {
this.#startTime = performance.now();
this.dataset.state = "counting";
this.#animate(this.#startTime);
}
/** Resets the counter to its initial value. */
reset(): void {
this.#stop();
this.#render(this.from);
this.dataset.state = "idle";
}
connectedCallback(): void {
this.#render(this.from);
this.dataset.state = "idle";
if (this.delay > 0) {
this.#timer = setTimeout(() => this.start(), this.delay);
} else {
this.start();
}
}
disconnectedCallback(): void {
this.#stop();
}
#stop(): void {
if (this.#frame) { cancelAnimationFrame(this.#frame); this.#frame = null; }
if (this.#timer) { clearTimeout(this.#timer); this.#timer = null; }
}
#easeOut(t: number): number {
return 1 - (1 - t) ** 3;
}
#animate = (now: number): void => {
const elapsed = now - this.#startTime;
const progress = Math.min(elapsed / this.duration, 1);
const eased = this.#easeOut(progress);
const current = this.from + (this.to - this.from) * eased;
this.#render(current);
if (progress < 1) {
this.#frame = requestAnimationFrame(this.#animate);
} else {
this.dataset.state = "done";
emit(this, "complete", {});
}
};
#render(value: number): void {
const formatted = value.toFixed(this.decimals);
this.textContent = `${this.prefix}${formatted}${this.suffix}`;
}
}

View File

@ -0,0 +1,6 @@
import { PettyCounter } from "./counter";
export { PettyCounter };
if (!customElements.get("petty-counter")) {
customElements.define("petty-counter", PettyCounter);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-data-table", description: "Sortable table with click-to-sort column headers", tier: 3, attributes: [], parts: [{ name: "body", element: "tbody", description: "Table body containing sortable rows" }], events: [{ name: "petty-sort", detail: "{ column: string, direction: string }", description: "Fires when a column header is clicked, with column name and sort direction" }], example: `<petty-data-table><table><thead><tr><th data-sort="name">Name</th><th data-sort="age">Age</th></tr></thead><tbody data-part="body"><tr><td>Alice</td><td>30</td></tr></tbody></table></petty-data-table>` };

View File

@ -0,0 +1,54 @@
import { emit } from "../../shared/helpers";
/** PettyDataTable — sortable table with click-to-sort column headers. */
export class PettyDataTable extends HTMLElement {
connectedCallback(): void {
const headers = this.querySelectorAll<HTMLElement>("th[data-sort]");
for (const th of headers) {
th.setAttribute("role", "columnheader");
th.setAttribute("aria-sort", "none");
th.style.cursor = "pointer";
th.addEventListener("click", this.#onHeaderClick);
}
}
disconnectedCallback(): void {
const headers = this.querySelectorAll<HTMLElement>("th[data-sort]");
for (const th of headers) th.removeEventListener("click", this.#onHeaderClick);
}
#onHeaderClick = (e: Event): void => {
const th = e.currentTarget as HTMLElement;
const column = th.dataset.sort ?? "";
const current = th.getAttribute("aria-sort") ?? "none";
const next = current === "ascending" ? "descending" : "ascending";
this.#clearSorts();
th.setAttribute("aria-sort", next);
th.dataset.state = next;
this.#sortBy(th, next === "ascending" ? 1 : -1);
emit(this, "sort", { column, direction: next });
};
#clearSorts(): void {
const headers = this.querySelectorAll<HTMLElement>("th[data-sort]");
for (const th of headers) { th.setAttribute("aria-sort", "none"); th.dataset.state = "none"; }
}
#sortBy(th: HTMLElement, dir: number): void {
const tbody = this.querySelector("tbody[data-part=body]") ?? this.querySelector("tbody");
if (!tbody) return;
const headerRow = th.parentElement;
if (!headerRow) return;
const colIdx = Array.from(headerRow.children).indexOf(th);
const rows = Array.from(tbody.querySelectorAll("tr"));
rows.sort((a, b) => {
const aText = a.children[colIdx]?.textContent?.trim() ?? "";
const bText = b.children[colIdx]?.textContent?.trim() ?? "";
const aNum = Number(aText);
const bNum = Number(bText);
if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) return (aNum - bNum) * dir;
return aText.localeCompare(bText) * dir;
});
for (const row of rows) tbody.appendChild(row);
}
}

View File

@ -0,0 +1,6 @@
import { PettyDataTable } from "./data-table";
export { PettyDataTable };
if (!customElements.get("petty-data-table")) {
customElements.define("petty-data-table", PettyDataTable);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-date-picker", description: "Date input with calendar popover integration", tier: 2, attributes: [{ name: "value", type: "string", description: "Selected date in ISO format" }, { name: "min", type: "string", description: "Minimum selectable date" }, { name: "max", type: "string", description: "Maximum selectable date" }, { name: "disabled", type: "boolean", description: "Disables the date picker" }], parts: [{ name: "input", element: "input", description: "Text input for date entry" }, { name: "trigger", element: "button", description: "Button that opens the calendar popover" }, { name: "calendar", element: "div", description: "Popover container for the calendar" }], events: [{ name: "petty-change", detail: "{ value: string }", description: "Fires when a date is selected from input or calendar" }], example: `<petty-date-picker><input data-part="input" type="date" /><button data-part="trigger">Pick</button><div data-part="calendar" popover><petty-calendar></petty-calendar></div></petty-date-picker>` };

View File

@ -0,0 +1,55 @@
import { emit } from "../../shared/helpers";
/** PettyDatePicker — date input with calendar popover integration. */
export class PettyDatePicker extends HTMLElement {
static observedAttributes = ["value", "min", "max", "disabled"];
get value(): string { return this.#input()?.value ?? ""; }
set value(v: string) { const input = this.#input(); if (input) input.value = v; }
connectedCallback(): void {
const trigger = this.querySelector("[data-part=trigger]");
const input = this.#input();
if (trigger) {
trigger.setAttribute("aria-haspopup", "dialog");
trigger.setAttribute("aria-expanded", "false");
}
input?.addEventListener("change", this.#onInputChange);
this.addEventListener("petty-change", this.#onCalendarSelect);
const pop = this.querySelector("[data-part=calendar]");
if (pop) pop.addEventListener("toggle", this.#onToggle as EventListener);
}
disconnectedCallback(): void {
this.#input()?.removeEventListener("change", this.#onInputChange);
this.removeEventListener("petty-change", this.#onCalendarSelect);
const pop = this.querySelector("[data-part=calendar]");
pop?.removeEventListener("toggle", this.#onToggle as EventListener);
}
#input(): HTMLInputElement | null { return this.querySelector("input[data-part=input]"); }
#onToggle = (e: ToggleEvent): void => {
const trigger = this.querySelector("[data-part=trigger]");
if (trigger) trigger.setAttribute("aria-expanded", String(e.newState === "open"));
};
#onInputChange = (): void => {
const val = this.#input()?.value ?? "";
emit(this, "change", { value: val, source: "input" });
};
#onCalendarSelect = (e: Event): void => {
const ce = e as CustomEvent;
if (ce.detail?.source === "input") return;
const val = ce.detail?.value;
if (!val) return;
e.stopPropagation();
const input = this.#input();
if (input) input.value = val;
const cal = this.querySelector("[data-part=calendar]");
if (cal instanceof HTMLElement && "hidePopover" in cal) cal.hidePopover();
emit(this, "change", { value: val });
};
}

View File

@ -0,0 +1,6 @@
import { PettyDatePicker } from "./date-picker";
export { PettyDatePicker };
if (!customElements.get("petty-date-picker")) {
customElements.define("petty-date-picker", PettyDatePicker);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-dialog", description: "Headless dialog built on native <dialog> with ARIA linking and close events", tier: 1, attributes: [], parts: [], events: [{ name: "petty-close", detail: "{ value: string }", description: "Fires when the dialog closes, detail contains return value" }], example: `<petty-dialog><button commandfor="my-dlg" command="show-modal">Open</button><dialog id="my-dlg"><h2>Title</h2><p>Content</p><button commandfor="my-dlg" command="close">Close</button></dialog></petty-dialog>` };

View File

@ -1,3 +1,6 @@
import { emit, listen } from "../../shared/helpers";
import { uniqueId } from "../../shared/aria";
/**
* PettyDialog headless dialog custom element built on native `<dialog>`.
*
@ -17,6 +20,8 @@
* This element adds: ARIA linking, close event with return value, programmatic API.
*/
export class PettyDialog extends HTMLElement {
#cleanup: (() => void) | null = null;
/** Finds the first `<dialog>` child element. */
get dialogElement(): HTMLDialogElement | null {
return this.querySelector("dialog");
@ -45,29 +50,24 @@ export class PettyDialog extends HTMLElement {
if (!dlg) return;
this.#linkAria(dlg);
dlg.addEventListener("close", this.#handleClose);
this.#cleanup = listen(dlg, [["close", this.#handleClose]]);
}
/** @internal */
disconnectedCallback(): void {
const dlg = this.dialogElement;
dlg?.removeEventListener("close", this.#handleClose);
this.#cleanup?.();
this.#cleanup = null;
}
#handleClose = (): void => {
const dlg = this.dialogElement;
this.dispatchEvent(new CustomEvent("petty-close", {
bubbles: true,
detail: { value: dlg?.returnValue ?? "" },
}));
emit(this, "close", { value: dlg?.returnValue ?? "" });
};
#linkAria(dlg: HTMLDialogElement): void {
const heading = dlg.querySelector("h1, h2, h3, h4, h5, h6");
if (!heading) return;
if (!heading.id) heading.id = `petty-dlg-title-${PettyDialog.#counter++}`;
if (!heading.id) heading.id = uniqueId("petty-dlg-title");
dlg.setAttribute("aria-labelledby", heading.id);
}
static #counter = 0;
}

View File

@ -1,4 +1,5 @@
export { PettyDialog } from "./dialog";
import { PettyDialog } from "./dialog";
export { PettyDialog };
if (!customElements.get("petty-dialog")) {
customElements.define("petty-dialog", PettyDialog);

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-drawer", description: "Slide-in panel built on native dialog with side positioning", tier: 3, attributes: [{ name: "side", type: "string", default: "right", description: "Side the drawer slides from: left, right, top, bottom" }], parts: [], events: [{ name: "petty-close", detail: "{ value: string }", description: "Fires when the drawer closes, detail contains return value" }], example: `<petty-drawer side="left"><button commandfor="my-drawer" command="show-modal">Open</button><dialog id="my-drawer"><h2>Menu</h2><button commandfor="my-drawer" command="close">Close</button></dialog></petty-drawer>` };

View File

@ -0,0 +1,64 @@
import { emit, listen } from "../../shared/helpers";
import { uniqueId } from "../../shared/aria";
/** PettyDrawer — slide-in panel built on native dialog with side positioning. */
export class PettyDrawer extends HTMLElement {
static observedAttributes = ["side"];
#cleanup: (() => void) | null = null;
get dialogElement(): HTMLDialogElement | null {
return this.querySelector("dialog");
}
get isOpen(): boolean {
return this.dialogElement?.open ?? false;
}
/** Opens the drawer as a modal. */
open(): void {
const dlg = this.dialogElement;
if (dlg && !dlg.open) dlg.showModal();
}
/** Closes the drawer with an optional return value. */
close(returnValue?: string): void {
const dlg = this.dialogElement;
if (dlg?.open) dlg.close(returnValue);
}
connectedCallback(): void {
const dlg = this.dialogElement;
if (!dlg) return;
this.#syncSide();
this.#linkAria(dlg);
this.#cleanup = listen(dlg, [["close", this.#handleClose]]);
}
disconnectedCallback(): void {
this.#cleanup?.();
this.#cleanup = null;
}
attributeChangedCallback(): void {
this.#syncSide();
}
#syncSide(): void {
const side = this.getAttribute("side") ?? "right";
this.dataset.side = side;
const dlg = this.dialogElement;
if (dlg) dlg.dataset.side = side;
}
#handleClose = (): void => {
const dlg = this.dialogElement;
emit(this, "close", { value: dlg?.returnValue ?? "" });
};
#linkAria(dlg: HTMLDialogElement): void {
const heading = dlg.querySelector("h1, h2, h3, h4, h5, h6");
if (!heading) return;
if (!heading.id) heading.id = uniqueId("petty-drawer-title");
dlg.setAttribute("aria-labelledby", heading.id);
}
}

View File

@ -0,0 +1,6 @@
import { PettyDrawer } from "./drawer";
export { PettyDrawer };
if (!customElements.get("petty-drawer")) {
customElements.define("petty-drawer", PettyDrawer);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-dropdown-menu", description: "Action menu built on the Popover API with keyboard navigation", tier: 2, attributes: [], parts: [{ name: "trigger", element: "button", description: "Button that opens the dropdown menu" }, { name: "content", element: "div", description: "Popover container for menu items" }], events: [{ name: "petty-select", detail: "{ value: string }", description: "Fires when a menu item is selected" }], example: `<petty-dropdown-menu><button data-part="trigger" popovertarget="menu">Actions</button><div id="menu" data-part="content" popover><petty-menu-item>Edit</petty-menu-item><petty-menu-item>Delete</petty-menu-item></div></petty-dropdown-menu>` };

View File

@ -1,3 +1,5 @@
import { emit } from "../../shared/helpers";
/** PettyDropdownMenu — action menu built on the Popover API. */
export class PettyDropdownMenu extends HTMLElement {
/** The trigger button element. */
@ -62,7 +64,7 @@ export class PettyDropdownMenu extends HTMLElement {
#handleClick = (e: MouseEvent): void => {
const item = (e.target as HTMLElement).closest("petty-menu-item");
if (!item || item.hasAttribute("disabled")) return;
this.dispatchEvent(new CustomEvent("petty-select", { bubbles: true, detail: { value: item.textContent?.trim() ?? "" } }));
emit(this, "select", { value: item.textContent?.trim() ?? "" });
this.contentElement?.hidePopover();
};

View File

@ -1,5 +1,6 @@
export { PettyDropdownMenu } from "./dropdown-menu";
export { PettyMenuItem } from "./menu-item";
import { PettyDropdownMenu } from "./dropdown-menu";
import { PettyMenuItem } from "./menu-item";
export { PettyDropdownMenu, PettyMenuItem };
if (!customElements.get("petty-dropdown-menu")) {
customElements.define("petty-dropdown-menu", PettyDropdownMenu);

View File

@ -1,3 +1,5 @@
import { uniqueId } from "../../shared/aria";
/**
* PettyFormField form field wrapper with label, control, and error linking.
*
@ -20,7 +22,7 @@ export class PettyFormField extends HTMLElement {
/** @internal */
connectedCallback(): void {
const name = this.getAttribute("name") ?? "";
const controlId = `petty-field-${name}-${PettyFormField.#counter++}`;
const controlId = uniqueId(`petty-field-${name}`);
const errorId = `${controlId}-error`;
const label = this.querySelector("[data-part=label]");
@ -51,6 +53,4 @@ export class PettyFormField extends HTMLElement {
if (error) error.textContent = "";
if (control) control.removeAttribute("aria-invalid");
}
static #counter = 0;
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-form", description: "Form wrapper with Zod validation and accessible error display", tier: 3, attributes: [], parts: [], events: [{ name: "petty-submit", detail: "{ data: Record<string, FormDataEntryValue> }", description: "Fires on valid form submission with form data" }, { name: "petty-invalid", detail: "{ errors: Array<{ path: Array<string | number>, message: string }> }", description: "Fires when validation fails with error details" }], example: `<petty-form><form><petty-form-field name="email"><label data-part="label">Email</label><input data-part="control" type="email" /><span data-part="error"></span></petty-form-field><button type="submit">Submit</button></form></petty-form>` };

View File

@ -1,3 +1,12 @@
import { emit } from "../../shared/helpers";
interface SchemaLike {
safeParse: (data: unknown) => {
success: boolean;
error?: { issues: Array<{ path: Array<string | number>; message: string }> };
};
}
/**
* PettyForm form wrapper with Zod validation and accessible error display.
*
@ -21,15 +30,10 @@
* "petty-submit" on success or "petty-invalid" on failure.
*/
export class PettyForm extends HTMLElement {
#schema: {
safeParse: (data: unknown) => {
success: boolean;
error?: { issues: Array<{ path: Array<string | number>; message: string }> };
};
} | null = null;
#schema: SchemaLike | null = null;
/** Set a Zod schema for validation. */
setSchema(schema: typeof this.#schema): void {
setSchema(schema: SchemaLike | null): void {
this.#schema = schema;
}
@ -58,18 +62,12 @@ export class PettyForm extends HTMLElement {
if (!result.success) {
const issues = result.error?.issues ?? [];
this.#showErrors(issues);
this.dispatchEvent(new CustomEvent("petty-invalid", {
bubbles: true,
detail: { errors: issues },
}));
emit(this, "invalid", { errors: issues });
return;
}
}
this.dispatchEvent(new CustomEvent("petty-submit", {
bubbles: true,
detail: { data },
}));
emit(this, "submit", { data });
};
#clearErrors(): void {

View File

@ -1,5 +1,6 @@
export { PettyForm } from "./form";
export { PettyFormField } from "./form-field";
import { PettyForm } from "./form";
import { PettyFormField } from "./form-field";
export { PettyForm, PettyFormField };
if (!customElements.get("petty-form")) {
customElements.define("petty-form", PettyForm);

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-hover-card", description: "Rich hover preview using Popover API with configurable open/close delays", tier: 2, attributes: [{ name: "open-delay", type: "number", default: "300", description: "Delay in ms before showing the card" }, { name: "close-delay", type: "number", default: "200", description: "Delay in ms before hiding the card" }], parts: [{ name: "trigger", element: "a", description: "Element that triggers the hover card on mouse enter/focus" }, { name: "content", element: "div", description: "Popover container for the card content" }], events: [{ name: "petty-toggle", detail: "{ open: boolean }", description: "Fires when the hover card opens or closes" }], example: `<petty-hover-card><a data-part="trigger" href="/user">@user</a><div data-part="content" popover>User details here</div></petty-hover-card>` };

View File

@ -0,0 +1,62 @@
import { uniqueId } from "../../shared/aria";
import { emit, listen, part } from "../../shared/helpers";
/** PettyHoverCard — rich hover preview using Popover API with open/close delays. */
export class PettyHoverCard extends HTMLElement {
static observedAttributes = ["open-delay", "close-delay"];
#showTimer: ReturnType<typeof setTimeout> | null = null;
#hideTimer: ReturnType<typeof setTimeout> | null = null;
#cleanupTrigger: (() => void) | null = null;
#cleanupContent: (() => void) | null = null;
connectedCallback(): void {
const trigger = part<HTMLElement>(this, "trigger");
const content = part<HTMLElement>(this, "content");
if (!trigger || !content) return;
if (!content.id) content.id = uniqueId("petty-hc");
trigger.setAttribute("aria-describedby", content.id);
this.#cleanupTrigger = listen(trigger, [
["mouseenter", this.#onEnter],
["mouseleave", this.#onLeave],
["focus", this.#onEnter],
["blur", this.#onLeave],
]);
this.#cleanupContent = listen(content, [
["mouseenter", this.#onContentEnter],
["mouseleave", this.#onLeave],
]);
}
disconnectedCallback(): void {
this.#clearTimers();
this.#cleanupTrigger?.();
this.#cleanupTrigger = null;
this.#cleanupContent?.();
this.#cleanupContent = null;
}
#openDelay(): number { return Number(this.getAttribute("open-delay") ?? 300); }
#closeDelay(): number { return Number(this.getAttribute("close-delay") ?? 200); }
#clearTimers(): void {
if (this.#showTimer) { clearTimeout(this.#showTimer); this.#showTimer = null; }
if (this.#hideTimer) { clearTimeout(this.#hideTimer); this.#hideTimer = null; }
}
#show(): void {
const content = part<HTMLElement>(this, "content");
if (content && !content.matches(":popover-open")) content.showPopover();
emit(this, "toggle", { open: true });
}
#hide(): void {
const content = part<HTMLElement>(this, "content");
if (content && content.matches(":popover-open")) content.hidePopover();
emit(this, "toggle", { open: false });
}
#onEnter = (): void => { this.#clearTimers(); this.#showTimer = setTimeout(() => this.#show(), this.#openDelay()); };
#onLeave = (): void => { this.#clearTimers(); this.#hideTimer = setTimeout(() => this.#hide(), this.#closeDelay()); };
#onContentEnter = (): void => { this.#clearTimers(); };
}

View File

@ -0,0 +1,6 @@
import { PettyHoverCard } from "./hover-card";
export { PettyHoverCard };
if (!customElements.get("petty-hover-card")) {
customElements.define("petty-hover-card", PettyHoverCard);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-image", description: "Image element with fallback display on load failure", tier: 3, attributes: [], parts: [{ name: "fallback", element: "div", description: "Fallback content shown when image fails to load" }], events: [], example: `<petty-image><img src="/photo.jpg" alt="Photo" /><div data-part="fallback">Image unavailable</div></petty-image>` };

View File

@ -0,0 +1,38 @@
/** PettyImage — image element with fallback display on load failure. */
export class PettyImage extends HTMLElement {
connectedCallback(): void {
this.dataset.state = "loading";
const img = this.querySelector("img");
if (!img) { this.#showFallback(); return; }
img.addEventListener("load", this.#onLoad);
img.addEventListener("error", this.#onError);
if (img.complete && img.naturalWidth > 0) this.#onLoad();
else if (img.complete) this.#onError();
}
disconnectedCallback(): void {
const img = this.querySelector("img");
img?.removeEventListener("load", this.#onLoad);
img?.removeEventListener("error", this.#onError);
}
#onLoad = (): void => {
this.dataset.state = "loaded";
const img = this.querySelector("img");
const fallback = this.querySelector("[data-part=fallback]") as HTMLElement | null;
if (img) img.style.display = "";
if (fallback) fallback.style.display = "none";
};
#onError = (): void => {
this.dataset.state = "error";
this.#showFallback();
};
#showFallback(): void {
const img = this.querySelector("img");
const fallback = this.querySelector("[data-part=fallback]") as HTMLElement | null;
if (img) img.style.display = "none";
if (fallback) fallback.style.display = "";
}
}

View File

@ -0,0 +1,6 @@
import { PettyImage } from "./image";
export { PettyImage };
if (!customElements.get("petty-image")) {
customElements.define("petty-image", PettyImage);
}

View File

@ -0,0 +1,6 @@
import { PettyLink } from "./link";
export { PettyLink };
if (!customElements.get("petty-link")) {
customElements.define("petty-link", PettyLink);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-link", description: "Headless anchor wrapper with disabled and external link support", tier: 1, attributes: [{ name: "disabled", type: "boolean", description: "Disables the link and prevents navigation" }, { name: "external", type: "boolean", description: "Opens link in new tab with noopener noreferrer" }], parts: [], events: [], example: `<petty-link external><a href="https://example.com">Visit site</a></petty-link>` };

View File

@ -0,0 +1,41 @@
/** PettyLink — headless anchor wrapper with disabled and external support. */
export class PettyLink extends HTMLElement {
static observedAttributes = ["disabled", "external"];
get anchorElement(): HTMLAnchorElement | null {
return this.querySelector("a");
}
connectedCallback(): void {
this.#sync();
this.addEventListener("click", this.#handleClick);
}
disconnectedCallback(): void {
this.removeEventListener("click", this.#handleClick);
}
attributeChangedCallback(): void {
this.#sync();
}
#sync(): void {
const a = this.anchorElement;
if (!a) return;
if (this.hasAttribute("external")) {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
}
if (this.hasAttribute("disabled")) {
a.setAttribute("aria-disabled", "true");
a.tabIndex = -1;
} else {
a.removeAttribute("aria-disabled");
a.removeAttribute("tabindex");
}
}
#handleClick = (e: Event): void => {
if (this.hasAttribute("disabled")) e.preventDefault();
};
}

View File

@ -0,0 +1,8 @@
import { PettyListbox } from "./listbox";
import { PettyListboxOption } from "./listbox-option";
export { PettyListbox, PettyListboxOption };
if (!customElements.get("petty-listbox")) {
customElements.define("petty-listbox", PettyListbox);
customElements.define("petty-listbox-option", PettyListboxOption);
}

View File

@ -0,0 +1,20 @@
/** PettyListboxOption — single option within a listbox. */
export class PettyListboxOption extends HTMLElement {
static observedAttributes = ["value", "disabled"];
get value(): string { return this.getAttribute("value") ?? this.textContent?.trim() ?? ""; }
get disabled(): boolean { return this.hasAttribute("disabled"); }
connectedCallback(): void {
this.setAttribute("role", "option");
this.setAttribute("tabindex", "-1");
this.setAttribute("aria-selected", "false");
if (this.disabled) this.setAttribute("aria-disabled", "true");
}
attributeChangedCallback(name: string): void {
if (name === "disabled") {
this.setAttribute("aria-disabled", String(this.disabled));
}
}
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-listbox", description: "Inline selectable list with single or multiple selection and keyboard navigation", tier: 3, attributes: [{ name: "value", type: "string", description: "Currently selected value (comma-separated for multiple)" }, { name: "default-value", type: "string", description: "Initial selected value" }, { name: "multiple", type: "boolean", description: "Allows selecting multiple options" }], parts: [], events: [{ name: "petty-change", detail: "{ value: string }", description: "Fires when selection changes" }], example: `<petty-listbox default-value="a"><petty-listbox-option value="a">Alpha</petty-listbox-option><petty-listbox-option value="b">Beta</petty-listbox-option></petty-listbox>` };

View File

@ -0,0 +1,76 @@
import { signal, effect } from "../../signals";
import { emit, initialValue } from "../../shared/helpers";
/** PettyListbox — inline selectable list with single or multiple selection. */
export class PettyListbox extends HTMLElement {
static observedAttributes = ["value", "default-value", "multiple"];
readonly #value = signal("");
#stopEffect: (() => void) | null = null;
get value(): string { return this.#value.get(); }
set value(v: string) { this.#value.set(v); }
get multiple(): boolean { return this.hasAttribute("multiple"); }
/** Selects a value, toggling in multiple mode. */
selectValue(v: string): void {
if (this.multiple) {
const current = this.#value.get().split(",").filter(Boolean);
const idx = current.indexOf(v);
if (idx >= 0) current.splice(idx, 1);
else current.push(v);
this.#value.set(current.join(","));
} else {
this.#value.set(v);
}
emit(this, "change", { value: this.#value.get() });
}
connectedCallback(): void {
const init = initialValue(this);
if (init) this.#value.set(init);
this.#stopEffect = effect(() => this.#syncChildren());
this.addEventListener("keydown", this.#onKeydown);
this.addEventListener("click", this.#onClick);
}
disconnectedCallback(): void {
this.#stopEffect = null;
this.removeEventListener("keydown", this.#onKeydown);
this.removeEventListener("click", this.#onClick);
}
attributeChangedCallback(name: string, _old: string | null, next: string | null): void {
if (name === "value" && next !== null) this.#value.set(next);
}
#options(): HTMLElement[] {
return Array.from(this.querySelectorAll("petty-listbox-option:not([disabled])"));
}
#syncChildren(): void {
const selected = new Set(this.#value.get().split(",").filter(Boolean));
const options = this.querySelectorAll<HTMLElement>("petty-listbox-option");
for (const opt of options) {
const isSelected = selected.has(opt.getAttribute("value") ?? "");
opt.dataset.state = isSelected ? "selected" : "unselected";
opt.setAttribute("aria-selected", String(isSelected));
}
}
#onClick = (e: MouseEvent): void => {
const opt = (e.target as HTMLElement).closest("petty-listbox-option");
if (!opt || opt.hasAttribute("disabled")) return;
this.selectValue(opt.getAttribute("value") ?? "");
};
#onKeydown = (e: KeyboardEvent): void => {
const items = this.#options();
const active = document.activeElement as HTMLElement;
const idx = items.indexOf(active);
if (e.key === "ArrowDown") { e.preventDefault(); items[(idx + 1) % items.length]?.focus(); }
else if (e.key === "ArrowUp") { e.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") ?? ""); }
};
}

View File

@ -0,0 +1,6 @@
import { PettyLoadingIndicator } from "./loading-indicator";
export { PettyLoadingIndicator };
if (!customElements.get("petty-loading-indicator")) {
customElements.define("petty-loading-indicator", PettyLoadingIndicator);
}

View File

@ -0,0 +1,35 @@
/** PettyLoadingIndicator — M3 Expressive shape-morphing loading animation. */
export class PettyLoadingIndicator extends HTMLElement {
static observedAttributes = ["size", "contained"];
get size(): number { return Number(this.getAttribute("size") ?? 48); }
get contained(): boolean { return this.hasAttribute("contained"); }
connectedCallback(): void {
this.setAttribute("role", "progressbar");
this.setAttribute("aria-label", this.getAttribute("aria-label") ?? "Loading");
this.#render();
}
attributeChangedCallback(): void {
this.#render();
}
#render(): void {
const px = `${this.size}px`;
const el = this.querySelector("[data-part=indicator]");
if (el instanceof HTMLElement) {
el.style.width = px;
el.style.height = px;
return;
}
const container = document.createElement("div");
container.dataset.part = "container";
const indicator = document.createElement("div");
indicator.dataset.part = "indicator";
indicator.style.width = px;
indicator.style.height = px;
container.appendChild(indicator);
this.appendChild(container);
}
}

View File

@ -0,0 +1,6 @@
import { PettyMeter } from "./meter";
export { PettyMeter };
if (!customElements.get("petty-meter")) {
customElements.define("petty-meter", PettyMeter);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-meter", description: "Value gauge with low/high/optimum state computation", tier: 3, attributes: [{ name: "value", type: "number", default: "0", description: "Current meter value" }, { name: "min", type: "number", default: "0", description: "Minimum value" }, { name: "max", type: "number", default: "100", description: "Maximum value" }, { name: "low", type: "number", description: "Low threshold value" }, { name: "high", type: "number", description: "High threshold value" }, { name: "optimum", type: "number", description: "Optimum value" }], parts: [{ name: "fill", element: "div", description: "Fill element sized via --petty-meter-value CSS custom property" }], events: [], example: `<petty-meter value="65" min="0" max="100" low="25" high="75"><div data-part="fill"></div></petty-meter>` };

View File

@ -0,0 +1,47 @@
/** PettyMeter — value gauge with low/high/optimum state computation. */
export class PettyMeter extends HTMLElement {
static observedAttributes = ["value", "min", "max", "low", "high", "optimum"];
connectedCallback(): void {
this.setAttribute("role", "meter");
this.#sync();
}
attributeChangedCallback(): void {
this.#sync();
}
#num(attr: string, fallback: number): number {
const v = this.getAttribute(attr);
return v !== null ? Number(v) : fallback;
}
#computeState(val: number, low: number, high: number, optimum: number): string {
if (val <= low) return "low";
if (val >= high) return "high";
if (Math.abs(val - optimum) <= (high - low) * 0.1) return "optimum";
return "medium";
}
#updateFill(fraction: number): void {
const fill = this.querySelector("[data-part=fill]");
if (fill instanceof HTMLElement) fill.style.setProperty("--petty-meter-value", String(fraction));
}
#sync(): void {
const val = this.#num("value", 0);
const min = this.#num("min", 0);
const max = this.#num("max", 100);
const low = this.#num("low", min);
const high = this.#num("high", max);
const optimum = this.#num("optimum", (low + high) / 2);
this.setAttribute("aria-valuenow", String(val));
this.setAttribute("aria-valuemin", String(min));
this.setAttribute("aria-valuemax", String(max));
this.dataset.state = this.#computeState(val, low, high, optimum);
const range = max - min;
this.#updateFill(range > 0 ? (val - min) / range : 0);
}
}

View File

@ -0,0 +1,8 @@
import { PettyNavigationMenu } from "./navigation-menu";
import { PettyNavigationMenuItem } from "./navigation-menu-item";
export { PettyNavigationMenu, PettyNavigationMenuItem };
if (!customElements.get("petty-navigation-menu")) {
customElements.define("petty-navigation-menu", PettyNavigationMenu);
customElements.define("petty-navigation-menu-item", PettyNavigationMenuItem);
}

View File

@ -0,0 +1,50 @@
/** PettyNavigationMenuItem — nav item with optional popover content on hover. */
export class PettyNavigationMenuItem extends HTMLElement {
#showTimer: ReturnType<typeof setTimeout> | null = null;
#hideTimer: ReturnType<typeof setTimeout> | null = null;
connectedCallback(): void {
const trigger = this.querySelector("[data-part=trigger]");
const content = this.querySelector("[data-part=content]");
if (!trigger || !content) return;
trigger.addEventListener("mouseenter", this.#onEnter);
trigger.addEventListener("mouseleave", this.#onLeave);
content.addEventListener("mouseenter", this.#onContentEnter);
content.addEventListener("mouseleave", this.#onLeave);
}
disconnectedCallback(): void {
this.#clearTimers();
const trigger = this.querySelector("[data-part=trigger]");
const content = this.querySelector("[data-part=content]");
trigger?.removeEventListener("mouseenter", this.#onEnter);
trigger?.removeEventListener("mouseleave", this.#onLeave);
content?.removeEventListener("mouseenter", this.#onContentEnter);
content?.removeEventListener("mouseleave", this.#onLeave);
}
#content(): HTMLElement | null { return this.querySelector("[data-part=content]"); }
#clearTimers(): void {
if (this.#showTimer) { clearTimeout(this.#showTimer); this.#showTimer = null; }
if (this.#hideTimer) { clearTimeout(this.#hideTimer); this.#hideTimer = null; }
}
#onEnter = (): void => {
this.#clearTimers();
this.#showTimer = setTimeout(() => {
const c = this.#content();
if (c && !c.matches(":popover-open")) c.showPopover();
}, 100);
};
#onLeave = (): void => {
this.#clearTimers();
this.#hideTimer = setTimeout(() => {
const c = this.#content();
if (c && c.matches(":popover-open")) c.hidePopover();
}, 200);
};
#onContentEnter = (): void => { this.#clearTimers(); };
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-navigation-menu", description: "Horizontal nav with optional popover dropdowns on hover", tier: 3, attributes: [{ name: "orientation", type: "string", default: "horizontal", description: "Layout orientation: horizontal or vertical" }], parts: [{ name: "trigger", element: "button", description: "Nav item trigger that shows content on hover (on petty-navigation-menu-item)" }, { name: "content", element: "div", description: "Popover dropdown content (on petty-navigation-menu-item)" }], events: [], example: `<petty-navigation-menu><nav><petty-navigation-menu-item><button data-part="trigger">Products</button><div data-part="content" popover>Product links</div></petty-navigation-menu-item></nav></petty-navigation-menu>` };

View File

@ -0,0 +1,13 @@
/** PettyNavigationMenu — horizontal nav with optional popover dropdowns. */
export class PettyNavigationMenu extends HTMLElement {
static observedAttributes = ["orientation"];
connectedCallback(): void {
if (!this.querySelector("nav")) this.setAttribute("role", "navigation");
this.dataset.orientation = this.getAttribute("orientation") ?? "horizontal";
}
attributeChangedCallback(): void {
this.dataset.orientation = this.getAttribute("orientation") ?? "horizontal";
}
}

View File

@ -0,0 +1,6 @@
import { PettyNumberField } from "./number-field";
export { PettyNumberField };
if (!customElements.get("petty-number-field")) {
customElements.define("petty-number-field", PettyNumberField);
}

View File

@ -0,0 +1,3 @@
import type { ComponentMeta } from "../../schema";
export const schema: ComponentMeta = { tag: "petty-number-field", description: "Numeric input with increment/decrement buttons and value clamping", tier: 3, attributes: [{ name: "min", type: "number", description: "Minimum allowed value" }, { name: "max", type: "number", description: "Maximum allowed value" }, { name: "step", type: "number", default: "1", description: "Step increment/decrement amount" }, { name: "value", type: "number", description: "Current numeric value" }, { name: "disabled", type: "boolean", description: "Disables the field" }, { name: "name", type: "string", description: "Form field name" }], parts: [{ name: "control", element: "input", description: "The numeric input element" }, { name: "label", element: "label", description: "Label auto-linked to the input" }, { name: "increment", element: "button", description: "Button to increase the value" }, { name: "decrement", element: "button", description: "Button to decrease the value" }], events: [{ name: "petty-change", detail: "{ value: number }", description: "Fires when the value changes" }], example: `<petty-number-field min="0" max="10" value="5"><label data-part="label">Qty</label><button data-part="decrement">-</button><input data-part="control" type="number" /><button data-part="increment">+</button></petty-number-field>` };

View File

@ -0,0 +1,91 @@
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()); };
}

View File

@ -0,0 +1,8 @@
import { PettyPagination } from "./pagination";
import { PettyPaginationItem } from "./pagination-item";
export { PettyPagination, PettyPaginationItem };
if (!customElements.get("petty-pagination")) {
customElements.define("petty-pagination", PettyPagination);
customElements.define("petty-pagination-item", PettyPaginationItem);
}

View File

@ -0,0 +1,41 @@
/** PettyPaginationItem — single page button within a pagination component. */
export class PettyPaginationItem extends HTMLElement {
static observedAttributes = ["value", "type", "disabled"];
connectedCallback(): void {
this.setAttribute("role", "button");
this.setAttribute("tabindex", "0");
this.addEventListener("click", this.#handleClick);
this.addEventListener("keydown", this.#handleKeydown);
}
disconnectedCallback(): void {
this.removeEventListener("click", this.#handleClick);
this.removeEventListener("keydown", this.#handleKeydown);
}
attributeChangedCallback(name: string): void {
if (name === "disabled") {
this.setAttribute("aria-disabled", String(this.hasAttribute("disabled")));
}
}
#pagination(): { goToPage(n: number): void; currentPage: number } | null {
return this.closest("petty-pagination") as { goToPage(n: number): void; currentPage: number } | null;
}
#activate(): void {
if (this.hasAttribute("disabled")) return;
const pag = this.#pagination();
if (!pag) return;
const type = this.getAttribute("type");
if (type === "prev") pag.goToPage(pag.currentPage - 1);
else if (type === "next") pag.goToPage(pag.currentPage + 1);
else pag.goToPage(Number(this.getAttribute("value") ?? 1));
}
#handleClick = (): void => { this.#activate(); };
#handleKeydown = (e: KeyboardEvent): void => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); this.#activate(); }
};
}

Some files were not shown because too many files have changed in this diff Show More