3. Adding Interaction

Now that we have our templates set up, it's time to make things interactive. We might do some things that are changed in later steps, those are to showcase some different methods of doing things, and help you understand the thinking process.

Editing a Todo

First, let's make our TodoItem interactive, so we can mark it as completed. Down the road we might want to manage this in the parent component, but let's start with just the TodoItem in isolation.

Create src/components/todo-item/TodoItem.ts, first with an empty component.

import { defineComponent } from '@muban/muban';

export const TodoItem = defineComponent({
  name: 'todo-item',
  setup() {
    return [];
  },
});
1
2
3
4
5
6
7
8

Nothing special going on here, just make sure that the name matches that in the data-component template.

Refs

Next, let's add our refs;

import { defineComponent } from '@muban/muban';

export const TodoItem = defineComponent({
  name: 'todo-item',
  refs: {
    completedInput: 'completedInput',
    title: 'title',
    destroyButton: 'destroyButton',
    editInput: 'editInput',
  },
  setup() {
    return [];
  },
});




 
 
 
 
 
 




1
2
3
4
5
6
7
8
9
10
11
12
13
14

The refs object specifies which elements from the template / DOM we want to use in our component. The keys of that object is what we will be using in our setup function later. The value is the "definition" of the ref – in this case, the value of the data-ref attribute.

If we only care about the HTML Element, and it's required, we can use a string value as a shortcut, what we are doing here. It's the same as doing completedInput: refElement('completedInput'),. refElement also has some options as its second parameter, something we might come across later, or you can look up in the docs.

There are other "refDefinitions" you can use (for components, or collections of elements or components), which will also be covered at later steps.

Props

Next up are the props;

import { defineComponent, propType } from '@muban/muban';

export const TodoItem = defineComponent({
  name: 'todo-item',
  refs: {
    completedInput: 'completedInput',
    title: 'title',
    destroyButton: 'destroyButton',
    editInput: 'editInput',
  },
  props: {
    title: propType.string.source({ type: 'text', target: 'title' }),
    isCompleted: propType.boolean.source({ type: 'css', name: 'completed' }),
  },
  setup() {
    return [];
  },
});










 
 
 
 




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Instead of (only) receiving props from our parent components, in most cases we extract props from the HTML. Here want to extract the title and isCompleted props.

Same as with refs, the keys of the props object is what we will use in the setup function, and the value is the "prop definition". Here we use the propType helper to define what we want to extract, where from, if it has a default value, what type it should be converted to, etc.

title uses propType.string, since we want it as a string. isCompleted should be a boolean, so we use propType.boolean.

We want to extract the title prop from the title ref, so we specify that as target – the element (ref) we want to extract it from. Since we want the inner text of the <label>, we use the text type.

For the isCompleted, we want know if the completed class was set on the component root element. Since it's the root, we don't have to specify a target. The type is set to css since we want to check the css class name, and name is completed, because we want to check for that value. If it's present, the result will be true – otherwise it's false.

Just as with the refs, we expect these values to always be there. Otherwise we could have configured the props as .optional – where they become undefined when not present – or .defaultValue('foo') to receive that value when missing.

Setup

import { bind, defineComponent, propType, ref } from '@muban/muban';

export const TodoItem = defineComponent({
  name: 'todo-item',
  refs: {
    completedInput: 'completedInput',
    title: 'title',
    destroyButton: 'destroyButton',
    editInput: 'editInput',
  },
  props: {
    title: propType.string.source({ type: 'text', target: 'title' }),
    isCompleted: propType.boolean.source({ type: 'css', name: 'completed' }),
  },
  setup({ props, refs }) {
    const isCompleted = ref(props.isCompleted);

    return [
      bind(refs.self, {
        css: { completed: isCompleted },
      }),
      bind(refs.completedInput, {
        checked: isCompleted,
      }),
    ];
  },
});














 
 
 
 
 
 
 
 
 
 
 
 

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

Lastly we work on the setup function, where we add our bindings.

We receive our props and refs in the setup function, they have been "constructed" based on the definitions above, so we can use them.

