Learn how to build this simple Todo app.
Note: the slide version of this workshop is available here.
TODO:
Clone the git repository for this workshop and get into the project folder.
gh repo clone trouni/workshop-vue3-todo
# OR if you don't have the gh CLI:
# git clone git@github.com:trouni/workshop-vue3-todo.git
cd workshop-vue3-todo
OR download the ZIP file and unzip the archive to your projects folder or desktop.
TODO:
Run yarn install
to install the project's dependencies, then yarn dev
to launch a local server.
yarn install # Installs dependencies
yarn dev # Compiles and hot-reloads for development
You should now be able to view your app on http://localhost:3000.
Not required, but you can also install these useful tools:
- Vue Language Features (Volar), a language support extension built for Vue.
- Vue.js devtools, a Chrome extension to debug your Vue app from the browser.
<!-- Component.vue -->
<script>
// Data & logic
</script>
<template>
<!-- HTML structure -->
</template>
<style>
/* Styling */
</style>
Create a new /components/TasksList.vue
file with the following code:
<!-- TasksList.vue -->
<template>
<p>This is my first Vue.js component</p>
</template>
Then import the component in your App.vue
file.
<!-- App.vue -->
<script setup>
import TasksList from './components/TasksList.vue';
</script>
<template>
<!-- ... -->
<main>
<img alt="Vue logo" src="./assets/logo.svg" height="100" />
<h1>Task Manager</h1>
<TasksList />
</main>
<!-- ... -->
</template>
Let's replace the text with a static card:
<!-- TasksList.vue -->
<template>
<div class="task-card">
<div>
<h3>Create a card component</h3>
<p>Create a new TaskCard.vue file in the components folder, then import it in TasksList.vue</p>
</div>
</div>
</template>
You can add styling rules for each component in the <style>
section. Try it out!
You can use this styling I prepared for the card, but we should first move the task card into its own component. Move the previous code into a TaskCard.vue
and add this styling:
<!-- TaskCard.vue -->
<style lang="scss">
.task-card {
display: flex;
min-height: 6rem;
text-align: left;
background-color: saturate(rgba(#41b883, 0.03), 30%);
border-bottom: solid 1px rgba(#35495e, 0.1);
border-left: solid 8px #41b883;
transition: all 0.3s;
p { font-size: 0.9rem; }
&:hover {
transform: scale(1.02);
background-color: white;
box-shadow: 2px 3px 10px rgba(black, 0.1);
}
& > div:first-child {
margin: 0 1rem;
padding: 1rem 0;
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: center;
}
&.done {
border-left: solid 8px rgba(#35495e, 0.3);
background-color: rgba(#35495e, 0.08);
h3, p { text-decoration: line-through; opacity: 0.3; }
&:hover { transform: unset; box-shadow: unset; }
}
}
</style>
Any top-level bindings (including variables, function declarations, and imports) declared inside <script setup>
are directly usable in the template using the {{ variable }}
syntax:
<!-- TaskCard.vue -->
<script setup>
// Any variable or function defined here is directly accessible in the template
const title = "Make the card component dynamic"
const description = "Learn about using the data option and passing data to child components using props"
const done = false
</script>
<template>
<div class="task-card">
<div>
<h3>{{ title }}</h3>
<p>{{ description }}</p>
</div>
<div>{{ done ? "✅" : "⭕️" }}</div>
</div>
</template>
We need to tell Vue that the component should be updated every time a change is detected on these variables. We make them reactive using ref
.
<!-- TaskCard.vue -->
<script setup>
import { ref } from 'vue';
const title = ref("Make the card component dynamic")
const description = ref("Learn about using the data option and passing data to child components using props")
const done = ref(false)
</script>
💡Tip: Use the Vue.js devtools, to interact with your data and see how changes re-render the component.
We want our component to be reusable:
Let's assign the title
and description
dynamically.
Props are custom attributes you can register on a component. When a value is passed to a prop attribute, it becomes a property on that component instance.
Props are basically special data attributes that come from the parent component.
Let's replace our variables and define some props using defineProps
:
<!-- TaskCard.vue -->
<script setup>
defineProps({
title: String,
description: String,
done: { type: Boolean, default: false }
})
</script>
We can now pass the title
and description
when rendering the TaskCard
component instead (in the parent component):
<!-- TasksList.vue <template> -->
<TaskCard
title="Make the card component dynamic"
description="Learn about using the data option and passing data to child components using props"
done="true"
/>
Let's define multiple tasks to make things more interesting! Instead of ref
, we use reactive
to make objects reactive.
<!-- TasksList.vue -->
<script setup>
import { reactive } from 'vue';
const tasks = reactive([
{
title: "Create a card component",
description:
"Create a new TaskCard.vue file in the components folder, then import it in TasksList",
done: true,
},
{
title: "Make the card component dynamic",
description:
"Learn about using the data option and passing data to child components using props",
done: true,
},
{
title: "Bind the attributes to the data",
description:
"Use the v-bind directive to bind the title and description to our data",
done: false,
},
])
</script>
We want to use the values from our tasks
in the title
and description
attributes of our TaskCard
component, but mustaches cannot be used inside HTML attributes.
Instead, we use a v-bind
directive (shorthand :
).
<!-- TasksList.vue -->
<template>
<TaskCard v-bind:title="tasks[0].title" v-bind:description="tasks[0].description" v-bind:done="tasks[0].done" />
<!-- OR using the shorthand: -->
<TaskCard :title="tasks[1].title" :description="tasks[1].description" :done="tasks[1].done" />
</template>
We can apply classes conditionally too!. For example, we add a .done
class to the card when the task is completed (done
is true
).
<!-- TaskCard.vue -->
<template>
<!-- <div :class="['my-first-class', 'another-class', { 'a-conditional-class': conditionToAddTheClass }]"> -->
<div :class="['task-card', { done: done }]">
</template>
Let's now display all of our tasks by iterating over the tasks
array using the v-for
directive.
<!-- TasksList.vue <template> -->
<TaskCard
v-for="(task, index) in tasks"
:key="index"
:title="task.title"
:description="task.description"
:done="task.done"
/>
Let's display a small message when we have no tasks in our app.
<!-- TasksList.vue <template> -->
<template>
<div v-if="tasks.length > 0" class="tasks-list">
<!-- TaskCard -->
</div>
</template>
<p v-else>You don't have any tasks yet...</p>
Implement an addTask()
function to push a new task at the beginning of the tasks
array.
<!-- TasksList.vue -->
<script setup>
// ...
function addTask(title, description, done = false) {
tasks.unshift({ title, description, done });
}
// ...
</script>
Let's add a button to test that our method works and make it listen to click
events using v-on
(shorthand @
).
<!-- TasksList.vue <template> -->
<template>
<button class="btn round-icon" v-on:click="addTask('My new task', 'My new description')">+</button>
</template>
We still need to be able to enter the title
and description
ourselves. Let's add some inputs to our TasksList
.
<!-- TasksList.vue <template> -->
<template>
<div class="task-card new-task">
<div>
<input type="text" placeholder="What would you like to do?" />
<textarea placeholder="Add some details about your task..."></textarea>
</div>
</div>
</template>
Add the following styles to your TasksList
component:
<!-- TasksList.vue -->
<style lang="scss" scoped>
@keyframes expand-vertical { from { min-height: 0; height: 0; } to { min-height: 6rem; } }
.task-card.new-task {
animation: expand-vertical 0.2s;
overflow: hidden;
background-color: white;
border-left: solid 5px #35495e;
&, &:hover { transform: scale(1.1); box-shadow: 2px 3px 10px rgba(black, 0.2); }
& + .tasks-list { pointer-events: none; }
input { font-size: 1.17rem; font-weight: bold; width: 100%; }
textarea { width: 100%; font-size: 0.9rem; resize: none; }
}
</style>
We can bind the input fields with data attributes in our component.
<!-- TasksList.vue -->
<script setup>
// ...
const newTitle = ref('')
const newDescription = ref('')
</script>
<template>
<!-- ... -->
<input type="text" placeholder="What would you like to do?"
v-model="newTitle"
/>
<textarea placeholder="Add some details about your task..."
v-model="newDescription"
></textarea>
<!-- ... -->
</template>
You can chain actions by separating them with a comma:
<!-- TasksList.vue -->
<template>
<!-- ... -->
<button
class="btn round-icon"
@click="addTask(newTitle, newDescription), resetForm()"
>+</button>
<!-- ... -->
</template>
Define the new resetForm()
function. Note that reactive properties need to be updated using the .value
attribute.
<!-- TasksList.vue -->
<script setup>
// ...
function addTask(title, description, done = false) {
tasks.unshift({ title, description, done });
}
function resetForm() {
newTitle.value = ""
newDescription.value = ""
}
// ...
</script>
Let's only show the inputs after we click on the +
button.
<!-- TasksList.vue -->
<script setup>
// ...
const newFormVisible = ref(false)
// ...
function resetForm() {
newTitle.value = ""
newDescription.value = ""
newFormVisible.value = false;
}
</script>
<template>
<div>
<button class="btn round-icon" @click="newFormVisible = !newFormVisible">
{{ newFormVisible ? "✕" : "+" }}
</button>
<div
v-show="newFormVisible"
class="task-card new-task"
@keyup.enter="addTask(newTitle, newDescription), resetForm()"
>
<!-- ... -->
<p v-else-if="!newFormVisible">You don't have any tasks yet...</p>
</div>
</template>
We can easily store the tasks
array directly in the user's browser:
<!-- TasksList.vue -->
<script setup>
import { reactive, ref, watch } from 'vue';
// ...
const tasks = reactive(JSON.parse(localStorage.getItem("tasks")) || [])
watch(tasks, (newX) => {
localStorage.setItem("tasks", JSON.stringify(tasks));
})
function clearTasks() {
tasks.splice(0)
}
// ...
</script>
To pass data upstream (i.e. from a child to a parent component):
- the child component emits a custom event using
$emit('custom-event')
- the parent component listens for that event using
v-on:custom-event=
(or@custom-event=
)
Let's move the input fields and style from TasksList
to a new component NewTask
. In this component, we remove the addTask
method and replace it with $emit
to send a custom event upstream, that contains the new title and description:
<!-- NewTask.vue -->
<script setup>
import { ref } from 'vue';
const newTitle = ref('')
const newDescription = ref('')
function resetForm() {
newTitle.value = ''
newDescription.value = ''
}
</script>
<template>
<div class="task-card new-task" @keyup.enter="$emit('add-task', newTitle, newDescription), resetForm()">
<!-- ... -->
</div>
</template>
<style lang="scss" scoped>
// Don't forget to copy over the styling
</style>
The parent component TasksList
listens for the custom add-task
event, and calls the addTask
method when the event is triggered.
<!-- TasksList.vue -->
<template>
<!-- ... -->
<NewTask @add-task="addTask" />
<!-- ... -->
</template>
Implement this toggleTask
method in the TasksList
component:
<!-- TasksList.vue -->
<script setup>
// ...
function toggleTask(taskIndex) {
const taskToUpdate = tasks[taskIndex];
taskToUpdate.done = !taskToUpdate.done;
}
</script>
Create this new UI component Checkbox
:
<!-- Checkbox.vue -->
<script setup>
defineProps({
checked: Boolean
})
</script>
<template>
<div :class="['checkbox', { checked }]"></div>
</template>
<style lang="scss" scoped>
$check-color: #41b883;
.checkbox {
display: flex;
align-self: center;
border: solid 2px $check-color;
border-radius: 50%;
width: 2rem;
height: 2rem;
margin: 0 1rem;
cursor: pointer;
&:hover { background-color: rgba($check-color, 0.2); transform: scale(1.1); }
&.checked { background-color: $check-color; }
&.checked:after {
content: "";
display: inline-block;
transform: rotate(45deg) translate(-35%, 40%);
height: 1rem;
width: 1rem;
margin-left: 60%;
border-bottom: 5px solid white;
border-right: 5px solid white;
}
}
</style>
Modify your TaskCard
component to use the Checkbox
and $emit
a custom event on click
.
<!-- TaskCard.vue -->
<script setup>
import Checkbox from "./Checkbox";
defineProps({
title: String,
description: String,
done: { type: Boolean, default: false },
taskIndex: Number, // <-- Add this as well
})
</script>
<template>
<div :class="['task-card', { done }]">
<div>
<h3>{{ title }}</h3>
<p>{{ description }}</p>
</div>
<Checkbox @click="$emit('toggle-task', taskIndex)" :checked="done" />
</div>
</template>
Finally, update your TaskCard
component in your TasksList.vue
file to pass the task index and listen for the toggle-task
event:
<!-- TasksList.vue -->
<template>
<!-- ... -->
<TaskCard v-for="(task, index) in tasks" :key="index" :title="task.title" :description="task.description" :done="task.done"
:task-index="index"
@toggle-task="toggleTask"
/>
<!-- ... -->
</template>
You can view the finished code for the app here.