1
0
mirror of https://github.com/morris/vanilla-todo.git synced 2025-01-17 12:48:15 +01:00

update to ES2020, some refactoring and cleanups

This commit is contained in:
Morris Brodersen 2022-05-09 15:46:58 +02:00
parent b4c57030f8
commit 2fbfc5e650
26 changed files with 1507 additions and 2129 deletions

View File

@ -1,24 +1,19 @@
module.exports = { module.exports = {
extends: ['eslint:recommended', 'plugin:compat/recommended'], extends: 'eslint:recommended',
globals: {
Set: 'readonly',
Map: 'readonly',
},
env: { env: {
browser: true, browser: true,
es2020: true,
}, },
parserOptions: { parserOptions: {
ecmaVersion: 5, ecmaVersion: 2020,
sourceType: 'module',
}, },
rules: {}, rules: {
settings: { 'object-shorthand': 'error',
polyfills: [ 'prefer-arrow-callback': 'error',
'Set', 'arrow-body-style': ['error', 'as-needed'],
'Map', 'no-var': 'error',
'fetch', 'prefer-template': 'error',
'Object.assign', 'no-console': 'error',
'requestAnimationFrame',
'performance.now',
],
}, },
}; };

View File

@ -1,4 +1,4 @@
Copyright 2020-2021 Morris Brodersen <mb@morrisbrodersen.de> Copyright 2020-2022 Morris Brodersen <mb@morrisbrodersen.de>
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.

184
README.md
View File

