stimeo-ui 0.2.0 → 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 +59 -0
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/carousel_controller.js +272 -0
- 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/count_up_controller.js +8 -1
- 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/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- 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/highlight_controller.js +6 -4
- data/dist/controllers/intersection_controller.js +41 -18
- data/dist/controllers/lazy_frame_controller.js +33 -11
- data/dist/controllers/masonry_controller.js +142 -0
- 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/overflow_indicator_controller.js +178 -27
- data/dist/controllers/password_reveal_controller.js +117 -0
- 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 +36 -9
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +275 -0
- data/dist/index.js +811 -295
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +28 -2
|
@@ -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
|
|
|
@@ -2,6 +2,11 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/highlight_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/reduced_motion.ts
|
|
6
|
+
function prefersReducedMotion() {
|
|
7
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
// src/utils/safe_timeout.ts
|
|
6
11
|
var TimerRegistry = class {
|
|
7
12
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -89,7 +94,7 @@ var HighlightController = class extends Controller {
|
|
|
89
94
|
}
|
|
90
95
|
/** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
|
|
91
96
|
#highlight(el) {
|
|
92
|
-
if (
|
|
97
|
+
if (prefersReducedMotion()) return;
|
|
93
98
|
el.setAttribute("data-highlight", "true");
|
|
94
99
|
this.dispatch("start", { target: el, detail: { element: el } });
|
|
95
100
|
this.#timeouts.set(() => {
|
|
@@ -97,9 +102,6 @@ var HighlightController = class extends Controller {
|
|
|
97
102
|
this.dispatch("end", { target: el, detail: { element: el } });
|
|
98
103
|
}, this.durationValue);
|
|
99
104
|
}
|
|
100
|
-
#prefersReducedMotion() {
|
|
101
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
102
|
-
}
|
|
103
105
|
};
|
|
104
106
|
|
|
105
107
|
export { HighlightController };
|
|
@@ -3,6 +3,12 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
3
3
|
// src/controllers/intersection_controller.ts
|
|
4
4
|
|
|
5
5
|
// src/utils/intersection_watcher.ts
|
|
6
|
+
function isBeforeRootStart(entry) {
|
|
7
|
+
const rect = entry.boundingClientRect;
|
|
8
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
9
|
+
const rootTop = entry.rootBounds?.top ?? 0;
|
|
10
|
+
return rect.bottom <= rootTop;
|
|
11
|
+
}
|
|
6
12
|
var IntersectionWatcher = class {
|
|
7
13
|
#onEntries;
|
|
8
14
|
#observer = null;
|
|
@@ -18,6 +24,11 @@ var IntersectionWatcher = class {
|
|
|
18
24
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
19
25
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
20
26
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
27
|
+
*
|
|
28
|
+
* @throws Whatever the platform throws for an invalid `rootMargin`/`threshold`
|
|
29
|
+
* or a failing `observe()`. The exception is passed through unchanged, but
|
|
30
|
+
* the watcher rolls back first: every target observed so far is released and
|
|
31
|
+
* `active` stays `false`, so a caller that retries starts from a clean slate.
|
|
21
32
|
*/
|
|
22
33
|
start(targets, options = {}) {
|
|
23
34
|
this.stop();
|
|
@@ -25,25 +36,42 @@ var IntersectionWatcher = class {
|
|
|
25
36
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
26
37
|
if (list.length === 0) return false;
|
|
27
38
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
let observer = null;
|
|
40
|
+
try {
|
|
41
|
+
observer = new IntersectionObserver(
|
|
42
|
+
(entries) => {
|
|
43
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
44
|
+
},
|
|
45
|
+
{ root, rootMargin: options.rootMargin, threshold: options.threshold }
|
|
46
|
+
);
|
|
47
|
+
for (const target of list) observer.observe(target);
|
|
48
|
+
this.#observer = observer;
|
|
49
|
+
this.#active = true;
|
|
50
|
+
return true;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
observer?.disconnect();
|
|
53
|
+
this.#observer = null;
|
|
54
|
+
this.#active = false;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
37
57
|
}
|
|
38
58
|
/**
|
|
39
59
|
* Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
|
|
40
60
|
* only reports *changes*, but `observe()` always reports the present state,
|
|
41
61
|
* so unobserve→observe turns "still intersecting" into a fresh callback.
|
|
62
|
+
*
|
|
63
|
+
* @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
|
|
64
|
+
* first, so it never stays live with a half-rearmed target.
|
|
42
65
|
*/
|
|
43
66
|
rearm(target) {
|
|
44
67
|
if (!this.#observer) return;
|
|
45
|
-
|
|
46
|
-
|
|
68
|
+
try {
|
|
69
|
+
this.#observer.unobserve(target);
|
|
70
|
+
this.#observer.observe(target);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
this.stop();
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
47
75
|
}
|
|
48
76
|
/** Severs the observer; late queued callbacks become no-ops via the guard. */
|
|
49
77
|
stop() {
|
|
@@ -80,7 +108,7 @@ var IntersectionController = class extends Controller {
|
|
|
80
108
|
this.element.style.setProperty(RATIO_PROPERTY, String(ratio));
|
|
81
109
|
this.dispatch("change", { detail: { intersecting, ratio } });
|
|
82
110
|
this.#syncIntersecting(intersecting, ratio, entry);
|
|
83
|
-
this.#syncPassed(!intersecting &&
|
|
111
|
+
this.#syncPassed(!intersecting && isBeforeRootStart(entry));
|
|
84
112
|
}
|
|
85
113
|
}
|
|
86
114
|
connect() {
|
|
@@ -128,7 +156,7 @@ var IntersectionController = class extends Controller {
|
|
|
128
156
|
if (this.onceValue) this.#watcher.stop();
|
|
129
157
|
} else if (!intersecting && previous === "true") {
|
|
130
158
|
this.dispatch("exit", {
|
|
131
|
-
detail: { ratio, position:
|
|
159
|
+
detail: { ratio, position: isBeforeRootStart(entry) ? "before" : "after" }
|
|
132
160
|
});
|
|
133
161
|
}
|
|
134
162
|
}
|
|
@@ -144,11 +172,6 @@ var IntersectionController = class extends Controller {
|
|
|
144
172
|
const changed = previous === null ? passed : previous === "true" !== passed;
|
|
145
173
|
if (changed) this.dispatch("passed", { detail: { passed } });
|
|
146
174
|
}
|
|
147
|
-
/** True when the element sits entirely before the root's start (top) edge. */
|
|
148
|
-
#isBefore(entry) {
|
|
149
|
-
const rootTop = entry.rootBounds?.top ?? 0;
|
|
150
|
-
return entry.boundingClientRect.bottom <= rootTop;
|
|
151
|
-
}
|
|
152
175
|
/** The configured `threshold`, clamped to the 0..1 the observer accepts. */
|
|
153
176
|
#clampedThreshold() {
|
|
154
177
|
return Math.min(1, Math.max(0, this.thresholdValue));
|
|
@@ -18,6 +18,11 @@ var IntersectionWatcher = class {
|
|
|
18
18
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
19
19
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
20
20
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
21
|
+
*
|
|
22
|
+
* @throws Whatever the platform throws for an invalid `rootMargin`/`threshold`
|
|
23
|
+
* or a failing `observe()`. The exception is passed through unchanged, but
|
|
24
|
+
* the watcher rolls back first: every target observed so far is released and
|
|
25
|
+
* `active` stays `false`, so a caller that retries starts from a clean slate.
|
|
21
26
|
*/
|
|
22
27
|
start(targets, options = {}) {
|
|
23
28
|
this.stop();
|
|
@@ -25,25 +30,42 @@ var IntersectionWatcher = class {
|
|
|
25
30
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
26
31
|
if (list.length === 0) return false;
|
|
27
32
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
let observer = null;
|
|
34
|
+
try {
|
|
35
|
+
observer = new IntersectionObserver(
|
|
36
|
+
(entries) => {
|
|
37
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
38
|
+
},
|
|
39
|
+
{ root, rootMargin: options.rootMargin, threshold: options.threshold }
|
|
40
|
+
);
|
|
41
|
+
for (const target of list) observer.observe(target);
|
|
42
|
+
this.#observer = observer;
|
|
43
|
+
this.#active = true;
|
|
44
|
+
return true;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
observer?.disconnect();
|
|
47
|
+
this.#observer = null;
|
|
48
|
+
this.#active = false;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
37
51
|
}
|
|
38
52
|
/**
|
|
39
53
|
* Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
|
|
40
54
|
* only reports *changes*, but `observe()` always reports the present state,
|
|
41
55
|
* so unobserve→observe turns "still intersecting" into a fresh callback.
|
|
56
|
+
*
|
|
57
|
+
* @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
|
|
58
|
+
* first, so it never stays live with a half-rearmed target.
|
|
42
59
|
*/
|
|
43
60
|
rearm(target) {
|
|
44
61
|
if (!this.#observer) return;
|
|
45
|
-
|
|
46
|
-
|
|
62
|
+
try {
|
|
63
|
+
this.#observer.unobserve(target);
|
|
64
|
+
this.#observer.observe(target);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
this.stop();
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
47
69
|
}
|
|
48
70
|
/** Severs the observer; late queued callbacks become no-ops via the guard. */
|
|
49
71
|
stop() {
|