New Release / ST3
This commit is contained in:
commit
dec5f8f1d2
70
.claude/hooks/naglint.sh
Executable file
70
.claude/hooks/naglint.sh
Executable file
@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# NagLint PostToolUse hook — runs after Write/Edit on source files
|
||||
|
||||
NAG=/Users/matsbosson/Documents/StayThree/NagLint/target/release/nag
|
||||
|
||||
INPUT=$(cat -)
|
||||
FILE=$(echo "$INPUT" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get('tool_input', {}).get('file_path', ''))
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$FILE" ]; then
|
||||
echo "{}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$FILE" in
|
||||
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.mts|*.cts|*.rs) ;;
|
||||
*) echo "{}"; exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ ! -f "$FILE" ]; then
|
||||
echo "{}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RAW=$("$NAG" "$FILE" --agent-json 2>/dev/null)
|
||||
|
||||
if [ "$RAW" = "{}" ] || [ -z "$RAW" ]; then
|
||||
echo "{}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# For .tsx/.jsx files: filter out AI-08 (logic density) — JSX is declarative
|
||||
case "$FILE" in
|
||||
*.tsx|*.jsx)
|
||||
FILTERED=$(echo "$RAW" | python3 -c "
|
||||
import sys, json
|
||||
|
||||
raw = sys.stdin.read().strip()
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception:
|
||||
print(raw)
|
||||
sys.exit(0)
|
||||
|
||||
if d.get('decision') != 'block':
|
||||
print(raw)
|
||||
sys.exit(0)
|
||||
|
||||
reason = d.get('reason', '')
|
||||
filtered_lines = [l for l in reason.splitlines() if '[AI-08]' not in l]
|
||||
filtered_reason = '\n'.join(filtered_lines).strip()
|
||||
|
||||
if not filtered_reason:
|
||||
print('{}')
|
||||
else:
|
||||
d['reason'] = filtered_reason
|
||||
ctx = d.get('hookSpecificOutput', {}).get('additionalContext', '')
|
||||
ctx_lines = [l for l in ctx.splitlines() if '[AI-08]' not in l]
|
||||
d['hookSpecificOutput']['additionalContext'] = '\n'.join(ctx_lines)
|
||||
print(json.dumps(d))
|
||||
" 2>/dev/null)
|
||||
echo "${FILTERED:-$RAW}"
|
||||
;;
|
||||
*)
|
||||
echo "$RAW"
|
||||
;;
|
||||
esac
|
||||
2
.claude/settings.json
Normal file
2
.claude/settings.json
Normal file
@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.tsdown/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.superpowers/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 StayThree
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
107
README.md
Normal file
107
README.md
Normal file
@ -0,0 +1,107 @@
|
||||
# PettyUI
|
||||
|
||||
51 headless Web Components. Zero dependencies. ~5KB gzipped.
|
||||
|
||||
Built on browser-native APIs — Popover, `<dialog>`, Navigation API, View Transitions. No framework required. Works everywhere.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install pettyui
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Import what you need. Each component registers itself as a Custom Element.
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import "pettyui/dialog";
|
||||
import "pettyui/select";
|
||||
import "pettyui/tabs";
|
||||
</script>
|
||||
|
||||
<petty-dialog>
|
||||
<button commandfor="my-dlg" command="show-modal" type="button">Open</button>
|
||||
<dialog id="my-dlg">
|
||||
<h2>Hello</h2>
|
||||
<p>This is a native dialog with ARIA linking, focus trap, and Escape — all from the browser.</p>
|
||||
<button commandfor="my-dlg" command="close" type="button">Close</button>
|
||||
</dialog>
|
||||
</petty-dialog>
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
**Inputs** — Button, TextField, NumberField, Checkbox, Switch, RadioGroup, Slider, Toggle, ToggleGroup, Select, Combobox, Listbox, TagsInput, Form, DatePicker
|
||||
|
||||
**Navigation** — Link, Breadcrumbs, Tabs, Accordion, Collapsible, Pagination, NavigationMenu, Wizard
|
||||
|
||||
**Overlays** — Dialog, AlertDialog, Drawer, Popover, Tooltip, HoverCard, DropdownMenu, ContextMenu, CommandPalette
|
||||
|
||||
**Feedback** — Alert, Toast, Progress, Meter, LoadingIndicator
|
||||
|
||||
**Layout** — Avatar, Badge, Card, Image, Separator, Skeleton
|
||||
|
||||
**Data** — Calendar, DataTable, VirtualList
|
||||
|
||||
**Animation** — Typewriter, Counter, Stagger, Reveal, Parallax
|
||||
|
||||
## Why
|
||||
|
||||
- **Zero runtime** — 500-byte signals core. No virtual DOM, no framework overhead.
|
||||
- **Browser-native** — Popover API for overlays, `<dialog>` for modals, Navigation API for routing. No polyfills.
|
||||
- **No Shadow DOM** — Style with plain CSS, Tailwind, or anything. No `::part()` needed.
|
||||
- **AI-native** — Zod schemas for every component. MCP tools for agent integration.
|
||||
- **Works everywhere** — React, Vue, Svelte, Astro, plain HTML. It's just Custom Elements.
|
||||
|
||||
## Styling
|
||||
|
||||
PettyUI is headless. Components use `data-state` and `data-part` attributes for styling hooks.
|
||||
|
||||
```css
|
||||
petty-tab[data-state="active"] {
|
||||
border-bottom: 2px solid blue;
|
||||
}
|
||||
|
||||
petty-switch[data-state="on"] [data-part="thumb"] {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
```
|
||||
|
||||
Optional default theme and animations:
|
||||
|
||||
```js
|
||||
import "pettyui/theme";
|
||||
import "pettyui/animations";
|
||||
```
|
||||
|
||||
## Signals
|
||||
|
||||
Built-in reactivity in ~30 lines (~500 bytes):
|
||||
|
||||
```js
|
||||
import { signal, effect } from "pettyui/signals";
|
||||
|
||||
const count = signal(0);
|
||||
effect(() => console.log(count.get()));
|
||||
count.set(1); // logs 1
|
||||
```
|
||||
|
||||
## Router
|
||||
|
||||
SPA routing with the Navigation API (~15 lines):
|
||||
|
||||
```js
|
||||
import { initRouter } from "pettyui/router";
|
||||
initRouter();
|
||||
```
|
||||
|
||||
```html
|
||||
<a href="/about">About</a>
|
||||
<main data-petty-outlet><!-- content swaps here --></main>
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
12
package.json
Normal file
12
package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "pettyui-monorepo",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"test": "pnpm -r test",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
77
packages/core/package.json
Normal file
77
packages/core/package.json
Normal file
@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "pettyui",
|
||||
"version": "2.0.0-alpha.0",
|
||||
"description": "Zero-dependency headless Web Components built on browser standards. AI-native.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./signals": "./src/signals.ts",
|
||||
"./router": "./src/router.ts",
|
||||
"./theme": "./src/theme.css",
|
||||
"./animations": "./src/animations.css",
|
||||
"./counter": "./src/components/counter/index.ts",
|
||||
"./accordion": "./src/components/accordion/index.ts",
|
||||
"./alert": "./src/components/alert/index.ts",
|
||||
"./alert-dialog": "./src/components/alert-dialog/index.ts",
|
||||
"./avatar": "./src/components/avatar/index.ts",
|
||||
"./badge": "./src/components/badge/index.ts",
|
||||
"./breadcrumbs": "./src/components/breadcrumbs/index.ts",
|
||||
"./button": "./src/components/button/index.ts",
|
||||
"./calendar": "./src/components/calendar/index.ts",
|
||||
"./card": "./src/components/card/index.ts",
|
||||
"./checkbox": "./src/components/checkbox/index.ts",
|
||||
"./collapsible": "./src/components/collapsible/index.ts",
|
||||
"./combobox": "./src/components/combobox/index.ts",
|
||||
"./command-palette": "./src/components/command-palette/index.ts",
|
||||
"./context-menu": "./src/components/context-menu/index.ts",
|
||||
"./data-table": "./src/components/data-table/index.ts",
|
||||
"./date-picker": "./src/components/date-picker/index.ts",
|
||||
"./dialog": "./src/components/dialog/index.ts",
|
||||
"./drawer": "./src/components/drawer/index.ts",
|
||||
"./dropdown-menu": "./src/components/dropdown-menu/index.ts",
|
||||
"./form": "./src/components/form/index.ts",
|
||||
"./hover-card": "./src/components/hover-card/index.ts",
|
||||
"./image": "./src/components/image/index.ts",
|
||||
"./link": "./src/components/link/index.ts",
|
||||
"./loading-indicator": "./src/components/loading-indicator/index.ts",
|
||||
"./listbox": "./src/components/listbox/index.ts",
|
||||
"./meter": "./src/components/meter/index.ts",
|
||||
"./navigation-menu": "./src/components/navigation-menu/index.ts",
|
||||
"./number-field": "./src/components/number-field/index.ts",
|
||||
"./pagination": "./src/components/pagination/index.ts",
|
||||
"./popover": "./src/components/popover/index.ts",
|
||||
"./progress": "./src/components/progress/index.ts",
|
||||
"./radio-group": "./src/components/radio-group/index.ts",
|
||||
"./select": "./src/components/select/index.ts",
|
||||
"./separator": "./src/components/separator/index.ts",
|
||||
"./skeleton": "./src/components/skeleton/index.ts",
|
||||
"./slider": "./src/components/slider/index.ts",
|
||||
"./switch": "./src/components/switch/index.ts",
|
||||
"./tabs": "./src/components/tabs/index.ts",
|
||||
"./text-field": "./src/components/text-field/index.ts",
|
||||
"./toast": "./src/components/toast/index.ts",
|
||||
"./toggle": "./src/components/toggle/index.ts",
|
||||
"./toggle-group": "./src/components/toggle-group/index.ts",
|
||||
"./tooltip": "./src/components/tooltip/index.ts",
|
||||
"./virtual-list": "./src/components/virtual-list/index.ts",
|
||||
"./tags-input": "./src/components/tags-input/index.ts",
|
||||
"./typewriter": "./src/components/typewriter/index.ts",
|
||||
"./stagger": "./src/components/stagger/index.ts",
|
||||
"./reveal": "./src/components/reveal/index.ts",
|
||||
"./parallax": "./src/components/parallax/index.ts",
|
||||
"./wizard": "./src/components/wizard/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsdown": "^0.21.7",
|
||||
"typescript": "^6.0.2",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
137
packages/core/src/animations.css
Normal file
137
packages/core/src/animations.css
Normal file
@ -0,0 +1,137 @@
|
||||
/* PettyUI Animations — stagger, reveal, and transition CSS */
|
||||
|
||||
.petty-stagger-hidden { opacity: 0; }
|
||||
|
||||
.petty-stagger-fade-up {
|
||||
animation: pettyFadeUp 0.5s ease both;
|
||||
}
|
||||
.petty-stagger-fade-down {
|
||||
animation: pettyFadeDown 0.5s ease both;
|
||||
}
|
||||
.petty-stagger-fade-left {
|
||||
animation: pettyFadeLeft 0.5s ease both;
|
||||
}
|
||||
.petty-stagger-fade-right {
|
||||
animation: pettyFadeRight 0.5s ease both;
|
||||
}
|
||||
.petty-stagger-scale {
|
||||
animation: pettyScale 0.5s ease both;
|
||||
}
|
||||
.petty-stagger-blur {
|
||||
animation: pettyBlur 0.6s ease both;
|
||||
}
|
||||
|
||||
.petty-reveal-hidden { opacity: 0; }
|
||||
|
||||
.petty-reveal-fade-up {
|
||||
animation: pettyFadeUp 0.6s ease both;
|
||||
}
|
||||
.petty-reveal-fade-down {
|
||||
animation: pettyFadeDown 0.6s ease both;
|
||||
}
|
||||
.petty-reveal-fade-left {
|
||||
animation: pettyFadeLeft 0.6s ease both;
|
||||
}
|
||||
.petty-reveal-fade-right {
|
||||
animation: pettyFadeRight 0.6s ease both;
|
||||
}
|
||||
.petty-reveal-scale {
|
||||
animation: pettyScale 0.6s ease both;
|
||||
}
|
||||
.petty-reveal-blur {
|
||||
animation: pettyBlur 0.7s ease both;
|
||||
}
|
||||
|
||||
petty-typewriter.petty-typewriter-cursor::after {
|
||||
content: "|";
|
||||
animation: pettyCursorBlink 0.8s step-end infinite;
|
||||
}
|
||||
petty-typewriter[data-state="done"].petty-typewriter-cursor::after {
|
||||
animation: pettyCursorBlink 0.8s step-end 3;
|
||||
}
|
||||
|
||||
petty-counter { font-variant-numeric: tabular-nums; }
|
||||
|
||||
@keyframes pettyFadeUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes pettyFadeDown {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes pettyFadeLeft {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes pettyFadeRight {
|
||||
from { opacity: 0; transform: translateX(-20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes pettyScale {
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes pettyBlur {
|
||||
from { opacity: 0; filter: blur(8px); transform: translateY(10px); }
|
||||
to { opacity: 1; filter: blur(0); transform: translateY(0); }
|
||||
}
|
||||
@keyframes pettyCursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
dialog[open] {
|
||||
animation: pettyDialogIn 0.2s ease;
|
||||
}
|
||||
@keyframes pettyDialogIn {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
[popover]:popover-open {
|
||||
animation: pettyPopoverIn 0.15s ease;
|
||||
}
|
||||
@keyframes pettyPopoverIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
[data-part="toast"] {
|
||||
animation: pettySlideIn 0.3s ease;
|
||||
}
|
||||
@keyframes pettySlideIn {
|
||||
from { opacity: 0; transform: translateX(100%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
petty-loading-indicator { display: inline-flex; align-items: center; justify-content: center; position: relative; }
|
||||
petty-loading-indicator [data-part="container"] { display: flex; align-items: center; justify-content: center; position: relative; }
|
||||
|
||||
petty-loading-indicator [data-part="indicator"] {
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(54, 192, 241, 0.12);
|
||||
border-top-color: #36c0f1;
|
||||
border-right-color: rgba(54, 192, 241, 0.4);
|
||||
animation: pettyLoadSpin 1.1s linear infinite, pettyLoadPulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
petty-loading-indicator [data-part="container"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
border-bottom-color: rgba(54, 192, 241, 0.2);
|
||||
border-left-color: rgba(54, 192, 241, 0.1);
|
||||
animation: pettyLoadSpin 2.4s linear infinite reverse;
|
||||
}
|
||||
|
||||
@keyframes pettyLoadSpin {
|
||||
to { rotate: 360deg; }
|
||||
}
|
||||
|
||||
@keyframes pettyLoadPulse {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(54, 192, 241, 0.15); }
|
||||
50% { box-shadow: 0 0 20px rgba(54, 192, 241, 0.35); }
|
||||
}
|
||||
82
packages/core/src/components/accordion/accordion-item.ts
Normal file
82
packages/core/src/components/accordion/accordion-item.ts
Normal file
@ -0,0 +1,82 @@
|
||||
/**
|
||||
* PettyAccordionItem — wraps a single `<details>` element within a PettyAccordion.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* <petty-accordion-item value="section-1">
|
||||
* <details>
|
||||
* <summary>Section 1</summary>
|
||||
* <div data-part="content">Content 1</div>
|
||||
* </details>
|
||||
* </petty-accordion-item>
|
||||
* ```
|
||||
*
|
||||
* Manages data-state ("open" | "closed") and disabled behaviour on the
|
||||
* underlying `<details>` and `<summary>` elements.
|
||||
*/
|
||||
export class PettyAccordionItem extends HTMLElement {
|
||||
static observedAttributes = ["value", "disabled"];
|
||||
|
||||
/** The `<details>` element nested directly inside this item. */
|
||||
get detailsElement(): HTMLDetailsElement | null {
|
||||
return this.querySelector("details");
|
||||
}
|
||||
|
||||
/** Whether the item's details element is currently open. */
|
||||
get isOpen(): boolean {
|
||||
return this.detailsElement?.open ?? false;
|
||||
}
|
||||
|
||||
/** The value attribute, falling back to summary text content. */
|
||||
get value(): string {
|
||||
const explicit = this.getAttribute("value");
|
||||
if (explicit !== null) return explicit;
|
||||
return this.querySelector("summary")?.textContent?.trim() ?? "";
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
connectedCallback(): void {
|
||||
this.#syncState();
|
||||
this.#applyDisabled();
|
||||
const details = this.detailsElement;
|
||||
if (details) {
|
||||
details.addEventListener("toggle", this.#handleToggle);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
disconnectedCallback(): void {
|
||||
const details = this.detailsElement;
|
||||
details?.removeEventListener("toggle", this.#handleToggle);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
attributeChangedCallback(name: string): void {
|
||||
if (name === "disabled") this.#applyDisabled();
|
||||
}
|
||||
|
||||
#handleToggle = (): void => {
|
||||
this.#syncState();
|
||||
};
|
||||
|
||||
#syncState(): void {
|
||||
const details = this.detailsElement;
|
||||
const state = details?.open ? "open" : "closed";
|
||||
this.setAttribute("data-state", state);
|
||||
const summary = details?.querySelector("summary");
|
||||
if (summary) summary.setAttribute("aria-expanded", String(details?.open ?? false));
|
||||
}
|
||||
|
||||
#applyDisabled(): void {
|
||||
const disabled = this.hasAttribute("disabled");
|
||||
const summary = this.detailsElement?.querySelector("summary");
|
||||
if (!summary) return;
|
||||
if (disabled) {
|
||||
summary.setAttribute("aria-disabled", "true");
|
||||
summary.setAttribute("tabindex", "-1");
|
||||
} else {
|
||||
summary.removeAttribute("aria-disabled");
|
||||
summary.removeAttribute("tabindex");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-accordion", description: "Headless accordion built on native <details> elements with single/multiple mode", tier: 1, attributes: [{ name: "type", type: "string", default: "single", description: "Accordion mode: single closes others on open, multiple allows many open" }], parts: [{ name: "content", element: "div", description: "Content area inside each details element" }], events: [{ name: "petty-change", detail: "{ value: string[] }", description: "Fires when open items change, detail contains array of open values" }], example: `<petty-accordion><petty-accordion-item value="one"><details><summary>Section 1</summary><div data-part="content">Content 1</div></details></petty-accordion-item></petty-accordion>` };
|
||||
82
packages/core/src/components/accordion/accordion.ts
Normal file
82
packages/core/src/components/accordion/accordion.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { emit, listen } from "../../shared/helpers";
|
||||
|
||||
/**
|
||||
* PettyAccordion — headless accordion built on native `<details>` elements.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* <petty-accordion>
|
||||
* <petty-accordion-item>
|
||||
* <details>
|
||||
* <summary>Section 1</summary>
|
||||
* <div data-part="content">Content 1</div>
|
||||
* </details>
|
||||
* </petty-accordion-item>
|
||||
* <petty-accordion-item>
|
||||
* <details>
|
||||
* <summary>Section 2</summary>
|
||||
* <div data-part="content">Content 2</div>
|
||||
* </details>
|
||||
* </petty-accordion-item>
|
||||
* </petty-accordion>
|
||||
* ```
|
||||
*
|
||||
* The browser handles: open/close toggle, keyboard navigation within summary.
|
||||
* This element adds: single/multiple mode, petty-change event with open values.
|
||||
*/
|
||||
export class PettyAccordion extends HTMLElement {
|
||||
static observedAttributes = ["type"];
|
||||
|
||||
/** The accordion mode: "single" closes others on open, "multiple" allows many open. */
|
||||
get type(): "single" | "multiple" {
|
||||
const val = this.getAttribute("type");
|
||||
return val === "multiple" ? "multiple" : "single";
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
connectedCallback(): void {
|
||||
this.addEventListener("toggle", this.#handleToggle, true);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
disconnectedCallback(): void {
|
||||
this.removeEventListener("toggle", this.#handleToggle, true);
|
||||
}
|
||||
|
||||
#handleToggle = (event: Event): void => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLDetailsElement)) return;
|
||||
|
||||
if (this.type === "single" && target.open) {
|
||||
this.#closeOthers(target);
|
||||
}
|
||||
|
||||
this.#dispatchChange();
|
||||
};
|
||||
|
||||
#closeOthers(opened: HTMLDetailsElement): void {
|
||||
const items = this.querySelectorAll<HTMLDetailsElement>("details");
|
||||
items.forEach((details) => {
|
||||
if (details !== opened && details.open) {
|
||||
details.open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#dispatchChange(): void {
|
||||
const openValues = this.#collectOpenValues();
|
||||
emit(this, "change", { value: openValues });
|
||||
}
|
||||
|
||||
#collectOpenValues(): string[] {
|
||||
const items = this.querySelectorAll("petty-accordion-item");
|
||||
const values: string[] = [];
|
||||
items.forEach((item) => {
|
||||
const details = item.querySelector("details");
|
||||
if (!details?.open) return;
|
||||
const val = item.getAttribute("value") ?? item.querySelector("summary")?.textContent?.trim() ?? "";
|
||||
if (val) values.push(val);
|
||||
});
|
||||
return values;
|
||||
}
|
||||
}
|
||||
8
packages/core/src/components/accordion/index.ts
Normal file
8
packages/core/src/components/accordion/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { PettyAccordion } from "./accordion";
|
||||
import { PettyAccordionItem } from "./accordion-item";
|
||||
export { PettyAccordion, PettyAccordionItem };
|
||||
|
||||
if (!customElements.get("petty-accordion")) {
|
||||
customElements.define("petty-accordion", PettyAccordion);
|
||||
customElements.define("petty-accordion-item", PettyAccordionItem);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-alert-dialog", description: "Confirmation dialog with role=alertdialog on native dialog element", tier: 3, attributes: [], parts: [], events: [{ name: "petty-close", detail: "{ value: string }", description: "Fires when dialog closes, detail contains the return value" }], example: `<petty-alert-dialog><dialog><h2>Confirm</h2><p>Are you sure?</p><button value="cancel">Cancel</button><button value="confirm">Confirm</button></dialog></petty-alert-dialog>` };
|
||||
59
packages/core/src/components/alert-dialog/alert-dialog.ts
Normal file
59
packages/core/src/components/alert-dialog/alert-dialog.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { emit, listen } from "../../shared/helpers";
|
||||
import { uniqueId } from "../../shared/aria";
|
||||
|
||||
/** PettyAlertDialog — confirmation dialog with role="alertdialog" on native dialog. */
|
||||
export class PettyAlertDialog extends HTMLElement {
|
||||
#cleanup: (() => void) | null = null;
|
||||
|
||||
get dialogElement(): HTMLDialogElement | null {
|
||||
return this.querySelector("dialog");
|
||||
}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this.dialogElement?.open ?? false;
|
||||
}
|
||||
|
||||
/** Opens the alert dialog as a modal. */
|
||||
open(): void {
|
||||
const dlg = this.dialogElement;
|
||||
if (dlg && !dlg.open) dlg.showModal();
|
||||
}
|
||||
|
||||
/** Closes the alert dialog with an optional return value. */
|
||||
close(returnValue?: string): void {
|
||||
const dlg = this.dialogElement;
|
||||
if (dlg?.open) dlg.close(returnValue);
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
const dlg = this.dialogElement;
|
||||
if (!dlg) return;
|
||||
dlg.setAttribute("role", "alertdialog");
|
||||
dlg.setAttribute("aria-modal", "true");
|
||||
this.#linkAria(dlg);
|
||||
this.#cleanup = listen(dlg, [["close", this.#handleClose]]);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#cleanup?.();
|
||||
this.#cleanup = null;
|
||||
}
|
||||
|
||||
#handleClose = (): void => {
|
||||
const dlg = this.dialogElement;
|
||||
emit(this, "close", { value: dlg?.returnValue ?? "" });
|
||||
};
|
||||
|
||||
#linkAria(dlg: HTMLDialogElement): void {
|
||||
const heading = dlg.querySelector("h1, h2, h3, h4, h5, h6");
|
||||
if (heading) {
|
||||
if (!heading.id) heading.id = uniqueId("petty-adlg-title");
|
||||
dlg.setAttribute("aria-labelledby", heading.id);
|
||||
}
|
||||
const desc = dlg.querySelector("p");
|
||||
if (desc) {
|
||||
if (!desc.id) desc.id = uniqueId("petty-adlg-desc");
|
||||
dlg.setAttribute("aria-describedby", desc.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/alert-dialog/index.ts
Normal file
6
packages/core/src/components/alert-dialog/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyAlertDialog } from "./alert-dialog";
|
||||
export { PettyAlertDialog };
|
||||
|
||||
if (!customElements.get("petty-alert-dialog")) {
|
||||
customElements.define("petty-alert-dialog", PettyAlertDialog);
|
||||
}
|
||||
3
packages/core/src/components/alert/alert.schema.ts
Normal file
3
packages/core/src/components/alert/alert.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-alert", description: "Inline status message with variant-driven ARIA role", tier: 1, attributes: [{ name: "variant", type: "string", default: "default", description: "Alert variant: default, error, warning, success, info. Error/warning sets role=alert" }], parts: [], events: [], example: `<petty-alert variant="error"><p>Something went wrong.</p></petty-alert>` };
|
||||
23
packages/core/src/components/alert/alert.ts
Normal file
23
packages/core/src/components/alert/alert.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/** PettyAlert — inline status message with variant-driven ARIA role. */
|
||||
export class PettyAlert extends HTMLElement {
|
||||
static observedAttributes = ["variant"];
|
||||
|
||||
get variant(): string {
|
||||
return this.getAttribute("variant") ?? "default";
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.#sync();
|
||||
}
|
||||
|
||||
attributeChangedCallback(): void {
|
||||
this.#sync();
|
||||
}
|
||||
|
||||
#sync(): void {
|
||||
const v = this.variant;
|
||||
this.dataset.variant = v;
|
||||
const isUrgent = v === "error" || v === "warning";
|
||||
this.setAttribute("role", isUrgent ? "alert" : "status");
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/alert/index.ts
Normal file
6
packages/core/src/components/alert/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyAlert } from "./alert";
|
||||
export { PettyAlert };
|
||||
|
||||
if (!customElements.get("petty-alert")) {
|
||||
customElements.define("petty-alert", PettyAlert);
|
||||
}
|
||||
3
packages/core/src/components/avatar/avatar.schema.ts
Normal file
3
packages/core/src/components/avatar/avatar.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-avatar", description: "Image with automatic fallback display on load error", tier: 3, attributes: [], parts: [{ name: "fallback", element: "span", description: "Fallback content shown when image fails to load" }], events: [], example: `<petty-avatar><img src="/photo.jpg" alt="User" /><span data-part="fallback">AB</span></petty-avatar>` };
|
||||
38
packages/core/src/components/avatar/avatar.ts
Normal file
38
packages/core/src/components/avatar/avatar.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/** PettyAvatar — image with automatic fallback on load error. */
|
||||
export class PettyAvatar extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.dataset.state = "loading";
|
||||
const img = this.querySelector("img");
|
||||
if (!img) { this.#showFallback(); return; }
|
||||
img.addEventListener("load", this.#onLoad);
|
||||
img.addEventListener("error", this.#onError);
|
||||
if (img.complete && img.naturalWidth > 0) this.#onLoad();
|
||||
else if (img.complete) this.#onError();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
const img = this.querySelector("img");
|
||||
img?.removeEventListener("load", this.#onLoad);
|
||||
img?.removeEventListener("error", this.#onError);
|
||||
}
|
||||
|
||||
#onLoad = (): void => {
|
||||
this.dataset.state = "loaded";
|
||||
const img = this.querySelector("img");
|
||||
const fallback = this.querySelector("[data-part=fallback]") as HTMLElement | null;
|
||||
if (img) img.style.display = "";
|
||||
if (fallback) fallback.style.display = "none";
|
||||
};
|
||||
|
||||
#onError = (): void => {
|
||||
this.dataset.state = "error";
|
||||
this.#showFallback();
|
||||
};
|
||||
|
||||
#showFallback(): void {
|
||||
const img = this.querySelector("img");
|
||||
const fallback = this.querySelector("[data-part=fallback]") as HTMLElement | null;
|
||||
if (img) img.style.display = "none";
|
||||
if (fallback) fallback.style.display = "";
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/avatar/index.ts
Normal file
6
packages/core/src/components/avatar/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyAvatar } from "./avatar";
|
||||
export { PettyAvatar };
|
||||
|
||||
if (!customElements.get("petty-avatar")) {
|
||||
customElements.define("petty-avatar", PettyAvatar);
|
||||
}
|
||||
3
packages/core/src/components/badge/badge.schema.ts
Normal file
3
packages/core/src/components/badge/badge.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-badge", description: "Display-only status indicator with variant support", tier: 3, attributes: [{ name: "variant", type: "string", default: "default", description: "Visual variant for styling" }], parts: [{ name: "badge", element: "span", description: "The badge element itself (set automatically)" }], events: [], example: `<petty-badge variant="success">Active</petty-badge>` };
|
||||
17
packages/core/src/components/badge/badge.ts
Normal file
17
packages/core/src/components/badge/badge.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/** PettyBadge — display-only status indicator with variant support. */
|
||||
export class PettyBadge extends HTMLElement {
|
||||
static observedAttributes = ["variant"];
|
||||
|
||||
get variant(): string {
|
||||
return this.getAttribute("variant") ?? "default";
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.dataset.variant = this.variant;
|
||||
this.dataset.part = "badge";
|
||||
}
|
||||
|
||||
attributeChangedCallback(): void {
|
||||
this.dataset.variant = this.variant;
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/badge/index.ts
Normal file
6
packages/core/src/components/badge/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyBadge } from "./badge";
|
||||
export { PettyBadge };
|
||||
|
||||
if (!customElements.get("petty-badge")) {
|
||||
customElements.define("petty-badge", PettyBadge);
|
||||
}
|
||||
24
packages/core/src/components/breadcrumbs/breadcrumb-item.ts
Normal file
24
packages/core/src/components/breadcrumbs/breadcrumb-item.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/** PettyBreadcrumbItem — single breadcrumb with current-page detection. */
|
||||
export class PettyBreadcrumbItem extends HTMLElement {
|
||||
static observedAttributes = ["current"];
|
||||
|
||||
connectedCallback(): void {
|
||||
this.dataset.part = "item";
|
||||
this.#sync();
|
||||
}
|
||||
|
||||
attributeChangedCallback(): void {
|
||||
this.#sync();
|
||||
}
|
||||
|
||||
#sync(): void {
|
||||
const isCurrent = this.hasAttribute("current");
|
||||
const target = this.querySelector("a") ?? this;
|
||||
if (isCurrent) {
|
||||
target.setAttribute("aria-current", "page");
|
||||
} else {
|
||||
target.removeAttribute("aria-current");
|
||||
}
|
||||
this.dataset.state = isCurrent ? "current" : "default";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-breadcrumbs", description: "Navigation breadcrumb trail with ARIA landmarks", tier: 3, attributes: [], parts: [{ name: "item", element: "li", description: "Individual breadcrumb item (on petty-breadcrumb-item)" }], events: [], example: `<petty-breadcrumbs><ol><petty-breadcrumb-item><a href="/">Home</a></petty-breadcrumb-item><petty-breadcrumb-item current><a href="/docs">Docs</a></petty-breadcrumb-item></ol></petty-breadcrumbs>` };
|
||||
11
packages/core/src/components/breadcrumbs/breadcrumbs.ts
Normal file
11
packages/core/src/components/breadcrumbs/breadcrumbs.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/** PettyBreadcrumbs — navigation breadcrumb trail with ARIA landmarks. */
|
||||
export class PettyBreadcrumbs extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
if (!this.querySelector("nav")) {
|
||||
this.setAttribute("role", "navigation");
|
||||
}
|
||||
this.setAttribute("aria-label", this.getAttribute("aria-label") ?? "Breadcrumb");
|
||||
const list = this.querySelector("ol, ul");
|
||||
if (list) list.setAttribute("role", "list");
|
||||
}
|
||||
}
|
||||
8
packages/core/src/components/breadcrumbs/index.ts
Normal file
8
packages/core/src/components/breadcrumbs/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { PettyBreadcrumbs } from "./breadcrumbs";
|
||||
import { PettyBreadcrumbItem } from "./breadcrumb-item";
|
||||
export { PettyBreadcrumbs, PettyBreadcrumbItem };
|
||||
|
||||
if (!customElements.get("petty-breadcrumbs")) {
|
||||
customElements.define("petty-breadcrumbs", PettyBreadcrumbs);
|
||||
customElements.define("petty-breadcrumb-item", PettyBreadcrumbItem);
|
||||
}
|
||||
3
packages/core/src/components/button/button.schema.ts
Normal file
3
packages/core/src/components/button/button.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-button", description: "Headless button wrapper with loading and disabled states", tier: 3, attributes: [{ name: "disabled", type: "boolean", description: "Disables the button" }, { name: "loading", type: "boolean", description: "Shows loading state, disables interaction" }], parts: [], events: [], example: `<petty-button><button>Click me</button></petty-button>` };
|
||||
53
packages/core/src/components/button/button.ts
Normal file
53
packages/core/src/components/button/button.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* PettyButton — headless button wrapper with loading and disabled states.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* <petty-button>
|
||||
* <button>Click me</button>
|
||||
* </petty-button>
|
||||
* ```
|
||||
*/
|
||||
export class PettyButton extends HTMLElement {
|
||||
static observedAttributes = ["disabled", "loading"];
|
||||
|
||||
/** The child `<button>` element. */
|
||||
get buttonElement(): HTMLButtonElement | null {
|
||||
return this.querySelector("button");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
connectedCallback(): void {
|
||||
this.#sync();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
attributeChangedCallback(): void {
|
||||
this.#sync();
|
||||
}
|
||||
|
||||
#sync(): void {
|
||||
const btn = this.buttonElement;
|
||||
if (!btn) return;
|
||||
|
||||
const disabled = this.hasAttribute("disabled");
|
||||
const loading = this.hasAttribute("loading");
|
||||
|
||||
btn.disabled = disabled || loading;
|
||||
btn.setAttribute("aria-disabled", String(disabled || loading));
|
||||
|
||||
if (loading) {
|
||||
btn.setAttribute("aria-busy", "true");
|
||||
this.dataset.state = "loading";
|
||||
} else {
|
||||
btn.removeAttribute("aria-busy");
|
||||
this.dataset.state = disabled ? "disabled" : "idle";
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
this.dataset.disabled = "";
|
||||
} else {
|
||||
delete this.dataset.disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/button/index.ts
Normal file
6
packages/core/src/components/button/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyButton } from "./button";
|
||||
export { PettyButton };
|
||||
|
||||
if (!customElements.get("petty-button")) {
|
||||
customElements.define("petty-button", PettyButton);
|
||||
}
|
||||
3
packages/core/src/components/calendar/calendar.schema.ts
Normal file
3
packages/core/src/components/calendar/calendar.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-calendar", description: "Month grid with day selection and month navigation", tier: 3, attributes: [{ name: "value", type: "string", description: "Selected date in ISO format (YYYY-MM-DD)" }, { name: "min", type: "string", description: "Minimum selectable date in ISO format" }, { name: "max", type: "string", description: "Maximum selectable date in ISO format" }], parts: [{ name: "title", element: "div", description: "Displays the current month and year" }, { name: "prev-month", element: "button", description: "Navigate to previous month" }, { name: "next-month", element: "button", description: "Navigate to next month" }, { name: "body", element: "tbody", description: "Table body where day cells are rendered" }, { name: "day", element: "button", description: "Individual day button with data-date attribute" }], events: [{ name: "petty-change", detail: "{ value: string }", description: "Fires when a day is selected with ISO date" }], example: `<petty-calendar value="2025-01-15"><header><button data-part="prev-month"><</button><span data-part="title"></span><button data-part="next-month">></button></header><table><tbody data-part="body"></tbody></table></petty-calendar>` };
|
||||
93
packages/core/src/components/calendar/calendar.ts
Normal file
93
packages/core/src/components/calendar/calendar.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { signal, effect } from "../../signals";
|
||||
import { emit } from "../../shared/helpers";
|
||||
|
||||
/** PettyCalendar — month grid with day selection and month navigation. */
|
||||
export class PettyCalendar extends HTMLElement {
|
||||
static observedAttributes = ["value", "min", "max"];
|
||||
|
||||
readonly #month = signal(new Date().getMonth());
|
||||
readonly #year = signal(new Date().getFullYear());
|
||||
readonly #selected = signal("");
|
||||
#stopEffect: (() => void) | null = null;
|
||||
|
||||
get value(): string { return this.#selected.get(); }
|
||||
set value(v: string) { this.#selected.set(v); }
|
||||
|
||||
connectedCallback(): void {
|
||||
const init = this.getAttribute("value") ?? "";
|
||||
if (init) { this.#selected.set(init); this.#parseMonth(init); }
|
||||
this.#stopEffect = effect(() => this.#render());
|
||||
this.querySelector("[data-part=prev-month]")?.addEventListener("click", this.#onPrev);
|
||||
this.querySelector("[data-part=next-month]")?.addEventListener("click", this.#onNext);
|
||||
this.addEventListener("click", this.#onDayClick);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#stopEffect = null;
|
||||
this.querySelector("[data-part=prev-month]")?.removeEventListener("click", this.#onPrev);
|
||||
this.querySelector("[data-part=next-month]")?.removeEventListener("click", this.#onNext);
|
||||
this.removeEventListener("click", this.#onDayClick);
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, next: string | null): void {
|
||||
if (name === "value" && next) { this.#selected.set(next); this.#parseMonth(next); }
|
||||
}
|
||||
|
||||
#parseMonth(dateStr: string): void {
|
||||
const d = new Date(dateStr);
|
||||
if (!Number.isNaN(d.getTime())) { this.#month.set(d.getMonth()); this.#year.set(d.getFullYear()); }
|
||||
}
|
||||
|
||||
#onPrev = (): void => {
|
||||
if (this.#month.get() === 0) { this.#month.set(11); this.#year.set(this.#year.get() - 1); }
|
||||
else this.#month.set(this.#month.get() - 1);
|
||||
};
|
||||
|
||||
#onNext = (): void => {
|
||||
if (this.#month.get() === 11) { this.#month.set(0); this.#year.set(this.#year.get() + 1); }
|
||||
else this.#month.set(this.#month.get() + 1);
|
||||
};
|
||||
|
||||
#onDayClick = (e: Event): void => {
|
||||
const btn = (e.target as HTMLElement).closest("[data-date]");
|
||||
if (!btn || btn.hasAttribute("data-disabled")) return;
|
||||
const date = (btn as HTMLElement).dataset.date ?? "";
|
||||
this.#selected.set(date);
|
||||
emit(this, "change", { value: date });
|
||||
};
|
||||
|
||||
#createDayCell(day: number, iso: string, sel: string, today: string): HTMLTableCellElement {
|
||||
const td = document.createElement("td");
|
||||
const btn = document.createElement("button");
|
||||
btn.dataset.date = iso;
|
||||
btn.dataset.part = "day";
|
||||
btn.setAttribute("role", "gridcell");
|
||||
btn.textContent = String(day);
|
||||
if (iso === sel) btn.dataset.state = "selected";
|
||||
if (iso === today) btn.dataset.state = btn.dataset.state ? `${btn.dataset.state} today` : "today";
|
||||
td.appendChild(btn);
|
||||
return td;
|
||||
}
|
||||
|
||||
#render(): void {
|
||||
const m = this.#month.get();
|
||||
const y = this.#year.get();
|
||||
const sel = this.#selected.get();
|
||||
const title = this.querySelector("[data-part=title]");
|
||||
if (title) title.textContent = `${new Date(y, m).toLocaleString("default", { month: "long" })} ${y}`;
|
||||
const body = this.querySelector("[data-part=body]");
|
||||
if (!body) return;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const firstDay = new Date(y, m, 1).getDay();
|
||||
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
||||
body.replaceChildren();
|
||||
let row = document.createElement("tr");
|
||||
for (let i = 0; i < firstDay; i++) row.appendChild(document.createElement("td"));
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const iso = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
row.appendChild(this.#createDayCell(d, iso, sel, today));
|
||||
if ((firstDay + d) % 7 === 0) { body.appendChild(row); row = document.createElement("tr"); }
|
||||
}
|
||||
if (row.children.length > 0) body.appendChild(row);
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/calendar/index.ts
Normal file
6
packages/core/src/components/calendar/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyCalendar } from "./calendar";
|
||||
export { PettyCalendar };
|
||||
|
||||
if (!customElements.get("petty-calendar")) {
|
||||
customElements.define("petty-calendar", PettyCalendar);
|
||||
}
|
||||
6
packages/core/src/components/card/card-content.ts
Normal file
6
packages/core/src/components/card/card-content.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/** PettyCardContent — structural body section within a card. */
|
||||
export class PettyCardContent extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.dataset.part = "content";
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/card/card-footer.ts
Normal file
6
packages/core/src/components/card/card-footer.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/** PettyCardFooter — structural footer section within a card. */
|
||||
export class PettyCardFooter extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.dataset.part = "footer";
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/card/card-header.ts
Normal file
6
packages/core/src/components/card/card-header.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/** PettyCardHeader — structural header section within a card. */
|
||||
export class PettyCardHeader extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.dataset.part = "header";
|
||||
}
|
||||
}
|
||||
3
packages/core/src/components/card/card.schema.ts
Normal file
3
packages/core/src/components/card/card.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-card", description: "Structural container with optional heading-based ARIA labelling", tier: 3, attributes: [], parts: [{ name: "header", element: "div", description: "Card header section (petty-card-header)" }, { name: "content", element: "div", description: "Card body section (petty-card-content)" }, { name: "footer", element: "div", description: "Card footer section (petty-card-footer)" }], events: [], example: `<petty-card><petty-card-header><h3>Title</h3></petty-card-header><petty-card-content><p>Body text</p></petty-card-content><petty-card-footer><button>Action</button></petty-card-footer></petty-card>` };
|
||||
13
packages/core/src/components/card/card.ts
Normal file
13
packages/core/src/components/card/card.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { uniqueId } from "../../shared/aria";
|
||||
|
||||
/** PettyCard — structural container with optional heading-based labelling. */
|
||||
export class PettyCard extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
const heading = this.querySelector("h1, h2, h3, h4, h5, h6");
|
||||
if (heading) {
|
||||
this.setAttribute("role", "article");
|
||||
if (!heading.id) heading.id = uniqueId("petty-card-title");
|
||||
this.setAttribute("aria-labelledby", heading.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
packages/core/src/components/card/index.ts
Normal file
12
packages/core/src/components/card/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { PettyCard } from "./card";
|
||||
import { PettyCardHeader } from "./card-header";
|
||||
import { PettyCardContent } from "./card-content";
|
||||
import { PettyCardFooter } from "./card-footer";
|
||||
export { PettyCard, PettyCardHeader, PettyCardContent, PettyCardFooter };
|
||||
|
||||
if (!customElements.get("petty-card")) {
|
||||
customElements.define("petty-card", PettyCard);
|
||||
customElements.define("petty-card-header", PettyCardHeader);
|
||||
customElements.define("petty-card-content", PettyCardContent);
|
||||
customElements.define("petty-card-footer", PettyCardFooter);
|
||||
}
|
||||
3
packages/core/src/components/checkbox/checkbox.schema.ts
Normal file
3
packages/core/src/components/checkbox/checkbox.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-checkbox", description: "Tri-state checkbox with label wiring and change events", tier: 3, attributes: [{ name: "checked", type: "boolean", description: "Whether the checkbox is checked" }, { name: "indeterminate", type: "boolean", description: "Whether the checkbox is in indeterminate state" }, { name: "disabled", type: "boolean", description: "Disables the checkbox" }, { name: "name", type: "string", description: "Form field name" }, { name: "value", type: "string", default: "on", description: "Value submitted when checked" }], parts: [{ name: "control", element: "input", description: "The native checkbox input element" }, { name: "label", element: "label", description: "Label element auto-linked to the control" }], events: [{ name: "petty-change", detail: "{ checked: boolean, indeterminate: boolean }", description: "Fires when checked state changes" }], example: `<petty-checkbox name="agree"><input data-part="control" type="checkbox" /><label data-part="label">I agree</label></petty-checkbox>` };
|
||||
62
packages/core/src/components/checkbox/checkbox.ts
Normal file
62
packages/core/src/components/checkbox/checkbox.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { signal } from "../../signals";
|
||||
import { emit, listen, part, wireLabel } from "../../shared/helpers";
|
||||
|
||||
/** PettyCheckbox — tri-state checkbox with label wiring and change events. */
|
||||
export class PettyCheckbox extends HTMLElement {
|
||||
static observedAttributes = ["checked", "indeterminate", "disabled", "name", "value"];
|
||||
|
||||
readonly #checked = signal(false);
|
||||
readonly #indeterminate = signal(false);
|
||||
#cleanup = (): void => {};
|
||||
|
||||
get checked(): boolean { return this.#checked.get(); }
|
||||
set checked(v: boolean) { this.#checked.set(v); this.#sync(); }
|
||||
|
||||
get indeterminate(): boolean { return this.#indeterminate.get(); }
|
||||
set indeterminate(v: boolean) { this.#indeterminate.set(v); this.#sync(); }
|
||||
|
||||
connectedCallback(): void {
|
||||
const input = this.#input();
|
||||
if (!input) return;
|
||||
if (this.hasAttribute("checked")) this.#checked.set(true);
|
||||
if (this.hasAttribute("indeterminate")) this.#indeterminate.set(true);
|
||||
wireLabel(input, part(this, "label"), "petty-cb");
|
||||
this.#sync();
|
||||
this.#cleanup = listen(input, [["change", this.#handleChange]]);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#cleanup();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, next: string | null): void {
|
||||
if (name === "checked") this.#checked.set(next !== null);
|
||||
if (name === "indeterminate") this.#indeterminate.set(next !== null);
|
||||
this.#sync();
|
||||
}
|
||||
|
||||
#input(): HTMLInputElement | null {
|
||||
return part<HTMLInputElement>(this, "control");
|
||||
}
|
||||
|
||||
#sync(): void {
|
||||
const input = this.#input();
|
||||
if (!input) return;
|
||||
input.checked = this.#checked.get();
|
||||
input.indeterminate = this.#indeterminate.get();
|
||||
input.disabled = this.hasAttribute("disabled");
|
||||
if (this.hasAttribute("name")) input.name = this.getAttribute("name") ?? "";
|
||||
if (this.hasAttribute("value")) input.value = this.getAttribute("value") ?? "on";
|
||||
const state = this.#indeterminate.get() ? "indeterminate" : this.#checked.get() ? "checked" : "unchecked";
|
||||
this.dataset.state = state;
|
||||
}
|
||||
|
||||
#handleChange = (): void => {
|
||||
const input = this.#input();
|
||||
if (!input) return;
|
||||
this.#checked.set(input.checked);
|
||||
this.#indeterminate.set(false);
|
||||
this.#sync();
|
||||
emit(this, "change", { checked: input.checked, indeterminate: false });
|
||||
};
|
||||
}
|
||||
6
packages/core/src/components/checkbox/index.ts
Normal file
6
packages/core/src/components/checkbox/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyCheckbox } from "./checkbox";
|
||||
export { PettyCheckbox };
|
||||
|
||||
if (!customElements.get("petty-checkbox")) {
|
||||
customElements.define("petty-checkbox", PettyCheckbox);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-collapsible", description: "Single disclosure wrapper on native details element", tier: 1, attributes: [{ name: "disabled", type: "boolean", description: "Prevents opening the collapsible" }], parts: [], events: [{ name: "petty-toggle", detail: "{ open: boolean }", description: "Fires when the collapsible opens or closes" }], example: `<petty-collapsible><details><summary>Toggle</summary><div data-part="content">Hidden content</div></details></petty-collapsible>` };
|
||||
77
packages/core/src/components/collapsible/collapsible.ts
Normal file
77
packages/core/src/components/collapsible/collapsible.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { emit, listen } from "../../shared/helpers";
|
||||
|
||||
/** PettyCollapsible — single disclosure wrapper on native details element. */
|
||||
export class PettyCollapsible extends HTMLElement {
|
||||
static observedAttributes = ["disabled"];
|
||||
|
||||
#cleanup = (): void => {};
|
||||
|
||||
get detailsElement(): HTMLDetailsElement | null {
|
||||
return this.querySelector("details");
|
||||
}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this.detailsElement?.open ?? false;
|
||||
}
|
||||
|
||||
/** Opens the collapsible. */
|
||||
open(): void {
|
||||
const d = this.detailsElement;
|
||||
if (d && !this.hasAttribute("disabled")) d.open = true;
|
||||
}
|
||||
|
||||
/** Closes the collapsible. */
|
||||
close(): void {
|
||||
const d = this.detailsElement;
|
||||
if (d) d.open = false;
|
||||
}
|
||||
|
||||
/** Toggles the collapsible open/closed state. */
|
||||
toggle(): void {
|
||||
if (this.isOpen) this.close();
|
||||
else this.open();
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
const d = this.detailsElement;
|
||||
if (!d) return;
|
||||
this.#syncState();
|
||||
this.#applyDisabled();
|
||||
this.#cleanup = listen(d, [["toggle", this.#handleToggle]]);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#cleanup();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string): void {
|
||||
if (name === "disabled") this.#applyDisabled();
|
||||
}
|
||||
|
||||
#handleToggle = (): void => {
|
||||
this.#syncState();
|
||||
emit(this, "toggle", { open: this.isOpen });
|
||||
};
|
||||
|
||||
#syncState(): void {
|
||||
const d = this.detailsElement;
|
||||
this.dataset.state = d?.open ? "open" : "closed";
|
||||
const summary = d?.querySelector("summary");
|
||||
if (summary) summary.setAttribute("aria-expanded", String(d?.open ?? false));
|
||||
}
|
||||
|
||||
#applyDisabled(): void {
|
||||
const disabled = this.hasAttribute("disabled");
|
||||
const summary = this.detailsElement?.querySelector("summary");
|
||||
if (!summary) return;
|
||||
if (disabled) {
|
||||
summary.setAttribute("aria-disabled", "true");
|
||||
summary.addEventListener("click", this.#preventToggle);
|
||||
} else {
|
||||
summary.removeAttribute("aria-disabled");
|
||||
summary.removeEventListener("click", this.#preventToggle);
|
||||
}
|
||||
}
|
||||
|
||||
#preventToggle = (e: Event): void => { e.preventDefault(); };
|
||||
}
|
||||
6
packages/core/src/components/collapsible/index.ts
Normal file
6
packages/core/src/components/collapsible/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyCollapsible } from "./collapsible";
|
||||
export { PettyCollapsible };
|
||||
|
||||
if (!customElements.get("petty-collapsible")) {
|
||||
customElements.define("petty-collapsible", PettyCollapsible);
|
||||
}
|
||||
18
packages/core/src/components/combobox/combobox-option.ts
Normal file
18
packages/core/src/components/combobox/combobox-option.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/** PettyComboboxOption — single option within a combobox listbox. */
|
||||
export class PettyComboboxOption extends HTMLElement {
|
||||
static observedAttributes = ["value", "disabled"];
|
||||
|
||||
get value(): string { return this.getAttribute("value") ?? this.textContent?.trim() ?? ""; }
|
||||
get disabled(): boolean { return this.hasAttribute("disabled"); }
|
||||
|
||||
connectedCallback(): void {
|
||||
this.setAttribute("role", "option");
|
||||
this.setAttribute("tabindex", "-1");
|
||||
this.setAttribute("aria-selected", "false");
|
||||
if (this.disabled) this.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string): void {
|
||||
if (name === "disabled") this.setAttribute("aria-disabled", String(this.disabled));
|
||||
}
|
||||
}
|
||||
3
packages/core/src/components/combobox/combobox.schema.ts
Normal file
3
packages/core/src/components/combobox/combobox.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-combobox", description: "Searchable select with popover listbox and keyboard navigation", tier: 2, attributes: [{ name: "value", type: "string", description: "Currently selected value" }, { name: "placeholder", type: "string", description: "Placeholder text for the input" }, { name: "disabled", type: "boolean", description: "Disables the combobox" }], parts: [{ name: "input", element: "input", description: "Text input for search filtering" }, { name: "listbox", element: "div", description: "Popover container for options" }], events: [{ name: "petty-change", detail: "{ value: string }", description: "Fires when an option is selected" }], example: `<petty-combobox><input data-part="input" /><div data-part="listbox" popover><petty-combobox-option value="one">One</petty-combobox-option></div></petty-combobox>` };
|
||||
83
packages/core/src/components/combobox/combobox.ts
Normal file
83
packages/core/src/components/combobox/combobox.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { uniqueId } from "../../shared/aria";
|
||||
import { emit } from "../../shared/helpers";
|
||||
|
||||
/** PettyCombobox — searchable select with popover listbox and keyboard nav. */
|
||||
export class PettyCombobox extends HTMLElement {
|
||||
static observedAttributes = ["value", "placeholder", "disabled"];
|
||||
|
||||
#highlightIndex = -1;
|
||||
|
||||
connectedCallback(): void {
|
||||
const input = this.#input();
|
||||
const lb = this.#listbox();
|
||||
if (!input || !lb) return;
|
||||
if (!lb.id) lb.id = uniqueId("petty-combo-lb");
|
||||
input.setAttribute("aria-controls", lb.id);
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
input.setAttribute("aria-autocomplete", "list");
|
||||
input.addEventListener("input", this.#onInput);
|
||||
input.addEventListener("keydown", this.#onKeydown);
|
||||
lb.addEventListener("click", this.#onClick);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#input()?.removeEventListener("input", this.#onInput);
|
||||
this.#input()?.removeEventListener("keydown", this.#onKeydown);
|
||||
this.#listbox()?.removeEventListener("click", this.#onClick);
|
||||
}
|
||||
|
||||
#input(): HTMLInputElement | null { return this.querySelector("input[data-part=input]"); }
|
||||
#listbox(): HTMLElement | null { return this.querySelector("[data-part=listbox]"); }
|
||||
|
||||
#options(): HTMLElement[] {
|
||||
return Array.from(this.querySelectorAll("petty-combobox-option:not([disabled]):not([hidden])"));
|
||||
}
|
||||
|
||||
#onInput = (): void => {
|
||||
const input = this.#input();
|
||||
const lb = this.#listbox();
|
||||
if (!input || !lb) return;
|
||||
const query = input.value.toLowerCase();
|
||||
const all = this.querySelectorAll<HTMLElement>("petty-combobox-option");
|
||||
for (const opt of all) {
|
||||
const match = (opt.textContent ?? "").toLowerCase().includes(query);
|
||||
opt.toggleAttribute("hidden", !match);
|
||||
}
|
||||
if (!lb.matches(":popover-open")) lb.showPopover();
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
this.#highlightIndex = -1;
|
||||
};
|
||||
|
||||
#onKeydown = (e: KeyboardEvent): void => {
|
||||
const items = this.#options();
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); this.#highlight(Math.min(this.#highlightIndex + 1, items.length - 1), items); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); this.#highlight(Math.max(this.#highlightIndex - 1, 0), items); }
|
||||
else if (e.key === "Enter") { e.preventDefault(); this.#selectHighlighted(items); }
|
||||
else if (e.key === "Escape") { this.#listbox()?.hidePopover(); this.#input()?.setAttribute("aria-expanded", "false"); }
|
||||
};
|
||||
|
||||
#highlight(idx: number, items: HTMLElement[]): void {
|
||||
for (const opt of items) opt.removeAttribute("data-highlighted");
|
||||
if (items[idx]) { items[idx].setAttribute("data-highlighted", ""); this.#input()?.setAttribute("aria-activedescendant", items[idx].id || ""); }
|
||||
this.#highlightIndex = idx;
|
||||
}
|
||||
|
||||
#selectHighlighted(items: HTMLElement[]): void {
|
||||
const opt = items[this.#highlightIndex];
|
||||
if (opt) this.#select(opt);
|
||||
}
|
||||
|
||||
#onClick = (e: MouseEvent): void => {
|
||||
const opt = (e.target as HTMLElement).closest("petty-combobox-option");
|
||||
if (opt && !opt.hasAttribute("disabled")) this.#select(opt as HTMLElement);
|
||||
};
|
||||
|
||||
#select(opt: HTMLElement): void {
|
||||
const val = opt.getAttribute("value") ?? opt.textContent?.trim() ?? "";
|
||||
const input = this.#input();
|
||||
if (input) input.value = opt.textContent?.trim() ?? val;
|
||||
this.#listbox()?.hidePopover();
|
||||
input?.setAttribute("aria-expanded", "false");
|
||||
emit(this, "change", { value: val });
|
||||
}
|
||||
}
|
||||
8
packages/core/src/components/combobox/index.ts
Normal file
8
packages/core/src/components/combobox/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { PettyCombobox } from "./combobox";
|
||||
import { PettyComboboxOption } from "./combobox-option";
|
||||
export { PettyCombobox, PettyComboboxOption };
|
||||
|
||||
if (!customElements.get("petty-combobox")) {
|
||||
customElements.define("petty-combobox", PettyCombobox);
|
||||
customElements.define("petty-combobox-option", PettyComboboxOption);
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
/** PettyCommandPaletteItem — single command option within the palette. */
|
||||
export class PettyCommandPaletteItem extends HTMLElement {
|
||||
static observedAttributes = ["value", "disabled"];
|
||||
|
||||
get value(): string { return this.getAttribute("value") ?? this.textContent?.trim() ?? ""; }
|
||||
get disabled(): boolean { return this.hasAttribute("disabled"); }
|
||||
|
||||
connectedCallback(): void {
|
||||
this.setAttribute("role", "option");
|
||||
this.setAttribute("tabindex", "-1");
|
||||
if (this.disabled) this.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string): void {
|
||||
if (name === "disabled") this.setAttribute("aria-disabled", String(this.disabled));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-command-palette", description: "Search-driven command menu using native dialog, opened with Cmd+K", tier: 2, attributes: [], parts: [{ name: "search", element: "input", description: "Search input for filtering commands" }, { name: "list", element: "div", description: "Container for command items" }], events: [{ name: "petty-select", detail: "{ value: string }", description: "Fires when a command item is selected" }], example: `<petty-command-palette><dialog><input data-part="search" placeholder="Search..." /><div data-part="list"><petty-command-palette-item value="save">Save</petty-command-palette-item></div></dialog></petty-command-palette>` };
|
||||
@ -0,0 +1,79 @@
|
||||
import { emit } from "../../shared/helpers";
|
||||
|
||||
/** PettyCommandPalette — search-driven command menu using native dialog. */
|
||||
export class PettyCommandPalette extends HTMLElement {
|
||||
#highlightIndex = -1;
|
||||
|
||||
/** Opens the command palette dialog. */
|
||||
open(): void {
|
||||
const dlg = this.querySelector("dialog");
|
||||
if (dlg && !dlg.open) { dlg.showModal(); this.#search()?.focus(); }
|
||||
}
|
||||
|
||||
/** Closes the command palette dialog. */
|
||||
close(): void {
|
||||
const dlg = this.querySelector("dialog");
|
||||
if (dlg?.open) dlg.close();
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
document.addEventListener("keydown", this.#onGlobalKey);
|
||||
this.#search()?.addEventListener("input", this.#onSearch);
|
||||
this.#list()?.addEventListener("click", this.#onClick);
|
||||
this.#list()?.addEventListener("keydown", this.#onListKey);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
document.removeEventListener("keydown", this.#onGlobalKey);
|
||||
this.#search()?.removeEventListener("input", this.#onSearch);
|
||||
this.#list()?.removeEventListener("click", this.#onClick);
|
||||
this.#list()?.removeEventListener("keydown", this.#onListKey);
|
||||
}
|
||||
|
||||
#search(): HTMLInputElement | null { return this.querySelector("input[data-part=search]"); }
|
||||
#list(): HTMLElement | null { return this.querySelector("[data-part=list]"); }
|
||||
|
||||
#visibleItems(): HTMLElement[] {
|
||||
return Array.from(this.querySelectorAll("petty-command-palette-item:not([hidden]):not([disabled])"));
|
||||
}
|
||||
|
||||
#onGlobalKey = (e: KeyboardEvent): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); this.open(); }
|
||||
};
|
||||
|
||||
#onSearch = (): void => {
|
||||
const query = (this.#search()?.value ?? "").toLowerCase();
|
||||
const all = this.querySelectorAll<HTMLElement>("petty-command-palette-item");
|
||||
for (const item of all) {
|
||||
const match = (item.textContent ?? "").toLowerCase().includes(query);
|
||||
item.toggleAttribute("hidden", !match);
|
||||
}
|
||||
this.#highlightIndex = -1;
|
||||
};
|
||||
|
||||
#onListKey = (e: Event): void => {
|
||||
const ke = e as KeyboardEvent;
|
||||
const items = this.#visibleItems();
|
||||
if (ke.key === "ArrowDown") { ke.preventDefault(); this.#highlight(Math.min(this.#highlightIndex + 1, items.length - 1), items); }
|
||||
else if (ke.key === "ArrowUp") { ke.preventDefault(); this.#highlight(Math.max(this.#highlightIndex - 1, 0), items); }
|
||||
else if (ke.key === "Enter") { ke.preventDefault(); this.#selectItem(items[this.#highlightIndex]); }
|
||||
};
|
||||
|
||||
#highlight(idx: number, items: HTMLElement[]): void {
|
||||
for (const item of items) item.removeAttribute("data-highlighted");
|
||||
if (items[idx]) { items[idx].setAttribute("data-highlighted", ""); items[idx].focus(); }
|
||||
this.#highlightIndex = idx;
|
||||
}
|
||||
|
||||
#onClick = (e: Event): void => {
|
||||
const target = (e.target as HTMLElement).closest("petty-command-palette-item");
|
||||
if (target && !target.hasAttribute("disabled")) this.#selectItem(target as HTMLElement);
|
||||
};
|
||||
|
||||
#selectItem(item: HTMLElement | undefined): void {
|
||||
if (!item) return;
|
||||
const val = item.getAttribute("value") ?? item.textContent?.trim() ?? "";
|
||||
emit(this, "select", { value: val });
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
8
packages/core/src/components/command-palette/index.ts
Normal file
8
packages/core/src/components/command-palette/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { PettyCommandPalette } from "./command-palette";
|
||||
import { PettyCommandPaletteItem } from "./command-palette-item";
|
||||
export { PettyCommandPalette, PettyCommandPaletteItem };
|
||||
|
||||
if (!customElements.get("petty-command-palette")) {
|
||||
customElements.define("petty-command-palette", PettyCommandPalette);
|
||||
customElements.define("petty-command-palette-item", PettyCommandPaletteItem);
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
/** PettyContextMenuItem — single item within a context menu. */
|
||||
export class PettyContextMenuItem extends HTMLElement {
|
||||
static observedAttributes = ["disabled"];
|
||||
|
||||
connectedCallback(): void {
|
||||
this.setAttribute("role", "menuitem");
|
||||
this.setAttribute("tabindex", "-1");
|
||||
if (this.hasAttribute("disabled")) this.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string): void {
|
||||
if (name === "disabled") {
|
||||
this.setAttribute("aria-disabled", String(this.hasAttribute("disabled")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-context-menu", description: "Right-click menu using Popover API with keyboard navigation", tier: 2, attributes: [], parts: [{ name: "trigger", element: "div", description: "Element that triggers the context menu on right-click" }, { name: "content", element: "div", description: "Popover container for menu items" }], events: [{ name: "petty-select", detail: "{ value: string }", description: "Fires when a menu item is selected" }], example: `<petty-context-menu><div data-part="trigger">Right-click here</div><div data-part="content" popover><petty-context-menu-item value="copy">Copy</petty-context-menu-item></div></petty-context-menu>` };
|
||||
63
packages/core/src/components/context-menu/context-menu.ts
Normal file
63
packages/core/src/components/context-menu/context-menu.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { emit } from "../../shared/helpers";
|
||||
|
||||
/** PettyContextMenu — right-click menu using Popover API with keyboard nav. */
|
||||
export class PettyContextMenu extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
const trigger = this.querySelector("[data-part=trigger]");
|
||||
const content = this.#content();
|
||||
if (!trigger || !content) return;
|
||||
trigger.addEventListener("contextmenu", this.#onContext);
|
||||
content.addEventListener("keydown", this.#onKeydown);
|
||||
content.addEventListener("click", this.#onClick);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
const trigger = this.querySelector("[data-part=trigger]");
|
||||
const content = this.#content();
|
||||
trigger?.removeEventListener("contextmenu", this.#onContext);
|
||||
content?.removeEventListener("keydown", this.#onKeydown);
|
||||
content?.removeEventListener("click", this.#onClick);
|
||||
}
|
||||
|
||||
#content(): HTMLElement | null { return this.querySelector("[data-part=content]"); }
|
||||
|
||||
#items(): HTMLElement[] {
|
||||
return Array.from(this.querySelectorAll("petty-context-menu-item:not([disabled])"));
|
||||
}
|
||||
|
||||
#onContext = (e: Event): void => {
|
||||
e.preventDefault();
|
||||
const me = e as MouseEvent;
|
||||
const content = this.#content();
|
||||
if (!content) return;
|
||||
content.style.setProperty("--petty-ctx-x", `${me.clientX}px`);
|
||||
content.style.setProperty("--petty-ctx-y", `${me.clientY}px`);
|
||||
content.showPopover();
|
||||
this.#items()[0]?.focus();
|
||||
};
|
||||
|
||||
#onKeydown = (e: KeyboardEvent): void => {
|
||||
const items = this.#items();
|
||||
const active = document.activeElement as HTMLElement;
|
||||
const idx = items.indexOf(active);
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
items[(idx + 1) % items.length]?.focus();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
items[(idx - 1 + items.length) % items.length]?.focus();
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
active?.click();
|
||||
} else if (e.key === "Escape") {
|
||||
this.#content()?.hidePopover();
|
||||
}
|
||||
};
|
||||
|
||||
#onClick = (e: MouseEvent): void => {
|
||||
const item = (e.target as HTMLElement).closest("petty-context-menu-item");
|
||||
if (!item || item.hasAttribute("disabled")) return;
|
||||
emit(this, "select", { value: item.getAttribute("value") ?? item.textContent?.trim() ?? "" });
|
||||
this.#content()?.hidePopover();
|
||||
};
|
||||
}
|
||||
8
packages/core/src/components/context-menu/index.ts
Normal file
8
packages/core/src/components/context-menu/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { PettyContextMenu } from "./context-menu";
|
||||
import { PettyContextMenuItem } from "./context-menu-item";
|
||||
export { PettyContextMenu, PettyContextMenuItem };
|
||||
|
||||
if (!customElements.get("petty-context-menu")) {
|
||||
customElements.define("petty-context-menu", PettyContextMenu);
|
||||
customElements.define("petty-context-menu-item", PettyContextMenuItem);
|
||||
}
|
||||
74
packages/core/src/components/counter/counter.ts
Normal file
74
packages/core/src/components/counter/counter.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { emit } from "../../shared/helpers";
|
||||
|
||||
/** PettyCounter — animates a number from start to end with easing. */
|
||||
export class PettyCounter extends HTMLElement {
|
||||
static observedAttributes = ["from", "to", "duration", "delay", "decimals", "prefix", "suffix"];
|
||||
|
||||
#frame: ReturnType<typeof requestAnimationFrame> | null = null;
|
||||
#startTime = 0;
|
||||
#timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
get from(): number { return Number(this.getAttribute("from") ?? 0); }
|
||||
get to(): number { return Number(this.getAttribute("to") ?? 100); }
|
||||
get duration(): number { return Number(this.getAttribute("duration") ?? 2000); }
|
||||
get delay(): number { return Number(this.getAttribute("delay") ?? 0); }
|
||||
get decimals(): number { return Number(this.getAttribute("decimals") ?? 0); }
|
||||
get prefix(): string { return this.getAttribute("prefix") ?? ""; }
|
||||
get suffix(): string { return this.getAttribute("suffix") ?? ""; }
|
||||
|
||||
/** Starts the counter animation. */
|
||||
start(): void {
|
||||
this.#startTime = performance.now();
|
||||
this.dataset.state = "counting";
|
||||
this.#animate(this.#startTime);
|
||||
}
|
||||
|
||||
/** Resets the counter to its initial value. */
|
||||
reset(): void {
|
||||
this.#stop();
|
||||
this.#render(this.from);
|
||||
this.dataset.state = "idle";
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.#render(this.from);
|
||||
this.dataset.state = "idle";
|
||||
if (this.delay > 0) {
|
||||
this.#timer = setTimeout(() => this.start(), this.delay);
|
||||
} else {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#stop();
|
||||
}
|
||||
|
||||
#stop(): void {
|
||||
if (this.#frame) { cancelAnimationFrame(this.#frame); this.#frame = null; }
|
||||
if (this.#timer) { clearTimeout(this.#timer); this.#timer = null; }
|
||||
}
|
||||
|
||||
#easeOut(t: number): number {
|
||||
return 1 - (1 - t) ** 3;
|
||||
}
|
||||
|
||||
#animate = (now: number): void => {
|
||||
const elapsed = now - this.#startTime;
|
||||
const progress = Math.min(elapsed / this.duration, 1);
|
||||
const eased = this.#easeOut(progress);
|
||||
const current = this.from + (this.to - this.from) * eased;
|
||||
this.#render(current);
|
||||
if (progress < 1) {
|
||||
this.#frame = requestAnimationFrame(this.#animate);
|
||||
} else {
|
||||
this.dataset.state = "done";
|
||||
emit(this, "complete", {});
|
||||
}
|
||||
};
|
||||
|
||||
#render(value: number): void {
|
||||
const formatted = value.toFixed(this.decimals);
|
||||
this.textContent = `${this.prefix}${formatted}${this.suffix}`;
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/counter/index.ts
Normal file
6
packages/core/src/components/counter/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyCounter } from "./counter";
|
||||
export { PettyCounter };
|
||||
|
||||
if (!customElements.get("petty-counter")) {
|
||||
customElements.define("petty-counter", PettyCounter);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-data-table", description: "Sortable table with click-to-sort column headers", tier: 3, attributes: [], parts: [{ name: "body", element: "tbody", description: "Table body containing sortable rows" }], events: [{ name: "petty-sort", detail: "{ column: string, direction: string }", description: "Fires when a column header is clicked, with column name and sort direction" }], example: `<petty-data-table><table><thead><tr><th data-sort="name">Name</th><th data-sort="age">Age</th></tr></thead><tbody data-part="body"><tr><td>Alice</td><td>30</td></tr></tbody></table></petty-data-table>` };
|
||||
54
packages/core/src/components/data-table/data-table.ts
Normal file
54
packages/core/src/components/data-table/data-table.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { emit } from "../../shared/helpers";
|
||||
|
||||
/** PettyDataTable — sortable table with click-to-sort column headers. */
|
||||
export class PettyDataTable extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
const headers = this.querySelectorAll<HTMLElement>("th[data-sort]");
|
||||
for (const th of headers) {
|
||||
th.setAttribute("role", "columnheader");
|
||||
th.setAttribute("aria-sort", "none");
|
||||
th.style.cursor = "pointer";
|
||||
th.addEventListener("click", this.#onHeaderClick);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
const headers = this.querySelectorAll<HTMLElement>("th[data-sort]");
|
||||
for (const th of headers) th.removeEventListener("click", this.#onHeaderClick);
|
||||
}
|
||||
|
||||
#onHeaderClick = (e: Event): void => {
|
||||
const th = e.currentTarget as HTMLElement;
|
||||
const column = th.dataset.sort ?? "";
|
||||
const current = th.getAttribute("aria-sort") ?? "none";
|
||||
const next = current === "ascending" ? "descending" : "ascending";
|
||||
this.#clearSorts();
|
||||
th.setAttribute("aria-sort", next);
|
||||
th.dataset.state = next;
|
||||
this.#sortBy(th, next === "ascending" ? 1 : -1);
|
||||
emit(this, "sort", { column, direction: next });
|
||||
};
|
||||
|
||||
#clearSorts(): void {
|
||||
const headers = this.querySelectorAll<HTMLElement>("th[data-sort]");
|
||||
for (const th of headers) { th.setAttribute("aria-sort", "none"); th.dataset.state = "none"; }
|
||||
}
|
||||
|
||||
#sortBy(th: HTMLElement, dir: number): void {
|
||||
const tbody = this.querySelector("tbody[data-part=body]") ?? this.querySelector("tbody");
|
||||
if (!tbody) return;
|
||||
const headerRow = th.parentElement;
|
||||
if (!headerRow) return;
|
||||
const colIdx = Array.from(headerRow.children).indexOf(th);
|
||||
const rows = Array.from(tbody.querySelectorAll("tr"));
|
||||
rows.sort((a, b) => {
|
||||
const aText = a.children[colIdx]?.textContent?.trim() ?? "";
|
||||
const bText = b.children[colIdx]?.textContent?.trim() ?? "";
|
||||
const aNum = Number(aText);
|
||||
const bNum = Number(bText);
|
||||
if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) return (aNum - bNum) * dir;
|
||||
return aText.localeCompare(bText) * dir;
|
||||
});
|
||||
for (const row of rows) tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/data-table/index.ts
Normal file
6
packages/core/src/components/data-table/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyDataTable } from "./data-table";
|
||||
export { PettyDataTable };
|
||||
|
||||
if (!customElements.get("petty-data-table")) {
|
||||
customElements.define("petty-data-table", PettyDataTable);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-date-picker", description: "Date input with calendar popover integration", tier: 2, attributes: [{ name: "value", type: "string", description: "Selected date in ISO format" }, { name: "min", type: "string", description: "Minimum selectable date" }, { name: "max", type: "string", description: "Maximum selectable date" }, { name: "disabled", type: "boolean", description: "Disables the date picker" }], parts: [{ name: "input", element: "input", description: "Text input for date entry" }, { name: "trigger", element: "button", description: "Button that opens the calendar popover" }, { name: "calendar", element: "div", description: "Popover container for the calendar" }], events: [{ name: "petty-change", detail: "{ value: string }", description: "Fires when a date is selected from input or calendar" }], example: `<petty-date-picker><input data-part="input" type="date" /><button data-part="trigger">Pick</button><div data-part="calendar" popover><petty-calendar></petty-calendar></div></petty-date-picker>` };
|
||||
55
packages/core/src/components/date-picker/date-picker.ts
Normal file
55
packages/core/src/components/date-picker/date-picker.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { emit } from "../../shared/helpers";
|
||||
|
||||
/** PettyDatePicker — date input with calendar popover integration. */
|
||||
export class PettyDatePicker extends HTMLElement {
|
||||
static observedAttributes = ["value", "min", "max", "disabled"];
|
||||
|
||||
get value(): string { return this.#input()?.value ?? ""; }
|
||||
set value(v: string) { const input = this.#input(); if (input) input.value = v; }
|
||||
|
||||
connectedCallback(): void {
|
||||
const trigger = this.querySelector("[data-part=trigger]");
|
||||
const input = this.#input();
|
||||
if (trigger) {
|
||||
trigger.setAttribute("aria-haspopup", "dialog");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
input?.addEventListener("change", this.#onInputChange);
|
||||
this.addEventListener("petty-change", this.#onCalendarSelect);
|
||||
|
||||
const pop = this.querySelector("[data-part=calendar]");
|
||||
if (pop) pop.addEventListener("toggle", this.#onToggle as EventListener);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#input()?.removeEventListener("change", this.#onInputChange);
|
||||
this.removeEventListener("petty-change", this.#onCalendarSelect);
|
||||
const pop = this.querySelector("[data-part=calendar]");
|
||||
pop?.removeEventListener("toggle", this.#onToggle as EventListener);
|
||||
}
|
||||
|
||||
#input(): HTMLInputElement | null { return this.querySelector("input[data-part=input]"); }
|
||||
|
||||
#onToggle = (e: ToggleEvent): void => {
|
||||
const trigger = this.querySelector("[data-part=trigger]");
|
||||
if (trigger) trigger.setAttribute("aria-expanded", String(e.newState === "open"));
|
||||
};
|
||||
|
||||
#onInputChange = (): void => {
|
||||
const val = this.#input()?.value ?? "";
|
||||
emit(this, "change", { value: val, source: "input" });
|
||||
};
|
||||
|
||||
#onCalendarSelect = (e: Event): void => {
|
||||
const ce = e as CustomEvent;
|
||||
if (ce.detail?.source === "input") return;
|
||||
const val = ce.detail?.value;
|
||||
if (!val) return;
|
||||
e.stopPropagation();
|
||||
const input = this.#input();
|
||||
if (input) input.value = val;
|
||||
const cal = this.querySelector("[data-part=calendar]");
|
||||
if (cal instanceof HTMLElement && "hidePopover" in cal) cal.hidePopover();
|
||||
emit(this, "change", { value: val });
|
||||
};
|
||||
}
|
||||
6
packages/core/src/components/date-picker/index.ts
Normal file
6
packages/core/src/components/date-picker/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyDatePicker } from "./date-picker";
|
||||
export { PettyDatePicker };
|
||||
|
||||
if (!customElements.get("petty-date-picker")) {
|
||||
customElements.define("petty-date-picker", PettyDatePicker);
|
||||
}
|
||||
3
packages/core/src/components/dialog/dialog.schema.ts
Normal file
3
packages/core/src/components/dialog/dialog.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-dialog", description: "Headless dialog built on native <dialog> with ARIA linking and close events", tier: 1, attributes: [], parts: [], events: [{ name: "petty-close", detail: "{ value: string }", description: "Fires when the dialog closes, detail contains return value" }], example: `<petty-dialog><button commandfor="my-dlg" command="show-modal">Open</button><dialog id="my-dlg"><h2>Title</h2><p>Content</p><button commandfor="my-dlg" command="close">Close</button></dialog></petty-dialog>` };
|
||||
73
packages/core/src/components/dialog/dialog.ts
Normal file
73
packages/core/src/components/dialog/dialog.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { emit, listen } from "../../shared/helpers";
|
||||
import { uniqueId } from "../../shared/aria";
|
||||
|
||||
/**
|
||||
* PettyDialog — headless dialog custom element built on native `<dialog>`.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* <petty-dialog>
|
||||
* <button commandfor="my-dlg" command="show-modal">Open</button>
|
||||
* <dialog id="my-dlg">
|
||||
* <h2>Title</h2>
|
||||
* <p>Content</p>
|
||||
* <button commandfor="my-dlg" command="close">Close</button>
|
||||
* </dialog>
|
||||
* </petty-dialog>
|
||||
* ```
|
||||
*
|
||||
* The browser handles: modal behavior, backdrop, focus trap, Escape, Invoker Commands.
|
||||
* This element adds: ARIA linking, close event with return value, programmatic API.
|
||||
*/
|
||||
export class PettyDialog extends HTMLElement {
|
||||
#cleanup: (() => void) | null = null;
|
||||
|
||||
/** Finds the first `<dialog>` child element. */
|
||||
get dialogElement(): HTMLDialogElement | null {
|
||||
return this.querySelector("dialog");
|
||||
}
|
||||
|
||||
/** Whether the dialog is currently open. */
|
||||
get isOpen(): boolean {
|
||||
return this.dialogElement?.open ?? false;
|
||||
}
|
||||
|
||||
/** Opens the dialog as a modal. */
|
||||
open(): void {
|
||||
const dlg = this.dialogElement;
|
||||
if (dlg && !dlg.open) dlg.showModal();
|
||||
}
|
||||
|
||||
/** Closes the dialog with an optional return value. */
|
||||
close(returnValue?: string): void {
|
||||
const dlg = this.dialogElement;
|
||||
if (dlg?.open) dlg.close(returnValue);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
connectedCallback(): void {
|
||||
const dlg = this.dialogElement;
|
||||
if (!dlg) return;
|
||||
|
||||
this.#linkAria(dlg);
|
||||
this.#cleanup = listen(dlg, [["close", this.#handleClose]]);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
disconnectedCallback(): void {
|
||||
this.#cleanup?.();
|
||||
this.#cleanup = null;
|
||||
}
|
||||
|
||||
#handleClose = (): void => {
|
||||
const dlg = this.dialogElement;
|
||||
emit(this, "close", { value: dlg?.returnValue ?? "" });
|
||||
};
|
||||
|
||||
#linkAria(dlg: HTMLDialogElement): void {
|
||||
const heading = dlg.querySelector("h1, h2, h3, h4, h5, h6");
|
||||
if (!heading) return;
|
||||
if (!heading.id) heading.id = uniqueId("petty-dlg-title");
|
||||
dlg.setAttribute("aria-labelledby", heading.id);
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/dialog/index.ts
Normal file
6
packages/core/src/components/dialog/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyDialog } from "./dialog";
|
||||
export { PettyDialog };
|
||||
|
||||
if (!customElements.get("petty-dialog")) {
|
||||
customElements.define("petty-dialog", PettyDialog);
|
||||
}
|
||||
3
packages/core/src/components/drawer/drawer.schema.ts
Normal file
3
packages/core/src/components/drawer/drawer.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-drawer", description: "Slide-in panel built on native dialog with side positioning", tier: 3, attributes: [{ name: "side", type: "string", default: "right", description: "Side the drawer slides from: left, right, top, bottom" }], parts: [], events: [{ name: "petty-close", detail: "{ value: string }", description: "Fires when the drawer closes, detail contains return value" }], example: `<petty-drawer side="left"><button commandfor="my-drawer" command="show-modal">Open</button><dialog id="my-drawer"><h2>Menu</h2><button commandfor="my-drawer" command="close">Close</button></dialog></petty-drawer>` };
|
||||
64
packages/core/src/components/drawer/drawer.ts
Normal file
64
packages/core/src/components/drawer/drawer.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { emit, listen } from "../../shared/helpers";
|
||||
import { uniqueId } from "../../shared/aria";
|
||||
|
||||
/** PettyDrawer — slide-in panel built on native dialog with side positioning. */
|
||||
export class PettyDrawer extends HTMLElement {
|
||||
static observedAttributes = ["side"];
|
||||
#cleanup: (() => void) | null = null;
|
||||
|
||||
get dialogElement(): HTMLDialogElement | null {
|
||||
return this.querySelector("dialog");
|
||||
}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this.dialogElement?.open ?? false;
|
||||
}
|
||||
|
||||
/** Opens the drawer as a modal. */
|
||||
open(): void {
|
||||
const dlg = this.dialogElement;
|
||||
if (dlg && !dlg.open) dlg.showModal();
|
||||
}
|
||||
|
||||
/** Closes the drawer with an optional return value. */
|
||||
close(returnValue?: string): void {
|
||||
const dlg = this.dialogElement;
|
||||
if (dlg?.open) dlg.close(returnValue);
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
const dlg = this.dialogElement;
|
||||
if (!dlg) return;
|
||||
this.#syncSide();
|
||||
this.#linkAria(dlg);
|
||||
this.#cleanup = listen(dlg, [["close", this.#handleClose]]);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#cleanup?.();
|
||||
this.#cleanup = null;
|
||||
}
|
||||
|
||||
attributeChangedCallback(): void {
|
||||
this.#syncSide();
|
||||
}
|
||||
|
||||
#syncSide(): void {
|
||||
const side = this.getAttribute("side") ?? "right";
|
||||
this.dataset.side = side;
|
||||
const dlg = this.dialogElement;
|
||||
if (dlg) dlg.dataset.side = side;
|
||||
}
|
||||
|
||||
#handleClose = (): void => {
|
||||
const dlg = this.dialogElement;
|
||||
emit(this, "close", { value: dlg?.returnValue ?? "" });
|
||||
};
|
||||
|
||||
#linkAria(dlg: HTMLDialogElement): void {
|
||||
const heading = dlg.querySelector("h1, h2, h3, h4, h5, h6");
|
||||
if (!heading) return;
|
||||
if (!heading.id) heading.id = uniqueId("petty-drawer-title");
|
||||
dlg.setAttribute("aria-labelledby", heading.id);
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/drawer/index.ts
Normal file
6
packages/core/src/components/drawer/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyDrawer } from "./drawer";
|
||||
export { PettyDrawer };
|
||||
|
||||
if (!customElements.get("petty-drawer")) {
|
||||
customElements.define("petty-drawer", PettyDrawer);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-dropdown-menu", description: "Action menu built on the Popover API with keyboard navigation", tier: 2, attributes: [], parts: [{ name: "trigger", element: "button", description: "Button that opens the dropdown menu" }, { name: "content", element: "div", description: "Popover container for menu items" }], events: [{ name: "petty-select", detail: "{ value: string }", description: "Fires when a menu item is selected" }], example: `<petty-dropdown-menu><button data-part="trigger" popovertarget="menu">Actions</button><div id="menu" data-part="content" popover><petty-menu-item>Edit</petty-menu-item><petty-menu-item>Delete</petty-menu-item></div></petty-dropdown-menu>` };
|
||||
79
packages/core/src/components/dropdown-menu/dropdown-menu.ts
Normal file
79
packages/core/src/components/dropdown-menu/dropdown-menu.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { emit } from "../../shared/helpers";
|
||||
|
||||
/** PettyDropdownMenu — action menu built on the Popover API. */
|
||||
export class PettyDropdownMenu extends HTMLElement {
|
||||
/** The trigger button element. */
|
||||
get triggerElement(): HTMLElement | null {
|
||||
return this.querySelector("[data-part=trigger]");
|
||||
}
|
||||
|
||||
/** The menu content element. */
|
||||
get contentElement(): HTMLElement | null {
|
||||
return this.querySelector("[data-part=content]");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
connectedCallback(): void {
|
||||
const trigger = this.triggerElement;
|
||||
const content = this.contentElement;
|
||||
if (!trigger || !content) return;
|
||||
|
||||
trigger.setAttribute("aria-haspopup", "menu");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
if (content.id) trigger.setAttribute("aria-controls", content.id);
|
||||
|
||||
content.addEventListener("toggle", this.#handleToggle as EventListener);
|
||||
content.addEventListener("keydown", this.#handleKeyDown);
|
||||
content.addEventListener("click", this.#handleClick);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
disconnectedCallback(): void {
|
||||
const content = this.contentElement;
|
||||
content?.removeEventListener("toggle", this.#handleToggle as EventListener);
|
||||
content?.removeEventListener("keydown", this.#handleKeyDown);
|
||||
content?.removeEventListener("click", this.#handleClick);
|
||||
}
|
||||
|
||||
#handleToggle = (e: ToggleEvent): void => {
|
||||
const open = e.newState === "open";
|
||||
this.triggerElement?.setAttribute("aria-expanded", String(open));
|
||||
if (open) this.#focusFirst();
|
||||
};
|
||||
|
||||
#handleKeyDown = (e: KeyboardEvent): void => {
|
||||
const items = this.#getItems();
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLElement)) return;
|
||||
const idx = items.indexOf(active);
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const next = idx < items.length - 1 ? idx + 1 : 0;
|
||||
items[next]?.focus();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const prev = idx > 0 ? idx - 1 : items.length - 1;
|
||||
items[prev]?.focus();
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
active.click();
|
||||
}
|
||||
};
|
||||
|
||||
#handleClick = (e: MouseEvent): void => {
|
||||
const item = (e.target as HTMLElement).closest("petty-menu-item");
|
||||
if (!item || item.hasAttribute("disabled")) return;
|
||||
emit(this, "select", { value: item.textContent?.trim() ?? "" });
|
||||
this.contentElement?.hidePopover();
|
||||
};
|
||||
|
||||
#focusFirst(): void {
|
||||
const first = this.#getItems()[0];
|
||||
if (first) first.focus();
|
||||
}
|
||||
|
||||
#getItems(): HTMLElement[] {
|
||||
return Array.from(this.querySelectorAll("petty-menu-item:not([disabled])"));
|
||||
}
|
||||
}
|
||||
8
packages/core/src/components/dropdown-menu/index.ts
Normal file
8
packages/core/src/components/dropdown-menu/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { PettyDropdownMenu } from "./dropdown-menu";
|
||||
import { PettyMenuItem } from "./menu-item";
|
||||
export { PettyDropdownMenu, PettyMenuItem };
|
||||
|
||||
if (!customElements.get("petty-dropdown-menu")) {
|
||||
customElements.define("petty-dropdown-menu", PettyDropdownMenu);
|
||||
customElements.define("petty-menu-item", PettyMenuItem);
|
||||
}
|
||||
17
packages/core/src/components/dropdown-menu/menu-item.ts
Normal file
17
packages/core/src/components/dropdown-menu/menu-item.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/** PettyMenuItem — a single actionable item within a dropdown menu. */
|
||||
export class PettyMenuItem extends HTMLElement {
|
||||
/** @internal */
|
||||
connectedCallback(): void {
|
||||
this.setAttribute("role", "menuitem");
|
||||
this.setAttribute("tabindex", "-1");
|
||||
}
|
||||
|
||||
static get observedAttributes(): string[] { return ["disabled"]; }
|
||||
|
||||
/** @internal */
|
||||
attributeChangedCallback(name: string): void {
|
||||
if (name === "disabled") {
|
||||
this.setAttribute("aria-disabled", this.hasAttribute("disabled") ? "true" : "false");
|
||||
}
|
||||
}
|
||||
}
|
||||
56
packages/core/src/components/form/form-field.ts
Normal file
56
packages/core/src/components/form/form-field.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { uniqueId } from "../../shared/aria";
|
||||
|
||||
/**
|
||||
* PettyFormField — form field wrapper with label, control, and error linking.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* <petty-form-field name="email">
|
||||
* <label data-part="label">Email</label>
|
||||
* <input data-part="control" type="email" />
|
||||
* <span data-part="error"></span>
|
||||
* </petty-form-field>
|
||||
* ```
|
||||
*
|
||||
* On connect, generates stable IDs and wires up aria-describedby, htmlFor,
|
||||
* and the name attribute on the control. Call setError/clearError to manage
|
||||
* inline validation state — or let petty-form do it automatically.
|
||||
*/
|
||||
export class PettyFormField extends HTMLElement {
|
||||
static get observedAttributes(): string[] { return ["name"]; }
|
||||
|
||||
/** @internal */
|
||||
connectedCallback(): void {
|
||||
const name = this.getAttribute("name") ?? "";
|
||||
const controlId = uniqueId(`petty-field-${name}`);
|
||||
const errorId = `${controlId}-error`;
|
||||
|
||||
const label = this.querySelector("[data-part=label]");
|
||||
const control = this.querySelector("[data-part=control]");
|
||||
const error = this.querySelector("[data-part=error]");
|
||||
|
||||
if (control) {
|
||||
control.id = controlId;
|
||||
if (!control.getAttribute("name")) control.setAttribute("name", name);
|
||||
control.setAttribute("aria-describedby", errorId);
|
||||
}
|
||||
if (label && control) (label as HTMLLabelElement).htmlFor = controlId;
|
||||
if (error) error.id = errorId;
|
||||
}
|
||||
|
||||
/** Display an error message on this field. */
|
||||
setError(message: string): void {
|
||||
const error = this.querySelector("[data-part=error]");
|
||||
const control = this.querySelector("[data-part=control]");
|
||||
if (error) error.textContent = message;
|
||||
if (control) control.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
|
||||
/** Clear the error message on this field. */
|
||||
clearError(): void {
|
||||
const error = this.querySelector("[data-part=error]");
|
||||
const control = this.querySelector("[data-part=control]");
|
||||
if (error) error.textContent = "";
|
||||
if (control) control.removeAttribute("aria-invalid");
|
||||
}
|
||||
}
|
||||
3
packages/core/src/components/form/form.schema.ts
Normal file
3
packages/core/src/components/form/form.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-form", description: "Form wrapper with Zod validation and accessible error display", tier: 3, attributes: [], parts: [], events: [{ name: "petty-submit", detail: "{ data: Record<string, FormDataEntryValue> }", description: "Fires on valid form submission with form data" }, { name: "petty-invalid", detail: "{ errors: Array<{ path: Array<string | number>, message: string }> }", description: "Fires when validation fails with error details" }], example: `<petty-form><form><petty-form-field name="email"><label data-part="label">Email</label><input data-part="control" type="email" /><span data-part="error"></span></petty-form-field><button type="submit">Submit</button></form></petty-form>` };
|
||||
93
packages/core/src/components/form/form.ts
Normal file
93
packages/core/src/components/form/form.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { emit } from "../../shared/helpers";
|
||||
|
||||
interface SchemaLike {
|
||||
safeParse: (data: unknown) => {
|
||||
success: boolean;
|
||||
error?: { issues: Array<{ path: Array<string | number>; message: string }> };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PettyForm — form wrapper with Zod validation and accessible error display.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* <petty-form>
|
||||
* <form>
|
||||
* <petty-form-field name="email">
|
||||
* <label data-part="label">Email</label>
|
||||
* <input data-part="control" type="email" />
|
||||
* <span data-part="error"></span>
|
||||
* </petty-form-field>
|
||||
* <button type="submit">Submit</button>
|
||||
* </form>
|
||||
* </petty-form>
|
||||
* ```
|
||||
*
|
||||
* Set a Zod schema via `el.setSchema(z.object({...}))`. Listens for
|
||||
* form submit, runs validation, and distributes error messages to
|
||||
* petty-form-field elements via data-part selectors. Dispatches
|
||||
* "petty-submit" on success or "petty-invalid" on failure.
|
||||
*/
|
||||
export class PettyForm extends HTMLElement {
|
||||
#schema: SchemaLike | null = null;
|
||||
|
||||
/** Set a Zod schema for validation. */
|
||||
setSchema(schema: SchemaLike | null): void {
|
||||
this.#schema = schema;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
connectedCallback(): void {
|
||||
const form = this.querySelector("form");
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", this.#handleSubmit);
|
||||
form.setAttribute("novalidate", "");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
disconnectedCallback(): void {
|
||||
const form = this.querySelector("form");
|
||||
form?.removeEventListener("submit", this.#handleSubmit);
|
||||
}
|
||||
|
||||
#handleSubmit = (e: Event): void => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
this.#clearErrors();
|
||||
|
||||
if (this.#schema) {
|
||||
const result = this.#schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
const issues = result.error?.issues ?? [];
|
||||
this.#showErrors(issues);
|
||||
emit(this, "invalid", { errors: issues });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
emit(this, "submit", { data });
|
||||
};
|
||||
|
||||
#clearErrors(): void {
|
||||
for (const el of Array.from(this.querySelectorAll("[data-part=error]"))) {
|
||||
el.textContent = "";
|
||||
}
|
||||
for (const el of Array.from(this.querySelectorAll("[data-part=control]"))) {
|
||||
el.removeAttribute("aria-invalid");
|
||||
}
|
||||
}
|
||||
|
||||
#showErrors(issues: Array<{ path: Array<string | number>; message: string }>): void {
|
||||
for (const issue of issues) {
|
||||
const name = String(issue.path[0] ?? "");
|
||||
const field = this.querySelector(`petty-form-field[name="${name}"]`);
|
||||
if (!field) continue;
|
||||
const errorEl = field.querySelector("[data-part=error]");
|
||||
if (errorEl) errorEl.textContent = issue.message;
|
||||
const control = field.querySelector("[data-part=control]");
|
||||
if (control) control.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/core/src/components/form/index.ts
Normal file
8
packages/core/src/components/form/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { PettyForm } from "./form";
|
||||
import { PettyFormField } from "./form-field";
|
||||
export { PettyForm, PettyFormField };
|
||||
|
||||
if (!customElements.get("petty-form")) {
|
||||
customElements.define("petty-form", PettyForm);
|
||||
customElements.define("petty-form-field", PettyFormField);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-hover-card", description: "Rich hover preview using Popover API with configurable open/close delays", tier: 2, attributes: [{ name: "open-delay", type: "number", default: "300", description: "Delay in ms before showing the card" }, { name: "close-delay", type: "number", default: "200", description: "Delay in ms before hiding the card" }], parts: [{ name: "trigger", element: "a", description: "Element that triggers the hover card on mouse enter/focus" }, { name: "content", element: "div", description: "Popover container for the card content" }], events: [{ name: "petty-toggle", detail: "{ open: boolean }", description: "Fires when the hover card opens or closes" }], example: `<petty-hover-card><a data-part="trigger" href="/user">@user</a><div data-part="content" popover>User details here</div></petty-hover-card>` };
|
||||
62
packages/core/src/components/hover-card/hover-card.ts
Normal file
62
packages/core/src/components/hover-card/hover-card.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { uniqueId } from "../../shared/aria";
|
||||
import { emit, listen, part } from "../../shared/helpers";
|
||||
|
||||
/** PettyHoverCard — rich hover preview using Popover API with open/close delays. */
|
||||
export class PettyHoverCard extends HTMLElement {
|
||||
static observedAttributes = ["open-delay", "close-delay"];
|
||||
|
||||
#showTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
#hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
#cleanupTrigger: (() => void) | null = null;
|
||||
#cleanupContent: (() => void) | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
const trigger = part<HTMLElement>(this, "trigger");
|
||||
const content = part<HTMLElement>(this, "content");
|
||||
if (!trigger || !content) return;
|
||||
if (!content.id) content.id = uniqueId("petty-hc");
|
||||
trigger.setAttribute("aria-describedby", content.id);
|
||||
this.#cleanupTrigger = listen(trigger, [
|
||||
["mouseenter", this.#onEnter],
|
||||
["mouseleave", this.#onLeave],
|
||||
["focus", this.#onEnter],
|
||||
["blur", this.#onLeave],
|
||||
]);
|
||||
this.#cleanupContent = listen(content, [
|
||||
["mouseenter", this.#onContentEnter],
|
||||
["mouseleave", this.#onLeave],
|
||||
]);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#clearTimers();
|
||||
this.#cleanupTrigger?.();
|
||||
this.#cleanupTrigger = null;
|
||||
this.#cleanupContent?.();
|
||||
this.#cleanupContent = null;
|
||||
}
|
||||
|
||||
#openDelay(): number { return Number(this.getAttribute("open-delay") ?? 300); }
|
||||
#closeDelay(): number { return Number(this.getAttribute("close-delay") ?? 200); }
|
||||
|
||||
#clearTimers(): void {
|
||||
if (this.#showTimer) { clearTimeout(this.#showTimer); this.#showTimer = null; }
|
||||
if (this.#hideTimer) { clearTimeout(this.#hideTimer); this.#hideTimer = null; }
|
||||
}
|
||||
|
||||
#show(): void {
|
||||
const content = part<HTMLElement>(this, "content");
|
||||
if (content && !content.matches(":popover-open")) content.showPopover();
|
||||
emit(this, "toggle", { open: true });
|
||||
}
|
||||
|
||||
#hide(): void {
|
||||
const content = part<HTMLElement>(this, "content");
|
||||
if (content && content.matches(":popover-open")) content.hidePopover();
|
||||
emit(this, "toggle", { open: false });
|
||||
}
|
||||
|
||||
#onEnter = (): void => { this.#clearTimers(); this.#showTimer = setTimeout(() => this.#show(), this.#openDelay()); };
|
||||
#onLeave = (): void => { this.#clearTimers(); this.#hideTimer = setTimeout(() => this.#hide(), this.#closeDelay()); };
|
||||
#onContentEnter = (): void => { this.#clearTimers(); };
|
||||
}
|
||||
6
packages/core/src/components/hover-card/index.ts
Normal file
6
packages/core/src/components/hover-card/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyHoverCard } from "./hover-card";
|
||||
export { PettyHoverCard };
|
||||
|
||||
if (!customElements.get("petty-hover-card")) {
|
||||
customElements.define("petty-hover-card", PettyHoverCard);
|
||||
}
|
||||
3
packages/core/src/components/image/image.schema.ts
Normal file
3
packages/core/src/components/image/image.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-image", description: "Image element with fallback display on load failure", tier: 3, attributes: [], parts: [{ name: "fallback", element: "div", description: "Fallback content shown when image fails to load" }], events: [], example: `<petty-image><img src="/photo.jpg" alt="Photo" /><div data-part="fallback">Image unavailable</div></petty-image>` };
|
||||
38
packages/core/src/components/image/image.ts
Normal file
38
packages/core/src/components/image/image.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/** PettyImage — image element with fallback display on load failure. */
|
||||
export class PettyImage extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.dataset.state = "loading";
|
||||
const img = this.querySelector("img");
|
||||
if (!img) { this.#showFallback(); return; }
|
||||
img.addEventListener("load", this.#onLoad);
|
||||
img.addEventListener("error", this.#onError);
|
||||
if (img.complete && img.naturalWidth > 0) this.#onLoad();
|
||||
else if (img.complete) this.#onError();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
const img = this.querySelector("img");
|
||||
img?.removeEventListener("load", this.#onLoad);
|
||||
img?.removeEventListener("error", this.#onError);
|
||||
}
|
||||
|
||||
#onLoad = (): void => {
|
||||
this.dataset.state = "loaded";
|
||||
const img = this.querySelector("img");
|
||||
const fallback = this.querySelector("[data-part=fallback]") as HTMLElement | null;
|
||||
if (img) img.style.display = "";
|
||||
if (fallback) fallback.style.display = "none";
|
||||
};
|
||||
|
||||
#onError = (): void => {
|
||||
this.dataset.state = "error";
|
||||
this.#showFallback();
|
||||
};
|
||||
|
||||
#showFallback(): void {
|
||||
const img = this.querySelector("img");
|
||||
const fallback = this.querySelector("[data-part=fallback]") as HTMLElement | null;
|
||||
if (img) img.style.display = "none";
|
||||
if (fallback) fallback.style.display = "";
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/image/index.ts
Normal file
6
packages/core/src/components/image/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyImage } from "./image";
|
||||
export { PettyImage };
|
||||
|
||||
if (!customElements.get("petty-image")) {
|
||||
customElements.define("petty-image", PettyImage);
|
||||
}
|
||||
6
packages/core/src/components/link/index.ts
Normal file
6
packages/core/src/components/link/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyLink } from "./link";
|
||||
export { PettyLink };
|
||||
|
||||
if (!customElements.get("petty-link")) {
|
||||
customElements.define("petty-link", PettyLink);
|
||||
}
|
||||
3
packages/core/src/components/link/link.schema.ts
Normal file
3
packages/core/src/components/link/link.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-link", description: "Headless anchor wrapper with disabled and external link support", tier: 1, attributes: [{ name: "disabled", type: "boolean", description: "Disables the link and prevents navigation" }, { name: "external", type: "boolean", description: "Opens link in new tab with noopener noreferrer" }], parts: [], events: [], example: `<petty-link external><a href="https://example.com">Visit site</a></petty-link>` };
|
||||
41
packages/core/src/components/link/link.ts
Normal file
41
packages/core/src/components/link/link.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/** PettyLink — headless anchor wrapper with disabled and external support. */
|
||||
export class PettyLink extends HTMLElement {
|
||||
static observedAttributes = ["disabled", "external"];
|
||||
|
||||
get anchorElement(): HTMLAnchorElement | null {
|
||||
return this.querySelector("a");
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.#sync();
|
||||
this.addEventListener("click", this.#handleClick);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.removeEventListener("click", this.#handleClick);
|
||||
}
|
||||
|
||||
attributeChangedCallback(): void {
|
||||
this.#sync();
|
||||
}
|
||||
|
||||
#sync(): void {
|
||||
const a = this.anchorElement;
|
||||
if (!a) return;
|
||||
if (this.hasAttribute("external")) {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
if (this.hasAttribute("disabled")) {
|
||||
a.setAttribute("aria-disabled", "true");
|
||||
a.tabIndex = -1;
|
||||
} else {
|
||||
a.removeAttribute("aria-disabled");
|
||||
a.removeAttribute("tabindex");
|
||||
}
|
||||
}
|
||||
|
||||
#handleClick = (e: Event): void => {
|
||||
if (this.hasAttribute("disabled")) e.preventDefault();
|
||||
};
|
||||
}
|
||||
8
packages/core/src/components/listbox/index.ts
Normal file
8
packages/core/src/components/listbox/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { PettyListbox } from "./listbox";
|
||||
import { PettyListboxOption } from "./listbox-option";
|
||||
export { PettyListbox, PettyListboxOption };
|
||||
|
||||
if (!customElements.get("petty-listbox")) {
|
||||
customElements.define("petty-listbox", PettyListbox);
|
||||
customElements.define("petty-listbox-option", PettyListboxOption);
|
||||
}
|
||||
20
packages/core/src/components/listbox/listbox-option.ts
Normal file
20
packages/core/src/components/listbox/listbox-option.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/** PettyListboxOption — single option within a listbox. */
|
||||
export class PettyListboxOption extends HTMLElement {
|
||||
static observedAttributes = ["value", "disabled"];
|
||||
|
||||
get value(): string { return this.getAttribute("value") ?? this.textContent?.trim() ?? ""; }
|
||||
get disabled(): boolean { return this.hasAttribute("disabled"); }
|
||||
|
||||
connectedCallback(): void {
|
||||
this.setAttribute("role", "option");
|
||||
this.setAttribute("tabindex", "-1");
|
||||
this.setAttribute("aria-selected", "false");
|
||||
if (this.disabled) this.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string): void {
|
||||
if (name === "disabled") {
|
||||
this.setAttribute("aria-disabled", String(this.disabled));
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/core/src/components/listbox/listbox.schema.ts
Normal file
3
packages/core/src/components/listbox/listbox.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-listbox", description: "Inline selectable list with single or multiple selection and keyboard navigation", tier: 3, attributes: [{ name: "value", type: "string", description: "Currently selected value (comma-separated for multiple)" }, { name: "default-value", type: "string", description: "Initial selected value" }, { name: "multiple", type: "boolean", description: "Allows selecting multiple options" }], parts: [], events: [{ name: "petty-change", detail: "{ value: string }", description: "Fires when selection changes" }], example: `<petty-listbox default-value="a"><petty-listbox-option value="a">Alpha</petty-listbox-option><petty-listbox-option value="b">Beta</petty-listbox-option></petty-listbox>` };
|
||||
76
packages/core/src/components/listbox/listbox.ts
Normal file
76
packages/core/src/components/listbox/listbox.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { signal, effect } from "../../signals";
|
||||
import { emit, initialValue } from "../../shared/helpers";
|
||||
|
||||
/** PettyListbox — inline selectable list with single or multiple selection. */
|
||||
export class PettyListbox extends HTMLElement {
|
||||
static observedAttributes = ["value", "default-value", "multiple"];
|
||||
|
||||
readonly #value = signal("");
|
||||
#stopEffect: (() => void) | null = null;
|
||||
|
||||
get value(): string { return this.#value.get(); }
|
||||
set value(v: string) { this.#value.set(v); }
|
||||
|
||||
get multiple(): boolean { return this.hasAttribute("multiple"); }
|
||||
|
||||
/** Selects a value, toggling in multiple mode. */
|
||||
selectValue(v: string): void {
|
||||
if (this.multiple) {
|
||||
const current = this.#value.get().split(",").filter(Boolean);
|
||||
const idx = current.indexOf(v);
|
||||
if (idx >= 0) current.splice(idx, 1);
|
||||
else current.push(v);
|
||||
this.#value.set(current.join(","));
|
||||
} else {
|
||||
this.#value.set(v);
|
||||
}
|
||||
emit(this, "change", { value: this.#value.get() });
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
const init = initialValue(this);
|
||||
if (init) this.#value.set(init);
|
||||
this.#stopEffect = effect(() => this.#syncChildren());
|
||||
this.addEventListener("keydown", this.#onKeydown);
|
||||
this.addEventListener("click", this.#onClick);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#stopEffect = null;
|
||||
this.removeEventListener("keydown", this.#onKeydown);
|
||||
this.removeEventListener("click", this.#onClick);
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, next: string | null): void {
|
||||
if (name === "value" && next !== null) this.#value.set(next);
|
||||
}
|
||||
|
||||
#options(): HTMLElement[] {
|
||||
return Array.from(this.querySelectorAll("petty-listbox-option:not([disabled])"));
|
||||
}
|
||||
|
||||
#syncChildren(): void {
|
||||
const selected = new Set(this.#value.get().split(",").filter(Boolean));
|
||||
const options = this.querySelectorAll<HTMLElement>("petty-listbox-option");
|
||||
for (const opt of options) {
|
||||
const isSelected = selected.has(opt.getAttribute("value") ?? "");
|
||||
opt.dataset.state = isSelected ? "selected" : "unselected";
|
||||
opt.setAttribute("aria-selected", String(isSelected));
|
||||
}
|
||||
}
|
||||
|
||||
#onClick = (e: MouseEvent): void => {
|
||||
const opt = (e.target as HTMLElement).closest("petty-listbox-option");
|
||||
if (!opt || opt.hasAttribute("disabled")) return;
|
||||
this.selectValue(opt.getAttribute("value") ?? "");
|
||||
};
|
||||
|
||||
#onKeydown = (e: KeyboardEvent): void => {
|
||||
const items = this.#options();
|
||||
const active = document.activeElement as HTMLElement;
|
||||
const idx = items.indexOf(active);
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); items[(idx + 1) % items.length]?.focus(); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); items[(idx - 1 + items.length) % items.length]?.focus(); }
|
||||
else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (active) this.selectValue(active.getAttribute("value") ?? ""); }
|
||||
};
|
||||
}
|
||||
6
packages/core/src/components/loading-indicator/index.ts
Normal file
6
packages/core/src/components/loading-indicator/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyLoadingIndicator } from "./loading-indicator";
|
||||
export { PettyLoadingIndicator };
|
||||
|
||||
if (!customElements.get("petty-loading-indicator")) {
|
||||
customElements.define("petty-loading-indicator", PettyLoadingIndicator);
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
/** PettyLoadingIndicator — M3 Expressive shape-morphing loading animation. */
|
||||
export class PettyLoadingIndicator extends HTMLElement {
|
||||
static observedAttributes = ["size", "contained"];
|
||||
|
||||
get size(): number { return Number(this.getAttribute("size") ?? 48); }
|
||||
get contained(): boolean { return this.hasAttribute("contained"); }
|
||||
|
||||
connectedCallback(): void {
|
||||
this.setAttribute("role", "progressbar");
|
||||
this.setAttribute("aria-label", this.getAttribute("aria-label") ?? "Loading");
|
||||
this.#render();
|
||||
}
|
||||
|
||||
attributeChangedCallback(): void {
|
||||
this.#render();
|
||||
}
|
||||
|
||||
#render(): void {
|
||||
const px = `${this.size}px`;
|
||||
const el = this.querySelector("[data-part=indicator]");
|
||||
if (el instanceof HTMLElement) {
|
||||
el.style.width = px;
|
||||
el.style.height = px;
|
||||
return;
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
container.dataset.part = "container";
|
||||
const indicator = document.createElement("div");
|
||||
indicator.dataset.part = "indicator";
|
||||
indicator.style.width = px;
|
||||
indicator.style.height = px;
|
||||
container.appendChild(indicator);
|
||||
this.appendChild(container);
|
||||
}
|
||||
}
|
||||
6
packages/core/src/components/meter/index.ts
Normal file
6
packages/core/src/components/meter/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PettyMeter } from "./meter";
|
||||
export { PettyMeter };
|
||||
|
||||
if (!customElements.get("petty-meter")) {
|
||||
customElements.define("petty-meter", PettyMeter);
|
||||
}
|
||||
3
packages/core/src/components/meter/meter.schema.ts
Normal file
3
packages/core/src/components/meter/meter.schema.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { ComponentMeta } from "../../schema";
|
||||
|
||||
export const schema: ComponentMeta = { tag: "petty-meter", description: "Value gauge with low/high/optimum state computation", tier: 3, attributes: [{ name: "value", type: "number", default: "0", description: "Current meter value" }, { name: "min", type: "number", default: "0", description: "Minimum value" }, { name: "max", type: "number", default: "100", description: "Maximum value" }, { name: "low", type: "number", description: "Low threshold value" }, { name: "high", type: "number", description: "High threshold value" }, { name: "optimum", type: "number", description: "Optimum value" }], parts: [{ name: "fill", element: "div", description: "Fill element sized via --petty-meter-value CSS custom property" }], events: [], example: `<petty-meter value="65" min="0" max="100" low="25" high="75"><div data-part="fill"></div></petty-meter>` };
|
||||
47
packages/core/src/components/meter/meter.ts
Normal file
47
packages/core/src/components/meter/meter.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/** PettyMeter — value gauge with low/high/optimum state computation. */
|
||||
export class PettyMeter extends HTMLElement {
|
||||
static observedAttributes = ["value", "min", "max", "low", "high", "optimum"];
|
||||
|
||||
connectedCallback(): void {
|
||||
this.setAttribute("role", "meter");
|
||||
this.#sync();
|
||||
}
|
||||
|
||||
attributeChangedCallback(): void {
|
||||
this.#sync();
|
||||
}
|
||||
|
||||
#num(attr: string, fallback: number): number {
|
||||
const v = this.getAttribute(attr);
|
||||
return v !== null ? Number(v) : fallback;
|
||||
}
|
||||
|
||||
#computeState(val: number, low: number, high: number, optimum: number): string {
|
||||
if (val <= low) return "low";
|
||||
if (val >= high) return "high";
|
||||
if (Math.abs(val - optimum) <= (high - low) * 0.1) return "optimum";
|
||||
return "medium";
|
||||
}
|
||||
|
||||
#updateFill(fraction: number): void {
|
||||
const fill = this.querySelector("[data-part=fill]");
|
||||
if (fill instanceof HTMLElement) fill.style.setProperty("--petty-meter-value", String(fraction));
|
||||
}
|
||||
|
||||
#sync(): void {
|
||||
const val = this.#num("value", 0);
|
||||
const min = this.#num("min", 0);
|
||||
const max = this.#num("max", 100);
|
||||
const low = this.#num("low", min);
|
||||
const high = this.#num("high", max);
|
||||
const optimum = this.#num("optimum", (low + high) / 2);
|
||||
|
||||
this.setAttribute("aria-valuenow", String(val));
|
||||
this.setAttribute("aria-valuemin", String(min));
|
||||
this.setAttribute("aria-valuemax", String(max));
|
||||
this.dataset.state = this.#computeState(val, low, high, optimum);
|
||||
|
||||
const range = max - min;
|
||||
this.#updateFill(range > 0 ? (val - min) / range : 0);
|
||||
}
|
||||
}
|
||||
8
packages/core/src/components/navigation-menu/index.ts
Normal file
8
packages/core/src/components/navigation-menu/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { PettyNavigationMenu } from "./navigation-menu";
|
||||
import { PettyNavigationMenuItem } from "./navigation-menu-item";
|
||||
export { PettyNavigationMenu, PettyNavigationMenuItem };
|
||||
|
||||
if (!customElements.get("petty-navigation-menu")) {
|
||||
customElements.define("petty-navigation-menu", PettyNavigationMenu);
|
||||
customElements.define("petty-navigation-menu-item", PettyNavigationMenuItem);
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
/** PettyNavigationMenuItem — nav item with optional popover content on hover. */
|
||||
export class PettyNavigationMenuItem extends HTMLElement {
|
||||
#showTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
#hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
const trigger = this.querySelector("[data-part=trigger]");
|
||||
const content = this.querySelector("[data-part=content]");
|
||||
if (!trigger || !content) return;
|
||||
trigger.addEventListener("mouseenter", this.#onEnter);
|
||||
trigger.addEventListener("mouseleave", this.#onLeave);
|
||||
content.addEventListener("mouseenter", this.#onContentEnter);
|
||||
content.addEventListener("mouseleave", this.#onLeave);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#clearTimers();
|
||||
const trigger = this.querySelector("[data-part=trigger]");
|
||||
const content = this.querySelector("[data-part=content]");
|
||||
trigger?.removeEventListener("mouseenter", this.#onEnter);
|
||||
trigger?.removeEventListener("mouseleave", this.#onLeave);
|
||||
content?.removeEventListener("mouseenter", this.#onContentEnter);
|
||||
content?.removeEventListener("mouseleave", this.#onLeave);
|
||||
}
|
||||
|
||||
#content(): HTMLElement | null { return this.querySelector("[data-part=content]"); }
|
||||
|
||||
#clearTimers(): void {
|
||||
if (this.#showTimer) { clearTimeout(this.#showTimer); this.#showTimer = null; }
|
||||
if (this.#hideTimer) { clearTimeout(this.#hideTimer); this.#hideTimer = null; }
|
||||
}
|
||||
|
||||
#onEnter = (): void => {
|
||||
this.#clearTimers();
|
||||
this.#showTimer = setTimeout(() => {
|
||||
const c = this.#content();
|
||||
if (c && !c.matches(":popover-open")) c.showPopover();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
#onLeave = (): void => {
|
||||
this.#clearTimers();
|
||||
this.#hideTimer = setTimeout(() => {
|
||||
const c = this.#content();
|
||||
if (c && c.matches(":popover-open")) c.hidePopover();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
#onContentEnter = (): void => { this.#clearTimers(); };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user