import { renderText, html } from "./dom.js"; import { reminderLabel } from "./reminders.js"; // reminder-picker component class ReminderPicker extends HTMLElement { constructor() { super(); this.reminder = this.#new(); } connectedCallback() { this.dateInput = this.querySelector(".date"); this.timeInput = this.querySelector(".time"); this.repeatInput = this.querySelector(".repeat"); this.clearButton = this.querySelector(".clear"); const that = this; this.dateInput.addEventListener("change", () => { that.reminder.date = that.dateInput.value; this.#emitUpdate(); }); this.timeInput.addEventListener("change", () => { that.reminder.time = that.timeInput.value; this.#emitUpdate(); }); this.repeatInput.addEventListener("change", () => { that.reminder.unit = that.repeatInput.value; this.#emitUpdate(); }); this.clearButton.addEventListener("click", () => { this.reminder = this.#new(); this.reminder.enabled = false; this.#updateUI(); this.#emitUpdate(); }); this.#updateUI(); } enableDefault() { this.reminder = this.#new(); this.reminder.enabled = true; this.#updateUI(); this.#emitUpdate(); } set value(reminder) { this.reminder = reminder || this.#new(); this.#updateUI(); } get value() { return this.reminder; } get label() { return reminderLabel(this.reminder); } #updateUI() { this.dateInput.value = this.reminder.date; this.timeInput.value = this.reminder.time; this.repeatInput.value = this.reminder.unit; } #new() { const now = new Date().toISOString(); const [date, rest] = now.split("T"); const fullTime = rest.split(".")[0]; const [hour, minute, _minute] = fullTime.split(":"); return { enabled: false, count: 1, date: date, time: `${hour}:${minute}`, unit: "", }; } #emitUpdate() { if (this.reminder.date && this.reminder.time) { this.dispatchEvent(new Event("reminderUpdate", { bubbles: true })); } } } customElements.define("reminder-picker", ReminderPicker); // editable-area component class EditableArea extends HTMLElement { static observedAttributes = ["readonly"]; constructor() { super(); } connectedCallback() { const text = this.textContent; this.displayElement = document.createElement("p"); this.displayElement.classList.add("display"); this.inputElement = document.createElement("textarea"); this.inputElement.value = text; this.replaceChildren(this.displayElement, this.inputElement); this.inputElement.addEventListener("input", (e) => { this.dispatchEvent(new Event("contentChange", { bubbles: true })); this.#sync(); }); const onTouchMove = () => { this.inputElement.blur(); }; this.inputElement.addEventListener("focus", () => { document.addEventListener("touchmove", onTouchMove); }); this.inputElement.addEventListener("blur", () => { document.removeEventListener("touchmove", onTouchMove); }); new ResizeObserver(() => { this.inputElement.style.height = this.displayElement.scrollHeight + "px"; this.inputElement.style.width = this.displayElement.scrollWidth + "px"; }).observe(this.displayElement); this.#updateReadonly(); this.#sync(); } attributeChangedCallback(name, oldValue, newValue) { if (name === "readonly") { this.#updateReadonly(); } } get value() { return this.inputElement.value; } set value(text) { this.inputElement.value = text; this.#sync(); } focusStart() { this.inputElement.focus(); this.inputElement.selectionEnd = 0; } focusEnd() { this.inputElement.focus(); this.inputElement.selectionStart = this.inputElement.value.length; } cutTextAfterCursor() { const cursorPos = this.inputElement.selectionStart; const text = this.inputElement.value; this.inputElement.value = text.slice(0, cursorPos); this.#sync(); return text.slice(cursorPos); } append(text) { const cursorStartPos = this.inputElement.selectionStart; const cursorEndPos = this.inputElement.selectionEnd; this.inputElement.value += text; this.inputElement.selectionStart = cursorStartPos; this.inputElement.selectionEnd = cursorEndPos; this.#sync(); } cursorAtStart() { return this.inputElement.selectionEnd === 0; } cursorAtEnd() { return this.inputElement.selectionStart === this.inputElement.value.length; } #updateReadonly() { if (this.inputElement) { const readonly = ["", "true"].includes(this.attributes.readonly?.value); this.inputElement.disabled = readonly; } } #sync() { const value = this.inputElement.value; if (value.trim() === "") { this.classList.add("empty"); this.classList.remove("non-empty"); } else { this.classList.remove("empty"); this.classList.add("non-empty"); } this.displayElement.replaceChildren(...renderText(value)); } } customElements.define("editable-area", EditableArea); // task-list-item component class TaskListItem extends HTMLElement { static observedAttributes = ["checked"]; constructor() { super(); } connectedCallback() { const text = this.textContent; this.textContent = ""; const template = document.querySelector("#task-list-item").content; Array.from(template.children).forEach((child) => { this.appendChild(child.cloneNode(true)); }); this.taskList = this.closest("task-list"); this.handleElement = this.querySelector(".handle"); this.checkboxElement = this.querySelector(".checkbox input"); this.contentElement = this.querySelector("editable-area"); this.contentElement.value = text; this.removeButton = this.querySelector(".remove button"); this.handleElement.addEventListener("click", (e) => e.preventDefault()); this.removeButton.addEventListener("click", (e) => { this.dispatchEvent(new Event("removeTaskWithButton", { bubbles: true })); e.preventDefault(); }); this.contentElement.addEventListener("keydown", (e) => { if (e.key === "Backspace" && this.contentElement.cursorAtStart()) { this.dispatchEvent( new CustomEvent("removeTask", { bubbles: true, detail: this.contentElement.cutTextAfterCursor(), }), ); e.preventDefault(); } else if (e.key === "Enter") { const textAfterCursor = this.contentElement.cutTextAfterCursor(); this.dispatchEvent( new CustomEvent("addTask", { bubbles: true, detail: textAfterCursor, }), ); e.preventDefault(); } else if (e.key === "ArrowUp" && this.contentElement.cursorAtStart()) { this.dispatchEvent(new Event("moveToPrevTask", { bubbles: true })); e.preventDefault(); } else if (e.key === "ArrowDown" && this.contentElement.cursorAtEnd()) { this.dispatchEvent(new Event("moveToNextTask", { bubbles: true })); e.preventDefault(); } }); this.checkboxElement.addEventListener("change", () => this.dispatchEvent( new CustomEvent("contentChange", { bubbles: true, detail: { operation: "toggle_task" }, }), ), ); // drag and drop events this.handleElement.addEventListener("mousedown", () => { this.parentNode.setAttribute("draggable", "true"); }); this.parentNode.addEventListener("dragstart", (e) => { const isHandle = e.explicitOriginalTarget.closest ? e.explicitOriginalTarget.closest(".handle") : e.explicitOriginalTarget.parentNode.closest(".handle"); if (isHandle) { this.parentNode.classList.add("dragging"); } else { e.preventDefault(); } }); this.parentNode.addEventListener("dragend", () => { this.parentNode.setAttribute("draggable", "false"); this.parentNode.classList.remove("dragging"); this.dispatchEvent( new CustomEvent("contentChange", { bubbles: true, detail: { operation: "reorder_task" }, }), ); }); this.parentNode.addEventListener("touchstart", (e) => { if (e.target.closest("div").classList.contains("handle")) { this.parentNode.classList.add("dragging"); this.taskList.dragActiveElement = this.parentNode; if (!this.taskList.dragPlaceholder) { this.taskList.dragPlaceholder = document.createElement("div"); this.taskList.dragPlaceholder.classList.add("drag-placeholder"); this.taskList.dragPlaceholder.textContent = ">"; } e.preventDefault(); } }); const taskListUL = this.taskList.querySelector("ul"); this.parentNode.addEventListener("touchmove", (e) => { if (this.taskList.dragActiveElement) { const touch = e.touches[0]; this.parentNode.style.position = "absolute"; this.parentNode.style.left = `${touch.clientX}px`; this.parentNode.style.top = `${touch.clientY}px`; this.parentNode.style.width = "240px"; if (this.taskList.dragPlaceholder) { const afterElement = getDragAfterElement(taskListUL, touch.clientY); if (afterElement) { taskListUL.insertBefore( this.taskList.dragPlaceholder, afterElement, ); } else { taskListUL.appendChild(this.taskList.dragPlaceholder); } } e.preventDefault(); } }); this.parentNode.addEventListener("touchend", () => { if (this.taskList.dragActiveElement) { this.parentNode.classList.remove("dragging"); if ( this.taskList.dragActiveElement && this.taskList.dragPlaceholder && this.taskList.dragPlaceholder.parentNode ) { this.taskList.dragPlaceholder.parentNode.insertBefore( this.taskList.dragActiveElement, this.taskList.dragPlaceholder, ); this.taskList.dragPlaceholder.remove(); this.taskList.dragActiveElement.style.position = "static"; this.taskList.dragActiveElement.style.left = ""; this.taskList.dragActiveElement.style.top = ""; this.taskList.dragActiveElement.style.width = ""; } this.taskList.dragActiveElement = null; this.taskList.dragPlaceholder = null; this.dispatchEvent( new CustomEvent("contentChange", { bubbles: true, detail: { operation: "reorder_task" }, }), ); } }); this.#updateChecked(); this.dispatchEvent(new Event("contentChange", { bubbles: true })); } disconnectedCallback() { this.textContent = this.contentElement.value; this.setAttribute("checked", this.checkboxElement.checked); } attributeChangedCallback(name, oldValue, newValue) { if (name === "checked") { this.#updateChecked(); } } append(text) { this.contentElement.append(text); } get value() { return { order: parseInt(this.dataset.order), content: this.contentElement.value, checked: !!this.checkboxElement.checked, }; } set value(item) { this.dataset.order = item.order; this.checkboxElement.checked = item.checked; this.contentElement.value = item.content; } focusStart() { this.contentElement.focusStart(); } focusEnd() { this.contentElement.focusEnd(); } #updateChecked() { if (this.checkboxElement) { const checked = ["", "true"].includes(this.attributes.checked?.value); this.checkboxElement.checked = checked; } } } customElements.define("task-list-item", TaskListItem); // task-list component class TaskList extends HTMLElement { dragPlaceholder = null; dragActiveElement = null; constructor() { super(); } connectedCallback() { const that = this; this.listElement = this.querySelector("ul"); this.addEventListener("removeTaskWithButton", (e) => { const tasksCount = this.querySelectorAll("task-list-item").length; if (tasksCount === 1) { const newLI = document.createElement("li"); const newItem = document.createElement("task-list-item"); newLI.appendChild(newItem); this.querySelector("ul").appendChild(newLI); newItem.focusStart(); } const currentLI = e.target.parentNode; currentLI.remove(); this.dispatchEvent(new Event("contentChange", { bubbles: true })); }); this.addEventListener("removeTask", (e) => { const textToAppend = e.detail || ""; const currentLI = e.target.parentNode; const previousItem = currentLI.previousElementSibling?.querySelector("task-list-item"); if (previousItem) { previousItem.focusEnd(); previousItem.append(textToAppend); currentLI.remove(); this.dispatchEvent(new Event("contentChange", { bubbles: true })); } }); this.addEventListener("addTask", (e) => { const text = e.detail || ""; const currentLI = e.target.parentNode; const newLI = document.createElement("li"); const newItem = document.createElement("task-list-item"); newItem.textContent = text; newLI.appendChild(newItem); currentLI.after(newLI); newItem.focusStart(); this.dispatchEvent(new Event("contentChange", { bubbles: true })); }); this.addEventListener("moveToNextTask", (e) => { const currentLI = e.target.parentNode; const nextItem = currentLI.nextElementSibling?.querySelector("task-list-item"); if (nextItem) { nextItem.focusStart(); } }); this.addEventListener("moveToPrevTask", (e) => { const currentLI = e.target.parentNode; const prevItem = currentLI.previousElementSibling?.querySelector("task-list-item"); if (prevItem) { prevItem.focusEnd(); } }); this.addEventListener("contentChange", (e) => { const operation = e.detail?.operation; if (operation === "toggle_task") { that.value = that.value; } else if (operation === "reorder_task") { const tasks = this.#valueNoOrder().map((task, idx) => { return { ...task, order: idx }; }); that.value = tasks; } }); // drag and drop this.listElement.addEventListener("dragover", (e) => { e.preventDefault(); const afterElement = getDragAfterElement(this.listElement, e.clientY); const draggable = document.querySelector(".dragging"); if (afterElement == null) { this.listElement.appendChild(draggable); } else { this.listElement.insertBefore(draggable, afterElement); } }); } get value() { const tasks = this.#valueNoOrder().sort((a, b) => a.order - b.order); return tasks; } set value(tasks) { const sortedTasks = [...tasks] .map((task, idx) => { return { ...task, order: idx }; }) .sort((a, b) => { const maxOrder = Math.max(a.order, b.order) + 1; return ( (a.checked ? maxOrder : 0) + a.order - b.order - (b.checked ? maxOrder : 0) ); }); this.listElement.replaceChildren(); sortedTasks.forEach((task) => { const item = html`