~repos /atoms-element

#js

git clone https://pyrossh.dev/repos/atoms-element.git

A simple web component library for defining your custom elements. It works on both client and server.


38cee1ba Peter John

4 years ago
add state typings and improve stuff
Files changed (4) hide show
  1. example/app-counter.js +37 -58
  2. index.d.ts +22 -11
  3. index.js +430 -572
  4. index.test.js +35 -1
example/app-counter.js CHANGED
@@ -1,64 +1,43 @@
1
- import { createElement, html, object, number, string } from '../index.js';
1
+ import { createElement, createState, html } from '../index.js';
2
2
 
3
+ export default createElement({
3
- const name = () => 'app-counter';
4
+ name: 'app-counter',
4
-
5
- const attrTypes = () => ({
5
+ attrs: {
6
- name: string().required(),
6
+ name: '',
7
- meta: object({
7
+ meta: {
8
- start: number(),
8
+ start: '',
9
- }),
9
+ },
10
- });
11
-
12
- const stateTypes = () => ({
13
- count: number()
14
- .required()
15
- .default((attrs) => attrs.meta?.start || 0)
16
- .action('increment', ({ state }) => state.setCount(state.count + 1))
17
- .action('decrement', ({ state }) => state.setCount(state.count - 1)),
18
- });
19
-
20
- const computedTypes = () => ({
21
- sum: number()
22
- .required()
23
- .compute('count', (count) => {
24
- return count + 10;
25
- }),
10
+ },
26
- warningClass: string()
11
+ state: {
27
- .required()
12
+ count: 0,
28
- .compute('count', (count) => {
29
- return count > 10 ? 'text-red-500' : '';
30
- }),
13
+ },
14
+ reducer: {
15
+ increment: (state) => ({ ...state, count: state.count + 1 }),
16
+ decrement: (state) => ({ ...state, count: state.count - 1 }),
31
- });
17
+ },
32
-
33
- const render = ({ attrs, state, computed }) => {
18
+ render: ({ attrs, state, actions }) => {
34
- const { name, meta } = attrs;
19
+ const { name, meta } = attrs;
35
- const { count, increment, decrement } = state;
20
+ const { count } = state;
36
- const { sum, warningClass } = computed;
21
+ const sum = count + 10;
22
+ const warningClass = count > 10 ? 'text-red-500' : '';
37
23
 
38
- return html`
24
+ return html`
39
- <div>
25
+ <div>
40
- <div class="mb-2">
26
+ <div class="mb-2">
41
- Counter: ${name}
27
+ Counter: ${name}
42
- <span>starts at ${meta?.start}</span>
28
+ <span>starts at ${meta?.start}</span>
43
- </div>
29
+ </div>
44
- <div class="flex flex-1 flex-row items-center text-gray-700">
30
+ <div class="flex flex-1 flex-row items-center text-gray-700">
45
- <button class="bg-gray-300 text-gray-700 rounded hover:bg-gray-200 px-4 py-2 text-3xl focus:outline-none" @click=${decrement}>-</button>
31
+ <button class="bg-gray-300 text-gray-700 rounded hover:bg-gray-200 px-4 py-2 text-3xl focus:outline-none" @click=${actions.decrement}>-</button>
32
+ <div class="mx-20">
33
+ <h1 class="text-3xl font-mono ${warningClass}">${count}</h1>
34
+ </div>
35
+ <button class="bg-gray-300 text-gray-700 rounded hover:bg-gray-200 px-4 py-2 text-3xl focus:outline-none" @click=${actions.increment}>+</button>
36
+ </div>
46
37
  <div class="mx-20">
47
- <h1 class="text-3xl font-mono ${warningClass}">${count}</h1>
38
+ <h1 class="text-xl font-mono">Sum: ${sum}</h1>
48
39
  </div>
49
- <button class="bg-gray-300 text-gray-700 rounded hover:bg-gray-200 px-4 py-2 text-3xl focus:outline-none" @click=${increment}>+</button>
50
40
  </div>
51
- <div class="mx-20">
52
- <h1 class="text-xl font-mono">Sum: ${sum}</h1>
53
- </div>
54
- </div>
55
- `;
41
+ `;
56
- };
42
+ },
57
-
58
- export default createElement({
59
- name,
60
- attrTypes,
61
- stateTypes,
62
- computedTypes,
63
- render,
64
43
  });
index.d.ts CHANGED
@@ -44,16 +44,6 @@ export class AtomsElement {
44
44
  export const getConfig = () => Config;
45
45
  export const getLocation = () => Location;
46
46
 
47
- export type CreateElementProps = {
48
- name: () => string;
49
- attrTypes?: () => {[key: string]: any};
50
- stateTypes?: () => {[key: string]: any};
51
- computedTypes?: () => {[key: string]: any};
52
- render: () => any
53
- }
54
-
55
- export const createElement = (props: CreateElementProps) => CreateElementProps;
56
-
57
47
  export type PageRenderProps = {
58
48
  props: any;
59
49
  headScript: string;
@@ -61,4 +51,25 @@ export type PageRenderProps = {
61
51
  }
62
52
  export type Handler = (props: any) => string;
63
53
 
64
- export const createPage = (props: { head: Handler, body: Handler}) => (props: PageRenderProps) => string;
54
+ export const createPage = (props: { head: Handler, body: Handler}) => (props: PageRenderProps) => string;
55
+
56
+ export type State<P, Q> = {
57
+ getState: () => P;
58
+ subscribe: (fn: (v: P) => void) => void;
59
+ } & { [K in keyof Q]: (v: any) => void}
60
+
61
+ export function createState<P, Q extends {[k: string]: (state: P, v: any) => P}>(props: { state: P, reducer: Q }): State<P, Q>;
62
+
63
+
64
+ export type ElementState<P, Q> = P & { [K in keyof Q]: (v: any) => void}
65
+
66
+
67
+ export type CreateElementProps<N, P, Q> = {
68
+ name: string;
69
+ attrs: N;
70
+ state: State<P, Q>;
71
+ reducer: Q
72
+ render: (props: { attrs: N, state: P, actions: { [K in keyof Q]: (v: any) => void;} }) => any
73
+ }
74
+
75
+ export function createElement<N, P, Q extends {[k: string]: (state: P, v: any) => P}>(props: CreateElementProps<N, P, Q>): CreateElementProps<N, P, Q>;
index.js CHANGED
@@ -3,6 +3,280 @@ import { html, render as litRender, directive, NodePart, isPrimitive } from './l
3
3
  const isBrowser = typeof window !== 'undefined';
4
4
  export { html, isBrowser };
5
5
 
6
+ const lastAttributeNameRegex =
7
+ /([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F "'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/;
8
+ const tagRE = /<[a-zA-Z0-9\-\!\/](?:"[^"]*"|'[^']*'|[^'">])*>/g;
9
+ const whitespaceRE = /^\s*$/;
10
+ const attrRE = /\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g;
11
+ const voidElements = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
12
+
13
+ const parseTag = (tag) => {
14
+ const res = {
15
+ type: 'tag',
16
+ name: '',
17
+ voidElement: false,
18
+ attrs: {},
19
+ children: [],
20
+ };
21
+
22
+ const tagMatch = tag.match(/<\/?([^\s]+?)[/\s>]/);
23
+ if (tagMatch) {
24
+ res.name = tagMatch[1];
25
+ if (voidElements.includes(tagMatch[1]) || tag.charAt(tag.length - 2) === '/') {
26
+ res.voidElement = true;
27
+ }
28
+
29
+ // handle comment tag
30
+ if (res.name.startsWith('!--')) {
31
+ const endIndex = tag.indexOf('-->');
32
+ return {
33
+ type: 'comment',
34
+ comment: endIndex !== -1 ? tag.slice(4, endIndex) : '',
35
+ };
36
+ }
37
+ }
38
+
39
+ const reg = new RegExp(attrRE);
40
+ let result = null;
41
+ for (;;) {
42
+ result = reg.exec(tag);
43
+
44
+ if (result === null) {
45
+ break;
46
+ }
47
+
48
+ if (!result[0].trim()) {
49
+ continue;
50
+ }
51
+
52
+ if (result[1]) {
53
+ const attr = result[1].trim();
54
+ let arr = [attr, ''];
55
+
56
+ if (attr.indexOf('=') > -1) {
57
+ arr = attr.split('=');
58
+ }
59
+
60
+ res.attrs[arr[0]] = arr[1];
61
+ reg.lastIndex--;
62
+ } else if (result[2]) {
63
+ res.attrs[result[2]] = result[3].trim().substring(1, result[3].length - 1);
64
+ }
65
+ }
66
+
67
+ return res;
68
+ };
69
+ const parseHtml = (html) => {
70
+ const result = [];
71
+ const arr = [];
72
+ let current;
73
+ let level = -1;
74
+
75
+ // handle text at top level
76
+ if (html.indexOf('<') !== 0) {
77
+ var end = html.indexOf('<');
78
+ result.push({
79
+ type: 'text',
80
+ content: end === -1 ? html : html.substring(0, end),
81
+ });
82
+ }
83
+
84
+ html.replace(tagRE, function (tag, index) {
85
+ const isOpen = tag.charAt(1) !== '/';
86
+ const isComment = tag.startsWith('<!--');
87
+ const start = index + tag.length;
88
+ const nextChar = html.charAt(start);
89
+ let parent;
90
+
91
+ if (isComment) {
92
+ const comment = parseTag(tag);
93
+
94
+ // if we're at root, push new base node
95
+ if (level < 0) {
96
+ result.push(comment);
97
+ return result;
98
+ }
99
+ parent = arr[level];
100
+ parent.children.push(comment);
101
+ return result;
102
+ }
103
+
104
+ if (isOpen) {
105
+ level++;
106
+
107
+ current = parseTag(tag);
108
+
109
+ if (!current.voidElement && nextChar && nextChar !== '<') {
110
+ current.children.push({
111
+ type: 'text',
112
+ content: html.slice(start, html.indexOf('<', start)),
113
+ });
114
+ }
115
+
116
+ // if we're at root, push new base node
117
+ if (level === 0) {
118
+ result.push(current);
119
+ }
120
+
121
+ parent = arr[level - 1];
122
+
123
+ if (parent) {
124
+ parent.children.push(current);
125
+ }
126
+
127
+ arr[level] = current;
128
+ }
129
+
130
+ if (!isOpen || current.voidElement) {
131
+ if (level > -1 && (current.voidElement || current.name === tag.slice(2, -1))) {
132
+ level--;
133
+ // move current up a level to match the end tag
134
+ current = level === -1 ? result : arr[level];
135
+ }
136
+ if (nextChar !== '<' && nextChar) {
137
+ // trailing text node
138
+ // if we're at the root, push a base text node. otherwise add as
139
+ // a child to the current node.
140
+ parent = level === -1 ? result : arr[level].children;
141
+
142
+ // calculate correct end of the content slice in case there's
143
+ // no tag after the text node.
144
+ const end = html.indexOf('<', start);
145
+ let content = html.slice(start, end === -1 ? undefined : end);
146
+ // if a node is nothing but whitespace, collapse it as the spec states:
147
+ // https://www.w3.org/TR/html4/struct/text.html#h-9.1
148
+ if (whitespaceRE.test(content)) {
149
+ content = ' ';
150
+ }
151
+ // don't add whitespace-only text nodes if they would be trailing text nodes
152
+ // or if they would be leading whitespace-only text nodes:
153
+ // * end > -1 indicates this is not a trailing text node
154
+ // * leading node is when level is -1 and parent has length 0
155
+ if ((end > -1 && level + parent.length >= 0) || content !== ' ') {
156
+ parent.push({
157
+ type: 'text',
158
+ content: content,
159
+ });
160
+ }
161
+ }
162
+ }
163
+ });
164
+
165
+ return result;
166
+ };
167
+
168
+ const stringifyAttrs = (attrs) => {
169
+ const buff = [];
170
+ for (let key in attrs) {
171
+ buff.push(key + '="' + attrs[key] + '"');
172
+ }
173
+ if (!buff.length) {
174
+ return '';
175
+ }
176
+ return ' ' + buff.join(' ');
177
+ };
178
+
179
+ const stringifyHtml = (buff, doc) => {
180
+ switch (doc.type) {
181
+ case 'text':
182
+ return buff + doc.content;
183
+ case 'tag':
184
+ buff += '<' + doc.name + (doc.attrs ? stringifyAttrs(doc.attrs) : '') + (doc.voidElement ? '/>' : '>');
185
+ if (doc.voidElement) {
186
+ return buff;
187
+ }
188
+ return buff + doc.children.reduce(stringifyHtml, '') + '</' + doc.name + '>';
189
+ case 'comment':
190
+ buff += '<!--' + doc.comment + '-->';
191
+ return buff;
192
+ }
193
+ };
194
+
195
+ const hydrate = (node) => {
196
+ const Clazz = getElement(node.name);
197
+ if (Clazz) {
198
+ const newAttrs = {};
199
+ Object.keys(node.attrs).forEach((key) => {
200
+ const newValue = node.attrs[key];
201
+ newAttrs[key] = newValue && newValue.startsWith(`{`) ? JSON.parse(newValue.replace(/'/g, `"`)) : newValue;
202
+ });
203
+ const instance = new Clazz(newAttrs);
204
+ const res = instance.render();
205
+ node.children = parseHtml(res);
206
+ }
207
+ if (node.children) {
208
+ for (const child of node.children) {
209
+ hydrate(child);
210
+ }
211
+ }
212
+ };
213
+
214
+ const wrapAttribute = (attrName, suffix, text, v) => {
215
+ let buffer = text;
216
+ const hasQuote = suffix && suffix.includes(`="`);
217
+ if (attrName && !hasQuote) {
218
+ buffer += `"`;
219
+ }
220
+ buffer += v;
221
+ if (attrName && !hasQuote) {
222
+ buffer += `"`;
223
+ }
224
+ return buffer;
225
+ };
226
+
227
+ export const renderHtml = isBrowser
228
+ ? litRender
229
+ : (template) => {
230
+ let js = '';
231
+ template.strings.forEach((text, i) => {
232
+ const value = template.values[i];
233
+ const type = typeof value;
234
+ let attrName, suffix;
235
+ const matchName = lastAttributeNameRegex.exec(text);
236
+ if (matchName) {
237
+ attrName = matchName[2];
238
+ suffix = matchName[3];
239
+ }
240
+ if (value === null || !(type === 'object' || type === 'function' || type === 'undefined')) {
241
+ js += wrapAttribute(attrName, suffix, text, type !== 'string' ? String(value) : value);
242
+ } else if (Array.isArray(value) && value.find((item) => item && item.strings && item.type === 'html')) {
243
+ js += text;
244
+ value.forEach((v) => {
245
+ js += renderHtml(v);
246
+ });
247
+ } else if (type === 'object') {
248
+ // TemplateResult
249
+ if (value.strings && value.type === 'html') {
250
+ js += text;
251
+ js += renderHtml(value);
252
+ } else {
253
+ js += wrapAttribute(attrName, suffix, text, JSON.stringify(value).replace(/"/g, `'`));
254
+ }
255
+ } else if (type == 'function') {
256
+ if (attrName) {
257
+ js += text.replace(' ' + attrName + '=', '');
258
+ } else {
259
+ // js += text;
260
+ // js += value();
261
+ }
262
+ } else if (type !== 'undefined') {
263
+ js += text;
264
+ js += value.toString();
265
+ } else {
266
+ js += text;
267
+ // console.log('value', value);
268
+ }
269
+ });
270
+ const nodes = parseHtml(js);
271
+ for (const node of nodes) {
272
+ hydrate(node);
273
+ }
274
+ const html = nodes.reduce((acc, node) => {
275
+ return acc + stringifyHtml('', node);
276
+ }, '');
277
+ return html;
278
+ };
279
+
6
280
  const hyphenate = (s) => s.replace(/[A-Z]|^ms/g, '-$&').toLowerCase();
7
281
  const percent = (v) => (v * 100).toFixed(2) + '%';
8
282
  const createStyle = (...kvs) => {
@@ -57,7 +331,8 @@ const extractClasses = (html) => {
57
331
  return str;
58
332
  };
59
333
 
60
- export const getClassList = (template) => {
334
+ const getClassList = (template) => {
335
+ // need to have this incase of shadowDom
61
336
  // const classes = template.strings.reduce((acc, item) => acc + extractClasses(item), '').split(' ');
62
337
  const classes = [];
63
338
  template.values.forEach((item) => {
@@ -70,19 +345,6 @@ export const getClassList = (template) => {
70
345
  return classes;
71
346
  };
72
347
 
73
- export const apply = (classes) => {
74
- const classStyles = {};
75
- classes.split(' ').forEach((cls) => {
76
- const styles = classLookup[cls];
77
- if (styles) {
78
- Object.keys(styles).forEach((key) => {
79
- classStyles[key] = styles[key];
80
- });
81
- }
82
- });
83
- return classStyles;
84
- };
85
-
86
348
  export const generateTWStyleSheet = (classList) => {
87
349
  let styleSheet = ``;
88
350
  classList.forEach((cls) => {
@@ -630,593 +892,190 @@ const pageStyles = {
630
892
  display: 'flex',
631
893
  flexDirection: 'column',
632
894
  flex: '1 1 0%',
633
- minWidth: '320px',
634
- minHeight: '100vh',
635
- fontWeight: 400,
636
- color: 'rgba(44, 62, 80, 1)',
637
- direction: 'ltr',
638
- fontSynthesis: 'none',
639
- textRendering: 'optimizeLegibility',
640
- },
641
- };
642
-
643
- // hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500
644
-
645
- const previousValues = new WeakMap();
646
- export const unsafeHTML = isBrowser
647
- ? directive((value) => (part) => {
648
- if (!(part instanceof NodePart)) {
649
- throw new Error('unsafeHTML can only be used in text bindings');
650
- }
651
- const previousValue = previousValues.get(part);
652
- if (previousValue !== undefined && isPrimitive(value) && value === previousValue.value && part.value === previousValue.fragment) {
653
- return;
654
- }
655
- const template = document.createElement('template');
656
- template.innerHTML = value; // innerHTML casts to string internally
657
- const fragment = document.importNode(template.content, true);
658
- part.setValue(fragment);
659
- previousValues.set(part, { value, fragment });
660
- })
661
- : (value) => value;
662
-
663
- const logError = (msg) => {
664
- if (isBrowser ? window.__DEV__ : global.__DEV) {
665
- console.warn(msg);
666
- }
667
- };
668
-
669
- const validator = (type, validate) => (innerType) => {
670
- const isPrimitiveType = ['number', 'string', 'boolean'].includes(type);
671
- const common = {
672
- type: type,
673
- parse: isPrimitiveType ? (attr) => attr : (attr) => (attr ? JSON.parse(attr.replace(/'/g, `"`)) : null),
674
- validate: (context, data) => {
675
- if (data === null || typeof data === 'undefined') {
676
- if (common.__required) {
677
- logError(`'${context}' Field is required`);
678
- }
679
- return;
680
- }
681
- if (!isPrimitiveType) {
682
- validate(innerType, context, data);
683
- } else {
684
- const dataType = typeof data;
685
- if (dataType !== type) {
686
- logError(`'${context}' Expected type '${type}' got type '${dataType}'`);
687
- }
688
- }
689
- },
690
- };
691
- common.required = () => {
692
- common.__required = true;
693
- return common;
694
- };
695
- common.default = (fnOrValue) => {
696
- common.__default = fnOrValue;
697
- return common;
698
- };
699
- common.compute = (...args) => {
700
- const fn = args[args.length - 1];
701
- const deps = args.slice(0, args.length - 1);
702
- common.__compute = {
703
- fn,
704
- deps,
705
- };
706
- return common;
707
- };
708
- common.action = (name, fn) => {
709
- if (!common.__handlers) {
710
- common.__handlers = {};
711
- }
712
- common.__handlers[name] = fn;
713
- return common;
714
- };
715
- return common;
716
- };
717
-
718
- export const number = validator('number');
719
- export const string = validator('string');
720
- export const boolean = validator('boolean');
721
- export const object = validator('object', (innerType, context, data) => {
722
- if (data.constructor !== Object) {
723
- logError(`'${context}' Expected object literal '{}' got '${typeof data}'`);
724
- }
725
- for (const key of Object.keys(innerType)) {
726
- const fieldValidator = innerType[key];
727
- const item = data[key];
728
- fieldValidator.validate(`${context}.${key}`, item);
729
- }
730
- });
731
- export const array = validator('array', (innerType, context, data) => {
732
- if (!Array.isArray(data)) {
733
- logError(`Expected Array got ${data}`);
734
- }
735
- for (let i = 0; i < data.length; i++) {
736
- const item = data[i];
737
- innerType.validate(`${context}[${i}]`, item);
738
- }
739
- });
740
-
741
- const fifo = (q) => q.shift();
742
- const microtask = (flush) => () => queueMicrotask(flush);
743
-
744
- const registry = {};
745
- const BaseElement = isBrowser ? window.HTMLElement : class {};
746
-
747
- export class AtomsElement extends BaseElement {
748
- static register() {
749
- registry[this.name] = this;
750
- if (isBrowser) {
751
- if (window.customElements.get(this.name)) {
752
- return;
753
- } else {
754
- window.customElements.define(this.name, registry[this.name]);
755
- }
756
- }
757
- }
758
-
759
- static getElement(name) {
760
- return registry[name];
761
- }
762
-
763
- static get observedAttributes() {
764
- if (!this.attrTypes) {
765
- return [];
766
- }
767
- return Object.keys(this.attrTypes).map((k) => k.toLowerCase());
768
- }
769
-
770
- constructor(attrs) {
771
- super();
772
- this._dirty = false;
773
- this._connected = false;
774
- this.attrs = attrs || {};
775
- this.state = {};
776
- this.config = isBrowser ? window.config : global.config;
777
- this.location = isBrowser ? window.location : global.location;
778
- this.prevClassList = [];
779
- if (!isBrowser) {
780
- this.initState();
781
- } else {
782
- // this.shadow = this.attachShadow({ mode: 'open' });
783
- }
784
- }
785
-
786
- initAttrs() {
787
- Object.keys(this.constructor.attrTypes).forEach((key) => {
788
- const attrType = this.constructor.attrTypes[key];
789
- const newValue = this.getAttribute(key.toLowerCase());
790
- const data = attrType.parse(newValue);
791
- attrType.validate(`<${this.constructor.name}> ${key}`, data);
792
- this.attrs[key] = data;
793
- });
794
- }
795
-
796
- initState() {
797
- Object.keys(this.constructor.stateTypes).forEach((key) => {
798
- const stateType = this.constructor.stateTypes[key];
799
- if (!this.state[key] && typeof stateType.__default !== 'undefined') {
800
- this.state[key] = typeof stateType.__default === 'function' ? stateType.__default(this.attrs, this.state) : stateType.__default;
801
- }
802
- const setKey = `set${key[0].toUpperCase()}${key.slice(1)}`;
803
- this.state[setKey] = (v) => {
804
- // TODO: check type on set
805
- this.state[key] = typeof v === 'function' ? v(this.state[key]) : v;
806
- this.update();
807
- };
808
- if (stateType.__handlers) {
809
- Object.keys(stateType.__handlers).map((hkey) => {
810
- this.state[hkey] = () => stateType.__handlers[hkey]({ attrs: this.attrs, state: this.state });
811
- });
812
- }
813
- });
814
- }
815
-
816
- connectedCallback() {
817
- this._connected = true;
818
- this.initAttrs();
819
- this.initState();
820
- this.update();
821
- }
822
- disconnectedCallback() {
823
- this._connected = false;
824
- }
825
-
826
- attributeChangedCallback(key, oldValue, newValue) {
827
- if (this._connected) {
828
- this.initAttrs();
829
- this.update();
830
- }
831
- }
832
-
833
- update() {
834
- if (this._dirty) {
835
- return;
836
- }
837
- this._dirty = true;
838
- this.enqueueUpdate();
839
- }
840
-
841
- _performUpdate() {
842
- if (!this._connected) {
843
- return;
844
- }
845
- this.renderTemplate();
846
- this._dirty = false;
847
- }
848
-
849
- batch(runner, pick, callback) {
850
- const q = [];
851
- const flush = () => {
852
- let p;
853
- while ((p = pick(q))) callback(p);
854
- };
855
- const run = runner(flush);
856
- q.push(this) === 1 && run();
857
- }
858
-
859
- enqueueUpdate() {
860
- this.batch(microtask, fifo, () => this._performUpdate());
861
- }
862
-
863
- get computed() {
864
- return Object.keys(this.constructor.computedTypes).reduceRight((acc, key) => {
865
- const type = this.constructor.computedTypes[key];
866
- const state = this.state;
867
- const values = type.__compute.deps.reduce((dacc, key) => {
868
- if (typeof state[key] !== undefined) {
869
- dacc.push(state[key]);
870
- }
871
- return dacc;
872
- }, []);
873
- acc[key] = type.__compute.fn(...values);
874
- return acc;
875
- }, {});
876
- }
877
-
878
- renderTemplate() {
879
- const template = this.render();
880
- if (isBrowser) {
881
- // TODO: this can be optimized when we know whether the value belongs in a class (AttributePart)
882
- // maybe do this in lit-html itselfs
883
- const newClassList = getClassList(template).filter((cls) => {
884
- const globalStyles = document.getElementById('global').textContent;
885
- return !globalStyles.includes('.' + cls);
886
- });
887
- if (newClassList.length > 0) {
888
- document.getElementById('global').textContent += generateTWStyleSheet(newClassList);
889
- }
890
- render(template, this);
891
- // For shadows only
892
- // if (!this.styleElement) {
893
- // render(template, this.shadow);
894
- // const styleSheet = generateTWStyleSheet(classList);
895
- // this.prevClassList = classList;
896
- // this.styleElement = document.createElement('style');
897
- // this.shadow.appendChild(this.styleElement).textContent = css(pageStyles) + styleSheet;
898
- // } else {
899
- // const missingClassList = classList.filter((cls) => !this.prevClassList.includes(cls));
900
- // if (missingClassList.length > 0) {
901
- // const styleSheet = generateTWStyleSheet(missingClassList);
902
- // this.styleElement.textContent += '\n' + styleSheet;
903
- // this.prevClassList.push(...missingClassList);
904
- // }
905
- // render(template, this.shadow);
906
- // }
907
- } else {
908
- return render(template);
909
- }
910
- }
911
- }
912
- export const getConfig = () => (isBrowser ? window.props.config : global.props.config);
913
- export const getLocation = () => (isBrowser ? window.location : global.location);
914
-
915
- export const createElement = ({ name, attrTypes, stateTypes, computedTypes, render }) => {
916
- const Element = class extends AtomsElement {
917
- static name = name();
918
-
919
- static attrTypes = attrTypes ? attrTypes() : {};
920
-
921
- static stateTypes = stateTypes ? stateTypes() : {};
922
-
923
- static computedTypes = computedTypes ? computedTypes() : {};
924
-
925
- render() {
926
- return render({
927
- attrs: this.attrs,
928
- state: this.state,
929
- computed: this.computed,
930
- });
931
- }
932
- };
933
- Element.register();
934
- return { name, attrTypes, stateTypes, computedTypes, render };
935
- };
936
-
937
- const lastAttributeNameRegex =
938
- /([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F "'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/;
939
- const tagRE = /<[a-zA-Z0-9\-\!\/](?:"[^"]*"|'[^']*'|[^'">])*>/g;
940
- const whitespaceRE = /^\s*$/;
941
- const attrRE = /\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g;
942
- const voidElements = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
943
-
944
- const parseTag = (tag) => {
945
- const res = {
946
- type: 'tag',
947
- name: '',
948
- voidElement: false,
949
- attrs: {},
950
- children: [],
951
- };
952
-
953
- const tagMatch = tag.match(/<\/?([^\s]+?)[/\s>]/);
954
- if (tagMatch) {
955
- res.name = tagMatch[1];
956
- if (voidElements.includes(tagMatch[1]) || tag.charAt(tag.length - 2) === '/') {
957
- res.voidElement = true;
958
- }
959
-
960
- // handle comment tag
961
- if (res.name.startsWith('!--')) {
962
- const endIndex = tag.indexOf('-->');
963
- return {
964
- type: 'comment',
965
- comment: endIndex !== -1 ? tag.slice(4, endIndex) : '',
966
- };
967
- }
968
- }
969
-
970
- const reg = new RegExp(attrRE);
971
- let result = null;
972
- for (;;) {
973
- result = reg.exec(tag);
974
-
975
- if (result === null) {
976
- break;
977
- }
978
-
979
- if (!result[0].trim()) {
980
- continue;
981
- }
895
+ minWidth: '320px',
896
+ minHeight: '100vh',
897
+ fontWeight: 400,
898
+ color: 'rgba(44, 62, 80, 1)',
899
+ direction: 'ltr',
900
+ fontSynthesis: 'none',
901
+ textRendering: 'optimizeLegibility',
902
+ },
903
+ };
982
904
 
983
- if (result[1]) {
984
- const attr = result[1].trim();
905
+ // hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500
985
- let arr = [attr, ''];
986
906
 
907
+ const previousValues = new WeakMap();
908
+ export const unsafeHTML = isBrowser
987
- if (attr.indexOf('=') > -1) {
909
+ ? directive((value) => (part) => {
988
- arr = attr.split('=');
910
+ if (!(part instanceof NodePart)) {
911
+ throw new Error('unsafeHTML can only be used in text bindings');
912
+ }
913
+ const previousValue = previousValues.get(part);
914
+ if (previousValue !== undefined && isPrimitive(value) && value === previousValue.value && part.value === previousValue.fragment) {
915
+ return;
989
916
  }
917
+ const template = document.createElement('template');
918
+ template.innerHTML = value; // innerHTML casts to string internally
919
+ const fragment = document.importNode(template.content, true);
920
+ part.setValue(fragment);
921
+ previousValues.set(part, { value, fragment });
922
+ })
923
+ : (value) => value;
990
924
 
991
- res.attrs[arr[0]] = arr[1];
992
- reg.lastIndex--;
993
- } else if (result[2]) {
925
+ const fifo = (q) => q.shift();
994
- res.attrs[result[2]] = result[3].trim().substring(1, result[3].length - 1);
926
+ const microtask = (flush) => () => queueMicrotask(flush);
995
- }
996
- }
997
927
 
928
+ export const createState = ({ state, reducer }) => {
929
+ let initial = state;
930
+ const subs = new Set();
931
+ return {
932
+ getValue: () => initial,
933
+ subscribe: (fn) => {
934
+ subs.add(fn);
935
+ },
936
+ unsubscribe: (fn) => {
937
+ subs.remove(fn);
938
+ },
939
+ actions: Object.keys(reducer).reduce((acc, key) => {
940
+ const reduce = reducer[key];
941
+ acc[key] = (v) => {
942
+ initial = reduce(initial, v);
943
+ subs.forEach((sub) => {
944
+ sub(initial);
945
+ });
946
+ };
998
- return res;
947
+ return acc;
948
+ }, {}),
949
+ };
999
950
  };
1000
- const parseHtml = (html) => {
1001
- const result = [];
1002
- const arr = [];
1003
- let current;
1004
- let level = -1;
1005
951
 
952
+ const registry = {};
953
+ export const getConfig = () => (isBrowser ? window.props.config : global.props.config);
954
+ export const getLocation = () => (isBrowser ? window.location : global.location);
1006
- // handle text at top level
955
+ export const getElement = (name) => registry[name];
956
+ export const registerElement = (name, clazz) => {
957
+ registry[name] = clazz;
958
+ if (isBrowser) {
1007
- if (html.indexOf('<') !== 0) {
959
+ if (window.customElements.get(name)) {
1008
- var end = html.indexOf('<');
1009
- result.push({
960
+ return;
1010
- type: 'text',
961
+ } else {
1011
- content: end === -1 ? html : html.substring(0, end),
962
+ window.customElements.define(name, registry[name]);
1012
- });
963
+ }
1013
964
  }
1014
-
1015
- html.replace(tagRE, function (tag, index) {
1016
- const isOpen = tag.charAt(1) !== '/';
1017
- const isComment = tag.startsWith('<!--');
1018
- const start = index + tag.length;
1019
- const nextChar = html.charAt(start);
1020
- let parent;
1021
-
1022
- if (isComment) {
1023
- const comment = parseTag(tag);
1024
-
1025
- // if we're at root, push new base node
1026
- if (level < 0) {
1027
- result.push(comment);
1028
- return result;
1029
- }
965
+ };
1030
- parent = arr[level];
966
+ const BaseElement = isBrowser ? window.HTMLElement : class {};
967
+ export const createElement = ({ name, attrs, state, reducer, render: renderFn }) => {
1031
- parent.children.push(comment);
968
+ const Element = class extends BaseElement {
1032
- return result;
969
+ static get observedAttributes() {
970
+ return Object.keys(attrs || {}).map((k) => k.toLowerCase());
1033
971
  }
1034
972
 
1035
- if (isOpen) {
973
+ constructor(ssrAttrs) {
1036
- level++;
974
+ super();
1037
-
975
+ this._dirty = false;
976
+ this._connected = false;
1038
- current = parseTag(tag);
977
+ this.attrs = ssrAttrs || attrs;
1039
-
978
+ this.state = createState({ state, reducer });
979
+ this.config = isBrowser ? window.config : global.config;
1040
- if (!current.voidElement && nextChar && nextChar !== '<') {
980
+ this.location = isBrowser ? window.location : global.location;
1041
- current.children.push({
1042
- type: 'text',
981
+ // this.prevClassList = [];
1043
- content: html.slice(start, html.indexOf('<', start)),
982
+ // this.shadow = this.attachShadow({ mode: 'open' });
1044
- });
1045
- }
983
+ }
1046
984
 
1047
- // if we're at root, push new base node
1048
- if (level === 0) {
985
+ connectedCallback() {
986
+ this._connected = true;
987
+ this.state.subscribe(() => this.update());
1049
- result.push(current);
988
+ this.update();
1050
- }
989
+ }
1051
990
 
991
+ disconnectedCallback() {
1052
- parent = arr[level - 1];
992
+ this._connected = false;
993
+ this.state.unsubscribe(() => this.update());
994
+ }
1053
995
 
996
+ attributeChangedCallback(key, oldValue, newValue) {
997
+ this.attrs[key] = newValue && newValue.startsWith(`{`) ? JSON.parse(newValue.replace(/'/g, `"`)) : newValue;
1054
- if (parent) {
998
+ if (this._connected) {
1055
- parent.children.push(current);
999
+ this.update();
1056
1000
  }
1057
-
1058
- arr[level] = current;
1059
1001
  }
1060
1002
 
1003
+ update() {
1061
- if (!isOpen || current.voidElement) {
1004
+ if (this._dirty) {
1062
- if (level > -1 && (current.voidElement || current.name === tag.slice(2, -1))) {
1063
- level--;
1005
+ return;
1064
- // move current up a level to match the end tag
1065
- current = level === -1 ? result : arr[level];
1066
1006
  }
1067
- if (nextChar !== '<' && nextChar) {
1007
+ this._dirty = true;
1068
- // trailing text node
1008
+ this.enqueueUpdate();
1069
- // if we're at the root, push a base text node. otherwise add as
1070
- // a child to the current node.
1009
+ }
1071
- parent = level === -1 ? result : arr[level].children;
1072
1010
 
1073
- // calculate correct end of the content slice in case there's
1074
- // no tag after the text node.
1011
+ _performUpdate() {
1075
- const end = html.indexOf('<', start);
1076
- let content = html.slice(start, end === -1 ? undefined : end);
1077
- // if a node is nothing but whitespace, collapse it as the spec states:
1078
- // https://www.w3.org/TR/html4/struct/text.html#h-9.1
1079
- if (whitespaceRE.test(content)) {
1012
+ if (!this._connected) {
1080
- content = ' ';
1081
- }
1082
- // don't add whitespace-only text nodes if they would be trailing text nodes
1083
- // or if they would be leading whitespace-only text nodes:
1084
- // * end > -1 indicates this is not a trailing text node
1085
- // * leading node is when level is -1 and parent has length 0
1086
- if ((end > -1 && level + parent.length >= 0) || content !== ' ') {
1087
- parent.push({
1013
+ return;
1088
- type: 'text',
1089
- content: content,
1090
- });
1091
- }
1092
1014
  }
1015
+ this.render();
1016
+ this._dirty = false;
1093
1017
  }
1094
- });
1095
1018
 
1019
+ batch(runner, pick, callback) {
1096
- return result;
1020
+ const q = [];
1021
+ const flush = () => {
1022
+ let p;
1023
+ while ((p = pick(q))) callback(p);
1097
- };
1024
+ };
1098
-
1099
- const stringifyAttrs = (attrs) => {
1100
- const buff = [];
1025
+ const run = runner(flush);
1101
- for (let key in attrs) {
1102
- buff.push(key + '="' + attrs[key] + '"');
1026
+ q.push(this) === 1 && run();
1103
- }
1027
+ }
1104
- if (!buff.length) {
1105
- return '';
1106
- }
1107
- return ' ' + buff.join(' ');
1108
- };
1109
1028
 
1110
- const stringifyHtml = (buff, doc) => {
1111
- switch (doc.type) {
1029
+ enqueueUpdate() {
1112
- case 'text':
1113
- return buff + doc.content;
1030
+ this.batch(microtask, fifo, () => this._performUpdate());
1114
- case 'tag':
1115
- buff += '<' + doc.name + (doc.attrs ? stringifyAttrs(doc.attrs) : '') + (doc.voidElement ? '/>' : '>');
1116
- if (doc.voidElement) {
1117
- return buff;
1118
- }
1031
+ }
1119
- return buff + doc.children.reduce(stringifyHtml, '') + '</' + doc.name + '>';
1120
- case 'comment':
1121
- buff += '<!--' + doc.comment + '-->';
1122
- return buff;
1123
- }
1124
- };
1125
1032
 
1033
+ render() {
1126
- const hydrate = (node) => {
1034
+ const template = renderFn({
1035
+ attrs: this.attrs,
1127
- const Clazz = AtomsElement.getElement(node.name);
1036
+ state: this.state.getValue(),
1128
- if (Clazz) {
1129
- const newAttrs = {};
1037
+ actions: this.state.actions,
1130
- Object.keys(node.attrs).forEach((key) => {
1038
+ });
1131
- const attrType = Clazz.attrTypes[key];
1132
- if (attrType) {
1039
+ if (isBrowser) {
1040
+ // TODO: this can be optimized when we know whether the value belongs in a class (AttributePart)
1041
+ // maybe do this in lit-html itselfs
1042
+ const newClassList = getClassList(template).filter((cls) => {
1043
+ const globalStyles = document.getElementById('global').textContent;
1044
+ return !globalStyles.includes('.' + cls);
1045
+ });
1046
+ if (newClassList.length > 0) {
1047
+ document.getElementById('global').textContent += generateTWStyleSheet(newClassList);
1048
+ }
1049
+ renderHtml(template, this);
1050
+ // For shadows only
1051
+ // if (!this.styleElement) {
1052
+ // render(template, this.shadow);
1053
+ // const styleSheet = generateTWStyleSheet(classList);
1133
- newAttrs[key] = attrType.parse(node.attrs[key]);
1054
+ // this.prevClassList = classList;
1055
+ // this.styleElement = document.createElement('style');
1056
+ // this.shadow.appendChild(this.styleElement).textContent = css(pageStyles) + styleSheet;
1057
+ // } else {
1058
+ // const missingClassList = classList.filter((cls) => !this.prevClassList.includes(cls));
1059
+ // if (missingClassList.length > 0) {
1060
+ // const styleSheet = generateTWStyleSheet(missingClassList);
1061
+ // this.styleElement.textContent += '\n' + styleSheet;
1062
+ // this.prevClassList.push(...missingClassList);
1063
+ // }
1064
+ // render(template, this.shadow);
1065
+ // }
1134
1066
  } else {
1135
- newAttrs[key] = node.attrs[key];
1067
+ return renderHtml(template);
1136
1068
  }
1137
- });
1138
- const instance = new Clazz(newAttrs);
1139
- const res = instance.renderTemplate();
1140
- node.children = parseHtml(res);
1141
- }
1142
- if (node.children) {
1143
- for (const child of node.children) {
1144
- hydrate(child);
1145
1069
  }
1146
- }
1147
- };
1070
+ };
1148
-
1149
- const wrapAttribute = (attrName, suffix, text, v) => {
1150
- let buffer = text;
1151
- const hasQuote = suffix && suffix.includes(`="`);
1152
- if (attrName && !hasQuote) {
1071
+ registerElement(name, Element);
1153
- buffer += `"`;
1154
- }
1155
- buffer += v;
1156
- if (attrName && !hasQuote) {
1072
+ return { name, attrs, state, render: renderFn };
1157
- buffer += `"`;
1158
- }
1159
- return buffer;
1160
1073
  };
1161
1074
 
1162
- export const render = isBrowser
1163
- ? litRender
1164
- : (template) => {
1165
- let js = '';
1166
- template.strings.forEach((text, i) => {
1167
- const value = template.values[i];
1168
- const type = typeof value;
1169
- let attrName, suffix;
1170
- const matchName = lastAttributeNameRegex.exec(text);
1171
- if (matchName) {
1172
- attrName = matchName[2];
1173
- suffix = matchName[3];
1174
- }
1175
- if (value === null || !(type === 'object' || type === 'function' || type === 'undefined')) {
1176
- js += wrapAttribute(attrName, suffix, text, type !== 'string' ? String(value) : value);
1177
- } else if (Array.isArray(value) && value.find((item) => item && item.strings && item.type === 'html')) {
1178
- js += text;
1179
- value.forEach((v) => {
1180
- js += render(v);
1181
- });
1182
- } else if (type === 'object') {
1183
- // TemplateResult
1184
- if (value.strings && value.type === 'html') {
1185
- js += text;
1186
- js += render(value);
1187
- } else {
1188
- js += wrapAttribute(attrName, suffix, text, JSON.stringify(value).replace(/"/g, `'`));
1189
- }
1190
- } else if (type == 'function') {
1191
- if (attrName) {
1192
- js += text.replace(' ' + attrName + '=', '');
1193
- } else {
1194
- // js += text;
1195
- // js += value();
1196
- }
1197
- } else if (type !== 'undefined') {
1198
- js += text;
1199
- js += value.toString();
1200
- } else {
1201
- js += text;
1202
- // console.log('value', value);
1203
- }
1204
- });
1205
- const nodes = parseHtml(js);
1206
- for (const node of nodes) {
1207
- hydrate(node);
1208
- }
1209
- const html = nodes.reduce((acc, node) => {
1210
- return acc + stringifyHtml('', node);
1211
- }, '');
1212
- return html;
1213
- };
1214
-
1215
1075
  export const createPage = ({ head, body }) => {
1216
1076
  return ({ headScript, bodyScript, lang, props }) => {
1217
- const isProd = process.env.NODE_ENV === 'production';
1218
- const headHtml = render(head(props));
1077
+ const headHtml = renderHtml(head(props));
1219
- const bodyHtml = render(body(props));
1078
+ const bodyHtml = renderHtml(body(props));
1220
1079
  const classes = extractClasses(bodyHtml);
1221
1080
  return `
1222
1081
  <!DOCTYPE html>
@@ -1238,7 +1097,6 @@ export const createPage = ({ head, body }) => {
1238
1097
  <body>
1239
1098
  ${bodyHtml}
1240
1099
  <script>
1241
- window.__DEV__ = ${!isProd};
1242
1100
  window.props = ${JSON.stringify(props)};
1243
1101
  </script>
1244
1102
  ${bodyScript}
index.test.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { expect, test, jest } from '@jest/globals';
2
- import { AtomsElement, createElement, createPage, html, render, number, boolean, string, array, object, unsafeHTML, css } from './index.js';
2
+ import { AtomsElement, createElement, createPage, createState, html, render, number, boolean, string, array, object, unsafeHTML, css } from './index.js';
3
3
 
4
4
  global.__DEV = true;
5
5
 
@@ -50,6 +50,40 @@ primitives.forEach((value) =>
50
50
  }),
51
51
  );
52
52
 
53
+ test.only('createState', () => {
54
+ // computed: {
55
+ // sum: (state, v) => state.count + v,
56
+ // },
57
+ const countState = createState({
58
+ state: {
59
+ count: 0,
60
+ },
61
+ reducer: {
62
+ increment: (state, a) => ({ ...state, count: state.count + a }),
63
+ decrement: (state, a) => ({ ...state, count: state.count - a }),
64
+ },
65
+ });
66
+ createElement({
67
+ name: 'hello',
68
+ attrs: {
69
+ name: '123',
70
+ },
71
+ state: countState,
72
+ render: ({ attrs, state, actions }) => {
73
+ console.log('attrs', attrs);
74
+ },
75
+ });
76
+ // countState.subscribe((v) => {
77
+ // console.log(v);
78
+ // });
79
+ // countState.increment(4);
80
+ // console.log(countState.sum(21));
81
+ // countState.decrement(1);
82
+ // console.log(countState.sum(21));
83
+ // countState.decrement(2);
84
+ // console.log(countState.sum(21));
85
+ });
86
+
53
87
  test('object', () => {
54
88
  const spy = jest.spyOn(global.console, 'warn').mockImplementation();
55
89
  const context = 'data';