By doing const isCompleted = ref(props.isCompleted); we create a new observable ref, so we can change this value inside the component (when toggling the checkbox). We can't mutate the incoming props. props.isCompleted is passed as the "initial value" for that ref.

Now that we have that ref, we can use it to do two things:

  1. When isCompleted changes, we want to toggle the completed on refs.self
  2. When the checked state of refs.completedInput changes, we want to update isCompleted.

And that's exactly what those two bindings do. We return an array with bindings, where each binding receives the ref to bind to, and a "binding object" that specifies what to do when things change.

Note that we pass the observable ref as a binding value, not the ref.value. This allows the binding to watch for changes in the ref, and update the dom whenever ref.value is updated later.

Some bindings are "read only", they are used to update the DOM when they are changed. Other bindings are "write only", those are callback functions to react on events. And a small set are "two-way bindings", which can be bound to a ref, where the value of the ref and the state of the DOM are always kept in sync, whichever changes first.

Adding to the parent

To have our TodoItem component initialized based on the HTML, we need to register it to the parent. Until we actually need these components as refs to add bindings to, we can add them in the components array, just so they are "known".

import { defineComponent } from '@muban/muban';
import { TodoItem } from '../todo-item/TodoItem';

export const App = defineComponent({
  name: 'app',
  components: [TodoItem],
  setup() {
    console.log('App Running...');
    return [];
  },
});

 



 





1
2
3
4
5
6
7
8
9
10
11

We added components: [TodoItem], here.

If you look at your page again, you should now be able to click the checkbox left of the todo, and see it being checked off.

Note that we haven't done anything with the title in our component yet, this will come in a future step when we are going to implement the editing state.

Editing state

To reduce the amount of duplicate code, we are just focussing on the setup part of the TodoItem now;

  setup({ props, refs }) {
    const isCompleted = ref(props.isCompleted);
    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);

    return [
      bind(refs.self, {
        css: {
          completed: isCompleted,
          editing: isEditing,
        },
      }),
      bind(refs.completedInput, {
        checked: isCompleted,
      }),
      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();
            });
          },
        },
      }),
      bind(refs.editInput, {
        textInput: editValue,
      }),
    ];
  },


 
 
 





 





 
 
 
 
 
 
 
 
 
 
 
 
 
 
 


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

We created a isEditing ref to switch editing mode. We updated the css binding on refs.self to include toggling the editing css class.

Then we added a binding for refs.title where we add a dblclick event. In this event we're switching isEditing. value to true. Note that we're using .value when reading or writing the value stored inside the ref.

We are also focussing the edit input – but with a small delay to give the DOM time to update itself. Note that to let typescript understand we can do element?.focus() we have now typed our input ref as editInput: refElement<HTMLInputElement>('editInput'),.

And we created an editValue ref, initialising it to the extracted value from the props, and use that for the textInput binding on your refs.editInput – which set the initial value correctly, but will also change the ref when updating the input.

When we visit our page now, and double click on the label, it should switch to editing mode, and focus the input. Unfortunately we don't have a way to exit the editing mode. This is our next step;

  setup({ props, refs }) {
    const isCompleted = ref(props.isCompleted);
    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 title = ref(props.title);

    const exitEditing = (saveValue = false) => {
      // either save the value, or restore it to the previous state
      if (saveValue) {
        title.value = editValue.value;
      } else {
        editValue.value = title.value;
      }
      // exit editing mode
      isEditing.value = false;
    };

    return [
      bind(refs.self, {
        css: {
          completed: isCompleted,
          editing: isEditing,
        },
      }),
      bind(refs.completedInput, {
        checked: isCompleted,
      }),
      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: 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);
          },
        },
      }),
    ];
  },





 

 
 
 
 
 
 
 
 
 
 






















 



 
 
 
 
 
 
 
 
 
 
 
 



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
import { bind, 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: {
    title: propType.string.source({ type: 'text', target: 'title' }),
    isCompleted: propType.boolean.source({ type: 'css', name: 'completed' }),
  },
  setup({ props, refs }) {
    const isCompleted = ref(props.isCompleted);
    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 title = ref(props.title);

    const exitEditing = (saveValue = false) => {
      // either save the value, or restore it to the previous state
      if (saveValue) {
        title.value = editValue.value;
      } else {
        editValue.value = title.value;
      }
      // exit editing mode
      isEditing.value = false;
    };

    return [
      bind(refs.self, {
        css: {
          completed: isCompleted,
          editing: isEditing,
        },
      }),
      bind(refs.completedInput, {
        checked: isCompleted,
      }),
      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: 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);
          },
        },
      }),
    ];
  },
});
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

