stimeo-ui 0.8.0 → 0.9.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.
@@ -8,6 +8,39 @@ function isReservedArrowChord(event, allow = []) {
8
8
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
9
  }
10
10
 
11
+ // src/utils/microtask_coalescer.ts
12
+ var MicrotaskCoalescer = class {
13
+ #run;
14
+ #queued = false;
15
+ #active = false;
16
+ #generation = 0;
17
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
18
+ constructor(run) {
19
+ this.#run = run;
20
+ }
21
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
22
+ activate() {
23
+ this.#active = true;
24
+ }
25
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
26
+ cancel() {
27
+ this.#active = false;
28
+ this.#queued = false;
29
+ this.#generation += 1;
30
+ }
31
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
32
+ schedule() {
33
+ if (!this.#active || this.#queued) return;
34
+ this.#queued = true;
35
+ const generation = this.#generation;
36
+ queueMicrotask(() => {
37
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
38
+ this.#queued = false;
39
+ this.#run();
40
+ });
41
+ }
42
+ };
43
+
11
44
  // src/utils/before_cache_reset.ts
12
45
  var BeforeCacheReset = class _BeforeCacheReset {
13
46
  /** Every subscribed instance, iterated by the one shared document listener. */
@@ -69,26 +102,34 @@ var TabindexLoan = class {
69
102
  };
70
103
 
71
104
  // src/controllers/resizable_controller.ts
105
+ var DEFAULT_MIN = 0;
106
+ var DEFAULT_MAX = 100;
107
+ var DEFAULT_VALUE = 50;
108
+ var DEFAULT_STEP = 1;
72
109
  var ResizableController = class extends Controller {
73
110
  static targets = ["primary", "secondary", "separator"];
74
111
  static values = {
75
- min: { type: Number, default: 0 },
76
- max: { type: Number, default: 100 },
77
- step: { type: Number, default: 1 },
78
- value: { type: Number, default: 50 }
112
+ min: { type: Number, default: DEFAULT_MIN },
113
+ max: { type: Number, default: DEFAULT_MAX },
114
+ step: { type: Number, default: DEFAULT_STEP },
115
+ value: { type: Number, default: DEFAULT_VALUE }
79
116
  };
80
117
  static actions = ["onKeydown", "onPointerDown", "toggle"];
81
118
  static events = ["change"];
82
- /** The value held before the current collapse, restored when toggling back. */
83
- #valueBeforeCollapse = 50;
119
+ /** Where the divider sat before the current collapse; `null` when unknown. */
120
+ #valueBeforeCollapse = null;
84
121
  /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
85
122
  #dragAbort = null;
86
123
  /** `tabindex` lent to a pane so `F6` can put focus on it; panes carry none. */
87
124
  #paneTabindex = new TabindexLoan();
88
125
  /** Releases the pane cycle listener bound in {@link connect}. */
89
126
  #cycleAbort = null;
127
+ /** Folds the range and position inputs of one morph batch into a single paint. */
128
+ #repaint = new MicrotaskCoalescer(() => this.#render());
90
129
  connect() {
91
- this.#clampAndSync();
130
+ this.element.removeAttribute("data-dragging");
131
+ this.#repaint.activate();
132
+ this.#render();
92
133
  this.#cycleAbort = new AbortController();
93
134
  this.element.addEventListener("keydown", this.#onCycleKeydown, {
94
135
  signal: this.#cycleAbort.signal
@@ -100,17 +141,19 @@ var ResizableController = class extends Controller {
100
141
  this.#dragAbort = null;
101
142
  this.#cycleAbort?.abort();
102
143
  this.#cycleAbort = null;
144
+ this.#repaint.cancel();
103
145
  this.#paneTabindex.returnAll();
146
+ this.element.removeAttribute("data-dragging");
104
147
  }
105
148
  /** Moves focus to the next pane on `F6`, wrapping; entering at the first one. */
106
149
  #onCycleKeydown = (event) => {
107
150
  if (event.key !== "F6") return;
151
+ if (event.isComposing) return;
108
152
  if (event.defaultPrevented) return;
109
153
  if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
110
154
  const panes = [];
111
155
  if (this.hasPrimaryTarget) panes.push(this.primaryTarget);
112
156
  if (this.hasSecondaryTarget) panes.push(this.secondaryTarget);
113
- if (panes.length === 0) return;
114
157
  const target = event.target;
115
158
  const current = panes.findIndex((pane) => target instanceof Node && pane.contains(target));
116
159
  const next = panes[(current + 1) % panes.length];
@@ -119,12 +162,21 @@ var ResizableController = class extends Controller {
119
162
  this.#paneTabindex.lend(next);
120
163
  next.focus();
121
164
  };
122
- /**
123
- * Stimulus lifecycle callback when the valueValue changes.
124
- * Keeps CSS fractions and ARIA status completely aligned.
125
- */
165
+ /** Re-renders when application code (or a Turbo morph) changes `value` at runtime. */
126
166
  valueValueChanged() {
127
- this.#clampAndSync();
167
+ this.#repaint.schedule();
168
+ }
169
+ /** Re-renders when application code (or a Turbo morph) changes `min` at runtime. */
170
+ minValueChanged() {
171
+ this.#repaint.schedule();
172
+ }
173
+ /** Re-renders when application code (or a Turbo morph) changes `max` at runtime. */
174
+ maxValueChanged() {
175
+ this.#repaint.schedule();
176
+ }
177
+ /** Re-publishes range and position onto a separator swapped in after connect. */
178
+ separatorTargetConnected() {
179
+ this.#repaint.schedule();
128
180
  }
129
181
  /** Starts active pointer drag tracking and locks capture. */
130
182
  onPointerDown(event) {
@@ -145,44 +197,44 @@ var ResizableController = class extends Controller {
145
197
  onKeydown(event) {
146
198
  if (isReservedArrowChord(event)) return;
147
199
  if (!this.hasSeparatorTarget) return;
148
- const orientation = this.separatorTarget.getAttribute("aria-orientation") || "vertical";
149
- const isVertical = orientation === "vertical";
200
+ const { min, max } = this.#range;
201
+ const isVertical = this.#isVertical;
150
202
  let handled = true;
151
- let nextValue = this.valueValue;
203
+ let nextValue = this.#position;
152
204
  switch (event.key) {
153
205
  case "ArrowLeft":
154
206
  if (isVertical) {
155
- nextValue -= this.stepValue;
207
+ nextValue -= this.#step;
156
208
  } else {
157
209
  handled = false;
158
210
  }
159
211
  break;
160
212
  case "ArrowRight":
161
213
  if (isVertical) {
162
- nextValue += this.stepValue;
214
+ nextValue += this.#step;
163
215
  } else {
164
216
  handled = false;
165
217
  }
166
218
  break;
167
219
  case "ArrowUp":
168
220
  if (!isVertical) {
169
- nextValue -= this.stepValue;
221
+ nextValue -= this.#step;
170
222
  } else {
171
223
  handled = false;
172
224
  }
173
225
  break;
174
226
  case "ArrowDown":
175
227
  if (!isVertical) {
176
- nextValue += this.stepValue;
228
+ nextValue += this.#step;
177
229
  } else {
178
230
  handled = false;
179
231
  }
180
232
  break;
181
233
  case "Home":
182
- nextValue = this.minValue;
234
+ nextValue = min;
183
235
  break;
184
236
  case "End":
185
- nextValue = this.maxValue;
237
+ nextValue = max;
186
238
  break;
187
239
  case "Enter":
188
240
  event.preventDefault();
@@ -194,65 +246,86 @@ var ResizableController = class extends Controller {
194
246
  }
195
247
  if (handled) {
196
248
  event.preventDefault();
197
- this.valueValue = Math.max(this.minValue, Math.min(nextValue, this.maxValue));
198
- this.#clampAndSync();
249
+ this.valueValue = Math.max(min, Math.min(nextValue, max));
250
+ this.#render();
199
251
  this.#dispatchChange();
200
252
  }
201
253
  }
202
- /** Double-click or Enter to collapse/restore pane to min/max levels. */
254
+ /** Double-click or Enter to collapse the primary pane, or put it back. */
203
255
  toggle() {
204
- const threshold = this.minValue + (this.maxValue - this.minValue) / 2;
205
- if (this.valueValue > this.minValue) {
206
- this.#valueBeforeCollapse = this.valueValue;
207
- this.valueValue = this.minValue;
256
+ const { min, max } = this.#range;
257
+ if (this.#position > min) {
258
+ this.#valueBeforeCollapse = this.#position;
259
+ this.valueValue = min;
208
260
  } else {
209
- this.valueValue = this.#valueBeforeCollapse >= threshold ? this.#valueBeforeCollapse : this.maxValue;
261
+ this.valueValue = this.#valueBeforeCollapse ?? max;
262
+ this.#valueBeforeCollapse = null;
210
263
  }
211
- this.#clampAndSync();
264
+ this.#render();
212
265
  this.#dispatchChange();
213
266
  }
214
267
  #onPointerMove = (event) => {
215
268
  if (!this.hasSeparatorTarget) return;
216
269
  const rect = this.element.getBoundingClientRect();
217
- const orientation = this.separatorTarget.getAttribute("aria-orientation") || "vertical";
218
- const isVertical = orientation === "vertical";
219
- let fraction = 0.5;
220
- if (isVertical) {
221
- fraction = (event.clientX - rect.left) / rect.width;
222
- } else {
223
- fraction = (event.clientY - rect.top) / rect.height;
224
- }
225
- fraction = Math.max(0, Math.min(fraction, 1));
226
- const percent = Math.round(fraction * 100);
227
- this.valueValue = Math.max(this.minValue, Math.min(percent, this.maxValue));
228
- this.#clampAndSync();
270
+ const raw = this.#isVertical ? (event.clientX - rect.left) / rect.width : (event.clientY - rect.top) / rect.height;
271
+ const fraction = Number.isFinite(raw) ? Math.max(0, Math.min(raw, 1)) : 0;
272
+ const { min, max } = this.#range;
273
+ this.valueValue = Math.max(min, Math.min(Math.round(fraction * 100), max));
274
+ this.#render();
229
275
  };
230
276
  #onPointerUp = (event) => {
231
- if (!this.hasSeparatorTarget) return;
232
- const separator = this.separatorTarget;
233
- separator.releasePointerCapture(event.pointerId);
234
277
  this.element.removeAttribute("data-dragging");
235
278
  this.#dragAbort?.abort();
236
279
  this.#dragAbort = null;
280
+ if (this.hasSeparatorTarget) {
281
+ this.separatorTarget.releasePointerCapture(event.pointerId);
282
+ }
237
283
  this.#dispatchChange();
238
284
  };
239
- #clampAndSync() {
240
- const clamped = Math.max(this.minValue, Math.min(this.valueValue, this.maxValue));
241
- if (this.valueValue !== clamped) {
242
- this.valueValue = clamped;
285
+ /**
286
+ * Publishes the position: the fraction consumer CSS multiplies a pane by, and
287
+ * the range assistive tech reads off the separator. The clamped position is
288
+ * written back to the Value so it, ARIA, CSS, and dispatched events agree.
289
+ *
290
+ * @stimeoRenderRoot
291
+ */
292
+ #render() {
293
+ const { min, max } = this.#range;
294
+ const position = this.#position;
295
+ if (this.valueValue !== position) {
296
+ this.valueValue = position;
243
297
  }
244
- const fraction = clamped / 100;
245
- this.element.style.setProperty("--stimeo--resizable-fraction", String(fraction));
298
+ this.element.style.setProperty("--stimeo--resizable-fraction", String(position / 100));
246
299
  if (this.hasSeparatorTarget) {
247
- this.separatorTarget.setAttribute("aria-valuenow", String(clamped));
248
- this.separatorTarget.setAttribute("aria-valuemin", String(this.minValue));
249
- this.separatorTarget.setAttribute("aria-valuemax", String(this.maxValue));
300
+ this.separatorTarget.setAttribute("aria-valuenow", String(position));
301
+ this.separatorTarget.setAttribute("aria-valuemin", String(min));
302
+ this.separatorTarget.setAttribute("aria-valuemax", String(max));
250
303
  }
251
304
  }
252
305
  #dispatchChange() {
253
306
  const fraction = this.valueValue / 100;
254
307
  this.dispatch("change", { detail: { value: this.valueValue, fraction } });
255
308
  }
309
+ /** The declared range after validation; an unreadable bound uses its default. */
310
+ get #range() {
311
+ const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN;
312
+ const max = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX;
313
+ return { min, max: Math.max(min, max) };
314
+ }
315
+ /** The declared position, clamped into the validated range. */
316
+ get #position() {
317
+ const { min, max } = this.#range;
318
+ const value = Number.isFinite(this.valueValue) ? this.valueValue : DEFAULT_VALUE;
319
+ return Math.max(min, Math.min(value, max));
320
+ }
321
+ /** The keyboard increment; a non-positive or unreadable one uses the default. */
322
+ get #step() {
323
+ return Number.isFinite(this.stepValue) && this.stepValue > 0 ? this.stepValue : DEFAULT_STEP;
324
+ }
325
+ /** Whether the divider runs vertically; a separator that does not say is horizontal. */
326
+ get #isVertical() {
327
+ return this.hasSeparatorTarget && this.separatorTarget.getAttribute("aria-orientation") === "vertical";
328
+ }
256
329
  };
257
330
 
258
331
  export { ResizableController };