1
0
mirror of https://github.com/morris/vanilla-todo.git synced 2025-08-22 21:52:54 +02:00

backup es5 version

This commit is contained in:
Morris Brodersen
2022-05-09 15:46:28 +02:00
parent 17786e3a27
commit b4c57030f8
30 changed files with 3331 additions and 0 deletions

24
es5/.eslintrc.js Normal file
View File

@@ -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',
],
},
};

831
es5/README.md Normal file
View File

@@ -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 = [
'<h1 class="title"></h1>',
'<p class="description"></p>',
'<div class="my-counter"></div>',
].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 = [
'<p>',
' <span class="value"></span>',
' <button class="increment">Increment</button>',
' <button class="decrement">Decrement</button>',
'</p>',
].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 <div class="hello-world"></div> 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 = '<div class="items"></div>';
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**&mdash;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&mdash;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) &mdash;
[@opethrocks](https://github.com/opethrocks)
- Fixed [#3](https://github.com/morris/vanilla-todo/issues/3) (navigation bug) &mdash;
[@anchepiece](https://github.com/anchepiece),
[@jcoussard](https://github.com/jcoussard)
- Fixed [#4](https://github.com/morris/vanilla-todo/issues/4) (double item creation) &mdash;
[@n0nick](https://github.com/n0nick)
- Fixed [#1](https://github.com/morris/vanilla-todo/issues/4) (bad links) &mdash;
[@roryokane](https://github.com/roryokane)
- Initial version.

53
es5/public/index.html Normal file
View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<title>VANILLA TODO</title>
<link rel="stylesheet" href="styles/base.css" />
<link rel="stylesheet" href="styles/app-button.css" />
<link rel="stylesheet" href="styles/app-collapsible.css" />
<link rel="stylesheet" href="styles/app-footer.css" />
<link rel="stylesheet" href="styles/app-header.css" />
<link rel="stylesheet" href="styles/app-icon.css" />
<link rel="stylesheet" href="styles/todo-custom-list.css" />
<link rel="stylesheet" href="styles/todo-day.css" />
<link rel="stylesheet" href="styles/todo-frame.css" />
<link rel="stylesheet" href="styles/todo-item-input.css" />
<link rel="stylesheet" href="styles/todo-item.css" />
</head>
<body>
<div class="todo-app"></div>
<script
nomodule
crossorigin
src="https://polyfill.io/v3/polyfill.min.js?features=Set%2CMap%2CObject.assign%2Cfetch%2CrequestAnimationFrame%2CNodeList.prototype.forEach%2CElement.prototype.classList%2Cperformance.now%2CNode.prototype.contains%2CElement.prototype.dataset"
></script>
<script src="scripts/AppCollapsible.js"></script>
<script src="scripts/AppDraggable.js"></script>
<script src="scripts/AppFlip.js"></script>
<script src="scripts/AppFps.js"></script>
<script src="scripts/AppIcon.js"></script>
<script src="scripts/AppSortable.js"></script>
<script src="scripts/TodoApp.js"></script>
<script src="scripts/TodoCustomList.js"></script>
<script src="scripts/TodoDay.js"></script>
<script src="scripts/TodoFrameCustom.js"></script>
<script src="scripts/TodoFrameDays.js"></script>
<script src="scripts/TodoItemInput.js"></script>
<script src="scripts/TodoItem.js"></script>
<script src="scripts/TodoList.js"></script>
<script src="scripts/TodoStore.js"></script>
<script src="scripts/util.js"></script>
<script>
VT.TodoApp(document.querySelector('.todo-app'));
</script>
</body>
</html>

View File

@@ -0,0 +1,29 @@
/* global VT */
window.VT = window.VT || {};
VT.AppCollapsible = function (el) {
var state = {
show: true,
};
el.querySelector('.bar > .toggle').addEventListener('click', function () {
update({ show: !state.show });
});
el.appCollapsible = {
update: update,
};
function update(next) {
Object.assign(state, next);
el.querySelector('.bar > .toggle > .app-icon').classList.toggle(
'-r180',
state.show
);
el.querySelectorAll('.body').forEach(function (el) {
el.style.height = state.show ? el.children[0].offsetHeight + 'px' : '0';
});
}
};

View File

@@ -0,0 +1,349 @@
/* global VT */
window.VT = window.VT || {};
VT.AppDraggable = function (el, options) {
var dragThreshold = options.dragThreshold || 5;
var dropRange = options.dropRange || 50;
var dropRangeSquared = dropRange * dropRange;
var originX, originY;
var clientX, clientY;
var startTime;
var dragging = false;
var clicked = false;
var data;
var image;
var imageSource;
var imageX, imageY;
var currentTarget;
el.addEventListener('touchstart', start);
el.addEventListener('mousedown', start);
// maybe prevent click
el.addEventListener(
'click',
function (e) {
if (dragging || clicked) {
e.preventDefault();
e.stopImmediatePropagation();
}
},
true
);
function start(e) {
if (el.classList.contains('_nodrag')) return;
if (e.type === 'mousedown' && e.button !== 0) return;
if (e.touches && e.touches.length > 1) return;
e.preventDefault();
var p = getPositionHost(e);
clientX = originX = p.clientX || p.pageX;
clientY = originY = p.clientY || p.pageY;
startTime = Date.now();
startListening();
}
function move(e) {
e.preventDefault();
var p = getPositionHost(e);
clientX = p.clientX || p.pageX;
clientY = p.clientY || p.pageY;
if (dragging) {
dispatchDrag();
dispatchTarget();
return;
}
var deltaX = clientX - originX;
var deltaY = clientY - originY;
if (Math.abs(deltaX) < dragThreshold && Math.abs(deltaY) < dragThreshold) {
return;
}
// prevent unintentional dragging on touch devices
if (e.touches && Date.now() - startTime < 50) {
stopListening();
return;
}
dragging = true;
data = {};
dispatchStart();
dispatchDrag();
dispatchTarget();
dispatchOverContinuously();
}
function end(e) {
e.preventDefault();
if (!dragging) {
e.target.click();
clicked = true;
}
stopListening();
requestAnimationFrame(function () {
clicked = false;
if (dragging) {
dispatchTarget();
dispatchEnd();
}
});
}
function startListening() {
el.addEventListener('touchmove', move);
el.addEventListener('touchend', end);
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', end);
}
function stopListening() {
el.removeEventListener('touchmove', move);
el.removeEventListener('touchend', end);
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', end);
}
//
function dispatchStart() {
setImage(el);
el.dispatchEvent(
new CustomEvent('draggableStart', {
detail: buildDetail(),
bubbles: true,
})
);
}
function dispatchDrag() {
image.dispatchEvent(
new CustomEvent('draggableDrag', {
detail: buildDetail(),
bubbles: true,
})
);
}
function dispatchTarget() {
if (!dragging) return;
var nextTarget = getTarget();
if (nextTarget === currentTarget) return;
if (currentTarget) {
currentTarget.addEventListener('draggableLeave', removeDropClassOnce);
currentTarget.dispatchEvent(
new CustomEvent('draggableLeave', {
detail: buildDetail(),
bubbles: true,
})
);
}
if (nextTarget) {
nextTarget.addEventListener('draggableEnter', addDropClassOnce);
nextTarget.dispatchEvent(
new CustomEvent('draggableEnter', {
detail: buildDetail(),
bubbles: true,
})
);
}
currentTarget = nextTarget;
}
function dispatchOverContinuously() {
if (!dragging) return;
dispatchOver();
setTimeout(dispatchOver, 50);
}
function dispatchOver() {
if (currentTarget) {
currentTarget.dispatchEvent(
new CustomEvent('draggableOver', {
detail: buildDetail(),
bubbles: true,
})
);
}
setTimeout(dispatchOver, 50);
}
function dispatchEnd() {
if (currentTarget) {
currentTarget.addEventListener('draggableDrop', cleanUpOnce);
currentTarget.dispatchEvent(
new CustomEvent('draggableDrop', {
detail: buildDetail(),
bubbles: true,
})
);
} else {
image.dispatchEvent(
new CustomEvent('draggableCancel', {
detail: buildDetail(),
bubbles: true,
})
);
}
}
//
function buildDetail() {
var detail = {
el: el,
data: data,
image: image,
imageSource: imageSource,
originX: originX,
originY: originY,
clientX: clientX,
clientY: clientY,
imageX: imageX,
imageY: imageY,
setImage: function (source) {
setImage(source);
detail.image = image;
},
};
return detail;
}
function setImage(source) {
if (imageSource === source) return;
imageSource = source;
removeImage();
image = imageSource.cloneNode(true);
image.style.position = 'fixed';
image.style.left = '0';
image.style.top = '0';
image.style.width = imageSource.offsetWidth + 'px';
image.style.height = imageSource.offsetHeight + 'px';
image.style.margin = '0';
image.style.zIndex = 9999;
image.classList.add('-dragging');
var rect = source.getBoundingClientRect();
imageX = originX - rect.left;
imageY = originY - rect.top;
image.addEventListener('draggableDrag', function (e) {
var x = e.detail.clientX - e.detail.imageX;
var y = e.detail.clientY - e.detail.imageY;
image.style.transition = 'none';
image.style.transform = 'translate(' + x + 'px, ' + y + 'px)';
});
image.addEventListener('draggableCancel', cleanUp);
document.body.appendChild(image);
}
function addDropClassOnce(e) {
e.target.removeEventListener(e.type, addDropClassOnce);
e.target.classList.add('-drop');
}
function removeDropClassOnce(e) {
e.target.removeEventListener(e.type, addDropClassOnce);
e.target.classList.remove('-drop');
}
function cleanUpOnce(e) {
e.target.removeEventListener(e.type, cleanUpOnce);
cleanUp();
}
function cleanUp() {
if (currentTarget) {
currentTarget.classList.remove('-drop');
}
removeImage();
data = null;
image = null;
imageSource = null;
currentTarget = null;
}
function removeImage() {
if (image && image.parentNode) {
image.parentNode.removeChild(image);
}
}
function getTarget() {
var candidates = [];
document.querySelectorAll(options.dropSelector).forEach(function (el) {
var rect = el.getBoundingClientRect();
var distanceSquared = pointDistanceToRectSquared(clientX, clientY, rect);
if (distanceSquared > dropRangeSquared) return;
candidates.push({
el: el,
distance2: distanceSquared,
});
});
candidates.sort(function (a, b) {
if (a.distance2 === 0 && b.distance2 === 0) {
// in this case, the client position is inside both rectangles
// if A contains B, B is the correct target and vice versa
// TODO sort by z-index somehow?
return a.el.contains(b.el) ? -1 : b.el.contains(a.el) ? 1 : 0;
}
// sort by distance, ascending
return a.distance2 - b.distance2;
});
return candidates.length > 0 ? candidates[0].el : null;
}
function pointDistanceToRectSquared(x, y, rect) {
var dx =
x < rect.left ? x - rect.left : x > rect.right ? x - rect.right : 0;
var dy =
y < rect.top ? y - rect.top : y > rect.bottom ? y - rect.bottom : 0;
return dx * dx + dy * dy;
}
function getPositionHost(e) {
if (e.targetTouches && e.targetTouches.length > 0) {
return e.targetTouches[0];
}
if (e.changedTouches && e.changedTouches.length > 0) {
return e.changedTouches[0];
}
return e;
}
};

View File

@@ -0,0 +1,197 @@
/* global VT */
window.VT = window.VT || {};
VT.AppFlip = function (el, options) {
var enabled = options.initialDelay === 0;
var first;
var level = 0;
// enable animations only after an initial delay
setTimeout(function () {
enabled = true;
}, options.initialDelay || 100);
// take a snapshot before any HTML changes
// do this only for the first beforeFlip event in the current cycle
el.addEventListener('beforeFlip', function () {
if (!enabled) return;
if (++level > 1) return;
first = snapshot();
});
// take a snapshot after HTML changes, calculate and play animations
// do this only for the last flip event in the current cycle
el.addEventListener('flip', function () {
if (!enabled) return;
if (--level > 0) return;
var last = snapshot();
var toRemove = invertForRemoval(first, last);
var toAnimate = invertForAnimation(first, last);
requestAnimationFrame(function () {
requestAnimationFrame(function () {
remove(toRemove);
animate(toAnimate);
first = null;
});
});
});
// build a snapshot of the current HTML's client rectangles
// includes original transforms and hierarchy
function snapshot() {
var map = new Map();
el.querySelectorAll(options.selector).forEach(function (el) {
var key = el.dataset.key || el;
// parse original transform
// i.e. strip inverse transform using "scale(1)" marker
var transform = el.style.transform
? el.style.transform.replace(/^.*scale\(1\)/, '')
: '';
map.set(key, {
key: key,
el: el,
rect: el.getBoundingClientRect(),
ancestor: null,
transform: transform,
});
});
resolveAncestors(map);
return map;
}
function resolveAncestors(map) {
map.forEach(function (entry) {
var current = entry.el.parentNode;
while (current && current !== el) {
var ancestor = map.get(current.dataset.key || current);
if (ancestor) {
entry.ancestor = ancestor;
return;
}
current = current.parentNode;
}
});
}
// reinsert removed elements at their original position
function invertForRemoval(first, last) {
var toRemove = [];
first.forEach(function (entry) {
if (entry.el.classList.contains('_noflip')) return;
if (!needsRemoval(entry)) return;
entry.el.style.position = 'fixed';
entry.el.style.left = entry.rect.left + 'px';
entry.el.style.top = entry.rect.top + 'px';
entry.el.style.width = entry.rect.right - entry.rect.left + 'px';
entry.el.style.transition = 'none';
entry.el.style.transform = '';
el.appendChild(entry.el);
toRemove.push(entry);
});
return toRemove;
function needsRemoval(entry) {
if (entry.ancestor && needsRemoval(entry.ancestor)) {
return false;
}
return !last.has(entry.key);
}
}
// set position of moved elements to their original position
// or set opacity to zero for new elements to appear nicely
function invertForAnimation(first, last) {
var toAnimate = [];
last.forEach(function (entry) {
if (entry.el.classList.contains('_noflip')) return;
calculate(entry);
if (entry.appear) {
entry.el.style.transition = 'none';
entry.el.style.opacity = '0';
toAnimate.push(entry);
} else if (entry.deltaX !== 0 || entry.deltaY !== 0) {
// set inverted transform with "scale(1)" marker, see above
entry.el.style.transition = 'none';
entry.el.style.transform =
'translate(' +
entry.deltaX +
'px, ' +
entry.deltaY +
'px) scale(1) ' +
entry.transform;
toAnimate.push(entry);
}
});
return toAnimate;
// calculate inverse transform relative to any animated ancestors
function calculate(entry) {
if (entry.calculated) return;
entry.calculated = true;
var b = first.get(entry.key);
if (b) {
entry.deltaX = b.rect.left - entry.rect.left;
entry.deltaY = b.rect.top - entry.rect.top;
if (entry.ancestor) {
calculate(entry.ancestor);
entry.deltaX -= entry.ancestor.deltaX;
entry.deltaY -= entry.ancestor.deltaY;
}
} else {
entry.appear = true;
entry.deltaX = 0;
entry.deltaY = 0;
}
}
}
// play remove animations and remove elements after timeout
function remove(entries) {
entries.forEach(function (entry) {
entry.el.style.transition = '';
entry.el.style.opacity = '0';
});
setTimeout(function () {
entries.forEach(function (entry) {
if (entry.el.parentNode) {
entry.el.parentNode.removeChild(entry.el);
}
});
}, options.removeTimeout);
}
// play move/appear animations
function animate(entries) {
entries.forEach(function (entry) {
entry.el.style.transition = '';
entry.el.style.transform = entry.transform;
entry.el.style.opacity = '';
});
}
};

View File

@@ -0,0 +1,40 @@
/* global VT */
window.VT = window.VT || {};
VT.AppFps = function (el) {
var sampleSize = 20;
var times = [];
tick();
function tick() {
requestAnimationFrame(tick);
times.push(performance.now());
if (times.length <= sampleSize) return;
var min = Infinity;
var max = 0;
var sum = 0;
for (var i = 1; i < sampleSize + 1; ++i) {
var delta = times[i] - times[i - 1];
min = Math.min(min, delta);
max = Math.max(max, delta);
sum += delta;
}
var fps = (sampleSize / sum) * 1000;
el.innerText =
fps.toFixed(0) +
' fps (' +
min.toFixed(0) +
' ms - ' +
max.toFixed(0) +
' ms)';
times = [];
}
};

View File

@@ -0,0 +1,24 @@
/* global VT */
window.VT = window.VT || {};
VT.AppIcon = function (el) {
if (el.children.length > 0) return;
var id = el.dataset.id;
var promise = VT.AppIcon.cache[id];
if (!promise) {
var url = VT.AppIcon.baseUrl + id + '.svg';
promise = VT.AppIcon.cache[id] = fetch(url).then(function (r) {
return r.text();
});
}
promise.then(function (svg) {
el.innerHTML = el.classList.contains('-double') ? svg + svg : svg;
});
};
VT.AppIcon.baseUrl =
'https://rawcdn.githack.com/primer/octicons/ff7f6eee63fa2f2d24d02e3aa76a87db48e4b6f6/icons/';
VT.AppIcon.cache = {};

View File

@@ -0,0 +1,146 @@
/* global VT */
window.VT = window.VT || {};
VT.AppSortable = function (el, options) {
var placeholder;
var placeholderSource;
var horizontal = options.direction === 'horizontal';
var currentIndex = -1;
el.addEventListener('draggableStart', function (e) {
e.detail.image.addEventListener('draggableCancel', cleanUp);
});
el.addEventListener('draggableOver', function (e) {
maybeDispatchUpdate(calculateIndex(e.detail.image), e);
});
el.addEventListener('draggableLeave', function (e) {
maybeDispatchUpdate(-1, e);
});
el.addEventListener('draggableDrop', function (e) {
el.dispatchEvent(
new CustomEvent('sortableDrop', {
detail: buildDetail(e),
bubbles: true,
})
);
});
el.addEventListener('sortableUpdate', function (e) {
if (!placeholder) {
e.detail.setPlaceholder(e.detail.originalEvent.detail.imageSource);
}
if (e.detail.index >= 0) {
insertPlaceholder(e.detail.index);
} else {
removePlaceholder();
}
removeByKey(e.detail.data.key);
});
el.addEventListener('sortableDrop', cleanUp);
function maybeDispatchUpdate(index, originalEvent) {
if (index !== currentIndex) {
currentIndex = index;
el.dispatchEvent(
new CustomEvent('sortableUpdate', {
detail: buildDetail(originalEvent),
bubbles: true,
})
);
}
}
function cleanUp() {
removePlaceholder();
placeholder = null;
placeholderSource = null;
currentIndex = -1;
}
function buildDetail(e) {
var detail = {
data: e.detail.data,
index: currentIndex,
placeholder: placeholder,
setPlaceholder: function (source) {
setPlaceholder(source);
detail.placeholder = placeholder;
},
originalEvent: e,
};
return detail;
}
function setPlaceholder(source) {
if (placeholderSource === source) return;
placeholderSource = source;
removePlaceholder();
placeholder = placeholderSource.cloneNode(true);
placeholder.classList.add('-placeholder');
placeholder.removeAttribute('data-key');
}
function insertPlaceholder(index) {
if (placeholder && el.children[index] !== placeholder) {
if (placeholder.parentNode === el) el.removeChild(placeholder);
el.insertBefore(placeholder, el.children[index]);
}
}
function removePlaceholder() {
if (placeholder && placeholder.parentNode) {
placeholder.parentNode.removeChild(placeholder);
}
}
function removeByKey(key) {
for (var i = 0, l = el.children.length; i < l; ++i) {
var child = el.children[i];
if (child && child.dataset.key === key) {
el.removeChild(child);
}
}
}
function calculateIndex(image) {
if (el.children.length === 0) return 0;
var isBefore = horizontal ? isLeft : isAbove;
var rect = image.getBoundingClientRect();
var p = 0;
for (var i = 0, l = el.children.length; i < l; ++i) {
var child = el.children[i];
if (isBefore(rect, child.getBoundingClientRect())) return i - p;
if (child === placeholder) p = 1;
}
return el.children.length - p;
}
function isAbove(rectA, rectB) {
return (
rectA.top + (rectA.bottom - rectA.top) / 2 <=
rectB.top + (rectB.bottom - rectB.top) / 2
);
}
function isLeft(rectA, rectB) {
return (
rectA.left + (rectA.right - rectA.left) / 2 <=
rectB.left + (rectB.right - rectB.left) / 2
);
}
};

View File

@@ -0,0 +1,135 @@
/* global VT */
window.VT = window.VT || {};
VT.TodoApp = function (el) {
var state = {
items: [],
customLists: [],
at: VT.formatDateId(new Date()),
customAt: 0,
};
el.innerHTML = [
'<header class="app-header">',
' <h1 class="title">VANILLA TODO</h1>',
' <p class="app-fps"></p>',
'</header>',
'<div class="todo-frame -days"></div>',
'<div class="app-collapsible">',
' <p class="bar">',
' <button class="app-button -circle toggle"><i class="app-icon" data-id="chevron-up-24"></i></button>',
' </p>',
' <div class="body">',
' <div class="todo-frame -custom"></div>',
' </div>',
'</div>',
'<footer class="app-footer">',
' <p>',
' VANILLA TODO &copy 2020 <a href="https://morrisbrodersen.de">Morris Brodersen</a>',
' &mdash; A case study on viable techniques for vanilla web development.',
' <a href="https://github.com/morris/vanilla-todo">About →</a>',
' </p>',
'</footer>',
].join('\n');
VT.AppFlip(el, {
selector: '.todo-item, .todo-item-input, .todo-day, .todo-custom-list',
removeTimeout: 200,
});
VT.TodoStore(el);
el.querySelectorAll('.app-collapsible').forEach(VT.AppCollapsible);
el.querySelectorAll('.app-icon').forEach(VT.AppIcon);
el.querySelectorAll('.app-fps').forEach(VT.AppFps);
VT.TodoFrameDays(el.querySelector('.todo-frame.-days'));
VT.TodoFrameCustom(el.querySelector('.todo-frame.-custom'));
// each of these events make changes to the HTML to be animated using FLIP
// listening to them using "capture" dispatches "beforeFlip" before any changes
el.addEventListener('todoData', beforeFlip, true);
el.addEventListener('sortableUpdate', beforeFlip, true);
el.addEventListener('draggableCancel', beforeFlip, true);
el.addEventListener('draggableDrop', beforeFlip, true);
// some necessary work to orchestrate drag & drop with FLIP animations
el.addEventListener('draggableStart', function (e) {
e.detail.image.classList.add('_noflip');
el.appendChild(e.detail.image);
});
el.addEventListener('draggableCancel', function (e) {
e.detail.image.classList.remove('_noflip');
update();
});
el.addEventListener('draggableDrop', function (e) {
e.detail.image.classList.remove('_noflip');
});
el.addEventListener('sortableUpdate', function (e) {
e.detail.placeholder.classList.add('_noflip');
});
// dispatch "focusOther" .use-focus-other inputs if they are not active
// ensures only one edit input is active
el.addEventListener('focusin', function (e) {
if (!e.target.classList.contains('use-focus-other')) return;
document.querySelectorAll('.use-focus-other').forEach(function (el) {
if (el === e.target) return;
el.dispatchEvent(new CustomEvent('focusOther'));
});
});
// listen to the TodoStore's data
// this is the main update
// everything else is related to drag & drop or FLIP animations
el.addEventListener('todoData', function (e) {
update(e.detail);
});
// dispatch "flip" after HTML changes from these events
// this plays the FLIP animations
el.addEventListener('todoData', flip);
el.addEventListener('sortableUpdate', flip);
el.addEventListener('draggableCancel', flip);
el.addEventListener('draggableDrop', flip);
el.todoStore.load();
function update(next) {
Object.assign(state, next);
el.querySelector('.todo-frame.-days').todoFrameDays.update({
items: state.items,
at: state.at,
});
el.querySelector('.todo-frame.-custom').todoFrameCustom.update({
lists: state.customLists,
items: state.items,
at: state.customAt,
});
el.querySelectorAll('.app-collapsible').forEach(function (el) {
el.appCollapsible.update();
});
}
function beforeFlip() {
el.dispatchEvent(
new CustomEvent('beforeFlip', {
bubbles: true,
})
);
}
function flip() {
el.dispatchEvent(
new CustomEvent('flip', {
bubbles: true,
})
);
}
};

View File

@@ -0,0 +1,148 @@
/* global VT */
window.VT = window.VT || {};
VT.TodoCustomList = function (el) {
var state = {
list: null,
editing: false,
};
var startEditing = false;
var saveOnBlur = true;
el.innerHTML = [
'<div class="header">',
' <h3 class="title"></h3>',
' <p class="form">',
' <input type="text" class="input use-focus-other">',
' <button class="app-button delete"><i class="app-icon" data-id="trashcan-16"></i></button>',
' </p>',
'</div>',
'<div class="todo-list"></div>',
].join('\n');
var titleEl = el.querySelector('.title');
var inputEl = el.querySelector('.input');
var deleteEl = el.querySelector('.delete');
VT.AppDraggable(titleEl, {
dropSelector: '.todo-frame.-custom .container',
});
VT.TodoList(el.querySelector('.todo-list'));
el.querySelectorAll('.app-icon').forEach(VT.AppIcon);
titleEl.addEventListener('click', function () {
startEditing = true;
update({ editing: true });
});
deleteEl.addEventListener('touchstart', function () {
saveOnBlur = false;
});
deleteEl.addEventListener('mousedown', function () {
saveOnBlur = false;
});
inputEl.addEventListener('blur', function () {
if (saveOnBlur) save();
saveOnBlur = true;
});
inputEl.addEventListener('focusOther', function () {
if (state.editing) save();
});
inputEl.addEventListener('keyup', function (e) {
switch (e.keyCode) {
case 13: // enter
save();
break;
case 27: // escape
cancelEdit();
break;
}
});
deleteEl.addEventListener('click', function () {
if (state.list.items.length > 0) {
if (
!confirm(
'Deleting this list will delete its items as well. Are you sure?'
)
) {
return;
}
}
el.dispatchEvent(
new CustomEvent('deleteList', {
detail: state.list,
bubbles: true,
})
);
});
el.addEventListener('draggableStart', function (e) {
if (e.target !== titleEl) return;
e.detail.data.list = state.list;
e.detail.data.key = state.list.id;
// update image (default would only be title element)
e.detail.setImage(el);
// override for horizontal dragging only
e.detail.image.addEventListener('draggableDrag', function (e) {
var x = e.detail.clientX - e.detail.imageX;
var y = e.detail.originY - e.detail.imageY;
e.detail.image.style.transform = 'translate(' + x + 'px, ' + y + 'px)';
});
});
el.addEventListener('addItem', function (e) {
e.detail.listId = state.list.id;
});
el.addEventListener('moveItem', function (e) {
e.detail.listId = state.list.id;
e.detail.index = e.detail.index || 0;
});
el.todoCustomList = {
update: update,
};
function save() {
el.dispatchEvent(
new CustomEvent('saveList', {
detail: { list: state.list, title: inputEl.value.trim() },
bubbles: true,
})
);
update({ editing: false });
}
function cancelEdit() {
saveOnBlur = false;
update({ editing: false });
}
function update(next) {
Object.assign(state, next);
titleEl.innerText = state.list.title || '...';
el.querySelector('.todo-list').todoList.update({ items: state.list.items });
el.querySelector('.todo-list > .todo-item-input').dataset.key =
'todo-item-input' + state.list.id;
el.classList.toggle('-editing', state.editing);
if (state.editing && startEditing) {
inputEl.value = state.list.title;
inputEl.focus();
inputEl.select();
startEditing = false;
}
}
};

View File

@@ -0,0 +1,51 @@
/* global VT */
window.VT = window.VT || {};
VT.TodoDay = function (el) {
var state = {
dateId: el.dataset.key,
items: [],
};
el.innerHTML = [
'<div class="header">',
' <h3 class="dayofweek"></h3>',
' <h6 class="date"></h6>',
'</div>',
'<div class="todo-list"></div>',
].join('\n');
VT.TodoList(el.querySelector('.todo-list'));
el.addEventListener('addItem', function (e) {
e.detail.listId = state.dateId;
});
el.addEventListener('moveItem', function (e) {
e.detail.listId = state.dateId;
e.detail.index = e.detail.index || 0;
});
el.todoDay = {
update: update,
};
function update(next) {
Object.assign(state, next);
var date = new Date(state.dateId);
var today = new Date();
today.setHours(0, 0, 0, 0);
var tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
el.classList.toggle('-past', date < today);
el.classList.toggle('-today', date >= today && date < tomorrow);
el.querySelector('.header > .dayofweek').innerText = VT.formatDayOfWeek(
date
);
el.querySelector('.header > .date').innerText = VT.formatDate(date);
el.querySelector('.todo-list').todoList.update({ items: state.items });
}
};

View File

@@ -0,0 +1,166 @@
/* global VT */
window.VT = window.VT || {};
VT.TodoFrameCustom = function (el) {
var state = {
lists: [],
items: [],
at: 0,
show: true,
};
el.innerHTML = [
'<div class="leftcontrols">',
' <p><button class="app-button -circle -xl back"><i class="app-icon" data-id="chevron-left-24"></i></button></p>',
'</div>',
'<div class="container"></div>',
'<div class="rightcontrols">',
' <p><button class="app-button -circle -xl forward"><i class="app-icon" data-id="chevron-right-24"></i></button></p>',
' <p><button class="app-button -circle -xl add"><i class="app-icon" data-id="plus-circle-24"></i></button></p>',
'</div>',
].join('\n');
VT.AppSortable(el.querySelector('.container'), { direction: 'horizontal' });
setTimeout(function () {
el.classList.add('-animated');
}, 200);
el.querySelectorAll('.app-icon').forEach(VT.AppIcon);
el.querySelector('.back').addEventListener('click', function () {
el.dispatchEvent(
new CustomEvent('customSeek', { detail: -1, bubbles: true })
);
});
el.querySelector('.forward').addEventListener('click', function () {
el.dispatchEvent(
new CustomEvent('customSeek', { detail: 1, bubbles: true })
);
});
el.querySelector('.add').addEventListener('click', function () {
el.dispatchEvent(new CustomEvent('addList', { detail: {}, bubbles: true }));
// TODO seek if not at end
});
el.addEventListener('sortableDrop', function (e) {
if (!e.detail.data.list) return;
el.dispatchEvent(
new CustomEvent('moveList', {
detail: {
list: e.detail.data.list,
index: e.detail.index,
},
bubbles: true,
})
);
});
el.addEventListener('draggableOver', function (e) {
if (!e.detail.data.list) return;
updatePositions();
});
el.todoFrameCustom = {
update: update,
};
function update(next) {
Object.assign(state, next);
var lists = getLists();
var container = el.querySelector('.container');
var obsolete = new Set(container.children);
var childrenByKey = new Map();
obsolete.forEach(function (child) {
childrenByKey.set(child.dataset.key, child);
});
var children = lists.map(function (list) {
var child = childrenByKey.get(list.id);
if (child) {
obsolete.delete(child);
} else {
child = document.createElement('div');
child.className = 'card todo-custom-list';
child.dataset.key = list.id;
VT.TodoCustomList(child);
}
child.todoCustomList.update({ list: list });
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]);
}
});
updatePositions();
updateHeight();
}
function updatePositions() {
el.querySelectorAll('.container > *').forEach(function (child, index) {
child.style.transform = 'translateX(' + (index - state.at) * 100 + '%)';
});
}
function updateHeight() {
var height = 280;
var container = el.querySelector('.container');
var i, l;
for (i = 0, l = container.children.length; i < l; ++i) {
height = Math.max(container.children[i].offsetHeight, height);
}
el.style.height = height + 50 + 'px';
for (i = 0, l = container.children.length; i < l; ++i) {
container.children[i].style.height = height + 'px';
}
}
function getLists() {
var lists = state.lists.map(function (list) {
return {
id: list.id,
index: list.index,
title: list.title,
items: getItemsForList(list.id),
};
});
lists.sort(function (a, b) {
return a.index - b.index;
});
return lists;
}
function getItemsForList(listId) {
var items = state.items.filter(function (item) {
return item.listId === listId;
});
items.sort(function (a, b) {
return a.index - b.index;
});
return items;
}
};

View File

@@ -0,0 +1,138 @@
/* global VT */
window.VT = window.VT || {};
VT.TodoFrameDays = function (el) {
var RANGE = 14;
var state = {
items: [],
at: VT.formatDateId(new Date()),
};
el.innerHTML = [
'<nav class="leftcontrols">',
' <p><button class="app-button -circle -xl backward"><i class="app-icon" data-id="chevron-left-24"></i></button></p>',
' <p><button class="app-button fastbackward"><i class="app-icon -double" data-id="chevron-left-16"></i></i></button></p>',
' <p><button class="app-button home"><i class="app-icon" data-id="home-16"></i></button></p>',
'</nav>',
'<div class="container"></div>',
'<nav class="rightcontrols">',
' <p><button class="app-button -circle -xl forward"><i class="app-icon" data-id="chevron-right-24"></i></button></p>',
' <p><button class="app-button fastforward"><i class="app-icon -double" data-id="chevron-right-16"></i></button></p>',
'</nav>',
].join('\n');
setTimeout(function () {
el.classList.add('-animated');
}, 200);
el.querySelectorAll('.app-icon').forEach(VT.AppIcon);
el.querySelector('.backward').addEventListener('click', function () {
el.dispatchEvent(new CustomEvent('seek', { detail: -1, bubbles: true }));
});
el.querySelector('.forward').addEventListener('click', function () {
el.dispatchEvent(new CustomEvent('seek', { detail: 1, bubbles: true }));
});
el.querySelector('.fastbackward').addEventListener('click', function () {
el.dispatchEvent(new CustomEvent('seek', { detail: -5, bubbles: true }));
});
el.querySelector('.fastforward').addEventListener('click', function () {
el.dispatchEvent(new CustomEvent('seek', { detail: 5, bubbles: true }));
});
el.querySelector('.home').addEventListener('click', function () {
el.dispatchEvent(new CustomEvent('seekHome', { bubbles: true }));
});
el.todoFrameDays = {
update: update,
};
function update(next) {
Object.assign(state, next);
var days = getDays();
var container = el.querySelector('.container');
var obsolete = new Set(container.children);
var childrenByKey = new Map();
obsolete.forEach(function (child) {
childrenByKey.set(child.dataset.key, child);
});
var children = days.map(function (day) {
var child = childrenByKey.get(day.id);
if (child) {
obsolete.delete(child);
} else {
child = document.createElement('div');
child.className = 'card todo-day';
child.dataset.key = day.id;
VT.TodoDay(child);
}
child.todoDay.update(day);
child.style.transform = 'translateX(' + day.position * 100 + '%)';
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]);
}
});
updateHeight();
}
function updateHeight() {
var height = 280;
var container = el.querySelector('.container');
for (var i = 0, l = container.children.length; i < l; ++i) {
height = Math.max(container.children[i].offsetHeight, height);
}
el.style.height = height + 50 + 'px';
}
function getDays() {
var days = [];
for (var i = 0; i < 2 * RANGE; ++i) {
var t = new Date(state.at);
t.setDate(t.getDate() - RANGE + i);
var id = VT.formatDateId(t);
days.push({
id: id,
items: getItemsForDay(id),
position: -RANGE + i,
});
}
return days;
}
function getItemsForDay(dateId) {
var items = state.items.filter(function (item) {
return item.listId === dateId;
});
items.sort(function (a, b) {
return a.index - b.index;
});
return items;
}
};

View File

@@ -0,0 +1,152 @@
/* global VT */
window.VT = window.VT || {};
VT.TodoItem = function (el) {
var state = {
item: null,
editing: false,
};
var startEditing = false;
var saveOnBlur = true;
el.innerHTML = [
'<div class="checkbox">',
' <input type="checkbox">',
'</div>',
'<p class="label"></p>',
'<p class="form">',
' <input type="text" class="input use-focus-other">',
' <button class="app-button save"><i class="app-icon" data-id="check-16"></i></button>',
'</p>',
].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;
}
}
};

View File

@@ -0,0 +1,63 @@
/* global VT */
window.VT = window.VT || {};
VT.TodoItemInput = function (el) {
var saveOnBlur = true;
el.innerHTML = [
'<input type="text" class="input use-focus-other">',
'<button class="app-button save"><i class="app-icon" data-id="plus-24"></i></button>',
].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();
}
};

View File

@@ -0,0 +1,71 @@
/* global VT */
window.VT = window.VT || {};
VT.TodoList = function (el) {
var state = {
items: [],
};
el.innerHTML = [
'<div class="items"></div>',
'<div class="todo-item-input"></div>',
].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,
};
};

View File

@@ -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,
};
};

View File

@@ -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()];
};

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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%;
}
}

View File

@@ -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;
}

View File

@@ -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;
}