Starting at the bottom, we added a keydown and blur event to detect the different ways to exit our editing state. Some of which should save the value (passing true), where hitting Escape should discard our entered value.

The exitEditing function will then either save the editValue into the new title ref, or revert the editValue to the value the title had.

Lastly we added the text: title, binding to our refs.title, to update the label when exiting editing state with the new value.

Now we can enter and exit the edit state freely, and choose to accept or revert the value we entered.

In our next step we are going to try to create new Todos, which might require us to change some things in this component as well.

Adding new Todos

As with the TodoItem, let's create our component file in src/components/app-header/AppHeader.ts.

import { defineComponent } from '@muban/muban';

export const AppHeader = defineComponent({
  name: 'app-header',
  setup({ refs, props }) {
    return [];
  },
});
1
2
3
4
5
6
7
8

And to make sure we don't forget, add the AppHeader to the components array in App.

import { defineComponent } from '@muban/muban';
import { AppHeader } from '../app-header/AppHeader';
import { TodoItem } from '../todo-item/TodoItem';

export const App = defineComponent({
  name: 'app',
  components: [AppHeader, TodoItem],
  setup() {
    console.log('App Running...');
    return [];
  },
});

 




 





1
2
3
4
5
6
7
8
9
10
11
12

With the basic setup there, let's add our refs, props and bindings.

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

We have our newTodoInput ref, that's used to add bindings to.

We configured a onCreate prop, that will be passed from the parent component later. func types can never be extracted from the HTML, so should always be passed from the parent.

The order in which all components are initialized means that onCreate will never be present in the setup immediately, but it will be once the component is mounted and bindings are executed.

Also note the generic argument for shape<(value: string) => void>(), we use that to tell TypeScript what our function will be like. In this case, the function expects a value to be passed, and won't return anything.

For our setup, we start with a inputValue ref, where we store the value of our input field using the textInput: inputValue binding.

