Compare commits

...

No commits in common. "bf576905a7cdf6a9308cf5f003840bb4df6ed81a" and "9896e6828d6943e51228d6d303050ec282318514" have entirely different histories.

226 changed files with 7 additions and 13307 deletions

View File

@ -1,70 +0,0 @@
#!/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

View File

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

View File

@ -1,40 +0,0 @@
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Biome check
run: pnpm biome check packages/
- name: ESLint (Solid rules)
run: pnpm eslint packages --ext .ts,.tsx
- name: Type check
run: pnpm -r typecheck
- name: Tests
run: pnpm -r test
- name: Build
run: pnpm -r build

6
.gitignore vendored
View File

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

20
LICENSE
View File

@ -1,21 +1,9 @@
MIT License
Copyright (c) 2026 StayThree
Copyright (c) 2026 mats.bosson
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:
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 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.
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.

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# PettyUI
51 headless Web Components. 0 deps. ~5KB. Popover API, native Dialog, Navigation API. AI-native.

View File

@ -1,36 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error"
},
"style": {
"useConst": "error",
"useTemplate": "error"
},
"a11y": {
"useSemanticElements": "off"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all"
}
},
"files": {
"ignore": ["node_modules", "dist", ".tsdown", "coverage"]
}
}

View File

@ -1,323 +0,0 @@
# PettyUI AI-First Architecture Design
**Date:** 2026-03-29
**Status:** Approved
**Supersedes:** All prior specs and plans in this directory
## Vision
PettyUI is the first component library where **the AI is the intended user, not an afterthought**. The primary consumer is LLMs generating code. Human developers benefit as a side effect of good AI ergonomics. Distribution follows the proven Radix → shadcn two-layer model: headless core + copy-paste styled registry.
## Package Structure
```
packages/
core/ → Headless primitives with Zod-first props + meta
mcp/ → MCP server: discovery, validation, code generation
registry/ → Styled copy-paste components (shadcn layer for Solid)
```
No separate `schemas/` package. The Zod schema lives WITH the component because it IS the component's type system.
## Component Inventory
Organized by **AI task** — what the agent is trying to build — not UI taxonomy.
### BUILD A FORM (AI's #1 task)
| Component | Status | Notes |
|-----------|--------|-------|
| TextField | Done | |
| Checkbox | Done | |
| Switch | Done | |
| RadioGroup | Done | |
| Select | Done | |
| Combobox | Done | |
| Slider | Done | |
| NumberField | Done | |
| ToggleGroup | Done | |
| **Form** | New | Validation integration (Zod v4), field grouping, error display, aria-describedby linking |
| **DatePicker** | New | Calendar + input, range selection, locale-aware |
| **Calendar** | New | Standalone month/year grid, keyboard nav |
### BUILD NAVIGATION
| Component | Status | Notes |
|-----------|--------|-------|
| Tabs | Done | |
| Breadcrumbs | Done | |
| Link | Done | |
| DropdownMenu | Done | |
| **CommandPalette** | New | cmdk pattern — search, keyboard nav, grouped actions |
| **NavigationMenu** | New | Horizontal nav with dropdown submenus, hover intent |
### BUILD AN OVERLAY
| Component | Status | Notes |
|-----------|--------|-------|
| Dialog | Done | |
| AlertDialog | Done | |
| Drawer | Done | |
| Popover | Done | |
| Tooltip | Done | |
| HoverCard | Done | |
| Toast | Done | |
### BUILD A DASHBOARD / DATA VIEW
| Component | Status | Notes |
|-----------|--------|-------|
| Progress | Done | |
| Badge | Done | |
| Skeleton | Done | |
| Alert | Done | |
| **DataTable** | New | Sorting, filtering, pagination, column resize, row selection |
| **VirtualList** | New | Virtualized rendering for large datasets, variable row heights |
| **Avatar** | New | Image + fallback initials, status indicator |
### BUILD LAYOUT & STRUCTURE
| Component | Status | Notes |
|-----------|--------|-------|
| Accordion | Done | |
| Collapsible | Done | |
| **Card** | New | Header/Content/Footer compound, token contract |
| **Wizard/Stepper** | New | Multi-step flows, step validation, linear/non-linear |
### BUILD INTERACTIONS
| Component | Status | Notes |
|-----------|--------|-------|
| Button | Done | |
| Toggle | Done | |
### CUT (remove from exports, keep internally if needed)
| Component | Reason |
|-----------|--------|
| ContextMenu | Niche desktop pattern |
| Image | `<img loading="lazy">` covers it |
| Meter | Nobody uses it vs Progress |
| Separator | Keep as menu sub-component only, drop standalone export |
| Pagination | Keep as DataTable internal, drop standalone export |
| ColorPicker suite | Design-tool-only, <5% of projects |
| Menubar | Desktop OS pattern |
| TimeField | Extremely specialized |
| Rating | Trivial, single-purpose |
| FileField | Browser native + dropzone libs |
| SegmentedControl | ToggleGroup covers this |
### Totals
| Category | Done | Cut | New | Final Total |
|----------|------|-----|-----|-------------|
| Components | 28 | -3 | +10 | 35 |
| Standalone exports | 28 | -5 | +10 | 33 |
| Primitives | 5 | 0 | +1 (createVirtualizer) | 6 |
| Utilities | 6 | 0 | 0 | 6 |
## Schema Architecture: Zod-First, Single Source of Truth
Every component's props are defined AS Zod schemas. TypeScript types are derived FROM the schemas. No duplication, no drift.
### Per-Component Pattern
```typescript
// components/dialog/dialog.props.ts
import { z } from "zod/v4";
export const DialogRootProps = z.object({
open: z.boolean().optional()
.describe("Controlled open state"),
defaultOpen: z.boolean().optional()
.describe("Initial open state (uncontrolled)"),
onOpenChange: z.function().args(z.boolean()).returns(z.void()).optional()
.describe("Called when open state changes"),
modal: z.boolean().default(true)
.describe("Whether to trap focus and add backdrop"),
});
// TypeScript types DERIVED from schema
export type DialogRootProps = z.infer<typeof DialogRootProps>;
// Metadata — just enough for MCP discovery
export const DialogMeta = {
name: "Dialog",
description: "Modal overlay requiring user acknowledgment",
parts: ["Root", "Trigger", "Portal", "Overlay", "Content", "Title", "Description", "Close"] as const,
requiredParts: ["Root", "Content", "Title"] as const,
} as const;
```
### What This Enables
| AI asks... | Schema provides... |
|---|---|
| "What components can build a form?" | `.describe()` strings + MCP semantic search |
| "What props does Select accept?" | Full typed contract with descriptions |
| "Is this Dialog usage valid?" | Runtime validation against schema |
| "Which parts are required for a11y?" | `requiredParts` in meta |
## MCP Server Architecture
The MCP server is the AI's interface to PettyUI. It reads Zod schemas and meta directly from `core/`.
### Tools
```
pettyui.discover → "I need to collect user input" → returns matching components with schemas
pettyui.inspect → "Tell me about Dialog" → returns full props schema + parts + required
pettyui.validate → "Is this Dialog usage correct?" → validates JSX/props against schema
pettyui.add → "Add Dialog to my project" → copies styled component from registry
pettyui.compose → "Build a settings form" → returns composed multi-component JSX
```
### pettyui.discover
```
Input: { intent: "I need a searchable dropdown" }
Output: [
{ name: "Combobox", match: 0.95, description: "..." },
{ name: "CommandPalette", match: 0.72, description: "..." },
{ name: "Select", match: 0.6, description: "..." }
]
```
Uses `.describe()` strings for semantic matching. No rigid taxonomy — descriptions ARE the search index.
### pettyui.inspect
```
Input: { component: "Dialog" }
Output: {
props: { /* Zod schema as JSON Schema */ },
parts: ["Root", "Trigger", "Portal", "Overlay", "Content", "Title", "Description", "Close"],
requiredParts: ["Root", "Content", "Title"],
example: "/* minimal valid usage */",
source: "/* full component source if requested */"
}
```
### pettyui.validate
```
Input: { component: "Dialog", jsx: "<Dialog.Root><Dialog.Content>hello</Dialog.Content></Dialog.Root>" }
Output: {
valid: false,
errors: ["Missing required part: Title. Dialog.Content must contain Dialog.Title for accessibility"],
fix: "<Dialog.Root><Dialog.Content><Dialog.Title>...</Dialog.Title>hello</Dialog.Content></Dialog.Root>"
}
```
### pettyui.add
```
Input: { component: "Dialog", style: "default" }
Output: {
files: [{ path: "src/components/ui/dialog.tsx", content: "/* styled component */" }],
dependencies: ["pettyui/dialog"]
}
```
### pettyui.compose
```
Input: { intent: "login form with email and password", components: ["Form", "TextField", "Button"] }
Output: { jsx: "/* complete composed component */", imports: ["pettyui/form", "pettyui/text-field", "pettyui/button"] }
```
### MCP Server Does NOT Do
- No styling opinions — registry's job
- No state management — components handle their own
- No routing — out of scope
- No build tooling — tsdown/vite
## Registry Layer (shadcn Model for Solid)
### What Gets Copied
`pettyui add dialog` creates `src/components/ui/dialog.tsx`:
```typescript
import { Dialog as DialogPrimitive } from "pettyui/dialog";
import { cn } from "@/lib/utils";
const DialogContent = (props) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay class={cn("fixed inset-0 bg-black/50 ...")} />
<DialogPrimitive.Content
class={cn("fixed left-1/2 top-1/2 ... bg-background rounded-lg shadow-lg", props.class)}
>
{props.children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
export { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogDescription, DialogClose };
```
### Registry Principles
1. **Tailwind + CSS variables** — styles co-located, tokens via `--` variables
2. **Imports headless from core** — registry depends on `pettyui/*`, not copy-pasted behavior
3. **Pre-composed for 90% case** — DialogContent includes Portal + Overlay automatically
4. **Fully editable** — your file after copy
5. **One file per component** — AI reads one file, gets everything
### Theme Contract
```css
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--muted: 0 0% 96.1%;
--border: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
```
### CLI
```bash
pettyui init # Sets up tokens, utils, tsconfig paths
pettyui add dialog # Copies styled dialog component
pettyui add form # Copies styled form with Zod integration
pettyui diff dialog # Shows what you've changed vs upstream
```
## Build Order
```
Phase 1: Foundation Phase 2: Advanced Phase 3: AI Layer
───────────────── ────────────────── ─────────────────
Zod-first props refactor DataTable MCP server
(migrate 28 components) CommandPalette pettyui.discover
Meta objects on all DatePicker + Calendar pettyui.inspect
components Wizard/Stepper pettyui.validate
Card + Avatar (simple) Form system pettyui.add
NavigationMenu VirtualList + primitive pettyui.compose
Cut ContextMenu/Image/
Meter/Separator/Pagination
Registry scaffolding Registry: styled versions CLI wrapper
(init, tokens, utils) of all components pettyui init/add/diff
```
Phase 1 is mostly refactoring what exists. Phase 2 is the hard new work. Phase 3 is where PettyUI becomes unique.
## Key Design Principles
1. **AI is the primary user** — every API decision optimizes for LLM code generation
2. **Zod-first** — schemas ARE the type system, not a parallel description
3. **Sub-path exports**`pettyui/dialog` not `pettyui` barrel. Prevents hallucination
4. **Compound components**`Dialog.Root` + `Dialog.Content` over prop explosion
5. **Sensible defaults** — components work with zero props, accept controlled props when needed
6. **Union types over booleans**`variant: 'primary' | 'secondary'` not `isPrimary?: boolean`
7. **Consistent naming** — same patterns everywhere, reduces AI search space
8. **CSS variables for theming** — AI handles CSS vars naturally vs JS theme providers
9. **`.describe()` on every prop** — this IS the documentation
10. **Runtime validation feedback** — AI generates, validates, fixes. Feedback loop

