完整目录请见Odoo 14全新前端框架 OWL(Odoo Web Library)官方文档中文版
🦉 OWL教程:待办清单TodoApp 🦉
本教程中,我们将创建一个简单的待办清单应用。该应用需满足以下要求:
- 让用户可以创建并删除任务
- 任务可标记为完成
- 可对任务进行过滤展示活跃/已完成任务
通过本项目有机会发现并学习Owl的一些重要概念,比如组件、存储以及如何组织应用。
内容
- 项目配置
- 添加第一个组件
- 展示任务清单
- 布局:一些基础css
- 将任务提取为子组件
- 添加任务(第一部分)
- 添加任务(第二部分)
- 切换任务状态
- 删除任务
- 使用存储
- 在本地存储中保存任务
- 过滤任务
- 最后的修饰
- 最终代码
项目配置
本教程中,我们创建一个非常简单的项目,包含静态文件但没有其它工具。第一步是创建如下的文件结构:
1 2 3 4 5 |
todoapp/ index.html app.css app.js owl.js |
应用的入口是index.html
,内容如下:
1 2 3 4 5 6 7 8 9 10 11 |
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>OWL Todo App</title> <link rel="stylesheet" href="app.css" /> <script src="owl.js"></script> <script src="app.js"></script> </head> <body></body> </html> |
当前app.css
可留空。稍后用于对应用添加样式。在app.js
中编写主代码。现在使用如下代码:
1 2 3 |
(function () { console.log("hello owl", owl.__info__.version); })(); |
注意这里把所有代码放到了立即执行的函数中,来避免对全局产生任何影响。
最后应当从Owl仓库下载最新版的owl.js
(你也可以直接使用owl.min.js
)。
访问https://github.com/odoo/owl/releases下载最新版本 owl.js
此时,项目准备就绪。在浏览器中加载index.html
文件,页面内容为空,标题为Owl Todo App
,在控制台中会打印出hello owl 1.0.0
这样的消息。
添加第一个组件
Owl应用由组件构成,包含一个根组件。下面我们来定义一个App
组件。使用如下代码替换掉app.js
中函数的内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
const { Component } = owl; const { xml } = owl.tags; const { whenReady } = owl.utils; // Owl Components class App extends Component { static template = xml`<div>todo app</div>`; } // Setup code function setup() { const app = new App(); app.mount(document.body); } whenReady(setup); |
此时重新加载页面会显示一条消息。
代码非常简单,但我们来详细讲解一下最后一行代码。浏览器会尝试尽快执行app.js
中的JS代码,在有可能在加载App
组件时DOM尚未准备就绪。为避免这一问题,我们使用whenReady
帮助函数来将setup
函数的执行延迟至DOM准备就绪后。
注意1:在更大型的项目中,我们会把代码分割成多个文件,在子文件夹中放置组件,主文件会用于初始化应用。但这里是一个非常小的项目,我们希望保持尽量简单。
注意2:本教程中使用了静态类字段语法。有些浏览器尚不支持。大部分真实项目会对代码进行转译,不会存在问题,但要让本教程的代码在各个浏览器中正常使用,需要将每个static
关键词转换为类赋值:
1 2 |
class App extends Component {} App.template = xml`<div>todo app</div>`; |
注意3:使用xml
helper 行内模板很好,但没有语法高亮,这会很容易写出格式错误的xml。针对这一情况有些编辑器支持语法高亮。例如,VS Code中有一个插件Comment tagged template
,安装后会正常显示带标签模板:
1 |
static template = xml /* xml */`<div>todo app</div>`; |
注意4:大型应用可能需要对模板进行翻译。使用行内模板会让其变得困难,因为我们需要额外的工具类从代码中提取 xml,再使用翻译值进行替换。
展示任务清单
现在已完成基础工作。是时候考虑任务这块了。为完成所需,我们要使用如下键的对象数组记录任务。
id
: 数字。对于唯一标识任务极其有用。因其标题由用户创建或编辑,无法保证唯一性。因为我们对每个任务生成一个唯一数字id
。title
: 字符串,说明任务是关于什么的。isCompleted
: 布尔值,记录任务的状态。
既然决定好了状态的内部格式,可以对App
组件添加一些演示数据及模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
class App extends Component { static template = xml/* xml */ ` <div class="task-list"> <t t-foreach="tasks" t-as="task" t-key="task.id"> <div class="task"> <input type="checkbox" t-att-checked="task.isCompleted"/> <span><t t-esc="task.title"/></span> </div> </t> </div>`; tasks = [ { id: 1, title: "buy milk", isCompleted: true, }, { id: 2, title: "clean house", isCompleted: false, }, ]; } |
模板中包含一个t-foreach
循环对任务进行遍历。它可丰组件中查找任务
列表,因为组件是渲染上下文。注意我们将每个任务的id
作为t-key
,这是常见做法。有两个css类:task-list
和 task
,在下一节中进行使用。
最后,注意t-att-checked
属性的使用:在属性名前添加t-att
来让其成为动态属性。Owl将会运行表达式并设置结果为属性值。
布局:一些基础css
至此,我们的任务清单还不好看。在app.css
中添加如下样式:
1 2 3 4 5 6 7 8 9 10 11 |
.task-list { width: 300px; margin: 50px auto; background: aliceblue; padding: 10px; } .task { font-size: 18px; color: #111111; } |
这样好多了。下面添加一些其它功能:完成的任务样式应当有不同,来显示其重要程度较低。这时可以对每个任务添加一个动态css类:
1 |
<div class="task" t-att-class="task.isCompleted ? 'done' : ''"> |
1 2 3 |
.task.done { opacity: 0.7; } |
注意在这里我们再次使用到了动态属性。
将任务提取为子组件
现在很清晰应当有一个Task
组件用于封装任务的外观和行为。
Task
组件会展示傻,但不能拥有任务的状态:一段数据仅能有一个所有者。否则会是自找麻烦。因此Task
组件会以prop
获取其数据。这表示数据仍由App
组件所持有,但可由Task
组件使用(不进行修改)。
因为我们在动代码,趁机可以对代码进行一些重构:
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 |
// ------------------------------------------------------------------------- // Task Component // ------------------------------------------------------------------------- const TASK_TEMPLATE = xml /* xml */` <div class="task" t-att-class="props.task.isCompleted ? 'done' : ''"> <input type="checkbox" t-att-checked="props.task.isCompleted"/> <span><t t-esc="props.task.title"/></span> </div>`; class Task extends Component { static template = TASK_TEMPLATE; static props = ["task"]; } // ------------------------------------------------------------------------- // App Component // ------------------------------------------------------------------------- const APP_TEMPLATE = xml /* xml */` <div class="task-list"> <t t-foreach="tasks" t-as="task" t-key="task.id"> <Task task="task"/> </t> </div>`; class App extends Component { static template = APP_TEMPLATE; static components = { Task }; tasks = [ ... ]; } // ------------------------------------------------------------------------- // Setup code // ------------------------------------------------------------------------- function setup() { owl.config.mode = "dev"; const app = new App(); app.mount(document.body); } whenReady(setup); |
这背后发生了很多事:
- 首先当前在文件上方定义了一个子组件
Task
, - 添加子组件时,需要将其添加到父组件的静态
components
键中,这样Owl可对其进行引用, - 模板从组件中进行提取,让“视图/模板”代码与“脚本/行为”代码更易于区分。
Task
组件有一个props
键:用作校验。它表明每个Task
仅给定一个属性,名为task
。若非如此,Owl会抛出一条错误。这对于重构组件极为有用。- 最后要启用props校验,需要将Owl的模式设置为
dev
。通过setup
函数实现。注意在真实的生产环境应予以删除,因为dev
模式由于有额外的检查、校验会有一些慢。
添加任务(第一部分)
我们还在使用硬编码的任务。是时候让用户可以自己添加任务了。第一步是对App
组件添加一个输入框。这个输入框位于任务清单之外,因此需要调整App
的模板、JS 和 CSS(注意其中对 task-list 样式的修改):
1 2 3 4 5 6 7 8 |
<div class="todo-app"> <input placeholder="Enter a new task" t-on-keyup="addTask"/> <div class="task-list"> <t t-foreach="tasks" t-as="task" t-key="task.id"> <Task task="task"/> </t> </div> </div> |
1 2 3 4 5 6 7 8 9 |
addTask(ev) { // 13 is keycode for ENTER if (ev.keyCode === 13) { const title = ev.target.value.trim(); ev.target.value = ""; console.log('adding task', title); // todo } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
.todo-app { width: 300px; margin: 50px auto; background: aliceblue; padding: 10px; } .todo-app > input { display: block; margin: auto; } .task-list { margin-top: 8px; } |
现在输入框可以使用了,它会在用户每添加一个任务时在控制台中进行记录。注意在加载页面时,光标不会聚焦到输入框。但添加任务是任务清单的核心功能,我们对输入框进行聚焦来让这一操作更快速。
因为App
是一个组件,它存在一个mounted
生命周期方法可供实现。我们还需要获取对输入框的引用,可通过带有useRef
钩子的t-ref
指令。
1 |
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/> |
1 2 |
// 在文件顶部添加 const { useRef } = owl.hooks; |
1 2 3 4 5 6 |
// 在App内部 inputRef = useRef("add-input"); mounted() { this.inputRef.el.focus(); } |
inputRef
以类字段进行定义,因此和在构造函数中进行定义是一样的。它告知Owl使用对应的t-ref
关键词来引用元素。然后我们实现了mounted
生命周期方法,对当前引用的输入框进行聚焦显示。
添加任务(第二部分)
在前一部分中,我们尚未实现任务创建部分的代码。下面进行编写。
需要有一种方式来生成id
数字。这只需在App
中添加一个nextId
数字。同时,我们从App
中删除演示任务:
1 2 |
nextId = 1; tasks = []; |
这时可以开始实现addTask
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
addTask(ev) { // 13 is keycode for ENTER if (ev.keyCode === 13) { const title = ev.target.value.trim(); ev.target.value = ""; if (title) { const newTask = { id: this.nextId++, title: title, isCompleted: false, }; this.tasks.push(newTask); } } } |
这基本上可以了,但如果测试的话,会注意到在用户输入Enter
时不会显示新任务。但如果添加了debugger
或console.log
语句的话,会看到代码实际上正常运行了。问题在于Owl不知道它需要重新渲染用户界面。可以使用useState
钩子来将tasks
变为响应式解决这一问题:
1 2 3 4 5 |
// 文件顶部 const { useRef, useState } = owl.hooks; // 使用如下代码替换App中的task定义 tasks = useState([]); |
这时就可以正常使用了。
切换任务状态
如果尝试将任务标记为完成,可能会注意到透明度没有任何变化。这是因为还没有写修改isCompleted
标记的代码。
这里有一个有趣的状况:任务由Task
组件显示,但它却不是状态的所有者,因而无法修改状态。我们转而希望通过通讯请求来切换App
组件任务的状态。因App
是Task
的父组件,可以在Task
中触发一个事件并在App
中监听该事件。
在Task
中,修改input
如下:
1 |
<input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/> |
并添加toggleTask
方法:
1 2 3 |
toggleTask() { this.trigger('toggle-task', {id: this.props.task.id}); } |
下面我们需要在App
模板中监听该事件:
1 |
<div class="task-list" t-on-toggle-task="toggleTask"> |
并实现toggleTask
的代码:
1 2 3 4 |
toggleTask(ev) { const task = this.tasks.find(t => t.id === ev.detail.id); task.isCompleted = !task.isCompleted; } |
删除任务
下面添加删除任务的功能。首先对每个任务添加一个垃圾筒图标,然后像前面小节中一样逐步修改。
第一部,更新Task
模板、css和js:
1 2 3 4 5 |
<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''"> <input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/> <span><t t-esc="props.task.title"/></span> <span class="delete" t-on-click="deleteTask">🗑</span> </div> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
.task { font-size: 18px; color: #111111; display: grid; grid-template-columns: 30px auto 30px; } .task > input { margin: auto; } .delete { opacity: 0; cursor: pointer; text-align: center; } .task:hover .delete { opacity: 1; } |
现在,我们需要监听App
中的delete-task
事件:
1 |
<div class="task-list" t-on-toggle-task="toggleTask" t-on-delete-task="deleteTask"> |
1 2 3 4 |
deleteTask(ev) { const index = this.tasks.findIndex(t => t.id === ev.detail.id); this.tasks.splice(index, 1); } |
使用存储(store)
查看代码,很明显处理任务的代码散落在不止一个地方。同时UI和业务逻辑代码混在一起。Owl有一种通过用户界面管理状态的方式:Store
.。
我们在应用中进行使用。这对我们的应用是一个比较大的重构,因为涉及到从组件提取出所有任务相关代码。下面是app.js
文件的新内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
const { Component, Store } = owl; const { xml } = owl.tags; const { whenReady } = owl.utils; const { useRef, useDispatch, useStore } = owl.hooks; // ------------------------------------------------------------------------- // Store // ------------------------------------------------------------------------- const actions = { addTask({ state }, title) { title = title.trim(); if (title) { const task = { id: state.nextId++, title: title, isCompleted: false, }; state.tasks.push(task); } }, toggleTask({ state }, id) { const task = state.tasks.find((t) => t.id === id); task.isCompleted = !task.isCompleted; }, deleteTask({ state }, id) { const index = state.tasks.findIndex((t) => t.id === id); state.tasks.splice(index, 1); }, }; const initialState = { nextId: 1, tasks: [], }; // ------------------------------------------------------------------------- // Task Component // ------------------------------------------------------------------------- const TASK_TEMPLATE = xml/* xml */ ` <div class="task" t-att-class="props.task.isCompleted ? 'done' : ''"> <input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="dispatch('toggleTask', props.task.id)"/> <span><t t-esc="props.task.title"/></span> <span class="delete" t-on-click="dispatch('deleteTask', props.task.id)">🗑</span> </div>`; class Task extends Component { static template = TASK_TEMPLATE; static props = ["task"]; dispatch = useDispatch(); } // ------------------------------------------------------------------------- // App Component // ------------------------------------------------------------------------- const APP_TEMPLATE = xml/* xml */ ` <div class="todo-app"> <input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/> <div class="task-list"> <t t-foreach="tasks" t-as="task" t-key="task.id"> <Task task="task"/> </t> </div> </div>`; class App extends Component { static template = APP_TEMPLATE; static components = { Task }; inputRef = useRef("add-input"); tasks = useStore((state) => state.tasks); dispatch = useDispatch(); mounted() { this.inputRef.el.focus(); } addTask(ev) { // 13 is keycode for ENTER if (ev.keyCode === 13) { this.dispatch("addTask", ev.target.value); ev.target.value = ""; } } } // ------------------------------------------------------------------------- // Setup code // ------------------------------------------------------------------------- function setup() { owl.config.mode = "dev"; const store = new Store({ actions, state: initialState }); App.env.store = store; const app = new App(); app.mount(document.body); } whenReady(setup); |
在本地存储中保存任务
现在我们的TodoApp似乎使用正常,但用户关闭或刷新浏览器后就现原形了!仅在内存中保存应用状态真的是很不便。要解决这一问题,可以把任务保存到本地存储中。对当前的代码只需做简单的修改:仅setup代码需要做更新。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function makeStore() { const localState = window.localStorage.getItem("todoapp"); const state = localState ? JSON.parse(localState) : initialState; const store = new Store({ state, actions }); store.on("update", null, () => { localStorage.setItem("todoapp", JSON.stringify(store.state)); }); return store; } function setup() { owl.config.mode = "dev"; App.env.store = makeStore(); const app = new App(); app.mount(document.body); } |
关键点是存储是一种EventBus
,在更新时会触发update
事件。
过滤任务
我们差不多已经完成了,可以添加、更新、删除任务。唯一还少的功能是根据完成状态显示任务。我们将在App
中记录过滤的状态,然后根据其值过滤出可见任务。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// 在文件顶部,重新添加useState const { useRef, useDispatch, useState, useStore } = owl.hooks; // in App: filter = useState({value: "all"}) get displayedTasks() { switch (this.filter.value) { case "active": return this.tasks.filter(t => !t.isCompleted); case "completed": return this.tasks.filter(t => t.isCompleted); case "all": return this.tasks; } } setFilter(filter) { this.filter.value = filter; } |
最后,我们需要显示可见的过滤器。可以实现的同时在主列表下方以小面板显示任务数量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
<div class="todo-app"> <input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/> <div class="task-list"> <t t-foreach="displayedTasks" t-as="task" t-key="task.id"> <Task task="task"/> </t> </div> <div class="task-panel" t-if="tasks.length"> <div class="task-counter"> <t t-esc="displayedTasks.length"/> <t t-if="displayedTasks.length lt tasks.length"> / <t t-esc="tasks.length"/> </t> task(s) </div> <div> <span t-foreach="['all', 'active', 'completed']" t-as="f" t-key="f" t-att-class="{active: filter.value===f}" t-on-click="setFilter(f)" t-esc="f"/> </div> </div> </div> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
.task-panel { color: #0088ff; margin-top: 8px; font-size: 14px; display: flex; } .task-panel .task-counter { flex-grow: 1; } .task-panel span { padding: 5px; cursor: pointer; } .task-panel span.active { font-weight: bold; } |
注意这里我们使用对象语法动态地设置了过滤器class:如果键和值匹配则是需要显示 class 的。
最后的修饰
我们的列表功能已完成。还可以再添加一些细节来改善用户体验。
- 在用户鼠标悬浮到任务上方时添加视觉反馈:
123.task:hover {background-color: #def0ff;} - 可点击任务标题来切换复选框状态:
1234<input type="checkbox" t-att-checked="props.task.isCompleted"t-att-id="props.task.id"t-on-click="dispatch('toggleTask', props.task.id)"/><label t-att-for="props.task.id"><t t-esc="props.task.title"/></label> - 对已完成任务的标题添加中划线:
123.task.done label {text-decoration: line-through;}
最终代码
至此就完成了我们的应用。功能实现,UI代码与业务逻辑进行了很好的分离,可进行测试,代码均小于150行(包含模板)。
为方便读者参考,以下是最终的代码:
1 2 3 4 5 6 7 8 9 10 11 |
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>OWL Todo App</title> <link rel="stylesheet" href="app.css" /> <script src="owl.js"></script> <script src="app.js"></script> </head> <body></body> </html> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
(function () { const { Component, Store } = owl; const { xml } = owl.tags; const { whenReady } = owl.utils; const { useRef, useDispatch, useState, useStore } = owl.hooks; // ------------------------------------------------------------------------- // Store // ------------------------------------------------------------------------- const actions = { addTask({ state }, title) { title = title.trim(); if (title) { const task = { id: state.nextId++, title: title, isCompleted: false, }; state.tasks.push(task); } }, toggleTask({ state }, id) { const task = state.tasks.find((t) => t.id === id); task.isCompleted = !task.isCompleted; }, deleteTask({ state }, id) { const index = state.tasks.findIndex((t) => t.id === id); state.tasks.splice(index, 1); }, }; const initialState = { nextId: 1, tasks: [], }; // ------------------------------------------------------------------------- // Task Component // ------------------------------------------------------------------------- const TASK_TEMPLATE = xml/* xml */ ` <div class="task" t-att-class="props.task.isCompleted ? 'done' : ''"> <input type="checkbox" t-att-checked="props.task.isCompleted" t-att-id="props.task.id" t-on-click="dispatch('toggleTask', props.task.id)"/> <label t-att-for="props.task.id"><t t-esc="props.task.title"/></label> <span class="delete" t-on-click="dispatch('deleteTask', props.task.id)">🗑</span> </div>`; class Task extends Component { static template = TASK_TEMPLATE; static props = ["task"]; dispatch = useDispatch(); } // ------------------------------------------------------------------------- // App Component // ------------------------------------------------------------------------- const APP_TEMPLATE = xml/* xml */ ` <div class="todo-app"> <input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/> <div class="task-list"> <Task t-foreach="displayedTasks" t-as="task" t-key="task.id" task="task"/> </div> <div class="task-panel" t-if="tasks.length"> <div class="task-counter"> <t t-esc="displayedTasks.length"/> <t t-if="displayedTasks.length lt tasks.length"> / <t t-esc="tasks.length"/> </t> task(s) </div> <div> <span t-foreach="['all', 'active', 'completed']" t-as="f" t-key="f" t-att-class="{active: filter.value===f}" t-on-click="setFilter(f)" t-esc="f"/> </div> </div> </div>`; class App extends Component { static template = APP_TEMPLATE; static components = { Task }; inputRef = useRef("add-input"); tasks = useStore((state) => state.tasks); filter = useState({ value: "all" }); dispatch = useDispatch(); mounted() { this.inputRef.el.focus(); } addTask(ev) { // 13 is keycode for ENTER if (ev.keyCode === 13) { this.dispatch("addTask", ev.target.value); ev.target.value = ""; } } get displayedTasks() { switch (this.filter.value) { case "active": return this.tasks.filter((t) => !t.isCompleted); case "completed": return this.tasks.filter((t) => t.isCompleted); case "all": return this.tasks; } } setFilter(filter) { this.filter.value = filter; } } // ------------------------------------------------------------------------- // Setup code // ------------------------------------------------------------------------- function makeStore() { const localState = window.localStorage.getItem("todoapp"); const state = localState ? JSON.parse(localState) : initialState; const store = new Store({ state, actions }); store.on("update", null, () => { localStorage.setItem("todoapp", JSON.stringify(store.state)); }); return store; } function setup() { owl.config.mode = "dev"; App.env.store = makeStore(); const app = new App(); app.mount(document.body); } whenReady(setup); })(); |
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 |
.todo-app { width: 300px; margin: 50px auto; background: aliceblue; padding: 10px; } .todo-app > input { display: block; margin: auto; } .task-list { margin-top: 8px; } .task { font-size: 18px; color: #111111; display: grid; grid-template-columns: 30px auto 30px; } .task:hover { background-color: #def0ff; } .task > input { margin: auto; } .delete { opacity: 0; cursor: pointer; text-align: center; } .task:hover .delete { opacity: 1; } .task.done { opacity: 0.7; } .task.done label { text-decoration: line-through; } .task-panel { color: #0088ff; margin-top: 8px; font-size: 14px; display: flex; } .task-panel .task-counter { flex-grow: 1; } .task-panel span { padding: 5px; cursor: pointer; } .task-panel span.active { font-weight: bold; } |