And just like in our TodoItem bindings, we use the keyup to detect when we press [Enter]. Once we do, we call our onCreate callback (using ?. optional chaining, since we can't be sure the parent already passed this prop), and pass the value we have stored in inputValue. Lastly, we reset our input back to '', so we can immediately start entering our next Todo.

If you look at your running application now, you should be able to type in the input field, and hit [enter] to empty the input again. Just like with the TodoItem this component now only works on isolation, we haven't connected it to the parent. This is what's next.

Connecting child components

Control the list of todos in the App

Before we start adding new todos, let's move the control of rendering our individual Todo items to our App component.

First, we create a ref to store our todo data in:

const todos = ref([]);

Next, we need to fill it with data. Since we already could have todos rendered on the server, we probably want to extract these items from the page. Since we already have our TodoItem components mounted, and their props extract the required info from the HTML, we can try to retrieve that information.

refs: {
  todoItems: refComponents(TodoItem),
},

Here we define a new ref in our App component. We're using refComponents to get a collection of components. We pass TodoItem as a Component there, since we are interested in those. This will look for data-component="todo-item" elements in the DOM, and initialized the TodoItem component. The instances of those components will be available in the setup function as refs.todoItems.

Now that we have configured TodoItem as a ref, we can remove it from the components: [] Array.

In our setup function we can now access these refs to get access to the data;

setup({ refs }) {
  const initialTodoItems = refs.todoItems
    .getComponents()
    .map(({ props: { title, isCompleted } }) => ({ title, isCompleted }));
  const todos = ref(initialTodoItems);
  
  return [];
}
  1. refs.todoItems.getComponents() retrieves all TodoItem component instances, where we .map over them.
  2. ({ props : { title, isCompleted } }) destructures those two props, which is similar to map(item => item.props. title).
  3. For each component, we return the two props we're interested in; ({ title, isCompleted }).

initialTodoItems now contains an array of todo objects, which we use to initialize our todos ref.

If we would add console.log(todos.value), we'd see:

[
  { title: 'Taste JavaScript', isCompleted: true },
  { title: 'Buy a unicorn', isCompleted: false }
]

In order to use this extracted data to render our Todo items (and later add or remove them), we use bindTemplate;

  refs: {
    todoList: 'todoList',
    todoItems: refComponents(TodoItem),
  },

 


    return [
      bindTemplate(refs.todoList, todos, (items) =>
        items.map((itemData) => todoItemTemplate(itemData)).join(''),
      ),
    ];

 
 
 

  1. First we add a todoList ref definition, this is the <ul> container for our Todo items.
  2. We pass this ref as our first parameter, since we want to modify the content of this element.
  3. todos is passed second, this is the reactive data that bindTemplate is watching for changes
  4. Whenever todos changes, our 3rd parameter – a template function – is executed. We use it to render our todoItemTemplate with the passed data, and return the mapped result. This will then replace the innerHTML of the <ul> container we bind to.
  5. bindTemplate will also make sure that whenever the HTML is updated, it initializes all new components.

In total, our code should now look like this;

import { bindTemplate, defineComponent, ref, refComponents } from '@muban/muban';
import { AppHeader } from '../app-header/AppHeader';
import { TodoItem } from '../todo-item/TodoItem';
import { todoItemTemplate } from '../todo-item/TodoItem.template';

export const App = defineComponent({
  name: 'app',
  components: [AppHeader],
  refs: {
    todoList: 'todoList',
    todoItems: refComponents(TodoItem),
  },
  setup({ refs }) {
    const initialTodoItems = refs.todoItems
      .getComponents()
      .map(({ props: { title, isCompleted } }) => ({ title, isCompleted }));
    const todos = ref(initialTodoItems);

    return [
      bindTemplate(refs.todoList, todos, (items) =>
        items.map((itemData) => todoItemTemplate(itemData)).join(''),
      ),
    ];
  },
});







 

 
 


 
 
 
 


 
 
 



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

And if you would look in the browser, nothing would have visually changed. However, we're now ready to add new Todos.

Add a new Todo

To get access to the new Todo, we need to pass our onCreate function to the AppHeader component.

We start by adding this as a refComponent, so we can bind to it. We can now also remove the components array completely.

    appHeader: refComponent(AppHeader),

Next, we add our binding;

      bind(refs.appHeader, {
        onCreate(newTodo) {
          todos.value = todos.value.concat({ title: newTodo, isCompleted: false });
        },
      }),

Here we pass the onCreate function to our appHeader component, so when you submit a new one, this function is called. In the function, we take our newTodo and create an object (completed is false when we add it), and add it to our existing array.

Because we use a ref – which only "triggers" changes when you set the .value, we use the immutable concat method to construct a new array with the new item, and assign that to todos.value.

With this in place, our bindTemplate should automatically render our new Todo item when onCreate is called.

Our App component now looks like this:

import {
  bind,
  bindTemplate,
  defineComponent,
  ref,
  refComponent,
  refComponents,
} from '@muban/muban';
import { AppHeader } from '../app-header/AppHeader';
import { TodoItem } from '../todo-item/TodoItem';
import { todoItemTemplate } from '../todo-item/TodoItem.template';

export const App = defineComponent({
  name: 'app',
  refs: {
    appHeader: refComponent(AppHeader),
    todoList: 'todoList',
    todoItems: refComponents(TodoItem),
  },
  setup({ refs }) {
    const initialTodoItems = refs.todoItems
      .getComponents()
      .map(({ props: { title, isCompleted } }) => ({ title, isCompleted }));
    const todos = ref(initialTodoItems);

    return [
      bind(refs.appHeader, {
        onCreate(newTodo) {
          todos.value = todos.value.concat({ title: newTodo, isCompleted: false });
        },
      }),
      bindTemplate(refs.todoList, todos, (items) =>
        items.map((itemData) => todoItemTemplate(itemData)).join(''),
      ),
    ];
  },
});















 










 
 
 
 
 






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

And if you look at your browser now, you should be able to add new Todos!

However, if we edit an existing Todo, and we add a new one after that, our earlier edit is reverted. This is because those edit changes are stored locally in our component, and our App now manages the data of all our Todos when it updates the list.

So our next step is to propagate changes from the TodoItem components back to the App.

Syncing TodoItem with the App

For this next step we're going to make a few changes in the TodoItem component. Currently, that component is "stateful" (in regard to the title/completed), since it keeps that state internally. Since we want to have that managed by the App now, we want the TodoItem to become "stateless".

We can do that by removing the internal ref, using the props directly in our bindings, and dispatch change events whenever those values update. Then make sure to update our App component connect everything up.

The changes in TodoItem look like this:

We add a prop to trigger changes.

    onChange:
      propType.func.optional.shape<(data: { title?: string; isCompleted?: boolean }) => void>(),

We remove the refs that stored the internal state:

    const isCompleted = ref(props.isCompleted);
    const title = ref(props.title);

We update our exitEditing function with these changes:

      if (saveValue) {
        props.onChange?.({ title: editValue.value });
      } else {
        editValue.value = props.title;
      }

 

 

We change our title and isCompleted bindings to use the props in a computed. If we don't wrap it in a computed, we would be passing a non-reactive value, which means our bindings wouldn't update.

      bind(refs.self, {
        css: {
          completed: computed(() => props.isCompleted),
          editing: isEditing,
        },
      }),


 



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











 

Then we update our checked binding on the completedInput ref to be read/writable. We read the incoming props, and when the checked changes, we call the onChange with the new value.

      bind(refs.completedInput, {
        checked: computed({
          get: () => props.isCompleted,
          set(value) {
            props.onChange?.({
              isCompleted: value,
            });
          },
        }),
      }),

 
 
 
 
 
 
 
 

Our TodoItem now looks like this:

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: {
    title: propType.string.source({ type: 'text', target: 'title' }),
    isCompleted: propType.boolean.source({ type: 'css', name: 'completed' }),
    onChange:
      propType.func.optional.shape<(data: { title?: string; isCompleted?: boolean }) => 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?.({ 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?.({
              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);
          },
        },
      }),
    ];
  },
});













 
 









 

 








 




 
 
 
 
 
 
 
 












 



















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

