objs-core 1.1.1 → 2.0.1

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.
package/objs.js ADDED
@@ -0,0 +1,3805 @@
1
+ /**
2
+ * @fileoverview Objs-core library
3
+ * @version 2.0
4
+ * @author Roman Torshin
5
+ * @license Apache-2.0
6
+ */
7
+
8
+ /** @type {boolean} When true, enables debug flag and debug logging (o.debug, result.debug, console.log in returner/o.first). */
9
+ const __DEV__ = true;
10
+
11
+ /**
12
+ * Main Objs function for DOM manipulation and state control
13
+ * @function Objs
14
+ * @param {any} query - Selector, DOM element to use, an array of elements, inited ID or nothing for creating an element
15
+ * @returns {Object} Objs instance with DOM manipulation methods
16
+ */
17
+ const o = (query) => {
18
+ let result = {
19
+ els: [],
20
+ ie: {},
21
+ delegated: {},
22
+ parented: {},
23
+ store: {},
24
+ refs: {},
25
+ states: [],
26
+ isDebug: false,
27
+ currentState: "",
28
+ savedStates: {},
29
+ isRoot: false,
30
+ _parent: null,
31
+ },
32
+ ONE = 1,
33
+ TWO = 2,
34
+ THREE = 3,
35
+ booleanType = "boolean",
36
+ objectType = "object",
37
+ functionType = "function",
38
+ stringType = "string",
39
+ numberType = "number",
40
+ notEmptyStringType = "notEmptyString",
41
+ undefinedType = "undefined",
42
+ _reactProp = "dangerouslySetInnerHTML",
43
+ u,
44
+ D = o.D,
45
+ start = -1,
46
+ finish = 0,
47
+ select = 0,
48
+ ssr = typeof process !== "undefined" || o.D === o.DocumentMVP,
49
+ i = 0,
50
+ j = 0;
51
+ const self = result; // capture so connect() always passes this instance to loader
52
+
53
+ /**
54
+ * Shortcut for typeof operator
55
+ * @param {any} obj - Object to check type of
56
+ * @returns {string} Type of the object
57
+ */
58
+ const type = (obj) => typeof obj;
59
+
60
+ /**
61
+ * Iterate through object properties
62
+ * @param {Object} obj - Object to iterate through
63
+ * @param {Function} func - Function to execute for each property
64
+ */
65
+ const cycleObj = (obj, func) => {
66
+ for (const item in obj) if (Object.hasOwn(obj, item)) func(item, obj);
67
+ };
68
+
69
+ /**
70
+ * Error handling function
71
+ * @type {Function}
72
+ */
73
+ const error = o.onError;
74
+ const typeVerify = (pairs) => o.verify(pairs);
75
+
76
+ /**
77
+ * Creates a function that returns values or the result object
78
+ * @param {Function} f - Function to wrap
79
+ * @returns {Function} Wrapped function that handles errors and returns
80
+ */
81
+ const returner = (f, name = "") => {
82
+ return (...a) => {
83
+ if (__DEV__ && (o.debug || result.isDebug)) {
84
+ console.log(
85
+ name ? `${name}()` : f,
86
+ a.length ? "with " + a.join(", ") : "without parameters",
87
+ );
88
+ }
89
+ try {
90
+ const res = f(a[0], a[ONE], a[TWO], a[THREE]);
91
+ return res !== u ? res : result;
92
+ } catch (err) {
93
+ error(err, name);
94
+ }
95
+ };
96
+ };
97
+
98
+ /**
99
+ * Iterate through selected elements
100
+ * @param {Function} f - Function to execute for each element
101
+ */
102
+ const iterator = (f) => {
103
+ for (i = finish; i <= start; i++) f();
104
+ };
105
+
106
+ /**
107
+ * Convert query to DOM element
108
+ * @param {any} el - Element to convert
109
+ * @returns {Element} DOM element
110
+ */
111
+ const toEl = (el) => {
112
+ if (el?.els) return el.el; // ObjsInstance → first DOM element
113
+ if (type(el) !== objectType) el = o.first(el).el;
114
+ return el;
115
+ };
116
+
117
+ /**
118
+ * Set result object properties
119
+ * @param {boolean} clearStates - Whether to clear states
120
+ * @param {Array} els - Elements to set properties for
121
+ */
122
+ const setResultVals = (clearStates = true, els = result.els) => {
123
+ const ln = els.length;
124
+ result.length = ln;
125
+ start = ln - ONE;
126
+ finish = 0;
127
+ result.el = ln ? els[0] : u;
128
+ result.last = ln ? els[start] : u;
129
+ if (clearStates) {
130
+ cycleObj(result.states, (i, state) => {
131
+ delete result[state[i]];
132
+ });
133
+ result.states = [];
134
+ result.ie = {};
135
+ }
136
+ };
137
+ // sets new objects to operate
138
+ result.reset = o;
139
+
140
+ /**
141
+ * Transform DOM elements based on state and props
142
+ * @param {Element} el - DOM element to transform
143
+ * @param {Object} state - State data
144
+ * @param {Object} props - Additional props and dynamic content
145
+ */
146
+ const transform = (el, state, props) => {
147
+ // filter state vs current state
148
+ cycleObj(state, (s) => {
149
+ let value = state[s];
150
+
151
+ // eval functions in attributes
152
+ if (type(value) === functionType) {
153
+ value = value(props);
154
+ }
155
+
156
+ // prepare objs to append
157
+ if (s === "append" && type(value) === objectType) {
158
+ if (value.els) {
159
+ value = [value];
160
+ }
161
+ if (value[0]?.els) {
162
+ valueBuff = [];
163
+ cycleObj(value, (i) => {
164
+ valueBuff.push(...value[i].els);
165
+ });
166
+ value = valueBuff;
167
+ }
168
+ }
169
+
170
+ if (
171
+ value !== u &&
172
+ el.getAttribute(s) !== value &&
173
+ ![
174
+ "tag",
175
+ "tagName",
176
+ "name",
177
+ "sample",
178
+ "state",
179
+ "events",
180
+ "ssr",
181
+ "nodeName",
182
+ "revertChildren",
183
+ "root",
184
+ "ref",
185
+ ].includes(s)
186
+ ) {
187
+ // insert html
188
+ ["html", "innerHTML"].includes(s)
189
+ ? (el.innerHTML = value)
190
+ : // className alias
191
+ s === "className"
192
+ ? el.setAttribute("class", value)
193
+ : // attach dataset
194
+ s === "dataset" && type(value) === objectType
195
+ ? cycleObj(value, (data) => {
196
+ el.dataset[data] = value[data];
197
+ })
198
+ : // classes
199
+ s === "toggleClass"
200
+ ? el.classList.toggle(value)
201
+ : s === "addClass"
202
+ ? type(value) === objectType
203
+ ? el.classList.add(...value)
204
+ : el.classList.add(value)
205
+ : s === "removeClass"
206
+ ? el.classList.remove(value)
207
+ : // style attribute
208
+ s === "style" && type(value) === objectType
209
+ ? cycleObj(value, (data) => {
210
+ el.style[data] = value[data];
211
+ })
212
+ : // append DOM objects
213
+ (s === "append" || s === "children" || s === "childNodes") &&
214
+ type(value) === objectType
215
+ ? cycleObj(value.length ? value : [value], (j) => {
216
+ if (s === "append" || !el.childNodes[j]) {
217
+ el.appendChild(value[j]);
218
+ } else if (el.childNodes[j] !== value[j]) {
219
+ el.childNodes[j].replaceWith(value[j]);
220
+ }
221
+ })
222
+ : // set attributes
223
+ el.setAttribute(s, value);
224
+ }
225
+ });
226
+
227
+ el.dataset["oState"] = state.state;
228
+ // autotag: set data-{o.autotag}="component-name" from states.name
229
+ if (o.autotag && state.name) {
230
+ el.dataset[o.autotag] = o.camelToKebab(state.name);
231
+ }
232
+ };
233
+
234
+ if (__DEV__) {
235
+ /**
236
+ * Enable debug mode
237
+ */
238
+ result.debug = returner(() => {
239
+ result.isDebug = true;
240
+ }, "debug ON");
241
+ }
242
+
243
+ /**
244
+ * Save object by key in o.getSaved{}
245
+ */
246
+ result.saveAs = returner((key) => {
247
+ typeVerify([[key, [notEmptyStringType]]]);
248
+ if (!o.getSaved[key]) {
249
+ o.getSaved[key] = result;
250
+ } else if (o.debug || result.isDebug) {
251
+ console.warn("the key exists (not saved):" + key);
252
+ }
253
+ }, "saveAs");
254
+
255
+ /**
256
+ * Unmount the component
257
+ * @returns {boolean} True if the component was unmounted
258
+ */
259
+ result.unmount = () => {
260
+ if (o.debug || result.isDebug) {
261
+ console.log("unmount() for initID:" + result.initID);
262
+ }
263
+ if (type(result.remove) === functionType) {
264
+ result.remove();
265
+ } else if (result.els?.length) {
266
+ result.els.forEach((el) => {
267
+ if (el?.parentNode) el.parentNode.removeChild(el);
268
+ });
269
+ }
270
+ o.inits[result.initID] = undefined;
271
+ result = {};
272
+ return true;
273
+ };
274
+
275
+ /**
276
+ * Initialize states and create state functions
277
+ * @param {Object|Function|Component} states - States object or Component function
278
+ */
279
+ result.init = returner((states) => {
280
+ typeVerify([[states, [objectType, functionType]]]);
281
+ const initN = result.initID || o.inits.length || 0;
282
+ result.initID = initN;
283
+ setResultVals();
284
+ o.inits[result.initID] = result;
285
+
286
+ // React Component usage for rendering
287
+ if (type(states) === "function") {
288
+ result.render = returner((props) => {
289
+ const root = D.createElement("div");
290
+ setTimeout(() => {
291
+ o.reactRender(states, root, props);
292
+ });
293
+ result.add(root);
294
+ });
295
+ return;
296
+ }
297
+
298
+ // fast initialisation
299
+ if (type(states) !== objectType || states.render === u) {
300
+ states = {
301
+ render: states,
302
+ };
303
+ }
304
+
305
+ // cycle threw states
306
+ cycleObj(states, (state) => {
307
+ // prevent render override
308
+ if (result?.render && state === "render") {
309
+ return;
310
+ }
311
+ // save state name to clear object by reset();
312
+ result.states.push(state);
313
+ // add method named as state
314
+ result[state] = returner((props = [{}]) => {
315
+ result.currentState = state;
316
+ const data = states[state] || { tag: "div" };
317
+ const slice = Array.isArray(result.els)
318
+ ? result.els.slice(finish, start + ONE)
319
+ : [];
320
+ const els = slice.length ? slice : (result.els || []);
321
+
322
+ if (type(data) === objectType) {
323
+ data.state = state;
324
+ data["data-o-init"] = initN;
325
+ }
326
+
327
+ // creation elements for prop in props
328
+ const newEl = (n, prop = {}) => {
329
+ if (type(data) === objectType) {
330
+ return D.createElement(data.tag || data.tagName || "div");
331
+ } else {
332
+ const newElem = D.createElement("div");
333
+ newElem.innerHTML = type(data) === functionType ? data(prop) : data;
334
+ if (newElem.children.length > ONE || !newElem.firstElementChild) {
335
+ newElem.dataset.oInit = n;
336
+ return newElem;
337
+ } else {
338
+ newElem.firstElementChild.dataset.oInit = n;
339
+ return newElem.firstElementChild;
340
+ }
341
+ }
342
+ };
343
+
344
+ // properties creation
345
+ const rawData = props; // raw argument before array-wrapping
346
+ !props.length ? (props = [props]) : props;
347
+
348
+ // creating elements if no one was selected
349
+ const creation = !els[0] && state === "render";
350
+ props = props.map((prop, i) => {
351
+ const newProp = Object.assign({}, type(prop) === objectType ? prop : {}, {
352
+ self: result,
353
+ o,
354
+ i: prop.i === u ? i : prop.i,
355
+ parent: result._parent,
356
+ data: Array.isArray(rawData) ? rawData[i] : rawData,
357
+ });
358
+ if (creation && (!data.ssr || ssr)) {
359
+ els.push(newEl(initN, newProp));
360
+ }
361
+ return newProp;
362
+ });
363
+ if (creation) {
364
+ result.els = els;
365
+ setResultVals(false);
366
+ }
367
+
368
+ // initing events
369
+ const initSSR = () => {
370
+ cycleObj(data.events, (event) => {
371
+ result.on(event, data.events[event]);
372
+ });
373
+ };
374
+
375
+ // changing element if there is data object
376
+ if (els) {
377
+ j = els.length === props.length;
378
+ els.map((el, i) => {
379
+ props[j ? i : 0].i = i + finish;
380
+ const buff = type(data) === functionType ? data(props[j ? i : 0]) : data;
381
+ if (type(buff) === objectType) {
382
+ buff["state"] = state;
383
+ if (creation) {
384
+ buff["data-o-init"] = initN;
385
+ buff["data-o-init-i"] = i;
386
+ }
387
+ transform(el, buff, props[j ? i : 0]);
388
+ }
389
+ });
390
+ if (creation) {
391
+ result.refs = {};
392
+ result.els.forEach((el) => {
393
+ if (!el.querySelectorAll) return;
394
+ el.querySelectorAll("[ref]").forEach((refEl) => {
395
+ result.refs[refEl.getAttribute("ref")] = o(refEl);
396
+ refEl.removeAttribute("ref");
397
+ });
398
+ });
399
+ }
400
+ }
401
+
402
+ // init events if there is data object and events are defined
403
+ if (creation && type(data) === objectType && data.events) {
404
+ // check for SSR and SSR flag in data object to allow testing in browser
405
+ if (!ssr && !data.ssr) {
406
+ initSSR();
407
+ }
408
+ }
409
+ });
410
+ });
411
+ const renderState = states.render || states;
412
+ if (
413
+ !ssr &&
414
+ type(renderState) === objectType &&
415
+ renderState.events &&
416
+ renderState.ssr
417
+ ) {
418
+ result.initSSRAfterGettingSSR = () => {
419
+ result.refs = {};
420
+ result.els.forEach((el) => {
421
+ if (!el.querySelectorAll) return;
422
+ el.querySelectorAll("[ref]").forEach((refEl) => {
423
+ result.refs[refEl.getAttribute("ref")] = o(refEl);
424
+ refEl.removeAttribute("ref");
425
+ });
426
+ });
427
+ cycleObj(renderState.events, (event) => {
428
+ result.on(event, renderState.events[event]);
429
+ });
430
+ };
431
+ }
432
+ }, "init");
433
+
434
+ /**
435
+ * Connect loader to result
436
+ * @param {Object} loader - Loader object
437
+ * @param {string} state - State name
438
+ * @param {Function} fail - Fail state name
439
+ */
440
+ result.connect = returner((loader, state = "render", fail) => {
441
+ typeVerify([
442
+ [loader, [objectType]],
443
+ [state, [notEmptyStringType]],
444
+ [fail, [stringType, undefinedType]],
445
+ ]);
446
+ loader.connect(self, state, fail);
447
+ }, "connect");
448
+
449
+ /**
450
+ * Get SSR elements
451
+ * @param {number} initId - Initialization ID
452
+ */
453
+ result.getSSR = returner((initId) => {
454
+ typeVerify([[initId, [numberType, undefinedType]]]);
455
+ const effectiveId = initId !== undefined ? initId : result.initID;
456
+ if (
457
+ ssr ||
458
+ (type(initId) === undefinedType && type(result.initID) === undefinedType)
459
+ ) {
460
+ return;
461
+ }
462
+ const ssrEls = o.D.querySelectorAll(`[data-o-init="${effectiveId}"]`);
463
+
464
+ if (ssrEls.length && !result.els.length) {
465
+ result.els = Array.from(ssrEls);
466
+ result.initID = initId;
467
+ o.inits[initId] = result;
468
+ setResultVals(false);
469
+
470
+ if (type(result.initSSRAfterGettingSSR) === functionType) {
471
+ result.initSSRAfterGettingSSR();
472
+ delete result.initSSRAfterGettingSSR;
473
+ }
474
+ }
475
+ }, "getSSR");
476
+
477
+ /**
478
+ * Initialize state with props
479
+ * @param {Object} state - State object
480
+ * @param {Object} props - Props to initialize with
481
+ */
482
+ result.initState = returner((state, props) => {
483
+ typeVerify([
484
+ [state, [objectType]],
485
+ [props, [objectType, undefinedType]],
486
+ ]);
487
+ result.init(state).render(props);
488
+ }, "initState");
489
+
490
+ /**
491
+ * Parse state of element
492
+ * @param {Element} el - Element to parse
493
+ * @param {String} stateId - Title for state saving
494
+ * @param {boolean} root - Parse child elements if true
495
+ * @returns {Object} State object
496
+ */
497
+ const parseState = (el, stateId, root) => {
498
+ const attrs = el.attributes;
499
+ const stateData = {
500
+ tagName: el.tagName.toLowerCase(),
501
+ };
502
+
503
+ for (const attr of attrs) {
504
+ stateData[attr.nodeName] = attr.value;
505
+ }
506
+
507
+ if (root) {
508
+ stateData.innerHTML = el.innerHTML;
509
+ stateData.revertChildren = [];
510
+ const initedChildren = el.querySelectorAll("[data-o-init]");
511
+ for (const child of initedChildren) {
512
+ const initId = child.getAttribute("data-o-init");
513
+ stateData.revertChildren.push(initId);
514
+ // save state of children for revert
515
+ o.inits[initId]?.saveState(stateId, false);
516
+ }
517
+ }
518
+
519
+ return stateData;
520
+ };
521
+
522
+ /**
523
+ * Save state of elements
524
+ * @param {string|undefined} stateId - State string ID or will be 'fastSavedState'
525
+ * @param {boolean} root - Root element flag
526
+ * @returns {Object} State object
527
+ */
528
+ result.saveState = returner((stateId, root = true) => {
529
+ typeVerify([
530
+ [stateId, [notEmptyStringType, undefinedType]],
531
+ [root, [booleanType]],
532
+ ]);
533
+
534
+ if (!result.el) {
535
+ throw Error("saveState(): There are no elements to save");
536
+ }
537
+
538
+ const targetState = stateId ? stateId : "fastSavedState";
539
+ const stateRevert = { els: [], parentNode: result.el.parentNode, root: root };
540
+
541
+ iterator(() => {
542
+ stateRevert.els.push(parseState(result.els[i], targetState, root));
543
+ });
544
+
545
+ stateRevert.ie = Object.assign({}, result.ie);
546
+ stateRevert.delegated = Object.assign({}, result.delegated);
547
+ stateRevert.store = Object.assign({}, result.store);
548
+
549
+ // save the save flag
550
+ result.isRoot = result.isRoot || root;
551
+ result.savedStates[targetState] = stateRevert;
552
+ }, "saveState");
553
+
554
+ /**
555
+ * Revert state of elements
556
+ * @param {string|undefined} state - State name or will be 'fastSavedState'
557
+ */
558
+ result.revertState = returner((state) => {
559
+ typeVerify([[state, [notEmptyStringType, undefinedType]]]);
560
+
561
+ const targetState = state ? state : "fastSavedState";
562
+
563
+ if (!result.savedStates[targetState]) {
564
+ throw Error(
565
+ `revertState(): The state "${targetState}" should have been saved by saveState()`,
566
+ );
567
+ }
568
+
569
+ const stateRevert = result.savedStates[targetState];
570
+
571
+ // turn off all event listeners connected to the elements
572
+ result.offAll();
573
+ result.offDelegate();
574
+
575
+ // revert elements
576
+ result.store = Object.assign({}, stateRevert.store);
577
+ stateRevert.els.forEach((elData, index) => {
578
+ // create element if not exist
579
+ if (!result.els[index]) {
580
+ const newEl = o.D.createElement(elData.tagName);
581
+ // if element was in DOM
582
+ if (stateRevert.parentNode) {
583
+ if (index) {
584
+ result.els[index - 1].after(newEl);
585
+ } else {
586
+ stateRevert.parentNode.append(newEl);
587
+ }
588
+ }
589
+ result.add(newEl);
590
+ }
591
+ transform(result.els[index], elData);
592
+ });
593
+
594
+ // revert event listeners
595
+ result.delegated = Object.assign({}, stateRevert.delegated);
596
+ result.ie = Object.assign({}, stateRevert.ie);
597
+ result.onAll();
598
+ cycleObj(stateRevert.delegated, (ev) => {
599
+ stateRevert.delegated[ev].forEach((f) => {
600
+ iterator(() => {
601
+ result.els[i].addEventListener(ev, f);
602
+ });
603
+ });
604
+ });
605
+
606
+ result.currentState = targetState;
607
+
608
+ // revert children from HTML
609
+ if (stateRevert.root) {
610
+ stateRevert.els.forEach(({ rootElement }) => {
611
+ rootElement.revertChildren.forEach((initId) => {
612
+ o.inits[initId]?.revertState(targetState);
613
+ o('[data-o-init="' + initId + '"]').els.forEach((el, index) => {
614
+ el.replaceWith(o.inits[initId]?.els[index]);
615
+ });
616
+ });
617
+ });
618
+ }
619
+ }, "revertState");
620
+
621
+ /**
622
+ * Lose state of elements
623
+ * @param {string} stateId - State ID
624
+ */
625
+ result.loseState = returner((stateId) => {
626
+ typeVerify([[stateId, [notEmptyStringType]]]);
627
+
628
+ if (result.savedStates[stateId]) {
629
+ delete result.savedStates[stateId];
630
+ iterator(() => {
631
+ const initedChildren = result.els[i].querySelectorAll("[data-o-init]");
632
+ for (const child of initedChildren) {
633
+ const initId = child.getAttribute("data-o-init");
634
+ o.inits[initId]?.loseState(stateId);
635
+ }
636
+ });
637
+ }
638
+ }, "sample");
639
+
640
+ /**
641
+ * Get state object from existing DOM element
642
+ * @param {string} state - State title (optional)
643
+ * @returns {Object} State object
644
+ */
645
+ result.sample = returner((state = "render") => {
646
+ typeVerify([[state, [notEmptyStringType]]]);
647
+ return { [state]: parseState(result.els[finish]) };
648
+ }, "sample");
649
+
650
+ /**
651
+ * Select element to control
652
+ * @param {number} i - Index of element to select
653
+ */
654
+ result.select = returner((i) => {
655
+ typeVerify([[i, [numberType, undefinedType]]]);
656
+ if (i === u) {
657
+ i = result.length - ONE;
658
+ }
659
+ start = i;
660
+ finish = i;
661
+ result.el = result.els[i];
662
+ select = ONE;
663
+ }, "select");
664
+
665
+ /**
666
+ * Select all elements to control
667
+ */
668
+ result.all = returner(() => {
669
+ start = result.length - ONE;
670
+ finish = 0;
671
+ result.el = result.els[0];
672
+ select = 0;
673
+ }, "all");
674
+
675
+ /**
676
+ * Remove selected element or all elements from DOM
677
+ * @param {number} j - Index of element to remove
678
+ */
679
+ result.remove = returner((j) => {
680
+ typeVerify([[j, [numberType, undefinedType]]]);
681
+ if (j === u && select) {
682
+ j = finish;
683
+ }
684
+
685
+ if (j !== u) {
686
+ const el = result.els[j];
687
+ if (el?.parentNode) {
688
+ el.parentNode.removeChild(el);
689
+ } else if (el === undefined && j >= result.els.length) {
690
+ if (o.onError) o.onError("remove(" + j + "): index out of bounds", "remove");
691
+ }
692
+ } else {
693
+ iterator(() => {
694
+ const el = result.els[i];
695
+ if (el?.parentNode) el.parentNode.removeChild(el);
696
+ });
697
+ }
698
+ setResultVals(false);
699
+ }, "remove");
700
+
701
+ /**
702
+ * Skip element from control list
703
+ * @param {number} j - Index of element to skip
704
+ */
705
+ result.skip = returner((j) => {
706
+ typeVerify([[j, [numberType, undefinedType]]]);
707
+ if (j === u) {
708
+ j = finish;
709
+ }
710
+
711
+ result.els.splice(i, ONE);
712
+ setResultVals();
713
+ }, "skip");
714
+
715
+ /**
716
+ * Add element to control list
717
+ * @param {any} el - Element to add
718
+ */
719
+ result.add = returner((el) => {
720
+ typeVerify([[el, [stringType, objectType, numberType]]]);
721
+ if (result.initID !== u) {
722
+ return;
723
+ }
724
+
725
+ if (type(el) === "string" && el !== "") {
726
+ result.els.push(...Array.from(D.querySelectorAll(el)));
727
+ } else if (type(el) === objectType) {
728
+ if (el.tagName) {
729
+ result.els.push(el);
730
+ } else if (el.els) {
731
+ result.els.push(...el.els);
732
+ } else if (el.length && el[0].tagName) {
733
+ result.els.push(...el);
734
+ }
735
+ } else if (type(el) === "number" && o.inits[el]) {
736
+ result = o.inits[el];
737
+ }
738
+
739
+ setResultVals(false);
740
+
741
+ if (result.initID !== u) {
742
+ result.dataset({ oInit: result.initID });
743
+ }
744
+ }, "add");
745
+
746
+ /**
747
+ * Append elements inside another element
748
+ * @param {Element|String} el - Parent element
749
+ */
750
+ result.appendInside = returner((el) => {
751
+ typeVerify([[el, [objectType, notEmptyStringType]]]);
752
+ if (el?.els) result._parent = el; // store ObjsInstance parent reference
753
+ iterator(() => {
754
+ toEl(el).appendChild(result.els[i]);
755
+ });
756
+ }, "appendInside");
757
+
758
+ /**
759
+ * Append elements before another element
760
+ * @param {Element} el - Reference element
761
+ */
762
+ result.appendBefore = returner((el) => {
763
+ typeVerify([[el, [objectType, notEmptyStringType]]]);
764
+ iterator(() => {
765
+ toEl(el).parentNode.insertBefore(result.els[i], toEl(el));
766
+ });
767
+ }, "appendBefore");
768
+
769
+ /**
770
+ * Append elements after another element
771
+ * @param {Element} el - Reference element
772
+ */
773
+ result.appendAfter = returner((el) => {
774
+ typeVerify([[el, [objectType, notEmptyStringType]]]);
775
+ iterator(() => {
776
+ toEl(el).after(...result.els);
777
+ });
778
+ }, "appendAfter");
779
+
780
+ /**
781
+ * Find child elements
782
+ * @param {string} innerQuery - Query selector
783
+ * @returns {Object} Objs instance with found elements
784
+ */
785
+ result.find = returner((innerQuery = "") => {
786
+ typeVerify([[innerQuery, stringType]]);
787
+ const newEls = [];
788
+
789
+ iterator(() => {
790
+ newEls.push(...Array.from(result.els[i].querySelectorAll(":scope " + innerQuery)));
791
+ });
792
+
793
+ return o(newEls);
794
+ }, "find");
795
+
796
+ /**
797
+ * Find first child element by query
798
+ * @param {string} innerQuery - Query selector
799
+ * @returns {Object} Objs instance with found element
800
+ */
801
+ result.first = returner((innerQuery = "") => {
802
+ typeVerify([[innerQuery, stringType]]);
803
+ let buff = u;
804
+ const newEls = [];
805
+
806
+ iterator(() => {
807
+ buff = result.els[i].querySelector(innerQuery);
808
+ if (buff) {
809
+ newEls.push(buff);
810
+ }
811
+ });
812
+
813
+ return o(newEls);
814
+ }, "first");
815
+
816
+ /**
817
+ * Set, delete or get attribute
818
+ * @param {string} attr - Attribute name
819
+ * @param {any} val - Attribute value
820
+ */
821
+ result.attr = returner((attr, val) => {
822
+ if (val !== null) {
823
+ typeVerify([
824
+ [attr, stringType],
825
+ [val, [stringType, undefinedType]],
826
+ ]);
827
+ }
828
+ if (val === u) {
829
+ const attrs = [];
830
+ iterator(() => {
831
+ attrs[i] = result.els[i].getAttribute(attr);
832
+ });
833
+ return select ? attrs[0] : attrs;
834
+ } else if (val !== null) {
835
+ iterator(() => {
836
+ result.els[i].setAttribute(attr, val);
837
+ });
838
+ } else {
839
+ iterator(() => {
840
+ result.els[i].removeAttribute(attr);
841
+ });
842
+ }
843
+ }, "attr");
844
+
845
+ /**
846
+ * Get all attributes
847
+ * @returns {Array|Object} Array of attribute objects or single attribute object
848
+ */
849
+ result.attrs = returner(() => {
850
+ const res = [];
851
+ iterator(() => {
852
+ const obj = {};
853
+ [...result.els[i].attributes].forEach((attr) => {
854
+ obj[attr.nodeName] = attr.nodeValue;
855
+ });
856
+ res.push(obj);
857
+ });
858
+ return select ? res[0] : res;
859
+ }, "attrs");
860
+
861
+ /**
862
+ * Control dataset
863
+ * @param {Object} values - Dataset values
864
+ */
865
+ result.dataset = returner((values) => {
866
+ typeVerify([[values, [objectType, undefinedType]]]);
867
+ if (typeof values === objectType) {
868
+ iterator(() => {
869
+ cycleObj(values, (data) => {
870
+ result.els[i].dataset[data] = values[data];
871
+ });
872
+ });
873
+ } else {
874
+ const res = [];
875
+ iterator(() => {
876
+ res.push({ ...result.els[i].dataset });
877
+ });
878
+ return select ? res[0] : res;
879
+ }
880
+ }, "dataset");
881
+
882
+ /**
883
+ * Set style attribute
884
+ * @param {string} val - Style value
885
+ */
886
+ result.style = returner((val) => {
887
+ if (val !== null) typeVerify([[val, [stringType, undefinedType]]]);
888
+ result.attr("style", val);
889
+ }, "style");
890
+
891
+ /**
892
+ * Set CSS styles from object. Pass null to remove the style attribute entirely.
893
+ * @param {Object|null} styles - CSS styles object, or null to remove
894
+ */
895
+ result.css = returner((styles = {}) => {
896
+ if (styles === null) {
897
+ result.style(null);
898
+ return;
899
+ }
900
+ typeVerify([[styles, objectType]]);
901
+ let val = "";
902
+ cycleObj(styles, (style) => {
903
+ val += style + ":" + styles[style].replace('"', "'") + ";";
904
+ });
905
+ result.style(val || null);
906
+ }, "css");
907
+
908
+ /**
909
+ * Set class attribute
910
+ * @param {string} cl - Class name
911
+ */
912
+ result.setClass = returner((cl) => {
913
+ typeVerify([[cl, stringType]]);
914
+ iterator(() => {
915
+ result.els[i].setAttribute("class", cl);
916
+ });
917
+ }, "setClass");
918
+
919
+ /**
920
+ * Add one or more classes to elements
921
+ * @param {...string} cls - Class names
922
+ */
923
+ result.addClass = returner((...cls) => {
924
+ iterator(() => {
925
+ result.els[i].classList.add(...cls);
926
+ });
927
+ }, "addClass");
928
+
929
+ /**
930
+ * Remove one or more classes from elements
931
+ * @param {...string} cls - Class names
932
+ */
933
+ result.removeClass = returner((...cls) => {
934
+ iterator(() => {
935
+ result.els[i].classList.remove(...cls);
936
+ });
937
+ }, "removeClass");
938
+
939
+ /**
940
+ * Toggle class on elements
941
+ * @param {string} cl - Class name
942
+ * @param {boolean} check - Whether to add or remove class
943
+ */
944
+ result.toggleClass = returner((cl, check) => {
945
+ typeVerify([
946
+ [cl, notEmptyStringType],
947
+ [check, [booleanType, undefinedType]],
948
+ ]);
949
+ iterator(() => {
950
+ result.els[i].classList.toggle(cl, check);
951
+ });
952
+ }, "toggleClass");
953
+
954
+ /**
955
+ * Check if elements have class
956
+ * @param {string} cl - Class name
957
+ * @returns {boolean} Whether elements have the class
958
+ */
959
+ result.haveClass = (cl) => {
960
+ typeVerify([[cl, notEmptyStringType]]);
961
+ let res = true;
962
+ iterator(() => {
963
+ if (!result.els[i].classList.contains(cl)) {
964
+ res = false;
965
+ }
966
+ });
967
+ if (result.isDebug || o.debug) {
968
+ console.log("haveClass() with", cl);
969
+ }
970
+ return res;
971
+ };
972
+
973
+ /**
974
+ * Set or get innerHTML
975
+ * @param {string} html - HTML content
976
+ */
977
+ result.innerHTML = returner((html) => {
978
+ typeVerify([[html, [stringType, undefinedType]]]);
979
+ if (html !== u) {
980
+ iterator(() => {
981
+ result.els[i].innerHTML = html;
982
+ });
983
+ } else {
984
+ let res = "";
985
+ iterator(() => {
986
+ res +=
987
+ ssr && result.els[i].innerHTML.length === 0
988
+ ? o.D.parseElement(result.els[i], false)
989
+ : result.els[i].innerHTML;
990
+ });
991
+ return res;
992
+ }
993
+ }, "innerHTML");
994
+
995
+ /**
996
+ * Set innerText
997
+ * @param {string} text - Text content
998
+ */
999
+ result.innerText = returner((text) => {
1000
+ typeVerify([[text, [stringType]]]);
1001
+ iterator(() => {
1002
+ result.els[i].innerText = text;
1003
+ });
1004
+ }, "innerText");
1005
+
1006
+ /**
1007
+ * Set textContent
1008
+ * @param {string} text - Text content
1009
+ */
1010
+ result.textContent = returner((text) => {
1011
+ typeVerify([[text, [stringType]]]);
1012
+ iterator(() => {
1013
+ result.els[i].textContent = text;
1014
+ });
1015
+ }, "textContent");
1016
+
1017
+ /**
1018
+ * Get or set HTML of DOM elements
1019
+ * @param {string} value - HTML value to set
1020
+ * @returns {string} HTML content
1021
+ */
1022
+ result.html = returner((value) => {
1023
+ typeVerify([[value, [stringType, undefinedType]]]);
1024
+ if (value !== undefined) {
1025
+ result.innerHTML(value);
1026
+ } else {
1027
+ let html = "";
1028
+ iterator(() => {
1029
+ html += ssr ? result.els[i].outerHTML() : result.els[i].outerHTML;
1030
+ });
1031
+
1032
+ return html;
1033
+ }
1034
+ }, "html");
1035
+
1036
+ /**
1037
+ * Get or set the value property of form elements (input, textarea, select).
1038
+ * @param {string} [value] - Value to set. Omit to get.
1039
+ * @returns {string|ObjsInstance} Current value (getter) or instance (setter)
1040
+ */
1041
+ result.val = returner((value) => {
1042
+ if (value === undefined) return result.el?.value;
1043
+ iterator(() => {
1044
+ result.els[i].value = value;
1045
+ });
1046
+ }, "val");
1047
+
1048
+ /**
1049
+ * Iterate through elements
1050
+ * @param {Function} f - Function to execute for each element
1051
+ */
1052
+ result.forEach = returner((f) => {
1053
+ typeVerify([[f, [functionType]]]);
1054
+ iterator(() => {
1055
+ f({ self: result, i, o, el: result.els[i] });
1056
+ });
1057
+ }, "forEach");
1058
+
1059
+ /**
1060
+ * Transform to React Element or Component
1061
+ * @param {Object} reactObj - React
1062
+ * @returns {Object} React Element or Component
1063
+ */
1064
+ result.prepareFor = returner((reactArg, ReactComponent) => {
1065
+ typeVerify([
1066
+ [reactArg, [objectType, functionType, undefinedType]],
1067
+ [ReactComponent, [functionType, undefinedType]],
1068
+ ]);
1069
+
1070
+ // Accept either the full React object or React.createElement directly
1071
+ const isFullReact =
1072
+ reactArg && type(reactArg) === objectType && reactArg.createElement;
1073
+ if (!isFullReact && type(reactArg) !== functionType) {
1074
+ throw Error(
1075
+ "prepareFor(): pass React (full object) or React.createElement as first argument",
1076
+ );
1077
+ }
1078
+
1079
+ const createElement = isFullReact ? reactArg.createElement : reactArg;
1080
+ const useEffect = isFullReact ? reactArg.useEffect : undefined;
1081
+
1082
+ // Component function
1083
+ return (p) => {
1084
+ if (p.ref === u) {
1085
+ throw Error("No ref property to convert Objs to React");
1086
+ }
1087
+
1088
+ const props = Object.assign({}, p);
1089
+ const reactElement = createElement("div", { ref: p.ref });
1090
+ delete props.ref;
1091
+
1092
+ // render once
1093
+ useEffect(() => {
1094
+ cycleObj(props, (key) => {
1095
+ if (key.substring(0, 1) === "on") {
1096
+ const e = o.camelToKebab(key).split("-")[1];
1097
+ result.on(e, props[key]);
1098
+ delete props[key];
1099
+ }
1100
+ });
1101
+
1102
+ // create elements
1103
+ result.render(props);
1104
+ result.appendInside(reactElement.ref.current);
1105
+ }, []);
1106
+
1107
+ return reactElement;
1108
+ };
1109
+ }, "prepareFor");
1110
+
1111
+ /**
1112
+ * Add event listener
1113
+ * @param {string} a - Event types
1114
+ * @param {Function} b - Event handler
1115
+ * @param {Object} c - Options
1116
+ * @param {boolean} d - Use capture
1117
+ */
1118
+ result.on = returner((a, b, c, d) => {
1119
+ typeVerify([
1120
+ [a, [notEmptyStringType]],
1121
+ [b, [functionType]],
1122
+ [c, [objectType, undefinedType]],
1123
+ [d, [booleanType, undefinedType]],
1124
+ ]);
1125
+ a.split(", ").forEach((ev) => {
1126
+ iterator(() => {
1127
+ result.els[i].addEventListener(ev, b, c, d);
1128
+ });
1129
+ if (!result.ie[ev]) {
1130
+ result.ie[ev] = [];
1131
+ }
1132
+ result.ie[ev].push([b, c, d]);
1133
+ });
1134
+ }, "on");
1135
+
1136
+ /**
1137
+ * Remove event listener
1138
+ * @param {string} a - Event types
1139
+ * @param {Function} b - Event handler
1140
+ * @param {Object} c - Options
1141
+ */
1142
+ result.off = returner((a, b, c) => {
1143
+ typeVerify([
1144
+ [a, [notEmptyStringType]],
1145
+ [b, [functionType]],
1146
+ [c, [objectType, undefinedType]],
1147
+ ]);
1148
+ a.split(", ").forEach((ev) => {
1149
+ iterator(() => {
1150
+ result.els[i].removeEventListener(ev, b, c);
1151
+ });
1152
+ if (result.ie[ev]) {
1153
+ result.ie[ev] = result.ie[ev].filter((f) => f[0] !== b);
1154
+ }
1155
+ });
1156
+ }, "off");
1157
+
1158
+ /**
1159
+ * Delegate event by selector
1160
+ * @param {string} e - Event types
1161
+ * @param {string} selector - CSS selector
1162
+ * @param {Function} f - Event handler
1163
+ */
1164
+ result.onDelegate = returner((e, selector, f) => {
1165
+ typeVerify([
1166
+ [e, [notEmptyStringType]],
1167
+ [selector, [notEmptyStringType]],
1168
+ [f, [functionType]],
1169
+ ]);
1170
+ e.split(", ").forEach((ev) => {
1171
+ const delegateCheck = (event) => {
1172
+ const delegate = event.target.closest(selector);
1173
+ if (delegate) {
1174
+ event.delegate = delegate;
1175
+ event.objs = result;
1176
+ f(event);
1177
+ }
1178
+ };
1179
+ iterator(() => {
1180
+ result.els[i].addEventListener(ev, delegateCheck);
1181
+ });
1182
+ if (!result.delegated[ev]) {
1183
+ result.delegated[ev] = [];
1184
+ }
1185
+ result.delegated[ev].push(delegateCheck);
1186
+ });
1187
+ }, "onDelegate");
1188
+
1189
+ /**
1190
+ * Remove delegated events
1191
+ * @param {string} e - Event type
1192
+ */
1193
+ result.offDelegate = returner((e) => {
1194
+ typeVerify([[e, [notEmptyStringType]]]);
1195
+ cycleObj(result.delegated, (ev) => {
1196
+ if (!e || e === ev) {
1197
+ while (result.delegated[ev].length) {
1198
+ const f = result.delegated[ev].pop();
1199
+ iterator(() => {
1200
+ result.els[i].removeEventListener(ev, f);
1201
+ });
1202
+ }
1203
+ }
1204
+ });
1205
+ result.delegated = {};
1206
+ }, "offDelegate");
1207
+
1208
+ /**
1209
+ * Add parent event listener
1210
+ * @param {string} e - Event types
1211
+ * @param {string|Object} selector - CSS selector or parent element
1212
+ * @param {Function} f - Event handler
1213
+ */
1214
+ result.onParent = returner((e, selector, f) => {
1215
+ typeVerify([
1216
+ [e, [notEmptyStringType]],
1217
+ [selector, [notEmptyStringType, objectType]],
1218
+ [f, [functionType]],
1219
+ ]);
1220
+ const parent = type(selector) === objectType ? selector : o.D.querySelector(selector);
1221
+ e.split(", ").forEach((ev) => {
1222
+ const parentCheck = (event) => {
1223
+ event.objs = result;
1224
+ f(event);
1225
+ };
1226
+ parent.addEventListener(ev, parentCheck);
1227
+ if (!result.parented[ev]) {
1228
+ result.parented[ev] = [];
1229
+ }
1230
+ result.parented[ev].push(parentCheck);
1231
+ });
1232
+ }, "onParent");
1233
+
1234
+ /**
1235
+ * Remove parent event listener
1236
+ * @param {string} e - Event type
1237
+ * @param {string|Object} query - CSS selector or parent element
1238
+ */
1239
+ result.offParent = returner((e, query) => {
1240
+ typeVerify([
1241
+ [e, [notEmptyStringType]],
1242
+ [query, [notEmptyStringType, objectType]],
1243
+ ]);
1244
+ const parent = type(query) === objectType ? query : o.D.querySelector(query);
1245
+ cycleObj(result.parented, (ev) => {
1246
+ if (!e || e === ev) {
1247
+ result.parented[ev].forEach((f) => {
1248
+ parent.removeEventListener(ev, f);
1249
+ });
1250
+ delete result.parented[ev];
1251
+ }
1252
+ });
1253
+ }, "offParent");
1254
+
1255
+ /**
1256
+ * Turn on or off all event listeners
1257
+ * @param {string} type - Event type
1258
+ * @param {boolean} off - Whether to remove listeners
1259
+ */
1260
+ result.onAll = returner((type, off) => {
1261
+ typeVerify([
1262
+ [type, [notEmptyStringType, undefinedType]],
1263
+ [off, [booleanType, undefinedType]],
1264
+ ]);
1265
+ cycleObj(result.ie, (ev, events) => {
1266
+ if (!type || type === ev) {
1267
+ events[ev].forEach((data) => {
1268
+ iterator(() => {
1269
+ if (off) {
1270
+ result.els[i].removeEventListener(ev, data[0]);
1271
+ } else {
1272
+ result.els[i].addEventListener(ev, data[0], data[ONE], data[TWO]);
1273
+ }
1274
+ });
1275
+ });
1276
+ }
1277
+ });
1278
+ }, "onAll");
1279
+
1280
+ /**
1281
+ * Turn off all event listeners
1282
+ * @param {string} type - Event type
1283
+ */
1284
+ result.offAll = returner((type) => {
1285
+ typeVerify([[type, [notEmptyStringType]]]);
1286
+ result.onAll(type, ONE);
1287
+ }, "offAll");
1288
+
1289
+ /**
1290
+ * Making result object
1291
+ */
1292
+ if (query) {
1293
+ result.add(query);
1294
+ }
1295
+
1296
+ result.take = (innerQuery) => {
1297
+ typeVerify([[innerQuery, [stringType, objectType, numberType]]]);
1298
+ result.add(innerQuery);
1299
+
1300
+ if (result.el) {
1301
+ const initID = result.el.dataset["oInit"];
1302
+
1303
+ if (initID !== u && o.inits[initID]) {
1304
+ if (result.length === ONE) {
1305
+ j = result.els[0];
1306
+ Object.assign(result, o.inits[initID]);
1307
+ result.els = [j];
1308
+ } else {
1309
+ result = o.inits[initID];
1310
+ }
1311
+ setResultVals(false, result.els);
1312
+ return result;
1313
+ }
1314
+ }
1315
+ };
1316
+
1317
+ return result;
1318
+ };
1319
+
1320
+ /**
1321
+ * Get first element matching query
1322
+ * @param {string} query - CSS selector
1323
+ * @returns {Object} Objs instance with found element
1324
+ */
1325
+ o.first = (query) => {
1326
+ o.verify([[query, ["notEmptyString"]]]);
1327
+ if (__DEV__ && o.debug) {
1328
+ console.log(query, " -> ", "o.first()");
1329
+ }
1330
+ return o(o.D.querySelector(query)).select(0);
1331
+ };
1332
+
1333
+ o.inits = []; // all initialised objects
1334
+ o.getSaved = {}; // all saved elements for metrics
1335
+ o.errors = []; // all errors
1336
+ o.showErrors = false; // on and off errors
1337
+ o.logErrors = () => {
1338
+ o.errors.length ? o.errors.forEach((e) => console.error(e)) : console.log("No errors");
1339
+ }; // log errors
1340
+ o.onError = (e, name) => {
1341
+ // function for errors
1342
+ if (o.showErrors) {
1343
+ console.error(e, name);
1344
+ } else {
1345
+ o.errors.push(e);
1346
+ if (name) {
1347
+ o.errors.push(name);
1348
+ }
1349
+ }
1350
+ };
1351
+ o.reactRender = () => new Error("React render function is not defined");
1352
+ /**
1353
+ * When set to a string (e.g. "qa"), auto-sets data-{autotag}="component-name" on
1354
+ * rendered elements using states.name (camelCase → kebab-case).
1355
+ * @type {string|undefined}
1356
+ * @example o.autotag = "qa"; // adds data-qa="submit-button" if states.name = "SubmitButton"
1357
+ */
1358
+ o.autotag = undefined;
1359
+
1360
+ /**
1361
+ * Returns a { 'data-{autotag}': 'kebab-name' } object for spreading onto React JSX elements.
1362
+ * @param {string} componentName - e.g. 'CheckoutButton'
1363
+ * @returns {Record<string, string>} e.g. { 'data-qa': 'checkout-button' }
1364
+ * @example <button {...o.reactQA('CheckoutButton')} onClick={fn}>Checkout</button>
1365
+ */
1366
+ o.reactQA = (name) => ({
1367
+ ["data-" + (o.autotag || "qa")]: name
1368
+ .replace(/([A-Z])/g, (_, l) => "-" + l.toLowerCase())
1369
+ .replace(/^-/, ""),
1370
+ });
1371
+
1372
+ o.specialTypes = {
1373
+ notEmptyString: (val, type) => {
1374
+ return type === "string" && val.length;
1375
+ },
1376
+ array: (val) => {
1377
+ return Array.isArray(val);
1378
+ },
1379
+ promise: (val) => {
1380
+ return val instanceof Promise || Boolean(val && typeof val.then === "function");
1381
+ },
1382
+ };
1383
+
1384
+ /**
1385
+ * Verify types of pairs
1386
+ * @param {Array} pairs - Array of pairs
1387
+ * @param {boolean} safe - Whether to return false if type verification fails
1388
+ * @returns {boolean} True if type verification passes, false if safe is true, otherwise Error
1389
+ */
1390
+ o.verify = (pairs, safe = false) => {
1391
+ for (const pair of pairs) {
1392
+ const type = typeof pair[0];
1393
+ let expectedTypes = Array.isArray(pair[1]) ? pair[1] : [pair[1]];
1394
+ let isValid = false;
1395
+
1396
+ // Check if type matches any of the remaining expected types
1397
+ if (expectedTypes.includes(type)) {
1398
+ return true;
1399
+ } else {
1400
+ expectedTypes = expectedTypes.filter((t) => !!o.specialTypes[t]);
1401
+ }
1402
+
1403
+ // Check for special types
1404
+ for (const expectedType of expectedTypes) {
1405
+ isValid = o.specialTypes[expectedType](pair[0], type);
1406
+ if (isValid) {
1407
+ return true;
1408
+ }
1409
+ }
1410
+ }
1411
+
1412
+ if (safe) {
1413
+ return false;
1414
+ }
1415
+ return new Error("Type verification failed");
1416
+ };
1417
+ o.safeVerify = (pairs) => {
1418
+ return o.verify(pairs, true);
1419
+ };
1420
+
1421
+ /**
1422
+ * Creating elements from state
1423
+ *
1424
+ * @param {object} states State, states or function/string
1425
+ */
1426
+ o.init = (states, reactRender) => o().init(states, reactRender);
1427
+
1428
+ /**
1429
+ * Initialize state with props
1430
+ * @param {Object} state - State object
1431
+ * @param {Object} props - Props to initialize with
1432
+ */
1433
+ o.initState = (state, props) => o().init(state).render(props);
1434
+
1435
+ /**
1436
+ * Take query
1437
+ * @param {string} query - Query
1438
+ * @returns {Object} Objs instance with found elements
1439
+ */
1440
+ o.take = (query) => o().take(query);
1441
+
1442
+ /**
1443
+ * Get states
1444
+ * @returns {Array} States
1445
+ */
1446
+ o.getStates = () =>
1447
+ o.inits.reduce((acc, result) => {
1448
+ acc.push(result?.states);
1449
+ return acc;
1450
+ }, []);
1451
+
1452
+ /**
1453
+ * Get stores
1454
+ * @returns {Array} Stores
1455
+ */
1456
+ o.getStores = () =>
1457
+ o.inits.reduce((acc, result) => {
1458
+ acc.push(result?.store);
1459
+ return acc;
1460
+ }, []);
1461
+
1462
+ /**
1463
+ * Get listeners
1464
+ * @returns {Array} Listeners
1465
+ */
1466
+ o.getListeners = () =>
1467
+ o.inits.reduce((acc, result) => {
1468
+ acc.push(result?.ie);
1469
+ return acc;
1470
+ }, []);
1471
+
1472
+ /**
1473
+ * Create a reactive store with built-in subscribe / notify.
1474
+ * @param {Object} defaults - Initial state and methods
1475
+ * @returns {Object} Store with .subscribe(component, stateName), .notify(), and .reset()
1476
+ */
1477
+ o.createStore = (defaults) => {
1478
+ const store = Object.assign({}, defaults);
1479
+ store._defaults = Object.assign({}, defaults);
1480
+ store._listeners = [];
1481
+ store.subscribe = function (component, stateName) {
1482
+ this._listeners.push((data) => component[stateName]?.(data));
1483
+ return this;
1484
+ };
1485
+ store.notify = function () {
1486
+ this._listeners.forEach((fn) => fn(this));
1487
+ };
1488
+ store.reset = function () {
1489
+ const skip = { _listeners: 1, subscribe: 1, notify: 1, _defaults: 1, reset: 1 };
1490
+ for (const key of Object.keys(this._defaults)) {
1491
+ if (!skip[key]) this[key] = this._defaults[key];
1492
+ }
1493
+ };
1494
+ return store;
1495
+ };
1496
+
1497
+ // Short values (o.U = async "waiting" sentinel for tests)
1498
+ o.U = undefined;
1499
+ o.W = 2;
1500
+ o.H = 100;
1501
+ o.F = false;
1502
+
1503
+ /**
1504
+ * Check if object has property
1505
+ * @param {Object} a - Object
1506
+ * @param {string} b - Property
1507
+ * @returns {boolean} Whether object has property
1508
+ */
1509
+ o.C = (a, b) => {
1510
+ return Object.hasOwn(a, b);
1511
+ };
1512
+
1513
+ /**
1514
+ * Convert kebab case to camel case
1515
+ * @param {string} str - String
1516
+ * @returns {string} Converted string
1517
+ */
1518
+ o.kebabToCamel = (str) => str.replace(/-./g, (m) => m.toUpperCase()[1]);
1519
+
1520
+ /**
1521
+ * Convert camel case to kebab case
1522
+ * @param {string} str - String
1523
+ * @returns {string} Converted string
1524
+ */
1525
+ o.camelToKebab = (str) => str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
1526
+
1527
+ /**
1528
+ * Route
1529
+ * @param {string|function|boolean} path - Path or function returning path
1530
+ * @param {function|object} task - Callback function or state object (optional)
1531
+ * @returns {boolean|Object} True/false for path match current path and callback is provided, otherwise object with objs instance or empty object for inline logic
1532
+ */
1533
+ o.route = (path, task) => {
1534
+ o.verify([
1535
+ [path, ["notEmptyString", "function", "boolean"]],
1536
+ [task, ["function", "object"]],
1537
+ ]);
1538
+ const result = typeof path === "function" ? path(window.location.pathname) : path;
1539
+
1540
+ if (result === true || window.location.pathname === result) {
1541
+ if (task) {
1542
+ if (typeof task === "function") {
1543
+ task();
1544
+ return true;
1545
+ } else {
1546
+ return task;
1547
+ }
1548
+ } else {
1549
+ return o;
1550
+ }
1551
+ } else {
1552
+ return typeof task === "function" ? false : {};
1553
+ }
1554
+ };
1555
+
1556
+ /**
1557
+ * Router
1558
+ * @param {Object} routes - Routes
1559
+ * @returns {boolean} True if path matches any path
1560
+ */
1561
+ o.router = (routes = {}) => {
1562
+ o.verify([[routes, ["object"]]]);
1563
+ for (const route in routes) {
1564
+ if (o.C(routes, route)) {
1565
+ if (window.location.pathname === route) {
1566
+ if (typeof routes[route] === "function") {
1567
+ routes[route]();
1568
+ return true;
1569
+ } else {
1570
+ return routes[route];
1571
+ }
1572
+ }
1573
+ }
1574
+ }
1575
+ return false;
1576
+ };
1577
+
1578
+ /**
1579
+ * DocumentMVP parser for Objs SSR core function
1580
+ */
1581
+ o.DocumentMVP = {
1582
+ addEventListener: () => {},
1583
+ parseElement: (elem, outer = true) => {
1584
+ o.verify([
1585
+ [elem, ["object", "string"]],
1586
+ [outer, ["boolean"]],
1587
+ ]);
1588
+ const attrToStr = (attrs, prefix = "") => {
1589
+ let attrStr = "";
1590
+ for (const attr in attrs) {
1591
+ if (o.C(attrs, attr)) {
1592
+ attrStr += ` ${prefix}${o.camelToKebab(attr)}="${
1593
+ typeof attrs[attr] !== "object"
1594
+ ? attrs[attr]
1595
+ : Object.entries(attrs[attr])
1596
+ .map((e) => `${e[0]}: ${e[1]};`)
1597
+ .join(" ")
1598
+ }"`;
1599
+ }
1600
+ }
1601
+ return attrStr;
1602
+ };
1603
+
1604
+ if (typeof elem === "string") {
1605
+ return elem;
1606
+ }
1607
+
1608
+ if (outer) {
1609
+ const selfClosing = [
1610
+ "area",
1611
+ "base",
1612
+ "br",
1613
+ "col",
1614
+ "embed",
1615
+ "hr",
1616
+ "img",
1617
+ "input",
1618
+ "link",
1619
+ "meta",
1620
+ "param",
1621
+ "source",
1622
+ "track",
1623
+ "wbr",
1624
+ ];
1625
+ const tagName = elem.tagName.toLowerCase();
1626
+ // Ensure data-o-init is always serialized (SSR hydration)
1627
+ const dataOInit = elem.attributes["data-o-init"];
1628
+ const dataOInitAttr = dataOInit !== undefined ? ` data-o-init="${dataOInit}"` : "";
1629
+ return `<${tagName}${elem.className ? ` class="${elem.className}"` : ""}${attrToStr(elem.attributes)}${dataOInitAttr}${attrToStr(elem.dataset, "data-")}${selfClosing.includes(tagName) ? "/" : ""}>${selfClosing.includes(tagName) ? "" : elem.innerHTML.length ? elem.innerHTML : elem.children.map((el) => o.D.parseElement(el)).join("")}${!selfClosing.includes(tagName) ? `</${tagName}>` : ""}`;
1630
+ }
1631
+
1632
+ return elem.innerHTML.length
1633
+ ? elem.innerHTML
1634
+ : elem.children.map((el) => o.D.parseElement(el)).join("");
1635
+ },
1636
+ createElement: (tag) => {
1637
+ o.verify([[tag, ["notEmptyString"]]]);
1638
+ const elem = {
1639
+ tagName: tag.toUpperCase(),
1640
+ attributes: {},
1641
+ innerHTML: "",
1642
+ children: [],
1643
+ dataset: {},
1644
+ className: "",
1645
+ classArray: [],
1646
+ style: {},
1647
+ addEventListener: () => {},
1648
+ removeEventListener: () => {},
1649
+ };
1650
+ elem.classList = {
1651
+ add: (...cl) => {
1652
+ o.verify([[cl, ["array"]]]);
1653
+ elem.classArray.push(cl);
1654
+ elem.className = elem.classArray.join(" ");
1655
+ },
1656
+ has: (cl) => {
1657
+ o.verify([[cl, ["notEmptyString"]]]);
1658
+ return elem.classArray.includes(cl);
1659
+ },
1660
+ remove: (cl) => {
1661
+ o.verify([[cl, ["notEmptyString"]]]);
1662
+ elem.classArray = elem.classArray.filter((listed) => cl !== listed);
1663
+ elem.className = elem.classArray.join(" ");
1664
+ },
1665
+ };
1666
+ elem.classList.toggle = (cl) => {
1667
+ o.verify([[cl, ["notEmptyString"]]]);
1668
+ if (elem.classList.has(cl)) {
1669
+ elem.classList.remove(cl);
1670
+ } else {
1671
+ elem.classList.add(cl);
1672
+ }
1673
+ };
1674
+ elem.setAttribute = (attr, val) => {
1675
+ o.verify([
1676
+ [attr, ["notEmptyString"]],
1677
+ [val, ["string", "number", "boolean", "undefined"]],
1678
+ ]);
1679
+ elem.attributes[attr] = val;
1680
+ };
1681
+ elem.getAttribute = (attr) => {
1682
+ o.verify([[attr, ["notEmptyString"]]]);
1683
+ return elem.attributes[attr];
1684
+ };
1685
+ elem.removeAttribute = (attr) => {
1686
+ o.verify([[attr, ["notEmptyString"]]]);
1687
+ delete elem.attributes[attr];
1688
+ };
1689
+ elem.appendChild = (el) => {
1690
+ o.verify([[el, ["object"]]]);
1691
+ elem.children.push(el);
1692
+ elem.firstElementChild = elem.children[0];
1693
+ };
1694
+ elem.outerHTML = () => o.D.parseElement(elem);
1695
+ return elem;
1696
+ },
1697
+ };
1698
+
1699
+ /**
1700
+ * Document object
1701
+ * @type {Object}
1702
+ */
1703
+ o.D =
1704
+ typeof document !== "undefined" && typeof process === "undefined"
1705
+ ? document
1706
+ : o.DocumentMVP;
1707
+
1708
+ /**
1709
+ * Cookies
1710
+ * Minimal version. You can set your own functions for more comfortable usage.
1711
+ *
1712
+ * @argument {string} title Cookie name
1713
+ * @argument {string} value Cookie name
1714
+ * @argument {object} params parameters
1715
+ *
1716
+ * @returns {void}
1717
+ */
1718
+ o.setCookie = (title, value = "", params = {}) => {
1719
+ o.verify([
1720
+ [title, ["notEmptyString"]],
1721
+ [value, ["string", "number", "boolean"]],
1722
+ [params, ["object"]],
1723
+ ]);
1724
+ if (o.D.cookie === undefined) {
1725
+ console.log("Cookies are not supported on server side");
1726
+ return;
1727
+ }
1728
+ let str = encodeURIComponent(title) + "=" + encodeURIComponent(value);
1729
+ params = {
1730
+ path: "/",
1731
+ ...params,
1732
+ };
1733
+ if (params.expires instanceof Date) {
1734
+ params.expires = params.expires.toUTCString();
1735
+ } else if (typeof params.expires === "number") {
1736
+ params.expires = new Date(params.expires).toUTCString();
1737
+ }
1738
+ for (const key in params) {
1739
+ str += "; " + key;
1740
+ const r = params[key];
1741
+ if (r !== true) {
1742
+ str += "=" + r;
1743
+ }
1744
+ }
1745
+ o.D.cookie = str;
1746
+ };
1747
+
1748
+ /**
1749
+ * Get cookie value
1750
+ * @param {string} title - Cookie name
1751
+ * @returns {string|undefined} Cookie value
1752
+ */
1753
+ o.getCookie = (title) => {
1754
+ o.verify([[title, ["notEmptyString"]]]);
1755
+ if (o.D.cookie === undefined) {
1756
+ console.log("Cookies are not supported on server side");
1757
+ return;
1758
+ }
1759
+ const val = o.D.cookie.match(
1760
+ RegExp("(?:^|; )" + title.replace(/([.$?*|{}()[\]\\/+^])/g, "\\$1") + "=([^;]*)"),
1761
+ );
1762
+ return val ? decodeURIComponent(val[1]) : void 0;
1763
+ };
1764
+
1765
+ /**
1766
+ * Delete cookie
1767
+ * @param {string} title - Cookie name
1768
+ */
1769
+ o.deleteCookie = (title) => {
1770
+ o.verify([[title, ["notEmptyString"]]]);
1771
+ o.setCookie(title, "", { "max-age": 0 });
1772
+ };
1773
+
1774
+ /**
1775
+ * Clear all cookies
1776
+ */
1777
+ o.clearCookies = () => {
1778
+ if (o.D.cookie === undefined) {
1779
+ console.log("Cookies are not supported on server side");
1780
+ return;
1781
+ }
1782
+ const ca = o.D.cookie.split(";");
1783
+ while (ca.length) {
1784
+ let c = ca.pop();
1785
+ while (c.charAt(0) === " ") {
1786
+ c = c.substring(1);
1787
+ }
1788
+ const key = c.split("=")[0];
1789
+ o.deleteCookie(key);
1790
+ }
1791
+ };
1792
+
1793
+ /**
1794
+ * Clear localStorage
1795
+ * @param {boolean} all - Whether to clear all items
1796
+ */
1797
+ o.clearLocalStorage = (all) => {
1798
+ o.verify([[all, ["boolean", "undefined"]]]);
1799
+ if (typeof localStorage === "undefined") {
1800
+ return;
1801
+ }
1802
+ if (all) {
1803
+ localStorage.clear();
1804
+ } else {
1805
+ for (let i = localStorage.length - 1; i >= 0; i--) {
1806
+ const key = localStorage.key(i);
1807
+ if (key.indexOf("oInc-") === -1 && key.indexOf("oTest-") === -1) {
1808
+ localStorage.removeItem(key);
1809
+ }
1810
+ }
1811
+ }
1812
+ };
1813
+
1814
+ /**
1815
+ * Clear sessionStorage
1816
+ * @param {boolean} onlyTests - Whether to clear only test items
1817
+ */
1818
+ o.clearSessionStorage = (onlyTests) => {
1819
+ o.verify([[onlyTests, ["boolean", "undefined"]]]);
1820
+ if (typeof sessionStorage === "undefined") {
1821
+ return;
1822
+ }
1823
+ if (!onlyTests) {
1824
+ sessionStorage.clear();
1825
+ } else {
1826
+ for (let i = sessionStorage.length - 1; i >= 0; i--) {
1827
+ const key = sessionStorage.key(i);
1828
+ if (key && key.indexOf("oTest-") === 0) {
1829
+ sessionStorage.removeItem(key);
1830
+ }
1831
+ }
1832
+ }
1833
+ };
1834
+
1835
+ /**
1836
+ * Clear test storage
1837
+ */
1838
+ o.clearTestsStorage = () => {
1839
+ o.clearSessionStorage(1);
1840
+ };
1841
+
1842
+ /**
1843
+ * Clear cookies, localStorage (non-inc/test keys), and test-related sessionStorage.
1844
+ * Call after Cookies/LS/SS tests for a clean slate.
1845
+ */
1846
+ o.clearAfterTests = () => {
1847
+ o.clearCookies();
1848
+ o.clearLocalStorage(false);
1849
+ o.clearTestsStorage();
1850
+ };
1851
+
1852
+ /**
1853
+ * Make AJAX request
1854
+ * @param {string} url - Request URL
1855
+ * @param {Object} props - Request properties
1856
+ * @returns {Promise} Fetch promise
1857
+ */
1858
+ o.ajax = (url, props = {}) => {
1859
+ o.verify([
1860
+ [url, ["notEmptyString"]],
1861
+ [props, ["object"]],
1862
+ ]);
1863
+ const row = new URLSearchParams();
1864
+ if (props.data && typeof props.data === "object") {
1865
+ for (const param in props.data) {
1866
+ if (o.C(props.data, param)) {
1867
+ if (typeof props.data[param] === "object") {
1868
+ row.set(param, encodeURIComponent(JSON.stringify(props.data[param])));
1869
+ } else {
1870
+ row.set(param, props.data[param]);
1871
+ }
1872
+ }
1873
+ }
1874
+ if (props.method.toLowerCase() === "get") {
1875
+ url += "?" + row.toString();
1876
+ } else if (!props.body) {
1877
+ props.body = row;
1878
+ }
1879
+ delete props.data;
1880
+ }
1881
+ if (!props.headers) {
1882
+ props.headers = { "X-Requested-With": "XMLHttpRequest" };
1883
+ }
1884
+
1885
+ return fetch(url, props);
1886
+ };
1887
+
1888
+ /**
1889
+ * Make GET request
1890
+ * @param {string} url - Request URL
1891
+ * @param {Object} props - Request properties
1892
+ * @returns {Promise} Fetch promise
1893
+ */
1894
+ o.get = (url, props = {}) => {
1895
+ o.verify([
1896
+ [url, ["notEmptyString"]],
1897
+ [props, ["object"]],
1898
+ ]);
1899
+ return o.ajax(url, { ...props, method: "GET" });
1900
+ };
1901
+
1902
+ /**
1903
+ * Make POST request
1904
+ * @param {string} url - Request URL
1905
+ * @param {Object} props - Request properties
1906
+ * @returns {Promise} Fetch promise
1907
+ */
1908
+ o.post = (url, props = {}) => {
1909
+ o.verify([
1910
+ [url, ["notEmptyString"]],
1911
+ [props, ["object"]],
1912
+ ]);
1913
+ return o.ajax(url, { ...props, method: "POST" });
1914
+ };
1915
+
1916
+ /**
1917
+ * New loader
1918
+ * @param {Promise|undefined} promise - Promise
1919
+ * @returns {Object} Loader object
1920
+ */
1921
+ o.newLoader = (promise) => {
1922
+ o.verify([[promise, ["promise", "undefined"]]]);
1923
+ let listeners = [];
1924
+ let data = null;
1925
+ let finished = false;
1926
+ let error = false;
1927
+
1928
+ const reload = (p) => {
1929
+ finished = false;
1930
+ error = false;
1931
+ data = null;
1932
+ setTimeout(() => {
1933
+ p.then((response) => {
1934
+ finished = true;
1935
+ if (!response.ok && typeof response.ok !== "undefined") {
1936
+ error = true;
1937
+ listeners.forEach(([listener, _state, fail]) => {
1938
+ fail ? listener[fail](response) : "";
1939
+ });
1940
+ } else if (typeof response.json === "function") {
1941
+ response.json().then((jsonData) => {
1942
+ data = jsonData;
1943
+ listeners.forEach(([listener, state]) => {
1944
+ listener[state](data);
1945
+ });
1946
+ });
1947
+ } else {
1948
+ data = response;
1949
+ listeners.forEach(([listener, state]) => {
1950
+ listener[state](data);
1951
+ });
1952
+ }
1953
+ }).catch((err) => {
1954
+ error = true;
1955
+ listeners.forEach(([listener, _state, fail]) => {
1956
+ fail ? listener[fail](err) : "";
1957
+ });
1958
+ });
1959
+ }, 33);
1960
+ };
1961
+
1962
+ if (promise) {
1963
+ reload(promise);
1964
+ }
1965
+
1966
+ return {
1967
+ reload,
1968
+ isObjsLoader: true,
1969
+ listeners,
1970
+ isFinished: () => finished,
1971
+ getStore: () => data,
1972
+ connect: (listener, state = "render", fail) => {
1973
+ o.verify([
1974
+ [listener, ["object"]],
1975
+ [state, ["notEmptyString"]],
1976
+ [fail, ["string", "undefined"]],
1977
+ ]);
1978
+ if (finished) {
1979
+ if (error) {
1980
+ fail ? listener[fail]() : "";
1981
+ } else if (typeof listener[state] === "function") {
1982
+ listener[state](data);
1983
+ }
1984
+ } else {
1985
+ listeners.push([listener, state, fail]);
1986
+ }
1987
+ },
1988
+ disconnect: (listener) => {
1989
+ o.verify([[listener, ["object"]]]);
1990
+ listeners = listeners.filter(([l]) => l !== listener);
1991
+ },
1992
+ };
1993
+ };
1994
+
1995
+ /**
1996
+ * Get URL parameters
1997
+ * @param {string} key - Parameter key
1998
+ * @returns {Object|string} Parameters object or specific parameter value
1999
+ */
2000
+ o.getParams = (key) => {
2001
+ o.verify([[key, ["string", "undefined"]]]);
2002
+ const params = {};
2003
+ const paramsRaw = new URLSearchParams(window.location.search).entries();
2004
+
2005
+ for (const entry of paramsRaw) {
2006
+ params[entry[0]] = entry[1];
2007
+ }
2008
+
2009
+ return key ? params[key] : params;
2010
+ };
2011
+
2012
+ /* include function parameters */
2013
+ o.incCache = true; // cache in localStorage
2014
+ o.incCacheExp = 1000 * 60 * 60 * 24; // cache time
2015
+ o.incTimeout = 6000; // ms for timing to load function
2016
+ o.incSource = ""; // link to source folder
2017
+ o.incForce = o.F; // reload loaded files or not
2018
+ o.incAsync = true; // async or in order loading
2019
+ o.incCors = o.F; // allow loading from other domains
2020
+ o.incSeparator = "?"; // separator for file hash cache
2021
+ o.incFns = {}; // array of name:status for all functions
2022
+ o.incSet = [0]; // saving callbacks and change them for 1 value if executed
2023
+ o.incReady = [0]; // array of all included sets and statuses
2024
+ o.incN = 0; // index of the next set
2025
+ o.incGetHash = (path) => path.split(o.incSeparator)[1] || "";
2026
+
2027
+ /**
2028
+ * Check the state status or a function in it, also checks its status to true
2029
+ *
2030
+ * @param {number} set index
2031
+ * @param {number} fnId index
2032
+ * @returns {boolean}
2033
+ */
2034
+ o.incCheck = (set = 0, fnId, loaded = 0) => {
2035
+ o.verify([
2036
+ [set, ["number"]],
2037
+ [fnId, ["number", "undefined"]],
2038
+ [loaded, ["number"]],
2039
+ ]);
2040
+ if (!loaded && set && fnId === o.U && o.incReady[set]) {
2041
+ return o.incSet[set] === 1;
2042
+ }
2043
+
2044
+ if (o.incReady[set] === o.U || o.incReady[set][fnId] === o.U) {
2045
+ return o.F;
2046
+ }
2047
+
2048
+ o.incReady[set][fnId].loaded = loaded;
2049
+ o.incFns[o.incReady[set][fnId].name] = loaded;
2050
+ o.incReady[set][0] += loaded;
2051
+
2052
+ if (set && o.incReady[set].length === o.incReady[set][0]) {
2053
+ if (typeof o.incSet[set] === "function") {
2054
+ o.incSet[set](set);
2055
+ }
2056
+ o.incSet[set] = 1;
2057
+ }
2058
+
2059
+ return o.incSet[set] === 1;
2060
+ };
2061
+
2062
+ /**
2063
+ * Clear all cache and all loaded files info
2064
+ * @param {boolean} all - Whether to clear cache or cache and DOM elements
2065
+ */
2066
+ o.incCacheClear = (all = o.F) => {
2067
+ o.verify([[all, ["boolean"]]]);
2068
+ for (const name in o.incFns) {
2069
+ if (o.C(o.incFns, name)) {
2070
+ localStorage.removeItem("oInc-" + name);
2071
+ localStorage.removeItem("oInc-" + name + "-expires");
2072
+ }
2073
+ }
2074
+ if (all) {
2075
+ o.incReady.forEach((val, i) => {
2076
+ if (i) {
2077
+ val.forEach((_a, j) => {
2078
+ if (j) {
2079
+ o("#oInc-" + i + "-" + j).remove();
2080
+ }
2081
+ });
2082
+ }
2083
+ });
2084
+
2085
+ o.incN = 0;
2086
+ o.incFns = {};
2087
+ o.incSet = [0];
2088
+ o.incReady = [0];
2089
+ }
2090
+ return true;
2091
+ };
2092
+
2093
+ /**
2094
+ * Include external resources
2095
+ * @param {Object} sources - Resources to include
2096
+ * @param {Function} callBack - Success callback
2097
+ * @param {Function} callBad - Error callback
2098
+ * @returns {boolean} Whether inclusion was successful
2099
+ */
2100
+ o.inc = (sources, callBack, callBad) => {
2101
+ o.verify([
2102
+ [sources, ["object", "undefined"]],
2103
+ [callBack, ["function", "undefined"]],
2104
+ [callBad, ["function", "undefined"]],
2105
+ ]);
2106
+ if (typeof localStorage === "undefined") {
2107
+ return;
2108
+ }
2109
+ let sourcesN = 0,
2110
+ sourcesReady = 0,
2111
+ hash = "",
2112
+ preload = false;
2113
+ const f = "function",
2114
+ no = -1;
2115
+
2116
+ if (typeof sources !== "object" || !sources) {
2117
+ return o.incSet[0];
2118
+ }
2119
+
2120
+ o.incSet[0]++;
2121
+ const setN = o.incSet[0];
2122
+ o.incSet[setN] = callBack || 0;
2123
+ o.incReady[setN] = [];
2124
+ const fnsStatus = o.incReady[setN];
2125
+ fnsStatus[0] = 1;
2126
+ const fnId = {};
2127
+
2128
+ for (const name in sources) {
2129
+ if (o.C(sources, name)) {
2130
+ // preload and cache functionality
2131
+ if (name === "preload") {
2132
+ preload = true;
2133
+ continue;
2134
+ }
2135
+
2136
+ hash = o.incGetHash(sources[name]);
2137
+ sourcesN++;
2138
+ o.incN++;
2139
+
2140
+ const tag = sources[name].indexOf(".css") > no ? "style" : "script";
2141
+ sources[name] = (o.incSource ? o.incSource + "/" : "") + sources[name];
2142
+
2143
+ // skip loading if already loaded with page and not forced
2144
+ if (
2145
+ Number.isNaN(Number(name)) &&
2146
+ o.C(o.incFns, name) &&
2147
+ o.incFns[name] &&
2148
+ !o.incForce
2149
+ ) {
2150
+ fnsStatus[sourcesN] = {
2151
+ name,
2152
+ loaded: 1,
2153
+ };
2154
+ sourcesReady++;
2155
+ continue;
2156
+ }
2157
+
2158
+ // fixing if loaded needed
2159
+ fnsStatus[sourcesN] = {
2160
+ name,
2161
+ loaded: 0,
2162
+ };
2163
+
2164
+ if (Number.isNaN(Number(name))) {
2165
+ o.incFns[name] = 0;
2166
+ }
2167
+
2168
+ if (
2169
+ Number.isNaN(Number(name)) &&
2170
+ o.incCache &&
2171
+ (sources[name].substring(0, 4) !== "http" || !o.incCors) &&
2172
+ window.location.protocol !== "file:" &&
2173
+ (sources[name].indexOf(".css") > no || sources[name].indexOf(".js") > no)
2174
+ ) {
2175
+ // if cache is enabled
2176
+ const ls = localStorage,
2177
+ script = ls.getItem("oInc-" + name),
2178
+ cacheSavedTill = ls.getItem("oInc-" + name + "-expires"),
2179
+ cacheHash = ls.getItem("oInc-" + name + "-hash");
2180
+
2181
+ if (
2182
+ script &&
2183
+ cacheSavedTill &&
2184
+ Date.now() < cacheSavedTill &&
2185
+ cacheHash === hash
2186
+ ) {
2187
+ // load from cache if not preload
2188
+ if (!preload) {
2189
+ o.initState({
2190
+ tag,
2191
+ id: "oInc-" + setN + "-" + sourcesN,
2192
+ innerHTML: script,
2193
+ "data-o-inc": setN,
2194
+ }).appendInside("head");
2195
+ }
2196
+ fnsStatus[sourcesN].loaded = 1;
2197
+ o.incFns[name] = 1;
2198
+ sourcesReady++;
2199
+ } else {
2200
+ // loading and caching new files
2201
+ fnId[name] = sourcesN;
2202
+ o.get(sources[name], { mode: o.incCors ? "cors" : "same-origin" }).then(
2203
+ (response) => {
2204
+ if (response.status !== 200) {
2205
+ o.onError
2206
+ ? o.onError({
2207
+ message: o.incSource + sources[name] + " was not loaded",
2208
+ })
2209
+ : "";
2210
+ return;
2211
+ }
2212
+ response.text().then((script) => {
2213
+ ls.setItem("oInc-" + name, script);
2214
+ ls.setItem("oInc-" + name + "-expires", Date.now() + o.incCacheExp);
2215
+ ls.setItem("oInc-" + name + "-hash", hash);
2216
+ // execute if not preload
2217
+ if (!preload) {
2218
+ o.initState({
2219
+ tag,
2220
+ id: "oInc-" + setN + "-" + fnId[name],
2221
+ innerHTML: script,
2222
+ "data-o-inc": setN,
2223
+ }).appendInside("head");
2224
+ }
2225
+ o.incCheck(setN, fnId[name], 1);
2226
+ });
2227
+ },
2228
+ );
2229
+ }
2230
+ } else {
2231
+ // standart loading without caching
2232
+ const state = {
2233
+ tag,
2234
+ id: "oInc-" + setN + "-" + sourcesN,
2235
+ "data-o-inc": setN,
2236
+ async: o.incAsync,
2237
+ onload: "o.incCheck(" + setN + "," + sourcesN + ",1)",
2238
+ };
2239
+ if (sources[name].indexOf(".css") > no) {
2240
+ state.tag = "link";
2241
+ state.rel = "stylesheet";
2242
+ state.href = sources[name];
2243
+ } else if (sources[name].indexOf(".js") > no) {
2244
+ state.src = sources[name];
2245
+ } else {
2246
+ state.tag = "img";
2247
+ state.style = "display:none;";
2248
+ state.src = sources[name];
2249
+ }
2250
+ o.initState(state).appendInside(state.style ? "body" : "head");
2251
+ }
2252
+ }
2253
+ }
2254
+
2255
+ fnsStatus[0] += sourcesReady;
2256
+
2257
+ if (sourcesN !== 0) {
2258
+ if (sourcesReady === sourcesN) {
2259
+ // if everything included
2260
+ if (typeof callBack === f) {
2261
+ callBack(setN);
2262
+ }
2263
+ } else {
2264
+ // starting timeout for loading
2265
+ setTimeout(
2266
+ (set) => {
2267
+ if (o.incReady[set] && o.incReady[set].length < o.incReady[set][0]) {
2268
+ o.incSet[set] = 0;
2269
+ if (typeof callBad === f) {
2270
+ callBad(setN);
2271
+ }
2272
+ }
2273
+ },
2274
+ o.incTimeout,
2275
+ setN,
2276
+ );
2277
+ }
2278
+ }
2279
+
2280
+ return o.incSet[0];
2281
+ };
2282
+
2283
+ // ─── Store adapters (always present, prod + dev) ──────────────────────────
2284
+
2285
+ /**
2286
+ * Connect a Redux store to an Objs component state.
2287
+ * Calls component[state](selector(store.getState())) immediately and on every store change.
2288
+ * Multiple connections per component are allowed — each covers a different state/slice.
2289
+ *
2290
+ * @param {Object} store - Redux store (must have .getState() and .subscribe())
2291
+ * @param {Function} selector - (storeState) => sliceData
2292
+ * @param {Object} component - Objs instance (result of o.init(...).render())
2293
+ * @param {string} [state='render'] - Name of the component state method to call with the data
2294
+ * @returns {Function} Unsubscribe function
2295
+ * @example
2296
+ * const card = o.init(cardStates).render();
2297
+ * const unsub = o.connectRedux(store, s => s.userName, card, 'updateName');
2298
+ * // later: unsub() to disconnect
2299
+ */
2300
+ o.connectRedux = (store, selector, component, state = "render") => {
2301
+ o.verify([
2302
+ [store, ["object"]],
2303
+ [selector, ["function"]],
2304
+ [component, ["object"]],
2305
+ [state, ["notEmptyString"]],
2306
+ ]);
2307
+ const update = () => {
2308
+ if (typeof component[state] === "function") {
2309
+ const val = selector(store.getState());
2310
+ component[state](val !== null && typeof val === "object" ? val : { value: val });
2311
+ }
2312
+ };
2313
+ update(); // fire immediately with current state
2314
+ return store.subscribe(update);
2315
+ };
2316
+
2317
+ /**
2318
+ * Connect a MobX observable to an Objs component state via autorun.
2319
+ * Accepts mobx as first param to avoid a hard dependency.
2320
+ *
2321
+ * @param {Object} mobx - MobX instance (must have .autorun())
2322
+ * @param {Object} observable - MobX observable object
2323
+ * @param {Function} accessor - (observable) => sliceData
2324
+ * @param {Object} component - Objs instance
2325
+ * @param {string} [state='render'] - Component state method name
2326
+ * @returns {Function} Disposer function (call to stop observing)
2327
+ * @example
2328
+ * const unsub = o.connectMobX(mobx, appStore, s => s.count, counter, 'updateCount');
2329
+ */
2330
+ o.connectMobX = (mobx, observable, accessor, component, state = "render") => {
2331
+ o.verify([
2332
+ [mobx, ["object"]],
2333
+ [observable, ["object"]],
2334
+ [accessor, ["function"]],
2335
+ [component, ["object"]],
2336
+ [state, ["notEmptyString"]],
2337
+ ]);
2338
+ return mobx.autorun(() => {
2339
+ if (typeof component[state] === "function") {
2340
+ const val = accessor(observable);
2341
+ component[state](val !== null && typeof val === "object" ? val : { value: val });
2342
+ }
2343
+ });
2344
+ };
2345
+
2346
+ /**
2347
+ * Default React context object for bridging Objs with React trees.
2348
+ * Use as the value of React.createContext() in your app.
2349
+ * @type {Object}
2350
+ */
2351
+ o.ObjsContext = null;
2352
+
2353
+ /**
2354
+ * Create a React HOC that connects a React Context value to an Objs component.
2355
+ * The returned component renders nothing — it is a pure side-effect bridge.
2356
+ *
2357
+ * @param {Object} React - React instance
2358
+ * @param {Object} Context - React context created with React.createContext()
2359
+ * @param {Function} selector - (contextValue) => data to pass to component state
2360
+ * @param {Object} component - Objs instance
2361
+ * @param {string} [state='render'] - Component state method name
2362
+ * @returns {Function} React functional component (bridge)
2363
+ * @example
2364
+ * const CartBridge = o.withReactContext(React, CartContext, ctx => ctx.count, menuCart, 'updateCount');
2365
+ * // Mount <CartBridge /> anywhere inside <CartContext.Provider>
2366
+ */
2367
+ o.withReactContext = (React, Context, selector, component, state = "render") => {
2368
+ return function ObjsContextBridge() {
2369
+ const value = React.useContext(Context);
2370
+ React.useEffect(() => {
2371
+ if (typeof component[state] === "function") {
2372
+ const val = selector(value);
2373
+ component[state](val !== null && typeof val === "object" ? val : { value: val });
2374
+ }
2375
+ }, [value]);
2376
+ return null;
2377
+ };
2378
+ };
2379
+
2380
+ if (__DEV__) {
2381
+ o.debug = false; // on and off debug output
2382
+ }
2383
+
2384
+ /* tests function parameters */
2385
+ o.tLog = []; // test sessions and results
2386
+ o.tRes = []; // test results
2387
+ o.tStatus = []; // test statuses
2388
+ o.tFns = []; // callbacks
2389
+ o.tShowOk = o.F; // show success tests or only errors
2390
+ o.tStyled = o.F; // styled HTML results or plain style
2391
+ o.tTime = 2000; // timeout for async tests
2392
+ o.tests = []; // tests with storage
2393
+ o.tAutolog = o.F; // auto log to console
2394
+ o.tBeforeEach = undefined; // called before each test case
2395
+ o.tAfterEach = undefined; // called after each test case
2396
+
2397
+ /**
2398
+ * Add test
2399
+ * @param {string} title - Test title
2400
+ * @param {...Array} tests - Test cases
2401
+ * @returns {Object} Test object with run method and testId
2402
+ */
2403
+ o.addTest = (title, ...tests) => {
2404
+ o.verify([
2405
+ [title, ["notEmptyString"]],
2406
+ [tests, ["array"]],
2407
+ ]);
2408
+ // Support {before, after} hooks object as last argument
2409
+ let hooks = {};
2410
+ if (
2411
+ tests.length &&
2412
+ typeof tests[tests.length - 1] === "object" &&
2413
+ !Array.isArray(tests[tests.length - 1])
2414
+ ) {
2415
+ hooks = tests.pop();
2416
+ }
2417
+ const testId = o.tests.length;
2418
+ o.tests[testId] = { title, tests, hooks };
2419
+
2420
+ return {
2421
+ run: () => {
2422
+ if (typeof hooks.before === "function") {
2423
+ o.tBeforeEach = hooks.before;
2424
+ }
2425
+ if (typeof hooks.after === "function") {
2426
+ o.tAfterEach = hooks.after;
2427
+ }
2428
+ o.runTest(testId);
2429
+ },
2430
+ autorun: () => {
2431
+ if (typeof hooks.before === "function") {
2432
+ o.tBeforeEach = hooks.before;
2433
+ }
2434
+ if (typeof hooks.after === "function") {
2435
+ o.tAfterEach = hooks.after;
2436
+ }
2437
+ o.runTest(testId, true);
2438
+ },
2439
+ testId,
2440
+ };
2441
+ };
2442
+
2443
+ /**
2444
+ * Load Logs from session storage
2445
+ * @returns {Array} Logs from sessionStorage
2446
+ */
2447
+ o.updateLogs = () => {
2448
+ for (let i = 0; i < o.tests.length; i++) {
2449
+ o.tLog[i] = o.tLog[testN] = sessionStorage.getItem(`oTest-Log-${testN}`) || "";
2450
+ }
2451
+ return o.tLog;
2452
+ };
2453
+
2454
+ /**
2455
+ * Run test by ID and autorun
2456
+ * @param {number} testId - Test ID
2457
+ * @param {boolean} autoRun - Whether to autorun for reload page tests
2458
+ */
2459
+ o.runTest = (testId = 0, autoRun, savePrev) => {
2460
+ o.verify([
2461
+ [testId, ["number"]],
2462
+ [autoRun, ["boolean", "undefined"]],
2463
+ [savePrev, ["boolean", "undefined"]],
2464
+ ]);
2465
+ if (!o.tests[testId]) {
2466
+ return;
2467
+ }
2468
+
2469
+ if (!savePrev) {
2470
+ sessionStorage?.removeItem(`oTest-Log-${testId}`);
2471
+ sessionStorage?.removeItem(`oTest-Res-${testId}`);
2472
+ sessionStorage?.removeItem(`oTest-Status-${testId}`);
2473
+ }
2474
+
2475
+ // run with sessionStorage cache
2476
+ sessionStorage?.setItem(`oTest-Run`, testId);
2477
+
2478
+ if (autoRun) {
2479
+ sessionStorage?.setItem(`oTest-Autorun`, autoRun);
2480
+ } else {
2481
+ sessionStorage?.removeItem(`oTest-Autorun`);
2482
+ }
2483
+
2484
+ const testSession = o.tests[testId];
2485
+ let lastTest = testSession.tests.pop();
2486
+
2487
+ // creates callback function with autostop/autorun
2488
+ if (typeof lastTest !== "function") {
2489
+ testSession.tests.push(lastTest);
2490
+ lastTest = () => {};
2491
+ }
2492
+
2493
+ testSession.tests.push((testN) => {
2494
+ lastTest(testN);
2495
+ sessionStorage.setItem("dddd", 1);
2496
+ sessionStorage?.removeItem(`oTest-Run`);
2497
+ if (autoRun) {
2498
+ o.runTest(testId + 1, autoRun);
2499
+ }
2500
+ });
2501
+
2502
+ o.test(testSession.title, ...testSession.tests);
2503
+ };
2504
+
2505
+ // service
2506
+ o.tPre = '<div style="font-family:monospace;text-align:left;">';
2507
+ o.tOk = '<span style="background:#cfc;padding: 0 15px;">OK</span> ';
2508
+ o.tXx = '<div style="background:#fcc;padding:3px;">';
2509
+ o.tDc = "</div>";
2510
+
2511
+ /**
2512
+ * Run test with title and test cases
2513
+ * @param {string} title - Test title
2514
+ * @param {...Array} tests - Array of test cases, each containing test title and test function/result
2515
+ * @returns {Object} Test control object with run method and testId
2516
+ */
2517
+ o.test = (title = "", ...tests) => {
2518
+ o.verify([
2519
+ [title, ["notEmptyString"]],
2520
+ [tests, ["array"]],
2521
+ ]);
2522
+ // get test ID from sessionStorage
2523
+ const testSession = sessionStorage?.getItem(`oTest-Run`);
2524
+ const testN = testSession || o.tLog.length;
2525
+ let waits = 0,
2526
+ preOk = "├ OK: ",
2527
+ preXx = "├ ✘ ",
2528
+ posOk = "\n",
2529
+ posXx = "\n",
2530
+ row = "",
2531
+ num = tests.length,
2532
+ done = 0;
2533
+
2534
+ const log = (line = "", error = false, log = false) => {
2535
+ if (o.tAutolog) {
2536
+ if (error) {
2537
+ console.error(line);
2538
+ } else if (o.tShowOk || log) {
2539
+ console.log(line);
2540
+ }
2541
+ }
2542
+ };
2543
+
2544
+ if (typeof tests[num - 1] === "function") {
2545
+ o.tFns[testN] = tests[num - 1];
2546
+ num--;
2547
+ }
2548
+
2549
+ // get tLog from sessionStorage
2550
+ if (testSession) {
2551
+ o.tLog[testN] = sessionStorage.getItem(`oTest-Log-${testN}`) || "";
2552
+ o.tRes[testN] = sessionStorage.getItem(`oTest-Res-${testN}`) || false;
2553
+ o.tStatus[testN] = JSON.parse(
2554
+ sessionStorage.getItem(`oTest-Status-${testN}`) || "[]",
2555
+ );
2556
+ for (let i = 0; i < o.tStatus[testN].length; i++) {
2557
+ const s = o.tStatus[testN][i];
2558
+ if (s === true || s === false) done++;
2559
+ }
2560
+ }
2561
+
2562
+ if (o.tStyled) {
2563
+ preOk = o.tPre + o.tOk;
2564
+ preXx = o.tPre + o.tXx;
2565
+ posOk = o.tDc;
2566
+ posXx = posOk + posOk;
2567
+ }
2568
+
2569
+ if (!testSession || o.tLog[testN].length === 0) {
2570
+ log("╒ " + title + " #" + testN, false, true);
2571
+
2572
+ if (o.tStyled) {
2573
+ o.tLog[testN] = "<div><b>" + title + " #" + testN + "</b></div>";
2574
+ } else {
2575
+ o.tLog[testN] = "╒ " + title + " #" + testN + "\n";
2576
+ }
2577
+
2578
+ o.tRes[testN] = o.F;
2579
+ o.tStatus[testN] = [];
2580
+ }
2581
+
2582
+ for (let i = o.tStatus[testN].length; i < num; i++) {
2583
+ const testInfo = {
2584
+ n: testN,
2585
+ i,
2586
+ title: tests[i][0],
2587
+ tShowOk: o.tShowOk,
2588
+ tStyled: o.tStyled,
2589
+ };
2590
+
2591
+ // test or title only
2592
+ let res = tests[i][1];
2593
+ if (typeof res === "undefined") {
2594
+ if (o.tStyled) {
2595
+ o.tLog[testN] += "<div>" + testInfo.title + "</div>";
2596
+ } else {
2597
+ o.tLog[testN] += testInfo.title + "\n";
2598
+ }
2599
+ log("├ " + testInfo.title, false, true);
2600
+ o.tStatus[testN][i] = true;
2601
+ done++;
2602
+ continue;
2603
+ }
2604
+
2605
+ if (typeof o.tBeforeEach === "function") {
2606
+ o.tBeforeEach(testInfo);
2607
+ }
2608
+
2609
+ if (typeof res === "function") {
2610
+ try {
2611
+ res = res(testInfo);
2612
+ } catch (error) {
2613
+ res = error.message;
2614
+ if (o.onError) {
2615
+ o.onError(error);
2616
+ }
2617
+ }
2618
+ }
2619
+
2620
+ if (typeof o.tAfterEach === "function") {
2621
+ o.tAfterEach(testInfo, res);
2622
+ }
2623
+
2624
+ // async step: wait for Promise then call testUpdate
2625
+ if (res && typeof res.then === "function") {
2626
+ waits++;
2627
+ const timeoutId = setTimeout(() => {
2628
+ testInfo.title += " (timeout)";
2629
+ o.testUpdate(testInfo);
2630
+ }, o.tTime);
2631
+ res
2632
+ .then((value) => {
2633
+ clearTimeout(timeoutId);
2634
+ const ok = value === true || (value && value.ok === true);
2635
+ const msg =
2636
+ value && value.errors && value.errors.length
2637
+ ? value.errors.join("; ")
2638
+ : typeof value === "string"
2639
+ ? value
2640
+ : "";
2641
+ o.testUpdate(testInfo, ok, ok ? "" : msg ? ": " + msg : "");
2642
+ })
2643
+ .catch((err) => {
2644
+ clearTimeout(timeoutId);
2645
+ o.testUpdate(testInfo, false, err.message || "Promise rejected");
2646
+ });
2647
+ continue;
2648
+ }
2649
+
2650
+ // test processing
2651
+ if (typeof o.tStatus[testN][i] === "undefined") {
2652
+ o.tStatus[testN][i] = typeof res === "string" ? o.F : res;
2653
+ } else {
2654
+ // stop for reload test
2655
+ sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
2656
+ return; // reloading...
2657
+ }
2658
+ if (res === true) {
2659
+ done++;
2660
+ if (o.tShowOk) {
2661
+ o.tLog[testN] += preOk + tests[i][0] + posOk;
2662
+ log("├ OK: " + tests[i][0]);
2663
+ }
2664
+ } else if (res !== o.U) {
2665
+ o.tLog[testN] += preXx + tests[i][0] + (res !== o.F ? ": " + res : "") + posXx;
2666
+ log("├ ✘ " + tests[i][0] + (res !== o.F ? ": " + res : ""), true);
2667
+ } else {
2668
+ waits++;
2669
+ setTimeout(
2670
+ (info) => {
2671
+ info.title += " (timeout)";
2672
+ o.testUpdate(info);
2673
+ },
2674
+ o.tTime,
2675
+ testInfo,
2676
+ );
2677
+ }
2678
+ }
2679
+
2680
+ o.tRes[testN] = done === num;
2681
+ row = waits ? "├ " : "╘ ";
2682
+ row += "DONE " + done + "/" + num + (waits ? ", waiting: " + waits : "");
2683
+ log(row, done + waits !== num);
2684
+ if (!waits) {
2685
+ log();
2686
+ }
2687
+
2688
+ if (o.tStyled) {
2689
+ o.tLog[testN] +=
2690
+ o.tPre +
2691
+ '<div style="color:' +
2692
+ (done + waits !== num ? "red" : "green") +
2693
+ ';"><b>DONE ' +
2694
+ done +
2695
+ "/" +
2696
+ num +
2697
+ (waits ? ", waiting: " + waits : "") +
2698
+ "</b>" +
2699
+ o.tDc +
2700
+ o.tDc;
2701
+ } else {
2702
+ o.tLog[testN] += row + "\n";
2703
+ }
2704
+
2705
+ // Save test results to sessionStorage
2706
+ if (testSession) {
2707
+ sessionStorage.setItem(`oTest-Log-${testN}`, o.tLog[testN]);
2708
+ sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
2709
+ sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
2710
+ }
2711
+
2712
+ if (!waits && typeof o.tFns[testN] === "function") {
2713
+ o.tFns[testN](testN);
2714
+ }
2715
+
2716
+ return testN;
2717
+ };
2718
+
2719
+ /**
2720
+ * Update test state
2721
+ * @param {Object} info - Test info
2722
+ * @param {boolean} res - Test result
2723
+ * @param {string} suff - Suffix
2724
+ */
2725
+ o.testUpdate = (info, res = o.F, suff = "") => {
2726
+ o.verify([
2727
+ [info, ["object"]],
2728
+ [res, ["boolean", "string"]],
2729
+ [suff, ["string"]],
2730
+ ]);
2731
+ let row = "";
2732
+ const testN = info.n;
2733
+ const log = (line = "", error = false) => {
2734
+ if (o.tAutolog) {
2735
+ if (error) {
2736
+ console.error(line);
2737
+ } else {
2738
+ console.log(line);
2739
+ }
2740
+ }
2741
+ };
2742
+
2743
+ if (o.tStatus[testN][info.i] === o.U || o.tStatus[testN][info.i] === null) {
2744
+ o.tStatus[testN][info.i] = res === true;
2745
+ if (res === true) {
2746
+ if (info.tShowOk) {
2747
+ row = "├ OK: " + info.title + suff;
2748
+ log(row);
2749
+ if (info.tStyled) {
2750
+ o.tLog[testN] += o.tPre + o.tOk + info.title + suff + o.tDc;
2751
+ } else {
2752
+ o.tLog[testN] += row + "\n";
2753
+ }
2754
+ }
2755
+ } else {
2756
+ o.tRes[testN] = o.F;
2757
+ row = "├ ✘ " + info.title + (res ? ": " + res : "") + suff;
2758
+ log(row, true);
2759
+ if (info.tStyled) {
2760
+ o.tLog[testN] +=
2761
+ o.tPre + o.tXx + info.title + suff + (res ? ": " + res : "") + o.tDc + o.tDc;
2762
+ } else {
2763
+ o.tLog[testN] += row + "\n";
2764
+ }
2765
+ }
2766
+
2767
+ let fails = 0,
2768
+ n = 0;
2769
+
2770
+ for (const s of o.tStatus[testN]) {
2771
+ if (s === o.U || s === null) {
2772
+ // if waiting tests there (null = restored from JSON)
2773
+ return;
2774
+ }
2775
+ if (!s) {
2776
+ fails++;
2777
+ }
2778
+ n++;
2779
+ }
2780
+
2781
+ // if test is in progress and not completed
2782
+ if (sessionStorage?.getItem("oTest-Run") === testN) {
2783
+ // save test results to sessionStorage
2784
+ sessionStorage.setItem(`oTest-Log-${testN}`, o.tLog[testN]);
2785
+ sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
2786
+ sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
2787
+
2788
+ if (n < o.tests[testN].tests.length) {
2789
+ return;
2790
+ }
2791
+ }
2792
+
2793
+ o.tRes[testN] = !fails;
2794
+ row = fails ? "FAILED " + fails + "/" + n : "DONE " + n + "/" + n;
2795
+ log("╘ " + row, Boolean(fails));
2796
+ log();
2797
+
2798
+ if (info.tStyled) {
2799
+ o.tLog[testN] +=
2800
+ o.tPre +
2801
+ '<b style="color:' +
2802
+ (!fails ? "green" : "red") +
2803
+ ';">' +
2804
+ row +
2805
+ "</b>" +
2806
+ o.tDc;
2807
+ } else {
2808
+ o.tLog[testN] += "╘ " + row + "\n";
2809
+ }
2810
+
2811
+ if (typeof o.tFns[testN] === "function") {
2812
+ o.tFns[testN](testN);
2813
+ }
2814
+ }
2815
+ };
2816
+
2817
+ if (sessionStorage?.getItem("oTest-Run")) {
2818
+ window?.addEventListener(
2819
+ "load",
2820
+ () => {
2821
+ o.runTest(
2822
+ sessionStorage?.getItem("oTest-Run"),
2823
+ sessionStorage?.getItem("oTest-Autorun") || o.F,
2824
+ true,
2825
+ );
2826
+ },
2827
+ false,
2828
+ );
2829
+ }
2830
+
2831
+ /**
2832
+ * Measure element dimensions and visibility
2833
+ * @param {Element} el - DOM element
2834
+ * @returns {{width:number, height:number, top:number, left:number, visible:boolean, opacity:string, zIndex:string}}
2835
+ */
2836
+ o.measure = (el) => {
2837
+ if (!el) {
2838
+ return {};
2839
+ }
2840
+ const rect = el.getBoundingClientRect();
2841
+ const style = window.getComputedStyle(el);
2842
+ return {
2843
+ width: rect.width,
2844
+ height: rect.height,
2845
+ top: rect.top,
2846
+ left: rect.left,
2847
+ visible: style.display !== "none" && style.visibility !== "hidden" && rect.width > 0,
2848
+ opacity: style.opacity,
2849
+ zIndex: style.zIndex,
2850
+ };
2851
+ };
2852
+
2853
+ /**
2854
+ * Assert element is visible — returns true/false for use in o.test()
2855
+ * @param {Element} el
2856
+ * @returns {boolean}
2857
+ */
2858
+ o.assertVisible = (el) => {
2859
+ const m = o.measure(el);
2860
+ return Boolean(m.visible);
2861
+ };
2862
+
2863
+ /**
2864
+ * Assert element matches expected size, padding, or margin (design system / UI verification).
2865
+ * @param {Element} el
2866
+ * @param {{w?: number, h?: number, padding?: number, paddingTop?: number, paddingRight?: number, paddingBottom?: number, paddingLeft?: number, margin?: number, marginTop?: number, marginRight?: number, marginBottom?: number, marginLeft?: number}} expected
2867
+ * @returns {boolean|string}
2868
+ */
2869
+ o.assertSize = (el, expected = {}) => {
2870
+ if (!el) return "element is null";
2871
+ const m = o.measure(el);
2872
+ if (expected.w !== undefined && Math.round(m.width) !== expected.w) {
2873
+ return `width: expected ${expected.w}, got ${Math.round(m.width)}`;
2874
+ }
2875
+ if (expected.h !== undefined && Math.round(m.height) !== expected.h) {
2876
+ return `height: expected ${expected.h}, got ${Math.round(m.height)}`;
2877
+ }
2878
+ const getPx = (prop) => {
2879
+ const v = window.getComputedStyle(el).getPropertyValue(prop);
2880
+ return v ? parseFloat(v) : 0;
2881
+ };
2882
+ if (expected.padding !== undefined && getPx("padding-top") !== expected.padding) {
2883
+ return `padding: expected ${expected.padding}, got ${getPx("padding-top")}`;
2884
+ }
2885
+ if (expected.paddingTop !== undefined && getPx("padding-top") !== expected.paddingTop) {
2886
+ return `paddingTop: expected ${expected.paddingTop}, got ${getPx("padding-top")}`;
2887
+ }
2888
+ if (
2889
+ expected.paddingRight !== undefined &&
2890
+ getPx("padding-right") !== expected.paddingRight
2891
+ ) {
2892
+ return `paddingRight: expected ${expected.paddingRight}, got ${getPx("padding-right")}`;
2893
+ }
2894
+ if (
2895
+ expected.paddingBottom !== undefined &&
2896
+ getPx("padding-bottom") !== expected.paddingBottom
2897
+ ) {
2898
+ return `paddingBottom: expected ${expected.paddingBottom}, got ${getPx("padding-bottom")}`;
2899
+ }
2900
+ if (
2901
+ expected.paddingLeft !== undefined &&
2902
+ getPx("padding-left") !== expected.paddingLeft
2903
+ ) {
2904
+ return `paddingLeft: expected ${expected.paddingLeft}, got ${getPx("padding-left")}`;
2905
+ }
2906
+ if (expected.margin !== undefined && getPx("margin-top") !== expected.margin) {
2907
+ return `margin: expected ${expected.margin}, got ${getPx("margin-top")}`;
2908
+ }
2909
+ if (expected.marginTop !== undefined && getPx("margin-top") !== expected.marginTop) {
2910
+ return `marginTop: expected ${expected.marginTop}, got ${getPx("margin-top")}`;
2911
+ }
2912
+ if (
2913
+ expected.marginRight !== undefined &&
2914
+ getPx("margin-right") !== expected.marginRight
2915
+ ) {
2916
+ return `marginRight: expected ${expected.marginRight}, got ${getPx("margin-right")}`;
2917
+ }
2918
+ if (
2919
+ expected.marginBottom !== undefined &&
2920
+ getPx("margin-bottom") !== expected.marginBottom
2921
+ ) {
2922
+ return `marginBottom: expected ${expected.marginBottom}, got ${getPx("margin-bottom")}`;
2923
+ }
2924
+ if (expected.marginLeft !== undefined && getPx("margin-left") !== expected.marginLeft) {
2925
+ return `marginLeft: expected ${expected.marginLeft}, got ${getPx("margin-left")}`;
2926
+ }
2927
+ return true;
2928
+ };
2929
+
2930
+ /**
2931
+ * Recorder state for user action capture.
2932
+ * Available in all builds so QA testers can record on staging/production.
2933
+ * Note: intercepts window.fetch and captures request/response data — review before using on production.
2934
+ */
2935
+ o.recorder = {
2936
+ active: false,
2937
+ actions: [],
2938
+ mocks: {},
2939
+ initialData: {},
2940
+ assertions: [],
2941
+ observeRoot: null,
2942
+ _originalFetch: null,
2943
+ _listeners: [],
2944
+ _observer: null,
2945
+ };
2946
+
2947
+ /**
2948
+ * Start recording user interactions
2949
+ * @param {string} [observe] - CSS selector to scope the MutationObserver (reduces assertion noise)
2950
+ * @param {string[]} [events] - Events to record (default: click, mouseover, scroll, input, change)
2951
+ * @param {{[event: string]: number}} [timeouts] - Debounce delays per event type in ms
2952
+ */
2953
+ o.startRecording = (observe, events, timeouts) => {
2954
+ if (o.recorder.active) {
2955
+ return;
2956
+ }
2957
+ const defaultEvents = ["click", "mouseover", "scroll", "input", "change"];
2958
+ const defaultStepDelays = {
2959
+ click: 100,
2960
+ mouseover: 50,
2961
+ scroll: 30,
2962
+ input: 50,
2963
+ change: 50,
2964
+ };
2965
+ const listenEvents = events || defaultEvents;
2966
+ const stepDelays = Object.assign({}, defaultStepDelays, timeouts || {});
2967
+ const captureDebounce = { scroll: 30, mouseover: 50 };
2968
+ const rec = o.recorder;
2969
+ rec.active = true;
2970
+ rec.actions = [];
2971
+ rec.mocks = {};
2972
+ rec.stepDelays = stepDelays;
2973
+ rec.initialData = { url: window.location.href, timestamp: Date.now() };
2974
+
2975
+ rec.observeRoot = observe || null;
2976
+ rec.assertions = [];
2977
+
2978
+ // snapshot current o.inits data
2979
+ o.inits.forEach((inst, idx) => {
2980
+ if (inst?.store) {
2981
+ rec.initialData["init_" + idx] = JSON.parse(JSON.stringify(inst.store));
2982
+ }
2983
+ });
2984
+
2985
+ // intercept fetch
2986
+ rec._originalFetch = window.fetch;
2987
+ window.fetch = async (url, opts = {}) => {
2988
+ const method = (opts.method || "GET").toUpperCase();
2989
+ let reqBody;
2990
+ try {
2991
+ reqBody = opts.body ? JSON.parse(opts.body) : undefined;
2992
+ } catch (_e) {
2993
+ reqBody = opts.body;
2994
+ }
2995
+ const response = await rec._originalFetch(url, opts);
2996
+ const clone = response.clone();
2997
+ let respBody;
2998
+ try {
2999
+ respBody = await clone.json();
3000
+ } catch (_e) {
3001
+ respBody = await clone.text().catch(() => null);
3002
+ }
3003
+ const key = method + ":" + url;
3004
+ rec.mocks[key] = {
3005
+ url,
3006
+ method,
3007
+ request: reqBody,
3008
+ response: respBody,
3009
+ status: response.status,
3010
+ };
3011
+ return response;
3012
+ };
3013
+
3014
+ // Internal Objs attributes must not be used for selectors (they change across restores).
3015
+ const unstableDataAttrs = { "data-o-init": 1, "data-o-init-i": 1, "data-o-state": 1 };
3016
+ const qualify = (sel, fromNode) => {
3017
+ if (o.D.querySelectorAll(sel).length <= 1) return sel;
3018
+ let node = fromNode;
3019
+ while (node && node !== o.D.body) {
3020
+ let ancestorSel = node.id ? "#" + node.id : null;
3021
+ if (!ancestorSel && node.attributes) {
3022
+ const autotagAttr = o.autotag ? "data-" + o.autotag : null;
3023
+ if (autotagAttr) {
3024
+ const val = node.getAttribute(autotagAttr);
3025
+ if (val != null) ancestorSel = `[${autotagAttr}="${val}"]`;
3026
+ }
3027
+ if (!ancestorSel) {
3028
+ for (const attr of node.attributes) {
3029
+ if (attr.name.startsWith("data-") && !unstableDataAttrs[attr.name]) {
3030
+ ancestorSel = `[${attr.name}="${attr.value}"]`;
3031
+ break;
3032
+ }
3033
+ }
3034
+ }
3035
+ }
3036
+ if (ancestorSel) {
3037
+ const q = ancestorSel + " " + sel;
3038
+ if (o.D.querySelectorAll(q).length === 1) return q;
3039
+ }
3040
+ node = node.parentElement;
3041
+ }
3042
+ return sel;
3043
+ };
3044
+
3045
+ // Build a selector string for a DOM node (reuses qualify logic)
3046
+ const buildSelector = (node) => {
3047
+ if (!node || node.nodeType !== 1) return "";
3048
+ let sel = "";
3049
+ if (node.dataset) {
3050
+ const qaKey = o.autotag && node.dataset[o.autotag];
3051
+ if (qaKey) {
3052
+ sel = qualify(`[data-${o.autotag}="${qaKey}"]`, node.parentElement);
3053
+ }
3054
+ }
3055
+ if (!sel && node.tagName) {
3056
+ const base = node.id
3057
+ ? "#" + node.id
3058
+ : node.className
3059
+ ? node.tagName.toLowerCase() + "." + [...node.classList].join(".")
3060
+ : node.tagName.toLowerCase();
3061
+ sel = node.id ? base : qualify(base, node.parentElement);
3062
+ }
3063
+ return sel;
3064
+ };
3065
+
3066
+ // Scoped MutationObserver: captures DOM mutations tied to the last recorded action
3067
+ const observeTarget = (observe && o.D.querySelector(observe)) || o.D.body;
3068
+ rec._observer = new MutationObserver((mutations) => {
3069
+ const actionIdx = rec.actions.length - 1;
3070
+ if (actionIdx < 0) return;
3071
+ mutations.forEach((m) => {
3072
+ const addAssertionIndex = (sel, node) => {
3073
+ let listSelector;
3074
+ let index;
3075
+ if (sel && observeTarget) {
3076
+ const matches = observeTarget.querySelectorAll(sel);
3077
+ if (matches.length > 1) {
3078
+ let n = node;
3079
+ while (n && n !== observeTarget && n.nodeType === 1) {
3080
+ const qaAttr = o.autotag && n.dataset && n.dataset[o.autotag];
3081
+ if (qaAttr) {
3082
+ const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
3083
+ const itemMatches = observeTarget.querySelectorAll(itemSel);
3084
+ if (itemMatches.length > 1) {
3085
+ const idx = [...itemMatches].indexOf(n);
3086
+ if (idx !== -1) {
3087
+ listSelector = itemSel;
3088
+ index = idx;
3089
+ break;
3090
+ }
3091
+ }
3092
+ }
3093
+ n = n.parentElement;
3094
+ }
3095
+ }
3096
+ }
3097
+ return { listSelector, index };
3098
+ };
3099
+ if (m.type === "childList") {
3100
+ m.addedNodes.forEach((node) => {
3101
+ if (node.nodeType !== 1) return;
3102
+ if (!node.offsetParent) return;
3103
+ const sel = buildSelector(node);
3104
+ if (!sel) return;
3105
+ if (
3106
+ rec.assertions.some(
3107
+ (a) =>
3108
+ a.actionIdx === actionIdx && a.selector === sel && a.type === "visible",
3109
+ )
3110
+ )
3111
+ return;
3112
+ // Prefer stable content (e.g. .task-text) so assertions survive reorder/restore
3113
+ const textEl = node.querySelector?.(".task-text") || node;
3114
+ const text =
3115
+ (textEl.textContent?.trim() || node.textContent?.trim() || "").slice(0, 80) ||
3116
+ undefined;
3117
+ const { listSelector: aListSel, index: aIdx } = addAssertionIndex(sel, node);
3118
+ const a = { actionIdx, type: "visible", selector: sel, text };
3119
+ if (aListSel != null) a.listSelector = aListSel;
3120
+ if (aIdx != null) a.index = aIdx;
3121
+ rec.assertions.push(a);
3122
+ });
3123
+ }
3124
+ if (m.type === "attributes") {
3125
+ const sel = buildSelector(m.target);
3126
+ if (!sel) return;
3127
+ if (
3128
+ rec.assertions.some(
3129
+ (a) => a.actionIdx === actionIdx && a.selector === sel && a.type === "class",
3130
+ )
3131
+ )
3132
+ return;
3133
+ const { listSelector: aListSel, index: aIdx } = addAssertionIndex(sel, m.target);
3134
+ const a = {
3135
+ actionIdx,
3136
+ type: "class",
3137
+ selector: sel,
3138
+ className: m.target.className,
3139
+ };
3140
+ if (aListSel != null) a.listSelector = aListSel;
3141
+ if (aIdx != null) a.index = aIdx;
3142
+ rec.assertions.push(a);
3143
+ }
3144
+ });
3145
+ });
3146
+ rec._observer.observe(observeTarget, {
3147
+ childList: true,
3148
+ subtree: true,
3149
+ attributes: true,
3150
+ attributeFilter: [
3151
+ "class",
3152
+ "style",
3153
+ "hidden",
3154
+ "disabled",
3155
+ "aria-expanded",
3156
+ "aria-checked",
3157
+ ],
3158
+ });
3159
+
3160
+ // attach DOM listeners
3161
+ const timers = {};
3162
+ listenEvents.forEach((ev) => {
3163
+ const handler = (e) => {
3164
+ const target = e.target;
3165
+ if (
3166
+ observe &&
3167
+ observeTarget &&
3168
+ target?.nodeType === 1 &&
3169
+ !observeTarget.contains(target)
3170
+ ) {
3171
+ return;
3172
+ }
3173
+ // Capture selector and values EAGERLY in the capture phase, before any
3174
+ // bubble listener can mutate the DOM (e.g. a delete button removing its own li).
3175
+ let selector = "";
3176
+ if (target?.dataset) {
3177
+ const qaKey = o.autotag && target.dataset[o.autotag];
3178
+ if (qaKey) {
3179
+ selector = qualify(`[data-${o.autotag}="${qaKey}"]`, target.parentElement);
3180
+ }
3181
+ }
3182
+ if (!selector && target?.tagName) {
3183
+ const base = target.id
3184
+ ? "#" + target.id
3185
+ : target.className
3186
+ ? target.tagName.toLowerCase() + "." + [...target.classList].join(".")
3187
+ : target.tagName.toLowerCase();
3188
+ selector = target.id ? base : qualify(base, target.parentElement);
3189
+ }
3190
+ // When selector matches multiple elements, store listSelector + targetIndex for replay by index
3191
+ let listSelector;
3192
+ let targetIndex;
3193
+ if (selector && observeTarget) {
3194
+ const matches = observeTarget.querySelectorAll(selector);
3195
+ if (matches.length > 1) {
3196
+ let node = target;
3197
+ while (node && node !== observeTarget && node.nodeType === 1) {
3198
+ const qaAttr = o.autotag && node.dataset && node.dataset[o.autotag];
3199
+ if (qaAttr) {
3200
+ const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
3201
+ const itemMatches = observeTarget.querySelectorAll(itemSel);
3202
+ if (itemMatches.length > 1) {
3203
+ const idx = [...itemMatches].indexOf(node);
3204
+ if (idx !== -1) {
3205
+ listSelector = itemSel;
3206
+ targetIndex = idx;
3207
+ break;
3208
+ }
3209
+ }
3210
+ }
3211
+ node = node.parentElement;
3212
+ }
3213
+ }
3214
+ }
3215
+ const targetType = target?.tagName
3216
+ ? target.tagName.toLowerCase() + (target.type ? ":" + target.type : "")
3217
+ : undefined;
3218
+ // For scroll, capture position at event time (before page may jump)
3219
+ const scrollY = ev === "scroll" ? window.scrollY : undefined;
3220
+ // For input/change, capture value; for checkboxes also capture checked state
3221
+ const value = ev === "input" || ev === "change" ? target?.value : undefined;
3222
+ const checked =
3223
+ ev === "change" && (target?.type === "checkbox" || target?.type === "radio")
3224
+ ? target?.checked
3225
+ : undefined;
3226
+
3227
+ const delay =
3228
+ stepDelays[ev] !== undefined ? stepDelays[ev] : (captureDebounce[ev] ?? 0);
3229
+ const pushAction = () => {
3230
+ const action = { type: ev, target: selector, time: Date.now() };
3231
+ if (targetType) action.targetType = targetType;
3232
+ if (scrollY !== undefined) action.scrollY = scrollY;
3233
+ if (value !== undefined) action.value = value;
3234
+ if (checked !== undefined) action.checked = checked;
3235
+ if (listSelector != null) action.listSelector = listSelector;
3236
+ if (targetIndex != null) action.targetIndex = targetIndex;
3237
+ rec.actions.push(action);
3238
+ };
3239
+ if (delay === 0) {
3240
+ pushAction();
3241
+ } else {
3242
+ clearTimeout(timers[ev]);
3243
+ timers[ev] = setTimeout(pushAction, delay);
3244
+ }
3245
+ };
3246
+ o.D.addEventListener(ev, handler, true);
3247
+ rec._listeners.push({ ev, handler });
3248
+ });
3249
+ };
3250
+
3251
+ /**
3252
+ * Stop recording and return captured data
3253
+ * @returns {{actions: Array, mocks: Object, initialData: Object}}
3254
+ */
3255
+ o.stopRecording = () => {
3256
+ const rec = o.recorder;
3257
+ rec.active = false;
3258
+ if (rec._originalFetch) {
3259
+ window.fetch = rec._originalFetch;
3260
+ rec._originalFetch = null;
3261
+ }
3262
+ rec._listeners.forEach(({ ev, handler }) => {
3263
+ o.D.removeEventListener(ev, handler, true);
3264
+ });
3265
+ rec._listeners = [];
3266
+ if (rec._observer) {
3267
+ rec._observer.disconnect();
3268
+ rec._observer = null;
3269
+ }
3270
+ return {
3271
+ actions: [...rec.actions],
3272
+ mocks: { ...rec.mocks },
3273
+ initialData: { ...rec.initialData },
3274
+ stepDelays: { ...rec.stepDelays },
3275
+ assertions: [...(rec.assertions || [])],
3276
+ observeRoot: rec.observeRoot || null,
3277
+ };
3278
+ };
3279
+
3280
+ /**
3281
+ * Clear recording from sessionStorage
3282
+ * @param {number} [id] - Recording ID, or clears all if omitted
3283
+ */
3284
+ o.clearRecording = (id) => {
3285
+ if (id !== undefined) {
3286
+ sessionStorage?.removeItem("oTest-Recording-" + id);
3287
+ } else {
3288
+ for (let i = sessionStorage?.length - 1; i >= 0; i--) {
3289
+ const key = sessionStorage?.key(i);
3290
+ if (key && key.indexOf("oTest-Recording-") === 0) {
3291
+ sessionStorage?.removeItem(key);
3292
+ }
3293
+ }
3294
+ }
3295
+ };
3296
+
3297
+ /**
3298
+ * Export a recording as a ready-to-commit o.addTest() code string.
3299
+ * Available in all builds so QA testers can export tests from staging.
3300
+ * @param {{actions: Array, mocks: Object, initialData: Object}} recording
3301
+ * @returns {string}
3302
+ */
3303
+ o.exportTest = (recording) => {
3304
+ const cases = recording.actions
3305
+ .map((a) => {
3306
+ let body;
3307
+ if (a.type === "scroll") {
3308
+ body = ` window.scrollTo(0, ${a.scrollY || 0}); return true;\n`;
3309
+ } else if (a.type === "input" || a.type === "change") {
3310
+ body =
3311
+ (a.value !== undefined ? ` el.value = ${JSON.stringify(a.value)};\n` : "") +
3312
+ (a.checked !== undefined ? ` el.checked = ${a.checked};\n` : "") +
3313
+ ` el.dispatchEvent(new Event('${a.type}', {bubbles:true})); return true;\n`;
3314
+ } else {
3315
+ const useNativeClick = a.type === "click";
3316
+ body = useNativeClick
3317
+ ? ` el.click(); return true;\n`
3318
+ : ` el.dispatchEvent(new MouseEvent('${a.type}', {bubbles:true,cancelable:true})); return true;\n`;
3319
+ }
3320
+ return (
3321
+ ` ['${a.type} on ${a.target}', () => {\n` +
3322
+ ` const el = document.querySelector('${a.target}');\n` +
3323
+ ` if (!el) return 'element not found';\n` +
3324
+ body +
3325
+ ` }],`
3326
+ );
3327
+ })
3328
+ .join("\n");
3329
+
3330
+ const mocksStr = Object.keys(recording.mocks).length
3331
+ ? JSON.stringify(recording.mocks, null, 2)
3332
+ : "{}";
3333
+
3334
+ return (
3335
+ `// Auto-generated by o.exportTest() — review and anonymize mocks before committing\n` +
3336
+ `const recordingMocks = ${mocksStr};\n\n` +
3337
+ `o.addTest('Recorded test', [\n${cases}\n], () => {\n` +
3338
+ ` // teardown\n});\n`
3339
+ );
3340
+ };
3341
+
3342
+ /**
3343
+ * Export a recording as a ready-to-run Playwright .spec.ts file string.
3344
+ * Available in all builds so QA testers can generate Playwright tests from staging.
3345
+ * @param {{actions: Array, mocks: Object, initialData: Object}} recording
3346
+ * @param {{testName?: string, baseUrl?: string}} [options]
3347
+ * @returns {string}
3348
+ */
3349
+ o.exportPlaywrightTest = (recording, options = {}) => {
3350
+ const testName = options.testName || "Recorded session";
3351
+ const rawUrl = recording.initialData?.url ?? "/";
3352
+ let path = "/";
3353
+ try {
3354
+ path = new URL(rawUrl).pathname || "/";
3355
+ } catch (_e) {
3356
+ path = rawUrl;
3357
+ }
3358
+ const baseUrl = options.baseUrl || path;
3359
+
3360
+ const routes = Object.values(recording.mocks)
3361
+ .map((mock) => {
3362
+ const urlPath = mock.url.startsWith("/") ? mock.url : "/" + mock.url;
3363
+ const body = JSON.stringify(mock.response);
3364
+ return (
3365
+ ` await page.route('**${urlPath}', async route => {\n` +
3366
+ ` await route.fulfill({ status: ${mock.status || 200}, contentType: 'application/json',\n` +
3367
+ ` body: JSON.stringify(${body}) });\n` +
3368
+ ` });`
3369
+ );
3370
+ })
3371
+ .join("\n");
3372
+
3373
+ const sd = Object.assign(
3374
+ { click: 100, mouseover: 50, scroll: 30, input: 50, change: 50 },
3375
+ recording.stepDelays || {},
3376
+ );
3377
+ const steps = recording.actions
3378
+ .map((action, i) => {
3379
+ const loc =
3380
+ action.listSelector != null && action.targetIndex != null
3381
+ ? action.target !== action.listSelector
3382
+ ? `page.locator(${JSON.stringify(action.listSelector)}).nth(${action.targetIndex}).locator(${JSON.stringify(action.target)})`
3383
+ : `page.locator(${JSON.stringify(action.listSelector)}).nth(${action.targetIndex})`
3384
+ : `page.locator(${JSON.stringify(action.target)})`;
3385
+ const wait = ` await page.waitForTimeout(${sd[action.type] || 50});`;
3386
+ let step;
3387
+ if (action.type === "scroll") {
3388
+ step = ` await page.evaluate(() => window.scrollTo(0, ${action.scrollY || 0}));`;
3389
+ } else if (action.type === "mouseover") {
3390
+ step = ` await ${loc}.hover();\n // Pure CSS :hover — no DOM mutation to assert.\n // Fix: toggle a class in a mouseover handler, or add a page.screenshot() visual check.`;
3391
+ } else if (action.type === "input") {
3392
+ step = ` await ${loc}.fill(${JSON.stringify(action.value || "")});`;
3393
+ } else if (action.type === "change") {
3394
+ const tt = action.targetType || "";
3395
+ if (tt.indexOf(":checkbox") !== -1 || tt.indexOf(":radio") !== -1) {
3396
+ const on =
3397
+ action.checked !== undefined
3398
+ ? action.checked
3399
+ : action.value === "true" || action.value === "on";
3400
+ step = ` await ${loc}.${on ? "check" : "uncheck"}();`;
3401
+ } else if (tt.indexOf("select") !== -1) {
3402
+ step = ` await ${loc}.selectOption(${JSON.stringify(action.value || "")});`;
3403
+ } else {
3404
+ step = ` await ${loc}.fill(${JSON.stringify(action.value || "")});`;
3405
+ }
3406
+ } else {
3407
+ step = ` await ${loc}.click();`;
3408
+ }
3409
+
3410
+ const asserts = (recording.assertions || [])
3411
+ .filter((a) => a.actionIdx === i)
3412
+ .filter(
3413
+ (a, j, arr) =>
3414
+ arr.findIndex(
3415
+ (x) =>
3416
+ x.selector === a.selector && x.type === a.type && x.index === a.index,
3417
+ ) === j,
3418
+ )
3419
+ .map((a) => {
3420
+ const aLoc =
3421
+ a.listSelector != null && a.index != null
3422
+ ? a.selector !== a.listSelector
3423
+ ? `page.locator(${JSON.stringify(a.listSelector)}).nth(${a.index}).locator(${JSON.stringify(a.selector)})`
3424
+ : `page.locator(${JSON.stringify(a.listSelector)}).nth(${a.index})`
3425
+ : `page.locator(${JSON.stringify(a.selector)})`;
3426
+ if (a.type === "visible") {
3427
+ let s = ` await expect(${aLoc}).toBeVisible();`;
3428
+ if (a.text)
3429
+ s += `\n await expect(${aLoc}).toContainText(${JSON.stringify(a.text)});`;
3430
+ return s;
3431
+ }
3432
+ if (a.type === "class") {
3433
+ return ` // class on ${a.selector} changed to: "${a.className}"`;
3434
+ }
3435
+ return "";
3436
+ })
3437
+ .filter(Boolean)
3438
+ .join("\n");
3439
+
3440
+ return step + "\n" + wait + (asserts ? "\n" + asserts : "");
3441
+ })
3442
+ .join("\n");
3443
+
3444
+ const hasAutoAssertions = (recording.assertions || []).length > 0;
3445
+ return (
3446
+ `// Auto-generated by o.exportPlaywrightTest() — review and anonymize mocks before committing\n` +
3447
+ `// Prerequisites: npm install @playwright/test && npx playwright install chromium\n` +
3448
+ `// Run: npx playwright test recorded.spec.ts\n` +
3449
+ `import { test, expect } from '@playwright/test';\n\n` +
3450
+ `test(${JSON.stringify(testName)}, async ({ page }) => {\n` +
3451
+ (routes
3452
+ ? ` // Network mocks — edit/anonymize before committing\n` + routes + "\n\n"
3453
+ : "") +
3454
+ ` // Set baseURL in playwright.config.ts: { use: { baseURL: 'https://staging.example.com' } }\n` +
3455
+ ` await page.goto(${JSON.stringify(baseUrl)});\n\n` +
3456
+ (steps ? steps + "\n\n" : "") +
3457
+ (!hasAutoAssertions
3458
+ ? ` // TODO: Add assertions before committing, e.g.:\n` +
3459
+ ` // await expect(page.locator('[data-qa="success-panel"]')).toBeVisible();\n` +
3460
+ ` // await expect(page).toHaveURL(/\\/confirmation/);\n` +
3461
+ ` // await expect(page.locator('[data-qa="error-banner"]')).not.toBeVisible();\n`
3462
+ : ` // Auto-generated assertions above — review for correctness before committing\n`) +
3463
+ `});\n`
3464
+ );
3465
+ };
3466
+
3467
+ // Available in all builds so assessors can replay and see results (testOverlay) on staging.
3468
+ /**
3469
+ * Play back a recording as an automated test sequence
3470
+ * @param {{actions: Array, mocks: Object}} recording
3471
+ * @param {Object} [mockOverrides] - Additional mock overrides (anonymized data)
3472
+ * @returns {number} testId
3473
+ */
3474
+ o.playRecording = (recording, mockOverrides = {}) => {
3475
+ const allMocks = Object.assign({}, recording.mocks, mockOverrides);
3476
+ // install mock fetch
3477
+ const origFetch = window.fetch;
3478
+ window.fetch = (url, opts = {}) => {
3479
+ const method = (opts.method || "GET").toUpperCase();
3480
+ const key = method + ":" + url;
3481
+ if (allMocks[key]) {
3482
+ const mock = allMocks[key];
3483
+ const body =
3484
+ typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
3485
+ return Promise.resolve(new Response(body, { status: mock.status || 200 }));
3486
+ }
3487
+ return origFetch(url, opts);
3488
+ };
3489
+
3490
+ const testCases = recording.actions.map((action) => [
3491
+ `${action.type} on ${action.target}`,
3492
+ () => {
3493
+ let el = null;
3494
+ if (action.target) {
3495
+ if (action.listSelector != null && action.targetIndex != null) {
3496
+ const items = o.D.querySelectorAll(action.listSelector);
3497
+ const item = items[action.targetIndex];
3498
+ if (item) {
3499
+ el =
3500
+ action.target !== action.listSelector
3501
+ ? item.querySelector(action.target)
3502
+ : item;
3503
+ if (!el && action.target !== action.listSelector) el = item;
3504
+ }
3505
+ } else {
3506
+ el = o.D.querySelector(action.target);
3507
+ }
3508
+ }
3509
+ if (!el && action.type !== "scroll") {
3510
+ return `element not found: ${action.target}`;
3511
+ }
3512
+ if (action.type === "scroll") {
3513
+ window.scrollTo(0, action.scrollY || 0);
3514
+ } else if (action.type === "input" || action.type === "change") {
3515
+ if (action.value !== undefined) el.value = action.value;
3516
+ if (action.checked !== undefined) el.checked = action.checked;
3517
+ el.dispatchEvent(new Event(action.type, { bubbles: true }));
3518
+ } else {
3519
+ if (action.type === "click") {
3520
+ el.click();
3521
+ } else {
3522
+ el.dispatchEvent(
3523
+ new MouseEvent(action.type, { bubbles: true, cancelable: true }),
3524
+ );
3525
+ }
3526
+ }
3527
+ return true;
3528
+ },
3529
+ ]);
3530
+
3531
+ const testId = o.test("Recorded playback", ...testCases, () => {
3532
+ window.fetch = origFetch;
3533
+ });
3534
+ return testId;
3535
+ };
3536
+
3537
+ // ─── Test results overlay (all builds — for assessors to see auto + manual results) ───
3538
+
3539
+ /**
3540
+ * Render a draggable overlay (same position and style as testConfirm) that shows test results (o.tLog / o.tRes).
3541
+ * Fully closable (Close removes it from DOM). Reopened when o.testOverlay() is called again (e.g. after a new test run).
3542
+ * Call o.testOverlay() then o.testOverlay.showPanel() after a test run to show results (creates overlay if closed).
3543
+ */
3544
+ o.testOverlay = () => {
3545
+ const btnId = "o-test-overlay-btn";
3546
+ const panelId = "o-test-overlay-panel";
3547
+ if (o("#" + btnId).el) {
3548
+ return;
3549
+ }
3550
+
3551
+ const updatePanel = () => {
3552
+ const panel = o("#" + panelId);
3553
+ if (!panel.el) return;
3554
+ const total = o.tRes.length;
3555
+ const passed = o.tRes.filter(Boolean).length;
3556
+ let html = `<b>Tests: ${passed}/${total}</b><hr style="margin:4px 0">`;
3557
+ o.tLog.forEach((log, i) => {
3558
+ const ok = o.tRes[i];
3559
+ html += `<div style="margin:2px 0;padding:2px 4px;border-radius:3px;background:${ok ? "#d4edda" : "#f8d7da"};color:${ok ? "#155724" : "#721c24"};font-size:11px;white-space:pre-wrap">${log || "Test #" + i}</div>`;
3560
+ });
3561
+ html += `<button id="o-test-export" style="margin-top:6px;padding:4px 8px;font-size:11px;cursor:pointer">Export results</button>`;
3562
+ panel.html(html);
3563
+ o("#o-test-export").on("click", () => {
3564
+ const data = JSON.stringify({ results: o.tRes, logs: o.tLog }, null, 2);
3565
+ const blob = new Blob([data], { type: "application/json" });
3566
+ const a = o.D.createElement("a");
3567
+ a.href = URL.createObjectURL(blob);
3568
+ a.download = "objs-test-results.json";
3569
+ a.click();
3570
+ });
3571
+ };
3572
+
3573
+ const overlayStyle = {
3574
+ position: "fixed",
3575
+ left: "50%",
3576
+ bottom: "50px",
3577
+ transform: "translateX(-50%)",
3578
+ "z-index": "999999",
3579
+ width: "fit-content",
3580
+ "max-width": "min(90vw, 420px)",
3581
+ "font-family": "system-ui,sans-serif",
3582
+ cursor: "grab",
3583
+ "user-select": "text",
3584
+ };
3585
+
3586
+ const box = o
3587
+ .initState({
3588
+ tag: "div",
3589
+ id: btnId,
3590
+ className: "o-test-overlay",
3591
+ style:
3592
+ "position:fixed;left:50%;bottom:50px;transform:translateX(-50%);z-index:999999;width:fit-content;max-width:min(90vw,420px);font-family:system-ui,sans-serif;cursor:grab;user-select:text;",
3593
+ html:
3594
+ `<div class="o-test-overlay-bar" style="display:flex;flex-direction:column;align-items:stretch;padding:10px 14px;background:#1e293b;color:#e2e8f0;border:1px solid #334155;border-radius:8px;font-size:14px;cursor:grab;min-width:200px;">` +
3595
+ `<div style="display:flex;align-items:center;gap:12px;">` +
3596
+ `<span class="o-test-overlay-summary" style="flex:1;font-size:13px;">Tests: 0/0</span>` +
3597
+ `<button type="button" class="o-test-overlay-toggle" style="padding:6px 10px;background:#334155;color:#e2e8f0;border:none;border-radius:6px;cursor:pointer;font-size:12px;">List</button>` +
3598
+ `<button type="button" class="o-test-overlay-close" style="padding:4px 8px;background:transparent;color:#94a3b8;border:none;border-radius:4px;cursor:pointer;font-size:16px;line-height:1;" title="Close">×</button>` +
3599
+ `</div></div>` +
3600
+ `<div id="${panelId}" style="display:none;margin-top:4px;padding:8px;background:#fff;border:1px solid #334155;border-radius:6px;max-height:60vh;overflow-y:auto;box-shadow:0 2px 8px rgba(0,0,0,.15);font-size:11px;user-select:text;cursor:text;"></div>`,
3601
+ })
3602
+ .appendInside("body");
3603
+
3604
+ const applyOverlayStyle = () => {
3605
+ box.css(overlayStyle);
3606
+ };
3607
+ let drag = null;
3608
+ const onMove = (e) => {
3609
+ if (!drag) return;
3610
+ overlayStyle.left = drag.left + (e.clientX - drag.startX) + "px";
3611
+ overlayStyle.top = drag.top + (e.clientY - drag.startY) + "px";
3612
+ delete overlayStyle.bottom;
3613
+ overlayStyle.transform = "none";
3614
+ applyOverlayStyle();
3615
+ };
3616
+ const onUp = () => {
3617
+ if (drag) {
3618
+ overlayStyle.cursor = "grab";
3619
+ applyOverlayStyle();
3620
+ }
3621
+ drag = null;
3622
+ };
3623
+ box.on("mousedown", (e) => {
3624
+ if (
3625
+ e.target.closest(".o-test-overlay-close") ||
3626
+ e.target.closest(".o-test-overlay-toggle") ||
3627
+ e.target.closest("#" + panelId)
3628
+ )
3629
+ return;
3630
+ const r = box.el.getBoundingClientRect();
3631
+ drag = { startX: e.clientX, startY: e.clientY, left: r.left, top: r.top };
3632
+ overlayStyle.cursor = "grabbing";
3633
+ applyOverlayStyle();
3634
+ });
3635
+ o.D.addEventListener("mousemove", onMove);
3636
+ o.D.addEventListener("mouseup", onUp);
3637
+
3638
+ const refreshSummary = () => {
3639
+ const summary = o(".o-test-overlay-summary");
3640
+ if (summary.els.length)
3641
+ summary.innerText(`Tests: ${o.tRes.filter(Boolean).length}/${o.tRes.length}`);
3642
+ };
3643
+
3644
+ box.first(".o-test-overlay-toggle").on("click", () => {
3645
+ const panel = o("#" + panelId);
3646
+ if (!panel.el) return;
3647
+ const isOpen = panel.el.style.display !== "none";
3648
+ panel.css({ display: isOpen ? "none" : "block" });
3649
+ if (!isOpen) updatePanel();
3650
+ });
3651
+
3652
+ box.first(".o-test-overlay-close").on("click", () => {
3653
+ o.D.removeEventListener("mousemove", onMove);
3654
+ o.D.removeEventListener("mouseup", onUp);
3655
+ box.remove();
3656
+ });
3657
+
3658
+ o.testOverlay.showPanel = () => {
3659
+ const panel = o("#" + panelId);
3660
+ if (!panel.el) return;
3661
+ panel.css({ display: "block" });
3662
+ updatePanel();
3663
+ refreshSummary();
3664
+ };
3665
+
3666
+ // Single patch of o.test to refresh panel when tests complete (use base so we don't stack)
3667
+ if (!o._testOverlayBase) o._testOverlayBase = o.test;
3668
+ o.test = (...args) => {
3669
+ const id = o._testOverlayBase(...args);
3670
+ const origFn = o.tFns[id];
3671
+ o.tFns[id] = (n) => {
3672
+ if (typeof origFn === "function") origFn(n);
3673
+ const panel = o("#" + panelId);
3674
+ if (panel.el && panel.el.style.display !== "none") updatePanel();
3675
+ refreshSummary();
3676
+ };
3677
+ return id;
3678
+ };
3679
+ };
3680
+
3681
+ /**
3682
+ * Pause an Objs browser test; minimal draggable bar so operator can see the page.
3683
+ * Only available in dev builds. NOT referenced in exportPlaywrightTest.
3684
+ * @param {string} label - Test title (shown as "Test title: Paused")
3685
+ * @param {string[]} [items] - Optional checklist for the operator (e.g. hover effects to verify); use labels so clicking text toggles checkbox
3686
+ * @param {{ confirm?: string }} [opts] - Continue button label (default "Continue")
3687
+ * @returns {Promise<{ ok: boolean, errors?: string[] }>} ok true if all items checked; errors = list of unchecked item texts when ok false
3688
+ */
3689
+ o.testConfirm = (label, items = [], opts = {}) =>
3690
+ new Promise((resolve) => {
3691
+ o(".o-tc-overlay").remove();
3692
+ const btnLabel = opts.confirm || "Continue";
3693
+ const hasCheckboxes = items.length > 0;
3694
+ const btnBg = hasCheckboxes ? "#dc2626" : "#2563eb";
3695
+ const itemIds = items.map((_, idx) => "o-tc-cb-" + idx);
3696
+ const checkboxStyle =
3697
+ ".o-tc-item-cb{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:2px solid #ef4444;border-radius:3px;background:#fef2f2;flex-shrink:0;cursor:pointer;}" +
3698
+ ".o-tc-item-cb:checked{border-color:#22c55e;background:#22c55e;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E\");background-size:12px 12px;background-position:center;}";
3699
+ const itemsHtml = hasCheckboxes
3700
+ ? `<style>${checkboxStyle}</style><ul class="o-tc-list" style="margin:8px 0 0;padding:0;list-style:none;font-size:13px;color:#cbd5e1;">` +
3701
+ items
3702
+ .map(
3703
+ (i, idx) =>
3704
+ `<li style="margin-bottom:4px;"><label for="${itemIds[idx]}" style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;"><input type="checkbox" id="${itemIds[idx]}" class="o-tc-item-cb"> <span>${i}</span></label></li>`,
3705
+ )
3706
+ .join("") +
3707
+ "</ul>"
3708
+ : "";
3709
+ const box = o
3710
+ .initState({
3711
+ tag: "div",
3712
+ className: "o-tc-overlay",
3713
+ style:
3714
+ "position:fixed;left:50%;bottom:50px;transform:translateX(-50%);z-index:999999;width:fit-content;max-width:min(90vw,400px);font-family:system-ui,sans-serif;cursor:grab;user-select:text;",
3715
+ html:
3716
+ `<div class="o-tc-bar" style="display:flex;flex-direction:column;align-items:stretch;padding:10px 14px;background:#1e293b;color:#e2e8f0;border:1px solid #334155;border-radius:8px;font-size:14px;cursor:grab;min-width:280px;">` +
3717
+ `<div style="display:flex;align-items:center;gap:12px;">` +
3718
+ `<span class="o-tc-label" style="flex:1;">${label}: Paused</span>` +
3719
+ `<button type="button" class="o-tc-ok" style="padding:6px 14px;background:${btnBg};color:#fff;border:none;border-radius:6px;font-weight:600;cursor:pointer;font-size:13px;flex-shrink:0;">${btnLabel}</button>` +
3720
+ `</div>` +
3721
+ itemsHtml +
3722
+ `</div>`,
3723
+ })
3724
+ .appendInside("body");
3725
+
3726
+ const okBtnStyles = {
3727
+ padding: "6px 14px",
3728
+ background: hasCheckboxes ? "#dc2626" : "#2563eb",
3729
+ color: "#fff",
3730
+ border: "none",
3731
+ "border-radius": "6px",
3732
+ "font-weight": "600",
3733
+ cursor: "pointer",
3734
+ "font-size": "13px",
3735
+ "flex-shrink": "0",
3736
+ };
3737
+ if (hasCheckboxes) {
3738
+ const okBtn = box.first(".o-tc-ok");
3739
+ const cbs = o(".o-tc-overlay .o-tc-item-cb");
3740
+ const updateBtn = () => {
3741
+ const allChecked = cbs.length > 0 && cbs.els.every((el) => el.checked);
3742
+ okBtn.css({ ...okBtnStyles, background: allChecked ? "#16a34a" : "#dc2626" });
3743
+ };
3744
+ cbs.on("change", updateBtn);
3745
+ }
3746
+
3747
+ let drag = null;
3748
+ const overlayStyle = {
3749
+ position: "fixed",
3750
+ left: "50%",
3751
+ bottom: "50px",
3752
+ transform: "translateX(-50%)",
3753
+ "z-index": "999999",
3754
+ width: "fit-content",
3755
+ "max-width": "min(90vw, 400px)",
3756
+ "font-family": "system-ui,sans-serif",
3757
+ cursor: "grab",
3758
+ "user-select": "text",
3759
+ };
3760
+ const applyOverlayStyle = () => {
3761
+ box.css(overlayStyle);
3762
+ };
3763
+ const onMove = (e) => {
3764
+ if (!drag) return;
3765
+ overlayStyle.left = drag.left + (e.clientX - drag.startX) + "px";
3766
+ overlayStyle.top = drag.top + (e.clientY - drag.startY) + "px";
3767
+ delete overlayStyle.bottom;
3768
+ overlayStyle.transform = "none";
3769
+ applyOverlayStyle();
3770
+ };
3771
+ const onUp = () => {
3772
+ if (drag) {
3773
+ overlayStyle.cursor = "grab";
3774
+ applyOverlayStyle();
3775
+ }
3776
+ drag = null;
3777
+ };
3778
+ box.on("mousedown", (e) => {
3779
+ if (e.target.closest(".o-tc-ok")) return;
3780
+ const r = box.el.getBoundingClientRect();
3781
+ drag = { startX: e.clientX, startY: e.clientY, left: r.left, top: r.top };
3782
+ overlayStyle.cursor = "grabbing";
3783
+ applyOverlayStyle();
3784
+ });
3785
+ o.D.addEventListener("mousemove", onMove);
3786
+ o.D.addEventListener("mouseup", onUp);
3787
+
3788
+ box.first(".o-tc-ok").on("click", () => {
3789
+ o.D.removeEventListener("mousemove", onMove);
3790
+ o.D.removeEventListener("mouseup", onUp);
3791
+ let unchecked = [];
3792
+ if (hasCheckboxes) {
3793
+ const cbsList = o(".o-tc-overlay .o-tc-item-cb");
3794
+ cbsList.els.forEach((el, idx) => {
3795
+ if (!el.checked && items[idx] !== undefined) unchecked.push(items[idx]);
3796
+ });
3797
+ }
3798
+ box.remove();
3799
+ if (unchecked.length === 0) {
3800
+ resolve({ ok: true });
3801
+ } else {
3802
+ resolve({ ok: false, errors: unchecked });
3803
+ }
3804
+ });
3805
+ });