Mark items as complete by toggling CSS classes (and keep the DOM accessible & tidy)
🧠 Why this lesson matters
After building the basic To-Do List with menu and core actions, it’s time to make it interactive in the browser. With just a few lines of JavaScript, we can:
- Add or remove CSS classes in response to user clicks.
- Visually mark an item as “completed” (line-through, faded color).
- Keep the DOM accessible by updating ARIA attributes.
🎯 Goal
Implement a “click-to-complete” feature: clicking an item marks it as complete, clicking again restores it.
🧩 Starter HTML structure
<section class="todo">
<h3>My Tasks</h3>
<ul id="todo-list">
<li class="todo-item" role="checkbox" aria-checked="false" tabindex="0">Buy coffee beans</li>
<li class="todo-item" role="checkbox" aria-checked="false" tabindex="0">Email the client</li>
<li class="todo-item" role="checkbox" aria-checked="false" tabindex="0">Book dentist appointment</li>
</ul>
</section>
🔎 Accessibility note
We use role="checkbox", aria-checked, and tabindex="0" for screen reader and keyboard navigation support. We’ll update aria-checked when toggling completion status.
🎨 CSS: style for completed items
.todo-item {
cursor: pointer;
padding: 8px 10px;
border-bottom: 1px solid #eee;
transition: background 0.15s ease;
}
.todo-item:hover,
.todo-item:focus {
background: #f7faff;
outline: none;
}
.todo-item.done {
text-decoration: line-through;
color: #6b7280; /* muted gray */
opacity: 0.85;
}
⚙️ JavaScript: toggle “done” class (click & keyboard)
We’ll use event delegation: instead of adding a listener to each <li>, we attach it to the parent <ul> and detect which item was clicked.
// script.js
const list = document.getElementById('todo-list');
function toggleItem(el) {
el.classList.toggle('done');
const isDone = el.classList.contains('done');
el.setAttribute('aria-checked', String(isDone));
}
list.addEventListener('click', (e) => {
const item = e.target.closest('.todo-item');
if (!item || !list.contains(item)) return;
toggleItem(item);
});
// Keyboard support: Space/Enter
list.addEventListener('keydown', (e) => {
const item = e.target.closest('.todo-item');
if (!item) return;
const key = e.key.toLowerCase();
if (key === ' ' || key === 'enter') {
e.preventDefault(); // prevent page scroll with Space
toggleItem(item);
}
});
💡 Why event delegation?
If the list grows (items added or removed), you don’t need to attach new listeners. You listen once on the parent list and handle the targets dynamically. Less memory, less code, more scalability.
📦 Variation: dedicated “Complete” button
If you prefer an explicit control for each item, add a button inside the <li>.
<ul id="todo-list">
<li class="todo-item" role="checkbox" aria-checked="false" tabindex="0">
Buy coffee beans
<button class="mark-btn" aria-label="Mark as complete">Complete</button>
</li>
</ul>
list.addEventListener('click', (e) => {
const btn = e.target.closest('.mark-btn');
if (!btn) return;
const item = btn.closest('.todo-item');
if (!item) return;
item.classList.toggle('done');
const isDone = item.classList.contains('done');
item.setAttribute('aria-checked', String(isDone));
// Update button text/label
btn.textContent = isDone ? 'Undo' : 'Complete';
btn.setAttribute('aria-label', isDone ? 'Mark as not complete' : 'Mark as complete');
});
🗃️ (Optional) Persist state with localStorage
Want completed status to remain after a page refresh? Serialize states to localStorage.
const STORAGE_KEY = 'todoStates-v1';
function saveState() {
const states = [];
list.querySelectorAll('.todo-item').forEach((li) => {
states.push({ text: li.textContent.trim(), done: li.classList.contains('done') });
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(states));
}
function loadState() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
try {
const states = JSON.parse(raw);
const items = list.querySelectorAll('.todo-item');
states.forEach((st, i) => {
const li = items[i];
if (!li) return;
li.classList.toggle('done', !!st.done);
li.setAttribute('aria-checked', String(!!st.done));
});
} catch (e) {
console.warn('Storage parse error', e);
}
}
list.addEventListener('click', (e) => {
if (e.target.closest('.todo-item')) {
toggleItem(e.target.closest('.todo-item'));
saveState();
}
});
list.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key.toLowerCase() === 'enter') {
toggleItem(e.target.closest('.todo-item'));
saveState();
}
});
document.addEventListener('DOMContentLoaded', loadState);
🚫 Common mistakes
- Overwriting
classNameinstead of usingclassList.toggle()(can remove other classes unintentionally). - Forgetting to update
aria-checked— the interface looks fine visually but isn’t accessible. - Attaching a listener to each <li> individually (not scalable).
- Handling only clicks and ignoring keyboard events (Space/Enter).
🧪 Exercises
- Dual class: in addition to
.done, add a.highlightclass when an item is focused (keyboard) and remove it on blur. - Dynamic counter: display “N completed out of M” and update it on each toggle.
- Filters: add three buttons “All / Active / Completed” that filter items using CSS classes (show/hide).
- Unique IDs: generate a unique ID for each item and persist state in localStorage using a {id: done} map instead of index matching.
📌 Key takeaways
element.classList.toggle('done')is the fastest way to mark an item as complete.- Event delegation keeps code lightweight and scalable.
- Accessibility: always update
aria-checkedand handle Space/Enter. localStoragecan persist state across page reloads.
