structured-fw 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/Config.ts +47 -0
  2. package/LICENSE +21 -0
  3. package/README.md +332 -0
  4. package/app/Types.ts +1 -0
  5. package/app/models/README.md +9 -0
  6. package/app/routes/README.md +19 -0
  7. package/app/views/README.md +1 -0
  8. package/app/views/layout.html +1 -0
  9. package/bin/structured +114 -0
  10. package/build/Config.d.ts +2 -0
  11. package/build/Config.js +31 -0
  12. package/build/app/Types.d.ts +1 -0
  13. package/build/app/Types.js +1 -0
  14. package/build/app/models/Users.d.ts +0 -0
  15. package/build/app/models/Users.js +1 -0
  16. package/build/app/routes/Auth.d.ts +0 -0
  17. package/build/app/routes/Auth.js +1 -0
  18. package/build/app/routes/Test.d.ts +2 -0
  19. package/build/app/routes/Test.js +101 -0
  20. package/build/app/routes/Todo.d.ts +0 -0
  21. package/build/app/routes/Todo.js +1 -0
  22. package/build/app/routes/Upload.d.ts +0 -0
  23. package/build/app/routes/Upload.js +1 -0
  24. package/build/app/routes/Validation.d.ts +2 -0
  25. package/build/app/routes/Validation.js +34 -0
  26. package/build/app/views/components/ClientImport/ClientImport.client.d.ts +2 -0
  27. package/build/app/views/components/ClientImport/ClientImport.client.js +4 -0
  28. package/build/app/views/components/ClientImport/Export.d.ts +1 -0
  29. package/build/app/views/components/ClientImport/Export.js +1 -0
  30. package/build/app/views/components/Conditionals/Conditionals.client.d.ts +2 -0
  31. package/build/app/views/components/Conditionals/Conditionals.client.js +43 -0
  32. package/build/app/views/components/FormTest/FormTestNested/FormTestNested.d.ts +8 -0
  33. package/build/app/views/components/FormTest/FormTestNested/FormTestNested.js +7 -0
  34. package/build/app/views/components/ModelsTest/ModelsTest.client.d.ts +2 -0
  35. package/build/app/views/components/ModelsTest/ModelsTest.client.js +5 -0
  36. package/build/app/views/components/MultipartForm/MultipartForm.client.d.ts +0 -0
  37. package/build/app/views/components/MultipartForm/MultipartForm.client.js +1 -0
  38. package/build/app/views/components/PassObject/PassObject.d.ts +10 -0
  39. package/build/app/views/components/PassObject/PassObject.js +10 -0
  40. package/build/app/views/components/PassObject/ReceiveObj/ReceiveObj.d.ts +6 -0
  41. package/build/app/views/components/PassObject/ReceiveObj/ReceiveObj.js +6 -0
  42. package/build/app/views/components/RedrawAbort/RedrawAbort.client.d.ts +2 -0
  43. package/build/app/views/components/RedrawAbort/RedrawAbort.client.js +6 -0
  44. package/build/app/views/components/RedrawAbort/RedrawAbort.d.ts +8 -0
  45. package/build/app/views/components/RedrawAbort/RedrawAbort.js +8 -0
  46. package/build/app/views/components/ServerSideContext/ServerSideContext.d.ts +7 -0
  47. package/build/app/views/components/ServerSideContext/ServerSideContext.js +10 -0
  48. package/build/assets/ts/Export.d.ts +1 -0
  49. package/build/assets/ts/Export.js +1 -0
  50. package/build/index.d.ts +1 -0
  51. package/build/index.js +3 -0
  52. package/build/system/Helpers.d.ts +3 -0
  53. package/build/system/Helpers.js +72 -0
  54. package/build/system/Symbols.d.ts +3 -0
  55. package/build/system/Symbols.js +3 -0
  56. package/build/system/Types.d.ts +171 -0
  57. package/build/system/Types.js +1 -0
  58. package/build/system/Util.d.ts +20 -0
  59. package/build/system/Util.js +336 -0
  60. package/build/system/client/App.d.ts +7 -0
  61. package/build/system/client/App.js +8 -0
  62. package/build/system/client/Client.d.ts +6 -0
  63. package/build/system/client/Client.js +9 -0
  64. package/build/system/client/ClientComponent.d.ts +68 -0
  65. package/build/system/client/ClientComponent.js +734 -0
  66. package/build/system/client/DataStore.d.ts +22 -0
  67. package/build/system/client/DataStore.js +64 -0
  68. package/build/system/client/DataStoreView.d.ts +19 -0
  69. package/build/system/client/DataStoreView.js +56 -0
  70. package/build/system/client/EventEmitter.d.ts +7 -0
  71. package/build/system/client/EventEmitter.js +31 -0
  72. package/build/system/client/Net.d.ts +13 -0
  73. package/build/system/client/Net.js +39 -0
  74. package/build/system/client/NetRequest.d.ts +13 -0
  75. package/build/system/client/NetRequest.js +45 -0
  76. package/build/system/server/Application.d.ts +31 -0
  77. package/build/system/server/Application.js +171 -0
  78. package/build/system/server/Component.d.ts +27 -0
  79. package/build/system/server/Component.js +249 -0
  80. package/build/system/server/Components.d.ts +12 -0
  81. package/build/system/server/Components.js +77 -0
  82. package/build/system/server/Cookies.d.ts +6 -0
  83. package/build/system/server/Cookies.js +19 -0
  84. package/build/system/server/Document.d.ts +24 -0
  85. package/build/system/server/Document.js +107 -0
  86. package/build/system/server/DocumentHead.d.ts +32 -0
  87. package/build/system/server/DocumentHead.js +118 -0
  88. package/build/system/server/FormValidation.d.ts +16 -0
  89. package/build/system/server/FormValidation.js +197 -0
  90. package/build/system/server/Handlebars.d.ts +11 -0
  91. package/build/system/server/Handlebars.js +34 -0
  92. package/build/system/server/Request.d.ts +21 -0
  93. package/build/system/server/Request.js +356 -0
  94. package/build/system/server/Session.d.ts +23 -0
  95. package/build/system/server/Session.js +114 -0
  96. package/build/system/server/dom/DOMFragment.d.ts +4 -0
  97. package/build/system/server/dom/DOMFragment.js +6 -0
  98. package/build/system/server/dom/DOMNode.d.ts +31 -0
  99. package/build/system/server/dom/DOMNode.js +110 -0
  100. package/build/system/server/dom/HTMLParser.d.ts +21 -0
  101. package/build/system/server/dom/HTMLParser.js +204 -0
  102. package/index.ts +4 -0
  103. package/package.json +31 -0
  104. package/system/Helpers.ts +97 -0
  105. package/system/Symbols.ts +6 -0
  106. package/system/Types.ts +234 -0
  107. package/system/Util.ts +488 -0
  108. package/system/client/App.ts +11 -0
  109. package/system/client/Client.ts +9 -0
  110. package/system/client/ClientComponent.ts +1117 -0
  111. package/system/client/DataStore.ts +101 -0
  112. package/system/client/DataStoreView.ts +82 -0
  113. package/system/client/EventEmitter.ts +38 -0
  114. package/system/client/Net.ts +58 -0
  115. package/system/client/NetRequest.ts +64 -0
  116. package/system/server/Application.ts +230 -0
  117. package/system/server/Component.ts +404 -0
  118. package/system/server/Components.ts +111 -0
  119. package/system/server/Cookies.ts +29 -0
  120. package/system/server/Document.ts +163 -0
  121. package/system/server/DocumentHead.ts +150 -0
  122. package/system/server/FormValidation.ts +231 -0
  123. package/system/server/Handlebars.ts +51 -0
  124. package/system/server/Request.ts +497 -0
  125. package/system/server/Session.ts +151 -0
  126. package/system/server/dom/DOMFragment.ts +7 -0
  127. package/system/server/dom/DOMNode.ts +140 -0
  128. package/system/server/dom/HTMLParser.ts +238 -0
  129. package/tsconfig.json +35 -0
