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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/dist/controllers/auto_submit_controller.js +94 -16
- data/dist/controllers/bulk_select_controller.js +139 -28
- data/dist/controllers/carousel_controller.js +451 -100
- data/dist/controllers/clipboard_controller.js +102 -20
- data/dist/controllers/color_picker_controller.js +180 -43
- data/dist/controllers/currency_input_controller.js +305 -74
- data/dist/controllers/data_grid_controller.js +150 -24
- data/dist/controllers/direct_upload_controller.js +12 -2
- data/dist/controllers/editable_controller.js +83 -30
- data/dist/controllers/file_dropzone_controller.js +386 -63
- data/dist/controllers/filter_controller.js +32 -1
- data/dist/controllers/flash_controller.js +3 -1
- data/dist/controllers/frame_loading_controller.js +2 -1
- data/dist/controllers/input_mask_controller.js +251 -76
- data/dist/controllers/masonry_controller.js +129 -17
- data/dist/controllers/nested_form_controller.js +450 -42
- data/dist/controllers/otp_controller.js +485 -114
- data/dist/controllers/reset_before_cache_controller.js +51 -5
- data/dist/controllers/resizable_controller.js +128 -55
- data/dist/controllers/spinner_controller.js +7 -5
- data/dist/controllers/submit_once_controller.js +3 -1
- data/dist/controllers/textarea_autosize_controller.js +131 -3
- data/dist/index.js +2764 -923
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -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
|
-
/**
|
|
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
|
-
*
|
|
80
|
-
* a
|
|
81
|
-
*
|
|
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
|
-
#
|
|
132
|
+
#connected = false;
|
|
84
133
|
connect() {
|
|
85
|
-
|
|
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).
|
|
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
|
-
/**
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
this.#resetTimerId = null;
|
|
130
|
-
}
|
|
214
|
+
announce(success ? this.announceCopiedTextValue : this.announceErrorTextValue);
|
|
215
|
+
this.#timers.clearAll();
|
|
131
216
|
if (this.feedbackDurationValue > 0) {
|
|
132
|
-
this.#
|
|
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
|
|
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
|
-
/**
|
|
85
|
-
#
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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
|
|
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.#
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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,
|
|
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.
|
|
219
|
-
|
|
220
|
-
for (const
|
|
221
|
-
this.
|
|
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
|
|
225
|
-
* settled on. Disabling alpha drops it from the model
|
|
226
|
-
*
|
|
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
|
|
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
|
|
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}`;
|