View File

@ -1,378 +0,0 @@
# PettyUI v2: Web Components Architecture
## Overview
PettyUI v2 is a complete rewrite from SolidJS to vanilla Web Components. Zero framework dependencies. Zero external runtime. Built on modern web platform APIs (Popover, `<dialog>`, Invoker Commands, Navigation API, View Transitions). AI-native with Zod schemas and MCP tools.
**Goal:** The smallest, fastest headless UI component library that exists, because it builds on the browser instead of replacing it. Capable of powering full SPA experiences with zero client-side framework.
**Non-goals:** Styled components. Shadow DOM. Framework-specific bindings. Build step requirements.
## Architecture
### Core Principles
1. **Browser-first** — use native APIs before writing JS. Popover API for overlays, `<dialog>` for modals, Invoker Commands for triggers, Navigation API for routing, View Transitions for animations, `<details>` for disclosure.
2. **Progressive enhancement** — components that can work before JS loads DO work before JS loads. JS upgrades add ARIA, keyboard navigation, and programmatic APIs.
3. **Zero dependencies** — no Lit, no Floating UI, no framework. The only runtime is a ~30-line signals utility (~500 bytes) bundled inline.
4. **AI-native** — Zod schemas describe every component's API. MCP tools (discover, inspect, compose, validate, add) let AI agents generate correct HTML without reading docs.
5. **Full SPA capability** — Navigation API + View Transitions replace client-side routers. PettyUI apps feel like SPAs with zero framework overhead.
### Tech Stack
- **Language:** TypeScript, compiled to ES modules
- **Reactivity:** Homegrown signals (~30 lines, ~500 bytes), aligned with TC39 Signals proposal
- **Components:** Custom Elements v1 (no Shadow DOM)
- **Positioning:** Simple CSS (relative/absolute). Consumer adds Floating UI if they need viewport-edge flipping — that's a styling concern, not a library concern.
- **Overlays:** Popover API + `<dialog>` element
- **Triggers:** Invoker Commands API (`commandfor`/`command` attributes)
- **Routing:** Navigation API (browser-native SPA router, Baseline Jan 2026)
- **Transitions:** View Transitions API (smooth page/component animations, Baseline 2025)
- **Build:** tsdown (same as v1)
- **Testing:** Vitest + @web/test-runner (real browser tests for Custom Elements)
- **Validation:** Zod v4 schemas (carried over from v1)
### Signals Utility
The entire reactivity system, bundled with the library (not an external dep):
```ts
let current: (() => void) | null = null;
interface Signal<T> {
get(): T;
set(value: T): void;
}
function signal<T>(value: T): Signal<T> {
const subs = new Set<() => void>();
return {
get() { if (current) subs.add(current); return value; },
set(v: T) { value = v; for (const fn of subs) fn(); },
};
}
function effect(fn: () => void): void {
const prev = current;
current = fn;
fn();
current = prev;
}
```
Components use `signal()` for internal state and `effect()` to sync state to DOM attributes/properties.
## Component Anatomy
### Three Tiers
**Tier 1 — HTML-native (works without JS):**
- Dialog, Popover, Tooltip, Collapsible/Accordion (via `<details>`), Alert, Separator, Link
- JS upgrade adds: ARIA linking, keyboard nav, custom events, programmatic API
**Tier 2 — Popover-enhanced (partially works without JS):**
- Select, DropdownMenu, ContextMenu, HoverCard, DatePicker, CommandPalette
- Popover API handles open/close. JS adds: keyboard nav, typeahead, ARIA, selection state
**Tier 3 — JS-required (inert until upgrade):**
- Tabs, Slider, Calendar, DataTable, VirtualList, Combobox, Wizard, Form, NumberField, Toast, Pagination
- These components need JS for core functionality. They render as plain HTML until the custom element upgrades.
### Composition: Nested Custom Elements
Complex components use nested custom elements. Each part is a registered element with its own lifecycle. Parts find their parent via `this.closest()`.
```html
<petty-dialog>
<button commandfor="my-dlg" command="show-modal">Open</button>
<dialog id="my-dlg">
<petty-dialog-title>Confirm</petty-dialog-title>
<petty-dialog-description>Are you sure?</petty-dialog-description>
<button commandfor="my-dlg" command="close">Cancel</button>
</dialog>
</petty-dialog>
```
**Rule:** If a part needs its own behavior (keyboard handling, ARIA management, event emission), it's a custom element. If it's pure structure, it's a plain element with `data-part` for styling hooks.
### Data Attributes
Following the established convention (Radix, Shoelace):
- `data-state` — component state: `open`, `closed`, `active`, `inactive`, `checked`, `unchecked`, `on`, `off`
- `data-part` — structural identification for CSS targeting
- `data-disabled` — disabled state flag
- `data-orientation``horizontal` or `vertical`
### Event Pattern
Components dispatch standard `CustomEvent`s with the `petty-` prefix:
```ts
this.dispatchEvent(new CustomEvent("petty-close", {
bubbles: true,
detail: { value: "confirm" },
}));
```
Consumers listen with standard DOM:
```js
dialog.addEventListener("petty-close", (e) => {
if (e.detail.value === "confirm") deleteItem();
});
```
### Property/Attribute Reflection
Components expose both property and attribute APIs:
```ts
// Attribute API (HTML)
<petty-select value="apple">
// Property API (JS)
document.querySelector("petty-select").value = "apple";
```
Properties are the source of truth. Attributes reflect via `attributeChangedCallback` → property setter. This avoids the string-only limitation of attributes for complex values.
## Component List (44 components, same as v1)
### Layout & Display (6)
- `petty-avatar` — image with fallback
- `petty-badge` — status indicator (simple element, no sub-parts)
- `petty-card` — container with `petty-card-header`, `petty-card-content`, `petty-card-footer`
- `petty-image` — image with fallback
- `petty-separator``<hr>` with role and orientation
- `petty-skeleton` — loading placeholder
### Inputs & Forms (14)
- `petty-button``<button>` wrapper with loading/disabled state
- `petty-text-field` — label + input + description + error
- `petty-number-field` — input with increment/decrement
- `petty-checkbox` — tri-state checkbox
- `petty-switch` — on/off toggle
- `petty-radio-group` + `petty-radio-item` — mutually exclusive options
- `petty-slider` — range input with track/thumb
- `petty-toggle` — pressed/unpressed button
- `petty-toggle-group` + items — single/multi toggle selection
- `petty-select` + `petty-select-trigger`, `petty-select-content`, `petty-select-option` — dropdown select using Popover API
- `petty-combobox` + parts — searchable select
- `petty-listbox` + `petty-listbox-option` — inline selectable list
- `petty-form` + `petty-form-field` — Zod-validated form
- `petty-date-picker` + parts — date input with calendar popover
### Navigation (8)
- `petty-link` — anchor with external/disabled support
- `petty-breadcrumbs` + `petty-breadcrumb-item` — navigation trail
- `petty-tabs` + `petty-tab`, `petty-tab-panel` — tabbed interface
- `petty-accordion` + `petty-accordion-item` — collapsible sections (uses `<details>` internally)
- `petty-collapsible` — single disclosure (uses `<details>` internally)
- `petty-pagination` + parts — page navigation
- `petty-nav-menu` + parts — horizontal nav with dropdowns
- `petty-wizard` + parts — multi-step flow
### Overlays (9)
- `petty-dialog` — wraps native `<dialog>` with ARIA + events
- `petty-alert-dialog` — confirmation dialog
- `petty-drawer` — slide-in panel (uses `<dialog>`)
- `petty-popover` — floating content (uses Popover API)
- `petty-tooltip` — hover label (uses Popover API + CSS Anchor)
- `petty-hover-card` — rich hover preview
- `petty-dropdown-menu` + items — action menu (uses Popover API)
- `petty-context-menu` + items — right-click menu
- `petty-command-palette` + parts — search-driven command menu
### Feedback & Status (4)
- `petty-alert` — inline status message
- `petty-toast` + `petty-toast-region` — temporary notification
- `petty-progress` — progress bar
- `petty-meter` — value gauge
### Data (3)
- `petty-calendar` + parts — month grid date picker
- `petty-data-table` + parts — sortable/filterable table
- `petty-virtual-list` — windowed scroll
## SPA Router (petty-router)
PettyUI includes an optional ~15-line SPA router built entirely on browser standards. No external dependency.
### How it works
The Navigation API (Baseline January 2026) provides browser-native SPA routing. View Transitions API (Baseline 2025) animates page swaps. Together they replace React Router, Vue Router, and every other client-side routing library.
```ts
// petty-router: the entire implementation
navigation.addEventListener("navigate", (event) => {
const url = new URL(event.destination.url);
if (url.origin !== location.origin) return;
event.intercept({
async handler() {
const res = await fetch(url.pathname, {
headers: { "X-Partial": "true" }
});
const html = await res.text();
document.startViewTransition(() => {
document.querySelector("[data-petty-outlet]").innerHTML = html;
});
}
});
});
```
### Consumer usage
```html
<script type="module">
import "pettyui/router";
</script>
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
</nav>
<main data-petty-outlet>
<!-- Content swapped here on navigation -->
</main>
```
Clicking a link: Navigation API intercepts → fetches HTML from server → View Transitions animates the swap → PettyUI components in the new HTML upgrade instantly. Back/forward buttons work. URL bar updates. No full page reload.
### What the server does
The server checks for the `X-Partial` header. If present, it returns just the page fragment (no `<html>`, `<head>`, etc.). If absent, it returns the full page (for direct URL visits, bookmarks, SEO crawlers).
### Browser support
| API | Chrome | Firefox | Safari | Edge |
|-----|--------|---------|--------|------|
| Navigation API | 102+ | 136+ | 18.2+ | 102+ |
| View Transitions (same-doc) | 111+ | 140+ | 18+ | 111+ |
Both are Baseline. For the rare old browser without support, links just work as normal full-page navigations — progressive enhancement.
## Platform APIs Usage Map
| Browser API | Replaces (v1) | Used by |
|-------------|---------------|---------|
| Popover API | `createDismiss()`, z-index management | Select, DropdownMenu, ContextMenu, Tooltip, Popover, HoverCard, CommandPalette, DatePicker |
| `<dialog>` | `createFocusTrap()`, scroll lock, backdrop | Dialog, AlertDialog, Drawer |
| Invoker Commands | JS click handlers for open/close | Dialog, AlertDialog, Popover triggers |
| Navigation API | React Router / client-side routers | petty-router (SPA navigation) |
| View Transitions | Animation JS / Framer Motion | petty-router (page transitions), Accordion, Dialog enter/exit |
| `<details>`/`<summary>` | Disclosure state management | Accordion, Collapsible |
| `inert` attribute | Manual aria-hidden + tabindex management | Dialog background freeze |
## File Structure
```
packages/core/
src/
signals.ts — signal() + effect() (~30 lines)
router.ts — petty-router SPA navigation (~15 lines)
shared/
keyboard.ts — shared keyboard navigation utilities
aria.ts — shared ARIA helpers
components/
dialog/
dialog.ts — PettyDialog custom element
dialog-title.ts — PettyDialogTitle
dialog-description.ts
dialog.schema.ts — Zod schema (carried from v1)
dialog.test.ts
index.ts — registration + exports
select/
select.ts
select-trigger.ts
select-content.ts
select-option.ts
select.schema.ts
select.test.ts
index.ts
... (same pattern for all 44)
index.ts — registers all elements, exports all
package.json
tsconfig.json
```
Each component is independently importable:
```ts
import "pettyui/dialog"; // registers <petty-dialog> etc.
```
Or consumers include a script tag:
```html
<script type="module" src="https://cdn.jsdelivr.net/npm/pettyui/dialog"></script>
```
## Migration from v1
### What carries over unchanged
- Zod schemas (props definitions, validation)
- MCP tools architecture (discover, inspect, compose, validate, add)
- Component registry metadata
- Test scenarios (same behaviors, different implementation)
- NagLint rules
### What gets rewritten
- All 44 component implementations (SolidJS JSX → Custom Elements)
- Utility primitives (createFloating → CSS anchor, createDismiss → Popover API, createFocusTrap → `<dialog>`)
- Test harness (SolidJS testing library → DOM-based testing)
### What gets deleted
- `solid-js` peer dependency
- `@floating-ui/dom` dependency
- `vite-plugin-solid`
- All `.tsx` files (replaced by `.ts`)
- Compound component Object.assign pattern
## Phased Rollout
### Phase 1: Foundation
- Signals utility
- Shared utilities (keyboard, ARIA, anchor fallback)
- Base component patterns established
### Phase 2: Simple Components (Tier 1 + easy Tier 3)
- Dialog, Alert, AlertDialog, Collapsible, Accordion
- Button, Badge, Separator, Link, Toggle, Skeleton, Avatar, Image
- Progress, Meter
### Phase 3: Selection & Input Components
- Select, Combobox, Listbox
- TextField, NumberField, Checkbox, Switch, RadioGroup, Slider
- ToggleGroup, Form
### Phase 4: Complex Components
- Tabs, Popover, Tooltip, HoverCard, DropdownMenu, ContextMenu
- Drawer, CommandPalette, DatePicker, Calendar
- Pagination, NavigationMenu, Wizard, Breadcrumbs
### Phase 5: Data Components + MCP Update
- DataTable, VirtualList, Toast
- Update MCP tools for new architecture
- Update registry
## Size Budget
| Item | v1 (SolidJS) | v2 (Web Components) |
|------|-------------|---------------------|
| Framework runtime | solid-js ~23KB | 0KB |
| Floating UI | @floating-ui/dom ~15KB | 0KB (simple CSS) |
| Router | N/A (framework-specific) | ~400 bytes (Navigation API wrapper) |
| Signals utility | (included in solid-js) | ~500 bytes |
| Per-component avg | ~2-4KB | ~0.5-2KB |
| **Total (all 44 + router)** | **~80-100KB** | **~20-35KB** |
## Success Criteria
1. Zero external runtime dependencies
2. Every component passes existing accessibility test scenarios
3. Tier 1 components function without JavaScript
4. Total gzipped bundle (all 44 components) under 15KB
5. INP < 100ms on component interactions (no hydration)
6. MCP tools work with the new architecture
7. Works in Chrome, Firefox, Safari, Edge (latest 2 versions)

