<header>
<svg width="20" height="20" viewBox="0 0 240 260" class="svg-check site-logo">
<polyline class="cls-1" points="16 149 72 225 225 16" pathLength="1" />
</svg>
<h1>TODOs</h1>
</header>
<form id="todo-form" class="todo-form">
<label>
<span class="screen-reader-text">New TODO</span>
<input type="text" name="todo">
</label>
<button id="button-add-todo" class="button-add-todo">
<svg viewBox="-20 -20 240 240" class="svg-plus">
<g>
<line x1="100" x2="100" y2="200" />
<line y1="100" x2="200" y2="100" />
</g>
</svg>
</button>
</form>
<ol id="todo-list" class="todo-list"></ol>
<footer>
<div class="todo-type-toggles">
<button aria-pressed="true">Active</button>
<button>Completed</button>
</div>
<div class="note">Double-click to edit a todo</div>
</footer>
* {
box-sizing: border-box;
}
html {
--gray800: oklch(10% 0% 0);
--gray600: oklch(40% 0% 0);
--gray100: oklch(92% 0% 0);
--brand: oklch(85% 0.3 145);
font-family: system-ui, sans-serif;
background: var(--gray100);
}
body {
margin: 0;
}
header {
background: var(--gray800);
color: white;
padding: 1rem;
display: flex;
align-items: center;
gap: 1rem;
h1 {
margin: 0;
font-variation-settings: "wght" 900, "wdth" 700;
}
.svg-check {
width: 2lh;
height: 2lh;
stroke: var(--brand);
}
}
.svg-check,
.svg-plus {
fill: none;
stroke-width: 30;
stroke-linecap: round;
stroke-linejoin: round;
display: block;
pointer-events: none;
}
.todo-form {
background: var(--gray600);
justify-content: center;
padding: 1rem;
display: flex;
gap: 0.5rem;
align-items: stretch;
label {
flex: 1 0;
max-width: 40ch;
}
input {
background: var(--gray100);
width: 100%;
border: 0;
padding: 0.5rem;
box-shadow: inset 1px 1px 2px oklch(0% 0% 0 / 0.6);
border-radius: 5px;
font: inherit;
font-size: 2rem;
&:focus {
background: white;
}
}
.svg-plus {
stroke: black;
width: 66%;
}
}
.button-add-todo {
border: 0;
border-radius: 5px;
font: inherit;
font-size: 2rem;
font-weight: 900;
background: var(--brand);
aspect-ratio: 1;
height: 3.35rem;
display: inline-grid;
place-items: center;
line-height: 0;
&:hover,
&:focus {
background: color-mix(in oklch, var(--brand) 100%, black 10%);
}
&.shake {
rotate: 0deg;
transform-origin: bottom right;
animation: shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
&.added {
transform-origin: center center;
animation: added 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
}
@keyframes shake {
50% {
rotate: -12deg;
}
}
@keyframes added {
50% {
rotate: 1turn;
translate: 0 50px;
}
}
.todo-list {
padding: 1rem;
list-style: none;
li {
background: white;
box-shadow: 1px 1px 2px oklch(0% 0% 0 / 0.2);
padding: 0.5rem;
margin-block-end: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
.button-complete {
order: -1;
}
.svg-check {
stroke: black;
position: absolute;
width: 2rem;
height: 2rem;
top: -0.5rem;
left: 0.1rem;
opacity: 0;
}
&.complete {
.svg-check {
opacity: 1;
stroke-dasharray: 1;
stroke-dashoffset: 1;
/* https://css-tricks.com/a-trick-that-makes-drawing-svg-lines-way-easier/ */
animation: do-check 1s infinite alternate;
}
}
&.editing {
outline: 1px solid var(--brand);
.text {
display: none;
}
}
&[data-complete="true"] {
.text {
text-decoration: line-through;
text-decoration-thickness: 2px;
text-decoration-color: var(--brand);
opacity: 0.5;
}
}
&.empty {
justify-content: center;
background: none;
box-shadow: none;
}
}
}
@keyframes do-check {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
.form-edit {
width: 100%;
}
.input-edit {
font: inherit;
border: 0;
width: 100%;
padding: 0;
&:focus {
outline: none;
}
}
.button-complete {
border: 0;
border-radius: 3px;
background: var(--gray100);
box-shadow: inset 1px 1px 2px oklch(0% 0% 0 / 0.2);
padding: 0.25rem;
width: 1.5rem;
height: 1.5rem;
position: relative;
}
.screen-reader-text {
text-indent: -9999em;
width: 0;
height: 0;
display: block;
}
footer {
font-size: 0.8rem;
> .note {
opacity: 0.5;
}
display: flex;
gap: 1rem;
justify-content: space-between;
align-items: center;
padding: 0 1rem;
}
.todo-type-toggles {
display: flex;
button {
border: 0;
border-radius: 8px;
padding: 0.2rem 1rem;
background: var(--button-bg, white);
box-shadow: 1px 1px 3px oklch(0% 0% 0 / 0.2);
&[aria-pressed="true"] {
--button-bg: var(--brand);
box-shadow: 0 1px 1px oklch(0% 0% 0 / 0.4);
}
&:active {
position: relative;
top: 1px;
}
&:hover,
&:focus-visible {
background: color-mix(in oklch, var(--button-bg), black 10%);
}
}
button:first-child {
border-radius: 5px 0 0 5px;
}
button:last-child {
border-radius: 0 5px 5px 0;
}
}
// Useful for nuking data quick if you need to
// localStorage["data"] = JSON.stringify([])
// UI constants
const form = document.querySelector("#todo-form");
const list = document.querySelector("#todo-list");
const buttonAddTodo = document.querySelector("#button-add-todo");
const toggles = document.querySelectorAll(".todo-type-toggles > button");
// Enums
const states = {
ACTIVE: "Active",
COMPLETED: "Completed"
};
// Get Data on page load
let TODOs = [];
if (localStorage["data"] !== null && localStorage["data"] !== undefined) {
TODOs = JSON.parse(localStorage["data"]);
}
// console.log({ TODOs });
function buildUI(state) {
let HTML = ``;
let viewTODOs = [];
if (state === states.COMPLETED) {
viewTODOs = TODOs.filter((todo) => todo.complete);
} else {
viewTODOs = TODOs.filter((todo) => !todo.complete);
}
if (viewTODOs.length === 0) {
HTML = `<li class="empty">Nothing to do!</li>`;
}
viewTODOs.forEach((todo) => {
if (todo !== null) {
HTML += `<li id="${todo.id}" style="view-transition-name: list-item-${todo.id};" data-complete="${todo.complete}">
<span class="text">${todo.title}</span>
<button aria-label="Complete" class="button-complete">
<svg width="20" height="20" viewBox="0 0 241.44 259.83" class="svg-check">
<polyline points="16.17 148.63 72.17 225.63 225.17 11.63" pathLength="1" />
</svg>
</button>
</li>`;
}
});
list.innerHTML = HTML;
}
form.addEventListener("submit", (event) => {
event.preventDefault();
// Don't allow empty todo
if (!form[0].value) {
buttonAddTodo.classList.add("shake");
return;
}
addTodo(event);
form.reset();
});
buttonAddTodo.addEventListener("animationend", () => {
buttonAddTodo.classList.remove("shake", "added");
});
function addTodo() {
// TODO (lol): Sanitize user input.
TODOs.push({
title: form[0].value,
complete: false,
id: self.crypto.randomUUID()
});
localStorage["data"] = JSON.stringify(TODOs);
buttonAddTodo.classList.add("added");
buildUI();
}
document.documentElement.addEventListener("click", (event) => {
if (event.target.classList.contains("button-complete")) {
toggleTodo(event);
}
});
list.addEventListener("dblclick", (event) => {
const listItem = event.target.closest("li");
// If already editing, let it be.
if (listItem.classList.contains("editing")) return;
listItem.classList.add("editing");
const textItem = listItem.querySelector(".text");
listItem.insertAdjacentHTML(
"beforeend",
`<form onsubmit="updateTodo(event);" class="form-edit"><input onblur="updateTodo(event);" type="text" class="input-edit" value="${textItem.textContent}"></form>`
);
const input = listItem.querySelector(".input-edit");
input.focus();
// put cursor at end of input
input.setSelectionRange(input.value.length, input.value.length);
});
function updateTodo(event) {
event.preventDefault();
const listItem = event.target.closest("li");
const textItem = listItem.querySelector(".text");
const inputItem = listItem.querySelector(".input-edit");
const form = listItem.querySelector(".form-edit");
textItem.textContent = inputItem.value;
listItem.classList.remove("editing");
form.remove();
TODOs = TODOs.map((todo) => {
if (todo.id === listItem.id) {
todo.title = inputItem.value;
}
return todo;
});
localStorage["data"] = JSON.stringify(TODOs);
}
function toggleTodo(event) {
const listItem = event.target.parentElement;
// Trigger complete animation
listItem.classList.toggle("complete");
setTimeout(() => {
if (listItem.dataset.complete === "true") {
TODOs = TODOs.filter((todo) => !todo.complete);
if (!document.startViewTransition) {
buildUI(states.COMPLETED);
} else {
document.startViewTransition(() => {
buildUI();
});
}
} else {
TODOs.forEach((todo) => {
if (todo.id === listItem.id) {
todo.complete = !todo.complete;
}
});
if (!document.startViewTransition) {
buildUI(states.ACTIVE);
} else {
document.startViewTransition(() => {
buildUI();
});
}
}
localStorage["data"] = JSON.stringify(TODOs);
}, 1000);
}
toggles.forEach((toggle) => {
toggle.addEventListener("click", (event) => {
toggles.forEach((toggle) => {
toggle.setAttribute("aria-pressed", false);
});
toggle.setAttribute("aria-pressed", true);
if (toggle.textContent === states.ACTIVE) {
buildUI(states.ACTIVE);
} else {
buildUI(states.COMPLETED);
}
});
});
buildUI();
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.