├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── docs ├── CNAME ├── components │ ├── my-counter.js │ ├── simple-clock.js │ ├── todo-list.js │ ├── x-logo.js │ └── x-pager.js ├── data.js ├── docs.html ├── events.html ├── forms.html ├── index.html ├── my-counter.html ├── repeated-blocks.html ├── simple-clock.html ├── styles.html ├── template-syntax.html └── todo-list.html ├── examples ├── .prettierrc ├── 02-components-and-partials │ ├── README.md │ ├── components │ │ ├── app.js │ │ ├── myFooter.js │ │ └── myHeader.js │ ├── index.html │ ├── synergy.js │ └── utils.js └── todo-app │ └── index.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── attribute.js ├── constants.js ├── context.js ├── css.js ├── define.js ├── formControl.js ├── helpers.js ├── index.js ├── list.js ├── mergeSlots.js ├── partial.js ├── proxy.js ├── render.js ├── template.js ├── token.js └── update.js ├── test ├── actions.js ├── define.js ├── helpers.js ├── hydrate.js ├── if.js ├── interpolations.js ├── iterations.js ├── middleware.js ├── partials.js ├── scope.js ├── slots.js ├── todo-app.js └── user-input.js ├── types └── index.d.ts └── website ├── components ├── my-counter.js ├── simple-clock.js ├── todo-list.js ├── x-logo.js └── x-pager.js ├── customElements.js ├── data └── nav.js ├── head.js ├── package-lock.json ├── package.json ├── pages ├── api.md ├── events.md ├── forms.md ├── getting-started.md ├── hydration.md ├── index.md ├── my-counter.md ├── repeated-blocks.md ├── simple-clock.md ├── styles.md ├── template-syntax.md └── todo-list.md ├── public └── data.js ├── server.js ├── templates ├── document.js └── nav.js └── utils.js /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '33 14 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | coverage 3 | node_modules 4 | dist 5 | cjs 6 | scratch* 7 | synergy.js -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 17.9.1 4 | git: 5 | depth: 1 6 | branches: 7 | only: 8 | - master 9 | after_success: 10 | - "npm run coveralls" 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Matt Donkin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [synergy](https://synergyjs.org) 2 | 3 | ## [![npm](https://img.shields.io/npm/v/synergy.svg)](http://npm.im/synergy) [![Build Status](https://travis-ci.com/defx/synergy.svg?branch=master)](https://travis-ci.com/defx/synergy) [![Coverage Status](https://coveralls.io/repos/github/defx/synergy/badge.svg?branch=master)](https://coveralls.io/github/defx/synergy?branch=master) [![gzip size](https://img.badgesize.io/https://unpkg.com/synergy/dist/synergy.min.js?compression=gzip&label=gzip)]() 4 | 5 | Synergy is a JavaScript library for building Web Components 6 | 7 | ## Features 8 | 9 | - Simple templates for declarative data & event binding 10 | - Reactive data bindings update your view efficiently and 11 | automatically 12 | - Full component workflow using standard Custom Elements 13 | - Small footprint (<5k) 14 | - No special compiler, plugins, required 15 | - Minimal learning curve (almost entirely standard HTML, JS, 16 | and CSS!) 17 | - Interoperable with other libraries and frameworks 18 | 19 | [Learn how to use Synergy in your own project](https://synergyjs.org/learn/introduction). 20 | 21 | ## Browser Support 22 | 23 | Works in any 24 | [modern browser](https://caniuse.com/mdn-javascript_builtins_proxy_proxy) 25 | that supports JavaScript Proxy. 26 | 27 | ## Installation 28 | 29 | Synergy is available from npm: 30 | 31 | ```bash 32 | $ npm i synergy 33 | ``` 34 | 35 | You can also import Synergy directly in the browser via CDN: 36 | 37 | ```html 38 | 41 | ``` 42 | 43 | ## Documentation 44 | 45 | You can find the Synergy documentation 46 | [on the website](https://synergyjs.org/). 47 | 48 | ## Example 49 | 50 | ### Step 1. Define your custom element 51 | 52 | ```html 53 | 58 | ``` 59 | 60 | ### Step 2. Use the Custom Element 61 | 62 | ```html 63 | 64 | ``` 65 | 66 | This example will render "Hello Kimberley!" into a container 67 | on the page. 68 | 69 | You'll notice that everything here is valid HTML and JS, and 70 | you can copy and paste this example and run it directly in 71 | the browser with no need to compile or install anything 72 | special to make it work. 73 | 74 | ### License 75 | 76 | Synergy is [MIT licensed](./LICENSE). 77 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | synergyjs.org 2 | -------------------------------------------------------------------------------- /docs/components/my-counter.js: -------------------------------------------------------------------------------- 1 | import { define } from "/synergy.js" 2 | 3 | const name = "my-counter" 4 | 5 | const factory = () => ({ 6 | state: { count: 0 }, 7 | update: { 8 | increment: (state) => ({ 9 | ...state, 10 | count: state.count + 1, 11 | }), 12 | }, 13 | }) 14 | 15 | const template = /* html */ ` 16 | 17 | ` 18 | 19 | const css = /* css */ ` 20 | button { 21 | cursor: pointer; 22 | padding: 0.5rem 1rem; 23 | background-color: gold; 24 | color: tomato; 25 | } 26 | ` 27 | 28 | define(name, factory, template) 29 | -------------------------------------------------------------------------------- /docs/components/simple-clock.js: -------------------------------------------------------------------------------- 1 | import { define } from "/synergy.js" 2 | 3 | define( 4 | "simple-clock", 5 | () => { 6 | let t 7 | 8 | return { 9 | update: { 10 | setTime: (state, { payload }) => { 11 | return { 12 | ...state, 13 | time: payload, 14 | } 15 | }, 16 | }, 17 | connectedCallback: ({ dispatch, getState }) => { 18 | t = setInterval(() => { 19 | dispatch({ 20 | type: "setTime", 21 | payload: new Date().toLocaleTimeString(), 22 | }) 23 | }, 100) 24 | }, 25 | disconnectedCallback: () => { 26 | clearInterval(t) 27 | }, 28 | } 29 | }, 30 | /* html */ ` 31 |

{{ time }}

32 | ` 33 | ) 34 | -------------------------------------------------------------------------------- /docs/components/todo-list.js: -------------------------------------------------------------------------------- 1 | import { define } from "/synergy.js" 2 | 3 | define( 4 | "todo-list", 5 | () => { 6 | return { 7 | state: { 8 | todos: [], 9 | }, 10 | update: { 11 | addTodo: (state, { event: { key } }) => { 12 | if (key !== "Enter" || !state.newTodo?.length) return state 13 | 14 | return { 15 | ...state, 16 | newTodo: null, 17 | todos: state.todos.concat({ 18 | title: state.newTodo, 19 | completed: false, 20 | }), 21 | } 22 | }, 23 | removeTodo: (state, { scope: { todo } }) => { 24 | return { 25 | ...state, 26 | todos: state.todos.filter(({ title }) => title !== todo.title), 27 | } 28 | }, 29 | }, 30 | getState: (state) => { 31 | const n = state.todos.filter(({ completed }) => !completed).length 32 | 33 | return { 34 | ...state, 35 | itemsLeft: `${n} item${n === 1 ? "" : "s"} left`, 36 | } 37 | }, 38 | } 39 | }, 40 | /* html */ ` 41 | 42 | 49 |

{{ itemsLeft }}

