@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/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
- let isOverwrite = el.getAttribute("state-overwrite");
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 == "value") {
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
- async function stateAttributes(element) {
111
- let sessionState = {};
112
- let localState = {};
113
-
114
- try { sessionState = JSON.parse(sessionStorage.getItem("state") || "{}"); } catch(e) {}
115
- try { localState = JSON.parse(localStorage.getItem("state") || "{}"); } catch(e) {}
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")) elements.push(element);
120
- let nestedElements = element.querySelectorAll("[state-to]");
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 (current.hasAttribute("state-to") && !elements.includes(current)) {
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
- for (let toKey of stateToKeys) {
159
- if (isLocal) {
160
- Object.assign(localState, { [`${toKey}`]: attrValues });
161
- localChanged = true;
162
- } else {
163
- Object.assign(sessionState, { [`${toKey}`]: attrValues });
164
- sessionChanged = true;
165
- }
166
-
167
- _getStateId(attrValues, toKey);
168
- }
169
- }
170
-
171
- let href = element.closest("href");
172
- if (!changeState && !href) {
173
- // --- IMPROVEMENT: Explicitly support [state-title] only, with document.title fallback (No native 'title' fallback) ---
174
- let title = element.getAttribute("state-title") || document.title || "";
175
-
176
- // --- IMPROVEMENT: Update document.title immediately on click/action ---
177
- if (title) {
178
- document.title = title;
179
- }
180
-
181
- history.pushState(
182
- { sessionState: JSON.stringify(sessionState), localState: JSON.stringify(localState), title, url: location.href },
183
- title,
184
- location.href
185
- );
186
- }
187
-
188
- if (!changeState) {
189
- if (sessionChanged) sessionStorage.setItem("state", JSON.stringify(sessionState));
190
- if (localChanged) localStorage.setItem("state", JSON.stringify(localState));
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 default { initElements, initElement, stateAttributes };
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
+ });