rspace-online/lib/folk-packing-list.ts

343 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 260px;
min-height: 200px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #7c3aed;
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.progress-info {
padding: 8px 12px;
font-size: 11px;
color: #64748b;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e2e8f0;
}
.progress-bar {
width: 80px;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #7c3aed;
border-radius: 3px;
transition: width 0.3s ease;
}
.packing-list {
padding: 8px 12px;
max-height: 300px;
overflow-y: auto;
}
.category-label {
font-size: 10px;
font-weight: 600;
color: #7c3aed;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 6px 0 3px;
}
.item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 12px;
cursor: pointer;
}
.item:hover {
background: #f8fafc;
border-radius: 4px;
padding: 4px 6px;
margin: 0 -6px;
}
.checkbox {
width: 16px;
height: 16px;
border: 2px solid #cbd5e1;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
}
.checkbox.checked {
background: #7c3aed;
border-color: #7c3aed;
}
.checkbox.checked::after {
content: "✓";
color: white;
font-size: 10px;
font-weight: 700;
}
.item-name {
color: #1e293b;
}
.item-name.packed {
text-decoration: line-through;
color: #94a3b8;
}
.item-qty {
font-size: 10px;
color: #94a3b8;
margin-left: auto;
}
.add-form {
padding: 8px 12px;
border-top: 1px solid #e2e8f0;
}
.add-form input {
width: 100%;
padding: 6px 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 12px;
outline: none;
}
.add-form input:focus {
border-color: #7c3aed;
}
`;
export interface PackingEntry {
id: string;
name: string;
category?: string;
packed: boolean;
quantity: number;
}
declare global {
interface HTMLElementTagNameMap {
"folk-packing-list": FolkPackingList;
}
}
export class FolkPackingList extends FolkShape {
static override tagName = "folk-packing-list";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#items: PackingEntry[] = [];
#listEl: HTMLElement | null = null;
#progressTextEl: HTMLElement | null = null;
#progressFillEl: HTMLElement | null = null;
get items() { return this.#items; }
set items(v: PackingEntry[]) {
this.#items = v;
this.#render();
this.dispatchEvent(new CustomEvent("content-change"));
}
get packedCount() {
return this.#items.filter((i) => i.packed).length;
}
addItem(item: PackingEntry) {
this.#items.push(item);
this.#render();
this.dispatchEvent(new CustomEvent("content-change"));
}
toggleItem(id: string) {
const item = this.#items.find((i) => i.id === id);
if (item) {
item.packed = !item.packed;
this.#render();
this.dispatchEvent(new CustomEvent("content-change"));
}
}
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>🎒</span>
<span>Packing List</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="progress-info">
<span class="progress-text"></span>
<div class="progress-bar"><div class="progress-fill"></div></div>
</div>
<div class="packing-list"></div>
<div class="add-form">
<input type="text" placeholder="Add item..." class="add-input" />
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
this.#listEl = wrapper.querySelector(".packing-list");
this.#progressTextEl = wrapper.querySelector(".progress-text");
this.#progressFillEl = wrapper.querySelector(".progress-fill");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
const addInput = wrapper.querySelector(".add-input") as HTMLInputElement;
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
addInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && addInput.value.trim()) {
e.stopPropagation();
this.addItem({
id: `pack-${Date.now()}`,
name: addInput.value.trim(),
packed: false,
quantity: 1,
});
addInput.value = "";
}
});
addInput.addEventListener("click", (e) => e.stopPropagation());
this.#render();
return root;
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
#render() {
if (!this.#listEl || !this.#progressTextEl || !this.#progressFillEl) return;
const total = this.#items.length;
const packed = this.packedCount;
const pct = total > 0 ? (packed / total) * 100 : 0;
this.#progressTextEl.textContent = `${packed}/${total} packed`;
this.#progressFillEl.style.width = `${pct}%`;
if (total === 0) {
this.#listEl.innerHTML = '<div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">No items yet</div>';
return;
}
// Group by category
const groups = new Map<string, PackingEntry[]>();
for (const item of this.#items) {
const cat = item.category || "Other";
if (!groups.has(cat)) groups.set(cat, []);
groups.get(cat)!.push(item);
}
let result = "";
for (const [cat, items] of groups) {
result += `<div class="category-label">${this.#escapeHtml(cat)}</div>`;
for (const item of items) {
result += `
<div class="item" data-id="${item.id}">
<div class="checkbox ${item.packed ? "checked" : ""}"></div>
<span class="item-name ${item.packed ? "packed" : ""}">${this.#escapeHtml(item.name)}</span>
${item.quantity > 1 ? `<span class="item-qty">x${item.quantity}</span>` : ""}
</div>
`;
}
}
this.#listEl.innerHTML = result;
// Add click handlers for checkboxes
this.#listEl.querySelectorAll(".item").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const id = (el as HTMLElement).dataset.id;
if (id) this.toggleItem(id);
});
});
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-packing-list",
items: this.#items,
};
}
}