atoms-element v5.0.0

#js

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

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


e264357Peter John 2026-08-07T11:03:54+05:30
improvements
__snapshots__/index.test.js.snap CHANGED
@@ -1,39 +1,17 @@
1
1
  // Jest Snapshot v1, https://goo.gl/fbAQLP
2
2
 
3
+ exports[`createElement styles are scoped and injected via SSR 1`] = `
3
- exports[`compileTw 1`] = `
4
+ "@scope (styled-element) {
5
+
6
+ :scope {
7
+ display: flex;
8
+ }
9
+ .count {
10
+ color: magenta;
11
+ }
12
+
13
+ }
4
14
  "
5
- .text-white {
6
- color: rgba(255, 255, 255, 1);
7
- }
8
-
9
- .font-bold {
10
- font-weight: 700;
11
- }
12
-
13
- .border {
14
- border-width: 1px;
15
- }
16
-
17
- .border-black {
18
- border-color: rgba(0, 0, 0, 1);
19
- }
20
-
21
- .p-4 {
22
- padding: 1rem;
23
- }
24
-
25
- .m-4 {
26
- margin: 1rem;
27
- }
28
-
29
- .w-20 {
30
- width: 5rem;
31
- }
32
-
33
- .h-20 {
34
- height: 5rem;
35
- }
36
- "
37
15
  `;
38
16
 
39
17
  exports[`createElement with attrs and hooks 1`] = `
@@ -312,31 +290,6 @@ body {
312
290
  }
313
291
 
314
292
 
315
- .flex {
316
- display: flex;
317
- }
318
-
319
- .flex-1 {
320
- flex: 1;
321
- }
322
-
323
- .flex-col {
324
- flex-direction: column;
325
- }
326
-
327
- .mt-20 {
328
- margin-top: 5rem;
329
- }
330
-
331
- .items-center {
332
- align-items: center;
333
- }
334
-
335
- .text-5xl {
336
- font-size: 3rem;
337
- line-height: 1;
338
- }
339
-
340
293
  </style>
341
294
  <script type=\\"module\\"><script>
342
295
  </head>
@@ -354,28 +307,15 @@ line-height: 1;
354
307
  "
355
308
  `;
356
309
 
357
- exports[`css 1`] = `
310
+ exports[`css tagged template 1`] = `
358
- "button {
359
-
360
- color: magenta;
361
- font-size: 10px;
362
-
363
- font-size: 64px;
364
-
365
- color: black;
366
-
367
-
368
- color: navy;
369
-
370
- }
371
- container {
372
-
373
- flex: 1;
374
- align-items: center;
375
- justify-content: center;
376
-
377
- }
378
311
  "
312
+ :scope {
313
+ display: flex;
314
+ }
315
+ button {
316
+ color: magenta;
317
+ }
318
+ "
379
319
  `;
380
320
 
381
321
  exports[`render attribute keys 1`] = `
@@ -408,3 +348,5 @@ exports[`renderHtml 1`] = `
408
348
  <div>
409
349
  <app-counter name=\\"123\\" class=\\"abc high\\" age=\\"1\\" details1=\\"{'name':'123','address':{'street':'1'}}\\" items=\\"[1,2,3]\\"></app-counter> </div>"
410
350
  `;
351
+
352
+ exports[`renderHtml escapes text and attribute values 1`] = `"<div class=\\"a&quot; onmouseover=&quot;alert(1)\\">&lt;script&gt;alert(1)&lt;/script&gt;</div>"`;
example/elements/app-counter.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useEffect } from '../../index.js';
2
- import { createElement, html, useReducer } from '../../index.js';
2
+ import { createElement, css, html, useReducer } from '../../index.js';
3
3
  import { totalReducer } from '../store.js';
4
4
 
5
5
  const Counter = ({ name, meta }) => {
@@ -10,11 +10,21 @@ const Counter = ({ name, meta }) => {
10
10
  err: null,
11
11
  },
12
12
  reducer: {
13
- increment: (state) => ({ ...state, count: state.count + 1 }),
13
+ increment: (state) => {
14
+ state.count += 1;
15
+ },
14
- decrement: (state) => ({ ...state, count: state.count - 1 }),
16
+ decrement: (state) => {
17
+ state.count -= 1;
18
+ },
15
- setLoading: (state, v) => ({ ...state, loading: v }),
19
+ setLoading: (state, v) => {
20
+ state.loading = v;
21
+ },
16
- setData: (state, data) => ({ ...state, data }),
22
+ setData: (state, data) => {
23
+ state.data = data;
24
+ },
17
- setErr: (state, err) => ({ ...state, err }),
25
+ setErr: (state, err) => {
26
+ state.err = err;
27
+ },
18
28
  },
19
29
  effects: {
20
30
  loadData: async (actions, id) => {
@@ -41,23 +51,63 @@ const Counter = ({ name, meta }) => {
41
51
  actions.decrement();
42
52
  totalReducer.actions.decrement(count - 1);
43
53
  };
44
- const warningClass = count > 10 ? 'text-red-500' : '';
54
+ const warningClass = count > 10 ? 'warning' : '';
45
55
 
46
56
  return html`
47
- <div class="mt-10">
57
+ <div class="heading">
48
- <div class="mb-2">
58
+ <div class="label">
49
59
  Counter: ${name}
50
60
  <span>starts at ${meta?.start}</span>
51
61
  </div>
52
- <div class="flex flex-1 flex-row items-center text-gray-700">
62
+ <div class="controls">
53
- <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>
63
+ <button @click=${decrement}>-</button>
54
- <div class="mx-20">
64
+ <div class="count">
55
- <h1 class="text-3xl font-mono ${warningClass}">${count}</h1>
65
+ <h1 class="${warningClass}">${count}</h1>
56
66
  </div>
57
- <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>
67
+ <button @click=${increment}>+</button>
58
68
  </div>
59
69
  </div>
60
70
  `;
61
71
  };
62
72
 
73
+ Counter.styles = css`
74
+ :scope {
75
+ display: block;
76
+ margin-top: 2.5rem;
77
+ }
78
+ .heading {
79
+ color: rgba(55, 65, 81, 1);
80
+ }
81
+ .label {
82
+ margin-bottom: 0.5rem;
83
+ }
84
+ .controls {
85
+ display: flex;
86
+ align-items: center;
87
+ }
88
+ .count {
89
+ margin: 0 5rem;
90
+ }
91
+ .count h1 {
92
+ font-size: 1.875rem;
93
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
94
+ }
95
+ .count h1.warning {
96
+ color: rgba(239, 68, 68, 1);
97
+ }
98
+ button {
99
+ background-color: rgba(209, 213, 219, 1);
100
+ color: rgba(55, 65, 81, 1);
101
+ border-radius: 0.25rem;
102
+ padding: 0.5rem 1rem;
103
+ font-size: 1.875rem;
104
+ }
105
+ button:hover {
106
+ background-color: rgba(229, 231, 235, 1);
107
+ }
108
+ button:focus {
109
+ outline: none;
110
+ }
111
+ `;
112
+
63
113
  export default createElement(import.meta, Counter);
example/elements/app-total.js CHANGED
@@ -1,13 +1,24 @@
1
- import { createElement, html, useReducer } from '../../index.js';
1
+ import { createElement, css, html, useReducer } from '../../index.js';
2
2
  import { totalReducer } from '../store.js';
3
3
 
4
4
  const Total = () => {
5
5
  const { total } = useReducer(totalReducer);
6
6
  return html`
