stimeo-ui 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +163 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +313 -0
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +252 -0
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +194 -17
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +246 -0
- data/dist/controllers/date_range_picker_controller.js +441 -0
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/editable_controller.js +169 -0
- data/dist/controllers/file_dropzone_controller.js +165 -0
- data/dist/controllers/filter_controller.js +86 -0
- data/dist/controllers/flash_controller.js +36 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/intersection_controller.js +67 -19
- data/dist/controllers/lazy_frame_controller.js +54 -11
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +2 -2
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +785 -0
- data/dist/controllers/multi_select_controller.js +755 -0
- data/dist/controllers/navigation_menu_controller.js +511 -0
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +246 -27
- data/dist/controllers/overflow_menu_controller.js +381 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +192 -0
- data/dist/controllers/rating_controller.js +16 -2
- data/dist/controllers/read_more_controller.js +238 -0
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +101 -14
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +40 -6
- data/dist/controllers/scrollspy_controller.js +369 -74
- data/dist/controllers/separator_controller.js +96 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/skeleton_controller.js +1 -1
- data/dist/controllers/slider_controller.js +32 -6
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +1 -1
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +9 -4
- data/dist/controllers/sticky_observer_controller.js +109 -20
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +295 -0
- data/dist/controllers/theme_controller.js +42 -13
- data/dist/controllers/time_picker_controller.js +231 -0
- data/dist/controllers/toast_controller.js +40 -14
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +691 -0
- data/dist/index.js +4256 -915
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +28 -2
|
@@ -0,0 +1,169 @@
|
|
|
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 (event.defaultPrevented) return;
|
|
111
|
+
if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
this.#save(true);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Saves on blur when `submitOnBlur` is set; otherwise keeps editing. */
|
|
117
|
+
onBlur() {
|
|
118
|
+
if (!this.#isEditing) return;
|
|
119
|
+
if (this.submitOnBlurValue) this.#save(false);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Returns to display mode, reflecting the input into the display text and
|
|
123
|
+
* dispatching `change` when the value differs from where editing began.
|
|
124
|
+
*
|
|
125
|
+
* @param restoreFocus - Move focus back to the display element (explicit
|
|
126
|
+
* keyboard commit) rather than honoring the user's new focus target (blur).
|
|
127
|
+
*/
|
|
128
|
+
#save(restoreFocus) {
|
|
129
|
+
if (!this.#isEditing) return;
|
|
130
|
+
const value = this.inputTarget.value;
|
|
131
|
+
const previous = this.#previousValue;
|
|
132
|
+
this.displayTarget.textContent = value;
|
|
133
|
+
this.#setMode("display");
|
|
134
|
+
if (restoreFocus) this.displayTarget.focus();
|
|
135
|
+
if (value !== previous) {
|
|
136
|
+
this.dispatch("change", { detail: { value, previous } });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Discards edits, returns to display mode, and dispatches `cancel`. */
|
|
140
|
+
#cancel() {
|
|
141
|
+
if (!this.#isEditing) return;
|
|
142
|
+
this.#setMode("display");
|
|
143
|
+
this.displayTarget.focus();
|
|
144
|
+
this.dispatch("cancel", { detail: {} });
|
|
145
|
+
}
|
|
146
|
+
/** Toggles the `data-mode` flag and the `hidden` state of both elements. */
|
|
147
|
+
#setMode(mode) {
|
|
148
|
+
this.element.dataset.mode = mode;
|
|
149
|
+
const editing = mode === "editing";
|
|
150
|
+
if (this.hasDisplayTarget) this.displayTarget.hidden = editing;
|
|
151
|
+
if (this.hasInputTarget) this.inputTarget.hidden = !editing;
|
|
152
|
+
}
|
|
153
|
+
/** Current display text, trimmed — the value shown when not editing. */
|
|
154
|
+
get #currentValue() {
|
|
155
|
+
return (this.displayTarget.textContent ?? "").trim();
|
|
156
|
+
}
|
|
157
|
+
/** Whether the editing control is a multi-line `<textarea>`. */
|
|
158
|
+
get #isMultiline() {
|
|
159
|
+
return this.inputTarget.tagName === "TEXTAREA";
|
|
160
|
+
}
|
|
161
|
+
/** Whether the controller is currently in edit mode. */
|
|
162
|
+
get #isEditing() {
|
|
163
|
+
return this.element.dataset.mode === "editing";
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export { EditableController };
|
|
168
|
+
//# sourceMappingURL=editable_controller.js.map
|
|
169
|
+
//# 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
|
|
|
@@ -216,7 +216,7 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
216
216
|
}
|
|
217
217
|
/**
|
|
218
218
|
* Where focus should land for an invalid control. A visible control is focused
|
|
219
|
-
* directly
|
|
219
|
+
* directly — the case for native fields and radios. A validatable mirror
|
|
220
220
|
* (the `hidden` attribute) cannot receive focus, so focus is delegated to the
|
|
221
221
|
* visible widget: the owning field's `control` target when it is itself
|
|
222
222
|
* focusable, else its first focusable descendant (e.g. a roving-tabindex
|
|
@@ -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,10 +3,17 @@ 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;
|
|
9
15
|
#active = false;
|
|
16
|
+
#usingPlatformDefaults = false;
|
|
10
17
|
constructor(onEntries) {
|
|
11
18
|
this.#onEntries = onEntries;
|
|
12
19
|
}
|
|
@@ -14,10 +21,22 @@ var IntersectionWatcher = class {
|
|
|
14
21
|
get active() {
|
|
15
22
|
return this.#active;
|
|
16
23
|
}
|
|
24
|
+
/** Whether the live observer discarded configured options after construction failed. */
|
|
25
|
+
get usingPlatformDefaults() {
|
|
26
|
+
return this.#usingPlatformDefaults;
|
|
27
|
+
}
|
|
17
28
|
/**
|
|
18
29
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
19
30
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
20
31
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
32
|
+
* If initial construction with the configured options fails, the watcher
|
|
33
|
+
* warns and retries once with the same root and platform defaults.
|
|
34
|
+
*
|
|
35
|
+
* @throws The fallback constructor error if both construction attempts fail,
|
|
36
|
+
* or whatever the platform throws from `observe()`. The exception is passed
|
|
37
|
+
* through unchanged, but the watcher rolls back first: every target observed
|
|
38
|
+
* so far is released and `active` stays `false`, so a caller that retries
|
|
39
|
+
* starts from a clean slate.
|
|
21
40
|
*/
|
|
22
41
|
start(targets, options = {}) {
|
|
23
42
|
this.stop();
|
|
@@ -25,31 +44,61 @@ var IntersectionWatcher = class {
|
|
|
25
44
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
26
45
|
if (list.length === 0) return false;
|
|
27
46
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
(entries) => {
|
|
31
|
-
if (this.#active) this.#onEntries(entries);
|
|
32
|
-
}
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
let observer = null;
|
|
48
|
+
try {
|
|
49
|
+
const onEntries = (entries) => {
|
|
50
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
observer = new IntersectionObserver(onEntries, {
|
|
54
|
+
root,
|
|
55
|
+
rootMargin: options.rootMargin,
|
|
56
|
+
threshold: options.threshold
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.warn(
|
|
60
|
+
"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.",
|
|
61
|
+
error
|
|
62
|
+
);
|
|
63
|
+
observer = new IntersectionObserver(onEntries, { root });
|
|
64
|
+
this.#usingPlatformDefaults = true;
|
|
65
|
+
}
|
|
66
|
+
for (const target of list) observer.observe(target);
|
|
67
|
+
this.#observer = observer;
|
|
68
|
+
this.#active = true;
|
|
69
|
+
return true;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
observer?.disconnect();
|
|
72
|
+
this.#observer = null;
|
|
73
|
+
this.#active = false;
|
|
74
|
+
this.#usingPlatformDefaults = false;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
37
77
|
}
|
|
38
78
|
/**
|
|
39
79
|
* Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
|
|
40
80
|
* only reports *changes*, but `observe()` always reports the present state,
|
|
41
81
|
* so unobserve→observe turns "still intersecting" into a fresh callback.
|
|
82
|
+
*
|
|
83
|
+
* @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
|
|
84
|
+
* first, so it never stays live with a half-rearmed target.
|
|
42
85
|
*/
|
|
43
86
|
rearm(target) {
|
|
44
87
|
if (!this.#observer) return;
|
|
45
|
-
|
|
46
|
-
|
|
88
|
+
try {
|
|
89
|
+
this.#observer.unobserve(target);
|
|
90
|
+
this.#observer.observe(target);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
this.stop();
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
47
95
|
}
|
|
48
96
|
/** Severs the observer; late queued callbacks become no-ops via the guard. */
|
|
49
97
|
stop() {
|
|
50
98
|
this.#active = false;
|
|
51
99
|
this.#observer?.disconnect();
|
|
52
100
|
this.#observer = null;
|
|
101
|
+
this.#usingPlatformDefaults = false;
|
|
53
102
|
}
|
|
54
103
|
};
|
|
55
104
|
|
|
@@ -68,6 +117,8 @@ var IntersectionController = class extends Controller {
|
|
|
68
117
|
static events = ["enter", "exit", "change", "passed"];
|
|
69
118
|
/** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */
|
|
70
119
|
#watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
|
|
120
|
+
/** Threshold actually installed in the live observer (0 after option fallback). */
|
|
121
|
+
#effectiveThreshold = 0;
|
|
71
122
|
/** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */
|
|
72
123
|
#generation = 0;
|
|
73
124
|
#onIntersect(entries) {
|
|
@@ -75,21 +126,23 @@ var IntersectionController = class extends Controller {
|
|
|
75
126
|
for (const entry of entries) {
|
|
76
127
|
if (!this.#watcher.active || this.#generation !== generation) return;
|
|
77
128
|
const ratio = entry.intersectionRatio;
|
|
78
|
-
const threshold = this.#
|
|
129
|
+
const threshold = this.#effectiveThreshold;
|
|
79
130
|
const intersecting = threshold > 0 ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON : entry.isIntersecting;
|
|
80
131
|
this.element.style.setProperty(RATIO_PROPERTY, String(ratio));
|
|
81
132
|
this.dispatch("change", { detail: { intersecting, ratio } });
|
|
82
133
|
this.#syncIntersecting(intersecting, ratio, entry);
|
|
83
|
-
this.#syncPassed(!intersecting &&
|
|
134
|
+
this.#syncPassed(!intersecting && isBeforeRootStart(entry));
|
|
84
135
|
}
|
|
85
136
|
}
|
|
86
137
|
connect() {
|
|
87
138
|
if (this.onceValue && this.element.getAttribute("data-intersecting") === "true") return;
|
|
139
|
+
this.#effectiveThreshold = this.#clampedThreshold();
|
|
88
140
|
this.#watcher.start(this.element, {
|
|
89
141
|
rootSelector: this.rootSelectorValue,
|
|
90
142
|
rootMargin: this.rootMarginValue,
|
|
91
143
|
threshold: this.#thresholds()
|
|
92
144
|
});
|
|
145
|
+
if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;
|
|
93
146
|
}
|
|
94
147
|
disconnect() {
|
|
95
148
|
this.#watcher.stop();
|
|
@@ -128,7 +181,7 @@ var IntersectionController = class extends Controller {
|
|
|
128
181
|
if (this.onceValue) this.#watcher.stop();
|
|
129
182
|
} else if (!intersecting && previous === "true") {
|
|
130
183
|
this.dispatch("exit", {
|
|
131
|
-
detail: { ratio, position:
|
|
184
|
+
detail: { ratio, position: isBeforeRootStart(entry) ? "before" : "after" }
|
|
132
185
|
});
|
|
133
186
|
}
|
|
134
187
|
}
|
|
@@ -144,11 +197,6 @@ var IntersectionController = class extends Controller {
|
|
|
144
197
|
const changed = previous === null ? passed : previous === "true" !== passed;
|
|
145
198
|
if (changed) this.dispatch("passed", { detail: { passed } });
|
|
146
199
|
}
|
|
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
200
|
/** The configured `threshold`, clamped to the 0..1 the observer accepts. */
|
|
153
201
|
#clampedThreshold() {
|
|
154
202
|
return Math.min(1, Math.max(0, this.thresholdValue));
|