Then to sync everything up, we add this new binding to our App:

      ...bindMap(refs.todoItems, (_, refIndex) => ({
        onChange(newProps) {
          todos.value = todos.value.map((item, index) =>
            index === refIndex ? { ...item, ...newProps } : item,
          );
        },
      })),
1
2
3
4
5
6
7
import {
  bind,
  bindMap,
  bindTemplate,
  defineComponent,
  ref,
  refComponent,
  refComponents,
} from '@muban/muban';
import { AppHeader } from '../app-header/AppHeader';
import { TodoItem } from '../todo-item/TodoItem';
import { todoItemTemplate } from '../todo-item/TodoItem.template';

export const App = defineComponent({
  name: 'app',
  refs: {
    appHeader: refComponent(AppHeader),
    todoList: 'todoList',
    todoItems: refComponents(TodoItem),
  },
  setup({ refs }) {
    const initialTodoItems = refs.todoItems
      .getComponents()
      .map(({ props: { title, isCompleted } }) => ({ title, isCompleted }));
    const todos = ref(initialTodoItems);

    return [
      bind(refs.appHeader, {
        onCreate(newTodo) {
          todos.value = todos.value.concat({ title: newTodo, isCompleted: false });
        },
      }),
      bindTemplate(refs.todoList, todos, (items) =>
        items.map((itemData) => todoItemTemplate(itemData)).join(''),
      ),
      ...bindMap(refs.todoItems, (_, refIndex) => ({
        onChange(newProps) {
          todos.value = todos.value.map((item, index) =>
            index === refIndex ? { ...item, ...newProps } : 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

We're using bindMap as a utility to create a unique binding for each item in our component collection. It returns an Array, so we ... spread it out.

For each item in the collection it executes the callback function, where we receive the ref to the individual component, and the index in the list.

Whenever onChange is called, we map over our todo list, and update the changed item based on index. The newly mapped array is assigned back to todos.value, which will make sure that whenever the bindTemplate is updated later, it passes the updated values to each item.

With this in place, if we check our app again, we should be able to edit existing and add new todo items, without anything getting reverted to outdated information.

Deleting a Todo

Now that we can Add and Edit todos, it should also be possible for us to Delete them. For this to work, we need to add click bindings to our delete button, and add a onDelete prop we can call. Then in our App we need to pass the onDelete, and remove our Todo from the list.

In our TodoItem we add our onDelete prop:

    onDelete: propType.func.optional.shape<() => void>(),
1

Then add our click binding:

      bind(refs.destroyButton, {
        click() {
          props.onDelete?.();
        },
      }),
1
2
3
4
5
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: {
    title: propType.string.source({ type: 'text', target: 'title' }),
    isCompleted: propType.boolean.source({ type: 'css', name: 'completed' }),
    onChange:
      propType.func.optional.shape<(data: { title?: string; isCompleted?: boolean }) => void>(),
    onDelete: propType.func.optional.shape<() => 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?.({ 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?.({
              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?.();
        },
      }),
    ];
  },
});















 






























































 
 
 
 
 



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

And lastly, in our App we handle the deletion by filtering the todos based on index.

      ...bindMap(refs.todoItems, (_, refIndex) => ({
        onChange(newProps) {
          todos.value = todos.value.map((item, index) =>
            index === refIndex ? { ...item, ...newProps } : item,
          );
        },
        onDelete() {
          todos.value = todos.value.filter((_, index) => index !== refIndex);
        },
      })),






 
 
 

1
2
3
4
5
6
7
8
9
10
import {
  bind,
  bindMap,
  bindTemplate,
  defineComponent,
  ref,
  refComponent,
  refComponents,
} from '@muban/muban';
import { AppHeader } from '../app-header/AppHeader';
import { TodoItem } from '../todo-item/TodoItem';
import { todoItemTemplate } from '../todo-item/TodoItem.template';

export const App = defineComponent({
  name: 'app',
  refs: {
    appHeader: refComponent(AppHeader),
    todoList: 'todoList',
    todoItems: refComponents(TodoItem),
  },
  setup({ refs }) {
    const initialTodoItems = refs.todoItems
      .getComponents()
      .map(({ props: { title, isCompleted } }) => ({ title, isCompleted }));
    const todos = ref(initialTodoItems);

    return [
      bind(refs.appHeader, {
        onCreate(newTodo) {
          todos.value = todos.value.concat({ title: newTodo, isCompleted: false });
        },
      }),
      bindTemplate(refs.todoList, todos, (items) =>
        items.map((itemData) => todoItemTemplate(itemData)).join(''),
      ),
      ...bindMap(refs.todoItems, (_, refIndex) => ({
        onChange(newProps) {
          todos.value = todos.value.map((item, index) =>
            index === refIndex ? { ...item, ...newProps } : item,
          );
        },
        onDelete() {
          todos.value = todos.value.filter((_, index) => index !== refIndex);
        },
      })),
    ];
  },
});









































 
 
 




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

Now we should be able to delete items.

Manage our todos

Next up the AppFooter, where we:

  1. show the uncompleted todo count – which should update after we add/remove/complete any todo
  2. can clear the completed todos from the list – easier than deleting them one by one
  3. filter our todos based on isCompleted

First, create our empty component:

import { defineComponent } from '@muban/muban';

export const AppFooter = defineComponent({
  name: 'app-footer',
  setup() {
    return [];
  },
});
1
2
3
4
5
6
7
8

Remaining count

We can now add all our refs to the component, including the remainingCount, so we can update it.

  refs: {
    remainingCount: 'remainingCount',
    clearCompletedButton: 'clearCompletedButton',
    filterAll: 'filterAll',
    filterActive: 'filterActive',
    filterCompleted: 'filterCompleted',
  },

 





1
2
3
4
5
6
7

And our props, including the remainingCount we will receive from the parent component. We are not going to try to extract it from the HTML, but instead set it to 0 as default, since everything is client-rendered (and we only receive this information from the parent).

  props: {
    remainingTodoCount: propType.number.defaultValue(0),
    onClearCompleted: propType.func.optional.shape<() => void>(),
  },

 


1
2
3
4

Don't forget to add the refs and props to the setup function, so we can use them:

  setup({ refs, props }) {
1

Then, we add the binding, to update the DOM whenever the prop changes.

      bind(refs.remainingCount, {
        html: computed(
          () =>
            `<strong>${props.remainingTodoCount}</strong> ${
              props.remainingTodoCount === 1 ? 'item' : 'items'
            } left`,
        ),
      }),
1
2
3
4
5
6
7
8

Clear Completed

We already added our refs and props in the previous step, so we only have to add the click binding to refs.clearCompletedButton to call the onClearCompleted prop.

      bind(refs.clearCompletedButton, {
        click() {
          props.onClearCompleted?.();
        },
      }),
1
2
3
4
5
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>(),
  },
  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?.();
        },
      }),
    ];
  },
});





 
 





 
 



 
 
 
 
 
 
 
 
 
 
 
 
 



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

