├── .github
└── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── .gitignore
├── .npmignore
├── LICENSE
├── MosaicLogo.png
├── README.md
├── examples
├── count-label.js
├── example-page-1.js
├── example-page-2.js
├── example-page-3.js
├── home-page.js
├── index.css
├── index.html
├── index.js
├── mixins.js
├── portfolio-label.js
├── portfolio.js
├── round-button.js
├── router-card.js
└── shadow-example.js
├── package.json
├── src
├── index.ts
├── mad.ts
├── memory.ts
├── observable.ts
├── options.ts
├── parser.ts
├── portfolio.ts
├── router.ts
├── templating.ts
└── util.ts
└── tsconfig.json
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .cache/
3 | dist/
4 | dist-examples/
5 | ts-build/
6 | package-lock.json
7 | .DS_Store
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .cache/
3 | example-dist/
4 | ts-build/
5 | package-lock.json
6 | example/
7 | .DS_Store
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Adeola Uthman
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 |
--------------------------------------------------------------------------------
/MosaicLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Authman2/Mosaic/0849c3f848762af694dc973418e3a679ee204bf7/MosaicLogo.png
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | # Mosaic
9 | Mosaic is a declarative front-end JavaScript library for building user interfaces!
10 |
11 | 💠 **(Web) Component-Based**: Mosaic uses the Custom Elements API to create web components that can keep track of their own data, actions, and more, and can be included in other components to create front-end web applications.
12 |
13 | ⚡️ **Observable Data**: Mosaic uses Observables to keep track of changes to a component's data. This means
14 | that there is no need to call "setState" or anything like that to update a component - instead just change the data directly.
15 |
16 | 🧠 **Smart DOM**: Updates in Mosaic work by remembering which nodes are dynamic (i.e. subject to change) and traveling directly to those nodes to make changes, rather than traversing the tree again.
17 |
18 | 🔀 **Built-in Router**: Comes with a basic, client-side router which allows Mosaic components to be used as separate pages.
19 |
20 | 🌐 **State Manager**: Comes with a built-in global state manager called *Portfolio*.
21 |
22 | 👌 **Lightweight**: The minified Mosaic library comes in at a size of 28KB.
23 |
24 | 🔖 **Tagged Template Literals**: Views are written using tagged template literals, which means there is no need for a compiler:
25 | ```javascript
26 | const name = "Mosaic";
27 | html`Welcome to ${name}! `
28 | ```
29 |
30 | ## Demo
31 | Here is an example of a simple Mosaic application. All you need is an index.html file and an index.js file.
32 | For a more detailed example, run the project inside the "example" folder.
33 |
34 | **index.html**:
35 | ```html
36 |
37 |
38 | My Mosaic App
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | ```
47 | **index.js**:
48 | ```js
49 | // Import Mosaic
50 | import Mosaic, { html } from 'mosaic-framework';
51 |
52 | // Create components
53 | Mosaic({
54 | name: 'my-label',
55 | data: {
56 | text: ''
57 | },
58 | view: self => {
59 | return html`
60 | ${ self.data.text }
61 | This is a custom label component!
62 | ${self.descendants}
63 | `;
64 | }
65 | });
66 | const app = Mosaic({
67 | name: 'my-app',
68 | element: 'root',
69 | data: {
70 | title: "Mosaic App"
71 | },
72 | sayHello: function() {
73 | console.log("Hello World!!");
74 | console.log("This component is ", this);
75 | },
76 | view: function() {
77 | return html`
78 | This is a ${this.data.title}!
79 | Click below to print a message!
80 | Click Me!
81 |
82 |
83 | Awesome, right?
84 |
85 | `;
86 | }
87 | });
88 |
89 | // Paint the Mosaic onto the page.
90 | app.paint();
91 | ```
92 |
93 | ## Installation
94 | The easiest way to use Mosaic is to first install the npm package by using:
95 | ```shell
96 | npm install --save mosaic-framework
97 | ```
98 | ```shell
99 | yarn add mosaic-framework --save
100 | ```
101 | or with a script tag.
102 | ```html
103 |
104 | ```
105 | **(Optional)** For fast builds and hot reloading, install the build tool "Parcel." This is not required, though, as Mosaic uses built-in JavaScript features. This means that no build tool is required, but any may be used if it helps the overall project structure.
106 | ```shell
107 | npm install --save-dev parcel-bundler
108 | ```
109 | Now you are ready to use Mosaic!
110 |
111 | # Author
112 | - Year: 2019
113 | - Programmer: Adeola Uthman
114 | - Languages/Tools: JavaScript, TypeScript, Parcel
115 |
--------------------------------------------------------------------------------
/examples/count-label.js:
--------------------------------------------------------------------------------
1 | import Mosaic, { html } from '../src/index';
2 |
3 | export default Mosaic({
4 | name: 'count-label',
5 | data: { count: 0 },
6 | created() {
7 | this.timer = setInterval(() => {
8 | this.data.count = Math.floor(Math.random() * 1000);
9 | }, 1000);
10 | },
11 | willDestroy() {
12 | if(this.timer) clearInterval(this.timer);
13 | },
14 | view() {
15 | return html`Count: ${this.data.count} `
16 | }
17 | })
--------------------------------------------------------------------------------
/examples/example-page-1.js:
--------------------------------------------------------------------------------
1 | import Mosaic, { html } from '../src/index';
2 |
3 | import './router-card';
4 | import './mixins';
5 | import './shadow-example';
6 |
7 |
8 | export default Mosaic({
9 | name: 'example-page-1',
10 | created() {
11 | window.scrollTo({ top: 0 });
12 | },
13 | view() {
14 | return html`
15 | More Examples
16 |
17 |
18 |
19 |
20 | Router Reference : Every page that uses the Mosaic router will automatically
21 | send the router reference down through the component chain so that every nested
22 | component has access to it. This allows an infinitely nested component to move over
23 | to another page without having to pass a function up through the component tree.
24 |
25 |
26 |
27 |
28 |
29 |
30 | Mixins : Mosaic also supports mixins, which are used to group similar component
31 | properties into one object that can be reused by other components. This means that you
32 | can write, for example, a mixin for a generic button component, then create more specific
33 | buttons based on that mixin. Below you will find two components that use the same mixin
34 | to update the count property. Note, however, that each count property in "data" is
35 | independent for each component.
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | Shadow DOM : Back to building design systems, let's talk about the Shadow DOM.
44 | This allows us to create components whose styles are not affected by the CSS from the
45 | rest of your app. This makes building independent components for something like a
46 | design system very easy and convenient. Just specify the "useShadow" property when
47 | creating a component!
48 |
49 |
50 |
51 | `
52 | }
53 | })
--------------------------------------------------------------------------------
/examples/example-page-2.js:
--------------------------------------------------------------------------------
1 | import Mosaic, { html } from '../src/index';
2 |
3 | let code = 97;
4 | let randomKey = () => Math.random().toString(36).slice(2);
5 | export default Mosaic({
6 | name: 'example-page-2',
7 | data: {
8 | letters: []
9 | },
10 | addLetter() {
11 | let str = String.fromCharCode(code);
12 |
13 | // Addition.
14 | this.data.letters.push({
15 | letter: str,
16 | key: randomKey()
17 | });
18 | code += 1;
19 | },
20 | removeSecondAndThird() {
21 | // Deletion.
22 | this.data.letters = this.data.letters.filter((_, idx) => idx !== 1 && idx !== 2);
23 | },
24 | modifySecond() {
25 | // Modification.
26 | this.data.letters[1] = {
27 | letter: 'x',
28 | key: this.data.letters[1].key,
29 | }
30 | },
31 | view() {
32 | return html`
33 | More Examples (cont.)
34 |
35 |
36 |
37 |
38 | Efficient Rendering : Mosaic uses keyed arrays to make efficient
39 | updates to lists. The example below uses letters and indices as keys to
40 | make fast updates whenever there is an addition, removal, or modification.
41 |
42 |
43 | Push Letter
44 |
45 |
46 | Remove items 2 and 3
47 |
48 |
49 | Change item 2
50 |
51 |
52 |
53 |
54 | ${Mosaic.list(this.data.letters, obj => obj.key, (obj, index) => {
55 | return html`
56 |
${index + 1}.) ${obj.letter}
57 |
`
58 | })}
59 |
60 |
61 |
62 | `
63 | }
64 | })
--------------------------------------------------------------------------------
/examples/example-page-3.js:
--------------------------------------------------------------------------------
1 | import Mosaic, { html } from '../src/index';
2 |
3 | Mosaic({
4 | name: 'temp-comp',
5 | data: {
6 | title: ''
7 | },
8 | view: function() {
9 | return html`
10 | ${this.data.title}
11 |
12 |
13 | `
14 | }
15 | });
16 |
17 | const sheet = new CSSStyleSheet();
18 | const color = 'lightcoral;'
19 | const ss = `
20 | h1 {
21 | color: ${color};
22 | font-style: italic;
23 | }
24 | button {
25 | border: none;
26 | outline: none;
27 | padding: 20px;
28 | cursor: pointer;
29 | transition-duration: 0.15s;
30 | background-color: mediumseagreen;
31 | }
32 | button:hover {
33 | background-color: seagreen;
34 | }
35 | `
36 | sheet.replaceSync(ss);
37 | Mosaic({
38 | name: 'test-stylesheet',
39 | useShadow: true,
40 | stylesheets: [sheet],
41 | view: function() {
42 | return html`
43 | Here is an example using a constructable stylesheet!
44 |
45 | Click Me!
46 |
47 | `
48 | }
49 | });
50 |
51 | export default Mosaic({
52 | name: 'example-page-3',
53 | data: {
54 | count: 0
55 | },
56 | created: function() {
57 | setInterval(() => {
58 | this.data.count += 1;
59 | }, 1000);
60 | },
61 | view: function() {
62 | return html`
63 | More Examples (cont.)
64 |
65 |
66 |
67 |
75 | This is an example of conditionally rendering inline
76 | styles using multiple dynamic parts within the same
77 | attribute string.
78 |
79 |
80 |
81 | ${
82 | html`
83 |
84 | ${'some text goes here: ' + this.data.count}
85 |
86 | `
87 | }
88 |
89 |
90 | `
91 | }
92 | })
--------------------------------------------------------------------------------
/examples/home-page.js:
--------------------------------------------------------------------------------
1 | import Mosaic, { html } from '../src/index';
2 | import Logo from '../MosaicLogo.png';
3 |
4 | import portfolio from './portfolio';
5 |
6 | export default Mosaic({
7 | element: 'root',
8 | name: 'home-page',
9 | data: {
10 | num: 0
11 | },
12 | created() {
13 | this.timer = setInterval(() => {
14 | this.data.num = Math.floor(Math.random() * 1000);
15 | }, 1000);
16 | },
17 | willDestroy() {
18 | clearInterval(this.timer);
19 | },
20 | view: self => html`
21 |
22 | Welcome to Mosaic!
23 | A front-end JavaScript library for building declarative UIs with web components!
24 |
25 |
26 |
27 |
28 | Web Components : Mosaic components are really just web components.
29 | Even simpler, they are just HTML elements, which means they work really well
30 | with your app regardless of the other frameworks/libraries you are using.
31 | Check out this example of a label that shows a random number every second.
32 | They are all the same component, but each instance acts independently.
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | Design Systems : Mosaic is also a great library for implementing design
42 | systems. Since Mosaics are basically just an extension of web components, it
43 | is extremely easy to include them in any web project. Complex components can
44 | be created once then included in your view to create dynamic apps. These
45 | components will handle updating themselves efficiently when a data change is
46 | detected. Check out these examples of button components below! Each one is an
47 | instance of the same component, but with different data injected into it to
48 | give it a different look and feel.
49 |
50 |
51 |
52 | Primary
53 |
54 |
55 |
56 | Success
57 |
58 |
59 |
60 | Danger
61 |
62 |
63 |
64 | Warning
65 |
66 |
67 |
68 | Neutral
69 |
70 |
71 |
72 |
73 |
74 | Portfolio : "Portfolio" is the built-in, global state manager for
75 | Mosaic apps. All you have to do is specify a reference to a Portfolio
76 | object when initializing your component. After that, any instance of that
77 | component will be updated whenever a change to the global state occurs.
78 | This works through the use of "dependencies" which get added and removed
79 | throughout the lifecycle of your app. Each label here is using the portfolio,
80 | and when we update the global state each component gets updated. It is also
81 | important to note that components do not need to subscribe to changes in the
82 | portfolio in order to make changes to the global state. Click the "+" and "-"
83 | buttons to see the portfolio get updated!
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | +
92 |
93 |
94 | -
95 |
96 |
97 |
98 |
99 |
100 | Router : Lastly, Mosaic is also great for creating SPAs! It comes
101 | with a built-in, client-side router that lets you use components as
102 | pages. Hit the buttons below to check out the different pages
103 | of this example app!
104 |
105 |
106 | Example Page 1
107 |
108 |
109 | Example Page 2
110 |
111 |
112 | Example Page 3
113 |
114 |
115 | `
116 | });
--------------------------------------------------------------------------------
/examples/index.css:
--------------------------------------------------------------------------------
1 | home-page {
2 | position: absolute;
3 | top: 0px;
4 | left: 0px;
5 | right: 0px;
6 | bottom: 0px;
7 | padding: 20px;
8 | color: #4341AE;
9 | margin-left: 10%;
10 | margin-right: 10%;
11 | padding-top: 50px;
12 | text-align: center;
13 | letter-spacing: 1.2px;
14 | background-color: white;
15 | font-family: Avenir, sans-serif;
16 | }
17 |
18 | img {
19 | width: 80px;
20 | height: 80px;
21 | }
22 |
23 | p {
24 | font-size: 18px;
25 | }
26 | h3 {
27 | font-weight: 400;
28 | color: rgb(114, 114, 114);
29 | }
30 |
31 | section {
32 | text-align: center;
33 | margin-bottom: 100px;
34 | }
35 | section h3 {
36 | text-align: left;
37 | }
38 |
39 | count-label {
40 | width: 150px;
41 | padding: 10px;
42 | font-size: 18px;
43 | text-align: center;
44 | margin-right: 25px;
45 | padding-bottom: 0px;
46 | margin-bottom: 15px;
47 | display: inline-block;
48 | color: mediumseagreen;
49 | border-bottom: 4px solid mediumseagreen;
50 | }
51 |
52 | round-button {
53 | position: relative;
54 | margin: 50px;
55 | border: none;
56 | padding: 15px;
57 | outline: none;
58 | display: block;
59 | color: white;
60 | font-size: 18px;
61 | cursor: pointer;
62 | background: none;
63 | max-width: 200px;
64 | user-select: none;
65 | margin-left: auto;
66 | margin-right: auto;
67 | border-radius: 5px;
68 | padding-right: 20px;
69 | text-decoration: none;
70 | transition-duration: 0.15s;
71 | box-shadow: 2px 2px 20px 4px rgba(0,0,0,0.15);
72 | }
73 | round-button:hover {
74 | transform: scale(1.05);
75 | box-shadow: inset 0px 0px 100px 100px rgba(255, 255, 255, 0.4);
76 | }
77 | round-button:active {
78 | transform: scale(0.95);
79 | }
80 | round-button * {
81 | user-select: none;
82 | }
83 |
84 | section:nth-of-type(3) round-button {
85 | padding: 0px;
86 | }
87 |
88 | ion-icon {
89 | position: relative;
90 | top: 3px;
91 | }
92 |
93 | portfolio-label {
94 | margin-right: 20px;
95 | display: inline-block;
96 | }
97 |
98 | example-page-1, example-page-2, example-page-3 {
99 | color: #4341AE;
100 | font-family: Avenir;
101 | }
102 | example-page-1 > h1, example-page-2 > h1, example-page-3 > h1 {
103 | margin-left: 5%;
104 | margin-right: 5%;
105 | }
106 | example-page-1 > section, example-page-2 > section, example-page-3 > section {
107 | margin-left: 5%;
108 | margin-right: 5%;
109 | }
110 | router-card > div {
111 | position: relative;
112 | margin: 50px;
113 | padding: 20px;
114 | text-align: left;
115 | font-family: Avenir;
116 | border-radius: 10px;
117 | background-color: white;
118 | color: rgb(114, 114, 114);
119 | box-shadow: 2px 2px 20px 4px rgba(0,0,0,0.15);
120 | }
121 | router-card > * {
122 | position: relative;
123 | }
124 | router-card round-button {
125 | width: 100px;
126 | text-align: center;
127 | height: fit-content;
128 | }
--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Welcome to Mosaic
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/examples/index.js:
--------------------------------------------------------------------------------
1 | import Mosaic, { Router } from '../src/index';
2 |
3 | import Home from './home-page';
4 | import ExamplePage1 from './example-page-1';
5 | import ExamplePage2 from './example-page-2';
6 | import ExamplePage3 from './example-page-3';
7 |
8 | import './count-label';
9 | import './round-button';
10 | import './portfolio-label';
11 |
12 | import './index.css';
13 |
14 | const router = new Router('root');
15 | router.addRoute('/', Home);
16 | router.addRoute('/example-page-1', ExamplePage1);
17 | router.addRoute('/example-page-2', ExamplePage2);
18 | router.addRoute('/example-page-3', ExamplePage3);
19 | router.paint();
--------------------------------------------------------------------------------
/examples/mixins.js:
--------------------------------------------------------------------------------
1 | import Mosaic, { html } from '../src/index';
2 |
3 | const MyMixin = {
4 | data: {
5 | count: 0
6 | },
7 | created: function() {
8 | console.log("Created this from a mixin!");
9 |
10 | this.timer = setInterval(() => {
11 | this.data.count = Math.floor(Math.random() * 1000);
12 | }, 1000);
13 | },
14 | willDestroy() {
15 | clearInterval(this.timer);
16 | }
17 | }
18 |
19 | export const m1 = Mosaic({
20 | mixins: [MyMixin],
21 | name: 'mixin-one',
22 | data: {
23 | library: "Mosaic"
24 | },
25 | created() {
26 | console.log(`This lifecycle function will be called after the mixin's lifecycle function.`, this.data);
27 | },
28 | view() {
29 | return html`
30 |
31 | Mixin One
32 |
33 | Count: ${this.data.count}
34 |
35 | `
36 | }
37 | });
38 | export const m2 = Mosaic({
39 | mixins: [MyMixin],
40 | name: 'mixin-two',
41 | view() {
42 | return html`
43 |
44 | Mixin Two
45 |
46 | Count: ${this.data.count}
47 |
48 | `
49 | }
50 | });
--------------------------------------------------------------------------------
/examples/portfolio-label.js:
--------------------------------------------------------------------------------
1 | import Mosaic, { html } from '../src/index';
2 |
3 | import portfolio from './portfolio';
4 |
5 |
6 | export default Mosaic({
7 | name: 'portfolio-label',
8 | portfolio,
9 | view() {
10 | const count = portfolio.get('count');
11 | return html`Count: ${count} `
12 | }
13 | })
--------------------------------------------------------------------------------
/examples/portfolio.js:
--------------------------------------------------------------------------------
1 | import { Portfolio } from '../src/index';
2 |
3 | export default new Portfolio({
4 | count: 0
5 | }, (event, data, other) => {
6 | if(event === 'count-up') data.count += 1;
7 | else if(event === 'count-down') data.count -= 1;
8 | })
--------------------------------------------------------------------------------
/examples/round-button.js:
--------------------------------------------------------------------------------
1 | import Mosaic, { html } from '../src/index';
2 |
3 | export default Mosaic({
4 | name: 'round-button',
5 | data: {
6 | click: () => {}
7 | },
8 | received({ type }) {
9 | if(!type) return;
10 |
11 | // Here, we are using the "type" attribute on round-buttons to determine
12 | // what color the button should be when the attribute is received. This
13 | // example of changing the background color of the component does not
14 | // require a repaint, which keeps such updates very performant.
15 | switch(type) {
16 | case 'primary': this.style.backgroundColor = 'cornflowerblue'; break;
17 | case 'success': this.style.backgroundColor = 'mediumseagreen'; break;
18 | case 'danger': this.style.backgroundColor = 'crimson'; break;
19 | case 'warning': this.style.backgroundColor = 'goldenrod'; break;
20 | case 'neutral': this.style.backgroundColor = 'gray'; break;
21 | default: this.style.backgroundColor = 'mediumseagreen'; break;
22 | }
23 | },
24 | pointerUp() {
25 | const { click } = this.data;
26 | if(click) click();
27 | },
28 | created() {
29 | this.addEventListener('click', this.pointerUp);
30 | },
31 | willDestroy() {
32 | this.removeEventListener('click', this.pointerUp);
33 | },
34 | view() {
35 | return html`${this.descendants}`
36 | }
37 | })
--------------------------------------------------------------------------------
/examples/router-card.js:
--------------------------------------------------------------------------------
1 | import Mosaic, { html } from '../src/index';
2 |
3 | Mosaic({
4 | name: 'router-card-bottom',
5 | useRouter() {
6 | this.router.send('/example-page-2');
7 | },
8 | view: self => html`
9 |
10 | Use Router
11 |
12 | `
13 | });
14 |
15 | export default Mosaic({
16 | name: 'router-card',
17 | view() {
18 | return html`
19 |
Router Card
20 |
21 | The button below is doubly nested from the main router page.
22 | Mosaic still gives it access to the router, so you can click
23 | on it to move to a new page.
24 |
25 |
26 |
27 |
`
28 | }
29 | });
--------------------------------------------------------------------------------
/examples/shadow-example.js:
--------------------------------------------------------------------------------
1 | import Mosaic, { html } from '../src/index';
2 |
3 | // Styles can be grouped into separate components.
4 | Mosaic({
5 | name: 'shadow-styles',
6 | view: _ => html``
34 | })
35 |
36 | // Export the shadow dom example.
37 | export default Mosaic({
38 | name: 'shadow-example',
39 | data: {
40 | count: 0
41 | },
42 | useShadow: true,
43 | created() {
44 | this.timer = setInterval(() => {
45 | this.data.count = Math.floor(Math.random() * 1000);
46 | }, 1000);
47 | },
48 | willDestroy() {
49 | clearInterval(this.timer);
50 | },
51 | showSomething() {
52 | alert("Hi from the shadow dom!");
53 | },
54 | view() {
55 | return html`
56 |
57 |
58 |
59 |
Shadow DOM
60 | This component is being rendered using the Shadow DOM!
61 | Notice how the styles don't leak out of this component.
62 | Count: ${this.data.count}
63 |
64 | Click to alert a message!
65 | `
66 | }
67 | })
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mosaic-framework",
3 | "version": "0.9.1",
4 | "description": "A front-end JavaScript library for creating user interfaces",
5 | "main": "dist/index.js",
6 | "scripts": {
7 | "test": "parcel -p 3000 examples/index.html --out-dir dist-examples",
8 | "clean": "rm -rf .cache dist dist-examples ts-build",
9 | "move-declarations": "cp ts-build/index.d.ts dist/index.d.ts && cp ts-build/observable.d.ts dist/observable.d.ts && cp ts-build/options.d.ts dist/options.d.ts && cp ts-build/router.d.ts dist/router.d.ts && cp ts-build/portfolio.d.ts dist/portfolio.d.ts",
10 | "build": "tsc && parcel build ts-build/index.js --no-source-maps --out-dir dist --global Mosaic && npm run move-declarations && rm -f dist/index.js.map",
11 | "deploy": "npm publish --access=public",
12 | "bnd": "npm run build && npm run deploy && git checkout dev"
13 | },
14 | "keywords": [
15 | "frontend",
16 | "javascipt",
17 | "ui"
18 | ],
19 | "files": [
20 | "dist/"
21 | ],
22 | "author": "Adeola Uthman",
23 | "license": "MIT",
24 | "repository": {
25 | "url": "https://github.com/authman2/Mosaic"
26 | },
27 | "homepage": "https://mosaicjs.site",
28 | "devDependencies": {
29 | "parcel-bundler": "^1.12.3",
30 | "typescript": "^3.5.3"
31 | },
32 | "dependencies": {},
33 | "types": "dist/index.d.ts"
34 | }
35 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { MosaicComponent, MosaicOptions, ViewFunction, KeyedArray, InjectionPoint } from './options';
2 | import Observable from './observable';
3 | import Router from './router';
4 | import Portfolio from './portfolio';
5 | import { randomKey, nodeMarker, goUpToConfigureRouter, applyMixin, runLifecycle } from './util';
6 | import { getTemplate, _repaint } from './templating';
7 |
8 | export default function Mosaic(options: MosaicOptions): MosaicComponent {
9 | // Configure some basic properties.
10 | const copyOptions = Object.assign({}, options);
11 | const tid: string = randomKey();
12 |
13 | // Error checking.
14 | if(typeof copyOptions.name !== 'string')
15 | throw new Error('Name must be specified and must be a string.');
16 | if((copyOptions as any).descendants)
17 | throw new Error('You cannot directly set the "descendants" property on a component.');
18 |
19 | // Define the custom element.
20 | customElements.define(copyOptions.name, class extends MosaicComponent {
21 | constructor() {
22 | super();
23 |
24 | // Setup initial Mosaic properties.
25 | this.initiallyRendered = false;
26 | this.tid = tid;
27 | this.iid = randomKey();
28 | this.data = new Observable(Object.assign({}, copyOptions.data || {}), old => {
29 | if(this.barrier === true) return;
30 | runLifecycle('willUpdate', this, old);
31 | }, () => {
32 | if(this.barrier === true) return;
33 | this.repaint();
34 | runLifecycle('updated', this);
35 | });
36 |
37 | // Configure all of the properties if they exist.
38 | let _options = Object.keys(copyOptions);
39 | for(let i = 0; i < _options.length; i++) {
40 | let key = _options[i];
41 | if(key === 'element') continue;
42 | else if(key === 'data') continue;
43 | else this[key] = options[key];
44 | }
45 |
46 | // Apply any mixins that are present in the options.
47 | if(copyOptions.mixins) {
48 | for(let i = 0; i < copyOptions.mixins.length; i++) {
49 | this.barrier = true;
50 | const mixin = copyOptions.mixins[i];
51 | applyMixin(this, mixin);
52 | this.barrier = false;
53 | }
54 | }
55 |
56 | // See if you need to attach the shadow dom based on the options.
57 | if(copyOptions.useShadow === true)
58 | this._shadow = this.attachShadow({ mode: 'open' });
59 |
60 | // Adoptable stylesheets.
61 | // TODO: The array of stylesheets should be dynamic, so when you
62 | // add/remove from the array it should trigegr a repaint.
63 | if(copyOptions.stylesheets && this._shadow) {
64 | let sheets: CSSStyleSheet[] = [];
65 | for(let i = 0; i < copyOptions.stylesheets.length; i++) {
66 | const ss = copyOptions.stylesheets[i];
67 | if(ss instanceof CSSStyleSheet)
68 | sheets.push(ss);
69 | else if(typeof ss === 'string') {
70 | const sheet = new CSSStyleSheet();
71 | (sheet as any).replaceSync(ss);
72 | sheets.push(sheet);
73 | }
74 | }
75 | (this._shadow as any).adoptedStyleSheets = sheets;
76 | }
77 | }
78 |
79 | connectedCallback() {
80 | // 1.) Remove any child nodes and save them as to the descendants
81 | // property so that it can optionally be used later on.
82 | if(!this.initiallyRendered) {
83 | if(this.childNodes.length !== 0)
84 | this.descendants.append(...this.childNodes);
85 | }
86 |
87 | // 2.) Add portfolio dependency.
88 | if(this.portfolio) this.portfolio.addDependency(this);
89 |
90 | // 3.) Clear any existing content that was in there before.
91 | if(!this.initiallyRendered) this.innerHTML = '';
92 |
93 | // 4.) Make sure we have the router property.
94 | goUpToConfigureRouter.call(this);
95 |
96 | // 5.) Find the template for this component, clone it, and repaint.
97 | const template = getTemplate(this);
98 | const cloned = document.importNode(template.content, true);
99 | if(!this.initiallyRendered) {
100 | if(this._shadow) this._shadow.appendChild(cloned);
101 | else this.appendChild(cloned);
102 | }
103 | this.repaint();
104 |
105 | // 6.) If there are any attributes present on this element at
106 | // connection time and they are not dynamic (i.e. their value does
107 | // not match the nodeMarker) then you can receive them as data.
108 | if(this.initiallyRendered === false) {
109 | let receivedAttributes = {};
110 | let receivedData = {};
111 | for(let i = 0; i < this.attributes.length; i++) {
112 | const { name, value } = this.attributes[i];
113 | if(value === nodeMarker) continue;
114 |
115 | if(this.data.hasOwnProperty(name)) receivedData[name] = value;
116 | else receivedAttributes[name] = value;
117 | }
118 |
119 | // Send the attributes through lifecycle functions.
120 | if(Object.keys(receivedAttributes).length > 0)
121 | runLifecycle('received', this, receivedAttributes);
122 |
123 | // 7.) Save the new data and repaint.
124 | if(Object.keys(receivedData).length > 0) {
125 | this.barrier = true;
126 | const keys = Object.keys(receivedData);
127 | for(let i = 0; i < keys.length; i++) {
128 | const key = keys[i];
129 | // If the attribute type is a string, but the initial
130 | // value in the component is something else, try to
131 | // parse it as such.
132 | if(typeof receivedData[key] === 'string') {
133 | if(typeof this.data[key] === 'number')
134 | this.data[key] = parseFloat(receivedData[key]);
135 | else if(typeof this.data[key] === 'bigint')
136 | this.data[key] = parseInt(receivedData[key]);
137 | else if(typeof this.data[key] === 'boolean')
138 | this.data[key] = receivedData[key] === 'true' ? true : false;
139 | else if(Array.isArray(this.data[key])) {
140 | const condensed = receivedData[key].replace(/'/gi, '"');
141 | const parsed = JSON.parse(condensed);
142 | this.data[key] = parsed;
143 | } else if(typeof this.data[key] === 'object')
144 | this.data[key] = JSON.parse(receivedData[key]);
145 | else
146 | this.data[key] = receivedData[key];
147 | } else {
148 | this.data[key] = receivedData[key];
149 | }
150 | }
151 | this.barrier = false;
152 | this.repaint();
153 | }
154 | }
155 |
156 | // 8.) If you come here as a OTT from an array, then be sure to
157 | // repaint again. This is because with the way that the keyed
158 | // array patcher is currently set up, it will insert all the
159 | // nodes from a fragment (i.e. not in the DOM yet).
160 | if(this.hasOwnProperty('arrayOTT') && this.view) {
161 | const ott = this['arrayOTT'];
162 | const node = ott.instance;
163 | const mems = ott.memories;
164 | const vals = ott.values;
165 | _repaint(node, mems, [], vals, true);
166 | }
167 |
168 | // 9.) Make sure the component knows that it has been fully rendered
169 | // for the first time. This makes the router work. Then call the
170 | // created lifecycle function.
171 | runLifecycle('created', this);
172 | this.initiallyRendered = true;
173 | }
174 |
175 | disconnectedCallback() {
176 | if(this.portfolio) this.portfolio.removeDependency(this);
177 | runLifecycle('willDestroy', this);
178 | }
179 |
180 | paint(arg?: string|HTMLElement|Object) {
181 | let isElement: boolean = typeof arg === 'string' || arg instanceof HTMLElement;
182 | let look: InjectionPoint = copyOptions.element || (this as any).element;
183 |
184 | // Check if the user is injecting into the base element here.
185 | if(isElement) {
186 | if(typeof arg === 'string') look = document.getElementById(arg);
187 | else if(arg instanceof HTMLElement) look = arg;
188 | }
189 | // Look for an injection of data.
190 | else if(typeof arg === 'object') {
191 | this.barrier = true;
192 | let keys = Object.keys(arg);
193 | for(let i = 0; i < keys.length; i++) {
194 | const key = keys[i];
195 | const val = arg[key];
196 | this.data[key] = val;
197 | }
198 | this.barrier = false;
199 | }
200 |
201 | // Paint into the base element.
202 | let element = typeof look === 'string' ? document.getElementById(look) : look;
203 | if(!element)
204 | throw new Error(`Could not find the base element: ${copyOptions.element}.`);
205 | element.appendChild(this);
206 | }
207 |
208 | repaint() {
209 | const template = getTemplate(this);
210 | const memories = (template as any).memories;
211 |
212 | if(!this.view) return;
213 | const newValues = this.view(this).values;
214 | const repaintNode = this._shadow ? this._shadow : this;
215 | _repaint(repaintNode, memories, this.oldValues, newValues);
216 |
217 | this.oldValues = newValues;
218 | }
219 |
220 | set(data: {}) {
221 | this.barrier = true;
222 | const keys = Object.keys(data);
223 | for(let i = 0; i < keys.length; i++) {
224 | const key = keys[i];
225 | this.data[key] = data[key];
226 | }
227 | this.barrier = false;
228 | this.repaint();
229 | runLifecycle('updated', this);
230 | }
231 |
232 | setAttribute(qualifiedName: string, value: any) {
233 | super.setAttribute(qualifiedName, value);
234 |
235 | // Overload the setAttribute function so that people
236 | // using Mosaic components a DOM nodes can still have
237 | // the "received" lifecycle function called.
238 | let obj = {};
239 | obj[qualifiedName] = value;
240 | runLifecycle('received', this, obj);
241 | }
242 | });
243 |
244 | const component = document.createElement(copyOptions.name);
245 | return component as MosaicComponent;
246 | }
247 |
248 | /** A function for efficiently rendering a list in a component. */
249 | Mosaic.list = function(items: any[], key: Function, map: Function): KeyedArray {
250 | const keys = items.map((itm, index) => key(itm, index));
251 | const mapped = items.map((itm, index) => {
252 | return {
253 | ...map(itm, index),
254 | key: keys[index]
255 | }
256 | });
257 | const stringified = mapped.map(json => JSON.stringify(json));
258 | return { keys, items: mapped, stringified, __isKeyedArray: true };
259 | }
260 |
261 | declare global {
262 | interface Window {
263 | Mosaic: typeof Mosaic;
264 | }
265 | }
266 | const html = (strings, ...values): ViewFunction => ({ strings, values, __isTemplate: true });
267 | window.Mosaic = Mosaic;
268 | export { html, Router, Portfolio };
--------------------------------------------------------------------------------
/src/mad.ts:
--------------------------------------------------------------------------------
1 | import { ViewFunction } from "./options";
2 |
3 | /** An array diffing algorithm that gives back the fewest number of
4 | modifications, additions, and deletions from one array of strings
5 | to another. Based on the implementation by the JSDiff library. */
6 | export default class MAD {
7 |
8 | constructor(private first: ViewFunction[], private second: ViewFunction[]) {
9 | this.first = first;
10 | this.second = second;
11 | }
12 |
13 |
14 | /** The main function to compute and return the fewest changes
15 | * from the old array to the new one. */
16 | diff(finished?: ((value) => any)) {
17 | let oldLength = this.first.length;
18 | let newLength = this.second.length;
19 |
20 | // Calculate the length of the edit script.
21 | let editLength = 1;
22 | let maxEditLength = oldLength + newLength;
23 | let bestPath: any[] = [{
24 | components: [],
25 | newPos: -1,
26 | }];
27 |
28 | // Define an end value function.
29 | const done = (value) => {
30 | if(finished) {
31 | setTimeout(() => finished(value), 0);
32 | return true;
33 | } else {
34 | return value;
35 | }
36 | }
37 |
38 | // When the content start with the same value.
39 | let oldPos = this.extractCommon(bestPath[0], 0);
40 | if(bestPath[0].newPos + 1 >= newLength && oldPos + 1 >= oldLength) {
41 | return done([{
42 | edit: this.second,
43 | count: this.second.length
44 | }]);
45 | }
46 |
47 | // This is where most of the work is done. Finds all permutations
48 | // of the edit script.
49 | const findEditLength = () => {
50 | for(let path = -1 * editLength; path <= editLength; path += 2) {
51 | let base;
52 | let addPath = bestPath[path - 1];
53 | let deletePath = bestPath[path + 1];
54 | let oldPos = (deletePath ? deletePath.newPos : 0) - path;
55 |
56 | // Value will not be used again, so clear it.
57 | if(addPath) bestPath[path - 1] = undefined;
58 |
59 | // Value unchanged, so skip to the next grid point.
60 | let canAdd = addPath && addPath.newPos + 1 < newLength;
61 | let canDelete = deletePath && 0 <= oldPos && oldPos < oldLength;
62 | if(!canAdd && !canDelete) {
63 | bestPath[path] = undefined;
64 | continue;
65 | }
66 |
67 | // Move to the diagonal where the position of the path in
68 | // the new array is at the farthes point from the origin
69 | // and does not go over the bounds of the diff graph.
70 | if(!canAdd || (canDelete && addPath.newPos < deletePath.newPos)) {
71 | base = this.clonePath(deletePath);
72 | this.pushComponent(base.components, undefined, true);
73 | } else {
74 | base = addPath;
75 | base.newPos += 1;
76 | this.pushComponent(base.components, true, undefined);
77 | }
78 |
79 | oldPos = this.extractCommon(base, path);
80 |
81 | // If you reach the end of the arrays then you're done.
82 | if(base.newPos + 1 >= newLength && oldPos + 1 >= oldLength) {
83 | const edits = this.constructEdits(base.components, true);
84 | return done(edits);
85 | }
86 | // Otherwise, this is a potential best path and we need to
87 | // keep going.
88 | else {
89 | bestPath[path] = base;
90 | }
91 | }
92 |
93 | editLength += 1;
94 | }
95 |
96 | // Goes through the edit iterations. Using this kind of function
97 | // makes async work too.
98 | const callback = finished;
99 | if(callback) {
100 | (function exec() {
101 | setTimeout(() => {
102 | if(editLength > maxEditLength) return callback([]);
103 | if(!findEditLength()) exec();
104 | }, 0);
105 | }());
106 | } else {
107 | while(editLength <= maxEditLength) {
108 | let ret = findEditLength();
109 | if(ret) return ret;
110 | }
111 | }
112 | }
113 |
114 | /** Utility functions. */
115 | private extractCommon(basePath, diagonalPath) {
116 | let oldLength = this.first.length;
117 | let newLength = this.second.length;
118 |
119 | let newPos = basePath.newPos;
120 | let oldPos = newPos - diagonalPath;
121 |
122 | let commonCount = 0;
123 |
124 | while(newPos + 1 < newLength
125 | && oldPos + 1 < oldLength
126 | && this.equals(this.second[newPos + 1], this.first[oldPos + 1])) {
127 |
128 | newPos += 1;
129 | oldPos += 1;
130 | commonCount += 1;
131 | }
132 |
133 | if(commonCount) basePath.components.push({ count: commonCount });
134 |
135 | basePath.newPos = newPos;
136 | return oldPos;
137 | }
138 |
139 | private clonePath(path) {
140 | return {
141 | newPos: path.newPos,
142 | components: path.components.slice()
143 | };
144 | }
145 |
146 | private pushComponent(components, added, deleted) {
147 | let last = components[components.length - 1];
148 |
149 | // Make a clone of the component.
150 | if(last && last.added === added && last.deleted === deleted) {
151 | components[components.length - 1] = {
152 | count: last.count + 1,
153 | added,
154 | deleted
155 | };
156 | } else {
157 | components.push({
158 | count: 1,
159 | added,
160 | deleted
161 | });
162 | }
163 | }
164 |
165 | private constructEdits(components, useLongestToken) {
166 | let componentPos = 0;
167 | let componentLength = components.length;
168 | let newPos = 0;
169 | let oldPos = 0;
170 |
171 | for(; componentPos < componentLength; componentPos++) {
172 | let comp = components[componentPos];
173 | if(!comp.deleted) {
174 | if(!comp.added && useLongestToken) {
175 | let edit = this.second.slice(newPos, newPos + comp.count)
176 | .map((value, index) => {
177 | let oldVal = this.first[oldPos + index];
178 | let len = edit ? edit.length : 0;
179 | return this.first.length > len ? oldVal : edit;
180 | });
181 |
182 | comp.edit = edit.slice();
183 | } else {
184 | comp.edit = this.second.slice(newPos, newPos + comp.count);
185 | }
186 | newPos += comp.count;
187 |
188 | // Common Case.
189 | if(!comp.added) oldPos += comp.count;
190 | } else {
191 | comp.edit = this.first.slice(oldPos, oldPos + comp.count);
192 | oldPos += comp.count;
193 |
194 | // Reverse the add and removes so removes happen first.
195 | if(componentPos && components[componentPos - 1].added) {
196 | let temp = components[componentPos - 1];
197 | components[componentPos - 1] = components[componentPos];
198 | components[componentPos] = temp;
199 | }
200 | }
201 | }
202 | return components;
203 | }
204 |
205 | private equals(one: ViewFunction, two: ViewFunction): boolean {
206 | const sameValues = (''+one.values === ''+two.values);
207 | const sameKey =((one as any).key && (two as any).key && (one as any).key === (two as any).key);
208 |
209 | // Deletions should not necessarily go through each item again.
210 | if(this.first.length > this.second.length) return sameValues || sameKey;
211 | else return sameValues;
212 | }
213 | }
--------------------------------------------------------------------------------
/src/memory.ts:
--------------------------------------------------------------------------------
1 | import { nodeMarker, insertAfter, isBooleanAttribute, objectFromArray, runLifecycle } from './util';
2 | import { MemoryOptions, MosaicComponent } from './options';
3 | import { OTT, _repaint } from './templating';
4 | import MAD from './mad';
5 |
6 | /** Represents a piece of dynamic content in the markup. */
7 | export default class Memory {
8 | constructor(public config: MemoryOptions) {}
9 |
10 | /** Batches an update together with other component updates so that
11 | * later on they can all perform a single repaint. */
12 | batch(component: MosaicComponent, batchName: string, batchValue: any) {
13 | // Add the (name, value) pair as a batch operation to be carried out
14 | // at the end of the parent component's repaint cycle.
15 | const isData = component.data.hasOwnProperty(batchName);
16 | const batchFunc = isData ? '_batchData' : '_batchAttribute';
17 | component[batchFunc](batchName, batchValue);
18 |
19 | // Check if the number of batches matches up to the number of
20 | // attributes present on the HTML element tag. Checking this number
21 | // is fine because you don't split up attributes from data until
22 | // the end of this step.
23 | const bts = component._getBatches();
24 |
25 | const totalLength = this.config.trackedAttributeCount || 0;
26 | const attrsLength = bts.attributes.length;
27 | const dataLength = bts.data.length;
28 | if(attrsLength + dataLength >= totalLength) {
29 | // Go through the immediately nested nodes and update them with the
30 | // new data, while also sending over the parsed attributes. Then
31 | // clear the batch when you are done.
32 | const justData = objectFromArray(bts.data);
33 | const justAttrs = objectFromArray(bts.attributes);
34 |
35 | // Set the data on the component then repaint it.
36 | if(bts.data.length > 0) {
37 | component.barrier = true;
38 | let keys = Object.keys(justData);
39 | for(let i = 0; i < keys.length; i++) {
40 | const key = keys[i];
41 | const val = justData[key];
42 | component.data[key] = val;
43 | }
44 | component.barrier = false;
45 | }
46 |
47 | // Make the component receive the HTML attributes.
48 | if(bts.attributes.length > 0)
49 | runLifecycle('received', component, justAttrs);
50 |
51 | // Repaint.
52 | if(bts.data.length > 0)
53 | component.repaint();
54 |
55 | // When you are done performing the batcehd updates, clear
56 | // the batch so you can do it again for the next update.
57 | component._resetBatches();
58 | }
59 | }
60 |
61 | /** Applies the changes to the appropriate DOM nodes when data changes. */
62 | commit(element: ChildNode|Element|ShadowRoot, pointer: ChildNode|Element, oldValue: any, newValue: any) {
63 | // console.log(element, pointer, oldValue, newValue, this);
64 | switch(this.config.type) {
65 | case 'node':
66 | this.commitNode(element, pointer, oldValue, newValue);
67 | break;
68 | case 'attribute':
69 | if(!this.config.attribute) break;
70 | const { name } = this.config.attribute;
71 | const func = this.config.isEvent ?
72 | this.commitEvent.bind(this) : this.commitAttribute.bind(this);
73 | func(element, pointer, name, oldValue, newValue);
74 | break;
75 | }
76 | }
77 |
78 | /** Applies changes to memories of type "node." */
79 | commitNode(element: HTMLElement|ChildNode|ShadowRoot, pointer: HTMLElement|ChildNode, oldValue: any, newValue: any) {
80 | // If you come across a node inside of a Mosaic component, then do not
81 | // actually add it to the DOM. Instead, let it be rendered by the
82 | // constructor and set into the "descendants" property so the component
83 | // itself can decide whether or not to use it as a descendants property.
84 | if(this.config.isComponentType === true && pointer instanceof MosaicComponent)
85 | return;
86 |
87 | if(Array.isArray(newValue)) {
88 | let items = newValue;
89 | let frag = document.createDocumentFragment();
90 | for(let i = 0; i < items.length; i++) {
91 | let item = items[i];
92 | let ott = OTT(item);
93 | let node = ott.instance;
94 | _repaint(node, ott.memories, [], ott.values, true);
95 | frag.append(node);
96 | }
97 | let addition = document.createElement('div');
98 | addition.appendChild(frag);
99 | pointer.replaceWith(addition);
100 | }
101 | if(typeof newValue === 'object' && newValue.__isTemplate) {
102 | const ott = OTT(newValue);
103 | const inst = ott.instance;
104 | pointer.replaceWith(inst);
105 | _repaint(inst, ott.memories, [], ott.values, true);
106 | }
107 | if(typeof newValue === 'object' && newValue.__isKeyedArray) {
108 | this.commitArray(element, pointer, oldValue, newValue);
109 | }
110 | else if(typeof newValue === 'function') {
111 | const called = newValue();
112 | const ott = OTT(called);
113 | const inst = ott.instance;
114 | pointer.replaceWith(inst);
115 | _repaint(inst, ott.memories, [], ott.values, true);
116 | }
117 | else {
118 | pointer.replaceWith(newValue);
119 | }
120 | }
121 |
122 | /** Applies attributee changes. */
123 | commitAttribute(element: HTMLElement|ChildNode|ShadowRoot, pointer: HTMLElement|ChildNode,
124 | name: string, oldValue: any, newValue: any) {
125 |
126 | const attribute = (pointer as Element).attributes.getNamedItem(name);
127 |
128 | // If you come across a boolean attribute that should be true, then add
129 | // it as an attribute.
130 | if(!attribute) {
131 | if(isBooleanAttribute(name) && newValue === true)
132 | (pointer as Element).setAttribute(name, 'true');
133 | return;
134 | }
135 |
136 | // Replace the first instance of the marker with the new value.
137 | // Then be sure to set the attribute value to this newly replaced
138 | // string so that on the next dynamic attribute it goes to the next
139 | // position to replace (notice how the new value gets converted to a
140 | // string first. This ensures attribute safety).
141 | const newAttributeValue = attribute.value
142 | .replace(nodeMarker, ''+newValue)
143 | .replace(oldValue, ''+newValue);
144 | const setValue = newAttributeValue.length > 0 ? newAttributeValue : newValue;
145 | (pointer as Element).setAttribute(name, setValue);
146 |
147 | // Add or remove boolean attributes. Make sure to also the tracked
148 | // attribute count so that you know how many attributes to check
149 | // for at any given time of an update cycle.
150 | if(isBooleanAttribute(name)) {
151 | if(newValue === true) {
152 | (pointer as Element).setAttribute(name, 'true');
153 | if(this.config.trackedAttributeCount)
154 | this.config.trackedAttributeCount += 1;
155 | } else {
156 | (pointer as Element).removeAttribute(name);
157 | if(this.config.trackedAttributeCount)
158 | this.config.trackedAttributeCount -= 1;
159 | }
160 | }
161 |
162 | // Remove the function attribute so it's not cluttered. The event
163 | // listener will still exist on the element, though.
164 | if(typeof newValue === 'function') {
165 | (pointer as Element).removeAttribute(name);
166 |
167 | // Since you're removing the function as an attribute, be sure
168 | // to update the tracked attribute count so we're not always
169 | // looking for it during a batched update.
170 | if(this.config.trackedAttributeCount)
171 | this.config.trackedAttributeCount -= 1;
172 | }
173 |
174 | // Batch the pointer element and the attribute [name, value] pair together so that
175 | // it can be update all at once at the end of the repaint cycle.
176 | if(this.config.isComponentType === true && pointer instanceof MosaicComponent)
177 | this.batch(pointer, name, newValue);
178 | }
179 |
180 | /** Applies event changes such as adding/removing listeners. */
181 | commitEvent(element: HTMLElement|ChildNode|ShadowRoot, pointer: HTMLElement|ChildNode,
182 | name: string, oldValue: any, newValue: any) {
183 |
184 | const events = (pointer as any).eventHandlers || {};
185 | const shortName = name.substring(2);
186 |
187 | // If there's no new value, then try to remove the event listener.
188 | if(!newValue && events[name])
189 | (pointer as Element).removeEventListener(shortName, events[name]);
190 |
191 | // While there is a new value, add it to an "eventHandlers" property
192 | // so that you can always keep track of the element's functions.
193 | else if(newValue) {
194 | events[name] = newValue.bind(element);
195 | (pointer as any).eventHandlers = events;
196 | (pointer as Element).addEventListener(
197 | shortName,
198 | (pointer as any).eventHandlers[name]
199 | );
200 | }
201 |
202 | // Remove the attribute from the DOM tree to avoid clutter.
203 | if((pointer as Element).hasAttribute(name)) {
204 | (pointer as Element).removeAttribute(name);
205 | if(this.config.trackedAttributeCount)
206 | this.config.trackedAttributeCount -= 1;
207 | }
208 |
209 | // Batch the pointer element and the attribute [name, value] pair together so that
210 | // it can be update all at once at the end of the repaint cycle.
211 | if(this.config.isComponentType === true && pointer instanceof MosaicComponent)
212 | this.batch(pointer, name, newValue);
213 | }
214 |
215 | /** Helper function for applying changes to arrays. */
216 | commitArray(element: HTMLElement|ChildNode|ShadowRoot, pointer: HTMLElement|ChildNode,
217 | oldValue: any, newValue: any) {
218 | const oldItems = oldValue && typeof oldValue === 'object' && oldValue.__isKeyedArray
219 | ? oldValue.items : [];
220 | const newItems = newValue && typeof newValue === 'object' && newValue.__isKeyedArray
221 | ? newValue.items : [];
222 |
223 | // Heuristics: For repaints that contain only additions or deletions
224 | // don't bother going through the MAD algorithm. Instead, just perform
225 | // the same operation on everything.
226 | // All Additions:
227 | if(oldItems.length === 0 && newItems.length > 0) {
228 | let frag = document.createDocumentFragment();
229 | for(let i = 0; i < newItems.length; i++) {
230 | const item = newItems[i];
231 | const ott = OTT(item, item.key);
232 | const node = ott.instance;
233 | node.arrayOTT = ott;
234 |
235 | // Only repaint here if it is NOT a Mosaic component.
236 | if(!(node instanceof MosaicComponent))
237 | _repaint(node, ott.memories, [], ott.values, true);
238 |
239 | // Add each item to a document fragment, then set all of it
240 | // at the end for improved DOM performance.
241 | frag.appendChild(node);
242 | }
243 | insertAfter(frag, pointer);
244 | return;
245 | }
246 | // All Deletions:
247 | if(oldItems.length > 0 && newItems.length === 0) {
248 | for(let i = 0; i < oldItems.length; i++) {
249 | // Find the node and remove it from the DOM.
250 | const key = oldItems[i].key;
251 | const found = document.querySelector(`[key='${key}']`);
252 | if(found) found.remove();
253 | }
254 | return;
255 | }
256 |
257 | // Use "MAD" to find the differences in the arrays.
258 | const mad = new MAD(oldItems, newItems);
259 | const diffs = mad.diff();
260 |
261 | // Keep track of the operation index starting from the beginning of
262 | // the array. Loop through until the end of the list.
263 | let opIndex = 0;
264 | for(let i = 0; i < diffs.length; i++) {
265 | const { added, deleted, count, edit } = diffs[i];
266 |
267 | // Modification.
268 | if(deleted && (i + 1) < diffs.length && diffs[i+1].added && count === diffs[i+1].count) {
269 | // There could be more than one modification at a time, so run
270 | // through each one and replace the node at the old index with
271 | // a rendered OTT at the same index.
272 | for(let j = 0; j < edit.length; j++) {
273 | const modItem = edit[j];
274 | const modRef = document.querySelector(`[key="${modItem.key}"]`);
275 |
276 | const newItem = diffs[i+1].edit[j];
277 | const ott = OTT(newItem, newItem.key);
278 | const node = ott.instance;
279 | node.arrayOTT = ott;
280 |
281 | // Only repaint here if it is NOT a Mosaic component.
282 | if(!(node instanceof MosaicComponent))
283 | _repaint(node, ott.memories, [], ott.values, true);
284 |
285 | if(modRef) modRef.replaceWith(node);
286 | }
287 |
288 | // You now have to skip over the next operation, which is technically
289 | // an addition. This addition is no longer necessary since we determined
290 | // that it was really a modification.
291 | i += 1;
292 | }
293 |
294 | // Handle "add" operations.
295 | else if(added) {
296 | // For each item in the edit, add it starting from the op index.
297 | let ref: HTMLElement|ChildNode|null = pointer;
298 |
299 | // First we have to make sure we have the right insertion index.
300 | // Sometimes you are inserting items into the middle of an array,
301 | // and other times you are appending to the end of the array.
302 | if(oldItems.length > 0) ref = document.querySelector(`[key="${oldItems[opIndex - 1].key}"]`);
303 | if(!ref) ref = document.querySelector(`[key="${oldItems[oldItems.length - 1].key}"]`);
304 |
305 | let frag = document.createDocumentFragment();
306 | for(let j = 0; j < edit.length; j++) {
307 | const addition = edit[j];
308 | const ott = OTT(addition, addition.key);
309 | const node = ott.instance;
310 | node.arrayOTT = ott;
311 |
312 | // Only repaint here if it is NOT a Mosaic component.
313 | if(!(node instanceof MosaicComponent))
314 | _repaint(node, ott.memories, [], ott.values, true);
315 |
316 | // Append to a document fragment for faster repainting.
317 | frag.appendChild(node);
318 | }
319 |
320 | // Insert the fragment into the reference spot.
321 | ref = insertAfter(frag, ref);
322 | }
323 |
324 | // Handle "delete" operations.
325 | else if(deleted) {
326 | // For each item in the edit, add it starting from the op index.
327 | for(let j = 0; j < edit.length; j++) {
328 | const obj = edit[j];
329 | const found = document.querySelector(`[key='${obj.key}']`);
330 | if(found) found.remove();
331 | }
332 |
333 | // When we make a deletion, we have to go back one index because
334 | // the length of the array is now shorter.
335 | opIndex -= count;
336 | }
337 |
338 | // Update the operation index as we move through the array.
339 | opIndex += count;
340 | }
341 | }
342 | }
--------------------------------------------------------------------------------
/src/observable.ts:
--------------------------------------------------------------------------------
1 | /** An object that can perform a given function when its data changes. */
2 | export default class Observable {
3 | constructor(target: Object, willUpdate?: Function, didUpdate?: Function) {
4 | return new Proxy(target, {
5 | get(target, name, receiver) {
6 | // if(target[name] && Array.isArray(target[name]) && !target[name].isObservableArray) {
7 | // const obs = new Observable(target[name], willUpdate, didUpdate);
8 | // (obs as any).isObservableArray = true;
9 | // return obs;
10 | // }
11 | if(target[name] && Array.isArray(target[name]))
12 | return new Observable(target[name], willUpdate, didUpdate);
13 | return Reflect.get(target, name, receiver);
14 | },
15 | set(target, name, value, receiver) {
16 | if(willUpdate) willUpdate(Object.assign({}, target));
17 | target[name] = value;
18 | if(didUpdate) didUpdate(target);
19 | return Reflect.set(target, name, value, receiver);
20 | }
21 | })
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/options.ts:
--------------------------------------------------------------------------------
1 | import Portfolio from "./portfolio";
2 | import Observable from './observable';
3 |
4 | /** A type that can be used to clear Typescript errors with objects. */
5 | type Any = any;
6 |
7 | /** A batched update during the rendering cycle. */
8 | export type BatchUpdate = [
9 | string,
10 | any
11 | ];
12 |
13 | /** The methods that users can call on Mosaics. */
14 | export class MosaicComponent extends HTMLElement {
15 | iid: string = '';
16 | tid: string = '';
17 | created?: Function;
18 | updated?: Function;
19 | router?: HTMLElement;
20 | portfolio?: Portfolio;
21 | willDestroy?: Function;
22 | barrier: boolean = false;
23 | useShadow: boolean = false;
24 | protected _shadow?: ShadowRoot;
25 | protected mixins: Object[] = [];
26 | protected oldValues: any[] = [];
27 | willUpdate?: (old?: Any) => void;
28 | received?: (attributes: Any) => void;
29 | data: Observable = new Observable({});
30 | stylesheets?: string[]|CSSStyleSheet[] = [];
31 | protected initiallyRendered: boolean = false;
32 | view?: (self?: MosaicComponent) => ViewFunction;
33 | descendants: DocumentFragment = document.createDocumentFragment();
34 | protected batches: { attributes: BatchUpdate[], data: BatchUpdate[] } = { attributes: [], data: [] };
35 |
36 | // Methods for the developer.
37 | public paint(arg?: string|HTMLElement|Object) {};
38 | public repaint() {};
39 | public set(data: Object) {};
40 |
41 | // Internal methods that should not be used by the developer.
42 | public _batchData(name: string, value: any) {
43 | this.batches.data.push([name, value]);
44 | }
45 | public _batchAttribute(name: string, value: any) {
46 | this.batches.attributes.push([name, value]);
47 | }
48 | public _getBatches() {
49 | return this.batches;
50 | }
51 | public _resetBatches() {
52 | this.batches = { attributes: [], data: [] };
53 | }
54 | }
55 |
56 | /** The configuration options for a Mosaic component. */
57 | export interface MosaicOptions extends Any {
58 | name: string;
59 | data?: Object;
60 | mixins?: Object[];
61 | created?: Function;
62 | updated?: Function;
63 | useShadow?: boolean;
64 | router?: HTMLElement;
65 | portfolio?: Portfolio;
66 | willDestroy?: Function;
67 | willUpdate?: (old: Any) => void;
68 | received?: (attributes: Any) => void;
69 | element?: string|Element|HTMLElement;
70 | stylesheets?: string[]|CSSStyleSheet[];
71 | view?: (self?: MosaicComponent) => ViewFunction;
72 | }
73 |
74 | /** Config options for a memory. */
75 | export interface MemoryOptions {
76 | type: string;
77 | steps: number[];
78 | attribute?: { name: string };
79 | isEvent?: boolean;
80 | isComponentType?: boolean;
81 | trackedAttributeCount?: number;
82 | }
83 |
84 | /** A custom type for efficient arrays. */
85 | export interface KeyedArray {
86 | keys: any[];
87 | items: any[];
88 | stringified: string[];
89 | __isKeyedArray: boolean;
90 | }
91 |
92 | /** The format of the Portfolio action. */
93 | export type PortfolioAction = (event: string, data: Any, additionalData: Any) => any;
94 |
95 | /** A tagged template literal view function. */
96 | export type ViewFunction = {
97 | strings: string[],
98 | values: any[],
99 | __isTemplate: true
100 | };
101 |
102 | /** A helper type for shorter syntax. */
103 | export type InjectionPoint = string|Element|HTMLElement|undefined|null;
--------------------------------------------------------------------------------
/src/parser.ts:
--------------------------------------------------------------------------------
1 | import { lastAttributeNameRegex, nodeMarker, traverse } from "./util";
2 | import Memory from "./memory";
3 |
4 | /** Takes the strings of a tagged template literal and
5 | * turns it into a full html string. */
6 | export function buildHTML(strings) {
7 | let html = '';
8 | const length = strings.length - 1;
9 |
10 | for(let i = 0; i < length; i++) {
11 | const str = strings[i];
12 | const attributeMatch = lastAttributeNameRegex.exec(str);
13 |
14 | // Node.
15 | if(attributeMatch === null) html += str + nodeMarker;
16 | // Attribute.
17 | else html += str.substring(0, attributeMatch.index) + attributeMatch[1] +
18 | attributeMatch[2] + attributeMatch[3] + nodeMarker;
19 | }
20 | html += strings[length];
21 | return html;
22 | }
23 |
24 | /** Memorizes parts of a DOM tree that contain dynamic content
25 | * and returns a list of memories of where those parts are. */
26 | export function memorize(t: HTMLTemplateElement): Memory[] {
27 | let ret: Memory[] = [];
28 | const temp: HTMLTemplateElement = document.importNode(t, true);
29 | traverse(temp.content, (node: Element, steps: number[]) => {
30 | // console.dir(node);
31 | switch(node.nodeType) {
32 | case 1: ret = ret.concat(parseAttributes(node, steps)); break;
33 | case 3: ret = ret.concat(parseText((node as any), steps)); break;
34 | case 8: ret = ret.concat(parseNode(node as any, steps)); break;
35 | default: break;
36 | }
37 | });
38 | return ret;
39 | }
40 |
41 | // Helper functions to parse attributes, nodes, and text.
42 | function parseAttributes(node: Element, steps: number[]): Memory[] {
43 | if(!node.attributes) return [];
44 | let ret: Memory[] = [];
45 | const defined = customElements.get(node.nodeName.toLowerCase()) !== undefined;
46 |
47 | // Make sure to keep track of how many dynamic attributes are needed
48 | // to trigger a repaint from a Memory perspective.
49 | let trackedAttributeCount = 0;
50 |
51 | const regex = new RegExp(`[a-z|A-Z| ]*${nodeMarker}[a-z|A-Z| ]*`, 'g');
52 | for(let i = 0; i < node.attributes.length; i++) {
53 | const { name, value } = node.attributes[i];
54 | const match = value.match(regex);
55 | if(!match || match.length < 1) continue;
56 |
57 | // Split the value to see where the dynamic parts in the string are.
58 | const _split = (name === 'style' ? value.split(';') : value.split(' '));
59 | const split = _split.filter(str => str.length > 0);
60 |
61 | for(let j = 0; j < split.length; j++) {
62 | const item = split[j].trim();
63 | const isDynamic = new RegExp(nodeMarker, 'gi');
64 |
65 | // Make sure you only add memories for dynamic attributes.
66 | if(isDynamic.test(item)) {
67 | trackedAttributeCount += 1;
68 | ret.push(new Memory({
69 | type: 'attribute',
70 | steps,
71 | isComponentType: defined,
72 | isEvent: name.startsWith('on') && name.length > 2,
73 | attribute: { name },
74 | trackedAttributeCount
75 | }));
76 | }
77 | }
78 | }
79 | return ret;
80 | }
81 | function parseNode(node: Text, steps: number[]): Memory[] {
82 | const check = nodeMarker.replace('','');
83 | if(node.textContent !== check) return [];
84 |
85 | // Check if the parent element is defined as a Mosaic component.
86 | let defined = customElements.get(node.nodeName.toLowerCase()) !== undefined;
87 | let defined2 = false;
88 | if(node.parentElement)
89 | defined2 = customElements.get(node.parentElement.nodeName.toLowerCase()) !== undefined;
90 |
91 | return [new Memory({
92 | type: "node",
93 | steps,
94 | isComponentType: defined || defined2
95 | })];
96 | }
97 | function parseText(node: Text, steps: number[]): Memory[] {
98 | let ret: Memory[] = [];
99 | let parent = node.parentNode;
100 | let strings = node.data.split(nodeMarker);
101 | let len = strings.length - 1;
102 |
103 | // Check if the parent element is defined as a Mosaic component.
104 | let defined = customElements.get(node.nodeName.toLowerCase()) !== undefined;
105 | let defined2 = false;
106 | if(node.parentElement)
107 | defined2 = customElements.get(node.parentElement.nodeName.toLowerCase()) !== undefined;
108 |
109 | for(let i = 0; i < len; i++) {
110 | let insert: Node;
111 | let str = strings[i];
112 |
113 | if(str === '') insert = document.createComment('');
114 | else {
115 | const match = lastAttributeNameRegex.exec(str);
116 | if(match !== null)
117 | str = str.slice(0, match.index) + match[1] + match[2].slice(0, -len) + match[3];
118 | insert = document.createTextNode(str);
119 | }
120 |
121 | if(parent) {
122 | parent.insertBefore(insert, node);
123 | ret.push(new Memory({
124 | type: 'node',
125 | steps,
126 | isComponentType: defined || defined2,
127 | }));
128 | }
129 | }
130 | return ret;
131 | }
--------------------------------------------------------------------------------
/src/portfolio.ts:
--------------------------------------------------------------------------------
1 | import { PortfolioAction } from './options';
2 |
3 | /** A central data store for Mosaic applications. */
4 | export default class Portfolio {
5 | /** @interal */
6 | private data: Object;
7 | /** @interal */
8 | private dependencies: Set;
9 | /** @interal */
10 | private action: PortfolioAction;
11 |
12 | constructor(data: Object, action: PortfolioAction) {
13 | this.data = data;
14 | this.action = action;
15 | this.dependencies = new Set();
16 | }
17 |
18 | /** Returns the value of a data property. */
19 | get(name: string) {
20 | return this.data[name];
21 | }
22 |
23 | /** Adds a dependency to this portfolio. @internal */
24 | addDependency(component: HTMLElement) {
25 | this.dependencies.add(component);
26 | }
27 |
28 | /** Removes a dependency from this portfolio. @internal */
29 | removeDependency(component: HTMLElement) {
30 | this.dependencies.delete(component);
31 | }
32 |
33 | /** Dispatches one or more events and updates its dependencies. */
34 | dispatch(event: string|string[], additional: Object = {}) {
35 | if(!this.action)
36 | throw new Error(`You must define an action in the Portfolio constructor before dispatching events.`);
37 |
38 | // Trigger the events.
39 | if(Array.isArray(event)) event.forEach(eve => this.action(eve, this.data, additional));
40 | else this.action(event, this.data, additional);
41 |
42 | // Repaint all of the dependencies.
43 | this.dependencies.forEach(component => (component as any).repaint());
44 | }
45 | }
--------------------------------------------------------------------------------
/src/router.ts:
--------------------------------------------------------------------------------
1 | /** A client-side routing solution for Mosaic apps. */
2 | export default class Router {
3 | public data: Object;
4 |
5 | /** @internal */
6 | private routes: Object;
7 | /** @internal */
8 | private current: string;
9 | /** @internal */
10 | private notFound?: HTMLElement;
11 | /** @internal */
12 | private element: Element;
13 |
14 | constructor(element?: string|Element) {
15 | this.data = {};
16 | this.routes = {};
17 | this.current = '/';
18 |
19 | if(typeof element === 'string') this.element = document.getElementById(element) || document.body;
20 | else if(element) this.element = element;
21 | else this.element = document.body;
22 |
23 | window.onpopstate = () => {
24 | let oldURL = window.location.pathname;
25 | this.data = Object.assign({}, this.data);
26 | this.current = oldURL;
27 | this.render(oldURL);
28 | }
29 | }
30 |
31 | /** Private function for rendering a new page from the router. @internal */
32 | private render(path: string) {
33 | let route = this.routes[path];
34 | if(!route) {
35 | if(this.notFound) {
36 | this.data['status'] = 404;
37 | route = this.notFound;
38 | } else route = document.createElement('div');
39 | }
40 |
41 | // Render the component at this route. By calling "appendChild"
42 | // you are essentially calling the "connectedCallback."
43 | this.element.innerHTML = '';
44 | this.element.appendChild(route);
45 | }
46 |
47 | /** Adds a new route. */
48 | addRoute(path: string|string[], component: HTMLElement) {
49 | // Configure the component.
50 | const addPath = path => {
51 | (component as any).router = this;
52 | this.routes[path] = component;
53 | };
54 |
55 | if(Array.isArray(path))
56 | for(let i = 0; i < path.length; i++) addPath(path[i]);
57 | else addPath(path);
58 | }
59 |
60 | /** Sets a "Not Found" page to use for when a route is not defined. */
61 | setNotFound(component: HTMLElement) {
62 | this.notFound = component;
63 | }
64 |
65 | /** Sends the router over to the specified destination if it is defined. */
66 | send(to: string, data: Object = {}) {
67 | this.current = to;
68 | this.data = Object.assign({}, data);
69 | window.history.pushState({}, this.current, window.location.origin + this.current);
70 | this.render(this.current);
71 | }
72 |
73 | /** Paints the router onto the page. */
74 | paint() {
75 | // Render the proper component based on the route.
76 | if(window.location.pathname !== this.current)
77 | this.current = window.location.pathname;
78 | this.render(this.current);
79 | }
80 | }
--------------------------------------------------------------------------------
/src/templating.ts:
--------------------------------------------------------------------------------
1 | import { MosaicComponent, ViewFunction } from "./options";
2 | import { buildHTML, memorize } from './parser';
3 | import { changed, step } from "./util";
4 | import Memory from './memory';
5 |
6 | /** Finds or creates the template associated with a component. */
7 | export function getTemplate(component: MosaicComponent): HTMLTemplateElement {
8 | const found = document.getElementById(component.tid) as HTMLTemplateElement;
9 | if(found) return found;
10 | else {
11 | if(!component.view) return document.createElement('template');
12 |
13 | const { strings } = component.view(component);
14 | const template = document.createElement('template');
15 | template.id = component.tid;
16 | template.innerHTML = buildHTML(strings);
17 | (template as any).memories = memorize(template);
18 |
19 | document.body.appendChild(template);
20 | return template;
21 | }
22 | }
23 |
24 | /** Renders a One Time Template. Still requires repainting. */
25 | export function OTT(view: ViewFunction, key?: string) {
26 | // Create and memorize the template.
27 | let cloned;
28 | const templateKey = key || encodeURIComponent(view.strings.join(''));
29 | let template = templateKey ?
30 | document.getElementById(templateKey) as HTMLTemplateElement
31 | :
32 | document.createElement('template');
33 |
34 | // Only run through this block of code if you have a template key.
35 | if(templateKey) {
36 | // If there's no template, then create one.
37 | if(!template) {
38 | template = document.createElement('template');
39 | template.id = templateKey;
40 | template.innerHTML = buildHTML(view.strings);
41 | (template as any).memories = memorize(template);
42 | }
43 | }
44 | // Otherwise, just make a new template in the moment, but don't save it.
45 | else {
46 | template.innerHTML = buildHTML(view.strings);
47 | (template as any).memories = memorize(template);
48 | }
49 | cloned = document.importNode(template.content, true).firstChild as HTMLElement;
50 | document.body.appendChild(template);
51 |
52 | // Set the key of the element and return it. Also set a special attribute
53 | // on the instance so that we always know that it is a OTT.
54 | if(key && cloned) cloned.setAttribute('key', key);
55 | if(cloned) cloned.isOTT = true;
56 |
57 | return {
58 | instance: cloned,
59 | values: view.values,
60 | memories: (template as any).memories,
61 | };
62 | }
63 |
64 | /** A global repaint function, which can be used for templates and components. */
65 | export function _repaint(element: HTMLElement|ShadowRoot, memories: Memory[],
66 | oldValues: any[], newValues: any[], isOTT: boolean = false) {
67 | for(let i = 0; i < memories.length; i++) {
68 | const mem: Memory = memories[i];
69 |
70 | // Get the reference to the true node that you are pointing at.
71 | // We have to splice the array for OTTs because they do not have
72 | // a holding container such as .
73 | let steps = mem.config.steps.slice();
74 | let pointer = step(element, steps, isOTT) as ChildNode;
75 |
76 | // Get the old and new values.
77 | let oldv = oldValues[i];
78 | let newv = newValues[i];
79 |
80 | // For conditional rendering.
81 | let alwaysUpdateFunction = mem.config.type === 'node';
82 |
83 | // Compare and commit.
84 | if(changed(oldv, newv, alwaysUpdateFunction))
85 | mem.commit(element, pointer, oldv, newv);
86 | }
87 | }
--------------------------------------------------------------------------------
/src/util.ts:
--------------------------------------------------------------------------------
1 | import { MosaicComponent } from "./options";
2 | import { _repaint } from "./templating";
3 |
4 | // The placeholders in the HTML.
5 | export const nodeMarker = ``;
6 | export const lastAttributeNameRegex = /([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F \x09\x0a\x0c\x0d"'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/;
7 |
8 | /** Returns a random key as a string. */
9 | export const randomKey = (): string => Math.random().toString(36).slice(2);
10 |
11 | /** Returns whether or not a value is a primitive type. */
12 | export function isPrimitive(value: any) {
13 | return (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'bigint');
14 | };
15 |
16 | /** Returns whether an attribute is a boolean attribute. */
17 | export const isBooleanAttribute = (name: string) => {
18 | let str = `async|autocomplete|autofocus|autoplay|border|challenge|checked|compact|`;
19 | str += `contenteditable|controlsdefault|defer|disabled|formNoValidate|frameborder|hidden|`;
20 | str += `indeterminate|ismap|loop|multiple|muted|nohref|noresizenoshade|novalidate|nowrap|`;
21 | str += `open|readonly|required|reversed|scoped|scrolling|seamless|selected|sortable|spell|`;
22 | str += `check|translate`;
23 | let regex = new RegExp(str, 'gi');
24 | return regex.test(name);
25 | }
26 |
27 | /** Checks if an object is an OTTType. */
28 |
29 | /** Insert a DOM node after a given node. */
30 | export function insertAfter(newNode, referenceNode) {
31 | referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
32 | return newNode;
33 | }
34 |
35 | /** Returns an object from an array of key value pairs. */
36 | export function objectFromArray(array: any[]) {
37 | if((Object as any).fromEntries) return (Object as any).fromEntries(array);
38 | else return Array.from(array).reduce((acc, [key,value]) => Object.assign(acc, { [key]: value }), {});
39 | }
40 |
41 | /** Traverses a dom tree and performs an action at each level. */
42 | export function traverse($node: Node|HTMLElement|ChildNode, action: Function, steps: number[] = []) {
43 | if(action) action($node, steps);
44 | let children = $node.childNodes;
45 | for(var i = 0; i < children.length; i++) {
46 | traverse(children[i], action, steps.concat(i));
47 | }
48 | }
49 |
50 | /** Applies mixin properties to a Mosaic component. */
51 | export function applyMixin(to: MosaicComponent, from: Object) {
52 | let keys = Object.keys(from);
53 | for(let i = 0; i < keys.length; i++) {
54 | let k = keys[i];
55 | if(k === 'data') {
56 | let dKeys = Object.keys(from[k]);
57 | for(let j = 0; j < dKeys.length; j++) {
58 | let dk = dKeys[j];
59 | to.data[dk] = from[k][dk];
60 | }
61 | }
62 | else if(k === 'created' || k === 'willUpdate' || k === 'updated'
63 | || k === 'willDestroy' || k === 'received') {
64 | if(!Array.isArray(to[k])) {
65 | const func = to[k];
66 | to[k] = [from[k]] as any;
67 | if(func) (to[k] as any).push(func);
68 | } else {
69 | (to[k] as any).splice(0, 0, from[k]);
70 | }
71 | }
72 | else {
73 | to[k] = from[k];
74 | }
75 | }
76 | }
77 |
78 | /** Performs a particular lifecycle function on a Mosaic. Accounts for the
79 | * possible array of lifecycle functions that come with mixins. */
80 | export function runLifecycle(name: string, component: MosaicComponent, ...args) {
81 | if(component[name]) {
82 | if(Array.isArray(component[name]))
83 | component[name].forEach(func => func.call(component, ...args));
84 | else component[name](...args);
85 | }
86 | }
87 |
88 | /** Steps down through the child nodes until it reaches the last step. */
89 | export function step(parent: ChildNode|Element|ShadowRoot, steps: number[], isOTT: boolean = false) {
90 | let child = parent;
91 | let start = isOTT ? 1 : 0;
92 |
93 | for(let i = start; i < steps.length; i++) {
94 | let next: number = steps[i];
95 | if(child.childNodes.length >= next) {
96 | let nextChild = child.childNodes[next];
97 | if(nextChild) child = child.childNodes[next];
98 | else continue;
99 | }
100 | }
101 | return child;
102 | }
103 |
104 | /** A function that goes up through the component chain attempting to
105 | * find the router so that each element can have a reference to it. */
106 | export function goUpToConfigureRouter() {
107 | // See if there is an element that already has a router property.
108 | // If so, take it (this may go all the way back up the tree).
109 | let i = 0;
110 | let parent = this.parentNode;
111 | while(parent && parent.parentNode && parent.parentNode.nodeName !== 'BODY') {
112 | if((parent as any).router) {
113 | this.router = (parent as any).router;
114 | break;
115 | }
116 | parent = parent.parentNode;
117 | i += 1;
118 | if(i > 100000) break;
119 | }
120 | if(parent && parent.firstChild && (parent.firstChild as any).router)
121 | this.router = (parent.firstChild as any).router;
122 | }
123 |
124 | /** Compares two values are returns false if they are the same and
125 | * true if they are different (i.e. they changed). */
126 | export function changed(oldv: any, newv: any, isOTT?: boolean) {
127 | // If no old value, then it is the first render so it did change.
128 | // Or if there is an old value and no new value, then it changed.
129 | if(!oldv) return true;
130 | if(oldv && !newv) return true;
131 |
132 | // Compare by type.
133 | if(isPrimitive(newv)) return oldv !== newv;
134 | else if(typeof newv === 'function') {
135 | if(isOTT && isOTT === true) return true;
136 | else return (''+oldv) !== (''+newv);
137 | }
138 | else if(Array.isArray(newv)) return true;
139 | else if(typeof newv === 'object') {
140 | // Template:
141 | if(oldv.__isTemplate) {
142 | // If the new value is not a template, then it changed.
143 | if(!newv.__isTemplate) return true;
144 | // If the new value is a template, but a different one.
145 | else if(''+oldv.values !== ''+newv.values) return true;
146 |
147 | // Otherwise, there is no difference.
148 | return false;
149 | }
150 | // KeyedArray:
151 | else if(oldv.__isKeyedArray) {
152 | // The new value is not a keyed array, so different.
153 | if(!newv.__isKeyedArray) return true;
154 | // If the new value is a keyed array, but has different
155 | // keys, then you know it changed.
156 | if(''+oldv.keys !== ''+newv.keys) return true;
157 | // A modification could also be triggered by a change
158 | // in values.
159 | if(''+oldv.stringified !== ''+newv.stringified) return true;
160 | }
161 | // Object:
162 | else {
163 | return !Object.is(oldv, newv);
164 | }
165 | }
166 | return false;
167 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "commonjs",
5 | "outDir": "ts-build/",
6 | "typeRoots": ["ts-build/"],
7 | "removeComments": true,
8 | "declaration": true,
9 | "strict": true,
10 | "noImplicitAny": false,
11 | "noImplicitThis": false,
12 | "stripInternal": true
13 | },
14 | "include": ["src/*"],
15 | }
--------------------------------------------------------------------------------