mirror of
https://github.com/jaandrle/deka-dom-el
synced 2025-04-02 04:05:52 +02:00
382 lines
11 KiB
JavaScript
382 lines
11 KiB
JavaScript
import { dispatchEvent, el, memo, on, scope } from "deka-dom-el";
|
||
import { S } from "deka-dom-el/signals";
|
||
|
||
/**
|
||
* Main TodoMVC application component
|
||
*
|
||
* Creates and manages the TodoMVC application with the following features:
|
||
* - Todo items management (add, edit, delete)
|
||
* - Filtering by status (all, active, completed)
|
||
* - Client-side routing via URL hash
|
||
* - Persistent storage with localStorage
|
||
*
|
||
* @returns {HTMLElement} The root TodoMVC application element
|
||
*/
|
||
function Todos(){
|
||
const { signal } = scope;
|
||
const pageS = routerSignal(S, signal);
|
||
const todosS = todosSignal();
|
||
/** Derived signal that filters todos based on current route */
|
||
const todosFilteredS = S(()=> {
|
||
const todos = todosS.get();
|
||
const filter = pageS.get();
|
||
if (filter === "all") return todos;
|
||
return todos.filter(todo => {
|
||
if (filter === "active") return !todo.completed;
|
||
if (filter === "completed") return todo.completed;
|
||
});
|
||
});
|
||
const todosRemainingS = S(()=> todosS.get().filter(todo => !todo.completed).length);
|
||
|
||
/** @type {ddeElementAddon<HTMLInputElement>} */
|
||
const onToggleAll = on("change", event => {
|
||
const checked = /** @type {HTMLInputElement} */ (event.target).checked;
|
||
S.action(todosS, "completeAll", checked);
|
||
});
|
||
const formNewTodo = "newTodo";
|
||
/** @type {ddeElementAddon<HTMLFormElement>} */
|
||
const onSubmitNewTodo = on("submit", event => {
|
||
event.preventDefault();
|
||
const input = /** @type {HTMLInputElement} */(
|
||
/** @type {HTMLFormElement} */(event.target).elements.namedItem(formNewTodo)
|
||
);
|
||
const title = input.value.trim();
|
||
if (!title) return;
|
||
|
||
S.action(todosS, "add", title);
|
||
input.value = "";
|
||
});
|
||
const onClearCompleted = on("click", () => S.action(todosS, "clearCompleted"));
|
||
const onDelete = on("todo:delete", ev =>
|
||
S.action(todosS, "delete", /** @type {{ detail: Todo["id"] }} */(ev).detail));
|
||
const onEdit = on("todo:edit", ev =>
|
||
S.action(todosS, "edit", /** @type {{ detail: Partial<Todo> & { id: Todo["id"] } }} */(ev).detail));
|
||
|
||
return el("section", { className: "todoapp" }).append(
|
||
el("header", { className: "header" }).append(
|
||
el("h1", "todos"),
|
||
el("form", null, onSubmitNewTodo).append(
|
||
el("input", {
|
||
className: "new-todo",
|
||
name: formNewTodo,
|
||
placeholder: "What needs to be done?",
|
||
autocomplete: "off",
|
||
autofocus: true
|
||
})
|
||
)
|
||
),
|
||
S.el(todosS, todos => !todos.length
|
||
? el()
|
||
: el("main", { className: "main" }).append(
|
||
el("input", {
|
||
id: "toggle-all",
|
||
className: "toggle-all",
|
||
type: "checkbox"
|
||
}, onToggleAll),
|
||
el("label", { htmlFor: "toggle-all", title: "Mark all as complete" }),
|
||
el("ul", { className: "todo-list" }).append(
|
||
S.el(todosFilteredS, filteredTodos => filteredTodos.map(todo =>
|
||
memo(todo.id, ()=> el(TodoItem, todo, onDelete, onEdit)))
|
||
)
|
||
)
|
||
)
|
||
),
|
||
S.el(todosS, ({ length }) => !length
|
||
? el()
|
||
: el("footer", { className: "footer" }).append(
|
||
el("span", { className: "todo-count" }).append(
|
||
el("strong", length + " " + (length === 1 ? "item" : "items")),
|
||
),
|
||
memo("filters", ()=>
|
||
el("ul", { className: "filters" }).append(
|
||
...[ "All", "Active", "Completed" ].map(textContent =>
|
||
el("li").append(
|
||
el("a", {
|
||
textContent,
|
||
classList: { selected: S(()=> pageS.get() === textContent.toLowerCase()) },
|
||
href: `#${textContent.toLowerCase()}`
|
||
})
|
||
)
|
||
)
|
||
),
|
||
),
|
||
length - todosRemainingS.get() === 0
|
||
? el()
|
||
: memo("delete", () =>
|
||
el("button",
|
||
{ textContent: "Clear completed", className: "clear-completed" },
|
||
onClearCompleted)
|
||
)
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Todo item data structure
|
||
* @typedef {{ title: string, id: string, completed: boolean }} Todo
|
||
*/
|
||
|
||
/**
|
||
* Component for rendering an individual todo item
|
||
*
|
||
* Features:
|
||
* - Display todo with completed state
|
||
* - Toggle completion status
|
||
* - Delete todo
|
||
* - Edit todo with double-click
|
||
* - Cancel edit with Escape key
|
||
*
|
||
* @param {Todo} todo - The todo item data
|
||
* @fires {void} todo:delete - todo deletion event
|
||
* @fires {Partial<Todo>} todo:edit - todo edits event
|
||
*/
|
||
function TodoItem({ id, title, completed }) {
|
||
const { host }= scope;
|
||
const isEditing = S(false);
|
||
const isCompleted = S(completed);
|
||
|
||
/** @type {(id: string) => void} Dispatch function for deleting todo */
|
||
const dispatchDelete= dispatchEvent("todo:delete", host);
|
||
/** @type {(data: {id: string, [key: string]: any}) => void} Dispatch function for editing todo */
|
||
const dispatchEdit = dispatchEvent("todo:edit", host);
|
||
|
||
/** @type {ddeElementAddon<HTMLInputElement>} */
|
||
const onToggleCompleted = on("change", (ev) => {
|
||
const completed= /** @type {HTMLInputElement} */(ev.target).checked;
|
||
isCompleted.set(completed);
|
||
dispatchEdit({ id, completed });
|
||
});
|
||
/** @type {ddeElementAddon<HTMLButtonElement>} */
|
||
const onDelete = on("click", () => dispatchDelete(id));
|
||
/** @type {ddeElementAddon<HTMLLabelElement>} */
|
||
const onStartEdit = on("dblclick", () => isEditing.set(true));
|
||
/** @type {ddeElementAddon<HTMLInputElement>} */
|
||
const onBlurEdit = on("blur", event => {
|
||
const value = /** @type {HTMLInputElement} */(event.target).value.trim();
|
||
if (value) {
|
||
dispatchEdit({ id, title: value });
|
||
} else {
|
||
dispatchDelete(id);
|
||
}
|
||
isEditing.set(false);
|
||
});
|
||
const formEdit = "edit";
|
||
/** @type {ddeElementAddon<HTMLFormElement>} */
|
||
const onSubmitEdit = on("submit", event => {
|
||
event.preventDefault();
|
||
const input = /** @type {HTMLFormElement} */(event.target).elements.namedItem(formEdit);
|
||
const value = /** @type {HTMLInputElement} */(input).value.trim();
|
||
if (value) {
|
||
dispatchEdit({ id, title: value });
|
||
} else {
|
||
dispatchDelete(id);
|
||
}
|
||
isEditing.set(false);
|
||
});
|
||
|
||
/**
|
||
* Event handler for keyboard events in edit mode
|
||
* @type {ddeElementAddon<HTMLInputElement>}
|
||
*/
|
||
const onKeyDown = on("keydown", event => {
|
||
if (event.key !== "Escape") return;
|
||
isEditing.set(false);
|
||
});
|
||
|
||
return el("li", { classList: { completed: isCompleted, editing: isEditing } }).append(
|
||
el("div", { className: "view" }).append(
|
||
el("input", {
|
||
className: "toggle",
|
||
type: "checkbox",
|
||
checked: completed
|
||
}, onToggleCompleted),
|
||
el("label", { textContent: title }, onStartEdit),
|
||
el("button", { ariaLabel: "Delete todo", className: "destroy" }, onDelete)
|
||
),
|
||
S.el(isEditing, editing => !editing
|
||
? el()
|
||
: el("form", null, onSubmitEdit).append(
|
||
el("input", {
|
||
className: "edit",
|
||
name: formEdit,
|
||
value: title,
|
||
}, onBlurEdit, onKeyDown, addFocus)
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
// Set up the document head
|
||
document.head.append(
|
||
el("title", "TodoMVC: dd<el>"),
|
||
el("meta", { name: "description", content: "A TodoMVC implementation using dd<el>." }),
|
||
el("link", {
|
||
rel: "stylesheet",
|
||
href: "https://cdn.jsdelivr.net/npm/todomvc-common@1.0.5/base.css"
|
||
}),
|
||
el("link", {
|
||
rel: "stylesheet",
|
||
href: "https://cdn.jsdelivr.net/npm/todomvc-app-css@2.4.2/index.css"
|
||
})
|
||
);
|
||
|
||
// Set up the document body
|
||
document.body.append(
|
||
el(Todos),
|
||
el("footer", { className: "info" }).append(
|
||
el("p", "Double-click to edit a todo"),
|
||
el("p").append(
|
||
"Created with ",
|
||
el("a", { textContent: "deka-dom-el", href: "https://github.com/jaandrle/deka-dom-el" })
|
||
),
|
||
el("p").append(
|
||
"Part of ",
|
||
el("a", { textContent: "TodoMVC", href: "http://todomvc.com" })
|
||
)
|
||
)
|
||
);
|
||
|
||
/**
|
||
* Utility function to set focus on an input element
|
||
* Uses requestAnimationFrame to ensure the element is rendered
|
||
* before trying to focus it
|
||
*
|
||
* @param {HTMLInputElement} editInput - The input element to focus
|
||
* @returns {number} The requestAnimationFrame ID
|
||
*/
|
||
function addFocus(editInput){
|
||
return requestAnimationFrame(()=> {
|
||
editInput.focus();
|
||
editInput.selectionStart = editInput.selectionEnd = editInput.value.length;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Creates a signal for managing todos with persistence
|
||
*
|
||
* Features:
|
||
* - Loads todos from localStorage on initialization
|
||
* - Automatically saves todos to localStorage on changes
|
||
* - Provides actions for adding, editing, deleting todos
|
||
*/
|
||
function todosSignal(){
|
||
const store_key = "dde-todos";
|
||
// Try to load todos from localStorage
|
||
let savedTodos = [];
|
||
try {
|
||
const stored = localStorage.getItem(store_key);
|
||
if (stored) {
|
||
savedTodos = JSON.parse(stored);
|
||
}
|
||
} catch (_) {}
|
||
|
||
const out= S(/** @type {Todo[]} */(savedTodos || []), {
|
||
/**
|
||
* Add a new todo
|
||
* @param {string} value - The title of the new todo
|
||
*/
|
||
add(value){
|
||
this.value.push({
|
||
completed: false,
|
||
title: value,
|
||
id: uuid(),
|
||
});
|
||
},
|
||
/**
|
||
* Edit an existing todo
|
||
* @param {{ id: string, [key: string]: any }} data - Object containing id and fields to update
|
||
*/
|
||
edit({ id, ...update }){
|
||
const index = this.value.findIndex(t => t.id === id);
|
||
if (index === -1) return this.stopPropagation();
|
||
Object.assign(this.value[index], update);
|
||
},
|
||
/**
|
||
* Delete a todo by id
|
||
* @param {string} id - The id of the todo to delete
|
||
*/
|
||
delete(id){
|
||
const index = this.value.findIndex(t => t.id === id);
|
||
if (index === -1) return this.stopPropagation();
|
||
this.value.splice(index, 1);
|
||
},
|
||
/**
|
||
* Remove all completed todos
|
||
*/
|
||
clearCompleted() {
|
||
this.value = this.value.filter(todo => !todo.completed);
|
||
},
|
||
completeAll(state= true) {
|
||
this.value.forEach(todo => todo.completed = state);
|
||
},
|
||
/**
|
||
* Handle cleanup when signal is cleared
|
||
*/
|
||
[S.symbols.onclear](){
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Save todos to localStorage whenever the signal changes
|
||
* @param {Todo[]} value - Current todos array
|
||
*/
|
||
S.on(out, /** @param {Todo[]} value */ function saveTodos(value) {
|
||
try {
|
||
localStorage.setItem(store_key, JSON.stringify(value));
|
||
} catch (e) {
|
||
console.error("Failed to save todos to localStorage", e);
|
||
// Optionally, provide user feedback
|
||
}
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Creates a signal for managing route state
|
||
*
|
||
* @param {typeof S} signal - The signal constructor from a library
|
||
* @param {AbortSignal} abortSignal
|
||
*/
|
||
function routerSignal(signal, abortSignal){
|
||
const initial = location.hash.replace("#", "") || "all";
|
||
const out = signal(initial, {
|
||
/**
|
||
* Set the current route
|
||
* @param {"all"|"active"|"completed"} hash - The route to set
|
||
*/
|
||
set(hash){
|
||
location.hash = hash;
|
||
//this.value = hash;
|
||
},
|
||
});
|
||
|
||
// Setup hash change listener
|
||
window.addEventListener("hashchange", () => {
|
||
const hash = location.hash.replace("#", "") || "all";
|
||
//S.action(out, "set", /** @type {"all"|"active"|"completed"} */(hash));
|
||
out.set(hash);
|
||
}, { signal: abortSignal });
|
||
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Generates a RFC4122 version 4 compliant UUID
|
||
* Used to create unique identifiers for todo items
|
||
*
|
||
* @returns {string} A randomly generated UUID
|
||
*/
|
||
function uuid() {
|
||
let uuid = "";
|
||
for (let i = 0; i < 32; i++) {
|
||
let random = (Math.random() * 16) | 0;
|
||
|
||
if (i === 8 || i === 12 || i === 16 || i === 20)
|
||
uuid += "-";
|
||
|
||
uuid += (i === 12 ? 4 : i === 16 ? (random & 3) | 8 : random).toString(16);
|
||
}
|
||
return uuid;
|
||
}
|