View File

@ -1,22 +0,0 @@
import solid from "eslint-plugin-solid";
import tsParser from "@typescript-eslint/parser";
export default [
{
files: ["packages/**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
},
plugins: { solid },
rules: {
"solid/reactivity": "error",
"solid/no-destructure": "error",
"solid/prefer-for": "warn",
"solid/no-react-deps": "error",
"solid/no-react-specific-props": "error",
},
},
{
ignores: ["node_modules/**", "dist/**", "coverage/**"],
},
];

View File

@ -1,19 +0,0 @@
{
"name": "pettyui-monorepo",
"private": true,
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test",
"lint": "biome check . && eslint packages --ext .ts,.tsx",
"format": "biome format --write .",
"typecheck": "pnpm -r typecheck"
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.0.0",
"eslint-plugin-solid": "^0.14.0",
"typescript": "^6.0.2",
"zod": "^4.3.6"
}
}

View File

@ -1,77 +0,0 @@
{
"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

@ -1,137 +0,0 @@
/* 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

@ -1,82 +0,0 @@
/**
* 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

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

View File

@ -1,82 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,3 +0,0 @@
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

@ -1,59 +0,0 @@
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

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

View File

@ -1,3 +0,0 @@
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

@ -1,23 +0,0 @@
/** 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

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

View File

@ -1,3 +0,0 @@
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

@ -1,38 +0,0 @@
/** 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

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

View File

@ -1,3 +0,0 @@
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

@ -1,17 +0,0 @@
/** 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

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

View File

@ -1,24 +0,0 @@
/** 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

@ -1,3 +0,0 @@
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

@ -1,11 +0,0 @@
/** 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

@ -1,8 +0,0 @@
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

@ -1,3 +0,0 @@
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

@ -1,53 +0,0 @@
/**
* 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

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

View File

@ -1,3 +0,0 @@
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

@ -1,93 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

@ -1,3 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,3 +0,0 @@
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

@ -1,62 +0,0 @@
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

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

View File

@ -1,3 +0,0 @@
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

@ -1,77 +0,0 @@
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

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

View File

@ -1,18 +0,0 @@
/** 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

@ -1,3 +0,0 @@
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

@ -1,83 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,17 +0,0 @@
/** 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

@ -1,3 +0,0 @@
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

@ -1,79 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,16 +0,0 @@
/** 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

@ -1,3 +0,0 @@
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

@ -1,63 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,74 +0,0 @@
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

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

View File

@ -1,3 +0,0 @@
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

@ -1,54 +0,0 @@
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

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

View File

@ -1,3 +0,0 @@
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

@ -1,55 +0,0 @@
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

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

View File

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

View File

@ -1,73 +0,0 @@
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

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

View File

@ -1,3 +0,0 @@
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

@ -1,64 +0,0 @@
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

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

View File

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

View File

@ -1,79 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,17 +0,0 @@
/** 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

@ -1,56 +0,0 @@
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

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

View File

@ -1,93 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,3 +0,0 @@
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

@ -1,62 +0,0 @@
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

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

View File

@ -1,3 +0,0 @@
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

@ -1,38 +0,0 @@
/** 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

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

View File

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

View File

@ -1,3 +0,0 @@
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

@ -1,41 +0,0 @@
/** 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

@ -1,8 +0,0 @@
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

@ -1,20 +0,0 @@
/** 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

@ -1,3 +0,0 @@
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

@ -1,76 +0,0 @@
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

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

View File

@ -1,35 +0,0 @@
/** 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);
}
}

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