세 번째 단계에서는 상단의 **필터 단추 셋('전체' · '진행 중' · '완료')**과 하단의 '완료한 일 지우기'(#clear-done) 단추를 연결합니다. 가장 중요한 핵심은 필터를 바꿀 때 원본 todos 배열을 덮어쓰지 않고, render() 안에서 화면에 보일 줄(visible)만 따로 골라낸다는 점입니다.
앞 레슨에서 만든 app.js 펼치기
// app.js (13-2까지)
'use strict';
let todos = [
{
id: 1,
text: '우유 사기',
done: false,
},
{
id: 2,
text: '복습하기',
done: true,
},
];todos.push({
id: 3,
text: '산책하기',
done: false,
});
let nextId = 4;
const listEl = document
.querySelector(
'#todo-list',
);
const emptyEl = document
.querySelector('#empty');
const countEl = document
.querySelector('#count');const formEl = document
.querySelector(
'#todo-form',
);
const inputEl = document
.querySelector(
'#todo-input',
);
function makeRow(t) {
const li = document
.createElement('li');
li.dataset.id =
String(t.id); li.classList.toggle(
'done',
t.done,
);
const box = document
.createElement('input');
box.type = 'checkbox';
box.checked = t.done;
box.setAttribute(
'aria-label',
'완료 표시',
);
const span = document
.createElement('span'); span.className = 'text';
span.textContent = t.text;
const btn = document
.createElement('button');
btn.type = 'button';
btn.className = 'delete';
btn.textContent = '×';
li.append(box, span, btn);
return li;
}function render() {
listEl.textContent = '';
for (const t of todos) {
const r = makeRow(t);
listEl.append(r);
}
emptyEl.hidden =
todos.length > 0;
const left = todos.filter(
(t) => !t.done,
).length;
countEl.textContent =
`전체 ${todos.length}개` +
` 중 ${left}개 남음`;
}formEl.addEventListener(
'submit',
(event) => {
event.preventDefault();
const text =
inputEl.value.trim();
if (text === '') return;
const item = {
id: nextId++,
text,
done: false,
}; todos.push(item);
inputEl.value = '';
render();
},
);listEl.addEventListener(
'click',
(event) => {
const li = event.target
.closest('li');
if (!li) return;
const id = Number(
li.dataset.id,
);
const del = event.target
.closest('.delete');
if (del) {
todos = todos.filter(
(t) => t.id !== id,
); } else {
const item =
todos.find(
(t) => t.id === id,
);
if (item) {
item.done =
!item.done;
}
}
render();
},
);
render();1필터 상태 변수 filter와 보일 줄 계산
현재 어떤 필터가 켜져 있는지를 나타내는 상태 변수 let filter = 'all';('all', 'active', 'done' 중 하나)을 추가합니다.
그리고 render() 안에서 todos를 바로 도는 대신, filter 값에 맞춰 화면에 그릴 배열 **visible**을 먼저 골라냅니다.
// filter 값에 따라 보일 줄 고르기
function getVisible(list, f) {
if (f === 'active') {
return list.filter(
(t) => !t.done,
);
}
if (f === 'done') {
return list.filter(
(t) => t.done,
);
}
return list;
}
이어서 세 가지 필터 값에서 getVisible이 무엇을 돌려주는지 Node로 확인해 봅니다.
const sample = [
{ id: 1, done: false },
{ id: 2, done: true },
{ id: 3, done: false },
];
console.log(
getVisible(sample, 'all')
.length,
);
console.log(
getVisible(sample, 'active')
.length,
);
console.log(
getVisible(sample, 'done')
.length,
);
console.log(sample.length);
3
2
1
3getVisible은 새 배열을 만들어 돌려줄 뿐 원본 배열 sample의 길이 3은 전혀 건드리지 않습니다. 만약 필터 단추를 누를 때 todos = todos.filter(...)로 원본 todos에 덮어써 버리면, 다시 '전체' 단추를 눌렀을 때 아까 숨겼던 항목들이 영영 사라져 돌아오지 못합니다.
2render() 고치기와 필터 단추 연결
app.js에서 필터 단추 묶음(#filters)과 완료 지우기 단추(#clear-done)를 추가로 찾고, render()가 visible 배열과 필터 단추의 .active 클래스를 함께 갱신하도록 고칩니다.
let filter = 'all';
const filtersEl = document
.querySelector('#filters');
const clearEl = document
.querySelector(
'#clear-done',
);
이어서 render() 함수를 아래처럼 바꿉니다.
function render() {
const visible = getVisible(
todos,
filter,
);
listEl.textContent = '';
for (const t of visible) {
listEl.append(makeRow(t));
}
emptyEl.hidden =
visible.length > 0;
이어서 필터 단추의 .active 표시와 남은 개수를 갱신합니다.
const btns = filtersEl
.querySelectorAll(
'button',
);
for (const b of btns) {
const on =
b.dataset.filter ===
filter;
b.classList.toggle(
'active',
on,
);
}
const left = todos.filter(
(t) => !t.done,
).length;
countEl.textContent =
`전체 ${todos.length}개 중 ` +
`${left}개 남음`;
}
classList.toggle('active', on): 세 필터 단추를 돌며data-filter가 지금filter와 같은 단추에만.active클래스를 켜고 나머지는 끕니다.emptyEl.hidden:visible.length > 0을 대입해 완료한 일이 하나도 없을 때'완료'탭을 누르면visible.length가0이 되어'할 일이 없습니다.'안내가 보입니다.
3#filters 위임과 #clear-done 리스너
이제 #filters 클릭 리스너와 #clear-done 클릭 리스너를 등록합니다.
filtersEl.addEventListener(
'click',
(event) => {
const btn = event.target
.closest('button');
if (!btn) return;
filter =
btn.dataset.filter;
render();
},
);
이어서 '완료한 일 지우기' 단추 리스너를 등록합니다.
clearEl.addEventListener(
'click',
() => {
todos = todos.filter(
(t) => !t.done,
);
render();
},
);
- '진행 중' 클릭 → 2줄만 표시
- '전체' 클릭 → 3줄 모두 표시
- '완료한 일 지우기' → 완료 줄 삭제자주 틀리는 곳
- 필터를 바꿀 때
todos = todos.filter(...)로 원본을 덮어쓴다: 원본todos를 덮어쓰면 필터링이 아니라 영구 삭제가 됩니다. 원본todos를 바꾸는 곳은 개별 삭제(×)와'완료한 일 지우기'(#clear-done) 두 곳뿐이고, 필터는render()안에서visible만 골라 그립니다. - 클릭한 필터 단추에만
classList.add('active')를 부른다: 이전에 켜져 있던 단추에서active를 빼지 않으면 단추 세 개가 모두 시꺼멓게 켜집니다.render()에서classList.toggle('active', on)으로 한 번에 맞추면 겹치지 않습니다.
확인 문제
'진행 중'필터 단추를 눌렀을 때 원본todos배열을 직접 바꾸지 않고 별도의visible배열을 계산해 그리는 까닭은 무엇인가요?
정답
원본 todos 배열을 덮어쓰면 완료된 항목들이 메모리에서 아예 지워져서, 나중에 '전체'나 '완료' 단추를 눌렀을 때 다시 보여 줄 수 없기 때문입니다.
'완료한 일 지우기'(#clear-done) 단추를 눌렀을 때todos배열에서 아직 끝나지 않은(!t.done) 항목만 남기려면 어떤 배열 메서드를 써야 하나요?
정답
todos.filter((t) => !t.done)을 불러 todos에 다시 대입합니다.