stimeo-ui 0.9.0 → 0.11.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 +149 -0
- data/dist/cable/index.js +226 -43
- 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/pointer_drag_controller.js +64 -18
- data/dist/controllers/portal_controller.js +32 -11
- data/dist/controllers/preview_guard_controller.js +180 -20
- data/dist/controllers/resizable_controller.js +1 -1
- data/dist/controllers/roving_controller.js +127 -14
- 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/stick_to_bottom_controller.js +149 -28
- 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 +1188 -318
- 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 };
|
|
@@ -64,6 +64,7 @@ var DetachGate = class _DetachGate {
|
|
|
64
64
|
};
|
|
65
65
|
|
|
66
66
|
// src/controllers/pointer_drag_controller.ts
|
|
67
|
+
var NATIVE_KEY_OWNERS = "input, textarea, select, button, a[href], summary, [contenteditable]";
|
|
67
68
|
var PointerDragController = class _PointerDragController extends Controller {
|
|
68
69
|
static targets = ["handle"];
|
|
69
70
|
static values = {
|
|
@@ -73,6 +74,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
73
74
|
disabled: { type: Boolean, default: false },
|
|
74
75
|
follow: { type: Boolean, default: false }
|
|
75
76
|
};
|
|
77
|
+
static actions = ["reset"];
|
|
76
78
|
static events = ["start", "move", "end", "cancel"];
|
|
77
79
|
/** Marker attribute recording that this controller set the touch-action. */
|
|
78
80
|
static #TOUCH_ACTION_MARKER = "data-pointer-drag-touch-action";
|
|
@@ -106,23 +108,49 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
106
108
|
}
|
|
107
109
|
this.#teardown();
|
|
108
110
|
}
|
|
109
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Returns the element to its origin: drops the committed follow offset and the
|
|
113
|
+
* inline `translate` that carries it.
|
|
114
|
+
*
|
|
115
|
+
* In follow mode the inline `translate` belongs to this controller, and the
|
|
116
|
+
* committed offset lives in a field the DOM cannot reach — so a consumer that
|
|
117
|
+
* wants the element back at the start, or that has written a position of its
|
|
118
|
+
* own, needs this to make the two agree again. An in-flight drag is cancelled
|
|
119
|
+
* first, or its deltas would land on top of the offset just cleared.
|
|
120
|
+
*/
|
|
121
|
+
reset() {
|
|
122
|
+
const live = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
|
|
123
|
+
if (this.#pointer || this.#keyboard) this.#teardownSessions();
|
|
124
|
+
if (live) this.#dispatchCancel(live);
|
|
125
|
+
this.#followBase = { x: 0, y: 0 };
|
|
126
|
+
this.#applyFollow(0, 0);
|
|
127
|
+
}
|
|
110
128
|
handleTargetConnected(handle) {
|
|
129
|
+
if (handle !== this.element) this.#restoreHandle(this.element);
|
|
111
130
|
this.#prepareHandle(handle);
|
|
112
131
|
}
|
|
113
132
|
handleTargetDisconnected(handle) {
|
|
114
|
-
if (!this.element.contains(handle)) {
|
|
115
|
-
if (this.#pointer?.handle === handle) this.#endPointerSession();
|
|
116
|
-
if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
|
|
117
|
-
}
|
|
118
133
|
this.#restoreHandle(handle);
|
|
134
|
+
if (this.element.contains(handle)) return;
|
|
135
|
+
const interrupted = this.#pointer?.handle === handle && this.#pointer.started ? this.#pointer.pointerType : this.#keyboard?.handle === handle ? "keyboard" : null;
|
|
136
|
+
if (this.#pointer?.handle === handle) this.#endPointerSession();
|
|
137
|
+
if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
|
|
138
|
+
if (!this.hasHandleTarget) this.#prepareHandle(this.element);
|
|
139
|
+
if (interrupted) this.#dispatchCancel(interrupted);
|
|
119
140
|
}
|
|
120
|
-
/**
|
|
121
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Re-derives the handles' touch-action when the axis changes.
|
|
143
|
+
*
|
|
144
|
+
* Two layers, like the `tabindex` loan: the marker says the value was once
|
|
145
|
+
* ours, and the current inline value says it still is. A consumer that wrote
|
|
146
|
+
* its own `touch-action` after connect keeps it.
|
|
147
|
+
*/
|
|
148
|
+
axisValueChanged(_value, previousValue) {
|
|
149
|
+
const lent = this.#touchActionFor(previousValue ?? this.axisValue);
|
|
122
150
|
for (const handle of this.#handles()) {
|
|
123
|
-
if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER))
|
|
124
|
-
|
|
125
|
-
|
|
151
|
+
if (!handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) continue;
|
|
152
|
+
if (handle.style.touchAction !== lent) continue;
|
|
153
|
+
handle.style.touchAction = this.#touchActionForAxis();
|
|
126
154
|
}
|
|
127
155
|
}
|
|
128
156
|
/** Cancels any in-flight session when the controller is disabled mid-drag. */
|
|
@@ -203,9 +231,10 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
203
231
|
const handle = this.#handleFor(event.target);
|
|
204
232
|
if (!handle) return;
|
|
205
233
|
if (event.defaultPrevented) return;
|
|
234
|
+
if (event.isComposing) return;
|
|
235
|
+
if (this.#ownsNativeKeys(event.target, handle)) return;
|
|
206
236
|
if (isReservedArrowChord(event)) return;
|
|
207
237
|
if (event.key === "Escape") {
|
|
208
|
-
if (event.isComposing) return;
|
|
209
238
|
if (this.#pointer?.started) {
|
|
210
239
|
const { pointerType } = this.#pointer;
|
|
211
240
|
this.#teardownSessions();
|
|
@@ -314,6 +343,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
314
343
|
const interrupted = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
|
|
315
344
|
this.#teardownSessions();
|
|
316
345
|
for (const handle of this.#handles()) this.#restoreHandle(handle);
|
|
346
|
+
this.#followReset();
|
|
317
347
|
if (interrupted && this.element.isConnected) this.#dispatchCancel(interrupted);
|
|
318
348
|
}
|
|
319
349
|
/**
|
|
@@ -383,8 +413,12 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
383
413
|
}
|
|
384
414
|
/** `touch-action` that lets the page keep panning on the locked axis only. */
|
|
385
415
|
#touchActionForAxis() {
|
|
386
|
-
|
|
387
|
-
|
|
416
|
+
return this.#touchActionFor(this.axisValue);
|
|
417
|
+
}
|
|
418
|
+
/** The `touch-action` an `axis` declaration lends, for any axis value. */
|
|
419
|
+
#touchActionFor(axis) {
|
|
420
|
+
if (axis === "x") return "pan-y";
|
|
421
|
+
if (axis === "y") return "pan-x";
|
|
388
422
|
return "none";
|
|
389
423
|
}
|
|
390
424
|
/**
|
|
@@ -395,8 +429,10 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
395
429
|
* are marker-owned so `#restoreHandle` reverts them symmetrically on teardown.
|
|
396
430
|
*/
|
|
397
431
|
#prepareHandle(handle) {
|
|
398
|
-
|
|
399
|
-
|
|
432
|
+
const lent = this.#touchActionForAxis();
|
|
433
|
+
const marked = handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
|
|
434
|
+
if (handle.style.touchAction === "" || marked && handle.style.touchAction === lent) {
|
|
435
|
+
handle.style.touchAction = lent;
|
|
400
436
|
handle.setAttribute(_PointerDragController.#TOUCH_ACTION_MARKER, "true");
|
|
401
437
|
}
|
|
402
438
|
if (handle.tabIndex < 0 && !handle.hasAttribute("tabindex")) {
|
|
@@ -404,17 +440,27 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
404
440
|
handle.setAttribute(_PointerDragController.#TABINDEX_MARKER, "true");
|
|
405
441
|
}
|
|
406
442
|
}
|
|
443
|
+
/** Whether the key belongs to a native control or editing surface in the handle. */
|
|
444
|
+
#ownsNativeKeys(target, handle) {
|
|
445
|
+
const owner = target?.closest(NATIVE_KEY_OWNERS) ?? null;
|
|
446
|
+
return owner !== null && owner !== handle && handle.contains(owner);
|
|
447
|
+
}
|
|
407
448
|
/** Reverts the marker-owned touch-action + tabindex (authored values untouched). */
|
|
408
449
|
#restoreHandle(handle) {
|
|
409
450
|
if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
|
|
410
|
-
handle.style.touchAction
|
|
451
|
+
if (handle.style.touchAction === this.#touchActionForAxis()) {
|
|
452
|
+
handle.style.touchAction = "";
|
|
453
|
+
}
|
|
411
454
|
handle.removeAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
|
|
412
455
|
}
|
|
413
456
|
if (handle.hasAttribute(_PointerDragController.#TABINDEX_MARKER)) {
|
|
414
|
-
handle.
|
|
415
|
-
if (
|
|
457
|
+
const ours = handle.getAttribute("tabindex") === "0";
|
|
458
|
+
if (ours && document.activeElement !== handle) {
|
|
416
459
|
handle.removeAttribute("tabindex");
|
|
460
|
+
handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
|
|
461
|
+
return;
|
|
417
462
|
}
|
|
463
|
+
if (!ours) handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
|
|
418
464
|
}
|
|
419
465
|
}
|
|
420
466
|
};
|
|
@@ -69,15 +69,42 @@ var PortalController = class extends Controller {
|
|
|
69
69
|
static events = ["mount", "unmount"];
|
|
70
70
|
/** Decides whether a `disconnect()` is an in-page move or a real detach. */
|
|
71
71
|
#gate = new DetachGate();
|
|
72
|
+
/** The `to` declaration after validation; the default when it cannot be parsed. */
|
|
73
|
+
#toSelector = "body";
|
|
74
|
+
/**
|
|
75
|
+
* Validates the destination declaration once, keeping only a selector the engine can
|
|
76
|
+
* read.
|
|
77
|
+
*
|
|
78
|
+
* A selector reads back as an ordinary string, so a malformed one survives until it is
|
|
79
|
+
* handed to the DOM — where it would take the whole teleport down and leave the part
|
|
80
|
+
* silently doing nothing. Falling back to the default puts the node somewhere visible
|
|
81
|
+
* instead, which reads as a mistake and can be traced back to the declaration.
|
|
82
|
+
*/
|
|
83
|
+
toValueChanged() {
|
|
84
|
+
const selector = this.toValue;
|
|
85
|
+
if (selector.length > 0) {
|
|
86
|
+
try {
|
|
87
|
+
this.element.matches(selector);
|
|
88
|
+
this.#toSelector = selector;
|
|
89
|
+
return;
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
this.#toSelector = "body";
|
|
94
|
+
}
|
|
72
95
|
connect() {
|
|
73
96
|
this.#gate.cancel();
|
|
74
|
-
|
|
97
|
+
const existing = portalState.get(this.element);
|
|
98
|
+
if (existing) {
|
|
99
|
+
existing.owner = this;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
75
102
|
const node = this.hasContentTarget ? this.contentTarget : this.element;
|
|
76
103
|
const destination = this.#destination();
|
|
77
104
|
if (!destination || destination === node || node.contains(destination)) return;
|
|
78
105
|
const placeholder = document.createComment("stimeo--portal");
|
|
79
106
|
node.parentNode?.insertBefore(placeholder, node);
|
|
80
|
-
portalState.set(this.element, { node, placeholder });
|
|
107
|
+
portalState.set(this.element, { node, placeholder, owner: this });
|
|
81
108
|
if (this.positionValue === "prepend") {
|
|
82
109
|
destination.prepend(node);
|
|
83
110
|
} else {
|
|
@@ -96,7 +123,7 @@ var PortalController = class extends Controller {
|
|
|
96
123
|
}
|
|
97
124
|
this.#gate.disconnected(this, () => {
|
|
98
125
|
const current = portalState.get(this.element);
|
|
99
|
-
if (current) this.#restore(current);
|
|
126
|
+
if (current?.owner === this) this.#restore(current);
|
|
100
127
|
});
|
|
101
128
|
}
|
|
102
129
|
/** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */
|
|
@@ -112,15 +139,9 @@ var PortalController = class extends Controller {
|
|
|
112
139
|
placeholder.remove();
|
|
113
140
|
this.dispatch("unmount", { detail: {} });
|
|
114
141
|
}
|
|
115
|
-
/** Resolves the destination
|
|
142
|
+
/** Resolves the destination from the validated `to` selector. */
|
|
116
143
|
#destination() {
|
|
117
|
-
|
|
118
|
-
if (!selector) return null;
|
|
119
|
-
try {
|
|
120
|
-
return document.querySelector(selector);
|
|
121
|
-
} catch {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
144
|
+
return document.querySelector(this.#toSelector);
|
|
124
145
|
}
|
|
125
146
|
};
|
|
126
147
|
|