343 lines
7.2 KiB
TypeScript
343 lines
7.2 KiB
TypeScript
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: "\u2713";
|
|
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>\uD83C\uDF92</span>
|
|
<span>Packing List</span>
|
|
</span>
|
|
<div class="header-actions">
|
|
<button class="close-btn" title="Close">\u00D7</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,
|
|
};
|
|
}
|
|
}
|