~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.


a4d51747 Peter John

4 years ago
new class based approach
Files changed (2) hide show
  1. src/index.js +128 -167
  2. test/index.test.js +62 -76
src/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { html, render as litRender, directive, NodePart, AttributePart, PropertyPart, isPrimitive } from './lit-html.js';
2
2
 
3
- const registry = {};
4
3
  const isBrowser = typeof window !== 'undefined';
5
4
  export { html, isBrowser };
6
5
 
@@ -138,14 +137,10 @@ export const classMap = isBrowser
138
137
  return value;
139
138
  };
140
139
 
141
- let currentCursor;
142
- let currentComponent;
143
- let logError = (msg) => {
140
+ const logError = (msg) => {
141
+ if (isBrowser ? window.__DEV__ : global.__DEV) {
144
- console.warn(msg);
142
+ console.warn(msg);
145
- };
143
+ }
146
-
147
- export const setLogError = (fn) => {
148
- logError = fn;
149
144
  };
150
145
 
151
146
  const checkRequired = (context, data) => {
@@ -230,62 +225,20 @@ export const array = checkComplex('array', (innerType, context, data) => {
230
225
  });
231
226
  export const func = checkComplex('function', (innerType, context, data) => {});
232
227
 
233
- export const hooks = (config) => {
234
- const h = currentComponent.hooks;
235
- const c = currentComponent;
236
- const index = currentCursor++;
237
- if (h.values.length <= index && config.oncreate) {
238
- h.values[index] = config.oncreate(h, c, index);
239
- }
240
- if (config.onupdate) {
241
- h.values[index] = config.onupdate(h, c, index);
242
- }
243
- return h.values[index];
244
- };
245
-
246
- export const __setCurrent__ = (c) => {
247
- currentComponent = c;
248
- currentCursor = 0;
249
- };
250
-
251
- export const useDispatchEvent = (name) =>
252
- hooks({
253
- oncreate: (_, c) => (data) => c.dispatchEvent(new CustomEvent(name, data)),
254
- });
255
- export const useRef = (initialValue) =>
256
- hooks({
257
- oncreate: (_h, _c) => ({ current: initialValue }),
258
- });
259
- export const useState = (initialState) =>
260
- hooks({
261
- oncreate: (h, c, i) => [
262
- typeof initialState === 'function' ? initialState() : initialState,
263
- function setState(nextState) {
264
- const state = h.values[i][0];
265
- if (typeof nextState === 'function') {
266
- nextState = nextState(state);
267
- }
268
- if (!Object.is(state, nextState)) {
269
- h.values[i][0] = nextState;
270
- c.update();
271
- }
272
- },
273
- ],
274
- });
275
- export const useReducer = (reducer, initialState) =>
228
+ // export const useReducer = (reducer, initialState) =>
276
- hooks({
229
+ // hooks({
277
- oncreate: (h, c, i) => [
230
+ // oncreate: (h, c, i) => [
278
- initialState,
231
+ // initialState,
279
- function dispatch(action) {
232
+ // function dispatch(action) {
280
- const state = h.values[i][0];
233
+ // const state = h.values[i][0];
281
- const nextState = reducer(state, action);
234
+ // const nextState = reducer(state, action);
282
- if (!Object.is(state, nextState)) {
235
+ // if (!Object.is(state, nextState)) {
283
- h.values[i][0] = nextState;
236
+ // h.values[i][0] = nextState;
284
- c.update();
237
+ // c.update();
285
- }
238
+ // }
286
- },
239
+ // },
287
- ],
240
+ // ],
288
- });
241
+ // });
289
242
  const depsChanged = (prev, next) => prev == null || next.some((f, i) => !Object.is(f, prev[i]));
290
243
  export const useEffect = (handler, deps) =>
291
244
  hooks({
@@ -296,39 +249,39 @@ export const useEffect = (handler, deps) =>
296
249
  }
297
250
  },
298
251
  });
