@cocreate/state 1.28.0 → 1.31.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/CONTRIBUTING.md CHANGED
@@ -13,7 +13,7 @@ Fork the [project on Github](https://github.com/CoCreate-app/CoCreate-state) and
13
13
  ```
14
14
  git state https://github.com/contributor/CoCreate-state.git
15
15
  cd CoCreate-state
16
- git remote add upstream https://github.com/CoCreate-app/CoCreate-state.git
16
+ git remote add upstream https://gitlab.com/cocreatejs/CoCreate-state.git
17
17
  ```
18
18
 
19
19
  ## Create a Topic Branch
package/README.md CHANGED
@@ -1,91 +1,284 @@
1
1
  # CoCreate-state
2
2
 
3
- Data attributes which can be used for components in CoCreate framework combined as a group of attributes that dynamically change attributes & fetch custom data. Take it for a spin in our [playground!](https://cocreate.app/docs/state)
3
+ `@cocreate/state` provides declarative state persistence and state-driven UI updates for CoCreate applications. A source element stores selected values under one or more keys, and elements with matching `state-id` attributes replay those values into their attributes, values, classes, or element methods.
4
4
 
5
- ![minified](https://img.badgesize.io/https://cdn.cocreate.app/state/latest/CoCreate-state.min.js?style=flat-square&label=minified&color=orange)
6
- ![gzip](https://img.badgesize.io/https://cdn.cocreate.app/state/latest/CoCreate-state.min.js?compression=gzip&style=flat-square&label=gzip&color=yellow)
7
- ![brotli](https://img.badgesize.io/https://cdn.cocreate.app/state/latest/CoCreate-state.min.js?compression=brotli&style=flat-square&label=brotli)
8
- ![GitHub latest release](https://img.shields.io/github/v/release/CoCreate-app/CoCreate-state?style=flat-square)
9
- ![License](https://img.shields.io/github/license/CoCreate-app/CoCreate-state?style=flat-square)
10
- ![Hiring](https://img.shields.io/static/v1?style=flat-square&label=&message=Hiring&color=blueviolet)
5
+ [Docs and demo](https://cocreate.app/docs/state) | [Changelog](https://github.com/CoCreate-app/CoCreate-state/blob/master/CHANGELOG.md) | [Issues](https://github.com/CoCreate-app/CoCreate-state/issues)
11
6
 
12
- ![CoCreate-state](https://cdn.cocreate.app/docs/CoCreate-state.gif)
7
+ ## Installation
13
8
 
14
- ## [Docs & Demo](https://cocreate.app/docs/state)
9
+ ### CDN
15
10
 
16
- For a complete guide and working demo refer to the [doumentation](https://cocreate.app/docs/state)
11
+ ```html
12
+ <script src="https://cdn.cocreate.app/state/latest/CoCreate-state.min.js"></script>
13
+ ```
14
+
15
+ ### npm
16
+
17
+ ```bash
18
+ npm install @cocreate/state
19
+ ```
20
+
21
+ ```javascript
22
+ import "@cocreate/state";
23
+ ```
24
+
25
+ ### yarn
26
+
27
+ ```bash
28
+ yarn add @cocreate/state
29
+ ```
30
+
31
+ ## Core model
32
+
33
+ State has two sides:
17
34
 
18
- ## CDN
35
+ - A **writer** has `state-to="key"` and one or more `state.*` attributes.
36
+ - A **reader** has `state-id="key"` and receives the stored values associated with that key.
19
37
 
20
38
  ```html
21
- <script src="https://cdn.cocreate.app/state/latest/CoCreate-state.min.js"></script>
39
+ <button
40
+ state-to="profile"
41
+ state.data-name="Ada Lovelace"
42
+ state.data-role="admin"
43
+ >
44
+ Select profile
45
+ </button>
46
+
47
+ <div state-id="profile" data-name="" data-role="">
48
+ Profile details
49
+ </div>
22
50
  ```
23
51
 
52
+ When the button is activated, the module stores `{ "data-name": "Ada Lovelace", "data-role": "admin" }` under `profile`, then applies those values to the reader. The reader declares the target attributes because ordinary attributes are only assigned when the target already has that attribute. State keys and IDs can be comma-separated and whitespace is trimmed.
53
+
54
+ ## Writer attributes
55
+
56
+ | Attribute | Purpose | Example |
57
+ | --- | --- | --- |
58
+ | `state-to` | One or more comma-separated state keys to write. | `state-to="profile, sidebar"` |
59
+ | `state.*` | Captures an attribute value under the name after `state.`. | `state.name="Ada"` |
60
+ | `state-localstorage` | Writes to local storage instead of session storage. | `state-localstorage` |
61
+ | `state-onstoragechange` | Uses local storage and opts a reader into storage-change reinitialization. | `state-onstoragechange` |
62
+ | `state-title` | Sets the document title used for the history entry. | `state-title="Profile"` |
63
+
64
+ The `state.*` syntax is an attribute namespace. For example, `state.class="selected"` stores a class value under `class`; `state.value=""` reads the writer's current value through `getValue()`.
65
+
66
+ ## Reader attributes
67
+
68
+ | Attribute | Purpose | Example |
69
+ | --- | --- | --- |
70
+ | `state-id` | One or more state keys whose values should be applied to the element. | `state-id="profile"` |
71
+ | `state-overwrite` | Allows overwriting by default; set to `false` to preserve existing values. | `state-overwrite="false"` |
72
+ | `state-localstorage` | Reads from local storage instead of session storage. | `state-localstorage` |
73
+ | `state-onstoragechange` | Reads local storage and allows the reader to refresh after a storage event. | `state-onstoragechange` |
74
+
75
+ The reader applies each stored property according to the following rules:
76
+
77
+ - `value` updates the element's value or calls its CoCreate `setValue()` method.
78
+ - `class` adds the stored class names to the existing class list.
79
+ - An ordinary HTML attribute is set only when the reader already declares that attribute; overwrite controls whether its existing value may be replaced.
80
+ - A key beginning with `$` invokes the matching element method or assigns its property. For example, `$focus` can call `focus()` when the target supports it.
81
+
82
+ ## Session and local storage
83
+
84
+ By default, state is stored in the CoCreate session storage adapter under the single `state` record. Add `state-localstorage` to the writer and reader to use the local storage adapter instead.
85
+
24
86
  ```html
25
- <script src="https://cdn.cocreate.app/state/latest/CoCreate-state.min.css"></script>
87
+ <button
88
+ state-to="theme"
89
+ state-localstorage
90
+ state.mode="dark"
91
+ >
92
+ Dark theme
93
+ </button>
94
+
95
+ <body state-id="theme" state-localstorage>
96
+ ...
97
+ </body>
26
98
  ```
27
99
 
28
- ## NPM
100
+ The storage record is a JSON object keyed by state ID:
29
101
 
30
- ```shell
31
- $ npm i @cocreate/state
102
+ ```json
103
+ {
104
+ "theme": {
105
+ "mode": "dark"
106
+ }
107
+ }
32
108
  ```
33
109
 
34
- ## yarn
110
+ `state-onstoragechange` is also treated as a local-storage marker. Readers with this attribute are reinitialized when a browser `storage` event for the `state` key is received. Storage events normally notify other documents, not the document that performed the write.
111
+
112
+ ## Overwrite behavior
35
113
 
36
- ```shell
37
- $ yarn install @cocreate/state
114
+ State overwrites existing reader values by default. No `state-overwrite` attribute is needed:
115
+
116
+ ```html
117
+ <form state-id="offcanvas-app-form" array="" object=""></form>
118
+ <button state-to="offcanvas-app-form" state.array="apps" state.object="first-id">First app</button>
119
+ <button state-to="offcanvas-app-form" state.array="apps" state.object="second-id">Second app</button>
38
120
  ```
39
121
 
40
- # Table of Contents
122
+ Clicking the buttons in order changes the form's `object` from `first-id` to `second-id`.
41
123
 
42
- - [Table of Contents](#table-of-contents)
43
- - [Announcements](#announcements)
44
- - [Roadmap](#roadmap)
45
- - [How to Contribute](#how-to-contribute)
46
- - [About](#about)
47
- - [License](#license)
124
+ Set `state-overwrite="false"` on the **reader** to preserve its existing nonempty attributes:
48
125
 
49
- <a name="announcements"></a>
126
+ ```html
127
+ <form state-id="initial-app" state-overwrite="false" array="" object=""></form>
128
+ <button state-to="initial-app" state.array="apps" state.object="first-id">First app</button>
129
+ <button state-to="initial-app" state.array="apps" state.object="second-id">Second app</button>
130
+ ```
50
131
 
51
- # Announcements
132
+ The first click fills the empty `array` and `object` attributes. The second click leaves those values unchanged. An attribute that already has a nonempty value in the HTML is preserved from the start.
52
133
 
53
- All updates to this library are documented in our [CHANGELOG](https://github.com/CoCreate-app/CoCreate-state/blob/master/CHANGELOG.md) and [releases](https://github.com/CoCreate-app/CoCreate-state/releases). You may also subscribe to email for releases and breaking changes.
134
+ | Reader attribute | Behavior |
135
+ | --- | --- |
136
+ | Omitted | Overwrite existing values (default). |
137
+ | `state-overwrite` or `state-overwrite=""` | Overwrite existing values. |
138
+ | `state-overwrite="true"` | Overwrite existing values. |
139
+ | `state-overwrite="false"` | Preserve existing nonempty values. |
54
140
 
55
- <a name="roadmap"></a>
141
+ Use the exact value `"false"` to disable overwrite. The stored `overwrite` property does not control this behavior.
56
142
 
57
- # Roadmap
143
+ This is a check of the reader's current value, not a one-time initialization flag. Clearing a declared attribute to `""` allows state to fill it again. Stored state can still change, and other readers can continue receiving updates.
58
144
 
59
- If you are interested in the future direction of this project, please take a look at our open [issues](https://github.com/CoCreate-app/CoCreate-state/issues) and [pull requests](https://github.com/CoCreate-app/CoCreate-state/pulls). We would love to hear your feedback.
145
+ Readers must declare the ordinary attributes they accept, such as `array=""` and `object=""`; enabling overwrite does not add undeclared attributes. Values, classes, and `$` methods follow the rules below.
60
146
 
61
- <a name="about"></a>
147
+ ## Values, classes, and methods
62
148
 
63
- # About
149
+ ### Values
64
150
 
65
- CoCreate-state is guided and supported by the CoCreate Developer Experience Team.
151
+ An empty `state.value` attribute reads the writer's current value asynchronously:
66
152
 
67
- Please Email the Developer Experience Team [here](mailto:develop@cocreate.app) in case of any queries.
153
+ ```html
154
+ <input id="query" value="co">
155
+ <button state-to="search" state.value="">Save search</button>
156
+ <input state-id="search">
157
+ ```
68
158
 
69
- CoCreate-state is maintained and funded by CoCreate. The names and logos for CoCreate are trademarks of CoCreate, LLC.
159
+ If the value is not empty, the literal attribute value is stored. Readers may use native `.value` assignment or `setValue()` depending on the element state and overwrite rules.
70
160
 
71
- <a name="contribute"></a>
161
+ ### Classes
72
162
 
73
- # How to Contribute
163
+ Class values are split on whitespace and added to the reader without removing existing classes:
74
164
 
75
- We encourage contribution to our libraries (you might even score some nifty swag), please see our [CONTRIBUTING](https://github.com/CoCreate-app/CoCreate-state/blob/master/CONTRIBUTING.md) guide for details.
165
+ ```html
166
+ <button state-to="tabs" state.class="active">Current</button>
167
+ <div state-id="tabs" class="tab">Tab</div>
168
+ ```
76
169
 
77
- We want this library to be community-driven, and CoCreate led. We need your help to realize this goal. To help make sure we are building the right things in the right order, we ask that you create [issues](https://github.com/CoCreate-app/CoCreate-state/issues) and [pull requests](https://github.com/CoCreate-app/CoCreate-state/pulls) or merely upvote or comment on existing issues or pull requests.
170
+ State currently adds classes; it does not provide a declarative class-removal operation.
78
171
 
79
- We appreciate your continued support, thank you!
172
+ ### Element methods and properties
80
173
 
81
- <a name="license"></a>
174
+ Keys beginning with `$` are interpreted as element operations:
82
175
 
83
- # License
176
+ ```html
177
+ <button state-to="focus" state.$focus="">Focus input</button>
178
+ <input state-id="focus">
179
+ ```
84
180
 
85
- This software is dual-licensed under the GNU Affero General Public License version 3 (AGPLv3) and a commercial license.
181
+ The target must expose the named property or method. A method receives the stored value as its argument. Unsupported operations are ignored.
182
+
183
+ ## Navigation and browser history
86
184
 
87
- - **Open Source Use**: For open-source projects and non-commercial use, this software is available under the AGPLv3. The AGPLv3 allows you to freely use, modify, and distribute this software, provided that all modifications and derivative works are also licensed under the AGPLv3. For the full license text, see the [LICENSE file](https://github.com/CoCreate-app/CoCreate-state/blob/master/LICENSE).
185
+ State writes create a history entry at the current URL and include serialized session state, local state, title, and URL in the history state object. `state-title` updates `document.title` before the entry is pushed, falling back to the current document title.
88
186
 
89
- - **Commercial Use**: For-profit companies and individuals intending to use this software for commercial purposes must obtain a commercial license. The commercial license is available when you sign up for an API key on our [website](https://cocreate.app). This license permits proprietary use and modification of the software without the copyleft requirements of the AGPLv3. It is ideal for integrating this software into proprietary commercial products and applications.
187
+ The special values `$back` and `$forward` in a stored `src` or `href` request browser history navigation:
188
+
189
+ ```html
190
+ <button state-to="navigation" state.href="$back">Back</button>
191
+ <button state-to="navigation" state.href="$forward">Forward</button>
192
+ ```
193
+
194
+ When either value is used, the module calls `history.back()` or `history.forward()` and does not write a new state record for that activation. On `popstate`, stored session and local state are restored and all `state-id` readers are initialized again.
195
+
196
+ ## Actions integration
197
+
198
+ The module registers a `state` action with `@cocreate/actions`. Action chains can therefore invoke state persistence and wait for the `stateEnd` completion event:
199
+
200
+ ```html
201
+ <button
202
+ actions="state, notify"
203
+ state-to="profile"
204
+ state.name="Ada"
205
+ >
206
+ Save
207
+ </button>
208
+ ```
209
+
210
+ Direct clicks on `[state-to]` elements are handled automatically. If the clicked element is inside an element whose `actions` attribute contains `state`, the document click handler skips the direct call so the action system can own the operation and completion lifecycle.
211
+
212
+ ## JavaScript API
213
+
214
+ The default export exposes the following functions:
215
+
216
+ ```javascript
217
+ import CoCreateState from "@cocreate/state";
218
+
219
+ CoCreateState.initElements(document.querySelectorAll("[state-id]"));
220
+ CoCreateState.initElement(document.querySelector("[state-id]"));
221
+ await CoCreateState.stateAttributes(document.querySelector("[state-to]"));
222
+ ```
223
+
224
+ ### `initElements(elements)`
225
+
226
+ Initializes each reader in an iterable collection. It reads the selected storage record and applies matching state IDs.
227
+
228
+ ### `initElement(element)`
229
+
230
+ Initializes one reader. A comma-separated `state-id` applies values from each matching key in order.
231
+
232
+ ### `stateAttributes(element)`
233
+
234
+ Collects `state.*` attributes from the clicked or action element, writes them under each `state-to` key, updates matching readers immediately, and dispatches `stateEnd` when complete.
235
+
236
+ ## Lifecycle
237
+
238
+ - The module initializes `[state-id]` elements during module load.
239
+ - Added nodes matching `[state-id]` are initialized through `@cocreate/observer`.
240
+ - A document click on the nearest `[state-to]` ancestor triggers a state write unless an enclosing action chain owns `state`.
241
+ - Browser `storage` events refresh readers opted into `state-onstoragechange`.
242
+ - Browser `popstate` restores the state captured in the history entry and reinitializes readers.
243
+
244
+ ## Current limitations and implementation notes
245
+
246
+ These points reflect the current implementation and are important when designing integrations:
247
+
248
+ - State is stored as one JSON object under the `state` key in each storage adapter. State values should therefore remain JSON serializable.
249
+ - `state-localstorage` and `state-onstoragechange` select local storage for both writers and readers. A reader using only session storage will not see a local state key.
250
+ - State writers call `_getStateId()` immediately, so matching readers update before the storage write completes. Storage and reader updates are intentionally coupled.
251
+ - Class state is additive. Writing a new class does not remove a previous state class.
252
+ - Ordinary attribute assignment requires the reader to declare the target attribute. Overwrite is enabled by default; `state-overwrite="false"` preserves nonempty existing attributes.
253
+ - The source checks `element.closest("href")` when deciding whether to push history. `href` is normally an attribute on an anchor rather than an HTML element name, so links should be tested carefully; this is a likely navigation bug and should be reviewed before relying on it.
254
+ - `init()` registers global click, storage, load, and popstate listeners immediately when the module is imported. Applications should avoid loading the module more than once.
255
+ - There is no built-in state deletion or reset attribute. Clearing a key currently requires writing a new state object through the storage adapter or adding an explicit integration.
256
+
257
+ ## Troubleshooting
258
+
259
+ ### A reader does not update
260
+
261
+ Confirm that its `state-id` exactly matches a `state-to` key, including whitespace after comma splitting. Confirm that the reader and writer select the same storage type and that the stored state JSON is valid.
262
+
263
+ ### A value is not replaced
264
+
265
+ Check whether the reader has `state-overwrite="false"`. Remove it to restore the default overwrite behavior. For ordinary attributes, also ensure the reader declares the target attribute.
266
+
267
+ ### Another tab does not refresh
268
+
269
+ Use `state-localstorage` or `state-onstoragechange` on the reader. The browser storage event is cross-document and does not fire in the same document that performed the write.
270
+
271
+ ### The action chain continues too early
272
+
273
+ Use the registered action name `state` and ensure the action element receives the `stateEnd` event. Direct calls to a custom wrapper must await `stateAttributes()` when sequencing depends on completion.
274
+
275
+ ## Contributing
276
+
277
+ Please read the [contributing guide](https://github.com/CoCreate-app/CoCreate-state/blob/master/CONTRIBUTING.md), open an [issue](https://github.com/CoCreate-app/CoCreate-state/issues), or submit a pull request. Changes to storage selection, state shape, attribute assignment, history behavior, and the `stateEnd` event are compatibility-sensitive and should include documentation and examples.
278
+
279
+ ## License
280
+
281
+ This software is dual-licensed under the GNU Affero General Public License version 3 (AGPLv3) and a commercial license.
90
282
 
91
- If you have not purchased a commercial license and intend to use this software for commercial purposes, you are required to sign up for an API key on our website.
283
+ - **Open source use:** For open-source projects and non-commercial use, the software is available under the AGPLv3. See the [LICENSE file](https://github.com/CoCreate-app/CoCreate-state/blob/master/LICENSE).
284
+ - **Commercial use:** For-profit use requires a commercial license from [CoCreate](https://cocreate.app).
@@ -1,14 +1,14 @@
1
- <!DOCTYPE html>
1
+ <!doctype html>
2
2
  <html lang="en">
3
- <body class="padding:5px">
4
- <input state_id="test" state-overwrite />
5
- <a actions="state" state-value="hello" state_to="test"
6
- >Click to state: hello</a
7
- >
8
- <a actions="state" state-value="goodbye" state_to="test"
9
- >Click to state: goodbye</a
10
- >
3
+ <body class="padding:5px">
4
+ <input state_id="test" />
5
+ <a actions="state" state-value="hello" state_to="test"
6
+ >Click to state: hello</a
7
+ >
8
+ <a actions="state" state-value="goodbye" state_to="test"
9
+ >Click to state: goodbye</a
10
+ >
11
11
 
12
- <script src="https://CoCreate.app/dist/CoCreate.js"></script>
13
- </body>
12
+ <script src="https://CoCreate.app/dist/CoCreate.js"></script>
13
+ </body>
14
14
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocreate/state",
3
- "version": "1.28.0",
3
+ "version": "1.31.0",
4
4
  "description": "Data attributes which can be used for components in CoCreate framework combined as a group of attributes that dynamically change attributes & fetch custom data.",
5
5
  "keywords": [
6
6
  "state",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
25
- "url": "git+https://github.com/CoCreate-app/CoCreate-state.git"
25
+ "url": "git+https://gitlab.com/cocreatejs/CoCreate-state.git"
26
26
  },
27
27
  "author": "CoCreate LLC",
28
28
  "license": "AGPL-3.0",
@@ -36,14 +36,14 @@
36
36
  },
37
37
  "main": "./src/index.js",
38
38
  "dependencies": {
39
- "@cocreate/actions": "^1.23.0",
40
- "@cocreate/crud-client": "^1.36.0",
41
- "@cocreate/element-prototype": "^1.34.0",
42
- "@cocreate/local-storage": "^1.18.0",
43
- "@cocreate/observer": "^1.21.0"
39
+ "@cocreate/actions": "^1.24.0",
40
+ "@cocreate/crud-client": "^1.37.0",
41
+ "@cocreate/element-prototype": "^1.35.0",
42
+ "@cocreate/local-storage": "^1.20.0",
43
+ "@cocreate/observer": "^1.23.0"
44
44
  },
45
45
  "devDependencies": {
46
- "@cocreate/webpack": "^1.7.0"
46
+ "@cocreate/webpack": "^1.9.0"
47
47
  },
48
48
  "allowScripts": {
49
49
  "@cocreate/actions@1.21.4": true,
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,14 +38,22 @@ 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
 
32
59
  function initElements(elements) {
@@ -44,7 +71,7 @@ function initElement(element) {
44
71
  let statedAttributes = storageObj.getItem("state");
45
72
 
46
73
  if (!statedAttributes || statedAttributes.length == 0) return;
47
-
74
+
48
75
  try {
49
76
  statedAttributes = JSON.parse(statedAttributes);
50
77
  } catch(e) {
@@ -63,24 +90,18 @@ function initElement(element) {
63
90
  }
64
91
 
65
92
  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"];
93
+ const isOverwrite = el.getAttribute("state-overwrite") !== "false";
75
94
 
76
95
  for (const key of Object.keys(attrValues)) {
77
96
  _setAttributeValue(el, key, attrValues[key], isOverwrite);
78
97
  }
79
98
  }
80
99
 
81
- async function _setAttributeValue(element, attribute, value, isOverwrite) {
100
+ async function _setAttributeValue(element, attribute, value, isOverwrite) {
82
101
  if (!element.getAttribute(attribute) || isOverwrite) {
83
- if (attribute == "value") {
102
+ if (attribute === "hidden") {
103
+ element.hidden = value !== false && value !== "false" && value !== null;
104
+ } else if (attribute == "value") {
84
105
  if (element.value == "" || (element.value && isOverwrite))
85
106
  element.value = value;
86
107
  else if (
@@ -104,24 +125,172 @@ async function _setAttributeValue(element, attribute, value, isOverwrite) {
104
125
  }
105
126
  }
106
127
  }
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
-
128
+ }
129
+ }
130
+
131
+ function getStoredState(storageObj) {
132
+ try {
133
+ const state = JSON.parse(storageObj.getItem("state") || "{}");
134
+ return state && typeof state === "object" && !Array.isArray(state)
135
+ ? state
136
+ : {};
137
+ } catch (error) {
138
+ return {};
139
+ }
140
+ }
141
+
142
+ function normalizeUnset(value) {
143
+ if (Array.isArray(value))
144
+ return value.map(String).map(key => key.trim()).filter(Boolean);
145
+ if (typeof value === "string")
146
+ return value.split(",").map(key => key.trim()).filter(Boolean);
147
+ return [];
148
+ }
149
+
150
+ function unsetKeys(state, keys) {
151
+ if (!state || typeof state !== "object") return false;
152
+ let changed = false;
153
+ for (const key of keys) {
154
+ if (!Object.prototype.hasOwnProperty.call(state, key)) continue;
155
+ delete state[key];
156
+ changed = true;
157
+ }
158
+ return changed;
159
+ }
160
+
161
+ function unsetElementValues(element, keys) {
162
+ for (const key of keys) {
163
+ if (key.startsWith("$")) {
164
+ const property = key.slice(1);
165
+ if (
166
+ Object.prototype.hasOwnProperty.call(element, property) &&
167
+ typeof element[property] !== "function"
168
+ ) {
169
+ try {
170
+ delete element[property];
171
+ } catch (error) {
172
+ element[property] = undefined;
173
+ }
174
+ }
175
+ continue;
176
+ }
177
+
178
+ if (key === "value") {
179
+ element.setValue("");
180
+ element.removeAttribute("value");
181
+ } else {
182
+ element.removeAttribute(key);
183
+ }
184
+ }
185
+ }
186
+
187
+ function _unsetStateId(keys, stateId) {
188
+ if (!keys.length) return;
189
+ const elements = document.querySelectorAll("[state-id]");
190
+ for (const element of elements) {
191
+ const ids = (element.getAttribute("state-id") || "")
192
+ .split(",")
193
+ .map(id => id.trim())
194
+ .filter(Boolean);
195
+ if (ids.includes(stateId)) unsetElementValues(element, keys);
196
+ }
197
+ }
198
+
199
+ function mergeState(targetState, state, updates) {
200
+ if (!state || typeof state !== "object" || Array.isArray(state))
201
+ return false;
202
+
203
+ let changed = false;
204
+ for (const [stateId, values] of Object.entries(state)) {
205
+ if (!values || typeof values !== "object" || Array.isArray(values))
206
+ continue;
207
+
208
+ const unset = normalizeUnset(values.$unset);
209
+ const nextValues = { ...values };
210
+ delete nextValues.$unset;
211
+ targetState[stateId] = {
212
+ ...targetState[stateId],
213
+ ...nextValues
214
+ };
215
+ if (unset.length) {
216
+ unsetKeys(targetState[stateId], unset);
217
+ if (updates) updates.push(() => _unsetStateId(unset, stateId));
218
+ else _unsetStateId(unset, stateId);
219
+ }
220
+ const valuesToApply = { ...targetState[stateId] };
221
+ if (updates) updates.push(() => _getStateId(valuesToApply, stateId));
222
+ else _getStateId(valuesToApply, stateId);
223
+ changed = Object.keys(nextValues).length > 0 || unset.length > 0 || changed;
224
+ }
225
+
226
+ return changed;
227
+ }
228
+
229
+ function persistState({
230
+ sessionState,
231
+ localState,
232
+ sessionChanged,
233
+ localChanged,
234
+ title = document.title,
235
+ updateHistory = true
236
+ }) {
237
+ if (sessionChanged)
238
+ sessionStorage.setItem("state", JSON.stringify(sessionState));
239
+ if (localChanged)
240
+ localStorage.setItem("state", JSON.stringify(localState));
241
+ if (sessionChanged) void syncActiveState();
242
+
243
+ if (title) document.title = title;
244
+ if (updateHistory) {
245
+ history.pushState(
246
+ {
247
+ sessionState: JSON.stringify(sessionState),
248
+ localState: JSON.stringify(localState),
249
+ title,
250
+ url: location.href
251
+ },
252
+ title,
253
+ location.href
254
+ );
255
+ }
256
+ }
257
+
258
+ function setState(state, options = {}) {
259
+ const sessionState = getStoredState(sessionStorage);
260
+ const localState = getStoredState(localStorage);
261
+ const isLocal =
262
+ options.storage === "local" || options.localStorage === true;
263
+ const changed = mergeState(isLocal ? localState : sessionState, state);
264
+
265
+ if (!changed) return false;
266
+
267
+ persistState({
268
+ sessionState,
269
+ localState,
270
+ sessionChanged: !isLocal,
271
+ localChanged: isLocal,
272
+ title: options.title || document.title || ""
273
+ });
274
+ return true;
275
+ }
276
+
277
+ async function stateAttributes(element) {
278
+ let sessionState = getStoredState(sessionStorage);
279
+ let localState = getStoredState(localStorage);
280
+
281
+ const updates = [];
117
282
  let elements = [];
118
283
 
119
- if (element.hasAttribute("state-to")) elements.push(element);
120
- let nestedElements = element.querySelectorAll("[state-to]");
284
+ if (element.hasAttribute("state-to") || element.hasAttribute("state"))
285
+ elements.push(element);
286
+ let nestedElements = element.querySelectorAll("[state-to], [state]");
121
287
  elements.push(...nestedElements);
122
288
  let current = element;
123
289
  while (current) {
124
- if (current.hasAttribute("state-to") && !elements.includes(current)) {
290
+ if (
291
+ (current.hasAttribute("state-to") || current.hasAttribute("state")) &&
292
+ !elements.includes(current)
293
+ ) {
125
294
  elements.push(current);
126
295
  }
127
296
  current = current.parentElement;
@@ -133,6 +302,25 @@ async function stateAttributes(element) {
133
302
  let triggerIsLocal = element.hasAttribute("state-localstorage") || element.hasAttribute("state-onstoragechange");
134
303
 
135
304
  for (let i = 0; i < elements.length; i++) {
305
+ let isLocal =
306
+ triggerIsLocal ||
307
+ elements[i].hasAttribute("state-localstorage") ||
308
+ elements[i].hasAttribute("state-onstoragechange");
309
+ const targetState = isLocal ? localState : sessionState;
310
+ const stateValue = elements[i].getAttribute("state");
311
+
312
+ if (stateValue) {
313
+ try {
314
+ const parsedState = JSON.parse(stateValue);
315
+ if (mergeState(targetState, parsedState, updates)) {
316
+ if (isLocal) localChanged = true;
317
+ else sessionChanged = true;
318
+ }
319
+ } catch (error) {
320
+ console.warn("Invalid state JSON:", stateValue, error);
321
+ }
322
+ }
323
+
136
324
  let attrValues = await _getAttributeValues(elements[i]);
137
325
  if (attrValues.src === "$back" || attrValues.href === "$back") {
138
326
  changeState = true;
@@ -149,46 +337,51 @@ async function stateAttributes(element) {
149
337
 
150
338
  let state_to = elements[i].getAttribute("state-to");
151
339
  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
340
 
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
- }
341
+ // Parse state-to string into an array of trimmed individual target keys
342
+ let stateToKeys = state_to.split(',').map(s => s.trim()).filter(Boolean);
343
+
344
+ for (let toKey of stateToKeys) {
345
+ // A state-to writer defines the complete state for its target.
346
+ // Replace the prior value so stale attributes and actions do not
347
+ // survive subsequent writes.
348
+ const unset = normalizeUnset(attrValues.$unset);
349
+ const nextValues = { ...attrValues };
350
+ delete nextValues.$unset;
351
+
352
+ if (Object.keys(nextValues).length) {
353
+ updates.push(() => _getStateId(nextValues, toKey));
354
+ targetState[toKey] = nextValues;
355
+ } else if (!targetState[toKey]) {
356
+ targetState[toKey] = {};
357
+ }
358
+
359
+ if (unset.length) {
360
+ unsetKeys(targetState[toKey], unset);
361
+ updates.push(() => _unsetStateId(unset, toKey));
362
+ }
363
+
364
+ if (Object.keys(nextValues).length || unset.length) {
365
+ if (isLocal) localChanged = true;
366
+ else sessionChanged = true;
367
+ }
368
+ }
369
+ }
370
+
371
+ let href = element.closest("href");
372
+ if (!changeState) {
373
+ const title = element.getAttribute("state-title") || document.title || "";
374
+ persistState({
375
+ sessionState,
376
+ localState,
377
+ sessionChanged,
378
+ localChanged,
379
+ title,
380
+ updateHistory: !href
381
+ });
382
+ // Source observers must see the complete selection in X-State.
383
+ for (const update of updates) update();
384
+ }
192
385
 
193
386
  element.dispatchEvent(
194
387
  new CustomEvent("stateEnd", {
@@ -252,7 +445,7 @@ window.addEventListener("popstate", function (event) {
252
445
  if (event.state.sessionState || event.state.localState) {
253
446
  if (event.state.sessionState) sessionStorage.setItem("state", event.state.sessionState);
254
447
  if (event.state.localState) localStorage.setItem("state", event.state.localState);
255
-
448
+
256
449
  let elements = document.querySelectorAll("[state-id]");
257
450
  initElements(elements);
258
451
  }
@@ -287,4 +480,5 @@ Action.init({
287
480
 
288
481
  init();
289
482
 
290
- export default { initElements, initElement, stateAttributes };
483
+ export { setState };
484
+ export default { initElements, initElement, stateAttributes, setState };
@@ -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,51 @@
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 storage = { getItem: () => stored, setItem: (_, value) => { stored = value; } };
12
+ const context = vm.createContext({
13
+ console, sessionStorage: storage,
14
+ localStorage: { getItem: () => '{}', setItem() {} },
15
+ document: { title: 'Apps' }, location: { href: 'http://localhost/apps' },
16
+ history: { pushState() {} }, CustomEvent: class {},
17
+ syncActiveState() {},
18
+ getStoredState: store => JSON.parse(store.getItem('state')),
19
+ normalizeUnset: () => [], unsetKeys() {}, _unsetStateId() {},
20
+ _getAttributeValues: async element => element.values,
21
+ _getStateId(values, target) {
22
+ if (target === 'offcanvas-app-view') requests.push(JSON.parse(stored)['offcanvas-app-form'].object);
23
+ }
24
+ });
25
+ vm.runInContext(source.slice(source.indexOf('function mergeState('), source.indexOf('function setState(')) +
26
+ source.slice(source.indexOf('async function stateAttributes('), source.indexOf('async function _getAttributeValues(')), context);
27
+ return { apply: context.stateAttributes, requests };
28
+ }
29
+
30
+ function element(attributes, values = {}, children = []) {
31
+ return { values, parentElement: null,
32
+ getAttribute: name => attributes[name] ?? null,
33
+ hasAttribute: name => Object.hasOwn(attributes, name),
34
+ querySelectorAll: () => children, closest: () => null, dispatchEvent() {} };
35
+ }
36
+
37
+ for (const json of [false, true]) {
38
+ test(`source requests see the newly selected app (${json ? 'JSON' : 'nested attributes'})`, async () => {
39
+ const { apply, requests } = setup();
40
+ for (const id of ['first-app', 'second-app']) {
41
+ const view = { src: '/apps/apps/aside.html' };
42
+ const record = { array: 'apps', object: id };
43
+ const trigger = json
44
+ ? element({ state: JSON.stringify({ 'offcanvas-app-view': view, 'offcanvas-app-form': record }) })
45
+ : element({ 'state-to': 'offcanvas-app-view' }, view,
46
+ [element({ 'state-to': 'offcanvas-app-form' }, record)]);
47
+ await apply(trigger);
48
+ }
49
+ assert.deepEqual(requests, ['first-app', 'second-app']);
50
+ });
51
+ }