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
|
@@ -9,23 +9,32 @@ var StepperController = class extends Controller {
|
|
|
9
9
|
};
|
|
10
10
|
static actions = ["goto", "next", "prev"];
|
|
11
11
|
static events = ["change"];
|
|
12
|
+
#isConnected = false;
|
|
12
13
|
/** Normalizes an out-of-range initial `index` and renders the initial state. */
|
|
13
14
|
connect() {
|
|
14
|
-
this
|
|
15
|
-
this.#
|
|
15
|
+
this.#isConnected = true;
|
|
16
|
+
this.#normalizeAndRender();
|
|
17
|
+
}
|
|
18
|
+
disconnect() {
|
|
19
|
+
this.#isConnected = false;
|
|
20
|
+
}
|
|
21
|
+
/** Re-renders when Turbo Morph or application code changes `index` at runtime. */
|
|
22
|
+
indexValueChanged() {
|
|
23
|
+
if (!this.#isConnected) return;
|
|
24
|
+
this.#normalizeAndRender();
|
|
16
25
|
}
|
|
17
26
|
/** Advances to the next step (ignored at the last step). */
|
|
18
27
|
next() {
|
|
19
|
-
this.#moveTo(this.indexValue + 1);
|
|
28
|
+
this.#moveTo(this.#clampIndex(this.indexValue) + 1);
|
|
20
29
|
}
|
|
21
30
|
/** Returns to the previous step (ignored at the first step). */
|
|
22
31
|
prev() {
|
|
23
|
-
this.#moveTo(this.indexValue - 1);
|
|
32
|
+
this.#moveTo(this.#clampIndex(this.indexValue) - 1);
|
|
24
33
|
}
|
|
25
34
|
/** Jumps to the step carried in the action's `index` param. */
|
|
26
35
|
goto(event) {
|
|
27
36
|
const target = Number(event.params.index);
|
|
28
|
-
if (!Number.isFinite(target)) return;
|
|
37
|
+
if (!Number.isFinite(target) || !Number.isInteger(target)) return;
|
|
29
38
|
this.#moveTo(target);
|
|
30
39
|
}
|
|
31
40
|
/**
|
|
@@ -35,16 +44,24 @@ var StepperController = class extends Controller {
|
|
|
35
44
|
*/
|
|
36
45
|
#moveTo(target) {
|
|
37
46
|
const total = this.stepTargets.length;
|
|
47
|
+
if (!Number.isFinite(target) || !Number.isInteger(target)) return;
|
|
38
48
|
if (target < 0 || target >= total) return;
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
49
|
+
const current = this.#clampIndex(this.indexValue);
|
|
50
|
+
if (target === current) return;
|
|
51
|
+
if (this.linearValue && target > current + 1) return;
|
|
52
|
+
const previous = current;
|
|
42
53
|
this.indexValue = target;
|
|
43
|
-
this.#render();
|
|
54
|
+
this.#render(target);
|
|
44
55
|
this.dispatch("change", {
|
|
45
56
|
detail: { index: target, previous, step: this.stepTargets[target] }
|
|
46
57
|
});
|
|
47
58
|
}
|
|
59
|
+
/** Normalizes the public Value and reflects it without dispatching an action event. */
|
|
60
|
+
#normalizeAndRender() {
|
|
61
|
+
const normalized = this.#clampIndex(this.indexValue);
|
|
62
|
+
if (!Object.is(normalized, this.indexValue)) this.indexValue = normalized;
|
|
63
|
+
this.#render(normalized);
|
|
64
|
+
}
|
|
48
65
|
/**
|
|
49
66
|
* Derives each step's `data-state` and the current button's `aria-current`.
|
|
50
67
|
*
|
|
@@ -52,8 +69,7 @@ var StepperController = class extends Controller {
|
|
|
52
69
|
* contract assumes one operable button per step. If a step needs multiple
|
|
53
70
|
* buttons, mark the navigational one first (or this would target the wrong one).
|
|
54
71
|
*/
|
|
55
|
-
#render() {
|
|
56
|
-
const current = this.indexValue;
|
|
72
|
+
#render(current) {
|
|
57
73
|
this.stepTargets.forEach((step, index) => {
|
|
58
74
|
step.dataset.state = index < current ? "complete" : index === current ? "current" : "upcoming";
|
|
59
75
|
const button = step.querySelector("button");
|
|
@@ -68,7 +84,7 @@ var StepperController = class extends Controller {
|
|
|
68
84
|
/** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */
|
|
69
85
|
#clampIndex(index) {
|
|
70
86
|
const last = this.stepTargets.length - 1;
|
|
71
|
-
if (last < 0) return 0;
|
|
87
|
+
if (last < 0 || !Number.isFinite(index)) return 0;
|
|
72
88
|
return Math.min(last, Math.max(0, Math.trunc(index)));
|
|
73
89
|
}
|
|
74
90
|
};
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/stick_to_bottom_controller.ts
|
|
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
|
+
|
|
3
10
|
// src/controllers/stick_to_bottom_controller.ts
|
|
4
11
|
var countElements = (nodes) => {
|
|
5
12
|
let n = 0;
|
|
@@ -87,12 +94,9 @@ var StickToBottomController = class extends Controller {
|
|
|
87
94
|
return this.hasContentTarget ? this.contentTarget : this.element;
|
|
88
95
|
}
|
|
89
96
|
#behavior() {
|
|
90
|
-
if (
|
|
97
|
+
if (prefersReducedMotion()) return "auto";
|
|
91
98
|
return this.behaviorValue === "smooth" ? "smooth" : "auto";
|
|
92
99
|
}
|
|
93
|
-
#prefersReducedMotion() {
|
|
94
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
95
|
-
}
|
|
96
100
|
};
|
|
97
101
|
|
|
98
102
|
export { StickToBottomController };
|
|
@@ -3,6 +3,12 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
3
3
|
// src/controllers/sticky_observer_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() {
|
|
@@ -65,23 +93,63 @@ var StickyObserverController = class extends Controller {
|
|
|
65
93
|
#watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
|
|
66
94
|
/** Last reported stuck state, so `change` fires only on transitions. */
|
|
67
95
|
#stuck = null;
|
|
96
|
+
/** Target currently owned by the watcher, used to avoid duplicate restarts. */
|
|
97
|
+
#observedSentinel = null;
|
|
98
|
+
#connected = false;
|
|
68
99
|
#onIntersect(entries) {
|
|
69
|
-
const entry
|
|
70
|
-
|
|
71
|
-
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
if (!this.#connected || !this.#watcher.active) return;
|
|
102
|
+
this.#setStuck(!entry.isIntersecting && isBeforeRootStart(entry));
|
|
103
|
+
}
|
|
72
104
|
}
|
|
73
105
|
connect() {
|
|
74
|
-
|
|
75
|
-
this.#
|
|
76
|
-
|
|
77
|
-
rootMargin: `-${this.offsetValue}px 0px 0px 0px`,
|
|
78
|
-
threshold: [0]
|
|
79
|
-
});
|
|
106
|
+
this.#connected = true;
|
|
107
|
+
this.#stuck = null;
|
|
108
|
+
this.#syncObserver();
|
|
80
109
|
}
|
|
81
110
|
disconnect() {
|
|
111
|
+
this.#connected = false;
|
|
82
112
|
this.#watcher.stop();
|
|
113
|
+
this.#observedSentinel = null;
|
|
83
114
|
this.#stuck = null;
|
|
84
115
|
}
|
|
116
|
+
/** Starts observation when a sentinel is inserted after connection. */
|
|
117
|
+
sentinelTargetConnected() {
|
|
118
|
+
if (this.#connected) this.#syncObserver();
|
|
119
|
+
}
|
|
120
|
+
/** Stops or transfers observation when the current sentinel is removed. */
|
|
121
|
+
sentinelTargetDisconnected() {
|
|
122
|
+
if (this.#connected) this.#syncObserver();
|
|
123
|
+
}
|
|
124
|
+
/** Reflects the last snapshot onto an element inserted after that snapshot. */
|
|
125
|
+
elementTargetConnected(element) {
|
|
126
|
+
if (this.#stuck !== null) {
|
|
127
|
+
element.setAttribute("data-stuck", this.#stuck ? "true" : "false");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Rebuilds the observer when Turbo morphs the configured root. */
|
|
131
|
+
rootSelectorValueChanged() {
|
|
132
|
+
if (this.#connected) this.#syncObserver(true);
|
|
133
|
+
}
|
|
134
|
+
/** Rebuilds the observer when Turbo morphs the configured top offset. */
|
|
135
|
+
offsetValueChanged() {
|
|
136
|
+
if (this.#connected) this.#syncObserver(true);
|
|
137
|
+
}
|
|
138
|
+
#syncObserver(force = false) {
|
|
139
|
+
const sentinel = this.hasSentinelTarget ? this.sentinelTarget : null;
|
|
140
|
+
if (!force && sentinel === this.#observedSentinel && this.#watcher.active) return;
|
|
141
|
+
this.#watcher.stop();
|
|
142
|
+
this.#observedSentinel = null;
|
|
143
|
+
if (!sentinel) return;
|
|
144
|
+
const configuredOffset = this.offsetValue;
|
|
145
|
+
const offset = Number.isFinite(configuredOffset) ? configuredOffset : 0;
|
|
146
|
+
const started = this.#watcher.start(sentinel, {
|
|
147
|
+
rootSelector: this.rootSelectorValue,
|
|
148
|
+
rootMargin: `${-offset}px 0px 0px 0px`,
|
|
149
|
+
threshold: [0]
|
|
150
|
+
});
|
|
151
|
+
if (started) this.#observedSentinel = sentinel;
|
|
152
|
+
}
|
|
85
153
|
/** Reflects the stuck state onto the sticky element and emits `change`. */
|
|
86
154
|
#setStuck(next) {
|
|
87
155
|
if (next === this.#stuck) return;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/tags_input_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/utils/roving_tabindex.ts
|
|
53
|
+
var RovingTabindex = class {
|
|
54
|
+
/** Returns the current ordered item elements; called on every operation. */
|
|
55
|
+
#getItems;
|
|
56
|
+
/**
|
|
57
|
+
* @param getItems - Returns the current ordered item elements. Called on every
|
|
58
|
+
* operation so the live target list is always used.
|
|
59
|
+
*/
|
|
60
|
+
constructor(getItems) {
|
|
61
|
+
this.#getItems = getItems;
|
|
62
|
+
}
|
|
63
|
+
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
64
|
+
get activeIndex() {
|
|
65
|
+
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
69
|
+
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
70
|
+
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
71
|
+
* "nothing is currently tabbable".
|
|
72
|
+
*
|
|
73
|
+
* @param index - Position of the item to make tabbable.
|
|
74
|
+
* @param options - Pass `{ focus: true }` to also move DOM focus to that item.
|
|
75
|
+
*/
|
|
76
|
+
setActive(index, { focus = false } = {}) {
|
|
77
|
+
const items = this.#getItems();
|
|
78
|
+
items.forEach((item, i) => {
|
|
79
|
+
item.tabIndex = i === index ? 0 : -1;
|
|
80
|
+
});
|
|
81
|
+
if (focus) items[index]?.focus();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/controllers/tags_input_controller.ts
|
|
86
|
+
var TagsInputController = class extends Controller {
|
|
87
|
+
static targets = ["input", "tags", "tag", "tagTemplate", "status", "fields"];
|
|
88
|
+
static values = {
|
|
89
|
+
delimiter: { type: String, default: "," },
|
|
90
|
+
max: { type: Number, default: 0 },
|
|
91
|
+
allowDuplicates: { type: Boolean, default: false },
|
|
92
|
+
name: { type: String, default: "tags[]" }
|
|
93
|
+
};
|
|
94
|
+
static actions = ["onKeydown"];
|
|
95
|
+
static events = ["change", "reject"];
|
|
96
|
+
#roving = new RovingTabindex(() => this.#removeButtons);
|
|
97
|
+
/** Owns IME lifecycle state for commit-key guarding. */
|
|
98
|
+
#composition = new CompositionTracker();
|
|
99
|
+
/** Wires tag-list keyboard navigation and removal, and seeds the single Tab stop. */
|
|
100
|
+
connect() {
|
|
101
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
102
|
+
this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
|
|
103
|
+
this.tagsTarget.addEventListener("click", this.#onTagClick);
|
|
104
|
+
this.#syncState();
|
|
105
|
+
}
|
|
106
|
+
/** Releases the delegated listeners so no handler outlives the element. */
|
|
107
|
+
disconnect() {
|
|
108
|
+
this.#composition.disconnect();
|
|
109
|
+
this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
|
|
110
|
+
this.tagsTarget.removeEventListener("click", this.#onTagClick);
|
|
111
|
+
}
|
|
112
|
+
/** Tracks an input added initially or after connect. */
|
|
113
|
+
inputTargetConnected(input) {
|
|
114
|
+
this.#composition.observe(input);
|
|
115
|
+
}
|
|
116
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
117
|
+
inputTargetDisconnected(input) {
|
|
118
|
+
this.#composition.unobserve(input);
|
|
119
|
+
}
|
|
120
|
+
/** Commits on `Enter`/delimiter and deletes the last tag on empty `Backspace`. */
|
|
121
|
+
onKeydown(event) {
|
|
122
|
+
if (this.#composition.isComposing(event)) return;
|
|
123
|
+
if (event.key === "Enter" || event.key === this.delimiterValue) {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
this.#commitInput();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (event.key === "Backspace" && this.inputTarget.value === "") {
|
|
129
|
+
const buttons = this.#removeButtons;
|
|
130
|
+
if (buttons.length > 0) {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
this.#removeAt(buttons.length - 1);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (event.key === "ArrowLeft" && this.inputTarget.value === "") {
|
|
137
|
+
const buttons = this.#removeButtons;
|
|
138
|
+
if (buttons.length > 0) {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
this.#roving.setActive(buttons.length - 1, { focus: true });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Deletes the tag whose remove button was clicked. Delegated on the tags
|
|
146
|
+
* container (like `#onTagKeydown`) rather than bound per chip via
|
|
147
|
+
* `data-action`, so it works the instant a chip is appended without waiting on
|
|
148
|
+
* Stimulus to wire a freshly created element.
|
|
149
|
+
*/
|
|
150
|
+
#onTagClick = (event) => {
|
|
151
|
+
const button = event.target.closest("button");
|
|
152
|
+
if (!button || !this.tagsTarget.contains(button)) return;
|
|
153
|
+
const index = this.#removeButtons.indexOf(button);
|
|
154
|
+
if (index !== -1) this.#removeAt(index);
|
|
155
|
+
};
|
|
156
|
+
/** Validates and adds the current input value as a tag, then clears the input. */
|
|
157
|
+
#commitInput() {
|
|
158
|
+
const value = this.inputTarget.value.trim();
|
|
159
|
+
if (value === "") {
|
|
160
|
+
this.#reject(value, "empty");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (this.maxValue > 0 && this.tagTargets.length >= this.maxValue) {
|
|
164
|
+
this.#reject(value, "max");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!this.allowDuplicatesValue && this.#values.includes(value)) {
|
|
168
|
+
this.#reject(value, "duplicate");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.#appendTag(value);
|
|
172
|
+
this.inputTarget.value = "";
|
|
173
|
+
this.#announce(value);
|
|
174
|
+
this.#syncState();
|
|
175
|
+
this.dispatch("change", { detail: { tags: this.#values } });
|
|
176
|
+
}
|
|
177
|
+
/** Builds one chip from the template and appends it to the tag list. */
|
|
178
|
+
#appendTag(value) {
|
|
179
|
+
if (!this.hasTagTemplateTarget) return;
|
|
180
|
+
const fragment = this.tagTemplateTarget.content.cloneNode(true);
|
|
181
|
+
const tag = fragment.querySelector('[data-stimeo--tags-input-target="tag"]');
|
|
182
|
+
const label = fragment.querySelector('[data-tags-input-slot="label"]');
|
|
183
|
+
const button = fragment.querySelector("button");
|
|
184
|
+
if (!tag || !button) return;
|
|
185
|
+
tag.dataset.value = value;
|
|
186
|
+
if (label) label.textContent = value;
|
|
187
|
+
button.setAttribute("aria-label", `Remove ${value}`);
|
|
188
|
+
button.tabIndex = -1;
|
|
189
|
+
this.tagsTarget.appendChild(fragment);
|
|
190
|
+
}
|
|
191
|
+
/** Removes the tag at `index`, then moves focus to a neighbor or the input. */
|
|
192
|
+
#removeAt(index) {
|
|
193
|
+
const tag = this.tagTargets[index];
|
|
194
|
+
if (!tag) return;
|
|
195
|
+
const value = tag.dataset.value ?? "";
|
|
196
|
+
tag.remove();
|
|
197
|
+
this.#announce(value);
|
|
198
|
+
this.#syncState();
|
|
199
|
+
this.dispatch("change", { detail: { tags: this.#values } });
|
|
200
|
+
const remaining = this.#removeButtons;
|
|
201
|
+
if (remaining.length === 0) {
|
|
202
|
+
this.inputTarget.focus();
|
|
203
|
+
} else {
|
|
204
|
+
this.#roving.setActive(Math.min(index, remaining.length - 1), { focus: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Handles arrow navigation and deletion within the chip list (delegated). */
|
|
208
|
+
#onTagKeydown = (event) => {
|
|
209
|
+
const button = event.target.closest("button");
|
|
210
|
+
if (!button) return;
|
|
211
|
+
const buttons = this.#removeButtons;
|
|
212
|
+
const index = buttons.indexOf(button);
|
|
213
|
+
if (index === -1) return;
|
|
214
|
+
switch (event.key) {
|
|
215
|
+
case "ArrowLeft":
|
|
216
|
+
if (index > 0) {
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
this.#roving.setActive(index - 1, { focus: true });
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
case "ArrowRight":
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
if (index < buttons.length - 1) {
|
|
224
|
+
this.#roving.setActive(index + 1, { focus: true });
|
|
225
|
+
} else {
|
|
226
|
+
this.inputTarget.focus();
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
case "Delete":
|
|
230
|
+
case "Backspace":
|
|
231
|
+
event.preventDefault();
|
|
232
|
+
this.#removeAt(index);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
/** Rebuilds the hidden form fields, the `full` flag, and the roving Tab stop. */
|
|
237
|
+
#syncState() {
|
|
238
|
+
if (this.hasFieldsTarget) {
|
|
239
|
+
this.fieldsTarget.replaceChildren(
|
|
240
|
+
...this.#values.map((value) => {
|
|
241
|
+
const input = document.createElement("input");
|
|
242
|
+
input.type = "hidden";
|
|
243
|
+
input.name = this.nameValue;
|
|
244
|
+
input.value = value;
|
|
245
|
+
return input;
|
|
246
|
+
})
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const full = this.maxValue > 0 && this.tagTargets.length >= this.maxValue;
|
|
250
|
+
this.element.toggleAttribute("data-stimeo--tags-input-full", full);
|
|
251
|
+
if (this.#removeButtons.length > 0 && this.#roving.activeIndex === -1) {
|
|
252
|
+
this.#roving.setActive(0);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** Mirrors the changed tag into the live region for assistive tech. */
|
|
256
|
+
#announce(value) {
|
|
257
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = value;
|
|
258
|
+
}
|
|
259
|
+
/** Reports a rejected addition via `stimeo--tags-input:reject`. */
|
|
260
|
+
#reject(value, reason) {
|
|
261
|
+
this.dispatch("reject", { detail: { value, reason } });
|
|
262
|
+
}
|
|
263
|
+
/** The remove buttons in document order (the roving navigation set). */
|
|
264
|
+
get #removeButtons() {
|
|
265
|
+
return Array.from(this.tagsTarget.querySelectorAll("button"));
|
|
266
|
+
}
|
|
267
|
+
/** Current tag values in order. */
|
|
268
|
+
get #values() {
|
|
269
|
+
return this.tagTargets.map((tag) => tag.dataset.value ?? "");
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export { TagsInputController };
|
|
274
|
+
//# sourceMappingURL=tags_input_controller.js.map
|
|
275
|
+
//# sourceMappingURL=tags_input_controller.js.map
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/theme_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/safe_storage.ts
|
|
6
|
+
function readLocalStorage(key) {
|
|
7
|
+
try {
|
|
8
|
+
return window.localStorage.getItem(key);
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function writeLocalStorage(key, value) {
|
|
14
|
+
try {
|
|
15
|
+
window.localStorage.setItem(key, value);
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
// src/controllers/theme_controller.ts
|
|
4
21
|
var MODES = ["light", "dark", "system"];
|
|
5
22
|
var isMode = (value) => typeof value === "string" && MODES.includes(value);
|
|
@@ -133,19 +150,12 @@ var ThemeController = class extends Controller {
|
|
|
133
150
|
}
|
|
134
151
|
/** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
|
|
135
152
|
#readStored() {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return isMode(value) ? value : null;
|
|
139
|
-
} catch {
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
153
|
+
const value = readLocalStorage(this.storageKeyValue);
|
|
154
|
+
return isMode(value) ? value : null;
|
|
142
155
|
}
|
|
143
156
|
/** Persists the mode, swallowing storage errors (private mode / quota). */
|
|
144
157
|
#writeStored(mode) {
|
|
145
|
-
|
|
146
|
-
window.localStorage.setItem(this.storageKeyValue, mode);
|
|
147
|
-
} catch {
|
|
148
|
-
}
|
|
158
|
+
writeLocalStorage(this.storageKeyValue, mode);
|
|
149
159
|
}
|
|
150
160
|
};
|
|
151
161
|
|