stimeo-ui 0.9.0 → 0.10.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 +74 -0
- data/dist/controllers/alert_dialog_controller.js +17 -7
- data/dist/controllers/announcer_controller.js +1 -1
- data/dist/controllers/bulk_select_controller.js +2 -2
- data/dist/controllers/collapsible_controller.js +1 -1
- data/dist/controllers/command_palette_controller.js +15 -5
- data/dist/controllers/confirm_controller.js +15 -5
- data/dist/controllers/countdown_controller.js +1 -1
- data/dist/controllers/data_grid_controller.js +1 -1
- data/dist/controllers/dialog_controller.js +15 -5
- data/dist/controllers/direct_upload_controller.js +1 -5
- data/dist/controllers/drawer_controller.js +17 -7
- data/dist/controllers/focus_controller.js +45 -7
- data/dist/controllers/form_field_controller.js +1 -1
- data/dist/controllers/menubar_controller.js +2 -2
- data/dist/controllers/multi_select_controller.js +1 -1
- data/dist/controllers/overflow_menu_controller.js +2 -2
- data/dist/controllers/password_reveal_controller.js +102 -3
- data/dist/controllers/password_strength_controller.js +287 -42
- data/dist/controllers/resizable_controller.js +1 -1
- data/dist/controllers/scroll_restore_controller.js +127 -27
- data/dist/controllers/scroll_visibility_controller.js +167 -14
- data/dist/controllers/sidebar_controller.js +15 -5
- data/dist/controllers/step_indicator_controller.js +0 -5
- data/dist/controllers/theme_controller.js +151 -40
- data/dist/controllers/toast_controller.js +2 -2
- data/dist/controllers/transition_controller.js +79 -15
- data/dist/index.js +740 -178
- data/dist/positioning/index.js +84 -25
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -2,6 +2,92 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/password_strength_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
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/before_cache_reset.ts
|
|
23
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
24
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
25
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
26
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
27
|
+
static #onBeforeCache = () => {
|
|
28
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
29
|
+
};
|
|
30
|
+
#rewind;
|
|
31
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
32
|
+
constructor(rewind) {
|
|
33
|
+
this.#rewind = rewind;
|
|
34
|
+
}
|
|
35
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
36
|
+
activate() {
|
|
37
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
38
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
39
|
+
if (first) {
|
|
40
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
44
|
+
deactivate() {
|
|
45
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
46
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
47
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// src/utils/coerce.ts
|
|
52
|
+
function toFiniteNumber(raw) {
|
|
53
|
+
if (raw === null || raw === void 0 || raw === "") return null;
|
|
54
|
+
const value = typeof raw === "number" ? raw : Number(raw);
|
|
55
|
+
return Number.isFinite(value) ? value : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/utils/microtask_coalescer.ts
|
|
59
|
+
var MicrotaskCoalescer = class {
|
|
60
|
+
#run;
|
|
61
|
+
#queued = false;
|
|
62
|
+
#active = false;
|
|
63
|
+
#generation = 0;
|
|
64
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
65
|
+
constructor(run) {
|
|
66
|
+
this.#run = run;
|
|
67
|
+
}
|
|
68
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
69
|
+
activate() {
|
|
70
|
+
this.#active = true;
|
|
71
|
+
}
|
|
72
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
73
|
+
cancel() {
|
|
74
|
+
this.#active = false;
|
|
75
|
+
this.#queued = false;
|
|
76
|
+
this.#generation += 1;
|
|
77
|
+
}
|
|
78
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
79
|
+
schedule() {
|
|
80
|
+
if (!this.#active || this.#queued) return;
|
|
81
|
+
this.#queued = true;
|
|
82
|
+
const generation = this.#generation;
|
|
83
|
+
queueMicrotask(() => {
|
|
84
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
85
|
+
this.#queued = false;
|
|
86
|
+
this.#run();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
5
91
|
// src/utils/safe_timeout.ts
|
|
6
92
|
var TimerRegistry = class {
|
|
7
93
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -75,6 +161,7 @@ var DEFAULT_LEVELS = ["weak", "fair", "good", "strong"];
|
|
|
75
161
|
var LENGTH_MILESTONES = [8, 12, 16];
|
|
76
162
|
var MAX_POINTS = LENGTH_MILESTONES.length + (CLASS_PATTERNS.length - 1);
|
|
77
163
|
var STRENGTH_BANDS = ["weak", "fair", "good", "strong"];
|
|
164
|
+
var MIN_LEVELS = 2;
|
|
78
165
|
var PasswordStrengthController = class _PasswordStrengthController extends Controller {
|
|
79
166
|
static targets = ["input", "meter", "label"];
|
|
80
167
|
static values = {
|
|
@@ -82,50 +169,143 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
82
169
|
// A JSON list read through `parseStringList` rather than Stimulus's `Array`
|
|
83
170
|
// type: that reader throws out of the value observer before any callback
|
|
84
171
|
// runs, so one malformed attribute would stop the meter connecting.
|
|
85
|
-
levels: { type: String, default: "" }
|
|
172
|
+
levels: { type: String, default: "" },
|
|
173
|
+
announceText: { type: String, default: "" }
|
|
86
174
|
};
|
|
87
|
-
static actions = ["evaluate"];
|
|
88
|
-
static events = ["change"];
|
|
89
|
-
/** Delay (ms) before
|
|
175
|
+
static actions = ["evaluate", "setScore"];
|
|
176
|
+
static events = ["change", "reconcile"];
|
|
177
|
+
/** Delay (ms) before one settled level is sent to the shared announcer. */
|
|
90
178
|
static #announceDelay = 200;
|
|
91
179
|
#timers = new SafeTimeout();
|
|
180
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
181
|
+
#repaint = new MicrotaskCoalescer(() => this.#reconcile());
|
|
182
|
+
#levels = [...DEFAULT_LEVELS];
|
|
92
183
|
#announceId = null;
|
|
184
|
+
#announcedLevel = null;
|
|
185
|
+
#externalScore = null;
|
|
186
|
+
#lastDetail = null;
|
|
187
|
+
/** Reflects the current DOM state and opens the reconciliation window. */
|
|
93
188
|
connect() {
|
|
94
|
-
this.#
|
|
189
|
+
this.#repaint.activate();
|
|
190
|
+
this.#beforeCache.activate();
|
|
191
|
+
this.#lastDetail = this.#detail(this.#render());
|
|
95
192
|
}
|
|
193
|
+
/** Releases the reconciliation window, the cache subscription and the debounce. */
|
|
96
194
|
disconnect() {
|
|
97
|
-
this.#
|
|
98
|
-
this.#
|
|
195
|
+
this.#repaint.cancel();
|
|
196
|
+
this.#beforeCache.deactivate();
|
|
197
|
+
this.#cancelAnnouncement();
|
|
198
|
+
this.#announcedLevel = null;
|
|
199
|
+
this.#externalScore = null;
|
|
200
|
+
this.#lastDetail = null;
|
|
99
201
|
}
|
|
100
202
|
/** Re-evaluates strength from the input. Bound via `data-action` (`input`). */
|
|
101
203
|
evaluate() {
|
|
102
|
-
this.#
|
|
204
|
+
this.#externalScore = null;
|
|
205
|
+
this.#commit();
|
|
103
206
|
}
|
|
104
207
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
208
|
+
* Adopts a score computed outside the built-in heuristic, clamped into the
|
|
209
|
+
* declared scale. An unreadable value leaves the current score standing, and
|
|
210
|
+
* the next `evaluate` hands scoring back to the heuristic.
|
|
108
211
|
*/
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
212
|
+
setScore(event) {
|
|
213
|
+
const next = toFiniteNumber(event.params?.score ?? event.detail?.score);
|
|
214
|
+
if (next === null) return;
|
|
215
|
+
this.#externalScore = next;
|
|
216
|
+
this.#commit();
|
|
217
|
+
}
|
|
218
|
+
/** Re-reads the scale when application code or a Turbo morph changes `levels`. */
|
|
219
|
+
levelsValueChanged() {
|
|
220
|
+
this.#levels = this.#readLevels();
|
|
221
|
+
this.#repaint.schedule();
|
|
222
|
+
}
|
|
223
|
+
/** Repaints when application code or a Turbo morph changes `minScore`. */
|
|
224
|
+
minScoreValueChanged() {
|
|
225
|
+
this.#repaint.schedule();
|
|
226
|
+
}
|
|
227
|
+
/** Repaints when application code or a Turbo morph changes `announceText`. */
|
|
228
|
+
announceTextValueChanged() {
|
|
229
|
+
this.#repaint.schedule();
|
|
230
|
+
}
|
|
231
|
+
/** Scores the field added or replaced at runtime. */
|
|
232
|
+
inputTargetConnected() {
|
|
233
|
+
this.#repaint.schedule();
|
|
234
|
+
}
|
|
235
|
+
/** Falls back to the pristine state after the scored field is removed. */
|
|
236
|
+
inputTargetDisconnected() {
|
|
237
|
+
this.#repaint.schedule();
|
|
238
|
+
}
|
|
239
|
+
/** Syncs a meter added or replaced at runtime, which carries no value yet. */
|
|
240
|
+
meterTargetConnected() {
|
|
241
|
+
this.#repaint.schedule();
|
|
242
|
+
}
|
|
243
|
+
/** Repaints the remaining output after a meter is removed. */
|
|
244
|
+
meterTargetDisconnected() {
|
|
245
|
+
this.#repaint.schedule();
|
|
246
|
+
}
|
|
247
|
+
/** Fills a readout added or replaced at runtime. */
|
|
248
|
+
labelTargetConnected() {
|
|
249
|
+
this.#repaint.schedule();
|
|
250
|
+
}
|
|
251
|
+
/** Repaints the remaining output after a readout is removed. */
|
|
252
|
+
labelTargetDisconnected() {
|
|
253
|
+
this.#repaint.schedule();
|
|
254
|
+
}
|
|
255
|
+
/** Reflects one confirmed scoring pass and offers its level to the reader. */
|
|
256
|
+
#commit() {
|
|
257
|
+
const reading = this.#render();
|
|
258
|
+
this.#lastDetail = this.#detail(reading);
|
|
259
|
+
this.dispatch("change", { detail: this.#lastDetail });
|
|
260
|
+
this.#scheduleAnnouncement(reading);
|
|
261
|
+
}
|
|
262
|
+
/** Repaints one mutation batch and reports a changed controller-derived state. */
|
|
263
|
+
#reconcile() {
|
|
264
|
+
const previous = this.#lastDetail;
|
|
265
|
+
const owed = this.#announceId !== null;
|
|
266
|
+
this.#cancelAnnouncement();
|
|
267
|
+
const reading = this.#render();
|
|
268
|
+
const detail = this.#detail(reading);
|
|
269
|
+
this.#lastDetail = detail;
|
|
270
|
+
if (reading.score === 0) this.#announcedLevel = null;
|
|
271
|
+
if (owed) this.#scheduleAnnouncement(reading);
|
|
272
|
+
if (previous && this.#detailsDiffer(previous, detail)) {
|
|
273
|
+
this.dispatch("reconcile", { detail });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Synchronizes the meter ARIA, the root state hooks, the fill custom property
|
|
278
|
+
* and the visible level readout.
|
|
279
|
+
*
|
|
280
|
+
* @stimeoRenderRoot
|
|
281
|
+
*/
|
|
282
|
+
#render() {
|
|
283
|
+
const max = this.#levels.length;
|
|
284
|
+
const score = this.#currentScore(max);
|
|
285
|
+
const level = this.#levels[score - 1] ?? "";
|
|
286
|
+
const meetsMin = score > 0 && score >= this.#minScore;
|
|
287
|
+
const band = this.#band(score, max);
|
|
115
288
|
this.#reflectMeter(score, max);
|
|
116
|
-
this.#reflectRoot(score, max);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
289
|
+
this.#reflectRoot(score, max, band, meetsMin);
|
|
290
|
+
this.#writeLabel(level);
|
|
291
|
+
return { score, level, max, meetsMin, band };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The declared minimum, or the Value's default (`0`, an inert gate) when the
|
|
295
|
+
* declaration cannot be read as a number. Every comparison against an unread
|
|
296
|
+
* number answers false, which would fail even the strongest password instead
|
|
297
|
+
* of leaving the gate off, so the unreadable declaration is confined to its
|
|
298
|
+
* own Value the way an unreadable scale is.
|
|
299
|
+
*/
|
|
300
|
+
get #minScore() {
|
|
301
|
+
return Number.isFinite(this.minScoreValue) ? this.minScoreValue : 0;
|
|
302
|
+
}
|
|
303
|
+
/** The externally supplied score when one stands, else the heuristic's. */
|
|
304
|
+
#currentScore(max) {
|
|
305
|
+
if (this.#externalScore !== null) {
|
|
306
|
+
return Math.min(max, Math.max(0, Math.round(this.#externalScore)));
|
|
120
307
|
}
|
|
121
|
-
this.
|
|
122
|
-
detail: { score, level: label, max, meetsMin: score > 0 && score >= this.minScoreValue }
|
|
123
|
-
});
|
|
124
|
-
if (this.#announceId !== null) this.#timers.clear(this.#announceId);
|
|
125
|
-
this.#announceId = this.#timers.set(() => {
|
|
126
|
-
this.#writeLabel(label);
|
|
127
|
-
this.#announceId = null;
|
|
128
|
-
}, _PasswordStrengthController.#announceDelay);
|
|
308
|
+
return this.#score(this.hasInputTarget ? this.inputTarget.value : "", max);
|
|
129
309
|
}
|
|
130
310
|
/** Syncs the meter target's ARIA value attributes (`0..levels.length`). */
|
|
131
311
|
#reflectMeter(score, max) {
|
|
@@ -139,25 +319,27 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
139
319
|
* empty), the `data-below-min` hook when the score is under `minScore`, and the
|
|
140
320
|
* `0–1` fill the consumer's CSS turns into the bar width.
|
|
141
321
|
*/
|
|
142
|
-
#reflectRoot(score, max) {
|
|
143
|
-
const band = this.#band(score, max);
|
|
322
|
+
#reflectRoot(score, max, band, meetsMin) {
|
|
144
323
|
this.#toggle("data-strength", band, band.length > 0);
|
|
145
|
-
this.#toggle("data-below-min", "true", score > 0 &&
|
|
146
|
-
|
|
147
|
-
this.element.style.setProperty("--stimeo--password-strength", String(ratio));
|
|
324
|
+
this.#toggle("data-below-min", "true", score > 0 && !meetsMin);
|
|
325
|
+
this.element.style.setProperty("--stimeo--password-strength", String(score / max));
|
|
148
326
|
}
|
|
149
|
-
|
|
150
|
-
|
|
327
|
+
/** Writes the visible readout only when its text actually changed. */
|
|
328
|
+
#writeLabel(text) {
|
|
329
|
+
if (!this.hasLabelTarget || this.labelTarget.textContent === text) return;
|
|
330
|
+
this.labelTarget.textContent = text;
|
|
151
331
|
}
|
|
152
332
|
/**
|
|
153
333
|
* Locale-independent styling band (one of {@link STRENGTH_BANDS}) for `score`
|
|
154
|
-
* out of `max`. Empty input → `""`.
|
|
155
|
-
*
|
|
334
|
+
* out of `max`. Empty input → `""`. Maps the position within the declared scale
|
|
335
|
+
* onto the position within the fixed bands, so both ends stay anchored on any
|
|
336
|
+
* level count: the weakest score is always `weak` and the strongest `strong`,
|
|
337
|
+
* and only the middle collapses when the scales differ in size.
|
|
156
338
|
*/
|
|
157
339
|
#band(score, max) {
|
|
158
|
-
if (score <= 0
|
|
159
|
-
const index = Math.
|
|
160
|
-
return STRENGTH_BANDS[
|
|
340
|
+
if (score <= 0) return "";
|
|
341
|
+
const index = Math.round((score - 1) / (max - 1) * (STRENGTH_BANDS.length - 1));
|
|
342
|
+
return STRENGTH_BANDS[index] ?? STRENGTH_BANDS[0];
|
|
161
343
|
}
|
|
162
344
|
/** Sets `name` to `value` when `on`, else removes it (value/presence data hook). */
|
|
163
345
|
#toggle(name, value, on) {
|
|
@@ -167,6 +349,11 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
167
349
|
this.element.removeAttribute(name);
|
|
168
350
|
}
|
|
169
351
|
}
|
|
352
|
+
/** The declared level labels, or the defaults when the scale cannot order. */
|
|
353
|
+
#readLevels() {
|
|
354
|
+
const declared = parseStringList(this.levelsValue, DEFAULT_LEVELS);
|
|
355
|
+
return declared.length >= MIN_LEVELS ? declared : [...DEFAULT_LEVELS];
|
|
356
|
+
}
|
|
170
357
|
/**
|
|
171
358
|
* Lightweight zero-dependency strength heuristic returning an integer in
|
|
172
359
|
* `[0, max]` (`max` = number of levels). Empty input is `0` (no level); any
|
|
@@ -176,7 +363,7 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
176
363
|
* alone cannot mask trivial repetition.
|
|
177
364
|
*/
|
|
178
365
|
#score(password, max) {
|
|
179
|
-
if (password.length === 0
|
|
366
|
+
if (password.length === 0) return 0;
|
|
180
367
|
let points = 0;
|
|
181
368
|
for (const milestone of LENGTH_MILESTONES) {
|
|
182
369
|
if (password.length >= milestone) points += 1;
|
|
@@ -186,6 +373,64 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
186
373
|
const bucketed = Math.round(points / MAX_POINTS * max);
|
|
187
374
|
return Math.min(max, Math.max(1, bucketed));
|
|
188
375
|
}
|
|
376
|
+
/** Debounces one i18n-neutral message for a level transition into the announcer. */
|
|
377
|
+
#scheduleAnnouncement(reading) {
|
|
378
|
+
this.#cancelAnnouncement();
|
|
379
|
+
if (reading.score === 0) {
|
|
380
|
+
this.#announcedLevel = null;
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (reading.level === this.#announcedLevel) return;
|
|
384
|
+
const message = fillTemplate(this.announceTextValue, {
|
|
385
|
+
level: reading.level,
|
|
386
|
+
score: reading.score,
|
|
387
|
+
max: reading.max,
|
|
388
|
+
band: reading.band
|
|
389
|
+
});
|
|
390
|
+
if (message.trim().length === 0) return;
|
|
391
|
+
this.#announceId = this.#timers.set(() => {
|
|
392
|
+
this.#announcedLevel = reading.level;
|
|
393
|
+
announce(message);
|
|
394
|
+
this.#announceId = null;
|
|
395
|
+
}, _PasswordStrengthController.#announceDelay);
|
|
396
|
+
}
|
|
397
|
+
/** Cancels the one outstanding message without touching visible output. */
|
|
398
|
+
#cancelAnnouncement() {
|
|
399
|
+
if (this.#announceId !== null) this.#timers.clear(this.#announceId);
|
|
400
|
+
this.#announceId = null;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Returns every output derived from the field value to its pristine form.
|
|
404
|
+
*
|
|
405
|
+
* The value itself is not carried in a Turbo snapshot, so a band, a fill or a
|
|
406
|
+
* readout left behind would describe a password the restored page no longer
|
|
407
|
+
* holds. The pass is silent: `connect()` derives the state again from whatever
|
|
408
|
+
* the restored field contains.
|
|
409
|
+
*/
|
|
410
|
+
#rewindForCache() {
|
|
411
|
+
this.#cancelAnnouncement();
|
|
412
|
+
this.#announcedLevel = null;
|
|
413
|
+
this.#externalScore = null;
|
|
414
|
+
this.#lastDetail = null;
|
|
415
|
+
this.#reflectMeter(0, this.#levels.length);
|
|
416
|
+
this.#toggle("data-strength", "", false);
|
|
417
|
+
this.#toggle("data-below-min", "true", false);
|
|
418
|
+
this.element.style.removeProperty("--stimeo--password-strength");
|
|
419
|
+
this.#writeLabel("");
|
|
420
|
+
}
|
|
421
|
+
/** Selects the public event state from the richer internal reading. */
|
|
422
|
+
#detail(reading) {
|
|
423
|
+
return {
|
|
424
|
+
score: reading.score,
|
|
425
|
+
level: reading.level,
|
|
426
|
+
max: reading.max,
|
|
427
|
+
meetsMin: reading.meetsMin
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/** Compares exactly the state carried by `change` and `reconcile`. */
|
|
431
|
+
#detailsDiffer(left, right) {
|
|
432
|
+
return left.score !== right.score || left.level !== right.level || left.max !== right.max || left.meetsMin !== right.meetsMin;
|
|
433
|
+
}
|
|
189
434
|
};
|
|
190
435
|
|
|
191
436
|
export { PasswordStrengthController };
|
|
@@ -251,7 +251,7 @@ var ResizableController = class extends Controller {
|
|
|
251
251
|
this.#dispatchChange();
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
|
-
/**
|
|
254
|
+
/** Collapses the primary pane to its minimum, or returns it to the last position. */
|
|
255
255
|
toggle() {
|
|
256
256
|
const { min, max } = this.#range;
|
|
257
257
|
if (this.#position > min) {
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
3
|
// src/controllers/scroll_restore_controller.ts
|
|
4
|
+
function parseStored(raw) {
|
|
5
|
+
if (raw === null) return null;
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(raw);
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function storedOffset(value) {
|
|
13
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
14
|
+
}
|
|
4
15
|
var ScrollRestoreController = class extends Controller {
|
|
5
16
|
static values = {
|
|
6
17
|
key: { type: String, default: "" },
|
|
@@ -13,6 +24,26 @@ var ScrollRestoreController = class extends Controller {
|
|
|
13
24
|
/** Last offset captured while the element was live; persisted as-is on teardown. */
|
|
14
25
|
#lastTop = 0;
|
|
15
26
|
#lastLeft = 0;
|
|
27
|
+
/**
|
|
28
|
+
* The offsets this controller's own restore produced, awaiting the `scroll`
|
|
29
|
+
* the engine fires for them. A restore the layout cannot reach yet lands short,
|
|
30
|
+
* so treating that echo as the reader's position would cut the saved offset
|
|
31
|
+
* down to whatever the unfinished layout allowed.
|
|
32
|
+
*/
|
|
33
|
+
#echoTop = null;
|
|
34
|
+
#echoLeft = null;
|
|
35
|
+
/** Offsets stored for an axis that is not tracked, carried through saves. */
|
|
36
|
+
#carried = {};
|
|
37
|
+
/**
|
|
38
|
+
* Whether each axis holds an offset worth saving — one this namespace restored
|
|
39
|
+
* or the reader moved. A save writes only the axes that are marked, so an
|
|
40
|
+
* offset taken under a different key or axis is never re-published as if the
|
|
41
|
+
* reader had left it there.
|
|
42
|
+
*/
|
|
43
|
+
#capturedTop = false;
|
|
44
|
+
#capturedLeft = false;
|
|
45
|
+
/** Whether the controller is connected; Value callbacks outside that window do not resync. */
|
|
46
|
+
#connected = false;
|
|
16
47
|
#onScroll = () => {
|
|
17
48
|
this.#capture();
|
|
18
49
|
if (this.#rafId !== null) return;
|
|
@@ -22,58 +53,127 @@ var ScrollRestoreController = class extends Controller {
|
|
|
22
53
|
});
|
|
23
54
|
};
|
|
24
55
|
connect() {
|
|
56
|
+
this.#connected = true;
|
|
25
57
|
this.#storageKey = this.#resolveKey();
|
|
26
|
-
if (!this.#storageKey) return;
|
|
27
|
-
this.#restore();
|
|
28
58
|
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
59
|
+
if (this.#storageKey) this.#restore();
|
|
29
60
|
}
|
|
30
61
|
disconnect() {
|
|
31
|
-
|
|
62
|
+
this.#connected = false;
|
|
32
63
|
this.element.removeEventListener("scroll", this.#onScroll);
|
|
64
|
+
this.#cancelFrame();
|
|
65
|
+
this.#persist();
|
|
66
|
+
}
|
|
67
|
+
/** Re-derives the namespace when application code or a Turbo morph moves `key`. */
|
|
68
|
+
keyValueChanged() {
|
|
69
|
+
this.#resync();
|
|
70
|
+
}
|
|
71
|
+
/** Re-derives the tracked axes when application code or a Turbo morph moves `axis`. */
|
|
72
|
+
axisValueChanged() {
|
|
73
|
+
this.#resync();
|
|
74
|
+
}
|
|
75
|
+
/** Rebuilds the persistence state around the Values as they now read. */
|
|
76
|
+
#resync() {
|
|
77
|
+
if (!this.#connected) return;
|
|
78
|
+
this.#cancelFrame();
|
|
79
|
+
this.#storageKey = this.#resolveKey();
|
|
80
|
+
this.#echoTop = null;
|
|
81
|
+
this.#echoLeft = null;
|
|
82
|
+
this.#carried = {};
|
|
83
|
+
this.#capturedTop = false;
|
|
84
|
+
this.#capturedLeft = false;
|
|
85
|
+
if (this.#storageKey) this.#restore();
|
|
86
|
+
}
|
|
87
|
+
/** Drops the pending coalesced save, if one is queued. */
|
|
88
|
+
#cancelFrame() {
|
|
33
89
|
if (this.#rafId !== null) {
|
|
34
90
|
cancelAnimationFrame(this.#rafId);
|
|
35
91
|
this.#rafId = null;
|
|
36
92
|
}
|
|
37
|
-
this.#persist();
|
|
38
93
|
}
|
|
39
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* Records the live scroll offset for the configured axis.
|
|
96
|
+
*
|
|
97
|
+
* An axis holds its echo until that axis actually moves. A scroll event names
|
|
98
|
+
* no axis, so consuming both on the first one to arrive would leave the other
|
|
99
|
+
* unguarded: on `both`, moving one axis would take the clamped reading of the
|
|
100
|
+
* untouched axis as the reader's own position and save it, cutting the stored
|
|
101
|
+
* offset down to whatever the layout could reach at restore time.
|
|
102
|
+
*/
|
|
40
103
|
#capture() {
|
|
41
|
-
if (this.#tracksVertical)
|
|
42
|
-
|
|
104
|
+
if (this.#tracksVertical) {
|
|
105
|
+
const top = this.element.scrollTop;
|
|
106
|
+
if (top !== this.#echoTop) {
|
|
107
|
+
this.#echoTop = null;
|
|
108
|
+
this.#lastTop = top;
|
|
109
|
+
this.#capturedTop = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (this.#tracksHorizontal) {
|
|
113
|
+
const left = this.element.scrollLeft;
|
|
114
|
+
if (left !== this.#echoLeft) {
|
|
115
|
+
this.#echoLeft = null;
|
|
116
|
+
this.#lastLeft = left;
|
|
117
|
+
this.#capturedLeft = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
43
120
|
}
|
|
44
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* Writes the marked axes, carrying through any offset held for an untracked one.
|
|
123
|
+
* Without a key, or with neither axis marked, it writes nothing.
|
|
124
|
+
*/
|
|
45
125
|
#persist() {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (this.#
|
|
126
|
+
if (!this.#storageKey || !(this.#capturedTop || this.#capturedLeft)) return;
|
|
127
|
+
const data = { ...this.#carried };
|
|
128
|
+
if (this.#tracksVertical && this.#capturedTop) data.top = this.#lastTop;
|
|
129
|
+
if (this.#tracksHorizontal && this.#capturedLeft) data.left = this.#lastLeft;
|
|
49
130
|
try {
|
|
50
|
-
sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
|
|
131
|
+
window.sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
|
|
51
132
|
} catch {
|
|
52
133
|
}
|
|
53
134
|
}
|
|
54
135
|
/** Applies the persisted scroll offset, if any, without moving focus. */
|
|
55
136
|
#restore() {
|
|
56
|
-
let raw
|
|
57
|
-
try {
|
|
58
|
-
raw = sessionStorage.getItem(this.#storageKey);
|
|
59
|
-
} catch {
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
if (raw === null) return;
|
|
63
|
-
let data;
|
|
137
|
+
let raw;
|
|
64
138
|
try {
|
|
65
|
-
|
|
139
|
+
raw = window.sessionStorage.getItem(this.#storageKey);
|
|
66
140
|
} catch {
|
|
67
141
|
return;
|
|
68
142
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
143
|
+
const data = parseStored(raw);
|
|
144
|
+
if (data === null) return;
|
|
145
|
+
this.#echoTop = null;
|
|
146
|
+
this.#echoLeft = null;
|
|
147
|
+
const top = storedOffset(data.top);
|
|
148
|
+
const left = storedOffset(data.left);
|
|
149
|
+
const options = { behavior: "instant" };
|
|
150
|
+
let requested = false;
|
|
151
|
+
if (this.#tracksVertical) {
|
|
152
|
+
if (top !== null) {
|
|
153
|
+
options.top = top;
|
|
154
|
+
this.#lastTop = top;
|
|
155
|
+
this.#capturedTop = true;
|
|
156
|
+
requested = true;
|
|
157
|
+
}
|
|
158
|
+
} else if (data.top !== void 0) {
|
|
159
|
+
this.#carried.top = data.top;
|
|
72
160
|
}
|
|
73
|
-
if (this.#tracksHorizontal
|
|
74
|
-
|
|
75
|
-
|
|
161
|
+
if (this.#tracksHorizontal) {
|
|
162
|
+
if (left !== null) {
|
|
163
|
+
options.left = left;
|
|
164
|
+
this.#lastLeft = left;
|
|
165
|
+
this.#capturedLeft = true;
|
|
166
|
+
requested = true;
|
|
167
|
+
}
|
|
168
|
+
} else if (data.left !== void 0) {
|
|
169
|
+
this.#carried.left = data.left;
|
|
76
170
|
}
|
|
171
|
+
if (!requested) return;
|
|
172
|
+
const beforeTop = this.element.scrollTop;
|
|
173
|
+
const beforeLeft = this.element.scrollLeft;
|
|
174
|
+
this.element.scrollTo(options);
|
|
175
|
+
if (this.element.scrollTop !== beforeTop) this.#echoTop = this.element.scrollTop;
|
|
176
|
+
if (this.element.scrollLeft !== beforeLeft) this.#echoLeft = this.element.scrollLeft;
|
|
77
177
|
}
|
|
78
178
|
/** The `sessionStorage` key: explicit `key`, else the element `id`, else none. */
|
|
79
179
|
#resolveKey() {
|