stimeo-ui 0.5.0 → 0.7.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.
Files changed (230) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/dist/controllers/alert_dialog_controller.d.ts +2 -0
  3. package/dist/controllers/alert_dialog_controller.js +75 -4
  4. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  5. package/dist/controllers/aspect_ratio_controller.d.ts +1 -3
  6. package/dist/controllers/aspect_ratio_controller.js +19 -11
  7. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  8. package/dist/controllers/auto_submit_controller.d.ts +2 -0
  9. package/dist/controllers/auto_submit_controller.js.map +1 -1
  10. package/dist/controllers/avatar_controller.d.ts +36 -15
  11. package/dist/controllers/avatar_controller.js +237 -40
  12. package/dist/controllers/avatar_controller.js.map +1 -1
  13. package/dist/controllers/breadcrumb_controller.d.ts +2 -0
  14. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  15. package/dist/controllers/bulk_select_controller.d.ts +2 -0
  16. package/dist/controllers/bulk_select_controller.js.map +1 -1
  17. package/dist/controllers/calendar_controller.d.ts +2 -0
  18. package/dist/controllers/calendar_controller.js.map +1 -1
  19. package/dist/controllers/carousel_controller.d.ts +12 -3
  20. package/dist/controllers/carousel_controller.js +85 -9
  21. package/dist/controllers/carousel_controller.js.map +1 -1
  22. package/dist/controllers/character_counter_controller.d.ts +52 -20
  23. package/dist/controllers/character_counter_controller.js +338 -63
  24. package/dist/controllers/character_counter_controller.js.map +1 -1
  25. package/dist/controllers/checkbox_controller.d.ts +34 -5
  26. package/dist/controllers/checkbox_controller.js +136 -25
  27. package/dist/controllers/checkbox_controller.js.map +1 -1
  28. package/dist/controllers/color_picker_controller.d.ts +10 -1
  29. package/dist/controllers/color_picker_controller.js +35 -9
  30. package/dist/controllers/color_picker_controller.js.map +1 -1
  31. package/dist/controllers/combobox_controller.d.ts +2 -0
  32. package/dist/controllers/combobox_controller.js.map +1 -1
  33. package/dist/controllers/command_palette_controller.d.ts +2 -0
  34. package/dist/controllers/command_palette_controller.js +75 -4
  35. package/dist/controllers/command_palette_controller.js.map +1 -1
  36. package/dist/controllers/conditional_fields_controller.d.ts +42 -14
  37. package/dist/controllers/conditional_fields_controller.js +345 -51
  38. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  39. package/dist/controllers/confirm_controller.d.ts +2 -0
  40. package/dist/controllers/confirm_controller.js +75 -4
  41. package/dist/controllers/confirm_controller.js.map +1 -1
  42. package/dist/controllers/count_up_controller.d.ts +2 -0
  43. package/dist/controllers/count_up_controller.js.map +1 -1
  44. package/dist/controllers/countdown_controller.d.ts +4 -0
  45. package/dist/controllers/countdown_controller.js.map +1 -1
  46. package/dist/controllers/currency_input_controller.d.ts +2 -0
  47. package/dist/controllers/currency_input_controller.js.map +1 -1
  48. package/dist/controllers/data_grid_controller.d.ts +3 -0
  49. package/dist/controllers/data_grid_controller.js.map +1 -1
  50. package/dist/controllers/date_range_picker_controller.d.ts +23 -4
  51. package/dist/controllers/date_range_picker_controller.js +157 -30
  52. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  53. package/dist/controllers/dialog_controller.js +75 -4
  54. package/dist/controllers/dialog_controller.js.map +1 -1
  55. package/dist/controllers/direct_upload_controller.d.ts +56 -24
  56. package/dist/controllers/direct_upload_controller.js +201 -45
  57. package/dist/controllers/direct_upload_controller.js.map +1 -1
  58. package/dist/controllers/dirty_form_controller.d.ts +14 -6
  59. package/dist/controllers/dirty_form_controller.js +192 -29
  60. package/dist/controllers/dirty_form_controller.js.map +1 -1
  61. package/dist/controllers/dismissible_controller.d.ts +2 -0
  62. package/dist/controllers/dismissible_controller.js +83 -18
  63. package/dist/controllers/dismissible_controller.js.map +1 -1
  64. package/dist/controllers/drawer_controller.js +75 -4
  65. package/dist/controllers/drawer_controller.js.map +1 -1
  66. package/dist/controllers/empty_state_controller.d.ts +2 -0
  67. package/dist/controllers/empty_state_controller.js.map +1 -1
  68. package/dist/controllers/file_dropzone_controller.d.ts +9 -1
  69. package/dist/controllers/file_dropzone_controller.js +26 -3
  70. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  71. package/dist/controllers/filter_controller.d.ts +2 -0
  72. package/dist/controllers/filter_controller.js.map +1 -1
  73. package/dist/controllers/flash_controller.d.ts +4 -0
  74. package/dist/controllers/flash_controller.js.map +1 -1
  75. package/dist/controllers/focus_controller.d.ts +4 -3
  76. package/dist/controllers/focus_controller.js +75 -4
  77. package/dist/controllers/focus_controller.js.map +1 -1
  78. package/dist/controllers/form_field_controller.d.ts +50 -10
  79. package/dist/controllers/form_field_controller.js +280 -62
  80. package/dist/controllers/form_field_controller.js.map +1 -1
  81. package/dist/controllers/form_validation_controller.d.ts +10 -8
  82. package/dist/controllers/form_validation_controller.js +208 -83
  83. package/dist/controllers/form_validation_controller.js.map +1 -1
  84. package/dist/controllers/frame_loading_controller.d.ts +2 -0
  85. package/dist/controllers/frame_loading_controller.js.map +1 -1
  86. package/dist/controllers/highlight_controller.d.ts +2 -0
  87. package/dist/controllers/highlight_controller.js.map +1 -1
  88. package/dist/controllers/hover_card_controller.d.ts +2 -2
  89. package/dist/controllers/hover_card_controller.js.map +1 -1
  90. package/dist/controllers/idle_controller.d.ts +5 -3
  91. package/dist/controllers/idle_controller.js +27 -5
  92. package/dist/controllers/idle_controller.js.map +1 -1
  93. package/dist/controllers/input_mask_controller.d.ts +2 -0
  94. package/dist/controllers/input_mask_controller.js.map +1 -1
  95. package/dist/controllers/lazy_frame_controller.d.ts +2 -0
  96. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  97. package/dist/controllers/listbox_controller.d.ts +2 -0
  98. package/dist/controllers/listbox_controller.js.map +1 -1
  99. package/dist/controllers/local_time_controller.d.ts +2 -0
  100. package/dist/controllers/local_time_controller.js.map +1 -1
  101. package/dist/controllers/masonry_controller.d.ts +2 -0
  102. package/dist/controllers/masonry_controller.js.map +1 -1
  103. package/dist/controllers/menubar_controller.js +5 -3
  104. package/dist/controllers/menubar_controller.js.map +1 -1
  105. package/dist/controllers/meter_controller.d.ts +2 -0
  106. package/dist/controllers/meter_controller.js.map +1 -1
  107. package/dist/controllers/multi_select_controller.d.ts +48 -12
  108. package/dist/controllers/multi_select_controller.js +460 -151
  109. package/dist/controllers/multi_select_controller.js.map +1 -1
  110. package/dist/controllers/nested_form_controller.d.ts +2 -0
  111. package/dist/controllers/nested_form_controller.js.map +1 -1
  112. package/dist/controllers/network_status_controller.d.ts +2 -0
  113. package/dist/controllers/network_status_controller.js.map +1 -1
  114. package/dist/controllers/number_input_controller.d.ts +26 -6
  115. package/dist/controllers/number_input_controller.js +317 -51
  116. package/dist/controllers/number_input_controller.js.map +1 -1
  117. package/dist/controllers/otp_controller.d.ts +3 -0
  118. package/dist/controllers/otp_controller.js.map +1 -1
  119. package/dist/controllers/overflow_indicator_controller.d.ts +2 -0
  120. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  121. package/dist/controllers/overflow_menu_controller.d.ts +2 -0
  122. package/dist/controllers/overflow_menu_controller.js +6 -1
  123. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  124. package/dist/controllers/pagination_controller.d.ts +2 -0
  125. package/dist/controllers/pagination_controller.js +35 -1
  126. package/dist/controllers/pagination_controller.js.map +1 -1
  127. package/dist/controllers/password_reveal_controller.d.ts +2 -0
  128. package/dist/controllers/password_reveal_controller.js.map +1 -1
  129. package/dist/controllers/password_strength_controller.d.ts +5 -3
  130. package/dist/controllers/password_strength_controller.js +20 -2
  131. package/dist/controllers/password_strength_controller.js.map +1 -1
  132. package/dist/controllers/persist_controller.d.ts +33 -19
  133. package/dist/controllers/persist_controller.js +449 -120
  134. package/dist/controllers/persist_controller.js.map +1 -1
  135. package/dist/controllers/pointer_drag_controller.d.ts +3 -0
  136. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  137. package/dist/controllers/popover_controller.d.ts +2 -2
  138. package/dist/controllers/popover_controller.js +77 -4
  139. package/dist/controllers/popover_controller.js.map +1 -1
  140. package/dist/controllers/portal_controller.d.ts +3 -2
  141. package/dist/controllers/portal_controller.js.map +1 -1
  142. package/dist/controllers/preview_guard_controller.d.ts +2 -0
  143. package/dist/controllers/preview_guard_controller.js.map +1 -1
  144. package/dist/controllers/progress_controller.d.ts +3 -0
  145. package/dist/controllers/progress_controller.js.map +1 -1
  146. package/dist/controllers/radio_group_controller.d.ts +44 -15
  147. package/dist/controllers/radio_group_controller.js +540 -56
  148. package/dist/controllers/radio_group_controller.js.map +1 -1
  149. package/dist/controllers/rating_controller.d.ts +38 -31
  150. package/dist/controllers/rating_controller.js +276 -89
  151. package/dist/controllers/rating_controller.js.map +1 -1
  152. package/dist/controllers/reading_progress_controller.d.ts +2 -0
  153. package/dist/controllers/reading_progress_controller.js.map +1 -1
  154. package/dist/controllers/resizable_controller.d.ts +2 -0
  155. package/dist/controllers/resizable_controller.js +33 -0
  156. package/dist/controllers/resizable_controller.js.map +1 -1
  157. package/dist/controllers/roving_controller.d.ts +6 -0
  158. package/dist/controllers/roving_controller.js +60 -5
  159. package/dist/controllers/roving_controller.js.map +1 -1
  160. package/dist/controllers/scroll_area_controller.d.ts +25 -11
  161. package/dist/controllers/scroll_area_controller.js +557 -125
  162. package/dist/controllers/scroll_area_controller.js.map +1 -1
  163. package/dist/controllers/scroll_visibility_controller.d.ts +2 -0
  164. package/dist/controllers/scroll_visibility_controller.js +33 -0
  165. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  166. package/dist/controllers/scrollspy_controller.d.ts +2 -0
  167. package/dist/controllers/scrollspy_controller.js.map +1 -1
  168. package/dist/controllers/separator_controller.d.ts +54 -11
  169. package/dist/controllers/separator_controller.js +354 -38
  170. package/dist/controllers/separator_controller.js.map +1 -1
  171. package/dist/controllers/sidebar_controller.js +83 -10
  172. package/dist/controllers/sidebar_controller.js.map +1 -1
  173. package/dist/controllers/skeleton_controller.d.ts +2 -0
  174. package/dist/controllers/skeleton_controller.js.map +1 -1
  175. package/dist/controllers/slider_controller.d.ts +2 -0
  176. package/dist/controllers/slider_controller.js.map +1 -1
  177. package/dist/controllers/smart_sticky_header_controller.d.ts +2 -0
  178. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  179. package/dist/controllers/spinner_controller.d.ts +2 -0
  180. package/dist/controllers/spinner_controller.js.map +1 -1
  181. package/dist/controllers/step_indicator_controller.d.ts +2 -0
  182. package/dist/controllers/step_indicator_controller.js.map +1 -1
  183. package/dist/controllers/stepper_controller.d.ts +2 -0
  184. package/dist/controllers/stepper_controller.js.map +1 -1
  185. package/dist/controllers/stick_to_bottom_controller.d.ts +2 -0
  186. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  187. package/dist/controllers/sticky_observer_controller.d.ts +2 -0
  188. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  189. package/dist/controllers/submit_once_controller.d.ts +94 -38
  190. package/dist/controllers/submit_once_controller.js +399 -121
  191. package/dist/controllers/submit_once_controller.js.map +1 -1
  192. package/dist/controllers/switch_controller.d.ts +2 -0
  193. package/dist/controllers/switch_controller.js.map +1 -1
  194. package/dist/controllers/tags_input_controller.d.ts +43 -11
  195. package/dist/controllers/tags_input_controller.js +356 -120
  196. package/dist/controllers/tags_input_controller.js.map +1 -1
  197. package/dist/controllers/textarea_autosize_controller.d.ts +2 -0
  198. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  199. package/dist/controllers/theme_controller.d.ts +2 -0
  200. package/dist/controllers/theme_controller.js +8 -6
  201. package/dist/controllers/theme_controller.js.map +1 -1
  202. package/dist/controllers/time_picker_controller.d.ts +44 -13
  203. package/dist/controllers/time_picker_controller.js +296 -107
  204. package/dist/controllers/time_picker_controller.js.map +1 -1
  205. package/dist/controllers/toast_controller.d.ts +4 -0
  206. package/dist/controllers/toast_controller.js.map +1 -1
  207. package/dist/controllers/toggle_group_controller.d.ts +41 -13
  208. package/dist/controllers/toggle_group_controller.js +378 -55
  209. package/dist/controllers/toggle_group_controller.js.map +1 -1
  210. package/dist/controllers/toolbar_controller.js +5 -3
  211. package/dist/controllers/toolbar_controller.js.map +1 -1
  212. package/dist/controllers/tooltip_controller.d.ts +2 -2
  213. package/dist/controllers/tooltip_controller.js.map +1 -1
  214. package/dist/controllers/transition_controller.d.ts +4 -3
  215. package/dist/controllers/transition_controller.js.map +1 -1
  216. package/dist/controllers/tree_view_controller.d.ts +2 -0
  217. package/dist/controllers/tree_view_controller.js +7 -4
  218. package/dist/controllers/tree_view_controller.js.map +1 -1
  219. package/dist/index.js +4963 -1581
  220. package/dist/index.js.map +1 -1
  221. package/dist/inspector/cli.d.ts +123 -6
  222. package/dist/inspector/cli.js +212 -18
  223. package/dist/inspector/cli.js.map +1 -1
  224. package/dist/inspector/cli_bin.js +274 -50
  225. package/dist/inspector/cli_bin.js.map +1 -1
  226. package/dist/inspector/examples.json +15 -15
  227. package/dist/inspector/manifest.json +602 -80
  228. package/dist/positioning/index.d.ts +4 -2
  229. package/dist/positioning/index.js.map +1 -1
  230. package/package.json +1 -1
