├── .gitignore ├── .nvmrc ├── 9781484249666.jpg ├── Chapter01 ├── 1.1 - Framework's Way │ ├── next.config.js │ ├── package-lock.json │ ├── package.json │ ├── pages │ │ ├── index.js │ │ ├── pose.js │ │ └── wa.js │ └── style │ │ └── index.css ├── 1.2 - History of JavaScript Frameworks │ ├── AngularJS │ │ ├── index.html │ │ ├── package-lock.json │ │ └── package.json │ └── React │ │ ├── .cache │ │ ├── 13 │ │ │ └── 19a84cc6d2464384d27b502a466181.json │ │ ├── 33 │ │ │ └── 5e9e36af86e348a1db0324d7f78c25.json │ │ ├── 92 │ │ │ ├── 7654cd0d96b77e3dc0a7c856ede613.json │ │ │ └── e54c60050c02716a394c0e4f88e8c1.json │ │ ├── 3c │ │ │ └── 3fff5d3030f179d60cc9c028f402c0.json │ │ ├── 3f │ │ │ └── a8fb48c500e509bbc053a192bbc927.json │ │ ├── 7c │ │ │ └── 9a74dfe6df7f3f3aeb43f6cb2d8edf.json │ │ ├── a6 │ │ │ └── 4a5f2aa3c5f215a0b8f1c7338fe34b.json │ │ ├── bc │ │ │ └── 09318a31df2ea173748a477638d0c2.json │ │ ├── c2 │ │ │ └── 4ced6429cb018d0fb306f2becfb472.json │ │ ├── cf │ │ │ └── 72a19838dad6633b4f6ac93f07f6ca.json │ │ ├── d1 │ │ │ └── f44064282e138c6905a01ff0b659e5.json │ │ ├── de │ │ │ └── b626ccc37bf5b6a0fa5d7cdf5e7456.json │ │ └── fc │ │ │ └── 175a767589f045837dc5539f6c24e4.json │ │ ├── dist │ │ ├── React.e31bb0bc.js │ │ ├── React.e31bb0bc.map │ │ └── index.html │ │ ├── index.html │ │ ├── index.js │ │ ├── package-lock.json │ │ └── package.json └── README.md ├── Chapter02 ├── .babelrc ├── 01 │ ├── getTodos.js │ ├── index.html │ ├── index.js │ └── view.js ├── 02 │ ├── getTodos.js │ ├── index.html │ ├── index.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── counter.test.js │ │ ├── filters.js │ │ ├── filters.test.js │ │ ├── todos.js │ │ └── todos.test.js ├── 03 │ ├── getTodos.js │ ├── index.html │ ├── index.js │ ├── registry.js │ └── view │ │ ├── counter.js │ │ ├── counter.test.js │ │ ├── filters.js │ │ ├── filters.test.js │ │ ├── todos.js │ │ └── todos.test.js ├── 04 │ ├── getTodos.js │ ├── index.html │ ├── index.js │ ├── registry.js │ └── view │ │ ├── counter.js │ │ ├── counter.test.js │ │ ├── filters.js │ │ ├── filters.test.js │ │ ├── todos.js │ │ └── todos.test.js ├── 05 │ ├── applyDiff.js │ ├── getTodos.js │ ├── index.html │ ├── index.js │ ├── registry.js │ └── view │ │ ├── counter.js │ │ ├── counter.test.js │ │ ├── filters.js │ │ ├── filters.test.js │ │ ├── todos.js │ │ └── todos.test.js ├── README.md ├── favicon.ico ├── index.html ├── now.json ├── package-lock.json ├── package.json └── stats.js ├── Chapter03 ├── .babelrc ├── 00.1 │ ├── index.html │ └── index.js ├── 00.2 │ ├── index.html │ └── index.js ├── 00.3 │ ├── index.html │ └── index.js ├── 00.4 │ ├── index.html │ └── index.js ├── 00 │ ├── index.html │ └── index.js ├── 01.1 │ ├── applyDiff.js │ ├── getTodos.js │ ├── index.html │ ├── index.js │ ├── registry.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── counter.test.js │ │ ├── filters.js │ │ ├── filters.test.js │ │ ├── todos.js │ │ └── todos.test.js ├── 01.2 │ ├── applyDiff.js │ ├── index.html │ ├── index.js │ ├── registry.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── counter.test.js │ │ ├── filters.js │ │ ├── filters.test.js │ │ ├── todos.js │ │ └── todos.test.js ├── 01.3 │ ├── applyDiff.js │ ├── index.html │ ├── index.js │ ├── registry.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── counter.test.js │ │ ├── filters.js │ │ ├── filters.test.js │ │ ├── todos.js │ │ └── todos.test.js ├── 01.4 │ ├── applyDiff.js │ ├── index.html │ ├── index.js │ ├── registry.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── counter.test.js │ │ ├── filters.js │ │ ├── filters.test.js │ │ ├── todos.js │ │ └── todos.test.js ├── 01 │ ├── applyDiff.js │ ├── getTodos.js │ ├── index.html │ ├── index.js │ ├── registry.js │ └── view │ │ ├── counter.js │ │ ├── counter.test.js │ │ ├── filters.js │ │ ├── filters.test.js │ │ ├── todos.js │ │ └── todos.test.js ├── README.md ├── favicon.ico ├── index.html ├── now.json ├── package-lock.json └── package.json ├── Chapter04 ├── .babelrc ├── 00.1 │ ├── components │ │ └── HelloWorld.js │ ├── index.html │ └── index.js ├── 00.2 │ ├── components │ │ └── HelloWorld.js │ ├── index.html │ └── index.js ├── 00.3 │ ├── components │ │ ├── HelloWorld.js │ │ └── applyDiff.js │ ├── index.html │ └── index.js ├── 00.4 │ ├── components │ │ └── GitHubAvatar.js │ ├── index.html │ └── index.js ├── 00.5 │ ├── components │ │ └── GitHubAvatar.js │ ├── index.html │ └── index.js ├── 00 │ ├── components │ │ └── HelloWorld.js │ ├── index.html │ └── index.js ├── 01 │ ├── components │ │ ├── Application.js │ │ ├── Footer.js │ │ └── List.js │ ├── index.html │ └── index.js ├── README.md ├── favicon.ico ├── index.html ├── now.json ├── package-lock.json └── package.json ├── Chapter05 ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── 00 │ │ ├── http.js │ │ ├── index.html │ │ ├── index.js │ │ └── todos.js │ ├── 01 │ │ ├── http.js │ │ ├── index.html │ │ ├── index.js │ │ └── todos.js │ ├── 02 │ │ ├── http.js │ │ ├── index.html │ │ ├── index.js │ │ └── todos.js │ ├── favicon.ico │ └── index.html └── server.js ├── Chapter06 ├── 00.1 │ ├── index.html │ ├── index.js │ ├── pages.js │ └── router.js ├── 00.2 │ ├── index.html │ ├── index.js │ ├── pages.js │ └── router.js ├── 00 │ ├── index.html │ ├── index.js │ ├── pages.js │ └── router.js ├── 01.1 │ ├── index.html │ ├── index.js │ ├── pages.js │ └── router.js ├── 01 │ ├── index.html │ ├── index.js │ ├── pages.js │ └── router.js ├── 02 │ ├── index.html │ ├── index.js │ ├── pages.js │ └── router.js ├── README.md ├── favicon.ico ├── index.html ├── package-lock.json └── package.json ├── Chapter07 ├── .babelrc ├── 00 │ ├── applyDiff.js │ ├── index.html │ ├── index.js │ ├── model │ │ ├── model.js │ │ └── model.test.js │ ├── registry.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── filters.js │ │ └── todos.js ├── 01.1 │ ├── applyDiff.js │ ├── index.html │ ├── index.js │ ├── model │ │ └── state.js │ ├── registry.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── filters.js │ │ └── todos.js ├── 01 │ ├── applyDiff.js │ ├── index.html │ ├── index.js │ ├── model │ │ ├── model.js │ │ └── model.test.js │ ├── registry.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── filters.js │ │ └── todos.js ├── 02.1 │ ├── index.html │ └── index.js ├── 02.2 │ ├── applyDiff.js │ ├── index.html │ ├── index.js │ ├── model │ │ ├── model.js │ │ ├── observable.js │ │ └── observable.test.js │ ├── registry.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── filters.js │ │ └── todos.js ├── 02 │ ├── applyDiff.js │ ├── index.html │ ├── index.js │ ├── model │ │ ├── model.js │ │ ├── observable.js │ │ └── observable.test.js │ ├── registry.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── filters.js │ │ └── todos.js ├── 03.1 │ ├── applyDiff.js │ ├── index.html │ ├── index.js │ ├── model │ │ ├── eventBus.js │ │ ├── eventCreators.js │ │ ├── filter.js │ │ ├── model.js │ │ └── todos.js │ ├── registry.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── filters.js │ │ └── todos.js ├── 03 │ ├── applyDiff.js │ ├── index.html │ ├── index.js │ ├── model │ │ ├── eventBus.js │ │ ├── eventBus.test.js │ │ ├── eventCreators.js │ │ └── model.js │ ├── registry.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── filters.js │ │ └── todos.js ├── 04 │ ├── applyDiff.js │ ├── index.html │ ├── index.js │ ├── model │ │ ├── actionCreators.js │ │ └── reducer.js │ ├── registry.js │ └── view │ │ ├── app.js │ │ ├── counter.js │ │ ├── filters.js │ │ └── todos.js ├── 05.1 │ ├── components │ │ ├── Application.js │ │ ├── Footer.js │ │ └── List.js │ ├── index.html │ ├── index.js │ └── model │ │ ├── actions.js │ │ └── observable.js ├── 05 │ ├── components │ │ ├── Application.js │ │ ├── Footer.js │ │ └── List.js │ ├── index.html │ ├── index.js │ └── model │ │ ├── actions.js │ │ └── observable.js ├── README.md ├── favicon.ico ├── index.html ├── package-lock.json └── package.json ├── Chapter08 └── ADR-001.MD ├── Contributing.md ├── LICENSE ├── LICENSE.txt ├── README.md └── errata.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | .cache 63 | dist 64 | Chapter03/old 65 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v10.12.0 2 | -------------------------------------------------------------------------------- /9781484249666.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apress/frameworkless-front-end-development/747d119cd515f74d54cea67e92a31f45e0a803ea/9781484249666.jpg -------------------------------------------------------------------------------- /Chapter01/1.1 - Framework's Way/next.config.js: -------------------------------------------------------------------------------- 1 | const withCSS = require('@zeit/next-css') 2 | module.exports = withCSS() 3 | -------------------------------------------------------------------------------- /Chapter01/1.1 - Framework's Way/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dev": "next", 4 | "build": "next build", 5 | "start": "next start -p 8000", 6 | "deploy": "now --public && now alias" 7 | }, 8 | "dependencies": { 9 | "@zeit/next-css": "^1.0.1", 10 | "next": "^7.0.2", 11 | "react": "^16.6.0", 12 | "react-dom": "^16.6.0", 13 | "react-pose": "^4.0.1" 14 | }, 15 | "devDependencies": { 16 | "now": "^11.5.2", 17 | "standard": "^12.0.1" 18 | }, 19 | "now":{ 20 | "alias": "frameworkless-examples-react-animations" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Chapter01/1.1 - Framework's Way/pages/index.js: -------------------------------------------------------------------------------- 1 | import '../style/index.css' 2 | import Link from 'next/link' 3 | 4 | const Index = () => ( 5 |
6 |

React Animations Example

7 |

8 | 9 | Pose 10 | 11 |

12 |

13 | 14 | Web Animations API 15 | 16 |

17 |
18 | ) 19 | 20 | export default Index -------------------------------------------------------------------------------- /Chapter01/1.1 - Framework's Way/pages/pose.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import posed from 'react-pose' 3 | 4 | const Box = posed.div({ 5 | hidden: { opacity: 0 }, 6 | visible: { opacity: 1 }, 7 | transition: { 8 | ease: 'linear', 9 | duration: 500 10 | } 11 | }) 12 | 13 | class PoseExample extends Component { 14 | constructor (props) { 15 | super(props) 16 | this.state = { 17 | isVisible: true 18 | } 19 | 20 | this.toggle = this.toggle.bind(this) 21 | } 22 | 23 | toggle () { 24 | this.setState({ 25 | isVisible: !this.state.isVisible 26 | }) 27 | } 28 | 29 | render () { 30 | const { isVisible } = this.state 31 | const pose = isVisible ? 'visible' : 'hidden' 32 | return ( 33 |
34 | 35 | 36 |
37 | ) 38 | } 39 | } 40 | 41 | export default PoseExample 42 | -------------------------------------------------------------------------------- /Chapter01/1.1 - Framework's Way/pages/wa.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | const animationTiming = { 4 | duration: 500, 5 | ease: 'linear', 6 | fill: 'forwards' 7 | } 8 | 9 | const showKeyframes = [ 10 | { opacity: 0 }, 11 | { opacity: 1 } 12 | ] 13 | 14 | const hideKeyframes = [ 15 | ...showKeyframes 16 | ].reverse() 17 | class PosedExample extends Component { 18 | constructor (props) { 19 | super(props) 20 | this.state = { 21 | isVisible: true 22 | } 23 | 24 | this.toggle = this.toggle.bind(this) 25 | } 26 | 27 | toggle () { 28 | this.setState({ 29 | isVisible: !this.state.isVisible 30 | }) 31 | } 32 | 33 | componentDidUpdate (prevProps, prevState) { 34 | const { isVisible } = this.state 35 | if (prevState.isVisible !== isVisible) { 36 | const animation = isVisible ? showKeyframes : hideKeyframes 37 | this.div.animate(animation, animationTiming) 38 | } 39 | } 40 | render () { 41 | return ( 42 |
43 |
{ this.div = div }} className='box' /> 44 | 45 |
46 | ) 47 | } 48 | } 49 | 50 | export default PosedExample 51 | -------------------------------------------------------------------------------- /Chapter01/1.1 - Framework's Way/style/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | height: 100vh; 6 | width: 100%; 7 | padding: 0; 8 | margin: 0; 9 | } 10 | 11 | .box { 12 | width: 100px; 13 | height: 100px; 14 | background: red; 15 | transform-origin: 50% 50%; 16 | } 17 | 18 | button { 19 | width: 100%; 20 | margin-top: 50px; 21 | } -------------------------------------------------------------------------------- /Chapter01/1.2 - History of JavaScript Frameworks/AngularJS/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | AngularJS Example 5 | 6 | 7 | 8 | 9 |
10 | Value: 11 |

You entered: {{value}}

