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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/dist/cable/index.js +226 -43
  4. data/dist/controllers/alert_dialog_controller.js +17 -7
  5. data/dist/controllers/announcer_controller.js +1 -1
  6. data/dist/controllers/bulk_select_controller.js +2 -2
  7. data/dist/controllers/collapsible_controller.js +1 -1
  8. data/dist/controllers/command_palette_controller.js +15 -5
  9. data/dist/controllers/confirm_controller.js +15 -5
  10. data/dist/controllers/countdown_controller.js +1 -1
  11. data/dist/controllers/data_grid_controller.js +1 -1
  12. data/dist/controllers/dialog_controller.js +15 -5
  13. data/dist/controllers/direct_upload_controller.js +1 -5
  14. data/dist/controllers/drawer_controller.js +17 -7
  15. data/dist/controllers/focus_controller.js +45 -7
  16. data/dist/controllers/form_field_controller.js +1 -1
  17. data/dist/controllers/menubar_controller.js +2 -2
  18. data/dist/controllers/multi_select_controller.js +1 -1
  19. data/dist/controllers/overflow_menu_controller.js +2 -2
  20. data/dist/controllers/password_reveal_controller.js +102 -3
  21. data/dist/controllers/password_strength_controller.js +287 -42
  22. data/dist/controllers/pointer_drag_controller.js +64 -18
  23. data/dist/controllers/portal_controller.js +32 -11
  24. data/dist/controllers/preview_guard_controller.js +180 -20
  25. data/dist/controllers/resizable_controller.js +1 -1
  26. data/dist/controllers/roving_controller.js +127 -14
  27. data/dist/controllers/scroll_restore_controller.js +127 -27
  28. data/dist/controllers/scroll_visibility_controller.js +167 -14
  29. data/dist/controllers/sidebar_controller.js +15 -5
  30. data/dist/controllers/step_indicator_controller.js +0 -5
  31. data/dist/controllers/stick_to_bottom_controller.js +149 -28
  32. data/dist/controllers/theme_controller.js +151 -40
  33. data/dist/controllers/toast_controller.js +2 -2
  34. data/dist/controllers/transition_controller.js +79 -15
  35. data/dist/index.js +1188 -318
  36. data/dist/positioning/index.js +84 -25
  37. data/lib/stimeo/ui/version.rb +1 -1
  38. 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 the polite live-region label is written, to throttle SR flooding. */
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.#update({ announce: false });
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.#timers.clearAll();
98
- this.#announceId = null;
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.#update();
204
+ this.#externalScore = null;
205
+ this.#commit();
103
206
  }
104
207
  /**
105
- * Recomputes the strength. The meter ARIA, data hooks, the custom property and
106
- * the `change` event apply immediately; the live-region label text is debounced
107
- * unless `announce` is `false` (the initial render).
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
- #update(options = {}) {
110
- const password = this.hasInputTarget ? this.inputTarget.value : "";
111
- const labels = parseStringList(this.levelsValue, DEFAULT_LEVELS);
112
- const max = labels.length;
113
- const score = this.#score(password, max);
114
- const label = score > 0 ? labels[score - 1] ?? "" : "";
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
- if (options.announce === false) {
118
- this.#writeLabel(label);
119
- return;
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.dispatch("change", {
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 && score < this.minScoreValue);
146
- const ratio = max > 0 ? score / max : 0;
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
- #writeLabel(label) {
150
- if (this.hasLabelTarget) this.labelTarget.textContent = label;
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 → `""`. Quantizes the `score/max` ratio into the
155
- * four fixed bands, so a non-default level count still maps onto a stable hook.
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 || max <= 0) return "";
159
- const index = Math.ceil(score / max * STRENGTH_BANDS.length) - 1;
160
- return STRENGTH_BANDS[Math.min(STRENGTH_BANDS.length - 1, Math.max(0, index))] ?? "";
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 || max === 0) return 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
- /** Prepares handles added at runtime (touch-action + focusability). */
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
- /** Re-derives the handles' touch-action when the axis changes. */
121
- axisValueChanged() {
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
- handle.style.touchAction = this.#touchActionForAxis();
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
- if (this.axisValue === "x") return "pan-y";
387
- if (this.axisValue === "y") return "pan-x";
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
- if (handle.style.touchAction === "" || handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
399
- handle.style.touchAction = this.#touchActionForAxis();
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.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
415
- if (handle.getAttribute("tabindex") === "0" && document.activeElement !== handle) {
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
- if (portalState.has(this.element)) return;
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 for `to`, tolerating an invalid selector. */
142
+ /** Resolves the destination from the validated `to` selector. */
116
143
  #destination() {
117
- const selector = this.toValue.trim();
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