stimeo-ui 0.14.0 → 0.16.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 +212 -0
- data/README.md +120 -0
- data/dist/cable/index.js +123 -29
- data/dist/controllers/accordion_controller.js +98 -9
- data/dist/controllers/announcer_controller.js +96 -62
- data/dist/controllers/auto_submit_controller.js +83 -7
- data/dist/controllers/avatar_controller.js +1 -1
- data/dist/controllers/breadcrumb_controller.js +38 -11
- data/dist/controllers/bulk_select_controller.js +7 -6
- data/dist/controllers/calendar_controller.js +340 -123
- data/dist/controllers/carousel_controller.js +263 -38
- data/dist/controllers/character_counter_controller.js +40 -2
- data/dist/controllers/checkbox_controller.js +81 -12
- data/dist/controllers/clipboard_controller.js +63 -5
- data/dist/controllers/collapsible_controller.js +99 -14
- data/dist/controllers/color_picker_controller.js +80 -34
- data/dist/controllers/combobox_controller.js +106 -16
- data/dist/controllers/command_palette_controller.js +35 -3
- data/dist/controllers/conditional_fields_controller.js +85 -17
- data/dist/controllers/confirm_controller.js +3 -0
- data/dist/controllers/context_menu_controller.js +32 -12
- data/dist/controllers/countdown_controller.js +129 -26
- data/dist/controllers/currency_input_controller.js +221 -67
- data/dist/controllers/data_grid_controller.js +195 -29
- data/dist/controllers/date_range_picker_controller.js +151 -30
- data/dist/controllers/dialog_controller.js +35 -8
- data/dist/controllers/direct_upload_controller.js +22 -4
- data/dist/controllers/dirty_form_controller.js +46 -13
- data/dist/controllers/dismissible_controller.js +1 -0
- data/dist/controllers/drawer_controller.js +54 -19
- data/dist/controllers/dropdown_controller.js +36 -9
- data/dist/controllers/editable_controller.js +34 -0
- data/dist/controllers/file_dropzone_controller.js +144 -51
- data/dist/controllers/filter_controller.js +20 -6
- data/dist/controllers/flash_controller.js +432 -71
- data/dist/controllers/focus_controller.js +1 -0
- data/dist/controllers/form_field_controller.js +7 -5
- data/dist/controllers/form_validation_controller.js +19 -13
- data/dist/controllers/frame_loading_controller.js +45 -8
- data/dist/controllers/highlight_controller.js +82 -25
- data/dist/controllers/hover_card_controller.js +40 -14
- data/dist/controllers/idle_controller.js +90 -5
- data/dist/controllers/input_mask_controller.js +65 -9
- data/dist/controllers/intersection_controller.js +3 -0
- data/dist/controllers/lazy_frame_controller.js +11 -2
- data/dist/controllers/listbox_controller.js +203 -45
- data/dist/controllers/local_time_controller.js +10 -5
- data/dist/controllers/masonry_controller.js +31 -15
- data/dist/controllers/menu_controller.js +45 -16
- data/dist/controllers/menubar_controller.js +58 -24
- data/dist/controllers/meter_controller.js +9 -5
- data/dist/controllers/multi_select_controller.js +278 -104
- data/dist/controllers/navigation_menu_controller.js +48 -15
- data/dist/controllers/nested_form_controller.js +37 -8
- data/dist/controllers/network_status_controller.js +9 -1
- data/dist/controllers/number_input_controller.js +124 -21
- data/dist/controllers/optimistic_controller.js +42 -5
- data/dist/controllers/otp_controller.js +198 -55
- data/dist/controllers/overflow_indicator_controller.js +115 -21
- data/dist/controllers/overflow_menu_controller.js +141 -44
- data/dist/controllers/pagination_controller.js +74 -28
- data/dist/controllers/password_reveal_controller.js +59 -2
- data/dist/controllers/persist_controller.js +30 -8
- data/dist/controllers/pointer_drag_controller.js +131 -52
- data/dist/controllers/popover_controller.js +45 -11
- data/dist/controllers/portal_controller.js +6 -2
- data/dist/controllers/preview_guard_controller.js +16 -1
- data/dist/controllers/progress_controller.js +8 -4
- data/dist/controllers/radio_group_controller.js +42 -17
- data/dist/controllers/range_slider_controller.js +88 -42
- data/dist/controllers/rating_controller.js +39 -15
- data/dist/controllers/read_more_controller.js +100 -7
- data/dist/controllers/reading_progress_controller.js +65 -19
- data/dist/controllers/relative_time_controller.js +10 -5
- data/dist/controllers/resizable_controller.js +82 -22
- data/dist/controllers/scroll_area_controller.js +75 -27
- data/dist/controllers/scroll_restore_controller.js +37 -16
- data/dist/controllers/scroll_visibility_controller.js +49 -30
- data/dist/controllers/scrollspy_controller.js +71 -26
- data/dist/controllers/separator_controller.js +66 -37
- data/dist/controllers/sidebar_controller.js +77 -18
- data/dist/controllers/skeleton_controller.js +6 -1
- data/dist/controllers/slider_controller.js +82 -47
- data/dist/controllers/smart_sticky_header_controller.js +60 -26
- data/dist/controllers/sortable_controller.js +17 -2
- data/dist/controllers/spinner_controller.js +10 -2
- data/dist/controllers/step_indicator_controller.js +18 -17
- data/dist/controllers/stepper_controller.js +101 -19
- data/dist/controllers/stick_to_bottom_controller.js +104 -8
- data/dist/controllers/submit_once_controller.js +45 -9
- data/dist/controllers/switch_controller.js +101 -10
- data/dist/controllers/tabs_controller.js +21 -2
- data/dist/controllers/tags_input_controller.js +209 -59
- data/dist/controllers/textarea_autosize_controller.js +29 -3
- data/dist/controllers/theme_controller.js +64 -14
- data/dist/controllers/time_picker_controller.js +23 -8
- data/dist/controllers/toast_controller.js +451 -105
- data/dist/controllers/toggle_group_controller.js +159 -23
- data/dist/controllers/toolbar_controller.js +32 -0
- data/dist/controllers/tooltip_controller.js +39 -13
- data/dist/controllers/transition_controller.js +4 -0
- data/dist/controllers/tree_view_controller.js +169 -16
- data/dist/index.js +5002 -1911
- data/dist/positioning/index.js +2 -0
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -21,11 +21,19 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// src/utils/composition_tracker.ts
|
|
24
|
+
var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
25
|
+
"insertCompositionText",
|
|
26
|
+
"insertFromComposition",
|
|
27
|
+
"deleteCompositionText",
|
|
28
|
+
"deleteByComposition"
|
|
29
|
+
]);
|
|
24
30
|
var CompositionTracker = class {
|
|
25
31
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
26
32
|
#activeTargets = /* @__PURE__ */ new Set();
|
|
27
33
|
#onStart;
|
|
28
34
|
#onEnd;
|
|
35
|
+
/** The field whose confirming `input` is still owed, while the window is open. */
|
|
36
|
+
#confirmedTarget = null;
|
|
29
37
|
constructor(options = {}) {
|
|
30
38
|
this.#onStart = options.onStart;
|
|
31
39
|
this.#onEnd = options.onEnd;
|
|
@@ -35,6 +43,7 @@ var CompositionTracker = class {
|
|
|
35
43
|
if (this.#observedTargets.has(target)) return;
|
|
36
44
|
target.addEventListener("compositionstart", this.#handleStart);
|
|
37
45
|
target.addEventListener("compositionend", this.#handleEnd);
|
|
46
|
+
target.addEventListener("keydown", this.#handleKeydown);
|
|
38
47
|
this.#observedTargets.add(target);
|
|
39
48
|
}
|
|
40
49
|
/** Stops tracking one target and clears any active composition it owned. */
|
|
@@ -42,31 +51,59 @@ var CompositionTracker = class {
|
|
|
42
51
|
if (!this.#observedTargets.delete(target)) return;
|
|
43
52
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
44
53
|
target.removeEventListener("compositionend", this.#handleEnd);
|
|
54
|
+
target.removeEventListener("keydown", this.#handleKeydown);
|
|
45
55
|
this.#activeTargets.delete(target);
|
|
56
|
+
if (this.#confirmedTarget === target) this.#confirmedTarget = null;
|
|
46
57
|
}
|
|
47
58
|
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
48
59
|
disconnect() {
|
|
49
60
|
for (const target of this.#observedTargets) {
|
|
50
61
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
51
62
|
target.removeEventListener("compositionend", this.#handleEnd);
|
|
63
|
+
target.removeEventListener("keydown", this.#handleKeydown);
|
|
52
64
|
}
|
|
53
65
|
this.#observedTargets.clear();
|
|
54
66
|
this.#activeTargets.clear();
|
|
67
|
+
this.#confirmedTarget = null;
|
|
55
68
|
}
|
|
56
69
|
/** True when lifecycle tracking or the current event reports composition. */
|
|
57
70
|
isComposing(event) {
|
|
58
71
|
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
59
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether `event` is the `input` echoing the composition just confirmed.
|
|
75
|
+
*
|
|
76
|
+
* Asking closes the window either way, so one confirmation is folded at most
|
|
77
|
+
* once and a consumer asks once per `input`.
|
|
78
|
+
*/
|
|
79
|
+
consumesConfirmedInput(event) {
|
|
80
|
+
const confirmed = this.#confirmedTarget;
|
|
81
|
+
this.#confirmedTarget = null;
|
|
82
|
+
if (confirmed === null || confirmed !== event.target) return false;
|
|
83
|
+
const inputType = event.inputType;
|
|
84
|
+
return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
|
|
85
|
+
}
|
|
60
86
|
#handleStart = (event) => {
|
|
87
|
+
this.#confirmedTarget = null;
|
|
61
88
|
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
62
89
|
this.#onStart?.(event);
|
|
63
90
|
};
|
|
64
91
|
#handleEnd = (event) => {
|
|
65
92
|
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
93
|
+
this.#confirmedTarget = event.target;
|
|
66
94
|
this.#onEnd?.(event);
|
|
67
95
|
};
|
|
96
|
+
/** A key on an observed field opens an edit of its own, so no echo is owed. */
|
|
97
|
+
#handleKeydown = () => {
|
|
98
|
+
this.#confirmedTarget = null;
|
|
99
|
+
};
|
|
68
100
|
};
|
|
69
101
|
|
|
102
|
+
// src/utils/field_mirror.ts
|
|
103
|
+
function commitField(target) {
|
|
104
|
+
target.dispatchEvent(new Event("change", { bubbles: true }));
|
|
105
|
+
}
|
|
106
|
+
|
|
70
107
|
// src/utils/microtask_coalescer.ts
|
|
71
108
|
var MicrotaskCoalescer = class {
|
|
72
109
|
#run;
|
|
@@ -112,20 +149,64 @@ function scrollOptionIntoView(list, option) {
|
|
|
112
149
|
}
|
|
113
150
|
}
|
|
114
151
|
|
|
152
|
+
// src/utils/state_regions.ts
|
|
153
|
+
var StateRegions = class {
|
|
154
|
+
#whenTrue;
|
|
155
|
+
#whenFalse;
|
|
156
|
+
#taken = /* @__PURE__ */ new WeakSet();
|
|
157
|
+
constructor(options) {
|
|
158
|
+
this.#whenTrue = options.whenTrue;
|
|
159
|
+
this.#whenFalse = options.whenFalse ?? null;
|
|
160
|
+
}
|
|
161
|
+
/** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
|
|
162
|
+
reflect(host, isTrue) {
|
|
163
|
+
const shown = this.#inside(host, this.#whenTrue());
|
|
164
|
+
if (!this.#whenFalse) {
|
|
165
|
+
for (const region of shown) this.#write(region, !isTrue);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const hiddenSide = this.#inside(host, this.#whenFalse());
|
|
169
|
+
if (shown.length === 0 || hiddenSide.length === 0) {
|
|
170
|
+
for (const region of shown) this.#give(region);
|
|
171
|
+
for (const region of hiddenSide) this.#give(region);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
for (const region of shown) this.#write(region, !isTrue);
|
|
175
|
+
for (const region of hiddenSide) this.#write(region, isTrue);
|
|
176
|
+
}
|
|
177
|
+
/** Writes `hidden` where it moves, noting which regions it takes out of view. */
|
|
178
|
+
#write(region, hidden) {
|
|
179
|
+
if (hidden) this.#taken.add(region);
|
|
180
|
+
else this.#taken.delete(region);
|
|
181
|
+
if (region.hidden !== hidden) region.hidden = hidden;
|
|
182
|
+
}
|
|
183
|
+
/** Returns a region this instance hid; one it never hid keeps what it carries. */
|
|
184
|
+
#give(region) {
|
|
185
|
+
if (this.#taken.delete(region)) region.hidden = false;
|
|
186
|
+
}
|
|
187
|
+
/** The declared regions that sit within `host`, which may be the host itself. */
|
|
188
|
+
#inside(host, regions) {
|
|
189
|
+
return regions.filter((region) => host.contains(region));
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
115
193
|
// src/controllers/combobox_controller.ts
|
|
116
194
|
var ComboboxController = class extends Controller {
|
|
117
|
-
static targets = ["input", "list", "option"];
|
|
195
|
+
static targets = ["input", "list", "option", "empty"];
|
|
118
196
|
static actions = ["close", "filter", "onKeydown", "open", "selectByClick"];
|
|
119
197
|
static events = ["selected"];
|
|
198
|
+
/** Owns `hidden` on the regions declared for the empty state. */
|
|
199
|
+
#emptyRegion = new StateRegions({ whenTrue: () => this.emptyTargets });
|
|
120
200
|
/** Stable ID of the active option; the live element is resolved before every use. */
|
|
121
201
|
#activeId = null;
|
|
122
202
|
#connected = false;
|
|
123
203
|
/** Collapses one mutation batch of target callbacks into a single pass. */
|
|
124
204
|
#reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
|
|
125
205
|
/**
|
|
126
|
-
* Suppresses {@link open} for the duration of the
|
|
127
|
-
* `#select`, so committing a value (which returns focus
|
|
128
|
-
* does not immediately re-open the listbox via a `focus`-bound
|
|
206
|
+
* Suppresses {@link ComboboxController.open | open} for the duration of the
|
|
207
|
+
* programmatic re-focus in `#select`, so committing a value (which returns focus
|
|
208
|
+
* to the input) does not immediately re-open the listbox via a `focus`-bound
|
|
209
|
+
* action.
|
|
129
210
|
*/
|
|
130
211
|
#suppressOpen = false;
|
|
131
212
|
/** Owns IME lifecycle state; confirmed text re-filters the list once. */
|
|
@@ -154,6 +235,10 @@ var ComboboxController = class extends Controller {
|
|
|
154
235
|
inputTargetDisconnected(input) {
|
|
155
236
|
this.#composition.unobserve(input);
|
|
156
237
|
}
|
|
238
|
+
/** Settles a connecting region on the side the empty state is on. */
|
|
239
|
+
emptyTargetConnected() {
|
|
240
|
+
this.#reflectEmptyState();
|
|
241
|
+
}
|
|
157
242
|
/** Establishes the active-state baseline, then reconciles a runtime addition. */
|
|
158
243
|
optionTargetConnected(option) {
|
|
159
244
|
option.setAttribute("aria-selected", "false");
|
|
@@ -206,7 +291,7 @@ var ComboboxController = class extends Controller {
|
|
|
206
291
|
close() {
|
|
207
292
|
if (!this.hasListTarget) return;
|
|
208
293
|
this.listTarget.hidden = true;
|
|
209
|
-
this
|
|
294
|
+
this.#reflectEmptyState();
|
|
210
295
|
if (this.hasInputTarget) this.inputTarget.setAttribute("aria-expanded", "false");
|
|
211
296
|
this.#setActive(-1);
|
|
212
297
|
}
|
|
@@ -300,10 +385,10 @@ var ComboboxController = class extends Controller {
|
|
|
300
385
|
* Commits an option: fills the input, closes the listbox, notifies listeners.
|
|
301
386
|
*
|
|
302
387
|
* An input removed while the popup is open leaves the options clickable, and
|
|
303
|
-
* {@link close} already survives that state. Selection
|
|
304
|
-
* comes down and listeners still hear the choice, with only
|
|
305
|
-
* half — the value write, the focus return, and the native
|
|
306
|
-
* because there is no field to carry them.
|
|
388
|
+
* {@link ComboboxController.close | close} already survives that state. Selection
|
|
389
|
+
* does too: the popup comes down and listeners still hear the choice, with only
|
|
390
|
+
* the field-bound half — the value write, the focus return, and the native
|
|
391
|
+
* `change` — skipped, because there is no field to carry them.
|
|
307
392
|
*/
|
|
308
393
|
#select(option) {
|
|
309
394
|
const value = option.dataset.value ?? (option.textContent ?? "").trim();
|
|
@@ -319,23 +404,28 @@ var ComboboxController = class extends Controller {
|
|
|
319
404
|
this.inputTarget.focus();
|
|
320
405
|
this.#suppressOpen = false;
|
|
321
406
|
if (changed) {
|
|
322
|
-
this.inputTarget
|
|
407
|
+
commitField(this.inputTarget);
|
|
323
408
|
}
|
|
324
409
|
this.dispatch("selected", { detail: { value } });
|
|
325
410
|
}
|
|
326
411
|
/**
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* consumers decide how to present the empty state
|
|
330
|
-
*
|
|
412
|
+
* Writes whether the open listbox has zero matching options to the state hook
|
|
413
|
+
* `data-stimeo--combobox-empty` on the root element and to the regions declared
|
|
414
|
+
* for that state. Behavior only: consumers decide how to present the empty state
|
|
415
|
+
* via CSS keyed off the attribute, while the `empty` targets carry the copy.
|
|
416
|
+
*
|
|
417
|
+
* Every path that can change the answer — opening, filtering, reconciling a
|
|
418
|
+
* runtime option change, and closing — ends here, so the hook and the regions
|
|
419
|
+
* are a pure function of the state rather than of the order things happened in.
|
|
331
420
|
*/
|
|
332
421
|
#reflectEmptyState() {
|
|
333
422
|
const empty = !this.#isClosed && this.#visibleOptions().length === 0;
|
|
334
423
|
if (empty) {
|
|
335
|
-
this.element.setAttribute(
|
|
424
|
+
this.element.setAttribute(`data-${this.identifier}-empty`, "");
|
|
336
425
|
} else {
|
|
337
|
-
this.element.removeAttribute(
|
|
426
|
+
this.element.removeAttribute(`data-${this.identifier}-empty`);
|
|
338
427
|
}
|
|
428
|
+
this.#emptyRegion.reflect(this.element, empty);
|
|
339
429
|
}
|
|
340
430
|
/**
|
|
341
431
|
* Marks the visible option at `index` active via `aria-selected` and the
|
|
@@ -21,11 +21,19 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// src/utils/composition_tracker.ts
|
|
24
|
+
var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
25
|
+
"insertCompositionText",
|
|
26
|
+
"insertFromComposition",
|
|
27
|
+
"deleteCompositionText",
|
|
28
|
+
"deleteByComposition"
|
|
29
|
+
]);
|
|
24
30
|
var CompositionTracker = class {
|
|
25
31
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
26
32
|
#activeTargets = /* @__PURE__ */ new Set();
|
|
27
33
|
#onStart;
|
|
28
34
|
#onEnd;
|
|
35
|
+
/** The field whose confirming `input` is still owed, while the window is open. */
|
|
36
|
+
#confirmedTarget = null;
|
|
29
37
|
constructor(options = {}) {
|
|
30
38
|
this.#onStart = options.onStart;
|
|
31
39
|
this.#onEnd = options.onEnd;
|
|
@@ -35,6 +43,7 @@ var CompositionTracker = class {
|
|
|
35
43
|
if (this.#observedTargets.has(target)) return;
|
|
36
44
|
target.addEventListener("compositionstart", this.#handleStart);
|
|
37
45
|
target.addEventListener("compositionend", this.#handleEnd);
|
|
46
|
+
target.addEventListener("keydown", this.#handleKeydown);
|
|
38
47
|
this.#observedTargets.add(target);
|
|
39
48
|
}
|
|
40
49
|
/** Stops tracking one target and clears any active composition it owned. */
|
|
@@ -42,29 +51,52 @@ var CompositionTracker = class {
|
|
|
42
51
|
if (!this.#observedTargets.delete(target)) return;
|
|
43
52
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
44
53
|
target.removeEventListener("compositionend", this.#handleEnd);
|
|
54
|
+
target.removeEventListener("keydown", this.#handleKeydown);
|
|
45
55
|
this.#activeTargets.delete(target);
|
|
56
|
+
if (this.#confirmedTarget === target) this.#confirmedTarget = null;
|
|
46
57
|
}
|
|
47
58
|
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
48
59
|
disconnect() {
|
|
49
60
|
for (const target of this.#observedTargets) {
|
|
50
61
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
51
62
|
target.removeEventListener("compositionend", this.#handleEnd);
|
|
63
|
+
target.removeEventListener("keydown", this.#handleKeydown);
|
|
52
64
|
}
|
|
53
65
|
this.#observedTargets.clear();
|
|
54
66
|
this.#activeTargets.clear();
|
|
67
|
+
this.#confirmedTarget = null;
|
|
55
68
|
}
|
|
56
69
|
/** True when lifecycle tracking or the current event reports composition. */
|
|
57
70
|
isComposing(event) {
|
|
58
71
|
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
59
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether `event` is the `input` echoing the composition just confirmed.
|
|
75
|
+
*
|
|
76
|
+
* Asking closes the window either way, so one confirmation is folded at most
|
|
77
|
+
* once and a consumer asks once per `input`.
|
|
78
|
+
*/
|
|
79
|
+
consumesConfirmedInput(event) {
|
|
80
|
+
const confirmed = this.#confirmedTarget;
|
|
81
|
+
this.#confirmedTarget = null;
|
|
82
|
+
if (confirmed === null || confirmed !== event.target) return false;
|
|
83
|
+
const inputType = event.inputType;
|
|
84
|
+
return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
|
|
85
|
+
}
|
|
60
86
|
#handleStart = (event) => {
|
|
87
|
+
this.#confirmedTarget = null;
|
|
61
88
|
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
62
89
|
this.#onStart?.(event);
|
|
63
90
|
};
|
|
64
91
|
#handleEnd = (event) => {
|
|
65
92
|
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
93
|
+
this.#confirmedTarget = event.target;
|
|
66
94
|
this.#onEnd?.(event);
|
|
67
95
|
};
|
|
96
|
+
/** A key on an observed field opens an edit of its own, so no echo is owed. */
|
|
97
|
+
#handleKeydown = () => {
|
|
98
|
+
this.#confirmedTarget = null;
|
|
99
|
+
};
|
|
68
100
|
};
|
|
69
101
|
|
|
70
102
|
// src/utils/before_cache_reset.ts
|
|
@@ -511,9 +543,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
511
543
|
* the restored snapshot already shows the dialog open, honor that rather than
|
|
512
544
|
* re-deriving from the declarative `open` Value (which would slam a user-opened
|
|
513
545
|
* palette shut). The `open` Value only seeds the initial state of a genuinely
|
|
514
|
-
* fresh render. We normalize to a clean closed baseline first so
|
|
515
|
-
* runs its full setup — the
|
|
516
|
-
* reconnect and must be re-activated.
|
|
546
|
+
* fresh render. We normalize to a clean closed baseline first so
|
|
547
|
+
* {@link CommandPaletteController.open | open} runs its full setup — the
|
|
548
|
+
* `FocusTrap` is a fresh instance after a reconnect and must be re-activated.
|
|
517
549
|
*/
|
|
518
550
|
connect() {
|
|
519
551
|
document.addEventListener("keydown", this.#onGlobalKeydown);
|
|
@@ -26,6 +26,78 @@ function canTakeFocus(element) {
|
|
|
26
26
|
return !inheritsFieldsetDisabled(element);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// src/utils/form_reset_watcher.ts
|
|
30
|
+
var FormResetWatcher = class {
|
|
31
|
+
#owns;
|
|
32
|
+
#onReset;
|
|
33
|
+
#frames = /* @__PURE__ */ new Set();
|
|
34
|
+
#generation = 0;
|
|
35
|
+
/**
|
|
36
|
+
* @param owns - whether `form` owns a control this controller derives from.
|
|
37
|
+
* @param onReset - the reconciliation pass, run once per honoured reset.
|
|
38
|
+
*/
|
|
39
|
+
constructor(owns, onReset) {
|
|
40
|
+
this.#owns = owns;
|
|
41
|
+
this.#onReset = onReset;
|
|
42
|
+
}
|
|
43
|
+
/** Subscribes to `reset` at the document; call from `connect()`. Idempotent. */
|
|
44
|
+
observe() {
|
|
45
|
+
document.addEventListener("reset", this.#handleReset, true);
|
|
46
|
+
}
|
|
47
|
+
/** Unsubscribes and drops every pending pass; call from `disconnect()`. */
|
|
48
|
+
disconnect() {
|
|
49
|
+
document.removeEventListener("reset", this.#handleReset, true);
|
|
50
|
+
this.#generation += 1;
|
|
51
|
+
for (const frame of this.#frames) cancelAnimationFrame(frame);
|
|
52
|
+
this.#frames.clear();
|
|
53
|
+
}
|
|
54
|
+
#handleReset = (event) => {
|
|
55
|
+
const form = event.target;
|
|
56
|
+
if (!(form instanceof HTMLFormElement) || !this.#owns(form)) return;
|
|
57
|
+
const generation = this.#generation;
|
|
58
|
+
queueMicrotask(() => {
|
|
59
|
+
if (generation !== this.#generation) return;
|
|
60
|
+
if (event.eventPhase === Event.NONE) {
|
|
61
|
+
this.#settle(event);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const frame = requestAnimationFrame(() => {
|
|
65
|
+
this.#frames.delete(frame);
|
|
66
|
+
this.#settle(event);
|
|
67
|
+
});
|
|
68
|
+
this.#frames.add(frame);
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
/** Runs the pass unless a listener cancelled the reset before it landed. */
|
|
72
|
+
#settle(event) {
|
|
73
|
+
if (!event.defaultPrevented) this.#onReset();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// src/utils/listener_set.ts
|
|
78
|
+
var ListenerSet = class {
|
|
79
|
+
/** The generation every `add` joins until the next `dispose()`. */
|
|
80
|
+
#abort = new AbortController();
|
|
81
|
+
/**
|
|
82
|
+
* Attaches `handler` to the open generation, exactly as the caller spelled it.
|
|
83
|
+
*
|
|
84
|
+
* The set supplies the signal, so `options` carries everything else the DOM
|
|
85
|
+
* accepts — `capture` included, which has to match at release time and no
|
|
86
|
+
* longer has a second place to drift from.
|
|
87
|
+
*/
|
|
88
|
+
add(target, type, handler, options) {
|
|
89
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Releases every listener of the open generation, synchronously, and opens the
|
|
93
|
+
* next one. Idempotent, and safe before anything has been added.
|
|
94
|
+
*/
|
|
95
|
+
dispose() {
|
|
96
|
+
this.#abort.abort();
|
|
97
|
+
this.#abort = new AbortController();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
29
101
|
// src/utils/microtask_coalescer.ts
|
|
30
102
|
var MicrotaskCoalescer = class {
|
|
31
103
|
#run;
|
|
@@ -150,6 +222,11 @@ var ConditionalFieldsController = class extends Controller {
|
|
|
150
222
|
#observing = false;
|
|
151
223
|
/** Coalesces one target/morph/mutation batch into one full DOM reconciliation. */
|
|
152
224
|
#reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
|
|
225
|
+
#listeners = new ListenerSet();
|
|
226
|
+
#formReset = new FormResetWatcher(
|
|
227
|
+
(form) => this.#hasTriggerOwnedBy(form),
|
|
228
|
+
() => this.#reconcile.schedule()
|
|
229
|
+
);
|
|
153
230
|
/** Provides the last-resort focus landmark without claiming an authored tabindex. */
|
|
154
231
|
#rootTabindex = new TabindexLoan();
|
|
155
232
|
/** Watches retained targets and controls whose declarative or reflected state changed. */
|
|
@@ -166,15 +243,6 @@ var ConditionalFieldsController = class extends Controller {
|
|
|
166
243
|
#onMorph = () => {
|
|
167
244
|
this.#reconcile.schedule();
|
|
168
245
|
};
|
|
169
|
-
/** Reconciles the settled defaults after a non-cancelled owning form reset. */
|
|
170
|
-
#onReset = (event) => {
|
|
171
|
-
const form = event.target;
|
|
172
|
-
if (form instanceof HTMLFormElement && this.#hasTriggerOwnedBy(form)) {
|
|
173
|
-
queueMicrotask(() => {
|
|
174
|
-
if (!event.defaultPrevented) this.#reconcile.schedule();
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
};
|
|
178
246
|
/** Reflects initial live state and opens every retained-DOM reconciliation path. */
|
|
179
247
|
connect() {
|
|
180
248
|
this.#lastVisible = /* @__PURE__ */ new WeakMap();
|
|
@@ -188,20 +256,18 @@ var ConditionalFieldsController = class extends Controller {
|
|
|
188
256
|
subtree: true
|
|
189
257
|
});
|
|
190
258
|
this.#observing = true;
|
|
191
|
-
this.element
|
|
192
|
-
this.element
|
|
193
|
-
this.element
|
|
194
|
-
|
|
259
|
+
this.#listeners.add(this.element, "change", this.#onTriggerInput);
|
|
260
|
+
this.#listeners.add(this.element, "input", this.#onTriggerInput);
|
|
261
|
+
this.#listeners.add(this.element, "turbo:morph-element", this.#onMorph);
|
|
262
|
+
this.#formReset.observe();
|
|
195
263
|
}
|
|
196
264
|
/** Releases all external resources and returns the temporary focus landmark loan. */
|
|
197
265
|
disconnect() {
|
|
198
266
|
this.#reconcile.cancel();
|
|
199
267
|
this.#observing = false;
|
|
200
268
|
this.#observer.disconnect();
|
|
201
|
-
this.
|
|
202
|
-
this.
|
|
203
|
-
this.element.removeEventListener("turbo:morph-element", this.#onMorph);
|
|
204
|
-
document.removeEventListener("reset", this.#onReset, true);
|
|
269
|
+
this.#listeners.dispose();
|
|
270
|
+
this.#formReset.disconnect();
|
|
205
271
|
this.#rootTabindex.returnAll();
|
|
206
272
|
}
|
|
207
273
|
/** Reconciles after a trigger target is inserted or restored. */
|
|
@@ -235,6 +301,8 @@ var ConditionalFieldsController = class extends Controller {
|
|
|
235
301
|
/**
|
|
236
302
|
* Computes one settled plan, shows destinations before hiding sources, and then
|
|
237
303
|
* returns logical transitions in DOM order after every region is internally coherent.
|
|
304
|
+
*
|
|
305
|
+
* @stimeoRenderRoot
|
|
238
306
|
*/
|
|
239
307
|
#settle(triggers = this.triggerTargets) {
|
|
240
308
|
const regions = this.regionTargets;
|
|
@@ -423,6 +423,9 @@ var ConfirmController = class extends Controller {
|
|
|
423
423
|
/**
|
|
424
424
|
* Opens the dialog for `message` and resolves once the user confirms or cancels.
|
|
425
425
|
* Degrades to native `window.confirm` when no dialog target is present.
|
|
426
|
+
*
|
|
427
|
+
* @stimeoRuntimeOnly The labels word the one dialog this call opens; every opening writes both
|
|
428
|
+
* labels again, and the dialog stays hidden between prompts.
|
|
426
429
|
*/
|
|
427
430
|
#prompt(message) {
|
|
428
431
|
if (!this.hasDialogTarget) return Promise.resolve(window.confirm(message));
|
|
@@ -151,22 +151,37 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
151
151
|
}
|
|
152
152
|
};
|
|
153
153
|
|
|
154
|
+
// src/utils/state_reason.ts
|
|
155
|
+
var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
|
|
156
|
+
var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
|
|
157
|
+
function stateReasonFor(event) {
|
|
158
|
+
if (!event) return "api";
|
|
159
|
+
if (FOCUS_EVENTS.has(event.type)) return "focus";
|
|
160
|
+
if (POINTER_EVENTS.has(event.type)) return "pointer";
|
|
161
|
+
return "user";
|
|
162
|
+
}
|
|
163
|
+
|
|
154
164
|
// src/controllers/context_menu_controller.ts
|
|
155
165
|
var ContextMenuController = class extends Controller {
|
|
156
166
|
static targets = ["region", "menu", "item"];
|
|
157
167
|
static actions = ["activate", "onItemKeydown", "onRegionKeydown", "open"];
|
|
168
|
+
static events = ["close", "open"];
|
|
158
169
|
#timers = new SafeTimeout();
|
|
159
170
|
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
160
171
|
#escapeLayer = new EscapeLayer();
|
|
172
|
+
/** Whether state moves are reported: set once `connect()` settled the baseline. */
|
|
173
|
+
#reporting = false;
|
|
161
174
|
/** Starts closed and registers delegated activation and outside-pointer listeners. */
|
|
162
175
|
connect() {
|
|
163
|
-
this.#closeMenu();
|
|
176
|
+
this.#closeMenu("api");
|
|
164
177
|
this.element.addEventListener("click", this.#onItemClickCapture, true);
|
|
165
178
|
document.addEventListener("click", this.#onOutsidePointer, true);
|
|
166
179
|
document.addEventListener("contextmenu", this.#onOutsidePointer, true);
|
|
180
|
+
this.#reporting = true;
|
|
167
181
|
}
|
|
168
182
|
/** Releases the listeners, stack membership, and pending Tab-close task. */
|
|
169
183
|
disconnect() {
|
|
184
|
+
this.#reporting = false;
|
|
170
185
|
this.#timers.clearAll();
|
|
171
186
|
this.#escapeLayer.deactivate();
|
|
172
187
|
this.element.removeEventListener("click", this.#onItemClickCapture, true);
|
|
@@ -179,7 +194,7 @@ var ContextMenuController = class extends Controller {
|
|
|
179
194
|
*/
|
|
180
195
|
open(event) {
|
|
181
196
|
event.preventDefault();
|
|
182
|
-
this.#openAt(event.clientX, event.clientY);
|
|
197
|
+
this.#openAt(event.clientX, event.clientY, stateReasonFor(event));
|
|
183
198
|
}
|
|
184
199
|
/** Keyboard entry on the region: `Shift+F10` / `ContextMenu` open at center. */
|
|
185
200
|
onRegionKeydown(event) {
|
|
@@ -188,7 +203,7 @@ var ContextMenuController = class extends Controller {
|
|
|
188
203
|
if (!isContextKey) return;
|
|
189
204
|
event.preventDefault();
|
|
190
205
|
const rect = this.hasRegionTarget ? this.regionTarget.getBoundingClientRect() : { left: 0, top: 0, width: 0, height: 0 };
|
|
191
|
-
this.#openAt(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
206
|
+
this.#openAt(rect.left + rect.width / 2, rect.top + rect.height / 2, stateReasonFor(event));
|
|
192
207
|
}
|
|
193
208
|
/** Roving focus and closing keys inside the menu. */
|
|
194
209
|
onItemKeydown(event) {
|
|
@@ -221,39 +236,44 @@ var ContextMenuController = class extends Controller {
|
|
|
221
236
|
break;
|
|
222
237
|
case "Tab":
|
|
223
238
|
this.#timers.clearAll();
|
|
224
|
-
this.#timers.set(() => this.#closeMenu(), 0);
|
|
239
|
+
this.#timers.set(() => this.#closeMenu("focus"), 0);
|
|
225
240
|
break;
|
|
226
241
|
}
|
|
227
242
|
}
|
|
228
243
|
/** Closes after an item is activated and restores focus to the region. */
|
|
229
244
|
activate() {
|
|
230
|
-
this.#closeAndRestore();
|
|
245
|
+
this.#closeAndRestore("select");
|
|
231
246
|
}
|
|
232
247
|
/** Opens the menu at viewport coordinates `(x, y)` and focuses the first item. */
|
|
233
|
-
#openAt(x, y) {
|
|
248
|
+
#openAt(x, y, reason) {
|
|
234
249
|
if (!this.hasMenuTarget) return;
|
|
235
250
|
this.#timers.clearAll();
|
|
251
|
+
const was = this.#isOpen;
|
|
236
252
|
this.#escapeLayer.activate(document, {
|
|
237
|
-
onDismiss: () => this.#closeAndRestore(),
|
|
253
|
+
onDismiss: () => this.#closeAndRestore("escape"),
|
|
238
254
|
claims: claimsWhileFocusWithin(this.element)
|
|
239
255
|
});
|
|
240
256
|
this.menuTarget.style.setProperty("--stimeo--context-menu-x", `${x}px`);
|
|
241
257
|
this.menuTarget.style.setProperty("--stimeo--context-menu-y", `${y}px`);
|
|
242
258
|
this.menuTarget.hidden = false;
|
|
243
259
|
if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "open");
|
|
260
|
+
if (!was && this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
|
|
261
|
+
if (!this.#isOpen) return;
|
|
244
262
|
this.#navigableItems[0]?.focus();
|
|
245
263
|
}
|
|
246
|
-
/** Hides the menu
|
|
247
|
-
#closeMenu() {
|
|
264
|
+
/** Hides the menu, reflects the collapsed state on the region, and reports a move. */
|
|
265
|
+
#closeMenu(reason) {
|
|
248
266
|
this.#timers.clearAll();
|
|
249
267
|
this.#escapeLayer.deactivate();
|
|
250
268
|
if (!this.hasMenuTarget) return;
|
|
269
|
+
const was = this.#isOpen;
|
|
251
270
|
this.menuTarget.hidden = true;
|
|
252
271
|
if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "closed");
|
|
272
|
+
if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
|
|
253
273
|
}
|
|
254
274
|
/** Closes the menu and returns focus to the region (Escape / activation). */
|
|
255
|
-
#closeAndRestore() {
|
|
256
|
-
this.#closeMenu();
|
|
275
|
+
#closeAndRestore(reason) {
|
|
276
|
+
this.#closeMenu(reason);
|
|
257
277
|
if (this.hasRegionTarget) this.regionTarget.focus();
|
|
258
278
|
}
|
|
259
279
|
/**
|
|
@@ -264,7 +284,7 @@ var ContextMenuController = class extends Controller {
|
|
|
264
284
|
* open.
|
|
265
285
|
*/
|
|
266
286
|
#onOutsidePointer = (event) => {
|
|
267
|
-
if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
|
|
287
|
+
if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu("outside");
|
|
268
288
|
};
|
|
269
289
|
/**
|
|
270
290
|
* Captures clicks so `aria-disabled` commands cannot reach consumer handlers.
|