7
- <div class="m-20">
7
+ <div>
8
- <h1 class="text-xl font-mono">Total of 2 Counters: ${total}</h1>
8
+ <h1>Total of 2 Counters: ${total}</h1>
9
9
  </div>
10
10
  `;
11
11
  };
12
12
 
13
+ Total.styles = css`
14
+ :scope {
15
+ display: block;
16
+ margin: 5rem;
17
+ }
18
+ h1 {
19
+ font-size: 1.25rem;
20
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
21
+ }
22
+ `;
23
+
13
24
  export default createElement(import.meta, Total);
example/pages/index.js CHANGED
@@ -1,19 +1,30 @@
1
- import { createPage, html } from '../../index.js';
1
+ import { createPage, css, html } from '../../index.js';
2
2
  import '../elements/app-counter.js';
3
3
  import '../elements/app-total.js';
4
4
 
5
5
  const head = ({ config }) => {
6
+ return html`
6
- return html` <title>${config.title}</title> `;
7
+ <title>${config.title}</title>
8
+ <style>
9
+ ${css`
10
+ .page {
11
+ display: flex;
12
+ flex: 1;
13
+ flex-direction: column;
14
+ align-items: center;
15
+ justify-content: center;
16
+ }
17
+ `}
18
+ </style>
19
+ `;
7
20
  };
8
21
 
9
22
  const body = () => {
10
23
  return html`
11
- <div class="flex flex-1 flex-col items-center justify-center">
24
+ <div class="page">
12
25
  <app-counter name="1" meta="{'start': 5}"></app-counter>
13
26
  <app-counter name="2" meta="{'start': 7}"></app-counter>
14
- </div class="mt-10">
15
- <app-total></app-total>
27
+ <app-total></app-total>
16
- </div>
17
28
  </div>