@@ -0,0 +1,1117 @@
1
+ import { ClientComponentTransition, ClientComponentTransitions, InitializerFunction, LooseObject, StoreChangeCallback, StructuredClientConfig } from '../Types.js';
2
+ import { attributeValueFromString, attributeValueToString, mergeDeep, objectEach, queryStringDecodedSetValue, toCamelCase } from '../Util.js';
3
+ import { DataStoreView } from './DataStoreView.js';
4
+ import { DataStore } from './DataStore.js';
5
+ import { Net } from './Net.js';
6
+ import { NetRequest } from './NetRequest.js';
7
+ import { EventEmitter } from './EventEmitter.js';
8
+
9
+ // window.initializers will always be present
10
+ // each Document has a list of initializers used in components within it
11
+ // and they will be output as initializers = { componentName : initializer }
12
+ declare global {
13
+ interface Window {
14
+ initializers: Record<string, InitializerFunction | string>;
15
+ structuredClientConfig: StructuredClientConfig;
16
+ }
17
+ }
18
+
19
+ export class ClientComponent extends EventEmitter {
20
+ readonly name: string;
21
+ children: Array<ClientComponent> = [];
22
+ readonly parent: ClientComponent;
23
+ readonly domNode: HTMLElement;
24
+ readonly isRoot: boolean;
25
+ readonly root: ClientComponent;
26
+ store: DataStoreView;
27
+ private storeGlobal: DataStore;
28
+ readonly net: Net = new Net();
29
+ private initializerExecuted: boolean = false;
30
+
31
+ destroyed: boolean = false;
32
+
33
+ private redrawRequest: XMLHttpRequest | null = null;
34
+
35
+ // optional user defined callbacks
36
+ onDestroy?: Function;
37
+ onRedraw?: Function;
38
+
39
+ // callbacks bound using bind method
40
+ private bound: Array<{
41
+ element: HTMLElement;
42
+ event: string;
43
+ callback: (e: Event) => void;
44
+ }> = [];
45
+
46
+ // DOM elements within the component that have a data-if attribute
47
+ private conditionals: Array<HTMLElement> = [];
48
+
49
+ // available for use in data-if and data-classname-[className]
50
+ private conditionalCallbacks: Record<string, (args?: any) => boolean> = {};
51
+
52
+ private conditionalClassNames: Array<{
53
+ element: HTMLElement,
54
+ className: string
55
+ }> = [];
56
+
57
+ private refs: {
58
+ [key: string]: HTMLElement | ClientComponent;
59
+ } = {};
60
+ private refsArray: {
61
+ [key: string]: Array<HTMLElement | ClientComponent>;
62
+ } = {};
63
+
64
+ loaded: boolean = false;
65
+
66
+ // callback executed each time the component is redrawn
67
+ // this is the ideal place for binding any event listeners within component
68
+ private initializer: InitializerFunction | null = null;
69
+
70
+ // data-attr are parsed into an object
71
+ private data: {
72
+ [key: string]: any;
73
+ } = {};
74
+
75
+ constructor(parent: ClientComponent | null, name: string, domNode: HTMLElement, store: DataStore, runInitializer: boolean = true) {
76
+ super();
77
+ this.name = name;
78
+ this.domNode = domNode;
79
+ if (parent === null) {
80
+ this.isRoot = true;
81
+ this.root = this;
82
+ this.parent = this;
83
+ } else {
84
+ this.isRoot = false;
85
+ this.root = parent.root;
86
+ this.parent = parent;
87
+ }
88
+
89
+ this.storeGlobal = store;
90
+ this.store = new DataStoreView(this.storeGlobal, this);
91
+
92
+ if (this.isRoot) {
93
+ // only root gets initialized by itself
94
+ // rest of the component tree is initialized during initChildren
95
+ // this is in order to be able to await the children to initialize
96
+ // and in extension be able to tell when all components are initialized
97
+ this.init(runInitializer);
98
+ }
99
+ }
100
+
101
+ // initialize component and it's children recursively
102
+ private async init(runInitializer: boolean) {
103
+ const initializerExists = window.initializers !== undefined && this.name in window.initializers;
104
+ this.initRefs();
105
+ this.initData();
106
+ this.initModels();
107
+ this.initConditionals();
108
+ await this.initChildren();
109
+ this.promoteRefs();
110
+
111
+ // update conditionals whenever any data in component's store has changed
112
+ this.store.onChange('*', () => {
113
+ this.updateConditionals(true);
114
+ });
115
+
116
+ // run initializer, if one exists for current component
117
+ // if autoInit = false component will not be automatically initialized
118
+ if (runInitializer && initializerExists) {
119
+ await this.runInitializer();
120
+ }
121
+
122
+ // update conditionals as soon as component is initialized
123
+ if (this.conditionals.length > 0) {
124
+ if (! initializerExists) {
125
+ // component has no initializer, import all exported fields
126
+ this.store.import(undefined, false, false);
127
+ }
128
+ this.updateConditionals(false);
129
+ }
130
+
131
+ // deferred component, redraw it immediately
132
+ if (this.data.deferred === true) {
133
+ this.setData('deferred', false, false);
134
+ this.redraw();
135
+ }
136
+
137
+ this.loaded = true;
138
+
139
+ // component emits "ready" when initialized
140
+ // when a component emits "ready" it means it and all of it's children recursively have been initialized
141
+ // if root emits "ready", that means all components in current document are initialized
142
+ this.emit('ready');
143
+ }
144
+
145
+ // set initializer callback and execute it
146
+ private async runInitializer(isRedraw: boolean = false) {
147
+ const initializer = window.initializers[this.name];
148
+ if (! initializer) {return;}
149
+ if (! this.initializerExecuted && ! this.destroyed) {
150
+ let initializerFunction: InitializerFunction | null = null;
151
+ if (typeof initializer === 'string') {
152
+ // create an async function using AsyncFunction constructor
153
+ const AsyncFunction = async function () {}.constructor;
154
+ // @ts-ignore
155
+ initializerFunction = new AsyncFunction('const init = ' + initializer + '; await init.apply(this, [...arguments]);') as InitializerFunction;
156
+ } else {
157
+ initializerFunction = initializer;
158
+ }
159
+
160
+ if (initializerFunction) {
161
+ this.initializer = initializerFunction;
162
+ await this.initializer.apply(this, [{
163
+ net: this.net,
164
+ isRedraw
165
+ }]);
166
+ }
167
+ }
168
+ this.initializerExecuted = true;
169
+ }
170
+
171
+ // parse all data-[key] attributes found on this.domNode into this.data object
172
+ // key converted to camelCase
173
+ // values are expected to be encoded using attributeValueToString
174
+ // and will be decoded using attributeValueFromString
175
+ private initData(): void {
176
+ for (let i = 0; i < this.domNode.attributes.length; i++) {
177
+ // data-attr, convert to dataAttr and store value
178
+ if (/^((number|string|boolean|object|any):)?data-[^\s]+/.test(this.domNode.attributes[i].name)) {
179
+ const value = this.domNode.attributes[i].value;
180
+ const attrData = attributeValueFromString(value);
181
+
182
+ if (typeof attrData === 'object') {
183
+ this.setData(attrData.key, attrData.value, false);
184
+ } else {
185
+ // not a valid attribute data string, assign as is (string)
186
+ const key = toCamelCase(this.domNode.attributes[i].name.substring(5));
187
+ this.setData(key, attrData, false);
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ // array of attribute data all the way up to root, or the first component with no dependencies (no data-use attribute)
194
+ // used for redraw
195
+ public pathData(): Array<{
196
+ [key: string]: string;
197
+ }> {
198
+ let current: ClientComponent = this;
199
+ const data = [];
200
+ do {
201
+ data.push(current.data);
202
+ if (current.isRoot || !current.data.use) {
203
+ break;
204
+ }
205
+ current = current.parent;
206
+ } while (true);
207
+ return data.reverse();
208
+ }
209
+
210
+ // sets this.data[key] and optionally this.store[key], key is passed through toCamelCase
211
+ // sets data-[key]="value" attribute on this.domNode, value passed through attributeValueToString
212
+ // if updateStore is true (default) value is also applied to this.store
213
+ // returns this to allow chaining
214
+ public setData(key: string, value: any, updateStore: boolean = true): ClientComponent {
215
+ const dataKey = `data-${key}`;
216
+ this.domNode.setAttribute(dataKey, attributeValueToString(key, value));
217
+ const keyCamelCase = toCamelCase(key);
218
+ this.data[keyCamelCase] = value;
219
+ if (updateStore) {
220
+ this.store.set(keyCamelCase, value);
221
+ }
222
+ return this;
223
+ }
224
+
225
+ // find all DOM nodes with data-structured-component attribute within this component,
226
+ // instantiate a ClientComponent with them and add them to this.children
227
+ // if callback is a function, for each instantiated child
228
+ // callback is executed with child as first argument
229
+ private async initChildren(scope?: HTMLElement, callback?: (component: ClientComponent) => void): Promise<void> {
230
+ scope = scope || this.domNode;
231
+
232
+ // array of promises that are resolved when child nodes are recursively initialized
233
+ const childInitPromises: Array<Promise<void>> = [];
234
+
235
+ for (let i = 0; i < scope.childNodes.length; i++) {
236
+ const childNode = scope.childNodes[i];
237
+ if (childNode.nodeType == 1) {
238
+ if ((childNode as HTMLElement).hasAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`)) {
239
+ // found a child component, add to children
240
+ const component = new ClientComponent(this, (childNode as HTMLElement).getAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`) || '', childNode as HTMLElement, this.storeGlobal);
241
+ this.children.push(component);
242
+ if (typeof callback === 'function') {
243
+ callback(component);
244
+ }
245
+ childInitPromises.push(component.init(true));
246
+ } else {
247
+ // not a component, resume from here recursively
248
+ childInitPromises.push(this.initChildren((childNode as HTMLElement), callback));
249
+ }
250
+ }
251
+ }
252
+
253
+ // await children to be initialized
254
+ await Promise.all(childInitPromises);
255
+ }
256
+
257
+ // fetch from server and replace with new HTML
258
+ // if data is provided, each key will be set on component using this.setData
259
+ // and as such, component will receive it when rendering
260
+ public async redraw(data?: LooseObject): Promise<void> {
261
+
262
+ if (window.structuredClientConfig.componentRender === false) {
263
+ console.error(`Can't redraw component, component rendering URL disabled`);
264
+ return;
265
+ }
266
+
267
+ if (this.destroyed) {return;}
268
+
269
+ // set data if provided
270
+ if (data) {
271
+ objectEach(data, (key, val) => {
272
+ this.setData(key, val, false);
273
+ });
274
+ }
275
+
276
+ // abort existing redraw call, if in progress
277
+ if (this.redrawRequest !== null) {
278
+ this.redrawRequest.abort();
279
+ this.redrawRequest = null;
280
+ }
281
+
282
+ // request a component to be re-rendered on the server
283
+ // unwrap = true so that component container is excluded
284
+ // this component already has it's own container and we only care about what changed within it
285
+ const redrawRequest = new NetRequest('POST', window.structuredClientConfig.componentRender, {
286
+ 'content-type': 'application/json'
287
+ });
288
+ this.redrawRequest = redrawRequest.xhr;
289
+ const componentDataJSON = await redrawRequest.send(JSON.stringify({
290
+ component: this.name,
291
+ attributes: this.data,
292
+ unwrap: true
293
+ }));
294
+ // clear redraw request as the request is executed and does not need to be cancelled
295
+ // in case component gets redrawn again
296
+ this.redrawRequest = null;
297
+
298
+ // should only happen if a previous redraw attempt was aborted
299
+ if (componentDataJSON.length === 0) { return; }
300
+
301
+ // mark component as not loaded
302
+ this.loaded = false;
303
+
304
+ // if user has defined onRedraw callback, run it
305
+ if (typeof this.onRedraw === 'function') {
306
+ this.onRedraw.apply(this)
307
+ }
308
+
309
+ // remove all bound event listeners as DOM will get replaced in the process
310
+ this.unbindAll();
311
+
312
+ // destroy existing children as their associated domNode is no longer part of the DOM
313
+ // new children will be initialized based on the new DOM
314
+ // new DOM may contain same children (same componentId) however by destroying the children
315
+ // any store change listeners will be lost, before destroying each child
316
+ // keep a copy of the store change listeners, which we'll use later to restore those listeners
317
+ const childStoreChangeCallbacks: Record<string, Record<string, Array<StoreChangeCallback>>> = {}
318
+ Array.from(this.children).forEach((child) => {
319
+ childStoreChangeCallbacks[child.getData<string>('componentId')] = child.store.onChangeCallbacks();
320
+ child.remove();
321
+ });
322
+
323
+ const componentData: {
324
+ html: string;
325
+ initializers: Record<string, string>;
326
+ data: LooseObject;
327
+ } = JSON.parse(componentDataJSON);
328
+
329
+ // populate this.domNode with new HTML
330
+ this.domNode.innerHTML = componentData.html;
331
+
332
+ // apply new data received from the server as it may have changed
333
+ // only exported data is included here
334
+ objectEach(componentData.data, (key, val) => {
335
+ this.setData(key, val, false);
336
+ });
337
+
338
+ // add any new initializers to global initializers list
339
+ for (const key in componentData.initializers) {
340
+ if (!window.initializers[key]) {
341
+ window.initializers[key] = componentData.initializers[key];
342
+ }
343
+ }
344
+
345
+ // init new children, restoring their store change listeners in the process
346
+ await this.initChildren(this.domNode, (childNew) => {
347
+ const childNewId = childNew.getData<string>('componentId');
348
+ const existingChild = childNewId in childStoreChangeCallbacks;
349
+ if (existingChild) {
350
+ // child existed before redraw, re-apply onChange callbacks
351
+ objectEach(childStoreChangeCallbacks[childNewId], (key, callbacks) => {
352
+ callbacks.forEach((callback) => {
353
+ childNew.store.onChange(key, callback);
354
+ });
355
+ });
356
+ }
357
+
358
+ // idea was that existing child nodes would be initialized with isRedraw = true
359
+ // however after giving it some thought - probably not desirable
360
+ // the whole idea with isRedraw is to inform the initializer whether it's
361
+ // a fresh instance of ClientComponent or an existing one
362
+ // while child (even existing ones) are technically redrawn in this case,
363
+ // they do get a fresh instance of a ClientComponent, hence isRedraw = true would be misleading
364
+ // keeping this comment here in case in the future a need arises to inform children
365
+ // they were redrawn as a consequence of parent redraw
366
+ // if ever done, make sure to set 4th argument of initChildren to false (disabling autoInit)
367
+ // childNew.init(existingChild);
368
+ });
369
+
370
+ // re-init conditionals and refs
371
+ this.refs = {};
372
+ this.refsArray = {};
373
+ this.conditionals = [];
374
+ this.initRefs();
375
+ this.initModels();
376
+ this.initConditionals();
377
+ this.promoteRefs();
378
+
379
+ // run the initializer
380
+ if (this.initializer) {
381
+ this.initializerExecuted = false;
382
+ this.runInitializer(true);
383
+ }
384
+
385
+ this.updateConditionals(false);
386
+
387
+ // mark component as loaded
388
+ this.loaded = true;
389
+ }
390
+
391
+ // populates conditionals and conditionalClassNames
392
+ // these react to changes to store data
393
+ // to show/hide elements or apply/remove class names to/from them
394
+ private initConditionals(node?: HTMLElement): void {
395
+ const isSelf = node === undefined;
396
+ if (node === undefined) {
397
+ node = this.domNode;
398
+ }
399
+
400
+ for (const attribute of node.attributes) {
401
+ // data-if
402
+ if (attribute.name === 'data-if') {
403
+ this.conditionals.push(node);
404
+ }
405
+
406
+ // data-classname-[className]
407
+ if (attribute.name.startsWith('data-classname')) {
408
+ const className = attribute.name.substring(15);
409
+ this.conditionalClassNames.push({
410
+ element: node,
411
+ className
412
+ });
413
+ }
414
+ }
415
+
416
+ node.childNodes.forEach((child) => {
417
+ if (child.nodeType === 1 && (isSelf || !node?.hasAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`))) {
418
+ this.initConditionals(child as HTMLElement);
419
+ }
420
+ });
421
+ }
422
+
423
+ // initialize refs and refsArray within this component
424
+ // ref="refName"
425
+ // any DOM nodes with attribute ref="refName" will be stored under refs as { refName: HTMLElement }
426
+ // and can be returned using the ref method, refName should be unique,
427
+ // if multiple DOM nodes have the same refName, only the last one will be kept
428
+ // if ref is on a component tag, that ref will get promoted to ClientComponent
429
+ // array:ref="refName"
430
+ // any DOM nodes with attribute array:ref="refName" will be grouped
431
+ // under refsArray as { refName: Array<HTMLElement> } and can be returned using refArray method
432
+ // in contrast to ref, array:ref's refName does not need to be unique, in fact, since it's used
433
+ // to group as set of DOM nodes, it only makes sense if multiple DOM nodes share the same refName
434
+ private initRefs(node?: HTMLElement): void {
435
+ const isSelf = node === undefined;
436
+ if (node === undefined) {
437
+ node = this.domNode;
438
+ }
439
+
440
+ if (node.hasAttribute('ref')) {
441
+ this.refs[node.getAttribute('ref') || 'undefined'] = node;
442
+ }
443
+
444
+ if (node.hasAttribute('array:ref')) {
445
+ const key = node.getAttribute('array:ref') || 'undefined';
446
+ if (!(key in this.refsArray)) {
447
+ this.refsArray[key] = [];
448
+ }
449
+ this.refsArray[key].push(node);
450
+ }
451
+
452
+ node.childNodes.forEach((child) => {
453
+ if (child.nodeType === 1 && (isSelf || !node?.hasAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`))) {
454
+ this.initRefs(child as HTMLElement);
455
+ }
456
+ });
457
+ }
458
+
459
+ // make inputs with data-model="field" work
460
+ // nested data works too, data-model="obj[nested][key]" or data-model="obj[nested][key][]"
461
+ private initModels(node?: HTMLElement, modelNodes: Array<HTMLInputElement> = []) {
462
+ const isSelf = node === undefined;
463
+ if (node === undefined) {
464
+ node = this.domNode;
465
+ }
466
+
467
+ // given a HTMLInput element that has data-model attribute, returns an object with the data
468
+ // for example:
469
+ // data-model="name" may result with { name: "John" }
470
+ // data-model="user[name]" may result with { user: { name: "John" } }
471
+ const modelData = (node: HTMLInputElement): LooseObject => {
472
+ const field = node.getAttribute('data-model');
473
+ if (field) {
474
+ const isCheckbox = node.tagName === 'INPUT' && node.type === 'checkbox';
475
+ const valueRaw = isCheckbox ? node.checked : node.value;
476
+ const value = queryStringDecodedSetValue(field, valueRaw);
477
+ return value;
478
+ }
479
+ return {}
480
+ }
481
+
482
+ // given a loose object, sets all keys with corresponding value on current component
483
+ const update = (data: LooseObject) => {
484
+ objectEach(data, (key, val) => {
485
+ this.store.set(key, val);
486
+ });
487
+ }
488
+
489
+ if (node.hasAttribute('data-model') && (node.tagName === 'INPUT' || node.tagName === 'SELECT' || node.tagName === 'TEXTAREA')) {
490
+ // found a model node, store to array modelNodes
491
+ modelNodes.push(node as HTMLInputElement);
492
+ } else {
493
+ // not a model, but may contain models
494
+ // init model nodes recursively from here
495
+ node.childNodes.forEach((child) => {
496
+ if (child.nodeType === 1 && (isSelf || !node?.hasAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`))) {
497
+ this.initModels(child as HTMLElement, modelNodes);
498
+ }
499
+ });
500
+ }
501
+
502
+ if (isSelf) {
503
+ // all model nodes are now contained in modelNodes array
504
+
505
+ // data for the initial update, we want to gather all data up in one object
506
+ // so that nested keys don't trigger more updates than necessary
507
+ let data: LooseObject = {}
508
+ modelNodes.forEach((modelNode) => {
509
+ // on change, update component data
510
+ modelNode.addEventListener('input', () => {
511
+ let data = modelData(modelNode);
512
+ const key = Object.keys(data)[0];
513
+ if (typeof data[key] === 'object') {
514
+ const dataExisting = this.store.get<LooseObject>(key);
515
+ if (dataExisting !== undefined) {
516
+ data = mergeDeep({}, {[key]: dataExisting}, data);
517
+ } else {
518
+ data = mergeDeep({}, data);
519
+ }
520
+ }
521
+ update(data);
522
+ });
523
+
524
+ // include current node's data into initial update data
525
+ const field = modelNode.getAttribute('data-model');
526
+ if (field) {
527
+ const isCheckbox = modelNode.tagName === 'INPUT' && modelNode.type === 'checkbox';
528
+ const valueRaw = isCheckbox ? modelNode.checked : modelNode.value;
529
+ const value = queryStringDecodedSetValue(field, valueRaw);
530
+ data = mergeDeep(data, value);
531
+ }
532
+ });
533
+
534
+ // run the initial data update with data gathered from all model nodes
535
+ update(data);
536
+ }
537
+ }
538
+
539
+ // normally, ref will return a HTMLElement, however if ref attribute is found on a component tag
540
+ // this will upgrade it to ClientComponent
541
+ private promoteRefs() {
542
+ this.children.forEach((child) => {
543
+ // promote regular refs
544
+ const ref = child.domNode.getAttribute('ref');
545
+ if (ref) {
546
+ this.refs[ref] = child;
547
+ }
548
+
549
+ // promote array refs
550
+ const refArray = child.domNode.getAttribute('array:ref');
551
+ if (refArray !== null && refArray in this.refsArray) {
552
+ const nodeIndex = this.refsArray[refArray].indexOf(child.domNode);
553
+ if (nodeIndex > -1) {
554
+ this.refsArray[refArray].splice(nodeIndex, 1, child);
555
+ }
556
+ }
557
+ });
558
+ }
559
+
560
+ // returns a single HTMLElement or ClientComponent that has ref="refName" attribute
561
+ // if ref attribute is on a component tag, the ref will be promoted to ClientComponent
562
+ // in other cases it returns the HTMLElement
563
+ // this does not check if the ref exists
564
+ // you should make sure it does, otherwise you will get undefined at runtime
565
+ public ref<T>(refName: string): T {
566
+ return this.refs[refName] as T;
567
+ }
568
+
569
+ // returns an array of HTMLElement (type of the elements can be specified) that have array:ref="refName"
570
+ public refArray<T>(refName: string): Array<T> {
571
+ return (this.refsArray[refName] || []) as Array<T>;
572
+ }
573
+
574
+ // condition can be one of:
575
+ // 1) access to a boolean property in component store: [key]
576
+ // 2) comparison of a store property [key] ==|===|!=|<|>|<=|>= [comparison value or key]
577
+ // 3) method methodName() or methodName(arg)
578
+ private execCondition(conditionRaw: string): boolean {
579
+ const condition = conditionRaw.trim();
580
+ const isMethod = condition.endsWith(')');
581
+
582
+ if (isMethod) {
583
+ // method (case 3)
584
+ // method has to be in format !?[a-zA-Z]+[a-zA-Z0-9_]+\([^)]+\)
585
+ // extract expression parts
586
+ const parts = /^(!?)\s*([a-zA-Z]+[a-zA-Z0-9_]*)\(([^)]*)\)$/.exec(condition);
587
+ if (parts === null) {
588
+ console.error(`Could not parse condition ${condition}`);
589
+ return false;
590
+ }
591
+ const negated = parts[1] === '!';
592
+ const functionName = parts[2];
593
+ const args = parts[3].trim();
594
+
595
+ // make sure there is a registered callback with this name
596
+ if (typeof this.conditionalCallbacks[functionName] !== 'function') {
597
+ console.warn(`No registered conditional callback '${functionName}'`);
598
+ return false;
599
+ }
600
+
601
+ // run registered callback
602
+ const isTrue = this.conditionalCallbacks[functionName](args === '' ? undefined : eval(`(${args})`));
603
+ if (negated) {
604
+ return ! isTrue;
605
+ }
606
+ return isTrue;
607
+ } else {
608
+ // expression not a method
609
+ const parts = /^(!)?\s*([a-zA-Z]+[a-zA-Z0-9_]*)\s*((?:==)|(?:===)|(?:!=)|(?:!==)|<|>|(?:<=)|(?:>=))?\s*([^=].+)?$/.exec(condition);
610
+ if (parts === null) {
611
+ console.error(`Could not parse condition ${condition}`);
612
+ return false;
613
+ }
614
+
615
+ const property = parts[2];
616
+ const value = this.store.get(property);
617
+ const isComparison = parts[3] !== undefined;
618
+ if (isComparison) {
619
+ // comparison (case 2)
620
+ // left hand side is the property name, right hand side is an expression
621
+ let rightHandSide = null;
622
+ try {
623
+ // this won't fail as long as parts[4] is a recognized primitive (number, boolean, string...)
624
+ rightHandSide = eval(`${parts[4]}`);
625
+ } catch(e) {
626
+ // parts[4] failed to be parsed as a primitive
627
+ // assume it's a store value to allow comparing one store value to another
628
+ rightHandSide = this.store.get(parts[4]);
629
+ }
630
+
631
+ const comparisonSymbol = parts[3];
632
+
633
+ if (comparisonSymbol === '==') {
634
+ return value == rightHandSide;
635
+ } else if (comparisonSymbol === '===') {
636
+ return value === rightHandSide;
637
+ } else if (comparisonSymbol === '!=') {
638
+ return value != rightHandSide;
639
+ } else if (comparisonSymbol === '!==') {
640
+ return value !== rightHandSide;
641
+ } else {
642
+ // number comparison
643
+ if (typeof value !== 'number') {
644
+ // if value is not a number, these comparisons makes no sense, return false
645
+ return false;
646
+ }
647
+ if (comparisonSymbol === '>') {
648
+ return value > rightHandSide;
649
+ } else if (comparisonSymbol === '>=') {
650
+ return value >= rightHandSide;
651
+ } else if (comparisonSymbol === '<') {
652
+ return value < rightHandSide;
653
+ } else if (comparisonSymbol === '<=') {
654
+ return value <= rightHandSide;
655
+ }
656
+ }
657
+
658
+ return false;
659
+
660
+ } else {
661
+ // not a comparison (case 1)
662
+ const negated = parts[1] === '!';
663
+ const isTrue = this.store.get<boolean>(property);
664
+ if (negated) {
665
+ return !isTrue;
666
+ }
667
+ // value may not be a boolean, coerce to boolean without changing value
668
+ return !!isTrue;
669
+ }
670
+ }
671
+ }
672
+
673
+ // conditionals (data-if and data-classname-[className]) both support methods in condition
674
+ // eg. data-if="someMethod()"
675
+ // this allows users to define callbacks used in conditionals' conditions
676
+ // by default, this also runs updateConditionals
677
+ // as there might be conditionals that are using this callback
678
+ public conditionalCallback(name: string, callback: (args?: any) => boolean, updateConditionals: boolean = true): void {
679
+ this.conditionalCallbacks[name] = callback;
680
+ if (updateConditionals) {
681
+ this.updateConditionals(false);
682
+ }
683
+ }
684
+
685
+ // updates conditionals (data-if and data-classname-[className])
686
+ // data-if (conditionally show/hide DOM node)
687
+ // data-classname-[className] (conditionally add className to classList of the DOM node)
688
+ private updateConditionals(enableTransition: boolean) {
689
+ if (this.destroyed) {return;}
690
+
691
+ // data-if conditions
692
+ this.conditionals.forEach((node) => {
693
+ const condition = node.getAttribute('data-if');
694
+
695
+ if (typeof condition === 'string') {
696
+ const show = this.execCondition(condition);
697
+
698
+ if (show === true) {
699
+ // node.style.display = '';
700
+ this.show(node, enableTransition);
701
+ } else {
702
+ // node.style.display = 'none';
703
+ this.hide(node, enableTransition);
704
+ }
705
+ }
706
+ });
707
+
708
+ // data-classname conditions
709
+ this.conditionalClassNames.forEach((conditional) => {
710
+ const condition = conditional.element.getAttribute(`data-classname-${conditional.className}`);
711
+
712
+ if (typeof condition === 'string') {
713
+ const enableClassName = this.execCondition(condition);
714
+
715
+ if (enableClassName === true) {
716
+ conditional.element.classList.add(conditional.className);
717
+ } else {
718
+ conditional.element.classList.remove(conditional.className);
719
+ }
720
+ }
721
+ });
722
+ }
723
+
724
+ // remove the DOM node and delete from parent.children effectively removing self from the tree
725
+ // the method could be sync, but since we want to allow for potentially async user destructors
726
+ // it is async
727
+ public async remove(): Promise<void> {
728
+ if (!this.isRoot) {
729
+ // remove children recursively
730
+ const children = Array.from(this.children);
731
+ for (let i = 0; i < children.length; i++) {
732
+ await children[i].remove();
733
+ }
734
+
735
+ // remove from parent's children array
736
+ if (this.parent) {
737
+ this.parent.children.splice(this.parent.children.indexOf(this), 1);
738
+ }
739
+
740
+ // remove DOM node
741
+ this.domNode.parentElement?.removeChild(this.domNode);
742
+ await this.destroy();
743
+
744
+ }
745
+ }
746
+
747
+ // travel up the tree until a parent with given parentName is found
748
+ // if no such parent is found returns null
749
+ public parentFind(parentName: string): ClientComponent | null {
750
+ let parent = this.parent;
751
+ while (true) {
752
+ if (parent.name === parentName) {
753
+ return parent;
754
+ }
755
+
756
+ if (parent.isRoot) {
757
+ break;
758
+ }
759
+
760
+ parent = parent.parent;
761
+ }
762
+
763
+ return null;
764
+ }
765
+
766
+ // find a component with given name within this component
767
+ // if recursive = true, it searches recursively
768
+ // returns the first found component or null if no components were found
769
+ public find(componentName: string, recursive: boolean = true): null | ClientComponent {
770
+ for (let i = 0; i < this.children.length; i++) {
771
+ const child = this.children[i];
772
+ if (child.name == componentName) {
773
+ // found it
774
+ return child;
775
+ } else {
776
+ if (recursive) {
777
+ // search recursively, if found return
778
+ const inChild = child.find(componentName, recursive);
779
+ if (inChild) {
780
+ return inChild;
781
+ }
782
+ }
783
+ }
784
+ }
785
+ return null;
786
+ }
787
+
788
+ // find all components with given name within this component
789
+ // if recursive = true, it searches recursively
790
+ // returns an array of found components
791
+ public query(componentName: string, recursive: boolean = true, results: Array<ClientComponent> = []): Array<ClientComponent> {
792
+ for (let i = 0; i < this.children.length; i++) {
793
+ const child = this.children[i];
794
+ if (child.name == componentName) {
795
+ // found a component with name = componentName, add to results
796
+ results.push(child);
797
+ } else {
798
+ if (recursive) {
799
+ // search recursively, if found return
800
+ child.query(componentName, recursive, results);
801
+ }
802
+ }
803
+ }
804
+ return results;
805
+ }
806
+
807
+ // adds a new component to DOM/component tree
808
+ // appendTo is a selector within this component's DOM or a HTMLElement (which can be outside this component)
809
+ // data can be an object which is passed to added component
810
+ // regardless whether appendTo is within this component or not,
811
+ // added component will always be a child of this component
812
+ // returns a promise that resolves with the added component
813
+ public async add(appendTo: string | HTMLElement, componentName: string, data?: LooseObject): Promise<ClientComponent | null> {
814
+ if (window.structuredClientConfig.componentRender === false) {
815
+ console.error(`Can't add component, component rendering URL disabled`);
816
+ return null;
817
+ }
818
+
819
+ const container = typeof appendTo === 'string' ? this.domNode.querySelector(appendTo) : appendTo;
820
+
821
+ if (! (container instanceof HTMLElement)) {
822
+ throw new Error(`${this.name}.add() - appendTo selector not found within this component`);
823
+ }
824
+
825
+ // request rendered component from the server
826
+ // expected result is JSON, containing { html, initializers, data }
827
+ // unwrap set to false as we want the component container to be returned (unlike redraw)
828
+ const req = new NetRequest('POST', window.structuredClientConfig.componentRender, {
829
+ 'content-type': 'application/json'
830
+ });
831
+ const componentDataJSON = await req.send(JSON.stringify({
832
+ component: componentName,
833
+ attributes: data,
834
+ unwrap: false
835
+ }));
836
+
837
+ const res: {
838
+ html: string;
839
+ initializers: Record<string, string>;
840
+ data: LooseObject;
841
+ } = JSON.parse(componentDataJSON);
842
+
843
+ // if the current document did not include the added component (or components loaded within it)
844
+ // it's initializer will not be present in window.initializers
845
+ // add any missing initializers to global initializers list
846
+ for (let key in res.initializers) {
847
+ if (!window.initializers[key]) {
848
+ window.initializers[key] = res.initializers[key];
849
+ }
850
+ }
851
+
852
+ // create a temporary container to load the returned HTML into
853
+ const tmpContainer = document.createElement('div');
854
+ tmpContainer.innerHTML = res.html;
855
+
856
+ // get the first child, which is always the component wrapper <div data-structured-component="..."></div>
857
+ const componentNode = tmpContainer.firstChild as HTMLElement;
858
+
859
+ // create an instance of ClientComponent for the added component and add it to this.children
860
+ const component = new ClientComponent(this, componentName, componentNode, this.storeGlobal);
861
+ this.children.push(component);
862
+ await component.init(true);
863
+
864
+ // add the component's DOM node to container
865
+ container.appendChild(componentNode);
866
+
867
+ return component;
868
+ }
869
+
870
+ public getData<T>(key?: string): T {
871
+ if (!key) {
872
+ return this.data as T;
873
+ }
874
+ return this.data[key] as T;
875
+ }
876
+
877
+ // shows a previously hidden DOM node (domNode.style.display = '')
878
+ // if the DOM node has data-transition attributes, it will run the transition while showing the node
879
+ public show(domNode: HTMLElement, enableTransition: boolean = true): void {
880
+ if (!enableTransition) {
881
+ domNode.style.display = '';
882
+ return;
883
+ }
884
+
885
+ if (domNode.style.display !== 'none') { return; }
886
+
887
+ // const transitions = this.transitions.show;
888
+ const transitions = this.transitionAttributes(domNode).show;
889
+
890
+ const transitionsActive = Object.keys(transitions).filter((key: keyof ClientComponentTransition) => {
891
+ return transitions[key] !== false;
892
+ }).reduce((prev, curr) => {
893
+ const key = curr as keyof ClientComponentTransition;
894
+ prev[key] = transitions[key];
895
+ return prev;
896
+ }, {} as {
897
+ [key in keyof ClientComponentTransition]: false | number;
898
+ });
899
+
900
+ if (Object.keys(transitionsActive).length === 0) {
901
+ domNode.style.display = '';
902
+ return;
903
+ }
904
+
905
+ domNode.style.display = '';
906
+
907
+ const onTransitionEnd = (e: any) => {
908
+ domNode.style.opacity = '1';
909
+ domNode.style.transition = '';
910
+ domNode.style.transformOrigin = 'unset';
911
+ domNode.removeEventListener('transitionend', onTransitionEnd);
912
+ domNode.removeEventListener('transitioncancel', onTransitionEnd);
913
+ };
914
+
915
+ domNode.addEventListener('transitionend', onTransitionEnd);
916
+ domNode.addEventListener('transitioncancel', onTransitionEnd);
917
+
918
+ if (transitionsActive.slide) {
919
+
920
+ // if specified use given transformOrigin
921
+ const transformOrigin = domNode.getAttribute('data-transform-origin-show') || '50% 0';
922
+
923
+ domNode.style.transformOrigin = transformOrigin;
924
+ const axis = this.transitionAxis(domNode, 'show');
925
+ domNode.style.transform = `scale${axis}(0.01)`;
926
+ domNode.style.transition = `transform ${transitionsActive.slide / 1000}s`;
927
+ setTimeout(() => {
928
+ // domNode.style.height = height + 'px';
929
+ domNode.style.transform = `scale${axis}(1)`;
930
+ }, 100);
931
+ }
932
+
933
+ if (transitionsActive.fade) {
934
+ domNode.style.opacity = '0';
935
+ domNode.style.transition = `opacity ${transitionsActive.fade / 1000}s`;
936
+ setTimeout(() => {
937
+ domNode.style.opacity = '1';
938
+ }, 100);
939
+ }
940
+
941
+ }
942
+
943
+ // hides the given DOM node (domNode.style.display = 'none')
944
+ // if the DOM node has data-transition attributes, it will run the transition before hiding the node
945
+ public hide(domNode: HTMLElement, enableTransition: boolean = true): void {
946
+ if (!enableTransition) {
947
+ domNode.style.display = 'none';
948
+ return;
949
+ }
950
+
951
+ if (domNode.style.display === 'none') { return; }
952
+
953
+ // const transitions = this.transitions.hide;
954
+ const transitions = this.transitionAttributes(domNode).hide;
955
+
956
+ const transitionsActive = Object.keys(transitions).filter((key: keyof ClientComponentTransition) => {
957
+ return transitions[key] !== false;
958
+ }).reduce((prev, curr) => {
959
+ const key = curr as keyof ClientComponentTransition;
960
+ prev[key] = transitions[key];
961
+ return prev;
962
+ }, {} as {
963
+ [key in keyof ClientComponentTransition]: false | number;
964
+ });
965
+
966
+ if (Object.keys(transitionsActive).length === 0) {
967
+ // no transitions
968
+ domNode.style.display = 'none';
969
+ } else {
970
+
971
+ const onTransitionEnd = (e: any) => {
972
+ domNode.style.display = 'none';
973
+ domNode.style.opacity = '1';
974
+ domNode.style.transition = '';
975
+ domNode.style.transformOrigin = 'unset';
976
+ domNode.removeEventListener('transitionend', onTransitionEnd);
977
+ domNode.removeEventListener('transitioncancel', onTransitionEnd);
978
+ };
979
+
980
+ domNode.addEventListener('transitionend', onTransitionEnd);
981
+ domNode.addEventListener('transitioncancel', onTransitionEnd);
982
+
983
+ if (transitionsActive.slide) {
984
+ // domNode.style.overflowY = 'hidden';
985
+ // domNode.style.height = domNode.clientHeight + 'px';
986
+ // if specified use given transformOrigin
987
+ const transformOrigin = domNode.getAttribute('data-transform-origin-hide') || '50% 100%';
988
+
989
+ domNode.style.transformOrigin = transformOrigin;
990
+ domNode.style.transition = `transform ${transitionsActive.slide / 1000}s ease`;
991
+ setTimeout(() => {
992
+ // domNode.style.height = '2px';
993
+ const axis = this.transitionAxis(domNode, 'hide');
994
+ domNode.style.transform = `scale${axis}(0.01)`;
995
+ }, 100);
996
+ }
997
+
998
+ if (transitionsActive.fade) {
999
+ domNode.style.opacity = '1';
1000
+ domNode.style.transition = `opacity ${transitionsActive.fade / 1000}s`;
1001
+ setTimeout(() => {
1002
+ domNode.style.opacity = '0';
1003
+ }, 100);
1004
+ }
1005
+
1006
+ }
1007
+
1008
+ }
1009
+
1010
+ // reads attribute values of
1011
+ // data-transition-show-slide, data-transition-show-fade,
1012
+ // data-transition-hide-slide, data-transition-hide-fade
1013
+ // and parses them into ClientComponentTransitions object
1014
+ private transitionAttributes(domNode: HTMLElement): ClientComponentTransitions {
1015
+ const transitions: ClientComponentTransitions = {
1016
+ show: {
1017
+ slide: false,
1018
+ fade: false
1019
+ },
1020
+ hide: {
1021
+ slide: false,
1022
+ fade: false
1023
+ }
1024
+ };
1025
+
1026
+ objectEach(transitions, (transitionEvent, transition) => {
1027
+ objectEach(transition, (transitionType) => {
1028
+ const attributeName = `data-transition-${transitionEvent}-${transitionType}`;
1029
+ if (domNode.hasAttribute(attributeName)) {
1030
+ const valueRaw = domNode.getAttribute(attributeName);
1031
+ let value: number | false = false;
1032
+ if (typeof valueRaw === 'string' && /^\d+$/.test(valueRaw)) {
1033
+ value = parseInt(valueRaw);
1034
+ }
1035
+ transition[transitionType] = value;
1036
+ }
1037
+ });
1038
+ });
1039
+
1040
+ return transitions;
1041
+ }
1042
+
1043
+ // reads data-transition-axis-[show|hide] of given domNode
1044
+ // returns "" if attribute is missing or has an unrecognized value
1045
+ // return "X" or "Y" if a proper value is found
1046
+ private transitionAxis(domNode: HTMLElement, showHide: 'show' | 'hide'): 'X' | 'Y' | '' {
1047
+ const attributeName = `data-transition-axis-${showHide}`;
1048
+ if (! domNode.hasAttribute(attributeName)) {return '';}
1049
+ let val = domNode.getAttribute(attributeName);
1050
+ if (typeof val === 'string') {
1051
+ val = val.trim().toUpperCase();
1052
+ if (val.length > 0) {
1053
+ val = val.substring(0, 1);
1054
+ }
1055
+
1056
+ if (val != 'X' && val != 'Y') {
1057
+ // unrecognized value
1058
+ return '';
1059
+ }
1060
+
1061
+ return val;
1062
+ }
1063
+ return '';
1064
+ }
1065
+
1066
+ private async destroy(): Promise<void> {
1067
+
1068
+ // if being redrawn, abort redraw request
1069
+ if (this.redrawRequest) {
1070
+ this.redrawRequest.abort();
1071
+ this.redrawRequest = null;
1072
+ }
1073
+
1074
+ // if the user has defined a destroy callback, run it
1075
+ if (typeof this.onDestroy === 'function') {
1076
+ await this.onDestroy.apply(this);
1077
+ }
1078
+
1079
+ this.store.destroy();
1080
+
1081
+ // remove all event listeners attached to DOM elements
1082
+ this.unbindAll();
1083
+
1084
+ // clean up and free memory
1085
+ this.conditionals = [];
1086
+ this.conditionalClassNames = [];
1087
+ this.conditionalCallbacks = {};
1088
+ this.refs = {};
1089
+ this.refsArray = {};
1090
+ this.initializer = null;
1091
+ this.data = {};
1092
+
1093
+ // mark destroyed
1094
+ this.destroyed = true;
1095
+ }
1096
+
1097
+ // add an event listener to given DOM node
1098
+ // stores it to ClientComponent.bound so it can be unbound when needed using unbindAll
1099
+ public bind(element: HTMLElement, event: string, callback: (e: Event) => void): void {
1100
+ if (element instanceof HTMLElement) {
1101
+ this.bound.push({
1102
+ element,
1103
+ event,
1104
+ callback
1105
+ });
1106
+ element.addEventListener(event, callback);
1107
+ }
1108
+ }
1109
+
1110
+ // remove all bound event listeners using ClientComponent.bind
1111
+ private unbindAll() {
1112
+ this.bound.forEach((bound) => {
1113
+ bound.element.removeEventListener(bound.event, bound.callback);
1114
+ });
1115
+ this.bound = [];
1116
+ }
1117
+ }