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:
parent
3768af1369
commit
168b5642d0
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
@ -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",
|
||||
|
||||
137
packages/core/src/animations.css
Normal file
137
packages/core/src/animations.css
Normal 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); }
|
||||
}
|
||||
@ -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>` };
|
||||
@ -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[] {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>` };
|
||||
59
packages/core/src/components/alert-dialog/alert-dialog.ts
Normal file
59
packages/core/src/components/alert-dialog/alert-dialog.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/alert-dialog/index.ts
Normal file
6
packages/core/src/components/alert-dialog/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyAlertDialog } from "./alert-dialog";
|
||||
export { PettyAlertDialog };
|
||||
|
||||
if (!customElements.get("petty-alert-dialog")) {
|
||||
customElements.define("petty-alert-dialog", PettyAlertDialog);
|
||||
}
|
||||
3
packages/core/src/components/alert/alert.schema.ts
Normal file
3
packages/core/src/components/alert/alert.schema.ts
Normal 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>` };
|
||||
23
packages/core/src/components/alert/alert.ts
Normal file
23
packages/core/src/components/alert/alert.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/alert/index.ts
Normal file
6
packages/core/src/components/alert/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyAlert } from "./alert";
|
||||
export { PettyAlert };
|
||||
|
||||
if (!customElements.get("petty-alert")) {
|
||||
customElements.define("petty-alert", PettyAlert);
|
||||
}
|
||||
3
packages/core/src/components/avatar/avatar.schema.ts
Normal file
3
packages/core/src/components/avatar/avatar.schema.ts
Normal 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>` };
|
||||
38
packages/core/src/components/avatar/avatar.ts
Normal file
38
packages/core/src/components/avatar/avatar.ts
Normal 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 = "";
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/avatar/index.ts
Normal file
6
packages/core/src/components/avatar/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyAvatar } from "./avatar";
|
||||
export { PettyAvatar };
|
||||
|
||||
if (!customElements.get("petty-avatar")) {
|
||||
customElements.define("petty-avatar", PettyAvatar);
|
||||
}
|
||||
3
packages/core/src/components/badge/badge.schema.ts
Normal file
3
packages/core/src/components/badge/badge.schema.ts
Normal 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>` };
|
||||
17
packages/core/src/components/badge/badge.ts
Normal file
17
packages/core/src/components/badge/badge.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/badge/index.ts
Normal file
6
packages/core/src/components/badge/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyBadge } from "./badge";
|
||||
export { PettyBadge };
|
||||
|
||||
if (!customElements.get("petty-badge")) {
|
||||
customElements.define("petty-badge", PettyBadge);
|
||||
}
|
||||
24
packages/core/src/components/breadcrumbs/breadcrumb-item.ts
Normal file
24
packages/core/src/components/breadcrumbs/breadcrumb-item.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
@ -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>` };
|
||||
11
packages/core/src/components/breadcrumbs/breadcrumbs.ts
Normal file
11
packages/core/src/components/breadcrumbs/breadcrumbs.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
8
packages/core/src/components/breadcrumbs/index.ts
Normal file
8
packages/core/src/components/breadcrumbs/index.ts
Normal 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);
|
||||
}
|
||||
3
packages/core/src/components/button/button.schema.ts
Normal file
3
packages/core/src/components/button/button.schema.ts
Normal 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>` };
|
||||
53
packages/core/src/components/button/button.ts
Normal file
53
packages/core/src/components/button/button.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/button/index.ts
Normal file
6
packages/core/src/components/button/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyButton } from "./button";
|
||||
export { PettyButton };
|
||||
|
||||
if (!customElements.get("petty-button")) {
|
||||
customElements.define("petty-button", PettyButton);
|
||||
}
|
||||
3
packages/core/src/components/calendar/calendar.schema.ts
Normal file
3
packages/core/src/components/calendar/calendar.schema.ts
Normal 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"><</button><span data-part="title"></span><button data-part="next-month">></button></header><table><tbody data-part="body"></tbody></table></petty-calendar>` };
|
||||
93
packages/core/src/components/calendar/calendar.ts
Normal file
93
packages/core/src/components/calendar/calendar.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/calendar/index.ts
Normal file
6
packages/core/src/components/calendar/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyCalendar } from "./calendar";
|
||||
export { PettyCalendar };
|
||||
|
||||
if (!customElements.get("petty-calendar")) {
|
||||
customElements.define("petty-calendar", PettyCalendar);
|
||||
}
|
||||
6
packages/core/src/components/card/card-content.ts
Normal file
6
packages/core/src/components/card/card-content.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/** PettyCardContent — structural body section within a card. */
|
||||
export class PettyCardContent extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.dataset.part = "content";
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/card/card-footer.ts
Normal file
6
packages/core/src/components/card/card-footer.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/** PettyCardFooter — structural footer section within a card. */
|
||||
export class PettyCardFooter extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.dataset.part = "footer";
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/card/card-header.ts
Normal file
6
packages/core/src/components/card/card-header.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/** PettyCardHeader — structural header section within a card. */
|
||||
export class PettyCardHeader extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.dataset.part = "header";
|
||||
}
|
||||
}
|
||||
3
packages/core/src/components/card/card.schema.ts
Normal file
3
packages/core/src/components/card/card.schema.ts
Normal 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>` };
|
||||
13
packages/core/src/components/card/card.ts
Normal file
13
packages/core/src/components/card/card.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
packages/core/src/components/card/index.ts
Normal file
12
packages/core/src/components/card/index.ts
Normal 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);
|
||||
}
|
||||
3
packages/core/src/components/checkbox/checkbox.schema.ts
Normal file
3
packages/core/src/components/checkbox/checkbox.schema.ts
Normal 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>` };
|
||||
62
packages/core/src/components/checkbox/checkbox.ts
Normal file
62
packages/core/src/components/checkbox/checkbox.ts
Normal 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 });
|
||||
};
|
||||
}
|
||||
6
packages/core/src/components/checkbox/index.ts
Normal file
6
packages/core/src/components/checkbox/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyCheckbox } from "./checkbox";
|
||||
export { PettyCheckbox };
|
||||
|
||||
if (!customElements.get("petty-checkbox")) {
|
||||
customElements.define("petty-checkbox", PettyCheckbox);
|
||||
}
|
||||
@ -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>` };
|
||||
77
packages/core/src/components/collapsible/collapsible.ts
Normal file
77
packages/core/src/components/collapsible/collapsible.ts
Normal 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(); };
|
||||
}
|
||||
6
packages/core/src/components/collapsible/index.ts
Normal file
6
packages/core/src/components/collapsible/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyCollapsible } from "./collapsible";
|
||||
export { PettyCollapsible };
|
||||
|
||||
if (!customElements.get("petty-collapsible")) {
|
||||
customElements.define("petty-collapsible", PettyCollapsible);
|
||||
}
|
||||
18
packages/core/src/components/combobox/combobox-option.ts
Normal file
18
packages/core/src/components/combobox/combobox-option.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
3
packages/core/src/components/combobox/combobox.schema.ts
Normal file
3
packages/core/src/components/combobox/combobox.schema.ts
Normal 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>` };
|
||||
83
packages/core/src/components/combobox/combobox.ts
Normal file
83
packages/core/src/components/combobox/combobox.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
8
packages/core/src/components/combobox/index.ts
Normal file
8
packages/core/src/components/combobox/index.ts
Normal 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);
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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>` };
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
8
packages/core/src/components/command-palette/index.ts
Normal file
8
packages/core/src/components/command-palette/index.ts
Normal 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);
|
||||
}
|
||||
@ -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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>` };
|
||||
63
packages/core/src/components/context-menu/context-menu.ts
Normal file
63
packages/core/src/components/context-menu/context-menu.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
8
packages/core/src/components/context-menu/index.ts
Normal file
8
packages/core/src/components/context-menu/index.ts
Normal 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);
|
||||
}
|
||||
74
packages/core/src/components/counter/counter.ts
Normal file
74
packages/core/src/components/counter/counter.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/counter/index.ts
Normal file
6
packages/core/src/components/counter/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyCounter } from "./counter";
|
||||
export { PettyCounter };
|
||||
|
||||
if (!customElements.get("petty-counter")) {
|
||||
customElements.define("petty-counter", PettyCounter);
|
||||
}
|
||||
@ -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>` };
|
||||
54
packages/core/src/components/data-table/data-table.ts
Normal file
54
packages/core/src/components/data-table/data-table.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/data-table/index.ts
Normal file
6
packages/core/src/components/data-table/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyDataTable } from "./data-table";
|
||||
export { PettyDataTable };
|
||||
|
||||
if (!customElements.get("petty-data-table")) {
|
||||
customElements.define("petty-data-table", PettyDataTable);
|
||||
}
|
||||
@ -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>` };
|
||||
55
packages/core/src/components/date-picker/date-picker.ts
Normal file
55
packages/core/src/components/date-picker/date-picker.ts
Normal 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 });
|
||||
};
|
||||
}
|
||||
6
packages/core/src/components/date-picker/index.ts
Normal file
6
packages/core/src/components/date-picker/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyDatePicker } from "./date-picker";
|
||||
export { PettyDatePicker };
|
||||
|
||||
if (!customElements.get("petty-date-picker")) {
|
||||
customElements.define("petty-date-picker", PettyDatePicker);
|
||||
}
|
||||
3
packages/core/src/components/dialog/dialog.schema.ts
Normal file
3
packages/core/src/components/dialog/dialog.schema.ts
Normal 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>` };
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export { PettyDialog } from "./dialog";
|
||||
import { PettyDialog } from "./dialog";
|
||||
export { PettyDialog };
|
||||
|
||||
if (!customElements.get("petty-dialog")) {
|
||||
customElements.define("petty-dialog", PettyDialog);
|
||||
|
||||
3
packages/core/src/components/drawer/drawer.schema.ts
Normal file
3
packages/core/src/components/drawer/drawer.schema.ts
Normal 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>` };
|
||||
64
packages/core/src/components/drawer/drawer.ts
Normal file
64
packages/core/src/components/drawer/drawer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/drawer/index.ts
Normal file
6
packages/core/src/components/drawer/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyDrawer } from "./drawer";
|
||||
export { PettyDrawer };
|
||||
|
||||
if (!customElements.get("petty-drawer")) {
|
||||
customElements.define("petty-drawer", PettyDrawer);
|
||||
}
|
||||
@ -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>` };
|
||||
@ -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();
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
3
packages/core/src/components/form/form.schema.ts
Normal file
3
packages/core/src/components/form/form.schema.ts
Normal 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>` };
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>` };
|
||||
62
packages/core/src/components/hover-card/hover-card.ts
Normal file
62
packages/core/src/components/hover-card/hover-card.ts
Normal 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(); };
|
||||
}
|
||||
6
packages/core/src/components/hover-card/index.ts
Normal file
6
packages/core/src/components/hover-card/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyHoverCard } from "./hover-card";
|
||||
export { PettyHoverCard };
|
||||
|
||||
if (!customElements.get("petty-hover-card")) {
|
||||
customElements.define("petty-hover-card", PettyHoverCard);
|
||||
}
|
||||
3
packages/core/src/components/image/image.schema.ts
Normal file
3
packages/core/src/components/image/image.schema.ts
Normal 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>` };
|
||||
38
packages/core/src/components/image/image.ts
Normal file
38
packages/core/src/components/image/image.ts
Normal 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 = "";
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/image/index.ts
Normal file
6
packages/core/src/components/image/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyImage } from "./image";
|
||||
export { PettyImage };
|
||||
|
||||
if (!customElements.get("petty-image")) {
|
||||
customElements.define("petty-image", PettyImage);
|
||||
}
|
||||
6
packages/core/src/components/link/index.ts
Normal file
6
packages/core/src/components/link/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyLink } from "./link";
|
||||
export { PettyLink };
|
||||
|
||||
if (!customElements.get("petty-link")) {
|
||||
customElements.define("petty-link", PettyLink);
|
||||
}
|
||||
3
packages/core/src/components/link/link.schema.ts
Normal file
3
packages/core/src/components/link/link.schema.ts
Normal 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>` };
|
||||
41
packages/core/src/components/link/link.ts
Normal file
41
packages/core/src/components/link/link.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
8
packages/core/src/components/listbox/index.ts
Normal file
8
packages/core/src/components/listbox/index.ts
Normal 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);
|
||||
}
|
||||
20
packages/core/src/components/listbox/listbox-option.ts
Normal file
20
packages/core/src/components/listbox/listbox-option.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/core/src/components/listbox/listbox.schema.ts
Normal file
3
packages/core/src/components/listbox/listbox.schema.ts
Normal 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>` };
|
||||
76
packages/core/src/components/listbox/listbox.ts
Normal file
76
packages/core/src/components/listbox/listbox.ts
Normal 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") ?? ""); }
|
||||
};
|
||||
}
|
||||
6
packages/core/src/components/loading-indicator/index.ts
Normal file
6
packages/core/src/components/loading-indicator/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyLoadingIndicator } from "./loading-indicator";
|
||||
export { PettyLoadingIndicator };
|
||||
|
||||
if (!customElements.get("petty-loading-indicator")) {
|
||||
customElements.define("petty-loading-indicator", PettyLoadingIndicator);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/meter/index.ts
Normal file
6
packages/core/src/components/meter/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyMeter } from "./meter";
|
||||
export { PettyMeter };
|
||||
|
||||
if (!customElements.get("petty-meter")) {
|
||||
customElements.define("petty-meter", PettyMeter);
|
||||
}
|
||||
3
packages/core/src/components/meter/meter.schema.ts
Normal file
3
packages/core/src/components/meter/meter.schema.ts
Normal 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>` };
|
||||
47
packages/core/src/components/meter/meter.ts
Normal file
47
packages/core/src/components/meter/meter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
8
packages/core/src/components/navigation-menu/index.ts
Normal file
8
packages/core/src/components/navigation-menu/index.ts
Normal 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);
|
||||
}
|
||||
@ -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(); };
|
||||
}
|
||||
@ -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>` };
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/number-field/index.ts
Normal file
6
packages/core/src/components/number-field/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyNumberField } from "./number-field";
|
||||
export { PettyNumberField };
|
||||
|
||||
if (!customElements.get("petty-number-field")) {
|
||||
customElements.define("petty-number-field", PettyNumberField);
|
||||
}
|
||||
@ -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>` };
|
||||
91
packages/core/src/components/number-field/number-field.ts
Normal file
91
packages/core/src/components/number-field/number-field.ts
Normal 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()); };
|
||||
|
||||
}
|
||||
8
packages/core/src/components/pagination/index.ts
Normal file
8
packages/core/src/components/pagination/index.ts
Normal 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);
|
||||
}
|
||||
41
packages/core/src/components/pagination/pagination-item.ts
Normal file
41
packages/core/src/components/pagination/pagination-item.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user