@cocreate/state 1.29.0 → 2.0.0
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/.github/FUNDING.yml +1 -1
- package/.github/workflows/automated.yml +18 -7
- package/CHANGELOG.md +423 -392
- package/CONTRIBUTING.md +6 -6
- package/README.md +241 -48
- package/demo/iframe.html +1 -1
- package/demo/index.html +3 -3
- package/demo/pass-prefix.html +2 -2
- package/demo/pass-value-input.1.html +2 -2
- package/demo/pass-value-input.html +11 -11
- package/demo/pass-value.html +1 -1
- package/demo/test.html +1 -1
- package/docs/index.html +7 -7
- package/package.json +9 -9
- package/src/index.js +371 -69
- package/test/hidden-state.test.cjs +21 -0
- package/test/source-state-order.test.cjs +84 -0
package/src/index.js
CHANGED
|
@@ -6,10 +6,29 @@ import storage from "@cocreate/local-storage";
|
|
|
6
6
|
|
|
7
7
|
const { localStorage, sessionStorage } = storage;
|
|
8
8
|
|
|
9
|
+
async function syncActiveState() {
|
|
10
|
+
if (!("serviceWorker" in navigator)) return;
|
|
11
|
+
|
|
12
|
+
const message = {
|
|
13
|
+
action: "setActiveState",
|
|
14
|
+
state: sessionStorage.getItem("state") || "{}",
|
|
15
|
+
theme: window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
16
|
+
? "dark"
|
|
17
|
+
: "light"
|
|
18
|
+
};
|
|
19
|
+
const worker = navigator.serviceWorker.controller ||
|
|
20
|
+
(await navigator.serviceWorker.ready).active;
|
|
21
|
+
worker?.postMessage(message);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function syncVisibleState() {
|
|
25
|
+
if (document.visibilityState === "visible") void syncActiveState();
|
|
26
|
+
}
|
|
27
|
+
|
|
9
28
|
function init() {
|
|
10
29
|
let elements = document.querySelectorAll("[state-id]");
|
|
11
30
|
initElements(elements);
|
|
12
|
-
|
|
31
|
+
|
|
13
32
|
window.addEventListener("storage", function (e) {
|
|
14
33
|
if (e.key == "state") {
|
|
15
34
|
// Re-initialize only elements that are explicitly listening to storage changes
|
|
@@ -19,20 +38,38 @@ function init() {
|
|
|
19
38
|
initElements(elements);
|
|
20
39
|
}
|
|
21
40
|
});
|
|
22
|
-
|
|
41
|
+
|
|
23
42
|
document.addEventListener("click", function (e) {
|
|
24
|
-
const target = e.target.closest("[state-to]");
|
|
43
|
+
const target = e.target.closest("[state-to], [state]");
|
|
25
44
|
if (target) {
|
|
26
45
|
if (target.closest('[actions*="state"]')) return;
|
|
27
46
|
stateAttributes(target);
|
|
28
47
|
}
|
|
29
48
|
});
|
|
49
|
+
|
|
50
|
+
window.matchMedia("(prefers-color-scheme: dark)")
|
|
51
|
+
.addEventListener("change", syncActiveState);
|
|
52
|
+
navigator.serviceWorker?.addEventListener("controllerchange", syncActiveState);
|
|
53
|
+
window.addEventListener("focus", syncVisibleState);
|
|
54
|
+
window.addEventListener("pageshow", syncVisibleState);
|
|
55
|
+
document.addEventListener("visibilitychange", syncVisibleState);
|
|
56
|
+
syncVisibleState();
|
|
30
57
|
}
|
|
31
58
|
|
|
59
|
+
/**
|
|
60
|
+
*
|
|
61
|
+
* @public
|
|
62
|
+
* @environment client
|
|
63
|
+
*/
|
|
32
64
|
function initElements(elements) {
|
|
33
65
|
for (let element of elements) initElement(element);
|
|
34
66
|
}
|
|
35
67
|
|
|
68
|
+
/**
|
|
69
|
+
*
|
|
70
|
+
* @public
|
|
71
|
+
* @environment client
|
|
72
|
+
*/
|
|
36
73
|
function initElement(element) {
|
|
37
74
|
let state_id = element.getAttribute("state-id");
|
|
38
75
|
if (!state_id) return;
|
|
@@ -44,7 +81,7 @@ function initElement(element) {
|
|
|
44
81
|
let statedAttributes = storageObj.getItem("state");
|
|
45
82
|
|
|
46
83
|
if (!statedAttributes || statedAttributes.length == 0) return;
|
|
47
|
-
|
|
84
|
+
|
|
48
85
|
try {
|
|
49
86
|
statedAttributes = JSON.parse(statedAttributes);
|
|
50
87
|
} catch(e) {
|
|
@@ -63,24 +100,18 @@ function initElement(element) {
|
|
|
63
100
|
}
|
|
64
101
|
|
|
65
102
|
function _setAttributeValues(el, attrValues) {
|
|
66
|
-
|
|
67
|
-
if (isOverwrite === null || isOverwrite === undefined)
|
|
68
|
-
isOverwrite = attrValues["overwrite"];
|
|
69
|
-
|
|
70
|
-
if ((isOverwrite && isOverwrite != "false") || isOverwrite === "")
|
|
71
|
-
isOverwrite = true;
|
|
72
|
-
else if (!isOverwrite || isOverwrite === "false") isOverwrite = false;
|
|
73
|
-
|
|
74
|
-
// delete attrValues["overwrite"];
|
|
103
|
+
const isOverwrite = el.getAttribute("state-overwrite") !== "false";
|
|
75
104
|
|
|
76
105
|
for (const key of Object.keys(attrValues)) {
|
|
77
106
|
_setAttributeValue(el, key, attrValues[key], isOverwrite);
|
|
78
107
|
}
|
|
79
108
|
}
|
|
80
109
|
|
|
81
|
-
async function _setAttributeValue(element, attribute, value, isOverwrite) {
|
|
110
|
+
async function _setAttributeValue(element, attribute, value, isOverwrite) {
|
|
82
111
|
if (!element.getAttribute(attribute) || isOverwrite) {
|
|
83
|
-
if (attribute
|
|
112
|
+
if (attribute === "hidden") {
|
|
113
|
+
element.hidden = value !== false && value !== "false" && value !== null;
|
|
114
|
+
} else if (attribute == "value") {
|
|
84
115
|
if (element.value == "" || (element.value && isOverwrite))
|
|
85
116
|
element.value = value;
|
|
86
117
|
else if (
|
|
@@ -104,24 +135,182 @@ async function _setAttributeValue(element, attribute, value, isOverwrite) {
|
|
|
104
135
|
}
|
|
105
136
|
}
|
|
106
137
|
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getStoredState(storageObj) {
|
|
142
|
+
try {
|
|
143
|
+
const state = JSON.parse(storageObj.getItem("state") || "{}");
|
|
144
|
+
return state && typeof state === "object" && !Array.isArray(state)
|
|
145
|
+
? state
|
|
146
|
+
: {};
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return {};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeUnset(value) {
|
|
153
|
+
if (Array.isArray(value))
|
|
154
|
+
return value.map(String).map(key => key.trim()).filter(Boolean);
|
|
155
|
+
if (typeof value === "string")
|
|
156
|
+
return value.split(",").map(key => key.trim()).filter(Boolean);
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function unsetKeys(state, keys) {
|
|
161
|
+
if (!state || typeof state !== "object") return false;
|
|
162
|
+
let changed = false;
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
if (!Object.prototype.hasOwnProperty.call(state, key)) continue;
|
|
165
|
+
delete state[key];
|
|
166
|
+
changed = true;
|
|
167
|
+
}
|
|
168
|
+
return changed;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function unsetElementValues(element, keys) {
|
|
172
|
+
for (const key of keys) {
|
|
173
|
+
if (key.startsWith("$")) {
|
|
174
|
+
const property = key.slice(1);
|
|
175
|
+
if (
|
|
176
|
+
Object.prototype.hasOwnProperty.call(element, property) &&
|
|
177
|
+
typeof element[property] !== "function"
|
|
178
|
+
) {
|
|
179
|
+
try {
|
|
180
|
+
delete element[property];
|
|
181
|
+
} catch (error) {
|
|
182
|
+
element[property] = undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (key === "value") {
|
|
189
|
+
element.setValue("");
|
|
190
|
+
element.removeAttribute("value");
|
|
191
|
+
} else {
|
|
192
|
+
element.removeAttribute(key);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _unsetStateId(keys, stateId) {
|
|
198
|
+
if (!keys.length) return;
|
|
199
|
+
const elements = document.querySelectorAll("[state-id]");
|
|
200
|
+
for (const element of elements) {
|
|
201
|
+
const ids = (element.getAttribute("state-id") || "")
|
|
202
|
+
.split(",")
|
|
203
|
+
.map(id => id.trim())
|
|
204
|
+
.filter(Boolean);
|
|
205
|
+
if (ids.includes(stateId)) unsetElementValues(element, keys);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function mergeState(targetState, state, updates) {
|
|
210
|
+
if (!state || typeof state !== "object" || Array.isArray(state))
|
|
211
|
+
return false;
|
|
212
|
+
|
|
213
|
+
let changed = false;
|
|
214
|
+
for (const [stateId, values] of Object.entries(state)) {
|
|
215
|
+
if (!values || typeof values !== "object" || Array.isArray(values))
|
|
216
|
+
continue;
|
|
217
|
+
|
|
218
|
+
const unset = normalizeUnset(values.$unset);
|
|
219
|
+
const nextValues = { ...values };
|
|
220
|
+
delete nextValues.$unset;
|
|
221
|
+
targetState[stateId] = {
|
|
222
|
+
...targetState[stateId],
|
|
223
|
+
...nextValues
|
|
224
|
+
};
|
|
225
|
+
if (unset.length) {
|
|
226
|
+
unsetKeys(targetState[stateId], unset);
|
|
227
|
+
if (updates) updates.push(() => _unsetStateId(unset, stateId));
|
|
228
|
+
else _unsetStateId(unset, stateId);
|
|
229
|
+
}
|
|
230
|
+
const valuesToApply = { ...targetState[stateId] };
|
|
231
|
+
if (updates) updates.push(() => _getStateId(valuesToApply, stateId));
|
|
232
|
+
else _getStateId(valuesToApply, stateId);
|
|
233
|
+
changed = Object.keys(nextValues).length > 0 || unset.length > 0 || changed;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return changed;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function persistState({
|
|
240
|
+
sessionState,
|
|
241
|
+
localState,
|
|
242
|
+
sessionChanged,
|
|
243
|
+
localChanged,
|
|
244
|
+
title = document.title,
|
|
245
|
+
updateHistory = true
|
|
246
|
+
}) {
|
|
247
|
+
if (sessionChanged)
|
|
248
|
+
sessionStorage.setItem("state", JSON.stringify(sessionState));
|
|
249
|
+
if (localChanged)
|
|
250
|
+
localStorage.setItem("state", JSON.stringify(localState));
|
|
251
|
+
if (sessionChanged) void syncActiveState();
|
|
252
|
+
|
|
253
|
+
if (title) document.title = title;
|
|
254
|
+
if (updateHistory) {
|
|
255
|
+
history.pushState(
|
|
256
|
+
{
|
|
257
|
+
sessionState: JSON.stringify(sessionState),
|
|
258
|
+
localState: JSON.stringify(localState),
|
|
259
|
+
title,
|
|
260
|
+
url: location.href
|
|
261
|
+
},
|
|
262
|
+
title,
|
|
263
|
+
location.href
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
*
|
|
270
|
+
* @public
|
|
271
|
+
* @environment client
|
|
272
|
+
*/
|
|
273
|
+
function setState(state, options = {}) {
|
|
274
|
+
const sessionState = getStoredState(sessionStorage);
|
|
275
|
+
const localState = getStoredState(localStorage);
|
|
276
|
+
const isLocal =
|
|
277
|
+
options.storage === "local" || options.localStorage === true;
|
|
278
|
+
const changed = mergeState(isLocal ? localState : sessionState, state);
|
|
279
|
+
|
|
280
|
+
if (!changed) return false;
|
|
281
|
+
|
|
282
|
+
persistState({
|
|
283
|
+
sessionState,
|
|
284
|
+
localState,
|
|
285
|
+
sessionChanged: !isLocal,
|
|
286
|
+
localChanged: isLocal,
|
|
287
|
+
title: options.title || document.title || ""
|
|
288
|
+
});
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
*
|
|
294
|
+
* @public
|
|
295
|
+
* @environment client
|
|
296
|
+
*/
|
|
297
|
+
async function stateAttributes(element) {
|
|
298
|
+
let sessionState = getStoredState(sessionStorage);
|
|
299
|
+
let localState = getStoredState(localStorage);
|
|
300
|
+
|
|
301
|
+
const updates = [];
|
|
117
302
|
let elements = [];
|
|
118
303
|
|
|
119
|
-
if (element.hasAttribute("state-to")
|
|
120
|
-
|
|
304
|
+
if (element.hasAttribute("state-to") || element.hasAttribute("state"))
|
|
305
|
+
elements.push(element);
|
|
306
|
+
let nestedElements = element.querySelectorAll("[state-to], [state]");
|
|
121
307
|
elements.push(...nestedElements);
|
|
122
308
|
let current = element;
|
|
123
309
|
while (current) {
|
|
124
|
-
if (
|
|
310
|
+
if (
|
|
311
|
+
(current.hasAttribute("state-to") || current.hasAttribute("state")) &&
|
|
312
|
+
!elements.includes(current)
|
|
313
|
+
) {
|
|
125
314
|
elements.push(current);
|
|
126
315
|
}
|
|
127
316
|
current = current.parentElement;
|
|
@@ -133,6 +322,27 @@ async function stateAttributes(element) {
|
|
|
133
322
|
let triggerIsLocal = element.hasAttribute("state-localstorage") || element.hasAttribute("state-onstoragechange");
|
|
134
323
|
|
|
135
324
|
for (let i = 0; i < elements.length; i++) {
|
|
325
|
+
let isLocal =
|
|
326
|
+
triggerIsLocal ||
|
|
327
|
+
elements[i].hasAttribute("state-localstorage") ||
|
|
328
|
+
elements[i].hasAttribute("state-onstoragechange");
|
|
329
|
+
const targetState = isLocal ? localState : sessionState;
|
|
330
|
+
const stateValue = elements[i].getAttribute("state");
|
|
331
|
+
|
|
332
|
+
if (stateValue) {
|
|
333
|
+
try {
|
|
334
|
+
const parsedState = typeof stateValue === "string"
|
|
335
|
+
? JSON.parse(stateValue)
|
|
336
|
+
: stateValue;
|
|
337
|
+
if (mergeState(targetState, parsedState, updates)) {
|
|
338
|
+
if (isLocal) localChanged = true;
|
|
339
|
+
else sessionChanged = true;
|
|
340
|
+
}
|
|
341
|
+
} catch (error) {
|
|
342
|
+
console.warn("Invalid state JSON:", stateValue, error);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
136
346
|
let attrValues = await _getAttributeValues(elements[i]);
|
|
137
347
|
if (attrValues.src === "$back" || attrValues.href === "$back") {
|
|
138
348
|
changeState = true;
|
|
@@ -149,46 +359,51 @@ async function stateAttributes(element) {
|
|
|
149
359
|
|
|
150
360
|
let state_to = elements[i].getAttribute("state-to");
|
|
151
361
|
if (!state_to) continue;
|
|
152
|
-
|
|
153
|
-
let isLocal = triggerIsLocal || elements[i].hasAttribute("state-localstorage") || elements[i].hasAttribute("state-onstoragechange");
|
|
154
|
-
|
|
155
|
-
// Parse state-to string into an array of trimmed individual target keys
|
|
156
|
-
let stateToKeys = state_to.split(',').map(s => s.trim()).filter(Boolean);
|
|
157
362
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
363
|
+
// Parse state-to string into an array of trimmed individual target keys
|
|
364
|
+
let stateToKeys = state_to.split(',').map(s => s.trim()).filter(Boolean);
|
|
365
|
+
|
|
366
|
+
for (let toKey of stateToKeys) {
|
|
367
|
+
// A state-to writer defines the complete state for its target.
|
|
368
|
+
// Replace the prior value so stale attributes and actions do not
|
|
369
|
+
// survive subsequent writes.
|
|
370
|
+
const unset = normalizeUnset(attrValues.$unset);
|
|
371
|
+
const nextValues = { ...attrValues };
|
|
372
|
+
delete nextValues.$unset;
|
|
373
|
+
|
|
374
|
+
if (Object.keys(nextValues).length) {
|
|
375
|
+
updates.push(() => _getStateId(nextValues, toKey));
|
|
376
|
+
targetState[toKey] = nextValues;
|
|
377
|
+
} else if (!targetState[toKey]) {
|
|
378
|
+
targetState[toKey] = {};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (unset.length) {
|
|
382
|
+
unsetKeys(targetState[toKey], unset);
|
|
383
|
+
updates.push(() => _unsetStateId(unset, toKey));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (Object.keys(nextValues).length || unset.length) {
|
|
387
|
+
if (isLocal) localChanged = true;
|
|
388
|
+
else sessionChanged = true;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let href = element.closest("href");
|
|
394
|
+
if (!changeState) {
|
|
395
|
+
const title = element.getAttribute("state-title") || document.title || "";
|
|
396
|
+
persistState({
|
|
397
|
+
sessionState,
|
|
398
|
+
localState,
|
|
399
|
+
sessionChanged,
|
|
400
|
+
localChanged,
|
|
401
|
+
title,
|
|
402
|
+
updateHistory: !href
|
|
403
|
+
});
|
|
404
|
+
// Source observers must see the complete selection in X-State.
|
|
405
|
+
for (const update of updates) update();
|
|
406
|
+
}
|
|
192
407
|
|
|
193
408
|
element.dispatchEvent(
|
|
194
409
|
new CustomEvent("stateEnd", {
|
|
@@ -252,7 +467,7 @@ window.addEventListener("popstate", function (event) {
|
|
|
252
467
|
if (event.state.sessionState || event.state.localState) {
|
|
253
468
|
if (event.state.sessionState) sessionStorage.setItem("state", event.state.sessionState);
|
|
254
469
|
if (event.state.localState) localStorage.setItem("state", event.state.localState);
|
|
255
|
-
|
|
470
|
+
|
|
256
471
|
let elements = document.querySelectorAll("[state-id]");
|
|
257
472
|
initElements(elements);
|
|
258
473
|
}
|
|
@@ -287,4 +502,91 @@ Action.init({
|
|
|
287
502
|
|
|
288
503
|
init();
|
|
289
504
|
|
|
290
|
-
export
|
|
505
|
+
export { setState };
|
|
506
|
+
export default { initElements, initElement, stateAttributes, setState };
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
*
|
|
510
|
+
* @name state
|
|
511
|
+
* @kind member
|
|
512
|
+
* @attribute
|
|
513
|
+
* @environment client
|
|
514
|
+
*/
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
*
|
|
518
|
+
* @name state-id
|
|
519
|
+
* @kind member
|
|
520
|
+
* @attribute
|
|
521
|
+
* @environment client
|
|
522
|
+
*/
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
*
|
|
526
|
+
* @name state-localstorage
|
|
527
|
+
* @kind member
|
|
528
|
+
* @attribute
|
|
529
|
+
* @environment client
|
|
530
|
+
*/
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
*
|
|
534
|
+
* @name state-onstoragechange
|
|
535
|
+
* @kind member
|
|
536
|
+
* @attribute
|
|
537
|
+
* @environment client
|
|
538
|
+
*/
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
*
|
|
542
|
+
* @name state-overwrite
|
|
543
|
+
* @kind member
|
|
544
|
+
* @attribute
|
|
545
|
+
* @environment client
|
|
546
|
+
*/
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
*
|
|
550
|
+
* @name state-title
|
|
551
|
+
* @kind member
|
|
552
|
+
* @attribute
|
|
553
|
+
* @environment client
|
|
554
|
+
*/
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
*
|
|
558
|
+
* @name state-to
|
|
559
|
+
* @kind member
|
|
560
|
+
* @attribute
|
|
561
|
+
* @environment client
|
|
562
|
+
*/
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
*
|
|
566
|
+
* @name value
|
|
567
|
+
* @kind member
|
|
568
|
+
* @attribute
|
|
569
|
+
* @environment client
|
|
570
|
+
*/
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Data attributes which can be used for components in CoCreate framework combined as a group of attributes that dynamically change attributes & fetch custom data.
|
|
574
|
+
*
|
|
575
|
+
* @module state
|
|
576
|
+
* @environment client
|
|
577
|
+
*/
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Declarative selectors recognized by this module.
|
|
581
|
+
*
|
|
582
|
+
* @name declarative-selectors
|
|
583
|
+
* @kind member
|
|
584
|
+
* @selectors
|
|
585
|
+
* @environment client
|
|
586
|
+
* @example
|
|
587
|
+
* ```json
|
|
588
|
+
* [
|
|
589
|
+
* "[state-id]"
|
|
590
|
+
* ]
|
|
591
|
+
* ```
|
|
592
|
+
*/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
const source = fs.readFileSync(path.join(__dirname, '../src/index.js'), 'utf8');
|
|
7
|
+
const start = source.indexOf('async function _setAttributeValue(');
|
|
8
|
+
const end = source.indexOf('\nfunction getStoredState', start);
|
|
9
|
+
const context = vm.createContext({});
|
|
10
|
+
vm.runInContext(source.slice(start, end), context);
|
|
11
|
+
|
|
12
|
+
test('state can show and hide a folder section repeatedly', async () => {
|
|
13
|
+
const element = {
|
|
14
|
+
hidden: true,
|
|
15
|
+
getAttribute(name) { return name === 'hidden' && this.hidden ? '' : null; }
|
|
16
|
+
};
|
|
17
|
+
for (const [value, expected] of [[false, false], [true, true], ['false', false], ['', true]]) {
|
|
18
|
+
await context._setAttributeValue(element, 'hidden', value, true);
|
|
19
|
+
assert.equal(element.hidden, expected);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
const source = fs.readFileSync(path.join(__dirname, '../src/index.js'), 'utf8');
|
|
7
|
+
|
|
8
|
+
function setup() {
|
|
9
|
+
let stored = JSON.stringify({ 'offcanvas-app-form': { array: 'apps', object: 'previous' } });
|
|
10
|
+
const requests = [];
|
|
11
|
+
const warnings = [];
|
|
12
|
+
const updates = [];
|
|
13
|
+
const storage = { getItem: () => stored, setItem: (_, value) => { stored = value; } };
|
|
14
|
+
const context = vm.createContext({
|
|
15
|
+
console: { ...console, warn: (...args) => warnings.push(args) }, sessionStorage: storage,
|
|
16
|
+
localStorage: { getItem: () => '{}', setItem() {} },
|
|
17
|
+
document: { title: 'Apps' }, location: { href: 'http://localhost/apps' },
|
|
18
|
+
history: { pushState() {} }, CustomEvent: class {},
|
|
19
|
+
syncActiveState() {},
|
|
20
|
+
getStoredState: store => JSON.parse(store.getItem('state')),
|
|
21
|
+
normalizeUnset: () => [], unsetKeys() {}, _unsetStateId() {},
|
|
22
|
+
_getAttributeValues: async element => element.values,
|
|
23
|
+
_getStateId(values, target) {
|
|
24
|
+
updates.push({ target, values: JSON.parse(JSON.stringify(values)) });
|
|
25
|
+
if (target === 'offcanvas-app-view') requests.push(JSON.parse(stored)['offcanvas-app-form'].object);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
vm.runInContext(source.slice(source.indexOf('function mergeState('), source.indexOf('function setState(')) +
|
|
29
|
+
source.slice(source.indexOf('async function stateAttributes('), source.indexOf('async function _getAttributeValues(')), context);
|
|
30
|
+
return { apply: context.stateAttributes, requests, warnings, updates, getState: () => JSON.parse(stored) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function element(attributes, values = {}, children = []) {
|
|
34
|
+
return { values, parentElement: null,
|
|
35
|
+
getAttribute: name => attributes[name] ?? null,
|
|
36
|
+
hasAttribute: name => Object.hasOwn(attributes, name),
|
|
37
|
+
querySelectorAll: () => children, closest: () => null, dispatchEvent() {} };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const format of ['nested attributes', 'JSON', 'evaluated object']) {
|
|
41
|
+
test(`source requests see the newly selected app (${format})`, async () => {
|
|
42
|
+
const { apply, requests } = setup();
|
|
43
|
+
for (const id of ['first-app', 'second-app']) {
|
|
44
|
+
const view = { src: '/apps/apps/aside.html' };
|
|
45
|
+
const record = { array: 'apps', object: id };
|
|
46
|
+
const state = { 'offcanvas-app-view': view, 'offcanvas-app-form': record };
|
|
47
|
+
const trigger = format !== 'nested attributes'
|
|
48
|
+
? element({ state: format === 'JSON' ? JSON.stringify(state) : state })
|
|
49
|
+
: element({ 'state-to': 'offcanvas-app-view' }, view,
|
|
50
|
+
[element({ 'state-to': 'offcanvas-app-form' }, record)]);
|
|
51
|
+
await apply(trigger);
|
|
52
|
+
}
|
|
53
|
+
assert.deepEqual(requests, ['first-app', 'second-app']);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const evaluated of [false, true]) {
|
|
58
|
+
test(`new conversation reset accepts ${evaluated ? 'an AST-evaluated object' : 'JSON text'}`, async () => {
|
|
59
|
+
const { apply, warnings, updates, getState } = setup();
|
|
60
|
+
const state = {
|
|
61
|
+
'ai-conversation-messages': { 'filter-query-value': '', array: '', $renderValue: { object: [] } },
|
|
62
|
+
'ai-queue-conversation-filter': { 'filter-query-value': '' },
|
|
63
|
+
aiChat: { 'conversation-id': '' },
|
|
64
|
+
'ai-chat-conversation-id': { value: '' },
|
|
65
|
+
'ai-chat-model': { value: 'openai:gpt-5.4-nano', array: 'ai-conversations', object: '', realtime: 'false' }
|
|
66
|
+
};
|
|
67
|
+
await apply(element({ state: evaluated ? state : JSON.stringify(state) }));
|
|
68
|
+
assert.deepEqual(warnings, []);
|
|
69
|
+
for (const [id, values] of Object.entries(state)) {
|
|
70
|
+
assert.deepEqual(getState()[id], values);
|
|
71
|
+
assert.deepEqual(updates.find(update => update.target === id)?.values, values);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test('malformed state JSON still warns without changing stored state', async () => {
|
|
77
|
+
const { apply, warnings, updates, getState } = setup();
|
|
78
|
+
const before = getState();
|
|
79
|
+
await apply(element({ state: '{broken' }));
|
|
80
|
+
assert.equal(warnings.length, 1);
|
|
81
|
+
assert.equal(warnings[0][0], 'Invalid state JSON:');
|
|
82
|
+
assert.deepEqual(updates, []);
|
|
83
|
+
assert.deepEqual(getState(), before);
|
|
84
|
+
});
|