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>`;
}




 





 






 










1
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>(),
  },

 




 

 

1
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 {

 

1
2
3
            props.onChange?.(props.id, {
              isCompleted: value,
            });
 


1
2
3
        click() {
          props.onDelete?.(props.id);
        },

 

1
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')),
        },

 
 

1
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));
  };



 



 
 


 





 





 


1
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;

  1. We're filtering on item.id !== id, instead of the index.
  2. We sanitize the title using title.trim(), to ignore empty todos when adding them.
  3. 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',
});
1
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,
  };
}






 

 

 






 



 














 



 

 









 
 
 
 
 
 
 
 
 

1
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:

  1. It creates the todo ref to contain all the data, based on the initialTodoItems passed to the function.
  2. It creates a remainingTodoCount computed, to be used in our component later.
  3. It creates 4 functions that operate on our todos (add, remove, update, clear).
  4. It creates the allDone computed, used by the toggleAll (making use of the remainingTodoCount computed)
  5. It returns all refs/computed/functions

Now we can use this in App.ts.

    const { todos, remainingTodoCount, addTodo, removeTodo, updateTodo, clearCompleted, allDone } =
      useTodos(initialTodoItems);
1
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,
      }),
    ];


 

 
 
 









 
 





 
 


1
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;
}

 





1
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'),
    );
  });


 

 
 
 

 
 
 
 
 
 
 

1
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,
  };


 
 







1
2
3
4
5
6
7
8
9
10
11
    const {
      todos,
      filteredTodos,
      selectedFilter,
      remainingTodoCount,
      addTodo,
      removeTodo,
      updateTodo,
      clearCompleted,
      allDone,
    } = useTodos(initialTodoItems);


 
 







1
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(''),
      ),
 


1
2
3
      bind(refs.appFooter, {
        style: {
          display: computed(() => (todos.value.length === 0 ? 'none' : 'block')),
        },
        remainingTodoCount,
        selectedFilter,
        onClearCompleted: clearCompleted,
      }),





 


1
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,
  },



 

1
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') },
      }),

 


 


 

1
2
3
4
5
6
7
8
9

Sync to LocalStorage

Lastly, sync to LocalStorage is relatively simple;

  1. We read what's in LocalStorage, and pass that as the initialTodos.
  2. Whenever the todos list changes, we write that back to LocalStorage.
    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));
    });
 













 
 
 
1
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',
});
1
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,
      }),
    ];
  },
});
1
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>
  `;
}
1
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,
  };
}
1
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);
        },
      }),
    ];
  },
});
1
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>`;
}
1
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 = '';
            }
          },
        },
      }),
    ];
  },
});
1
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>`;
}
1
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') },
      }),
    ];
  },
});
1
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>
  `;
}
1
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