├── .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 |
43 |
{ this.div = div }} className='box' />
44 | Toggle
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 | [](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 | ${text}
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 | ${text}
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 | ${text}
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 | ${text}
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 | ${text}
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 | [](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 | Click Here
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 | Click Here
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 | Click Here
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 |
Attribute Handler
12 |
addEventListener Handler (1)
13 |
addEventListener Handler (2)
14 |
addEventListener Handler (3)
15 |
Event Object example
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 | [](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 |
11 | DOM Events API
12 |
19 | TodoMVC Application with Events
20 |
27 |
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 |
Change everything to blue!
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 |
Change everything to blue!
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 |
23 |
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 |
23 |
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 | [](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 | [](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 |
Read Todos list
12 |
Add Todo
13 |
Update todo
14 |
Delete Todo
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 |
Read Todos list
12 |
Add Todo
13 |
Update todo
14 |
Delete Todo
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 |
Read Todos list
12 |
Add Todo
13 |
Update todo
14 |
Delete Todo
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 |
18 | Go To Index
19 |
20 |
21 | Go To List
22 |
23 |
24 | Dummy Page
25 |
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 | 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/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 |
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 |
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 | 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/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 |
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 | [](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 |
11 |
12 | Hash
13 |
18 |
19 |
20 | History API
21 |
25 |
26 | Navigo Example
27 |
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 | [](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 |
13 | External state
14 |
15 | Observable State
16 |
20 |
21 |
22 | Reactive Programming
23 |
28 |
29 |
30 | Event Bus
31 |
35 |
36 | Redux
37 |
38 | State Management with Web Components
39 |
43 |
44 |
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 | ***
--------------------------------------------------------------------------------