50 | 51 | ` 52 | ) 53 | -------------------------------------------------------------------------------- /docs/components/x-logo.js: -------------------------------------------------------------------------------- 1 | import { partial } from "../synergy.js" 2 | 3 | partial( 4 | "x-logo", 5 | /* HTML */ ` 6 | 11 | 21 | 25 | 29 | 33 | 34 | 35 | `, 36 | /* CSS */ ` 37 | x-logo { 38 | height: 2rem; 39 | } 40 | 41 | svg { 42 | height: 2rem; 43 | /* margin: 0 0.5rem; */ 44 | margin-right: 0.5rem; 45 | } 46 | 47 | path { 48 | stroke: teal; 49 | fill: teal; 50 | } 51 | 52 | ` 53 | ) 54 | -------------------------------------------------------------------------------- /docs/components/x-pager.js: -------------------------------------------------------------------------------- 1 | import { define } from "../synergy.js" 2 | import { flatNav } from "../data.js" 3 | 4 | define( 5 | "x-pager", 6 | () => { 7 | const items = flatNav 8 | const { pathname } = location 9 | const index = items.findIndex(({ href }) => href === pathname) 10 | 11 | return { 12 | state: { 13 | prev: items[index - 1], 14 | next: items[index + 1], 15 | }, 16 | } 17 | }, 18 | /* html */ ` 19 | 20 | NEXT{{ next.title }} 21 | 24 | 25 | 26 | PREV{{ prev.title }} 27 | 30 | 31 | 32 | `, 33 | /* css */ ` 34 | x-pager { 35 | display: flex; 36 | flex-direction: column; 37 | padding: var(--s3) 0; 38 | } 39 | 40 | x-pager > * { 41 | flex: 1; 42 | } 43 | 44 | a { 45 | padding: var(--s3); 46 | border: 1px solid #eaeaea; 47 | } 48 | 49 | svg { 50 | stroke: currentColor; 51 | stroke-width: 1px; 52 | margin: auto 0; 53 | } 54 | 55 | a { 56 | margin-top: 0; 57 | text-decoration: none; 58 | vertical-align: center; 59 | font-size: var(--s2); 60 | color: #111; 61 | } 62 | 63 | a:hover { 64 | border: 1px solid rgb(102, 204, 51); 65 | color: rgb(102, 204, 51); 66 | } 67 | 68 | a span { 69 | color: #222; 70 | display: block; 71 | font-size: var(--s1); 72 | } 73 | 74 | a:nth-of-type(2) { 75 | text-align: right; 76 | } 77 | 78 | a:nth-of-type(1) { 79 | text-align: left; 80 | } 81 | 82 | a:nth-of-type(2) svg { 83 | float: left; 84 | } 85 | 86 | a:nth-of-type(1) svg { 87 | float: right; 88 | } 89 | 90 | a:nth-of-type(1) { 91 | margin-bottom: var(--s1); 92 | } 93 | 94 | @media screen and (min-width: 1024px) { 95 | x-pager { 96 | flex-direction: row-reverse; 97 | } 98 | 99 | a:nth-of-type(1) { 100 | margin-bottom: 0; 101 | } 102 | 103 | a:nth-of-type(2) { 104 | margin-right: 1rem; 105 | } 106 | } 107 | ` 108 | ) 109 | -------------------------------------------------------------------------------- /docs/data.js: -------------------------------------------------------------------------------- 1 | export const navigation = [ 2 | { 3 | title: "Getting Started", 4 | items: [ 5 | { 6 | title: "Introduction", 7 | href: "/", 8 | }, 9 | ], 10 | }, 11 | { 12 | title: "Learn by Example", 13 | items: [ 14 | { 15 | title: "my-counter", 16 | href: "/my-counter", 17 | }, 18 | { 19 | title: "simple-clock", 20 | href: "/simple-clock", 21 | }, 22 | { 23 | title: "todo-list", 24 | href: "/todo-list", 25 | }, 26 | ], 27 | }, 28 | { 29 | title: "Reference", 30 | items: [ 31 | { 32 | title: "Template syntax", 33 | href: "/template-syntax", 34 | }, 35 | { 36 | title: "Repeated blocks", 37 | href: "/repeated-blocks", 38 | }, 39 | { 40 | title: "Events", 41 | href: "/events", 42 | }, 43 | { 44 | title: "Styles", 45 | href: "/styles", 46 | }, 47 | { 48 | title: "Forms", 49 | href: "/forms", 50 | }, 51 | { 52 | title: "Slots", 53 | href: "/slots", 54 | }, 55 | ], 56 | }, 57 | ] 58 | 59 | function flatten(items) { 60 | return items.reduce((acc, item) => { 61 | if (item.items) { 62 | acc.push(...item.items.map((v) => ({ ...v, category: item.title }))) 63 | } 64 | return acc 65 | }, []) 66 | } 67 | 68 | export const flatNav = flatten(navigation) 69 | -------------------------------------------------------------------------------- /docs/docs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 16 | 170 | 171 | 172 |
173 | 182 |
183 | 217 |
218 |
219 | 220 | 221 | -------------------------------------------------------------------------------- /docs/events.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Events | Synergy JS 9 | 16 | 170 | 171 | 172 |
173 | 217 |

Events

218 |

Synergy allows you to map events to update functions of the same name.

219 |

Model:

220 |
const factory = () => ({
221 |   update: {
222 |     sayHello: (state) => ({
223 |       ...state,
224 |       greeting: "Hello"
225 |     }),
226 |   })
227 | 
228 |

Template:

229 |
<button :onclick="sayHello">Say hello</button>
230 | 
231 |

Every update function accepts the current state as its first argument, its return value will provide the next state for the custom element.

232 |

Template scope

233 |

Because each repeated block creates a new variable scope, it is useful to be able to access those values within your handler. You can do this via the scope property of the second argument to your event handler.

234 |

Model:

235 |
const factory = () => {
236 |   return {
237 |     state: {
238 |       artists: [
239 |         {
240 |           name: "pablo picasso",
241 |           tags: ["painter", "sculptor", "printmaker", "ceramicist"],
242 |         },
243 |         {
244 |           name: "salvador dali",
245 |           tags: ["painter", "sculptor", "photographer", "writer"],
246 |         },
247 |       ],
248 |     },
249 |     update: {
250 |       select: (state, { scope }) => {
251 |         const { artist, tag } = scope
252 | 
253 |         return {
254 |           ...state,
255 |           selected: {
256 |             artist,
257 |             tag,
258 |           },
259 |         }
260 |       },
261 |     },
262 |   }
263 | }
264 | 
265 |

Template:

266 |
<article :each="artist in artists">
267 |   <h4>{{ artist.name }}</h4>
268 |   <ul>
269 |     <li :each="tag in artist.tags" :onclick="select">{{ tag }}</li>
270 |   </ul>
271 | </article>
272 | 
273 |
274 |
275 | 276 | 277 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Synergy - A JavaScript library for crafting user interfaces 9 | 16 | 170 | 171 | 172 |
173 | 217 |

What is Synergy?

218 |

Synergy 219 | combines declarative data and event binding with functional state 220 | management and reactive updates to allow you to build all types of 221 | user interface for the web, no matter how simple or complex.

222 |

Here's a simple example:

223 |

224 |
<my-counter></my-counter>
225 | 
226 | <script type="module">
227 |   import { define } from "https://unpkg.com/synergy@8.0.0"
228 | 
229 |   define(
230 |     "my-counter",
231 |     () => ({
232 |       state: { count: 0 },
233 |       update: {
234 |         increment: ({ count }) => ({
235 |           count: count + 1,
236 |         }),
237 |   }),
238 |     `<button :onclick="increment">Count is: {{ count }}</button>`
239 |   )
240 | </script>
241 | 
242 |

The above example demonstrates the three core features of Synergy:

243 | 254 |

Getting started

255 |

If you're new to Synergy then the best place to start is the Learn by Example section. It will introduce you to all of the features of Synergy by showing different examples that will help you to understand and learn quickly.

256 |
257 |
258 | 259 | 260 | -------------------------------------------------------------------------------- /docs/my-counter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Learn by example - My Counter | Synergy JS 9 | 16 | 170 | 171 | 172 |
173 | 217 |

my-counter

218 |

219 |

A key feature of any user interface is the ability to update its state in response to different events. In Synergy, this process is defined by the object returned from your custom elements factory function.

220 |

Lets take another look at the my-counter example from the home page:

221 |
const factory = () => ({
222 |   state: { count: 0 },
223 |   update: {
224 |     increment: ({ count }) => ({
225 |       count: count + 1,
226 |     }),
227 |   })
228 | 
229 |

In the example above, the returned object includes both the state and update properties.

230 |

State

231 |

State is an object that provides the initial data for your custom element. Any properties defined here can be used directly in the template via text or attribute bindings, which is exactly how the value of count is included inside the template using text interpolation:

232 |
<button :onclick="increment">Count is: {{ count }}</button>
233 | 
234 |

Update

235 |

Update is a dictionary of named state update functions that can be referenced directly in the template as event handlers, as per the :onclick="increment" binding in the example above.

236 |

Each state update function takes the current state as its first argument and returns the next state for the custom element.

237 |
238 |
239 | 240 | 241 | -------------------------------------------------------------------------------- /docs/simple-clock.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Learn by example - Simple Clock | Synergy JS 9 | 16 | 170 | 171 | 172 |
173 | 217 |

simple-clock

218 |

219 |

In this example we're going to create a custom element that displays the current time. We need a way to setup a scheduled event once the element is mounted to the DOM, and we will also need a way to programatically update state whenever that event fires to keep our time value synchronised with the real world.

220 |

connectedCallback is a lifecycle event that fires whenever the element is connected to the DOM...

221 |
const factory = () => {
222 |   return {
223 |     update: {
224 |       setTime: (state) => {
225 |         return {
226 |           ...state,
227 |           time: new Date().toLocaleTimeString(),
228 |         }
229 |       },
230 |     },
231 |     connectedCallback: ({ dispatch }) => {
232 |       setInterval(() => {
233 |         dispatch({
234 |           type: "setTime",
235 |         })
236 |       }, 100)
237 |     },
238 |   }
239 | }
240 | 
241 |
<p>Time: {{ time }}</p>
242 | 
243 |

In order to programatically update state we can use the dispatch function provided within the first argument to connectedCallback. The dispatch function accepts a single argument which is an object that must include a type property to identify the update function that we want to call.

244 |

disconnectedCallback

245 |

Of course, our element can be removed from the DOM at some later point, and we wouldn't want it to carry on scheduling re-renders in the background if there was nobody there to see it. So let's improve our simple-clock element to use the 246 | disconnectedCallback to clear our timer interval.

247 |
const factory = () => {
248 |   let intervalID
249 | 
250 |   return {
251 |     update: {
252 |       setTime: (state, { payload }) => {
253 |         return {
254 |           ...state,
255 |           time: payload,
256 |         }
257 |       },
258 |     },
259 |     connectedCallback: ({ dispatch }) => {
260 |       intervalID = setInterval(() => {
261 |         dispatch({
262 |           type: "setTime",
263 |           payload: new Date().toLocaleTimeString(),
264 |         })
265 |       }, 100)
266 |     },
267 |     disconnectedCallback: () => {
268 |       clearInterval(intervalID)
269 |     },
270 |   }
271 | }
272 | 
273 |
274 |
275 | 276 | 277 | -------------------------------------------------------------------------------- /docs/styles.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Styles | Synergy JS 9 | 16 | 170 | 171 | 172 |
173 | 217 |

Styles

218 |

Multiple classes with Array

219 |

State:

220 |
{
221 |   classes: ["w32", "h32", "rounded-full", "mx-auto"]
222 | }
223 | 
224 |

Template:

225 |
<img :class="classes" />
226 | 
227 |

Output:

228 |
<img class="w32 h32 rounded-full mx-auto" />
229 | 
230 |

Conditional Classes with Object

231 |

State:

232 |
{
233 |     classes: {
234 |         'mx-auto': true,
235 |         'has-error': false
236 |     }
237 | }
238 | 
239 |

Template:

240 |
<div :class="classes"></div>
241 | 
242 |

Output:

243 |
<div class="mx-auto"></div>
244 | 
245 |

Inline Styles

246 |

State:

247 |
{
248 |    primary: true,
249 |    style: {
250 |         display: "inline-block",
251 |         borderRadius: "3px",
252 |         background: this.primary ? "white" : "transparent",
253 |         color: this.primary ? "black" : "white",
254 |         border: "2px solid white",
255 |     }
256 | }
257 | 
258 |

Template:

259 |
<button :primary :style></button>
260 | 
261 |

Output:

262 |
<button
263 |   primary
264 |   style="
265 |     display: inline-block; 
266 |     border-radius: 3px; 
267 |     background: white; 
268 |     color: black; 
269 |     border: 2px solid white;"
270 | ></button>
271 | 
272 |
273 |
274 | 275 | 276 | -------------------------------------------------------------------------------- /examples/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /examples/02-components-and-partials/README.md: -------------------------------------------------------------------------------- 1 | # Components and partials 2 | 3 | Synergy allows you to create two types of components, _active_ and _passive_ components. Active components are the ones we've already looked at, which we will refer to simply as "components". Components are characterised primarily by the fact that they manage their own state. Passive components, on the other hand, don't manage state at all, they simply provide a template that is mixed into the parent components template, much like an HTML include. We refer to these types of components as "partials". Partials share state with the parent that includes them. Why? Because the power of components comes from the ability to break down your code into reusable chunks that can be recombined in different ways, but having to wire every single component together with props and independent state management can be time consuming to develop and maintain, and results in loading much more javascript than necessary. That why Synergy provides the option to create both components and partials, because sometimes we just want reusable chunks of HTML (and maybe also CSS). Remember, one of the key principles of good engineering design is to use the minimum number of moving parts required to achieve your goal. the fewer parts you have, the easier your design will be to build and maintain. "Less is more", as they say. 4 | -------------------------------------------------------------------------------- /examples/02-components-and-partials/components/app.js: -------------------------------------------------------------------------------- 1 | import { define } from "../synergy.js" 2 | import { html } from "../utils.js" 3 | 4 | import "./myHeader.js" 5 | import "./myFooter.js" 6 | 7 | define( 8 | "my-app", 9 | () => { 10 | return { 11 | title: "First & Foremost", 12 | } 13 | }, 14 | html` 15 | 16 | 17 | ` 18 | ) 19 | -------------------------------------------------------------------------------- /examples/02-components-and-partials/components/myFooter.js: -------------------------------------------------------------------------------- 1 | import { partial } from "../synergy.js" 2 | import { html } from "../utils.js" 3 | 4 | partial("my-footer", html` `) 5 | -------------------------------------------------------------------------------- /examples/02-components-and-partials/components/myHeader.js: -------------------------------------------------------------------------------- 1 | import { partial } from "../synergy.js" 2 | import { html, css } from "../utils.js" 3 | 4 | partial( 5 | "my-header", 6 | html` 7 |
8 | {{ title }} 9 |
10 | `, 11 | css` 12 | header { 13 | background-color: gold; 14 | } 15 | a { 16 | color: tomato; 17 | } 18 | ` 19 | ) 20 | -------------------------------------------------------------------------------- /examples/02-components-and-partials/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Components and partials - Synergy JS 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/02-components-and-partials/utils.js: -------------------------------------------------------------------------------- 1 | const idTpl = (strings, ...values) => { 2 | return strings.reduce((a, s, i) => a + s + (values[i] || ""), "") 3 | } 4 | 5 | export const html = idTpl 6 | 7 | export const css = (strings, ...values) => { 8 | return (name) => { 9 | const styles = strings.reduce((a, s, i) => a + s + (values[i] || ""), "") 10 | appendStyles(name, prefixSelectors(name, styles)) 11 | } 12 | } 13 | 14 | function prefixSelectors(prefix, css) { 15 | let insideBlock = false 16 | let look = true 17 | let output = "" 18 | let count = 0 19 | 20 | for (let char of css) { 21 | if (char === "}") { 22 | insideBlock = false 23 | look = true 24 | } else if (char === ",") { 25 | look = true 26 | } else if (char === "{") { 27 | insideBlock = true 28 | } else if (look && !insideBlock && !char.match(/\s/)) { 29 | let w = nextWord(css, count + 1) 30 | if ( 31 | w !== prefix && 32 | w.charAt(0) !== "@" && 33 | w.charAt(0) !== ":" && 34 | w.charAt(0) !== "*" && 35 | w !== "html" && 36 | w !== "body" 37 | ) { 38 | output += prefix + " " 39 | } 40 | look = false 41 | } 42 | output += char 43 | count += 1 44 | } 45 | } 46 | 47 | function appendStyles(name, css) { 48 | if (document.querySelector(`style#${name}`)) return 49 | 50 | const el = document.createElement("style") 51 | el.id = name 52 | el.innerHTML = prefixSelectors(name, css) 53 | document.head.appendChild(el) 54 | } 55 | -------------------------------------------------------------------------------- /examples/todo-app/index.js: -------------------------------------------------------------------------------- 1 | export const storage = { 2 | get: (k) => { 3 | let v = JSON.parse(localStorage.getItem(k)) 4 | return v 5 | }, 6 | set: (k, v) => { 7 | localStorage.setItem(k, JSON.stringify(v)) 8 | }, 9 | } 10 | 11 | const filters = { 12 | all: (todos) => todos, 13 | active: (todos) => todos.filter(({ completed }) => !completed), 14 | done: (todos) => todos.filter(({ completed }) => completed), 15 | } 16 | 17 | const KEYS = { 18 | RETURN: 13, 19 | ESCAPE: 27, 20 | } 21 | 22 | export const state = { 23 | filters: Object.keys(filters), 24 | todos: [], 25 | activeFilter: "all", 26 | } 27 | 28 | export const middleware = { 29 | keydown: (action, next) => { 30 | const { keyCode } = action.event 31 | 32 | switch (keyCode) { 33 | case KEYS.ESCAPE: { 34 | next({ ...action, type: "cancelEdit" }) 35 | break 36 | } 37 | case KEYS.RETURN: { 38 | next({ ...action, type: "saveEdit" }) 39 | break 40 | } 41 | default: { 42 | next(action) 43 | } 44 | } 45 | }, 46 | startEdit: (action, next, { refs }) => { 47 | next(action).then(() => refs.titleEditInput?.focus()) 48 | }, 49 | } 50 | 51 | export const subscribe = (state) => { 52 | storage.set("todos", state.todos) 53 | } 54 | 55 | export const getState = (state) => { 56 | const { todos, activeFilter } = state 57 | const n = todos.filter(({ completed }) => !completed).length 58 | 59 | return { 60 | ...state, 61 | allDone: todos.every((todo) => todo.completed), 62 | filteredTodos: filters[activeFilter](todos), 63 | numCompleted: todos.filter(({ completed }) => completed).length, 64 | itemsLeft: `${n} item${n === 1 ? "" : "s"} left`, 65 | } 66 | } 67 | 68 | export const update = { 69 | toggleAll: (state, { event }) => { 70 | let allDone = event.target.checked 71 | return { 72 | ...state, 73 | allDone, 74 | todos: state.todos.map((todo) => ({ ...todo, completed: allDone })), 75 | } 76 | }, 77 | todoInput: (state, { event }) => { 78 | if (event.keyCode === KEYS.RETURN) { 79 | return { 80 | ...state, 81 | todos: state.todos.concat({ 82 | title: state.newTodo, 83 | id: Date.now(), 84 | completed: false, 85 | }), 86 | newTodo: null, 87 | } 88 | } else { 89 | return { 90 | ...state, 91 | newTodo: title, 92 | } 93 | } 94 | }, 95 | startEdit: (state, { scope }) => { 96 | const todos = state.todos.map((todo) => ({ 97 | ...todo, 98 | editing: todo.id === scope.todo.id, 99 | })) 100 | const titleEdit = todos.find((todo) => todo.editing)?.title 101 | return { 102 | ...state, 103 | titleEdit, 104 | todos, 105 | } 106 | }, 107 | deleteTodo: (state, { scope }) => ({ 108 | ...state, 109 | todos: state.todos.filter((todo) => todo.id !== scope.todo.id), 110 | }), 111 | removeCompleted: (state) => ({ 112 | ...state, 113 | todos: state.todos.filter(({ completed }) => !completed), 114 | }), 115 | cancelEdit: (state) => ({ 116 | ...state, 117 | titleEdit: "", 118 | todos: state.todos.map((todo) => ({ 119 | ...todo, 120 | editing: false, 121 | })), 122 | }), 123 | saveEdit: (state) => { 124 | let titleEdit = state.titleEdit.trim() 125 | return { 126 | ...state, 127 | todos: titleEdit 128 | ? state.todos.map((todo) => { 129 | return { 130 | ...todo, 131 | title: todo.editing ? state.titleEdit : todo.title, 132 | editing: false, 133 | } 134 | }) 135 | : state.todos.filter(({ editing }) => !editing), 136 | } 137 | }, 138 | } 139 | 140 | export const markup = html` 141 | 159 |
160 |

todos

161 | 168 |
169 |
170 | 171 | 172 | 192 |
193 | 209 | ` 210 | 211 | export const factory = () => ({ 212 | update, 213 | middleware, 214 | subscribe, 215 | state, 216 | getState, 217 | }) 218 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "synergy", 3 | "version": "8.0.0", 4 | "description": "A JavaScript library for building Web Components", 5 | "main": "dist/synergy.min.js", 6 | "browser": "dist/synergy.min.js", 7 | "unpkg": "dist/synergy.min.js", 8 | "module": "src/index.js", 9 | "types": "types/index.d.ts", 10 | "files": [ 11 | "src", 12 | "dist", 13 | "cjs" 14 | ], 15 | "directories": { 16 | "test": "test" 17 | }, 18 | "scripts": { 19 | "test": "mocha-express --coverage", 20 | "test:watch": "mocha-express --watch", 21 | "build": "rm -rf dist && rollup -c && npm run size", 22 | "size": "gzip -c9 ./dist/synergy.min.js | wc -c", 23 | "coveralls": "cat ./coverage/lcov.info | coveralls", 24 | "prepublish": "npm t && npm run build", 25 | "release": "npm t && standard-version" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/defx/synergy.git" 30 | }, 31 | "keywords": [ 32 | "simple", 33 | "declarative", 34 | "binding", 35 | "dom", 36 | "ui", 37 | "user interface", 38 | "web components", 39 | "framework", 40 | "vue", 41 | "react" 42 | ], 43 | "author": "Matt Donkin", 44 | "license": "MIT", 45 | "bugs": { 46 | "url": "https://github.com/defx/synergy/issues" 47 | }, 48 | "homepage": "https://synergyjs.org", 49 | "devDependencies": { 50 | "coveralls": "^3.1.0", 51 | "mocha-express": "^0.1.2", 52 | "rollup": "^2.26.4", 53 | "rollup-plugin-terser": "^7.0.1", 54 | "standard-version": "^9.1.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { terser } from 'rollup-plugin-terser'; 2 | 3 | export default [ 4 | { 5 | input: 'src/index.js', 6 | output: { 7 | file: 'dist/synergy.js', 8 | format: 'es', 9 | }, 10 | }, 11 | { 12 | input: 'src/index.js', 13 | plugins: [terser()], 14 | output: { 15 | file: 'dist/synergy.min.js', 16 | format: 'es', 17 | }, 18 | }, 19 | { 20 | input: 'src/index.js', 21 | output: { 22 | dir: 'cjs', 23 | format: 'cjs', 24 | preserveModules: true, 25 | }, 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /src/attribute.js: -------------------------------------------------------------------------------- 1 | import { isPrimitive, typeOf } from "./helpers.js" 2 | 3 | const pascalToKebab = (string) => 4 | string.replace(/[\w]([A-Z])/g, function (m) { 5 | return m[0] + "-" + m[1].toLowerCase() 6 | }) 7 | 8 | const kebabToPascal = (string) => 9 | string.replace(/[\w]-([\w])/g, function (m) { 10 | return m[0] + m[2].toUpperCase() 11 | }) 12 | 13 | const parseStyles = (value) => { 14 | let type = typeof value 15 | 16 | if (type === "string") 17 | return value.split(";").reduce((o, value) => { 18 | const [k, v] = value.split(":").map((v) => v.trim()) 19 | if (k) o[k] = v 20 | return o 21 | }, {}) 22 | 23 | if (type === "object") return value 24 | 25 | return {} 26 | } 27 | 28 | const joinStyles = (value) => 29 | Object.entries(value) 30 | .map(([k, v]) => `${k}: ${v};`) 31 | .join(" ") 32 | 33 | const convertStyles = (o) => 34 | Object.keys(o).reduce((a, k) => { 35 | a[pascalToKebab(k)] = o[k] 36 | return a 37 | }, {}) 38 | 39 | export const applyAttribute = (node, name, value) => { 40 | if (name === "style") { 41 | value = joinStyles( 42 | convertStyles({ 43 | ...parseStyles(node.getAttribute("style")), 44 | ...parseStyles(value), 45 | }) 46 | ) 47 | } else if (name === "class") { 48 | switch (typeOf(value)) { 49 | case "Array": 50 | value = value.join(" ") 51 | break 52 | case "Object": 53 | value = Object.keys(value) 54 | .reduce((a, k) => { 55 | if (value[k]) a.push(k) 56 | return a 57 | }, []) 58 | .join(" ") 59 | break 60 | } 61 | } else if (!isPrimitive(value)) { 62 | return (node[kebabToPascal(name)] = value) 63 | } 64 | 65 | name = pascalToKebab(name) 66 | 67 | if (typeof value === "boolean") { 68 | if (name.startsWith("aria-")) { 69 | value = "" + value 70 | } else if (value) { 71 | value = "" 72 | } 73 | } 74 | 75 | let current = node.getAttribute(name) 76 | 77 | if (value === current) return 78 | 79 | if (typeof value === "string" || typeof value === "number") { 80 | node.setAttribute(name, value) 81 | } else { 82 | node.removeAttribute(name) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const TEXT = 1 2 | export const ATTRIBUTE = 2 3 | export const INPUT = 3 4 | export const EVENT = 4 5 | export const REPEAT = 5 6 | export const CONDITIONAL = 6 7 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import { getValueAtPath, isPrimitive } from "./helpers.js" 2 | 3 | const handler = ({ path, identifier, key, index, i, k }) => ({ 4 | get(target, property) { 5 | let x = getValueAtPath(path, target) 6 | 7 | // x === the collection 8 | 9 | if (property === identifier) { 10 | for (let n in x) { 11 | let v = x[n] 12 | if (key) { 13 | if (v[key] === k) return v 14 | } else { 15 | if (n == i) return v 16 | } 17 | } 18 | } 19 | 20 | if (property === index) { 21 | for (let n in x) { 22 | let v = x[n] 23 | if (key) { 24 | if (v[key] === k) return n 25 | } else { 26 | if (n == i) return n 27 | } 28 | } 29 | } 30 | 31 | let t = key ? x.find((v) => v[key] === k) : x?.[i] 32 | if (t?.hasOwnProperty?.(property)) return t[property] 33 | 34 | return Reflect.get(...arguments) 35 | }, 36 | set(target, property, value) { 37 | let x = getValueAtPath(path, target) 38 | let t = key ? x.find((v) => v[key] === k) : x[i] 39 | if (t && !isPrimitive(t)) { 40 | t[property] = value 41 | return true 42 | } 43 | 44 | return Reflect.set(...arguments) 45 | }, 46 | }) 47 | 48 | export const createContext = (v = []) => { 49 | let context = v 50 | return { 51 | get: () => context, 52 | push: (v) => context.push(v), 53 | wrap: (state) => { 54 | return context.reduce( 55 | (target, ctx) => new Proxy(target, handler(ctx)), 56 | state 57 | ) 58 | }, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/css.js: -------------------------------------------------------------------------------- 1 | function nextWord(css, count) { 2 | return css.slice(count - 1).split(/[\s+|\n+|,]/)[0] 3 | } 4 | 5 | function nextOpenBrace(css, count) { 6 | let index = css.slice(count - 1).indexOf("{") 7 | if (index > -1) { 8 | return count + index 9 | } 10 | } 11 | 12 | export function prefixSelectors(prefix, css) { 13 | let insideBlock = false 14 | let look = true 15 | let output = "" 16 | let count = 0 17 | let skip = false 18 | 19 | for (let char of css) { 20 | if (char === "@" && nextWord(css, count + 1) === "@media") { 21 | skip = nextOpenBrace(css, count) 22 | } 23 | 24 | if (skip) { 25 | if (skip === count) skip = false 26 | } 27 | 28 | if (!skip) { 29 | if (char === "}") { 30 | insideBlock = false 31 | look = true 32 | } else if (char === ",") { 33 | look = true 34 | } else if (char === "{") { 35 | insideBlock = true 36 | } else if (look && !insideBlock && !char.match(/\s/)) { 37 | let w = nextWord(css, count + 1) 38 | 39 | // console.log({ w }) 40 | 41 | if ( 42 | w !== prefix && 43 | w.charAt(0) !== "@" && 44 | w.charAt(0) !== ":" && 45 | w.charAt(0) !== "*" && 46 | w !== "html" && 47 | w !== "body" 48 | ) { 49 | output += prefix + " " 50 | } 51 | look = false 52 | } 53 | } 54 | output += char 55 | count += 1 56 | } 57 | 58 | return output 59 | } 60 | 61 | export function appendStyles(name, css) { 62 | if (document.querySelector(`style#${name}`)) return 63 | 64 | const el = document.createElement("style") 65 | el.id = name 66 | el.innerHTML = prefixSelectors(name, css) 67 | document.head.appendChild(el) 68 | } 69 | -------------------------------------------------------------------------------- /src/define.js: -------------------------------------------------------------------------------- 1 | import { configure } from "./update.js" 2 | import { render } from "./render.js" 3 | import { mergeSlots } from "./mergeSlots.js" 4 | import { appendStyles } from "./css.js" 5 | import { 6 | applyAttribute, 7 | attributeToProp, 8 | isPrimitive, 9 | pascalToKebab, 10 | getDataScript, 11 | createDataScript, 12 | } from "./helpers.js" 13 | 14 | function serialise(node, state) { 15 | let ds = getDataScript(node) || createDataScript(node) 16 | ds.innerText = JSON.stringify(state) 17 | } 18 | 19 | export const define = (name, factory, template, css) => { 20 | if (customElements.get(name)) return 21 | 22 | if (css) appendStyles(name, css) 23 | 24 | customElements.define( 25 | name, 26 | class extends HTMLElement { 27 | async connectedCallback() { 28 | if (!this.initialised) { 29 | let config = factory(this) 30 | 31 | if (config instanceof Promise) config = await config 32 | 33 | let { subscribe, shadow, observe = [] } = config 34 | 35 | this.connectedCallback = config.connectedCallback 36 | this.disconnectedCallback = config.disconnectedCallback 37 | 38 | const ds = getDataScript(this) 39 | 40 | const { dispatch, getState, onChange, updated, refs } = configure( 41 | { 42 | ...config, 43 | state: ds ? JSON.parse(ds.innerText) : config.state, 44 | }, 45 | this 46 | ) 47 | 48 | this.store = { dispatch, getState } 49 | 50 | let state = getState() 51 | 52 | let observedAttributes = observe.map(pascalToKebab) 53 | 54 | let sa = this.setAttribute 55 | this.setAttribute = (k, v) => { 56 | if (observedAttributes.includes(k)) { 57 | let { name, value } = attributeToProp(k, v) 58 | 59 | dispatch({ 60 | type: "SET", 61 | payload: { name, value }, 62 | }) 63 | } 64 | sa.apply(this, [k, v]) 65 | } 66 | let ra = this.removeAttribute 67 | this.removeAttribute = (k) => { 68 | if (observedAttributes.includes(k)) { 69 | let { name, value } = attributeToProp(k, null) 70 | dispatch({ 71 | type: "SET", 72 | payload: { name, value }, 73 | }) 74 | } 75 | ra.apply(this, [k]) 76 | } 77 | 78 | observedAttributes.forEach((name) => { 79 | let property = attributeToProp(name).name 80 | let value 81 | 82 | if (this.hasAttribute(name)) { 83 | value = this.getAttribute(name) 84 | } else { 85 | value = this[property] || state[property] 86 | } 87 | 88 | Object.defineProperty(this, property, { 89 | get() { 90 | return getState()[property] 91 | }, 92 | set(v) { 93 | dispatch({ 94 | type: "SET", 95 | payload: { name: property, value: v }, 96 | }) 97 | if (isPrimitive(v)) { 98 | applyAttribute(this, property, v) 99 | } 100 | }, 101 | }) 102 | 103 | this[property] = value 104 | }) 105 | 106 | let beforeMountCallback 107 | 108 | if (shadow) { 109 | this.attachShadow({ 110 | mode: shadow, 111 | }) 112 | } else { 113 | beforeMountCallback = (frag) => mergeSlots(this, frag) 114 | } 115 | 116 | onChange( 117 | render( 118 | this.shadowRoot || this, 119 | { getState, dispatch, refs }, 120 | template, 121 | () => { 122 | const state = getState() 123 | 124 | serialise(this, state) 125 | observe.forEach((k) => { 126 | let v = state[k] 127 | if (isPrimitive(v)) applyAttribute(this, k, v) 128 | }) 129 | subscribe?.(getState()) 130 | updated() 131 | }, 132 | beforeMountCallback 133 | ) 134 | ) 135 | 136 | if (!ds) serialise(this, getState()) 137 | } 138 | 139 | if (!this._C) this.connectedCallback?.(this.store) 140 | this._C = false 141 | } 142 | disconnectedCallback() { 143 | this._C = true 144 | requestAnimationFrame(() => { 145 | if (this._C) this.disconnectedCallback?.() 146 | this._C = false 147 | }) 148 | } 149 | } 150 | ) 151 | } 152 | -------------------------------------------------------------------------------- /src/formControl.js: -------------------------------------------------------------------------------- 1 | export const updateFormControl = (node, value) => { 2 | if (node.nodeName === "SELECT") { 3 | Array.from(node.querySelectorAll("option")).forEach((option) => { 4 | option.selected = value.includes(option.value) 5 | }) 6 | return 7 | } 8 | 9 | let checked 10 | 11 | switch (node.getAttribute("type")) { 12 | case "checkbox": 13 | checked = value 14 | if (node.checked === checked) break 15 | if (checked) { 16 | node.setAttribute("checked", "") 17 | } else { 18 | node.removeAttribute("checked") 19 | } 20 | break 21 | case "radio": 22 | checked = value === node.getAttribute("value") 23 | if (node.checked === checked) break 24 | node.checked = checked 25 | if (checked) { 26 | node.setAttribute("checked", "") 27 | } else { 28 | node.removeAttribute("checked") 29 | } 30 | break 31 | default: 32 | if (node.value !== value) { 33 | if (!isNaN(value) && node.value.slice(-1) === ".") break 34 | node.setAttribute("value", (node.value = value ?? "")) 35 | } 36 | 37 | break 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | export const wrapToken = (v) => { 2 | v = v.trim() 3 | if (v.startsWith("{{")) return v 4 | return `{{${v}}}` 5 | } 6 | 7 | export const last = (v = []) => v[v.length - 1] 8 | 9 | export const isWhitespace = (node) => { 10 | return node.nodeType === node.TEXT_NODE && node.nodeValue.trim() === "" 11 | } 12 | 13 | export const walk = (node, callback, deep = true) => { 14 | if (!node) return 15 | // if (node.matches?.(`script[type="application/synergy"]`)) 16 | // return walk(node.nextSibling, callback, deep) 17 | if (!isWhitespace(node)) { 18 | let v = callback(node) 19 | if (v === false) return 20 | if (v?.nodeName) return walk(v, callback, deep) 21 | } 22 | if (deep) walk(node.firstChild, callback, deep) 23 | walk(node.nextSibling, callback, deep) 24 | } 25 | 26 | const transformBrackets = (str = "") => { 27 | let parts = str.split(/(\[[^\]]+\])/).filter((v) => v) 28 | return parts.reduce((a, part) => { 29 | let v = part.charAt(0) === "[" ? "." + part.replace(/\./g, ":") : part 30 | return a + v 31 | }, "") 32 | } 33 | 34 | const getTarget = (path, target) => { 35 | let parts = transformBrackets(path) 36 | .split(".") 37 | .map((k) => { 38 | if (k.charAt(0) === "[") { 39 | let p = k.slice(1, -1).replace(/:/g, ".") 40 | return getValueAtPath(p, target) 41 | } else { 42 | return k 43 | } 44 | }) 45 | 46 | let t = 47 | parts.slice(0, -1).reduce((o, k) => { 48 | return o && o[k] 49 | }, target) || target 50 | return [t, last(parts)] 51 | } 52 | 53 | export const getValueAtPath = (path, target) => { 54 | let [a, b] = getTarget(path, target) 55 | let v = a?.[b] 56 | if (typeof v === "function") return v.bind(a) 57 | return v 58 | } 59 | 60 | export const setValueAtPath = (path, value, target) => { 61 | let [a, b] = getTarget(path, target) 62 | return (a[b] = value) 63 | } 64 | 65 | export const fragmentFromTemplate = (v) => { 66 | if (typeof v === "string") { 67 | if (v.charAt(0) === "#") { 68 | v = document.querySelector(v) 69 | } else { 70 | let tpl = document.createElement("template") 71 | tpl.innerHTML = v.trim() 72 | return tpl.content.cloneNode(true) 73 | } 74 | } 75 | if (v.nodeName === "TEMPLATE") return v.cloneNode(true).content 76 | if (v.nodeName === "defs") return v.firstElementChild.cloneNode(true) 77 | } 78 | 79 | export const debounce = (fn) => { 80 | let wait = false 81 | let invoke = false 82 | return () => { 83 | if (wait) { 84 | invoke = true 85 | } else { 86 | wait = true 87 | fn() 88 | requestAnimationFrame(() => { 89 | if (invoke) fn() 90 | wait = false 91 | }) 92 | } 93 | } 94 | } 95 | 96 | export const isPrimitive = (v) => v === null || typeof v !== "object" 97 | 98 | export const typeOf = (v) => 99 | Object.prototype.toString.call(v).match(/\s(.+[^\]])/)[1] 100 | 101 | export const pascalToKebab = (string) => 102 | string.replace(/[\w]([A-Z])/g, function (m) { 103 | return m[0] + "-" + m[1].toLowerCase() 104 | }) 105 | 106 | export const kebabToPascal = (string) => 107 | string.replace(/[\w]-([\w])/g, function (m) { 108 | return m[0] + m[2].toUpperCase() 109 | }) 110 | 111 | export const applyAttribute = (node, name, value) => { 112 | name = pascalToKebab(name) 113 | 114 | if (typeof value === "boolean") { 115 | if (name.startsWith("aria-")) { 116 | value = "" + value 117 | } else if (value) { 118 | value = "" 119 | } 120 | } 121 | 122 | if (typeof value === "string" || typeof value === "number") { 123 | node.setAttribute(name, value) 124 | } else { 125 | node.removeAttribute(name) 126 | } 127 | } 128 | 129 | export const attributeToProp = (k, v) => { 130 | let name = kebabToPascal(k) 131 | if (v === "") v = true 132 | if (k.startsWith("aria-")) { 133 | if (v === "true") v = true 134 | if (v === "false") v = false 135 | } 136 | return { 137 | name, 138 | value: v, 139 | } 140 | } 141 | 142 | export function getDataScript(node) { 143 | return node.querySelector( 144 | `script[type="application/synergy"][id="${node.nodeName}"]` 145 | ) 146 | } 147 | 148 | export function createDataScript(node) { 149 | let ds = document.createElement("script") 150 | ds.setAttribute("type", "application/synergy") 151 | ds.setAttribute("id", node.nodeName) 152 | node.append(ds) 153 | return ds 154 | } 155 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { define } from "./define.js" 2 | export { partial } from "./partial.js" 3 | -------------------------------------------------------------------------------- /src/list.js: -------------------------------------------------------------------------------- 1 | import { isWhitespace, walk } from "./helpers.js" 2 | 3 | export function parseEach(node) { 4 | let each = node.getAttribute(":each") 5 | let m = each?.match(/(.+)\s+in\s+(.+)/) 6 | if (!m) { 7 | if (!each) return m 8 | return { 9 | path: each.trim(), 10 | key: node.getAttribute(":key"), 11 | } 12 | } 13 | let [_, left, right] = m 14 | let parts = left.match(/\(([^\)]+)\)/) 15 | let [a, b] = (parts ? parts[1].split(",") : [left]).map((v) => v.trim()) 16 | 17 | return { 18 | path: right.trim(), 19 | identifier: b ? b : a, 20 | index: b ? a : b, 21 | key: node.getAttribute(":key"), 22 | } 23 | } 24 | 25 | const getBlockSize = (template) => { 26 | let i = 0 27 | walk(template.content?.firstChild || template.firstChild, () => i++, false) 28 | return i 29 | } 30 | 31 | const nextNonWhitespaceSibling = (node) => { 32 | return isWhitespace(node.nextSibling) 33 | ? nextNonWhitespaceSibling(node.nextSibling) 34 | : node.nextSibling 35 | } 36 | 37 | const getBlockFragments = (template, numBlocks) => { 38 | let blockSize = getBlockSize(template) 39 | 40 | let r = [] 41 | if (numBlocks) { 42 | while (numBlocks--) { 43 | let f = document.createDocumentFragment() 44 | let n = blockSize 45 | while (n--) { 46 | f.appendChild(nextNonWhitespaceSibling(template)) 47 | } 48 | r.push(f) 49 | } 50 | } 51 | return r 52 | } 53 | 54 | export const getBlocks = (template) => { 55 | let numBlocks = +(template.dataset.length || 0) 56 | let blockSize = getBlockSize(template) 57 | let r = [] 58 | let node = template 59 | if (numBlocks) { 60 | while (numBlocks--) { 61 | let f = [] 62 | let n = blockSize 63 | while (n--) { 64 | node = nextNonWhitespaceSibling(node) 65 | f.push(node) 66 | } 67 | r.push(f) 68 | } 69 | } 70 | return r 71 | } 72 | 73 | export const compareKeyedLists = (key, a = [], b = []) => { 74 | let delta = b.map(([k, item]) => 75 | !key ? (k in a ? k : -1) : a.findIndex(([_, v]) => v[key] === item[key]) 76 | ) 77 | if (a.length !== b.length || !delta.every((a, b) => a === b)) return delta 78 | } 79 | 80 | function lastChild(v) { 81 | return (v.nodeType === v.DOCUMENT_FRAGMENT_NODE && v.lastChild) || v 82 | } 83 | 84 | export const updateList = (template, delta, entries, createListItem) => { 85 | let n = +(template.dataset.length || 0) 86 | 87 | const unchanged = delta.length === n && delta.every((a, b) => a == b) 88 | 89 | if (unchanged) return 90 | 91 | let blocks = getBlockFragments(template, n) 92 | let t = template 93 | 94 | delta.forEach((i, newIndex) => { 95 | let frag = 96 | i === -1 97 | ? createListItem(entries[newIndex][1], entries[newIndex][0]) 98 | : blocks[i] 99 | let x = lastChild(frag) 100 | t.after(frag) 101 | t = x 102 | }) 103 | 104 | template.dataset.length = delta.length 105 | } 106 | -------------------------------------------------------------------------------- /src/mergeSlots.js: -------------------------------------------------------------------------------- 1 | const childNodes = (node) => { 2 | let frag = document.createDocumentFragment() 3 | while (node.firstChild) { 4 | frag.appendChild(node.firstChild) 5 | } 6 | return frag 7 | } 8 | 9 | export const mergeSlots = (targetNode, sourceNode) => { 10 | let namedSlots = sourceNode.querySelectorAll("slot[name]") 11 | 12 | namedSlots.forEach((slot) => { 13 | let name = slot.attributes.name.value 14 | let node = targetNode.querySelector(`[slot="${name}"]`) 15 | if (!node) { 16 | slot.parentNode.replaceChild(childNodes(slot), slot) 17 | return 18 | } 19 | node.removeAttribute("slot") 20 | slot.parentNode.replaceChild(node, slot) 21 | }) 22 | 23 | let defaultSlot = sourceNode.querySelector("slot:not([name])") 24 | 25 | if (defaultSlot) { 26 | defaultSlot.parentNode.replaceChild( 27 | childNodes(targetNode.innerHTML.trim() ? targetNode : defaultSlot), 28 | defaultSlot 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/partial.js: -------------------------------------------------------------------------------- 1 | import { appendStyles } from "./css.js" 2 | 3 | export const partials = {} 4 | 5 | export const partial = (name, html, css) => { 6 | if (css) appendStyles(name, css) 7 | partials[name.toUpperCase()] = html 8 | } 9 | -------------------------------------------------------------------------------- /src/proxy.js: -------------------------------------------------------------------------------- 1 | import { typeOf } from "./helpers.js" 2 | 3 | export const proxy = (root, callback) => { 4 | let proxyCache = new WeakMap() 5 | 6 | function createProxy(target, handler) { 7 | let proxy = proxyCache.get(target) 8 | if (proxy === undefined) { 9 | proxy = new Proxy(target, handler) 10 | proxyCache.set(target, proxy) 11 | } 12 | return proxy 13 | } 14 | 15 | const handler = { 16 | get(target, property) { 17 | if (["Object", "Array"].includes(typeOf(target[property]))) { 18 | return createProxy(target[property], handler) 19 | } else { 20 | return Reflect.get(...arguments) 21 | } 22 | }, 23 | set(target, property, value) { 24 | if (value === target[property]) return true 25 | 26 | callback() 27 | return Reflect.set(...arguments) 28 | }, 29 | deleteProperty() { 30 | callback() 31 | return Reflect.deleteProperty(...arguments) 32 | }, 33 | } 34 | 35 | return new Proxy(root, handler) 36 | } 37 | -------------------------------------------------------------------------------- /src/template.js: -------------------------------------------------------------------------------- 1 | export const convertToTemplate = (node) => { 2 | let ns = node.namespaceURI 3 | 4 | if (ns.endsWith("/svg")) { 5 | let tpl = document.createElementNS(ns, "defs") 6 | tpl.innerHTML = node.outerHTML 7 | node.parentNode.replaceChild(tpl, node) 8 | return tpl 9 | } else { 10 | if (node.nodeName === "TEMPLATE") return node 11 | 12 | let tpl = document.createElement("template") 13 | 14 | tpl.innerHTML = node.outerHTML 15 | node.parentNode.replaceChild(tpl, node) 16 | 17 | return tpl 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/token.js: -------------------------------------------------------------------------------- 1 | import { getValueAtPath } from "./helpers.js" 2 | 3 | const VALUE = 1 4 | const KEY = 2 5 | const FUNCTION = 3 6 | 7 | export const hasMustache = (v) => v.match(/({{[^{}]+}})/) 8 | 9 | export const getParts = (value) => 10 | value 11 | .trim() 12 | .split(/({{[^{}]+}})/) 13 | .filter((v) => v) 14 | .map((value) => { 15 | let match = value.match(/{{([^{}]+)}}/) 16 | 17 | if (!match) 18 | return { 19 | type: VALUE, 20 | value, 21 | negated: false, 22 | } 23 | 24 | value = match[1].trim() 25 | let negated = value.charAt(0) === "!" 26 | if (negated) value = value.slice(1) 27 | 28 | return { 29 | type: KEY, 30 | value, 31 | negated, 32 | } 33 | }) 34 | 35 | export const getValueFromParts = (target, parts) => { 36 | return parts.reduce((a, part) => { 37 | let { type, value, negated } = part 38 | 39 | let v 40 | 41 | if (type === VALUE) v = value 42 | if (type === KEY) { 43 | v = getValueAtPath(value, target) 44 | } 45 | if (type === FUNCTION) { 46 | let args = part.args.map((value) => getValueAtPath(value, target)) 47 | 48 | v = getValueAtPath(part.method, target)?.(...args) 49 | } 50 | 51 | if (negated) v = !v 52 | 53 | return a || a === 0 ? a + v : v 54 | }, "") 55 | } 56 | -------------------------------------------------------------------------------- /src/update.js: -------------------------------------------------------------------------------- 1 | import { setValueAtPath } from "./helpers.js" 2 | 3 | function systemReducer(state, action) { 4 | switch (action.type) { 5 | case "SET": { 6 | const { name, value } = action.payload 7 | 8 | let o = { ...state } 9 | setValueAtPath(name, value, o) 10 | 11 | return o 12 | } 13 | case "MERGE": { 14 | return { 15 | ...state, 16 | ...action.payload, 17 | } 18 | } 19 | } 20 | } 21 | 22 | export function configure({ 23 | update = {}, 24 | middleware = [], 25 | state: initialState = {}, 26 | getState: getStateWrapper = (v) => v, 27 | }) { 28 | let subscribers = [] 29 | let state 30 | let onChangeCallback = () => {} 31 | 32 | function updateState(o) { 33 | state = getStateWrapper({ ...o }) 34 | onChangeCallback() 35 | } 36 | 37 | updateState(initialState) 38 | 39 | const refs = {} 40 | 41 | function getState() { 42 | return { ...state } 43 | } 44 | 45 | function subscribe(fn) { 46 | subscribers.push(fn) 47 | } 48 | 49 | function updated() { 50 | subscribers.forEach((fn) => fn()) 51 | subscribers = [] 52 | } 53 | 54 | function onChange(fn) { 55 | onChangeCallback = fn 56 | } 57 | 58 | function dispatch(action) { 59 | const { type } = action 60 | 61 | if (type === "SET" || type === "MERGE") { 62 | updateState(systemReducer(getState(), action)) 63 | } else { 64 | const done = (action) => { 65 | if (action.type in update) { 66 | updateState(update[action.type](getState(), action)) 67 | } 68 | return { 69 | then: (fn) => 70 | new Promise((resolve) => { 71 | subscribe(() => { 72 | fn() 73 | resolve() 74 | }) 75 | }), 76 | } 77 | } 78 | 79 | middleware[action.type]?.(action, done, { 80 | getState, 81 | dispatch, 82 | refs, 83 | }) || done(action) 84 | } 85 | } 86 | 87 | return { 88 | dispatch, // dispatch an action to the reducers 89 | getState, // optionally provide a wrapper function to derive additional properties in state 90 | onChange, // use this callback to update your UI whenever state changes 91 | updated, // call this once you've updated the UI so that all subscribers will be invoked and then removed 92 | refs, // an empty object that you can attach element refs to (supplied on object passed as the third argument to middleware functions) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /test/actions.js: -------------------------------------------------------------------------------- 1 | import { define } from "../src/index.js" 2 | 3 | describe("actions", () => { 4 | it("dispatches the action", () => { 5 | let stack = [] 6 | 7 | let name = createName() 8 | 9 | let state = { 10 | artists: [ 11 | { 12 | name: "pablo picasso", 13 | tags: [ 14 | "painter", 15 | "sculptor", 16 | "printmaker", 17 | "ceramicist", 18 | "theatre designer", 19 | ], 20 | }, 21 | { 22 | name: "salvador dali", 23 | tags: ["painter", "sculptor", "photographer", "writer"], 24 | }, 25 | ], 26 | } 27 | 28 | define( 29 | name, 30 | () => ({ 31 | update: { 32 | foo: (state) => { 33 | stack.push("foo") 34 | return state 35 | }, 36 | }, 37 | state, 38 | }), 39 | html` 40 |
41 |

{{artist.name}}

42 | 45 |
46 | ` 47 | ) 48 | 49 | mount(html`<${name}>`) 50 | 51 | $("article:nth-of-type(2) li").click() //salvador dali painter 52 | 53 | assert.equal(stack.length, 1) 54 | assert.equal(stack[0], "foo") 55 | }) 56 | 57 | it("provides the template scope", () => { 58 | let stack = [] 59 | 60 | let name = createName() 61 | 62 | let state = { 63 | artists: [ 64 | { 65 | name: "pablo picasso", 66 | tags: [ 67 | "painter", 68 | "sculptor", 69 | "printmaker", 70 | "ceramicist", 71 | "theatre designer", 72 | ], 73 | }, 74 | { 75 | name: "salvador dali", 76 | tags: ["painter", "sculptor", "photographer", "writer"], 77 | }, 78 | ], 79 | } 80 | 81 | define( 82 | name, 83 | () => ({ 84 | update: { 85 | foo: (state, { scope }) => { 86 | stack.push(scope) 87 | return state 88 | }, 89 | }, 90 | state, 91 | }), 92 | html` 93 |
94 |

{{artist.name}}

95 | 98 |
99 | ` 100 | ) 101 | 102 | mount(html`<${name}>`) 103 | 104 | $("article:nth-of-type(2) li").click() //salvador dali painter 105 | 106 | assert.equal(stack.length, 1) 107 | assert.equal(stack[0].artists.length, 2) 108 | assert.equal(stack[0].artist.name, "salvador dali") 109 | assert.equal(stack[0].tag, "painter") 110 | }) 111 | 112 | it("provides the event object", () => { 113 | let stack = [] 114 | 115 | let name = createName() 116 | 117 | let state = { 118 | artists: [ 119 | { 120 | name: "pablo picasso", 121 | tags: [ 122 | "painter", 123 | "sculptor", 124 | "printmaker", 125 | "ceramicist", 126 | "theatre designer", 127 | ], 128 | }, 129 | { 130 | name: "salvador dali", 131 | tags: ["painter", "sculptor", "photographer", "writer"], 132 | }, 133 | ], 134 | } 135 | 136 | define( 137 | name, 138 | () => ({ 139 | update: { 140 | foo: (state, { event }) => { 141 | stack.push(event) 142 | return state 143 | }, 144 | }, 145 | state, 146 | }), 147 | html` 148 |
149 |

{{artist.name}}

150 | 153 |
154 | ` 155 | ) 156 | 157 | mount(html`<${name}>`) 158 | 159 | $("article:nth-of-type(2) li").click() //salvador dali painter 160 | 161 | assert.equal(stack[0] instanceof MouseEvent, true) 162 | assert.equal(stack[0].target.nodeName, "LI") 163 | }) 164 | }) 165 | -------------------------------------------------------------------------------- /test/define.js: -------------------------------------------------------------------------------- 1 | import { define } from "../src/index.js" 2 | 3 | describe("define", () => { 4 | it("should define a custom element", () => { 5 | let name = createName() 6 | define(name, () => {}, "") 7 | assert.ok(customElements.get(name)) 8 | }) 9 | 10 | it("initialise factory with node", () => { 11 | let name = createName() 12 | let el 13 | define( 14 | name, 15 | (node) => { 16 | el = node 17 | return { update: () => {} } 18 | }, 19 | "

" 20 | ) 21 | mount(` 22 | <${name}> 23 | `) 24 | 25 | assert.equal($(name), el) 26 | }) 27 | 28 | it("accepts template element", () => { 29 | let name = createName() 30 | 31 | let template = document.createElement("template") 32 | template.innerHTML = "

{{ title }}

" 33 | define( 34 | name, 35 | () => ({ 36 | state: { 37 | title: "ok!", 38 | }, 39 | }), 40 | template 41 | ) 42 | mount(` 43 | <${name}> 44 | `) 45 | 46 | assert.equal($("p").textContent, "ok!") 47 | }) 48 | 49 | it("reflects observed attribute changes on to viewmodel", async () => { 50 | let name = createName() 51 | define( 52 | name, 53 | () => ({ observe: ["title"], state: { title: "" } }), 54 | "

{{ title }}

" 55 | ) 56 | mount(` 57 | <${name} title="ok!"> 58 | `) 59 | $(name).setAttribute("title", "foo!") 60 | await nextFrame() 61 | assert.equal($(`${name} p`).textContent, "foo!") 62 | }) 63 | 64 | it("reflects viewmodel changes back on to attributes", async () => { 65 | let name = createName() 66 | 67 | define( 68 | name, 69 | () => ({ 70 | state: { 71 | show: true, 72 | }, 73 | update: { 74 | toggle: (state) => ({ 75 | ...state, 76 | show: !state.show, 77 | }), 78 | }, 79 | }), 80 | '

hello world!

' 81 | ) 82 | mount(` 83 | <${name}> 84 | `) 85 | let el = $("p") 86 | assert.ok(el.hasAttribute("hidden") === false) 87 | $(`${name} button`).click() 88 | await nextFrame() 89 | assert.ok(el.hasAttribute("hidden")) 90 | }) 91 | 92 | it("merges default slot", () => { 93 | let name = createName() 94 | define( 95 | name, 96 | () => ({}), 97 | html`

hello !

98 | !` 99 | ) 100 | mount(` 101 | <${name}>world 102 | `) 103 | 104 | assert.equal($(`${name} p`).innerText.trim(), "hello world!") 105 | }) 106 | 107 | it("merges named slots", () => { 108 | let name = createName() 109 | define( 110 | name, 111 | () => ({}), 112 | html`

113 | hello 114 |

` 115 | ) 116 | mount(` 117 | <${name}>! 118 | `) 119 | 120 | assert.equal($(`${name} p`).innerHTML.trim(), "!hello") 121 | }) 122 | 123 | it("converts between kebab and pascal casing", async () => { 124 | let name = createName() 125 | 126 | define( 127 | name, 128 | () => ({ 129 | observe: ["fooBar"], 130 | state: { 131 | fooBar: false, 132 | }, 133 | update: { 134 | toggle: (state) => ({ ...state, fooBar: !state.fooBar }), 135 | }, 136 | }), 137 | html`` 138 | ) 139 | mount(` 140 | <${name} foo-bar> 141 | `) 142 | 143 | assert.equal($(name).fooBar, true) 144 | 145 | $("button").click() 146 | await nextFrame() 147 | 148 | assert.equal($(`${name}`).hasAttribute("foo-bar"), false) 149 | }) 150 | 151 | it("correctly handles aria string booleans", async () => { 152 | let name = createName() 153 | 154 | define( 155 | name, 156 | () => ({ 157 | observe: ["ariaHidden"], 158 | state: { 159 | ariaHidden: true, 160 | }, 161 | update: { 162 | toggle: (state) => { 163 | return { 164 | ...state, 165 | ariaHidden: !state.ariaHidden, 166 | } 167 | }, 168 | }, 169 | }), 170 | html`` 171 | ) 172 | mount(` 173 | <${name} aria-hidden="false"> 174 | `) 175 | 176 | $("button").click() 177 | await nextFrame() 178 | assert.equal($(`${name}`).getAttribute("aria-hidden"), "true") 179 | }) 180 | 181 | it("forwards lifecycle events", async () => { 182 | let name = createName() 183 | 184 | let connected = false 185 | let disconnected = false 186 | let factory = () => { 187 | return { 188 | connectedCallback() { 189 | connected = true 190 | }, 191 | disconnectedCallback() { 192 | disconnected = true 193 | }, 194 | } 195 | } 196 | define(name, factory, "") 197 | mount(` 198 | <${name}> 199 | `) 200 | assert.ok(connected) 201 | assert.notOk(disconnected) 202 | $(name).remove() 203 | /* 204 | 205 | we need to wait here as synergy will avoid triggering lifecycle events if element is reconnected within the same frame (as can happen during a manual content slot) 206 | 207 | */ 208 | await nextFrame() 209 | assert.ok(disconnected) 210 | }) 211 | 212 | it("optionally supports shadow root", () => { 213 | let factory = () => ({ shadow: "open" }) 214 | 215 | let template = html` 216 | 223 | 224 | ` 225 | 226 | define("x-shadow", factory, template) 227 | 228 | mount(html`hello shadow`) 229 | 230 | let node = $("x-shadow") 231 | 232 | assert.ok(node.shadowRoot) 233 | }) 234 | 235 | it("accepts rich data as properties", async () => { 236 | define( 237 | "rich-props", 238 | () => ({ 239 | observe: ["arr", "obj"], 240 | state: { 241 | arr: [], 242 | obj: {}, 243 | }, 244 | }), 245 | ` 246 |

{{ obj.repo }}

247 |

{{ item }}

248 | ` 249 | ) 250 | 251 | let name = createName() 252 | 253 | const letters = "synergy".split("") 254 | 255 | const framework = { 256 | repo: "defx/synergy", 257 | } 258 | 259 | define( 260 | name, 261 | () => ({ 262 | state: { 263 | letters, 264 | framework, 265 | }, 266 | }), 267 | html` 268 | 269 | ` 270 | ) 271 | 272 | mount(html`<${name}>`) 273 | 274 | await nextFrame() 275 | 276 | const renderedLetters = $$("p").map((v) => v.textContent) 277 | assert.equal($("h3").textContent, framework.repo) 278 | assert.equal(letters.join(""), renderedLetters.join("")) 279 | }) 280 | 281 | it("reflects observed properties from viewmodel to element", async () => { 282 | let name = createName() 283 | 284 | define( 285 | name, 286 | () => ({ 287 | observe: ["foo"], 288 | state: { 289 | foo: "", 290 | }, 291 | update: { 292 | updateFoo: (state) => ({ 293 | ...state, 294 | foo: "baz", 295 | }), 296 | }, 297 | }), 298 | html`

{{ foo }}

` 299 | ) 300 | 301 | mount(`<${name}>`) 302 | 303 | $(`p`).click() 304 | 305 | await nextFrame() 306 | 307 | assert.equal($(name).foo, "baz") 308 | }) 309 | 310 | it("supports async initialisation", async () => { 311 | // @todo: improve this 312 | let name = createName() 313 | 314 | define( 315 | name, 316 | () => 317 | Promise.resolve({ 318 | state: { 319 | foo: "bar", 320 | }, 321 | }), 322 | html`

{{ foo }}

` 323 | ) 324 | 325 | mount(`<${name}>`) 326 | 327 | assert.ok($(name)) 328 | assert.equal($(name).textContent.trim(), "") 329 | 330 | await nextFrame() 331 | assert.equal($(name).innerText.trim(), "bar") 332 | }) 333 | }) 334 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | import { applyAttribute } from "../src/helpers.js" 2 | 3 | describe("applyAttribute", () => { 4 | it("sets boolean true as empty string", () => { 5 | let node = document.createElement("div") 6 | 7 | applyAttribute(node, "foo", true) 8 | 9 | assert.equal(node.getAttribute("foo"), "") 10 | }) 11 | it("removes attribute when boolean false", () => { 12 | let node = document.createElement("div") 13 | 14 | applyAttribute(node, "foo", false) 15 | 16 | assert.equal(node.getAttribute("foo"), null) 17 | }) 18 | it("sets aria- prefixed attributes with boolean false to string", () => { 19 | let node = document.createElement("div") 20 | 21 | applyAttribute(node, "ariaFoo", false) 22 | 23 | assert.equal(node.getAttribute("aria-foo"), "false") 24 | }) 25 | it("sets aria- prefixed attributes with boolean true to string", () => { 26 | let node = document.createElement("div") 27 | 28 | applyAttribute(node, "ariaFoo", true) 29 | 30 | assert.equal(node.getAttribute("aria-foo"), "true") 31 | }) 32 | it("sets non-aria attribute with falsy value to string", () => { 33 | let node = document.createElement("div") 34 | 35 | applyAttribute(node, "dataIndex", 0) 36 | 37 | assert.equal(node.getAttribute("data-index"), "0") 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /test/hydrate.js: -------------------------------------------------------------------------------- 1 | import { define } from "../src/index.js" 2 | 3 | describe("hydrate", () => { 4 | it("rehydrates event listeners", async () => { 5 | let name = createName() 6 | let stack = [] 7 | 8 | define( 9 | name, 10 | () => ({ 11 | update: { 12 | foo: () => stack.push("foo!"), 13 | }, 14 | }), 15 | html` 16 |
17 | 18 |
19 | ` 20 | ) 21 | 22 | mount(html`<${name}>click me!`) 23 | 24 | let node = $(name) 25 | let outerHTML = node.outerHTML 26 | 27 | node.remove() 28 | 29 | mount(outerHTML) 30 | 31 | assert.deepEqual(stack, []) 32 | 33 | $("#foo").click() 34 | 35 | assert.deepEqual(stack, ["foo!"]) 36 | }) 37 | it("rehydrates repeated blocks", async () => { 38 | let name = createName() 39 | let stack = [] 40 | 41 | define( 42 | name, 43 | () => { 44 | return { 45 | observe: ["todos"], 46 | state: { 47 | todos: [ 48 | { 49 | title: "feed the duck", 50 | }, 51 | { 52 | title: "walk the cat", 53 | }, 54 | ], 55 | }, 56 | update: { 57 | click: (state, action) => { 58 | stack.push(action.scope.todo.title) 59 | return state 60 | }, 61 | }, 62 | } 63 | }, 64 | ` 65 | 68 | ` 69 | ) 70 | 71 | mount(`<${name}>`) 72 | 73 | let html = $(name).outerHTML 74 | 75 | $(name).remove() 76 | 77 | mount(html) 78 | 79 | const { todos } = $(name) 80 | 81 | $(name).todos = todos.concat({ 82 | title: "eat the frog", 83 | }) 84 | 85 | // @todo: discuss this in the docs ... 86 | // $(name).todos.push({ 87 | // title: "eat the frog", 88 | // }) 89 | 90 | await nextFrame() 91 | 92 | $$(`li`)[2].click() 93 | 94 | assert.deepEqual(stack, ["eat the frog"]) 95 | }) 96 | 97 | it("rehydrates from the last state", async () => { 98 | let name = createName() 99 | let stack = [] 100 | 101 | define( 102 | name, 103 | () => { 104 | return { 105 | observe: ["todos"], 106 | state: { 107 | todos: [ 108 | { 109 | title: "feed the duck", 110 | }, 111 | { 112 | title: "walk the cat", 113 | }, 114 | ], 115 | }, 116 | update: { 117 | click: (state, { scope: { todo } }) => { 118 | stack.push(todo.title) 119 | return state 120 | }, 121 | }, 122 | } 123 | }, 124 | ` 125 | 128 | ` 129 | ) 130 | 131 | mount(`<${name}>`) 132 | 133 | const { todos } = $(name) 134 | 135 | $(name).todos = todos.concat({ 136 | title: "eat the frog", 137 | }) 138 | 139 | await nextFrame() 140 | 141 | let html = $(name).outerHTML 142 | 143 | $(name).remove() 144 | 145 | mount(html) 146 | 147 | await nextFrame() 148 | 149 | $("li:nth-of-type(3)").click() 150 | 151 | assert.deepEqual(stack, ["eat the frog"]) 152 | }) 153 | }) 154 | -------------------------------------------------------------------------------- /test/if.js: -------------------------------------------------------------------------------- 1 | import { define } from "../src/index.js" 2 | 3 | describe("if blocks", () => { 4 | it("renders the elements", () => { 5 | let name = createName() 6 | define( 7 | name, 8 | () => ({ 9 | state: { 10 | loggedIn: false, 11 | }, 12 | }), 13 | ` 14 | 15 | 16 | ` 17 | ) 18 | 19 | mount(`<${name}>`) 20 | 21 | assert.ok($("button#login")) 22 | assert.notOk($("button#logout")) 23 | }) 24 | it("renders the elements", () => { 25 | let name = createName() 26 | define( 27 | name, 28 | () => ({ 29 | state: { 30 | loggedIn: true, 31 | }, 32 | }), 33 | ` 34 | 35 | 36 | ` 37 | ) 38 | 39 | mount(`<${name}>`) 40 | 41 | assert.ok($("button#logout")) 42 | assert.notOk($("button#login")) 43 | }) 44 | it("toggles the elements", async () => { 45 | //... 46 | let name = createName() 47 | define( 48 | name, 49 | () => ({ 50 | state: { 51 | loggedIn: false, 52 | }, 53 | update: { 54 | logIn(state) { 55 | return { 56 | ...state, 57 | loggedIn: true, 58 | } 59 | }, 60 | logOut(state) { 61 | return { 62 | ...state, 63 | loggedIn: false, 64 | } 65 | }, 66 | }, 67 | }), 68 | ` 69 | 70 | 71 | ` 72 | ) 73 | 74 | mount(`<${name}>`) 75 | 76 | $("button#login").click() 77 | 78 | await nextFrame() 79 | 80 | assert.ok($("button#logout")) 81 | assert.notOk($("button#login")) 82 | }) 83 | it("binds other attributes on host", () => { 84 | const name = createName() 85 | const href = "https://bbc.co.uk/" 86 | define( 87 | name, 88 | () => { 89 | return { 90 | state: { 91 | show: true, 92 | href, 93 | }, 94 | } 95 | }, 96 | /* HTML */ ` ok ` 97 | ) 98 | mount(`<${name}>`) 99 | 100 | assert.equal($(`${name} a`).href, href) 101 | }) 102 | it("binds other attributes on host (reverse order)", () => { 103 | const name = createName() 104 | const href = "https://bbc.co.uk/" 105 | define( 106 | name, 107 | () => { 108 | return { 109 | state: { 110 | show: true, 111 | href, 112 | }, 113 | } 114 | }, 115 | /* HTML */ ` ok ` 116 | ) 117 | mount(`<${name}>`) 118 | 119 | assert.equal($(`${name} a`).href, href) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /test/interpolations.js: -------------------------------------------------------------------------------- 1 | import { define } from "../src/index.js" 2 | 3 | describe("interpolations", () => { 4 | it("should always cast primitive values to strings, unless null or undefined", () => { 5 | let name = createName() 6 | 7 | define( 8 | name, 9 | () => ({ 10 | state: { 11 | boolean: false, 12 | undefined: undefined, 13 | null: null, 14 | number: 0, 15 | string: "string", 16 | foo: "bar", 17 | }, 18 | }), 19 | html` 20 | 27 | ` 28 | ) 29 | 30 | mount(html`<${name}>`) 31 | 32 | assert.equal($("#boolean").textContent, "false") 33 | assert.equal($("#undefined").textContent, "") 34 | assert.equal($("#null").textContent, "") 35 | assert.equal($("#number").textContent, "0") 36 | assert.equal($("#string").textContent, "string") 37 | }) 38 | 39 | it("should support multiple bindings", () => { 40 | let name = createName() 41 | 42 | define( 43 | name, 44 | () => ({ 45 | state: { 46 | c1: "red", 47 | c2: "green", 48 | }, 49 | }), 50 | html`

{{c1}} + {{c2}}

` 51 | ) 52 | 53 | mount(html`<${name}>`) 54 | 55 | assert.equal($("p").textContent, "red + green") 56 | }) 57 | 58 | it("should apply all the values", () => { 59 | let name = createName() 60 | 61 | define( 62 | name, 63 | () => ({ 64 | state: { 65 | classes: ["one", "two", "three"], 66 | }, 67 | }), 68 | html`
` 69 | ) 70 | 71 | mount(html`<${name}>`) 72 | 73 | assert.equal($("section").className, "one two three") 74 | }) 75 | 76 | it("should apply all the keys with truthy values", () => { 77 | let name = createName() 78 | 79 | define( 80 | name, 81 | () => ({ 82 | state: { 83 | classes: { 84 | one: true, 85 | two: false, 86 | three: {}, 87 | four: null, 88 | five: "", 89 | six: "ok", 90 | }, 91 | }, 92 | }), 93 | html`
` 94 | ) 95 | 96 | mount(html`<${name}>`) 97 | 98 | assert.equal($("section").className, "one three six") 99 | }) 100 | 101 | it("should apply styles", () => { 102 | let name = createName() 103 | 104 | define( 105 | name, 106 | () => ({ 107 | state: { 108 | foo: ` 109 | background-color: gold; 110 | color: tomato; 111 | width: 100px; 112 | height: 100px; 113 | `, 114 | }, 115 | }), 116 | html`
` 117 | ) 118 | 119 | mount(html`<${name}>`) 120 | 121 | assert.equal( 122 | $("section").getAttribute("style"), 123 | "background-color: gold; color: tomato; width: 100px; height: 100px;" 124 | ) 125 | }) 126 | 127 | it("should preserve browser styles", async () => { 128 | let name = createName() 129 | 130 | define( 131 | name, 132 | () => ({ 133 | observe: ["foo"], 134 | state: { 135 | foo: ` 136 | background-color: gold; 137 | color: tomato; 138 | width: 100px; 139 | height: 100px; 140 | `, 141 | }, 142 | }), 143 | html`
` 144 | ) 145 | 146 | mount(html`<${name}>`) 147 | 148 | $("section").style.opacity = "0.5" 149 | 150 | assert.ok( 151 | $("section").getAttribute("style").includes("background-color: gold;") 152 | ) 153 | 154 | $(name).foo = ` 155 | background-color: tomato; 156 | color: gold; 157 | width: 100px; 158 | height: 100px; 159 | ` 160 | 161 | await nextFrame() 162 | 163 | assert.ok( 164 | $("section").getAttribute("style").includes("background-color: tomato;") 165 | ) 166 | 167 | assert.ok($("section").getAttribute("style").includes("opacity: 0.5;")) 168 | }) 169 | 170 | it("should apply styles (Object / kebab)", () => { 171 | let name = createName() 172 | 173 | define( 174 | name, 175 | () => ({ 176 | state: { 177 | foo: { 178 | "background-color": "gold", 179 | color: "tomato", 180 | width: "100px", 181 | height: "100px", 182 | }, 183 | }, 184 | }), 185 | html`
` 186 | ) 187 | 188 | mount(html`<${name}>`) 189 | 190 | assert.equal( 191 | $("section").getAttribute("style"), 192 | "background-color: gold; color: tomato; width: 100px; height: 100px;" 193 | ) 194 | }) 195 | 196 | it("should apply styles (Object / pascal)", () => { 197 | let name = createName() 198 | 199 | define( 200 | name, 201 | () => ({ 202 | state: { 203 | foo: { 204 | backgroundColor: "gold", 205 | color: "tomato", 206 | width: "100px", 207 | height: "100px", 208 | }, 209 | }, 210 | }), 211 | html`
` 212 | ) 213 | 214 | mount(html`<${name}>`) 215 | 216 | assert.equal( 217 | $("section").getAttribute("style"), 218 | "background-color: gold; color: tomato; width: 100px; height: 100px;" 219 | ) 220 | }) 221 | 222 | it("should allow whitespace formatting", () => { 223 | let name = createName() 224 | 225 | define( 226 | name, 227 | () => ({ 228 | state: { 229 | c1: "red", 230 | c2: "green", 231 | }, 232 | }), 233 | html`

{{ c2 }}

` 234 | ) 235 | 236 | mount(html`<${name}>`) 237 | 238 | assert.equal($("p").getAttribute("name"), "red") 239 | assert.equal($("p").textContent, "green") 240 | }) 241 | 242 | it("should support negation", async () => { 243 | let name = createName() 244 | 245 | define( 246 | name, 247 | () => ({ 248 | observe: ["foo"], 249 | state: { foo: true }, 250 | }), 251 | html`

boo!

` 252 | ) 253 | 254 | mount(html`<${name} foo>`) 255 | 256 | assert.notOk($("p").hidden) // [hidden] 257 | 258 | $(name).foo = false 259 | 260 | await nextFrame() 261 | 262 | assert.ok($("p").hidden) 263 | }) 264 | 265 | it("should support square brackets", () => { 266 | let name = createName() 267 | 268 | define( 269 | name, 270 | () => ({ 271 | state: { 272 | columns: ["one", "two", "three"], 273 | rows: [ 274 | { 275 | one: 1, 276 | two: 2, 277 | three: 3, 278 | }, 279 | { 280 | one: 3, 281 | two: 2, 282 | three: 1, 283 | }, 284 | { 285 | one: 1, 286 | two: 3, 287 | three: 2, 288 | }, 289 | ], 290 | }, 291 | }), 292 | html` 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 |
{{ column }}
{{ row[col] }}
301 | ` 302 | ) 303 | 304 | mount(html`<${name}>`) 305 | 306 | assert.equal($$("th").length, 3) 307 | assert.equal($$("tr").length, 4) 308 | assert.deepEqual( 309 | $$("td").map((v) => v.textContent.trim()), 310 | ["1", "2", "3", "3", "2", "1", "1", "3", "2"] 311 | ) 312 | }) 313 | 314 | it("resolves properties from the current item first", () => { 315 | let name = createName() 316 | 317 | let state = { 318 | foo: "bar", 319 | items: [ 320 | { 321 | x: 0, 322 | }, 323 | { 324 | x: 16, 325 | }, 326 | { 327 | x: 32, 328 | }, 329 | ], 330 | } 331 | 332 | define( 333 | name, 334 | () => ({ 335 | state, 336 | }), 337 | html` 338 |
344 | ` 345 | ) 346 | 347 | mount(html`<${name}>`) 348 | 349 | let nodes = $$(".foo") 350 | 351 | assert.equal(nodes.length, state.items.length) 352 | 353 | state.items.forEach(({ x }, i) => { 354 | let node = nodes[i] 355 | assert.equal(node.getAttribute("x"), x) 356 | assert.equal(node.getAttribute("foo"), state.foo) 357 | }) 358 | }) 359 | 360 | it("supports simple each (just the collection property)", () => { 361 | let name = createName() 362 | 363 | let state = { 364 | foo: "bar", 365 | items: [ 366 | { 367 | x: 0, 368 | }, 369 | { 370 | x: 16, 371 | }, 372 | { 373 | x: 32, 374 | }, 375 | ], 376 | } 377 | 378 | define( 379 | name, 380 | () => ({ 381 | state, 382 | }), 383 | html` 384 |
385 | ` 386 | ) 387 | 388 | mount(html`<${name}>`) 389 | 390 | let nodes = $$(".foo") 391 | 392 | assert.equal(nodes.length, state.items.length) 393 | 394 | state.items.forEach(({ x }, i) => { 395 | let node = nodes[i] 396 | assert.equal(node.getAttribute("x"), x) 397 | assert.equal(node.getAttribute("foo"), state.foo) 398 | }) 399 | }) 400 | 401 | it("supports shorthand attributes (when attribute name matches property name)", () => { 402 | let name = createName() 403 | 404 | let state = { 405 | foo: "bar", 406 | items: [ 407 | { 408 | x: 0, 409 | }, 410 | { 411 | x: 16, 412 | }, 413 | { 414 | x: 32, 415 | }, 416 | ], 417 | } 418 | 419 | define( 420 | name, 421 | () => ({ 422 | state, 423 | }), 424 | html`
` 425 | ) 426 | 427 | mount(html`<${name}>`) 428 | 429 | let nodes = $$(".foo") 430 | 431 | assert.equal(nodes.length, state.items.length) 432 | 433 | state.items.forEach(({ x }, i) => { 434 | let node = nodes[i] 435 | assert.equal(node.getAttribute("x"), x) 436 | assert.equal(node.getAttribute("foo"), state.foo) 437 | }) 438 | }) 439 | 440 | it("supports top-level text bindings", () => { 441 | define( 442 | "x-bind", 443 | () => ({ 444 | state: { 445 | one: "foo", 446 | two: "bar", 447 | three: "baz", 448 | }, 449 | }), 450 | /* html */ `{{ one }}/

{{ two }}

/{{ three }}` 451 | ) 452 | 453 | mount(/* html */ ``) 454 | 455 | const innerText = $(`x-bind`).innerText.replace(/\n+/g, "") 456 | 457 | assert.equal(innerText, "foo/bar/baz") 458 | }) 459 | }) 460 | -------------------------------------------------------------------------------- /test/middleware.js: -------------------------------------------------------------------------------- 1 | import { define } from "../src/index.js" 2 | 3 | describe("middleware", () => { 4 | it("enables actions to be modified", () => { 5 | const KEYS = { 6 | RETURN: 13, 7 | ESCAPE: 27, 8 | } 9 | let name = createName() 10 | let stack = [] 11 | define( 12 | name, 13 | () => ({ 14 | update: { 15 | saveEdit: (state, action) => { 16 | stack.push(action) 17 | return state 18 | }, 19 | cancelEdit: (state, action) => { 20 | stack.push(action) 21 | return state 22 | }, 23 | }, 24 | middleware: { 25 | keydown: (action, next) => { 26 | let { keyCode } = action.event 27 | switch (keyCode) { 28 | case KEYS.ESCAPE: { 29 | next({ ...action, type: "cancelEdit" }) 30 | break 31 | } 32 | case KEYS.RETURN: { 33 | next({ ...action, type: "saveEdit" }) 34 | break 35 | } 36 | } 37 | }, 38 | }, 39 | }), 40 | `` 41 | ) 42 | 43 | mount(html`<${name}>`) 44 | 45 | $(`button`).dispatchEvent( 46 | new KeyboardEvent("keydown", { 47 | keyCode: KEYS.RETURN, 48 | bubbles: true, 49 | }) 50 | ) 51 | 52 | $(`button`).dispatchEvent( 53 | new KeyboardEvent("keydown", { 54 | keyCode: KEYS.ESCAPE, 55 | bubbles: true, 56 | }) 57 | ) 58 | 59 | assert.equal(stack.length, 2) 60 | assert.equal(stack[0].type, "saveEdit") 61 | assert.equal(stack[1].type, "cancelEdit") 62 | }) 63 | 64 | it("allows promise chaining after the next update following an action", () => { 65 | let name = createName() 66 | define( 67 | name, 68 | () => ({ 69 | update: (state = state, action) => { 70 | switch (action.type) { 71 | case "toggle": { 72 | return { 73 | ...state, 74 | hidden: !state.hidden, 75 | } 76 | } 77 | default: 78 | return state 79 | } 80 | }, 81 | middleware: { 82 | toggle: (action, next) => { 83 | next(action).then(() => $("input").focus()) 84 | }, 85 | }, 86 | }), 87 | `` 88 | ) 89 | mount(`<${name}>`) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /test/partials.js: -------------------------------------------------------------------------------- 1 | import { define, partial } from "../src/index.js" 2 | 3 | describe("partial", () => { 4 | it("is included within parent template", () => { 5 | let partialName = createName() 6 | 7 | partial(partialName, html`

{{ foo }}

`) 8 | 9 | let name = createName() 10 | 11 | define( 12 | name, 13 | () => ({ 14 | observe: ["foo"], 15 | state: { 16 | foo: "bar", 17 | }, 18 | }), 19 | html`
<${partialName} />
` 20 | ) 21 | 22 | mount(`<${name}>`) 23 | 24 | assert.equal($(`${partialName} p`).textContent, "bar") 25 | 26 | $(name).foo = "baz" 27 | 28 | assert.equal($(`${partialName} p`).textContent, "baz") 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /test/scope.js: -------------------------------------------------------------------------------- 1 | import { define } from "../src/index.js" 2 | 3 | describe("scope", () => { 4 | let rootNode 5 | beforeEach(() => { 6 | rootNode = mount(html`
`) 7 | }) 8 | 9 | it("should observe context", () => { 10 | let name = createName() 11 | 12 | let state = { 13 | todo: "feed the dog", 14 | message: "Hej!", 15 | todos: [ 16 | { 17 | title: "walk the cat", 18 | subtitle: "ok", 19 | colour: "tomato", 20 | }, 21 | { 22 | title: "shampoo the dog", 23 | subtitle: "thanks", 24 | colour: "gold", 25 | }, 26 | ], 27 | } 28 | 29 | define( 30 | name, 31 | () => ({ 32 | state, 33 | }), 34 | html`

{{todo}}

35 | 43 |

{{todo}}

` 44 | ) 45 | 46 | mount(html`<${name}>`) 47 | 48 | assert.equal($("h1[first]").textContent.trim(), "feed the dog") 49 | assert.equal($("li p").textContent.trim(), "walk the cat") 50 | assert.equal($("li p:last-child").textContent.trim(), "Hej!") 51 | assert.equal($("h1[second]").textContent.trim(), "feed the dog") 52 | }) 53 | 54 | it("should support nested scopes", async () => { 55 | let name = createName() 56 | 57 | let state = { 58 | artists: [ 59 | { 60 | name: "pablo picasso", 61 | tags: [ 62 | "painter", 63 | "sculptor", 64 | "printmaker", 65 | "ceramicist", 66 | "theatre designer", 67 | ], 68 | }, 69 | { 70 | name: "salvador dali", 71 | tags: ["painter", "sculptor", "photographer", "writer"], 72 | }, 73 | ], 74 | } 75 | 76 | define( 77 | name, 78 | () => ({ 79 | state, 80 | }), 81 | html` 82 | 92 | ` 93 | ) 94 | 95 | mount(html`<${name}>`) 96 | 97 | assert.equal($("h4").textContent, state.artists[0].name) 98 | assert.equal( 99 | $$("article:nth-of-type(1) li").length, 100 | state.artists[0].tags.length 101 | ) 102 | assert.equal( 103 | $$("article:nth-of-type(2) li").length, 104 | state.artists[1].tags.length 105 | ) 106 | assert.equal($("article:nth-of-type(1) li").textContent, "painter 0:0") 107 | assert.equal($("article:nth-of-type(2) li").textContent, "painter 1:0") 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /test/slots.js: -------------------------------------------------------------------------------- 1 | import { define } from "../src/index.js" 2 | 3 | describe("slots", () => { 4 | it("doesn't trigger the disconnectedCallback if reconnected within the same frame", async () => { 5 | mount(/* html */ ` 6 | 7 | 8 | 9 | `) 10 | 11 | let callbacks = [] 12 | 13 | define( 14 | "simple-clock", 15 | () => { 16 | let t 17 | return { 18 | update: { 19 | setTime: (state, { payload }) => { 20 | return { 21 | ...state, 22 | time: payload, 23 | } 24 | }, 25 | }, 26 | connectedCallback: ({ dispatch }) => { 27 | callbacks.push("connected") 28 | t = setInterval(() => { 29 | dispatch({ 30 | type: "setTime", 31 | payload: new Date().toLocaleTimeString(), 32 | }) 33 | }, 100) 34 | }, 35 | disconnectedCallback: () => { 36 | callbacks.push("disconnected") 37 | clearInterval(t) 38 | }, 39 | } 40 | }, 41 | /* html */ ` 42 |

{{ time }}

43 | ` 44 | ) 45 | 46 | define( 47 | "x-app", 48 | () => ({}), 49 | /* html */ ` 50 |
51 | 52 |
53 | ` 54 | ) 55 | 56 | await nextFrame() 57 | 58 | assert.equal(callbacks.length, 1) 59 | assert.equal(callbacks[0], "connected") 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /test/user-input.js: -------------------------------------------------------------------------------- 1 | import { define } from "../src/index.js" 2 | 3 | describe("user input", () => { 4 | it("should bind the value to the named property", () => { 5 | let name = createName() 6 | 7 | let state = { 8 | message: "?", 9 | } 10 | 11 | define( 12 | name, 13 | () => ({ 14 | state, 15 | }), 16 | html` the message is: 17 | {{message}}` 18 | ) 19 | 20 | mount(html`<${name}>`) 21 | 22 | assert.equal($("span.message").textContent, "?") 23 | }) 24 | 25 | it("should bind the value to the named property (nested)", () => { 26 | let name = createName() 27 | 28 | let state = { 29 | nested: { 30 | message: "??", 31 | }, 32 | } 33 | 34 | define( 35 | name, 36 | () => ({ 37 | state, 38 | }), 39 | html` 40 | 41 | the message is: 42 | {{nested.message}} 43 | ` 44 | ) 45 | 46 | mount(html`<${name}>`) 47 | 48 | assert.equal($("span.message").textContent, "??") 49 | }) 50 | 51 | it("should bind the value to the named + scoped property", () => { 52 | let name = createName() 53 | 54 | let state = { 55 | todos: [ 56 | { 57 | title: "feed the cat", 58 | done: true, 59 | }, 60 | { 61 | title: "walk the dog", 62 | }, 63 | ], 64 | } 65 | 66 | define( 67 | name, 68 | () => ({ 69 | state, 70 | }), 71 | html` 72 | 80 | ` 81 | ) 82 | 83 | mount(html`<${name}>`) 84 | 85 | const li = $$("li") 86 | 87 | assert.equal(li[0].querySelector("input").checked, true) 88 | assert.equal(li[1].querySelector("input").checked, false) 89 | }) 90 | 91 | it("should check the correct radio button", () => { 92 | let name = createName() 93 | 94 | define( 95 | name, 96 | () => ({ 97 | state: { 98 | filter: "active", 99 | }, 100 | }), 101 | html` 102 | 103 | 104 | 105 | 106 | 112 | 113 | ` 114 | ) 115 | 116 | mount(html`<${name}>`) 117 | 118 | let checked = $(`input[type="radio"]:checked`) 119 | assert.equal(checked.value, "active") 120 | }) 121 | 122 | it("should check the correct radio button", () => { 123 | let name = createName() 124 | 125 | let state = {} 126 | 127 | define( 128 | name, 129 | () => ({ 130 | state, 131 | }), 132 | html` 133 | 134 | 135 | 136 | 137 | 143 | 144 | ` 145 | ) 146 | 147 | mount(html`<${name}>`) 148 | 149 | let checked = $(`#container input[type="radio"]:checked`) 150 | assert.equal(checked, null) 151 | }) 152 | 153 | it("should reflect the correct radio button", async () => { 154 | let name = createName() 155 | 156 | let state = { 157 | filter: "active", 158 | } 159 | 160 | define( 161 | name, 162 | () => ({ 163 | state, 164 | }), 165 | html` 166 | 167 | 168 | 169 | 170 | 176 | 177 | ` 178 | ) 179 | 180 | mount(html`<${name}>`) 181 | 182 | $(`input[value="complete"]`).click() 183 | 184 | await nextFrame() 185 | 186 | assert.equal($(`input:checked`).value, "complete") 187 | }) 188 | 189 | it("should select the correct option", async () => { 190 | let name = createName() 191 | 192 | let state = { 193 | pets: "hamster", 194 | } 195 | 196 | define( 197 | name, 198 | () => ({ 199 | observe: ["pets"], 200 | state, 201 | }), 202 | html` 203 | 204 | 213 | ` 214 | ) 215 | 216 | mount(html`<${name}>`) 217 | 218 | assert.equal($("select option:checked").value, "hamster") 219 | 220 | $(name).pets = "parrot" 221 | 222 | await nextFrame() 223 | 224 | assert.equal($("select option:checked").value, "parrot") 225 | }) 226 | 227 | it("should select the correct option (each option)", async () => { 228 | let name = createName() 229 | 230 | define( 231 | name, 232 | () => ({ 233 | observe: ["pets"], 234 | state: { 235 | pets: "Hamster", 236 | options: ["Dog", "Cat", "Hamster", "Parrot", "Spider", "Goldfish"], 237 | }, 238 | }), 239 | html` 240 | 241 | 247 | ` 248 | ) 249 | 250 | mount(html`<${name}>`) 251 | 252 | assert.equal($("select option:checked").value, "Hamster") 253 | 254 | $(name).pets = "Parrot" 255 | 256 | await nextFrame() 257 | 258 | assert.equal($("select option:checked").value, "Parrot") 259 | }) 260 | 261 | it("should select multiple", () => { 262 | let name = createName() 263 | 264 | let state = { 265 | pets: ["dog", "hamster"], 266 | } 267 | 268 | define( 269 | name, 270 | () => ({ 271 | state, 272 | }), 273 | html` 274 | 275 | 284 | ` 285 | ) 286 | 287 | mount(html`<${name}>`) 288 | 289 | assert.deepEqual( 290 | $$("select option:checked").map((option) => option.value), 291 | ["dog", "hamster"] 292 | ) 293 | }) 294 | 295 | it("should reflect selected option", async () => { 296 | let name = createName() 297 | 298 | define( 299 | name, 300 | () => ({ 301 | observe: ["pets"], 302 | state: { 303 | pets: ["hamster"], 304 | }, 305 | }), 306 | html` 307 | 308 | 317 | ` 318 | ) 319 | 320 | mount(html`<${name}>`) 321 | 322 | $("#pet-select").value = "parrot" 323 | 324 | $("#pet-select").dispatchEvent( 325 | new Event("input", { 326 | bubbles: true, 327 | }) 328 | ) 329 | 330 | await nextFrame() 331 | 332 | assert.deepEqual($(name).pets, "parrot") 333 | }) 334 | 335 | it("should bind the named textarea", () => { 336 | let name = createName() 337 | 338 | let state = { 339 | text: "ok", 340 | } 341 | 342 | define( 343 | name, 344 | () => ({ 345 | state, 346 | }), 347 | html` ` 348 | ) 349 | 350 | mount(html`<${name}>`) 351 | 352 | assert.equal($("textarea").value, "ok") 353 | }) 354 | }) 355 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export = synergy 2 | export as namespace synergy 3 | declare namespace synergy { 4 | function define( 5 | /** 6 | * The name for the new custom element. As per the Custom Element spec, 7 | * the name must include a hyphen. 8 | */ 9 | name: string, 10 | /** 11 | * A factory function that will be called whenever a new instance of your 12 | * custom element is created. Returns the object that will provide the data for your custom element. 13 | */ 14 | factory: ModelFactory, 15 | /** 16 | * The template represents the HTML markup for your element. 17 | */ 18 | template: HTMLTemplateElement | string, 19 | /** 20 | * CSS for your Custom Element will be partially scoped by prefixing selectors with the elements name to stop your styles from leaking out, whilst still allowing inheritance of more generic global styles. CSS will be copied once into the document head and shared between all instances of the Custom Element. 21 | */ 22 | css?: string 23 | ): void 24 | 25 | type State = { 26 | [key: string]: any 27 | } 28 | 29 | type ActionInput = { 30 | type: string 31 | payload: { 32 | [key: string]: any 33 | } 34 | } 35 | 36 | type Action = { 37 | type: string 38 | payload: { 39 | [key: string]: any 40 | } 41 | event: Event 42 | /* 43 | * The current state including the variable scope of the event origin (i.e., created with a Repeated Block) 44 | */ 45 | scope: { 46 | [key: string]: any 47 | } 48 | } 49 | 50 | type ActionHandler = { 51 | (currentState: State, action: Action): State 52 | } 53 | 54 | type Store = { 55 | getState(): State 56 | dispatch(action: ActionInput): void 57 | } 58 | 59 | type PromiseLike = { 60 | /** 61 | * 62 | * Returns a Promise that will be resolved after the next UI update 63 | */ 64 | then(fn: Function): Promise 65 | } 66 | 67 | type Next = { 68 | /** 69 | * Passes the action to the next handler in the stack 70 | */ 71 | (action: ActionInput): PromiseLike 72 | } 73 | 74 | type Middleware = { 75 | /** 76 | * A function that intercepts an action before it reaches its ActionHandler (if any), 77 | * providing the opportunity to execute side effect(s), make asynchronous calls, re-route actions 78 | * , dispatch new actions, etc 79 | */ 80 | (action: ActionInput, next: Next, store: Store) 81 | } 82 | 83 | type Model = { 84 | /** 85 | * Provides the initial state to the component for its very first render. 86 | */ 87 | state?: State 88 | /** 89 | * Invoked each time the custom element is appended into a 90 | * document-connected element 91 | */ 92 | connectedCallback?(store: Store): void 93 | /** 94 | * Invoked each time the custom element is disconnected from the document 95 | */ 96 | disconnectedCallback?(): void 97 | /** 98 | * 99 | */ 100 | update?: { 101 | [actionName: string]: ActionHandler 102 | } 103 | /** 104 | * A custom wrapper around calls to getState, giving you the ability to derive additional properties 105 | */ 106 | getState?(state: State): State 107 | /** 108 | * A debounced function that is called after every render cycle 109 | */ 110 | subscribe?: { 111 | (state: State): void 112 | } 113 | /** 114 | * 115 | */ 116 | middleware?: { 117 | [actionName: string]: Middleware | [Middleware] 118 | } 119 | /** 120 | * If this is omitted then Shadow DOM is not utilised and functionality is polyfilled. 121 | */ 122 | shadow?: "open" | "closed" 123 | } 124 | 125 | type ModelFactory = { 126 | ( 127 | /** 128 | * The Custom Element node 129 | */ 130 | element: HTMLElement 131 | ): Model | Promise 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /website/components/my-counter.js: -------------------------------------------------------------------------------- 1 | import { define } from "/synergy.js" 2 | 3 | const name = "my-counter" 4 | 5 | const factory = () => ({ 6 | state: { count: 0 }, 7 | update: { 8 | increment: (state) => ({ 9 | ...state, 10 | count: state.count + 1, 11 | }), 12 | }, 13 | }) 14 | 15 | const template = /* html */ ` 16 | 17 | ` 18 | 19 | const css = /* css */ ` 20 | button { 21 | cursor: pointer; 22 | padding: 0.5rem 1rem; 23 | background-color: gold; 24 | color: tomato; 25 | } 26 | ` 27 | 28 | define(name, factory, template) 29 | -------------------------------------------------------------------------------- /website/components/simple-clock.js: -------------------------------------------------------------------------------- 1 | import { define } from "/synergy.js" 2 | 3 | define( 4 | "simple-clock", 5 | () => { 6 | let t 7 | 8 | return { 9 | update: { 10 | setTime: (state, { payload }) => { 11 | return { 12 | ...state, 13 | time: payload, 14 | } 15 | }, 16 | }, 17 | connectedCallback: ({ dispatch, getState }) => { 18 | t = setInterval(() => { 19 | dispatch({ 20 | type: "setTime", 21 | payload: new Date().toLocaleTimeString(), 22 | }) 23 | }, 100) 24 | }, 25 | disconnectedCallback: () => { 26 | clearInterval(t) 27 | }, 28 | } 29 | }, 30 | /* html */ ` 31 |

{{ time }}

32 | ` 33 | ) 34 | -------------------------------------------------------------------------------- /website/components/todo-list.js: -------------------------------------------------------------------------------- 1 | import { define } from "/synergy.js" 2 | 3 | define( 4 | "todo-list", 5 | () => { 6 | return { 7 | state: { 8 | todos: [], 9 | }, 10 | update: { 11 | addTodo: (state, { event: { key } }) => { 12 | if (key !== "Enter" || !state.newTodo?.length) return state 13 | 14 | return { 15 | ...state, 16 | newTodo: null, 17 | todos: state.todos.concat({ 18 | title: state.newTodo, 19 | completed: false, 20 | }), 21 | } 22 | }, 23 | removeTodo: (state, { scope: { todo } }) => { 24 | return { 25 | ...state, 26 | todos: state.todos.filter(({ title }) => title !== todo.title), 27 | } 28 | }, 29 | }, 30 | getState: (state) => { 31 | const n = state.todos.filter(({ completed }) => !completed).length 32 | 33 | return { 34 | ...state, 35 | itemsLeft: `${n} item${n === 1 ? "" : "s"} left`, 36 | } 37 | }, 38 | } 39 | }, 40 | /* html */ ` 41 | 42 |
    43 |
  • 44 | 45 | {{ todo.title }} 46 | 47 |
  • 48 |
49 |

{{ itemsLeft }}

50 | 51 | ` 52 | ) 53 | -------------------------------------------------------------------------------- /website/components/x-logo.js: -------------------------------------------------------------------------------- 1 | import { partial } from "../synergy.js" 2 | 3 | partial( 4 | "x-logo", 5 | /* HTML */ ` 6 | 11 | 21 | 25 | 29 | 33 | 34 | 35 | `, 36 | /* CSS */ ` 37 | x-logo { 38 | height: 2rem; 39 | } 40 | 41 | svg { 42 | height: 2rem; 43 | /* margin: 0 0.5rem; */ 44 | margin-right: 0.5rem; 45 | } 46 | 47 | path { 48 | stroke: teal; 49 | fill: teal; 50 | } 51 | 52 | ` 53 | ) 54 | -------------------------------------------------------------------------------- /website/components/x-pager.js: -------------------------------------------------------------------------------- 1 | import { define } from "../synergy.js" 2 | import { flatNav } from "../data.js" 3 | 4 | define( 5 | "x-pager", 6 | () => { 7 | const items = flatNav 8 | const { pathname } = location 9 | const index = items.findIndex(({ href }) => href === pathname) 10 | 11 | return { 12 | state: { 13 | prev: items[index - 1], 14 | next: items[index + 1], 15 | }, 16 | } 17 | }, 18 | /* html */ ` 19 | 20 | NEXT{{ next.title }} 21 | 24 | 25 | 26 | PREV{{ prev.title }} 27 | 30 | 31 | 32 | `, 33 | /* css */ ` 34 | x-pager { 35 | display: flex; 36 | flex-direction: column; 37 | padding: var(--s3) 0; 38 | } 39 | 40 | x-pager > * { 41 | flex: 1; 42 | } 43 | 44 | a { 45 | padding: var(--s3); 46 | border: 1px solid #eaeaea; 47 | } 48 | 49 | svg { 50 | stroke: currentColor; 51 | stroke-width: 1px; 52 | margin: auto 0; 53 | } 54 | 55 | a { 56 | margin-top: 0; 57 | text-decoration: none; 58 | vertical-align: center; 59 | font-size: var(--s2); 60 | color: #111; 61 | } 62 | 63 | a:hover { 64 | border: 1px solid rgb(102, 204, 51); 65 | color: rgb(102, 204, 51); 66 | } 67 | 68 | a span { 69 | color: #222; 70 | display: block; 71 | font-size: var(--s1); 72 | } 73 | 74 | a:nth-of-type(2) { 75 | text-align: right; 76 | } 77 | 78 | a:nth-of-type(1) { 79 | text-align: left; 80 | } 81 | 82 | a:nth-of-type(2) svg { 83 | float: left; 84 | } 85 | 86 | a:nth-of-type(1) svg { 87 | float: right; 88 | } 89 | 90 | a:nth-of-type(1) { 91 | margin-bottom: var(--s1); 92 | } 93 | 94 | @media screen and (min-width: 1024px) { 95 | x-pager { 96 | flex-direction: row-reverse; 97 | } 98 | 99 | a:nth-of-type(1) { 100 | margin-bottom: 0; 101 | } 102 | 103 | a:nth-of-type(2) { 104 | margin-right: 1rem; 105 | } 106 | } 107 | ` 108 | ) 109 | -------------------------------------------------------------------------------- /website/customElements.js: -------------------------------------------------------------------------------- 1 | const ANON = /<([a-z]\w*-\w*)/gm 2 | 3 | function unique(arr) { 4 | return [...new Set(arr)] 5 | } 6 | 7 | export function listCustomElements(html = "") { 8 | const names = (html.match(ANON) || []).map((v) => v.slice(1)) 9 | return unique(names) 10 | } 11 | -------------------------------------------------------------------------------- /website/data/nav.js: -------------------------------------------------------------------------------- 1 | export const navigation = [ 2 | { 3 | title: "Examples", 4 | items: [ 5 | { 6 | title: "my-counter", 7 | href: "my-counter", 8 | }, 9 | { 10 | title: "simple-clock", 11 | href: "simple-clock", 12 | }, 13 | { 14 | title: "todo-list", 15 | href: "todo-list", 16 | }, 17 | ], 18 | }, 19 | { 20 | title: "Reference", 21 | items: [ 22 | { 23 | title: "Template syntax", 24 | href: "template-syntax", 25 | }, 26 | { 27 | title: "Repeated blocks", 28 | href: "repeated-blocks", 29 | }, 30 | { 31 | title: "Events", 32 | href: "events", 33 | }, 34 | { 35 | title: "Styles", 36 | href: "styles", 37 | }, 38 | { 39 | title: "Forms", 40 | href: "forms", 41 | }, 42 | ], 43 | }, 44 | ] 45 | 46 | function flatten(items) { 47 | return items.reduce((acc, item) => { 48 | if (item.items) { 49 | acc.push(...item.items.map((v) => ({ ...v, category: item.title }))) 50 | } 51 | return acc 52 | }, []) 53 | } 54 | 55 | export const flatNav = flatten(navigation) 56 | -------------------------------------------------------------------------------- /website/head.js: -------------------------------------------------------------------------------- 1 | const HEAD_OPEN = "" 2 | const HEAD_CLOSE = "" 3 | 4 | export const decapitate = (str) => { 5 | const start = str.indexOf(HEAD_OPEN) 6 | const end = str.indexOf(HEAD_CLOSE) 7 | if (start === -1) 8 | return { 9 | head: "", 10 | body: str, 11 | } 12 | return { 13 | head: str.slice(start + HEAD_OPEN.length, end).trim(), 14 | body: 15 | str.slice(0, start).trim() + str.slice(end + HEAD_CLOSE.length).trim(), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "synergydocs", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "description": "", 6 | "scripts": { 7 | "linklib": "cp ../dist/synergy.js public/", 8 | "start": "nodemon --ignore 'build/*' server.js", 9 | "build": "rm -r build ; mkdir build && cd build && wget -r -k -nH --html-extension http://localhost:3000", 10 | "postbuild": "cp -a public/. build && cp -r components build && echo 'synergyjs.org' > build/CNAME && rm -r ../docs && mv build ../docs", 11 | "serve": "cd build && npx serve -p 8000" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "express": "^4.18.2", 18 | "fs-extra": "^10.1.0", 19 | "globby": "^13.1.2", 20 | "highlight.js": "^11.7.0", 21 | "markdown-it": "^13.0.1", 22 | "nodemon": "^2.0.20", 23 | "puppeteer-core": "^15.5.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /website/pages/api.md: -------------------------------------------------------------------------------- 1 | 2 | API | Synergy JS 3 | 4 | 5 | # API 6 | 7 | ## Define 8 | 9 | Synergy's `define` function allows you to create a new HTML element. Once defined within an HTML document your custom element can be used on the page just like any other HTML tag. Your custom elements can be composed together, allowing you to build anything from a simple component to an entire website or application. 10 | 11 | The `define()` function registers a new Custom Element. 12 | 13 | ### Syntax 14 | 15 | ```js 16 | define(tagName, factory, template, styles) 17 | ``` 18 | 19 | ### Parameters 20 | 21 | - `tagName` (required) [string] - Name for the new Custom Element. As per the Custom Element 22 | spec, an elements name must include a hyphen to differentiate from standard built-in elements. 23 | 24 | - `factory` (required) [function] - A factory function that will be called whenever a new instance of your Custom Element is created. It will be provided with one argument which is the Custom Element node itself. The factory function returns a Model (see below) or a Promise that resolves to a Model. 25 | 26 | - `template` (required) [HTMLTemplateElement | string] - The HTML for your view. 27 | 28 | - `styles` (optional) [string] - The CSS for your custom element. The CSS will be transformed to apply lightweight scoping before being added to the head of the document. 29 | 30 | ### Return value 31 | 32 | None (`undefined`) 33 | 34 | ## Model 35 | 36 | The factory function provided as the second argument to `define` must return a plain JavaScript object that represents the element _Model_. 37 | 38 | ```ts 39 | type Model = { 40 | /** 41 | * Provides the initial state to the component for its very first render. 42 | */ 43 | state?: State 44 | /** 45 | * Invoked each time the custom element is appended into a 46 | * document-connected element 47 | */ 48 | connectedCallback?(store: Store): void 49 | /** 50 | * Invoked each time the custom element is disconnected from the document 51 | */ 52 | disconnectedCallback?(): void 53 | /** 54 | * 55 | */ 56 | update?: { 57 | [actionName: string]: ActionHandler 58 | } 59 | /** 60 | * A custom wrapper around calls to getState, giving you the ability to define derived properties, for example 61 | */ 62 | getState?(state: State): State 63 | /** 64 | * A debounced function that is called after every render cycle 65 | */ 66 | subscribe?: { 67 | (state: State): void 68 | } 69 | /** 70 | * 71 | */ 72 | middleware?: { 73 | [actionName: string]: Middleware | [Middleware] 74 | } 75 | /** 76 | * If this is omitted then Shadow DOM is not utilised and functionality is polyfilled. 77 | */ 78 | shadow?: "open" | "closed" 79 | } 80 | ``` 81 | 82 | ## High-level view 83 | 84 | The Synergy `define` function allows you to register a custom element on the page. Once defined, you can use your custom element like any other HTML tag. 85 | 86 | The `define` function takes up to four arguments: 87 | 88 | - `tagName` (required) [string] - Name for the new Custom Element. As per the Custom Element 89 | spec, an elements name must include a hyphen to differentiate from standard built-in elements. 90 | 91 | - `factory` (required) [function] - A factory function that will be called whenever a new instance of your Custom Element is created. It will be provided with one argument which is the Custom Element node itself. The factory function returns an Object (or a Promise that resolves to an Object) that defines the behaviour of the element. 92 | 93 | - `template` (required) [HTMLTemplateElement | string] - The HTML for your view. 94 | 95 | - `styles` (optional) [string] - The CSS for your custom element. The CSS will be transformed to apply lightweight scoping before being added to the head of the document. 96 | 97 | As you can see, the first, third, and fourth arguments are just strings. The third argument is standard HTML, and the fourth argument is standard CSS. One of the great things about Synergy is that it allows you to build UI using 100% standard, spec-compliant HTML, CSS, and JavaScript. 98 | 99 | In the next section we will look closer at our simple element example to understand more about how Synergys reactivity system works and what you can do with it. 100 | -------------------------------------------------------------------------------- /website/pages/events.md: -------------------------------------------------------------------------------- 1 | 2 | Events | Synergy JS 3 | 4 | 5 | # Events 6 | 7 | Synergy allows you to map events to update functions of the same name. 8 | 9 | Model: 10 | 11 | ```js 12 | const factory = () => ({ 13 | update: { 14 | sayHello: (state) => ({ 15 | ...state, 16 | greeting: "Hello" 17 | }), 18 | }) 19 | ``` 20 | 21 | Template: 22 | 23 | ```html 24 | 25 | ``` 26 | 27 | Every update function accepts the current state as its first argument, its return value will provide the next state for the custom element. 28 | 29 | ## Template scope 30 | 31 | Because each repeated block creates a new [_variable scope_](https://developer.mozilla.org/en-US/docs/Glossary/Scope), it is useful to be able to access those values within your handler. You can do this via the `scope` property of the second argument to your event handler. 32 | 33 | Model: 34 | 35 | ```js 36 | const factory = () => { 37 | return { 38 | state: { 39 | artists: [ 40 | { 41 | name: "pablo picasso", 42 | tags: ["painter", "sculptor", "printmaker", "ceramicist"], 43 | }, 44 | { 45 | name: "salvador dali", 46 | tags: ["painter", "sculptor", "photographer", "writer"], 47 | }, 48 | ], 49 | }, 50 | update: { 51 | select: (state, { scope }) => { 52 | const { artist, tag } = scope 53 | 54 | return { 55 | ...state, 56 | selected: { 57 | artist, 58 | tag, 59 | }, 60 | } 61 | }, 62 | }, 63 | } 64 | } 65 | ``` 66 | 67 | Template: 68 | 69 | ```html 70 |
71 |

{{ artist.name }}

72 |
    73 |
  • {{ tag }}
  • 74 |
75 |
76 | ``` 77 | -------------------------------------------------------------------------------- /website/pages/forms.md: -------------------------------------------------------------------------------- 1 | 2 | Forms | Synergy JS 3 | 4 | 5 | # Forms 6 | 7 | Synergy makes working with form data a breeze by automagically binding named form controls to state properties of the same name. 8 | 9 | State: 10 | 11 | ```js 12 | { 13 | color: "#4287f5" 14 | } 15 | ``` 16 | 17 | Template: 18 | 19 | ```html 20 | 21 | ``` 22 | 23 | ## Select 24 | 25 | Attribute a name to your ` 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ``` 51 | 52 | The standard HTML ` 58 | ``` 59 | 60 | A ` 76 | 77 | 78 | ``` 79 | 80 | As with ``. 83 | 84 | ```js 85 | { 86 | filter: "active" 87 | } 88 | ``` 89 | -------------------------------------------------------------------------------- /website/pages/getting-started.md: -------------------------------------------------------------------------------- 1 | 2 | Getting started | Synergy JS 3 | 4 | 5 | # Getting Started 6 | 7 | Synergy doesn't require any special toolchain, compiler, plugins etc. Its a tiny (~4k) package that gives you everything you need to start building directly in the browser. 8 | 9 | The quickest way to get started is to import the Synergy package directly from a CDN. 10 | 11 | ## Unpkg CDN 12 | 13 | ```html 14 | 17 | ``` 18 | 19 | You can also install directly into your project using NPM. 20 | 21 | ## NPM 22 | 23 | ```bash 24 | $ npm i synergy 25 | ``` 26 | -------------------------------------------------------------------------------- /website/pages/hydration.md: -------------------------------------------------------------------------------- 1 | 2 | Hydration | Synergy JS 3 | 4 | 5 | # Hydration 6 | 7 | Synergy components serialise their data inside a script element each time that their viewmodel changes and merge any deserialised data back into their viewmodel during instantion. This allows you to capture the current state of one or more components in a page at any point in time and then reload that into a browser later to effectively resume from where you left off. 8 | 9 | Just remember that data serialisation can increase the size of your page significantly, so if you have a large number of components that need to be prerendered but _not_ rehydrated (e.g., components that have no event bindings and never need to update after the first render), then you should strip out both the data scripts and the component definitions for those particular components to achieve the best possible performance. 10 | -------------------------------------------------------------------------------- /website/pages/index.md: -------------------------------------------------------------------------------- 1 | 2 | Synergy - A JavaScript library for crafting user interfaces 3 | 4 | 5 | ## What is Synergy? 6 | 7 | Synergy 8 | combines declarative data and event binding with functional state 9 | management and reactive updates to allow you to build all types of 10 | user interface for the web, no matter how simple or complex. 11 | 12 | Here's a simple example: 13 | 14 | 15 | 16 | ```html 17 | 18 | 19 | 34 | ``` 35 | 36 | The above example demonstrates the three core features of Synergy: 37 | 38 | - **Declarative data and event binding:** Synergy 39 | provides a declarative template syntax that allows you to describe 40 | HTML output based on JavaScript state. The syntax is very simple 41 | and easy to learn. 42 | - **Functional state management:** Synergy allows you 43 | to describe changes to state as individual functions that are as 44 | easy to reason about as they are to test. 45 | - **Reactive updates:** Synergy efficiently batches 46 | updates to your HTML whenever your state changes 47 | 48 | ## Getting started 49 | 50 | If you're new to Synergy then the best place to start is the _Learn by Example_ section. It will introduce you to all of the features of Synergy by showing different examples that will help you to understand and learn quickly. 51 | -------------------------------------------------------------------------------- /website/pages/my-counter.md: -------------------------------------------------------------------------------- 1 | 2 | Learn by example - My Counter | Synergy JS 3 | 4 | 5 | # my-counter 6 | 7 | 8 | 9 | A key feature of any user interface is the ability to update its state in response to different events. In Synergy, this process is defined by the object returned from your custom elements factory function. 10 | 11 | Lets take another look at the `my-counter` example from the home page: 12 | 13 | ```js 14 | const factory = () => ({ 15 | state: { count: 0 }, 16 | update: { 17 | increment: ({ count }) => ({ 18 | count: count + 1, 19 | }), 20 | }) 21 | ``` 22 | 23 | In the example above, the returned object includes both the `state` and `update` properties. 24 | 25 | ## State 26 | 27 | `State` is an object that provides the _initial_ data for your custom element. Any properties defined here can be used directly in the template via text or attribute bindings, which is exactly how the value of `count` is included inside the template using text interpolation: 28 | 29 | ```html 30 | 31 | ``` 32 | 33 | ## Update 34 | 35 | `Update` is a dictionary of named state update functions that can be referenced directly in the template as event handlers, as per the `:onclick="increment"` binding in the example above. 36 | 37 | Each state update function takes the _current_ state as its first argument and returns the _next_ state for the custom element. 38 | -------------------------------------------------------------------------------- /website/pages/repeated-blocks.md: -------------------------------------------------------------------------------- 1 | 2 | Repeated Blocks | Synergy JS 3 | 4 | 5 | # Repeated Blocks 6 | 7 | Repeated blocks work with both Arrays and Objects. 8 | 9 | Repeat a block of HTML for each item in a collection using a the `:each` attribute. 10 | 11 | State: 12 | 13 | ```js 14 | { 15 | names: ["kate", "kevin", "randall"] 16 | } 17 | ``` 18 | 19 | Template: 20 | 21 | ```html 22 |
    23 |
  • Hello {{ name }}
  • 24 |
25 | ``` 26 | 27 | ## Iteration Keys 28 | 29 | You can use parentheses to access the key as well as the value. 30 | 31 | Template: 32 | 33 | ```html 34 |
    35 |
  • todo {{ index }} of {{ todos.length }}
  • 36 |
37 | ``` 38 | 39 | ## Implicit scope 40 | 41 | Property access via the identifier is optional, you can also access directly like so... 42 | 43 | State: 44 | 45 | ```js 46 | { 47 | bars: [ 48 | { 49 | x: 0, 50 | y: 0, 51 | width: 32, 52 | height: 16, 53 | fill: "hsl(100, 50%, 50%)", 54 | }, 55 | { 56 | x: 0, 57 | y: 16, 58 | width: 64, 59 | height: 16, 60 | fill: "hsl(200, 50%, 50%)", 61 | }, 62 | //... 63 | ] 64 | } 65 | ``` 66 | 67 | Template: 68 | 69 | ```html 70 | 71 | 72 | 73 | ``` 74 | 75 | ...when accessing properties in this way, Synergy will first check to see if the property is defined on the current item within the iteration, and will otherwise try the same property against the viewmodel itself. 76 | 77 | ## Multiple top-level nodes 78 | 79 | If you need more than one top-level element then you can wrap your repeated block in a template like so: 80 | 81 | State: 82 | 83 | ```js 84 | { 85 | cryptids: [{ 86 | title: "Beast of Bodmin", 87 | description: "A large feline inhabiting Bodmin Moor." 88 | }, 89 | { 90 | title: "Morgawr", 91 | description: "A sea serpent." 92 | } 93 | { 94 | title: "Owlman", 95 | description: "A giant owl-like creature." 96 | } 97 | ] 98 | } 99 | ``` 100 | 101 | Template: 102 | 103 | ```html 104 |
105 | 109 |
110 | ``` 111 | 112 | ...or in the case of SVG you can use `` instead of `