@ -9,7 +9,7 @@ with a total transfer size of **44KB** (unminified).
More importantly, it's a case study showing that **vanilla web development** is More importantly, it's a case study showing that **vanilla web development** is
viable in terms of [maintainability](#521-the-good), viable in terms of [maintainability](#521-the-good),
and worthwhile in terms of [user experience](#51-user-experience) and worthwhile in terms of [user experience](#51-user-experience)
(**100%** faster loads and **90%** less bandwidth in this case). (**50%** less time to load and **90%** less bandwidth in this case).
**There's no custom framework invented here.** **There's no custom framework invented here.**
Instead, the case study was [designed](#22-rules) to discover Instead, the case study was [designed](#22-rules) to discover
@ -119,7 +119,7 @@ I came up with a set of rules to follow throughout the process:
- No build steps. - No build steps.
- No general-purpose utility functions related to the DOM/UI (2). - No general-purpose utility functions related to the DOM/UI (2).
(1) This is a moving target; I used ES5 for maximum support. (1) This is a moving target; the current version is using ES2020.
(2) These usually end up becoming a custom micro-framework, (2) These usually end up becoming a custom micro-framework,
thereby questioning why you didn't use one of the thereby questioning why you didn't use one of the
@ -162,16 +162,12 @@ plain HTML, CSS and JS files. The HTML and CSS mostly follows
which yields an intuitive, component-oriented structure. which yields an intuitive, component-oriented structure.
The stylesheets are slightly verbose. The stylesheets are slightly verbose.
I missed [SCSS](https://sass-lang.com/) or [LESS](http://lesscss.org/) here I missed [SCSS](https://sass-lang.com/) here
and I think one of these is a must-have for bigger projects. and I think one of these is a must-have for bigger projects.
Additionally, the global CSS namespace problem is unaddressed
(see e.g. [CSS Modules](https://github.com/css-modules/css-modules)).
ES6 modules are ruled out so all JavaScript lives under All JavaScript files are ES modules (`import`/`export`).
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 Basic code quality (code style, linting) is guided by
[Prettier](https://prettier.io), [stylelint](https://stylelint.io) and [Prettier](https://prettier.io), [stylelint](https://stylelint.io) and
@ -206,11 +202,11 @@ per matching element. This yields a simple mental model and synergizes
with the DOM and styles: with the DOM and styles:
``` ```
.todo-list -> VT.TodoList .todo-list -> TodoList
scripts/TodoList.js scripts/TodoList.js
styles/todo-list.css styles/todo-list.css
.app-collapsible -> VT.AppCollapsible .app-collapsible -> AppCollapsible
scripts/AppCollapsible.js scripts/AppCollapsible.js
styles/app-collapsible.css styles/app-collapsible.css
@ -229,40 +225,30 @@ provide behavior and rendering for the target element.
Here's a "Hello, World!" example of mount functions: Here's a "Hello, World!" example of mount functions:
```js ```js
// safely initialize namespace
window.MYAPP = window.MYAPP || {};
// define mount function // define mount function
// loosely mapped to ".hello-world" // loosely mapped to ".hello-world"
MYAPP.HelloWorld = function (el) { export function HelloWorld(el) {
// define initial state // define initial state
var state = { const state = {
title: 'Hello, World!', title: 'Hello, World!',
description: 'An example vanilla component', description: 'An example vanilla component',
counter: 0, counter: 0,
}; };
// set rigid base HTML // set rigid base HTML
// no ES6 template literals :( el.innerHTML = `
el.innerHTML = [ <h1 class="title"></h1>
'<h1 class="title"></h1>', <p class="description"></p>
'<p class="description"></p>', <div class="my-counter"></div>
'<div class="my-counter"></div>', `;
].join('\n');
// mount sub-components // mount sub-components
el.querySelectorAll('.my-counter').forEach(MYAPP.MyCounter); el.querySelectorAll('.my-counter').forEach(MYAPP.MyCounter);
// attach event listeners // attach event listeners
el.addEventListener('modifyCounter', function (e) { el.addEventListener('modifyCounter', (e) =>
update({ counter: state.counter + e.detail }); update({ counter: state.counter + e.detail })
}); );
// expose public interface
// use lower-case function name
el.helloWorld = {
update: update,
};
// initial update // initial update
update(); update();
@ -278,29 +264,30 @@ MYAPP.HelloWorld = function (el) {
el.querySelector('.description').innerText = state.description; el.querySelector('.description').innerText = state.description;
// pass data to sub-scomponents // pass data to sub-scomponents
el.querySelector('.my-counter').myCounter.update({ el.querySelector('.my-counter').dispatchEvent(
value: state.counter, new CustomEvent('updateMyCounter', {
}); detail: { value: state.counter },
})
);
}
} }
};
// define another component // define another component
// loosely mapped to ".my-counter" // loosely mapped to ".my-counter"
MYAPP.MyCounter = function (el) { export function MyCounter(el) {
// define initial state // define initial state
var state = { const state = {
value: 0, value: 0,
}; };
// set rigid base HTML // set rigid base HTML
// no ES6 template literals :( el.innerHTML = `
el.innerHTML = [ <p>
'<p>', <span class="value"></span>
' <span class="value"></span>', <button class="increment">Increment</button>
' <button class="increment">Increment</button>', <button class="decrement">Decrement</button>
' <button class="decrement">Decrement</button>', </p>
'</p>', `;
].join('\n');
// attach event listeners // attach event listeners
el.querySelector('.increment').addEventListener('click', function () { el.querySelector('.increment').addEventListener('click', function () {
@ -325,11 +312,7 @@ MYAPP.MyCounter = function (el) {
); );
}); });
// expose public interface el.addEventListener('updateMyCounter', (e) => update(e.detail));
// use lower-case function name
el.myCounter = {
update: update,
};
// define idempotent update function // define idempotent update function
function update(next) { function update(next) {
@ -337,11 +320,11 @@ MYAPP.MyCounter = function (el) {
el.querySelector('.value').innerText = state.value; el.querySelector('.value').innerText = state.value;
} }
}; }
// mount HelloWorld component(s) // mount HelloWorld component(s)
// any <div class="hello-world"></div> in the document will be mounted // any <div class="hello-world"></div> in the document will be mounted
document.querySelectorAll('.hello-world').forEach(MYAPP.HelloWorld); document.querySelectorAll('.hello-world').forEach(HelloWorld);
``` ```
This comes with quite some boilerplate but has useful properties, This comes with quite some boilerplate but has useful properties,
@ -352,7 +335,7 @@ For example, a mount function does not have to set any base HTML,
and may instead only set event listeners to enable some behavior. and may instead only set event listeners to enable some behavior.
Also note that an element can be mounted with multiple mount functions. 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`. For example, to-do items are mounted with `TodoItem` and `AppDraggable`.
Compared to React components, mount functions provide interesting flexibility as Compared to React components, mount functions provide interesting flexibility as
components and behaviors can be implemented using the same idiom and combined components and behaviors can be implemented using the same idiom and combined
@ -366,16 +349,17 @@ Reference:
#### 3.2.2. Data Flow #### 3.2.2. Data Flow
I found it effective to implement one-way data flow similar to React's approach. I found it effective to implement one-way data flow similar to React's approach,
however exclusively using custom DOM events.
- **Data flows downwards** from parent components to child components - **Data flows downwards** from parent components to child components
through their public interfaces (usually `update` functions). through custom DOM events.
- **Actions flow upwards** through custom DOM events (bubbling up), - **Actions flow upwards** through custom DOM events (bubbling up),
usually resulting in some parent component state change which is in turn usually resulting in some parent component state change which is in turn
propagated downwards through `update` functions. propagated downwards through data events.
The data store is factored into a separate behavior (`VT.TodoStore`). The data store is factored into a separate behavior (`TodoStore`).
It only receives and dispatches events, and encapsulates all of the data logic. 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 Listening to and dispatching events is slightly verbose with standard APIs and
certainly justifies introducing helpers. certainly justifies introducing helpers.
@ -424,35 +408,34 @@ amount of dynamic components efficiently. Here's a commented example
from the implementation outlining the reconciliation algorithm: from the implementation outlining the reconciliation algorithm:
```js ```js
/* global VT */ export function TodoList(el) {
window.VT = window.VT || {}; const state = {
VT.TodoList = function (el) {
var state = {
items: [], items: [],
}; };
el.innerHTML = '<div class="items"></div>'; el.innerHTML = `<div class="items"></div>`;
el.addEventListener('updateTodoList', (e) => update(e.detail));
function update(next) { function update(next) {
Object.assign(state, next); Object.assign(state, next);
var container = el.querySelector('.items'); const container = el.querySelector('.items');
// mark current children for removal // mark current children for removal
var obsolete = new Set(container.children); const obsolete = new Set(container.children);
// map current children by data-key // map current children by data-key
var childrenByKey = new Map(); const childrenByKey = new Map();
obsolete.forEach(function (child) { obsolete.forEach((child) =>
childrenByKey.set(child.getAttribute('data-key'), child); childrenByKey.set(child.getAttribute('data-key'), child)
}); );
// build new list of child elements from data // build new list of child elements from data
var children = state.items.map(function (item) { const children = state.items.map((item) => {
// find existing child by data-key // find existing child by data-key
var child = childrenByKey.get(item.id); let child = childrenByKey.get(item.id);
if (child) { if (child) {
// if child exists, keep it // if child exists, keep it
@ -466,32 +449,28 @@ VT.TodoList = function (el) {
child.setAttribute('data-key', item.id); child.setAttribute('data-key', item.id);
// mount component // mount component
VT.TodoItem(child); TodoItem(child);
} }
// update child // update child
child.todoItem.update({ item: item }); child.dispatchEvent(
new CustomEvent('updateTodoItem', { detail: { item: item } })
);
return child; return child;
}); });
// remove obsolete children // remove obsolete children
obsolete.forEach(function (child) { obsolete.forEach((child) => container.removeChild(child));
container.removeChild(child);
});
// (re-)insert new list of children // (re-)insert new list of children
children.forEach(function (child, index) { children.forEach((child, index) => {
if (child !== container.children[index]) { if (child !== container.children[index]) {
container.insertBefore(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. It's very verbose and has lots of opportunity to introduce bugs.
@ -570,7 +549,7 @@ In particular, dragging and dropping gives proper visual feedback
when elements are reordered. when elements are reordered.
_The latter was an improvement over the original application when I started _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 working on the case study in 2019. In the meantime, the TeuxDeux
team released an update with a much better drag & drop experience. Great job!_ team released an update with a much better drag & drop experience. Great job!_
One notable missing feature is Markdown support. It would be insensible One notable missing feature is Markdown support. It would be insensible
@ -632,7 +611,7 @@ and some opinionated statements based on my experience in the industry.
- Low coupling - Low coupling
- The result is literally just a bunch of HTML, CSS, and JS files. - 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**, All source files (HTML, CSS and JS) combine to **under 2400 lines of code**,
including comments and empty lines. including comments and empty lines.
For comparison, prettifying the original TeuxDeux's minified JS application For comparison, prettifying the original TeuxDeux's minified JS application
@ -645,11 +624,6 @@ I suspect a fully equivalent clone to be well below 10000 LOC, though._
- Stylesheets are a bit verbose. SCSS would help here. - Stylesheets are a bit verbose. SCSS would help here.
- Simple components require quite some boilerplate code. - 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 - `el.querySelectorAll(':scope ...')` is somewhat default/expected and
would justify a helper. would justify a helper.
- Listening to and dispatching events is slightly verbose. - Listening to and dispatching events is slightly verbose.
@ -661,6 +635,10 @@ would reduce the comparably low code size (see above) even further.
#### 5.2.3. The Bad #### 5.2.3. The Bad
- Class names share a global namespace.
- Event names share a global namespace.
- Especially problematic for events that bubble up.
- No code completion in HTML strings.
- The separation between base HTML and dynamic rendering is not ideal - The separation between base HTML and dynamic rendering is not ideal
when compared to JSX, for example. when compared to JSX, for example.
- JSX/virtual DOM techniques provide much better development ergonomics. - JSX/virtual DOM techniques provide much better development ergonomics.
@ -672,10 +650,10 @@ would reduce the comparably low code size (see above) even further.
e.g. watch elements of selector X (at all times) and ensure the desired e.g. watch elements of selector X (at all times) and ensure the desired
behaviors are mounted once on them. behaviors are mounted once on them.
- No type safety. I've always been a proponent of dynamic languages - No type safety. I've always been a proponent of dynamic languages
but since TypeScripts' type system provides the best of both worlds, but since TypeScript's type system provides the best of both worlds,
I cannot recommend using it enough. I cannot recommend using it enough.
- We're effectively locked out of using NPM dependencies that don't provide - We're effectively locked out of using NPM dependencies that don't provide
browser builds as we cannot use CommonJS or ES6 modules. browser-ready builds (ES modules or UMD).
- Most frameworks handle a lot of browser inconsistencies **for free** and - Most frameworks handle a lot of browser inconsistencies **for free** and
continuously monitor regressions with extensive test suites. continuously monitor regressions with extensive test suites.
The cost of browser testing is surely a lot higher The cost of browser testing is surely a lot higher
@ -703,6 +681,17 @@ after all:
- Rendering is idempotent and complete (React's pure `render` function). - Rendering is idempotent and complete (React's pure `render` function).
- One-way data flow (React) - One-way data flow (React)
An open question is if these patterns hold for library authors.
Although not considered during the study, some observations can be made:
- The JavaScript itself would be fine to share as ES modules.
- However, event naming needs great care, as dispatching (bubbling) events
from imported behaviors can trigger parent listeners in consumer code.
- Can be mitigated by providing options to prefix or map event names.
- CSS names share a global namespace and need to be managed as well.
- Could be mitigated by prefixing as well, however making the JavaScript
a bit more complex.
## 6. Conclusion ## 6. Conclusion
The result of this study is a working todo application with decent UI/UX and The result of this study is a working todo application with decent UI/UX and
@ -809,9 +798,16 @@ Thanks!
## 9. Changelog ## 9. Changelog
### 05/2022
- Refactored for ES2020
- Refactored for event-driven communication exclusively
- Moved original ES5-based version of the study to [/es5](./es5)
- Added assessment regarding library authoring
### 01/2021 ### 01/2021
- Add [response section](#82-response) - Added [response section](#82-response)
### 10/2020 ### 10/2020

2096
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,9 @@
"scripts": { "scripts": {
"format": "prettier --write *.js 'public/scripts/*.js' 'public/styles/**/*.css'", "format": "prettier --write *.js 'public/scripts/*.js' 'public/styles/**/*.css'",
"format-check": "prettier --check *.js 'public/scripts/**/*.js' 'public/styles/**/*.css'", "format-check": "prettier --check *.js 'public/scripts/**/*.js' 'public/styles/**/*.css'",
"lint": "eslint .", "lint": "eslint public",
"lint-styles": "stylelint public/styles/*" "lint-styles": "stylelint public/styles/*",
"serve": "http-server public"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -14,8 +15,9 @@
}, },
"keywords": [ "keywords": [
"vanilla", "vanilla",
"radio", "html",
"somafm" "javascript",
"css"
], ],
"author": "Morris Brodersen <mb@morrisbrodersen.de>", "author": "Morris Brodersen <mb@morrisbrodersen.de>",
"license": "ISC", "license": "ISC",
@ -24,11 +26,12 @@
}, },
"homepage": "https://github.com/morris/vanilla-todo#readme", "homepage": "https://github.com/morris/vanilla-todo#readme",
"devDependencies": { "devDependencies": {
"eslint": "^7.17.0", "eslint": "^8.14.0",
"eslint-plugin-compat": "^3.9.0", "eslint-plugin-compat": "^4.0.2",
"prettier": "^2.2.1", "http-server": "^14.1.0",
"stylelint": "^13.8.0", "prettier": "^2.6.2",
"stylelint-config-standard": "^20.0.0", "stylelint": "^14.8.2",
"stylelint-config-standard": "^25.0.0",
"stylelint-rscss": "^0.4.0" "stylelint-rscss": "^0.4.0"
} }
} }

View File

@ -24,30 +24,10 @@
<body> <body>
<div class="todo-app"></div> <div class="todo-app"></div>
<script <script type="module">
nomodule import { TodoApp } from './scripts/TodoApp.js';
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> TodoApp(document.querySelector('.todo-app'));
VT.TodoApp(document.querySelector('.todo-app'));
</script> </script>
</body> </body>
</html> </html>

View File

@ -1,18 +1,15 @@
/* global VT */ export function AppCollapsible(el) {
window.VT = window.VT || {}; const state = {
VT.AppCollapsible = function (el) {
var state = {
show: true, show: true,
}; };
el.querySelector('.bar > .toggle').addEventListener('click', function () { el.addEventListener('collapse', (e) => update({ show: !e.detail }));
update({ show: !state.show });
});
el.appCollapsible = { el.querySelector('.bar > .toggle').addEventListener('click', () =>
update: update, update({ show: !state.show })
}; );
update();
function update(next) { function update(next) {
Object.assign(state, next); Object.assign(state, next);
@ -22,8 +19,8 @@ VT.AppCollapsible = function (el) {
state.show state.show
); );
el.querySelectorAll('.body').forEach(function (el) { el.querySelectorAll('.body').forEach((el) => {
el.style.height = state.show ? el.children[0].offsetHeight + 'px' : '0'; el.style.height = state.show ? `${el.children[0].offsetHeight}px` : '0';
}); });
} }
}; }

View File

@ -1,21 +1,18 @@
/* global VT */ export function AppDraggable(el, options) {
window.VT = window.VT || {}; const dragThreshold = options.dragThreshold ?? 5;
const dropRange = options.dropRange ?? 50;
const dropRangeSquared = dropRange * dropRange;
VT.AppDraggable = function (el, options) { let originX, originY;
var dragThreshold = options.dragThreshold || 5; let clientX, clientY;
var dropRange = options.dropRange || 50; let startTime;
var dropRangeSquared = dropRange * dropRange; let dragging = false;
let clicked = false;
var originX, originY; let data;
var clientX, clientY; let image;
var startTime; let imageSource;
var dragging = false; let imageX, imageY;
var clicked = false; let currentTarget;
var data;
var image;
var imageSource;
var imageX, imageY;
var currentTarget;
el.addEventListener('touchstart', start); el.addEventListener('touchstart', start);
el.addEventListener('mousedown', start); el.addEventListener('mousedown', start);
@ -23,7 +20,7 @@ VT.AppDraggable = function (el, options) {
// maybe prevent click // maybe prevent click
el.addEventListener( el.addEventListener(
'click', 'click',
function (e) { (e) => {
if (dragging || clicked) { if (dragging || clicked) {
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
@ -39,9 +36,9 @@ VT.AppDraggable = function (el, options) {
e.preventDefault(); e.preventDefault();
var p = getPositionHost(e); const p = getPositionHost(e);
clientX = originX = p.clientX || p.pageX; clientX = originX = p.clientX ?? p.pageX;
clientY = originY = p.clientY || p.pageY; clientY = originY = p.clientY ?? p.pageY;
startTime = Date.now(); startTime = Date.now();
startListening(); startListening();
@ -50,9 +47,9 @@ VT.AppDraggable = function (el, options) {
function move(e) { function move(e) {
e.preventDefault(); e.preventDefault();
var p = getPositionHost(e); const p = getPositionHost(e);
clientX = p.clientX || p.pageX; clientX = p.clientX ?? p.pageX;
clientY = p.clientY || p.pageY; clientY = p.clientY ?? p.pageY;
if (dragging) { if (dragging) {
dispatchDrag(); dispatchDrag();
@ -60,8 +57,8 @@ VT.AppDraggable = function (el, options) {
return; return;
} }
var deltaX = clientX - originX; const deltaX = clientX - originX;
var deltaY = clientY - originY; const deltaY = clientY - originY;
if (Math.abs(deltaX) < dragThreshold && Math.abs(deltaY) < dragThreshold) { if (Math.abs(deltaX) < dragThreshold && Math.abs(deltaY) < dragThreshold) {
return; return;
@ -92,7 +89,7 @@ VT.AppDraggable = function (el, options) {
stopListening(); stopListening();
requestAnimationFrame(function () { requestAnimationFrame(() => {
clicked = false; clicked = false;
if (dragging) { if (dragging) {
@ -141,7 +138,7 @@ VT.AppDraggable = function (el, options) {
function dispatchTarget() { function dispatchTarget() {
if (!dragging) return; if (!dragging) return;
var nextTarget = getTarget(); const nextTarget = getTarget();
if (nextTarget === currentTarget) return; if (nextTarget === currentTarget) return;
@ -210,18 +207,18 @@ VT.AppDraggable = function (el, options) {
// //
function buildDetail() { function buildDetail() {
var detail = { const detail = {
el: el, el,
data: data, data,
image: image, image,
imageSource: imageSource, imageSource,
originX: originX, originX,
originY: originY, originY,
clientX: clientX, clientX,
clientY: clientY, clientY,
imageX: imageX, imageX,
imageY: imageY, imageY,
setImage: function (source) { setImage: (source) => {
setImage(source); setImage(source);
detail.image = image; detail.image = image;
}, },
@ -240,21 +237,21 @@ VT.AppDraggable = function (el, options) {
image.style.position = 'fixed'; image.style.position = 'fixed';
image.style.left = '0'; image.style.left = '0';
image.style.top = '0'; image.style.top = '0';
image.style.width = imageSource.offsetWidth + 'px'; image.style.width = `${imageSource.offsetWidth}px`;
image.style.height = imageSource.offsetHeight + 'px'; image.style.height = `${imageSource.offsetHeight}px`;
image.style.margin = '0'; image.style.margin = '0';
image.style.zIndex = 9999; image.style.zIndex = 9999;
image.classList.add('-dragging'); image.classList.add('-dragging');
var rect = source.getBoundingClientRect(); const rect = source.getBoundingClientRect();
imageX = originX - rect.left; imageX = originX - rect.left;
imageY = originY - rect.top; imageY = originY - rect.top;
image.addEventListener('draggableDrag', function (e) { image.addEventListener('draggableDrag', (e) => {
var x = e.detail.clientX - e.detail.imageX; const x = e.detail.clientX - e.detail.imageX;
var y = e.detail.clientY - e.detail.imageY; const y = e.detail.clientY - e.detail.imageY;
image.style.transition = 'none'; image.style.transition = 'none';
image.style.transform = 'translate(' + x + 'px, ' + y + 'px)'; image.style.transform = `translate(${x}px, ${y}px)`;
}); });
image.addEventListener('draggableCancel', cleanUp); image.addEventListener('draggableCancel', cleanUp);
@ -278,9 +275,7 @@ VT.AppDraggable = function (el, options) {
} }
function cleanUp() { function cleanUp() {
if (currentTarget) { currentTarget?.classList.remove('-drop');
currentTarget.classList.remove('-drop');
}
removeImage(); removeImage();
@ -291,27 +286,29 @@ VT.AppDraggable = function (el, options) {
} }
function removeImage() { function removeImage() {
if (image && image.parentNode) { image?.parentNode?.removeChild(image);
image.parentNode.removeChild(image);
}
} }
function getTarget() { function getTarget() {
var candidates = []; const candidates = [];
document.querySelectorAll(options.dropSelector).forEach(function (el) { document.querySelectorAll(options.dropSelector).forEach((el) => {
var rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
var distanceSquared = pointDistanceToRectSquared(clientX, clientY, rect); const distanceSquared = pointDistanceToRectSquared(
clientX,
clientY,
rect
);
if (distanceSquared > dropRangeSquared) return; if (distanceSquared > dropRangeSquared) return;
candidates.push({ candidates.push({
el: el, el,
distance2: distanceSquared, distance2: distanceSquared,
}); });
}); });
candidates.sort(function (a, b) { candidates.sort((a, b) => {
if (a.distance2 === 0 && b.distance2 === 0) { if (a.distance2 === 0 && b.distance2 === 0) {
// in this case, the client position is inside both rectangles // in this case, the client position is inside both rectangles
// if A contains B, B is the correct target and vice versa // if A contains B, B is the correct target and vice versa
@ -327,9 +324,9 @@ VT.AppDraggable = function (el, options) {
} }
function pointDistanceToRectSquared(x, y, rect) { function pointDistanceToRectSquared(x, y, rect) {
var dx = const dx =
x < rect.left ? x - rect.left : x > rect.right ? x - rect.right : 0; x < rect.left ? x - rect.left : x > rect.right ? x - rect.right : 0;
var dy = const dy =
y < rect.top ? y - rect.top : y > rect.bottom ? y - rect.bottom : 0; y < rect.top ? y - rect.top : y > rect.bottom ? y - rect.bottom : 0;
return dx * dx + dy * dy; return dx * dx + dy * dy;
@ -346,4 +343,4 @@ VT.AppDraggable = function (el, options) {
return e; return e;
} }
}; }

View File

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

View File

@ -1,9 +1,6 @@
/* global VT */ export function AppFps(el) {
window.VT = window.VT || {}; const sampleSize = 20;
let times = [];
VT.AppFps = function (el) {
var sampleSize = 20;
var times = [];
tick(); tick();
@ -14,27 +11,23 @@ VT.AppFps = function (el) {
if (times.length <= sampleSize) return; if (times.length <= sampleSize) return;
var min = Infinity; let min = Infinity;
var max = 0; let max = 0;
var sum = 0; let sum = 0;
for (var i = 1; i < sampleSize + 1; ++i) { for (let i = 1; i < sampleSize + 1; ++i) {
var delta = times[i] - times[i - 1]; const delta = times[i] - times[i - 1];
min = Math.min(min, delta); min = Math.min(min, delta);
max = Math.max(max, delta); max = Math.max(max, delta);
sum += delta; sum += delta;
} }
var fps = (sampleSize / sum) * 1000; const fps = (sampleSize / sum) * 1000;
el.innerText = el.innerText = `${fps.toFixed(0)} fps (${min.toFixed(0)} ms - ${max.toFixed(
fps.toFixed(0) + 0
' fps (' + )} ms)`;
min.toFixed(0) +
' ms - ' +
max.toFixed(0) +
' ms)';
times = []; times = [];
} }
}; }

View File

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

@ -1,34 +1,29 @@
/* global VT */ export function AppSortable(el, options) {
window.VT = window.VT || {}; let placeholder;
let placeholderSource;
const horizontal = options.direction === 'horizontal';
let currentIndex = -1;
VT.AppSortable = function (el, options) { el.addEventListener('draggableStart', (e) =>
var placeholder; e.detail.image.addEventListener('draggableCancel', cleanUp)
var placeholderSource; );
var horizontal = options.direction === 'horizontal';
var currentIndex = -1;
el.addEventListener('draggableStart', function (e) { el.addEventListener('draggableOver', (e) =>
e.detail.image.addEventListener('draggableCancel', cleanUp); maybeDispatchUpdate(calculateIndex(e.detail.image), e)
}); );
el.addEventListener('draggableOver', function (e) { el.addEventListener('draggableLeave', (e) => maybeDispatchUpdate(-1, e));
maybeDispatchUpdate(calculateIndex(e.detail.image), e);
});
el.addEventListener('draggableLeave', function (e) { el.addEventListener('draggableDrop', (e) =>
maybeDispatchUpdate(-1, e);
});
el.addEventListener('draggableDrop', function (e) {
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('sortableDrop', { new CustomEvent('sortableDrop', {
detail: buildDetail(e), detail: buildDetail(e),
bubbles: true, bubbles: true,
}) })
)
); );
});
el.addEventListener('sortableUpdate', function (e) { el.addEventListener('sortableUpdate', (e) => {
if (!placeholder) { if (!placeholder) {
e.detail.setPlaceholder(e.detail.originalEvent.detail.imageSource); e.detail.setPlaceholder(e.detail.originalEvent.detail.imageSource);
} }
@ -65,11 +60,11 @@ VT.AppSortable = function (el, options) {
} }
function buildDetail(e) { function buildDetail(e) {
var detail = { const detail = {
data: e.detail.data, data: e.detail.data,
index: currentIndex, index: currentIndex,
placeholder: placeholder, placeholder,
setPlaceholder: function (source) { setPlaceholder: (source) => {
setPlaceholder(source); setPlaceholder(source);
detail.placeholder = placeholder; detail.placeholder = placeholder;
}, },
@ -98,14 +93,12 @@ VT.AppSortable = function (el, options) {
} }
function removePlaceholder() { function removePlaceholder() {
if (placeholder && placeholder.parentNode) { placeholder?.parentNode?.removeChild(placeholder);
placeholder.parentNode.removeChild(placeholder);
}
} }
function removeByKey(key) { function removeByKey(key) {
for (var i = 0, l = el.children.length; i < l; ++i) { for (let i = 0, l = el.children.length; i < l; ++i) {
var child = el.children[i]; const child = el.children[i];
if (child && child.dataset.key === key) { if (child && child.dataset.key === key) {
el.removeChild(child); el.removeChild(child);
@ -116,12 +109,12 @@ VT.AppSortable = function (el, options) {
function calculateIndex(image) { function calculateIndex(image) {
if (el.children.length === 0) return 0; if (el.children.length === 0) return 0;
var isBefore = horizontal ? isLeft : isAbove; const isBefore = horizontal ? isLeft : isAbove;
var rect = image.getBoundingClientRect(); const rect = image.getBoundingClientRect();
var p = 0; let p = 0;
for (var i = 0, l = el.children.length; i < l; ++i) { for (let i = 0, l = el.children.length; i < l; ++i) {
var child = el.children[i]; const child = el.children[i];
if (isBefore(rect, child.getBoundingClientRect())) return i - p; if (isBefore(rect, child.getBoundingClientRect())) return i - p;
if (child === placeholder) p = 1; if (child === placeholder) p = 1;
@ -143,4 +136,4 @@ VT.AppSortable = function (el, options) {
rectB.left + (rectB.right - rectB.left) / 2 rectB.left + (rectB.right - rectB.left) / 2
); );
} }
}; }

View File

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

View File

@ -1,58 +1,59 @@
/* global VT */ import { AppDraggable } from './AppDraggable.js';
window.VT = window.VT || {}; import { AppIcon } from './AppIcon.js';
import { TodoList } from './TodoList.js';
VT.TodoCustomList = function (el) { export function TodoCustomList(el) {
var state = { const state = {
list: null, list: null,
editing: false, editing: false,
}; };
var startEditing = false; let startEditing = false;
var saveOnBlur = true; let saveOnBlur = true;
el.innerHTML = [ el.innerHTML = `
'<div class="header">', <div class="header">
' <h3 class="title"></h3>', <h3 class="title"></h3>
' <p class="form">', <p class="form">
' <input type="text" class="input use-focus-other">', <input type="text" class="input use-focus-other">
' <button class="app-button delete"><i class="app-icon" data-id="trashcan-16"></i></button>', <button class="app-button delete"><i class="app-icon" data-id="trashcan-16"></i></button>
' </p>', </p>
'</div>', </div>
'<div class="todo-list"></div>', <div class="todo-list"></div>
].join('\n'); `;
var titleEl = el.querySelector('.title'); const titleEl = el.querySelector('.title');
var inputEl = el.querySelector('.input'); const inputEl = el.querySelector('.input');
var deleteEl = el.querySelector('.delete'); const deleteEl = el.querySelector('.delete');
VT.AppDraggable(titleEl, { AppDraggable(titleEl, {
dropSelector: '.todo-frame.-custom .container', dropSelector: '.todo-frame.-custom .container',
}); });
VT.TodoList(el.querySelector('.todo-list')); el.querySelectorAll('.app-icon').forEach(AppIcon);
el.querySelectorAll('.app-icon').forEach(VT.AppIcon); TodoList(el.querySelector('.todo-list'));
titleEl.addEventListener('click', function () { titleEl.addEventListener('click', () => {
startEditing = true; startEditing = true;
update({ editing: true }); update({ editing: true });
}); });
deleteEl.addEventListener('touchstart', function () { deleteEl.addEventListener('touchstart', () => {
saveOnBlur = false; saveOnBlur = false;
}); });
deleteEl.addEventListener('mousedown', function () { deleteEl.addEventListener('mousedown', () => {
saveOnBlur = false; saveOnBlur = false;
}); });
inputEl.addEventListener('blur', function () { inputEl.addEventListener('blur', () => {
if (saveOnBlur) save(); if (saveOnBlur) save();
saveOnBlur = true; saveOnBlur = true;
}); });
inputEl.addEventListener('focusOther', function () { inputEl.addEventListener('focusOther', () => {
if (state.editing) save(); if (state.editing) save();
}); });
inputEl.addEventListener('keyup', function (e) { inputEl.addEventListener('keyup', (e) => {
switch (e.keyCode) { switch (e.keyCode) {
case 13: // enter case 13: // enter
save(); save();
@ -63,7 +64,7 @@ VT.TodoCustomList = function (el) {
} }
}); });
deleteEl.addEventListener('click', function () { deleteEl.addEventListener('click', () => {
if (state.list.items.length > 0) { if (state.list.items.length > 0) {
if ( if (
!confirm( !confirm(
@ -82,7 +83,7 @@ VT.TodoCustomList = function (el) {
); );
}); });
el.addEventListener('draggableStart', function (e) { el.addEventListener('draggableStart', (e) => {
if (e.target !== titleEl) return; if (e.target !== titleEl) return;
e.detail.data.list = state.list; e.detail.data.list = state.list;
@ -92,25 +93,23 @@ VT.TodoCustomList = function (el) {
e.detail.setImage(el); e.detail.setImage(el);
// override for horizontal dragging only // override for horizontal dragging only
e.detail.image.addEventListener('draggableDrag', function (e) { e.detail.image.addEventListener('draggableDrag', (e) => {
var x = e.detail.clientX - e.detail.imageX; const x = e.detail.clientX - e.detail.imageX;
var y = e.detail.originY - e.detail.imageY; const y = e.detail.originY - e.detail.imageY;
e.detail.image.style.transform = 'translate(' + x + 'px, ' + y + 'px)'; e.detail.image.style.transform = `translate(${x}px, ${y}px)`;
}); });
}); });
el.addEventListener('addItem', function (e) { el.addEventListener('addItem', (e) => {
e.detail.listId = state.list.id; e.detail.listId = state.list.id;
}); });
el.addEventListener('moveItem', function (e) { el.addEventListener('moveItem', (e) => {
e.detail.listId = state.list.id; e.detail.listId = state.list.id;
e.detail.index = e.detail.index || 0; e.detail.index = e.detail.index ?? 0;
}); });
el.todoCustomList = { el.addEventListener('todoCustomList', (e) => update({ list: e.detail }));
update: update,
};
function save() { function save() {
el.dispatchEvent( el.dispatchEvent(
@ -132,9 +131,13 @@ VT.TodoCustomList = function (el) {
titleEl.innerText = state.list.title || '...'; titleEl.innerText = state.list.title || '...';
el.querySelector('.todo-list').todoList.update({ items: state.list.items }); el.querySelector('.todo-list').dispatchEvent(
el.querySelector('.todo-list > .todo-item-input').dataset.key = new CustomEvent('todoItems', { detail: state.list.items })
'todo-item-input' + state.list.id; );
el.querySelector(
'.todo-list > .todo-item-input'
).dataset.key = `todo-item-input${state.list.id}`;
el.classList.toggle('-editing', state.editing); el.classList.toggle('-editing', state.editing);
@ -145,4 +148,4 @@ VT.TodoCustomList = function (el) {
startEditing = false; startEditing = false;
} }
} }
}; }

View File

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

View File

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

View File

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

View File

@ -1,45 +1,46 @@
/* global VT */ import { AppDraggable } from './AppDraggable.js';
window.VT = window.VT || {}; import { AppIcon } from './AppIcon.js';
VT.TodoItem = function (el) { export function TodoItem(el) {
var state = { const state = {
item: null, item: null,
editing: false, editing: false,
}; };
var startEditing = false;
var saveOnBlur = true;
el.innerHTML = [ let startEditing = false;
'<div class="checkbox">', let saveOnBlur = true;
' <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'); el.innerHTML = `
var labelEl = el.querySelector('.label'); <div class="checkbox">
var inputEl = el.querySelector('.input'); <input type="checkbox">
var saveEl = el.querySelector('.save'); </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>
`;
VT.AppDraggable(el, { const checkboxEl = el.querySelector('.checkbox');
const labelEl = el.querySelector('.label');
const inputEl = el.querySelector('.input');
const saveEl = el.querySelector('.save');
AppDraggable(el, {
dropSelector: '.todo-list > .items', dropSelector: '.todo-list > .items',
}); });
el.querySelectorAll('.app-icon').forEach(VT.AppIcon); el.querySelectorAll('.app-icon').forEach(AppIcon);
checkboxEl.addEventListener('touchstart', function () { checkboxEl.addEventListener('touchstart', () => {
saveOnBlur = false; saveOnBlur = false;
}); });
checkboxEl.addEventListener('mousedown', function () { checkboxEl.addEventListener('mousedown', () => {
saveOnBlur = false; saveOnBlur = false;
}); });
checkboxEl.addEventListener('click', function () { checkboxEl.addEventListener('click', () => {
if (state.editing) save(); if (state.editing) save();
el.dispatchEvent( el.dispatchEvent(
@ -53,12 +54,12 @@ VT.TodoItem = function (el) {
); );
}); });
labelEl.addEventListener('click', function () { labelEl.addEventListener('click', () => {
startEditing = true; startEditing = true;
update({ editing: true }); update({ editing: true });
}); });
inputEl.addEventListener('keyup', function (e) { inputEl.addEventListener('keyup', (e) => {
switch (e.keyCode) { switch (e.keyCode) {
case 13: // enter case 13: // enter
save(); save();
@ -69,39 +70,37 @@ VT.TodoItem = function (el) {
} }
}); });
inputEl.addEventListener('blur', function () { inputEl.addEventListener('blur', () => {
if (saveOnBlur) save(); if (saveOnBlur) save();
saveOnBlur = true; saveOnBlur = true;
}); });
inputEl.addEventListener('focusOther', function () { inputEl.addEventListener('focusOther', () => {
if (state.editing) save(); if (state.editing) save();
}); });
saveEl.addEventListener('mousedown', function () { saveEl.addEventListener('mousedown', () => {
saveOnBlur = false; saveOnBlur = false;
}); });
saveEl.addEventListener('click', save); saveEl.addEventListener('click', save);
el.addEventListener('draggableStart', function (e) { el.addEventListener('draggableStart', (e) => {
e.detail.data.item = state.item; e.detail.data.item = state.item;
e.detail.data.key = state.item.id; e.detail.data.key = state.item.id;
}); });
el.todoItem = { el.addEventListener('todoItem', (e) => update({ item: e.detail }));
update: update,
};
function save() { function save() {
var label = inputEl.value.trim(); const label = inputEl.value.trim();
if (label === '') { if (label === '') {
// deferred deletion prevents a bug at reconciliation in TodoList: // deferred deletion prevents a bug at reconciliation in TodoList:
// Failed to execute 'removeChild' on 'Node': The node to be removed is // 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' // no longer a child of this node. Perhaps it was moved in a 'blur'
// event handler? // event handler?
requestAnimationFrame(function () { requestAnimationFrame(() => {
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('deleteItem', { new CustomEvent('deleteItem', {
detail: state.item, detail: state.item,
@ -117,7 +116,7 @@ VT.TodoItem = function (el) {
new CustomEvent('saveItem', { new CustomEvent('saveItem', {
detail: { detail: {
item: state.item, item: state.item,
label: label, label,
}, },
bubbles: true, bubbles: true,
}) })
@ -149,4 +148,4 @@ VT.TodoItem = function (el) {
startEditing = false; startEditing = false;
} }
} }
}; }

View File

@ -1,20 +1,19 @@
/* global VT */ import { AppIcon } from './AppIcon.js';
window.VT = window.VT || {};
VT.TodoItemInput = function (el) { export function TodoItemInput(el) {
var saveOnBlur = true; let saveOnBlur = true;
el.innerHTML = [ el.innerHTML = `
'<input type="text" class="input use-focus-other">', <input type="text" class="input use-focus-other">
'<button class="app-button save"><i class="app-icon" data-id="plus-24"></i></button>', <button class="app-button save"><i class="app-icon" data-id="plus-24"></i></button>
].join('\n'); `;
var inputEl = el.querySelector('.input'); const inputEl = el.querySelector('.input');
var saveEl = el.querySelector('.save'); const saveEl = el.querySelector('.save');
el.querySelectorAll('.app-icon').forEach(VT.AppIcon); el.querySelectorAll('.app-icon').forEach(AppIcon);
inputEl.addEventListener('keyup', function (e) { inputEl.addEventListener('keyup', (e) => {
switch (e.keyCode) { switch (e.keyCode) {
case 13: // enter case 13: // enter
save(); save();
@ -25,24 +24,24 @@ VT.TodoItemInput = function (el) {
} }
}); });
inputEl.addEventListener('blur', function () { inputEl.addEventListener('blur', () => {
if (saveOnBlur) save(); if (saveOnBlur) save();
saveOnBlur = true; saveOnBlur = true;
}); });
inputEl.addEventListener('focusOther', save); inputEl.addEventListener('focusOther', save);
saveEl.addEventListener('mousedown', function () { saveEl.addEventListener('mousedown', () => {
saveOnBlur = false; saveOnBlur = false;
}); });
saveEl.addEventListener('click', function () { saveEl.addEventListener('click', () => {
save(); save();
inputEl.focus(); inputEl.focus();
}); });
function save() { function save() {
var label = inputEl.value.trim(); const label = inputEl.value.trim();
if (label === '') return; if (label === '') return;
@ -50,7 +49,7 @@ VT.TodoItemInput = function (el) {
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('addItem', { new CustomEvent('addItem', {
detail: { label: label }, detail: { label },
bubbles: true, bubbles: true,
}) })
); );
@ -60,4 +59,4 @@ VT.TodoItemInput = function (el) {
inputEl.value = ''; inputEl.value = '';
inputEl.blur(); inputEl.blur();
} }
}; }

View File

@ -1,20 +1,21 @@
/* global VT */ import { AppSortable } from './AppSortable.js';
window.VT = window.VT || {}; import { TodoItem } from './TodoItem.js';
import { TodoItemInput } from './TodoItemInput.js';
VT.TodoList = function (el) { export function TodoList(el) {
var state = { const state = {
items: [], items: [],
}; };
el.innerHTML = [ el.innerHTML = `
'<div class="items"></div>', <div class="items"></div>
'<div class="todo-item-input"></div>', <div class="todo-item-input"></div>
].join('\n'); `;
VT.AppSortable(el.querySelector('.items'), {}); AppSortable(el.querySelector('.items'), {});
VT.TodoItemInput(el.querySelector('.todo-item-input')); TodoItemInput(el.querySelector('.todo-item-input'));
el.addEventListener('sortableDrop', function (e) { el.addEventListener('sortableDrop', (e) =>
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('moveItem', { new CustomEvent('moveItem', {
detail: { detail: {
@ -23,22 +24,22 @@ VT.TodoList = function (el) {
}, },
bubbles: true, bubbles: true,
}) })
)
); );
});
el.addEventListener('todoItems', (e) => update({ items: e.detail }));
function update(next) { function update(next) {
Object.assign(state, next); Object.assign(state, next);
var container = el.querySelector('.items'); const container = el.querySelector('.items');
var obsolete = new Set(container.children); const obsolete = new Set(container.children);
var childrenByKey = new Map(); const childrenByKey = new Map();
obsolete.forEach(function (child) { obsolete.forEach((child) => childrenByKey.set(child.dataset.key, child));
childrenByKey.set(child.dataset.key, child);
});
var children = state.items.map(function (item) { const children = state.items.map((item) => {
var child = childrenByKey.get(item.id); let child = childrenByKey.get(item.id);
if (child) { if (child) {
obsolete.delete(child); obsolete.delete(child);
@ -46,26 +47,20 @@ VT.TodoList = function (el) {
child = document.createElement('div'); child = document.createElement('div');
child.classList.add('todo-item'); child.classList.add('todo-item');
child.dataset.key = item.id; child.dataset.key = item.id;
VT.TodoItem(child); TodoItem(child);
} }
child.todoItem.update({ item: item }); child.dispatchEvent(new CustomEvent('todoItem', { detail: item }));
return child; return child;
}); });
obsolete.forEach(function (child) { obsolete.forEach((child) => container.removeChild(child));
container.removeChild(child);
});
children.forEach(function (child, index) { children.forEach((child, index) => {
if (child !== container.children[index]) { if (child !== container.children[index]) {
container.insertBefore(child, container.children[index]); container.insertBefore(child, container.children[index]);
} }
}); });
} }
}
el.todoList = {
update: update,
};
};

View File

@ -1,28 +1,30 @@
/* global VT */ import { formatDateId, uuid } from './util.js';
window.VT = window.VT || {};
VT.TodoStore = function (el) { export function TodoStore(el) {
var state = { const state = {
items: [], items: [],
customLists: [], customLists: [],
at: VT.formatDateId(new Date()), at: formatDateId(new Date()),
customAt: 0, customAt: 0,
}; };
var storeTimeout;
el.addEventListener('addItem', function (e) { let storeTimeout;
var index = 0;
state.items.forEach(function (item) { el.addEventListener('loadStore', load);
el.addEventListener('addItem', (e) => {
let index = 0;
for (const item of state.items) {
if (item.listId === e.detail.listId) { if (item.listId === e.detail.listId) {
index = Math.max(index, item.index + 1); index = Math.max(index, item.index + 1);
} }
}); }
state.items.push({ state.items.push({
id: VT.uuid(), id: uuid(),
listId: e.detail.listId, listId: e.detail.listId,
index: index, index,
label: e.detail.label, label: e.detail.label,
done: false, done: false,
}); });
@ -30,71 +32,61 @@ VT.TodoStore = function (el) {
dispatch({ items: state.items }); dispatch({ items: state.items });
}); });
el.addEventListener('checkItem', function (e) { el.addEventListener('checkItem', (e) => {
if (e.detail.item.done === e.detail.done) return; if (e.detail.item.done === e.detail.done) return;
e.detail.item.done = e.detail.done; e.detail.item.done = e.detail.done;
dispatch({ items: state.items }); dispatch({ items: state.items });
}); });
el.addEventListener('saveItem', function (e) { el.addEventListener('saveItem', (e) => {
if (e.detail.item.label === e.detail.label) return; if (e.detail.item.label === e.detail.label) return;
e.detail.item.label = e.detail.label; e.detail.item.label = e.detail.label;
dispatch({ items: state.items }); dispatch({ items: state.items });
}); });
el.addEventListener('moveItem', function (e) { el.addEventListener('moveItem', (e) => {
var movedItem = state.items.find(function (item) { const movedItem = state.items.find((item) => item.id === e.detail.item.id);
return item.id === e.detail.item.id;
});
var listItems = state.items.filter(function (item) { const listItems = state.items.filter(
return item.listId === e.detail.listId && item !== movedItem; (item) => item.listId === e.detail.listId && item !== movedItem
}); );
listItems.sort(function (a, b) { listItems.sort((a, b) => a.index - b.index);
return a.index - b.index;
});
movedItem.listId = e.detail.listId; movedItem.listId = e.detail.listId;
listItems.splice(e.detail.index, 0, movedItem); listItems.splice(e.detail.index, 0, movedItem);
listItems.forEach(function (item, index) { listItems.forEach((item, index) => {
item.index = index; item.index = index;
}); });
dispatch({ items: state.items }); dispatch({ items: state.items });
}); });
el.addEventListener('deleteItem', function (e) { el.addEventListener('deleteItem', (e) => {
dispatch({ dispatch({ items: state.items.filter((item) => item.id !== e.detail.id) });
items: state.items.filter(function (item) {
return item.id !== e.detail.id;
}),
});
}); });
el.addEventListener('addList', function (e) { el.addEventListener('addList', (e) => {
var index = 0; let index = 0;
state.customLists.forEach(function (customList) { for (const customList of state.customLists) {
index = Math.max(index, customList.index + 1); index = Math.max(index, customList.index + 1);
}); }
state.customLists.push({ state.customLists.push({
id: VT.uuid(), id: uuid(),
index: index, index,
title: e.detail.title || '', title: e.detail.title || '',
}); });
dispatch({ customLists: state.customLists }); dispatch({ customLists: state.customLists });
}); });
el.addEventListener('saveList', function (e) { el.addEventListener('saveList', (e) => {
var list = state.customLists.find(function (l) { const list = state.customLists.find((l) => l.id === e.detail.list.id);
return l.id === e.detail.list.id;
});
if (list.title === e.detail.title) return; if (list.title === e.detail.title) return;
@ -103,49 +95,43 @@ VT.TodoStore = function (el) {
dispatch({ customLists: state.customLists }); dispatch({ customLists: state.customLists });
}); });
el.addEventListener('moveList', function (e) { el.addEventListener('moveList', (e) => {
var movedListIndex = state.customLists.findIndex(function (list) { const movedListIndex = state.customLists.findIndex(
return list.id === e.detail.list.id; (list) => list.id === e.detail.list.id
}); );
var movedList = state.customLists[movedListIndex]; const movedList = state.customLists[movedListIndex];
state.customLists.splice(movedListIndex, 1); state.customLists.splice(movedListIndex, 1);
state.customLists.sort(function (a, b) { state.customLists.sort((a, b) => a.index - b.index);
return a.index - b.index;
});
state.customLists.splice(e.detail.index, 0, movedList); state.customLists.splice(e.detail.index, 0, movedList);
state.customLists.forEach(function (item, index) { state.customLists.forEach((item, index) => {
item.index = index; item.index = index;
}); });
dispatch({ customLists: state.customLists }); dispatch({ customLists: state.customLists });
}); });
el.addEventListener('deleteList', function (e) { el.addEventListener('deleteList', (e) => {
dispatch({ dispatch({
customLists: state.customLists.filter(function (customList) { customLists: state.customLists.filter(
return customList.id !== e.detail.id; (customList) => customList.id !== e.detail.id
}), ),
}); });
}); });
el.addEventListener('seek', function (e) { el.addEventListener('seek', (e) => {
var t = new Date(state.at + ' 00:00:00'); const t = new Date(`${state.at} 00:00:00`);
t.setDate(t.getDate() + e.detail); t.setDate(t.getDate() + e.detail);
dispatch({ dispatch({ at: formatDateId(t) });
at: VT.formatDateId(t),
});
}); });
el.addEventListener('seekHome', function () { el.addEventListener('seekHome', () =>
dispatch({ dispatch({ at: formatDateId(new Date()) })
at: VT.formatDateId(new Date()), );
});
});
el.addEventListener('customSeek', function (e) { el.addEventListener('customSeek', (e) => {
dispatch({ dispatch({
customAt: Math.max( customAt: Math.max(
0, 0,
@ -156,7 +142,7 @@ VT.TodoStore = function (el) {
function dispatch(next) { function dispatch(next) {
Object.assign(state, next); Object.assign(state, next);
store(); save();
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('todoData', { new CustomEvent('todoData', {
@ -175,24 +161,21 @@ VT.TodoStore = function (el) {
try { try {
dispatch(JSON.parse(localStorage.todo)); dispatch(JSON.parse(localStorage.todo));
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console
console.warn(err); console.warn(err);
} }
} }
function store() { function save() {
clearTimeout(storeTimeout); clearTimeout(storeTimeout);
storeTimeout = setTimeout(function () { storeTimeout = setTimeout(() => {
try { try {
localStorage.todo = JSON.stringify(state); localStorage.todo = JSON.stringify(state);
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console
console.warn(err); console.warn(err);
} }
}, 100); }, 100);
} }
}
el.todoStore = {
dispatch: dispatch,
load: load,
};
};

View File

@ -1,54 +1,45 @@
/* global VT */ export function uuid() {
window.VT = window.VT || {}; return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0,
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; v = c == 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16); return v.toString(16);
}); });
}; }
VT.formatDateId = function (date) { export function formatDateId(date) {
var y = date.getFullYear(); const y = date.getFullYear();
var m = date.getMonth() + 1; const m = date.getMonth() + 1;
var d = date.getDate(); const d = date.getDate();
const ys = y.toString().padStart(4, '0');
const ms = m.toString().padStart(2, '0');
const ds = d.toString().padStart(2, '0');
return ( return `${ys}-${ms}-${ds}`;
y.toString().padStart(4, '0') + }
'-' +
m.toString().padStart(2, '0') +
'-' +
d.toString().padStart(2, '0')
);
};
VT.formatDate = function (date) { export function formatDate(date) {
return ( const m = formatMonth(date);
VT.formatMonth(date) + const d = formatDayOfMonth(date);
' ' + const y = date.getFullYear().toString().padStart(4, '0');
VT.formatDayOfMonth(date) + return `${m} ${d} ${y}`;
' ' + }
date.getFullYear().toString().padStart(4, '0')
);
};
VT.formatDayOfMonth = function (date) { export function formatDayOfMonth(date) {
var d = date.getDate(); const d = date.getDate();
var t = d % 10; const t = d % 10;
return d === 11 || d === 12 || d === 13 return d === 11 || d === 12 || d === 13
? d + 'th' ? `${d}th`
: t === 1 : t === 1
? d + 'st' ? `${d}st`
: t === 2 : t === 2
? d + 'nd' ? `${d}nd`
: t === 3 : t === 3
? d + 'rd' ? `${d}rd`
: d + 'th'; : `${d}th`;
}; }
VT.DAY_NAMES = [ export const DAY_NAMES = [
'Sunday', 'Sunday',
'Monday', 'Monday',
'Tuesday', 'Tuesday',
@ -58,11 +49,11 @@ VT.DAY_NAMES = [
'Saturday', 'Saturday',
]; ];
VT.formatDayOfWeek = function (date) { export function formatDayOfWeek(date) {
return VT.DAY_NAMES[date.getDay()]; return DAY_NAMES[date.getDay()];
}; }
VT.MONTH_NAMES = [ export const MONTH_NAMES = [
'January', 'January',
'February', 'February',
'March', 'March',
@ -77,6 +68,6 @@ VT.MONTH_NAMES = [
'December', 'December',
]; ];
VT.formatMonth = function (date) { export function formatMonth(date) {
return VT.MONTH_NAMES[date.getMonth()]; return MONTH_NAMES[date.getMonth()];
}; }

View File

@ -6,7 +6,7 @@
background: #eee; background: #eee;
} }
.app-collapsible > .bar > .app-button:active { .app-collapsible > .bar > .toggle:active {
background: #fff; background: #fff;
} }

View File

@ -9,7 +9,7 @@
margin: 0; margin: 0;
} }
.app-header > .app-fps { .app-header > .fps {
position: absolute; position: absolute;
top: 10px; top: 10px;
right: 20px; right: 20px;

View File

@ -8,7 +8,7 @@
width: 1em; width: 1em;
height: 1em; height: 1em;
vertical-align: bottom; vertical-align: bottom;
fill: currentColor; fill: currentcolor;
transition: transform 0.1s ease-out; transition: transform 0.1s ease-out;
} }

View File

@ -52,8 +52,8 @@
} }
.todo-custom-list.-dragging { .todo-custom-list.-dragging {
box-shadow: 10px 0 12px -14px rgba(0, 0, 0, 0.3), box-shadow: 10px 0 12px -14px rgba(0, 0, 0, 30%),
-10px 0 12px -14px rgba(0, 0, 0, 0.3); -10px 0 12px -14px rgba(0, 0, 0, 30%);
background: #fff; background: #fff;
opacity: 0.8; opacity: 0.8;
} }