stimeo-ui 0.12.0 → 0.13.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.
@@ -51,6 +51,15 @@ function parseISOMonthString(monthStr) {
51
51
  function toISOMonthString(date) {
52
52
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
53
53
  }
54
+ function monthLabelFormatter(locale) {
55
+ const options = { month: "long", year: "numeric" };
56
+ try {
57
+ return new Intl.DateTimeFormat(locale, options);
58
+ } catch (error) {
59
+ if (!(error instanceof RangeError)) throw error;
60
+ return new Intl.DateTimeFormat("en", options);
61
+ }
62
+ }
54
63
 
55
64
  // src/utils/safe_timeout.ts
56
65
  var TimerRegistry = class {
@@ -288,8 +297,7 @@ var CalendarController = class extends Controller {
288
297
  const month = monthStart.getMonth() + 1;
289
298
  if (this.hasLabelTarget) {
290
299
  const lang = document.documentElement.lang || "en";
291
- const formatter = new Intl.DateTimeFormat(lang, { month: "long", year: "numeric" });
292
- this.labelTarget.textContent = formatter.format(monthStart);
300
+ this.labelTarget.textContent = monthLabelFormatter(lang).format(monthStart);
293
301
  }
294
302
  const days = this.#calculateGridDays(year, month);
295
303
  const dayElements = this.dayTargets;
@@ -252,7 +252,7 @@ var CarouselController = class extends Controller {
252
252
  #handledEvents = /* @__PURE__ */ new WeakSet();
253
253
  /**
254
254
  * Whether `connect()` has run. Scheduling is already inert outside that window
255
- * ({@link MicrotaskCoalescer}), so this gates the Value callbacks Stimulus
255
+ * (`MicrotaskCoalescer`), so this gates the Value callbacks Stimulus
256
256
  * delivers ahead of `connect()`.
257
257
  */
258
258
  #connected = false;
@@ -512,7 +512,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
512
512
  * re-deriving from the declarative `open` Value (which would slam a user-opened
513
513
  * palette shut). The `open` Value only seeds the initial state of a genuinely
514
514
  * fresh render. We normalize to a clean closed baseline first so {@link open}
515
- * runs its full setup — the {@link FocusTrap} is a fresh instance after a
515
+ * runs its full setup — the `FocusTrap` is a fresh instance after a
516
516
  * reconnect and must be re-activated.
517
517
  */
518
518
  connect() {
@@ -617,7 +617,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
617
617
  }
618
618
  /**
619
619
  * Combobox navigation keys (arrows / Home / End / Enter), bound to the input.
620
- * `Tab` (focus trap) and `Escape` (close) are owned by the {@link FocusTrap} at
620
+ * `Tab` (focus trap) and `Escape` (close) are owned by the shared `FocusTrap` at
621
621
  * the document level, so they work no matter which element inside the dialog has
622
622
  * focus — not only the input.
623
623
  */
@@ -80,6 +80,15 @@ function parseISOMonthString(monthStr) {
80
80
  function toISOMonthString(date) {
81
81
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
82
82
  }
83
+ function monthLabelFormatter(locale) {
84
+ const options = { month: "long", year: "numeric" };
85
+ try {
86
+ return new Intl.DateTimeFormat(locale, options);
87
+ } catch (error) {
88
+ if (!(error instanceof RangeError)) throw error;
89
+ return new Intl.DateTimeFormat("en", options);
90
+ }
91
+ }
83
92
 
84
93
  // src/utils/microtask_coalescer.ts
85
94
  var MicrotaskCoalescer = class {
@@ -433,7 +442,7 @@ var DateRangePickerController = class extends Controller {
433
442
  if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
434
443
  if (this.hasMonthLabelTarget) {
435
444
  const lang = document.documentElement.lang || "en";
436
- const formatter = monthFormatter(lang);
445
+ const formatter = monthLabelFormatter(lang);
437
446
  this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
438
447
  }
439
448
  const [rangeStart, rangeEnd] = this.#visualRange();
@@ -588,15 +597,6 @@ function normalizeISO(value) {
588
597
  function orderRange(start, end) {
589
598
  return start && end && end < start ? [end, start] : [start, end];
590
599
  }
591
- function monthFormatter(locale) {
592
- const options = { month: "long", year: "numeric" };
593
- try {
594
- return new Intl.DateTimeFormat(locale, options);
595
- } catch (error) {
596
- if (!(error instanceof RangeError)) throw error;
597
- return new Intl.DateTimeFormat("en", options);
598
- }
599
- }
600
600
  function computePreset(name) {
601
601
  const today = /* @__PURE__ */ new Date();
602
602
  const todayStr = toISODateString(today);
@@ -586,7 +586,7 @@ var DrawerController = class extends Controller {
586
586
  * rather than being re-derived from the declarative `open` Value (which would
587
587
  * close a user-opened drawer). The `open` Value only seeds a genuinely fresh
588
588
  * render. We normalize to a clean closed baseline first so {@link open} runs its
589
- * full reveal + trap activation — the {@link FocusTrap} is inactive after a
589
+ * full reveal + trap activation — the `FocusTrap` is inactive after a
590
590
  * disconnect and must be re-activated.
591
591
  */
592
592
  connect() {
@@ -719,7 +719,7 @@ var DrawerController = class extends Controller {
719
719
  * Runs once the close transition has finished: applies `hidden` to the panel
720
720
  * and overlay, then reverts the modal side effects (scroll lock, background
721
721
  * `inert`, keydown listener) and restores focus to the opener. Deferring the
722
- * {@link FocusTrap} teardown to here — rather than at {@link close} time — keeps
722
+ * `FocusTrap` teardown to here — rather than at {@link close} time — keeps
723
723
  * the background unreachable and focus trapped for the whole exit animation.
724
724
  */
725
725
  #applyHidden(panel) {
@@ -1,32 +1,115 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/optimistic_controller.ts
4
- var TOGGLED_MARKER = "data-optimistic-toggled";
4
+
5
+ // src/utils/detach_gate.ts
6
+ var DetachGate = class _DetachGate {
7
+ /** Set while a probe is queued, waiting for a reconnect to cancel it. */
8
+ #pending = false;
9
+ /**
10
+ * True while a probe is queued — the last disconnect was ambiguous and no
11
+ * reconnect has cancelled it yet. Read it from `connect()` to tell the
12
+ * reconnect half of an in-page move from a first connect: a controller whose
13
+ * initialisation restarts a measurement (a min-duration floor, an elapsed
14
+ * counter) must skip it for the move, where nothing actually restarted.
15
+ */
16
+ get pending() {
17
+ return this.#pending;
18
+ }
19
+ /**
20
+ * True when the disconnect is definitely a real detach — the element left
21
+ * the document, or `data-controller` no longer lists the identifier. False
22
+ * means ambiguous (in-page move or observed-root exit), NOT "alive".
23
+ */
24
+ static isDetached(host) {
25
+ if (!host.element.isConnected) return true;
26
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
27
+ return !tokens.includes(host.identifier);
28
+ }
29
+ /**
30
+ * Call from `disconnect()`: runs `teardown` synchronously on a definite
31
+ * detach (fast path), otherwise defers it one microtask — a reconnect
32
+ * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
33
+ * One microtask is the whole probe window: Stimulus reconnects a moved
34
+ * element within the same mutation batch, before the checkpoint drains.
35
+ */
36
+ disconnected(host, teardown) {
37
+ if (_DetachGate.isDetached(host)) {
38
+ this.#pending = false;
39
+ teardown();
40
+ return;
41
+ }
42
+ this.#pending = true;
43
+ queueMicrotask(() => {
44
+ if (!this.#pending) return;
45
+ this.#pending = false;
46
+ teardown();
47
+ });
48
+ }
49
+ /**
50
+ * Disarms a pending probe. Call from `connect()` (the reconnect that proves
51
+ * an in-page move) and from the head of any teardown path not routed through
52
+ * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
53
+ * never run the teardown a second time.
54
+ */
55
+ cancel() {
56
+ this.#pending = false;
57
+ }
58
+ };
59
+
60
+ // src/controllers/optimistic_controller.ts
61
+ var HIDDEN_MARKER = "data-optimistic-toggled";
62
+ var BUSY_MARKER = "data-optimistic-busy";
63
+ var AUTHORED_ABSENT = "absent";
64
+ var AUTHORED_VALUE = "value:";
65
+ var SYNTHETIC_SUBMISSION = {};
66
+ function submissionOf(event) {
67
+ const detail = event.detail;
68
+ return detail?.formSubmission ?? SYNTHETIC_SUBMISSION;
69
+ }
5
70
  var OptimisticController = class extends Controller {
6
71
  static targets = ["show", "hide"];
7
72
  static events = ["commit", "rollback"];
73
+ /** The submission that owns the applied optimistic state, if any. */
74
+ #pending = null;
75
+ #gate = new DetachGate();
76
+ /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */
77
+ #connected = false;
8
78
  connect() {
9
- if (this.element.hasAttribute("data-optimistic")) this.#revert();
79
+ const restored = !this.#connected && !this.#gate.pending;
80
+ this.#gate.cancel();
81
+ this.#connected = true;
82
+ if (restored && this.element.hasAttribute("data-optimistic")) this.#revert();
10
83
  this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
11
84
  this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
12
85
  }
13
86
  disconnect() {
87
+ this.#connected = false;
14
88
  this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
15
89
  this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
90
+ this.#gate.disconnected(this, () => {
91
+ this.#pending = null;
92
+ });
16
93
  }
17
- #onSubmitStart = () => {
94
+ #onSubmitStart = (event) => {
95
+ this.#pending = submissionOf(event);
96
+ if (this.element instanceof HTMLFormElement && !this.element.hasAttribute(BUSY_MARKER)) {
97
+ this.element.setAttribute(BUSY_MARKER, AUTHORED_ABSENT);
98
+ }
99
+ this.#write(this.element, "aria-busy", "true", BUSY_MARKER);
18
100
  this.element.setAttribute("data-optimistic", "true");
19
- this.element.setAttribute("aria-busy", "true");
20
- for (const target of this.showTargets) this.#toggleHidden(target, false);
21
- for (const target of this.hideTargets) this.#toggleHidden(target, true);
101
+ for (const target of this.showTargets) this.#setHidden(target, false);
102
+ for (const target of this.hideTargets) this.#setHidden(target, true);
22
103
  };
23
104
  #onSubmitEnd = (event) => {
105
+ if (submissionOf(event) !== this.#pending) return;
106
+ this.#pending = null;
24
107
  const success = event.detail?.success === true;
25
- this.element.removeAttribute("aria-busy");
26
108
  if (success) {
27
109
  this.element.removeAttribute("data-optimistic");
110
+ this.#restore(this.element, "aria-busy", BUSY_MARKER, "true");
28
111
  for (const target of [...this.showTargets, ...this.hideTargets]) {
29
- target.removeAttribute(TOGGLED_MARKER);
112
+ target.removeAttribute(HIDDEN_MARKER);
30
113
  }
31
114
  this.dispatch("commit");
32
115
  } else {
@@ -34,21 +117,59 @@ var OptimisticController = class extends Controller {
34
117
  this.dispatch("rollback");
35
118
  }
36
119
  };
37
- /** Sets `hidden` only when it actually changes, marker-tracking the mutation. */
38
- #toggleHidden(target, hidden) {
39
- if (target.hidden === hidden) return;
40
- target.hidden = hidden;
41
- target.setAttribute(TOGGLED_MARKER, "true");
120
+ /** Hides or reveals a target, recording the authored `hidden` on first write. */
121
+ #setHidden(target, hidden) {
122
+ if (target.hasAttribute("hidden") === hidden) return;
123
+ this.#write(target, "hidden", hidden ? "" : null, HIDDEN_MARKER);
124
+ }
125
+ /** Writes an attribute, recording what it displaced under `marker` once. */
126
+ #write(element, attribute, value, marker) {
127
+ if (!element.hasAttribute(marker)) {
128
+ const authored = element.getAttribute(attribute);
129
+ element.setAttribute(
130
+ marker,
131
+ authored === null ? AUTHORED_ABSENT : `${AUTHORED_VALUE}${authored}`
132
+ );
133
+ }
134
+ if (value === null) element.removeAttribute(attribute);
135
+ else element.setAttribute(attribute, value);
42
136
  }
43
- /** Reverts exactly the mutations this controller made (marker-owned only). */
137
+ /**
138
+ * Puts back the value `marker` recorded and drops the record.
139
+ *
140
+ * `written` is what this controller last put in the attribute. Anything else
141
+ * there now belongs to whoever wrote it — Turbo owns `aria-busy` on a form and
142
+ * clears it before the terminal arrives, and a consumer may have moved a face
143
+ * mid-flight — so the record is dropped without touching the attribute. A
144
+ * marker this build did not write names no authored value, so the attribute
145
+ * goes back to being absent.
146
+ */
147
+ #restore(element, attribute, marker, written) {
148
+ const recorded = element.getAttribute(marker);
149
+ if (recorded === null) return;
150
+ element.removeAttribute(marker);
151
+ if (element.getAttribute(attribute) !== written) return;
152
+ if (recorded.startsWith(AUTHORED_VALUE)) {
153
+ element.setAttribute(attribute, recorded.slice(AUTHORED_VALUE.length));
154
+ return;
155
+ }
156
+ element.removeAttribute(attribute);
157
+ }
158
+ /**
159
+ * Puts back exactly the markup this controller displaced (record-owned only).
160
+ *
161
+ * A face can be registered on both sides, and the hide pass runs last, so what
162
+ * this controller wrote there is `hidden` — matching it against the reveal's
163
+ * absent value instead would read as someone else's write and leave the face
164
+ * hidden. Each target is therefore restored once, against the write it last
165
+ * received.
166
+ */
44
167
  #revert() {
45
168
  this.element.removeAttribute("data-optimistic");
46
- this.element.removeAttribute("aria-busy");
47
- for (const target of [...this.showTargets, ...this.hideTargets]) {
48
- if (target.hasAttribute(TOGGLED_MARKER)) {
49
- target.hidden = !target.hidden;
50
- target.removeAttribute(TOGGLED_MARKER);
51
- }
169
+ this.#restore(this.element, "aria-busy", BUSY_MARKER, "true");
170
+ const hidden = new Set(this.hideTargets);
171
+ for (const target of /* @__PURE__ */ new Set([...this.showTargets, ...this.hideTargets])) {
172
+ this.#restore(target, "hidden", HIDDEN_MARKER, hidden.has(target) ? "" : null);
52
173
  }
53
174
  }
54
175
  };
@@ -101,7 +101,7 @@ var ToastController = class extends Controller {
101
101
  static events = ["dismiss", "show"];
102
102
  /**
103
103
  * Registry for every auto-dismiss and transition-finalize timer the controller
104
- * schedules. {@link SafeTimeout} owns *registration and teardown only*; the
104
+ * schedules. `SafeTimeout` owns *registration and teardown only*; the
105
105
  * pause/resume remaining-time accounting stays in `#activeTimeouts` so the
106
106
  * per-widget WCAG 2.2.1 semantics are not flattened into the helper.
107
107
  */
data/dist/index.js CHANGED
@@ -1886,6 +1886,15 @@ function parseISOMonthString(monthStr) {
1886
1886
  function toISOMonthString(date) {
1887
1887
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
1888
1888
  }
1889
+ function monthLabelFormatter(locale) {
1890
+ const options = { month: "long", year: "numeric" };
1891
+ try {
1892
+ return new Intl.DateTimeFormat(locale, options);
1893
+ } catch (error) {
1894
+ if (!(error instanceof RangeError)) throw error;
1895
+ return new Intl.DateTimeFormat("en", options);
1896
+ }
1897
+ }
1889
1898
 
1890
1899
  // src/controllers/calendar_controller.ts
1891
1900
  var OWNED_DISABLED = "data-stimeo--calendar-owns-disabled";
@@ -2070,8 +2079,7 @@ var CalendarController = class extends Controller {
2070
2079
  const month = monthStart.getMonth() + 1;
2071
2080
  if (this.hasLabelTarget) {
2072
2081
  const lang = document.documentElement.lang || "en";
2073
- const formatter = new Intl.DateTimeFormat(lang, { month: "long", year: "numeric" });
2074
- this.labelTarget.textContent = formatter.format(monthStart);
2082
+ this.labelTarget.textContent = monthLabelFormatter(lang).format(monthStart);
2075
2083
  }
2076
2084
  const days = this.#calculateGridDays(year, month);
2077
2085
  const dayElements = this.dayTargets;
@@ -2323,7 +2331,7 @@ var CarouselController = class extends Controller {
2323
2331
  #handledEvents = /* @__PURE__ */ new WeakSet();
2324
2332
  /**
2325
2333
  * Whether `connect()` has run. Scheduling is already inert outside that window
2326
- * ({@link MicrotaskCoalescer}), so this gates the Value callbacks Stimulus
2334
+ * (`MicrotaskCoalescer`), so this gates the Value callbacks Stimulus
2327
2335
  * delivers ahead of `connect()`.
2328
2336
  */
2329
2337
  #connected = false;
@@ -4383,7 +4391,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
4383
4391
  * re-deriving from the declarative `open` Value (which would slam a user-opened
4384
4392
  * palette shut). The `open` Value only seeds the initial state of a genuinely
4385
4393
  * fresh render. We normalize to a clean closed baseline first so {@link open}
4386
- * runs its full setup — the {@link FocusTrap} is a fresh instance after a
4394
+ * runs its full setup — the `FocusTrap` is a fresh instance after a
4387
4395
  * reconnect and must be re-activated.
4388
4396
  */
4389
4397
  connect() {
@@ -4488,7 +4496,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
4488
4496
  }
4489
4497
  /**
4490
4498
  * Combobox navigation keys (arrows / Home / End / Enter), bound to the input.
4491
- * `Tab` (focus trap) and `Escape` (close) are owned by the {@link FocusTrap} at
4499
+ * `Tab` (focus trap) and `Escape` (close) are owned by the shared `FocusTrap` at
4492
4500
  * the document level, so they work no matter which element inside the dialog has
4493
4501
  * focus — not only the input.
4494
4502
  */
@@ -6566,7 +6574,7 @@ var DateRangePickerController = class extends Controller {
6566
6574
  if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
6567
6575
  if (this.hasMonthLabelTarget) {
6568
6576
  const lang = document.documentElement.lang || "en";
6569
- const formatter = monthFormatter(lang);
6577
+ const formatter = monthLabelFormatter(lang);
6570
6578
  this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
6571
6579
  }
6572
6580
  const [rangeStart, rangeEnd] = this.#visualRange();
@@ -6721,15 +6729,6 @@ function normalizeISO(value) {
6721
6729
  function orderRange(start, end) {
6722
6730
  return start && end && end < start ? [end, start] : [start, end];
6723
6731
  }
6724
- function monthFormatter(locale) {
6725
- const options = { month: "long", year: "numeric" };
6726
- try {
6727
- return new Intl.DateTimeFormat(locale, options);
6728
- } catch (error) {
6729
- if (!(error instanceof RangeError)) throw error;
6730
- return new Intl.DateTimeFormat("en", options);
6731
- }
6732
- }
6733
6732
  function computePreset(name) {
6734
6733
  const today = /* @__PURE__ */ new Date();
6735
6734
  const todayStr = toISODateString(today);
@@ -7411,7 +7410,7 @@ var DrawerController = class extends Controller {
7411
7410
  * rather than being re-derived from the declarative `open` Value (which would
7412
7411
  * close a user-opened drawer). The `open` Value only seeds a genuinely fresh
7413
7412
  * render. We normalize to a clean closed baseline first so {@link open} runs its
7414
- * full reveal + trap activation — the {@link FocusTrap} is inactive after a
7413
+ * full reveal + trap activation — the `FocusTrap` is inactive after a
7415
7414
  * disconnect and must be re-activated.
7416
7415
  */
7417
7416
  connect() {
@@ -7544,7 +7543,7 @@ var DrawerController = class extends Controller {
7544
7543
  * Runs once the close transition has finished: applies `hidden` to the panel
7545
7544
  * and overlay, then reverts the modal side effects (scroll lock, background
7546
7545
  * `inert`, keydown listener) and restores focus to the opener. Deferring the
7547
- * {@link FocusTrap} teardown to here — rather than at {@link close} time — keeps
7546
+ * `FocusTrap` teardown to here — rather than at {@link close} time — keeps
7548
7547
  * the background unreachable and focus trapped for the whole exit animation.
7549
7548
  */
7550
7549
  #applyHidden(panel) {
@@ -14221,32 +14220,58 @@ var NumberInputController = class _NumberInputController extends Controller {
14221
14220
  this.#ariaValueMax.returnAll();
14222
14221
  }
14223
14222
  };
14224
- var TOGGLED_MARKER = "data-optimistic-toggled";
14223
+ var HIDDEN_MARKER = "data-optimistic-toggled";
14224
+ var BUSY_MARKER = "data-optimistic-busy";
14225
+ var AUTHORED_ABSENT = "absent";
14226
+ var AUTHORED_VALUE = "value:";
14227
+ var SYNTHETIC_SUBMISSION = {};
14228
+ function submissionOf(event) {
14229
+ const detail = event.detail;
14230
+ return detail?.formSubmission ?? SYNTHETIC_SUBMISSION;
14231
+ }
14225
14232
  var OptimisticController = class extends Controller {
14226
14233
  static targets = ["show", "hide"];
14227
14234
  static events = ["commit", "rollback"];
14235
+ /** The submission that owns the applied optimistic state, if any. */
14236
+ #pending = null;
14237
+ #gate = new DetachGate();
14238
+ /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */
14239
+ #connected = false;
14228
14240
  connect() {
14229
- if (this.element.hasAttribute("data-optimistic")) this.#revert();
14241
+ const restored = !this.#connected && !this.#gate.pending;
14242
+ this.#gate.cancel();
14243
+ this.#connected = true;
14244
+ if (restored && this.element.hasAttribute("data-optimistic")) this.#revert();
14230
14245
  this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
14231
14246
  this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
14232
14247
  }
14233
14248
  disconnect() {
14249
+ this.#connected = false;
14234
14250
  this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
14235
14251
  this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
14252
+ this.#gate.disconnected(this, () => {
14253
+ this.#pending = null;
14254
+ });
14236
14255
  }
14237
- #onSubmitStart = () => {
14256
+ #onSubmitStart = (event) => {
14257
+ this.#pending = submissionOf(event);
14258
+ if (this.element instanceof HTMLFormElement && !this.element.hasAttribute(BUSY_MARKER)) {
14259
+ this.element.setAttribute(BUSY_MARKER, AUTHORED_ABSENT);
14260
+ }
14261
+ this.#write(this.element, "aria-busy", "true", BUSY_MARKER);
14238
14262
  this.element.setAttribute("data-optimistic", "true");
14239
- this.element.setAttribute("aria-busy", "true");
14240
- for (const target of this.showTargets) this.#toggleHidden(target, false);
14241
- for (const target of this.hideTargets) this.#toggleHidden(target, true);
14263
+ for (const target of this.showTargets) this.#setHidden(target, false);
14264
+ for (const target of this.hideTargets) this.#setHidden(target, true);
14242
14265
  };
14243
14266
  #onSubmitEnd = (event) => {
14267
+ if (submissionOf(event) !== this.#pending) return;
14268
+ this.#pending = null;
14244
14269
  const success = event.detail?.success === true;
14245
- this.element.removeAttribute("aria-busy");
14246
14270
  if (success) {
14247
14271
  this.element.removeAttribute("data-optimistic");
14272
+ this.#restore(this.element, "aria-busy", BUSY_MARKER, "true");
14248
14273
  for (const target of [...this.showTargets, ...this.hideTargets]) {
14249
- target.removeAttribute(TOGGLED_MARKER);
14274
+ target.removeAttribute(HIDDEN_MARKER);
14250
14275
  }
14251
14276
  this.dispatch("commit");
14252
14277
  } else {
@@ -14254,21 +14279,59 @@ var OptimisticController = class extends Controller {
14254
14279
  this.dispatch("rollback");
14255
14280
  }
14256
14281
  };
14257
- /** Sets `hidden` only when it actually changes, marker-tracking the mutation. */
14258
- #toggleHidden(target, hidden) {
14259
- if (target.hidden === hidden) return;
14260
- target.hidden = hidden;
14261
- target.setAttribute(TOGGLED_MARKER, "true");
14282
+ /** Hides or reveals a target, recording the authored `hidden` on first write. */
14283
+ #setHidden(target, hidden) {
14284
+ if (target.hasAttribute("hidden") === hidden) return;
14285
+ this.#write(target, "hidden", hidden ? "" : null, HIDDEN_MARKER);
14286
+ }
14287
+ /** Writes an attribute, recording what it displaced under `marker` once. */
14288
+ #write(element, attribute, value, marker) {
14289
+ if (!element.hasAttribute(marker)) {
14290
+ const authored = element.getAttribute(attribute);
14291
+ element.setAttribute(
14292
+ marker,
14293
+ authored === null ? AUTHORED_ABSENT : `${AUTHORED_VALUE}${authored}`
14294
+ );
14295
+ }
14296
+ if (value === null) element.removeAttribute(attribute);
14297
+ else element.setAttribute(attribute, value);
14262
14298
  }
14263
- /** Reverts exactly the mutations this controller made (marker-owned only). */
14299
+ /**
14300
+ * Puts back the value `marker` recorded and drops the record.
14301
+ *
14302
+ * `written` is what this controller last put in the attribute. Anything else
14303
+ * there now belongs to whoever wrote it — Turbo owns `aria-busy` on a form and
14304
+ * clears it before the terminal arrives, and a consumer may have moved a face
14305
+ * mid-flight — so the record is dropped without touching the attribute. A
14306
+ * marker this build did not write names no authored value, so the attribute
14307
+ * goes back to being absent.
14308
+ */
14309
+ #restore(element, attribute, marker, written) {
14310
+ const recorded = element.getAttribute(marker);
14311
+ if (recorded === null) return;
14312
+ element.removeAttribute(marker);
14313
+ if (element.getAttribute(attribute) !== written) return;
14314
+ if (recorded.startsWith(AUTHORED_VALUE)) {
14315
+ element.setAttribute(attribute, recorded.slice(AUTHORED_VALUE.length));
14316
+ return;
14317
+ }
14318
+ element.removeAttribute(attribute);
14319
+ }
14320
+ /**
14321
+ * Puts back exactly the markup this controller displaced (record-owned only).
14322
+ *
14323
+ * A face can be registered on both sides, and the hide pass runs last, so what
14324
+ * this controller wrote there is `hidden` — matching it against the reveal's
14325
+ * absent value instead would read as someone else's write and leave the face
14326
+ * hidden. Each target is therefore restored once, against the write it last
14327
+ * received.
14328
+ */
14264
14329
  #revert() {
14265
14330
  this.element.removeAttribute("data-optimistic");
14266
- this.element.removeAttribute("aria-busy");
14267
- for (const target of [...this.showTargets, ...this.hideTargets]) {
14268
- if (target.hasAttribute(TOGGLED_MARKER)) {
14269
- target.hidden = !target.hidden;
14270
- target.removeAttribute(TOGGLED_MARKER);
14271
- }
14331
+ this.#restore(this.element, "aria-busy", BUSY_MARKER, "true");
14332
+ const hidden = new Set(this.hideTargets);
14333
+ for (const target of /* @__PURE__ */ new Set([...this.showTargets, ...this.hideTargets])) {
14334
+ this.#restore(target, "hidden", HIDDEN_MARKER, hidden.has(target) ? "" : null);
14272
14335
  }
14273
14336
  }
14274
14337
  };
@@ -23635,7 +23698,7 @@ var ToastController = class extends Controller {
23635
23698
  static events = ["dismiss", "show"];
23636
23699
  /**
23637
23700
  * Registry for every auto-dismiss and transition-finalize timer the controller
23638
- * schedules. {@link SafeTimeout} owns *registration and teardown only*; the
23701
+ * schedules. `SafeTimeout` owns *registration and teardown only*; the
23639
23702
  * pause/resume remaining-time accounting stays in `#activeTimeouts` so the
23640
23703
  * per-widget WCAG 2.2.1 semantics are not flattened into the helper.
23641
23704
  */
@@ -5,6 +5,6 @@ module Stimeo
5
5
  # Kept in lockstep with package.json (stable versions share the same
6
6
  # notation; a prerelease maps npm `-beta.N` ⇔ gem `.pre.beta.N` because
7
7
  # RubyGems forbids dashes). Bump both together.
8
- VERSION = "0.12.0"
8
+ VERSION = "0.13.0"
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stimeo-ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stimeo Labs
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-06 00:00:00.000000000 Z
11
+ date: 2026-09-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties