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 [];
},
});
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 [];
},
});
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 [];
},
});
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,
}),
];
},
});
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:
- When
isCompleted
changes, we want to toggle thecompleted
onrefs.self
- When the
checked
state ofrefs.completedInput
changes, we want to updateisCompleted
.
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 theref
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 [];
},
});
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,
}),
];
},
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);
},
},
}),
];
},
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);
},
},
}),
];
},
});
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 [];
},
});
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 [];
},
});
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 = '';
}
},
},
}),
];
},
});
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 [];
}
refs.todoItems.getComponents()
retrieves allTodoItem
component instances, where we.map
over them.({ props : { title, isCompleted } })
destructures those two props, which is similar tomap(item => item.props. title)
.- 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(''),
),
];
- First we add a
todoList
ref definition, this is the<ul>
container for our Todo items. - We pass this ref as our first parameter, since we want to modify the content of this element.
todos
is passed second, this is the reactive data thatbindTemplate
is watching for changes- Whenever
todos
changes, our 3rd parameter – a template function – is executed. We use it to render ourtodoItemTemplate
with the passed data, and return the mapped result. This will then replace theinnerHTML
of the<ul>
container we bind to. 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(''),
),
];
},
});
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(''),
),
];
},
});
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
.
TodoItem
with the App
Syncing 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);
},
},
}),
];
},
});
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,
);
},
})),
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,
);
},
})),
];
},
});
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>(),
Then add our click
binding:
bind(refs.destroyButton, {
click() {
props.onDelete?.();
},
}),
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?.();
},
}),
];
},
});
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);
},
})),
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);
},
})),
];
},
});
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:
- show the uncompleted todo count – which should update after we add/remove/complete any todo
- can clear the completed todos from the list – easier than deleting them one by one
- 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 [];
},
});
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',
},
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>(),
},
2
3
4
Don't forget to add the refs
and props
to the setup function, so we can use them:
setup({ refs, props }) {
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`,
),
}),
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?.();
},
}),
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?.();
},
}),
];
},
});
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),
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);
},
}),
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);
},
}),
];
},
});
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.