@@ -2,6 +2,64 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/persist_controller.ts
4
4
 
5
+ // src/utils/microtask_coalescer.ts
6
+ var MicrotaskCoalescer = class {
7
+ #run;
8
+ #queued = false;
9
+ #active = false;
10
+ #generation = 0;
11
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
12
+ constructor(run) {
13
+ this.#run = run;
14
+ }
15
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
16
+ activate() {
17
+ this.#active = true;
18
+ }
19
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
20
+ cancel() {
21
+ this.#active = false;
22
+ this.#queued = false;
23
+ this.#generation += 1;
24
+ }
25
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
26
+ schedule() {
27
+ if (!this.#active || this.#queued) return;
28
+ this.#queued = true;
29
+ const generation = this.#generation;
30
+ queueMicrotask(() => {
31
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
32
+ this.#queued = false;
33
+ this.#run();
34
+ });
35
+ }
36
+ };
37
+
38
+ // src/utils/safe_storage.ts
39
+ function readLocalStorage(key) {
40
+ try {
41
+ return { ok: true, value: window.localStorage.getItem(key) };
42
+ } catch (error) {
43
+ return { ok: false, error };
44
+ }
45
+ }
46
+ function writeLocalStorage(key, value) {
47
+ try {
48
+ window.localStorage.setItem(key, value);
49
+ return { ok: true, value: void 0 };
50
+ } catch (error) {
51
+ return { ok: false, error };
52
+ }
53
+ }
54
+ function removeLocalStorage(key) {
55
+ try {
56
+ window.localStorage.removeItem(key);
57
+ return { ok: true, value: void 0 };
58
+ } catch (error) {
59
+ return { ok: false, error };
60
+ }
61
+ }
62
+
5
63
  // src/utils/safe_timeout.ts
6
64
  var TimerRegistry = class {
7
65
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -55,203 +113,474 @@ var SafeTimeout = class extends TimerRegistry {
55
113
  }
56
114
  };
57
115
 
116
+ // src/utils/string_list.ts
117
+ function parseStringList(raw, fallback = []) {
118
+ const text = raw.trim();
119
+ if (text.length === 0) return [...fallback];
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(text);
123
+ } catch {
124
+ return [...fallback];
125
+ }
126
+ if (!Array.isArray(parsed)) return [...fallback];
127
+ return parsed.filter((entry) => typeof entry === "string");
128
+ }
129
+
58
130
  // src/controllers/persist_controller.ts
