stimeo-ui 0.14.0 → 0.15.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/dist/cable/index.js +60 -14
- data/dist/controllers/announcer_controller.js +88 -61
- data/dist/controllers/auto_submit_controller.js +40 -5
- data/dist/controllers/breadcrumb_controller.js +29 -3
- data/dist/controllers/calendar_controller.js +20 -3
- data/dist/controllers/checkbox_controller.js +27 -2
- data/dist/controllers/conditional_fields_controller.js +29 -6
- data/dist/controllers/currency_input_controller.js +16 -9
- data/dist/controllers/date_range_picker_controller.js +12 -3
- data/dist/controllers/dirty_form_controller.js +32 -12
- data/dist/controllers/flash_controller.js +145 -43
- data/dist/controllers/frame_loading_controller.js +29 -6
- data/dist/controllers/highlight_controller.js +63 -23
- data/dist/controllers/local_time_controller.js +10 -5
- data/dist/controllers/masonry_controller.js +31 -13
- data/dist/controllers/optimistic_controller.js +28 -4
- data/dist/controllers/overflow_indicator_controller.js +31 -8
- data/dist/controllers/overflow_menu_controller.js +29 -3
- data/dist/controllers/read_more_controller.js +31 -5
- data/dist/controllers/reading_progress_controller.js +65 -19
- data/dist/controllers/relative_time_controller.js +10 -5
- data/dist/controllers/scroll_area_controller.js +70 -26
- data/dist/controllers/scroll_restore_controller.js +37 -16
- data/dist/controllers/scroll_visibility_controller.js +49 -29
- data/dist/controllers/scrollspy_controller.js +65 -26
- data/dist/controllers/smart_sticky_header_controller.js +49 -25
- data/dist/controllers/stick_to_bottom_controller.js +29 -3
- data/dist/controllers/submit_once_controller.js +29 -6
- data/dist/controllers/textarea_autosize_controller.js +29 -3
- data/dist/controllers/toast_controller.js +136 -73
- data/dist/index.js +473 -435
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 401c70da7398b7e246c7858cebc821e6f9b6e6a079b196ad277180187aa1ae9d
|
|
4
|
+
data.tar.gz: ff726d279d0e91491558b60e69bacf76ae8a3d340c7ee5cd94982f8bc00e02d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 70da6b350c9495cfada5f6f17606e11092274160587faf5e62b8b7767e685d8dc4aa63bf096cf62d1f9e1ad6ee9629a7bbbba9eecd0999d061c0baff993648ce
|
|
7
|
+
data.tar.gz: 93f0139a4720f0b00109f0bb8ff3833be11d4780db1a8993effb7624b39f327861c09f3b7b6c0d37f8dbba4c26101aec6d0c81f2c35382f6f0c938655f42704f
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
|
|
8
8
|
change between releases.
|
|
9
9
|
|
|
10
|
+
## [0.15.0] - 2026-09-20
|
|
11
|
+
|
|
12
|
+
Minor release with no new components. Shared internals were consolidated across
|
|
13
|
+
five refactors; toast and the locale-aware components change behaviour, so read
|
|
14
|
+
Changed before upgrading. The Inspector manifest stays on schema v12.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- calendar, date-range-picker: a `locale` Value.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- toast: a toast whose dismiss deadline has already lapsed is no longer removed
|
|
23
|
+
the moment a pointer or focus enters it. It stays, marked `data-paused="true"`,
|
|
24
|
+
and leaves 1ms after the last hold is released — so focus never drops to
|
|
25
|
+
`<body>` from under the close button it was on.
|
|
26
|
+
- calendar, date-range-picker: the month label resolves its locale as the
|
|
27
|
+
`locale` Value, then the nearest `lang` (the element itself included), then the
|
|
28
|
+
runtime default — instead of `<html lang>` with an `"en"` fallback.
|
|
29
|
+
- currency-input: `locale` no longer defaults to `"en-US"` and resolves the same
|
|
30
|
+
way, so a page under `<html lang="de">` reads and writes `1.234,56`. The hidden
|
|
31
|
+
value stays in its ASCII normal form, and a declaration `Intl` rejects still
|
|
32
|
+
falls back to `"en-US"`.
|
|
33
|
+
- auto-submit: the `stimeo--announcer:announce` it bridges carries
|
|
34
|
+
`assertive: false` and a trimmed `message`; a message of only whitespace is not
|
|
35
|
+
announced.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- announcer: region timers are released on `disconnect()` and before a Turbo
|
|
40
|
+
snapshot, so an announcement made after reconnecting can no longer cancel the
|
|
41
|
+
auto-clear of a different live region.
|
|
42
|
+
|
|
10
43
|
## [0.14.0] - 2026-09-19
|
|
11
44
|
|
|
12
45
|
Minor release with no new components. input-mask changes what a broken `tokens`
|
|
@@ -1068,6 +1101,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
1068
1101
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
1069
1102
|
RubyGems.
|
|
1070
1103
|
|
|
1104
|
+
[0.15.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.15.0
|
|
1071
1105
|
[0.14.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.14.0
|
|
1072
1106
|
[0.13.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.13.0
|
|
1073
1107
|
[0.12.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.12.0
|
data/dist/cable/index.js
CHANGED
|
@@ -530,6 +530,51 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
530
530
|
}, _LiveCounterController.#announceDelay);
|
|
531
531
|
}
|
|
532
532
|
};
|
|
533
|
+
|
|
534
|
+
// src/utils/keyed_timers.ts
|
|
535
|
+
var KeyedTimers = class {
|
|
536
|
+
#timers = new SafeTimeout();
|
|
537
|
+
/** The pending timer of each key; an entry lives exactly as long as its timer. */
|
|
538
|
+
#pending = /* @__PURE__ */ new Map();
|
|
539
|
+
/**
|
|
540
|
+
* Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
|
|
541
|
+
* pending. The entry is dropped before the callback runs, so the callback sees
|
|
542
|
+
* the key unarmed and may arm it again for the next round.
|
|
543
|
+
*/
|
|
544
|
+
set(key, callback, delay) {
|
|
545
|
+
this.clear(key);
|
|
546
|
+
const id = this.#timers.set(() => {
|
|
547
|
+
this.#pending.delete(key);
|
|
548
|
+
callback();
|
|
549
|
+
}, delay);
|
|
550
|
+
this.#pending.set(key, id);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Cancels `key`'s pending timer, if it has one.
|
|
554
|
+
*
|
|
555
|
+
* A timer id is a positive integer, so `-1` stands for "nothing pending" and
|
|
556
|
+
* the registry ignores an id it does not own — the unarmed case needs no
|
|
557
|
+
* branch of its own, and no other key's timer can be reached from here.
|
|
558
|
+
*/
|
|
559
|
+
clear(key) {
|
|
560
|
+
this.#timers.clear(this.#pending.get(key) ?? -1);
|
|
561
|
+
this.#pending.delete(key);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Cancels every pending timer and forgets every key. Call this from a
|
|
565
|
+
* controller's `disconnect()` so no timer, and no entry, outlives the element.
|
|
566
|
+
*/
|
|
567
|
+
clearAll() {
|
|
568
|
+
this.#timers.clearAll();
|
|
569
|
+
this.#pending.clear();
|
|
570
|
+
}
|
|
571
|
+
/** Whether `key` has a timer pending. */
|
|
572
|
+
has(key) {
|
|
573
|
+
return this.#pending.has(key);
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// src/cable/presence_controller.ts
|
|
533
578
|
var BEACON_THROTTLE_MS = 2e3;
|
|
534
579
|
var DEFAULT_HEARTBEAT_MS = 15e3;
|
|
535
580
|
var DEFAULT_TIMEOUT_MS = 4e4;
|
|
@@ -554,6 +599,8 @@ var PresenceController = class _PresenceController extends Controller {
|
|
|
554
599
|
#subscription = null;
|
|
555
600
|
/** Present peers keyed by id (insertion order = join order). */
|
|
556
601
|
#peers = /* @__PURE__ */ new Map();
|
|
602
|
+
/** Each peer's auto-expiry timer, restarted by every beacon from that peer. */
|
|
603
|
+
#expiry = new KeyedTimers();
|
|
557
604
|
#timers = new SafeTimeout();
|
|
558
605
|
#intervals = new SafeInterval();
|
|
559
606
|
/** Epoch ms of the last outgoing beacon, for the convergence throttle. */
|
|
@@ -739,9 +786,8 @@ ${this.idValue}`;
|
|
|
739
786
|
}
|
|
740
787
|
const name = typeof beacon?.name === "string" ? beacon.name : "";
|
|
741
788
|
const existing = this.#peers.get(id);
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
this.#peers.set(id, { name, timer });
|
|
789
|
+
this.#expiry.set(id, () => this.#drop(id), this.#timeout);
|
|
790
|
+
this.#peers.set(id, { name });
|
|
745
791
|
if (existing === void 0) {
|
|
746
792
|
this.#appendClone(id, name);
|
|
747
793
|
this.#render();
|
|
@@ -757,7 +803,7 @@ ${this.idValue}`;
|
|
|
757
803
|
#drop(id) {
|
|
758
804
|
const peer = this.#peers.get(id);
|
|
759
805
|
if (peer === void 0) return;
|
|
760
|
-
this.#
|
|
806
|
+
this.#expiry.clear(id);
|
|
761
807
|
this.#peers.delete(id);
|
|
762
808
|
this.#removeClone(id);
|
|
763
809
|
this.#render();
|
|
@@ -834,6 +880,7 @@ ${this.idValue}`;
|
|
|
834
880
|
/** Clears the transient roster state (connect reset + disconnect teardown). */
|
|
835
881
|
#reset() {
|
|
836
882
|
this.#timers.clearAll();
|
|
883
|
+
this.#expiry.clearAll();
|
|
837
884
|
this.#pendingBeacon = null;
|
|
838
885
|
this.#announceId = null;
|
|
839
886
|
this.#peers.clear();
|
|
@@ -871,8 +918,10 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
871
918
|
/** Identifier parameters parsed once from their declaration, never in the hot path. */
|
|
872
919
|
#params = {};
|
|
873
920
|
#subscription = null;
|
|
874
|
-
/** Names currently typing (other clients),
|
|
875
|
-
#typers = /* @__PURE__ */ new
|
|
921
|
+
/** Names currently typing (other clients), in the order their first signal arrived. */
|
|
922
|
+
#typers = /* @__PURE__ */ new Set();
|
|
923
|
+
/** Each typer's auto-clear timer, restarted by every further signal from that name. */
|
|
924
|
+
#expiry = new KeyedTimers();
|
|
876
925
|
#timers = new SafeTimeout();
|
|
877
926
|
/** Epoch ms of the last broadcast, for leading-edge throttling. */
|
|
878
927
|
#lastSentAt = 0;
|
|
@@ -922,6 +971,7 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
922
971
|
this.#subscription?.unsubscribe();
|
|
923
972
|
this.#subscription = null;
|
|
924
973
|
this.#timers.clearAll();
|
|
974
|
+
this.#expiry.clearAll();
|
|
925
975
|
this.#announceId = null;
|
|
926
976
|
this.#typers.clear();
|
|
927
977
|
this.element.removeAttribute("data-typing");
|
|
@@ -945,13 +995,9 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
945
995
|
#onReceived(data) {
|
|
946
996
|
const name = data?.name;
|
|
947
997
|
if (typeof name !== "string" || name === "" || name === this.nameValue) return;
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
this.#typers.set(
|
|
952
|
-
name,
|
|
953
|
-
this.#timers.set(() => this.#untrack(name), this.#timeout)
|
|
954
|
-
);
|
|
998
|
+
const added = !this.#typers.has(name);
|
|
999
|
+
this.#typers.add(name);
|
|
1000
|
+
this.#expiry.set(name, () => this.#untrack(name), this.#timeout);
|
|
955
1001
|
if (added) this.#render();
|
|
956
1002
|
}
|
|
957
1003
|
/**
|
|
@@ -1011,7 +1057,7 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
1011
1057
|
}
|
|
1012
1058
|
/** Writes the current typer set onto the hook and the status slot. */
|
|
1013
1059
|
#paint() {
|
|
1014
|
-
const names = [...this.#typers
|
|
1060
|
+
const names = [...this.#typers];
|
|
1015
1061
|
this.element.setAttribute("data-typing", names.length > 0 ? "true" : "false");
|
|
1016
1062
|
if (this.hasStatusTarget) {
|
|
1017
1063
|
this.statusTarget.textContent = this.#message(names);
|
|
@@ -31,39 +31,6 @@ var BeforeCacheReset = class _BeforeCacheReset {
|
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
// src/utils/microtask_coalescer.ts
|
|
35
|
-
var MicrotaskCoalescer = class {
|
|
36
|
-
#run;
|
|
37
|
-
#queued = false;
|
|
38
|
-
#active = false;
|
|
39
|
-
#generation = 0;
|
|
40
|
-
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
41
|
-
constructor(run) {
|
|
42
|
-
this.#run = run;
|
|
43
|
-
}
|
|
44
|
-
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
45
|
-
activate() {
|
|
46
|
-
this.#active = true;
|
|
47
|
-
}
|
|
48
|
-
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
49
|
-
cancel() {
|
|
50
|
-
this.#active = false;
|
|
51
|
-
this.#queued = false;
|
|
52
|
-
this.#generation += 1;
|
|
53
|
-
}
|
|
54
|
-
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
55
|
-
schedule() {
|
|
56
|
-
if (!this.#active || this.#queued) return;
|
|
57
|
-
this.#queued = true;
|
|
58
|
-
const generation = this.#generation;
|
|
59
|
-
queueMicrotask(() => {
|
|
60
|
-
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
61
|
-
this.#queued = false;
|
|
62
|
-
this.#run();
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
|
|
67
34
|
// src/utils/safe_timeout.ts
|
|
68
35
|
var TimerRegistry = class {
|
|
69
36
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -117,6 +84,82 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
117
84
|
}
|
|
118
85
|
};
|
|
119
86
|
|
|
87
|
+
// src/utils/keyed_timers.ts
|
|
88
|
+
var KeyedTimers = class {
|
|
89
|
+
#timers = new SafeTimeout();
|
|
90
|
+
/** The pending timer of each key; an entry lives exactly as long as its timer. */
|
|
91
|
+
#pending = /* @__PURE__ */ new Map();
|
|
92
|
+
/**
|
|
93
|
+
* Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
|
|
94
|
+
* pending. The entry is dropped before the callback runs, so the callback sees
|
|
95
|
+
* the key unarmed and may arm it again for the next round.
|
|
96
|
+
*/
|
|
97
|
+
set(key, callback, delay) {
|
|
98
|
+
this.clear(key);
|
|
99
|
+
const id = this.#timers.set(() => {
|
|
100
|
+
this.#pending.delete(key);
|
|
101
|
+
callback();
|
|
102
|
+
}, delay);
|
|
103
|
+
this.#pending.set(key, id);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Cancels `key`'s pending timer, if it has one.
|
|
107
|
+
*
|
|
108
|
+
* A timer id is a positive integer, so `-1` stands for "nothing pending" and
|
|
109
|
+
* the registry ignores an id it does not own — the unarmed case needs no
|
|
110
|
+
* branch of its own, and no other key's timer can be reached from here.
|
|
111
|
+
*/
|
|
112
|
+
clear(key) {
|
|
113
|
+
this.#timers.clear(this.#pending.get(key) ?? -1);
|
|
114
|
+
this.#pending.delete(key);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Cancels every pending timer and forgets every key. Call this from a
|
|
118
|
+
* controller's `disconnect()` so no timer, and no entry, outlives the element.
|
|
119
|
+
*/
|
|
120
|
+
clearAll() {
|
|
121
|
+
this.#timers.clearAll();
|
|
122
|
+
this.#pending.clear();
|
|
123
|
+
}
|
|
124
|
+
/** Whether `key` has a timer pending. */
|
|
125
|
+
has(key) {
|
|
126
|
+
return this.#pending.has(key);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// src/utils/microtask_coalescer.ts
|
|
131
|
+
var MicrotaskCoalescer = class {
|
|
132
|
+
#run;
|
|
133
|
+
#queued = false;
|
|
134
|
+
#active = false;
|
|
135
|
+
#generation = 0;
|
|
136
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
137
|
+
constructor(run) {
|
|
138
|
+
this.#run = run;
|
|
139
|
+
}
|
|
140
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
141
|
+
activate() {
|
|
142
|
+
this.#active = true;
|
|
143
|
+
}
|
|
144
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
145
|
+
cancel() {
|
|
146
|
+
this.#active = false;
|
|
147
|
+
this.#queued = false;
|
|
148
|
+
this.#generation += 1;
|
|
149
|
+
}
|
|
150
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
151
|
+
schedule() {
|
|
152
|
+
if (!this.#active || this.#queued) return;
|
|
153
|
+
this.#queued = true;
|
|
154
|
+
const generation = this.#generation;
|
|
155
|
+
queueMicrotask(() => {
|
|
156
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
157
|
+
this.#queued = false;
|
|
158
|
+
this.#run();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
120
163
|
// src/controllers/announcer_controller.ts
|
|
121
164
|
var LEVELS = ["polite", "assertive"];
|
|
122
165
|
var AnnouncerController = class extends Controller {
|
|
@@ -126,8 +169,14 @@ var AnnouncerController = class extends Controller {
|
|
|
126
169
|
dedupeReannounce: { type: Boolean, default: true }
|
|
127
170
|
};
|
|
128
171
|
static actions = ["announce"];
|
|
129
|
-
/**
|
|
172
|
+
/** Drain timers, which belong to a politeness level rather than to a region. */
|
|
130
173
|
#timers = new SafeTimeout();
|
|
174
|
+
/**
|
|
175
|
+
* The one timer a region may have outstanding — its dedupe re-set, then its
|
|
176
|
+
* auto-clear. A region carries at most one, and the registry releases the
|
|
177
|
+
* previous one whenever a newer message takes the region over.
|
|
178
|
+
*/
|
|
179
|
+
#regionTimers = new KeyedTimers();
|
|
131
180
|
/** Live regions generated to stand in for absent targets, for teardown. */
|
|
132
181
|
#generated = /* @__PURE__ */ new Map();
|
|
133
182
|
/** Messages waiting to be written, oldest first, one queue per politeness. */
|
|
@@ -147,13 +196,6 @@ var AnnouncerController = class extends Controller {
|
|
|
147
196
|
});
|
|
148
197
|
/** Rewinds to an announceable initial state for the snapshot; see the remarks. */
|
|
149
198
|
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
150
|
-
/**
|
|
151
|
-
* The one timer a region may have outstanding — its dedupe re-set, then its
|
|
152
|
-
* auto-clear. Held weakly so a swapped-out target is not retained; a leftover
|
|
153
|
-
* id is harmless because {@link SafeTimeout.clear} no-ops on an id it does not
|
|
154
|
-
* own.
|
|
155
|
-
*/
|
|
156
|
-
#pending = /* @__PURE__ */ new WeakMap();
|
|
157
199
|
/**
|
|
158
200
|
* Guards against handling the same CustomEvent twice. An event dispatched on
|
|
159
201
|
* the controller element with `bubbles: true` reaches both the element and the
|
|
@@ -184,6 +226,7 @@ var AnnouncerController = class extends Controller {
|
|
|
184
226
|
this.element.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
|
|
185
227
|
window.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
|
|
186
228
|
this.#timers.clearAll();
|
|
229
|
+
this.#regionTimers.clearAll();
|
|
187
230
|
this.#queues.clear();
|
|
188
231
|
this.#draining.clear();
|
|
189
232
|
this.#removeGenerated();
|
|
@@ -314,13 +357,13 @@ var AnnouncerController = class extends Controller {
|
|
|
314
357
|
}
|
|
315
358
|
const region = this.#regionFor(level);
|
|
316
359
|
if (this.dedupeReannounceValue && region.textContent === message) {
|
|
317
|
-
this.#
|
|
360
|
+
this.#regionTimers.clear(region);
|
|
318
361
|
region.textContent = "";
|
|
319
362
|
this.#scheduleDrain(level);
|
|
320
363
|
return;
|
|
321
364
|
}
|
|
322
365
|
queue.shift();
|
|
323
|
-
this.#
|
|
366
|
+
this.#regionTimers.clear(region);
|
|
324
367
|
region.textContent = message;
|
|
325
368
|
this.#scheduleClear(region, message);
|
|
326
369
|
if (queue.length > 0) this.#scheduleDrain(level);
|
|
@@ -328,7 +371,7 @@ var AnnouncerController = class extends Controller {
|
|
|
328
371
|
/** Clears the region after `clearAfter` ms, unless a newer message replaced it. */
|
|
329
372
|
#scheduleClear(region, message) {
|
|
330
373
|
if (this.clearAfterValue <= 0) return;
|
|
331
|
-
this.#
|
|
374
|
+
this.#regionTimers.set(
|
|
332
375
|
region,
|
|
333
376
|
() => {
|
|
334
377
|
if (region.textContent === message) region.textContent = "";
|
|
@@ -336,23 +379,6 @@ var AnnouncerController = class extends Controller {
|
|
|
336
379
|
this.clearAfterValue
|
|
337
380
|
);
|
|
338
381
|
}
|
|
339
|
-
/**
|
|
340
|
-
* Arms `region`'s single pending timer. Callers reach here with the slot
|
|
341
|
-
* already free — `#drain` releases it before writing, and a fired timer clears its own
|
|
342
|
-
* entry below — so this does not cancel again.
|
|
343
|
-
*/
|
|
344
|
-
#schedule(region, callback, delay) {
|
|
345
|
-
const id = this.#timers.set(() => {
|
|
346
|
-
this.#pending.delete(region);
|
|
347
|
-
callback();
|
|
348
|
-
}, delay);
|
|
349
|
-
this.#pending.set(region, id);
|
|
350
|
-
}
|
|
351
|
-
/** Releases `region`'s pending timer, if it has one. */
|
|
352
|
-
#cancelPending(region) {
|
|
353
|
-
this.#timers.clear(this.#pending.get(region) ?? -1);
|
|
354
|
-
this.#pending.delete(region);
|
|
355
|
-
}
|
|
356
382
|
/**
|
|
357
383
|
* Resolves the live region for a politeness level.
|
|
358
384
|
*
|
|
@@ -378,6 +404,7 @@ var AnnouncerController = class extends Controller {
|
|
|
378
404
|
this.#queues.clear();
|
|
379
405
|
this.#draining.clear();
|
|
380
406
|
this.#timers.clearAll();
|
|
407
|
+
this.#regionTimers.clearAll();
|
|
381
408
|
for (const level of LEVELS) {
|
|
382
409
|
if (this.#hasTargetFor(level)) {
|
|
383
410
|
const target = level === "assertive" ? this.assertiveTarget : this.politeTarget;
|
|
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/auto_submit_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
// src/utils/before_cache_reset.ts
|
|
6
17
|
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
18
|
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
@@ -78,6 +89,30 @@ var CompositionTracker = class {
|
|
|
78
89
|
};
|
|
79
90
|
};
|
|
80
91
|
|
|
92
|
+
// src/utils/listener_set.ts
|
|
93
|
+
var ListenerSet = class {
|
|
94
|
+
/** The generation every `add` joins until the next `dispose()`. */
|
|
95
|
+
#abort = new AbortController();
|
|
96
|
+
/**
|
|
97
|
+
* Attaches `handler` to the open generation, exactly as the caller spelled it.
|
|
98
|
+
*
|
|
99
|
+
* The set supplies the signal, so `options` carries everything else the DOM
|
|
100
|
+
* accepts — `capture` included, which has to match at release time and no
|
|
101
|
+
* longer has a second place to drift from.
|
|
102
|
+
*/
|
|
103
|
+
add(target, type, handler, options) {
|
|
104
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Releases every listener of the open generation, synchronously, and opens the
|
|
108
|
+
* next one. Idempotent, and safe before anything has been added.
|
|
109
|
+
*/
|
|
110
|
+
dispose() {
|
|
111
|
+
this.#abort.abort();
|
|
112
|
+
this.#abort = new AbortController();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
81
116
|
// src/utils/safe_timeout.ts
|
|
82
117
|
var TimerRegistry = class {
|
|
83
118
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -146,6 +181,8 @@ var AutoSubmitController = class extends Controller {
|
|
|
146
181
|
#timers = new SafeTimeout();
|
|
147
182
|
/** Id of the pending debounce timer, so a new keystroke can reset it. */
|
|
148
183
|
#pendingId = 0;
|
|
184
|
+
/** Listeners that live with the bound form rather than with the connection. */
|
|
185
|
+
#formListeners = new ListenerSet();
|
|
149
186
|
/** The form the listeners are attached to; target callbacks rebind it. */
|
|
150
187
|
#boundForm = null;
|
|
151
188
|
/** Rewinds the transient state hooks just before Turbo snapshots the page. */
|
|
@@ -155,9 +192,7 @@ var AutoSubmitController = class extends Controller {
|
|
|
155
192
|
this.#boundForm?.removeAttribute("aria-busy");
|
|
156
193
|
const message = this.messageValue;
|
|
157
194
|
this.dispatch("done", { detail: { message: message || void 0 } });
|
|
158
|
-
if (this.announceValue
|
|
159
|
-
window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
|
|
160
|
-
}
|
|
195
|
+
if (this.announceValue) announce(message);
|
|
161
196
|
};
|
|
162
197
|
/** Owns delegated IME lifecycle state and submits confirmed input text. */
|
|
163
198
|
#composition = new CompositionTracker({
|
|
@@ -228,12 +263,12 @@ var AutoSubmitController = class extends Controller {
|
|
|
228
263
|
if (previous) {
|
|
229
264
|
this.#cancelPending();
|
|
230
265
|
previous.removeAttribute("data-auto-submit-pending");
|
|
231
|
-
|
|
266
|
+
this.#formListeners.dispose();
|
|
232
267
|
this.#composition.unobserve(previous);
|
|
233
268
|
}
|
|
234
269
|
this.#boundForm = form;
|
|
235
270
|
if (!form) return;
|
|
236
|
-
|
|
271
|
+
this.#formListeners.add(form, "turbo:submit-end", this.#onSubmitEnd);
|
|
237
272
|
this.#composition.observe(form);
|
|
238
273
|
}
|
|
239
274
|
/** Resolves the form: the explicit `form` target, else a `<form>` root, else null. */
|
|
@@ -63,10 +63,15 @@ var LayoutObserver = class {
|
|
|
63
63
|
#resizeObserverFactory;
|
|
64
64
|
#resizeObserver = null;
|
|
65
65
|
#observingViewport = false;
|
|
66
|
+
#loadContainer = null;
|
|
66
67
|
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
67
68
|
#handleViewportResize = () => {
|
|
68
69
|
this.#callback();
|
|
69
70
|
};
|
|
71
|
+
/** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
|
|
72
|
+
#handleDescendantLoad = () => {
|
|
73
|
+
this.#callback();
|
|
74
|
+
};
|
|
70
75
|
constructor(callback, options = {}) {
|
|
71
76
|
this.#callback = callback;
|
|
72
77
|
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
@@ -102,14 +107,35 @@ var LayoutObserver = class {
|
|
|
102
107
|
window.removeEventListener("resize", this.#handleViewportResize);
|
|
103
108
|
}
|
|
104
109
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
110
|
+
* Starts reporting a `load` from anywhere inside `container` — an image or a
|
|
111
|
+
* frame settling changes the box it sits in, and it measures as zero high until
|
|
112
|
+
* then. `load` does not bubble, so the subscription is a capture-phase listener
|
|
113
|
+
* on the container itself and nothing the caller spells.
|
|
114
|
+
*
|
|
115
|
+
* **One container at a time.** A further call moves the observation, so a widget
|
|
116
|
+
* whose content element is swapped at runtime releases the element it let go by
|
|
117
|
+
* naming the new one — there is no second place for the release to drift from.
|
|
118
|
+
*/
|
|
119
|
+
observeDescendantLoads(container) {
|
|
120
|
+
this.unobserveDescendantLoads();
|
|
121
|
+
this.#loadContainer = container;
|
|
122
|
+
container.addEventListener("load", this.#handleDescendantLoad, true);
|
|
123
|
+
}
|
|
124
|
+
/** Stops reporting descendant loads without affecting element or viewport observation. */
|
|
125
|
+
unobserveDescendantLoads() {
|
|
126
|
+
this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
|
|
127
|
+
this.#loadContainer = null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and removes
|
|
131
|
+
* the viewport and descendant-load listeners. Safe to call multiple times. Call
|
|
132
|
+
* this from a controller's `disconnect()`.
|
|
108
133
|
*/
|
|
109
134
|
disconnect() {
|
|
110
135
|
this.#resizeObserver?.disconnect();
|
|
111
136
|
this.#resizeObserver = null;
|
|
112
137
|
this.unobserveViewport();
|
|
138
|
+
this.unobserveDescendantLoads();
|
|
113
139
|
}
|
|
114
140
|
};
|
|
115
141
|
|
|
@@ -85,6 +85,11 @@ function monthLabelFormatter(locale) {
|
|
|
85
85
|
return intlFormatter(Intl.DateTimeFormat, locale, MONTH_LABEL_OPTIONS, "en");
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// src/utils/locale.ts
|
|
89
|
+
function resolveLocale(element, declared = "") {
|
|
90
|
+
return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
88
93
|
// src/utils/safe_timeout.ts
|
|
89
94
|
var TimerRegistry = class {
|
|
90
95
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -147,8 +152,9 @@ var CalendarController = class extends Controller {
|
|
|
147
152
|
selected: { type: String, default: "" },
|
|
148
153
|
min: { type: String, default: "" },
|
|
149
154
|
max: { type: String, default: "" },
|
|
150
|
-
weekStart: { type: Number, default: 0 }
|
|
155
|
+
weekStart: { type: Number, default: 0 },
|
|
151
156
|
// 0 = Sunday, 1 = Monday, etc.
|
|
157
|
+
locale: { type: String, default: "" }
|
|
152
158
|
};
|
|
153
159
|
static actions = ["next", "onKeydown", "prev", "selectByClick"];
|
|
154
160
|
static events = ["monthchange", "select"];
|
|
@@ -190,6 +196,17 @@ var CalendarController = class extends Controller {
|
|
|
190
196
|
this.#syncFocusedDateWithMonth();
|
|
191
197
|
this.render();
|
|
192
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Repaints the label when application code changes `locale` at runtime.
|
|
201
|
+
*
|
|
202
|
+
* Stimulus runs this for the initial Value too, before `connect()`. The paint
|
|
203
|
+
* it triggers is idempotent — the month comes from `month` (or, while that is
|
|
204
|
+
* still empty, from `focusedDate`), and `monthchange` only reports a move away
|
|
205
|
+
* from a month already announced, so the first paint never reports one.
|
|
206
|
+
*/
|
|
207
|
+
localeValueChanged() {
|
|
208
|
+
this.render();
|
|
209
|
+
}
|
|
193
210
|
/**
|
|
194
211
|
* Stimulus lifecycle callback triggered automatically when the selectedValue changes.
|
|
195
212
|
* Re-renders grid cells to update `aria-selected` indicators.
|
|
@@ -320,8 +337,8 @@ var CalendarController = class extends Controller {
|
|
|
320
337
|
const year = monthStart.getFullYear();
|
|
321
338
|
const month = monthStart.getMonth() + 1;
|
|
322
339
|
if (this.hasLabelTarget) {
|
|
323
|
-
const
|
|
324
|
-
this.labelTarget.textContent = monthLabelFormatter(
|
|
340
|
+
const locale = resolveLocale(this.element, this.localeValue);
|
|
341
|
+
this.labelTarget.textContent = monthLabelFormatter(locale).format(monthStart);
|
|
325
342
|
}
|
|
326
343
|
const days = this.#calculateGridDays(year, month);
|
|
327
344
|
const dayElements = this.dayTargets;
|
|
@@ -2,6 +2,30 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/checkbox_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/listener_set.ts
|
|
6
|
+
var ListenerSet = class {
|
|
7
|
+
/** The generation every `add` joins until the next `dispose()`. */
|
|
8
|
+
#abort = new AbortController();
|
|
9
|
+
/**
|
|
10
|
+
* Attaches `handler` to the open generation, exactly as the caller spelled it.
|
|
11
|
+
*
|
|
12
|
+
* The set supplies the signal, so `options` carries everything else the DOM
|
|
13
|
+
* accepts — `capture` included, which has to match at release time and no
|
|
14
|
+
* longer has a second place to drift from.
|
|
15
|
+
*/
|
|
16
|
+
add(target, type, handler, options) {
|
|
17
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Releases every listener of the open generation, synchronously, and opens the
|
|
21
|
+
* next one. Idempotent, and safe before anything has been added.
|
|
22
|
+
*/
|
|
23
|
+
dispose() {
|
|
24
|
+
this.#abort.abort();
|
|
25
|
+
this.#abort = new AbortController();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
5
29
|
// src/utils/microtask_coalescer.ts
|
|
6
30
|
var MicrotaskCoalescer = class {
|
|
7
31
|
#run;
|
|
@@ -42,6 +66,7 @@ var CheckboxController = class extends Controller {
|
|
|
42
66
|
static events = ["change", "reconcile"];
|
|
43
67
|
/** Collapses every lifecycle signal from one DOM update into one derived pass. */
|
|
44
68
|
#reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
|
|
69
|
+
#listeners = new ListenerSet();
|
|
45
70
|
/** Aggregate this root last settled on, so a derived repair is reported once. */
|
|
46
71
|
#committedState = null;
|
|
47
72
|
/** Watches authored checked-attribute changes on retained target elements. */
|
|
@@ -59,14 +84,14 @@ var CheckboxController = class extends Controller {
|
|
|
59
84
|
attributeFilter: ["checked"],
|
|
60
85
|
subtree: true
|
|
61
86
|
});
|
|
62
|
-
this.element
|
|
87
|
+
this.#listeners.add(this.element, "turbo:morph-element", this.#onMorph);
|
|
63
88
|
document.addEventListener("reset", this.#onReset, true);
|
|
64
89
|
}
|
|
65
90
|
/** Releases the observer, global reset listener, and every pending reconciliation. */
|
|
66
91
|
disconnect() {
|
|
67
92
|
this.#reconcile.cancel();
|
|
68
93
|
this.#checkedObserver.disconnect();
|
|
69
|
-
this.
|
|
94
|
+
this.#listeners.dispose();
|
|
70
95
|
document.removeEventListener("reset", this.#onReset, true);
|
|
71
96
|
}
|
|
72
97
|
/** Reconciles the aggregate for a parent added or replaced at runtime. */
|