├── .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 | 
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 | ${'swiip-heading-container'}>
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 |
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}>${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 |
--------------------------------------------------------------------------------