stimeo-ui 0.7.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.
@@ -2,6 +2,46 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/clipboard_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
+
16
+ // src/utils/before_cache_reset.ts
17
+ var BeforeCacheReset = class _BeforeCacheReset {
18
+ /** Every subscribed instance, iterated by the one shared document listener. */
19
+ static #subscribers = /* @__PURE__ */ new Set();
20
+ /** The shared listener; installed while at least one instance is subscribed. */
21
+ static #onBeforeCache = () => {
22
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
23
+ };
24
+ #rewind;
25
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
26
+ constructor(rewind) {
27
+ this.#rewind = rewind;
28
+ }
29
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
30
+ activate() {
31
+ const first = _BeforeCacheReset.#subscribers.size === 0;
32
+ _BeforeCacheReset.#subscribers.add(this);
33
+ if (first) {
34
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
35
+ }
36
+ }
37
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
38
+ deactivate() {
39
+ _BeforeCacheReset.#subscribers.delete(this);
40
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
41
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
42
+ }
43
+ };
44
+
5
45
  // src/utils/default_attribute.ts
6
46
  function setDefaultAttribute(element, name, value) {
7
47
  if (element.hasAttribute(name)) return false;
@@ -63,45 +103,59 @@ var SafeTimeout = class extends TimerRegistry {
63
103
  };
64
104
 
65
105
  // src/controllers/clipboard_controller.ts
106
+ var TRANSIENT_STATES = /* @__PURE__ */ new Set(["copied", "error"]);
66
107
  var ClipboardController = class extends Controller {
67
108
  static targets = ["source", "button", "feedback"];
68
109
  static values = {
69
110
  text: { type: String, default: "" },
70
111
  feedbackDuration: { type: Number, default: 2e3 },
71
112
  copiedLabel: { type: String, default: "Copied" },
72
- errorLabel: { type: String, default: "Copy failed" }
113
+ errorLabel: { type: String, default: "Copy failed" },
114
+ announceCopiedText: { type: String, default: "" },
115
+ announceErrorText: { type: String, default: "" }
73
116
  };
74
117
  static actions = ["copy"];
75
118
  static events = ["copy"];
76
- /** Auto-clear timer for the completion notice; torn down on disconnect. */
119
+ /**
120
+ * The pending return to idle — the only timer this controller schedules, so
121
+ * `clearAll()` is exactly "drop the auto-clear" and needs no id of its own.
122
+ */
77
123
  #timers = new SafeTimeout();
124
+ /** Returns the completion state to idle for the snapshot Turbo takes. */
125
+ #beforeCache = new BeforeCacheReset(() => this.#rewind());
78
126
  /**
79
- * The pending auto-clear timer id, or `null` when none is scheduled. Tracked so
80
- * a rapid second copy cancels the first window instead of letting a stale timer
81
- * reset the freshly-shown notice early.
127
+ * Whether this connection is still live. `copy()` suspends on the Clipboard API,
128
+ * and a teardown that lands while it is suspended must win: the continuation
129
+ * would otherwise write to an element nobody owns and arm a timer past the
130
+ * `clearAll()` that was supposed to be the last word.
82
131
  */
83
- #resetTimerId = null;
132
+ #connected = false;
84
133
  connect() {
85
- setDefaultAttribute(this.element, "data-state", "idle");
134
+ this.#connected = true;
135
+ this.#adopt();
136
+ this.#beforeCache.activate();
86
137
  }
87
138
  disconnect() {
139
+ this.#connected = false;
140
+ this.#beforeCache.deactivate();
88
141
  this.#timers.clearAll();
89
142
  }
90
143
  /**
91
144
  * Copies the resolved text and reports the outcome. Bound via `data-action`
92
- * (click). Always dispatches `stimeo--clipboard:copy` with `{ success, text }`
93
- * — including on failure — so consumers can react either way.
145
+ * (click). Dispatches `stimeo--clipboard:copy` with `{ success, text }` once per
146
+ * completed attempt — including on failure — so consumers can react either way.
147
+ * An attempt whose connection ended while it was in flight reports nothing.
94
148
  */
95
149
  async copy() {
96
150
  const text = this.#resolveText();
97
151
  let success = false;
98
152
  try {
99
- if (!navigator.clipboard?.writeText) throw new Error("Clipboard API unavailable");
100
153
  await navigator.clipboard.writeText(text);
101
154
  success = true;
102
155
  } catch {
103
156
  success = false;
104
157
  }
158
+ if (!this.#connected) return;
105
159
  this.#reportResult(success);
106
160
  this.dispatch("copy", { detail: { success, text } });
107
161
  }
@@ -118,24 +172,52 @@ var ClipboardController = class extends Controller {
118
172
  }
119
173
  return source.textContent ?? "";
120
174
  }
121
- /** Reflects the result on `data-state`, announces it, and schedules a reset. */
175
+ /**
176
+ * Reads the current state back from the DOM.
177
+ *
178
+ * A `copied` or `error` found at connect time is this controller's own output
179
+ * from a connection that is gone, and so is the timer that would have cleared it
180
+ * — nothing else would ever return the element to `idle`. Any other authored
181
+ * value belongs to the consumer and only a missing attribute takes the default.
182
+ */
183
+ #adopt() {
184
+ if (this.#inTransientState()) {
185
+ this.#reset();
186
+ return;
187
+ }
188
+ setDefaultAttribute(this.element, "data-state", "idle");
189
+ }
190
+ /** Whether `data-state` currently holds one of the values this controller writes. */
191
+ #inTransientState() {
192
+ const state = this.element.getAttribute("data-state");
193
+ return state !== null && TRANSIENT_STATES.has(state);
194
+ }
195
+ /**
196
+ * Returns the completion state to idle for the snapshot Turbo is about to take,
197
+ * so a page reached with the Back button does not report a copy that happened
198
+ * before the navigation. Only a state this controller wrote is rewound — an
199
+ * authored one is the consumer's and has to survive into the snapshot, exactly as
200
+ * `connect()` leaves it alone. State only — no `copy` is dispatched, which would
201
+ * claim a fresh copy ran.
202
+ */
203
+ #rewind() {
204
+ if (!this.#inTransientState()) return;
205
+ this.#timers.clearAll();
206
+ this.#reset();
207
+ }
208
+ /** Reflects the result, announces it, and schedules the return to idle. */
122
209
  #reportResult(success) {
