├── .babelrc ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── examples ├── 2048 │ ├── components │ │ ├── above-game.js │ │ ├── container.js │ │ ├── game.js │ │ ├── grid.js │ │ ├── heading.js │ │ ├── message.js │ │ ├── tile.js │ │ └── tiles.js │ ├── fonts │ │ ├── ClearSans-Bold-webfont.woff │ │ ├── ClearSans-Regular-webfont.woff │ │ └── clear-sans.css │ ├── game │ │ ├── .eslintrc │ │ ├── add.js │ │ ├── conf.js │ │ ├── end.js │ │ ├── init.js │ │ ├── move.js │ │ └── tile.js │ ├── index.css │ ├── index.html │ ├── index.js │ └── logic │ │ └── reducer.js ├── 2048-compat │ ├── components │ │ ├── above-game.js │ │ ├── container.js │ │ ├── game.js │ │ ├── grid.js │ │ ├── heading.js │ │ ├── message.js │ │ ├── tile.js │ │ └── tiles.js │ ├── fonts │ │ ├── ClearSans-Bold-webfont.woff │ │ ├── ClearSans-Regular-webfont.woff │ │ └── clear-sans.css │ ├── game │ │ ├── .eslintrc │ │ ├── add.js │ │ ├── conf.js │ │ ├── end.js │ │ ├── init.js │ │ ├── move.js │ │ └── tile.js │ ├── index.css │ ├── index.html │ ├── index.js │ └── logic │ │ └── reducer.js ├── counter │ ├── index.html │ └── index.js └── routing │ ├── index.html │ └── index.js ├── index.js ├── package-lock.json ├── package.json ├── router.js └── src ├── .eslintrc ├── __tests__ └── component │ ├── component.js │ ├── with-connected.js │ ├── with-handler.js │ ├── with-markup.js │ ├── with-prop.js │ ├── with-store.js │ └── with-style.js ├── component.js ├── parser.js ├── render.js ├── router ├── compo-path.js └── router.js ├── store.js └── utils.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { "targets": { "browsers": ["last 1 Chrome version"] } }] 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "browser": true 5 | }, 6 | "rules": { 7 | "no-shadow": "off", 8 | "no-underscore-dangle": "off", 9 | "no-param-reassign": "off", 10 | "class-methods-use-this": "off", 11 | "import/prefer-default-export": "off", 12 | "import/no-extraneous-dependencies": ["error", { "devDependencies": true }] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .cache 4 | .nyc_output 5 | .DS_Store 6 | coverage 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2018-present [Matthieu Lux](https://github.com/swiip) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Compo 2 | 3 | ![Travis Status](https://travis-ci.org/Swiip/compo.svg?branch=master) 4 | 5 | Compo is a JavaScript Web UI tiny library powering Web Components with a functional API and a Virtual DOM rendering. 6 | 7 | You have to **compo**·se your **compo**·nents by enriching them with each feature through a central composing function. Markup and Style are considered as a feature you can add to your components. 8 | 9 | ## Installation 10 | 11 | ``` 12 | npm install compo 13 | 14 | yarn add compo 15 | ``` 16 | 17 | ## Example 18 | 19 | ```javascript 20 | import { 21 | html, 22 | css, 23 | createStore, 24 | component, 25 | withProp, 26 | withStore, 27 | withStyle, 28 | withMarkup, 29 | } from 'compo'; 30 | 31 | createStore((state, action) => { 32 | switch (action.type) { 33 | case 'ADD': return state + 1; 34 | case 'SUB': return state - 1; 35 | default: return state; 36 | } 37 | }, 0); 38 | 39 | component( 40 | 'my-counter-label', 41 | withProp('value'), 42 | withStyle(({ value }) => css` 43 | :host { 44 | color: ${value < 1 ? 'red' : 'black'}; 45 | } 46 | `,), 47 | ); 48 | 49 | component( 50 | 'my-counter', 51 | withStore(({ getState, dispatch }) => ({ 52 | counter: getState(), 53 | add: () => dispatch({ type: 'ADD' }), 54 | sub: () => dispatch({ type: 'SUB' }), 55 | })), 56 | withMarkup(({ counter, add, sub }) => html` 57 |
58 | ${counter} 59 | 60 | 61 |
62 | `), 63 | ); 64 | ``` 65 | 66 | ## API 67 | 68 | ### component( name:String, ...enhancers:Array<(Component => Component)> ):void 69 | 70 | Define a [Custom Element](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements) with named `name` and enhanced by each enhancers. 71 | 72 | - `name` is directly passed to `customElement.define()` so you have to follow Web Components constraints such as a `-` in the name and only used once in the application. 73 | - `enhancers` are function taking in parameter a component class definition and returning a new one, most often my extending it. You can create your own but you can use all the `with` prefixed enhancers provided in the framework. 74 | 75 | ```javascript 76 | component( 77 | 'my-component', 78 | withProp('my-prop') 79 | ); 80 | ``` 81 | 82 | ### withMarkup( (props => Markup) ):(Component => Component) 83 | 84 | Define an enhancer which will render the `Markup` returned in the component and will re-render on every change detection. 85 | 86 | You'll obtain an `Markup` object by using the `html` tagged template described bellow. 87 | 88 | ```javascript 89 | component( 90 | 'my-component', 91 | withMarkup(() => html`
Hello World
`) 92 | ); 93 | ``` 94 | 95 | ### withStyle( (props => Style) ):(Component => Component) 96 | 97 | Define an enhancer which will add a `style` block with the `Style` returned and will update the style on every change detection. 98 | 99 | The `Style` object can be either a standard `string` or an object using the `css` tagged template described bellow. 100 | 101 | ```javascript 102 | component( 103 | 'my-component', 104 | withStyle(() => css`:host { color: red; }`) 105 | ); 106 | ``` 107 | 108 | ### withProp( name ):(Component => Component) 109 | 110 | Define an enhancer which will instrument and trigger an update on modification on the component property `name`. 111 | 112 | ```javascript 113 | component( 114 | 'my-component', 115 | withProp('my-prop') 116 | ); 117 | ``` 118 | 119 | ### withHandler( name, (props => handler) ):(Component => Component) 120 | 121 | Define an enhancer which will add a `name` property to the component with `handler` returned to be used in the markup. 122 | 123 | ```javascript 124 | component( 125 | 'my-component', 126 | withHandler(() => event => console.log('my handler', event)) 127 | ) 128 | ``` 129 | 130 | ### withConnected( (props => void) ):(Component => Component) 131 | 132 | Define an enhancer which will run the function in parameter when the component is connected corresponding to the Custom Element standard `connectedCallback` function. 133 | 134 | ```javascript 135 | component( 136 | 'my-component', 137 | withConnected(() => console.log('component connected')) 138 | ) 139 | ``` 140 | 141 | ### withStore( ((store, props) => object) ):(Component => Component) 142 | 143 | Define an enhancer which will run the function in parameter at every store updates and assign all return object properties to the component object. 144 | 145 | The store must be created beforehand by using `createStore` described bellow. 146 | 147 | ```javascript 148 | component( 149 | "my-component", 150 | withStore(({ getState, dispatch }) => { 151 | myData: getState().my.data, 152 | myAction: () => dispatch({ type: "MY_ACTION" }) 153 | }) 154 | ) 155 | ``` 156 | 157 | ### html 158 | 159 | ES2015 tagged template allowing to create DOM templates with rich interpolations. 160 | 161 | ```javascript 162 | html` 163 | 164 | ${content} 165 | 166 | ` 167 | ``` 168 | 169 | Known limitation: you currently can't use serveral interpolations in a single DOM node or property. 170 | 171 | ### css 172 | 173 | ES2015 tagged template allowing to create CSS content. 174 | 175 | To be perfectly honest it does absolutely nothing right now! Still reserving the API can be good and it triggers syntax highlighting in many editors. 176 | 177 | ```javascript 178 | css` 179 | my-component { 180 | color: red; 181 | } 182 | ` 183 | ``` 184 | 185 | ### createStore( ((state, action) => state), initialState ): Store 186 | Initialize the internal store with the reducer in argument. 187 | 188 | In contrary to Redux, you don't always need to get the `Store` returned. It's automatically passed to the `withStore` enhancer. 189 | 190 | ```javascript 191 | createStore((state, action) => { 192 | switch (action.type) { 193 | case 'ADD': return state + 1; 194 | case 'SUB': return state - 1; 195 | default: return state; 196 | } 197 | }, 0); 198 | ``` 199 | 200 | ## Router API 201 | 202 | ### withRouteEvent( ( url, props ) => void ):(Component => Component) 203 | 204 | Allow the component to have a callback on every url changes. 205 | 206 | ```javascript 207 | component( 208 | 'my-component', 209 | withRouteEvent((url) => console.log('new url', url)) 210 | ) 211 | ``` 212 | 213 | ### withRouteAction( [ handlerName ] = 'go' ):(Component => Component) 214 | 215 | Add a `handlerName` handler in the component which allow to trigger a routing to the url in parameter. 216 | 217 | ```javascript 218 | component( 219 | 'my-component', 220 | withRouteAction('navigate'), 221 | withHandler(({ navigate }) => (event) => navigate("/my-route")), 222 | ) 223 | ``` 224 | 225 | ### Component `compo-path` 226 | 227 | Built-in component allowing to insert a component depending on the current path. 228 | * `path`: the path which trigger the component. 229 | * `component`: the Web Component to use. 230 | 231 | ```html 232 | 233 | 234 | 235 | 236 | ``` 237 | 238 | ## Examples 239 | 240 | ### Counter 241 | 242 | Most basic example exactly the same as above in this readme. 243 | 244 | Try it in CodeSanbox: https://codesandbox.io/s/yv5y14o6pj 245 | 246 | ## 2048 247 | 248 | Advanced example implementing the popular 2048 game. 249 | 250 | Try it in CodeSanbox: https://codesandbox.io/s/k55w33zvkv 251 | 252 | ## 2048 compat 253 | 254 | Same as 2048 but with polyfill loaded to be tested on other browsers than Chrome 255 | 256 | *Strangely doesn't work yet on CodeSanbox* 257 | 258 | ## Routing 259 | 260 | Basic routing example using the integrated router 261 | 262 | ## Inspiration 263 | 264 | ### Other frameworks 265 | 266 | - [React](https://reactjs.org/) for the v-dom, applying changed by a diff mechanism. 267 | - [recompose](https://github.com/acdlite/recompose) for the composition API 268 | - [styled-components](https://www.styled-components.com/) for the CSS as ad integrant part as a component definition 269 | - [Redux](https://redux.js.org/) for the state management 270 | - [hyperapp](https://github.com/hyperapp/hyperapp) for proving that you can build a complete framework with only a few bytes 271 | 272 | ### Blogs 273 | 274 | - https://medium.com/@deathmood/how-to-write-your-own-virtual-dom-ee74acc13060 275 | - http://2ality.com/2014/07/jsx-template-strings.html 276 | - https://gist.github.com/lygaret/a68220defa69174bdec5 277 | 278 | ## Motivations 279 | 280 | It started with the exploration of the Web Components and Shadow DOM APIs and followed by the willing to use v-dom concepts in this contexts. 281 | 282 | Based upon that foundations, the objective was to have a functional API like _recompose_ to power Web Components. 283 | 284 | Minimalism and staying close and bounded to the standards. 285 | 286 | ## Compatibility 287 | 288 | Compo is not transpiled to old JavaScript and _really_ based upon Web Components so it only works out of the box on recent Chrome. It's also working quite well on Firefox 63.0 without any flag. 289 | 290 | It's planned to have a compatibility build using polyfills. 291 | 292 | ## Licence 293 | 294 | Compo is MIT licensed. See [LICENSE](./LICENSE.md). 295 | -------------------------------------------------------------------------------- /examples/2048-compat/components/above-game.js: -------------------------------------------------------------------------------- 1 | import { 2 | component, 3 | withStyle, 4 | withMarkup, 5 | html, 6 | css, 7 | } from '../../..'; 8 | 9 | component( 10 | 'swiip-above-game-container', 11 | withStyle(() => css` 12 | swiip-above-game-container { 13 | display: flex; 14 | flex-direction: row; 15 | justify-content: space-between; 16 | align-items: center; 17 | } 18 | `), 19 | ); 20 | 21 | component( 22 | 'swiip-restart-button', 23 | withStyle(() => css` 24 | swiip-restart-button { 25 | color: var(--light-text-white); 26 | background-color: var(--heavy-bg-brown); 27 | border-radius: 3px; 28 | padding: 0 20px; 29 | text-decoration: none; 30 | color: #f9f6f2; 31 | height: 40px; 32 | cursor: pointer; 33 | display: flex; 34 | text-align: center; 35 | justify-content: center; 36 | align-items: center; 37 | } 38 | `), 39 | ); 40 | 41 | const newGame = () => { 42 | console.log('New Game!'); 43 | }; 44 | 45 | component( 46 | 'swiip-above-game', 47 | withMarkup(() => html` 48 | 49 |

Join the numbers and get to the 2048 tile!

50 | New Game 51 |
52 | `), 53 | ); 54 | -------------------------------------------------------------------------------- /examples/2048-compat/components/container.js: -------------------------------------------------------------------------------- 1 | import { component, withStyle, css } from '../../..'; 2 | 3 | component( 4 | 'swiip-container', 5 | withStyle(() => css` 6 | swiip-container { 7 | display: block; 8 | width: 500px; 9 | margin: 0 auto; 10 | } 11 | `), 12 | ); 13 | -------------------------------------------------------------------------------- /examples/2048-compat/components/game.js: -------------------------------------------------------------------------------- 1 | import './grid'; 2 | import './tiles'; 3 | import './message'; 4 | 5 | import { 6 | component, 7 | withStyle, 8 | withMarkup, 9 | withStore, 10 | withHandler, 11 | withConnected, 12 | html, 13 | css, 14 | } from '../../..'; 15 | 16 | const keyMapping = { 17 | ArrowLeft: 0, 18 | ArrowUp: 1, 19 | ArrowRight: 2, 20 | ArrowDown: 3, 21 | }; 22 | 23 | component( 24 | 'swiip-game-container', 25 | withStyle(() => css` 26 | swiip-game-container { 27 | display: block; 28 | margin-top: 40px; 29 | position: relative; 30 | background: var(--light-bg-brown); 31 | border-radius: 6px; 32 | width: 500px; 33 | height: 500px; 34 | box-sizing: border-box; 35 | } 36 | `), 37 | ); 38 | 39 | component( 40 | 'swiip-game', 41 | withStore(({ dispatch }) => ({ 42 | move: key => dispatch({ 43 | type: 'MOVE', 44 | direction: keyMapping[key], 45 | randomPosition: Math.random(), 46 | randomValue: Math.random(), 47 | }), 48 | })), 49 | withHandler('keyHandler', ({ move }) => (event) => { 50 | if (keyMapping[event.key] !== undefined) { 51 | move(event.key); 52 | event.preventDefault(); 53 | } 54 | }), 55 | withConnected(({ keyHandler }) => { 56 | window.addEventListener('keydown', keyHandler); 57 | }), 58 | withMarkup(() => html` 59 | 60 | 61 | 62 | 63 | 64 | `), 65 | ); 66 | -------------------------------------------------------------------------------- /examples/2048-compat/components/grid.js: -------------------------------------------------------------------------------- 1 | import { range } from '../../../src/utils'; 2 | 3 | import { 4 | component, 5 | withStyle, 6 | withMarkup, 7 | withProp, 8 | html, 9 | css, 10 | } from '../../..'; 11 | 12 | import { size } from '../game/conf'; 13 | 14 | component( 15 | 'swiip-grid-container', 16 | withStyle(() => css` 17 | swiip-grid-container { 18 | position: absolute; 19 | top: 0; 20 | bottom: 0; 21 | left: 0; 22 | right: 0; 23 | 24 | display: grid; 25 | grid-template-columns: repeat(4, 100px); 26 | grid-template-rows: repeat(4, 100px); 27 | grid-gap: 20px 20px; 28 | justify-content: center; 29 | align-content: center; 30 | } 31 | `), 32 | ); 33 | 34 | component( 35 | 'swiip-grid-cell', 36 | withProp('x'), 37 | withProp('y'), 38 | withStyle(({ x, y }) => css` 39 | swiip-grid-cell[x="${x}"][y="${y}"] { 40 | position: absolute; 41 | height: 100px; 42 | width: 100px; 43 | border-radius: 3px; 44 | background-color: #cdc1b4; 45 | grid-area: ${x + 1} / ${y + 1}; 46 | } 47 | `), 48 | ); 49 | 50 | component( 51 | 'swiip-grid', 52 | withMarkup(() => html` 53 | 54 | ${range(size).map(x => 55 | range(size).map(y => html` 56 | 57 | 58 | `))} 59 | 60 | `), 61 | ); 62 | -------------------------------------------------------------------------------- /examples/2048-compat/components/heading.js: -------------------------------------------------------------------------------- 1 | import { 2 | component, 3 | withProp, 4 | withStyle, 5 | withMarkup, 6 | html, 7 | css, 8 | } from '../../..'; 9 | 10 | component( 11 | 'swiip-heading-container', 12 | withStyle(() => css` 13 | swiip-heading-container { 14 | display: flex; 15 | flex-direction: row; 16 | justify-content: space-between; 17 | } 18 | `), 19 | ); 20 | 21 | component( 22 | 'swiip-heading-title', 23 | withStyle(() => css` 24 | swiip-heading-title { 25 | font-size: 80px; 26 | font-weight: bold; 27 | margin: 0; 28 | } 29 | `), 30 | ); 31 | 32 | component( 33 | 'swiip-scores', 34 | withStyle(() => ` 35 | :host { 36 | display: flex; 37 | flex-direction: row; 38 | } 39 | `), 40 | ); 41 | 42 | component( 43 | 'swiip-score', 44 | withProp('label'), 45 | withStyle(({ label }) => css` 46 | :host { 47 | background-color: var(--light-bg-brown); 48 | color: white; 49 | padding: 20px 25px 10px 25px; 50 | font-size: 25px; 51 | font-weight: bold; 52 | height: 25px; 53 | margin: 3px; 54 | border-radius: 3px; 55 | text-align: center; 56 | position: relative; 57 | } 58 | 59 | :host:after { 60 | color: var(--disable-text-white); 61 | display: block; 62 | position: absolute; 63 | width: 100%; 64 | top: 6px; 65 | left: 0; 66 | font-size: 13px; 67 | content: "${label}"; 68 | } 69 | `), 70 | ); 71 | 72 | component( 73 | 'swiip-heading', 74 | withMarkup(() => html` 75 | 76 | 2048 77 | 78 | 123 79 | 456 80 | 81 | 82 | `), 83 | ); 84 | -------------------------------------------------------------------------------- /examples/2048-compat/components/message.js: -------------------------------------------------------------------------------- 1 | import { 2 | component, 3 | withProp, 4 | withStore, 5 | withStyle, 6 | withMarkup, 7 | html, 8 | css, 9 | } from '../../..'; 10 | 11 | component( 12 | 'swiip-message-container', 13 | withProp('show'), 14 | withStyle(({ show }) => css` 15 | swiip-message-container { 16 | position: absolute; 17 | top: 0; 18 | bottom: 0; 19 | left: 0; 20 | right: 0; 21 | border-radius: 6px; 22 | z-index: 20; 23 | background-color: #faf8ef99; 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | opacity: 0; 28 | transition: opacity .3s ease; 29 | opacity: ${show ? 1 : 0}; 30 | } 31 | `), 32 | ); 33 | 34 | component( 35 | 'swiip-message', 36 | withStore(({ getState }) => { 37 | const { won, lost } = getState(); 38 | return { won, lost }; 39 | }), 40 | withMarkup(({ won, lost }) => html` 41 | 42 |

43 | ${won ? 'You Win!' : ''} 44 | ${lost ? 'Game Over' : ''} 45 |

46 | 47 | `), 48 | ); 49 | -------------------------------------------------------------------------------- /examples/2048-compat/components/tile.js: -------------------------------------------------------------------------------- 1 | import { component, withProp, withStyle, css } from '../../..'; 2 | 3 | const tileParams = [ 4 | null, 5 | { 6 | color: 'inherit', 7 | background: '#eee4da', 8 | font: '55px', 9 | }, 10 | { 11 | color: 'inherit', 12 | background: '#ede0c8', 13 | font: '55px', 14 | }, 15 | { 16 | color: '#f9f6f2', 17 | background: '#f2b179', 18 | font: '55px', 19 | }, 20 | { 21 | color: '#f9f6f2', 22 | background: '#f59563', 23 | font: '55px', 24 | }, 25 | { 26 | color: '#f9f6f2', 27 | background: '#f67c5f', 28 | font: '55px', 29 | }, 30 | { 31 | color: '#f9f6f2', 32 | background: '#f65e3b', 33 | font: '55px', 34 | }, 35 | { 36 | color: '#f9f6f2', 37 | background: '#edcf72', 38 | font: '45px', 39 | }, 40 | { 41 | color: '#f9f6f2', 42 | background: '#edcc61', 43 | font: '45px', 44 | }, 45 | { 46 | color: '#f9f6f2', 47 | background: '#edc850', 48 | font: '45px', 49 | }, 50 | { 51 | color: '#f9f6f2', 52 | background: '#edc53f', 53 | font: '35px', 54 | }, 55 | { 56 | color: '#f9f6f2', 57 | background: '#edc22e', 58 | font: '35px', 59 | }, 60 | ]; 61 | 62 | const param = tile => tileParams[Math.log2(tile.value)]; 63 | 64 | component( 65 | 'swiip-tile', 66 | withProp('tile'), 67 | withStyle(({ tile }) => tile && css` 68 | swiip-tile[key="${tile.id}"] { 69 | position: absolute; 70 | height: 100px; 71 | width: 100px; 72 | border-radius: 3px; 73 | z-index: 10; 74 | font-weight: bold; 75 | font-size: 55px; 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | 80 | transition: all .3s ease; 81 | animation: .3s appear; 82 | 83 | top: ${20 + (120 * tile.row)}px; 84 | left: ${20 + (120 * tile.column)}px; 85 | color: ${param(tile).color}; 86 | background-color: ${param(tile).background}; 87 | font-size: ${param(tile).font}; 88 | z-index: ${tile.merged ? 9 : 10}; 89 | } 90 | 91 | @keyframes appear { 92 | from { 93 | height: 0; 94 | width: 0; 95 | opacity: 0; 96 | margin-top: 50px; 97 | margin-left: 50px; 98 | } 99 | to { 100 | height: 100px; 101 | width: 100px; 102 | opacity: 1; 103 | margin-top: 0; 104 | margin-left: 0; 105 | } 106 | } 107 | `), 108 | ); 109 | -------------------------------------------------------------------------------- /examples/2048-compat/components/tiles.js: -------------------------------------------------------------------------------- 1 | import { 2 | component, 3 | withMarkup, 4 | withStore, 5 | html, 6 | } from '../../..'; 7 | 8 | import './tile'; 9 | 10 | component( 11 | 'swiip-tiles', 12 | withStore(({ getState }) => { 13 | const { board } = getState(); 14 | const tiles = []; 15 | 16 | if (!board) { 17 | return tiles; 18 | } 19 | 20 | board.forEach((rows) => { 21 | rows.forEach((cell) => { 22 | if (cell.value > 0) { 23 | tiles.push(cell); 24 | } 25 | if (Array.isArray(cell.mergedTiles)) { 26 | tiles.push(...cell.mergedTiles); 27 | } 28 | }); 29 | }); 30 | 31 | return { tiles }; 32 | }), 33 | withMarkup(({ tiles = [] }) => html` 34 |
35 | ${tiles.map(tile => html` 36 | 37 | ${tile.value} 38 | 39 | `)} 40 |
41 | `), 42 | ); 43 | -------------------------------------------------------------------------------- /examples/2048-compat/fonts/ClearSans-Bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Swiip/compo/63a0cd2916851ad5db3c02789f6bfabc431cc9ed/examples/2048-compat/fonts/ClearSans-Bold-webfont.woff -------------------------------------------------------------------------------- /examples/2048-compat/fonts/ClearSans-Regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Swiip/compo/63a0cd2916851ad5db3c02789f6bfabc431cc9ed/examples/2048-compat/fonts/ClearSans-Regular-webfont.woff -------------------------------------------------------------------------------- /examples/2048-compat/fonts/clear-sans.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Clear Sans"; 3 | src: url("./ClearSans-Regular-webfont.woff") format("woff"); 4 | font-weight: normal; 5 | font-style: normal; 6 | } 7 | 8 | @font-face { 9 | font-family: "Clear Sans"; 10 | src: url("./ClearSans-Bold-webfont.woff") format("woff"); 11 | font-weight: 700; 12 | font-style: normal; 13 | } 14 | -------------------------------------------------------------------------------- /examples/2048-compat/game/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-bitwise": "off" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/2048-compat/game/add.js: -------------------------------------------------------------------------------- 1 | import { fourProbability } from './conf'; 2 | import { createTile } from './tile'; 3 | 4 | import { flatten } from '../../../src/utils'; 5 | 6 | export function chooseRandomTile(board, randomPosition, randomValue) { 7 | const emptyCells = flatten(board.map((row, rowIndex) => row.map((tile, columnIndex) => ({ 8 | rowIndex, columnIndex, value: tile.value, 9 | })))).filter(tile => tile.value === 0); 10 | const index = ~~(randomPosition * emptyCells.length); 11 | const cell = emptyCells[index]; 12 | const value = randomValue < fourProbability ? 4 : 2; 13 | return { 14 | row: cell.rowIndex, 15 | column: cell.columnIndex, 16 | value, 17 | }; 18 | } 19 | 20 | export function addTile(board, rowIndex, columnIndex, value) { 21 | return board.map((row, r) => row.map((tile, c) => { 22 | if (r === rowIndex && c === columnIndex) { 23 | tile = createTile(value); 24 | } 25 | return tile; 26 | })); 27 | } 28 | -------------------------------------------------------------------------------- /examples/2048-compat/game/conf.js: -------------------------------------------------------------------------------- 1 | export const size = 4; 2 | export const fourProbability = 0.1; 3 | export const end = 2048; 4 | -------------------------------------------------------------------------------- /examples/2048-compat/game/end.js: -------------------------------------------------------------------------------- 1 | import { flatten, range } from '../../../src/utils'; 2 | import { size, end } from './conf'; 3 | 4 | const deltaX = [-1, 0, 1, 0]; 5 | const deltaY = [0, -1, 0, 1]; 6 | 7 | export function hasWon(board) { 8 | return ( 9 | flatten(board.map(row => row.filter(column => column.value >= end))) 10 | .length > 0 11 | ); 12 | } 13 | 14 | export function hasLost(board) { 15 | let canMove = false; 16 | range(size).forEach((row) => { 17 | range(size).forEach((column) => { 18 | canMove |= board[row][column].value === 0; 19 | range(4).forEach((direction) => { 20 | const newRow = row + deltaX[direction]; 21 | const newColumn = column + deltaY[direction]; 22 | if ( 23 | newRow >= 0 && 24 | newRow < size && 25 | newColumn >= 0 && 26 | newColumn < size 27 | ) { 28 | canMove |= 29 | board[row][column].value === board[newRow][newColumn].value; 30 | } 31 | }); 32 | }); 33 | }); 34 | return !canMove; 35 | } 36 | -------------------------------------------------------------------------------- /examples/2048-compat/game/init.js: -------------------------------------------------------------------------------- 1 | import { range } from '../../../src/utils'; 2 | 3 | import { size } from './conf'; 4 | import { createTile } from './tile'; 5 | 6 | export function init() { 7 | const dimension = range(size); 8 | return dimension.map(() => dimension.map(() => createTile())); 9 | } 10 | -------------------------------------------------------------------------------- /examples/2048-compat/game/move.js: -------------------------------------------------------------------------------- 1 | import { times, range } from '../../../src/utils'; 2 | 3 | import { size } from './conf'; 4 | import { createTile } from './tile'; 5 | 6 | function rotateLeft(board) { 7 | return board.map((row, rowIndex) => 8 | row.map((cell, columnIndex) => 9 | board[columnIndex][size - rowIndex - 1])); 10 | } 11 | 12 | function moveLeft(board) { 13 | let changed = false; 14 | board = board.map((row) => { 15 | const currentRow = row.filter(tile => tile.value !== 0); 16 | return range(size).map((target) => { 17 | let targetTile; 18 | if (currentRow.length > 0) { 19 | targetTile = Object.assign({}, currentRow.shift()); 20 | } else { 21 | targetTile = createTile(); 22 | } 23 | if (currentRow.length > 0 && currentRow[0].value === targetTile.value) { 24 | const tile1 = targetTile; 25 | tile1.merged = true; 26 | targetTile = createTile(targetTile.value); 27 | targetTile.mergedTiles = []; 28 | targetTile.mergedTiles.push(tile1); 29 | const tile2 = Object.assign({}, currentRow.shift()); 30 | tile2.merged = true; 31 | targetTile.value += tile2.value; 32 | targetTile.mergedTiles.push(tile2); 33 | } 34 | changed |= targetTile.value !== row[target].value; 35 | return targetTile; 36 | }); 37 | }); 38 | return { board, changed }; 39 | } 40 | 41 | export function move(board, direction) { 42 | // 0 -> left, 1 -> up, 2 -> right, 3 -> down 43 | times(direction, () => { 44 | board = rotateLeft(board); 45 | }); 46 | const moveResult = moveLeft(board); 47 | board = moveResult.board; // eslint-disable-line prefer-destructuring 48 | times(4 - direction, () => { 49 | board = rotateLeft(board); 50 | }); 51 | return { board, changed: moveResult.changed }; 52 | } 53 | -------------------------------------------------------------------------------- /examples/2048-compat/game/tile.js: -------------------------------------------------------------------------------- 1 | let tileId = 0; 2 | 3 | export function createTile(value, row, column) { 4 | tileId += 1; 5 | 6 | return { 7 | id: tileId, 8 | value: value || 0, 9 | row: row || -1, 10 | column: column || -1, 11 | oldRow: -1, 12 | oldColumn: -1, 13 | }; 14 | } 15 | 16 | export function isNew(tile) { 17 | return tile.oldRow === -1; 18 | } 19 | 20 | export function hasMoved(tile) { 21 | return ( 22 | tile.oldRow !== -1 && 23 | (tile.oldRow !== tile.row || tile.oldColumn !== tile.column) 24 | ); 25 | } 26 | 27 | function updatePositions(tile, row, column) { 28 | return Object.assign({}, tile, { 29 | oldRow: tile.row, 30 | oldColumn: tile.column, 31 | row, 32 | column, 33 | }); 34 | } 35 | 36 | export function update(board) { 37 | return board.map((row, rowIndex) => row.map((tile, columnIndex) => { 38 | tile = updatePositions(tile, rowIndex, columnIndex); 39 | if (tile.mergedTiles) { 40 | tile.mergedTiles = tile.mergedTiles.map(mergedTile => 41 | updatePositions(mergedTile, rowIndex, columnIndex)); 42 | } 43 | return tile; 44 | })); 45 | } 46 | -------------------------------------------------------------------------------- /examples/2048-compat/index.css: -------------------------------------------------------------------------------- 1 | @import url("./fonts/clear-sans.css"); 2 | 3 | :root { 4 | --main-bg-color: #faf8ef; 5 | --main-text-color: #776e65; 6 | --light-bg-brown: #bbada0; 7 | --heavy-bg-brown: #8f7a66; 8 | 9 | --disable-text-white: #eee4da; 10 | --light-text-white: #f9f6f2; 11 | } 12 | 13 | html, 14 | body { 15 | margin: 0; 16 | padding: 0; 17 | background: var(--main-bg-color); 18 | color: var(--main-text-color); 19 | font-family: "Clear Sans", "Helvetica Neue", Arial, sans-serif; 20 | font-size: 18px; 21 | } 22 | 23 | body { 24 | margin: 80px 0; 25 | } 26 | 27 | h1 { 28 | color: red; 29 | } 30 | -------------------------------------------------------------------------------- /examples/2048-compat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/2048-compat/index.js: -------------------------------------------------------------------------------- 1 | import '@webcomponents/webcomponentsjs'; 2 | 3 | import { store, createStore } from '../..'; 4 | 5 | import { reducer } from './logic/reducer'; 6 | 7 | import './components/container'; 8 | import './components/heading'; 9 | import './components/above-game'; 10 | import './components/game'; 11 | 12 | createStore(reducer); 13 | 14 | store.dispatch({ 15 | type: 'START', 16 | randomPosition: Math.random(), 17 | randomValue: Math.random(), 18 | }); 19 | -------------------------------------------------------------------------------- /examples/2048-compat/logic/reducer.js: -------------------------------------------------------------------------------- 1 | import { init } from '../game/init'; 2 | import { chooseRandomTile, addTile } from '../game/add'; 3 | import { update } from '../game/tile'; 4 | import { move } from '../game/move'; 5 | import { hasWon, hasLost } from '../game/end'; 6 | 7 | export const reducer = (state, action) => { 8 | switch (action.type) { 9 | case 'START': { 10 | const newState = { 11 | board: init(), 12 | changed: false, 13 | won: false, 14 | lost: false, 15 | }; 16 | const { row, column, value } = chooseRandomTile( 17 | newState.board, 18 | action.randomPosition, 19 | action.randomValue, 20 | ); 21 | newState.board = addTile(newState.board, row, column, value); 22 | newState.board = update(newState.board); 23 | return newState; 24 | } 25 | case 'MOVE': { 26 | const newState = move(state.board, action.direction); 27 | if (newState.changed) { 28 | const { row, column, value } = chooseRandomTile( 29 | newState.board, 30 | action.randomPosition, 31 | action.randomValue, 32 | ); 33 | newState.board = addTile(newState.board, row, column, value); 34 | } 35 | newState.board = update(newState.board); 36 | newState.won = hasWon(newState.board); 37 | newState.lost = hasLost(newState.board); 38 | return newState; 39 | } 40 | default: { 41 | return state; 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /examples/2048/components/above-game.js: -------------------------------------------------------------------------------- 1 | import { 2 | component, 3 | withStyle, 4 | withMarkup, 5 | html, 6 | css, 7 | } from '../../..'; 8 | 9 | component( 10 | 'swiip-above-game-container', 11 | withStyle(() => css` 12 | :host { 13 | display: flex; 14 | flex-direction: row; 15 | justify-content: space-between; 16 | align-items: center; 17 | } 18 | `), 19 | ); 20 | 21 | component( 22 | 'swiip-restart-button', 23 | withStyle(() => css` 24 | :host { 25 | color: var(--light-text-white); 26 | background-color: var(--heavy-bg-brown); 27 | border-radius: 3px; 28 | padding: 0 20px; 29 | text-decoration: none; 30 | color: #f9f6f2; 31 | height: 40px; 32 | cursor: pointer; 33 | display: flex; 34 | text-align: center; 35 | justify-content: center; 36 | align-items: center; 37 | } 38 | `), 39 | ); 40 | 41 | const newGame = () => { 42 | console.log('New Game!'); 43 | }; 44 | 45 | component( 46 | 'swiip-above-game', 47 | withMarkup(() => html` 48 | 49 |

Join the numbers and get to the 2048 tile!

50 | New Game 51 |
52 | `), 53 | ); 54 | -------------------------------------------------------------------------------- /examples/2048/components/container.js: -------------------------------------------------------------------------------- 1 | import { component, withStyle, css } from '../../..'; 2 | 3 | component( 4 | 'swiip-container', 5 | withStyle(() => css` 6 | :host { 7 | display: block; 8 | width: 500px; 9 | margin: 0 auto; 10 | } 11 | `), 12 | ); 13 | -------------------------------------------------------------------------------- /examples/2048/components/game.js: -------------------------------------------------------------------------------- 1 | import './grid'; 2 | import './tiles'; 3 | import './message'; 4 | 5 | import { 6 | component, 7 | withStyle, 8 | withMarkup, 9 | withStore, 10 | withHandler, 11 | withConnected, 12 | html, 13 | css, 14 | } from '../../..'; 15 | 16 | const keyMapping = { 17 | ArrowLeft: 0, 18 | ArrowUp: 1, 19 | ArrowRight: 2, 20 | ArrowDown: 3, 21 | }; 22 | 23 | component( 24 | 'swiip-game-container', 25 | withStyle(() => css` 26 | :host { 27 | display: block; 28 | margin-top: 40px; 29 | position: relative; 30 | background: var(--light-bg-brown); 31 | border-radius: 6px; 32 | width: 500px; 33 | height: 500px; 34 | box-sizing: border-box; 35 | } 36 | `), 37 | ); 38 | 39 | component( 40 | 'swiip-game', 41 | withStore(({ dispatch }) => ({ 42 | move: key => dispatch({ 43 | type: 'MOVE', 44 | direction: keyMapping[key], 45 | randomPosition: Math.random(), 46 | randomValue: Math.random(), 47 | }), 48 | })), 49 | withHandler('keyHandler', ({ move }) => (event) => { 50 | if (keyMapping[event.key] !== undefined) { 51 | move(event.key); 52 | event.preventDefault(); 53 | } 54 | }), 55 | withConnected(({ keyHandler }) => { 56 | window.addEventListener('keydown', keyHandler); 57 | }), 58 | withMarkup(() => html` 59 | 60 | 61 | 62 | 63 | 64 | `), 65 | ); 66 | -------------------------------------------------------------------------------- /examples/2048/components/grid.js: -------------------------------------------------------------------------------- 1 | import { range } from '../../../src/utils'; 2 | 3 | import { 4 | component, 5 | withStyle, 6 | withMarkup, 7 | withProp, 8 | html, 9 | css, 10 | } from '../../..'; 11 | 12 | import { size } from '../game/conf'; 13 | 14 | component( 15 | 'swiip-grid-container', 16 | withStyle(() => css` 17 | :host { 18 | position: absolute; 19 | top: 0; 20 | bottom: 0; 21 | left: 0; 22 | right: 0; 23 | 24 | display: grid; 25 | grid-template-columns: repeat(4, 100px); 26 | grid-template-rows: repeat(4, 100px); 27 | grid-gap: 20px 20px; 28 | justify-content: center; 29 | align-content: center; 30 | } 31 | `), 32 | ); 33 | 34 | component( 35 | 'swiip-grid-cell', 36 | withProp('x'), 37 | withProp('y'), 38 | withStyle(({ x, y }) => css` 39 | :host { 40 | position: absolute; 41 | height: 100px; 42 | width: 100px; 43 | border-radius: 3px; 44 | background-color: #cdc1b4; 45 | grid-area: ${x + 1} / ${y + 1}; 46 | } 47 | `), 48 | ); 49 | 50 | component( 51 | 'swiip-grid', 52 | withMarkup(() => html` 53 | 54 | ${range(size).map(x => 55 | range(size).map(y => html` 56 | 57 | 58 | `))} 59 | 60 | `), 61 | ); 62 | -------------------------------------------------------------------------------- /examples/2048/components/heading.js: -------------------------------------------------------------------------------- 1 | import { 2 | component, 3 | withProp, 4 | withStyle, 5 | withMarkup, 6 | html, 7 | css, 8 | } from '../../..'; 9 | 10 | component( 11 | 'swiip-heading-container', 12 | withStyle(() => css` 13 | :host { 14 | display: flex; 15 | flex-direction: row; 16 | justify-content: space-between; 17 | } 18 | `), 19 | ); 20 | 21 | component( 22 | 'swiip-heading-title', 23 | withStyle(() => css` 24 | :host { 25 | font-size: 80px; 26 | font-weight: bold; 27 | margin: 0; 28 | } 29 | `), 30 | ); 31 | 32 | component( 33 | 'swiip-scores', 34 | withStyle(() => ` 35 | :host { 36 | display: flex; 37 | flex-direction: row; 38 | } 39 | `), 40 | ); 41 | 42 | component( 43 | 'swiip-score', 44 | withProp('label'), 45 | withStyle(({ label }) => css` 46 | :host { 47 | background-color: var(--light-bg-brown); 48 | color: white; 49 | padding: 20px 25px 10px 25px; 50 | font-size: 25px; 51 | font-weight: bold; 52 | height: 25px; 53 | margin: 3px; 54 | border-radius: 3px; 55 | text-align: center; 56 | position: relative; 57 | } 58 | 59 | :host:after { 60 | color: var(--disable-text-white); 61 | display: block; 62 | position: absolute; 63 | width: 100%; 64 | top: 6px; 65 | left: 0; 66 | font-size: 13px; 67 | content: "${label}"; 68 | } 69 | `), 70 | ); 71 | 72 | component( 73 | 'swiip-heading', 74 | withMarkup(() => html` 75 | <${'swiip-heading-container'}> 76 | 2048 77 | 78 | 123 79 | 456 80 | 81 | 82 | `), 83 | ); 84 | -------------------------------------------------------------------------------- /examples/2048/components/message.js: -------------------------------------------------------------------------------- 1 | import { 2 | component, 3 | withProp, 4 | withStore, 5 | withStyle, 6 | withMarkup, 7 | html, 8 | css, 9 | } from '../../..'; 10 | 11 | component( 12 | 'swiip-message-container', 13 | withProp('show'), 14 | withStyle(({ show }) => css` 15 | :host { 16 | position: absolute; 17 | top: 0; 18 | bottom: 0; 19 | left: 0; 20 | right: 0; 21 | border-radius: 6px; 22 | z-index: 20; 23 | background-color: #faf8ef99; 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | opacity: 0; 28 | transition: opacity .3s ease; 29 | opacity: ${show ? 1 : 0}; 30 | } 31 | `), 32 | ); 33 | 34 | component( 35 | 'swiip-message', 36 | withStore(({ getState }) => { 37 | const { won, lost } = getState(); 38 | return { won, lost }; 39 | }), 40 | withMarkup(({ won, lost }) => html` 41 | 42 |

43 | ${won ? 'You Win!' : ''} 44 | ${lost ? 'Game Over' : ''} 45 |

46 | 47 | `), 48 | ); 49 | -------------------------------------------------------------------------------- /examples/2048/components/tile.js: -------------------------------------------------------------------------------- 1 | import { component, withProp, withStyle, css } from '../../..'; 2 | 3 | const tileParams = [ 4 | null, 5 | { 6 | color: 'inherit', 7 | background: '#eee4da', 8 | font: '55px', 9 | }, 10 | { 11 | color: 'inherit', 12 | background: '#ede0c8', 13 | font: '55px', 14 | }, 15 | { 16 | color: '#f9f6f2', 17 | background: '#f2b179', 18 | font: '55px', 19 | }, 20 | { 21 | color: '#f9f6f2', 22 | background: '#f59563', 23 | font: '55px', 24 | }, 25 | { 26 | color: '#f9f6f2', 27 | background: '#f67c5f', 28 | font: '55px', 29 | }, 30 | { 31 | color: '#f9f6f2', 32 | background: '#f65e3b', 33 | font: '55px', 34 | }, 35 | { 36 | color: '#f9f6f2', 37 | background: '#edcf72', 38 | font: '45px', 39 | }, 40 | { 41 | color: '#f9f6f2', 42 | background: '#edcc61', 43 | font: '45px', 44 | }, 45 | { 46 | color: '#f9f6f2', 47 | background: '#edc850', 48 | font: '45px', 49 | }, 50 | { 51 | color: '#f9f6f2', 52 | background: '#edc53f', 53 | font: '35px', 54 | }, 55 | { 56 | color: '#f9f6f2', 57 | background: '#edc22e', 58 | font: '35px', 59 | }, 60 | ]; 61 | 62 | const param = tile => tileParams[Math.log2(tile.value)]; 63 | 64 | component( 65 | 'swiip-tile', 66 | withProp('tile'), 67 | withStyle(({ tile }) => tile && css` 68 | :host { 69 | position: absolute; 70 | height: 100px; 71 | width: 100px; 72 | border-radius: 3px; 73 | z-index: 10; 74 | font-weight: bold; 75 | font-size: 55px; 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | 80 | transition: all .3s ease; 81 | animation: .3s appear; 82 | 83 | top: ${20 + (120 * tile.row)}px; 84 | left: ${20 + (120 * tile.column)}px; 85 | color: ${param(tile).color}; 86 | background-color: ${param(tile).background}; 87 | font-size: ${param(tile).font}; 88 | z-index: ${tile.merged ? 9 : 10}; 89 | } 90 | 91 | @keyframes appear { 92 | from { 93 | height: 0; 94 | width: 0; 95 | opacity: 0; 96 | margin-top: 50px; 97 | margin-left: 50px; 98 | } 99 | to { 100 | height: 100px; 101 | width: 100px; 102 | opacity: 1; 103 | margin-top: 0; 104 | margin-left: 0; 105 | } 106 | } 107 | `), 108 | ); 109 | -------------------------------------------------------------------------------- /examples/2048/components/tiles.js: -------------------------------------------------------------------------------- 1 | import { 2 | component, 3 | withMarkup, 4 | withStore, 5 | html, 6 | } from '../../..'; 7 | 8 | import './tile'; 9 | 10 | component( 11 | 'swiip-tiles', 12 | withStore(({ getState }) => { 13 | const { board } = getState(); 14 | const tiles = []; 15 | 16 | if (!board) { 17 | return tiles; 18 | } 19 | 20 | board.forEach((rows) => { 21 | rows.forEach((cell) => { 22 | if (cell.value > 0) { 23 | tiles.push(cell); 24 | } 25 | if (Array.isArray(cell.mergedTiles)) { 26 | tiles.push(...cell.mergedTiles); 27 | } 28 | }); 29 | }); 30 | 31 | return { tiles }; 32 | }), 33 | withMarkup(({ tiles = [] }) => html` 34 |
35 | ${tiles.map(tile => html` 36 | 37 | ${tile.value} 38 | 39 | `)} 40 |
41 | `), 42 | ); 43 | -------------------------------------------------------------------------------- /examples/2048/fonts/ClearSans-Bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Swiip/compo/63a0cd2916851ad5db3c02789f6bfabc431cc9ed/examples/2048/fonts/ClearSans-Bold-webfont.woff -------------------------------------------------------------------------------- /examples/2048/fonts/ClearSans-Regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Swiip/compo/63a0cd2916851ad5db3c02789f6bfabc431cc9ed/examples/2048/fonts/ClearSans-Regular-webfont.woff -------------------------------------------------------------------------------- /examples/2048/fonts/clear-sans.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Clear Sans"; 3 | src: url("./ClearSans-Regular-webfont.woff") format("woff"); 4 | font-weight: normal; 5 | font-style: normal; 6 | } 7 | 8 | @font-face { 9 | font-family: "Clear Sans"; 10 | src: url("./ClearSans-Bold-webfont.woff") format("woff"); 11 | font-weight: 700; 12 | font-style: normal; 13 | } 14 | -------------------------------------------------------------------------------- /examples/2048/game/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-bitwise": "off" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/2048/game/add.js: -------------------------------------------------------------------------------- 1 | import { fourProbability } from './conf'; 2 | import { createTile } from './tile'; 3 | 4 | import { flatten } from '../../../src/utils'; 5 | 6 | export function chooseRandomTile(board, randomPosition, randomValue) { 7 | const emptyCells = flatten(board.map((row, rowIndex) => row.map((tile, columnIndex) => ({ 8 | rowIndex, columnIndex, value: tile.value, 9 | })))).filter(tile => tile.value === 0); 10 | const index = ~~(randomPosition * emptyCells.length); 11 | const cell = emptyCells[index]; 12 | const value = randomValue < fourProbability ? 4 : 2; 13 | return { 14 | row: cell.rowIndex, 15 | column: cell.columnIndex, 16 | value, 17 | }; 18 | } 19 | 20 | export function addTile(board, rowIndex, columnIndex, value) { 21 | return board.map((row, r) => row.map((tile, c) => { 22 | if (r === rowIndex && c === columnIndex) { 23 | tile = createTile(value); 24 | } 25 | return tile; 26 | })); 27 | } 28 | -------------------------------------------------------------------------------- /examples/2048/game/conf.js: -------------------------------------------------------------------------------- 1 | export const size = 4; 2 | export const fourProbability = 0.1; 3 | export const end = 2048; 4 | -------------------------------------------------------------------------------- /examples/2048/game/end.js: -------------------------------------------------------------------------------- 1 | import { flatten, range } from '../../../src/utils'; 2 | import { size, end } from './conf'; 3 | 4 | const deltaX = [-1, 0, 1, 0]; 5 | const deltaY = [0, -1, 0, 1]; 6 | 7 | export function hasWon(board) { 8 | return ( 9 | flatten(board.map(row => row.filter(column => column.value >= end))) 10 | .length > 0 11 | ); 12 | } 13 | 14 | export function hasLost(board) { 15 | let canMove = false; 16 | range(size).forEach((row) => { 17 | range(size).forEach((column) => { 18 | canMove |= board[row][column].value === 0; 19 | range(4).forEach((direction) => { 20 | const newRow = row + deltaX[direction]; 21 | const newColumn = column + deltaY[direction]; 22 | if ( 23 | newRow >= 0 && 24 | newRow < size && 25 | newColumn >= 0 && 26 | newColumn < size 27 | ) { 28 | canMove |= 29 | board[row][column].value === board[newRow][newColumn].value; 30 | } 31 | }); 32 | }); 33 | }); 34 | return !canMove; 35 | } 36 | -------------------------------------------------------------------------------- /examples/2048/game/init.js: -------------------------------------------------------------------------------- 1 | import { range } from '../../../src/utils'; 2 | 3 | import { size } from './conf'; 4 | import { createTile } from './tile'; 5 | 6 | export function init() { 7 | const dimension = range(size); 8 | return dimension.map(() => dimension.map(() => createTile())); 9 | } 10 | -------------------------------------------------------------------------------- /examples/2048/game/move.js: -------------------------------------------------------------------------------- 1 | import { times, range } from '../../../src/utils'; 2 | 3 | import { size } from './conf'; 4 | import { createTile } from './tile'; 5 | 6 | function rotateLeft(board) { 7 | return board.map((row, rowIndex) => 8 | row.map((cell, columnIndex) => 9 | board[columnIndex][size - rowIndex - 1])); 10 | } 11 | 12 | function moveLeft(board) { 13 | let changed = false; 14 | board = board.map((row) => { 15 | const currentRow = row.filter(tile => tile.value !== 0); 16 | return range(size).map((target) => { 17 | let targetTile; 18 | if (currentRow.length > 0) { 19 | targetTile = Object.assign({}, currentRow.shift()); 20 | } else { 21 | targetTile = createTile(); 22 | } 23 | if (currentRow.length > 0 && currentRow[0].value === targetTile.value) { 24 | const tile1 = targetTile; 25 | tile1.merged = true; 26 | targetTile = createTile(targetTile.value); 27 | targetTile.mergedTiles = []; 28 | targetTile.mergedTiles.push(tile1); 29 | const tile2 = Object.assign({}, currentRow.shift()); 30 | tile2.merged = true; 31 | targetTile.value += tile2.value; 32 | targetTile.mergedTiles.push(tile2); 33 | } 34 | changed |= targetTile.value !== row[target].value; 35 | return targetTile; 36 | }); 37 | }); 38 | return { board, changed }; 39 | } 40 | 41 | export function move(board, direction) { 42 | // 0 -> left, 1 -> up, 2 -> right, 3 -> down 43 | times(direction, () => { 44 | board = rotateLeft(board); 45 | }); 46 | const moveResult = moveLeft(board); 47 | board = moveResult.board; // eslint-disable-line prefer-destructuring 48 | times(4 - direction, () => { 49 | board = rotateLeft(board); 50 | }); 51 | return { board, changed: moveResult.changed }; 52 | } 53 | -------------------------------------------------------------------------------- /examples/2048/game/tile.js: -------------------------------------------------------------------------------- 1 | let tileId = 0; 2 | 3 | export function createTile(value, row, column) { 4 | tileId += 1; 5 | 6 | return { 7 | id: tileId, 8 | value: value || 0, 9 | row: row || -1, 10 | column: column || -1, 11 | oldRow: -1, 12 | oldColumn: -1, 13 | }; 14 | } 15 | 16 | export function isNew(tile) { 17 | return tile.oldRow === -1; 18 | } 19 | 20 | export function hasMoved(tile) { 21 | return ( 22 | tile.oldRow !== -1 && 23 | (tile.oldRow !== tile.row || tile.oldColumn !== tile.column) 24 | ); 25 | } 26 | 27 | function updatePositions(tile, row, column) { 28 | return Object.assign({}, tile, { 29 | oldRow: tile.row, 30 | oldColumn: tile.column, 31 | row, 32 | column, 33 | }); 34 | } 35 | 36 | export function update(board) { 37 | return board.map((row, rowIndex) => row.map((tile, columnIndex) => { 38 | tile = updatePositions(tile, rowIndex, columnIndex); 39 | if (tile.mergedTiles) { 40 | tile.mergedTiles = tile.mergedTiles.map(mergedTile => 41 | updatePositions(mergedTile, rowIndex, columnIndex)); 42 | } 43 | return tile; 44 | })); 45 | } 46 | -------------------------------------------------------------------------------- /examples/2048/index.css: -------------------------------------------------------------------------------- 1 | @import url("./fonts/clear-sans.css"); 2 | 3 | :root { 4 | --main-bg-color: #faf8ef; 5 | --main-text-color: #776e65; 6 | --light-bg-brown: #bbada0; 7 | --heavy-bg-brown: #8f7a66; 8 | 9 | --disable-text-white: #eee4da; 10 | --light-text-white: #f9f6f2; 11 | } 12 | 13 | html, 14 | body { 15 | margin: 0; 16 | padding: 0; 17 | background: var(--main-bg-color); 18 | color: var(--main-text-color); 19 | font-family: "Clear Sans", "Helvetica Neue", Arial, sans-serif; 20 | font-size: 18px; 21 | } 22 | 23 | body { 24 | margin: 80px 0; 25 | } 26 | 27 | h1 { 28 | color: red; 29 | } 30 | -------------------------------------------------------------------------------- /examples/2048/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/2048/index.js: -------------------------------------------------------------------------------- 1 | import { store, createStore } from '../..'; 2 | 3 | import { reducer } from './logic/reducer'; 4 | 5 | import './components/container'; 6 | import './components/heading'; 7 | import './components/above-game'; 8 | import './components/game'; 9 | 10 | createStore(reducer); 11 | 12 | store.dispatch({ 13 | type: 'START', 14 | randomPosition: Math.random(), 15 | randomValue: Math.random(), 16 | }); 17 | -------------------------------------------------------------------------------- /examples/2048/logic/reducer.js: -------------------------------------------------------------------------------- 1 | import { init } from '../game/init'; 2 | import { chooseRandomTile, addTile } from '../game/add'; 3 | import { update } from '../game/tile'; 4 | import { move } from '../game/move'; 5 | import { hasWon, hasLost } from '../game/end'; 6 | 7 | export const reducer = (state, action) => { 8 | switch (action.type) { 9 | case 'START': { 10 | const newState = { 11 | board: init(), 12 | changed: false, 13 | won: false, 14 | lost: false, 15 | }; 16 | const { row, column, value } = chooseRandomTile( 17 | newState.board, 18 | action.randomPosition, 19 | action.randomValue, 20 | ); 21 | newState.board = addTile(newState.board, row, column, value); 22 | newState.board = update(newState.board); 23 | return newState; 24 | } 25 | case 'MOVE': { 26 | const newState = move(state.board, action.direction); 27 | if (newState.changed) { 28 | const { row, column, value } = chooseRandomTile( 29 | newState.board, 30 | action.randomPosition, 31 | action.randomValue, 32 | ); 33 | newState.board = addTile(newState.board, row, column, value); 34 | } 35 | newState.board = update(newState.board); 36 | newState.won = hasWon(newState.board); 37 | newState.lost = hasLost(newState.board); 38 | return newState; 39 | } 40 | default: { 41 | return state; 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /examples/counter/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /examples/counter/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | html, 3 | css, 4 | createStore, 5 | component, 6 | withProp, 7 | withStore, 8 | withStyle, 9 | withMarkup, 10 | } from '../..'; 11 | 12 | createStore((state, action) => { 13 | switch (action.type) { 14 | case 'ADD': return state + 1; 15 | case 'SUB': return state - 1; 16 | default: return state; 17 | } 18 | }, 0); 19 | 20 | component( 21 | 'my-counter-label', 22 | withProp('value'), 23 | withStyle(({ value }) => css` 24 | :host { 25 | color: ${value < 1 ? 'red' : 'black'} 26 | } 27 | `), 28 | ); 29 | 30 | component( 31 | 'my-counter', 32 | withStore(({ getState, dispatch }) => ({ 33 | counter: getState(), 34 | add: () => dispatch({ type: 'ADD' }), 35 | sub: () => dispatch({ type: 'SUB' }), 36 | })), 37 | withMarkup(({ counter, add, sub }) => html` 38 |
39 | ${counter} 40 | 41 | 42 |
43 | `), 44 | ); 45 | -------------------------------------------------------------------------------- /examples/routing/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /examples/routing/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | html, 3 | component, 4 | withHandler, 5 | withMarkup, 6 | } from '../..'; 7 | 8 | import { withRouteAction } from '../../router'; 9 | 10 | 11 | component( 12 | 'my-routing', 13 | withRouteAction(), 14 | withHandler('toRoute1', ({ go }) => () => go('/route1')), 15 | withHandler('toRoute2', ({ go }) => () => go('/route2')), 16 | withMarkup(({ toRoute1, toRoute2 }) => html` 17 |
18 | Route 1 19 | Route 2 20 | 21 | 22 |
23 | `), 24 | ); 25 | 26 | component( 27 | 'my-component-1', 28 | withMarkup(() => html`

Component 1

`), 29 | ); 30 | 31 | component( 32 | 'my-component-2', 33 | withMarkup(() => html`

Component 2

`), 34 | ); 35 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export { 2 | html, 3 | css, 4 | } from './src/parser.js'; 5 | 6 | export { 7 | component, 8 | withProp, 9 | withHandler, 10 | withMarkup, 11 | withStyle, 12 | withConnected, 13 | withStore, 14 | } from './src/component.js'; 15 | 16 | export { 17 | store, 18 | createStore, 19 | } from './src/store.js'; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "compo", 3 | "version": "0.3.0", 4 | "description": "Compo·sing Web Compo·nents", 5 | "main": "index.js", 6 | "scripts": { 7 | "2048": "parcel --no-hmr --no-cache examples/2048/index.html", 8 | "2048-compat": "parcel --no-hmr --no-cache examples/2048-compat/index.html", 9 | "lint": "eslint src examples", 10 | "test": "ava", 11 | "test-coverage": "nyc ava && nyc report --reporter=html", 12 | "counter": "parcel --no-hmr --no-cache examples/counter/index.html", 13 | "routing": "parcel --no-hmr --no-cache examples/routing/index.html" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/Swiip/compo.git" 18 | }, 19 | "keywords": [ 20 | "javascript", 21 | "frontend", 22 | "ui", 23 | "library", 24 | "web-components" 25 | ], 26 | "author": "Matthieu Lux", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/Swiip/compo/issues" 30 | }, 31 | "homepage": "https://github.com/Swiip/compo#readme", 32 | "devDependencies": { 33 | "@babel/register": "^7.0.0-beta.51", 34 | "@webcomponents/webcomponentsjs": "^2.0.2", 35 | "ava": "1.0.0-beta.6", 36 | "eslint": "^4.19.1", 37 | "eslint-config-airbnb-base": "^12.1.0", 38 | "eslint-plugin-import": "^2.12.0", 39 | "nyc": "^12.0.2", 40 | "parcel-bundler": "^1.9.1", 41 | "proxyquire": "^2.1.0", 42 | "sinon": "^6.0.0" 43 | }, 44 | "ava": { 45 | "require": [ 46 | "@babel/register" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /router.js: -------------------------------------------------------------------------------- 1 | import './src/router/compo-path.js'; 2 | 3 | export { 4 | withRouteEvent, 5 | withRouteAction, 6 | } from './src/router/router.js'; 7 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "import/extensions": ['error', "always", { "ignorePackages": true} ] 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/__tests__/component/component.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | 4 | import { component } from '../../component.js'; 5 | 6 | let clock; 7 | 8 | test.before(() => { 9 | clock = sinon.useFakeTimers(); 10 | }); 11 | 12 | test.after(() => { 13 | clock.restore(); 14 | }); 15 | 16 | test('component function asynchronously call define with the right name', (t) => { 17 | const componentName = 'test'; 18 | global.customElements = { define: sinon.spy() }; 19 | global.HTMLElement = class Mock {}; 20 | 21 | component(componentName); 22 | 23 | t.false(global.customElements.define.called); 24 | clock.tick(0); 25 | t.true(global.customElements.define.called); 26 | t.is(global.customElements.define.getCall(0).args[0], componentName); 27 | }); 28 | 29 | test('component function define constructor, connectedCallback and update', (t) => { 30 | const componentName = 'test'; 31 | let ComponentClass; 32 | global.customElements = { 33 | define: (name, ComponentClassReceived) => { 34 | ComponentClass = ComponentClassReceived; 35 | }, 36 | }; 37 | 38 | component(componentName); 39 | 40 | clock.tick(0); 41 | const componentInstance = new ComponentClass(); 42 | t.deepEqual(componentInstance.__, {}); 43 | t.is(typeof componentInstance.connectedCallback, 'function'); 44 | t.is(typeof componentInstance.update, 'function'); 45 | t.is(componentInstance.connectedCallback(), undefined); 46 | t.is(componentInstance.update(), undefined); 47 | }); 48 | 49 | test('component function reuse __ data if present', (t) => { 50 | const componentName = 'test'; 51 | const originalData = { something: '' }; 52 | let ComponentClass; 53 | global.HTMLElement = class Mock { 54 | constructor() { 55 | this.__ = originalData; 56 | } 57 | }; 58 | global.customElements = { 59 | define: (name, ComponentClassReceived) => { 60 | ComponentClass = ComponentClassReceived; 61 | }, 62 | }; 63 | 64 | component(componentName); 65 | 66 | clock.tick(0); 67 | const componentInstance = new ComponentClass(); 68 | t.is(componentInstance.__, originalData); 69 | }); 70 | 71 | test('component function compose all enhancers', (t) => { 72 | const componentName = 'test'; 73 | const f1ReturnValue = 'f1ReturnValue'; 74 | const f2ReturnValue = 'f2ReturnValue'; 75 | global.HTMLElement = class Mock {}; 76 | global.customElements.define = sinon.spy(); 77 | const composeFunction1 = sinon.stub().returns(f1ReturnValue); 78 | const composeFunction2 = sinon.stub().returns(f2ReturnValue); 79 | 80 | component(componentName, composeFunction1, composeFunction2); 81 | 82 | clock.tick(0); 83 | t.true(composeFunction2.called); 84 | t.is(typeof composeFunction2.getCall(0).args[0], 'function'); 85 | t.true(composeFunction1.calledWith(f2ReturnValue)); 86 | t.true(global.customElements.define.calledWith(componentName, f1ReturnValue)); 87 | }); 88 | -------------------------------------------------------------------------------- /src/__tests__/component/with-connected.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | 4 | import { withConnected } from '../../component.js'; 5 | 6 | test('witConnected call the super then the handler', (t) => { 7 | const handler = sinon.spy(); 8 | const superConnectedCallback = sinon.spy(); 9 | 10 | const Component = withConnected(handler)(class { 11 | connectedCallback(...args) { 12 | return superConnectedCallback(...args); 13 | } 14 | }); 15 | const instance = new Component(); 16 | instance.connectedCallback(); 17 | 18 | t.true(superConnectedCallback.called); 19 | t.true(handler.calledWith(instance)); 20 | }); 21 | -------------------------------------------------------------------------------- /src/__tests__/component/with-handler.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | 4 | import { withHandler } from '../../component.js'; 5 | 6 | test('withHandler add the handler method', (t) => { 7 | const handlerName = 'handlerName'; 8 | const spy = sinon.spy(); 9 | const handler = () => spy; 10 | 11 | const Component = withHandler(handlerName, handler)(class {}); 12 | const instance = new Component(); 13 | instance[handlerName](); 14 | 15 | t.true(spy.called); 16 | }); 17 | 18 | test('withHandler bind the handler method', (t) => { 19 | const handlerName = 'handlerName'; 20 | const handler = ({ contextValue }) => () => contextValue; 21 | const contextValue = 'contextValue'; 22 | 23 | const Component = withHandler(handlerName, handler)(class { 24 | constructor() { 25 | this[contextValue] = contextValue; 26 | } 27 | }); 28 | const instance = new Component(); 29 | const handlerPointer = instance[handlerName]; 30 | const result = handlerPointer(); 31 | 32 | t.is(result, contextValue); 33 | }); 34 | -------------------------------------------------------------------------------- /src/__tests__/component/with-markup.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import proxyquire from 'proxyquire'; 4 | 5 | const setup = render => 6 | proxyquire('../../component.js', { 7 | './render.js': { render }, 8 | }).withMarkup; 9 | 10 | test('withMarkup add a connectedCallback which initiate shadow dom and render', (t) => { 11 | const shadowRoot = 'shadowRoot'; 12 | const handlerResult = 'handlerResult'; 13 | const render = sinon.spy(); 14 | const handler = sinon.stub().returns(handlerResult); 15 | const superConnectedCallback = sinon.spy(); 16 | const attachShadow = sinon.spy(); 17 | 18 | const withMarkup = setup(render); 19 | const Component = withMarkup(handler)(class { 20 | constructor() { 21 | this.shadowRoot = shadowRoot; 22 | } 23 | connectedCallback(...args) { 24 | return superConnectedCallback(...args); 25 | } 26 | attachShadow(...args) { 27 | return attachShadow(...args); 28 | } 29 | }); 30 | const instance = new Component(); 31 | instance.connectedCallback(); 32 | 33 | t.true(superConnectedCallback.called); 34 | t.true(attachShadow.called); 35 | t.is(attachShadow.getCall(0).args[0].mode, 'open'); 36 | t.true(handler.calledWith(instance)); 37 | t.true(render.calledWith(shadowRoot, handlerResult)); 38 | }); 39 | 40 | test('withMarkup add a update action which render', (t) => { 41 | const shadowRoot = 'shadowRoot'; 42 | const handlerResult = 'handlerResult'; 43 | const render = sinon.spy(); 44 | const handler = sinon.stub().returns(handlerResult); 45 | const superUpdate = sinon.spy(); 46 | 47 | const withMarkup = setup(render); 48 | const Component = withMarkup(handler)(class { 49 | constructor() { 50 | this.shadowRoot = shadowRoot; 51 | } 52 | update(...args) { 53 | return superUpdate(...args); 54 | } 55 | }); 56 | const instance = new Component(); 57 | instance.update(); 58 | 59 | t.true(superUpdate.called); 60 | t.true(handler.calledWith(instance)); 61 | t.true(render.calledWith(shadowRoot, handlerResult)); 62 | }); 63 | -------------------------------------------------------------------------------- /src/__tests__/component/with-prop.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | 4 | import { withProp } from '../../component.js'; 5 | 6 | test('withProp add a setter to the component class', (t) => { 7 | const propName = 'name'; 8 | const propValue = 'newValue'; 9 | 10 | const Component = withProp(propName)(class {}); 11 | const componentInstance = new Component(); 12 | componentInstance.__ = {}; 13 | componentInstance.setAttribute = sinon.spy(); 14 | componentInstance.update = sinon.spy(); 15 | 16 | componentInstance[propName] = propValue; 17 | 18 | t.is(componentInstance[propName], componentInstance.__[propName]); 19 | t.true(componentInstance.setAttribute.calledWith(propName, propValue)); 20 | t.true(componentInstance.update.called); 21 | }); 22 | 23 | test('withProp add a getter to the component class', (t) => { 24 | const propName = 'name'; 25 | const propValue = 'newValue'; 26 | 27 | const Component = withProp(propName)(class {}); 28 | const componentInstance = new Component(); 29 | componentInstance.__ = { [propName]: propValue }; 30 | 31 | t.is(componentInstance[propName], propValue); 32 | }); 33 | -------------------------------------------------------------------------------- /src/__tests__/component/with-store.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | 4 | import { withStore } from '../../component.js'; 5 | import { store } from '../../store.js'; 6 | 7 | test('withStore call the super, subscribe and call the handler', (t) => { 8 | const test = 'test'; 9 | const handler = sinon.stub().returns({ test }); 10 | const superConnectedCallback = sinon.spy(); 11 | const superUpdate = sinon.spy(); 12 | 13 | let handlerCallback; 14 | store.subscribe = (callback) => { handlerCallback = callback; }; 15 | 16 | const Component = withStore(handler)(class { 17 | connectedCallback(...args) { 18 | return superConnectedCallback(...args); 19 | } 20 | update(...args) { 21 | return superUpdate(...args); 22 | } 23 | }); 24 | const instance = new Component(); 25 | 26 | instance.connectedCallback(); 27 | 28 | t.true(superConnectedCallback.called); 29 | t.true(handler.calledWith(store, instance)); 30 | t.true(superUpdate.called); 31 | t.is(instance.test, test); 32 | 33 | delete instance.test; 34 | handler.resetHistory(); 35 | superUpdate.resetHistory(); 36 | 37 | handlerCallback(); 38 | 39 | t.true(handler.calledWith(store, instance)); 40 | t.true(superUpdate.called); 41 | t.is(instance.test, test); 42 | }); 43 | -------------------------------------------------------------------------------- /src/__tests__/component/with-style.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | 4 | import { withStyle } from '../../component.js'; 5 | 6 | test('withStyle add a connectedCallback which initiate shadow dom and render', (t) => { 7 | const handlerResult = 'handlerResult'; 8 | const createTextNodeResult = 'createTextNodeResult'; 9 | const handler = sinon.stub().returns(handlerResult); 10 | const superConnectedCallback = sinon.spy(); 11 | const attachShadow = sinon.spy(); 12 | const shadowRoot = { 13 | innerHTML: '', 14 | appendChild: sinon.spy(), 15 | }; 16 | const styleNode = { 17 | appendChild: sinon.spy(), 18 | }; 19 | global.document = { 20 | createElement: sinon.stub().returns(styleNode), 21 | createTextNode: sinon.stub().returns(createTextNodeResult), 22 | }; 23 | 24 | const Component = withStyle(handler)(class { 25 | constructor() { 26 | this.shadowRoot = shadowRoot; 27 | } 28 | connectedCallback(...args) { 29 | return superConnectedCallback(...args); 30 | } 31 | attachShadow(...args) { 32 | return attachShadow(...args); 33 | } 34 | }); 35 | const instance = new Component(); 36 | instance.connectedCallback(); 37 | 38 | t.true(superConnectedCallback.called); 39 | t.true(attachShadow.called); 40 | t.is(attachShadow.getCall(0).args[0].mode, 'open'); 41 | t.is(instance.shadowRoot.innerHTML, ''); 42 | t.true(handler.calledWith(instance)); 43 | t.true(global.document.createTextNode.calledWith(handlerResult)); 44 | t.true(styleNode.appendChild.calledWith(createTextNodeResult)); 45 | t.true(shadowRoot.appendChild.calledWith(styleNode)); 46 | }); 47 | 48 | test('withStyle add a update action which render', (t) => { 49 | const handlerResult = 'handlerResult'; 50 | const createTextNodeResult = 'createTextNodeResult'; 51 | const handler = sinon.stub().returns(handlerResult); 52 | const superUpdate = sinon.spy(); 53 | const styleNode = { 54 | childNodes: [{ remove: sinon.spy() }], 55 | appendChild: sinon.spy(), 56 | }; 57 | const shadowRoot = { 58 | querySelector: sinon.stub().returns(styleNode), 59 | }; 60 | global.document = { 61 | createTextNode: sinon.stub().returns(createTextNodeResult), 62 | }; 63 | 64 | const Component = withStyle(handler)(class { 65 | constructor() { 66 | this.shadowRoot = shadowRoot; 67 | } 68 | update(...args) { 69 | return superUpdate(...args); 70 | } 71 | }); 72 | const instance = new Component(); 73 | instance.update(); 74 | 75 | t.true(superUpdate.called); 76 | t.true(shadowRoot.querySelector.calledWith('style')); 77 | t.true(styleNode.childNodes[0].remove.called); 78 | t.true(handler.calledWith(instance)); 79 | t.true(global.document.createTextNode.calledWith(handlerResult)); 80 | t.true(styleNode.appendChild.calledWith(createTextNodeResult)); 81 | }); 82 | -------------------------------------------------------------------------------- /src/component.js: -------------------------------------------------------------------------------- 1 | import { compose } from './utils.js'; 2 | import { render } from './render.js'; 3 | import { store } from './store.js'; 4 | 5 | export const component = (name, ...enhancers) => { 6 | setTimeout(() => { 7 | const customElement = class extends HTMLElement { 8 | constructor() { 9 | super(); 10 | if (!this.__) { 11 | this.__ = {}; 12 | } 13 | } 14 | connectedCallback() {} 15 | update() {} 16 | }; 17 | customElements.define(name, compose(...enhancers)(customElement)); 18 | }); 19 | }; 20 | 21 | export const withProp = name => Base => 22 | class extends Base { 23 | set [name](value) { 24 | this.__[name] = value; 25 | this.setAttribute(name, value); 26 | this.update(); 27 | } 28 | get [name]() { 29 | return this.__[name]; 30 | } 31 | }; 32 | 33 | export const withMarkup = handler => Base => 34 | class extends Base { 35 | connectedCallback() { 36 | super.connectedCallback(); 37 | this.attachShadow({ mode: 'open' }); 38 | render(this.shadowRoot, handler(this)); 39 | } 40 | update() { 41 | super.update(); 42 | render(this.shadowRoot, handler(this)); 43 | } 44 | }; 45 | 46 | export const withStyle = handler => (Base) => { 47 | const createStyle = context => document.createTextNode(handler(context)); 48 | 49 | return class extends Base { 50 | connectedCallback() { 51 | super.connectedCallback(); 52 | this.attachShadow({ mode: 'open' }); 53 | this.shadowRoot.innerHTML = ''; 54 | const styleNode = document.createElement('style'); 55 | styleNode.appendChild(createStyle(this)); 56 | this.shadowRoot.appendChild(styleNode); 57 | } 58 | update() { 59 | super.update(); 60 | const styleNode = this.shadowRoot.querySelector('style'); 61 | styleNode.childNodes[0].remove(); 62 | styleNode.appendChild(createStyle(this)); 63 | } 64 | }; 65 | }; 66 | 67 | export const withHandler = (name, handler) => Base => 68 | class extends Base { 69 | constructor() { 70 | super(); 71 | this[name] = this[name].bind(this); 72 | } 73 | [name](event) { 74 | return handler(this)(event); 75 | } 76 | }; 77 | 78 | export const withConnected = hanlder => Base => 79 | class extends Base { 80 | connectedCallback() { 81 | super.connectedCallback(); 82 | hanlder(this); 83 | } 84 | }; 85 | 86 | export const withStore = handler => Base => 87 | class extends Base { 88 | connectedCallback() { 89 | super.connectedCallback(); 90 | const storeUpdateHandler = () => { 91 | Object.assign(this, handler(store, this)); 92 | this.update(); 93 | }; 94 | store.subscribe(storeUpdateHandler); 95 | storeUpdateHandler(); 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /src/parser.js: -------------------------------------------------------------------------------- 1 | import { forEach, flatten } from './utils.js'; 2 | 3 | const paramRegex = /__(\d)+/; 4 | const compoRegex = /(<\/?\s*)__(\d*)(\s*>)/g; 5 | 6 | const templateParser = (string) => { 7 | const template = document.createElement('template'); 8 | template.innerHTML = string.trim(); 9 | // console.log('parser', string.trim(), template.content.childNodes[0].cloneNode()) 10 | return template.content; 11 | }; 12 | 13 | const replaceAnchors = (parent, params) => { 14 | // console.log('replaceAnchors', parent, params) 15 | forEach(parent.childNodes, (childNode) => { 16 | if (childNode.attributes) { 17 | forEach(childNode.attributes, (attr) => { 18 | const match = attr.value.trim().match(paramRegex); 19 | if (match) { 20 | const param = params[parseInt(match[1], 10)]; 21 | if (!childNode.__) { 22 | childNode.__ = {}; 23 | } 24 | childNode.__[attr.name] = param; 25 | childNode.setAttribute(attr.name, param); 26 | if (attr.name.startsWith('on')) { 27 | childNode[attr.name] = param; 28 | } 29 | } 30 | }); 31 | } 32 | if (childNode.nodeValue) { 33 | // console.log('coucou', childNode.nodeValue, childNode.nodeValue.trim().match(paramRegex)); 34 | const match = childNode.nodeValue.trim().match(paramRegex); 35 | if (match) { 36 | const param = params[parseInt(match[1], 10)]; 37 | // console.log('coucou', param, param instanceof DocumentFragment || Array.isArray(param)); 38 | if (param instanceof DocumentFragment || Array.isArray(param)) { 39 | const children = Array.isArray(param) ? flatten(param) : [param]; 40 | parent.removeChild(childNode); 41 | children.forEach(child => parent.appendChild(child)); 42 | } else { 43 | childNode.nodeValue = param; 44 | // match.forEach(singleMatch => { 45 | // childNode.nodeValue.replace(singleMatch, param); 46 | // }) 47 | } 48 | } 49 | } else { 50 | replaceAnchors(childNode, params); 51 | } 52 | }); 53 | }; 54 | 55 | // ❤️ http://2ality.com/2014/07/jsx-template-strings.html 56 | // ❤️ https://gist.github.com/lygaret/a68220defa69174bdec5 57 | export function html(parts, ...params) { 58 | const stringWithAnchors = parts.reduce( 59 | (acc, part, i) => 60 | (i !== parts.length - 1 ? `${acc}${part}__${i}` : `${acc}${part}`), 61 | '', 62 | ); 63 | const stringWithComponents = stringWithAnchors.replace( 64 | compoRegex, 65 | (match, start, id, end) => `${start}${params[parseInt(id, 10)]}${end}`, 66 | ); 67 | const domWithAnchors = templateParser(stringWithComponents); 68 | // console.log('parse', stringWithAnchors, domWithAnchors); 69 | replaceAnchors(domWithAnchors, params); 70 | return domWithAnchors.childNodes[0]; 71 | } 72 | 73 | export function css(parts, ...params) { 74 | return parts.reduce( 75 | (acc, part, i) => 76 | (i !== parts.length - 1 ? `${acc}${part}${params[i]}` : `${acc}${part}`), 77 | '', 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/render.js: -------------------------------------------------------------------------------- 1 | import { forEach, find } from './utils.js'; 2 | 3 | function updateAttr(target, name, newAttr, oldAttr) { 4 | if (!newAttr) { 5 | target.removeAttribute(name); 6 | } else if (!oldAttr.value || newAttr.value !== oldAttr.value) { 7 | target.setAttribute(name, newAttr.value); 8 | } 9 | } 10 | 11 | function updateAttrs(target, newNode) { 12 | const attrNames = new Set(); 13 | [target, newNode].forEach(node => 14 | forEach(node.attributes, attr => attrNames.add(attr.name))); 15 | attrNames.forEach((name) => { 16 | updateAttr(target, name, newNode.attributes[name], target.attributes[name]); 17 | // console.log('set property', target, newNode, newNode[name], newNode.__) 18 | if (newNode.__ && newNode.__[name] !== undefined) { 19 | // console.log('set computed property', name, newNode.__[name]) 20 | target[name] = newNode.__[name]; 21 | } 22 | }); 23 | } 24 | 25 | function changed(node1, node2) { 26 | // console.log('changed', node1, node2, node1.tagName, node2.tagName); 27 | return ( 28 | ((node1.tagName !== undefined || node2.tagName !== undefined) && 29 | node1.tagName !== node2.tagName) || 30 | (node1.tagName === undefined && 31 | node2.tagName === undefined && 32 | node1.data !== node2.data) 33 | ); 34 | } 35 | 36 | function makeChildPairs(oldNode, newNode) { 37 | const pairs = []; 38 | 39 | // Matching keys 40 | forEach(oldNode.childNodes, (oldChildNode) => { 41 | if (oldChildNode.attributes && oldChildNode.attributes.key) { 42 | const match = find(newNode.childNodes, newChildNode => 43 | ( 44 | newChildNode.attributes && 45 | newChildNode.attributes.key && 46 | oldChildNode.attributes.key.value === 47 | newChildNode.attributes.key.value 48 | )); 49 | if (match) { 50 | pairs.push([oldChildNode, match]); 51 | } else { 52 | pairs.push([oldChildNode, undefined]); 53 | } 54 | } 55 | }); 56 | 57 | // Others 58 | let oldIndex = 0; 59 | let newIndex = 0; 60 | 61 | while ( 62 | oldIndex < oldNode.childNodes.length || 63 | newIndex < newNode.childNodes.length 64 | ) { 65 | const oldChildNode = oldNode.childNodes[oldIndex]; 66 | const newChildNode = newNode.childNodes[newIndex]; 67 | if ( 68 | oldChildNode !== undefined && 69 | pairs.find(pair => oldChildNode === pair[0]) 70 | ) { 71 | oldIndex += 1; 72 | } else if ( 73 | newChildNode !== undefined && 74 | pairs.find(pair => newChildNode === pair[1]) 75 | ) { 76 | newIndex += 1; 77 | } else { 78 | pairs.push([oldChildNode, newChildNode]); 79 | oldIndex += 1; 80 | newIndex += 1; 81 | } 82 | } 83 | 84 | return pairs; 85 | } 86 | 87 | function updateElement(parent, newNode, oldNode) { 88 | if (!oldNode) { 89 | parent.appendChild(newNode); 90 | } else if (!newNode) { 91 | parent.removeChild(oldNode); 92 | } else if (changed(newNode, oldNode)) { 93 | parent.replaceChild(newNode, oldNode); 94 | } else if (newNode.tagName) { 95 | // console.log('updateElement merge', oldNode, newNode) 96 | updateAttrs(oldNode, newNode); 97 | makeChildPairs(oldNode, newNode).forEach((pair) => { 98 | // console.log('pairs', pair[1], pair[0]) 99 | updateElement(oldNode, pair[1], pair[0]); 100 | }); 101 | } 102 | } 103 | 104 | export function render(parent, html) { 105 | // console.log('render2', parent, html, parent.childNodes[0]); 106 | updateElement(parent, html, parent.childNodes[0]); 107 | } 108 | -------------------------------------------------------------------------------- /src/router/compo-path.js: -------------------------------------------------------------------------------- 1 | import { component, withProp, withMarkup } from '../component.js'; 2 | import { html } from '../parser.js'; 3 | import { withRouteEvent } from './router.js'; 4 | 5 | component( 6 | 'compo-path', 7 | withProp('path'), 8 | withProp('component'), 9 | withRouteEvent((url, context) => { 10 | context.activated = context.path === url; 11 | }), 12 | withMarkup(({ activated, component }) => 13 | (activated ? html`<${component}>` : html`
`)), 14 | ); 15 | -------------------------------------------------------------------------------- /src/router/router.js: -------------------------------------------------------------------------------- 1 | const initRouter = () => { 2 | let url = window.location.pathname; 3 | window.history.replaceState(url, null, url); 4 | 5 | const subscribers = []; 6 | 7 | const router = {}; 8 | 9 | router.subscribe = (listener) => { 10 | subscribers.push(listener); 11 | listener(url); 12 | }; 13 | 14 | router.go = (newUrl) => { 15 | window.history.pushState(newUrl, null, newUrl); 16 | url = newUrl; 17 | subscribers.forEach(subscriber => subscriber(url)); 18 | }; 19 | 20 | window.addEventListener('popstate', (event) => { 21 | url = event.state; 22 | subscribers.forEach(subscriber => subscriber(url)); 23 | }); 24 | 25 | return router; 26 | }; 27 | 28 | const router = initRouter(); 29 | 30 | export const withRouteEvent = handler => Base => 31 | class extends Base { 32 | connectedCallback() { 33 | super.connectedCallback(); 34 | 35 | router.subscribe((url) => { 36 | handler(url, this); 37 | this.update(); 38 | }); 39 | } 40 | }; 41 | 42 | export const withRouteAction = (handlerName = 'go') => Base => 43 | class extends Base { 44 | [handlerName](url) { 45 | router.go(url); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | export const store = {}; 2 | 3 | export const createStore = (reducer, initialState) => { 4 | let state = initialState; 5 | const subscribers = []; 6 | 7 | store.getState = () => state; 8 | store.subscribe = listener => subscribers.push(listener); 9 | store.dispatch = (action) => { 10 | state = reducer(state, action); 11 | // console.log('dispatch', 'action :', action, 'new state :', state); 12 | subscribers.forEach(subscriber => subscriber()); 13 | }; 14 | 15 | return store; 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // ❤️ http://2ality.com/2013/11/initializing-arrays.html 2 | export function range(n) { 3 | return Array(...Array(n)).map((_, i) => i); 4 | } 5 | 6 | // ❤️ https://stackoverflow.com/a/15030117 7 | export function flatten(array) { 8 | return array.reduce( 9 | (flat, toFlatten) => flat.concat(Array.isArray(toFlatten) ? flatten(toFlatten) : toFlatten), 10 | [], 11 | ); 12 | } 13 | 14 | export function times(n, func) { 15 | for (let i = 0; i < n; i += 1) { 16 | func(); 17 | } 18 | } 19 | 20 | // Needed to use Array's functions on non "real" Arrays like NodeList 21 | export const forEach = (...args) => Array.prototype.forEach.call(...args); 22 | export const map = (...args) => Array.prototype.map.call(...args); 23 | export const reduce = (...args) => Array.prototype.reduce.call(...args); 24 | export const find = (...args) => Array.prototype.find.call(...args); 25 | 26 | // ❤️ https://github.com/acdlite/recompose/blob/master/src/packages/recompose/compose.js 27 | export const compose = (...funcs) => 28 | funcs.reduce((a, b) => (...args) => a(b(...args)), arg => arg); 29 | --------------------------------------------------------------------------------