13단원에서는 네 레슨에 걸쳐 **오늘 할 일 앱(app.js)**을 처음부터 끝까지 완성합니다. 첫 단계인 이 레슨에서는 할 일 배열 todos 하나를 기준으로 화면의 목록(#todo-list), 빈 안내(#empty), 남은 개수(#count) 세 곳을 한꺼번에 그리는 render() 함수를 만듭니다.
작업 폴더에 아래 index.html과 style.css를 준비하고 같은 폴더에 빈 app.js 파일을 만듭니다(index.html 받기 · style.css 받기). index.html 맨 아래 </body> 바로 위에는 <script src="app.js" defer> 태그를 넣어 둡니다.
완성 index.html 펼치기 (defer 스크립트 포함)
<!-- index.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width"
/>
<title>오늘 할 일</title>
<link
rel="stylesheet"
href="style.css"
/>
</head>
<body>
<main class="app">
<h1>오늘 할 일</h1>
<p id="quote" class="quote">
오늘도 한 걸음씩.
</p>
<form id="todo-form">
<label
class="sr-only"
for="todo-input"
>
할 일
</label>
<input
id="todo-input"
type="text"
placeholder="예: 장보기"
autocomplete="off"
maxlength="100"
/>
<button type="submit">
추가
</button>
</form>
<div
id="filters"
class="filters"
>
<button
type="button"
data-filter="all"
class="active"
>
전체
</button>
<button
type="button"
data-filter="active"
>
진행 중
</button>
<button
type="button"
data-filter="done"
>
완료
</button>
</div>
<ul id="todo-list"></ul>
<p
id="empty"
class="empty"
hidden
>
할 일이 없습니다.
</p>
<footer class="footer">
<p
id="count"
class="count"
>
전체 0개 중 0개 남음
</p>
<button
type="button"
id="clear-done"
class="clear"
>
완료한 일 지우기
</button>
</footer>
</main>
<script
src="app.js"
defer
></script>
</body>
</html>완성 style.css 펼치기 — 이 파일은 읽지 않아도 됩니다
/* style.css */
* {
box-sizing: border-box;
}
:root {
--bg: #f5f5f7;
--surface: #ffffff;
--text: #1d1d1f;
--muted: #6e6e73;
--line: #d2d2d7;
--accent: #005bb5;
--danger: #d70015;
color-scheme: light dark;
}
@media (
prefers-color-scheme: dark
) {
:root {
--bg: #121820;
--surface: #1d2633;
--text: #f1f4f8;
--muted: #a3adb8;
--line: #3a4552;
--accent: #9ac7ff;
--danger: #ff6b6b;
}
}
body {
margin: 0;
padding: 40px 16px;
background: var(--bg);
color: var(--text);
font-family:
system-ui,
sans-serif;
line-height: 1.6;
}
.app {
max-width: 520px;
margin: 0 auto;
padding: 24px;
background: var(--surface);
border-radius: 12px;
box-shadow:
0 1px 3px rgb(0 0 0 / 0.1);
}
h1 {
margin: 0 0 8px;
font-size: 1.5rem;
}
.quote {
margin: 0 0 20px;
padding: 12px;
background: var(--bg);
border-radius: 8px;
color: var(--muted);
font-size: 0.875rem;
}
#todo-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
#todo-input {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font-size: 0.9375rem;
font-family: inherit;
}
button {
padding: 10px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--surface);
font-size: 0.9375rem;
font-family: inherit;
cursor: pointer;
}
.filters {
display: flex;
gap: 6px;
margin-bottom: 16px;
}
.filters button {
padding: 6px 12px;
background: var(--bg);
color: var(--text);
font-size: 0.875rem;
}
.filters button.active {
background: var(--text);
color: var(--surface);
}
#todo-list {
margin: 0;
padding: 0;
list-style: none;
}
#todo-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 4px;
border-bottom:
1px solid var(--line);
}
#todo-list li:last-child {
border-bottom: none;
}
#todo-list input {
width: 18px;
height: 18px;
cursor: pointer;
}
.text {
flex: 1;
cursor: pointer;
overflow-wrap: anywhere;
}
.delete {
padding: 4px 10px;
background: transparent;
color: var(--muted);
font-size: 1.125rem;
line-height: 1;
}
.done .text {
color: var(--muted);
text-decoration: line-through;
}
.empty {
padding: 32px 0;
color: var(--muted);
text-align: center;
}
.footer {
display: flex;
align-items: center;
justify-content:
space-between;
margin-top: 16px;
padding-top: 16px;
border-top:
1px solid var(--line);
}
.count {
margin: 0;
color: var(--muted);
font-size: 0.875rem;
}
.clear {
padding: 6px 12px;
background: transparent;
color: var(--muted);
font-size: 0.875rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}1상태 배열 todos와 화면 요소 준비
할 일 한 줄은 { id, text, done } 속성을 가진 객체 하나로 나타냅니다. 처음 화면을 확인할 수 있도록 할 일 세 개를 todos 배열에 넣고, 화면에서 자주 쓸 요소들을 querySelector로 찾아 둡니다.
// app.js — 1. 상태와 요소 찾기
'use strict';
let todos = [
{
id: 1,
text: '우유 사기',
done: false,
},
{
id: 2,
text: ' 복습하기'.trim(),
done: true,
},
];
이어서 세 번째 항목을 넣고 목록(#todo-list), 빈 안내(#empty), 남은 개수(#count) 요소를 찾습니다.
todos.push({
id: 3,
text: '산책하기',
done: false,
});
const listEl = document
.querySelector('#todo-list');
const emptyEl = document
.querySelector('#empty');
const countEl = document
.querySelector('#count');
2한 줄 요소 만들기: makeRow(t)
할 일 객체 t 하나를 받아 <li data-id="..."> 안에 체크박스(<input type="checkbox">), 글자(<span class="text">), 삭제 단추(<button class="delete">)를 조립해 돌려주는 도우미 함수를 만듭니다.
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;
이어서 글자 span과 삭제 단추 btn을 만들어 li에 붙입니다.
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;
}
li.dataset.id = String(t.id): 다음 레슨에서 어느 줄이 눌렸는지 찾을 수 있게<li data-id="1">속성을 심어 둡니다.toggle('done', t.done)과box.checked = t.done: 완료된 할 일(t.done === true)이면.done취소선 클래스와 체크박스 체크 표시를 둘 다 켭니다.
3목록·빈 안내·개수를 한 번에 그리는 render()
상태 배열 todos와 화면은 저절로 맞물려 있지 않습니다. 그래서 render() 함수가 불릴 때마다 1) 목록을 비우고 새로 붙이기 → 2) 빈 안내 숨김/표시 → 3) 남은 개수 문구 계산을 한꺼번에 처리합니다.
function render() {
listEl.textContent = '';
for (const t of todos) {
listEl.append(makeRow(t));
}
emptyEl.hidden =
todos.length > 0;
const left = todos.filter(
(t) => !t.done,
).length;
countEl.textContent =
`전체 ${todos.length}개 중 ` +
`${left}개 남음`;
}
render();
- [ ] 우유 사기 ×
- [✓] 복습하기 × (취소선)
- [ ] 산책하기 ×
전체 3개 중 2개 남음emptyEl.hidden과 countEl.textContent 계산이 어떻게 돌아가는지 Node에서 그대로 확인해 봅니다.
const todos = [
{ id: 1, done: false },
{ id: 2, done: true },
{ id: 3, done: false },
];
const hide = todos.length > 0;
const left = todos.filter(
(t) => !t.done,
).length;
console.log('hidden:', hide);
console.log(
`전체 ${todos.length}개 중 ` +
`${left}개 남음`,
);
hidden: true
전체 3개 중 2개 남음- 할 일이 1개라도 있으면
todos.length > 0이true가 되어emptyEl.hidden = true(빈 안내 숨김)가 되고, 모두 지워져0개가 되면false가 되어'할 일이 없습니다.'가 나타납니다.
자주 틀리는 곳
todos배열만 만들어 두고 마지막에render()를 부르지 않는다: 함수를 정의만 해 두면 화면은 처음 HTML 그대로 비어 있습니다. 파일 끝에서render();를 한 번 불러야 첫 화면이 그려집니다.li에.done클래스만 붙이고box.checked = t.done을 빼먹는다:.done클래스는 글자에 취소선만 그어 줄 뿐 체크박스 네모 칸을 체크해 주지 않습니다. 두 속성을 함께 맞춰 줍니다.emptyEl.hidden조건을 반대로 적는다:hidden속성은 값이true일 때 숨겨집니다. 할 일이 있을 때(todos.length > 0) 숨겨야 하므로emptyEl.hidden에todos.length > 0을 대입합니다.
확인 문제
todos배열에 항목이 3개 있고 그중done: true인 항목이 2개일 때,todos.filter((t) => !t.done)의 길이(.length)는 얼마인가요?
정답
아직 끝내지 않은(!t.done) 항목의 개수이므로 **1**입니다.
- 모든 할 일을 지워서
todos.length가0이 되었습니다. 이때emptyEl.hidden에todos.length > 0을 대입하면 값은true인가요,false인가요? 화면에는 어떻게 보이나요?
정답
0 > 0은 false이므로 emptyEl.hidden이 false가 되어, 숨겨져 있던 '할 일이 없습니다.' 문단이 화면에 나타납니다.