123
210
  this.element.setAttribute("data-state", success ? "copied" : "error");
124
211
  if (this.hasFeedbackTarget) {
125
212
  this.feedbackTarget.textContent = success ? this.copiedLabelValue : this.errorLabelValue;
126
213
  }
127
- if (this.#resetTimerId !== null) {
128
- this.#timers.clear(this.#resetTimerId);
129
- this.#resetTimerId = null;
130
- }
214
+ announce(success ? this.announceCopiedTextValue : this.announceErrorTextValue);
215
+ this.#timers.clearAll();
131
216
  if (this.feedbackDurationValue > 0) {
132
- this.#resetTimerId = this.#timers.set(() => {
133
- this.#resetTimerId = null;
134
- this.#reset();
135
- }, this.feedbackDurationValue);
217
+ this.#timers.set(() => this.#reset(), this.feedbackDurationValue);
136
218
  }
137
219
  }
138
- /** Returns to the idle state and clears the completion notice. */
220
+ /** Returns to the idle state and empties the completion slot. */
139
221
  #reset() {
140
222
  this.element.setAttribute("data-state", "idle");
141
223
  if (this.hasFeedbackTarget) {
@@ -58,8 +58,62 @@ var MicrotaskCoalescer = class {
58
58
  }
59
59
  };
60
60
 
61
+ // src/utils/owned_pointer_session.ts
62
+ var OwnedPointerSession = class {
63
+ pointerId;
64
+ #owner;
65
+ #handlers;
66
+ #abort = new AbortController();
67
+ #active = true;
68
+ constructor(start, owner, handlers) {
69
+ this.pointerId = start.pointerId;
70
+ this.#owner = owner;
71
+ this.#handlers = handlers;
72
+ const { signal } = this.#abort;
73
+ owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
74
+ owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
75
+ owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
76
+ owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
77
+ try {
78
+ owner.setPointerCapture?.(this.pointerId);
79
+ } catch {
80
+ }
81
+ }
82
+ /** Whether this session still owns its pointer and listeners. */
83
+ get active() {
84
+ return this.#active;
85
+ }
86
+ /** Whether `event` belongs to the initiating pointer of the live session. */
87
+ owns(event) {
88
+ return this.#active && event.pointerId === this.pointerId;
89
+ }
90
+ /** Releases capture/listeners and invokes the end callback exactly once. */
91
+ end() {
92
+ if (!this.#active) return;
93
+ this.#active = false;
94
+ this.#abort.abort();
95
+ try {
96
+ this.#owner.releasePointerCapture?.(this.pointerId);
97
+ } catch {
98
+ }
99
+ this.#handlers.end?.();
100
+ }
101
+ #onMove = (event) => {
102
+ if (this.owns(event)) this.#handlers.move(event);
103
+ };
104
+ #onEndEvent = (event) => {
105
+ if (this.owns(event)) this.end();
106
+ };
107
+ #onLostCapture = (event) => {
108
+ const pointerId = event.pointerId;
109
+ if (typeof pointerId === "number" && pointerId !== this.pointerId) return;
110
+ this.end();
111
+ };
112
+ };
113
+
61
114
  // src/controllers/color_picker_controller.ts