Connect to parent

To initialize the AppFooter component, and connect the bindings, we have to add it to the refs in the App component.

    appFooter: refComponent(AppFooter),
1

And then add the bindings.

For the remainingTodoCount we filter our todos to count everything that isCompleted.

And when onClearCompleted is called, we filter to only keep items that were not isCompleted.

      bind(refs.appFooter, {
        remainingTodoCount: computed(() => todos.value.filter((todo) => !todo.isCompleted).length),
        onClearCompleted() {
          todos.value = todos.value.filter((todo) => !todo.isCompleted);
        },
      }),
1
2
3
4
5
6
import {
  bind,
  bindMap,
  bindTemplate,
  computed,
  defineComponent,
  ref,
  refComponent,
  refComponents,
} 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';

export const App = defineComponent({
  name: 'app',
  refs: {
    appHeader: refComponent(AppHeader),
    todoList: 'todoList',
    todoItems: refComponents(TodoItem),
    appFooter: refComponent(AppFooter),
  },
  setup({ refs }) {
    const initialTodoItems = refs.todoItems
      .getComponents()
      .map(({ props: { title, isCompleted } }) => ({ title, isCompleted }));
    const todos = ref(initialTodoItems);

    return [
      bind(refs.appHeader, {
        onCreate(newTodo) {
          todos.value = todos.value.concat({ title: newTodo, isCompleted: false });
        },
      }),
      bindTemplate(refs.todoList, todos, (items) =>
        items.map((itemData) => todoItemTemplate(itemData)).join(''),
      ),
      ...bindMap(refs.todoItems, (_, refIndex) => ({
        onChange(newProps) {
          todos.value = todos.value.map((item, index) =>
            index === refIndex ? { ...item, ...newProps } : item,
          );
        },
        onDelete() {
          todos.value = todos.value.filter((_, index) => index !== refIndex);
        },
      })),
      bind(refs.appFooter, {
        remainingTodoCount: computed(() => todos.value.filter((todo) => !todo.isCompleted).length),
        onClearCompleted() {
          todos.value = todos.value.filter((todo) => !todo.isCompleted);
        },
      }),
    ];
  },
});





















 


























 
 
 
 
 
 



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

At this point you should see the counter in the footer update whenever you add, remove or (un) complete todo items, and you should be able to use the "Clear Completed" button to remove everything that was checked off.

The Filtering is going to require quite some changes across the board, where we require some kind of "router", are going to include saving to localStorage, and add an id to our todo items to make things a bit easier.