New Release / ST3

This commit is contained in:
Mats Bosson 2026-03-29 02:30:05 +07:00
commit dec5f8f1d2
221 changed files with 12613 additions and 0 deletions

70
.claude/hooks/naglint.sh Executable file
View File

@ -0,0 +1,70 @@
#!/bin/bash
# NagLint PostToolUse hook — runs after Write/Edit on source files
NAG=/Users/matsbosson/Documents/StayThree/NagLint/target/release/nag
INPUT=$(cat -)
FILE=$(echo "$INPUT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('tool_input', {}).get('file_path', ''))
" 2>/dev/null)
if [ -z "$FILE" ]; then
echo "{}"
exit 0
fi
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.mts|*.cts|*.rs) ;;
*) echo "{}"; exit 0 ;;
esac
if [ ! -f "$FILE" ]; then
echo "{}"
exit 0
fi
RAW=$("$NAG" "$FILE" --agent-json 2>/dev/null)
if [ "$RAW" = "{}" ] || [ -z "$RAW" ]; then
echo "{}"
exit 0
fi
# For .tsx/.jsx files: filter out AI-08 (logic density) — JSX is declarative
case "$FILE" in
*.tsx|*.jsx)
FILTERED=$(echo "$RAW" | python3 -c "
import sys, json
raw = sys.stdin.read().strip()
try:
d = json.loads(raw)
except Exception:
print(raw)
sys.exit(0)
if d.get('decision') != 'block':
print(raw)
sys.exit(0)
reason = d.get('reason', '')
filtered_lines = [l for l in reason.splitlines() if '[AI-08]' not in l]
filtered_reason = '\n'.join(filtered_lines).strip()
if not filtered_reason:
print('{}')
else:
d['reason'] = filtered_reason
ctx = d.get('hookSpecificOutput', {}).get('additionalContext', '')
ctx_lines = [l for l in ctx.splitlines() if '[AI-08]' not in l]
d['hookSpecificOutput']['additionalContext'] = '\n'.join(ctx_lines)
print(json.dumps(d))
" 2>/dev/null)
echo "${FILTERED:-$RAW}"
;;
*)
echo "$RAW"
;;
esac

2
.claude/settings.json Normal file
View File

@ -0,0 +1,2 @@
{
}

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
.tsdown/
coverage/
*.tsbuildinfo
.superpowers/

21
LICENSE Normal file
View File

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

107
README.md Normal file
View File

@ -0,0 +1,107 @@
# PettyUI
51 headless Web Components. Zero dependencies. ~5KB gzipped.
Built on browser-native APIs — Popover, `<dialog>`, Navigation API, View Transitions. No framework required. Works everywhere.
## Install
```bash
npm install pettyui
```
## Usage
Import what you need. Each component registers itself as a Custom Element.
```html
<script type="module">
import "pettyui/dialog";
import "pettyui/select";
import "pettyui/tabs";
</script>
<petty-dialog>
<button commandfor="my-dlg" command="show-modal" type="button">Open</button>
<dialog id="my-dlg">
<h2>Hello</h2>
<p>This is a native dialog with ARIA linking, focus trap, and Escape — all from the browser.</p>
<button commandfor="my-dlg" command="close" type="button">Close</button>
</dialog>
</petty-dialog>
```
## Components
**Inputs** — Button, TextField, NumberField, Checkbox, Switch, RadioGroup, Slider, Toggle, ToggleGroup, Select, Combobox, Listbox, TagsInput, Form, DatePicker
**Navigation** — Link, Breadcrumbs, Tabs, Accordion, Collapsible, Pagination, NavigationMenu, Wizard
**Overlays** — Dialog, AlertDialog, Drawer, Popover, Tooltip, HoverCard, DropdownMenu, ContextMenu, CommandPalette
**Feedback** — Alert, Toast, Progress, Meter, LoadingIndicator
**Layout** — Avatar, Badge, Card, Image, Separator, Skeleton
**Data** — Calendar, DataTable, VirtualList
**Animation** — Typewriter, Counter, Stagger, Reveal, Parallax
## Why
- **Zero runtime** — 500-byte signals core. No virtual DOM, no framework overhead.
- **Browser-native** — Popover API for overlays, `<dialog>` for modals, Navigation API for routing. No polyfills.
- **No Shadow DOM** — Style with plain CSS, Tailwind, or anything. No `::part()` needed.
- **AI-native** — Zod schemas for every component. MCP tools for agent integration.
- **Works everywhere** — React, Vue, Svelte, Astro, plain HTML. It's just Custom Elements.
## Styling
PettyUI is headless. Components use `data-state` and `data-part` attributes for styling hooks.
```css
petty-tab[data-state="active"] {
border-bottom: 2px solid blue;
}
petty-switch[data-state="on"] [data-part="thumb"] {
transform: translateX(18px);
}
```
Optional default theme and animations:
```js
import "pettyui/theme";
import "pettyui/animations";
```
## Signals
Built-in reactivity in ~30 lines (~500 bytes):
```js
import { signal, effect } from "pettyui/signals";
const count = signal(0);
effect(() => console.log(count.get()));
count.set(1); // logs 1
```
## Router
SPA routing with the Navigation API (~15 lines):
```js
import { initRouter } from "pettyui/router";
initRouter();
```
```html
<a href="/about">About</a>
<main data-petty-outlet><!-- content swaps here --></main>
```
## License
MIT