62
115
  var COLOR_PROPERTY = "--stimeo--color";
116
+ var VALUE_TEXT_ATTRIBUTE = "data-value-text";
63
117
  var CHANNEL_RANGE = {
64
118
  hue: [0, 360],
65
119
  saturation: [0, 100],
@@ -79,10 +133,10 @@ var ColorPickerController = class extends Controller {
79
133
  get #mirrored() {
80
134
  return this.logicalTrackValue && isRtl(this.element);
81
135
  }
82
- /** The current color in the editing model. */
136
+ /** The current color in the editing model; its alpha is 100 while `alpha` is off. */
83
137
  #color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
84
- /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
85
- #dragAbort = null;
138
+ /** The pointer that owns the live drag, with the slider whose geometry maps it. */
139
+ #drag = null;
86
140
  /** Color the last repaint settled on, so a configuration-driven move is reported once. */
87
141
  #committedHex = null;
88
142
  /**
@@ -93,25 +147,48 @@ var ColorPickerController = class extends Controller {
93
147
  /** Seeds the model from the initial hex value and renders every surface. */
94
148
  connect() {
95
149
  this.#repaint.activate();
96
- const parsed = hexToHsla(this.valueValue);
97
- if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
150
+ this.#adoptValue();
98
151
  this.#render();
99
152
  }
100
153
  /** Cancels any active pointer drag so document listeners never leak. */
101
154
  disconnect() {
102
155
  this.#repaint.cancel();
103
- this.#dragAbort?.abort();
104
- this.#dragAbort = null;
156
+ this.#endDrag();
105
157
  }
106
158
  /** Repaints when application code (or a Turbo morph) changes `alpha` at runtime. */
107
159
  alphaValueChanged() {
108
160
  this.#repaint.schedule();
109
161
  }
162
+ /** Adopts a color application code (or a Turbo morph) put in `value` at runtime. */
163
+ valueValueChanged() {
164
+ if (this.valueValue === this.#committedHex) return;
165
+ this.#repaint.schedule();
166
+ }
167
+ /** Hydrates a channel slider inserted or replaced at runtime. */
168
+ sliderTargetConnected(slider) {
169
+ this.#renderSlider(slider);
170
+ }
171
+ /** Ends a gesture whose geometry target disappeared or ceased being a target. */
172
+ sliderTargetDisconnected(slider) {
173
+ if (this.#drag?.slider === slider) this.#endDrag();
174
+ }
175
+ /** Fills a hex input inserted or replaced at runtime with the current color. */
176
+ hexTargetConnected(hex) {
177
+ this.#mirrorColor(hex, this.#hexString());
178
+ }
179
+ /** Fills a form field inserted or replaced at runtime with the current color. */
180
+ fieldTargetConnected(field) {
181
+ this.#mirrorColor(field, this.#hexString());
182
+ }
183
+ /** Publishes the current color on a preview inserted or replaced at runtime. */
184
+ previewTargetConnected(preview) {
185
+ this.#publishColor(preview, this.#hexString());
186
+ }
110
187
  /** Keyboard stepping on the focused channel slider (APG Slider model). */
111
188
  onKeydown(event) {
112
189
  if (isReservedArrowChord(event)) return;
113
190
  const slider = event.currentTarget;
114
- const channel = this.#channelOf(slider);
191
+ const channel = this.#editableChannel(slider);
115
192
  if (!channel) return;
116
193
  const [min, max] = this.#rangeOf(slider, channel);
117
194
  const value = this.#color[channel];
@@ -143,34 +220,36 @@ var ColorPickerController = class extends Controller {
143
220
  event.preventDefault();
144
221
  this.#setChannel(channel, next, min, max);
145
222
  }
146
- /** Begins a pointer drag on a channel slider and tracks movement. */
223
+ /** Begins a primary-button drag on a channel slider, owned by its own pointer. */
147
224
  onPointerDown(event) {
225
+ if (event.button !== 0 || this.#drag) return;
148
226
  const slider = event.currentTarget;
149
- const channel = this.#channelOf(slider);
227
+ const channel = this.#editableChannel(slider);
150
228
  if (!channel) return;
151
- event.preventDefault();
152
- slider.focus();
153
229
  const [min, max] = this.#rangeOf(slider, channel);
154
230
  const mirrored = this.#mirrored;
155
231
  const update = (clientX) => {
156
232
  const rect = slider.getBoundingClientRect();
157
- if (rect.width === 0) return;
233
+ if (rect.width === 0) return false;
158
234
  const offset = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
159
235
  const fraction = mirrored ? 1 - offset : offset;
160
236
  this.#setChannel(channel, min + fraction * (max - min), min, max);
237
+ return true;
161
238
  };
162
- update(event.clientX);
163
- this.#dragAbort?.abort();
164
- const abort = new AbortController();
165
- this.#dragAbort = abort;
166
- const onMove = (move) => update(move.clientX);
167
- const onUp = () => {
168
- abort.abort();
169
- this.#dragAbort = null;
170
- };
171
- document.addEventListener("pointermove", onMove, { signal: abort.signal });
172
- document.addEventListener("pointerup", onUp, { signal: abort.signal });
173
- document.addEventListener("pointercancel", onUp, { signal: abort.signal });
239
+ if (!update(event.clientX)) return;
240
+ event.preventDefault();
241
+ slider.focus();
242
+ const drag = { pointer: null, slider };
243
+ drag.pointer = new OwnedPointerSession(event, slider, {
244
+ move: (move) => {
245
+ if (slider.isConnected) update(move.clientX);
246
+ else this.#endDrag();
247
+ },
248
+ end: () => {
249
+ if (this.#drag === drag) this.#drag = null;
250
+ }
251
+ });
252
+ this.#drag = drag;
174
253
  }
175
254
  /** Parses the hex input on confirm and syncs every channel + surface. */
176
255
  onHexInput() {
@@ -180,9 +259,22 @@ var ColorPickerController = class extends Controller {
180
259
  this.hexTarget.value = this.#hexString();
181
260
  return;
182
261
  }
183
- this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
262
+ this.#color = this.#opaqueUnlessEnabled(parsed);
184
263
  this.#commitColor();
185
264
  }
265
+ /** Replaces the model with the color `value` names, leaving an unparsable one alone. */
266
+ #adoptValue() {
267
+ const parsed = hexToHsla(this.valueValue);
268
+ if (parsed) this.#color = this.#opaqueUnlessEnabled(parsed);
269
+ }
270
+ /**
271
+ * The model a parsed color implies: alpha only survives while its channel is
272
+ * enabled, because `hexString()` would otherwise emit `#RRGGBB` while `change`
273
+ * reported `rgba.a < 1`.
274
+ */
275
+ #opaqueUnlessEnabled(parsed) {
276
+ return this.alphaValue ? parsed : { ...parsed, alpha: 100 };
277
+ }
186
278
  /** Clamps and snaps one channel to an integer, then re-renders + emits change. */
187
279
  #setChannel(channel, raw, min, max) {
188
280
  this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
@@ -201,32 +293,57 @@ var ColorPickerController = class extends Controller {
201
293
  }
202
294
  }
203
295
  /**
204
- * Reflects the model onto sliders, the hex input, preview, and form field.
296
+ * Reflects the model onto sliders, the hex input, preview, form field, and the
297
+ * `value` Value it serializes into.
205
298
  *
206
299
  * @stimeoRenderRoot
207
300
  */
208
301
  #render() {
209
- for (const slider of this.sliderTargets) {
210
- const channel = this.#channelOf(slider);
211
- if (!channel) continue;
212
- const value = this.#color[channel];
213
- slider.setAttribute("aria-valuenow", String(value));
214
- slider.setAttribute("aria-valuetext", valueText(channel, value));
215
- }
302
+ for (const slider of this.sliderTargets) this.#renderSlider(slider);
216
303
  const hex = this.#hexString();
217
304
  this.#committedHex = hex;
218
- if (this.hasHexTarget) this.hexTarget.value = hex;
219
- for (const field of this.fieldTargets) field.value = hex;
220
- for (const preview of this.previewTargets) preview.style.setProperty(COLOR_PROPERTY, hex);
221
- this.element.style.setProperty(COLOR_PROPERTY, hex);
305
+ if (this.valueValue !== hex) this.valueValue = hex;
306
+ if (this.hasHexTarget) this.#mirrorColor(this.hexTarget, hex);
307
+ for (const field of this.fieldTargets) this.#mirrorColor(field, hex);
308
+ for (const preview of this.previewTargets) this.#publishColor(preview, hex);
309
+ this.#publishColor(this.element, hex);
310
+ }
311
+ /** Writes one slider's announced range, value, and value text, skipping equal ones. */
312
+ #renderSlider(slider) {
313
+ const channel = this.#channelOf(slider);
314
+ if (!channel) return;
315
+ const [min, max] = this.#rangeOf(slider, channel);
316
+ const value = this.#color[channel];
317
+ const attributes = {
318
+ "aria-valuemin": String(min),
319
+ "aria-valuemax": String(max),
320
+ "aria-valuenow": String(value),
321
+ "aria-valuetext": this.#valueText(slider, channel, value)
322
+ };
323
+ for (const [name, next] of Object.entries(attributes)) {
324
+ if (slider.getAttribute(name) !== next) slider.setAttribute(name, next);
325
+ }
326
+ }
327
+ /** Mirrors the color into an input, leaving an already-equal value untouched. */
328
+ #mirrorColor(input, hex) {
329
+ if (input.value !== hex) input.value = hex;
330
+ }
331
+ /** Publishes the color as the consumer's CSS hook, skipping an equal value. */
332
+ #publishColor(element, hex) {
333
+ if (element.style.getPropertyValue(COLOR_PROPERTY) !== hex) {
334
+ element.style.setProperty(COLOR_PROPERTY, hex);
335
+ }
222
336
  }
