diff --git a/es5/.eslintrc.js b/es5/.eslintrc.js new file mode 100644 index 0000000..e529dd8 --- /dev/null +++ b/es5/.eslintrc.js @@ -0,0 +1,24 @@ +module.exports = { + extends: ['eslint:recommended', 'plugin:compat/recommended'], + globals: { + Set: 'readonly', + Map: 'readonly', + }, + env: { + browser: true, + }, + parserOptions: { + ecmaVersion: 5, + }, + rules: {}, + settings: { + polyfills: [ + 'Set', + 'Map', + 'fetch', + 'Object.assign', + 'requestAnimationFrame', + 'performance.now', + ], + }, +}; diff --git a/es5/README.md b/es5/README.md new file mode 100644 index 0000000..2d1755e --- /dev/null +++ b/es5/README.md @@ -0,0 +1,831 @@ +# VANILLA TODO (ES5) + +_You are reading the ES5-based version of this document, originally published +in 2019. The [current version](https://github.com/morris/vanilla-todo) is +based on modern JavaScript._ + +A [TeuxDeux](https://teuxdeux.com) clone in plain HTML, CSS and JavaScript +(no build steps). It's fully animated and runs smoothly at 60 FPS +with a total transfer size of **44KB** (unminified). + +**[Try it online →](https://raw.githack.com/morris/vanilla-todo/main/es5/public/index.html)** + +More importantly, it's a case study showing that **vanilla web development** is +viable in terms of [maintainability](#521-the-good), +and worthwhile in terms of [user experience](#51-user-experience) +(**100%** faster loads and **90%** less bandwidth in this case). + +**There's no custom framework invented here.** +Instead, the case study was [designed](#22-rules) to discover +minimum viable [patterns](#321-mount-functions) that are truly vanilla. +The result is maintainable, albeit [verbose](#522-the-verbose) and with +considerable duplication. + +If anything, the case study validates the value of build steps and frameworks, +but also demonstrates that standard web technologies can be used effectively and +there are only a few [critical areas](#523-the-bad) where a vanilla approach is +clearly inferior (especially in browser testing). + +_Intermediate understanding of the web platform is required to follow through._ + +## Table of Contents + +- [1. Motivation](#1-motivation) +- [2. Method](#2-method) + - [2.1. Subject](#21-subject) + - [2.2. Rules](#22-rules) + - [2.3. Goals](#23-goals) + - [2.3.1. User Experience](#231-user-experience) + - [2.3.2. Code Quality](#232-code-quality) + - [2.3.3. Generality of Patterns](#233-generality-of-patterns) +- [3. Implementation](#3-implementation) + - [3.1. Basic Structure](#31-basic-structure) + - [3.2. JavaScript Architecture](#32-javascript-architecture) + - [3.2.1. Mount Functions](#321-mount-functions) + - [3.2.2. Data Flow](#322-data-flow) + - [3.2.3. Rendering](#323-rendering) + - [3.2.4. Reconciliation](#324-reconciliation) + - [3.3. Drag & Drop](#33-drag--drop) + - [3.4. Animations](#34-animations) +- [4. Testing](#4-testing) +- [5. Assessment](#5-assessment) + - [5.1. User Experience](#51-user-experience) + - [5.2. Code Quality](#52-code-quality) + - [5.2.1. The Good](#521-the-good) + - [5.2.2. The Verbose](#522-the-verbose) + - [5.2.3. The Bad](#523-the-bad) + - [5.3. Generality of Patterns](#53-generality-of-patterns) +- [6. Conclusion](#6-conclusion) +- [7. What's Next?](#7-whats-next) +- [8. Appendix](#8-appendix) + - [8.1. Links](#81-links) + - [8.2. Response](#82-response) +- [9. Changelog](#9-changelog) + +## 1. Motivation + +I believe too little has been invested in researching +practical, scalable methods for building web applications +without third party dependencies. + +It's not enough to describe how to create DOM nodes +or how to toggle a class without a framework. +It's also rather harmful to write an article +saying you don't need library X, and then proceed in describing how +to roll your own untested, inferior version of X. + +What's missing are thorough examples of complex web applications +built only with standard web technologies, covering as many aspects of +the development process as possible. + +This case study is an attempt to fill this gap, at least a little bit, +and inspire further research in the area. + +## 2. Method + +The method for this case study is as follows: + +- Pick an interesting subject. +- Implement it using only standard web technologies. +- Document techniques and patterns found during the process. +- Assess the results by common quality standards. + +This section describes the method in more detail. + +### 2.1. Subject + +I've chosen to build a functionally equivalent clone of +[TeuxDeux](https://teuxdeux.com) for this study. +The user interface has interesting challenges, +in particular performant drag & drop when combined with animations. + +_The original TeuxDeux app deserves praise here. In my opinion it has the +best over-all concept and UX of all the to-do apps out there. +[Thank you!](https://fictivekin.com/)_ + +The user interface is arguably small (which is good for a case study) +but large enough to require thought on its architecture. + +However, it is lacking in some key areas: + +- Routing +- Asynchronous resource requests +- Server-side rendering + +### 2.2. Rules + +To produce valid vanilla solutions, and because constraints spark creativity, +I came up with a set of rules to follow throughout the process: + +- Only use standard web technologies. +- Only use widely supported JS features unless they can be polyfilled (1). +- No runtime JS dependencies (except polyfills). +- No build steps. +- No general-purpose utility functions related to the DOM/UI (2). + +(1) This is a moving target; I used ES5 for maximum support. + +(2) These usually end up becoming a custom micro-framework, +thereby questioning why you didn't use one of the +established and tested libraries/frameworks in the first place. + +### 2.3. Goals + +The results are going to be assessed by three major concerns: + +#### 2.3.1. User Experience + +The resulting product should be comparable to or better +than the original regarding functionality, performance and design. + +This includes testing major browsers and devices. + +#### 2.3.2. Code Quality + +The resulting implementation should adhere to +established code quality standards in the industry. + +This will be difficult to assess objectively, as we will see later. + +#### 2.3.3. Generality of Patterns + +The discovered techniques and patterns should be applicable in a wide +range of scenarios. + +## 3. Implementation + +This section walks through the resulting implementation, highlighting techniques +and problems found during the process. You're encouraged to inspect the +[source code](./public) alongside this section. + +### 3.1. Basic Structure + +Since build steps are ruled out, the codebase is organized around +plain HTML, CSS and JS files. The HTML and CSS mostly follows +[rscss](https://rscss.io) (devised by [Rico Sta. Cruz](https://ricostacruz.com)) +which yields an intuitive, component-oriented structure. + +The stylesheets are slightly verbose. +I missed [SCSS](https://sass-lang.com/) or [LESS](http://lesscss.org/) here +and I think one of these is a must-have for bigger projects. + +ES6 modules are ruled out so all JavaScript lives under +a global namespace (`VT`). This works everywhere but has some downsides +e.g. cannot be statically analyzed and may miss code completion. + +Polyfills are directly fetched from [polyfill.io](https://polyfill.io/). +I've set the `nomodule` script attribute so polyfills are only fetched +for older browsers. + +Basic code quality (code style, linting) is guided by +[Prettier](https://prettier.io), [stylelint](https://stylelint.io) and +[ESLint](https://eslint.org). +I've set the ESLint parser to ES5 to ensure only ES5 code is allowed. + +Note that I've opted out of web components completely. +I can't clearly articulate what I dislike about them +but I never missed them throughout this study. + +--- + +The basic structure comes with some boilerplate, +e.g. referencing all the individual stylesheets and scripts from the HTML; +probably enough to justify a simple build step. + +It is otherwise straight-forward and trivial to understand +(literally just a bunch of HTML, CSS and JS files). + +### 3.2. JavaScript Architecture + +Naturally, the JavaScript architecture is the most interesting part of this study. + +I found that using a combination of functions, +query selectors and DOM events is sufficient +to build a scalable, maintainable codebase, +albeit with some trade-offs as we will see later. + +Conceptually, the proposed architecture loosely maps +CSS selectors to JS functions which are _mounted_ (i.e. called) once +per matching element. This yields a simple mental model and synergizes +with the DOM and styles: + +``` +.todo-list -> VT.TodoList + scripts/TodoList.js + styles/todo-list.css + +.app-collapsible -> VT.AppCollapsible + scripts/AppCollapsible.js + styles/app-collapsible.css + +... +``` + +This proved to be a useful, repeatable pattern throughout all of the +implementation process. + +#### 3.2.1. Mount Functions + +_Mount functions_ take a DOM element as their (only) argument. +Their responsibility is to set up initial state, event listeners, and +provide behavior and rendering for the target element. + +Here's a "Hello, World!" example of mount functions: + +```js +// safely initialize namespace +window.MYAPP = window.MYAPP || {}; + +// define mount function +// loosely mapped to ".hello-world" +MYAPP.HelloWorld = function (el) { + // define initial state + var state = { + title: 'Hello, World!', + description: 'An example vanilla component', + counter: 0, + }; + + // set rigid base HTML + // no ES6 template literals :( + el.innerHTML = [ + '
', + '', + '', + ].join('\n'); + + // mount sub-components + el.querySelectorAll('.my-counter').forEach(MYAPP.MyCounter); + + // attach event listeners + el.addEventListener('modifyCounter', function (e) { + update({ counter: state.counter + e.detail }); + }); + + // expose public interface + // use lower-case function name + el.helloWorld = { + update: update, + }; + + // initial update + update(); + + // define idempotent update function + function update(next) { + // update state + // optionally optimize, e.g. bail out if state hasn't changed + Object.assign(state, next); + + // update own HTML + el.querySelector('.title').innerText = state.title; + el.querySelector('.description').innerText = state.description; + + // pass data to sub-scomponents + el.querySelector('.my-counter').myCounter.update({ + value: state.counter, + }); + } +}; + +// define another component +// loosely mapped to ".my-counter" +MYAPP.MyCounter = function (el) { + // define initial state + var state = { + value: 0, + }; + + // set rigid base HTML + // no ES6 template literals :( + el.innerHTML = [ + '', + ' ', + ' ', + ' ', + '
', + ].join('\n'); + + // attach event listeners + el.querySelector('.increment').addEventListener('click', function () { + // dispatch an action + // use .detail to transport data + el.dispatchEvent( + new CustomEvent('modifyCounter', { + detail: 1, + bubbles: true, + }) + ); + }); + + el.querySelector('.decrement').addEventListener('click', function () { + // dispatch an action + // use .detail to transport data + el.dispatchEvent( + new CustomEvent('modifyCounter', { + detail: -1, + bubbles: true, + }) + ); + }); + + // expose public interface + // use lower-case function name + el.myCounter = { + update: update, + }; + + // define idempotent update function + function update(next) { + Object.assign(state, next); + + el.querySelector('.value').innerText = state.value; + } +}; + +// mount HelloWorld component(s) +// any in the document will be mounted +document.querySelectorAll('.hello-world').forEach(MYAPP.HelloWorld); +``` + +This comes with quite some boilerplate but has useful properties, +as we will see in the following sections. + +Note that any part of a mount function is entirely optional. +For example, a mount function does not have to set any base HTML, +and may instead only set event listeners to enable some behavior. + +Also note that an element can be mounted with multiple mount functions. +For example, to-do items are mounted with `VT.TodoItem` and `VT.AppDraggable`. + +Compared to React components, mount functions provide interesting flexibility as +components and behaviors can be implemented using the same idiom and combined +arbitrarily. + +Reference: + +- [AppIcon.js](./public/scripts/AppIcon.js) +- [TodoItem.js](./public/scripts/TodoItem.js) +- [TodoItemInput.js](./public/scripts/TodoItemInput.js) + +#### 3.2.2. Data Flow + +I found it effective to implement one-way data flow similar to React's approach. + +- **Data flows downwards** from parent components to child components + through their public interfaces (usually `update` functions). +- **Actions flow upwards** through custom DOM events (bubbling up), + usually resulting in some parent component state change which is in turn + propagated downwards through `update` functions. + +The data store is factored into a separate behavior (`VT.TodoStore`). +It only receives and dispatches events, and encapsulates all of the data logic. + +Listening to and dispatching events is slightly verbose with standard APIs and +certainly justifies introducing helpers. +I didn't need event delegation à la jQuery for this study +but I believe it's a useful concept that is difficult to do +concisely with standard APIs. + +Reference: + +- [TodoDay.js](./public/scripts/TodoDay.js) +- [TodoStore.js](./public/scripts/TodoStore.js) + +#### 3.2.3. Rendering + +Naively re-rendering a whole component using `.innerHTML` should be avoided +as this may hurt performance and will likely break important functionality such +as input state, focus, text selection etc. which browsers have already been +optimizing for decades. + +As seen in [3.2.1.](#321-mount-functions), rendering is therefore split into +some rigid base HTML and an idempotent, complete update function which only +makes necessary changes. + +- **Idempotency** is key here, i.e. update functions may be called at any time + and should always render the component correctly. +- **Completeness** is equally important, i.e. update functions should render + the whole component, regardless of what triggered an update. + +In effect, this means almost all DOM manipulation is done in update functions, +which greatly contributes to robustness and readability of the codebase. + +As seen above this approach is quite verbose and ugly compared to JSX, for +example. However, it's very performant and can be further optimized +by checking for data changes, caching selectors, etc. +It is also simple to understand. + +Reference: + +- [TodoItem.js](./public/scripts/TodoItem.js) +- [TodoCustomList.js](./public/scripts/TodoCustomList.js) + +#### 3.2.4. Reconciliation + +Expectedly, the hardest part of the study was rendering a variable +amount of dynamic components efficiently. Here's a commented example +from the implementation outlining the reconciliation algorithm: + +```js +/* global VT */ +window.VT = window.VT || {}; + +VT.TodoList = function (el) { + var state = { + items: [], + }; + + el.innerHTML = ''; + + function update(next) { + Object.assign(state, next); + + var container = el.querySelector('.items'); + + // mark current children for removal + var obsolete = new Set(container.children); + + // map current children by data-key + var childrenByKey = new Map(); + + obsolete.forEach(function (child) { + childrenByKey.set(child.getAttribute('data-key'), child); + }); + + // build new list of child elements from data + var children = state.items.map(function (item) { + // find existing child by data-key + var child = childrenByKey.get(item.id); + + if (child) { + // if child exists, keep it + obsolete.delete(child); + } else { + // otherwise, create new child + child = document.createElement('div'); + child.classList.add('todo-item'); + + // set data-key + child.setAttribute('data-key', item.id); + + // mount component + VT.TodoItem(child); + } + + // update child + child.todoItem.update({ item: item }); + + return child; + }); + + // remove obsolete children + obsolete.forEach(function (child) { + container.removeChild(child); + }); + + // (re-)insert new list of children + children.forEach(function (child, index) { + if (child !== container.children[index]) { + container.insertBefore(child, container.children[index]); + } + }); + } + + el.todoList = { + update: update, + }; +}; +``` + +It's very verbose and has lots of opportunity to introduce bugs. +Compared to a simple loop in JSX, this seems insane. +It is quite performant as it does minimal work but is otherwise messy; +definitely a candidate for a utility function or library. + +### 3.3. Drag & Drop + +Implementing drag & drop from scratch was challenging, +especially regarding browser/device consistency. + +Using a library would have been a lot more cost-effective initially. +However, having a customized implementation paid off once I started +introducing animations as both had to be coordinated closely. +I can imagine this would have been a difficult problem +when using third party code for either. + +The drag & drop implementation is (again) based on DOM events and integrates +well with the remaining architecture. +It's clearly the most complex part of the study but I was able to implement it +without changing existing code besides mounting behaviors and +adding event handlers. + +I suspect the drag & drop implementation to have some subtle problems on +touch devices, as I haven't extensively tested them. Using a library for +identifying the gestures could be more sensible and would reduce costs in +testing browsers and devices. + +Reference: + +- [AppDraggable.js](./public/scripts/AppDraggable.js) +- [AppSortable.js](./public/scripts/AppSortable.js) +- [TodoList.js](./public/scripts/TodoList.js) + +### 3.4. Animations + +For the final product I wanted smooth animations for most user interactions. +This is a cross-cutting concern which was implemented using the +[FLIP](https://aerotwist.com/blog/flip-your-animations/) technique as devised +by [Paul Lewis](https://twitter.com/aerotwist). + +Implementing FLIP animations without a large refactoring was the biggest +challenge of this case study, especially in combination with drag & drop. +After days of work I was able to implement the algorithm in isolation and +coordinate it with other concerns at the application's root level. +The `useCapture` mode of `addEventListener` proved to be very useful +in this case. + +Reference: + +- [AppFlip.js](./public/scripts/AppFlip.js) +- [TodoApp.js](./public/scripts/TodoApp.js) + +## 4. Testing + +_TODO_ + +## 5. Assessment + +### 5.1. User Experience + +Most important features from the original TeuxDeux application are implemented +and usable: + +- Daily to-do lists +- Add/edit/delete to-do items +- Custom to-do lists +- Add/edit/delete custom to-do lists +- Drag & drop to-do items across lists +- Reorder custom to-do lists via drag & drop +- Local Storage persistence + +Additionally, most interactions are smoothly animated at 60 frames per second. +In particular, dragging and dropping gives proper visual feedback +when elements are reordered. + +_The latter was an improvement over the original application when I started +working on the case study some weeks ago. In the meantime, the TeuxDeux +team released an update with a much better drag & drop experience. Great job!_ + +One notable missing feature is Markdown support. It would be insensible +to implement Markdown from scratch; this is a valid candidate for using +an external library as it is entirely orthogonal to the remaining codebase. + +The application has been tested on latest Chrome, Firefox, Safari, +and Safari on iOS. + +_TODO Test more browsers and devices._ + +A fresh load of the original TeuxDeux application transfers around **435 KB** and +finishes loading at around **1000 ms**, sometimes up to 2000ms +(measured on 10/21 2020). +Reloads finish at around **500ms**. + +With a transferred size of around **44 KB**, the vanilla application consistently +loads in **300-500 ms**—not minified and with each script, stylesheet and icon +served as an individual file. Reloads finish at **100-200ms**; again, not +optimized at all (with e.g. asset hashing/indefinite caching). + +_To be fair, my implementation misses quite a few features from the original. +I suspect a fully equivalent clone to be well below 100 KB transfer, though._ + +_TODO Run more formal performance tests and add figures for the results._ + +### 5.2. Code Quality + +Unfortunately, it is quite hard to find undisputed, objective measurements +for code quality (besides trivialities like code style, linting, etc.). +The only generally accepted assessment seems to be peer reviewal. + +To have at least some degree of assessment of the code's quality, +the following sections summarize relevant facts about the codebase +and some opinionated statements based on my experience in the industry. + +#### 5.2.1. The Good + +- No build steps +- No external dependencies at runtime besides polyfills + - No dependency maintenance + - No breaking changes to monitor +- Used only standard technologies: + - Plain HTML, CSS and JavaScript + - Standard DOM APIs +- Very few concepts introduced: + - Mount functions (loosely mapped by CSS class names) + - State separated from the DOM + - Idempotent updates + - Data flow using custom events +- Compare the proposed architecture to the API/conceptual surface of Angular or React... +- Progressive developer experience + - Markup, style, and behavior are orthogonal and can be developed separately. + - Adding behavior has little impact on the markup besides adding classes. +- Debugging is straight-forward using modern browser developer tools. +- The app can be naturally enhanced from the outside by handling/dispatching + events (just like you can naturally animate some existing HTML). +- Little indirection +- Low coupling +- The result is literally just a bunch of HTML, CSS, and JS files. + +All source files (HTML, CSS and JS) combine to **under 2500 lines of code**, +including comments and empty lines. + +For comparison, prettifying the original TeuxDeux's minified JS application +bundle yields **48787 LOC** (10/21 2020). + +_To be fair, my implementation misses quite a few features from the original. +I suspect a fully equivalent clone to be well below 10000 LOC, though._ + +#### 5.2.2. The Verbose + +- Stylesheets are a bit verbose. SCSS would help here. +- Simple components require quite some boilerplate code. +- Writing HTML templates as an array of lines is ugly (and sub-optimal). +- ES5 is generally a lot more verbose than ES6. + - Especially arrow functions, template literals, + and async/await would make the code more readable. + - ES6 modules would eliminate the need for a global namespace. +- `el.querySelectorAll(':scope ...')` is somewhat default/expected and + would justify a helper. +- Listening to and dispatching events is slightly verbose. +- Although not used in this study, + event delegation is not trivial to implement without code duplication. + +Eliminating verbosities through build steps and a minimal set of helpers +would reduce the comparably low code size (see above) even further. + +#### 5.2.3. The Bad + +- The separation between base HTML and dynamic rendering is not ideal + when compared to JSX, for example. +- JSX/virtual DOM techniques provide much better development ergonomics. +- Reconciliation is verbose, brittle and repetitive. + I wouldn't recommend the proposed technique + without a well-tested helper function, at least. +- You have to remember mounting behaviors correctly when + creating new elements. It would be helpful to automate this somehow, + e.g. watch elements of selector X (at all times) and ensure the desired + behaviors are mounted once on them. +- No type safety. I've always been a proponent of dynamic languages + but since TypeScripts' type system provides the best of both worlds, + I cannot recommend using it enough. +- We're effectively locked out of using NPM dependencies that don't provide + browser builds as we cannot use CommonJS or ES6 modules. +- Most frameworks handle a lot of browser inconsistencies **for free** and + continuously monitor regressions with extensive test suites. + The cost of browser testing is surely a lot higher + when using a vanilla approach. + +--- + +Besides the issues described above, I believe the codebase is well organized +and there are clear paths for bugfixes and feature development. +Since there's no third party code, bugs are easy to find and fix, +and there are no dependency limitations to work around. + +A certain degree of DOM API knowledge is required but I believe this +should be a goal for any web developer. + +### 5.3. Generality of Patterns + +Assessing the generality of the discovered techniques objectively is +not really possible without production usage. From my experience, however, +I can't imagine any scenario where mount functions, event-based data flow etc. +are not applicable. The underlying principles power the established frameworks, +after all: + +- State is separated from the DOM (React, Angular, Vue). +- Rendering is idempotent and complete (React's pure `render` function). +- One-way data flow (React) + +## 6. Conclusion + +The result of this study is a working todo application with decent UI/UX and +most of the functionality of the original TeuxDeux app, +built using only standard web technologies. +It comes with better overall performance +at a fraction of the code size and bandwidth. + +The codebase seems manageable through a handful of simple concepts, +although it is quite verbose and even messy in some areas. +This could be mitigated by a small number of helper functions and +simple build steps (e.g. SCSS and TypeScript). + +The study's method helped discovering patterns and techniques that +are at least on par with a framework-based approach for the given subject, +without diverging into building a custom framework. + +A notable exception to the latter is rendering variable numbers of elements +in a concise way. I was unable to eliminate the verbosity involved +in basic but efficient reconciliation. +Further research is needed in this area, but for now this appears to be +a valid candidate for a (possibly external) general-purpose utility. + +When looking at the downsides, remember that all of the individual parts are +self-contained, highly decoupled, portable, and congruent to the web platform. +The resulting implementation cannot "rust", by definition, as no dependencies +can become out of date. + +Another thought to be taken with a grain of salt: I believe frameworks +make simple tasks even simpler, but hard tasks (e.g. implementing cross-cutting +concerns or performance optimizations) often more difficult. + +--- + +Setting some constraints up-front forced me to challenge +my assumptions and preconceptions about vanilla web development. +It was quite liberating to avoid general-purpose utilities and +get things done with what's readily available. + +As detailed in the assessment, +the study would likely be more convincing if build steps were allowed. +Modern JavaScript and SCSS could reduce most of +the unnecessarily verbose parts to a minimum. + +Finally, this case study does not question using dependencies or frameworks +in general—they do provide lots of value in many areas. +It was a constrained experiment designed to discover novel methods +for vanilla web development and, hopefully, +inspire innovation and further research in the area. + +## 7. What's Next? + +I'd love to hear feedback and ideas on any aspect of the case study. +It's still lacking in some important areas, e.g. testing techniques. + +Pull requests, questions, and bug reports are more than welcome! + +--- + +Here are a few ideas I'd like to see explored in the future: + +- Run another case study with TypeScript, SCSS, and build steps (seems promising). +- Research validation rules for utility functions and external dependencies. +- Experiment with architectures based on virtual DOM rendering and standard DOM events. +- Compile discovered rules, patterns and techniques into a comprehensive guide. + +Case studies constrained by a set of formal rules are an effective way to find +new patterns and techniques in a wide range of domains. +I'd love to see similar experiments in the future. + +## 8. Appendix + +### 8.1. Links + +General resources I've used extensively: + +- [MDN Web Docs](https://developer.mozilla.org) as a reference for DOM APIs +- [Can I use...](https://caniuse.com) as a reference for browser support +- [React](https://reactjs.org) as inspiration for the architecture + +Useful articles regarding FLIP animations: + +- [FLIP Your Animations (aerotwist.com)](https://aerotwist.com/blog/flip-your-animations) +- [Animating Layouts with the FLIP Technique (css-tricks.com)](https://css-tricks.com/animating-layouts-with-the-flip-technique) +- [Animating the Unanimatable (medium.com)](https://medium.com/developers-writing/animating-the-unanimatable-1346a5aab3cd) + +Projects I've inspected for drag & drop architecture: + +- [React DnD](https://github.com/react-dnd/react-dnd/) +- [react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd) +- [dragula](https://github.com/bevacqua/dragula) + +### 8.2. Response + +#### 10/2020 + +- Trending on [Hacker News](https://news.ycombinator.com/item?id=24893247) +- [Lobsters](https://lobste.rs/s/5gcrxh/case_study_on_vanilla_web_development) +- [@desandro (Twitter)](https://twitter.com/desandro/status/1321095247091433473) + (developer for the original TeuxDeux) +- [Reddit](https://www.reddit.com/r/javascript/comments/jj10k9/vanillatodo_a_case_study_on_viable_techniques_for/) + +Thanks! + +## 9. Changelog + +### 01/2021 + +- Added [response section](#82-response) + +### 10/2020 + +- Refactored for `dataset` [#2](https://github.com/morris/vanilla-todo/issues/2) — + [@opethrocks](https://github.com/opethrocks) +- Fixed [#3](https://github.com/morris/vanilla-todo/issues/3) (navigation bug) — + [@anchepiece](https://github.com/anchepiece), + [@jcoussard](https://github.com/jcoussard) +- Fixed [#4](https://github.com/morris/vanilla-todo/issues/4) (double item creation) — + [@n0nick](https://github.com/n0nick) +- Fixed [#1](https://github.com/morris/vanilla-todo/issues/4) (bad links) — + [@roryokane](https://github.com/roryokane) +- Initial version. diff --git a/es5/public/index.html b/es5/public/index.html new file mode 100644 index 0000000..6924273 --- /dev/null +++ b/es5/public/index.html @@ -0,0 +1,53 @@ + + + + + + +', + ' ', + ' ', + '
', + '', + ' ', + ' ', + '
', + ].join('\n'); + + var checkboxEl = el.querySelector('.checkbox'); + var labelEl = el.querySelector('.label'); + var inputEl = el.querySelector('.input'); + var saveEl = el.querySelector('.save'); + + VT.AppDraggable(el, { + dropSelector: '.todo-list > .items', + }); + + el.querySelectorAll('.app-icon').forEach(VT.AppIcon); + + checkboxEl.addEventListener('touchstart', function () { + saveOnBlur = false; + }); + + checkboxEl.addEventListener('mousedown', function () { + saveOnBlur = false; + }); + + checkboxEl.addEventListener('click', function () { + if (state.editing) save(); + + el.dispatchEvent( + new CustomEvent('checkItem', { + detail: { + item: state.item, + done: !state.item.done, + }, + bubbles: true, + }) + ); + }); + + labelEl.addEventListener('click', function () { + startEditing = true; + update({ editing: true }); + }); + + inputEl.addEventListener('keyup', function (e) { + switch (e.keyCode) { + case 13: // enter + save(); + break; + case 27: // escape + cancelEdit(); + break; + } + }); + + inputEl.addEventListener('blur', function () { + if (saveOnBlur) save(); + saveOnBlur = true; + }); + + inputEl.addEventListener('focusOther', function () { + if (state.editing) save(); + }); + + saveEl.addEventListener('mousedown', function () { + saveOnBlur = false; + }); + + saveEl.addEventListener('click', save); + + el.addEventListener('draggableStart', function (e) { + e.detail.data.item = state.item; + e.detail.data.key = state.item.id; + }); + + el.todoItem = { + update: update, + }; + + function save() { + var label = inputEl.value.trim(); + + if (label === '') { + // deferred deletion prevents a bug at reconciliation in TodoList: + // Failed to execute 'removeChild' on 'Node': The node to be removed is + // no longer a child of this node. Perhaps it was moved in a 'blur' + // event handler? + requestAnimationFrame(function () { + el.dispatchEvent( + new CustomEvent('deleteItem', { + detail: state.item, + bubbles: true, + }) + ); + }); + + return; + } + + el.dispatchEvent( + new CustomEvent('saveItem', { + detail: { + item: state.item, + label: label, + }, + bubbles: true, + }) + ); + + update({ editing: false }); + } + + function cancelEdit() { + saveOnBlur = false; + update({ editing: false }); + } + + function update(next) { + // TODO optimize + Object.assign(state, next); + + el.classList.toggle('-done', state.item.done); + checkboxEl.querySelector('input').checked = state.item.done; + labelEl.innerText = state.item.label; + + el.classList.toggle('-editing', state.editing); + el.classList.toggle('_nodrag', state.editing); + + if (state.editing && startEditing) { + inputEl.value = state.item.label; + inputEl.focus(); + inputEl.select(); + startEditing = false; + } + } +}; diff --git a/es5/public/scripts/TodoItemInput.js b/es5/public/scripts/TodoItemInput.js new file mode 100644 index 0000000..4696d2f --- /dev/null +++ b/es5/public/scripts/TodoItemInput.js @@ -0,0 +1,63 @@ +/* global VT */ +window.VT = window.VT || {}; + +VT.TodoItemInput = function (el) { + var saveOnBlur = true; + + el.innerHTML = [ + '', + '', + ].join('\n'); + + var inputEl = el.querySelector('.input'); + var saveEl = el.querySelector('.save'); + + el.querySelectorAll('.app-icon').forEach(VT.AppIcon); + + inputEl.addEventListener('keyup', function (e) { + switch (e.keyCode) { + case 13: // enter + save(); + break; + case 27: // escape + clear(); + break; + } + }); + + inputEl.addEventListener('blur', function () { + if (saveOnBlur) save(); + saveOnBlur = true; + }); + + inputEl.addEventListener('focusOther', save); + + saveEl.addEventListener('mousedown', function () { + saveOnBlur = false; + }); + + saveEl.addEventListener('click', function () { + save(); + inputEl.focus(); + }); + + function save() { + var label = inputEl.value.trim(); + + if (label === '') return; + + inputEl.value = ''; + + el.dispatchEvent( + new CustomEvent('addItem', { + detail: { label: label }, + bubbles: true, + }) + ); + } + + function clear() { + inputEl.value = ''; + inputEl.blur(); + } +}; diff --git a/es5/public/scripts/TodoList.js b/es5/public/scripts/TodoList.js new file mode 100644 index 0000000..9421a68 --- /dev/null +++ b/es5/public/scripts/TodoList.js @@ -0,0 +1,71 @@ +/* global VT */ +window.VT = window.VT || {}; + +VT.TodoList = function (el) { + var state = { + items: [], + }; + + el.innerHTML = [ + '', + '', + ].join('\n'); + + VT.AppSortable(el.querySelector('.items'), {}); + VT.TodoItemInput(el.querySelector('.todo-item-input')); + + el.addEventListener('sortableDrop', function (e) { + el.dispatchEvent( + new CustomEvent('moveItem', { + detail: { + item: e.detail.data.item, + index: e.detail.index, + }, + bubbles: true, + }) + ); + }); + + function update(next) { + Object.assign(state, next); + + var container = el.querySelector('.items'); + var obsolete = new Set(container.children); + var childrenByKey = new Map(); + + obsolete.forEach(function (child) { + childrenByKey.set(child.dataset.key, child); + }); + + var children = state.items.map(function (item) { + var child = childrenByKey.get(item.id); + + if (child) { + obsolete.delete(child); + } else { + child = document.createElement('div'); + child.classList.add('todo-item'); + child.dataset.key = item.id; + VT.TodoItem(child); + } + + child.todoItem.update({ item: item }); + + return child; + }); + + obsolete.forEach(function (child) { + container.removeChild(child); + }); + + children.forEach(function (child, index) { + if (child !== container.children[index]) { + container.insertBefore(child, container.children[index]); + } + }); + } + + el.todoList = { + update: update, + }; +}; diff --git a/es5/public/scripts/TodoStore.js b/es5/public/scripts/TodoStore.js new file mode 100644 index 0000000..a51fece --- /dev/null +++ b/es5/public/scripts/TodoStore.js @@ -0,0 +1,198 @@ +/* global VT */ +window.VT = window.VT || {}; + +VT.TodoStore = function (el) { + var state = { + items: [], + customLists: [], + at: VT.formatDateId(new Date()), + customAt: 0, + }; + var storeTimeout; + + el.addEventListener('addItem', function (e) { + var index = 0; + + state.items.forEach(function (item) { + if (item.listId === e.detail.listId) { + index = Math.max(index, item.index + 1); + } + }); + + state.items.push({ + id: VT.uuid(), + listId: e.detail.listId, + index: index, + label: e.detail.label, + done: false, + }); + + dispatch({ items: state.items }); + }); + + el.addEventListener('checkItem', function (e) { + if (e.detail.item.done === e.detail.done) return; + + e.detail.item.done = e.detail.done; + dispatch({ items: state.items }); + }); + + el.addEventListener('saveItem', function (e) { + if (e.detail.item.label === e.detail.label) return; + + e.detail.item.label = e.detail.label; + dispatch({ items: state.items }); + }); + + el.addEventListener('moveItem', function (e) { + var movedItem = state.items.find(function (item) { + return item.id === e.detail.item.id; + }); + + var listItems = state.items.filter(function (item) { + return item.listId === e.detail.listId && item !== movedItem; + }); + + listItems.sort(function (a, b) { + return a.index - b.index; + }); + + movedItem.listId = e.detail.listId; + listItems.splice(e.detail.index, 0, movedItem); + + listItems.forEach(function (item, index) { + item.index = index; + }); + + dispatch({ items: state.items }); + }); + + el.addEventListener('deleteItem', function (e) { + dispatch({ + items: state.items.filter(function (item) { + return item.id !== e.detail.id; + }), + }); + }); + + el.addEventListener('addList', function (e) { + var index = 0; + + state.customLists.forEach(function (customList) { + index = Math.max(index, customList.index + 1); + }); + + state.customLists.push({ + id: VT.uuid(), + index: index, + title: e.detail.title || '', + }); + + dispatch({ customLists: state.customLists }); + }); + + el.addEventListener('saveList', function (e) { + var list = state.customLists.find(function (l) { + return l.id === e.detail.list.id; + }); + + if (list.title === e.detail.title) return; + + list.title = e.detail.title; + + dispatch({ customLists: state.customLists }); + }); + + el.addEventListener('moveList', function (e) { + var movedListIndex = state.customLists.findIndex(function (list) { + return list.id === e.detail.list.id; + }); + var movedList = state.customLists[movedListIndex]; + + state.customLists.splice(movedListIndex, 1); + state.customLists.sort(function (a, b) { + return a.index - b.index; + }); + state.customLists.splice(e.detail.index, 0, movedList); + + state.customLists.forEach(function (item, index) { + item.index = index; + }); + + dispatch({ customLists: state.customLists }); + }); + + el.addEventListener('deleteList', function (e) { + dispatch({ + customLists: state.customLists.filter(function (customList) { + return customList.id !== e.detail.id; + }), + }); + }); + + el.addEventListener('seek', function (e) { + var t = new Date(state.at + ' 00:00:00'); + t.setDate(t.getDate() + e.detail); + + dispatch({ + at: VT.formatDateId(t), + }); + }); + + el.addEventListener('seekHome', function () { + dispatch({ + at: VT.formatDateId(new Date()), + }); + }); + + el.addEventListener('customSeek', function (e) { + dispatch({ + customAt: Math.max( + 0, + Math.min(state.customLists.length - 1, state.customAt + e.detail) + ), + }); + }); + + function dispatch(next) { + Object.assign(state, next); + store(); + + el.dispatchEvent( + new CustomEvent('todoData', { + detail: state, + bubbles: false, + }) + ); + } + + function load() { + if (!localStorage || !localStorage.todo) { + dispatch(state); + return; + } + + try { + dispatch(JSON.parse(localStorage.todo)); + } catch (err) { + console.warn(err); + } + } + + function store() { + clearTimeout(storeTimeout); + + storeTimeout = setTimeout(function () { + try { + localStorage.todo = JSON.stringify(state); + } catch (err) { + console.warn(err); + } + }, 100); + } + + el.todoStore = { + dispatch: dispatch, + load: load, + }; +}; diff --git a/es5/public/scripts/util.js b/es5/public/scripts/util.js new file mode 100644 index 0000000..2946500 --- /dev/null +++ b/es5/public/scripts/util.js @@ -0,0 +1,82 @@ +/* global VT */ +window.VT = window.VT || {}; + +VT.uuid = function () { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + var r = (Math.random() * 16) | 0, + v = c == 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +}; + +VT.formatDateId = function (date) { + var y = date.getFullYear(); + var m = date.getMonth() + 1; + var d = date.getDate(); + + return ( + y.toString().padStart(4, '0') + + '-' + + m.toString().padStart(2, '0') + + '-' + + d.toString().padStart(2, '0') + ); +}; + +VT.formatDate = function (date) { + return ( + VT.formatMonth(date) + + ' ' + + VT.formatDayOfMonth(date) + + ' ' + + date.getFullYear().toString().padStart(4, '0') + ); +}; + +VT.formatDayOfMonth = function (date) { + var d = date.getDate(); + var t = d % 10; + + return d === 11 || d === 12 || d === 13 + ? d + 'th' + : t === 1 + ? d + 'st' + : t === 2 + ? d + 'nd' + : t === 3 + ? d + 'rd' + : d + 'th'; +}; + +VT.DAY_NAMES = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +]; + +VT.formatDayOfWeek = function (date) { + return VT.DAY_NAMES[date.getDay()]; +}; + +VT.MONTH_NAMES = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +VT.formatMonth = function (date) { + return VT.MONTH_NAMES[date.getMonth()]; +}; diff --git a/es5/public/styles/app-button.css b/es5/public/styles/app-button.css new file mode 100644 index 0000000..74fa0a1 --- /dev/null +++ b/es5/public/styles/app-button.css @@ -0,0 +1,45 @@ +.app-button { + display: inline-block; + font-size: 1em; + line-height: 1em; + background: transparent; + margin: 0; + padding: 0.25em; + border: 0; + outline: 0; + cursor: pointer; + vertical-align: middle; + border-radius: 4px; + transition: all 0.1s ease-out; + color: #999; +} + +.app-button:hover { + color: #000; +} + +.app-button:active { + transform: translate(0, 1px); + color: #000; + background: #f3f3f3; +} + +.app-button:focus { + color: #000; +} + +.app-button.-circle { + width: 1.5em; + height: 1.5em; + border-radius: 50%; +} + +.app-button.-xl { + font-size: 1.5em; +} + +@media (min-width: 600px) { + .app-button.-xl { + font-size: 2em; + } +} diff --git a/es5/public/styles/app-collapsible.css b/es5/public/styles/app-collapsible.css new file mode 100644 index 0000000..e010ec3 --- /dev/null +++ b/es5/public/styles/app-collapsible.css @@ -0,0 +1,16 @@ +.app-collapsible > .bar { + height: 40px; + line-height: 37px; + margin: 0; + padding: 0 0.75em; + background: #eee; +} + +.app-collapsible > .bar > .app-button:active { + background: #fff; +} + +.app-collapsible > .body { + transition: height 0.2s ease-out; + overflow: hidden; +} diff --git a/es5/public/styles/app-footer.css b/es5/public/styles/app-footer.css new file mode 100644 index 0000000..7dea98f --- /dev/null +++ b/es5/public/styles/app-footer.css @@ -0,0 +1,14 @@ +.app-footer { + border-top: solid 1px #ccc; + padding: 2em; + font-size: 0.8em; + color: #999; +} + +.app-footer a { + color: #999; +} + +.app-footer > p { + margin: 0; +} diff --git a/es5/public/styles/app-header.css b/es5/public/styles/app-header.css new file mode 100644 index 0000000..058e8b0 --- /dev/null +++ b/es5/public/styles/app-header.css @@ -0,0 +1,20 @@ +.app-header { + background: #001f3f; + padding: 10px 20px; +} + +.app-header > .title { + color: #fff; + font-size: 1em; + margin: 0; +} + +.app-header > .app-fps { + position: absolute; + top: 10px; + right: 20px; + margin: 0; + font-size: 0.8em; + line-height: 1.5em; + color: #fff; +} diff --git a/es5/public/styles/app-icon.css b/es5/public/styles/app-icon.css new file mode 100644 index 0000000..4fa2240 --- /dev/null +++ b/es5/public/styles/app-icon.css @@ -0,0 +1,21 @@ +.app-icon { + display: inline-block; + vertical-align: text-top; +} + +.app-icon > svg { + display: inline-block; + width: 1em; + height: 1em; + vertical-align: bottom; + fill: currentColor; + transition: transform 0.1s ease-out; +} + +.app-icon.-r180 > svg { + transform: rotate(180deg); +} + +.app-icon.-double > svg:nth-child(2) { + margin-left: -0.5em; +} diff --git a/es5/public/styles/base.css b/es5/public/styles/base.css new file mode 100644 index 0000000..ad15356 --- /dev/null +++ b/es5/public/styles/base.css @@ -0,0 +1,22 @@ +html { + box-sizing: border-box; +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + font-size: 16px; + font-family: -apple-system, BlinkMacSystemFont, Helvetica, 'Helvetica Neue', + Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', sans-serif; +} diff --git a/es5/public/styles/todo-custom-list.css b/es5/public/styles/todo-custom-list.css new file mode 100644 index 0000000..b661885 --- /dev/null +++ b/es5/public/styles/todo-custom-list.css @@ -0,0 +1,64 @@ +.todo-custom-list { + padding: 0 0.5em; + transition: transform 0.2s ease-out, opacity 0.2s ease-out, + box-shadow 0.2s ease-out; +} + +.todo-custom-list > .header { + text-align: center; + padding: 2em 0; +} + +.todo-custom-list > .header > .title { + margin: 0 0 10px 0; + font-size: 1.5em; + line-height: normal; + cursor: pointer; +} + +.todo-custom-list > .header > .form { + display: none; + margin: 0 0 10px 0; + font-size: 1em; + line-height: 1em; +} + +.todo-custom-list > .header > .form > .input { + width: 70%; + font-size: 1.5em; + line-height: normal; + font-family: inherit; + font-weight: bold; + text-align: center; + padding: 0; + border: 0; + outline: 0; + border-radius: 0; + vertical-align: middle; +} + +.todo-custom-list > .header > .form > .delete { + position: absolute; + right: 0.5em; + top: 2em; +} + +.todo-custom-list.-editing > .header > .title { + display: none; +} + +.todo-custom-list.-editing > .header > .form { + display: block; +} + +.todo-custom-list.-dragging { + box-shadow: 10px 0 12px -14px rgba(0, 0, 0, 0.3), + -10px 0 12px -14px rgba(0, 0, 0, 0.3); + background: #fff; + opacity: 0.8; +} + +.todo-custom-list.-placeholder { + transition: none !important; + visibility: hidden; +} diff --git a/es5/public/styles/todo-day.css b/es5/public/styles/todo-day.css new file mode 100644 index 0000000..b46510b --- /dev/null +++ b/es5/public/styles/todo-day.css @@ -0,0 +1,34 @@ +.todo-day { + padding: 0 0.5em; +} + +.todo-day > .header { + text-align: center; + padding: 2em 0; +} + +.todo-day > .header > .dayofweek { + text-transform: uppercase; + margin: 0 0 0.25em 0; + font-size: 1.5em; +} + +.todo-day > .header > .date { + text-transform: uppercase; + font-weight: normal; + margin: 0.25em 0 0 0; + font-size: 0.8em; + color: #aaa; +} + +.todo-day.-past { + color: #ccc; +} + +.todo-day.-today > .header > .dayofweek { + color: #85144b; +} + +.todo-day.-today > .header > .date { + color: #000; +} diff --git a/es5/public/styles/todo-frame.css b/es5/public/styles/todo-frame.css new file mode 100644 index 0000000..29d2111 --- /dev/null +++ b/es5/public/styles/todo-frame.css @@ -0,0 +1,87 @@ +.todo-frame { + position: relative; + overflow: hidden; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.todo-frame > .leftcontrols, +.todo-frame > .rightcontrols { + position: absolute; + top: 0; + width: 52px; + padding: 1.5em 0; + text-align: center; +} + +.todo-frame > .leftcontrols { + left: 0; +} + +.todo-frame > .rightcontrols { + right: 0; +} + +.todo-frame > .leftcontrols > p, +.todo-frame > .rightcontrols > p { + margin: 0 0 0.5em 0; +} + +.todo-frame > .container { + position: absolute; + overflow: hidden; + top: 0; + right: 52px; + bottom: 0; + left: 52px; +} + +.todo-frame > .container > .card { + position: absolute; + top: 0; + left: 0; + width: 100%; + transition: transform 0.2s ease-out, opacity 0.2s ease-out; +} + +.todo-frame.-animated { + transition: height 0.2s ease-out; +} + +@media (min-width: 600px) { + .todo-frame > .container { + right: 70px; + left: 70px; + } + + .todo-frame > .leftcontrols, + .todo-frame > .rightcontrols { + width: 70px; + } + + .todo-frame > .container > .card { + width: 50%; + } +} + +@media (min-width: 768px) { + .todo-frame > .container > .card { + width: 33.333%; + } +} + +@media (min-width: 1024px) { + .todo-frame > .container > .card { + width: 25%; + } +} + +@media (min-width: 1280px) { + .todo-frame > .container > .card { + width: 20%; + } +} diff --git a/es5/public/styles/todo-item-input.css b/es5/public/styles/todo-item-input.css new file mode 100644 index 0000000..d0da866 --- /dev/null +++ b/es5/public/styles/todo-item-input.css @@ -0,0 +1,26 @@ +.todo-item-input { + position: relative; + margin: 0 0 0 30px; + padding: 0 30px 0 0; + border-bottom: 1px solid #ddd; + font-size: 0.8em; + line-height: 1.5em; + transition: transform 0.2s ease-out; +} + +.todo-item-input > .input { + border: 0; + border-radius: 0; + outline: 0; + padding: 0.25em 0; + width: 100%; + font-family: inherit; + font-size: inherit; + line-height: 1.5em; +} + +.todo-item-input > .save { + position: absolute; + top: 0.15em; + right: 0; +} diff --git a/es5/public/styles/todo-item.css b/es5/public/styles/todo-item.css new file mode 100644 index 0000000..dbcb52c --- /dev/null +++ b/es5/public/styles/todo-item.css @@ -0,0 +1,85 @@ +.todo-item { + position: relative; + font-size: 0.8em; + line-height: 1.5em; + margin: 0; + padding: 0.25em 0; + background: #fff; + transition: transform 0.2s ease-out, opacity 0.2s ease-out; + cursor: pointer; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.todo-item > .checkbox { + position: absolute; + top: 0; + left: 0; + width: 30px; + height: 2em; + line-height: 2em; +} + +.todo-item > .checkbox > input { + vertical-align: middle; + cursor: pointer; +} + +.todo-item > .label { + margin: 0 0 0 30px; + padding-bottom: 0.25em; + border-bottom: 1px solid #ddd; + overflow: hidden; + text-overflow: ellipsis; +} + +.todo-item > .form { + display: none; + margin: 0 0 0 30px; + padding-right: 24px; + border-bottom: 1px solid #999; +} + +.todo-item > .form > .input { + border: 0; + border-radius: 0; + outline: 0; + padding: 0 0 0.25em 0; + width: 100%; + font-family: inherit; + font-size: inherit; + line-height: 1.5em; + background: transparent; +} + +.todo-item > .form > .save { + position: absolute; + top: 0.15em; + right: 0; +} + +.todo-item.-done > .label { + color: #ccc; + text-decoration: line-through; +} + +.todo-item.-editing > .label { + display: none; +} + +.todo-item.-editing > .form { + display: block; +} + +.todo-item.-dragging { + opacity: 0.7; +} + +.todo-item.-placeholder { + transition: none !important; + visibility: hidden; +}