stimeo-ui 0.1.0.pre.beta.3 → 0.2.1
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 +149 -0
- data/README.md +6 -6
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/auto_submit_controller.js +56 -21
- data/dist/controllers/carousel_controller.js +272 -0
- data/dist/controllers/character_counter_controller.js +52 -15
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +213 -0
- data/dist/controllers/combobox_controller.js +64 -3
- data/dist/controllers/command_palette_controller.js +243 -39
- data/dist/controllers/confirm_controller.js +94 -8
- data/dist/controllers/context_menu_controller.js +191 -13
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/countdown_controller.js +2 -2
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +168 -0
- data/dist/controllers/date_range_picker_controller.js +417 -0
- data/dist/controllers/dialog_controller.js +94 -8
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/dropdown_controller.js +139 -16
- data/dist/controllers/editable_controller.js +168 -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/focus_controller.js +94 -8
- data/dist/controllers/form_validation_controller.js +2 -2
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/hover_card_controller.js +103 -27
- data/dist/controllers/intersection_controller.js +41 -18
- data/dist/controllers/lazy_frame_controller.js +33 -11
- data/dist/controllers/listbox_controller.js +1 -0
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menu_controller.js +181 -8
- data/dist/controllers/menubar_controller.js +433 -0
- data/dist/controllers/multi_select_controller.js +472 -0
- data/dist/controllers/navigation_menu_controller.js +384 -0
- data/dist/controllers/otp_controller.js +62 -22
- data/dist/controllers/overflow_indicator_controller.js +178 -27
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/pointer_drag_controller.js +3 -3
- data/dist/controllers/popover_controller.js +118 -27
- data/dist/controllers/range_slider_controller.js +166 -0
- data/dist/controllers/read_more_controller.js +194 -0
- data/dist/controllers/scroll_area_controller.js +15 -2
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +8 -4
- data/dist/controllers/scrollspy_controller.js +33 -11
- data/dist/controllers/separator_controller.js +87 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +8 -4
- data/dist/controllers/sticky_observer_controller.js +88 -20
- data/dist/controllers/tags_input_controller.js +275 -0
- data/dist/controllers/theme_controller.js +20 -10
- data/dist/controllers/time_picker_controller.js +212 -0
- data/dist/controllers/toast_controller.js +144 -38
- data/dist/controllers/tooltip_controller.js +148 -42
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +275 -0
- data/dist/index.js +1644 -643
- data/lib/stimeo/ui/version.rb +5 -4
- metadata +28 -2
|
@@ -1,35 +1,136 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/dropdown_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/aria_ids.ts
|
|
6
|
+
var counter = 0;
|
|
7
|
+
function uniqueId(prefix = "stimeo") {
|
|
8
|
+
let candidate;
|
|
9
|
+
do {
|
|
10
|
+
counter += 1;
|
|
11
|
+
candidate = `${prefix}-${counter}`;
|
|
12
|
+
} while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
|
|
13
|
+
return candidate;
|
|
14
|
+
}
|
|
15
|
+
function ensureId(element, prefix = "stimeo") {
|
|
16
|
+
if (element.id) return element.id;
|
|
17
|
+
const id = uniqueId(prefix);
|
|
18
|
+
element.id = id;
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/escape_layer.ts
|
|
23
|
+
function claimsWhileFocusWithin(element) {
|
|
24
|
+
return () => {
|
|
25
|
+
const active = element.ownerDocument.activeElement;
|
|
26
|
+
return active === null || active === element.ownerDocument.body || element.contains(active);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
var EscapeLayer = class _EscapeLayer {
|
|
30
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
31
|
+
#ownerDocument = null;
|
|
32
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
33
|
+
#onDismiss = null;
|
|
34
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
35
|
+
#claims = null;
|
|
36
|
+
/**
|
|
37
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
38
|
+
* the document's shared resolver listener if this is its first layer.
|
|
39
|
+
* Re-activating an already-active layer moves it to the top.
|
|
40
|
+
*/
|
|
41
|
+
activate(ownerDocument = document, options) {
|
|
42
|
+
this.deactivate();
|
|
43
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
44
|
+
if (!registry) {
|
|
45
|
+
registry = _EscapeLayer.#createRegistry();
|
|
46
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
47
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
48
|
+
}
|
|
49
|
+
registry.stack.push(this);
|
|
50
|
+
this.#ownerDocument = ownerDocument;
|
|
51
|
+
this.#onDismiss = options.onDismiss;
|
|
52
|
+
this.#claims = options.claims ?? null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
56
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
57
|
+
*/
|
|
58
|
+
deactivate() {
|
|
59
|
+
const ownerDocument = this.#ownerDocument;
|
|
60
|
+
if (!ownerDocument) return;
|
|
61
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
62
|
+
if (registry) {
|
|
63
|
+
const index = registry.stack.lastIndexOf(this);
|
|
64
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
65
|
+
if (registry.stack.length === 0) {
|
|
66
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
67
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.#ownerDocument = null;
|
|
71
|
+
this.#onDismiss = null;
|
|
72
|
+
this.#claims = null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
76
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
77
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
78
|
+
*/
|
|
79
|
+
get ownsEscape() {
|
|
80
|
+
const ownerDocument = this.#ownerDocument;
|
|
81
|
+
if (!ownerDocument) return false;
|
|
82
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
83
|
+
if (!registry) return false;
|
|
84
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
85
|
+
}
|
|
86
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
87
|
+
static #createRegistry() {
|
|
88
|
+
const registry = {
|
|
89
|
+
stack: [],
|
|
90
|
+
onKeydown: (event) => {
|
|
91
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
92
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
93
|
+
if (!owner) return;
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
owner.#onDismiss?.();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
return registry;
|
|
99
|
+
}
|
|
100
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
101
|
+
static #resolveOwner(stack) {
|
|
102
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
103
|
+
const layer = stack[index];
|
|
104
|
+
if (!layer) continue;
|
|
105
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
106
|
+
return layer;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
3
112
|
// src/controllers/dropdown_controller.ts
|
|
4
113
|
var DropdownController = class extends Controller {
|
|
5
114
|
static targets = ["trigger", "menu"];
|
|
6
115
|
static actions = ["close", "open", "toggle"];
|
|
116
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
117
|
+
#escapeLayer = new EscapeLayer();
|
|
7
118
|
/** Closes the menu when a click lands outside the controller's element. */
|
|
8
119
|
#onOutsideClick = (event) => {
|
|
9
120
|
if (!this.element.contains(event.target)) {
|
|
10
121
|
this.close();
|
|
11
122
|
}
|
|
12
123
|
};
|
|
13
|
-
/**
|
|
14
|
-
#onKeydown = (event) => {
|
|
15
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
16
|
-
this.close();
|
|
17
|
-
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
-
/**
|
|
21
|
-
* Starts in the closed state and registers the document-level listeners that
|
|
22
|
-
* power outside-click and `Escape` handling.
|
|
23
|
-
*/
|
|
124
|
+
/** Starts in the closed state and registers outside-click handling. */
|
|
24
125
|
connect() {
|
|
126
|
+
this.#associateTriggerWithMenu();
|
|
25
127
|
this.close();
|
|
26
|
-
document.addEventListener("click", this.#onOutsideClick);
|
|
27
|
-
document.addEventListener("keydown", this.#onKeydown);
|
|
128
|
+
document.addEventListener("click", this.#onOutsideClick, true);
|
|
28
129
|
}
|
|
29
|
-
/** Removes the
|
|
130
|
+
/** Removes the listeners registered in {@link connect}. */
|
|
30
131
|
disconnect() {
|
|
31
|
-
|
|
32
|
-
document.removeEventListener("
|
|
132
|
+
this.#escapeLayer.deactivate();
|
|
133
|
+
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
33
134
|
}
|
|
34
135
|
/** Toggles the menu between open and closed. Bound via `data-action`. */
|
|
35
136
|
toggle() {
|
|
@@ -42,6 +143,10 @@ var DropdownController = class extends Controller {
|
|
|
42
143
|
/** Reveals the menu and reflects the open state on the trigger. */
|
|
43
144
|
open() {
|
|
44
145
|
if (!this.hasMenuTarget) return;
|
|
146
|
+
this.#escapeLayer.activate(document, {
|
|
147
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
148
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
149
|
+
});
|
|
45
150
|
this.menuTarget.hidden = false;
|
|
46
151
|
if (this.hasTriggerTarget) {
|
|
47
152
|
this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
@@ -49,16 +154,34 @@ var DropdownController = class extends Controller {
|
|
|
49
154
|
}
|
|
50
155
|
/** Hides the menu and reflects the closed state on the trigger. */
|
|
51
156
|
close() {
|
|
157
|
+
this.#escapeLayer.deactivate();
|
|
52
158
|
if (!this.hasMenuTarget) return;
|
|
53
159
|
this.menuTarget.hidden = true;
|
|
54
160
|
if (this.hasTriggerTarget) {
|
|
55
161
|
this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
56
162
|
}
|
|
57
163
|
}
|
|
164
|
+
/** Closes and restores focus to the trigger (the keyboard-dismissal path). */
|
|
165
|
+
#closeAndRestore() {
|
|
166
|
+
this.close();
|
|
167
|
+
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
168
|
+
}
|
|
58
169
|
/** Whether the menu is currently visible. */
|
|
59
170
|
get #isOpen() {
|
|
60
171
|
return this.hasMenuTarget && !this.menuTarget.hidden;
|
|
61
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Associates the disclosure trigger and controlled region without clobbering
|
|
175
|
+
* authored markup.
|
|
176
|
+
*/
|
|
177
|
+
#associateTriggerWithMenu() {
|
|
178
|
+
if (!this.hasTriggerTarget || !this.hasMenuTarget) return;
|
|
179
|
+
if (this.triggerTarget.hasAttribute("aria-controls")) return;
|
|
180
|
+
this.triggerTarget.setAttribute(
|
|
181
|
+
"aria-controls",
|
|
182
|
+
ensureId(this.menuTarget, "stimeo--dropdown-menu")
|
|
183
|
+
);
|
|
184
|
+
}
|
|
62
185
|
};
|
|
63
186
|
|
|
64
187
|
export { DropdownController };
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/editable_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/composition_tracker.ts
|
|
6
|
+
var CompositionTracker = class {
|
|
7
|
+
#observedTargets = /* @__PURE__ */ new Set();
|
|
8
|
+
#activeTargets = /* @__PURE__ */ new Set();
|
|
9
|
+
#onStart;
|
|
10
|
+
#onEnd;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.#onStart = options.onStart;
|
|
13
|
+
this.#onEnd = options.onEnd;
|
|
14
|
+
}
|
|
15
|
+
/** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
|
|
16
|
+
observe(target) {
|
|
17
|
+
if (this.#observedTargets.has(target)) return;
|
|
18
|
+
target.addEventListener("compositionstart", this.#handleStart);
|
|
19
|
+
target.addEventListener("compositionend", this.#handleEnd);
|
|
20
|
+
this.#observedTargets.add(target);
|
|
21
|
+
}
|
|
22
|
+
/** Stops tracking one target and clears any active composition it owned. */
|
|
23
|
+
unobserve(target) {
|
|
24
|
+
if (!this.#observedTargets.delete(target)) return;
|
|
25
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
26
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
27
|
+
this.#activeTargets.delete(target);
|
|
28
|
+
}
|
|
29
|
+
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
30
|
+
disconnect() {
|
|
31
|
+
for (const target of this.#observedTargets) {
|
|
32
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
33
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
34
|
+
}
|
|
35
|
+
this.#observedTargets.clear();
|
|
36
|
+
this.#activeTargets.clear();
|
|
37
|
+
}
|
|
38
|
+
/** True when lifecycle tracking or the current event reports composition. */
|
|
39
|
+
isComposing(event) {
|
|
40
|
+
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
41
|
+
}
|
|
42
|
+
#handleStart = (event) => {
|
|
43
|
+
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
44
|
+
this.#onStart?.(event);
|
|
45
|
+
};
|
|
46
|
+
#handleEnd = (event) => {
|
|
47
|
+
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
48
|
+
this.#onEnd?.(event);
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// src/controllers/editable_controller.ts
|
|
53
|
+
var EditableController = class extends Controller {
|
|
54
|
+
static targets = ["display", "input"];
|
|
55
|
+
static values = {
|
|
56
|
+
submitOnBlur: { type: Boolean, default: true }
|
|
57
|
+
};
|
|
58
|
+
static actions = ["edit", "onBlur", "onDisplayKeydown", "onKeydown"];
|
|
59
|
+
static events = ["cancel", "change"];
|
|
60
|
+
/** The value captured when edit mode began, used to detect real changes. */
|
|
61
|
+
#previousValue = "";
|
|
62
|
+
/**
|
|
63
|
+
* Owns IME lifecycle state for the edit surface, so a keydown that belongs to
|
|
64
|
+
* a composition (cancel or confirm) is never treated as an edit command.
|
|
65
|
+
*/
|
|
66
|
+
#composition = new CompositionTracker();
|
|
67
|
+
/** Establishes the initial display mode (display shown, input hidden). */
|
|
68
|
+
connect() {
|
|
69
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
70
|
+
this.#setMode("display");
|
|
71
|
+
}
|
|
72
|
+
/** Releases the composition listeners so nothing outlives the element. */
|
|
73
|
+
disconnect() {
|
|
74
|
+
this.#composition.disconnect();
|
|
75
|
+
}
|
|
76
|
+
/** Tracks an input added initially or after connect (e.g. a Turbo swap). */
|
|
77
|
+
inputTargetConnected(input) {
|
|
78
|
+
this.#composition.observe(input);
|
|
79
|
+
}
|
|
80
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
81
|
+
inputTargetDisconnected(input) {
|
|
82
|
+
this.#composition.unobserve(input);
|
|
83
|
+
}
|
|
84
|
+
/** Enters edit mode: seeds the input from the display text, focuses, selects. */
|
|
85
|
+
edit() {
|
|
86
|
+
if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;
|
|
87
|
+
this.#previousValue = this.#currentValue;
|
|
88
|
+
this.inputTarget.value = this.#previousValue;
|
|
89
|
+
this.#setMode("editing");
|
|
90
|
+
this.inputTarget.focus();
|
|
91
|
+
this.inputTarget.select();
|
|
92
|
+
}
|
|
93
|
+
/** Adds `F2` as an editing entry point alongside the button's native activation. */
|
|
94
|
+
onDisplayKeydown(event) {
|
|
95
|
+
if (event.key === "F2") {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
this.edit();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Commits on `Enter` (or `Ctrl+Enter` when multiline) and cancels on `Escape`. */
|
|
101
|
+
onKeydown(event) {
|
|
102
|
+
if (this.#composition.isComposing(event)) return;
|
|
103
|
+
if (event.key === "Escape") {
|
|
104
|
+
if (event.defaultPrevented) return;
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
this.#cancel();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (event.key === "Enter") {
|
|
110
|
+
if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
this.#save(true);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Saves on blur when `submitOnBlur` is set; otherwise keeps editing. */
|
|
116
|
+
onBlur() {
|
|
117
|
+
if (!this.#isEditing) return;
|
|
118
|
+
if (this.submitOnBlurValue) this.#save(false);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Returns to display mode, reflecting the input into the display text and
|
|
122
|
+
* dispatching `change` when the value differs from where editing began.
|
|
123
|
+
*
|
|
124
|
+
* @param restoreFocus - Move focus back to the display element (explicit
|
|
125
|
+
* keyboard commit) rather than honoring the user's new focus target (blur).
|
|
126
|
+
*/
|
|
127
|
+
#save(restoreFocus) {
|
|
128
|
+
if (!this.#isEditing) return;
|
|
129
|
+
const value = this.inputTarget.value;
|
|
130
|
+
const previous = this.#previousValue;
|
|
131
|
+
this.displayTarget.textContent = value;
|
|
132
|
+
this.#setMode("display");
|
|
133
|
+
if (restoreFocus) this.displayTarget.focus();
|
|
134
|
+
if (value !== previous) {
|
|
135
|
+
this.dispatch("change", { detail: { value, previous } });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Discards edits, returns to display mode, and dispatches `cancel`. */
|
|
139
|
+
#cancel() {
|
|
140
|
+
if (!this.#isEditing) return;
|
|
141
|
+
this.#setMode("display");
|
|
142
|
+
this.displayTarget.focus();
|
|
143
|
+
this.dispatch("cancel", { detail: {} });
|
|
144
|
+
}
|
|
145
|
+
/** Toggles the `data-mode` flag and the `hidden` state of both elements. */
|
|
146
|
+
#setMode(mode) {
|
|
147
|
+
this.element.dataset.mode = mode;
|
|
148
|
+
const editing = mode === "editing";
|
|
149
|
+
if (this.hasDisplayTarget) this.displayTarget.hidden = editing;
|
|
150
|
+
if (this.hasInputTarget) this.inputTarget.hidden = !editing;
|
|
151
|
+
}
|
|
152
|
+
/** Current display text, trimmed — the value shown when not editing. */
|
|
153
|
+
get #currentValue() {
|
|
154
|
+
return (this.displayTarget.textContent ?? "").trim();
|
|
155
|
+
}
|
|
156
|
+
/** Whether the editing control is a multi-line `<textarea>`. */
|
|
157
|
+
get #isMultiline() {
|
|
158
|
+
return this.inputTarget.tagName === "TEXTAREA";
|
|
159
|
+
}
|
|
160
|
+
/** Whether the controller is currently in edit mode. */
|
|
161
|
+
get #isEditing() {
|
|
162
|
+
return this.element.dataset.mode === "editing";
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export { EditableController };
|
|
167
|
+
//# sourceMappingURL=editable_controller.js.map
|
|
168
|
+
//# sourceMappingURL=editable_controller.js.map
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/file_dropzone_controller.ts
|
|
4
|
+
var FileDropzoneController = class extends Controller {
|
|
5
|
+
static targets = ["zone", "trigger", "input", "list", "item", "itemTemplate", "status"];
|
|
6
|
+
static values = {
|
|
7
|
+
maxSize: { type: Number, default: 0 },
|
|
8
|
+
maxFiles: { type: Number, default: 0 },
|
|
9
|
+
dragLabel: { type: String, default: "Drop files to add them" }
|
|
10
|
+
};
|
|
11
|
+
static actions = ["onChange", "onDragLeave", "onDragOver", "onDrop", "openDialog"];
|
|
12
|
+
static events = ["change", "reject"];
|
|
13
|
+
/** Selected files paired with their rendered item and any preview objectURL. */
|
|
14
|
+
#entries = [];
|
|
15
|
+
/** Wires file removal as a delegated listener on the list container. */
|
|
16
|
+
connect() {
|
|
17
|
+
if (this.hasListTarget) this.listTarget.addEventListener("click", this.#onItemClick);
|
|
18
|
+
}
|
|
19
|
+
/** Revokes any outstanding preview URLs so none leaks across navigations. */
|
|
20
|
+
disconnect() {
|
|
21
|
+
if (this.hasListTarget) this.listTarget.removeEventListener("click", this.#onItemClick);
|
|
22
|
+
for (const entry of this.#entries) {
|
|
23
|
+
if (entry.url) URL.revokeObjectURL(entry.url);
|
|
24
|
+
}
|
|
25
|
+
this.#entries.length = 0;
|
|
26
|
+
}
|
|
27
|
+
/** Opens the native file dialog. Bound via `data-action` (trigger click). */
|
|
28
|
+
openDialog() {
|
|
29
|
+
this.inputTarget.click();
|
|
30
|
+
}
|
|
31
|
+
/** Adds the files chosen through the native dialog. */
|
|
32
|
+
onChange() {
|
|
33
|
+
if (this.inputTarget.files) this.#addFiles(this.inputTarget.files);
|
|
34
|
+
this.inputTarget.value = "";
|
|
35
|
+
}
|
|
36
|
+
/** Marks the zone as a drop target and announces the affordance in words. */
|
|
37
|
+
onDragOver(event) {
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
if (this.hasZoneTarget) this.zoneTarget.setAttribute("data-dragover", "");
|
|
40
|
+
this.#setStatus(this.dragLabelValue);
|
|
41
|
+
}
|
|
42
|
+
/** Clears the drag-over flag when the pointer leaves the zone. */
|
|
43
|
+
onDragLeave() {
|
|
44
|
+
if (this.hasZoneTarget) this.zoneTarget.removeAttribute("data-dragover");
|
|
45
|
+
}
|
|
46
|
+
/** Accepts dropped files, clearing the drag-over state. */
|
|
47
|
+
onDrop(event) {
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
if (this.hasZoneTarget) this.zoneTarget.removeAttribute("data-dragover");
|
|
50
|
+
if (event.dataTransfer?.files) this.#addFiles(event.dataTransfer.files);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Removes the file whose remove button was clicked. Delegated on the list
|
|
54
|
+
* container rather than bound per item via `data-action`, so it works the instant
|
|
55
|
+
* an item is appended without waiting on Stimulus to wire a freshly created element.
|
|
56
|
+
*/
|
|
57
|
+
#onItemClick = (event) => {
|
|
58
|
+
const button = event.target.closest("button");
|
|
59
|
+
if (!button || !this.hasListTarget || !this.listTarget.contains(button)) return;
|
|
60
|
+
const index = this.#entries.findIndex((entry) => entry.item.contains(button));
|
|
61
|
+
if (index !== -1) this.#removeAt(index);
|
|
62
|
+
};
|
|
63
|
+
/** Validates each incoming file and renders the accepted ones. */
|
|
64
|
+
#addFiles(files) {
|
|
65
|
+
let changed = false;
|
|
66
|
+
if (this.hasZoneTarget) this.zoneTarget.removeAttribute("data-stimeo--file-dropzone-invalid");
|
|
67
|
+
for (const file of Array.from(files)) {
|
|
68
|
+
const reason = this.#validate(file);
|
|
69
|
+
if (reason) {
|
|
70
|
+
if (this.hasZoneTarget) {
|
|
71
|
+
this.zoneTarget.setAttribute("data-stimeo--file-dropzone-invalid", "");
|
|
72
|
+
}
|
|
73
|
+
this.#setStatus(file.name);
|
|
74
|
+
this.dispatch("reject", { detail: { file, reason } });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
this.#appendFile(file);
|
|
78
|
+
this.#setStatus(file.name);
|
|
79
|
+
changed = true;
|
|
80
|
+
}
|
|
81
|
+
if (changed) this.dispatch("change", { detail: { files: this.#files } });
|
|
82
|
+
}
|
|
83
|
+
/** Returns the rejection reason for `file`, or `null` when it is acceptable. */
|
|
84
|
+
#validate(file) {
|
|
85
|
+
const limit = this.#effectiveMaxFiles;
|
|
86
|
+
if (limit > 0 && this.#entries.length >= limit) return "count";
|
|
87
|
+
if (!this.#matchesAccept(file)) return "type";
|
|
88
|
+
if (this.maxSizeValue > 0 && file.size > this.maxSizeValue) return "size";
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/** Builds one preview item (name, optional thumbnail, remove button). */
|
|
92
|
+
#appendFile(file) {
|
|
93
|
+
if (!this.hasItemTemplateTarget || !this.hasListTarget) return;
|
|
94
|
+
const fragment = this.itemTemplateTarget.content.cloneNode(true);
|
|
95
|
+
const item = fragment.querySelector('[data-stimeo--file-dropzone-target="item"]');
|
|
96
|
+
const name = fragment.querySelector('[data-file-dropzone-slot="name"]');
|
|
97
|
+
const thumb = fragment.querySelector('[data-file-dropzone-slot="thumb"]');
|
|
98
|
+
const button = fragment.querySelector("button");
|
|
99
|
+
if (!item) return;
|
|
100
|
+
if (name) name.textContent = file.name;
|
|
101
|
+
if (button) button.setAttribute("aria-label", `Remove ${file.name}`);
|
|
102
|
+
let url;
|
|
103
|
+
if (thumb && file.type.startsWith("image/")) {
|
|
104
|
+
url = URL.createObjectURL(file);
|
|
105
|
+
thumb.src = url;
|
|
106
|
+
thumb.alt = file.name;
|
|
107
|
+
thumb.hidden = false;
|
|
108
|
+
} else if (thumb) {
|
|
109
|
+
thumb.hidden = true;
|
|
110
|
+
}
|
|
111
|
+
this.listTarget.appendChild(fragment);
|
|
112
|
+
this.#entries.push({ file, item, url });
|
|
113
|
+
}
|
|
114
|
+
/** Removes entry `index`, revokes its preview, and re-homes focus. */
|
|
115
|
+
#removeAt(index) {
|
|
116
|
+
const entry = this.#entries[index];
|
|
117
|
+
if (!entry) return;
|
|
118
|
+
if (entry.url) URL.revokeObjectURL(entry.url);
|
|
119
|
+
entry.item.remove();
|
|
120
|
+
this.#entries.splice(index, 1);
|
|
121
|
+
this.#setStatus(entry.file.name);
|
|
122
|
+
this.dispatch("change", { detail: { files: this.#files } });
|
|
123
|
+
const buttons = this.#removeButtons;
|
|
124
|
+
if (buttons.length === 0) {
|
|
125
|
+
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
126
|
+
} else {
|
|
127
|
+
(buttons[index] ?? buttons[buttons.length - 1])?.focus();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Whether `file` satisfies the input's `accept` list (empty accepts all). */
|
|
131
|
+
#matchesAccept(file) {
|
|
132
|
+
const accept = this.inputTarget.accept.trim();
|
|
133
|
+
if (accept === "") return true;
|
|
134
|
+
const name = file.name.toLowerCase();
|
|
135
|
+
const type = file.type.toLowerCase();
|
|
136
|
+
return accept.split(",").some((raw) => {
|
|
137
|
+
const token = raw.trim().toLowerCase();
|
|
138
|
+
if (token === "") return false;
|
|
139
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
140
|
+
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
|
|
141
|
+
return type === token;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** Updates the live region so assistive tech announces the change. */
|
|
145
|
+
#setStatus(text) {
|
|
146
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = text;
|
|
147
|
+
}
|
|
148
|
+
/** Effective file cap: `maxFiles`, or 1 when the input is single-select. */
|
|
149
|
+
get #effectiveMaxFiles() {
|
|
150
|
+
if (this.maxFilesValue > 0) return this.maxFilesValue;
|
|
151
|
+
return this.inputTarget.multiple ? 0 : 1;
|
|
152
|
+
}
|
|
153
|
+
/** The remove buttons currently in the list, in order. */
|
|
154
|
+
get #removeButtons() {
|
|
155
|
+
return Array.from(this.listTarget.querySelectorAll("button"));
|
|
156
|
+
}
|
|
157
|
+
/** The accepted files in selection order. */
|
|
158
|
+
get #files() {
|
|
159
|
+
return this.#entries.map((entry) => entry.file);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export { FileDropzoneController };
|
|
164
|
+
//# sourceMappingURL=file_dropzone_controller.js.map
|
|
165
|
+
//# sourceMappingURL=file_dropzone_controller.js.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/filter_controller.ts
|
|
4
|
+
var FilterController = class extends Controller {
|
|
5
|
+
static targets = ["item", "control", "group", "empty"];
|
|
6
|
+
static values = { match: { type: String, default: "all" } };
|
|
7
|
+
static actions = ["apply", "clear"];
|
|
8
|
+
static events = ["change"];
|
|
9
|
+
#onChange = () => {
|
|
10
|
+
this.apply();
|
|
11
|
+
};
|
|
12
|
+
connect() {
|
|
13
|
+
this.apply();
|
|
14
|
+
this.element.addEventListener("change", this.#onChange);
|
|
15
|
+
}
|
|
16
|
+
disconnect() {
|
|
17
|
+
this.element.removeEventListener("change", this.#onChange);
|
|
18
|
+
}
|
|
19
|
+
/** Re-derives every item's visibility from the active tokens and syncs groups/empty. */
|
|
20
|
+
apply() {
|
|
21
|
+
const active = this.#activeTokens();
|
|
22
|
+
let visibleCount = 0;
|
|
23
|
+
for (const item of this.itemTargets) {
|
|
24
|
+
const visible = this.#matches(item, active);
|
|
25
|
+
item.hidden = !visible;
|
|
26
|
+
if (visible) visibleCount += 1;
|
|
27
|
+
}
|
|
28
|
+
for (const group of this.groupTargets) {
|
|
29
|
+
group.hidden = !this.#hasVisibleItem(group);
|
|
30
|
+
}
|
|
31
|
+
for (const empty of this.emptyTargets) {
|
|
32
|
+
empty.hidden = visibleCount > 0;
|
|
33
|
+
}
|
|
34
|
+
this.dispatch("change", {
|
|
35
|
+
detail: { active, visible: visibleCount, total: this.itemTargets.length }
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** Turns every control off (uncheck / aria-pressed="false") and re-applies. */
|
|
39
|
+
clear() {
|
|
40
|
+
for (const control of this.controlTargets) {
|
|
41
|
+
if (control instanceof HTMLInputElement) {
|
|
42
|
+
control.checked = false;
|
|
43
|
+
} else if (control.hasAttribute("aria-pressed")) {
|
|
44
|
+
control.setAttribute("aria-pressed", "false");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.apply();
|
|
48
|
+
}
|
|
49
|
+
/** The tokens of every control currently "on" (checked or aria-pressed="true"). */
|
|
50
|
+
#activeTokens() {
|
|
51
|
+
const tokens = [];
|
|
52
|
+
for (const control of this.controlTargets) {
|
|
53
|
+
if (this.#isOn(control)) tokens.push(this.#tokenOf(control));
|
|
54
|
+
}
|
|
55
|
+
return tokens.filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
/** Whether a control is in its "on" state. */
|
|
58
|
+
#isOn(control) {
|
|
59
|
+
if (control instanceof HTMLInputElement) return control.checked;
|
|
60
|
+
return control.getAttribute("aria-pressed") === "true";
|
|
61
|
+
}
|
|
62
|
+
/** A control's token: explicit `data-stimeo--filter-token`, else `data-value` / value. */
|
|
63
|
+
#tokenOf(control) {
|
|
64
|
+
const explicit = control.getAttribute("data-stimeo--filter-token") ?? control.dataset.value;
|
|
65
|
+
if (explicit) return explicit;
|
|
66
|
+
return control instanceof HTMLInputElement ? control.value : "";
|
|
67
|
+
}
|
|
68
|
+
/** Whether an item satisfies the active token set per `match` (empty active → shown). */
|
|
69
|
+
#matches(item, active) {
|
|
70
|
+
if (active.length === 0) return true;
|
|
71
|
+
const tokens = this.#tokensOf(item);
|
|
72
|
+
return this.matchValue === "any" ? active.some((token) => tokens.includes(token)) : active.every((token) => tokens.includes(token));
|
|
73
|
+
}
|
|
74
|
+
/** An item's declared tokens (`data-stimeo--filter-tokens`, space-separated). */
|
|
75
|
+
#tokensOf(item) {
|
|
76
|
+
return (item.getAttribute("data-stimeo--filter-tokens") ?? "").split(/\s+/).filter(Boolean);
|
|
77
|
+
}
|
|
78
|
+
/** Whether a group still contains at least one non-hidden item. */
|
|
79
|
+
#hasVisibleItem(group) {
|
|
80
|
+
return this.itemTargets.some((item) => !item.hidden && group.contains(item));
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export { FilterController };
|
|
85
|
+
//# sourceMappingURL=filter_controller.js.map
|
|
86
|
+
//# sourceMappingURL=filter_controller.js.map
|
|
@@ -55,6 +55,40 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
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
|
+
function maxTransitionTotalMs(style) {
|
|
89
|
+
return maxTotalMs(transitionTimings(style));
|
|
90
|
+
}
|
|
91
|
+
|
|
58
92
|
// src/controllers/flash_controller.ts
|
|
59
93
|
var ASSERTIVE_TYPES = /* @__PURE__ */ new Set(["alert", "error"]);
|
|
60
94
|
var MESSAGE_SELECTOR = '[data-stimeo--flash-target="message"]';
|
|
@@ -206,13 +240,10 @@ var FlashController = class extends Controller {
|
|
|
206
240
|
finalize();
|
|
207
241
|
}
|
|
208
242
|
}
|
|
209
|
-
/**
|
|
243
|
+
/** Maximum transition total (duration + delay) of `el` in ms (0 when none / unsupported). */
|
|
210
244
|
#transitionMs(el) {
|
|
211
245
|
if (typeof window.getComputedStyle !== "function") return 0;
|
|
212
|
-
|
|
213
|
-
const amount = Number.parseFloat(first);
|
|
214
|
-
if (Number.isNaN(amount)) return 0;
|
|
215
|
-
return first.endsWith("ms") ? amount : amount * 1e3;
|
|
246
|
+
return maxTransitionTotalMs(window.getComputedStyle(el));
|
|
216
247
|
}
|
|
217
248
|
};
|
|
218
249
|
|