12
package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "pettyui-monorepo",
"private": true,
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test",
"typecheck": "pnpm -r typecheck"
},
"devDependencies": {
"typescript": "^6.0.2"
}
}

View File

@ -0,0 +1,77 @@
{
"name": "pettyui",
"version": "2.0.0-alpha.0",
"description": "Zero-dependency headless Web Components built on browser standards. AI-native.",
"type": "module",
"exports": {
"./signals": "./src/signals.ts",
"./router": "./src/router.ts",
"./theme": "./src/theme.css",
"./animations": "./src/animations.css",
"./counter": "./src/components/counter/index.ts",
"./accordion": "./src/components/accordion/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",
"./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",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^4.3.6"
},
"devDependencies": {
"tsdown": "^0.21.7",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
}
}

View File

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

View File

@ -0,0 +1,82 @@
/**
* PettyAccordionItem wraps a single `<details>` element within a PettyAccordion.
*
* Usage:
* ```html
* <petty-accordion-item value="section-1">
* <details>
* <summary>Section 1</summary>
* <div data-part="content">Content 1</div>
* </details>
* </petty-accordion-item>
* ```
*
* Manages data-state ("open" | "closed") and disabled behaviour on the
* underlying `<details>` and `<summary>` elements.
*/
export class PettyAccordionItem extends HTMLElement {
static observedAttributes = ["value", "disabled"];
/** The `<details>` element nested directly inside this item. */
get detailsElement(): HTMLDetailsElement | null {
return this.querySelector("details");
}
/** Whether the item's details element is currently open. */
get isOpen(): boolean {
return this.detailsElement?.open ?? false;
}
/** The value attribute, falling back to summary text content. */
get value(): string {
const explicit = this.getAttribute("value");
if (explicit !== null) return explicit;
return this.querySelector("summary")?.textContent?.trim() ?? "";
}
/** @internal */
connectedCallback(): void {
this.#syncState();
this.#applyDisabled();
const details = this.detailsElement;
if (details) {
details.addEventListener("toggle", this.#handleToggle);
}
}
/** @internal */
disconnectedCallback(): void {
const details = this.detailsElement;
details?.removeEventListener("toggle", this.#handleToggle);
}
/** @internal */
attributeChangedCallback(name: string): void {
if (name === "disabled") this.#applyDisabled();
}
#handleToggle = (): void => {
this.#syncState();
};
#syncState(): void {
const details = this.detailsElement;
const state = details?.open ? "open" : "closed";
this.setAttribute("data-state", state);
const summary = details?.querySelector("summary");
if (summary) summary.setAttribute("aria-expanded", String(details?.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.setAttribute("tabindex", "-1");
} else {
summary.removeAttribute("aria-disabled");
summary.removeAttribute("tabindex");
}
}
}

View File

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

View File

@ -0,0 +1,82 @@
import { emit, listen } from "../../shared/helpers";
/**
* PettyAccordion headless accordion built on native `<details>` elements.
*
* Usage:
* ```html
* <petty-accordion>
* <petty-accordion-item>
* <details>
* <summary>Section 1</summary>
* <div data-part="content">Content 1</div>
* </details>
* </petty-accordion-item>
* <petty-accordion-item>
* <details>
* <summary>Section 2</summary>
* <div data-part="content">Content 2</div>
* </details>
* </petty-accordion-item>
* </petty-accordion>
* ```
*
* The browser handles: open/close toggle, keyboard navigation within summary.
* This element adds: single/multiple mode, petty-change event with open values.
*/
export class PettyAccordion extends HTMLElement {
static observedAttributes = ["type"];
/** The accordion mode: "single" closes others on open, "multiple" allows many open. */
get type(): "single" | "multiple" {
const val = this.getAttribute("type");
return val === "multiple" ? "multiple" : "single";
}
/** @internal */
connectedCallback(): void {
this.addEventListener("toggle", this.#handleToggle, true);
}
/** @internal */
disconnectedCallback(): void {
this.removeEventListener("toggle", this.#handleToggle, true);
}
#handleToggle = (event: Event): void => {
const target = event.target;
if (!(target instanceof HTMLDetailsElement)) return;
if (this.type === "single" && target.open) {
this.#closeOthers(target);
}
this.#dispatchChange();
};
#closeOthers(opened: HTMLDetailsElement): void {
const items = this.querySelectorAll<HTMLDetailsElement>("details");
items.forEach((details) => {
if (details !== opened && details.open) {
details.open = false;
}
});
}
#dispatchChange(): void {
const openValues = this.#collectOpenValues();
emit(this, "change", { value: openValues });
}
#collectOpenValues(): string[] {
const items = this.querySelectorAll("petty-accordion-item");
const values: string[] = [];
items.forEach((item) => {
const details = item.querySelector("details");
if (!details?.open) return;
const val = item.getAttribute("value") ?? item.querySelector("summary")?.textContent?.trim() ?? "";
if (val) values.push(val);
});
return values;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,73 @@
import { emit, listen } from "../../shared/helpers";
import { uniqueId } from "../../shared/aria";
/**
* PettyDialog headless dialog custom element built on native `<dialog>`.
*
* Usage:
* ```html
* <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>
* ```
*
* The browser handles: modal behavior, backdrop, focus trap, Escape, Invoker Commands.
* 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");
}
/** Whether the dialog is currently open. */
get isOpen(): boolean {
return this.dialogElement?.open ?? false;
}
/** Opens the dialog as a modal. */
open(): void {
const dlg = this.dialogElement;
if (dlg && !dlg.open) dlg.showModal();
}
/** Closes the dialog with an optional return value. */
close(returnValue?: string): void {
const dlg = this.dialogElement;
if (dlg?.open) dlg.close(returnValue);
}
/** @internal */
connectedCallback(): void {
const dlg = this.dialogElement;
if (!dlg) return;
this.#linkAria(dlg);
this.#cleanup = listen(dlg, [["close", this.#handleClose]]);
}
/** @internal */
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) return;
if (!heading.id) heading.id = uniqueId("petty-dlg-title");
dlg.setAttribute("aria-labelledby", heading.id);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,79 @@
import { emit } from "../../shared/helpers";
/** PettyDropdownMenu — action menu built on the Popover API. */
export class PettyDropdownMenu extends HTMLElement {
/** The trigger button element. */
get triggerElement(): HTMLElement | null {
return this.querySelector("[data-part=trigger]");
}
/** The menu content element. */
get contentElement(): HTMLElement | null {
return this.querySelector("[data-part=content]");
}
/** @internal */
connectedCallback(): void {
const trigger = this.triggerElement;
const content = this.contentElement;
if (!trigger || !content) return;
trigger.setAttribute("aria-haspopup", "menu");
trigger.setAttribute("aria-expanded", "false");
if (content.id) trigger.setAttribute("aria-controls", content.id);
content.addEventListener("toggle", this.#handleToggle as EventListener);
content.addEventListener("keydown", this.#handleKeyDown);
content.addEventListener("click", this.#handleClick);
}
/** @internal */
disconnectedCallback(): void {
const content = this.contentElement;
content?.removeEventListener("toggle", this.#handleToggle as EventListener);
content?.removeEventListener("keydown", this.#handleKeyDown);
content?.removeEventListener("click", this.#handleClick);
}
#handleToggle = (e: ToggleEvent): void => {
const open = e.newState === "open";
this.triggerElement?.setAttribute("aria-expanded", String(open));
if (open) this.#focusFirst();
};
#handleKeyDown = (e: KeyboardEvent): void => {
const items = this.#getItems();
const active = document.activeElement;
if (!(active instanceof HTMLElement)) return;
const idx = items.indexOf(active);
if (e.key === "ArrowDown") {
e.preventDefault();
const next = idx < items.length - 1 ? idx + 1 : 0;
items[next]?.focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prev = idx > 0 ? idx - 1 : items.length - 1;
items[prev]?.focus();
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
active.click();
}
};
#handleClick = (e: MouseEvent): void => {
const item = (e.target as HTMLElement).closest("petty-menu-item");
if (!item || item.hasAttribute("disabled")) return;
emit(this, "select", { value: item.textContent?.trim() ?? "" });
this.contentElement?.hidePopover();
};
#focusFirst(): void {
const first = this.#getItems()[0];
if (first) first.focus();
}
#getItems(): HTMLElement[] {
return Array.from(this.querySelectorAll("petty-menu-item:not([disabled])"));
}
}

View File

@ -0,0 +1,8 @@
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);
customElements.define("petty-menu-item", PettyMenuItem);
}