252
+
253
+ // createHooks(config) {
254
+ // const index = this.currentCursor++;
255
+ // if (this.hooks.values.length <= index && config.oncreate) {
256
+ // this.hooks.values[index] = config.oncreate(this.hooks, this, index);
257
+ // }
258
+ // if (config.onupdate) {
259
+ // this.hooks.values[index] = config.onupdate(this.hooks, this, index);
260
+ // }
261
+ // return this.hooks.values[index];
262
+ // }
263
+ //
299
- export const useLayoutEffect = (handler, deps) =>
264
+ // export const useLayoutEffect = (handler, deps) =>
300
- hooks({
265
+ // hooks({
301
- onupdate(h, _, i) {
266
+ // onupdate(h, _, i) {
302
- if (!deps || depsChanged(h.deps[i], deps)) {
267
+ // if (!deps || depsChanged(h.deps[i], deps)) {
303
- h.deps[i] = deps || [];
268
+ // h.deps[i] = deps || [];
304
- h.layoutEffects[i] = handler;
269
+ // h.layoutEffects[i] = handler;
305
- }
270
+ // }
306
- },
271
+ // },
307
- });
272
+ // });
308
- export const useMemo = (fn, deps) =>
273
+ // export const useMemo = (fn, deps) =>
309
- hooks({
274
+ // hooks({
310
- onupdate(h, _, i) {
275
+ // onupdate(h, _, i) {
311
- let value = h.values[i];
276
+ // let value = h.values[i];
312
- if (!deps || depsChanged(h.deps[i], deps)) {
277
+ // if (!deps || depsChanged(h.deps[i], deps)) {
313
- h.deps[i] = deps || [];
278
+ // h.deps[i] = deps || [];
314
- value = fn();
279
+ // value = fn();
315
- }
280
+ // }
316
- return value;
281
+ // return value;
317
- },
282
+ // },
318
- });
283
+ // });
319
- export const useCallback = (callback, deps) => useMemo(() => callback, deps);
284
+ // export const useCallback = (callback, deps) => useMemo(() => callback, deps);
320
- export const useConfig = () => {
321
- if (isBrowser) {
322
- return window.config;
323
- }
324
- return global.config;
325
- };
326
- export const useLocation = () => {
327
- if (isBrowser) {
328
- return window.location;
329
- }
330
- return global.location;
331
- };
332
285
 
