4. Completing the project
id
Adding an 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
todo
ref to contain all the data, based on theinitialTodoItems
passed to the function. - It creates a
remainingTodoCount
computed, to be used in our component later. - It creates 4 functions that operate on our todos (add, remove, update, clear).
- It creates the
allDone
computed, used by thetoggleAll
(making use of theremainingTodoCount
computed) - 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
LocalStorage
Sync to Lastly, sync to LocalStorage
is relatively simple;
- We read what's in
LocalStorage
, and pass that as theinitialTodos
. - Whenever the
todos
list 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