View File

@ -0,0 +1,17 @@
/** PettyMenuItem — a single actionable item within a dropdown menu. */
export class PettyMenuItem extends HTMLElement {
/** @internal */
connectedCallback(): void {
this.setAttribute("role", "menuitem");
this.setAttribute("tabindex", "-1");
}
static get observedAttributes(): string[] { return ["disabled"]; }
/** @internal */
attributeChangedCallback(name: string): void {
if (name === "disabled") {
this.setAttribute("aria-disabled", this.hasAttribute("disabled") ? "true" : "false");
}
}
}

View File

@ -0,0 +1,56 @@
import { uniqueId } from "../../shared/aria";
/**
* PettyFormField form field wrapper with label, control, and error linking.
*
* Usage:
* ```html
* <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>
* ```
*
* On connect, generates stable IDs and wires up aria-describedby, htmlFor,
* and the name attribute on the control. Call setError/clearError to manage
* inline validation state or let petty-form do it automatically.
*/
export class PettyFormField extends HTMLElement {
static get observedAttributes(): string[] { return ["name"]; }
/** @internal */
connectedCallback(): void {
const name = this.getAttribute("name") ?? "";
const controlId = uniqueId(`petty-field-${name}`);
const errorId = `${controlId}-error`;
const label = this.querySelector("[data-part=label]");
const control = this.querySelector("[data-part=control]");
const error = this.querySelector("[data-part=error]");
if (control) {
control.id = controlId;
if (!control.getAttribute("name")) control.setAttribute("name", name);
control.setAttribute("aria-describedby", errorId);
}
if (label && control) (label as HTMLLabelElement).htmlFor = controlId;
if (error) error.id = errorId;
}
/** Display an error message on this field. */
setError(message: string): void {
const error = this.querySelector("[data-part=error]");
const control = this.querySelector("[data-part=control]");
if (error) error.textContent = message;
if (control) control.setAttribute("aria-invalid", "true");
}
/** Clear the error message on this field. */
clearError(): void {
const error = this.querySelector("[data-part=error]");
const control = this.querySelector("[data-part=control]");
if (error) error.textContent = "";
if (control) control.removeAttribute("aria-invalid");
}
}