12 |
13 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Chapter01/1.2 - History of JavaScript Frameworks/AngularJS/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "http-server" 4 | }, 5 | "devDependencies": { 6 | "http-server": "^0.11.1" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Chapter01/1.2 - History of JavaScript Frameworks/React/.cache/92/e54c60050c02716a394c0e4f88e8c1.json: -------------------------------------------------------------------------------- 1 | {"id":"index.html","dependencies":[{"name":"./index.js","dynamic":true,"resolved":"/Users/strazz/dev/frameworkless-examples/Chapter01/1.2 - History of JavaScript Frameworks/React/index.js","parent":"/Users/strazz/dev/frameworkless-examples/Chapter01/1.2 - History of JavaScript Frameworks/React/index.html"}],"generated":{"html":"\n\n \n \n React \n \n\n
\n \n \n"},"hash":"cf02227ee807a891be5fbe30d0f9f084","cacheData":{}} -------------------------------------------------------------------------------- /Chapter01/1.2 - History of JavaScript Frameworks/React/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /Chapter01/1.2 - History of JavaScript Frameworks/React/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /Chapter01/1.2 - History of JavaScript Frameworks/React/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { render } from 'react-dom' 3 | 4 | class Timer extends Component { 5 | 6 | constructor(props){ 7 | super(props) 8 | this.state = { 9 | seconds: 0 10 | } 11 | } 12 | 13 | componentDidMount() { 14 | this.interval = setInterval(() => { 15 | const { seconds } = this.state 16 | this.setState({ 17 | seconds: seconds + 1 18 | }) 19 | },1000) 20 | } 21 | 22 | componentWillUnmount() { 23 | clearInterval(this.interval) 24 | } 25 | 26 | render(){ 27 | const { seconds } = this.state 28 | return ( 29 |
30 | Seconds Elapsed: {seconds} 31 |
32 | ) 33 | } 34 | } 35 | 36 | const mountNode = document.getElementById('app') 37 | 38 | render(, mountNode) 39 | -------------------------------------------------------------------------------- /Chapter01/1.2 - History of JavaScript Frameworks/React/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "parcel ./index.html", 4 | "build": "parcel build ./index.html" 5 | }, 6 | "dependencies": { 7 | "react": "^16.5.2", 8 | "react-dom": "^16.5.2", 9 | "react-pose": "^3.3.7" 10 | }, 11 | "devDependencies": { 12 | "babel-core": "^6.26.3", 13 | "babel-preset-env": "^1.7.0", 14 | "babel-preset-react": "^6.24.1", 15 | "parcel-bundler": "^1.10.3", 16 | "standard": "^12.0.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Chapter01/README.md: -------------------------------------------------------------------------------- 1 | # Chapter 1 - Let's Talk About Frameworks 2 | 3 | [![framework less](https://file-blyuofkggj.now.sh)](https://github.com/frameworkless-movement/manifesto) -------------------------------------------------------------------------------- /Chapter02/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": [ 5 | "transform-es2015-modules-commonjs" 6 | ] 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /Chapter02/01/getTodos.js: -------------------------------------------------------------------------------- 1 | const { faker } = window 2 | 3 | const createElement = () => ({ 4 | text: faker.random.words(2), 5 | completed: faker.random.boolean() 6 | }) 7 | 8 | const repeat = (elementFactory, number) => { 9 | const array = [] 10 | for (let index = 0; index < number; index++) { 11 | array.push(elementFactory()) 12 | } 13 | return array 14 | } 15 | 16 | export default () => { 17 | const howMany = faker.random.number(10) 18 | return repeat(createElement, howMany) 19 | } 20 | -------------------------------------------------------------------------------- /Chapter02/01/index.js: -------------------------------------------------------------------------------- 1 | import getTodos from './getTodos.js' 2 | import view from './view.js' 3 | 4 | const state = { 5 | todos: getTodos(), 6 | currentFilter: 'All' 7 | } 8 | 9 | const main = document.querySelector('.todoapp') 10 | 11 | window.requestAnimationFrame(() => { 12 | const newMain = view(main, state) 13 | main.replaceWith(newMain) 14 | }) 15 | -------------------------------------------------------------------------------- /Chapter02/01/view.js: -------------------------------------------------------------------------------- 1 | const getTodoElement = todo => { 2 | const { 3 | text, 4 | completed 5 | } = todo 6 | 7 | return ` 8 |
  • 9 |
    10 | 14 | 15 | 16 |
    17 | 18 |
  • ` 19 | } 20 | 21 | const getTodoCount = todos => { 22 | const notCompleted = todos 23 | .filter(todo => !todo.completed) 24 | 25 | const { length } = notCompleted 26 | if (length === 1) { 27 | return '1 Item left' 28 | } 29 | 30 | return `${length} Items left` 31 | } 32 | 33 | export default (targetElement, state) => { 34 | const { 35 | currentFilter, 36 | todos 37 | } = state 38 | 39 | const element = targetElement.cloneNode(true) 40 | 41 | const list = element.querySelector('.todo-list') 42 | const counter = element.querySelector('.todo-count') 43 | const filters = element.querySelector('.filters') 44 | 45 | list.innerHTML = todos.map(getTodoElement).join('') 46 | counter.textContent = getTodoCount(todos) 47 | 48 | Array 49 | .from(filters.querySelectorAll('li a')) 50 | .forEach(a => { 51 | if (a.textContent === currentFilter) { 52 | a.classList.add('selected') 53 | } else { 54 | a.classList.remove('selected') 55 | } 56 | }) 57 | 58 | return element 59 | } 60 | -------------------------------------------------------------------------------- /Chapter02/02/getTodos.js: -------------------------------------------------------------------------------- 1 | const { faker } = window 2 | 3 | const createElement = () => ({ 4 | text: faker.random.words(2), 5 | completed: faker.random.boolean() 6 | }) 7 | 8 | const repeat = (elementFactory, number) => { 9 | const array = [] 10 | for (let index = 0; index < number; index++) { 11 | array.push(elementFactory()) 12 | } 13 | return array 14 | } 15 | 16 | export default () => { 17 | const howMany = faker.random.number(10) 18 | return repeat(createElement, howMany) 19 | } 20 | -------------------------------------------------------------------------------- /Chapter02/02/index.js: -------------------------------------------------------------------------------- 1 | import getTodos from './getTodos.js' 2 | import appView from './view/app.js' 3 | 4 | const state = { 5 | todos: getTodos(), 6 | currentFilter: 'All' 7 | } 8 | 9 | const main = document.querySelector('.todoapp') 10 | 11 | window.requestAnimationFrame(() => { 12 | const newMain = appView(main, state) 13 | main.replaceWith(newMain) 14 | }) 15 | -------------------------------------------------------------------------------- /Chapter02/02/view/app.js: -------------------------------------------------------------------------------- 1 | import todosView from './todos.js' 2 | import counterView from './counter.js' 3 | import filtersView from './filters.js' 4 | 5 | export default (targetElement, state) => { 6 | const element = targetElement.cloneNode(true) 7 | 8 | const list = element 9 | .querySelector('.todo-list') 10 | const counter = element 11 | .querySelector('.todo-count') 12 | const filters = element 13 | .querySelector('.filters') 14 | 15 | list.replaceWith(todosView(list, state)) 16 | counter.replaceWith(counterView(counter, state)) 17 | filters.replaceWith(filtersView(filters, state)) 18 | 19 | return element 20 | } 21 | -------------------------------------------------------------------------------- /Chapter02/02/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter02/02/view/counter.test.js: -------------------------------------------------------------------------------- 1 | import counterView from './counter.js' 2 | 3 | let targetElement 4 | 5 | describe('counterView', () => { 6 | beforeEach(() => { 7 | targetElement = document.createElement('div') 8 | }) 9 | 10 | test('should put the number of not completed todo in a new DOM elements', () => { 11 | const newCounter = counterView(targetElement, { 12 | todos: [ 13 | { 14 | text: 'First', 15 | completed: true 16 | }, 17 | { 18 | text: 'Second', 19 | completed: false 20 | }, 21 | { 22 | text: 'Third', 23 | completed: false 24 | } 25 | ] 26 | }) 27 | expect(newCounter.textContent).toBe('2 Items left') 28 | }) 29 | 30 | test('should consider the singular form when only one item is left', () => { 31 | const newCounter = counterView(targetElement, { 32 | todos: [ 33 | { 34 | text: 'First', 35 | completed: true 36 | }, 37 | { 38 | text: 'Third', 39 | completed: false 40 | } 41 | ] 42 | }) 43 | expect(newCounter.textContent).toBe('1 Item left') 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /Chapter02/02/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }) => { 2 | const newCounter = targetElement.cloneNode(true) 3 | Array 4 | .from(newCounter.querySelectorAll('li a')) 5 | .forEach(a => { 6 | if (a.textContent === currentFilter) { 7 | a.classList.add('selected') 8 | } else { 9 | a.classList.remove('selected') 10 | } 11 | }) 12 | return newCounter 13 | } 14 | -------------------------------------------------------------------------------- /Chapter02/02/view/filters.test.js: -------------------------------------------------------------------------------- 1 | import filtersView from './filters.js' 2 | 3 | let targetElement 4 | const TEMPLATE = `` 15 | 16 | describe('filtersView', () => { 17 | beforeEach(() => { 18 | const tempElement = document.createElement('div') 19 | tempElement.innerHTML = TEMPLATE 20 | targetElement = tempElement.childNodes[0] 21 | }) 22 | 23 | test('should add the class "selected" to the anchor with the same text of the currentFilter', () => { 24 | const newCounter = filtersView(targetElement, { 25 | currentFilter: 'Active' 26 | }) 27 | 28 | const selectedItem = newCounter.querySelector('li a.selected') 29 | 30 | expect(selectedItem.textContent).toBe('Active') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /Chapter02/02/view/todos.js: -------------------------------------------------------------------------------- 1 | const getTodoElement = todo => { 2 | const { 3 | text, 4 | completed 5 | } = todo 6 | 7 | return ` 8 |
  • 9 |
    10 | 14 | 15 | 16 |
    17 | 18 |
  • ` 19 | } 20 | 21 | export default (targetElement, { todos }) => { 22 | const newTodoList = targetElement.cloneNode(true) 23 | const todosElements = todos 24 | .map(getTodoElement) 25 | .join('') 26 | newTodoList.innerHTML = todosElements 27 | return newTodoList 28 | } 29 | -------------------------------------------------------------------------------- /Chapter02/03/getTodos.js: -------------------------------------------------------------------------------- 1 | const { faker } = window 2 | 3 | const createElement = () => ({ 4 | text: faker.random.words(2), 5 | completed: faker.random.boolean() 6 | }) 7 | 8 | const repeat = (elementFactory, number) => { 9 | const array = [] 10 | for (let index = 0; index < number; index++) { 11 | array.push(elementFactory()) 12 | } 13 | return array 14 | } 15 | 16 | export default () => { 17 | const howMany = faker.random.number(10) 18 | return repeat(createElement, howMany) 19 | } 20 | -------------------------------------------------------------------------------- /Chapter02/03/index.js: -------------------------------------------------------------------------------- 1 | import getTodos from './getTodos.js' 2 | import todosView from './view/todos.js' 3 | import counterView from './view/counter.js' 4 | import filtersView from './view/filters.js' 5 | 6 | import registry from './registry.js' 7 | 8 | registry.add('todos', todosView) 9 | registry.add('counter', counterView) 10 | registry.add('filters', filtersView) 11 | 12 | const state = { 13 | todos: getTodos(), 14 | currentFilter: 'All' 15 | } 16 | 17 | window.requestAnimationFrame(() => { 18 | const main = document.querySelector('.todoapp') 19 | const newMain = registry.renderRoot(main, state) 20 | main.replaceWith(newMain) 21 | }) 22 | -------------------------------------------------------------------------------- /Chapter02/03/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state) => { 5 | const element = component(targetElement, state) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter02/03/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter02/03/view/counter.test.js: -------------------------------------------------------------------------------- 1 | import counterView from './counter.js' 2 | 3 | let targetElement 4 | 5 | describe('counterView', () => { 6 | beforeEach(() => { 7 | targetElement = document.createElement('div') 8 | }) 9 | 10 | test('should put the number of not completed todo in a new DOM elements', () => { 11 | const newCounter = counterView(targetElement, { 12 | todos: [ 13 | { 14 | text: 'First', 15 | completed: true 16 | }, 17 | { 18 | text: 'Second', 19 | completed: false 20 | }, 21 | { 22 | text: 'Third', 23 | completed: false 24 | } 25 | ] 26 | }) 27 | expect(newCounter.textContent).toBe('2 Items left') 28 | }) 29 | 30 | test('should consider the singular form when only one item is left', () => { 31 | const newCounter = counterView(targetElement, { 32 | todos: [ 33 | { 34 | text: 'First', 35 | completed: true 36 | }, 37 | { 38 | text: 'Third', 39 | completed: false 40 | } 41 | ] 42 | }) 43 | expect(newCounter.textContent).toBe('1 Item left') 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /Chapter02/03/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }) => { 2 | const newCounter = targetElement.cloneNode(true) 3 | Array 4 | .from(newCounter.querySelectorAll('li a')) 5 | .forEach(a => { 6 | if (a.textContent === currentFilter) { 7 | a.classList.add('selected') 8 | } else { 9 | a.classList.remove('selected') 10 | } 11 | }) 12 | return newCounter 13 | } 14 | -------------------------------------------------------------------------------- /Chapter02/03/view/filters.test.js: -------------------------------------------------------------------------------- 1 | import filtersView from './filters.js' 2 | 3 | let targetElement 4 | const TEMPLATE = `` 15 | 16 | describe('filtersView', () => { 17 | beforeEach(() => { 18 | const tempElement = document.createElement('div') 19 | tempElement.innerHTML = TEMPLATE 20 | targetElement = tempElement.childNodes[0] 21 | }) 22 | 23 | test('should add the class "selected" to the anchor with the same text of the currentFilter', () => { 24 | const newCounter = filtersView(targetElement, { 25 | currentFilter: 'Active' 26 | }) 27 | 28 | const selectedItem = newCounter.querySelector('li a.selected') 29 | 30 | expect(selectedItem.textContent).toBe('Active') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /Chapter02/03/view/todos.js: -------------------------------------------------------------------------------- 1 | const getTodoElement = todo => { 2 | const { 3 | text, 4 | completed 5 | } = todo 6 | 7 | return ` 8 |
  • 9 |
    10 | 14 | 15 | 16 |
    17 | 18 |
  • ` 19 | } 20 | 21 | export default (targetElement, { todos }) => { 22 | const newTodoList = targetElement.cloneNode(true) 23 | const todosElements = todos 24 | .map(getTodoElement) 25 | .join('') 26 | newTodoList.innerHTML = todosElements 27 | return newTodoList 28 | } 29 | -------------------------------------------------------------------------------- /Chapter02/04/getTodos.js: -------------------------------------------------------------------------------- 1 | const { faker } = window 2 | 3 | const createElement = () => ({ 4 | text: faker.random.words(2), 5 | completed: faker.random.boolean() 6 | }) 7 | 8 | const repeat = (elementFactory, number) => { 9 | const array = [] 10 | for (let index = 0; index < number; index++) { 11 | array.push(elementFactory()) 12 | } 13 | return array 14 | } 15 | 16 | export default () => { 17 | const howMany = faker.random.number(10) 18 | return repeat(createElement, howMany) 19 | } 20 | -------------------------------------------------------------------------------- /Chapter02/04/index.js: -------------------------------------------------------------------------------- 1 | import getTodos from './getTodos.js' 2 | import todosView from './view/todos.js' 3 | import counterView from './view/counter.js' 4 | import filtersView from './view/filters.js' 5 | 6 | import registry from './registry.js' 7 | 8 | registry.add('todos', todosView) 9 | registry.add('counter', counterView) 10 | registry.add('filters', filtersView) 11 | 12 | const state = { 13 | todos: getTodos(), 14 | currentFilter: 'All' 15 | } 16 | 17 | const render = () => { 18 | window.requestAnimationFrame(() => { 19 | const main = document.querySelector('.todoapp') 20 | const newMain = registry.renderRoot(main, state) 21 | main.replaceWith(newMain) 22 | }) 23 | } 24 | 25 | window.setInterval(() => { 26 | state.todos = getTodos() 27 | render() 28 | }, 5000) 29 | 30 | render() 31 | -------------------------------------------------------------------------------- /Chapter02/04/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state) => { 5 | const element = component(targetElement, state) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter02/04/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter02/04/view/counter.test.js: -------------------------------------------------------------------------------- 1 | import counterView from './counter.js' 2 | 3 | let targetElement 4 | 5 | describe('counterView', () => { 6 | beforeEach(() => { 7 | targetElement = document.createElement('div') 8 | }) 9 | 10 | test('should put the number of not completed todo in a new DOM elements', () => { 11 | const newCounter = counterView(targetElement, { 12 | todos: [ 13 | { 14 | text: 'First', 15 | completed: true 16 | }, 17 | { 18 | text: 'Second', 19 | completed: false 20 | }, 21 | { 22 | text: 'Third', 23 | completed: false 24 | } 25 | ] 26 | }) 27 | expect(newCounter.textContent).toBe('2 Items left') 28 | }) 29 | 30 | test('should consider the singular form when only one item is left', () => { 31 | const newCounter = counterView(targetElement, { 32 | todos: [ 33 | { 34 | text: 'First', 35 | completed: true 36 | }, 37 | { 38 | text: 'Third', 39 | completed: false 40 | } 41 | ] 42 | }) 43 | expect(newCounter.textContent).toBe('1 Item left') 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /Chapter02/04/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }) => { 2 | const newCounter = targetElement.cloneNode(true) 3 | Array 4 | .from(newCounter.querySelectorAll('li a')) 5 | .forEach(a => { 6 | if (a.textContent === currentFilter) { 7 | a.classList.add('selected') 8 | } else { 9 | a.classList.remove('selected') 10 | } 11 | }) 12 | return newCounter 13 | } 14 | -------------------------------------------------------------------------------- /Chapter02/04/view/filters.test.js: -------------------------------------------------------------------------------- 1 | import filtersView from './filters.js' 2 | 3 | let targetElement 4 | const TEMPLATE = `` 15 | 16 | describe('filtersView', () => { 17 | beforeEach(() => { 18 | const tempElement = document.createElement('div') 19 | tempElement.innerHTML = TEMPLATE 20 | targetElement = tempElement.childNodes[0] 21 | }) 22 | 23 | test('should add the class "selected" to the anchor with the same text of the currentFilter', () => { 24 | const newCounter = filtersView(targetElement, { 25 | currentFilter: 'Active' 26 | }) 27 | 28 | const selectedItem = newCounter.querySelector('li a.selected') 29 | 30 | expect(selectedItem.textContent).toBe('Active') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /Chapter02/04/view/todos.js: -------------------------------------------------------------------------------- 1 | const getTodoElement = todo => { 2 | const { 3 | text, 4 | completed 5 | } = todo 6 | 7 | return ` 8 |
  • 9 |
    10 | 14 | 15 | 16 |
    17 | 18 |
  • ` 19 | } 20 | 21 | export default (targetElement, { todos }) => { 22 | const newTodoList = targetElement.cloneNode(true) 23 | const todosElements = todos 24 | .map(getTodoElement) 25 | .join('') 26 | newTodoList.innerHTML = todosElements 27 | return newTodoList 28 | } 29 | -------------------------------------------------------------------------------- /Chapter02/05/applyDiff.js: -------------------------------------------------------------------------------- 1 | const isNodeChanged = (node1, node2) => { 2 | const n1Attributes = node1.attributes 3 | const n2Attributes = node2.attributes 4 | if (n1Attributes.length !== n2Attributes.length) { 5 | return true 6 | } 7 | 8 | const differentAttribute = Array 9 | .from(n1Attributes) 10 | .find(attribute => { 11 | const { name } = attribute 12 | const attribute1 = node1 13 | .getAttribute(name) 14 | const attribute2 = node2 15 | .getAttribute(name) 16 | 17 | return attribute1 !== attribute2 18 | }) 19 | 20 | if (differentAttribute) { 21 | return true 22 | } 23 | 24 | if (node1.children.length === 0 && 25 | node2.children.length === 0 && 26 | node1.textContent !== node2.textContent) { 27 | return true 28 | } 29 | 30 | return false 31 | } 32 | 33 | const applyDiff = ( 34 | parentNode, 35 | realNode, 36 | virtualNode) => { 37 | if (realNode && !virtualNode) { 38 | realNode.remove() 39 | return 40 | } 41 | 42 | if (!realNode && virtualNode) { 43 | parentNode.appendChild(virtualNode) 44 | return 45 | } 46 | 47 | if (isNodeChanged(virtualNode, realNode)) { 48 | realNode.replaceWith(virtualNode) 49 | return 50 | } 51 | 52 | const realChildren = Array.from(realNode.children) 53 | const virtualChildren = Array.from(virtualNode.children) 54 | 55 | const max = Math.max( 56 | realChildren.length, 57 | virtualChildren.length 58 | ) 59 | for (let i = 0; i < max; i++) { 60 | applyDiff( 61 | realNode, 62 | realChildren[i], 63 | virtualChildren[i] 64 | ) 65 | } 66 | } 67 | 68 | export default applyDiff 69 | -------------------------------------------------------------------------------- /Chapter02/05/getTodos.js: -------------------------------------------------------------------------------- 1 | const { faker } = window 2 | 3 | const createElement = () => ({ 4 | text: faker.random.words(2), 5 | completed: faker.random.boolean() 6 | }) 7 | 8 | const repeat = (elementFactory, number) => { 9 | const array = [] 10 | for (let index = 0; index < number; index++) { 11 | array.push(elementFactory()) 12 | } 13 | return array 14 | } 15 | 16 | export default () => { 17 | const howMany = faker.random.number(10) + 1 18 | return repeat(createElement, howMany) 19 | } 20 | -------------------------------------------------------------------------------- /Chapter02/05/index.js: -------------------------------------------------------------------------------- 1 | import getTodos from './getTodos.js' 2 | import todosView from './view/todos.js' 3 | import counterView from './view/counter.js' 4 | import filtersView from './view/filters.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | registry.add('todos', todosView) 10 | registry.add('counter', counterView) 11 | registry.add('filters', filtersView) 12 | 13 | const state = { 14 | todos: getTodos(), 15 | currentFilter: 'All' 16 | } 17 | 18 | const render = () => { 19 | window.requestAnimationFrame(() => { 20 | const main = document.querySelector('.todoapp') 21 | const newMain = registry.renderRoot(main, state) 22 | applyDiff(document.body, main, newMain) 23 | }) 24 | } 25 | 26 | window.setInterval(() => { 27 | state.todos = getTodos() 28 | render() 29 | }, 1000) 30 | 31 | render() 32 | -------------------------------------------------------------------------------- /Chapter02/05/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state) => { 5 | const element = component(targetElement, state) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter02/05/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter02/05/view/counter.test.js: -------------------------------------------------------------------------------- 1 | import counterView from './counter.js' 2 | 3 | let targetElement 4 | 5 | describe('counterView', () => { 6 | beforeEach(() => { 7 | targetElement = document.createElement('div') 8 | }) 9 | 10 | test('should put the number of not completed todo in a new DOM elements', () => { 11 | const newCounter = counterView(targetElement, { 12 | todos: [ 13 | { 14 | text: 'First', 15 | completed: true 16 | }, 17 | { 18 | text: 'Second', 19 | completed: false 20 | }, 21 | { 22 | text: 'Third', 23 | completed: false 24 | } 25 | ] 26 | }) 27 | expect(newCounter.textContent).toBe('2 Items left') 28 | }) 29 | 30 | test('should consider the singular form when only one item is left', () => { 31 | const newCounter = counterView(targetElement, { 32 | todos: [ 33 | { 34 | text: 'First', 35 | completed: true 36 | }, 37 | { 38 | text: 'Third', 39 | completed: false 40 | } 41 | ] 42 | }) 43 | expect(newCounter.textContent).toBe('1 Item left') 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /Chapter02/05/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }) => { 2 | const newCounter = targetElement.cloneNode(true) 3 | Array 4 | .from(newCounter.querySelectorAll('li a')) 5 | .forEach(a => { 6 | if (a.textContent === currentFilter) { 7 | a.classList.add('selected') 8 | } else { 9 | a.classList.remove('selected') 10 | } 11 | }) 12 | return newCounter 13 | } 14 | -------------------------------------------------------------------------------- /Chapter02/05/view/filters.test.js: -------------------------------------------------------------------------------- 1 | import filtersView from './filters.js' 2 | 3 | let targetElement 4 | const TEMPLATE = `` 15 | 16 | describe('filtersView', () => { 17 | beforeEach(() => { 18 | const tempElement = document.createElement('div') 19 | tempElement.innerHTML = TEMPLATE 20 | targetElement = tempElement.childNodes[0] 21 | }) 22 | 23 | test('should add the class "selected" to the anchor with the same text of the currentFilter', () => { 24 | const newCounter = filtersView(targetElement, { 25 | currentFilter: 'Active' 26 | }) 27 | 28 | const selectedItem = newCounter.querySelector('li a.selected') 29 | 30 | expect(selectedItem.textContent).toBe('Active') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /Chapter02/05/view/todos.js: -------------------------------------------------------------------------------- 1 | const getTodoElement = todo => { 2 | const { 3 | text, 4 | completed 5 | } = todo 6 | 7 | return ` 8 |
  • 9 |
    10 | 14 | 15 | 16 |
    17 | 18 |
  • ` 19 | } 20 | 21 | export default (targetElement, { todos }) => { 22 | const newTodoList = targetElement.cloneNode(true) 23 | const todosElements = todos 24 | .map(getTodoElement) 25 | .join('') 26 | newTodoList.innerHTML = todosElements 27 | return newTodoList 28 | } 29 | -------------------------------------------------------------------------------- /Chapter02/README.md: -------------------------------------------------------------------------------- 1 | # Chapter 2 - Rendering 2 | 3 | [![framework less](https://file-blyuofkggj.now.sh)](https://github.com/frameworkless-movement/manifesto) 4 | 5 | To start the examples just run: 6 | 7 | npm start -------------------------------------------------------------------------------- /Chapter02/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apress/frameworkless-front-end-development/747d119cd515f74d54cea67e92a31f45e0a803ea/Chapter02/favicon.ico -------------------------------------------------------------------------------- /Chapter02/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Frameworkless Frontend Development: Rendering 6 | 7 | 8 | 9 |

    Frameworkless Frontend Development: Rendering

    10 | 17 | 18 | -------------------------------------------------------------------------------- /Chapter02/now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "frameworkless-rendering", 4 | "alias": "frameworkless-rendering" 5 | } -------------------------------------------------------------------------------- /Chapter02/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "http-server", 4 | "deploy": "now && now alias", 5 | "test": "jest" 6 | }, 7 | "devDependencies": { 8 | "babel-plugin-transform-es2015-modules-commonjs": "6.26.2", 9 | "http-server": "0.11.1", 10 | "jest": "23.6.0", 11 | "now": "12.0.1", 12 | "standard": "12.0.1" 13 | }, 14 | "standard": { 15 | "globals": [ 16 | "expect", 17 | "test", 18 | "beforeEach", 19 | "describe" 20 | ] 21 | } 22 | } -------------------------------------------------------------------------------- /Chapter02/stats.js: -------------------------------------------------------------------------------- 1 | let panel 2 | let start 3 | let frames = 0 4 | 5 | const create = () => { 6 | const div = document.createElement('div') 7 | 8 | div.style.position = 'fixed' 9 | div.style.left = '0px' 10 | div.style.top = '0px' 11 | div.style.width = '50px' 12 | div.style.height = '50px' 13 | div.style.backgroundColor = 'black' 14 | div.style.color = 'white' 15 | 16 | return div 17 | } 18 | 19 | const tick = () => { 20 | frames++ 21 | const now = window.performance.now() 22 | if (now >= start + 1000) { 23 | panel.innerText = frames 24 | frames = 0 25 | start = now 26 | } 27 | window.requestAnimationFrame(tick) 28 | } 29 | 30 | const init = (parent = document.body) => { 31 | panel = create() 32 | 33 | window.requestAnimationFrame(() => { 34 | start = window.performance.now() 35 | parent.appendChild(panel) 36 | tick() 37 | }) 38 | } 39 | 40 | export default { 41 | init 42 | } 43 | -------------------------------------------------------------------------------- /Chapter03/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": [ 5 | "transform-es2015-modules-commonjs" 6 | ] 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /Chapter03/00.1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: DOM Events 7 | 8 | 15 | 16 | 17 | 18 |
    19 | This is a container 20 | 21 |
    22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Chapter03/00.1/index.js: -------------------------------------------------------------------------------- 1 | const button = document.querySelector('button') 2 | const div = document.querySelector('div') 3 | 4 | div.addEventListener('click', () => { 5 | console.log('Div Clicked') 6 | }, false) 7 | 8 | button.addEventListener('click', e => { 9 | console.log('Button Clicked') 10 | }, false) -------------------------------------------------------------------------------- /Chapter03/00.2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: DOM Events 7 | 8 | 15 | 16 | 17 | 18 |
    19 | This is a container 20 | 21 |
    22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Chapter03/00.2/index.js: -------------------------------------------------------------------------------- 1 | const button = document.querySelector('button') 2 | const div = document.querySelector('div') 3 | 4 | div.addEventListener('click', () => { 5 | console.log('Div Clicked') 6 | }, false) 7 | 8 | button.addEventListener('click', e => { 9 | e.stopPropagation() 10 | console.log('Button Clicked') 11 | }, false) -------------------------------------------------------------------------------- /Chapter03/00.3/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: DOM Events 7 | 8 | 15 | 16 | 17 | 18 |
    19 | This is a container 20 | 21 |
    22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Chapter03/00.3/index.js: -------------------------------------------------------------------------------- 1 | const button = document.querySelector('button') 2 | const div = document.querySelector('div') 3 | 4 | div.addEventListener('click', e => { 5 | console.log('Div Clicked') 6 | }, true) 7 | 8 | button.addEventListener('click', e => { 9 | console.log('Button Clicked') 10 | }, true) -------------------------------------------------------------------------------- /Chapter03/00.4/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: DOM Events 7 | 8 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Chapter03/00.4/index.js: -------------------------------------------------------------------------------- 1 | const EVENT_NAME = 'FiveCharInputValue' 2 | const input = document.querySelector('input') 3 | 4 | input.addEventListener('input', () => { 5 | const { length } = input.value 6 | console.log('input length', length) 7 | if (length === 5) { 8 | const time = (new Date()).getTime() 9 | const event = new CustomEvent(EVENT_NAME, { 10 | detail: { 11 | time 12 | } 13 | }) 14 | 15 | input.dispatchEvent(event) 16 | } 17 | }) 18 | 19 | input.addEventListener(EVENT_NAME, e => { 20 | console.log('handling custom event...', e.detail) 21 | }) 22 | -------------------------------------------------------------------------------- /Chapter03/00/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: DOM Events 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Chapter03/00/index.js: -------------------------------------------------------------------------------- 1 | let button = document.querySelector('#property') 2 | button.onclick = () => { 3 | console.log('Click managed using onclick property') 4 | } 5 | 6 | button = document.querySelector('#eventListener1') 7 | button.addEventListener('click', () => { 8 | console.log('Click managed using addEventListener') 9 | }) 10 | 11 | button = document.querySelector('#eventListener2') 12 | button.addEventListener('click', () => { 13 | console.log('First handler') 14 | }) 15 | button.addEventListener('click', () => { 16 | console.log('Second handler') 17 | }) 18 | 19 | button = document.querySelector('#eventListener3') 20 | const firstHandler = () => { 21 | console.log('First handler') 22 | } 23 | 24 | const secondHandler = () => { 25 | console.log('Second handler') 26 | } 27 | 28 | button.addEventListener('click', firstHandler) 29 | button.addEventListener('click', secondHandler) 30 | 31 | window.setTimeout(() => { 32 | const element = document.querySelector('#eventListener3') 33 | element.removeEventListener('click', firstHandler) 34 | element.removeEventListener('click', secondHandler) 35 | console.log('Removed Event Handlers') 36 | }, 1000) 37 | 38 | button = document.querySelector('#event') 39 | button.addEventListener('click', e => { 40 | console.log('event', e) 41 | }) 42 | 43 | -------------------------------------------------------------------------------- /Chapter03/01.1/applyDiff.js: -------------------------------------------------------------------------------- 1 | const isNodeChanged = (node1, node2) => { 2 | const n1Attributes = node1.attributes 3 | const n2Attributes = node2.attributes 4 | if (n1Attributes.length !== n2Attributes.length) { 5 | return true 6 | } 7 | 8 | const differentAttribute = Array 9 | .from(n1Attributes) 10 | .find(attribute => { 11 | const { name } = attribute 12 | const attribute1 = node1 13 | .getAttribute(name) 14 | const attribute2 = node2 15 | .getAttribute(name) 16 | 17 | return attribute1 !== attribute2 18 | }) 19 | 20 | if (differentAttribute) { 21 | return true 22 | } 23 | 24 | if (node1.children.length === 0 && 25 | node2.children.length === 0 && 26 | node1.textContent !== node2.textContent) { 27 | return true 28 | } 29 | 30 | return false 31 | } 32 | 33 | const applyDiff = ( 34 | parentNode, 35 | realNode, 36 | virtualNode) => { 37 | if (realNode && !virtualNode) { 38 | realNode.remove() 39 | return 40 | } 41 | 42 | if (!realNode && virtualNode) { 43 | parentNode.appendChild(virtualNode) 44 | return 45 | } 46 | 47 | if (isNodeChanged(virtualNode, realNode)) { 48 | realNode.replaceWith(virtualNode) 49 | return 50 | } 51 | 52 | const realChildren = Array.from(realNode.children) 53 | const virtualChildren = Array.from(virtualNode.children) 54 | 55 | const max = Math.max( 56 | realChildren.length, 57 | virtualChildren.length 58 | ) 59 | for (let i = 0; i < max; i++) { 60 | applyDiff( 61 | realNode, 62 | realChildren[i], 63 | virtualChildren[i] 64 | ) 65 | } 66 | } 67 | 68 | export default applyDiff 69 | -------------------------------------------------------------------------------- /Chapter03/01.1/getTodos.js: -------------------------------------------------------------------------------- 1 | const { faker } = window 2 | 3 | const createElement = () => ({ 4 | text: faker.random.words(2), 5 | completed: faker.random.boolean() 6 | }) 7 | 8 | const repeat = (elementFactory, number) => { 9 | const array = [] 10 | for (let index = 0; index < number; index++) { 11 | array.push(elementFactory()) 12 | } 13 | return array 14 | } 15 | 16 | export default () => { 17 | const howMany = faker.random.number(10) + 1 18 | return repeat(createElement, howMany) 19 | } 20 | -------------------------------------------------------------------------------- /Chapter03/01.1/index.js: -------------------------------------------------------------------------------- 1 | import getTodos from './getTodos.js' 2 | import todosView from './view/todos.js' 3 | import counterView from './view/counter.js' 4 | import filtersView from './view/filters.js' 5 | import appView from './view/app.js' 6 | import applyDiff from './applyDiff.js' 7 | 8 | import registry from './registry.js' 9 | 10 | registry.add('app', appView) 11 | registry.add('todos', todosView) 12 | registry.add('counter', counterView) 13 | registry.add('filters', filtersView) 14 | 15 | const state = { 16 | todos: getTodos(), 17 | currentFilter: 'All' 18 | } 19 | 20 | const render = () => { 21 | window.requestAnimationFrame(() => { 22 | const main = document.querySelector('#root') 23 | const newMain = registry.renderRoot(main, state) 24 | applyDiff(document.body, main, newMain) 25 | }) 26 | } 27 | 28 | render() 29 | -------------------------------------------------------------------------------- /Chapter03/01.1/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state) => { 5 | const element = component(targetElement, state) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter03/01.1/view/app.js: -------------------------------------------------------------------------------- 1 | let template 2 | 3 | const createAppElement = () => { 4 | if (!template) { 5 | template = document.getElementById('todo-app') 6 | } 7 | 8 | return template 9 | .content 10 | .firstElementChild 11 | .cloneNode(true) 12 | } 13 | 14 | export default (targetElement) => { 15 | const newApp = targetElement.cloneNode(true) 16 | newApp.innerHTML = '' 17 | newApp.appendChild(createAppElement()) 18 | return newApp 19 | } 20 | -------------------------------------------------------------------------------- /Chapter03/01.1/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter03/01.1/view/counter.test.js: -------------------------------------------------------------------------------- 1 | import counterView from './counter.js' 2 | 3 | let targetElement 4 | 5 | describe('counterView', () => { 6 | beforeEach(() => { 7 | targetElement = document.createElement('div') 8 | }) 9 | 10 | test('should put the number of not completed todo in a new DOM elements', () => { 11 | const newCounter = counterView(targetElement, { 12 | todos: [ 13 | { 14 | text: 'First', 15 | completed: true 16 | }, 17 | { 18 | text: 'Second', 19 | completed: false 20 | }, 21 | { 22 | text: 'Third', 23 | completed: false 24 | } 25 | ] 26 | }) 27 | expect(newCounter.textContent).toBe('2 Items left') 28 | }) 29 | 30 | test('should consider the singular form when only one item is left', () => { 31 | const newCounter = counterView(targetElement, { 32 | todos: [ 33 | { 34 | text: 'First', 35 | completed: true 36 | }, 37 | { 38 | text: 'Third', 39 | completed: false 40 | } 41 | ] 42 | }) 43 | expect(newCounter.textContent).toBe('1 Item left') 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /Chapter03/01.1/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }) => { 2 | const newCounter = targetElement.cloneNode(true) 3 | Array 4 | .from(newCounter.querySelectorAll('li a')) 5 | .forEach(a => { 6 | if (a.textContent === currentFilter) { 7 | a.classList.add('selected') 8 | } else { 9 | a.classList.remove('selected') 10 | } 11 | }) 12 | return newCounter 13 | } 14 | -------------------------------------------------------------------------------- /Chapter03/01.1/view/filters.test.js: -------------------------------------------------------------------------------- 1 | import filtersView from './filters.js' 2 | 3 | let targetElement 4 | const TEMPLATE = `` 15 | 16 | describe('filtersView', () => { 17 | beforeEach(() => { 18 | const tempElement = document.createElement('div') 19 | tempElement.innerHTML = TEMPLATE 20 | targetElement = tempElement.childNodes[0] 21 | }) 22 | 23 | test('should add the class "selected" to the anchor with the same text of the currentFilter', () => { 24 | const newCounter = filtersView(targetElement, { 25 | currentFilter: 'Active' 26 | }) 27 | 28 | const selectedItem = newCounter.querySelector('li a.selected') 29 | 30 | expect(selectedItem.textContent).toBe('Active') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /Chapter03/01.1/view/todos.js: -------------------------------------------------------------------------------- 1 | let template 2 | 3 | const createNewTodoNode = () => { 4 | if (!template) { 5 | template = document.getElementById('todo-item') 6 | } 7 | 8 | return template 9 | .content 10 | .firstElementChild 11 | .cloneNode(true) 12 | } 13 | 14 | const getTodoElement = todo => { 15 | const { 16 | text, 17 | completed 18 | } = todo 19 | 20 | const element = createNewTodoNode() 21 | 22 | element.querySelector('input.edit').value = text 23 | element.querySelector('label').textContent = text 24 | 25 | if (completed) { 26 | element 27 | .classList 28 | .add('completed') 29 | 30 | element 31 | .querySelector('input.toggle') 32 | .checked = true 33 | } 34 | 35 | return element 36 | } 37 | 38 | export default (targetElement, { todos }) => { 39 | const newTodoList = targetElement.cloneNode(true) 40 | 41 | newTodoList.innerHTML = '' 42 | 43 | todos 44 | .map(getTodoElement) 45 | .forEach(element => { 46 | newTodoList.appendChild(element) 47 | }) 48 | 49 | return newTodoList 50 | } 51 | -------------------------------------------------------------------------------- /Chapter03/01.2/applyDiff.js: -------------------------------------------------------------------------------- 1 | const isNodeChanged = (node1, node2) => { 2 | const n1Attributes = node1.attributes 3 | const n2Attributes = node2.attributes 4 | if (n1Attributes.length !== n2Attributes.length) { 5 | return true 6 | } 7 | 8 | const differentAttribute = Array 9 | .from(n1Attributes) 10 | .find(attribute => { 11 | const { name } = attribute 12 | const attribute1 = node1 13 | .getAttribute(name) 14 | const attribute2 = node2 15 | .getAttribute(name) 16 | 17 | return attribute1 !== attribute2 18 | }) 19 | 20 | if (differentAttribute) { 21 | return true 22 | } 23 | 24 | if (node1.children.length === 0 && 25 | node2.children.length === 0 && 26 | node1.textContent !== node2.textContent) { 27 | return true 28 | } 29 | 30 | return false 31 | } 32 | 33 | const applyDiff = ( 34 | parentNode, 35 | realNode, 36 | virtualNode) => { 37 | if (realNode && !virtualNode) { 38 | realNode.remove() 39 | return 40 | } 41 | 42 | if (!realNode && virtualNode) { 43 | parentNode.appendChild(virtualNode) 44 | return 45 | } 46 | 47 | if (isNodeChanged(virtualNode, realNode)) { 48 | realNode.replaceWith(virtualNode) 49 | return 50 | } 51 | 52 | const realChildren = Array.from(realNode.children) 53 | const virtualChildren = Array.from(virtualNode.children) 54 | 55 | const max = Math.max( 56 | realChildren.length, 57 | virtualChildren.length 58 | ) 59 | for (let i = 0; i < max; i++) { 60 | applyDiff( 61 | realNode, 62 | realChildren[i], 63 | virtualChildren[i] 64 | ) 65 | } 66 | } 67 | 68 | export default applyDiff 69 | -------------------------------------------------------------------------------- /Chapter03/01.2/index.js: -------------------------------------------------------------------------------- 1 | import todosView from './view/todos.js' 2 | import counterView from './view/counter.js' 3 | import filtersView from './view/filters.js' 4 | import appView from './view/app.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | registry.add('app', appView) 10 | registry.add('todos', todosView) 11 | registry.add('counter', counterView) 12 | registry.add('filters', filtersView) 13 | 14 | const state = { 15 | todos: [], 16 | currentFilter: 'All' 17 | } 18 | 19 | const events = { 20 | deleteItem: (index) => { 21 | state.todos.splice(index, 1) 22 | render() 23 | }, 24 | addItem: text => { 25 | state.todos.push({ 26 | text, 27 | completed: false 28 | }) 29 | render() 30 | } 31 | } 32 | 33 | const render = () => { 34 | window.requestAnimationFrame(() => { 35 | const main = document.querySelector('#root') 36 | 37 | const newMain = registry.renderRoot( 38 | main, 39 | state, 40 | events) 41 | 42 | applyDiff(document.body, main, newMain) 43 | }) 44 | } 45 | 46 | render() 47 | -------------------------------------------------------------------------------- /Chapter03/01.2/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state, events) => { 5 | const element = component(targetElement, state, events) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state, events)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state, events) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state, events) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter03/01.2/view/app.js: -------------------------------------------------------------------------------- 1 | let template 2 | 3 | const getTemplate = () => { 4 | if (!template) { 5 | template = document.getElementById('todo-app') 6 | } 7 | 8 | return template 9 | .content 10 | .firstElementChild 11 | .cloneNode(true) 12 | } 13 | 14 | const addEvents = (targetElement, events) => { 15 | targetElement 16 | .querySelector('.new-todo') 17 | .addEventListener('keypress', e => { 18 | if (e.key === 'Enter') { 19 | events.addItem(e.target.value) 20 | e.target.value = '' 21 | } 22 | }) 23 | } 24 | 25 | export default (targetElement, state, events) => { 26 | const newApp = targetElement.cloneNode(true) 27 | 28 | newApp.innerHTML = '' 29 | newApp.appendChild(getTemplate()) 30 | 31 | addEvents(newApp, events) 32 | 33 | return newApp 34 | } 35 | -------------------------------------------------------------------------------- /Chapter03/01.2/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter03/01.2/view/counter.test.js: -------------------------------------------------------------------------------- 1 | import counterView from './counter.js' 2 | 3 | let targetElement 4 | 5 | describe('counterView', () => { 6 | beforeEach(() => { 7 | targetElement = document.createElement('div') 8 | }) 9 | 10 | test('should put the number of not completed todo in a new DOM elements', () => { 11 | const newCounter = counterView(targetElement, { 12 | todos: [ 13 | { 14 | text: 'First', 15 | completed: true 16 | }, 17 | { 18 | text: 'Second', 19 | completed: false 20 | }, 21 | { 22 | text: 'Third', 23 | completed: false 24 | } 25 | ] 26 | }) 27 | expect(newCounter.textContent).toBe('2 Items left') 28 | }) 29 | 30 | test('should consider the singular form when only one item is left', () => { 31 | const newCounter = counterView(targetElement, { 32 | todos: [ 33 | { 34 | text: 'First', 35 | completed: true 36 | }, 37 | { 38 | text: 'Third', 39 | completed: false 40 | } 41 | ] 42 | }) 43 | expect(newCounter.textContent).toBe('1 Item left') 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /Chapter03/01.2/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }) => { 2 | const newCounter = targetElement.cloneNode(true) 3 | Array 4 | .from(newCounter.querySelectorAll('li a')) 5 | .forEach(a => { 6 | if (a.textContent === currentFilter) { 7 | a.classList.add('selected') 8 | } else { 9 | a.classList.remove('selected') 10 | } 11 | }) 12 | return newCounter 13 | } 14 | -------------------------------------------------------------------------------- /Chapter03/01.2/view/filters.test.js: -------------------------------------------------------------------------------- 1 | import filtersView from './filters.js' 2 | 3 | let targetElement 4 | const TEMPLATE = `` 15 | 16 | describe('filtersView', () => { 17 | beforeEach(() => { 18 | const tempElement = document.createElement('div') 19 | tempElement.innerHTML = TEMPLATE 20 | targetElement = tempElement.childNodes[0] 21 | }) 22 | 23 | test('should add the class "selected" to the anchor with the same text of the currentFilter', () => { 24 | const newCounter = filtersView(targetElement, { 25 | currentFilter: 'Active' 26 | }) 27 | 28 | const selectedItem = newCounter.querySelector('li a.selected') 29 | 30 | expect(selectedItem.textContent).toBe('Active') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /Chapter03/01.2/view/todos.js: -------------------------------------------------------------------------------- 1 | let template 2 | 3 | const createNewTodoNode = () => { 4 | if (!template) { 5 | template = document.getElementById('todo-item') 6 | } 7 | 8 | return template 9 | .content 10 | .firstElementChild 11 | .cloneNode(true) 12 | } 13 | 14 | const getTodoElement = (todo, index, events) => { 15 | const { 16 | text, 17 | completed 18 | } = todo 19 | 20 | const element = createNewTodoNode() 21 | 22 | element.querySelector('input.edit').value = text 23 | element.querySelector('label').textContent = text 24 | 25 | if (completed) { 26 | element.classList.add('completed') 27 | element 28 | .querySelector('input.toggle') 29 | .checked = true 30 | } 31 | 32 | const handler = e => events.deleteItem(index) 33 | 34 | element 35 | .querySelector('button.destroy') 36 | .addEventListener('click', handler) 37 | 38 | return element 39 | } 40 | 41 | export default (targetElement, { todos }, events) => { 42 | const newTodoList = targetElement.cloneNode(true) 43 | 44 | newTodoList.innerHTML = '' 45 | 46 | todos 47 | .map((todo, index) => getTodoElement(todo, index, events)) 48 | .forEach(element => { 49 | newTodoList.appendChild(element) 50 | }) 51 | 52 | return newTodoList 53 | } 54 | -------------------------------------------------------------------------------- /Chapter03/01.3/index.js: -------------------------------------------------------------------------------- 1 | import todosView from './view/todos.js' 2 | import counterView from './view/counter.js' 3 | import filtersView from './view/filters.js' 4 | import appView from './view/app.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | registry.add('app', appView) 10 | registry.add('todos', todosView) 11 | registry.add('counter', counterView) 12 | registry.add('filters', filtersView) 13 | 14 | const state = { 15 | todos: [], 16 | currentFilter: 'All' 17 | } 18 | 19 | const events = { 20 | addItem: text => { 21 | state.todos.push({ 22 | text, 23 | completed: false 24 | }) 25 | render() 26 | }, 27 | updateItem: (index, text) => { 28 | state.todos[index].text = text 29 | render() 30 | }, 31 | deleteItem: (index) => { 32 | state.todos.splice(index, 1) 33 | render() 34 | }, 35 | toggleItemCompleted: (index) => { 36 | const { 37 | completed 38 | } = state.todos[index] 39 | state.todos[index].completed = !completed 40 | render() 41 | }, 42 | completeAll: () => { 43 | state.todos.forEach(t => { 44 | t.completed = true 45 | }) 46 | render() 47 | }, 48 | clearCompleted: () => { 49 | state.todos = state.todos.filter( 50 | t => !t.completed 51 | ) 52 | render() 53 | }, 54 | changeFilter: filter => { 55 | state.currentFilter = filter 56 | render() 57 | } 58 | } 59 | 60 | const render = () => { 61 | window.requestAnimationFrame(() => { 62 | const main = document.querySelector('#root') 63 | 64 | const newMain = registry.renderRoot( 65 | main, 66 | state, 67 | events) 68 | 69 | applyDiff(document.body, main, newMain) 70 | }) 71 | } 72 | 73 | render() 74 | -------------------------------------------------------------------------------- /Chapter03/01.3/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state, events) => { 5 | const element = component(targetElement, state, events) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state, events)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state, events) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state, events) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter03/01.3/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter03/01.3/view/counter.test.js: -------------------------------------------------------------------------------- 1 | import counterView from './counter.js' 2 | 3 | let targetElement 4 | 5 | describe('counterView', () => { 6 | beforeEach(() => { 7 | targetElement = document.createElement('div') 8 | }) 9 | 10 | test('should put the number of not completed todo in a new DOM elements', () => { 11 | const newCounter = counterView(targetElement, { 12 | todos: [ 13 | { 14 | text: 'First', 15 | completed: true 16 | }, 17 | { 18 | text: 'Second', 19 | completed: false 20 | }, 21 | { 22 | text: 'Third', 23 | completed: false 24 | } 25 | ] 26 | }) 27 | expect(newCounter.textContent).toBe('2 Items left') 28 | }) 29 | 30 | test('should consider the singular form when only one item is left', () => { 31 | const newCounter = counterView(targetElement, { 32 | todos: [ 33 | { 34 | text: 'First', 35 | completed: true 36 | }, 37 | { 38 | text: 'Third', 39 | completed: false 40 | } 41 | ] 42 | }) 43 | expect(newCounter.textContent).toBe('1 Item left') 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /Chapter03/01.3/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }, { changeFilter }) => { 2 | const newFilters = targetElement.cloneNode(true) 3 | 4 | Array 5 | .from(newFilters.querySelectorAll('li a')) 6 | .forEach(a => { 7 | if (a.textContent === currentFilter) { 8 | a.classList.add('selected') 9 | } else { 10 | a.classList.remove('selected') 11 | } 12 | 13 | a.addEventListener('click', e => { 14 | e.preventDefault() 15 | changeFilter(a.textContent) 16 | }) 17 | }) 18 | 19 | return newFilters 20 | } 21 | -------------------------------------------------------------------------------- /Chapter03/01.3/view/filters.test.js: -------------------------------------------------------------------------------- 1 | import filtersView from './filters.js' 2 | 3 | let targetElement 4 | const TEMPLATE = `` 15 | 16 | describe('filtersView', () => { 17 | beforeEach(() => { 18 | const tempElement = document.createElement('div') 19 | tempElement.innerHTML = TEMPLATE 20 | targetElement = tempElement.childNodes[0] 21 | }) 22 | 23 | test('should add the class "selected" to the anchor with the same text of the currentFilter', () => { 24 | const newCounter = filtersView(targetElement, { 25 | currentFilter: 'Active' 26 | }) 27 | 28 | const selectedItem = newCounter.querySelector('li a.selected') 29 | 30 | expect(selectedItem.textContent).toBe('Active') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /Chapter03/01.4/applyDiff.js: -------------------------------------------------------------------------------- 1 | const isNodeChanged = (node1, node2) => { 2 | const n1Attributes = node1.attributes 3 | const n2Attributes = node2.attributes 4 | if (n1Attributes.length !== n2Attributes.length) { 5 | return true 6 | } 7 | 8 | const differentAttribute = Array 9 | .from(n1Attributes) 10 | .find(attribute => { 11 | const { name } = attribute 12 | const attribute1 = node1 13 | .getAttribute(name) 14 | const attribute2 = node2 15 | .getAttribute(name) 16 | 17 | return attribute1 !== attribute2 18 | }) 19 | 20 | if (differentAttribute) { 21 | return true 22 | } 23 | 24 | if (node1.children.length === 0 && 25 | node2.children.length === 0 && 26 | node1.textContent !== node2.textContent) { 27 | return true 28 | } 29 | 30 | return false 31 | } 32 | 33 | const applyDiff = ( 34 | parentNode, 35 | realNode, 36 | virtualNode) => { 37 | if (realNode && !virtualNode) { 38 | realNode.remove() 39 | return 40 | } 41 | 42 | if (!realNode && virtualNode) { 43 | parentNode.appendChild(virtualNode) 44 | return 45 | } 46 | 47 | if (isNodeChanged(virtualNode, realNode)) { 48 | realNode.replaceWith(virtualNode) 49 | return 50 | } 51 | 52 | const realChildren = Array.from(realNode.children) 53 | const virtualChildren = Array.from(virtualNode.children) 54 | 55 | const max = Math.max( 56 | realChildren.length, 57 | virtualChildren.length 58 | ) 59 | for (let i = 0; i < max; i++) { 60 | applyDiff( 61 | realNode, 62 | realChildren[i], 63 | virtualChildren[i] 64 | ) 65 | } 66 | } 67 | 68 | export default applyDiff 69 | -------------------------------------------------------------------------------- /Chapter03/01.4/index.js: -------------------------------------------------------------------------------- 1 | import todosView from './view/todos.js' 2 | import counterView from './view/counter.js' 3 | import filtersView from './view/filters.js' 4 | import appView from './view/app.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | registry.add('app', appView) 10 | registry.add('todos', todosView) 11 | registry.add('counter', counterView) 12 | registry.add('filters', filtersView) 13 | 14 | const state = { 15 | todos: [], 16 | currentFilter: 'All' 17 | } 18 | 19 | const events = { 20 | deleteItem: (index) => { 21 | state.todos.splice(index, 1) 22 | render() 23 | }, 24 | addItem: text => { 25 | state.todos.push({ 26 | text, 27 | completed: false 28 | }) 29 | render() 30 | } 31 | } 32 | 33 | const render = () => { 34 | window.requestAnimationFrame(() => { 35 | const main = document.querySelector('#root') 36 | 37 | const newMain = registry.renderRoot( 38 | main, 39 | state, 40 | events) 41 | 42 | applyDiff(document.body, main, newMain) 43 | }) 44 | } 45 | 46 | render() 47 | -------------------------------------------------------------------------------- /Chapter03/01.4/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state, events) => { 5 | const element = component(targetElement, state, events) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state, events)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state, events) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state, events) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter03/01.4/view/app.js: -------------------------------------------------------------------------------- 1 | let template 2 | 3 | const getTemplate = () => { 4 | if (!template) { 5 | template = document.getElementById('todo-app') 6 | } 7 | 8 | return template 9 | .content 10 | .firstElementChild 11 | .cloneNode(true) 12 | } 13 | 14 | const addEvents = (targetElement, events) => { 15 | targetElement 16 | .querySelector('.new-todo') 17 | .addEventListener('keypress', e => { 18 | if (e.key === 'Enter') { 19 | events.addItem(e.target.value) 20 | e.target.value = '' 21 | } 22 | }) 23 | } 24 | 25 | export default (targetElement, state, events) => { 26 | const newApp = targetElement.cloneNode(true) 27 | 28 | newApp.innerHTML = '' 29 | newApp.appendChild(getTemplate()) 30 | 31 | addEvents(newApp, events) 32 | 33 | return newApp 34 | } 35 | -------------------------------------------------------------------------------- /Chapter03/01.4/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter03/01.4/view/counter.test.js: -------------------------------------------------------------------------------- 1 | import counterView from './counter.js' 2 | 3 | let targetElement 4 | 5 | describe('counterView', () => { 6 | beforeEach(() => { 7 | targetElement = document.createElement('div') 8 | }) 9 | 10 | test('should put the number of not completed todo in a new DOM elements', () => { 11 | const newCounter = counterView(targetElement, { 12 | todos: [ 13 | { 14 | text: 'First', 15 | completed: true 16 | }, 17 | { 18 | text: 'Second', 19 | completed: false 20 | }, 21 | { 22 | text: 'Third', 23 | completed: false 24 | } 25 | ] 26 | }) 27 | expect(newCounter.textContent).toBe('2 Items left') 28 | }) 29 | 30 | test('should consider the singular form when only one item is left', () => { 31 | const newCounter = counterView(targetElement, { 32 | todos: [ 33 | { 34 | text: 'First', 35 | completed: true 36 | }, 37 | { 38 | text: 'Third', 39 | completed: false 40 | } 41 | ] 42 | }) 43 | expect(newCounter.textContent).toBe('1 Item left') 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /Chapter03/01.4/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }) => { 2 | const newCounter = targetElement.cloneNode(true) 3 | Array 4 | .from(newCounter.querySelectorAll('li a')) 5 | .forEach(a => { 6 | if (a.textContent === currentFilter) { 7 | a.classList.add('selected') 8 | } else { 9 | a.classList.remove('selected') 10 | } 11 | }) 12 | return newCounter 13 | } 14 | -------------------------------------------------------------------------------- /Chapter03/01.4/view/filters.test.js: -------------------------------------------------------------------------------- 1 | import filtersView from './filters.js' 2 | 3 | let targetElement 4 | const TEMPLATE = `` 15 | 16 | describe('filtersView', () => { 17 | beforeEach(() => { 18 | const tempElement = document.createElement('div') 19 | tempElement.innerHTML = TEMPLATE 20 | targetElement = tempElement.childNodes[0] 21 | }) 22 | 23 | test('should add the class "selected" to the anchor with the same text of the currentFilter', () => { 24 | const newCounter = filtersView(targetElement, { 25 | currentFilter: 'Active' 26 | }) 27 | 28 | const selectedItem = newCounter.querySelector('li a.selected') 29 | 30 | expect(selectedItem.textContent).toBe('Active') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /Chapter03/01.4/view/todos.js: -------------------------------------------------------------------------------- 1 | let template 2 | 3 | const createNewTodoNode = () => { 4 | if (!template) { 5 | template = document.getElementById('todo-item') 6 | } 7 | 8 | return template 9 | .content 10 | .firstElementChild 11 | .cloneNode(true) 12 | } 13 | 14 | const getTodoElement = (todo, index) => { 15 | const { 16 | text, 17 | completed 18 | } = todo 19 | 20 | const element = createNewTodoNode() 21 | 22 | element.querySelector('input.edit').value = text 23 | element.querySelector('label').textContent = text 24 | 25 | if (completed) { 26 | element.classList.add('completed') 27 | element 28 | .querySelector('input.toggle') 29 | .checked = true 30 | } 31 | 32 | element 33 | .querySelector('button.destroy') 34 | .dataset 35 | .index = index 36 | 37 | return element 38 | } 39 | 40 | export default (targetElement, state, events) => { 41 | const { todos } = state 42 | const { deleteItem } = events 43 | const newTodoList = targetElement.cloneNode(true) 44 | 45 | newTodoList.innerHTML = '' 46 | 47 | todos 48 | .map((todo, index) => getTodoElement(todo, index)) 49 | .forEach(element => { 50 | newTodoList.appendChild(element) 51 | }) 52 | 53 | newTodoList.addEventListener('click', e => { 54 | if (e.target.matches('button.destroy')) { 55 | deleteItem(e.target.dataset.index) 56 | } 57 | }) 58 | 59 | return newTodoList 60 | } 61 | -------------------------------------------------------------------------------- /Chapter03/01/applyDiff.js: -------------------------------------------------------------------------------- 1 | const isNodeChanged = (node1, node2) => { 2 | const n1Attributes = node1.attributes 3 | const n2Attributes = node2.attributes 4 | if (n1Attributes.length !== n2Attributes.length) { 5 | return true 6 | } 7 | 8 | const differentAttribute = Array 9 | .from(n1Attributes) 10 | .find(attribute => { 11 | const { name } = attribute 12 | const attribute1 = node1 13 | .getAttribute(name) 14 | const attribute2 = node2 15 | .getAttribute(name) 16 | 17 | return attribute1 !== attribute2 18 | }) 19 | 20 | if (differentAttribute) { 21 | return true 22 | } 23 | 24 | if (node1.children.length === 0 && 25 | node2.children.length === 0 && 26 | node1.textContent !== node2.textContent) { 27 | return true 28 | } 29 | 30 | return false 31 | } 32 | 33 | const applyDiff = ( 34 | parentNode, 35 | realNode, 36 | virtualNode) => { 37 | if (realNode && !virtualNode) { 38 | realNode.remove() 39 | return 40 | } 41 | 42 | if (!realNode && virtualNode) { 43 | parentNode.appendChild(virtualNode) 44 | return 45 | } 46 | 47 | if (isNodeChanged(virtualNode, realNode)) { 48 | realNode.replaceWith(virtualNode) 49 | return 50 | } 51 | 52 | const realChildren = Array.from(realNode.children) 53 | const virtualChildren = Array.from(virtualNode.children) 54 | 55 | const max = Math.max( 56 | realChildren.length, 57 | virtualChildren.length 58 | ) 59 | for (let i = 0; i < max; i++) { 60 | applyDiff( 61 | realNode, 62 | realChildren[i], 63 | virtualChildren[i] 64 | ) 65 | } 66 | } 67 | 68 | export default applyDiff 69 | -------------------------------------------------------------------------------- /Chapter03/01/getTodos.js: -------------------------------------------------------------------------------- 1 | const { faker } = window 2 | 3 | const createElement = () => ({ 4 | text: faker.random.words(2), 5 | completed: faker.random.boolean() 6 | }) 7 | 8 | const repeat = (elementFactory, number) => { 9 | const array = [] 10 | for (let index = 0; index < number; index++) { 11 | array.push(elementFactory()) 12 | } 13 | return array 14 | } 15 | 16 | export default () => { 17 | const howMany = faker.random.number(10) + 1 18 | return repeat(createElement, howMany) 19 | } 20 | -------------------------------------------------------------------------------- /Chapter03/01/index.js: -------------------------------------------------------------------------------- 1 | import getTodos from './getTodos.js' 2 | import todosView from './view/todos.js' 3 | import counterView from './view/counter.js' 4 | import filtersView from './view/filters.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | registry.add('todos', todosView) 10 | registry.add('counter', counterView) 11 | registry.add('filters', filtersView) 12 | 13 | const state = { 14 | todos: getTodos(), 15 | currentFilter: 'All' 16 | } 17 | 18 | const render = () => { 19 | window.requestAnimationFrame(() => { 20 | const main = document.querySelector('.todoapp') 21 | const newMain = registry.renderRoot(main, state) 22 | applyDiff(document.body, main, newMain) 23 | }) 24 | } 25 | 26 | render() 27 | -------------------------------------------------------------------------------- /Chapter03/01/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state) => { 5 | const element = component(targetElement, state) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter03/01/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter03/01/view/counter.test.js: -------------------------------------------------------------------------------- 1 | import counterView from './counter.js' 2 | 3 | let targetElement 4 | 5 | describe('counterView', () => { 6 | beforeEach(() => { 7 | targetElement = document.createElement('div') 8 | }) 9 | 10 | test('should put the number of not completed todo in a new DOM elements', () => { 11 | const newCounter = counterView(targetElement, { 12 | todos: [ 13 | { 14 | text: 'First', 15 | completed: true 16 | }, 17 | { 18 | text: 'Second', 19 | completed: false 20 | }, 21 | { 22 | text: 'Third', 23 | completed: false 24 | } 25 | ] 26 | }) 27 | expect(newCounter.textContent).toBe('2 Items left') 28 | }) 29 | 30 | test('should consider the singular form when only one item is left', () => { 31 | const newCounter = counterView(targetElement, { 32 | todos: [ 33 | { 34 | text: 'First', 35 | completed: true 36 | }, 37 | { 38 | text: 'Third', 39 | completed: false 40 | } 41 | ] 42 | }) 43 | expect(newCounter.textContent).toBe('1 Item left') 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /Chapter03/01/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }) => { 2 | const newCounter = targetElement.cloneNode(true) 3 | Array 4 | .from(newCounter.querySelectorAll('li a')) 5 | .forEach(a => { 6 | if (a.textContent === currentFilter) { 7 | a.classList.add('selected') 8 | } else { 9 | a.classList.remove('selected') 10 | } 11 | }) 12 | return newCounter 13 | } 14 | -------------------------------------------------------------------------------- /Chapter03/01/view/filters.test.js: -------------------------------------------------------------------------------- 1 | import filtersView from './filters.js' 2 | 3 | let targetElement 4 | const TEMPLATE = `` 15 | 16 | describe('filtersView', () => { 17 | beforeEach(() => { 18 | const tempElement = document.createElement('div') 19 | tempElement.innerHTML = TEMPLATE 20 | targetElement = tempElement.childNodes[0] 21 | }) 22 | 23 | test('should add the class "selected" to the anchor with the same text of the currentFilter', () => { 24 | const newCounter = filtersView(targetElement, { 25 | currentFilter: 'Active' 26 | }) 27 | 28 | const selectedItem = newCounter.querySelector('li a.selected') 29 | 30 | expect(selectedItem.textContent).toBe('Active') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /Chapter03/01/view/todos.js: -------------------------------------------------------------------------------- 1 | let template 2 | 3 | const createNewTodoNode = () => { 4 | if (!template) { 5 | template = document.getElementById('todo-item') 6 | } 7 | 8 | return template.content.firstElementChild.cloneNode(true) 9 | } 10 | 11 | const getTodoElement = todo => { 12 | const { 13 | text, 14 | completed 15 | } = todo 16 | 17 | const element = createNewTodoNode() 18 | 19 | element.querySelector('input.edit').value = text 20 | element.querySelector('label').textContent = text 21 | 22 | if (completed) { 23 | element.classList.add('completed') 24 | element.querySelector('input.toggle').checked = true 25 | } 26 | 27 | return element 28 | } 29 | 30 | export default (targetElement, { todos }) => { 31 | const newTodoList = targetElement.cloneNode(true) 32 | 33 | newTodoList.innerHTML = '' 34 | 35 | todos 36 | .map(getTodoElement) 37 | .forEach(element => { 38 | newTodoList.appendChild(element) 39 | }) 40 | 41 | return newTodoList 42 | } 43 | -------------------------------------------------------------------------------- /Chapter03/README.md: -------------------------------------------------------------------------------- 1 | # Chapter 3 - Managing DOM Events 2 | 3 | [![framework less](https://file-blyuofkggj.now.sh)](https://github.com/frameworkless-movement/manifesto) 4 | 5 | To start the examples just run: 6 | 7 | npm start -------------------------------------------------------------------------------- /Chapter03/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apress/frameworkless-front-end-development/747d119cd515f74d54cea67e92a31f45e0a803ea/Chapter03/favicon.ico -------------------------------------------------------------------------------- /Chapter03/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Frameworkless Frontend Development: DOM Events 6 | 7 | 8 | 9 |

    Frameworkless Frontend Development: DOM Events

    10 | 28 | 29 | -------------------------------------------------------------------------------- /Chapter03/now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "frameworkless-events", 4 | "alias": "frameworkless-events" 5 | } -------------------------------------------------------------------------------- /Chapter03/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "http-server", 4 | "deploy": "now && now alias", 5 | "test": "jest" 6 | }, 7 | "devDependencies": { 8 | "babel-plugin-transform-es2015-modules-commonjs": "6.26.2", 9 | "http-server": "0.11.1", 10 | "jest": "23.6.0", 11 | "now": "12.0.1", 12 | "standard": "12.0.1" 13 | }, 14 | "standard": { 15 | "globals": [ 16 | "expect", 17 | "test", 18 | "beforeEach", 19 | "describe", 20 | "CustomEvent" 21 | ] 22 | } 23 | } -------------------------------------------------------------------------------- /Chapter04/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": [ 5 | "transform-es2015-modules-commonjs" 6 | ] 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /Chapter04/00.1/components/HelloWorld.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_COLOR = 'black' 2 | 3 | export default class HelloWorld extends HTMLElement { 4 | get color () { 5 | return this.getAttribute('color') || DEFAULT_COLOR 6 | } 7 | 8 | set color (value) { 9 | this.setAttribute('color', value) 10 | } 11 | 12 | connectedCallback () { 13 | window.requestAnimationFrame(() => { 14 | const div = document.createElement('div') 15 | div.textContent = 'Hello World!' 16 | 17 | div.style.color = this.color 18 | 19 | this.appendChild(div) 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Chapter04/00.1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: Web Components 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Chapter04/00.1/index.js: -------------------------------------------------------------------------------- 1 | import HelloWorld from './components/HelloWorld.js' 2 | 3 | window.customElements.define('hello-world', HelloWorld) 4 | -------------------------------------------------------------------------------- /Chapter04/00.2/components/HelloWorld.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_COLOR = 'black' 2 | 3 | export default class HelloWorld extends HTMLElement { 4 | static get observedAttributes () { 5 | return ['color'] 6 | } 7 | 8 | get color () { 9 | return this.getAttribute('color') || DEFAULT_COLOR 10 | } 11 | 12 | set color (value) { 13 | this.setAttribute('color', value) 14 | } 15 | 16 | attributeChangedCallback (name, oldValue, newValue) { 17 | if (!this.div) { 18 | return 19 | } 20 | 21 | if (name === 'color') { 22 | this.div.style.color = newValue 23 | } 24 | } 25 | 26 | connectedCallback () { 27 | window.requestAnimationFrame(() => { 28 | this.div = document.createElement('div') 29 | this.div.textContent = 'Hello World!' 30 | this.div.style.color = this.color 31 | this.appendChild(this.div) 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Chapter04/00.2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: Web Components 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Chapter04/00.2/index.js: -------------------------------------------------------------------------------- 1 | import HelloWorld from './components/HelloWorld.js' 2 | 3 | window 4 | .customElements 5 | .define('hello-world', HelloWorld) 6 | 7 | const changeColorTo = color => { 8 | document 9 | .querySelectorAll('hello-world') 10 | .forEach(helloWorld => { 11 | helloWorld.color = color 12 | }) 13 | } 14 | 15 | document 16 | .querySelector('button') 17 | .addEventListener('click', () => { 18 | changeColorTo('blue') 19 | }) 20 | -------------------------------------------------------------------------------- /Chapter04/00.3/components/HelloWorld.js: -------------------------------------------------------------------------------- 1 | import applyDiff from './applyDiff.js' 2 | 3 | const DEFAULT_COLOR = 'black' 4 | 5 | const createDomElement = color => { 6 | const div = document.createElement('div') 7 | div.textContent = 'Hello World!' 8 | div.style.color = color 9 | return div 10 | } 11 | 12 | export default class HelloWorld extends HTMLElement { 13 | static get observedAttributes () { 14 | return ['color'] 15 | } 16 | 17 | get color () { 18 | return this.getAttribute('color') || DEFAULT_COLOR 19 | } 20 | 21 | set color (value) { 22 | this.setAttribute('color', value) 23 | } 24 | 25 | attributeChangedCallback (name, oldValue, newValue) { 26 | if (!this.hasChildNodes()) { 27 | return 28 | } 29 | 30 | applyDiff( 31 | this, 32 | this.firstElementChild, 33 | createDomElement(newValue) 34 | ) 35 | } 36 | 37 | connectedCallback () { 38 | window.requestAnimationFrame(() => { 39 | this.appendChild(createDomElement(this.color)) 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Chapter04/00.3/components/applyDiff.js: -------------------------------------------------------------------------------- 1 | const isNodeChanged = (node1, node2) => { 2 | const n1Attributes = node1.attributes 3 | const n2Attributes = node2.attributes 4 | if (n1Attributes.length !== n2Attributes.length) { 5 | return true 6 | } 7 | 8 | const differentAttribute = Array 9 | .from(n1Attributes) 10 | .find(attribute => { 11 | const { name } = attribute 12 | const attribute1 = node1 13 | .getAttribute(name) 14 | const attribute2 = node2 15 | .getAttribute(name) 16 | 17 | return attribute1 !== attribute2 18 | }) 19 | 20 | if (differentAttribute) { 21 | return true 22 | } 23 | 24 | if (node1.children.length === 0 && 25 | node2.children.length === 0 && 26 | node1.textContent !== node2.textContent) { 27 | return true 28 | } 29 | 30 | return false 31 | } 32 | 33 | const applyDiff = ( 34 | parentNode, 35 | realNode, 36 | virtualNode) => { 37 | if (realNode && !virtualNode) { 38 | realNode.remove() 39 | return 40 | } 41 | 42 | if (!realNode && virtualNode) { 43 | parentNode.appendChild(virtualNode) 44 | return 45 | } 46 | 47 | if (isNodeChanged(virtualNode, realNode)) { 48 | realNode.replaceWith(virtualNode) 49 | return 50 | } 51 | 52 | const realChildren = Array.from(realNode.children) 53 | const virtualChildren = Array.from(virtualNode.children) 54 | 55 | const max = Math.max( 56 | realChildren.length, 57 | virtualChildren.length 58 | ) 59 | for (let i = 0; i < max; i++) { 60 | applyDiff( 61 | realNode, 62 | realChildren[i], 63 | virtualChildren[i] 64 | ) 65 | } 66 | } 67 | 68 | export default applyDiff 69 | -------------------------------------------------------------------------------- /Chapter04/00.3/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: Web Components 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Chapter04/00.3/index.js: -------------------------------------------------------------------------------- 1 | import HelloWorld from './components/HelloWorld.js' 2 | 3 | window.customElements.define('hello-world', HelloWorld) 4 | 5 | const changeColorTo = color => { 6 | document 7 | .querySelectorAll('hello-world') 8 | .forEach(helloWorld => { 9 | helloWorld.color = color 10 | }) 11 | } 12 | 13 | document 14 | .querySelector('button') 15 | .addEventListener('click', () => { 16 | changeColorTo('blue') 17 | }) 18 | -------------------------------------------------------------------------------- /Chapter04/00.4/components/GitHubAvatar.js: -------------------------------------------------------------------------------- 1 | const ERROR_IMAGE = 'https://files-82ee7vgzc.now.sh' 2 | const LOADING_IMAGE = 'https://files-8bga2nnt0.now.sh' 3 | 4 | const getGitHubAvatarUrl = async user => { 5 | if (!user) { 6 | return 7 | } 8 | 9 | const url = `https://api.github.com/users/${user}` 10 | 11 | const response = await fetch(url) 12 | if (!response.ok) { 13 | throw new Error(response.statusText) 14 | } 15 | const data = await response.json() 16 | return data.avatar_url 17 | } 18 | 19 | export default class GitHubAvatar extends HTMLElement { 20 | constructor () { 21 | super() 22 | this.url = LOADING_IMAGE 23 | } 24 | 25 | get user () { 26 | return this.getAttribute('user') 27 | } 28 | 29 | set user (value) { 30 | this.setAttribute('user', value) 31 | } 32 | 33 | render () { 34 | window.requestAnimationFrame(() => { 35 | this.innerHTML = '' 36 | const img = document.createElement('img') 37 | img.src = this.url 38 | this.appendChild(img) 39 | }) 40 | } 41 | 42 | async loadNewAvatar () { 43 | const { user } = this 44 | if (!user) { 45 | return 46 | } 47 | try { 48 | this.url = await getGitHubAvatarUrl(user) 49 | } catch (e) { 50 | this.url = ERROR_IMAGE 51 | } 52 | 53 | this.render() 54 | } 55 | 56 | connectedCallback () { 57 | this.render() 58 | this.loadNewAvatar() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Chapter04/00.4/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: Web Components 7 | 8 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
    Icons made by Eleonor Wang from www.flaticon.com is licensed by CC 3.0 BY
    23 |
    Icons made by Smashicons from www.flaticon.com is licensed by CC 3.0 BY
    24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Chapter04/00.4/index.js: -------------------------------------------------------------------------------- 1 | import GitHubAvatar from './components/GitHubAvatar.js' 2 | 3 | window.customElements.define('github-avatar', GitHubAvatar) 4 | -------------------------------------------------------------------------------- /Chapter04/00.5/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: Web Components 7 | 8 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
    Icons made by Eleonor Wang from www.flaticon.com is licensed by CC 3.0 BY
    23 |
    Icons made by Smashicons from www.flaticon.com is licensed by CC 3.0 BY
    24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Chapter04/00.5/index.js: -------------------------------------------------------------------------------- 1 | import GitHubAvatar, { EVENTS } from './components/GitHubAvatar.js' 2 | 3 | window.customElements.define('github-avatar', GitHubAvatar) 4 | 5 | document 6 | .querySelectorAll('github-avatar') 7 | .forEach(avatar => { 8 | avatar 9 | .addEventListener( 10 | EVENTS.AVATAR_LOAD_COMPLETE, 11 | e => { 12 | console.log( 13 | 'Avatar Loaded', 14 | e.detail.avatar 15 | ) 16 | }) 17 | 18 | avatar 19 | .addEventListener( 20 | EVENTS.AVATAR_LOAD_ERROR, 21 | e => { 22 | console.log( 23 | 'Avatar Loading error', 24 | e.detail.error 25 | ) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /Chapter04/00/components/HelloWorld.js: -------------------------------------------------------------------------------- 1 | export default class HelloWorld extends HTMLElement { 2 | connectedCallback () { 3 | window.requestAnimationFrame(() => { 4 | this.innerHTML = '
    Hello World!
    ' 5 | }) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Chapter04/00/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: Web Components 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Chapter04/00/index.js: -------------------------------------------------------------------------------- 1 | import HelloWorld from './components/HelloWorld.js' 2 | 3 | window 4 | .customElements 5 | .define('hello-world', HelloWorld) 6 | -------------------------------------------------------------------------------- /Chapter04/01/components/Application.js: -------------------------------------------------------------------------------- 1 | import { EVENTS } from './List.js' 2 | 3 | export default class App extends HTMLElement { 4 | constructor () { 5 | super() 6 | this.state = { 7 | todos: [], 8 | filter: 'All' 9 | } 10 | 11 | this.template = document 12 | .getElementById('todo-app') 13 | } 14 | 15 | deleteItem (index) { 16 | this.state.todos.splice(index, 1) 17 | this.syncAttributes() 18 | } 19 | 20 | addItem (text) { 21 | this.state.todos.push({ 22 | text, 23 | completed: false 24 | }) 25 | this.syncAttributes() 26 | } 27 | 28 | syncAttributes () { 29 | this.list.todos = this.state.todos 30 | this.footer.todos = this.state.todos 31 | this.footer.filter = this.state.filter 32 | } 33 | 34 | connectedCallback () { 35 | window.requestAnimationFrame(() => { 36 | const content = this.template 37 | .content 38 | .firstElementChild 39 | .cloneNode(true) 40 | 41 | this.appendChild(content) 42 | 43 | this 44 | .querySelector('.new-todo') 45 | .addEventListener('keypress', e => { 46 | if (e.key === 'Enter') { 47 | this.addItem(e.target.value) 48 | e.target.value = '' 49 | } 50 | }) 51 | 52 | this.footer = this 53 | .querySelector('todomvc-footer') 54 | 55 | this.list = this.querySelector('todomvc-list') 56 | this.list.addEventListener( 57 | EVENTS.DELETE_ITEM, 58 | e => { 59 | this.deleteItem(e.detail.index) 60 | } 61 | ) 62 | 63 | this.syncAttributes() 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Chapter04/01/components/Footer.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default class Footer extends HTMLElement { 14 | static get observedAttributes () { 15 | return [ 16 | 'filter', 17 | 'todos' 18 | ] 19 | } 20 | 21 | get todos () { 22 | if (!this.hasAttribute('todos')) { 23 | return [] 24 | } 25 | 26 | return JSON.parse(this.getAttribute('todos')) 27 | } 28 | 29 | set todos (value) { 30 | this.setAttribute('todos', JSON.stringify(value)) 31 | } 32 | 33 | get filter () { 34 | return this.getAttribute('filter') 35 | } 36 | 37 | set filter (value) { 38 | this.setAttribute('filter', value) 39 | } 40 | 41 | connectedCallback () { 42 | const template = document.getElementById('footer') 43 | const content = template 44 | .content 45 | .firstElementChild 46 | .cloneNode(true) 47 | 48 | this.appendChild(content) 49 | 50 | const { 51 | filter, 52 | todos 53 | } = this 54 | 55 | this 56 | .querySelectorAll('li a') 57 | .forEach(a => { 58 | if (a.textContent === filter) { 59 | a.classList.add('selected') 60 | } else { 61 | a.classList.remove('selected') 62 | } 63 | }) 64 | 65 | const label = getTodoCount(todos) 66 | 67 | this 68 | .querySelector('span.todo-count') 69 | .textContent = label 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Chapter04/01/index.js: -------------------------------------------------------------------------------- 1 | import Application from './components/Application.js' 2 | import Footer from './components/Footer.js' 3 | import List from './components/List.js' 4 | 5 | window.customElements.define('todomvc-app', Application) 6 | window.customElements.define('todomvc-footer', Footer) 7 | window.customElements.define('todomvc-list', List) 8 | -------------------------------------------------------------------------------- /Chapter04/README.md: -------------------------------------------------------------------------------- 1 | # Chapter 4 - Web Components 2 | 3 | [![framework less](https://file-blyuofkggj.now.sh)](https://github.com/frameworkless-movement/manifesto) 4 | 5 | To start the examples just run: 6 | 7 | npm start -------------------------------------------------------------------------------- /Chapter04/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apress/frameworkless-front-end-development/747d119cd515f74d54cea67e92a31f45e0a803ea/Chapter04/favicon.ico -------------------------------------------------------------------------------- /Chapter04/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Frameworkless Frontend Development: Web Components 6 | 7 | 8 | 9 |

    Frameworkless Frontend Development: Web Components

    10 | 19 | 20 | -------------------------------------------------------------------------------- /Chapter04/now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "frameworkless-events", 4 | "alias": "frameworkless-events" 5 | } -------------------------------------------------------------------------------- /Chapter04/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "http-server", 4 | "deploy": "now && now alias", 5 | "test": "jest" 6 | }, 7 | "devDependencies": { 8 | "babel-plugin-transform-es2015-modules-commonjs": "6.26.2", 9 | "http-server": "0.11.1", 10 | "jest": "23.6.0", 11 | "now": "12.0.1", 12 | "standard": "12.0.1" 13 | }, 14 | "standard": { 15 | "globals": [ 16 | "expect", 17 | "test", 18 | "beforeEach", 19 | "describe", 20 | "CustomEvent", 21 | "HTMLElement", 22 | "fetch" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Chapter05/README.md: -------------------------------------------------------------------------------- 1 | # Chapter 5 - HTTP Requests 2 | 3 | [![framework less](https://file-blyuofkggj.now.sh)](https://github.com/frameworkless-movement/manifesto) 4 | 5 | To start the examples just run: 6 | 7 | npm start -------------------------------------------------------------------------------- /Chapter05/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "nodemon server" 4 | }, 5 | "dependencies": { 6 | "body-parser": "1.18.3", 7 | "express": "4.16.4", 8 | "lodash.findindex": "4.6.0", 9 | "uuid": "3.3.2" 10 | }, 11 | "devDependencies": { 12 | "nodemon": "1.18.10", 13 | "standard": "12.0.1" 14 | }, 15 | "standard": { 16 | "globals": [ 17 | "XMLHttpRequest", 18 | "fetch", 19 | "Headers", 20 | "axios" 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Chapter05/public/00/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: HTTP Requests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
    16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Chapter05/public/00/index.js: -------------------------------------------------------------------------------- 1 | import todos from './todos.js' 2 | 3 | const printResult = (action, result) => { 4 | const time = (new Date()).toTimeString() 5 | const node = document.createElement('p') 6 | node.textContent = `${action.toUpperCase()}: ${JSON.stringify(result)} (${time})` 7 | 8 | document 9 | .querySelector('div') 10 | .appendChild(node) 11 | } 12 | 13 | const onListClick = async () => { 14 | const result = await todos.list() 15 | printResult('list todos', result) 16 | } 17 | 18 | const onAddClick = async () => { 19 | const result = await todos.create('A simple todo Element') 20 | printResult('add todo', result) 21 | } 22 | 23 | const onUpdateClick = async () => { 24 | const list = await todos.list() 25 | 26 | const { id } = list[0] 27 | const newTodo = { 28 | id, 29 | completed: true 30 | } 31 | 32 | const result = await todos.update(newTodo) 33 | printResult('update todo', result) 34 | } 35 | 36 | const onDeleteClick = async () => { 37 | const list = await todos.list() 38 | const { id } = list[0] 39 | 40 | const result = await todos.delete(id) 41 | printResult('delete todo', result) 42 | } 43 | 44 | document 45 | .querySelector('button[data-list]') 46 | .addEventListener('click', onListClick) 47 | 48 | document 49 | .querySelector('button[data-add]') 50 | .addEventListener('click', onAddClick) 51 | 52 | document 53 | .querySelector('button[data-update]') 54 | .addEventListener('click', onUpdateClick) 55 | 56 | document 57 | .querySelector('button[data-delete]') 58 | .addEventListener('click', onDeleteClick) 59 | -------------------------------------------------------------------------------- /Chapter05/public/00/todos.js: -------------------------------------------------------------------------------- 1 | import http from './http.js' 2 | 3 | const HEADERS = { 4 | 'Content-Type': 'application/json' 5 | } 6 | 7 | const BASE_URL = '/api/todos' 8 | 9 | const list = () => http.get(BASE_URL) 10 | 11 | const create = text => { 12 | const todo = { 13 | text, 14 | completed: false 15 | } 16 | 17 | return http.post( 18 | BASE_URL, 19 | todo, 20 | HEADERS 21 | ) 22 | } 23 | 24 | const update = newTodo => { 25 | const url = `${BASE_URL}/${newTodo.id}` 26 | return http.patch( 27 | url, 28 | newTodo, 29 | HEADERS 30 | ) 31 | } 32 | 33 | const deleteTodo = id => { 34 | const url = `${BASE_URL}/${id}` 35 | return http.delete(url) 36 | } 37 | 38 | export default { 39 | list, 40 | create, 41 | update, 42 | delete: deleteTodo 43 | } 44 | -------------------------------------------------------------------------------- /Chapter05/public/01/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: HTTP Requests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
    16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Chapter05/public/01/index.js: -------------------------------------------------------------------------------- 1 | import todos from './todos.js' 2 | 3 | const NEW_TODO_TEXT = 'A simple todo Element' 4 | 5 | const printResult = (action, result) => { 6 | const time = (new Date()).toTimeString() 7 | const node = document.createElement('p') 8 | node.textContent = `${action.toUpperCase()}: ${JSON.stringify(result)} (${time})` 9 | 10 | document 11 | .querySelector('div') 12 | .appendChild(node) 13 | } 14 | 15 | const onListClick = async () => { 16 | const result = await todos.list() 17 | printResult('list todos', result) 18 | } 19 | 20 | const onAddClick = async () => { 21 | const result = await todos.create(NEW_TODO_TEXT) 22 | printResult('add todo', result) 23 | } 24 | 25 | const onUpdateClick = async () => { 26 | const list = await todos.list() 27 | 28 | const { id } = list[0] 29 | const newTodo = { 30 | id, 31 | completed: true 32 | } 33 | 34 | const result = await todos.update(newTodo) 35 | printResult('update todo', result) 36 | } 37 | 38 | const onDeleteClick = async () => { 39 | const list = await todos.list() 40 | const { id } = list[0] 41 | 42 | const result = await todos.delete(id) 43 | printResult('delete todo', result) 44 | } 45 | 46 | document 47 | .querySelector('button[data-list]') 48 | .addEventListener('click', onListClick) 49 | 50 | document 51 | .querySelector('button[data-add]') 52 | .addEventListener('click', onAddClick) 53 | 54 | document 55 | .querySelector('button[data-update]') 56 | .addEventListener('click', onUpdateClick) 57 | 58 | document 59 | .querySelector('button[data-delete]') 60 | .addEventListener('click', onDeleteClick) 61 | -------------------------------------------------------------------------------- /Chapter05/public/01/todos.js: -------------------------------------------------------------------------------- 1 | import http from './http.js' 2 | 3 | const HEADERS = { 4 | 'Content-Type': 'application/json' 5 | } 6 | 7 | const BASE_URL = '/api/todos' 8 | 9 | const list = () => http.get(BASE_URL) 10 | 11 | const create = text => { 12 | const todo = { 13 | text, 14 | completed: false 15 | } 16 | 17 | return http.post( 18 | BASE_URL, 19 | todo, 20 | HEADERS 21 | ) 22 | } 23 | 24 | const update = newTodo => { 25 | const url = `${BASE_URL}/${newTodo.id}` 26 | return http.patch( 27 | url, 28 | newTodo, 29 | HEADERS 30 | ) 31 | } 32 | 33 | const deleteTodo = id => { 34 | const url = `${BASE_URL}/${id}` 35 | return http.delete( 36 | url, 37 | HEADERS 38 | ) 39 | } 40 | 41 | export default { 42 | list, 43 | create, 44 | update, 45 | delete: deleteTodo 46 | } 47 | -------------------------------------------------------------------------------- /Chapter05/public/02/http.js: -------------------------------------------------------------------------------- 1 | const request = async params => { 2 | const { 3 | method = 'GET', 4 | url, 5 | headers = {}, 6 | body 7 | } = params 8 | 9 | const config = { 10 | url, 11 | method, 12 | headers, 13 | data: body 14 | } 15 | 16 | return axios(config) 17 | } 18 | 19 | const get = async (url, headers) => { 20 | const response = await request({ 21 | url, 22 | headers, 23 | method: 'GET' 24 | }) 25 | 26 | return response.data 27 | } 28 | 29 | const post = async (url, body, headers) => { 30 | const response = await request({ 31 | url, 32 | headers, 33 | method: 'POST', 34 | body 35 | }) 36 | return response.data 37 | } 38 | 39 | const put = async (url, body, headers) => { 40 | const response = await request({ 41 | url, 42 | headers, 43 | method: 'PUT', 44 | body 45 | }) 46 | return response.data 47 | } 48 | 49 | const patch = async (url, body, headers) => { 50 | const response = await request({ 51 | url, 52 | headers, 53 | method: 'PATCH', 54 | body 55 | }) 56 | return response.data 57 | } 58 | 59 | const deleteRequest = async (url, headers) => { 60 | const response = await request({ 61 | url, 62 | headers, 63 | method: 'DELETE' 64 | }) 65 | return response.data 66 | } 67 | 68 | export default { 69 | get, 70 | post, 71 | put, 72 | patch, 73 | delete: deleteRequest 74 | } 75 | -------------------------------------------------------------------------------- /Chapter05/public/02/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: HTTP Requests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
    16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Chapter05/public/02/index.js: -------------------------------------------------------------------------------- 1 | import todos from './todos.js' 2 | 3 | const printResult = (action, result) => { 4 | const time = (new Date()).toTimeString() 5 | const node = document.createElement('p') 6 | node.textContent = `${action.toUpperCase()}: ${JSON.stringify(result)} (${time})` 7 | 8 | document 9 | .querySelector('div') 10 | .appendChild(node) 11 | } 12 | 13 | const onListClick = async () => { 14 | const result = await todos.list() 15 | printResult('list todos', result) 16 | } 17 | 18 | const onAddClick = async () => { 19 | const result = await todos.create('A simple todo Element') 20 | printResult('add todo', result) 21 | } 22 | 23 | const onUpdateClick = async () => { 24 | const list = await todos.list() 25 | 26 | const { id } = list[0] 27 | const newTodo = { 28 | id, 29 | completed: true 30 | } 31 | 32 | const result = await todos.update(newTodo) 33 | printResult('update todo', result) 34 | } 35 | 36 | const onDeleteClick = async () => { 37 | const list = await todos.list() 38 | const { id } = list[0] 39 | 40 | const result = await todos.delete(id) 41 | printResult('delete todo', result) 42 | } 43 | 44 | document 45 | .querySelector('button[data-list]') 46 | .addEventListener('click', onListClick) 47 | 48 | document 49 | .querySelector('button[data-add]') 50 | .addEventListener('click', onAddClick) 51 | 52 | document 53 | .querySelector('button[data-update]') 54 | .addEventListener('click', onUpdateClick) 55 | 56 | document 57 | .querySelector('button[data-delete]') 58 | .addEventListener('click', onDeleteClick) 59 | -------------------------------------------------------------------------------- /Chapter05/public/02/todos.js: -------------------------------------------------------------------------------- 1 | import http from './http.js' 2 | 3 | const HEADERS = { 4 | 'Content-Type': 'application/json' 5 | } 6 | 7 | const BASE_URL = '/api/todos' 8 | 9 | const list = () => http.get(BASE_URL) 10 | 11 | const create = text => { 12 | const todo = { 13 | text, 14 | completed: false 15 | } 16 | 17 | return http.post( 18 | BASE_URL, 19 | todo, 20 | HEADERS 21 | ) 22 | } 23 | 24 | const update = newTodo => { 25 | const url = `${BASE_URL}/${newTodo.id}` 26 | return http.patch( 27 | url, 28 | newTodo, 29 | HEADERS 30 | ) 31 | } 32 | 33 | const deleteTodo = id => { 34 | const url = `${BASE_URL}/${id}` 35 | return http.delete( 36 | url, 37 | HEADERS 38 | ) 39 | } 40 | 41 | export default { 42 | list, 43 | create, 44 | update, 45 | delete: deleteTodo 46 | } 47 | -------------------------------------------------------------------------------- /Chapter05/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apress/frameworkless-front-end-development/747d119cd515f74d54cea67e92a31f45e0a803ea/Chapter05/public/favicon.ico -------------------------------------------------------------------------------- /Chapter05/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Frameworkless Frontend Development: HTTP Requests 6 | 7 | 8 | 9 |

    Frameworkless Frontend Development: HTTP Requests

    10 | 15 | 16 | -------------------------------------------------------------------------------- /Chapter05/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const bodyParser = require('body-parser') 3 | const uuidv4 = require('uuid/v4') 4 | const findIndex = require('lodash.findindex') 5 | 6 | const PORT = 8080 7 | 8 | const app = express() 9 | let todos = [] 10 | 11 | app.use(express.static('public')) 12 | app.use(bodyParser.json()) 13 | 14 | app.get('/api/todos', (req, res) => { 15 | res.send(todos) 16 | }) 17 | 18 | app.post('/api/todos', (req, res) => { 19 | const newTodo = { 20 | completed: false, 21 | ...req.body, 22 | id: uuidv4() 23 | } 24 | 25 | todos.push(newTodo) 26 | 27 | res.status(201) 28 | res.send(newTodo) 29 | }) 30 | 31 | app.patch('/api/todos/:id', (req, res) => { 32 | const updateIndex = findIndex( 33 | todos, 34 | t => t.id === req.params.id 35 | ) 36 | const oldTodo = todos[updateIndex] 37 | 38 | const newTodo = { 39 | ...oldTodo, 40 | ...req.body 41 | } 42 | 43 | todos[updateIndex] = newTodo 44 | 45 | res.send(newTodo) 46 | }) 47 | 48 | app.delete('/api/todos/:id', (req, res) => { 49 | todos = todos.filter( 50 | t => t.id !== req.params.id 51 | ) 52 | 53 | res.status(204) 54 | res.send() 55 | }) 56 | 57 | app.listen(PORT) 58 | -------------------------------------------------------------------------------- /Chapter06/00.1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: Routing 7 | 8 | 13 | 14 | 15 | 16 |
    17 | 20 | 23 | 26 |
    27 |
    28 | 29 |
    30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /Chapter06/00.1/index.js: -------------------------------------------------------------------------------- 1 | import createRouter from './router.js' 2 | import createPages from './pages.js' 3 | 4 | const container = document 5 | .querySelector('main') 6 | 7 | const pages = createPages(container) 8 | 9 | const router = createRouter() 10 | 11 | router 12 | .addRoute('#/', pages.home) 13 | .addRoute('#/list', pages.list) 14 | .setNotFound(pages.notFound) 15 | .start() 16 | 17 | const NAV_BTN_SELECTOR = 'button[data-navigate]' 18 | 19 | document 20 | .body 21 | .addEventListener('click', e => { 22 | const { target } = e 23 | if (target.matches(NAV_BTN_SELECTOR)) { 24 | const { navigate } = target.dataset 25 | router.navigate(navigate) 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /Chapter06/00.1/pages.js: -------------------------------------------------------------------------------- 1 | export default container => { 2 | const home = () => { 3 | container 4 | .textContent = 'This is Home page' 5 | } 6 | 7 | const list = () => { 8 | container 9 | .textContent = 'This is List Page' 10 | } 11 | 12 | const notFound = () => { 13 | container 14 | .textContent = 'Page Not Found!' 15 | } 16 | 17 | return { 18 | home, 19 | list, 20 | notFound 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Chapter06/00.1/router.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | const routes = [] 3 | let notFound = () => {} 4 | 5 | const router = {} 6 | 7 | const checkRoutes = () => { 8 | const currentRoute = routes.find(route => { 9 | return route.fragment === window.location.hash 10 | }) 11 | 12 | if (!currentRoute) { 13 | notFound() 14 | return 15 | } 16 | 17 | currentRoute.component() 18 | } 19 | 20 | router.addRoute = (fragment, component) => { 21 | routes.push({ 22 | fragment, 23 | component 24 | }) 25 | 26 | return router 27 | } 28 | 29 | router.setNotFound = cb => { 30 | notFound = cb 31 | return router 32 | } 33 | 34 | router.navigate = fragment => { 35 | window.location.hash = fragment 36 | } 37 | 38 | router.start = () => { 39 | window 40 | .addEventListener('hashchange', checkRoutes) 41 | if (!window.location.hash) { 42 | window.location.hash = '#/' 43 | } 44 | 45 | checkRoutes() 46 | } 47 | 48 | return router 49 | } 50 | -------------------------------------------------------------------------------- /Chapter06/00.2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: Routing 7 | 8 | 13 | 14 | 15 | 16 |
    17 | 18 | 19 | 20 | 21 | 22 | 23 |
    24 |
    25 | 26 |
    27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Chapter06/00.2/index.js: -------------------------------------------------------------------------------- 1 | import createRouter from './router.js' 2 | import createPages from './pages.js' 3 | 4 | const container = document.querySelector('main') 5 | 6 | const pages = createPages(container) 7 | 8 | const router = createRouter() 9 | 10 | router 11 | .addRoute('#/', pages.home) 12 | .addRoute('#/list', pages.list) 13 | .addRoute('#/list/:id', pages.detail) 14 | .addRoute('#/list/:id/:anotherId', pages.anotherDetail) 15 | .setNotFound(pages.notFound) 16 | .start() 17 | 18 | const NAV_BTN_SELECTOR = 'button[data-navigate]' 19 | 20 | document 21 | .body 22 | .addEventListener('click', e => { 23 | const { target } = e 24 | if (target.matches(NAV_BTN_SELECTOR)) { 25 | const { navigate } = target.dataset 26 | router.navigate(navigate) 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /Chapter06/00.2/pages.js: -------------------------------------------------------------------------------- 1 | export default container => { 2 | const home = () => { 3 | container 4 | .textContent = 'This is Home page' 5 | } 6 | 7 | const list = () => { 8 | container 9 | .textContent = 'This is List Page' 10 | } 11 | 12 | const detail = (params) => { 13 | const { id } = params 14 | container 15 | .textContent = `This is Detail Page with Id ${id}` 16 | } 17 | 18 | const anotherDetail = (params) => { 19 | const { id, anotherId } = params 20 | container 21 | .textContent = ` 22 | This is Detail Page with Id ${id} 23 | and AnotherId ${anotherId} 24 | ` 25 | } 26 | 27 | const notFound = () => { 28 | container 29 | .textContent = 'Page Not Found!' 30 | } 31 | 32 | return { 33 | home, 34 | list, 35 | detail, 36 | anotherDetail, 37 | notFound 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Chapter06/00/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: Routing 7 | 8 | 13 | 14 | 15 | 16 |
    17 | Go To Index 18 | Go To List 19 | Dummy Page 20 |
    21 |
    22 | 23 |
    24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Chapter06/00/index.js: -------------------------------------------------------------------------------- 1 | import createRouter from './router.js' 2 | import createPages from './pages.js' 3 | 4 | const container = document.querySelector('main') 5 | 6 | const pages = createPages(container) 7 | 8 | const router = createRouter() 9 | 10 | router 11 | .addRoute('#/', pages.home) 12 | .addRoute('#/list', pages.list) 13 | .setNotFound(pages.notFound) 14 | .start() 15 | -------------------------------------------------------------------------------- /Chapter06/00/pages.js: -------------------------------------------------------------------------------- 1 | export default container => { 2 | const home = () => { 3 | container 4 | .textContent = 'This is Home page' 5 | } 6 | 7 | const list = () => { 8 | container 9 | .textContent = 'This is List Page' 10 | } 11 | 12 | const notFound = () => { 13 | container 14 | .textContent = 'Page Not Found!' 15 | } 16 | 17 | return { 18 | home, 19 | list, 20 | notFound 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Chapter06/00/router.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | const routes = [] 3 | let notFound = () => {} 4 | 5 | const router = {} 6 | 7 | const checkRoutes = () => { 8 | const currentRoute = routes.find(route => { 9 | return route.fragment === window.location.hash 10 | }) 11 | 12 | if (!currentRoute) { 13 | notFound() 14 | return 15 | } 16 | 17 | currentRoute.component() 18 | } 19 | 20 | router.addRoute = (fragment, component) => { 21 | routes.push({ 22 | fragment, 23 | component 24 | }) 25 | 26 | return router 27 | } 28 | 29 | router.setNotFound = cb => { 30 | notFound = cb 31 | return router 32 | } 33 | 34 | router.start = () => { 35 | window 36 | .addEventListener('hashchange', checkRoutes) 37 | 38 | if (!window.location.hash) { 39 | window.location.hash = '#/' 40 | } 41 | 42 | checkRoutes() 43 | } 44 | 45 | return router 46 | } 47 | -------------------------------------------------------------------------------- /Chapter06/01.1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: Routing 7 | 8 | 13 | 14 | 15 | 16 |
    17 | Go To Index 18 | Go To List 19 | Go To Detail With Id 1 20 | Go To Detail With Id 2 21 | Go To Another Detail 22 | Dummy Page 23 |
    24 |
    25 | 26 |
    27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Chapter06/01.1/index.js: -------------------------------------------------------------------------------- 1 | import createRouter from './router.js' 2 | import createPages from './pages.js' 3 | 4 | const container = document.querySelector('main') 5 | 6 | const pages = createPages(container) 7 | 8 | const router = createRouter() 9 | 10 | router 11 | .addRoute('/', pages.home) 12 | .addRoute('/list', pages.list) 13 | .addRoute('/list/:id', pages.detail) 14 | .addRoute('/list/:id/:anotherId', pages.anotherDetail) 15 | .setNotFound(pages.notFound) 16 | .start() 17 | -------------------------------------------------------------------------------- /Chapter06/01.1/pages.js: -------------------------------------------------------------------------------- 1 | export default container => { 2 | const home = () => { 3 | container 4 | .textContent = 'This is Home page' 5 | } 6 | 7 | const list = () => { 8 | container 9 | .textContent = 'This is List Page' 10 | } 11 | 12 | const detail = (params) => { 13 | const { id } = params 14 | container 15 | .textContent = `This is Detail Page with Id ${id}` 16 | } 17 | 18 | const anotherDetail = (params) => { 19 | const { id, anotherId } = params 20 | container 21 | .textContent = `This is Detail Page with Id ${id} and AnotherId ${anotherId}` 22 | } 23 | 24 | const notFound = () => { 25 | container 26 | .textContent = 'Page Not Found!' 27 | } 28 | 29 | return { 30 | home, 31 | list, 32 | detail, 33 | anotherDetail, 34 | notFound 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Chapter06/01/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: Routing 7 | 8 | 13 | 14 | 15 | 16 |
    17 | 18 | 19 | 20 | 21 | 22 | 23 |
    24 |
    25 | 26 |
    27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Chapter06/01/index.js: -------------------------------------------------------------------------------- 1 | import createRouter from './router.js' 2 | import createPages from './pages.js' 3 | 4 | const container = document.querySelector('main') 5 | 6 | const pages = createPages(container) 7 | 8 | const router = createRouter() 9 | 10 | router 11 | .addRoute('/', pages.home) 12 | .addRoute('/list', pages.list) 13 | .addRoute('/list/:id', pages.detail) 14 | .addRoute('/list/:id/:anotherId', pages.anotherDetail) 15 | .setNotFound(pages.notFound) 16 | .start() 17 | 18 | const NAV_BTN_SELECTOR = 'button[data-navigate]' 19 | 20 | document 21 | .body 22 | .addEventListener('click', e => { 23 | const { target } = e 24 | if (target.matches(NAV_BTN_SELECTOR)) { 25 | const { navigate } = target.dataset 26 | router.navigate(navigate) 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /Chapter06/01/pages.js: -------------------------------------------------------------------------------- 1 | export default container => { 2 | const home = () => { 3 | container 4 | .textContent = 'This is Home page' 5 | } 6 | 7 | const list = () => { 8 | container 9 | .textContent = 'This is List Page' 10 | } 11 | 12 | const detail = (params) => { 13 | const { id } = params 14 | container 15 | .textContent = `This is Detail Page with Id ${id}` 16 | } 17 | 18 | const anotherDetail = (params) => { 19 | const { id, anotherId } = params 20 | container 21 | .textContent = `This is Detail Page with Id ${id} and AnotherId ${anotherId}` 22 | } 23 | 24 | const notFound = () => { 25 | container 26 | .textContent = 'Page Not Found!' 27 | } 28 | 29 | return { 30 | home, 31 | list, 32 | detail, 33 | anotherDetail, 34 | notFound 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Chapter06/02/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: Routing 7 | 8 | 13 | 14 | 15 | 16 | 17 |
    18 | Go To Index 19 | Go To List 20 | Go To Detail With Id 1 21 | Go To Detail With Id 2 22 | Go To Another Detail 23 | Dummy Page 24 |
    25 |
    26 | 27 |
    28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /Chapter06/02/index.js: -------------------------------------------------------------------------------- 1 | import createRouter from './router.js' 2 | import createPages from './pages.js' 3 | 4 | const container = document.querySelector('main') 5 | 6 | const pages = createPages(container) 7 | 8 | const router = createRouter() 9 | 10 | router 11 | .addRoute('/', pages.home) 12 | .addRoute('/list', pages.list) 13 | .addRoute('/list/:id', pages.detail) 14 | .addRoute('/list/:id/:anotherId', pages.anotherDetail) 15 | .setNotFound(pages.notFound) 16 | .start() 17 | -------------------------------------------------------------------------------- /Chapter06/02/pages.js: -------------------------------------------------------------------------------- 1 | export default container => { 2 | const home = () => { 3 | container 4 | .textContent = 'This is Home page' 5 | } 6 | 7 | const list = () => { 8 | container 9 | .textContent = 'This is List Page' 10 | } 11 | 12 | const detail = (params) => { 13 | const { id } = params 14 | container 15 | .textContent = `This is Detail Page with Id ${id}` 16 | } 17 | 18 | const anotherDetail = (params) => { 19 | const { id, anotherId } = params 20 | container 21 | .textContent = `This is Detail Page with Id ${id} and AnotherId ${anotherId}` 22 | } 23 | 24 | const notFound = () => { 25 | container 26 | .textContent = 'Page Not Found!' 27 | } 28 | 29 | return { 30 | home, 31 | list, 32 | detail, 33 | anotherDetail, 34 | notFound 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Chapter06/02/router.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | const navigoRouter = new window.Navigo() 3 | const router = {} 4 | 5 | router.addRoute = (path, callback) => { 6 | navigoRouter.on(path, callback) 7 | return router 8 | } 9 | 10 | router.setNotFound = cb => { 11 | navigoRouter.notFound(cb) 12 | return router 13 | } 14 | 15 | router.navigate = path => { 16 | navigoRouter.navigate(path) 17 | } 18 | 19 | router.start = () => { 20 | navigoRouter.resolve() 21 | return router 22 | } 23 | 24 | return router 25 | } 26 | -------------------------------------------------------------------------------- /Chapter06/README.md: -------------------------------------------------------------------------------- 1 | # Chapter 6 - Routing 2 | 3 | [![framework less](https://file-blyuofkggj.now.sh)](https://github.com/frameworkless-movement/manifesto) 4 | 5 | To start the examples just run: 6 | 7 | npm start -------------------------------------------------------------------------------- /Chapter06/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apress/frameworkless-front-end-development/747d119cd515f74d54cea67e92a31f45e0a803ea/Chapter06/favicon.ico -------------------------------------------------------------------------------- /Chapter06/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Frameworkless Frontend Development: Routing 6 | 7 | 8 | 9 |

    Frameworkless Frontend Development: Routing

    10 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Chapter06/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "http-server & serve -p 8081 -s 01 & serve -p 8082 -s 01.1 & serve -p 8083 -s 02", 4 | "test": "jest" 5 | }, 6 | "devDependencies": { 7 | "http-server": "0.11.1", 8 | "jest": "23.6.0", 9 | "serve": "10.1.2", 10 | "standard": "12.0.1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Chapter07/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": [ 5 | "transform-es2015-modules-commonjs" 6 | ] 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /Chapter07/00/index.js: -------------------------------------------------------------------------------- 1 | import todosView from './view/todos.js' 2 | import counterView from './view/counter.js' 3 | import filtersView from './view/filters.js' 4 | import appView from './view/app.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | import modelFactory from './model/model.js' 10 | 11 | registry.add('app', appView) 12 | registry.add('todos', todosView) 13 | registry.add('counter', counterView) 14 | registry.add('filters', filtersView) 15 | 16 | const model = modelFactory() 17 | 18 | const events = { 19 | addItem: text => { 20 | model.addItem(text) 21 | render(model.getState()) 22 | }, 23 | updateItem: (index, text) => { 24 | model.updateItem(index, text) 25 | render(model.getState()) 26 | }, 27 | deleteItem: (index) => { 28 | model.deleteItem(index) 29 | render(model.getState()) 30 | }, 31 | toggleItemCompleted: (index) => { 32 | model.toggleItemCompleted(index) 33 | render(model.getState()) 34 | }, 35 | completeAll: () => { 36 | model.completeAll() 37 | render(model.getState()) 38 | }, 39 | clearCompleted: () => { 40 | model.clearCompleted() 41 | render(model.getState()) 42 | }, 43 | changeFilter: filter => { 44 | model.changeFilter(filter) 45 | render(model.getState()) 46 | } 47 | } 48 | 49 | const render = (state) => { 50 | window.requestAnimationFrame(() => { 51 | const main = document.querySelector('#root') 52 | 53 | const newMain = registry.renderRoot( 54 | main, 55 | state, 56 | events) 57 | 58 | applyDiff(document.body, main, newMain) 59 | }) 60 | } 61 | 62 | render(model.getState()) 63 | -------------------------------------------------------------------------------- /Chapter07/00/model/model.test.js: -------------------------------------------------------------------------------- 1 | import modelFactory from './model.js' 2 | 3 | describe('TodoMVC Model', () => { 4 | test('data should be immutable', () => { 5 | const model = modelFactory() 6 | 7 | expect(() => { 8 | model.getState().currentFilter = 'WRONG' 9 | }).toThrow() 10 | }) 11 | 12 | test('should add an item', () => { 13 | const model = modelFactory() 14 | 15 | model.addItem('dummy') 16 | 17 | const { todos } = model.getState() 18 | 19 | expect(todos.length).toBe(1) 20 | expect(todos[0]).toEqual({ 21 | text: 'dummy', 22 | completed: false 23 | }) 24 | }) 25 | 26 | test('should not add an item when a falsy text is provided', () => { 27 | const model = modelFactory() 28 | 29 | model.addItem('') 30 | model.addItem(undefined) 31 | model.addItem(0) 32 | model.addItem() 33 | model.addItem(false) 34 | 35 | const { todos } = model.getState() 36 | 37 | expect(todos.length).toBe(0) 38 | }) 39 | 40 | test('should update an item', () => { 41 | const model = modelFactory({ 42 | todos: [{ 43 | text: 'dummy', 44 | completed: false 45 | }] 46 | }) 47 | 48 | model.updateItem(0, 'new-dummy') 49 | 50 | const { todos } = model.getState() 51 | 52 | expect(todos[0].text).toBe('new-dummy') 53 | }) 54 | 55 | test('should not update an item when an invalid index is provided', () => { 56 | const model = modelFactory({ 57 | todos: [{ 58 | text: 'dummy', 59 | completed: false 60 | }] 61 | }) 62 | 63 | model.updateItem(1, 'new-dummy') 64 | 65 | const { todos } = model.getState() 66 | 67 | expect(todos[0].text).toBe('dummy') 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /Chapter07/00/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state, events) => { 5 | const element = component(targetElement, state, events) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state, events)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state, events) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state, events) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter07/00/view/app.js: -------------------------------------------------------------------------------- 1 | let template 2 | 3 | const allTodosCompleted = todos => { 4 | if (todos.length === 0) { 5 | return false 6 | } 7 | return !todos.find(t => !t.completed) 8 | } 9 | 10 | const noCompletedItemIsPresent = todos => !todos.find(t => t.completed) 11 | 12 | const getTemplate = () => { 13 | if (!template) { 14 | template = document.getElementById('todo-app') 15 | } 16 | 17 | return template 18 | .content 19 | .firstElementChild 20 | .cloneNode(true) 21 | } 22 | 23 | const addEvents = (targetElement, events) => { 24 | const { clearCompleted, completeAll, addItem } = events 25 | 26 | targetElement 27 | .querySelector('.new-todo') 28 | .addEventListener('keypress', e => { 29 | if (e.key === 'Enter') { 30 | addItem(e.target.value) 31 | e.target.value = '' 32 | } 33 | }) 34 | 35 | targetElement 36 | .querySelector('input.toggle-all') 37 | .addEventListener('click', completeAll) 38 | 39 | targetElement 40 | .querySelector('.clear-completed') 41 | .addEventListener('click', clearCompleted) 42 | } 43 | 44 | export default (targetElement, state, events) => { 45 | const newApp = targetElement.cloneNode(true) 46 | 47 | newApp.innerHTML = '' 48 | newApp.appendChild(getTemplate()) 49 | 50 | if (noCompletedItemIsPresent(state.todos)) { 51 | newApp 52 | .querySelector('.clear-completed') 53 | .classList 54 | .add('hidden') 55 | } else { 56 | newApp 57 | .querySelector('.clear-completed') 58 | .classList 59 | .remove('hidden') 60 | } 61 | 62 | newApp 63 | .querySelector('input.toggle-all') 64 | .checked = allTodosCompleted(state.todos) 65 | 66 | addEvents(newApp, events) 67 | 68 | return newApp 69 | } 70 | -------------------------------------------------------------------------------- /Chapter07/00/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter07/00/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }, { changeFilter }) => { 2 | const newFilters = targetElement.cloneNode(true) 3 | 4 | Array 5 | .from(newFilters.querySelectorAll('li a')) 6 | .forEach(a => { 7 | if (a.textContent === currentFilter) { 8 | a.classList.add('selected') 9 | } else { 10 | a.classList.remove('selected') 11 | } 12 | 13 | a.addEventListener('click', e => { 14 | e.preventDefault() 15 | changeFilter(a.textContent) 16 | }) 17 | }) 18 | 19 | return newFilters 20 | } 21 | -------------------------------------------------------------------------------- /Chapter07/01.1/index.js: -------------------------------------------------------------------------------- 1 | import todosView from './view/todos.js' 2 | import counterView from './view/counter.js' 3 | import filtersView from './view/filters.js' 4 | import appView from './view/app.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | import stateFactory from './model/state.js' 10 | 11 | registry.add('app', appView) 12 | registry.add('todos', todosView) 13 | registry.add('counter', counterView) 14 | registry.add('filters', filtersView) 15 | 16 | const loadState = () => { 17 | const serializedState = window 18 | .localStorage 19 | .getItem('state') 20 | 21 | if (!serializedState) { 22 | return 23 | } 24 | 25 | return JSON.parse(serializedState) 26 | } 27 | 28 | const state = stateFactory(loadState()) 29 | 30 | const { 31 | addChangeListener, 32 | ...events 33 | } = state 34 | 35 | const render = (state) => { 36 | window.requestAnimationFrame(() => { 37 | const main = document.querySelector('#root') 38 | 39 | const newMain = registry.renderRoot( 40 | main, 41 | state, 42 | events) 43 | 44 | applyDiff(document.body, main, newMain) 45 | }) 46 | } 47 | 48 | addChangeListener(render) 49 | 50 | addChangeListener(state => { 51 | Promise.resolve().then(() => { 52 | window 53 | .localStorage 54 | .setItem('state', JSON.stringify(state)) 55 | }) 56 | }) 57 | 58 | addChangeListener(state => { 59 | console.log( 60 | `Current State (${(new Date()).getTime()})`, 61 | state 62 | ) 63 | }) 64 | -------------------------------------------------------------------------------- /Chapter07/01.1/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state, events) => { 5 | const element = component(targetElement, state, events) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state, events)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state, events) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state, events) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter07/01.1/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter07/01.1/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }, { changeFilter }) => { 2 | const newFilters = targetElement.cloneNode(true) 3 | 4 | Array 5 | .from(newFilters.querySelectorAll('li a')) 6 | .forEach(a => { 7 | if (a.textContent === currentFilter) { 8 | a.classList.add('selected') 9 | } else { 10 | a.classList.remove('selected') 11 | } 12 | 13 | a.addEventListener('click', e => { 14 | e.preventDefault() 15 | changeFilter(a.textContent) 16 | }) 17 | }) 18 | 19 | return newFilters 20 | } 21 | -------------------------------------------------------------------------------- /Chapter07/01/index.js: -------------------------------------------------------------------------------- 1 | import todosView from './view/todos.js' 2 | import counterView from './view/counter.js' 3 | import filtersView from './view/filters.js' 4 | import appView from './view/app.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | import modelFactory from './model/model.js' 10 | 11 | registry.add('app', appView) 12 | registry.add('todos', todosView) 13 | registry.add('counter', counterView) 14 | registry.add('filters', filtersView) 15 | 16 | const model = modelFactory() 17 | 18 | const { 19 | addChangeListener, 20 | ...events 21 | } = model 22 | 23 | const render = (state) => { 24 | window.requestAnimationFrame(() => { 25 | const main = document.querySelector('#root') 26 | 27 | const newMain = registry.renderRoot( 28 | main, 29 | state, 30 | events) 31 | 32 | applyDiff(document.body, main, newMain) 33 | }) 34 | } 35 | 36 | addChangeListener(render) 37 | -------------------------------------------------------------------------------- /Chapter07/01/model/model.test.js: -------------------------------------------------------------------------------- 1 | import modelFactory from './model.js' 2 | let model 3 | 4 | describe('observable model', () => { 5 | beforeEach(() => { 6 | model = modelFactory() 7 | }) 8 | 9 | test('listeners should be invoked immediatly', () => { 10 | let counter = 0 11 | model.addChangeListener(data => { 12 | counter++ 13 | }) 14 | expect(counter).toBe(1) 15 | }) 16 | 17 | test('listeners should be invoked when changing data', () => { 18 | let counter = 0 19 | model.addChangeListener(data => { 20 | counter++ 21 | }) 22 | model.addItem('dummy') 23 | expect(counter).toBe(2) 24 | }) 25 | 26 | test('listeners should be removed when unsubscribing', () => { 27 | let counter = 0 28 | const unsubscribe = model.addChangeListener(data => { 29 | counter++ 30 | }) 31 | unsubscribe() 32 | model.addItem('dummy') 33 | expect(counter).toBe(1) 34 | }) 35 | 36 | test('state should be immutable', () => { 37 | model.addChangeListener(data => { 38 | expect(() => { 39 | data.currentFilter = 'WRONG' 40 | }).toThrow() 41 | }) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /Chapter07/01/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state, events) => { 5 | const element = component(targetElement, state, events) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state, events)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state, events) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state, events) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter07/01/view/app.js: -------------------------------------------------------------------------------- 1 | let template 2 | 3 | const allTodosCompleted = todos => { 4 | if (todos.length === 0) { 5 | return false 6 | } 7 | return !todos.find(t => !t.completed) 8 | } 9 | 10 | const noCompletedItemIsPresent = todos => !todos.find(t => t.completed) 11 | 12 | const getTemplate = () => { 13 | if (!template) { 14 | template = document.getElementById('todo-app') 15 | } 16 | 17 | return template 18 | .content 19 | .firstElementChild 20 | .cloneNode(true) 21 | } 22 | 23 | const addEvents = (targetElement, events) => { 24 | const { clearCompleted, completeAll, addItem } = events 25 | 26 | targetElement 27 | .querySelector('.new-todo') 28 | .addEventListener('keypress', e => { 29 | if (e.key === 'Enter') { 30 | addItem(e.target.value) 31 | e.target.value = '' 32 | } 33 | }) 34 | 35 | targetElement 36 | .querySelector('input.toggle-all') 37 | .addEventListener('click', completeAll) 38 | 39 | targetElement 40 | .querySelector('.clear-completed') 41 | .addEventListener('click', clearCompleted) 42 | } 43 | 44 | export default (targetElement, state, events) => { 45 | const newApp = targetElement.cloneNode(true) 46 | 47 | newApp.innerHTML = '' 48 | newApp.appendChild(getTemplate()) 49 | 50 | if (noCompletedItemIsPresent(state.todos)) { 51 | newApp 52 | .querySelector('.clear-completed') 53 | .classList 54 | .add('hidden') 55 | } else { 56 | newApp 57 | .querySelector('.clear-completed') 58 | .classList 59 | .remove('hidden') 60 | } 61 | 62 | newApp 63 | .querySelector('input.toggle-all') 64 | .checked = allTodosCompleted(state.todos) 65 | 66 | addEvents(newApp, events) 67 | 68 | return newApp 69 | } 70 | -------------------------------------------------------------------------------- /Chapter07/01/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter07/01/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }, { changeFilter }) => { 2 | const newFilters = targetElement.cloneNode(true) 3 | 4 | Array 5 | .from(newFilters.querySelectorAll('li a')) 6 | .forEach(a => { 7 | if (a.textContent === currentFilter) { 8 | a.classList.add('selected') 9 | } else { 10 | a.classList.remove('selected') 11 | } 12 | 13 | a.addEventListener('click', e => { 14 | e.preventDefault() 15 | changeFilter(a.textContent) 16 | }) 17 | }) 18 | 19 | return newFilters 20 | } 21 | -------------------------------------------------------------------------------- /Chapter07/02.1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Frameworkless Frontend Development: State Management 9 | 10 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Chapter07/02.1/index.js: -------------------------------------------------------------------------------- 1 | const base = { 2 | foo: 'bar' 3 | } 4 | 5 | const handler = { 6 | get: (target, name) => { 7 | console.log(`Getting ${name}`) 8 | return target[name] 9 | }, 10 | set: (target, name, value) => { 11 | console.log(`Setting ${name} to ${value}`) 12 | target[name] = value 13 | return true 14 | } 15 | } 16 | 17 | const proxy = new Proxy(base, handler) 18 | 19 | proxy.foo = 'baz' 20 | console.log(`Logging ${proxy.foo}`) 21 | -------------------------------------------------------------------------------- /Chapter07/02.2/index.js: -------------------------------------------------------------------------------- 1 | import todosView from './view/todos.js' 2 | import counterView from './view/counter.js' 3 | import filtersView from './view/filters.js' 4 | import appView from './view/app.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | import actionsFactory from './model/model.js' 10 | 11 | registry.add('app', appView) 12 | registry.add('todos', todosView) 13 | registry.add('counter', counterView) 14 | registry.add('filters', filtersView) 15 | 16 | const actions = actionsFactory() 17 | 18 | const render = (state) => { 19 | window.requestAnimationFrame(() => { 20 | const main = document.querySelector('#root') 21 | 22 | const newMain = registry.renderRoot( 23 | main, 24 | state, 25 | actions) 26 | 27 | applyDiff(document.body, main, newMain) 28 | }) 29 | } 30 | 31 | actions.addChangeListener(render) 32 | -------------------------------------------------------------------------------- /Chapter07/02.2/model/observable.js: -------------------------------------------------------------------------------- 1 | const cloneDeep = x => { 2 | return JSON.parse(JSON.stringify(x)) 3 | } 4 | 5 | const freeze = state => Object.freeze(cloneDeep(state)) 6 | 7 | export default (initialState) => { 8 | let listeners = [] 9 | 10 | const proxy = new Proxy(cloneDeep(initialState), { 11 | set: (target, name, value) => { 12 | target[name] = value 13 | listeners.forEach(l => l(freeze(proxy))) 14 | return true 15 | } 16 | }) 17 | 18 | proxy.addChangeListener = cb => { 19 | listeners.push(cb) 20 | cb(freeze(proxy)) 21 | return () => { 22 | listeners = listeners.filter(l => l !== cb) 23 | } 24 | } 25 | 26 | return proxy 27 | } 28 | -------------------------------------------------------------------------------- /Chapter07/02.2/model/observable.test.js: -------------------------------------------------------------------------------- 1 | import observableFactory from './observable.js' 2 | 3 | let observable 4 | 5 | describe('observable factory with proxy', () => { 6 | beforeEach(() => { 7 | observable = observableFactory({ 8 | property: 'value' 9 | }) 10 | }) 11 | 12 | test('listeners should be invoked immediatly', () => { 13 | let counter = 0 14 | observable.addChangeListener(data => { 15 | counter++ 16 | }) 17 | expect(counter).toBe(1) 18 | }) 19 | 20 | test('listeners should be invoked when changing data', () => { 21 | let counter = 0 22 | observable.addChangeListener(data => { 23 | counter++ 24 | }) 25 | observable.property = 'another value' 26 | expect(counter).toBe(2) 27 | }) 28 | 29 | test('listeners should be removed when unsubscribing', () => { 30 | let counter = 0 31 | const unsubscribe = observable.addChangeListener(data => { 32 | counter++ 33 | }) 34 | unsubscribe() 35 | observable.property = 'another value' 36 | expect(counter).toBe(1) 37 | }) 38 | 39 | test('in listeners state should be immutable', () => { 40 | observable.addChangeListener(data => { 41 | expect(() => { 42 | data.property = 'another value' 43 | }).toThrow() 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /Chapter07/02.2/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state, events) => { 5 | const element = component(targetElement, state, events) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state, events)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state, events) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state, events) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter07/02.2/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter07/02.2/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }, { changeFilter }) => { 2 | const newFilters = targetElement.cloneNode(true) 3 | 4 | Array 5 | .from(newFilters.querySelectorAll('li a')) 6 | .forEach(a => { 7 | if (a.textContent === currentFilter) { 8 | a.classList.add('selected') 9 | } else { 10 | a.classList.remove('selected') 11 | } 12 | 13 | a.addEventListener('click', e => { 14 | e.preventDefault() 15 | changeFilter(a.textContent) 16 | }) 17 | }) 18 | 19 | return newFilters 20 | } 21 | -------------------------------------------------------------------------------- /Chapter07/02/index.js: -------------------------------------------------------------------------------- 1 | import todosView from './view/todos.js' 2 | import counterView from './view/counter.js' 3 | import filtersView from './view/filters.js' 4 | import appView from './view/app.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | import modelFactory from './model/model.js' 10 | 11 | registry.add('app', appView) 12 | registry.add('todos', todosView) 13 | registry.add('counter', counterView) 14 | registry.add('filters', filtersView) 15 | 16 | const model = modelFactory() 17 | 18 | const { 19 | addChangeListener, 20 | ...events 21 | } = model 22 | 23 | const render = (state) => { 24 | window.requestAnimationFrame(() => { 25 | const main = document.querySelector('#root') 26 | 27 | const newMain = registry.renderRoot( 28 | main, 29 | state, 30 | events) 31 | 32 | applyDiff(document.body, main, newMain) 33 | }) 34 | } 35 | 36 | addChangeListener(render) 37 | -------------------------------------------------------------------------------- /Chapter07/02/model/observable.js: -------------------------------------------------------------------------------- 1 | const cloneDeep = x => { 2 | return JSON.parse(JSON.stringify(x)) 3 | } 4 | 5 | const freeze = state => Object.freeze(cloneDeep(state)) 6 | 7 | export default (model, stateGetter) => { 8 | let listeners = [] 9 | 10 | const addChangeListener = cb => { 11 | listeners.push(cb) 12 | cb(freeze(stateGetter())) 13 | return () => { 14 | listeners = listeners 15 | .filter(element => element !== cb) 16 | } 17 | } 18 | 19 | const invokeListeners = () => { 20 | const data = freeze(stateGetter()) 21 | listeners.forEach(l => l(data)) 22 | } 23 | 24 | const wrapAction = originalAction => { 25 | return (...args) => { 26 | const value = originalAction(...args) 27 | invokeListeners() 28 | return value 29 | } 30 | } 31 | 32 | const baseProxy = { 33 | addChangeListener 34 | } 35 | 36 | return Object 37 | .keys(model) 38 | .filter(key => { 39 | return typeof model[key] === 'function' 40 | }) 41 | .reduce((proxy, key) => { 42 | const action = model[key] 43 | return { 44 | ...proxy, 45 | [key]: wrapAction(action) 46 | } 47 | }, baseProxy) 48 | } 49 | -------------------------------------------------------------------------------- /Chapter07/02/model/observable.test.js: -------------------------------------------------------------------------------- 1 | import observableFactory from './observable.js' 2 | 3 | let observable 4 | let state 5 | const actions = { 6 | aDummySetter: data => { 7 | state = data 8 | } 9 | } 10 | 11 | describe('observable factory', () => { 12 | beforeEach(() => { 13 | state = {} 14 | observable = observableFactory(actions, () => state) 15 | }) 16 | 17 | test('listeners should be invoked immediatly', () => { 18 | let counter = 0 19 | observable.addChangeListener(data => { 20 | counter++ 21 | }) 22 | expect(counter).toBe(1) 23 | }) 24 | 25 | test('listeners should be invoked when changing data', () => { 26 | let counter = 0 27 | observable.addChangeListener(data => { 28 | counter++ 29 | }) 30 | observable.aDummySetter('Value') 31 | expect(counter).toBe(2) 32 | }) 33 | 34 | test('listeners should be removed when unsubscribing', () => { 35 | let counter = 0 36 | const unsubscribe = observable.addChangeListener(data => { 37 | counter++ 38 | }) 39 | unsubscribe() 40 | observable.aDummySetter('Value') 41 | expect(counter).toBe(1) 42 | }) 43 | 44 | test('in listeners state should be immutable', () => { 45 | observable.aDummySetter({ 46 | name: 'Value' 47 | }) 48 | observable.addChangeListener(data => { 49 | expect(() => { 50 | data.name = 'Another Value' 51 | }).toThrow() 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /Chapter07/02/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state, events) => { 5 | const element = component(targetElement, state, events) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state, events)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state, events) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state, events) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter07/02/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter07/02/view/filters.js: -------------------------------------------------------------------------------- 1 | export default (targetElement, { currentFilter }, { changeFilter }) => { 2 | const newFilters = targetElement.cloneNode(true) 3 | 4 | Array 5 | .from(newFilters.querySelectorAll('li a')) 6 | .forEach(a => { 7 | if (a.textContent === currentFilter) { 8 | a.classList.add('selected') 9 | } else { 10 | a.classList.remove('selected') 11 | } 12 | 13 | a.addEventListener('click', e => { 14 | e.preventDefault() 15 | changeFilter(a.textContent) 16 | }) 17 | }) 18 | 19 | return newFilters 20 | } 21 | -------------------------------------------------------------------------------- /Chapter07/03.1/index.js: -------------------------------------------------------------------------------- 1 | import todosView from './view/todos.js' 2 | import counterView from './view/counter.js' 3 | import filtersView from './view/filters.js' 4 | import appView from './view/app.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | import eventBusFactory from './model/eventBus.js' 10 | import modelFactory from './model/model.js' 11 | 12 | registry.add('app', appView) 13 | registry.add('todos', todosView) 14 | registry.add('counter', counterView) 15 | registry.add('filters', filtersView) 16 | 17 | const modifiers = modelFactory() 18 | const eventBus = eventBusFactory(modifiers) 19 | 20 | const render = (state) => { 21 | window.requestAnimationFrame(() => { 22 | const main = document.querySelector('#root') 23 | 24 | const newMain = registry.renderRoot( 25 | main, 26 | state, 27 | eventBus.dispatch) 28 | 29 | applyDiff(document.body, main, newMain) 30 | }) 31 | } 32 | 33 | eventBus.subscribe(render) 34 | 35 | render(eventBus.getState()) 36 | -------------------------------------------------------------------------------- /Chapter07/03.1/model/eventBus.js: -------------------------------------------------------------------------------- 1 | const cloneDeep = x => { 2 | return JSON.parse(JSON.stringify(x)) 3 | } 4 | 5 | const freeze = state => Object.freeze(cloneDeep(state)) 6 | 7 | export default (model) => { 8 | let listeners = [] 9 | let state = model() 10 | 11 | const subscribe = listener => { 12 | listeners.push(listener) 13 | 14 | return () => { 15 | listeners = listeners.filter(l => l !== listener) 16 | } 17 | } 18 | 19 | const invokeSubscribers = () => { 20 | const data = freeze(state) 21 | listeners.forEach(l => l(data)) 22 | } 23 | 24 | const dispatch = event => { 25 | const newState = model(state, event) 26 | 27 | if (!newState) { 28 | throw new Error('modifiers should always return a value') 29 | } 30 | 31 | if (newState === state) { 32 | return 33 | } 34 | 35 | state = newState 36 | 37 | invokeSubscribers() 38 | } 39 | return { 40 | subscribe, 41 | dispatch, 42 | getState: () => freeze(state) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Chapter07/03.1/model/eventCreators.js: -------------------------------------------------------------------------------- 1 | const EVENT_TYPES = Object.freeze({ 2 | ITEM_ADDED: 'ITEM_ADDED', 3 | ITEM_UPDATED: 'ITEM_UPDATED', 4 | ITEM_DELETED: 'ITEM_DELETED', 5 | ITEMS_COMPLETED_TOGGLED: 'ITEMS_COMPLETED_TOGGLED', 6 | ITEMS_MARKED_AS_COMPLETED: 'ITEMS_MARKED_AS_COMPLETED', 7 | COMPLETED_ITEM_DELETED: 'COMPLETED_ITEM_DELETED', 8 | FILTER_CHANGED: 'FILTER_CHANGED' 9 | }) 10 | 11 | export default { 12 | addItem: text => ({ 13 | type: EVENT_TYPES.ITEM_ADDED, 14 | payload: text 15 | }), 16 | updateItem: (index, text) => ({ 17 | type: EVENT_TYPES.ITEM_UPDATED, 18 | payload: { 19 | text, 20 | index 21 | } 22 | }), 23 | deleteItem: index => ({ 24 | type: EVENT_TYPES.ITEM_DELETED, 25 | payload: index 26 | }), 27 | toggleItemCompleted: index => ({ 28 | type: EVENT_TYPES.ITEMS_COMPLETED_TOGGLED, 29 | payload: index 30 | }), 31 | completeAll: () => ({ 32 | type: EVENT_TYPES.ITEMS_MARKED_AS_COMPLETED 33 | }), 34 | clearCompleted: () => ({ 35 | type: EVENT_TYPES.COMPLETED_ITEM_DELETED 36 | }), 37 | changeFilter: filter => ({ 38 | type: EVENT_TYPES.FILTER_CHANGED, 39 | payload: filter 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /Chapter07/03.1/model/filter.js: -------------------------------------------------------------------------------- 1 | const changeFilter = (state, event) => { 2 | return event.payload 3 | } 4 | 5 | const modifiers = { 6 | FILTER_CHANGED: changeFilter 7 | } 8 | 9 | export default (prevState, event) => { 10 | if (!event) { 11 | return 'All' 12 | } 13 | 14 | const currentModifier = modifiers[event.type] 15 | 16 | if (!currentModifier) { 17 | return prevState 18 | } 19 | 20 | return currentModifier(prevState, event) 21 | } 22 | -------------------------------------------------------------------------------- /Chapter07/03.1/model/model.js: -------------------------------------------------------------------------------- 1 | import todosModifers from './todos.js' 2 | import filterModifers from './filter.js' 3 | 4 | const cloneDeep = x => { 5 | return JSON.parse(JSON.stringify(x)) 6 | } 7 | 8 | const INITIAL_STATE = { 9 | todos: [], 10 | currentFilter: 'All' 11 | } 12 | 13 | export default (initalState = INITIAL_STATE) => { 14 | return (prevState, event) => { 15 | if (!event) { 16 | return cloneDeep(initalState) 17 | } 18 | 19 | const { 20 | todos, 21 | currentFilter 22 | } = prevState 23 | 24 | const newTodos = todosModifers(todos, event) 25 | const newCurrentFilter = filterModifers(currentFilter, event) 26 | 27 | if (newTodos === todos && newCurrentFilter === currentFilter) { 28 | return prevState 29 | } 30 | 31 | return { 32 | todos: newTodos, 33 | currentFilter: newCurrentFilter 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Chapter07/03.1/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state, events) => { 5 | const element = component(targetElement, state, events) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state, events)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state, events) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state, events) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter07/03.1/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter07/03.1/view/filters.js: -------------------------------------------------------------------------------- 1 | import eventCreators from '../model/eventCreators.js' 2 | 3 | export default (targetElement, { currentFilter }, dispatch) => { 4 | const newFilters = targetElement.cloneNode(true) 5 | 6 | Array 7 | .from(newFilters.querySelectorAll('li a')) 8 | .forEach(a => { 9 | if (a.textContent === currentFilter) { 10 | a.classList.add('selected') 11 | } else { 12 | a.classList.remove('selected') 13 | } 14 | 15 | a.addEventListener('click', e => { 16 | e.preventDefault() 17 | dispatch(eventCreators.changeFilter(a.textContent)) 18 | }) 19 | }) 20 | 21 | return newFilters 22 | } 23 | -------------------------------------------------------------------------------- /Chapter07/03/index.js: -------------------------------------------------------------------------------- 1 | import todosView from './view/todos.js' 2 | import counterView from './view/counter.js' 3 | import filtersView from './view/filters.js' 4 | import appView from './view/app.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | import eventBusFactory from './model/eventBus.js' 10 | import modelFactory from './model/model.js' 11 | 12 | registry.add('app', appView) 13 | registry.add('todos', todosView) 14 | registry.add('counter', counterView) 15 | registry.add('filters', filtersView) 16 | 17 | const model = modelFactory() 18 | const eventBus = eventBusFactory(model) 19 | 20 | const render = (state) => { 21 | window.requestAnimationFrame(() => { 22 | const main = document.querySelector('#root') 23 | 24 | const newMain = registry.renderRoot( 25 | main, 26 | state, 27 | eventBus.dispatch) 28 | 29 | applyDiff(document.body, main, newMain) 30 | }) 31 | } 32 | 33 | eventBus.subscribe(render) 34 | 35 | render(eventBus.getState()) 36 | -------------------------------------------------------------------------------- /Chapter07/03/model/eventBus.js: -------------------------------------------------------------------------------- 1 | const cloneDeep = x => { 2 | return JSON.parse(JSON.stringify(x)) 3 | } 4 | 5 | const freeze = state => Object.freeze(cloneDeep(state)) 6 | 7 | export default (model) => { 8 | let listeners = [] 9 | let state = model() 10 | 11 | const subscribe = listener => { 12 | listeners.push(listener) 13 | 14 | return () => { 15 | listeners = listeners.filter(l => l !== listener) 16 | } 17 | } 18 | 19 | const invokeSubscribers = () => { 20 | const data = freeze(state) 21 | listeners.forEach(l => l(data)) 22 | } 23 | 24 | const dispatch = event => { 25 | const newState = model(state, event) 26 | 27 | if (!newState) { 28 | throw new Error('model should always return a value') 29 | } 30 | 31 | if (newState === state) { 32 | return 33 | } 34 | 35 | state = newState 36 | 37 | invokeSubscribers() 38 | } 39 | return { 40 | subscribe, 41 | dispatch, 42 | getState: () => freeze(state) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Chapter07/03/model/eventBus.test.js: -------------------------------------------------------------------------------- 1 | import eventBusFactory from './eventBus' 2 | let eventBus 3 | 4 | const counterModel = (state, event) => { 5 | if (!event) { 6 | return { 7 | counter: 0 8 | } 9 | } 10 | 11 | if (event.type !== 'COUNTER') { 12 | return state 13 | } 14 | 15 | return { 16 | counter: state.counter++ 17 | } 18 | } 19 | 20 | describe('eventBus', () => { 21 | beforeEach(() => { 22 | eventBus = eventBusFactory(counterModel) 23 | }) 24 | 25 | test('subscribers should be invoked when the model catch the event', () => { 26 | let counter = 0 27 | 28 | eventBus.subscribe(() => counter++) 29 | 30 | eventBus.dispatch({ type: 'COUNTER' }) 31 | 32 | expect(counter).toBe(1) 33 | }) 34 | 35 | test('subscribers should not be invoked when the model does not catch the event', () => { 36 | let counter = 0 37 | 38 | eventBus.subscribe(() => counter++) 39 | 40 | eventBus.dispatch({ type: 'NOT_COUNTER' }) 41 | 42 | expect(counter).toBe(0) 43 | }) 44 | 45 | test('subscribers should receive an immutable state', () => { 46 | eventBus.dispatch({ type: 'COUNTER' }) 47 | eventBus.subscribe((state) => { 48 | expect(() => { 49 | state.counter = 0 50 | }).toThrow() 51 | }) 52 | }) 53 | 54 | test('should throw error in the model does not return a state', () => { 55 | const eventBus = eventBusFactory(() => { 56 | return undefined 57 | }) 58 | 59 | expect(() => { 60 | eventBus.dispatch({ type: 'EVENT' }) 61 | }).toThrow() 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /Chapter07/03/model/eventCreators.js: -------------------------------------------------------------------------------- 1 | const EVENT_TYPES = Object.freeze({ 2 | ITEM_ADDED: 'ITEM_ADDED', 3 | ITEM_UPDATED: 'ITEM_UPDATED', 4 | ITEM_DELETED: 'ITEM_DELETED', 5 | ITEMS_COMPLETED_TOGGLED: 'ITEMS_COMPLETED_TOGGLED', 6 | ITEMS_MARKED_AS_COMPLETED: 'ITEMS_MARKED_AS_COMPLETED', 7 | COMPLETED_ITEM_DELETED: 'COMPLETED_ITEM_DELETED', 8 | FILTER_CHANGED: 'FILTER_CHANGED' 9 | }) 10 | 11 | export default { 12 | addItem: text => ({ 13 | type: EVENT_TYPES.ITEM_ADDED, 14 | payload: text 15 | }), 16 | updateItem: (index, text) => ({ 17 | type: EVENT_TYPES.ITEM_UPDATED, 18 | payload: { 19 | text, 20 | index 21 | } 22 | }), 23 | deleteItem: index => ({ 24 | type: EVENT_TYPES.ITEM_DELETED, 25 | payload: index 26 | }), 27 | toggleItemCompleted: index => ({ 28 | type: EVENT_TYPES.ITEMS_COMPLETED_TOGGLED, 29 | payload: index 30 | }), 31 | completeAll: () => ({ 32 | type: EVENT_TYPES.ITEMS_MARKED_AS_COMPLETED 33 | }), 34 | clearCompleted: () => ({ 35 | type: EVENT_TYPES.COMPLETED_ITEM_DELETED 36 | }), 37 | changeFilter: filter => ({ 38 | type: EVENT_TYPES.FILTER_CHANGED, 39 | payload: filter 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /Chapter07/03/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state, events) => { 5 | const element = component(targetElement, state, events) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state, events)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state, events) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state, events) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter07/03/view/app.js: -------------------------------------------------------------------------------- 1 | import eventCreators from '../model/eventCreators.js' 2 | 3 | let template 4 | 5 | const getTemplate = () => { 6 | if (!template) { 7 | template = document.getElementById('todo-app') 8 | } 9 | 10 | return template 11 | .content 12 | .firstElementChild 13 | .cloneNode(true) 14 | } 15 | 16 | const addEvents = (targetElement, dispatch) => { 17 | targetElement 18 | .querySelector('.new-todo') 19 | .addEventListener('keypress', e => { 20 | if (e.key === 'Enter') { 21 | const event = eventCreators 22 | .addItem(e.target.value) 23 | dispatch(event) 24 | e.target.value = '' 25 | } 26 | }) 27 | } 28 | 29 | export default (targetElement, state, dispatch) => { 30 | const newApp = targetElement.cloneNode(true) 31 | 32 | newApp.innerHTML = '' 33 | newApp.appendChild(getTemplate()) 34 | 35 | addEvents(newApp, dispatch) 36 | 37 | return newApp 38 | } 39 | -------------------------------------------------------------------------------- /Chapter07/03/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter07/03/view/filters.js: -------------------------------------------------------------------------------- 1 | import eventCreators from '../model/eventCreators.js' 2 | 3 | export default (targetElement, { currentFilter }, dispatch) => { 4 | const newFilters = targetElement.cloneNode(true) 5 | 6 | Array 7 | .from(newFilters.querySelectorAll('li a')) 8 | .forEach(a => { 9 | if (a.textContent === currentFilter) { 10 | a.classList.add('selected') 11 | } else { 12 | a.classList.remove('selected') 13 | } 14 | 15 | a.addEventListener('click', e => { 16 | e.preventDefault() 17 | dispatch(eventCreators.changeFilter(a.textContent)) 18 | }) 19 | }) 20 | 21 | return newFilters 22 | } 23 | -------------------------------------------------------------------------------- /Chapter07/04/index.js: -------------------------------------------------------------------------------- 1 | import todosView from './view/todos.js' 2 | import counterView from './view/counter.js' 3 | import filtersView from './view/filters.js' 4 | import appView from './view/app.js' 5 | import applyDiff from './applyDiff.js' 6 | 7 | import registry from './registry.js' 8 | 9 | import reducer from './model/reducer.js' 10 | 11 | registry.add('app', appView) 12 | registry.add('todos', todosView) 13 | registry.add('counter', counterView) 14 | registry.add('filters', filtersView) 15 | 16 | const INITIAL_STATE = { 17 | todos: [], 18 | currentFilter: 'All' 19 | } 20 | 21 | const { 22 | createStore 23 | } = Redux 24 | 25 | const store = createStore( 26 | reducer, 27 | INITIAL_STATE, 28 | window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() 29 | ) 30 | 31 | const render = () => { 32 | window.requestAnimationFrame(() => { 33 | const main = document.querySelector('#root') 34 | 35 | const newMain = registry.renderRoot( 36 | main, 37 | store.getState(), 38 | store.dispatch) 39 | 40 | applyDiff(document.body, main, newMain) 41 | }) 42 | } 43 | 44 | store.subscribe(render) 45 | 46 | render() 47 | -------------------------------------------------------------------------------- /Chapter07/04/model/actionCreators.js: -------------------------------------------------------------------------------- 1 | const ACTION_TYPES = Object.freeze({ 2 | ITEM_ADDED: 'ITEM_ADDED', 3 | ITEM_UPDATED: 'ITEM_UPDATED', 4 | ITEM_DELETED: 'ITEM_DELETED', 5 | ITEMS_COMPLETED_TOGGLED: 'ITEMS_COMPLETED_TOGGLED', 6 | ITEMS_MARKED_AS_COMPLETED: 'ITEMS_MARKED_AS_COMPLETED', 7 | COMPLETED_ITEM_DELETED: 'COMPLETED_ITEM_DELETED', 8 | FILTER_CHANGED: 'FILTER_CHANGED' 9 | }) 10 | 11 | export default { 12 | addItem: text => ({ 13 | type: ACTION_TYPES.ITEM_ADDED, 14 | payload: text 15 | }), 16 | updateItem: (index, text) => ({ 17 | type: ACTION_TYPES.ITEM_UPDATED, 18 | payload: { 19 | text, 20 | index 21 | } 22 | }), 23 | deleteItem: index => ({ 24 | type: ACTION_TYPES.ITEM_DELETED, 25 | payload: index 26 | }), 27 | toggleItemCompleted: index => ({ 28 | type: ACTION_TYPES.ITEMS_COMPLETED_TOGGLED, 29 | payload: index 30 | }), 31 | completeAll: () => ({ 32 | type: ACTION_TYPES.ITEMS_MARKED_AS_COMPLETED 33 | }), 34 | clearCompleted: () => ({ 35 | type: ACTION_TYPES.COMPLETED_ITEM_DELETED 36 | }), 37 | changeFilter: filter => ({ 38 | type: ACTION_TYPES.FILTER_CHANGED, 39 | payload: filter 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /Chapter07/04/registry.js: -------------------------------------------------------------------------------- 1 | const registry = {} 2 | 3 | const renderWrapper = component => { 4 | return (targetElement, state, events) => { 5 | const element = component(targetElement, state, events) 6 | 7 | const childComponents = element 8 | .querySelectorAll('[data-component]') 9 | 10 | Array 11 | .from(childComponents) 12 | .forEach(target => { 13 | const name = target 14 | .dataset 15 | .component 16 | 17 | const child = registry[name] 18 | if (!child) { 19 | return 20 | } 21 | 22 | target.replaceWith(child(target, state, events)) 23 | }) 24 | 25 | return element 26 | } 27 | } 28 | 29 | const add = (name, component) => { 30 | registry[name] = renderWrapper(component) 31 | } 32 | 33 | const renderRoot = (root, state, events) => { 34 | const cloneComponent = root => { 35 | return root.cloneNode(true) 36 | } 37 | 38 | return renderWrapper(cloneComponent)(root, state, events) 39 | } 40 | 41 | export default { 42 | add, 43 | renderRoot 44 | } 45 | -------------------------------------------------------------------------------- /Chapter07/04/view/counter.js: -------------------------------------------------------------------------------- 1 | const getTodoCount = todos => { 2 | const notCompleted = todos 3 | .filter(todo => !todo.completed) 4 | 5 | const { length } = notCompleted 6 | if (length === 1) { 7 | return '1 Item left' 8 | } 9 | 10 | return `${length} Items left` 11 | } 12 | 13 | export default (targetElement, { todos }) => { 14 | const newCounter = targetElement.cloneNode(true) 15 | newCounter.textContent = getTodoCount(todos) 16 | return newCounter 17 | } 18 | -------------------------------------------------------------------------------- /Chapter07/04/view/filters.js: -------------------------------------------------------------------------------- 1 | import actionCreators from '../model/actionCreators.js' 2 | 3 | export default (targetElement, { currentFilter }, dispatch) => { 4 | const newFilters = targetElement.cloneNode(true) 5 | 6 | Array 7 | .from(newFilters.querySelectorAll('li a')) 8 | .forEach(a => { 9 | if (a.textContent === currentFilter) { 10 | a.classList.add('selected') 11 | } else { 12 | a.classList.remove('selected') 13 | } 14 | 15 | a.addEventListener('click', e => { 16 | e.preventDefault() 17 | dispatch(actionCreators.changeFilter(a.textContent)) 18 | }) 19 | }) 20 | 21 | return newFilters 22 | } 23 | -------------------------------------------------------------------------------- /Chapter07/05.1/components/Application.js: -------------------------------------------------------------------------------- 1 | export default class App extends HTMLElement { 2 | constructor () { 3 | super() 4 | 5 | this.template = document 6 | .getElementById('todo-app') 7 | } 8 | 9 | deleteItem (index) { 10 | window 11 | .applicationContext 12 | .actions 13 | .deleteItem(index) 14 | } 15 | 16 | addItem (text) { 17 | window 18 | .applicationContext 19 | .actions 20 | .addItem(text) 21 | } 22 | 23 | connectedCallback () { 24 | window.requestAnimationFrame(() => { 25 | const content = this.template 26 | .content 27 | .firstElementChild 28 | .cloneNode(true) 29 | 30 | this.appendChild(content) 31 | 32 | this 33 | .querySelector('.new-todo') 34 | .addEventListener('keypress', e => { 35 | if (e.key === 'Enter') { 36 | this.addItem(e.target.value) 37 | e.target.value = '' 38 | } 39 | }) 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Chapter07/05.1/index.js: -------------------------------------------------------------------------------- 1 | import Application from './components/Application.js' 2 | import Footer from './components/Footer.js' 3 | import List from './components/List.js' 4 | 5 | import observableFactory from './model/observable.js' 6 | import actionsFactory from './model/actions.js' 7 | 8 | const INITIAL_STATE = { 9 | todos: [], 10 | currentFilter: 'All' 11 | } 12 | 13 | const observableState = observableFactory(INITIAL_STATE) 14 | const actions = actionsFactory(observableState) 15 | 16 | window.applicationContext = Object.freeze({ 17 | observableState, 18 | actions 19 | }) 20 | 21 | window.customElements.define('todomvc-app', Application) 22 | window.customElements.define('todomvc-footer', Footer) 23 | window.customElements.define('todomvc-list', List) 24 | -------------------------------------------------------------------------------- /Chapter07/05.1/model/observable.js: -------------------------------------------------------------------------------- 1 | const cloneDeep = x => { 2 | return JSON.parse(JSON.stringify(x)) 3 | } 4 | 5 | const freeze = state => Object.freeze(cloneDeep(state)) 6 | 7 | export default (initialState) => { 8 | let listeners = [] 9 | 10 | const proxy = new Proxy(cloneDeep(initialState), { 11 | set: (target, name, value) => { 12 | target[name] = value 13 | listeners.forEach(l => l(freeze(proxy))) 14 | return true 15 | } 16 | }) 17 | 18 | proxy.addChangeListener = cb => { 19 | listeners.push(cb) 20 | cb(freeze(proxy)) 21 | return () => { 22 | listeners = listeners.filter(element => element !== cb) 23 | } 24 | } 25 | 26 | return proxy 27 | } 28 | -------------------------------------------------------------------------------- /Chapter07/05/index.js: -------------------------------------------------------------------------------- 1 | import Application from './components/Application.js' 2 | import Footer from './components/Footer.js' 3 | import List from './components/List.js' 4 | 5 | window.customElements.define('todomvc-app', Application) 6 | window.customElements.define('todomvc-footer', Footer) 7 | window.customElements.define('todomvc-list', List) 8 | -------------------------------------------------------------------------------- /Chapter07/05/model/observable.js: -------------------------------------------------------------------------------- 1 | const cloneDeep = x => { 2 | return JSON.parse(JSON.stringify(x)) 3 | } 4 | 5 | const freeze = state => Object.freeze(cloneDeep(state)) 6 | 7 | export default (initialState) => { 8 | let listeners = [] 9 | 10 | const proxy = new Proxy(cloneDeep(initialState), { 11 | set: (target, name, value) => { 12 | target[name] = value 13 | listeners.forEach(l => l(freeze(proxy))) 14 | return true 15 | } 16 | }) 17 | 18 | proxy.addChangeListener = cb => { 19 | listeners.push(cb) 20 | cb(freeze(proxy)) 21 | return () => { 22 | listeners = listeners.filter(element => element !== cb) 23 | } 24 | } 25 | 26 | return proxy 27 | } 28 | -------------------------------------------------------------------------------- /Chapter07/README.md: -------------------------------------------------------------------------------- 1 | # Chapter 7 - State Management 2 | 3 | [![framework less](https://file-blyuofkggj.now.sh)](https://github.com/frameworkless-movement/manifesto) 4 | 5 | To start the examples just run: 6 | 7 | npm start -------------------------------------------------------------------------------- /Chapter07/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apress/frameworkless-front-end-development/747d119cd515f74d54cea67e92a31f45e0a803ea/Chapter07/favicon.ico -------------------------------------------------------------------------------- /Chapter07/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frameworkless Frontend Development: State Management 7 | 8 | 9 | 10 | 11 |

    Frameworkless Frontend Development: State Management

    12 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /Chapter07/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "http-server", 4 | "test": "jest" 5 | }, 6 | "devDependencies": { 7 | "babel-plugin-transform-es2015-modules-commonjs": "6.26.2", 8 | "http-server": "0.11.1", 9 | "jest": "23.6.0", 10 | "standard": "12.0.1" 11 | }, 12 | "standard": { 13 | "globals": [ 14 | "expect", 15 | "test", 16 | "beforeEach", 17 | "describe", 18 | "HTMLElement", 19 | "CustomEvent", 20 | "Redux" 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Chapter08/ADR-001.MD: -------------------------------------------------------------------------------- 1 | # ADR-001: Redux State Structure 2 | 3 | ## Context 4 | We have a React + Redux + Redux-Saga Cordova Application. Our product owner wants to test if the same application can run inside an Electron container. Right now our state contains both domain data and data relative to our particular UI. This is a roadblock for a real portable application. The "ui" part can use data, sagas and so on from the "core" part, but the contrary it's not permitted. 5 | 6 | ## Decision 7 | We will divide the redux state in two main "branches". The first one "core" will contain all the data that is relative to the domain of our application. The other branch is "ui", in this part of the state we keep the data relative to this specific UI (mobile application). For example the values in the forms or the open / close of menu or navigation data. In the future the "core" part could become a separate package that the Electron application could use. 8 | 9 | ## Status 10 | accepted 11 | 12 | ## Consequences 13 | Now it's easier to understand where to put variables in the Redux state. This is really helpful when try to solve a bug, because we usually know upfront where to look. Another consequence is that sometime we need some wrappers around sagas becase we can't just import ui part in the core. 14 | -------------------------------------------------------------------------------- /Contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to Apress Source Code 2 | 3 | Copyright for Apress source code belongs to the author(s). However, under fair use you are encouraged to fork and contribute minor corrections and updates for the benefit of the author(s) and other readers. 4 | 5 | ## How to Contribute 6 | 7 | 1. Make sure you have a GitHub account. 8 | 2. Fork the repository for the relevant book. 9 | 3. Create a new branch on which to make your change, e.g. 10 | `git checkout -b my_code_contribution` 11 | 4. Commit your change. Include a commit message describing the correction. Please note that if your commit message is not clear, the correction will not be accepted. 12 | 5. Submit a pull request. 13 | 14 | Thank you for your contribution! -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Francesco Strazzullo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Freeware License, some rights reserved 2 | 3 | Copyright (c) 2019 Francesco Strazzullo 4 | 5 | Permission is hereby granted, free of charge, to anyone obtaining a copy 6 | of this software and associated documentation files (the "Software"), 7 | to work with the Software within the limits of freeware distribution and fair use. 8 | This includes the rights to use, copy, and modify the Software for personal use. 9 | Users are also allowed and encouraged to submit corrections and modifications 10 | to the Software for the benefit of other users. 11 | 12 | It is not allowed to reuse, modify, or redistribute the Software for 13 | commercial use in any way, or for a user’s educational materials such as books 14 | or blog articles without prior permission from the copyright holder. 15 | 16 | The above copyright notice and this permission notice need to be included 17 | in all copies or substantial portions of the software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS OR APRESS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | 27 | 28 | -------------------------------------------------------------------------------- /errata.md: -------------------------------------------------------------------------------- 1 | # Errata for *Book Title* 2 | 3 | On **page xx** [Summary of error]: 4 | 5 | Details of error here. Highlight key pieces in **bold**. 6 | 7 | *** 8 | 9 | On **page xx** [Summary of error]: 10 | 11 | Details of error here. Highlight key pieces in **bold**. 12 | 13 | *** --------------------------------------------------------------------------------