stimeo-ui 0.2.0 → 0.3.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 +163 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +313 -0
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +252 -0
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +194 -17
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +246 -0
- data/dist/controllers/date_range_picker_controller.js +441 -0
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/editable_controller.js +169 -0
- data/dist/controllers/file_dropzone_controller.js +165 -0
- data/dist/controllers/filter_controller.js +86 -0
- data/dist/controllers/flash_controller.js +36 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/intersection_controller.js +67 -19
- data/dist/controllers/lazy_frame_controller.js +54 -11
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +2 -2
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +785 -0
- data/dist/controllers/multi_select_controller.js +755 -0
- data/dist/controllers/navigation_menu_controller.js +511 -0
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +246 -27
- data/dist/controllers/overflow_menu_controller.js +381 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +192 -0
- data/dist/controllers/rating_controller.js +16 -2
- data/dist/controllers/read_more_controller.js +238 -0
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +101 -14
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +40 -6
- data/dist/controllers/scrollspy_controller.js +369 -74
- data/dist/controllers/separator_controller.js +96 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/skeleton_controller.js +1 -1
- data/dist/controllers/slider_controller.js +32 -6
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +1 -1
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +9 -4
- data/dist/controllers/sticky_observer_controller.js +109 -20
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +295 -0
- data/dist/controllers/theme_controller.js +42 -13
- data/dist/controllers/time_picker_controller.js +231 -0
- data/dist/controllers/toast_controller.js +40 -14
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +691 -0
- data/dist/index.js +4256 -915
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +28 -2
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/collapsible_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/safe_timeout.ts
|
|
6
|
+
var TimerRegistry = class {
|
|
7
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
8
|
+
ids = /* @__PURE__ */ new Set();
|
|
9
|
+
/**
|
|
10
|
+
* Cancels a single tracked timer.
|
|
11
|
+
*
|
|
12
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
13
|
+
* registry), so callers can clear defensively without guarding.
|
|
14
|
+
*/
|
|
15
|
+
clear(id) {
|
|
16
|
+
if (this.ids.delete(id)) {
|
|
17
|
+
this.cancel(id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
22
|
+
* to guarantee no timer outlives the element.
|
|
23
|
+
*/
|
|
24
|
+
clearAll() {
|
|
25
|
+
for (const id of this.ids) {
|
|
26
|
+
this.cancel(id);
|
|
27
|
+
}
|
|
28
|
+
this.ids.clear();
|
|
29
|
+
}
|
|
30
|
+
/** Number of timers currently tracked (pending). */
|
|
31
|
+
get size() {
|
|
32
|
+
return this.ids.size;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
36
|
+
/**
|
|
37
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
38
|
+
*
|
|
39
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
40
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
41
|
+
*/
|
|
42
|
+
set(callback, delay) {
|
|
43
|
+
const id = this.schedule(() => {
|
|
44
|
+
this.ids.delete(id);
|
|
45
|
+
callback();
|
|
46
|
+
}, delay);
|
|
47
|
+
this.ids.add(id);
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
schedule(callback, delay) {
|
|
51
|
+
return window.setTimeout(callback, delay);
|
|
52
|
+
}
|
|
53
|
+
cancel(id) {
|
|
54
|
+
window.clearTimeout(id);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/utils/transition_completion.ts
|
|
59
|
+
function timeMs(value) {
|
|
60
|
+
const trimmed = value.trim();
|
|
61
|
+
const amount = Number.parseFloat(trimmed);
|
|
62
|
+
if (!Number.isFinite(amount)) return 0;
|
|
63
|
+
if (trimmed.endsWith("ms")) return amount;
|
|
64
|
+
if (trimmed.endsWith("s")) return amount * 1e3;
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
function cssList(value) {
|
|
68
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
function transitionTimings(style) {
|
|
71
|
+
const properties = cssList(style.transitionProperty);
|
|
72
|
+
const durations = cssList(style.transitionDuration).map(timeMs);
|
|
73
|
+
const delays = cssList(style.transitionDelay).map(timeMs);
|
|
74
|
+
const effectiveProperties = properties.length > 0 ? properties : Array.from({ length: Math.max(durations.length, delays.length, 1) }, () => "all");
|
|
75
|
+
const effectiveDurations = durations.length > 0 ? durations : [0];
|
|
76
|
+
const effectiveDelays = delays.length > 0 ? delays : [0];
|
|
77
|
+
return effectiveProperties.filter((property) => property !== "none").map((property, index) => ({
|
|
78
|
+
property,
|
|
79
|
+
totalMs: Math.max(
|
|
80
|
+
0,
|
|
81
|
+
(effectiveDurations[index % effectiveDurations.length] ?? 0) + (effectiveDelays[index % effectiveDelays.length] ?? 0)
|
|
82
|
+
)
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
function maxTotalMs(timings) {
|
|
86
|
+
return timings.reduce((max, { totalMs }) => Math.max(max, totalMs), 0);
|
|
87
|
+
}
|
|
88
|
+
var TransitionCompletion = class {
|
|
89
|
+
#timers = new SafeTimeout();
|
|
90
|
+
#element = null;
|
|
91
|
+
#complete = null;
|
|
92
|
+
#pendingProperties = null;
|
|
93
|
+
#deadline = 0;
|
|
94
|
+
/**
|
|
95
|
+
* Replaces any prior wait and invokes `complete` synchronously for a 0ms
|
|
96
|
+
* transition (including when `getComputedStyle` is unavailable).
|
|
97
|
+
*
|
|
98
|
+
* With a positive `options.timeoutMs` the synchronous fast-path is skipped and
|
|
99
|
+
* the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).
|
|
100
|
+
*/
|
|
101
|
+
wait(element, complete, options = {}) {
|
|
102
|
+
this.cancel();
|
|
103
|
+
const requested = options.timeoutMs ?? 0;
|
|
104
|
+
const override = Number.isFinite(requested) && requested > 0 ? requested : 0;
|
|
105
|
+
const timings = typeof window.getComputedStyle === "function" ? transitionTimings(window.getComputedStyle(element)) : [];
|
|
106
|
+
const maximum = maxTotalMs(timings);
|
|
107
|
+
if (maximum <= 0 && override <= 0) {
|
|
108
|
+
complete();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.#element = element;
|
|
112
|
+
this.#complete = complete;
|
|
113
|
+
this.#deadline = Date.now() + maximum;
|
|
114
|
+
const activeProperties = this.#activeTransitionProperties(element);
|
|
115
|
+
this.#pendingProperties = activeProperties.size > 0 ? activeProperties : this.#explicitPendingProperties(timings);
|
|
116
|
+
element.addEventListener("transitionend", this.#onTerminal);
|
|
117
|
+
element.addEventListener("transitioncancel", this.#onTerminal);
|
|
118
|
+
this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);
|
|
119
|
+
}
|
|
120
|
+
/** Cancels the pending wait without invoking its completion callback. */
|
|
121
|
+
cancel() {
|
|
122
|
+
this.#complete = null;
|
|
123
|
+
this.#teardown();
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Handles terminal events from the observed element only.
|
|
127
|
+
*
|
|
128
|
+
* For explicit property lists, every declared positive-time property must
|
|
129
|
+
* settle. For `all`, no reliable property set exists, so an event can finish
|
|
130
|
+
* only after the computed maximum time; the safety timeout owns the usual path.
|
|
131
|
+
*/
|
|
132
|
+
#onTerminal = (event) => {
|
|
133
|
+
if (event.target !== this.#element) return;
|
|
134
|
+
const transitionEvent = event;
|
|
135
|
+
if (transitionEvent.pseudoElement) return;
|
|
136
|
+
if (this.#pendingProperties) {
|
|
137
|
+
const propertyName = transitionEvent.propertyName;
|
|
138
|
+
const active = this.#activeTransitionProperties(this.#element);
|
|
139
|
+
if (active.has(propertyName)) return;
|
|
140
|
+
if (!this.#pendingProperties.delete(propertyName)) return;
|
|
141
|
+
if (this.#pendingProperties.size > 0) return;
|
|
142
|
+
if (active.size > 0) {
|
|
143
|
+
this.#pendingProperties = active;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.#finish();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (Date.now() >= this.#deadline) this.#finish();
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Returns active CSS transition properties expanded to the names reported by
|
|
153
|
+
* terminal events. CSS animations and pseudo-element effects are excluded.
|
|
154
|
+
*/
|
|
155
|
+
#activeTransitionProperties(element) {
|
|
156
|
+
if (!element || typeof element.getAnimations !== "function") return /* @__PURE__ */ new Set();
|
|
157
|
+
try {
|
|
158
|
+
const properties = element.getAnimations().flatMap((animation) => {
|
|
159
|
+
if (animation.playState === "idle" || animation.playState === "finished") return [];
|
|
160
|
+
const effect = animation.effect;
|
|
161
|
+
if (effect?.pseudoElement) return [];
|
|
162
|
+
const target = effect?.target;
|
|
163
|
+
if (target && target !== element) return [];
|
|
164
|
+
const property = animation.transitionProperty;
|
|
165
|
+
return typeof property === "string" && property.length > 0 ? [property] : [];
|
|
166
|
+
});
|
|
167
|
+
return new Set(properties);
|
|
168
|
+
} catch {
|
|
169
|
+
return /* @__PURE__ */ new Set();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */
|
|
173
|
+
#explicitPendingProperties(timings) {
|
|
174
|
+
if (timings.some(({ property }) => property === "all")) return null;
|
|
175
|
+
const pending = new Set(
|
|
176
|
+
timings.filter(({ totalMs }) => totalMs > 0).map(({ property }) => property)
|
|
177
|
+
);
|
|
178
|
+
return pending.size > 0 ? pending : null;
|
|
179
|
+
}
|
|
180
|
+
/** Completes exactly once, releasing listeners and the fallback before the callback. */
|
|
181
|
+
#finish() {
|
|
182
|
+
const complete = this.#complete;
|
|
183
|
+
if (!complete) return;
|
|
184
|
+
this.#complete = null;
|
|
185
|
+
this.#teardown();
|
|
186
|
+
complete();
|
|
187
|
+
}
|
|
188
|
+
/** Releases the exact element listeners and timer owned by the current wait. */
|
|
189
|
+
#teardown() {
|
|
190
|
+
this.#timers.clearAll();
|
|
191
|
+
this.#element?.removeEventListener("transitionend", this.#onTerminal);
|
|
192
|
+
this.#element?.removeEventListener("transitioncancel", this.#onTerminal);
|
|
193
|
+
this.#element = null;
|
|
194
|
+
this.#pendingProperties = null;
|
|
195
|
+
this.#deadline = 0;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// src/controllers/collapsible_controller.ts
|
|
200
|
+
var CollapsibleController = class extends Controller {
|
|
201
|
+
static targets = ["trigger", "content"];
|
|
202
|
+
static values = {
|
|
203
|
+
open: { type: Boolean, default: false }
|
|
204
|
+
};
|
|
205
|
+
static actions = ["toggle"];
|
|
206
|
+
/** Owns the cancellable close-transition wait and its bounded fallback. */
|
|
207
|
+
#transition = new TransitionCompletion();
|
|
208
|
+
/** Distinguishes dynamic target churn from the callbacks that precede `connect()`. */
|
|
209
|
+
#connected = false;
|
|
210
|
+
/**
|
|
211
|
+
* Establishes the initial open/closed state without waiting for a close transition.
|
|
212
|
+
*
|
|
213
|
+
* The DOM is the source of truth on reconnect (Turbo cache restore / morph): an
|
|
214
|
+
* **explicit** state attribute — `aria-expanded="true"`/`"false"` (or, with no
|
|
215
|
+
* trigger, `data-state="open"`/`"closed"`) — is honored verbatim so a region the
|
|
216
|
+
* user opened *or* closed survives a back-navigation, even when the declarative
|
|
217
|
+
* `open` Value disagrees. An already-open region remains open without a
|
|
218
|
+
* close/reopen cycle. The Value only seeds a genuinely fresh render where no
|
|
219
|
+
* state attribute is present yet; any opening animation in that case belongs to
|
|
220
|
+
* the consumer's CSS. Mirrors `sidebar`'s `#restoreCollapsed`.
|
|
221
|
+
*/
|
|
222
|
+
connect() {
|
|
223
|
+
this.#connected = true;
|
|
224
|
+
this.#apply(this.#initialOpen(), false);
|
|
225
|
+
}
|
|
226
|
+
/** Resolves the connect-time state: explicit DOM state wins, else the `open` Value. */
|
|
227
|
+
#initialOpen() {
|
|
228
|
+
if (this.hasTriggerTarget) {
|
|
229
|
+
const expanded = this.triggerTarget.getAttribute("aria-expanded");
|
|
230
|
+
if (expanded === "true") return true;
|
|
231
|
+
if (expanded === "false") return false;
|
|
232
|
+
} else if (this.hasContentTarget) {
|
|
233
|
+
const state = this.contentTarget.getAttribute("data-state");
|
|
234
|
+
if (state === "open") return true;
|
|
235
|
+
if (state === "closed") return false;
|
|
236
|
+
}
|
|
237
|
+
return this.openValue;
|
|
238
|
+
}
|
|
239
|
+
disconnect() {
|
|
240
|
+
this.#connected = false;
|
|
241
|
+
this.#transition.cancel();
|
|
242
|
+
}
|
|
243
|
+
/** Reconciles a replacement trigger target with the content's live state. */
|
|
244
|
+
triggerTargetConnected(trigger) {
|
|
245
|
+
if (!this.#connected || !this.hasContentTarget) return;
|
|
246
|
+
trigger.setAttribute(
|
|
247
|
+
"aria-expanded",
|
|
248
|
+
this.contentTarget.getAttribute("data-state") === "open" ? "true" : "false"
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
/** Reconciles a replacement content target with the disclosure's live state. */
|
|
252
|
+
contentTargetConnected(content) {
|
|
253
|
+
if (!this.#connected) return;
|
|
254
|
+
this.#transition.cancel();
|
|
255
|
+
const open = this.hasTriggerTarget ? this.triggerTarget.getAttribute("aria-expanded") === "true" : content.getAttribute("data-state") === "open";
|
|
256
|
+
this.#applyContent(content, open, false);
|
|
257
|
+
}
|
|
258
|
+
/** Cancels a wait tied to a content target that was removed or replaced. */
|
|
259
|
+
contentTargetDisconnected() {
|
|
260
|
+
this.#transition.cancel();
|
|
261
|
+
}
|
|
262
|
+
/** Toggles the region open/closed. Bound via `data-action` (click). */
|
|
263
|
+
toggle() {
|
|
264
|
+
this.#apply(!this.#isOpen, true);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Whether the region is logically open. Read from `aria-expanded` (not the
|
|
268
|
+
* content's `hidden`) so a click *during* a close transition — when `hidden`
|
|
269
|
+
* is still deferred — correctly reopens instead of re-closing.
|
|
270
|
+
*/
|
|
271
|
+
get #isOpen() {
|
|
272
|
+
if (this.hasTriggerTarget) {
|
|
273
|
+
return this.triggerTarget.getAttribute("aria-expanded") === "true";
|
|
274
|
+
}
|
|
275
|
+
return this.hasContentTarget && this.contentTarget.getAttribute("data-state") === "open";
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Drives the disclosure to `open`, syncing the trigger's `aria-expanded` and
|
|
279
|
+
* the content's `hidden` / `data-state` / height variable in an order where
|
|
280
|
+
* `hidden` never blocks measurement or the transition.
|
|
281
|
+
*
|
|
282
|
+
* @param open - Target state.
|
|
283
|
+
* @param waitForCloseTransition - When `false` (initial `connect`) the close
|
|
284
|
+
* path applies `hidden` immediately. This flag does not suppress consumer CSS
|
|
285
|
+
* on the open path.
|
|
286
|
+
*/
|
|
287
|
+
#apply(open, waitForCloseTransition) {
|
|
288
|
+
if (this.hasTriggerTarget) {
|
|
289
|
+
this.triggerTarget.setAttribute("aria-expanded", open ? "true" : "false");
|
|
290
|
+
}
|
|
291
|
+
if (!this.hasContentTarget) return;
|
|
292
|
+
const content = this.contentTarget;
|
|
293
|
+
this.#transition.cancel();
|
|
294
|
+
this.#applyContent(content, open, waitForCloseTransition);
|
|
295
|
+
}
|
|
296
|
+
/** Reflects one content target without relying on a later target lookup. */
|
|
297
|
+
#applyContent(content, open, waitForCloseTransition) {
|
|
298
|
+
if (open) {
|
|
299
|
+
content.hidden = false;
|
|
300
|
+
content.style.setProperty("--stimeo-collapsible-content-height", `${content.scrollHeight}px`);
|
|
301
|
+
content.setAttribute("data-state", "open");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
content.setAttribute("data-state", "closed");
|
|
305
|
+
if (waitForCloseTransition) {
|
|
306
|
+
this.#applyHiddenAfterTransition(content);
|
|
307
|
+
} else {
|
|
308
|
+
content.hidden = true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Re-applies `hidden` once the close transition settles. Guarded against a
|
|
313
|
+
* reopen mid-transition; the shared waiter also guarantees a bounded fallback
|
|
314
|
+
* when the browser emits no terminal transition event.
|
|
315
|
+
*/
|
|
316
|
+
#applyHiddenAfterTransition(content) {
|
|
317
|
+
this.#transition.wait(content, () => {
|
|
318
|
+
if (content.getAttribute("data-state") === "closed") {
|
|
319
|
+
content.hidden = true;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
export { CollapsibleController };
|
|
326
|
+
//# sourceMappingURL=collapsible_controller.js.map
|
|
327
|
+
//# sourceMappingURL=collapsible_controller.js.map
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/color_picker_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function logicalArrowKey(key, element) {
|
|
12
|
+
if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
|
|
13
|
+
if (!isRtl(element)) return key;
|
|
14
|
+
return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
|
|
15
|
+
}
|
|
16
|
+
function isReservedArrowChord(event, allow = []) {
|
|
17
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
18
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/utils/coerce.ts
|
|
22
|
+
function toFiniteNumber(raw) {
|
|
23
|
+
if (raw === null || raw === void 0 || raw === "") return null;
|
|
24
|
+
const value = typeof raw === "number" ? raw : Number(raw);
|
|
25
|
+
return Number.isFinite(value) ? value : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/controllers/color_picker_controller.ts
|
|
29
|
+
var COLOR_PROPERTY = "--stimeo-color";
|
|
30
|
+
var CHANNEL_RANGE = {
|
|
31
|
+
hue: [0, 360],
|
|
32
|
+
saturation: [0, 100],
|
|
33
|
+
lightness: [0, 100],
|
|
34
|
+
alpha: [0, 100]
|
|
35
|
+
};
|
|
36
|
+
var ColorPickerController = class extends Controller {
|
|
37
|
+
static targets = ["slider", "hex", "preview", "field"];
|
|
38
|
+
static values = {
|
|
39
|
+
value: { type: String, default: "#000000" },
|
|
40
|
+
alpha: { type: Boolean, default: false },
|
|
41
|
+
logicalTrack: { type: Boolean, default: false }
|
|
42
|
+
};
|
|
43
|
+
static actions = ["onHexInput", "onKeydown", "onPointerDown"];
|
|
44
|
+
static events = ["change"];
|
|
45
|
+
/** Whether the consumer declared a mirroring track and the direction mirrors it. */
|
|
46
|
+
get #mirrored() {
|
|
47
|
+
return this.logicalTrackValue && isRtl(this.element);
|
|
48
|
+
}
|
|
49
|
+
/** The current color in the editing model. */
|
|
50
|
+
#color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
|
|
51
|
+
/** Aborts in-progress pointer-drag listeners on drag end / teardown. */
|
|
52
|
+
#dragAbort = null;
|
|
53
|
+
/** Seeds the model from the initial hex value and renders every surface. */
|
|
54
|
+
connect() {
|
|
55
|
+
const parsed = hexToHsla(this.valueValue);
|
|
56
|
+
if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
|
|
57
|
+
this.#render();
|
|
58
|
+
}
|
|
59
|
+
/** Cancels any active pointer drag so document listeners never leak. */
|
|
60
|
+
disconnect() {
|
|
61
|
+
this.#dragAbort?.abort();
|
|
62
|
+
this.#dragAbort = null;
|
|
63
|
+
}
|
|
64
|
+
/** Keyboard stepping on the focused channel slider (APG Slider model). */
|
|
65
|
+
onKeydown(event) {
|
|
66
|
+
if (isReservedArrowChord(event)) return;
|
|
67
|
+
const slider = event.currentTarget;
|
|
68
|
+
const channel = this.#channelOf(slider);
|
|
69
|
+
if (!channel) return;
|
|
70
|
+
const [min, max] = this.#rangeOf(slider, channel);
|
|
71
|
+
const value = this.#color[channel];
|
|
72
|
+
let next = null;
|
|
73
|
+
switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
|
|
74
|
+
case "ArrowRight":
|
|
75
|
+
case "ArrowUp":
|
|
76
|
+
next = value + 1;
|
|
77
|
+
break;
|
|
78
|
+
case "ArrowLeft":
|
|
79
|
+
case "ArrowDown":
|
|
80
|
+
next = value - 1;
|
|
81
|
+
break;
|
|
82
|
+
case "PageUp":
|
|
83
|
+
next = value + 10;
|
|
84
|
+
break;
|
|
85
|
+
case "PageDown":
|
|
86
|
+
next = value - 10;
|
|
87
|
+
break;
|
|
88
|
+
case "Home":
|
|
89
|
+
next = min;
|
|
90
|
+
break;
|
|
91
|
+
case "End":
|
|
92
|
+
next = max;
|
|
93
|
+
break;
|
|
94
|
+
default:
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
this.#setChannel(channel, next, min, max);
|
|
99
|
+
}
|
|
100
|
+
/** Begins a pointer drag on a channel slider and tracks movement. */
|
|
101
|
+
onPointerDown(event) {
|
|
102
|
+
const slider = event.currentTarget;
|
|
103
|
+
const channel = this.#channelOf(slider);
|
|
104
|
+
if (!channel) return;
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
slider.focus();
|
|
107
|
+
const [min, max] = this.#rangeOf(slider, channel);
|
|
108
|
+
const mirrored = this.#mirrored;
|
|
109
|
+
const update = (clientX) => {
|
|
110
|
+
const rect = slider.getBoundingClientRect();
|
|
111
|
+
if (rect.width === 0) return;
|
|
112
|
+
const offset = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
|
113
|
+
const fraction = mirrored ? 1 - offset : offset;
|
|
114
|
+
this.#setChannel(channel, min + fraction * (max - min), min, max);
|
|
115
|
+
};
|
|
116
|
+
update(event.clientX);
|
|
117
|
+
this.#dragAbort?.abort();
|
|
118
|
+
const abort = new AbortController();
|
|
119
|
+
this.#dragAbort = abort;
|
|
120
|
+
const onMove = (move) => update(move.clientX);
|
|
121
|
+
const onUp = () => {
|
|
122
|
+
abort.abort();
|
|
123
|
+
this.#dragAbort = null;
|
|
124
|
+
};
|
|
125
|
+
document.addEventListener("pointermove", onMove, { signal: abort.signal });
|
|
126
|
+
document.addEventListener("pointerup", onUp, { signal: abort.signal });
|
|
127
|
+
document.addEventListener("pointercancel", onUp, { signal: abort.signal });
|
|
128
|
+
}
|
|
129
|
+
/** Parses the hex input on confirm and syncs every channel + surface. */
|
|
130
|
+
onHexInput() {
|
|
131
|
+
if (!this.hasHexTarget) return;
|
|
132
|
+
const parsed = hexToHsla(this.hexTarget.value);
|
|
133
|
+
if (!parsed) {
|
|
134
|
+
this.hexTarget.value = this.#hexString();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
|
|
138
|
+
this.#render();
|
|
139
|
+
}
|
|
140
|
+
/** Clamps and snaps one channel to an integer, then re-renders + emits change. */
|
|
141
|
+
#setChannel(channel, raw, min, max) {
|
|
142
|
+
this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
|
|
143
|
+
this.#render();
|
|
144
|
+
}
|
|
145
|
+
/** Reflects the model onto sliders, the hex input, preview, and form field. */
|
|
146
|
+
#render() {
|
|
147
|
+
for (const slider of this.sliderTargets) {
|
|
148
|
+
const channel = this.#channelOf(slider);
|
|
149
|
+
if (!channel) continue;
|
|
150
|
+
const value = this.#color[channel];
|
|
151
|
+
slider.setAttribute("aria-valuenow", String(value));
|
|
152
|
+
slider.setAttribute("aria-valuetext", valueText(channel, value));
|
|
153
|
+
}
|
|
154
|
+
const hex = this.#hexString();
|
|
155
|
+
if (this.hasHexTarget) this.hexTarget.value = hex;
|
|
156
|
+
for (const field of this.fieldTargets) field.value = hex;
|
|
157
|
+
for (const preview of this.previewTargets) preview.style.setProperty(COLOR_PROPERTY, hex);
|
|
158
|
+
this.element.style.setProperty(COLOR_PROPERTY, hex);
|
|
159
|
+
const rgb = hslToRgb(this.#color.hue, this.#color.saturation, this.#color.lightness);
|
|
160
|
+
this.dispatch("change", {
|
|
161
|
+
detail: { value: hex, rgba: { ...rgb, a: this.#color.alpha / 100 } }
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** The current color as `#RRGGBB`, or `#RRGGBBAA` when alpha is enabled. */
|
|
165
|
+
#hexString() {
|
|
166
|
+
const rgb = hslToRgb(this.#color.hue, this.#color.saturation, this.#color.lightness);
|
|
167
|
+
const base = `#${hex2(rgb.r)}${hex2(rgb.g)}${hex2(rgb.b)}`;
|
|
168
|
+
if (!this.alphaValue) return base;
|
|
169
|
+
return `${base}${hex2(Math.round(this.#color.alpha / 100 * 255))}`;
|
|
170
|
+
}
|
|
171
|
+
/** Reads a slider's `data-channel`, if it is a known channel. */
|
|
172
|
+
#channelOf(slider) {
|
|
173
|
+
const channel = slider.getAttribute("data-channel");
|
|
174
|
+
return channel && channel in CHANNEL_RANGE ? channel : null;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* A slider's `[min, max]` from aria-valuemin/max, falling back per channel.
|
|
178
|
+
* An absent or blank attribute means "not authored", not zero — coercing it to
|
|
179
|
+
* a number would pin the channel at `0` and make the per-channel default
|
|
180
|
+
* unreachable.
|
|
181
|
+
*/
|
|
182
|
+
#rangeOf(slider, channel) {
|
|
183
|
+
const [defMin, defMax] = CHANNEL_RANGE[channel];
|
|
184
|
+
return [
|
|
185
|
+
toFiniteNumber(slider.getAttribute("aria-valuemin")) ?? defMin,
|
|
186
|
+
toFiniteNumber(slider.getAttribute("aria-valuemax")) ?? defMax
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
function valueText(channel, value) {
|
|
191
|
+
const label = channel.charAt(0).toUpperCase() + channel.slice(1);
|
|
192
|
+
const unit = channel === "hue" ? "degrees" : "percent";
|
|
193
|
+
return `${label} ${value} ${unit}`;
|
|
194
|
+
}
|
|
195
|
+
function hex2(value) {
|
|
196
|
+
return Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
|
|
197
|
+
}
|
|
198
|
+
function hslToRgb(h, s, l) {
|
|
199
|
+
const sat = s / 100;
|
|
200
|
+
const light = l / 100;
|
|
201
|
+
const c = (1 - Math.abs(2 * light - 1)) * sat;
|
|
202
|
+
const hp = (h % 360 + 360) % 360 / 60;
|
|
203
|
+
const x = c * (1 - Math.abs(hp % 2 - 1));
|
|
204
|
+
let r = 0;
|
|
205
|
+
let g = 0;
|
|
206
|
+
let b = 0;
|
|
207
|
+
if (hp >= 0 && hp < 1) [r, g, b] = [c, x, 0];
|
|
208
|
+
else if (hp < 2) [r, g, b] = [x, c, 0];
|
|
209
|
+
else if (hp < 3) [r, g, b] = [0, c, x];
|
|
210
|
+
else if (hp < 4) [r, g, b] = [0, x, c];
|
|
211
|
+
else if (hp < 5) [r, g, b] = [x, 0, c];
|
|
212
|
+
else [r, g, b] = [c, 0, x];
|
|
213
|
+
const m = light - c / 2;
|
|
214
|
+
return {
|
|
215
|
+
r: Math.round((r + m) * 255),
|
|
216
|
+
g: Math.round((g + m) * 255),
|
|
217
|
+
b: Math.round((b + m) * 255)
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function rgbToHsl(r, g, b) {
|
|
221
|
+
const rn = r / 255;
|
|
222
|
+
const gn = g / 255;
|
|
223
|
+
const bn = b / 255;
|
|
224
|
+
const max = Math.max(rn, gn, bn);
|
|
225
|
+
const min = Math.min(rn, gn, bn);
|
|
226
|
+
const delta = max - min;
|
|
227
|
+
const l = (max + min) / 2;
|
|
228
|
+
let h = 0;
|
|
229
|
+
if (delta !== 0) {
|
|
230
|
+
if (max === rn) h = (gn - bn) / delta % 6;
|
|
231
|
+
else if (max === gn) h = (bn - rn) / delta + 2;
|
|
232
|
+
else h = (rn - gn) / delta + 4;
|
|
233
|
+
h = (h * 60 + 360) % 360;
|
|
234
|
+
}
|
|
235
|
+
const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
|
236
|
+
return { h: Math.round(h), s: Math.round(s * 100), l: Math.round(l * 100) };
|
|
237
|
+
}
|
|
238
|
+
function hexToHsla(input) {
|
|
239
|
+
const hex = input.trim().replace(/^#/, "");
|
|
240
|
+
if (!/^(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(hex)) return null;
|
|
241
|
+
const full = hex.length <= 4 ? hex.split("").map((char) => char + char).join("") : hex;
|
|
242
|
+
const r = Number.parseInt(full.slice(0, 2), 16);
|
|
243
|
+
const g = Number.parseInt(full.slice(2, 4), 16);
|
|
244
|
+
const b = Number.parseInt(full.slice(4, 6), 16);
|
|
245
|
+
const a = full.length === 8 ? Number.parseInt(full.slice(6, 8), 16) : 255;
|
|
246
|
+
const { h, s, l } = rgbToHsl(r, g, b);
|
|
247
|
+
return { hue: h, saturation: s, lightness: l, alpha: Math.round(a / 255 * 100) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export { ColorPickerController };
|
|
251
|
+
//# sourceMappingURL=color_picker_controller.js.map
|
|
252
|
+
//# sourceMappingURL=color_picker_controller.js.map
|