59
131
  var NON_VALUE_TYPES = /* @__PURE__ */ new Set(["file", "submit", "reset", "button", "image"]);
132
+ var SENSITIVE_TYPES = /* @__PURE__ */ new Set(["password"]);
133
+ var DEFAULT_EXCLUDE = ["authenticity_token", "_method", "utf8"];
60
134
  var STORAGE_PREFIX = "stimeo--persist:";
61
135
  var OCCURRENCE_SEP = "\0";
136
+ var OWNERSHIP_ATTRIBUTE = "data-controller";
137
+ var DEFAULT_DEBOUNCE = 400;
138
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
139
+ var isPersistValue = (value) => typeof value === "string" || typeof value === "boolean" || Array.isArray(value) && value.every((item) => typeof item === "string");
140
+ var parsePayload = (raw) => {
141
+ let parsed;
142
+ try {
143
+ parsed = JSON.parse(raw);
144
+ } catch {
145
+ return null;
146
+ }
147
+ if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.fields)) return null;
148
+ const fields = [];
149
+ const keys = /* @__PURE__ */ new Set();
150
+ for (const candidate of parsed.fields) {
151
+ if (!isRecord(candidate)) return null;
152
+ const { key, value } = candidate;
153
+ if (typeof key !== "string" || key.length === 0 || keys.has(key)) return null;
154
+ if (!isPersistValue(value)) return null;
155
+ keys.add(key);
156
+ fields.push({ key, value });
157
+ }
158
+ return { version: 1, fields };
159
+ };
160
+ var isPersistField = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement;
62
161
  var PersistController = class extends Controller {
63
162
  static targets = ["field"];
64
163
  static values = {
65
164
  key: { type: String, default: "" },
66
- debounce: { type: Number, default: 400 },
67
- exclude: { type: Array, default: ["password"] },
165
+ debounce: { type: Number, default: DEFAULT_DEBOUNCE },
166
+ // A malformed JSON list must fall back without aborting Stimulus connection.
167
+ exclude: { type: String, default: "" },
68
168
  clearOn: { type: String, default: "" }
69
169
  };
70
170
  static actions = ["clear"];
71
- static events = ["restore", "save", "clear"];
171
+ static events = ["restore", "save", "clear", "error"];
72
172
  #timeouts = new SafeTimeout();
73
- #saveId = null;
74
- #onInput = () => {
173
+ #restoreDynamic = new MicrotaskCoalescer(() => this.#restoreDynamicFields());
174
+ #observer = new MutationObserver((records) => this.#onMutations(records));
175
+ #pendingSave = null;
176
+ #connected = false;
177
+ #logicalKey = null;
178
+ #payload = null;
179
+ #registeredClearOn = null;
180
+ #excluded = DEFAULT_EXCLUDE;
181
+ #knownFields = /* @__PURE__ */ new WeakSet();
182
+ #forcedRestore = /* @__PURE__ */ new Set();
183
+ #onInput = (event) => {
184
+ const field = event.target;
185
+ if (!isPersistField(field) || !this.#ownsField(field)) return;
186
+ if (this.hasFieldTarget && !this.fieldTargets.includes(field)) return;
187
+ if (this.#keyOf(field) === null || !this.#persistable(field, this.#excluded)) return;
75
188
  this.#scheduleSave();
76
189
  };
77
190
  #onClearEvent = () => {
78
191
  this.clear();
79
192
  };
80
193
  connect() {
81
- if (this.#storageKey === null) return;
82
- this.#restore();
194
+ if (this.#connected) return;
195
+ this.#connected = true;
196
+ this.#logicalKey = this.#resolveLogicalKey();
197
+ this.#excluded = parseStringList(this.excludeValue, DEFAULT_EXCLUDE);
198
+ this.#knownFields = /* @__PURE__ */ new WeakSet();
199
+ this.#forcedRestore.clear();
200
+ this.element.removeAttribute("data-persist-restored");
83
201
  this.element.addEventListener("input", this.#onInput);
84
202
  this.element.addEventListener("change", this.#onInput);
85
- if (this.clearOnValue.length > 0) {
86
- this.element.addEventListener(this.clearOnValue, this.#onClearEvent);
87
- }
203
+ this.#syncClearOn();
204
+ this.#restoreDynamic.activate();
205
+ this.#observer.observe(this.element, {
206
+ attributes: true,
207
+ attributeFilter: [
208
+ "checked",
209
+ "data-controller",
210
+ "data-stimeo--persist-target",
211
+ "id",
212
+ "multiple",
213
+ "name",
214
+ "selected",
215
+ "type",
216
+ "value"
217
+ ],
218
+ childList: true,
219
+ subtree: true
220
+ });
221
+ this.#loadActiveDraft();
88
222
  }
89
223
  disconnect() {
224
+ this.#flushPendingSave();
225
+ this.#connected = false;
90
226
  this.element.removeEventListener("input", this.#onInput);
91
227
  this.element.removeEventListener("change", this.#onInput);
92
- if (this.clearOnValue.length > 0) {
93
- this.element.removeEventListener(this.clearOnValue, this.#onClearEvent);
94
- }
95
- if (this.#saveId !== null) {
96
- this.#timeouts.clear(this.#saveId);
97
- this.#saveId = null;
98
- this.#write();
99
- }
228
+ this.#unbindClearOn();
229
+ this.#observer.disconnect();
230
+ this.#restoreDynamic.cancel();
231
+ this.#forcedRestore.clear();
100
232
  this.#timeouts.clearAll();
233
+ this.element.removeAttribute("data-persist-restored");
234
+ this.#payload = null;
101
235
  }
102
- /** Drops the saved draft and clears the restored marker. */
236
+ /** Drops the active draft after storage confirms the removal. */
103
237
  clear() {
104
- const key = this.#storageKey;
105
- if (key === null) return;
106
- if (this.#saveId !== null) {
107
- this.#timeouts.clear(this.#saveId);
108
- this.#saveId = null;
238
+ const logicalKey = this.#logicalKey ?? this.#resolveLogicalKey();
239
+ if (logicalKey === null) return;
240
+ this.#cancelPendingSave();
241
+ const result = removeLocalStorage(this.#storageKey(logicalKey));
242
+ if (!result.ok) {
243
+ this.#dispatchError(logicalKey, "remove", "unavailable");
244
+ return;
109
245
  }
110
- this.#removeItem(key);
246
+ this.#payload = null;
111
247
  this.element.removeAttribute("data-persist-restored");
112
- this.dispatch("clear", { detail: { key: this.#logicalKey } });
248
+ this.dispatch("clear", { detail: { key: logicalKey } });
249
+ }
250
+ /** Rebinds the external clear event when its Value changes. */
251
+ clearOnValueChanged() {
252
+ if (this.#connected) this.#syncClearOn();
253
+ }
254
+ /** Switches storage namespaces without writing old edits under the new key. */
255
+ keyValueChanged() {
256
+ if (this.#connected) this.#switchLogicalKey();
257
+ }
258
+ /** Reschedules a pending write against the current debounce delay. */
259
+ debounceValueChanged() {
260
+ if (this.#connected && this.#pendingSave !== null) this.#scheduleSave();
261
+ }
262
+ /** Restores every field the new exclusion list makes eligible. */
263
+ excludeValueChanged() {
264
+ const previous = this.#excluded;
265
+ this.#excluded = parseStringList(this.excludeValue, DEFAULT_EXCLUDE);
266
+ if (!this.#connected) return;
267
+ for (const field of this.#candidateFields()) {
268
+ if (this.#persistable(field, this.#excluded) && !this.#persistable(field, previous)) {
269
+ this.#forcedRestore.add(field);
270
+ }
271
+ }
272
+ this.#restoreDynamic.schedule();
113
273
  }
114
274
  /** Schedules a debounced save. */
115
275
  #scheduleSave() {
116
- if (this.#saveId !== null) this.#timeouts.clear(this.#saveId);
117
- this.#saveId = this.#timeouts.set(() => {
118
- this.#saveId = null;
119
- this.#save();
120
- }, this.debounceValue);
121
- }
122
- /** Writes the current values and emits `save`. */
123
- #save() {
124
- this.#write();
125
- this.dispatch("save", { detail: { key: this.#logicalKey } });
126
- }
127
- /** Serializes persistable fields and stores them under the storage key. */
128
- #write() {
129
- const key = this.#storageKey;
130
- if (key === null) return;
131
- const data = {};
132
- for (const { field, key: fieldKey } of this.#fieldEntries()) {
276
+ const logicalKey = this.#logicalKey;
277
+ if (logicalKey === null) return;
278
+ this.#cancelPendingSave();
279
+ const id = this.#timeouts.set(() => {
280
+ this.#pendingSave = null;
281
+ this.#save(logicalKey);
282
+ }, this.#debounceDelay);
283
+ this.#pendingSave = { id, logicalKey };
284
+ }
285
+ /** Cancels the currently pending save, if any. */
286
+ #cancelPendingSave() {
287
+ const pending = this.#pendingSave;
288
+ if (pending === null) return;
289
+ this.#timeouts.clear(pending.id);
290
+ this.#pendingSave = null;
291
+ }
292
+ /** Flushes a pending edit through the same success/error event path as a timer. */
293
+ #flushPendingSave() {
294
+ const pending = this.#pendingSave;
295
+ if (pending === null) return;
296
+ this.#cancelPendingSave();
297
+ this.#save(pending.logicalKey);
298
+ }
299
+ /** Writes the active payload and emits `save` only after storage succeeds. */
300
+ #save(logicalKey) {
301
+ const payload = this.#serialize();
302
+ const result = writeLocalStorage(this.#storageKey(logicalKey), JSON.stringify(payload));
303
+ if (!result.ok) {
304
+ this.#dispatchError(logicalKey, "write", "unavailable");
305
+ return false;
306
+ }
307
+ this.#payload = payload;
308
+ this.dispatch("save", { detail: { key: logicalKey } });
309
+ return true;
310
+ }
311
+ /** Serializes every persistable field into a versioned, typed payload. */
312
+ #serialize() {
313
+ const fields = [];
314
+ for (const { field, key } of this.#fieldEntries()) {
133
315
  if (field instanceof HTMLInputElement && field.type === "checkbox") {
134
- data[fieldKey] = field.checked;
316
+ fields.push({ key, value: field.checked });
135
317
  } else if (field instanceof HTMLInputElement && field.type === "radio") {
136
- if (field.checked) data[fieldKey] = field.value;
318
+ if (field.checked) fields.push({ key, value: field.value });
137
319
  } else if (field instanceof HTMLSelectElement && field.multiple) {
138
- data[fieldKey] = Array.from(field.selectedOptions).map((o) => o.value);
320
+ fields.push({
321
+ key,
322
+ value: Array.from(field.selectedOptions).map((option) => option.value)
323
+ });
139
324
  } else {
140
- data[fieldKey] = field.value;
325
+ fields.push({ key, value: field.value });
141
326
  }
142
327
  }
143
- this.#setItem(key, JSON.stringify(data));
144
- }
145
- /** Applies any saved values to the fields, without moving focus. */
146
- #restore() {
147
- const key = this.#storageKey;
148
- if (key === null) return;
149
- const raw = this.#getItem(key);
150
- if (raw === null) return;
151
- let data;
152
- try {
153
- data = JSON.parse(raw);
154
- } catch {
328
+ return { version: 1, fields };
329
+ }
330
+ /** Reads, validates, and restores the active namespace. */
331
+ #loadActiveDraft() {
332
+ this.element.removeAttribute("data-persist-restored");
333
+ this.#payload = null;
334
+ this.#knownFields = /* @__PURE__ */ new WeakSet();
335
+ this.#forcedRestore.clear();
336
+ const logicalKey = this.#logicalKey;
337
+ if (logicalKey === null) {
338
+ this.#markCurrentFieldsKnown();
155
339
  return;
156
340
  }
157
- let restoredAny = false;
158
- for (const { field, key: fieldKey } of this.#fieldEntries()) {
159
- if (!Object.hasOwn(data, fieldKey)) continue;
160
- this.#applyValue(field, data[fieldKey]);
161
- restoredAny = true;
341
+ const result = readLocalStorage(this.#storageKey(logicalKey));
342
+ if (!result.ok) {
343
+ this.#dispatchError(logicalKey, "read", "unavailable");
344
+ this.#markCurrentFieldsKnown();
345
+ return;
162
346
  }
163
- if (restoredAny) {
347
+ if (result.value === null) {
348
+ this.#markCurrentFieldsKnown();
349
+ return;
350
+ }
351
+ const payload = parsePayload(result.value);
352
+ if (payload === null) {
353
+ const removal = removeLocalStorage(this.#storageKey(logicalKey));
354
+ this.#dispatchError(logicalKey, "read", "invalid-payload");
355
+ if (!removal.ok) this.#dispatchError(logicalKey, "remove", "unavailable");
356
+ this.#markCurrentFieldsKnown();
357
+ return;
358
+ }
359
+ this.#payload = payload;
360
+ this.#restoreEntries(null);
361
+ }
362
+ /** Restores fields inserted or made eligible after the initial connection. */
363
+ #restoreDynamicFields() {
364
+ const selected = new Set(this.#forcedRestore);
365
+ this.#forcedRestore.clear();
366
+ for (const { field } of this.#fieldEntries()) {
367
+ if (!this.#knownFields.has(field)) selected.add(field);
368
+ }
369
+ this.#restoreEntries(selected);
370
+ }
371
+ /** Applies the active payload to all fields, or only the supplied candidates. */
372
+ #restoreEntries(selected) {
373
+ const entries = this.#fieldEntries();
374
+ for (const { field } of entries) this.#knownFields.add(field);
375
+ const payload = this.#payload;
376
+ if (payload === null) return;
377
+ const savedFields = new Map(payload.fields.map((field) => [field.key, field]));
378
+ const restoredKeys = /* @__PURE__ */ new Set();
379
+ const processedRadios = /* @__PURE__ */ new Set();
380
+ for (const entry of entries) {
381
+ if (selected !== null && !selected.has(entry.field)) continue;
382
+ const saved = savedFields.get(entry.key);
383
+ if (saved === void 0) continue;
384
+ const { value } = saved;
385
+ if (entry.field instanceof HTMLInputElement && entry.field.type === "radio") {
386
+ if (processedRadios.has(entry.key)) continue;
387
+ processedRadios.add(entry.key);
388
+ const radios = entries.filter(
389
+ ({ key, field }) => key === entry.key && field instanceof HTMLInputElement && field.type === "radio"
390
+ ).map(({ field }) => field);
391
+ if (typeof value !== "string" || !radios.some((radio) => radio.value === value)) continue;
392
+ for (const radio of radios) radio.checked = radio.value === value;
393
+ restoredKeys.add(entry.key);
394
+ continue;
395
+ }
396
+ if (this.#applyValue(entry.field, value)) restoredKeys.add(entry.key);
397
+ }
398
+ if (restoredKeys.size > 0) {
399
+ this.#observer.takeRecords();
164
400
  this.element.setAttribute("data-persist-restored", "true");
165
401
  this.dispatch("restore", { detail: { key: this.#logicalKey } });
166
402
  }
167
403
  }
168
- /** Sets a single field's value from a stored entry. */
404
+ /** Applies a type-compatible stored value without coercion. */
169
405
  #applyValue(field, value) {
170
406
  if (field instanceof HTMLInputElement && field.type === "checkbox") {
171
- field.checked = Boolean(value);
172
- } else if (field instanceof HTMLInputElement && field.type === "radio") {
173
- field.checked = field.value === value;
174
- } else if (field instanceof HTMLSelectElement && field.multiple) {
175
- const selected = new Set(Array.isArray(value) ? value.map(String) : []);
407
+ if (typeof value !== "boolean") return false;
408
+ field.checked = value;
409
+ return true;
410
+ }
411
+ if (field instanceof HTMLSelectElement && field.multiple) {
412
+ if (!Array.isArray(value)) return false;
413
+ const available = new Set(Array.from(field.options).map((option) => option.value));
414
+ const selected = value.filter((candidate) => available.has(candidate));
415
+ if (value.length > 0 && selected.length === 0) return false;
416
+ const selectedValues = new Set(selected);
176
417
  for (const option of Array.from(field.options)) {
177
- option.selected = selected.has(option.value);
418
+ option.selected = selectedValues.has(option.value);
178
419
  }
179
- } else {
180
- field.value = String(value);
420
+ return true;
421
+ }
422
+ if (typeof value !== "string") return false;
423
+ if (field instanceof HTMLSelectElement) {
424
+ const exists = Array.from(field.options).some((option) => option.value === value);
425
+ if (!exists) return false;
181
426
  }
427
+ field.value = value;
428
+ return true;
429
+ }
430
+ /** Every control this instance owns, before the exclusion list narrows them. */
431
+ #candidateFields() {
432
+ return this.hasFieldTarget ? this.fieldTargets.filter(isPersistField) : Array.from(this.element.querySelectorAll("input, textarea, select")).filter(
433
+ (field) => this.#ownsField(field)
434
+ );
182
435
  }
183
436
  /**
184
- * Persistable fields paired with a stable storage key. Uniquely-named fields key
185
- * by their name. Repeated same-name fields (e.g. a `tags[]` checkbox group or
186
- * array text inputs) are disambiguated by DOM-order occurrence — the first keeps
187
- * its plain `name`, later ones get a NUL-separated index suffix — so each is
188
- * stored and restored individually instead of the last one clobbering the rest.
189
- * Radios are the exception: a group intentionally shares one key (one value per
190
- * name).
437
+ * Pairs persistable fields with DOM-order occurrence keys. Every HTML radio
438
+ * group (same name and form owner) occupies one shared occurrence slot.
191
439
  */
192
440
  #fieldEntries() {
193
441
  const entries = [];
194
- const occurrence = /* @__PURE__ */ new Map();
195
- for (const field of this.#fields()) {
442
+ const eligible = [];
443
+ for (const field of this.#candidateFields()) {
196
444
  const name = this.#keyOf(field);
197
- if (name === null) continue;
445
+ if (name === null || !this.#persistable(field, this.#excluded)) continue;
446
+ eligible.push({ field, name });
447
+ }
448
+ const nextOccurrence = /* @__PURE__ */ new Map();
449
+ const radioSlots = /* @__PURE__ */ new Map();
450
+ const allocate = (name) => {
451
+ const occurrence = nextOccurrence.get(name) ?? 0;
452
+ nextOccurrence.set(name, occurrence + 1);
453
+ return occurrence === 0 ? name : `${name}${OCCURRENCE_SEP}${occurrence}`;
454
+ };
455
+ for (const { field, name } of eligible) {
198
456
  if (field instanceof HTMLInputElement && field.type === "radio") {
199
- entries.push({ field, key: name });
457
+ const groups = radioSlots.get(name) ?? /* @__PURE__ */ new Map();
458
+ radioSlots.set(name, groups);
459
+ let key = groups.get(field.form);
460
+ if (key === void 0) {
461
+ key = allocate(name);
462
+ groups.set(field.form, key);
463
+ }
464
+ entries.push({ field, key });
200
465
  continue;
201
466
  }
202
- const seen = occurrence.get(name) ?? 0;
203
- occurrence.set(name, seen + 1);
204
- entries.push({ field, key: seen === 0 ? name : `${name}${OCCURRENCE_SEP}${seen}` });
467
+ entries.push({ field, key: allocate(name) });
205
468
  }
206
469
  return entries;
207
470
  }
208
- /** The fields to persist: `field` targets, or the element's named controls. */
209
- #fields() {
210
- const candidates = this.hasFieldTarget ? this.fieldTargets : Array.from(this.element.querySelectorAll("input, textarea, select"));
211
- return candidates.filter((field) => this.#persistable(field));
212
- }
213
471
  /** Whether a field carries a restorable, non-excluded value. */
214
- #persistable(field) {
215
- if (this.#keyOf(field) === null) return false;
216
- const type = field instanceof HTMLInputElement ? field.type : "";
217
- if (NON_VALUE_TYPES.has(type)) return false;
218
- if (this.excludeValue.includes(type)) return false;
219
- if (field.name.length > 0 && this.excludeValue.includes(field.name)) return false;
472
+ #persistable(field, excluded) {
473
+ if (NON_VALUE_TYPES.has(field.type) || SENSITIVE_TYPES.has(field.type)) return false;
474
+ if (excluded.includes(field.type)) return false;
475
+ if (field.name.length > 0 && excluded.includes(field.name)) return false;
220
476
  return true;
221
477
  }
222
478
  /** A stable storage sub-key for a field (its name, else id). */
223
479
  #keyOf(field) {
224
480
  return field.name || field.id || null;
225
481
  }
226
- /** The logical key (key Value or element id), or null when neither is set. */
227
- get #logicalKey() {
228
- const key = this.keyValue || this.element.id;
229
- return key.length > 0 ? key : null;
482
+ /** Whether this instance, rather than a nested Persist host, owns a field. */
483
+ #ownsField(field) {
484
+ return field.closest('[data-controller~="stimeo--persist"]') === this.element;
485
+ }
486
+ /** Marks all currently eligible fields without applying a payload. */
487
+ #markCurrentFieldsKnown() {
488
+ for (const { field } of this.#fieldEntries()) this.#knownFields.add(field);
230
489
  }
231
- /** The prefixed localStorage key, or null when persistence is disabled. */
232
- get #storageKey() {
233
- const logical = this.#logicalKey;
234
- return logical === null ? null : `${STORAGE_PREFIX}${logical}`;
490
+ /** Switches namespace after flushing the old namespace's pending edit. */
491
+ #switchLogicalKey() {
492
+ const next = this.#resolveLogicalKey();
493
+ if (next === this.#logicalKey) return;
494
+ this.#flushPendingSave();
495
+ this.#logicalKey = next;
496
+ this.#loadActiveDraft();
235
497
  }
236
- #getItem(key) {
237
- try {
238
- return window.localStorage.getItem(key);
239
- } catch {
240
- return null;
498
+ /** Synchronizes the exact event name owned by the `clearOn` Value. */
499
+ #syncClearOn() {
500
+ const next = this.#validEventName(this.clearOnValue);
501
+ if (next === this.#registeredClearOn) return;
502
+ this.#unbindClearOn();
503
+ if (next === null) return;
504
+ this.element.addEventListener(next, this.#onClearEvent);
505
+ this.#registeredClearOn = next;
506
+ }
507
+ /** Removes the event listener using the name that was actually registered. */
508
+ #unbindClearOn() {
509
+ if (this.#registeredClearOn === null) return;
510
+ this.element.removeEventListener(this.#registeredClearOn, this.#onClearEvent);
511
+ this.#registeredClearOn = null;
512
+ }
513
+ /** Returns a non-whitespace event type, or null when the hook is disabled. */
514
+ #validEventName(value) {
515
+ const name = value.trim();
516
+ return name.length > 0 && !/\s/.test(name) ? name : null;
517
+ }
518
+ /** Collects dynamic controls and select-option changes into one restore pass. */
519
+ #onMutations(records) {
520
+ let rootIdChanged = false;
521
+ for (const record of records) {
522
+ if (record.type === "attributes") {
523
+ if (record.target === this.element && record.attributeName === "id") {
524
+ rootIdChanged = true;
525
+ }
526
+ this.#collectAttributeCandidate(record.target, record.attributeName);
527
+ continue;
528
+ }
529
+ for (const node of record.addedNodes) this.#collectRestoreCandidate(node);
530
+ }
531
+ if (rootIdChanged && this.keyValue.length === 0) this.#switchLogicalKey();
532
+ this.#restoreDynamic.schedule();
533
+ }
534
+ /**
535
+ * Adds the controls one mutated attribute can affect. Only an ownership change on a
536
+ * descendant moves fields between Persist hosts, so that is the single case worth a
537
+ * subtree sweep; every other observed attribute describes one control, and sweeping
538
+ * from its container would re-apply the stored draft over edits still being debounced.
539
+ */
540
+ #collectAttributeCandidate(target, attributeName) {
541
+ if (attributeName === OWNERSHIP_ATTRIBUTE && target !== this.element) {
542
+ this.#collectRestoreCandidate(target);
543
+ return;
241
544
  }
545
+ this.#collectControlCandidate(target);
242
546
  }
243
- #setItem(key, value) {
244
- try {
245
- window.localStorage.setItem(key, value);
246
- } catch {
547
+ /** Adds a native control, or the select that owns a mutated option. */
548
+ #collectControlCandidate(node) {
549
+ if (isPersistField(node)) {
550
+ this.#forcedRestore.add(node);
551
+ return;
552
+ }
553
+ if (node instanceof HTMLOptionElement) {
554
+ const select = node.closest("select");
555
+ if (select) this.#forcedRestore.add(select);
247
556
  }
248
557
  }
249
- #removeItem(key) {
250
- try {
251
- window.localStorage.removeItem(key);
252
- } catch {
558
+ /** Adds every control inside an inserted or newly owned subtree. */
559
+ #collectRestoreCandidate(node) {
560
+ this.#collectControlCandidate(node);
561
+ if (!(node instanceof Element)) return;
562
+ for (const field of node.querySelectorAll("input, textarea, select")) {
563
+ this.#forcedRestore.add(field);
253
564
  }
254
565
  }
566
+ /** Resolves the logical key from the Value, falling back to the host id. */
567
+ #resolveLogicalKey() {
568
+ const key = this.keyValue || this.element.id;
569
+ return key.length > 0 ? key : null;
570
+ }
571
+ /** Namespaces one logical key within localStorage. */
572
+ #storageKey(logicalKey) {
573
+ return `${STORAGE_PREFIX}${logicalKey}`;
574
+ }
575
+ /** Normalizes invalid debounce Values to the documented default. */
576
+ get #debounceDelay() {
577
+ const value = this.debounceValue;
578
+ return Number.isFinite(value) && value >= 0 ? value : DEFAULT_DEBOUNCE;
579
+ }
580
+ /** Dispatches one observable storage failure without exposing browser errors. */
581
+ #dispatchError(key, operation, reason) {
582
+ this.dispatch("error", { detail: { key, operation, reason } });
583
+ }
255
584
  };
256
585
 
257
586
  export { PersistController };