stimeo-ui 0.5.0 → 0.7.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 +178 -0
- data/dist/controllers/alert_dialog_controller.js +75 -4
- data/dist/controllers/aspect_ratio_controller.js +19 -11
- data/dist/controllers/avatar_controller.js +237 -40
- data/dist/controllers/carousel_controller.js +85 -9
- data/dist/controllers/character_counter_controller.js +338 -63
- data/dist/controllers/checkbox_controller.js +136 -25
- data/dist/controllers/color_picker_controller.js +35 -9
- data/dist/controllers/command_palette_controller.js +75 -4
- data/dist/controllers/conditional_fields_controller.js +345 -51
- data/dist/controllers/confirm_controller.js +75 -4
- data/dist/controllers/date_range_picker_controller.js +157 -30
- data/dist/controllers/dialog_controller.js +75 -4
- data/dist/controllers/direct_upload_controller.js +201 -45
- data/dist/controllers/dirty_form_controller.js +192 -29
- data/dist/controllers/dismissible_controller.js +83 -18
- data/dist/controllers/drawer_controller.js +75 -4
- data/dist/controllers/file_dropzone_controller.js +26 -3
- data/dist/controllers/focus_controller.js +75 -4
- data/dist/controllers/form_field_controller.js +280 -62
- data/dist/controllers/form_validation_controller.js +208 -83
- data/dist/controllers/idle_controller.js +27 -5
- data/dist/controllers/menubar_controller.js +5 -3
- data/dist/controllers/multi_select_controller.js +460 -151
- data/dist/controllers/number_input_controller.js +317 -51
- data/dist/controllers/overflow_menu_controller.js +6 -1
- data/dist/controllers/pagination_controller.js +35 -1
- data/dist/controllers/password_strength_controller.js +20 -2
- data/dist/controllers/persist_controller.js +449 -120
- data/dist/controllers/popover_controller.js +77 -4
- data/dist/controllers/radio_group_controller.js +540 -56
- data/dist/controllers/rating_controller.js +276 -89
- data/dist/controllers/resizable_controller.js +33 -0
- data/dist/controllers/roving_controller.js +60 -5
- data/dist/controllers/scroll_area_controller.js +557 -125
- data/dist/controllers/scroll_visibility_controller.js +33 -0
- data/dist/controllers/separator_controller.js +354 -38
- data/dist/controllers/sidebar_controller.js +83 -10
- data/dist/controllers/submit_once_controller.js +399 -121
- data/dist/controllers/tags_input_controller.js +356 -120
- data/dist/controllers/theme_controller.js +8 -6
- data/dist/controllers/time_picker_controller.js +296 -107
- data/dist/controllers/toggle_group_controller.js +378 -55
- data/dist/controllers/toolbar_controller.js +5 -3
- data/dist/controllers/tree_view_controller.js +7 -4
- data/dist/index.js +4963 -1581
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -1,17 +1,89 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/checkbox_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/microtask_coalescer.ts
|
|
6
|
+
var MicrotaskCoalescer = class {
|
|
7
|
+
#run;
|
|
8
|
+
#queued = false;
|
|
9
|
+
#active = false;
|
|
10
|
+
#generation = 0;
|
|
11
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
12
|
+
constructor(run) {
|
|
13
|
+
this.#run = run;
|
|
14
|
+
}
|
|
15
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
16
|
+
activate() {
|
|
17
|
+
this.#active = true;
|
|
18
|
+
}
|
|
19
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
20
|
+
cancel() {
|
|
21
|
+
this.#active = false;
|
|
22
|
+
this.#queued = false;
|
|
23
|
+
this.#generation += 1;
|
|
24
|
+
}
|
|
25
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
26
|
+
schedule() {
|
|
27
|
+
if (!this.#active || this.#queued) return;
|
|
28
|
+
this.#queued = true;
|
|
29
|
+
const generation = this.#generation;
|
|
30
|
+
queueMicrotask(() => {
|
|
31
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
32
|
+
this.#queued = false;
|
|
33
|
+
this.#run();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
3
38
|
// src/controllers/checkbox_controller.ts
|
|
4
39
|
var CheckboxController = class extends Controller {
|
|
5
40
|
static targets = ["parent", "child"];
|
|
6
41
|
static actions = ["onChildChange", "onParentChange"];
|
|
7
|
-
static events = ["change"];
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
42
|
+
static events = ["change", "reconcile"];
|
|
43
|
+
/** Collapses every lifecycle signal from one DOM update into one derived pass. */
|
|
44
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
|
|
45
|
+
/** Aggregate this root last settled on, so a derived repair is reported once. */
|
|
46
|
+
#committedState = null;
|
|
47
|
+
/** Watches authored checked-attribute changes on retained target elements. */
|
|
48
|
+
#checkedObserver = new MutationObserver((records) => {
|
|
49
|
+
if (records.some((record) => this.#isManagedCheckbox(record.target))) {
|
|
50
|
+
this.#reconcile.schedule();
|
|
14
51
|
}
|
|
52
|
+
});
|
|
53
|
+
/** Reflects the initial aggregate and starts retained-DOM reconciliation. */
|
|
54
|
+
connect() {
|
|
55
|
+
this.#reconcile.activate();
|
|
56
|
+
this.#syncFromChildren();
|
|
57
|
+
this.#checkedObserver.observe(this.element, {
|
|
58
|
+
attributes: true,
|
|
59
|
+
attributeFilter: ["checked"],
|
|
60
|
+
subtree: true
|
|
61
|
+
});
|
|
62
|
+
this.element.addEventListener("turbo:morph-element", this.#onMorph);
|
|
63
|
+
document.addEventListener("reset", this.#onReset, true);
|
|
64
|
+
}
|
|
65
|
+
/** Releases the observer, global reset listener, and every pending reconciliation. */
|
|
66
|
+
disconnect() {
|
|
67
|
+
this.#reconcile.cancel();
|
|
68
|
+
this.#checkedObserver.disconnect();
|
|
69
|
+
this.element.removeEventListener("turbo:morph-element", this.#onMorph);
|
|
70
|
+
document.removeEventListener("reset", this.#onReset, true);
|
|
71
|
+
}
|
|
72
|
+
/** Reconciles the aggregate for a parent added or replaced at runtime. */
|
|
73
|
+
parentTargetConnected() {
|
|
74
|
+
this.#reconcile.schedule();
|
|
75
|
+
}
|
|
76
|
+
/** Reconciles the aggregate after a parent target leaves the group. */
|
|
77
|
+
parentTargetDisconnected() {
|
|
78
|
+
this.#reconcile.schedule();
|
|
79
|
+
}
|
|
80
|
+
/** Reconciles the aggregate for a child added at runtime. */
|
|
81
|
+
childTargetConnected() {
|
|
82
|
+
this.#reconcile.schedule();
|
|
83
|
+
}
|
|
84
|
+
/** Reconciles the aggregate after a child leaves the group. */
|
|
85
|
+
childTargetDisconnected() {
|
|
86
|
+
this.#reconcile.schedule();
|
|
15
87
|
}
|
|
16
88
|
/** Cascades the parent's state to every child. Bound via `data-action` (change). */
|
|
17
89
|
onParentChange() {
|
|
@@ -20,35 +92,74 @@ var CheckboxController = class extends Controller {
|
|
|
20
92
|
for (const child of this.childTargets) {
|
|
21
93
|
child.checked = checked;
|
|
22
94
|
}
|
|
23
|
-
this
|
|
24
|
-
const
|
|
25
|
-
this.
|
|
26
|
-
this.dispatch("change", { detail: { checked, indeterminate: false, state } });
|
|
95
|
+
this.#reflect(checked ? "all" : "none", true);
|
|
96
|
+
const detail = this.#settledDetail();
|
|
97
|
+
if (detail) this.dispatch("change", { detail });
|
|
27
98
|
}
|
|
28
99
|
/** Recomputes the parent from its children. Bound via `data-action` (change). */
|
|
29
100
|
onChildChange() {
|
|
30
|
-
this.#syncFromChildren(
|
|
101
|
+
this.#syncFromChildren();
|
|
102
|
+
const detail = this.#settledDetail();
|
|
103
|
+
if (detail) this.dispatch("change", { detail });
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Announces an aggregate this pass derived rather than the user. `change` stays
|
|
107
|
+
* reserved for the two public actions, so automation never reads a repair as an edit.
|
|
108
|
+
*/
|
|
109
|
+
#reconcileFromChildren() {
|
|
110
|
+
const previous = this.#committedState;
|
|
111
|
+
this.#syncFromChildren();
|
|
112
|
+
if (this.#committedState === previous) return;
|
|
113
|
+
const detail = this.#settledDetail();
|
|
114
|
+
if (detail) this.dispatch("reconcile", { detail });
|
|
31
115
|
}
|
|
32
116
|
/**
|
|
33
117
|
* Derives the parent's `checked`/`indeterminate` and the root `data-state` from
|
|
34
|
-
* the children
|
|
118
|
+
* the children. Writing state and reporting it are separate so the caller — not
|
|
119
|
+
* a flag threaded through the write — decides which event describes the cause.
|
|
35
120
|
*/
|
|
36
|
-
#syncFromChildren(
|
|
37
|
-
|
|
38
|
-
|
|
121
|
+
#syncFromChildren() {
|
|
122
|
+
this.#reflect(this.#aggregate(), this.childTargets.length > 0);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Reflects one aggregate state.
|
|
126
|
+
*
|
|
127
|
+
* @param writeParent - whether the state is authoritative over the `parent`
|
|
128
|
+
* target's own `checked` / `indeterminate`.
|
|
129
|
+
*/
|
|
130
|
+
#reflect(state, writeParent) {
|
|
131
|
+
if (writeParent && this.hasParentTarget) {
|
|
39
132
|
this.parentTarget.checked = state === "all";
|
|
40
133
|
this.parentTarget.indeterminate = state === "partial";
|
|
41
134
|
}
|
|
42
135
|
this.element.setAttribute("data-state", state);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
136
|
+
this.#committedState = state;
|
|
137
|
+
}
|
|
138
|
+
/** The settled aggregate as event detail, or `null` before anything has settled. */
|
|
139
|
+
#settledDetail() {
|
|
140
|
+
const state = this.#committedState;
|
|
141
|
+
if (state === null) return null;
|
|
142
|
+
return { checked: state === "all", indeterminate: state === "partial", state };
|
|
143
|
+
}
|
|
144
|
+
/** Reconciles retained targets after Turbo has finished morphing their live state. */
|
|
145
|
+
#onMorph = () => {
|
|
146
|
+
this.#reconcile.schedule();
|
|
147
|
+
};
|
|
148
|
+
/** Reconciles after a non-cancelled reset restores any managed checkbox. */
|
|
149
|
+
#onReset = (event) => {
|
|
150
|
+
const form = event.target;
|
|
151
|
+
if (!(form instanceof HTMLFormElement) || !this.#hasCheckboxOwnedBy(form)) return;
|
|
152
|
+
queueMicrotask(() => {
|
|
153
|
+
if (!event.defaultPrevented) this.#reconcile.schedule();
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
/** Whether a form owns at least one current parent or child target. */
|
|
157
|
+
#hasCheckboxOwnedBy(form) {
|
|
158
|
+
return [...this.parentTargets, ...this.childTargets].some((checkbox) => checkbox.form === form);
|
|
159
|
+
}
|
|
160
|
+
/** Whether an observed attribute mutation belongs to this controller's target set. */
|
|
161
|
+
#isManagedCheckbox(node) {
|
|
162
|
+
return this.parentTargets.some((checkbox) => checkbox === node) || this.childTargets.some((checkbox) => checkbox === node);
|
|
52
163
|
}
|
|
53
164
|
/**
|
|
54
165
|
* Computes the aggregate state. With children it counts them; with none it
|
|
@@ -74,7 +74,7 @@ var ColorPickerController = class extends Controller {
|
|
|
74
74
|
logicalTrack: { type: Boolean, default: false }
|
|
75
75
|
};
|
|
76
76
|
static actions = ["onHexInput", "onKeydown", "onPointerDown"];
|
|
77
|
-
static events = ["change"];
|
|
77
|
+
static events = ["change", "reconcile"];
|
|
78
78
|
/** Whether the consumer declared a mirroring track and the direction mirrors it. */
|
|
79
79
|
get #mirrored() {
|
|
80
80
|
return this.logicalTrackValue && isRtl(this.element);
|
|
@@ -83,14 +83,14 @@ var ColorPickerController = class extends Controller {
|
|
|
83
83
|
#color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
|
|
84
84
|
/** Aborts in-progress pointer-drag listeners on drag end / teardown. */
|
|
85
85
|
#dragAbort = null;
|
|
86
|
-
/**
|
|
86
|
+
/** Color the last repaint settled on, so a configuration-driven move is reported once. */
|
|
87
|
+
#committedHex = null;
|
|
87
88
|
/**
|
|
88
89
|
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
89
90
|
* pass Stimulus delivers before `connect()`.
|
|
90
91
|
*/
|
|
91
|
-
#repaint = new MicrotaskCoalescer(() =>
|
|
92
|
-
|
|
93
|
-
});
|
|
92
|
+
#repaint = new MicrotaskCoalescer(() => this.#reconcileColor());
|
|
93
|
+
/** Seeds the model from the initial hex value and renders every surface. */
|
|
94
94
|
connect() {
|
|
95
95
|
this.#repaint.activate();
|
|
96
96
|
const parsed = hexToHsla(this.valueValue);
|
|
@@ -181,12 +181,24 @@ var ColorPickerController = class extends Controller {
|
|
|
181
181
|
return;
|
|
182
182
|
}
|
|
183
183
|
this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
|
|
184
|
-
this.#
|
|
184
|
+
this.#commitColor();
|
|
185
185
|
}
|
|
186
186
|
/** Clamps and snaps one channel to an integer, then re-renders + emits change. */
|
|
187
187
|
#setChannel(channel, raw, min, max) {
|
|
188
188
|
this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
|
|
189
|
+
this.#commitColor();
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Renders the model and reports a color the user actually moved. A key pressed
|
|
193
|
+
* at a bound, a pointer that lands on the step already showing, and a re-confirmed
|
|
194
|
+
* hex all leave the committed color where it was, so no `change` describes them.
|
|
195
|
+
*/
|
|
196
|
+
#commitColor() {
|
|
197
|
+
const previous = this.#committedHex;
|
|
189
198
|
this.#render();
|
|
199
|
+
if (this.#committedHex !== previous) {
|
|
200
|
+
this.dispatch("change", { detail: this.#settledDetail() });
|
|
201
|
+
}
|
|
190
202
|
}
|
|
191
203
|
/**
|
|
192
204
|
* Reflects the model onto sliders, the hex input, preview, and form field.
|
|
@@ -202,14 +214,28 @@ var ColorPickerController = class extends Controller {
|
|
|
202
214
|
slider.setAttribute("aria-valuetext", valueText(channel, value));
|
|
203
215
|
}
|
|
204
216
|
const hex = this.#hexString();
|
|
217
|
+
this.#committedHex = hex;
|
|
205
218
|
if (this.hasHexTarget) this.hexTarget.value = hex;
|
|
206
219
|
for (const field of this.fieldTargets) field.value = hex;
|
|
207
220
|
for (const preview of this.previewTargets) preview.style.setProperty(COLOR_PROPERTY, hex);
|
|
208
221
|
this.element.style.setProperty(COLOR_PROPERTY, hex);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
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.
|
|
227
|
+
*/
|
|
228
|
+
#reconcileColor() {
|
|
229
|
+
const previous = this.#committedHex;
|
|
230
|
+
this.#render();
|
|
231
|
+
if (previous !== null && this.#committedHex !== previous) {
|
|
232
|
+
this.dispatch("reconcile", { detail: this.#settledDetail() });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** The settled color as event detail, shared by both report paths. */
|
|
236
|
+
#settledDetail() {
|
|
209
237
|
const rgb = hslToRgb(this.#color.hue, this.#color.saturation, this.#color.lightness);
|
|
210
|
-
this
|
|
211
|
-
detail: { value: hex, rgba: { ...rgb, a: this.#color.alpha / 100 } }
|
|
212
|
-
});
|
|
238
|
+
return { value: this.#hexString(), rgba: { ...rgb, a: this.#color.alpha / 100 } };
|
|
213
239
|
}
|
|
214
240
|
/** The current color as `#RRGGBB`, or `#RRGGBBAA` when alpha is enabled. */
|
|
215
241
|
#hexString() {
|
|
@@ -180,8 +180,81 @@ var EscapeLayer = class _EscapeLayer {
|
|
|
180
180
|
}
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
+
// src/utils/focus_candidate.ts
|
|
184
|
+
function inheritsFieldsetDisabled(control) {
|
|
185
|
+
let fieldset = control.closest("fieldset[disabled]");
|
|
186
|
+
while (fieldset) {
|
|
187
|
+
const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
|
|
188
|
+
if (!legend?.contains(control)) return true;
|
|
189
|
+
fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
function canTakeFocus(element) {
|
|
194
|
+
if (element.closest("[hidden], [inert]")) return false;
|
|
195
|
+
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
196
|
+
if (!("disabled" in element)) return true;
|
|
197
|
+
if (element.disabled) return false;
|
|
198
|
+
return !inheritsFieldsetDisabled(element);
|
|
199
|
+
}
|
|
200
|
+
var TAB_STOP_CANDIDATE_SELECTOR = [
|
|
201
|
+
"a[href]",
|
|
202
|
+
"area[href]",
|
|
203
|
+
"button",
|
|
204
|
+
"input",
|
|
205
|
+
"select",
|
|
206
|
+
"textarea",
|
|
207
|
+
"summary",
|
|
208
|
+
"iframe",
|
|
209
|
+
"audio[controls]",
|
|
210
|
+
"video[controls]",
|
|
211
|
+
"[tabindex]",
|
|
212
|
+
"[contenteditable]"
|
|
213
|
+
].join(",");
|
|
214
|
+
function isRenderedForFocus(element) {
|
|
215
|
+
const check = element.checkVisibility;
|
|
216
|
+
return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
|
|
217
|
+
}
|
|
218
|
+
function authoredTabindex(element) {
|
|
219
|
+
const value = element.getAttribute("tabindex");
|
|
220
|
+
if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
|
|
221
|
+
return Number(value);
|
|
222
|
+
}
|
|
223
|
+
function hasNativeTabStop(element) {
|
|
224
|
+
if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
|
|
225
|
+
return element.hasAttribute("href");
|
|
226
|
+
}
|
|
227
|
+
if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
if (element instanceof HTMLInputElement) return element.type !== "hidden";
|
|
231
|
+
if (element instanceof HTMLIFrameElement) return true;
|
|
232
|
+
if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
|
|
233
|
+
return element.hasAttribute("controls");
|
|
234
|
+
}
|
|
235
|
+
if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
|
|
236
|
+
const details = element.parentElement;
|
|
237
|
+
return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
function hasEditableTabStop(element) {
|
|
242
|
+
const value = element.getAttribute("contenteditable")?.toLowerCase();
|
|
243
|
+
return value === "" || value === "true" || value === "plaintext-only";
|
|
244
|
+
}
|
|
245
|
+
function isTabStop(element) {
|
|
246
|
+
if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
|
|
247
|
+
const tabindex = authoredTabindex(element);
|
|
248
|
+
if (tabindex !== null) return tabindex >= 0;
|
|
249
|
+
return hasNativeTabStop(element) || hasEditableTabStop(element);
|
|
250
|
+
}
|
|
251
|
+
function tabStopsWithin(root) {
|
|
252
|
+
return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
|
|
253
|
+
isTabStop
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
183
257
|
// src/utils/focus_trap.ts
|
|
184
|
-
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
185
258
|
var FocusTrap = class {
|
|
186
259
|
/** The element focused before activation, restored on deactivation. */
|
|
187
260
|
#previouslyFocused = null;
|
|
@@ -343,9 +416,7 @@ var FocusTrap = class {
|
|
|
343
416
|
}
|
|
344
417
|
/** Collects the container's currently focusable descendants in DOM order. */
|
|
345
418
|
#focusableElements() {
|
|
346
|
-
return
|
|
347
|
-
(el) => !el.hidden
|
|
348
|
-
);
|
|
419
|
+
return tabStopsWithin(this.#getContainer());
|
|
349
420
|
}
|
|
350
421
|
};
|
|
351
422
|
|