1 | import kebabCase from 'lodash.kebabcase';
|
2 | import camelCase from 'lodash.camelcase';
|
3 | import cloneDeep from 'lodash.clonedeep';
|
4 | import Property from './property';
|
5 | import { noop, decorate, clone, identity } from './utilities';
|
6 | import { ByFilter, EqualFilter } from './filters';
|
7 | import {
|
8 | isFunction,
|
9 | isNull,
|
10 | isEqualTo,
|
11 | isDeeplyEqual,
|
12 | isArray,
|
13 | isUndefined,
|
14 | isElement,
|
15 | isString,
|
16 | isEmpty,
|
17 | isObject,
|
18 | isPlainObject,
|
19 | assert
|
20 | } from './assertions';
|
21 |
|
22 |
|
23 |
|
24 | const internal = {
|
25 | factories: [],
|
26 | allowedHooks: ['attach', 'detach', 'initialize', 'render']
|
27 | };
|
28 |
|
29 | const $pwet = Symbol('pwet');
|
30 |
|
31 | internal.parseProperties = input => {
|
32 |
|
33 | const properties = [];
|
34 |
|
35 | if (!isObject(input))
|
36 | return properties;
|
37 |
|
38 | const keys = Object.keys(input);
|
39 |
|
40 | if (isEmpty(keys))
|
41 | return properties;
|
42 |
|
43 | return keys.reduce((properties, key) => {
|
44 |
|
45 | let property = input[key];
|
46 |
|
47 | if (!isObject(property))
|
48 | property = { defaultValue: property };
|
49 |
|
50 | property.name = key;
|
51 |
|
52 | property = Property(property);
|
53 |
|
54 | properties.push(property);
|
55 |
|
56 | return properties;
|
57 | }, properties);
|
58 | };
|
59 |
|
60 | internal.StatelessError = () => {
|
61 | throw new Error('Component is Stateless');
|
62 | };
|
63 |
|
64 | internal.defaultsHooks = {
|
65 | attach(component, attach) {
|
66 |
|
67 | attach(true);
|
68 | },
|
69 | initialize: (component, newProperties, initialize) => initialize(true)
|
70 | };
|
71 |
|
72 |
|
73 | const Component = (factory, element, dependencies) => {
|
74 |
|
75 | assert(Component.get(factory), `'factory' must be a defined component factory`);
|
76 | assert(isElement(element), `'element' must be a HTMLElement`);
|
77 |
|
78 | if (element[$pwet] !== void 0)
|
79 | return;
|
80 |
|
81 | let _isCreated = false;
|
82 | let _isAttached = false;
|
83 | let _isRendered = false;
|
84 | let _isInitializing = false;
|
85 | let _isInitialized = false;
|
86 | let _properties = {};
|
87 |
|
88 | if (factory.shadow)
|
89 | element.attachShadow(factory.shadow);
|
90 |
|
91 | const attach = () => {
|
92 |
|
93 | if (factory.logLevel > 0)
|
94 | console.log(`[${factory.tagName}]`, 'attach()');
|
95 |
|
96 | if (_isAttached)
|
97 | return;
|
98 |
|
99 | component.hooks.attach((shouldRender) => {
|
100 | _isAttached = true;
|
101 |
|
102 | if (!_isRendered && shouldRender)
|
103 | component.render();
|
104 | });
|
105 |
|
106 | };
|
107 |
|
108 | attach.after = (shouldUpdate = true) => {
|
109 |
|
110 |
|
111 |
|
112 | if (shouldUpdate)
|
113 | component.render();
|
114 |
|
115 | };
|
116 |
|
117 | const detach = () => {
|
118 |
|
119 | if (factory.logLevel > 0)
|
120 | console.log(`[${factory.tagName}]`, 'detach()');
|
121 |
|
122 | if (!_isAttached)
|
123 | return;
|
124 |
|
125 | _isAttached = false;
|
126 |
|
127 | component.hooks.detach();
|
128 | };
|
129 |
|
130 | const initialize = (properties = {}) => {
|
131 |
|
132 | if (_isInitializing)
|
133 | return;
|
134 |
|
135 | if (factory.logLevel > 0)
|
136 | console.log(`[${factory.tagName}]`, 'initialize()', properties, _properties);
|
137 |
|
138 | assert(isObject(properties) && !isNull(properties), `'properties' must be an object`);
|
139 |
|
140 | properties = factory.properties.reduce((newProperties, { name, coerce, defaultValue, isDataAttribute }) => {
|
141 |
|
142 | let oldValue = _properties[name];
|
143 | let newValue = properties[name];
|
144 |
|
145 | if (isUndefined(newValue) && isDataAttribute)
|
146 | newValue = properties[`data-${name}`];
|
147 |
|
148 |
|
149 |
|
150 |
|
151 |
|
152 |
|
153 |
|
154 |
|
155 |
|
156 |
|
157 |
|
158 | newValue = !isUndefined(newValue)
|
159 | ? coerce(newValue)
|
160 | : defaultValue;
|
161 |
|
162 | return Object.assign(newProperties, { [name]: newValue })
|
163 | }, {});
|
164 |
|
165 | if (isDeeplyEqual(properties, _properties)) {
|
166 | if (factory.logLevel > 0)
|
167 | console.warn(`[${factory.tagName}]`, 'aborted initialization (properties are unchanged)', properties, _properties);
|
168 | return;
|
169 | }
|
170 |
|
171 |
|
172 | _isInitializing = true;
|
173 |
|
174 |
|
175 | component.hooks.initialize(properties, (shouldRender) => {
|
176 | _properties = properties;
|
177 |
|
178 |
|
179 | _isInitializing = false;
|
180 | _isInitialized = true;
|
181 |
|
182 | if (shouldRender)
|
183 | component.render();
|
184 |
|
185 |
|
186 |
|
187 | });
|
188 | };
|
189 |
|
190 | const render = () => {
|
191 |
|
192 | if (factory.logLevel > 0)
|
193 | console.log(`[${factory.tagName}]`, 'render()', { _isAttached, ..._properties });
|
194 |
|
195 | if (!_isAttached)
|
196 | return;
|
197 |
|
198 | component.hooks.render();
|
199 |
|
200 | _isRendered = true;
|
201 | };
|
202 |
|
203 | const component = element[$pwet] = {
|
204 | isPwetComponent: true,
|
205 | element,
|
206 | factory,
|
207 | attach,
|
208 | render,
|
209 | detach,
|
210 | get isRendered() {
|
211 | return _isRendered
|
212 | },
|
213 | get isAttached() {
|
214 | return _isAttached
|
215 | },
|
216 | get isInitializing() {
|
217 | return _isInitializing
|
218 | },
|
219 | get isInitialized() {
|
220 | return _isInitialized
|
221 | },
|
222 | get hooks() {
|
223 | return _hooks
|
224 | },
|
225 |
|
226 |
|
227 |
|
228 |
|
229 |
|
230 |
|
231 | };
|
232 |
|
233 | Object.assign(element, {
|
234 | initialize
|
235 | });
|
236 |
|
237 | const _hooks = factory.allowedHooks.reduce((hooks, key) => {
|
238 | return Object.assign(hooks, { [key]: factory[key].bind(null, component) });
|
239 | }, {});
|
240 |
|
241 | Object.defineProperty(element, 'properties', {
|
242 | get() {
|
243 | return cloneDeep(_properties);
|
244 | },
|
245 | set: initialize
|
246 | });
|
247 |
|
248 | initialize(factory.properties.reduce((properties, { name, isDataAttribute, parse, defaultValue }) => {
|
249 |
|
250 | Object.defineProperty(element, name, {
|
251 | get() {
|
252 | return _properties[name];
|
253 | },
|
254 | set(newValue) {
|
255 |
|
256 | initialize(Object.assign(element.properties, {
|
257 | [name]: newValue
|
258 | }));
|
259 | }
|
260 | });
|
261 |
|
262 | let value = defaultValue;
|
263 |
|
264 | if (isDataAttribute) {
|
265 |
|
266 | const attributeValue = element.dataset[name];
|
267 |
|
268 |
|
269 | if (!isUndefined(attributeValue))
|
270 | value = parse(attributeValue);
|
271 | }
|
272 |
|
273 | return Object.assign(properties, { [name]: value });
|
274 | }, {}));
|
275 |
|
276 |
|
277 | const returned = factory.create(component, factory.dependencies);
|
278 |
|
279 | if (!isObject(returned) || isNull(returned))
|
280 | return component;
|
281 |
|
282 | Object.keys(returned)
|
283 | .forEach(key => {
|
284 |
|
285 |
|
286 |
|
287 |
|
288 |
|
289 |
|
290 |
|
291 | if (!factory.allowedHooks.includes(key))
|
292 | return;
|
293 |
|
294 | const hook = returned[key];
|
295 |
|
296 | assert(isFunction(hook), `'${key}' hook must be a function`);
|
297 |
|
298 | _hooks[key] = hook;
|
299 | });
|
300 |
|
301 |
|
302 | assert(_hooks.render !== noop, `'render' method is required`);
|
303 |
|
304 | const _attributes = factory.properties
|
305 | .filter(property => property.isAttribute === true)
|
306 | .reduce((attributes, attribute) => {
|
307 |
|
308 | let name = kebabCase(attribute.name);
|
309 |
|
310 | if (attribute.isDataAttribute)
|
311 | name = `data-${name}`;
|
312 |
|
313 | return Object.assign(attributes, { [name]: attribute });
|
314 | }, {});
|
315 |
|
316 | const _attributesName = Object.keys(_attributes);
|
317 |
|
318 | const _observer = new MutationObserver(mutations => {
|
319 |
|
320 |
|
321 |
|
322 |
|
323 | mutations = mutations
|
324 | .filter(({ attributeName }) => _attributesName.includes(attributeName))
|
325 | .map(({ attributeName:name, oldValue }) => ({
|
326 | name,
|
327 | oldValue,
|
328 | value: element.getAttribute(name)
|
329 | }))
|
330 | .filter(({ value, oldValue }) => !isEqualTo(value, oldValue));
|
331 |
|
332 | if (isEmpty(mutations))
|
333 | return;
|
334 |
|
335 | console.error(`[${factory.tagName}]`, 'ATTRIBUTES MUTATIONS', mutations.map(({ name, value }) => `${name}=${value}`));
|
336 | const { properties } = element;
|
337 |
|
338 | initialize(Object.assign(properties, mutations.reduce((attributes, { name, value }) => {
|
339 |
|
340 | const { parse, isDataAttribute } = _attributes[name];
|
341 |
|
342 | name = camelCase(isDataAttribute ? name.slice(5) : name);
|
343 |
|
344 | return Object.assign(attributes, { [name]: parse(value) });
|
345 | }, {})));
|
346 |
|
347 |
|
348 |
|
349 |
|
350 |
|
351 |
|
352 |
|
353 |
|
354 |
|
355 | });
|
356 |
|
357 | _observer.observe(element, { attributes: true, attributeOldValue: true });
|
358 |
|
359 | return component;
|
360 | };
|
361 |
|
362 | Component.get = input => internal.factories.find(EqualFilter(input));
|
363 |
|
364 | const ThinComponent = (factory) => {
|
365 |
|
366 |
|
367 |
|
368 |
|
369 |
|
370 |
|
371 |
|
372 |
|
373 |
|
374 |
|
375 |
|
376 | factory.create = decorate(factory.create, (next, component, ...args) => {
|
377 |
|
378 |
|
379 |
|
380 |
|
381 |
|
382 |
|
383 | return {
|
384 |
|
385 | render: () => next(component, ...args)
|
386 | }
|
387 | });
|
388 |
|
389 | return factory;
|
390 | };
|
391 |
|
392 | Component.define = (tagName, factory) => {
|
393 |
|
394 | if (isFunction(tagName)) {
|
395 | factory = tagName;
|
396 | tagName = factory.tagName || factory.name;
|
397 | }
|
398 |
|
399 | assert(isFunction(factory), `'factory' must be a function`);
|
400 |
|
401 | const { dependencies = {} } = factory;
|
402 |
|
403 | assert(isString(tagName) && tagName.length > 0, `'tagName' must be a string`);
|
404 |
|
405 | tagName = kebabCase(tagName);
|
406 |
|
407 | if (!tagName.includes('-'))
|
408 | tagName = `x-${tagName}`;
|
409 |
|
410 | assert(!Component.get(factory), `That component factory is already defined`);
|
411 | assert(!internal.factories.find(ByFilter('tagName', tagName)), `'${tagName}' component is already defined`);
|
412 | assert(isObject(dependencies) && !isNull(dependencies), `'dependencies' must be an object`);
|
413 |
|
414 | factory.tagName = tagName;
|
415 | factory.allowedHooks = [];
|
416 | if (!isFunction(factory.create))
|
417 | factory.create = factory;
|
418 | if (!isFunction(factory.attach))
|
419 | factory.attach = internal.defaultsHooks.attach;
|
420 | if (!isFunction(factory.initialize))
|
421 | factory.initialize = internal.defaultsHooks.initialize;
|
422 | if (!isFunction(factory.detach))
|
423 | factory.detach = noop;
|
424 | if (!isFunction(factory.render))
|
425 | factory.render = noop;
|
426 |
|
427 |
|
428 | if (!isUndefined(factory.decorators)) {
|
429 |
|
430 | if (!isArray(factory.decorators))
|
431 | factory.decorators = [factory.decorators];
|
432 |
|
433 | factory.decorators.forEach(decorator => {
|
434 | assert((isFunction(decorator)), `'decorator' must be a function`);
|
435 | decorator(factory, dependencies);
|
436 | });
|
437 |
|
438 |
|
439 |
|
440 |
|
441 |
|
442 |
|
443 |
|
444 |
|
445 |
|
446 | }
|
447 | if (!isUndefined(factory.shadow))
|
448 | assert((isPlainObject(factory.shadow)), `'shadow' must be a plain object`);
|
449 |
|
450 | assert(isFunction(factory.create), `'create' must be a function`);
|
451 | assert(isFunction(factory.attach), `'attach' must be a function`);
|
452 | assert(isFunction(factory.initialize), `'initialize' must be a function`);
|
453 | assert(isFunction(factory.detach), `'detach' must be a function`);
|
454 | assert(isFunction(factory.render), `'render' must be a function`);
|
455 |
|
456 | factory.tagName = tagName;
|
457 | factory.dependencies = dependencies;
|
458 |
|
459 | factory.properties = internal.parseProperties(factory.properties);
|
460 |
|
461 | factory.allowedHooks = factory.allowedHooks.reduce((hooks, hook) => {
|
462 |
|
463 | assert(isString(hook), `'hook' must be a string`);
|
464 |
|
465 | if (isString(hook) && !internal.allowedHooks.includes(hook))
|
466 | hooks.push(hook);
|
467 |
|
468 | return hooks;
|
469 | }, []).concat(internal.allowedHooks);
|
470 |
|
471 |
|
472 |
|
473 |
|
474 |
|
475 |
|
476 | internal.factories.push(factory);
|
477 |
|
478 |
|
479 |
|
480 | customElements.define(tagName, class extends HTMLElement {
|
481 | constructor() {
|
482 |
|
483 | super();
|
484 |
|
485 | Component(factory, this, dependencies);
|
486 | }
|
487 |
|
488 |
|
489 |
|
490 |
|
491 | connectedCallback() {
|
492 |
|
493 | this[$pwet].attach();
|
494 | }
|
495 | disconnectedCallback() {
|
496 |
|
497 | this[$pwet].detach();
|
498 | }
|
499 |
|
500 |
|
501 |
|
502 |
|
503 |
|
504 |
|
505 |
|
506 |
|
507 |
|
508 |
|
509 |
|
510 |
|
511 |
|
512 | });
|
513 | };
|
514 |
|
515 | export {
|
516 | $pwet,
|
517 | ThinComponent,
|
518 | Component as default
|
519 | } |
\ | No newline at end of file |