4. Completing the project
Adding an id
To more easily manage our Todo items when filtering and editing them, it helps to add an id to each item.
In the TodoItem.template.ts we simply add the id as required prop, and set it as a data-id attribute, so it can be extracted within the component.
import type { ComponentTemplateResult } from '@muban/template';
import { html } from '@muban/template';
export type TodoItemTemplateProps = {
id: string;
title: string;
isCompleted: boolean;
};
export function todoItemTemplate({
id,
title,
isCompleted,
}: TodoItemTemplateProps): ComponentTemplateResult {
// there is also an `editing` class, but that's only set when interacting with the element
return html`<li
data-component="todo-item"
data-id=${id}
class="${isCompleted ? 'completed' : ''}"
>
<div class="view">
<input data-ref="completedInput" class="toggle" type="checkbox" checked=${isCompleted} />
<label data-ref="title">${title}</label>
<button data-ref="destroyButton" class="destroy"></button>
</div>
<input data-ref="editInput" class="edit" />
</li>`;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Then, in the TodoItem.ts component, we define it has a required prop. We don't specify any source, since by default it will try to extract it from the root element, and use data- attributes as a default source (along with json and class, if it can't find an attribute).
We also update the shape of the onChange and onDelete props to require the id as a first parameter. Those callback functions can then use the id to identify the item, instead of trying to rely on an index.
props: {
id: propType.string,
title: propType.string.source({ type: 'text', target: 'title' }),
isCompleted: propType.boolean.source({ type: 'css', name: 'completed' }),
onChange:
propType.func.optional.shape<
(id: string, data: { title?: string; isCompleted?: boolean }) => void
>(),
onDelete: propType.func.optional.shape<(id: string) => void>(),
},
2
3
4
5
6
7
8
9
10
The places where we call those functions, we provide the props.id as a first parameter.
if (saveValue) {
props.onChange?.(props.id, { title: editValue.value });
} else {
2
3
props.onChange?.(props.id, {
isCompleted: value,
});
2
3
click() {
props.onDelete?.(props.id);
},
2
3
Now that we no longer need to use indices to find the todo item to update/remove, we don't have to use bindMap for our refs.todoItems anymore, making this a lot simpler:
bind(refs.appFooter, {
style: {
display: computed(() => (todos.value.length === 0 ? 'none' : 'block')),
},
2
3
4
As part of a big refactor, we will move most code to a separate file called useTodos.ts.. However, if you want to follow along with just this step, you can place these functions in the App.ts as well, since they only operate on the todos ref.
const addTodo = (title: string) => {
const sanitizedTitle = title.trim();
if (sanitizedTitle !== '') {
todos.value = todos.value.concat({ title: sanitizedTitle, isCompleted: false, id: nanoid() });
}
};
const removeTodo = (id: string) => {
todos.value = todos.value.filter((item) => item.id !== id);
};
const updateTodo = (id: string, newValues: Partial<TodoItemTemplateProps>) => {
let newItem = newValues;
if (newValues.title !== undefined) {
const sanitizedTitle = newValues.title.trim();
// if an empty title would be set, remove the item
if (sanitizedTitle === '') {
removeTodo(id);
return;
}
// update with sanitizedTitle when not empty
newItem = { ...newValues, title: sanitizedTitle };
}
todos.value = todos.value.map((item) => (item.id === id ? { ...item, ...newItem } : item));
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Besides adding the id (the highlighted lines, note that we use nanoid() to generate a random ID whenever we create a nte item), we made some other changes;
- We're filtering on
item.id !== id, instead of the index. - We sanitize the title using
title.trim(), to ignore empty todos when adding them. - When editing todos, we delete items that would otherwise have an empty title.
At this point we also stop rendering our initial todos from the main.ts, since they require an id, and we don't have any server storage. In the next steps these will be synced with LocalStorage, so that will be the initial data source on page load.
app.mount(appRoot, appTemplate, {
title: 'Todos',
});
2
3
Note; Full code of everything is shown at the bottom of this page.
Moving code to a "hook"
The code in the App.ts is growing, so now is a good time to move it into a separate function. This function receives the initial state, and returns some data and a set of functions to be used for interacting with that data.
Create a src/components/app/useTodos.ts file and add in the following code:
import { computed, ref } from '@muban/muban';
import { nanoid } from 'nanoid';
import type { TodoItemTemplateProps } from '../todo-item/TodoItem.template';
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function useTodos(initialTodoItems: Array<TodoItemTemplateProps>) {
const todos = ref(initialTodoItems);
const remainingTodoCount = computed(() => todos.value.filter((todo) => !todo.isCompleted).length);
const addTodo = (title: string) => {
const sanitizedTitle = title.trim();
if (sanitizedTitle !== '') {
todos.value = todos.value.concat({ title: sanitizedTitle, isCompleted: false, id: nanoid() });
}
};
const removeTodo = (id: string) => {
todos.value = todos.value.filter((item) => item.id !== id);
};
const updateTodo = (id: string, newValues: Partial<TodoItemTemplateProps>) => {
let newItem = newValues;
if (newValues.title !== undefined) {
const sanitizedTitle = newValues.title.trim();
// if an empty title would be set, remove the item
if (sanitizedTitle === '') {
removeTodo(id);
return;
}
// update with sanitizedTitle when not empty
newItem = { ...newValues, title: sanitizedTitle };
}
todos.value = todos.value.map((item) => (item.id === id ? { ...item, ...newItem } : item));
};
const clearCompleted = () => {
todos.value = todos.value.filter((todo) => !todo.isCompleted);
};
const allDone = computed({
get() {
return remainingTodoCount.value === 0;
},
set(isCompleted: boolean) {
todos.value = todos.value.map((todo) => ({
...todo,
isCompleted,
}));
},
});
return {
todos,
remainingTodoCount,
addTodo,
removeTodo,
updateTodo,
clearCompleted,
allDone,
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
This code does the following:
- It creates the
todoref to contain all the data, based on theinitialTodoItemspassed to the function. - It creates a
remainingTodoCountcomputed, to be used in our component later. - It creates 4 functions that operate on our todos (add, remove, update, clear).
- It creates the
allDonecomputed, used by thetoggleAll(making use of theremainingTodoCountcomputed) - It returns all refs/computed/functions
Now we can use this in App.ts.
const { todos, remainingTodoCount, addTodo, removeTodo, updateTodo, clearCompleted, allDone } =
useTodos(initialTodoItems);
2
First we call the useTodos hook, and destructure what's returned.
Then we can use this in our bindings. This now looks a lot nicer.
return [
bind(refs.appHeader, {
onCreate: addTodo,
}),
bind(refs.toggleAllInput, {
checked: allDone,
}),
bind(refs.mainSection, {
style: {
display: computed(() => (todos.value.length === 0 ? 'none' : 'block')),
},
}),
bindTemplate(refs.todoList, todos, (items) =>
items.map((itemData) => todoItemTemplate(itemData)).join(''),
),
bind(refs.todoItems, {
onChange: updateTodo,
onDelete: removeTodo,
}),
bind(refs.appFooter, {
style: {
display: computed(() => (todos.value.length === 0 ? 'none' : 'block')),
},
remainingTodoCount,
onClearCompleted: clearCompleted,
}),
];
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Adding routing
Since we have some <a href="#..."> code in the AppFooter, let's create a function that can extract this value from the URL when clicked. We add this in useTodos.ts since it's only used there for now.
function getFilterFromUrl(): 'active' | 'completed' | undefined {
const [, filter] = document.location.hash.split('/');
if (filter === 'active' || filter === 'completed') {
return filter;
}
return undefined;
}
2
3
4
5
6
7
Adding filtering
Now that we have that set up, let's add in all the code required to filter the todos.
export function useTodos(initialTodoItems: Array<TodoItemTemplateProps>) {
const todos = ref(initialTodoItems);
const selectedFilter = ref<'active' | 'completed' | undefined>(getFilterFromUrl());
useEventListener(window, 'popstate', () => {
selectedFilter.value = getFilterFromUrl();
});
const filteredTodos = computed(() => {
if (selectedFilter.value === undefined) {
return todos.value;
}
return todos.value.filter(
(todo) => todo.isCompleted === (selectedFilter.value === 'completed'),
);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
selectedFilter will keep track of which filter is selected, initialized by reading the current filter from the URL (this makes sure it's correct on page load).
Then, whenever the url changes (listing for popstate using the useEventListener hook from @muban/hooks), we update the selectedFilter.
Lastly, we create computed that filters the todos based on the selectedFilter. For performance reasons we the todos.value when no filter is set.
Then, make sure these are returned and destructured from the useTodos;
return {
todos,
filteredTodos,
selectedFilter,
remainingTodoCount,
addTodo,
removeTodo,
updateTodo,
clearCompleted,
allDone,
};
2
3
4
5
6
7
8
9
10
11
const {
todos,
filteredTodos,
selectedFilter,
remainingTodoCount,
addTodo,
removeTodo,
updateTodo,
clearCompleted,
allDone,
} = useTodos(initialTodoItems);
2
3
4
5
6
7
8
9
10
11
And then update our bindings:
bindTemplate(refs.todoList, filteredTodos, (items) =>
items.map((itemData) => todoItemTemplate(itemData)).join(''),
),
2
3
bind(refs.appFooter, {
style: {
display: computed(() => (todos.value.length === 0 ? 'none' : 'block')),
},
remainingTodoCount,
selectedFilter,
onClearCompleted: clearCompleted,
}),
2
3
4
5
6
7
8
And of course, update AppFooter.ts;
props: {
remainingTodoCount: propType.number.defaultValue(0),
onClearCompleted: propType.func.optional.shape<() => void>(),
selectedFilter: propType.string.optional,
},
2
3
4
5
bind(refs.filterAll, {
css: { selected: computed(() => props.selectedFilter === undefined) },
}),
bind(refs.filterActive, {
css: { selected: computed(() => props.selectedFilter === 'active') },
}),
bind(refs.filterCompleted, {
css: { selected: computed(() => props.selectedFilter === 'completed') },
}),
2
3
4
5
6
7
8
9
Sync to LocalStorage
Lastly, sync to LocalStorage is relatively simple;
- We read what's in
LocalStorage, and pass that as theinitialTodos. - Whenever the
todoslist changes, we write that back toLocalStorage.
const initialTodoItems = JSON.parse(localStorage.getItem('MUBAN_TODO_MVC_LIST') ?? '[]') || [];
const {
todos,
filteredTodos,
selectedFilter,
remainingTodoCount,
addTodo,
removeTodo,
updateTodo,
clearCompleted,
allDone,
} = useTodos(initialTodoItems);
watchEffect(() => {
localStorage.setItem('MUBAN_TODO_MVC_LIST', JSON.stringify(todos.value));
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
All code
Below you can find the latest code (split into two sections).
You can also find these in the repositoryopen in new window.
import './style.css';
import { createApp } from '@muban/muban';
import { App } from './components/app/App';
import { appTemplate } from './components/app/App.template';
const appRoot = document.getElementById('app')!;
const app = createApp(App);
app.mount(appRoot, appTemplate, {
title: 'Todos',
});
2
3
4
5
6
7
8
9
10
11
12
import {
bind,
bindTemplate,
computed,
defineComponent,
refComponent,
refComponents,
refElement,
watchEffect,
} from '@muban/muban';
import { AppFooter } from '../app-footer/AppFooter';
import { AppHeader } from '../app-header/AppHeader';
import { TodoItem } from '../todo-item/TodoItem';
import { todoItemTemplate } from '../todo-item/TodoItem.template';
import { useTodos } from './useTodos';
export const App = defineComponent({
name: 'app',
refs: {
appHeader: refComponent(AppHeader),
toggleAllInput: refElement<HTMLInputElement>('toggleAllInput'),
mainSection: 'mainSection',
todoList: 'todoList',
todoItems: refComponents(TodoItem),
appFooter: refComponent(AppFooter),
},
setup({ refs }) {
const initialTodoItems = JSON.parse(localStorage.getItem('MUBAN_TODO_MVC_LIST') ?? '[]') || [];
const {
todos,
filteredTodos,
selectedFilter,
remainingTodoCount,
addTodo,
removeTodo,
updateTodo,
clearCompleted,
allDone,
} = useTodos(initialTodoItems);
watchEffect(() => {
localStorage.setItem('MUBAN_TODO_MVC_LIST', JSON.stringify(todos.value));
});
return [
bind(refs.appHeader, {
onCreate: addTodo,
}),
bind(refs.toggleAllInput, {
checked: allDone,
}),
bind(refs.mainSection, {
style: {
display: computed(() => (todos.value.length === 0 ? 'none' : 'block')),
},
}),
bindTemplate(refs.todoList, filteredTodos, (items) =>
items.map((itemData) => todoItemTemplate(itemData)).join(''),
),
bind(refs.todoItems, {
onChange: updateTodo,
onDelete: removeTodo,
}),
bind(refs.appFooter, {
style: {
display: computed(() => (todos.value.length === 0 ? 'none' : 'block')),
},
remainingTodoCount,
selectedFilter,
onClearCompleted: clearCompleted,
}),
];
},
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import type { ComponentTemplateResult } from '@muban/template';
import { html } from '@muban/template';
import { appFooterTemplate } from '../app-footer/AppFooter.template';
import { appHeaderTemplate } from '../app-header/AppHeader.template';
import type { TodoItemTemplateProps } from '../todo-item/TodoItem.template';
import { todoItemTemplate } from '../todo-item/TodoItem.template';
export type AppTemplateProps = {
title?: string;
todos?: Array<TodoItemTemplateProps>;
};
export function appTemplate({ title, todos = [] }: AppTemplateProps = {}): ComponentTemplateResult {
return html`
<div data-component="app">
<section class="todoapp">
${appHeaderTemplate({ title })}
<section data-ref="mainSection" class="main">
<input data-ref="toggleAllInput" id="toggle-all" class="toggle-all" type="checkbox" />
<label for="toggle-all">Mark all as complete</label>
<ul data-ref="todoList" class="todo-list">
${todos.map((todo) => todoItemTemplate(todo))}
</ul>
</section>
${appFooterTemplate({ uncompletedCount: todos.filter((todo) => !todo.isCompleted).length })}
</section>
<footer class="info">
<p>Double-click to edit a todo</p>
<p>Created by <a href="http://github.com/mubanjs">Muban</a></p>
</footer>
</div>
`;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useEventListener } from '@muban/hooks';
import { computed, ref } from '@muban/muban';
import { nanoid } from 'nanoid';
import type { TodoItemTemplateProps } from '../todo-item/TodoItem.template';
function getFilterFromUrl(): 'active' | 'completed' | undefined {
const [, filter] = document.location.hash.split('/');
if (filter === 'active' || filter === 'completed') {
return filter;
}
return undefined;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function useTodos(initialTodoItems: Array<TodoItemTemplateProps>) {
const todos = ref(initialTodoItems);
const selectedFilter = ref<'active' | 'completed' | undefined>(getFilterFromUrl());
useEventListener(window, 'popstate', () => {
selectedFilter.value = getFilterFromUrl();
});
const filteredTodos = computed(() => {
if (selectedFilter.value === undefined) {
return todos.value;
}
return todos.value.filter(
(todo) => todo.isCompleted === (selectedFilter.value === 'completed'),
);
});
const remainingTodoCount = computed(() => todos.value.filter((todo) => !todo.isCompleted).length);
const addTodo = (title: string) => {
const sanitizedTitle = title.trim();
if (sanitizedTitle !== '') {
todos.value = todos.value.concat({ title: sanitizedTitle, isCompleted: false, id: nanoid() });
}
};
const removeTodo = (id: string) => {
todos.value = todos.value.filter((item) => item.id !== id);
};
const updateTodo = (id: string, newValues: Partial<TodoItemTemplateProps>) => {
let newItem = newValues;
if (newValues.title !== undefined) {
const sanitizedTitle = newValues.title.trim();
// if an empty title would be set, remove the item
if (sanitizedTitle === '') {
removeTodo(id);
return;
}
// update with sanitizedTitle when not empty
newItem = { ...newValues, title: sanitizedTitle };
}
todos.value = todos.value.map((item) => (item.id === id ? { ...item, ...newItem } : item));
};
const clearCompleted = () => {
todos.value = todos.value.filter((todo) => !todo.isCompleted);
};
const allDone = computed({
get() {
return remainingTodoCount.value === 0;
},
set(isCompleted: boolean) {
todos.value = todos.value.map((todo) => ({
...todo,
isCompleted,
}));
},
});
return {
todos,
filteredTodos,
selectedFilter,
remainingTodoCount,
addTodo,
removeTodo,
updateTodo,
clearCompleted,
allDone,
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import { bind, computed, defineComponent, propType, ref, refElement } from '@muban/muban';
export const TodoItem = defineComponent({
name: 'todo-item',
refs: {
completedInput: 'completedInput',
title: 'title',
destroyButton: 'destroyButton',
editInput: refElement<HTMLInputElement>('editInput'),
},
props: {
id: propType.string,
title: propType.string.source({ type: 'text', target: 'title' }),
isCompleted: propType.boolean.source({ type: 'css', name: 'completed' }),
onChange:
propType.func.optional.shape<
(id: string, data: { title?: string; isCompleted?: boolean }) => void
>(),
onDelete: propType.func.optional.shape<(id: string) => void>(),
},
setup({ props, refs }) {
const isEditing = ref(false);
// since we can exit edit mode without saving, we need to store the temp value here
const editValue = ref(props.title);
const exitEditing = (saveValue = false) => {
// either save the value, or restore it to the previous state
if (saveValue) {
props.onChange?.(props.id, { title: editValue.value });
} else {
editValue.value = props.title;
}
// exit editing mode
isEditing.value = false;
};
return [
bind(refs.self, {
css: {
completed: computed(() => props.isCompleted),
editing: isEditing,
},
}),
bind(refs.completedInput, {
checked: computed({
get: () => props.isCompleted,
set(value) {
props.onChange?.(props.id, {
isCompleted: value,
});
},
}),
}),
bind(refs.title, {
event: {
dblclick() {
isEditing.value = true;
// delay focussing until the element is updated to `display: block`
// the `hasFocus` binding has the same issue – being too quick
queueMicrotask(() => {
refs.editInput.element?.focus();
});
},
},
text: computed(() => props.title),
}),
bind(refs.editInput, {
textInput: editValue,
event: {
keydown(event) {
if (['Esc', 'Escape'].includes(event.key)) {
exitEditing();
} else if (['Enter'].includes(event.key)) {
exitEditing(true);
}
},
blur() {
exitEditing(true);
},
},
}),
bind(refs.destroyButton, {
click() {
props.onDelete?.(props.id);
},
}),
];
},
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import type { ComponentTemplateResult } from '@muban/template';
import { html } from '@muban/template';
export type TodoItemTemplateProps = {
id: string;
title: string;
isCompleted: boolean;
};
export function todoItemTemplate({
id,
title,
isCompleted,
}: TodoItemTemplateProps): ComponentTemplateResult {
// there is also an `editing` class, but that's only set when interacting with the element
return html`<li
data-component="todo-item"
data-id=${id}
class="${isCompleted ? 'completed' : ''}"
>
<div class="view">
<input data-ref="completedInput" class="toggle" type="checkbox" checked=${isCompleted} />
<label data-ref="title">${title}</label>
<button data-ref="destroyButton" class="destroy"></button>
</div>
<input data-ref="editInput" class="edit" />
</li>`;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { bind, defineComponent, propType, ref } from '@muban/muban';
export const AppHeader = defineComponent({
name: 'app-header',
refs: {
newTodoInput: 'newTodoInput',
},
props: {
onCreate: propType.func.optional.shape<(value: string) => void>(),
},
setup({ refs, props }) {
const inputValue = ref('');
return [
bind(refs.newTodoInput, {
textInput: inputValue,
event: {
keyup(event) {
if (event.key === 'Enter') {
props.onCreate?.(inputValue.value);
inputValue.value = '';
}
},
},
}),
];
},
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import type { ComponentTemplateResult } from '@muban/template';
import { html } from '@muban/template';
export type AppHeaderTemplateProps = {
title?: string;
};
export function appHeaderTemplate({
title = 'Todos',
}: AppHeaderTemplateProps = {}): ComponentTemplateResult {
return html`<div data-component="app-header" class="header">
<h1>${title}</h1>
<input
data-ref="newTodoInput"
class="new-todo"
placeholder="What needs to be done?"
autofocus
/>
</div>`;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { bind, computed, defineComponent, propType } from '@muban/muban';
export const AppFooter = defineComponent({
name: 'app-footer',
refs: {
remainingCount: 'remainingCount',
clearCompletedButton: 'clearCompletedButton',
filterAll: 'filterAll',
filterActive: 'filterActive',
filterCompleted: 'filterCompleted',
},
props: {
remainingTodoCount: propType.number.defaultValue(0),
onClearCompleted: propType.func.optional.shape<() => void>(),
selectedFilter: propType.string.optional,
},
setup({ refs, props }) {
return [
bind(refs.remainingCount, {
html: computed(
() =>
`<strong>${props.remainingTodoCount}</strong> ${
props.remainingTodoCount === 1 ? 'item' : 'items'
} left`,
),
}),
bind(refs.clearCompletedButton, {
click() {
props.onClearCompleted?.();
},
}),
bind(refs.filterAll, {
css: { selected: computed(() => props.selectedFilter === undefined) },
}),
bind(refs.filterActive, {
css: { selected: computed(() => props.selectedFilter === 'active') },
}),
bind(refs.filterCompleted, {
css: { selected: computed(() => props.selectedFilter === 'completed') },
}),
];
},
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import type { ComponentTemplateResult } from '@muban/template';
import { html } from '@muban/template';
type AppFooterTemplateProps = {
uncompletedCount?: number;
selectedFilter?: 'active' | 'completed';
};
export function appFooterTemplate({
uncompletedCount = 0,
selectedFilter,
}: AppFooterTemplateProps = {}): ComponentTemplateResult {
return html`
<footer data-component="app-footer" class="footer">
<span data-ref="remainingCount" class="todo-count">
<strong>${uncompletedCount}</strong> ${uncompletedCount === 1 ? 'item' : 'items'} left
</span>
<ul class="filters">
<li>
<a data-ref="filterAll" class="${!selectedFilter ? 'selected' : ''}" href="#/">All</a>
</li>
<li>
<a
data-ref="filterActive"
class="${selectedFilter === 'active' ? 'selected' : ''}"
href="#/active"
>
Active
</a>
</li>
<li>
<a
data-ref="filterCompleted"
class="${selectedFilter === 'completed' ? 'selected' : ''}"
href="#/completed"
>
Completed
</a>
</li>
</ul>
<!-- Hidden if no completed items are left ↓ -->
<button data-ref="clearCompletedButton" class="clear-completed">Clear completed</button>
</footer>
`;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45