18
29
  `;
19
30
  };
example/store.js CHANGED
@@ -5,7 +5,11 @@ export const totalReducer = createReducer({
5
5
  total: 0,
6
6
  },
7
7
  reducer: {
8
- increment: (state) => ({ ...state, total: state.total + 1 }),
8
+ increment: (state) => {
9
+ state.total += 1;
10
+ },
9
- decrement: (state) => ({ ...state, total: state.total - 1 }),
11
+ decrement: (state) => {
12
+ state.total -= 1;
13
+ },
10
14
  },
11
15
  });
index.d.ts CHANGED
@@ -31,18 +31,32 @@ export type Location = {
31
31
  }
32
32
 
33
33
 
34
- export type Reducer<P, Q, R> = {
34
+ export type ReducerActions<P> = {[k: string]: (state: P, v: any) => void};
35
+ export type EffectActions = {[k: string]: (actions: any, v: any) => void};
36
+
37
+ export type ActionsOf<Q> = { [K in keyof Q]: Q[K] extends (state: any, v: infer V) => any ? (v: V) => void : (v: any) => void };
38
+ export type EffectsOf<R> = { [K in keyof R]: R[K] extends (actions: any, v: infer V) => any ? (v: V) => void : (v: any) => void };
39
+
40
+ export type Reducer<P, Q extends ReducerActions<P>, R extends EffectActions = {}> = {
35
41
  getValue: () => P;
36
42
  subscribe: (fn: (v: P) => void) => void;
37
- } & { actions: { [K in keyof Q]: (v: any) => void}} & { effects: { [K in keyof R]: (v: any) => void}}
43
+ unsubscribe: (fn: (v: P) => void) => void;
38
-
39
- export type ReducerActions<P> = {[k: string]: (state: P, v: any) => P};
44
+ actions: ActionsOf<Q>;
40
- export type EffectActions<P, Q extends ReducerActions<P>> = {[k: string]: (actions: { [K in keyof Q]: (v: any) => void}, v: any) => void};
45
+ effects: EffectsOf<R>;
46
+ };
41
47
 
42
48
  export function createReducer<P, Q extends ReducerActions<P>>(props: { initial: P, reducer: Q }): Reducer<P, Q>;
43
49
 
44
- export function useReducer<P, Q extends ReducerActions<P>, R extends EffectActions<P, Q>>(props: Reducer<P, Q, R> | { initial: P, reducer: Q, effects: R }) : P & Reducer<P, Q, R>;
50
+ export function useReducer<P, Q extends ReducerActions<P>, R extends EffectActions = {}>(
51
+ props: { initial: P, reducer: Q, effects?: R },
52
+ selector?: (state: P) => any,
53
+ ): P & { actions: ActionsOf<Q>, effects: EffectsOf<R> };
54
+ export function useReducer<P, Q extends ReducerActions<P>, R extends EffectActions = {}>(
55
+ reducer: Reducer<P, Q, R>,
56
+ selector?: (state: P) => any,
57
+ ): P & { actions: ActionsOf<Q>, effects: EffectsOf<R> };
45
58
 
46
59
  export function createElement(meta: any, renderFn: any): any
60
+ export function css(strings: TemplateStringsArray, ...values: any[]): string;
47
61
  export type Handler = (props: any) => string;
48
62
  export function createPage(props: { head: Handler, body: Handler}): (props: { props: any, headScript: string, bodyScript: string }) => string;
index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { html, render as litRender, directive, NodePart, isPrimitive } from './lit-html.js';
2
+ import { create } from 'mutative';
2
3
 
3
4
  const isBrowser = typeof window !== 'undefined';
4
5
  export { html, isBrowser };
@@ -232,6 +233,10 @@ const wrapAttribute = (attrName, suffix, text, v) => {
232
233
  return buffer;
233
234
  };
234
235
 
236
+ const escapeAttribute = (s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
237
+ const escapeText = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
238
+ const UNSAFE_HTML = Symbol('unsafeHtml');
239
+
235
240
  export const renderHtml = isBrowser
236
241
  ? litRender
237
242
  : (template) => {
@@ -245,8 +250,11 @@ export const renderHtml = isBrowser
245
250
  attrName = matchName[2];
246
251
  suffix = matchName[3];
247
252
  }
253
+ if (value != null && type === 'object' && value[UNSAFE_HTML]) {
254
+ js += wrapAttribute(attrName, suffix, text, value.value);
248
- if (value === null || !(type === 'object' || type === 'function' || type === 'undefined')) {
255
+ } else if (value === null || !(type === 'object' || type === 'function' || type === 'undefined')) {
256
+ const str = type !== 'string' ? String(value) : value;
249
- js += wrapAttribute(attrName, suffix, text, type !== 'string' ? String(value) : value);
257
+ js += wrapAttribute(attrName, suffix, text, attrName ? escapeAttribute(str) : escapeText(str));
250
258
  } else if (Array.isArray(value) && value.find((item) => item && item.strings && item.type === 'html')) {
251
259
  js += text;
252
260
  value.forEach((v) => {
@@ -286,39 +294,15 @@ export const renderHtml = isBrowser
286
294
  };
287
295
 
288
296
  const hyphenate = (s) => s.replace(/[A-Z]|^ms/g, '-$&').toLowerCase();
289
- const percent = (v) => (v * 100).toFixed(2) + '%';
290
- const createStyle = (...kvs) => {
291
- const style = {};
292
- for (let i = 0; i < kvs.length; i += 2) {
293
- style[hyphenate(kvs[i])] = kvs[i + 1];
294
- }
295
- return style;
296
- };
297
297
 
298
- const mapApply = (obj) =>
299
- Object.keys(obj.keys).reduce((acc, key) => {
300
- Object.keys(obj.values).map((vkey) => {
301
- const suffix = vkey ? '-' + vkey : '';
298
+ export const css = (strings, ...values) => strings.reduce((acc, str, i) => acc + str + (i < values.length ? values[i] : ''), '');
302
- const className = `${key}${suffix}`;
303
- if (Array.isArray(obj.keys[key])) {
304
- const args = [];
305
- obj.keys[key].forEach((kk) => {
306
- args.push(kk, obj.values[vkey]);
307
- });
308
- acc[className] = createStyle(...args);
309
- } else {
310
- acc[className] = createStyle(obj.keys[key], obj.values[vkey]);
311
- }
312
- });
313
- return acc;
314
- }, {});
315
299
 
316
- export const css = (obj, isChild = false, indent = '') => {
300
+ const stylesFromObject = (obj, isChild = false, indent = '') => {
317
301
  const cssText = Object.keys(obj).reduce((acc, key) => {
318
302
  const value = obj[key];
319
303
  acc += !isChild ? `${key} {\n` : '';
320
304
  if (typeof value === 'object') {
321
- acc += '\n' + css(value, true, indent + ' ');
305
+ acc += '\n' + stylesFromObject(value, true, indent + ' ');
322
306
  } else {
323
307
  acc += ' ' + indent + hyphenate(key) + ': ' + value + ';\n';
324
308
  }
@@ -328,466 +312,6 @@ export const css = (obj, isChild = false, indent = '') => {
328
312
  return cssText;
329
313
  };
330
314
 
331
- const extractClasses = (html) => {
332
- let str = '';
333
- const matches = html.match(/class=(?:["']\W+\s*(?:\w+)\()?["']([^'"]+)['"]/gim);
334
- if (matches) {
335
- matches.forEach((matched, i) => {
336
- str += matched.replace('class="', '').replace('"', '') + (i === matches.length - 1 ? '' : ' ');
337
- });
338
- }
339
- return str;
340
- };
341
-
342
- const getClassList = (template) => {
343
- // need to have this incase of shadowDom
344
- // const classes = template.strings.reduce((acc, item) => acc + extractClasses(item), '').split(' ');
345
- const classes = [];
346
- template.values.forEach((item) => {
347
- if (typeof item === 'string') {
348
- const list = item.split(' ');
349
- return classes.push(...list.filter((cls) => classLookup[cls]));
350
- }
351
- return false;
352
- });
353
- return classes;
354
- };
355
-
356
- export const compileTw = (classList) => {
357
- let styleSheet = ``;
358
- classList.forEach((cls) => {
359
- const item = classLookup[cls];
360
- if (item) {
361
- const className = cls.replace(`/`, `\\/`);
362
- styleSheet += `
363
- .${className} {
364
- ${Object.keys(item)
365
- .map((key) => `${key}: ${item[key]};`)
366
- .join('\n')}
367
- }
368
- `;
369
- }
370
- });
371
- return styleSheet;
372
- };
373
-
374
- const colors = {
375
- keys: {
376
- bg: 'backgroundColor',
377
- text: 'color',
378
- divide: 'borderColor',
379
- border: 'borderColor',
380
- ring: '--tw-ring-color',
381
- 'border-l': 'borderLeftColor',
382
- 'border-r': 'borderRightColor',
383
- 'border-t': 'borderTopColor',
384
- 'border-b': 'borderBottomColor',
385
- },
386
- values: {
387
- transparent: 'transparent',
388
- current: 'currentColor',
389
- black: 'rgba(0, 0, 0, 1)',
390
- white: 'rgba(255, 255, 255, 1)',
391
- 'gray-50': 'rgba(249, 250, 251, 1)',
392
- 'gray-100': 'rgba(243, 244, 246, 1)',
393
- 'gray-200': 'rgba(229, 231, 235, 1)',
394
- 'gray-300': 'rgba(209, 213, 219, 1)',
395
- 'gray-400': 'rgba(156, 163, 175, 1)',
396
- 'gray-500': 'rgba(107, 114, 128, 1)',
397
- 'gray-600': 'rgba(75, 85, 99, 1)',
398
- 'gray-700': 'rgba(55, 65, 81, 1)',
399
- 'gray-800': 'rgba(31, 41, 55, 1)',
400
- 'gray-900': 'rgba(17, 24, 39, 1)',
401
- 'red-50': 'rgba(254, 242, 242, 1)',
402
- 'red-100': 'rgba(254, 226, 226, 1)',
403
- 'red-200': 'rgba(254, 202, 202, 1)',
404
- 'red-300': 'rgba(252, 165, 165, 1)',
405
- 'red-400': 'rgba(248, 113, 113, 1)',
406
- 'red-500': 'rgba(239, 68, 68, 1)',
407
- 'red-600': 'rgba(220, 38, 38, 1)',
408
- 'red-700': 'rgba(185, 28, 28, 1)',
409
- 'red-800': 'rgba(153, 27, 27, 1)',
410
- 'red-900': 'rgba(127, 29, 29, 1)',
411
- 'yellow-50': 'rgba(255, 251, 235, 1)',
412
- 'yellow-100': 'rgba(254, 243, 199, 1)',
413
- 'yellow-200': 'rgba(253, 230, 138, 1)',
414
- 'yellow-300': 'rgba(252, 211, 77, 1)',
415
- 'yellow-400': 'rgba(251, 191, 36, 1)',
416
- 'yellow-500': 'rgba(245, 158, 11, 1)',
417
- 'yellow-600': 'rgba(217, 119, 6, 1)',
418
- 'yellow-700': 'rgba(180, 83, 9, 1)',
419
- 'yellow-800': 'rgba(146, 64, 14, 1)',
420
- 'yellow-900': 'rgba(120, 53, 15, 1)',
421
- 'green-50': 'rgba(236, 253, 245, 1)',
422
- 'green-100': 'rgba(209, 250, 229, 1)',
423
- 'green-200': 'rgba(167, 243, 208, 1)',
424
- 'green-300': 'rgba(110, 231, 183, 1)',
425
- 'green-400': 'rgba(52, 211, 153, 1)',
426
- 'green-500': 'rgba(16, 185, 129, 1)',
427
- 'green-600': 'rgba(5, 150, 105, 1)',
428
- 'green-700': 'rgba(4, 120, 87, 1)',
429
- 'green-800': 'rgba(6, 95, 70, 1)',
430
- 'green-900': 'rgba(6, 78, 59, 1)',
431
- 'blue-50': 'rgba(239, 246, 255, 1)',
432
- 'blue-100': 'rgba(219, 234, 254, 1)',
433
- 'blue-200': 'rgba(191, 219, 254, 1)',
434
- 'blue-300': 'rgba(147, 197, 253, 1)',
435
- 'blue-400': 'rgba(96, 165, 250, 1)',
436
- 'blue-500': 'rgba(59, 130, 246, 1)',
437
- 'blue-600': 'rgba(37, 99, 235, 1)',
438
- 'blue-700': 'rgba(29, 78, 216, 1)',
439
- 'blue-800': 'rgba(30, 64, 175, 1)',
440
- 'blue-900': 'rgba(30, 58, 138, 1)',
441
- 'indigo-50': 'rgba(238, 242, 255, 1)',
442
- 'indigo-100': 'rgba(224, 231, 255, 1)',
443
- 'indigo-200': 'rgba(199, 210, 254, 1)',
444
- 'indigo-300': 'rgba(165, 180, 252, 1)',
445
- 'indigo-400': 'rgba(129, 140, 248, 1)',
446
- 'indigo-500': 'rgba(99, 102, 241, 1)',
447
- 'indigo-600': 'rgba(79, 70, 229, 1)',
448
- 'indigo-700': 'rgba(67, 56, 202, 1)',
449
- 'indigo-800': 'rgba(55, 48, 163, 1)',
450
- 'indigo-900': 'rgba(49, 46, 129, 1)',
451
- 'purple-50': 'rgba(245, 243, 255, 1)',
452
- 'purple-100': 'rgba(237, 233, 254, 1)',
453
- 'purple-200': 'rgba(221, 214, 254, 1)',
454
- 'purple-300': 'rgba(196, 181, 253, 1)',
455
- 'purple-400': 'rgba(167, 139, 250, 1)',
456
- 'purple-500': 'rgba(139, 92, 246, 1)',
457
- 'purple-600': 'rgba(124, 58, 237, 1)',
458
- 'purple-700': 'rgba(109, 40, 217, 1)',
459
- 'purple-800': 'rgba(91, 33, 182, 1)',
460
- 'purple-900': 'rgba(76, 29, 149, 1)',
461
- 'pink-50': 'rgba(253, 242, 248, 1)',
462
- 'pink-100': 'rgba(252, 231, 243, 1)',
463
- 'pink-200': 'rgba(251, 207, 232, 1)',
464
- 'pink-300': 'rgba(249, 168, 212, 1)',
465
- 'pink-400': 'rgba(244, 114, 182, 1)',
466
- 'pink-500': 'rgba(236, 72, 153, 1)',
467
- 'pink-600': 'rgba(219, 39, 119, 1)',
468
- 'pink-700': 'rgba(190, 24, 93, 1)',
469
- 'pink-800': 'rgba(157, 23, 77, 1)',
470
- 'pink-900': 'rgba(131, 24, 67, 1)',
471
- },
472
- };
473
-
474
- const spacing = {
475
- keys: {
476
- mr: 'marginRight',
477
- ml: 'marginLeft',
478
- mt: 'marginTop',
479
- mb: 'marginBottom',
480
- mx: ['marginLeft', 'marginRight'],
481
- my: ['marginTop', 'marginBottom'],
482
- m: 'margin',
483
- pr: 'paddingRight',
484
- pl: 'paddingLeft',
485
- pt: 'paddingTop',
486
- pb: 'paddingBottom',
487
- px: ['paddingLeft', 'paddingRight'],
488
- py: ['paddingTop', 'paddingBottom'],
489
- p: 'padding',
490
- },
491
- values: {
492
- auto: 'auto',
493
- 0: '0px',
494
- px: '1px',
495
- 0.5: '0.125rem',
496
- 1: '0.25rem',
497
- 1.5: '0.375rem',
498
- 2: '0.5rem',
499
- 2.5: '0.625rem',
500
- 3: '0.75rem',
501
- 3.5: '0.875rem',
502
- 4: '1rem',
503
- 5: '1.25rem',
504
- 6: '1.5rem',
505
- 7: '1.75rem',
506
- 8: '2rem',
507
- 9: '2.25rem',
508
- 10: '2.5rem',
509
- 11: '2.75rem',
510
- 12: '3rem',
511
- 14: '3.5rem',
512
- 16: '4rem',
513
- 20: '5rem',
514
- 24: '6rem',
515
- 28: '7rem',
516
- 32: '8rem',
517
- 36: '9rem',
518
- 40: '10rem',
519
- 44: '11rem',
520
- 48: '12rem',
521
- 52: '13rem',
522
- 56: '14rem',
523
- 60: '15rem',
524
- 64: '16rem',
525
- 72: '18rem',
526
- 80: '20rem',
527
- 96: '24rem',
528
- },
529
- };
530
-
531
- const radius = {
532
- keys: {
533
- rounded: 'borderRadius',
534
- 'rounded-t': 'borderTopRadius',
535
- 'rounded-r': 'borderRightRadius',
536
- 'rounded-l': 'borderLeftRadius',
537
- 'rounded-b': 'borderBottomRadius',
538
- 'rounded-tl': ['borderTopRadius', 'borderLeftRadius'],
539
- 'rounded-tr': ['borderTopRadius', 'borderRightRadius'],
540
- 'rounded-bl': ['borderBottomRadius', 'borderLeftRadius'],
541
- 'rounded-br': ['borderBottomRadius', 'borderRightRadius'],
542
- },
543
- values: {
544
- none: '0px',
545
- sm: '0.125rem',
546
- '': '0.25rem',
547
- md: '0.375rem',
548
- lg: '0.5rem',
549
- xl: '0.75rem',
550
- '2xl': '1rem',
551
- '3xl': '1.5rem',
552
- full: '9999px',
553
- },
554
- };
555
-
556
- const borders = {
557
- keys: {
558
- border: 'borderWidth',
559
- 'border-l': 'borderLeftWidth',
560
- 'border-r': 'borderRightWidth',
561
- 'border-t': 'borderTopWidth',
562
- 'border-b': 'borderBottomWidth',
563
- },
564
- values: {
565
- '': '1px',
566
- 0: '0px',
567
- 2: '2px',
568
- 4: '4px',
569
- 8: '8px',
570
- },
571
- };
572
-
573
- const sizes = {
574
- keys: {
575
- h: 'height',
576
- w: 'width',
577
- top: 'top',
578
- left: 'left',
579
- bottom: 'bottom',
580
- right: 'right',
581
- minh: 'minHeight',
582
- minw: 'minWidth',
583
- maxh: 'maxHeight',
584
- maxw: 'maxWidth',
585
- },
586
- values: {
587
- 0: '0px',
588
- px: '1px',
589
- 0.5: '0.125rem',
590
- 1: '0.25rem',
591
- 1.5: '0.375rem',
592
- 2: '0.5rem',
593
- 2.5: '0.625rem',
594
- 3: '0.75rem',
595
- 3.5: '0.875rem',
596
- 4: '1rem',
597
- 5: '1.25rem',
598
- 6: '1.5rem',
599
- 7: '1.75rem',
600
- 8: '2rem',
601
- 9: '2.25rem',
602
- 10: '2.5rem',
603
- 11: '2.75rem',
604
- 12: '3rem',
605
- 14: '3.5rem',
606
- 16: '4rem',
607
- 20: '5rem',
608
- 24: '6rem',
609
- 28: '7rem',
610
- 32: '8rem',
611
- 36: '9rem',
612
- 40: '10rem',
613
- 44: '11rem',
614
- 48: '12rem',
615
- 52: '13rem',
616
- 56: '14rem',
617
- 60: '15rem',
618
- 64: '16rem',
619
- 72: '18rem',
620
- 80: '20rem',
621
- 96: '24rem',
622
- auto: 'auto',
623
- min: 'min-content',
624
- max: 'max-content',
625
- '1/2': percent(1 / 2),
626
- '1/4': percent(1 / 4),
627
- '2/4': percent(2 / 4),
628
- '3/4': percent(3 / 4),
629
- '1/5': percent(1 / 5),
630
- '2/5': percent(2 / 5),
631
- '3/5': percent(3 / 5),
632
- '4/5': percent(4 / 5),
633
- '1/6': percent(1 / 6),
634
- '2/6': percent(2 / 6),
635
- '3/6': percent(3 / 6),
636
- '4/6': percent(4 / 6),
637
- '5/6': percent(5 / 6),
638
- '1/12': percent(1 / 12),
639
- '2/12': percent(2 / 12),
640
- '3/12': percent(3 / 12),
641
- '4/12': percent(4 / 12),
642
- '5/12': percent(5 / 12),
643
- '6/12': percent(6 / 12),
644
- '7/12': percent(7 / 12),
645
- '8/12': percent(8 / 12),
646
- '9/12': percent(9 / 12),
647
- '10/12': percent(10 / 12),
648
- '11/12': percent(11 / 12),
649
- full: percent(1),
650
- },
651
- };
652
-
653
- const classLookup = {
654
- flex: createStyle('display', 'flex'),
655
- 'inline-flex': createStyle('display', 'inline-flex'),
656
- block: createStyle('display', 'block'),
657
- 'inline-block': createStyle('display', 'inline-block'),
658
- inline: createStyle('display', 'inline'),
659
- table: createStyle('display', 'table'),
660
- 'inline-table': createStyle('display', 'inline-table'),
661
- 'inline-table': createStyle('display', 'inline-table'),
662
- grid: createStyle('display', 'grid'),
663
- 'inline-grid': createStyle('display', 'inline-grid'),
664
- contents: createStyle('display', 'contents'),
665
- 'list-item': createStyle('display', 'list-item'),
666
- hidden: createStyle('display', 'none'),
667
- 'flex-1': createStyle('flex', '1'),
668
- 'flex-row': createStyle('flexDirection', 'row'),
669
- 'flex-col': createStyle('flexDirection', 'column'),
670
- 'flex-wrap': createStyle('flexWrap', 'wrap'),
671
- 'flex-nowrap': createStyle('flexWrap', 'nowrap'),
672
- 'flex-wrap-reverse': createStyle('flexWrap', 'wrap-reverse'),
673
- 'items-baseline': createStyle('alignItems', 'baseline'),
674
- 'items-start': createStyle('alignItems', 'flex-start'),
675
- 'items-center': createStyle('alignItems', 'center'),
676
- 'items-end': createStyle('alignItems', 'flex-end'),
677
- 'items-stretch': createStyle('alignItems', 'stretch'),
678
- 'justify-start': createStyle('justifyContent', 'flex-start'),
679
- 'justify-end': createStyle('justifyContent', 'flex-end'),
680
- 'justify-center': createStyle('justifyContent', 'center'),
681
- 'justify-between': createStyle('justifyContent', 'space-between'),
682
- 'justify-around': createStyle('justifyContent', 'space-around'),
683
- 'justify-evenly': createStyle('justifyContent', 'space-evenly'),
684
- 'text-left': createStyle('textAlign', 'left'),
685
- 'text-center': createStyle('textAlign', 'center'),
686
- 'text-right': createStyle('textAlign', 'right'),
687
- 'text-justify': createStyle('textAlign', 'justify'),
688
- underline: createStyle('textDecoration', 'underline'),
689
- 'line-through': createStyle('textDecoration', 'line-through'),
690
- 'no-underline': createStyle('textDecoration', 'none'),
691
- 'whitespace-normal': createStyle('whiteSpace', 'normal'),
692
- 'whitespace-nowrap': createStyle('whiteSpace', 'nowrap'),
693
- 'whitespace-pre': createStyle('whiteSpace', 'pre'),
694
- 'whitespace-pre-line': createStyle('whiteSpace', 'pre-line'),
695
- 'whitespace-pre-wrap': createStyle('whiteSpace', 'pre-wrap'),
696
- 'break-normal': createStyle('wordBreak', 'normal', 'overflowWrap', 'normal'),
697
- 'break-words': createStyle('wordBreak', 'break-word'),
698
- 'break-all': createStyle('wordBreak', 'break-all'),
699
- 'font-sans': createStyle(
700
- 'fontFamily',
701
- `ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"`,
702
- ),
703
- 'font-serif': createStyle('fontFamily', `ui-serif, Georgia, Cambria, "Times New Roman", Times, serif`),
704
- 'font-mono': createStyle('fontFamily', `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace`),
705
- 'font-thin': createStyle('fontWeight', '100'),
706
- 'font-extralight': createStyle('fontWeight', '200'),
707
- 'font-light': createStyle('fontWeight', '300'),
708
- 'font-normal': createStyle('fontWeight', '400'),
709
- 'font-medium': createStyle('fontWeight', '500'),
710
- 'font-semibold': createStyle('fontWeight', '600'),
711
- 'font-bold': createStyle('fontWeight', '700'),
712
- 'font-extrabold': createStyle('fontWeight', '800'),
713
- 'font-black': createStyle('fontWeight', '900'),
714
- 'text-xs': createStyle('fontSize', '0.75rem', 'lineHeight', '1rem'),
715
- 'text-sm': createStyle('fontSize', '0.875rem', 'lineHeight', '1.25rem'),
716
- 'text-base': createStyle('fontSize', '1rem', 'lineHeight', '1.5rem'),
717
- 'text-lg': createStyle('fontSize', '1.125rem', 'lineHeight', '1.75rem'),
718
- 'text-xl': createStyle('fontSize', '1.25rem', 'lineHeight', '1.75rem'),
719
- 'text-2xl': createStyle('fontSize', '1.5rem', 'lineHeight', '2rem'),
720
- 'text-3xl': createStyle('fontSize', '1.875rem', 'lineHeight', '2.25rem'),
721
- 'text-4xl': createStyle('fontSize', '2.25rem', 'lineHeight', '2.5rem'),
722
- 'text-5xl': createStyle('fontSize', '3rem', 'lineHeight', '1'),
723
- 'text-6xl': createStyle('fontSize', '3.75rem;', 'lineHeight', '1'),
724
- 'text-7xl': createStyle('fontSize', '4.5rem', 'lineHeight', '1'),
725
- 'text-8xl': createStyle('fontSize', '6rem', 'lineHeight', '1'),
726
- 'text-9xl': createStyle('fontSize', '8rem', 'lineHeight', '1'),
727
- 'cursor-auto': createStyle('cursor', 'auto'),
728
- 'cursor-default': createStyle('cursor', 'default'),
729
- 'cursor-pointer': createStyle('cursor', 'pointer'),
730
- 'cursor-wait': createStyle('cursor', 'wait'),
731
- 'cursor-text': createStyle('cursor', 'text'),
732
- 'cursor-move': createStyle('cursor', 'move'),
733
- 'cursor-help': createStyle('cursor', 'help'),
734
- 'cursor-not-allowed': createStyle('cursor', 'not-allowed'),
735
- 'pointer-events-none': createStyle('pointerEvents', 'none'),
736
- 'pointer-events-auto': createStyle('pointerEvents', 'auto'),
737
- 'select-none': createStyle('userSelect', 'none'),
738
- 'select-text': createStyle('userSelect', 'text'),
739
- 'select-all': createStyle('userSelect', 'all'),
740
- 'select-auto': createStyle('userSelect', 'auto'),
741
- 'w-screen': '100vw',
742
- 'h-screen': '100vh',
743
- ...mapApply(sizes),
744
- ...mapApply(spacing),
745
- ...mapApply(colors),
746
- ...mapApply(borders),
747
- ...mapApply(radius),
748
- static: createStyle('position', 'static'),
749
- fixed: createStyle('position', 'fixed'),
750
- absolute: createStyle('position', 'absolute'),
751
- relative: createStyle('position', 'relative'),
752
- sticky: createStyle('position', 'sticky'),
753
- 'overflow-auto': createStyle('overflow', 'auto'),
754
- 'overflow-hidden': createStyle('overflow', 'hidden'),
755
- 'overflow-visible': createStyle('overflow', 'visible'),
756
- 'overflow-scroll': createStyle('overflow', 'scroll'),
757
- 'overflow-x-auto': createStyle('overflowX', 'auto'),
758
- 'overflow-y-auto': createStyle('overflowY', 'auto'),
759
- 'overflow-x-hidden': createStyle('overflowX', 'hidden'),
760
- 'overflow-y-hidden': createStyle('overflowY', 'hidden'),
761
- 'overflow-x-visible': createStyle('overflowX', 'visible'),
762
- 'overflow-y-visible': createStyle('overflowY', 'visible'),
763
- 'overflow-x-scroll': createStyle('overflowX', 'scroll'),
764
- 'overflow-y-scroll': createStyle('overflowY', 'scroll'),
765
- 'origin-center': createStyle('transformOrigin', 'center'),
766
- 'origin-top': createStyle('transformOrigin', 'top'),
767
- 'origin-top-right': createStyle('transformOrigin', 'top right'),
768
- 'origin-right': createStyle('transformOrigin', 'right'),
769
- 'origin-bottom-right': createStyle('transformOrigin', 'bottom right'),
770
- 'origin-bottom': createStyle('transformOrigin', 'bottom'),
771
- 'origin-bottom-left': createStyle('transformOrigin', 'bottom left'),
772
- 'origin-left': createStyle('transformOrigin', 'left'),
773
- 'origin-top-left': createStyle('transformOrigin', 'top left'),
774
- 'shadow-sm': createStyle('box-shadow', '0 0 #0000, 0 0 #0000, 0 1px 2px 0 rgba(0, 0, 0, 0.05)'),
775
- shadow: createStyle('box-shadow', '0 0 #0000, 0 0 #0000, 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)'),
776
- 'shadow-md': createStyle('box-shadow', '0 0 #0000, 0 0 #0000, 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'),
777
- 'shadow-lg': createStyle('box-shadow', '0 0 #0000, 0 0 #0000, 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)'),
778
- 'shadow-xl': createStyle('box-shadow', '0 0 #0000, 0 0 #0000, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)'),
779
- 'shadow-2xl': createStyle('box-shadow', '0 0 #0000, 0 0 #0000, 0 25px 50px -12px rgba(0, 0, 0, 0.25)'),
780
- 'shadow-inner': createStyle('box-shadow', '0 0 #0000, 0 0 #0000, inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)'),
781
- 'shadow-none': createStyle('box-shadow', '0 0 #0000, 0 0 #0000, 0 0 #0000'),
782
- 'ring-inset': createStyle('--tw-ring-inset', 'insest'),
783
- 'ring-0': createStyle('box-shadow', ' 0 0 0 calc(0px + 0px) rgba(59, 130, 246, 0.5)'),
784
- 'ring-1': createStyle('box-shadow', ' 0 0 0 calc(1px + 0px) rgba(59, 130, 246, 0.5)'),
785
- 'ring-2': createStyle('box-shadow', ' 0 0 0 calc(2px + 0px) rgba(59, 130, 246, 0.5)'),
786
- 'ring-4': createStyle('box-shadow', ' 0 0 0 calc(4px + 0px) rgba(59, 130, 246, 0.5)'),
787
- 'ring-8': createStyle('box-shadow', ' 0 0 0 calc(8px + 0px) rgba(59, 130, 246, 0.5)'),
788
- ring: createStyle('box-shadow', ' 0 0 0 calc(3px + 0px) rgba(59, 130, 246, 0.5)'),
789
- };
790
-
791
315
  const pageStyles = {
792
316
  '*, ::before, ::after': {
793
317
  boxSizing: 'border-box',
@@ -910,8 +434,6 @@ const pageStyles = {
910
434
  },
911
435
  };
912
436
 
913
- // hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500
914
-
915
437
  const previousValues = new WeakMap();
916
438
  export const unsafeHTML = isBrowser
917
439
  ? directive((value) => (part) => {
@@ -928,7 +450,7 @@ export const unsafeHTML = isBrowser
928
450
  part.setValue(fragment);
929
451
  previousValues.set(part, { value, fragment });
930
452
  })
931
- : (value) => value;
453
+ : (value) => ({ [UNSAFE_HTML]: true, value });
932
454
 
933
455
  const fifo = (q) => q.shift();
934
456
  const filo = (q) => q.pop();
@@ -952,7 +474,7 @@ export const createReducer = ({ initial, reducer, effects }) => {
952
474
  const actions = Object.keys(reducer).reduce((acc, key) => {
953
475
  const reduce = reducer[key];
954
476
  acc[key] = (v) => {
955
- value = reduce(value, v);
477
+ value = create(value, (draft) => reduce(draft, v));
956
478
  subs.forEach((sub) => {
957
479
  sub(value);
958
480
  });
@@ -970,7 +492,7 @@ export const createReducer = ({ initial, reducer, effects }) => {
970
492
  subs.add(fn);
971
493
  },
972
494
  unsubscribe: (fn) => {
973
- subs.remove(fn);
495
+ subs.delete(fn);
974
496
  },
975
497
  actions,
976
498
  effects: effectActions,
@@ -988,12 +510,21 @@ const currentComponent = {
988
510
  },
989
511
  };
990
512
 
513
+ const identity = (v) => v;
514
+
991
- export const useReducer = (reducer) => {
515
+ export const useReducer = (reducer, selector = identity) => {
992
516
  const comp = currentComponent.get();
993
517
  const index = comp.hooks.index++;
994
518
  if (!comp.hooks.data[index]) {
995
519
  comp.hooks.data[index] = reducer.subscribe ? reducer : createReducer(reducer);
520
+ comp.hooks.selected[index] = selector(comp.hooks.data[index].getValue());
996
- comp.hooks.data[index].subscribe(() => comp.update());
521
+ comp.hooks.data[index].subscribe((value) => {
522
+ const next = selector(value);
523
+ if (!Object.is(next, comp.hooks.selected[index])) {
524
+ comp.hooks.selected[index] = next;
525
+ comp.update();
526
+ }
527
+ });
997
528
  }
998
529
  const state = comp.hooks.data[index].getValue();
999
530
  return { ...state, actions: comp.hooks.data[index].actions, effects: comp.hooks.data[index].effects };
@@ -1008,6 +539,7 @@ export const useEffect = (fn, deps) => {
1008
539
  };
1009
540
 
1010
541
  const registry = {};
542
+ const injectedStyleTags = new Set();
1011
543
  export const getElement = (name) => registry[name];
1012
544
  const BaseElement = isBrowser ? window.HTMLElement : class {};
1013
545
  export const createElement = (meta, renderFn) => {
@@ -1017,6 +549,10 @@ export const createElement = (meta, renderFn) => {
1017
549
  return funcParams.map((k) => k.toLowerCase());
1018
550
  }
1019
551
 
552
+ static get styles() {
553
+ return renderFn.styles ? `@scope (${name}) {\n${renderFn.styles}\n}\n` : '';
554
+ }
555
+
1020
556
  constructor(ssrAttrs) {
1021
557
  super();
1022
558
  this._dirty = false;
@@ -1029,10 +565,9 @@ export const createElement = (meta, renderFn) => {
1029
565
  deps: {},
1030
566
  effects: {},
1031
567
  cleanups: {},
568
+ selected: {},
1032
569
  };
1033
570
  this.renderFn = renderFn;
1034
- // this.prevClassList = [];
1035
- // this.shadow = this.attachShadow({ mode: 'open' });
1036
571
  }
1037
572
 
1038
573
  connectedCallback() {
@@ -1042,6 +577,11 @@ export const createElement = (meta, renderFn) => {
1042
577
 
1043
578
  disconnectedCallback() {
1044
579
  this._connected = false;
580
+ const cleanups = this.hooks.cleanups;
581
+ Object.keys(cleanups).forEach((key) => {
582
+ cleanups[key] && cleanups[key]();
583
+ delete cleanups[key];
584
+ });
1045
585
  }
1046
586
 
1047
587
  attributeChangedCallback(key, oldValue, newValue) {
@@ -1110,32 +650,12 @@ export const createElement = (meta, renderFn) => {
1110
650
  location: isBrowser ? window.location : global?.location,
1111
651
  });
1112
652
  if (isBrowser) {
1113
- // TODO: this can be optimized when we know whether the value belongs in a class (AttributePart)
1114
- // maybe do this in lit-html itselfs
1115
- const newClassList = getClassList(template).filter((cls) => {
1116
- const globalStyles = document.getElementById('global').textContent;
653
+ const styles = RenderElement.styles;
1117
- return !globalStyles.includes('.' + cls);
1118
- });
1119
- if (newClassList.length > 0) {
654
+ if (styles && !injectedStyleTags.has(name)) {
655
+ injectedStyleTags.add(name);
1120
- document.getElementById('global').textContent += compileTw(newClassList);
656
+ document.getElementById('global').textContent += styles;
1121
657
  }
1122
658
  renderHtml(template, this);
1123
- // For shadows only
1124
- // if (!this.styleElement) {
1125
- // render(template, this.shadow);
1126
- // const styleSheet = compileTw(classList);
1127
- // this.prevClassList = classList;
1128
- // this.styleElement = document.createElement('style');
1129
- // this.shadow.appendChild(this.styleElement).textContent = css(pageStyles) + styleSheet;
1130
- // } else {
1131
- // const missingClassList = classList.filter((cls) => !this.prevClassList.includes(cls));
1132
- // if (missingClassList.length > 0) {
1133
- // const styleSheet = compileTw(missingClassList);
1134
- // this.styleElement.textContent += '\n' + styleSheet;
1135
- // this.prevClassList.push(...missingClassList);
1136
- // }
1137
- // render(template, this.shadow);
1138
- // }
1139
659
  } else {
1140
660
  return renderHtml(template);
1141
661
  }
@@ -1150,11 +670,18 @@ export const createElement = (meta, renderFn) => {
1150
670
  return renderFn;
1151
671
  };
1152
672
 
673
+ const collectComponentStyles = (html) =>
674
+ Object.keys(registry)
675
+ .filter((tag) => html.includes('<' + tag))
676
+ .map((tag) => registry[tag].styles)
677
+ .filter(Boolean)
678
+ .join('\n');
679
+
1153
680
  export const createPage = ({ head, body }) => {
1154
681
  return ({ headScript, bodyScript, lang, props }) => {
1155
682
  const headHtml = renderHtml(head(props));
1156
683
  const bodyHtml = renderHtml(body(props));
1157
- const classes = extractClasses(bodyHtml);
684
+ const componentStyles = collectComponentStyles(headHtml + bodyHtml);
1158
685
  return `
1159
686
  <!DOCTYPE html>
1160
687
  <html lang="${lang}">
@@ -1167,8 +694,8 @@ export const createPage = ({ head, body }) => {
1167
694
  <link rel="icon" type="image/png" href="/assets/icon.png" />
1168
695
  ${headHtml}
1169
696
  <style id="global">
697
+ ${stylesFromObject(pageStyles)}
1170
- ${css(pageStyles)}
698
+ ${componentStyles}
1171
- ${compileTw(new Set(classes.split(' ')))}
1172
699
  </style>
1173
700
  ${headScript}
1174
701
  </head>
index.test.js CHANGED
@@ -1,36 +1,36 @@
1
1
  import { expect, test, jest } from '@jest/globals';
2
- import { getElement, createElement, createPage, createReducer, html, renderHtml, unsafeHTML, css, compileTw, useReducer } from './index.js';
2
+ import { getElement, createElement, createPage, createReducer, html, renderHtml, unsafeHTML, css, useReducer, useEffect } from './index.js';
3
3
 
4
4
  global.__DEV = true;
5
5
 
6
- test('css', () => {
6
+ test('css tagged template', () => {
7
+ const color = 'magenta';
7
- const styles = css({
8
+ const styles = css`
9
+ :scope {
10
+ display: flex;
11
+ }
8
- button: {
12
+ button {
9
- color: 'magenta',
13
+ color: ${color};
10
- fontSize: '10px',
11
- '@media screen and (min-width:40em)': {
12
- fontSize: '64px',
13
- },
14
+ }
14
- ':hover': {
15
- color: 'black',
16
- },
17
- '@media screen and (min-width:56em)': {
18
- ':hover': {
19
- color: 'navy',
20
- },
21
- },
22
- },
23
- container: {
24
- flex: 1,
25
- alignItems: 'center',
26
- justifyContent: 'center',
27
- },
28
- });
15
+ `;
29
16
  expect(styles).toMatchSnapshot();
30
17
  });
31
18
 
19
+ test('createElement styles are scoped and injected via SSR', () => {
32
- test('compileTw', () => {
20
+ const Styled = () => {
21
+ return html` <div class="count"></div> `;
22
+ };
23
+ Styled.styles = css`
24
+ :scope {
25
+ display: flex;
26
+ }
27
+ .count {
28
+ color: magenta;
29
+ }
30
+ `;
33
- expect(compileTw('text-white font-bold border border-black p-4 m-4 w-20 h-20'.split(' '))).toMatchSnapshot();
31
+ createElement({ url: '/styled-element.js' }, Styled);
32
+ const Clazz = getElement('styled-element');
33
+ expect(Clazz.styles).toMatchSnapshot();
34
34
  });
35
35
 
36
36
  test('renderHtml', () => {
@@ -78,6 +78,16 @@ test('render unsafeHTML', () => {
78
78
  expect(res).toMatchSnapshot();
79
79
  });
80
80
 
81
+ test('renderHtml escapes text and attribute values', () => {
82
+ const evil = '<script>alert(1)</script>';
83
+ const quote = 'a" onmouseover="alert(1)';
84
+ const template = html`<div class="${quote}">${evil}</div>`;
85
+ const res = renderHtml(template);
86
+ expect(res).not.toContain('<script>');
87
+ expect(res).not.toContain('onmouseover="alert(1)"');
88
+ expect(res).toMatchSnapshot();
89
+ });
90
+
81
91
  test('render single template', () => {
82
92
  const template = html` <div>${html`NoCountry ${false}`}</div> `;
83
93
  const res = renderHtml(template);
@@ -104,23 +114,77 @@ test('createReducer', () => {
104
114
  const countReducer = createReducer({
105
115
  initial: {
106
116
  count: 0,
117
+ other: { untouched: true },
107
118
  },
108
119
  reducer: {
109
- increment: (state, a) => ({ ...state, count: state.count + a }),
120
+ increment: (state, a) => {
121
+ state.count += a;
122
+ },
110
- decrement: (state, a) => ({ ...state, count: state.count - a }),
123
+ decrement: (state, a) => {
124
+ state.count -= a;
125
+ },
111
126
  },
112
127
  });
113
128
  const mock = jest.fn();
114
129
  countReducer.subscribe(mock);
130
+ const before = countReducer.getValue();
115
131
  countReducer.actions.increment(4);
116
132
  expect(countReducer.getValue().count).toEqual(4);
117
- expect(mock).toBeCalledWith({ count: 4 });
133
+ expect(mock).toHaveBeenCalledWith({ count: 4, other: { untouched: true } });
118
134
  countReducer.actions.decrement(1);
119
135
  expect(countReducer.getValue().count).toEqual(3);
120
- expect(mock).toBeCalledWith({ count: 3 });
136
+ expect(mock).toHaveBeenCalledWith({ count: 3, other: { untouched: true } });
121
137
  countReducer.actions.decrement(2);
138
+ const after = countReducer.getValue();
122
- expect(countReducer.getValue().count).toEqual(1);
139
+ expect(after.count).toEqual(1);
123
- expect(mock).toBeCalledWith({ count: 1 });
140
+ expect(mock).toHaveBeenCalledWith({ count: 1, other: { untouched: true } });
141
+ expect(after).not.toBe(before);
142
+ expect(after.other).toBe(before.other);
143
+ });
144
+
145
+ test('useReducer selector only updates when the selected slice changes', () => {
146
+ const store = createReducer({
147
+ initial: { count: 0, other: 0 },
148
+ reducer: {
149
+ incCount: (state) => {
150
+ state.count += 1;
151
+ },
152
+ incOther: (state) => {
153
+ state.other += 1;
154
+ },
155
+ },
156
+ });
157
+ createElement({ url: '/count-only.js' }, () => {
158
+ useReducer(store, (state) => state.count);
159
+ return html` <div></div> `;
160
+ });
161
+ const Clazz = getElement('count-only');
162
+ const instance = new Clazz();
163
+ instance.render();
164
+ const updateSpy = jest.fn();
165
+ instance.update = updateSpy;
166
+
167
+ store.actions.incOther();
168
+ expect(updateSpy).not.toHaveBeenCalled();
169
+
170
+ store.actions.incCount();
171
+ expect(updateSpy).toHaveBeenCalledTimes(1);
172
+ });
173
+
174
+ test('useEffect cleanup runs on disconnect', () => {
175
+ const cleanup = jest.fn();
176
+ createElement({ url: '/cleanup-element.js' }, () => {
177
+ useEffect(() => cleanup, []);
178
+ return html` <div></div> `;
179
+ });
180
+ const Clazz = getElement('cleanup-element');
181
+ const instance = new Clazz();
182
+ instance.render();
183
+ instance._flushEffects();
184
+ expect(cleanup).not.toHaveBeenCalled();
185
+
186
+ instance.disconnectedCallback();
187
+ expect(cleanup).toHaveBeenCalledTimes(1);
124
188
  });
125
189
 
126
190
  test('createElement without attrs', () => {
@@ -140,7 +204,9 @@ test('createElement with attrs and hooks', () => {
140
204
  count: 3,
141
205
  },
142
206
  reducer: {
143
- setValue: (state, v) => ({ ...state, count: v }),
207
+ setValue: (state, v) => {
208
+ state.count = v;
209
+ },
144
210
  },
145
211
  });
146
212
  return html`
package-lock.json CHANGED
@@ -8,6 +8,9 @@
8
8
  "name": "atoms-element",
9
9
  "version": "3.0.1",
10
10
  "license": "MIT",
11
+ "dependencies": {
12
+ "mutative": "^1.3.0"
13
+ },
11
14
  "devDependencies": {
12
15
  "jest": "^27.0.5"
13
16
  },
@@ -3048,6 +3051,15 @@
3048
3051
  "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
3049
3052
  "dev": true
3050
3053
  },
3054
+ "node_modules/mutative": {
3055
+ "version": "1.3.0",
3056
+ "resolved": "https://registry.npmjs.org/mutative/-/mutative-1.3.0.tgz",
3057
+ "integrity": "sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ==",
3058
+ "license": "MIT",
3059
+ "engines": {
3060
+ "node": ">=14.0"
3061
+ }
3062
+ },
3051
3063
  "node_modules/natural-compare": {
3052
3064
  "version": "1.4.0",
3053
3065
  "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -6313,6 +6325,11 @@
6313
6325
  "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
6314
6326
  "dev": true
6315
6327
  },
6328
+ "mutative": {
6329
+ "version": "1.3.0",
6330
+ "resolved": "https://registry.npmjs.org/mutative/-/mutative-1.3.0.tgz",
6331
+ "integrity": "sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ=="
6332
+ },
6316
6333
  "natural-compare": {
6317
6334
  "version": "1.4.0",
6318
6335
  "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
package.json CHANGED
@@ -39,5 +39,8 @@
39
39
  "singleQuote": true,
40
40
  "arrowParens": "always",
41
41
  "trailingComma": "all"
42
+ },
43
+ "dependencies": {
44
+ "mutative": "^1.3.0"
42
45
  }
43
46
  }