View File

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

View File

@ -0,0 +1,93 @@
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.
*
* Usage:
* ```html
* <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>
* ```
*
* Set a Zod schema via `el.setSchema(z.object({...}))`. Listens for
* form submit, runs validation, and distributes error messages to
* petty-form-field elements via data-part selectors. Dispatches
* "petty-submit" on success or "petty-invalid" on failure.
*/
export class PettyForm extends HTMLElement {
#schema: SchemaLike | null = null;
/** Set a Zod schema for validation. */
setSchema(schema: SchemaLike | null): void {
this.#schema = schema;
}
/** @internal */
connectedCallback(): void {
const form = this.querySelector("form");
if (!form) return;
form.addEventListener("submit", this.#handleSubmit);
form.setAttribute("novalidate", "");
}
/** @internal */
disconnectedCallback(): void {
const form = this.querySelector("form");
form?.removeEventListener("submit", this.#handleSubmit);
}
#handleSubmit = (e: Event): void => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const data = Object.fromEntries(new FormData(form));
this.#clearErrors();
if (this.#schema) {
const result = this.#schema.safeParse(data);
if (!result.success) {
const issues = result.error?.issues ?? [];
this.#showErrors(issues);
emit(this, "invalid", { errors: issues });
return;
}
}
emit(this, "submit", { data });
};
#clearErrors(): void {
for (const el of Array.from(this.querySelectorAll("[data-part=error]"))) {
el.textContent = "";
}
for (const el of Array.from(this.querySelectorAll("[data-part=control]"))) {
el.removeAttribute("aria-invalid");
}
}
#showErrors(issues: Array<{ path: Array<string | number>; message: string }>): void {
for (const issue of issues) {
const name = String(issue.path[0] ?? "");
const field = this.querySelector(`petty-form-field[name="${name}"]`);
if (!field) continue;
const errorEl = field.querySelector("[data-part=error]");
if (errorEl) errorEl.textContent = issue.message;
const control = field.querySelector("[data-part=control]");
if (control) control.setAttribute("aria-invalid", "true");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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