333
286
  const batch = (runner, pick, callback) => {
334
287
  const q = [];
@@ -358,33 +311,60 @@ const enqueueEffects = batch(task, filo, (c) => c._flushEffects('effects'));
358
311
  const enqueueUpdate = batch(microtask, fifo, (c) => c._performUpdate());
359
312
 
360
313
  const BaseElement = isBrowser ? window.HTMLElement : class {};
361
-
314
+ const count = [0];
315
+ const registry = {};
362
316
  export class AtomsElement extends BaseElement {
317
+ static register() {
318
+ registry[this.name] = this;
319
+ if (isBrowser) {
320
+ if (window.customElements.get(this.name)) {
321
+ return;
322
+ } else {
323
+ window.customElements.define(this.name, registry[this.name]);
324
+ }
325
+ }
326
+ }
327
+
328
+ static getElement(name) {
329
+ return registry[name];
330
+ }
331
+
332
+ static getNextIndex() {
333
+ count[0] = count[0] + 1;
334
+ return count[0];
335
+ }
336
+
337
+ static get observedAttributes() {
338
+ if (!this.attrTypes) {
339
+ return [];
340
+ }
341
+ return Object.keys(this.attrTypes)
342
+ .filter((key) => this.attrTypes[key].type !== 'function')
343
+ .map((k) => k.toLowerCase());
344
+ }
345
+
363
- constructor() {
346
+ constructor(attrs) {
364
347
  super();
348
+ this._id = `atoms:${this.constructor.getNextIndex()}`;
365
349
  this._dirty = false;
366
350
  this._connected = false;
367
351
  this.hooks = {
352
+ currentCursor: 0,
368
353
  values: [],
369
354
  deps: [],
370
355
  effects: [],
371
356
  layoutEffects: [],
372
357
  cleanup: [],
373
358
  };
374
- this.props = {};
375
- this.attrTypes = {};
359
+ this.attrs = attrs;
376
- this.name = '';
377
- this.renderer = () => {};
360
+ this.config = isBrowser ? window.config : global.config;
378
- this.attrTypes = {};
379
- this.funcKeys = [];
380
- this.attrTypesMap = {};
361
+ this.location = isBrowser ? window.location : global.location;
381
362
  }
363
+
382
364
  connectedCallback() {
383
365
  this._connected = true;
384
366
  if (isBrowser) {
385
367
  this.update();
386
- } else {
387
- __setCurrent__(this);
388
368
  }
389
369
  }
390
370
  disconnectedCallback() {
@@ -396,13 +376,6 @@ export class AtomsElement extends BaseElement {
396
376
  }
397
377
 
398
378
  attributeChangedCallback(key, oldValue, newValue) {
399
- const attr = this.attrTypesMap[key];
400
- if (!attr) {
401
- return;
402
- }
403
- const data = attr.propType.parse(newValue);
404
- attr.propType.validate(`<${this.name}> ${key}`, data);
405
- this.props[attr.propName] = data;
406
379
  if (this._connected) {
407
380
  this.update();
408
381
  }
@@ -415,16 +388,18 @@ export class AtomsElement extends BaseElement {
415
388
  this._dirty = true;
416
389
  enqueueUpdate(this);
417
390
  }
391
+
418
392
  _performUpdate() {
419
393
  if (!this._connected) {
420
394
  return;
421
395
  }
396
+ this.hooks.currentCursor = 0;
422
- __setCurrent__(this);
397
+ render(this.render(), this);
423
- this.render();
424
398
  enqueueLayoutEffects(this);
425
399
  enqueueEffects(this);
426
400
  this._dirty = false;
427
401
  }
402
+
428
403
  _flushEffects(effectKey) {
429
404
  const effects = this.hooks[effectKey];
430
405
  const cleanups = this.hooks.cleanup;
@@ -440,58 +415,44 @@ export class AtomsElement extends BaseElement {
440
415
  }
441
416
  }
442
417
 
443
- render() {
418
+ useState(initialState) {
419
+ const index = this.hooks.currentCursor++;
444
- this.funcKeys.forEach((key) => {
420
+ if (this.hooks.values.length <= index) {
445
- this.props[key] = this[key];
421
+ this.hooks.values[index] = [
422
+ typeof initialState === 'function' ? initialState() : initialState,
423
+ (nextState) => {
424
+ const state = this.hooks.values[index][0];
425
+ if (typeof nextState === 'function') {
426
+ nextState = nextState(state);
427
+ }
428
+ if (!Object.is(state, nextState)) {
429
+ this.hooks.values[index][0] = nextState;
430
+ this.update();
431
+ }
446
- });
432
+ },
447
- if (isBrowser) {
448
- render(this.renderer(this.props), this);
449
- } else {
433
+ ];
450
- __setCurrent__(this);
451
- return render(this.renderer(this.props), this);
452
434
  }
435
+ return this.hooks.values[index];
453
436
  }
454
- }
455
- export const getElement = (name) => registry[name];
456
-
457
- export function defineElement(name, fn, attrTypes = {}) {
458
- const keys = Object.keys(attrTypes);
459
- registry[name] = {
460
- fn,
461
- attrTypes,
462
- Clazz: class extends AtomsElement {
463
- constructor(attrs) {
464
- super();
465
- this.name = name;
466
- this.renderer = fn;
467
- this.attrTypes = attrTypes;
468
- this.funcKeys = keys.filter((key) => attrTypes[key].type === 'function');
469
- this.attrTypesMap = keys
470
- .filter((key) => attrTypes[key].type !== 'function')
471
- .reduce((acc, key) => {
472
- acc[key.toLowerCase()] = {
473
- propName: key,
474
- propType: attrTypes[key],
475
- };
476
- return acc;
477
- }, {});
478
- if (attrs) {
479
- attrs.forEach((item) => {
480
- this.attributeChangedCallback(item.name, null, item.value);
481
- });
482
- }
483
- }
484
437
 
485
- static get observedAttributes() {
438
+ useRef() {
486
- return keys.map((k) => k.toLowerCase());
439
+ const index = this.currentCursor++;
487
- }
488
- },
489
- };
490
- if (isBrowser) {
491
- if (window.customElements.get(name)) {
440
+ if (this.hooks.values.length <= index) {
492
- return;
493
- } else {
494
- window.customElements.define(name, registry[name].Clazz);
441
+ this.hooks.values[index] = { current: initialValue };
495
442
  }
443
+ return this.hooks.values[index];
444
+ }
445
+
446
+ getAttrs() {
447
+ return Object.keys(this.constructor.attrTypes)
448
+ .filter((key) => this.constructor.attrTypes[key].type !== 'function')
449
+ .reduceRight((acc, key) => {
450
+ const attrType = this.constructor.attrTypes[key];
451
+ const newValue = isBrowser ? this.getAttribute(key.toLowerCase()) : this.attrs.find((item) => item.name === key.toLowerCase()).value;
452
+ const data = attrType.parse(newValue);
453
+ attrType.validate(`<${this.constructor.name}> ${key}`, data);
454
+ acc[key] = data;
455
+ return acc;
456
+ }, {});
496
457
  }
497
458
  }
test/index.test.js CHANGED
@@ -1,27 +1,7 @@
1
1
  import { expect, test, jest } from '@jest/globals';
2
- import {
3
- html,
4
- render,
5
- number,
6
- boolean,
7
- string,
8
- array,
9
- func,
10
- object,
11
- setLogError,
12
- defineElement,
13
- getElement,
14
- useConfig,
15
- useLocation,
16
- useState,
17
- unsafeHTML,
18
- classMap,
19
- } from '../src/index.js';
2
+ import { AtomsElement, html, render, number, boolean, string, array, func, object, unsafeHTML, classMap } from '../src/index.js';
20
3
 
21
- const logMock = jest.fn();
4
+ global.__DEV = true;
22
- setLogError(logMock);
23
-
24
- const expectError = (msg) => expect(logMock).toHaveBeenCalledWith(msg);
25
5
 
26
6
  const primitives = [
27
7
  {
@@ -46,6 +26,7 @@ const primitives = [
46
26
 
47
27
  primitives.forEach((value) =>
48
28
  it(`${value.type}`, () => {
29
+ const spy = jest.spyOn(global.console, 'warn').mockImplementation();
49
30
  const context = 'key';
50
31
  expect(value.validator.type).toEqual(value.type);
51
32
  expect(value.validator.isRequired.type).toEqual(value.type);
@@ -55,21 +36,23 @@ primitives.forEach((value) =>
55
36
  value.validator.isRequired.validate(context, v);
56
37
  }
57
38
  value.validator.isRequired.validate(context);
58
- expectError(`'key' Field is required`);
39
+ expect(console.warn).toHaveBeenCalledWith(`'key' Field is required`);
59
40
  for (const v of value.invalid) {
60
41
  value.validator.validate(context, v);
61
- expectError(`'key' Expected type '${value.type}' got type '${typeof v}'`);
42
+ expect(console.warn).toHaveBeenCalledWith(`'key' Expected type '${value.type}' got type '${typeof v}'`);
62
43
  }
44
+ spy.mockRestore();
63
45
  }),
64
46
  );
65
47
 
66
48
  test('object', () => {
49
+ const spy = jest.spyOn(global.console, 'warn').mockImplementation();
67
50
  const context = 'data';
68
51
  object({}).validate(context, { name: '123' });
69
52
  object({ name: string }).validate(context, { name: '123' });
70
53
  object({ name: string.isRequired }).validate(context, { name: '' });
71
54
  object({ name: string.isRequired }).validate(context, {});
72
- expectError(`'data.name' Field is required`);
55
+ expect(console.warn).toHaveBeenCalledWith(`'data.name' Field is required`);
73
56
 
74
57
  const schema = object({
75
58
  address: object({
@@ -78,14 +61,14 @@ test('object', () => {
78
61
  });
79
62
  schema.validate(context, {});
80
63
  schema.validate(context, '123');
81
- expectError(`'data' Expected object literal '{}' got 'string'`);
64
+ expect(console.warn).toHaveBeenCalledWith(`'data' Expected object literal '{}' got 'string'`);
82
65
  schema.validate(context, {
83
66
  address: {},
84
67
  });
85
68
  schema.validate(context, {
86
69
  address: '123',
87
70
  });
88
- expectError(`'data.address' Expected object literal '{}' got 'string'`);
71
+ expect(console.warn).toHaveBeenCalledWith(`'data.address' Expected object literal '{}' got 'string'`);
89
72
  schema.validate(context, {
90
73
  address: {
91
74
  street: 'avenue 1',
@@ -96,7 +79,7 @@ test('object', () => {
96
79
  street: false,
97
80
  },
98
81
  });
99
- expectError(`'data.address.street' Expected type 'string' got type 'boolean'`);
82
+ expect(console.warn).toHaveBeenCalledWith(`'data.address.street' Expected type 'string' got type 'boolean'`);
100
83
 
101
84
  const schema2 = object({
102
85
  address: object({
@@ -112,17 +95,19 @@ test('object', () => {
112
95
  schema2.validate(context, {
113
96
  address: {},
114
97
  });
115
- expectError(`'data.address.street' Field is required`);
98
+ expect(console.warn).toHaveBeenCalledWith(`'data.address.street' Field is required`);
99
+ spy.mockRestore();
116
100
  });
117
101
 
118
102
  test('array', () => {
103
+ const spy = jest.spyOn(global.console, 'warn').mockImplementation();
119
104
  const context = 'items';
120
105
  array(string).validate(context, ['123']);
121
106
  array(string).validate(context, [123]);
122
- expectError(`'items[0]' Expected type 'string' got type 'number'`);
107
+ expect(console.warn).toHaveBeenCalledWith(`'items[0]' Expected type 'string' got type 'number'`);
123
108
  array(array(string)).validate(context, [['123']]);
124
109
  array(array(string)).validate(context, [[123]]);
125
- expectError(`'items[0][0]' Expected type 'string' got type 'number'`);
110
+ expect(console.warn).toHaveBeenCalledWith(`'items[0][0]' Expected type 'string' got type 'number'`);
126
111
 
127
112
  const schema = object({
128
113
  street: string.isRequired,
@@ -130,25 +115,14 @@ test('array', () => {
130
115
  array(schema).validate(context, []);
131
116
  array(schema).validate(context, [{ street: '123' }, { street: '456' }, { street: '789' }]);
132
117
  array(schema).validate(context, [{}]);
133
- expectError(`'items[0].street' Field is required`);
118
+ expect(console.warn).toHaveBeenCalledWith(`'items[0].street' Field is required`);
134
119
  array(schema).validate(context, [{ street: false }]);
135
- expectError(`'items[0].street' Expected type 'string' got type 'boolean'`);
120
+ expect(console.warn).toHaveBeenCalledWith(`'items[0].street' Expected type 'string' got type 'boolean'`);
136
121
  array(schema).validate(context, [{ street: '123' }, {}]);
137
- expectError(`'items[1].street' Field is required`);
122
+ expect(console.warn).toHaveBeenCalledWith(`'items[1].street' Field is required`);
138
123
  array(schema).validate(context, [{ street: '123' }, { street: false }]);
139
- expectError(`'items[1].street' Expected type 'string' got type 'boolean'`);
124
+ expect(console.warn).toHaveBeenCalledWith(`'items[1].street' Expected type 'string' got type 'boolean'`);
140
- });
141
-
142
- test('useConfig', async () => {
143
- expect(useConfig()).toBe(undefined);
144
- global.config = {};
145
- expect(useConfig()).toMatchObject({});
146
- });
147
-
148
- test('useLocation', async () => {
149
- expect(useLocation()).toBe(undefined);
150
- global.location = {};
125
+ spy.mockRestore();
151
- expect(useLocation()).toMatchObject({});
152
126
  });
153
127
 
154
128
  test('render', async () => {
@@ -256,38 +230,50 @@ test('render multi template', async () => {
256
230
  `);
257
231
  });
258
232
 
259
- test('defineElement', async () => {
233
+ test('AtomsElement', async () => {
234
+ class AppItem extends AtomsElement {
235
+ static name = 'app-item';
236
+
260
- const attrTypes = {
237
+ static attrTypes = {
261
- perPage: string.isRequired,
238
+ perPage: string.isRequired,
262
- address: object({
239
+ address: object({
263
- street: string.isRequired,
240
+ street: string.isRequired,
264
- }).isRequired,
241
+ }).isRequired,
265
- renderItem: func(),
242
+ renderItem: func(),
266
- };
243
+ };
244
+
245
+ static css = ``;
246
+
247
+ render() {
248
+ const {
249
+ perPage,
267
- const AppItem = ({ perPage, address: { street }, renderItem }) => {
250
+ address: { street },
251
+ } = this.getAttrs();
268
- const [count] = useState(0);
252
+ const [count] = this.useState(0);
269
- return html`
253
+ return html`
270
- <div perPage=${perPage}>
254
+ <div perPage=${perPage}>
271
- <p>street: ${street}</p>
255
+ <p>street: ${street}</p>
272
- <p>count: ${count}</p>
256
+ <p>count: ${count}</p>
273
- ${renderItem()}
257
+ ${this.renderItem()}
274
- </div>
258
+ </div>
275
- `;
259
+ `;
276
- };
260
+ }
261
+ }
277
- defineElement('app-item', AppItem, attrTypes);
262
+ AppItem.register();
278
- const { Clazz } = getElement('app-item');
263
+ const Clazz = AtomsElement.getElement('app-item');
264
+ expect(Clazz.name).toEqual(AppItem.name);
279
- const instance = new Clazz([
265
+ const instance = new AppItem([
280
266
  { name: 'address', value: JSON.stringify({ street: '123' }).replace(/"/g, `'`) },
281
267
  { name: 'perpage', value: '1' },
282
268
  ]);
283
269
  instance.renderItem = () => html`<div><p>render item 1</p></div>`;
284
- expect(Clazz.observedAttributes).toEqual(['perpage', 'address', 'renderitem']);
270
+ expect(AppItem.observedAttributes).toEqual(['perpage', 'address']);
285
- const res = await instance.render();
271
+ const res = await render(instance.render());
286
272
  expect(res).toEqual(`
287
- <div perPage="1">
273
+ <div perPage="1">
288
- <p>street: 123</p>
274
+ <p>street: 123</p>
289
- <p>count: 0</p>
275
+ <p>count: 0</p>
290
- <div><p>render item 1</p></div>
276
+ <div><p>render item 1</p></div>
291
- </div>
277
+ </div>
292
- `);
278
+ `);
293
279
  });