223
337
  /**
224
- * Repaints after `alpha` changed at runtime and reports a color this controller
225
- * settled on. Disabling alpha drops it from the model, so the committed color can
226
- * move without a user edit; `change` stays reserved for the picker's own actions.
338
+ * Repaints after a declarative input changed at runtime and reports a color this
339
+ * controller settled on. Disabling alpha drops it from the model and an outside
340
+ * `value` names another color, so the committed color can move without a user
341
+ * edit; `change` stays reserved for the picker's own actions.
227
342
  */
228
343
  #reconcileColor() {
229
344
  const previous = this.#committedHex;
345
+ if (previous !== null && this.valueValue !== previous) this.#adoptValue();
346
+ if (!this.alphaValue) this.#color.alpha = 100;
230
347
  this.#render();
231
348
  if (previous !== null && this.#committedHex !== previous) {
232
349
  this.dispatch("reconcile", { detail: this.#settledDetail() });
@@ -247,7 +364,27 @@ var ColorPickerController = class extends Controller {
247
364
  /** Reads a slider's `data-channel`, if it is a known channel. */
248
365
  #channelOf(slider) {
249
366
  const channel = slider.getAttribute("data-channel");
250
- return channel && channel in CHANNEL_RANGE ? channel : null;
367
+ return channel && Object.hasOwn(CHANNEL_RANGE, channel) ? channel : null;
368
+ }
369
+ /**
370
+ * The channel a slider edits, or null when this picker edits none through it. An
371
+ * alpha slider authored while `alpha` is off edits nothing: moving it would leave
372
+ * the model translucent behind an opaque `#RRGGBB`.
373
+ */
374
+ #editableChannel(slider) {
375
+ const channel = this.#channelOf(slider);
376
+ return channel === "alpha" && !this.alphaValue ? null : channel;
377
+ }
378
+ /** The channel's announced text: the slider's template, or the built-in English. */
379
+ #valueText(slider, channel, value) {
380
+ const template = slider.getAttribute(VALUE_TEXT_ATTRIBUTE);
381
+ return template ? template.replaceAll("{value}", String(value)) : defaultValueText(channel, value);
382
+ }
383
+ /** Ends the live drag so no further movement of that pointer reaches the model. */
384
+ #endDrag() {
385
+ const drag = this.#drag;
386
+ this.#drag = null;
387
+ drag?.pointer?.end();
251
388
  }
252
389
  /**
253
390
  * A slider's `[min, max]` from aria-valuemin/max, falling back per channel.
@@ -263,7 +400,7 @@ var ColorPickerController = class extends Controller {
263
400
  ];
264
401
  }
265
402
  };
266
- function valueText(channel, value) {
403
+ function defaultValueText(channel, value) {
267
404
  const label = channel.charAt(0).toUpperCase() + channel.slice(1);
268
405
  const unit = channel === "hue" ? "degrees" : "percent";
269
406
  return `${label} ${value} ${unit}`;