stimeo-ui 0.2.1 → 0.4.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 +160 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +47 -6
- data/dist/controllers/collapsible_controller.js +2 -2
- data/dist/controllers/color_picker_controller.js +92 -7
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +226 -22
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/countdown_controller.js +112 -12
- data/dist/controllers/data_grid_controller.js +82 -4
- data/dist/controllers/date_range_picker_controller.js +77 -3
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/editable_controller.js +1 -0
- data/dist/controllers/empty_state_controller.js +24 -10
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/frame_loading_controller.js +177 -15
- data/dist/controllers/intersection_controller.js +36 -11
- data/dist/controllers/lazy_frame_controller.js +31 -10
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +102 -8
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +415 -63
- data/dist/controllers/meter_controller.js +145 -26
- data/dist/controllers/multi_select_controller.js +312 -29
- data/dist/controllers/navigation_menu_controller.js +154 -27
- data/dist/controllers/network_status_controller.js +28 -8
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +81 -13
- data/dist/controllers/overflow_menu_controller.js +408 -57
- data/dist/controllers/pagination_controller.js +163 -32
- 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/progress_controller.js +116 -9
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +100 -9
- data/dist/controllers/rating_controller.js +69 -2
- data/dist/controllers/read_more_controller.js +63 -19
- data/dist/controllers/relative_time_controller.js +133 -12
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +86 -12
- data/dist/controllers/scroll_restore_controller.js +1 -1
- data/dist/controllers/scroll_visibility_controller.js +33 -3
- data/dist/controllers/scrollspy_controller.js +346 -73
- data/dist/controllers/separator_controller.js +9 -0
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +73 -20
- data/dist/controllers/slider_controller.js +49 -8
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +228 -27
- data/dist/controllers/step_indicator_controller.js +82 -5
- data/dist/controllers/stick_to_bottom_controller.js +61 -10
- data/dist/controllers/sticky_observer_controller.js +32 -11
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +22 -2
- data/dist/controllers/theme_controller.js +22 -3
- data/dist/controllers/time_picker_controller.js +20 -1
- data/dist/controllers/toast_controller.js +4 -5
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/tree_view_controller.js +467 -51
- data/dist/index.js +4507 -907
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +2 -2
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/step_indicator_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/microtask_coalescer.ts
|
|
6
|
+
var MicrotaskCoalescer = class {
|
|
7
|
+
#run;
|
|
8
|
+
#queued = false;
|
|
9
|
+
#active = false;
|
|
10
|
+
#generation = 0;
|
|
11
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
12
|
+
constructor(run) {
|
|
13
|
+
this.#run = run;
|
|
14
|
+
}
|
|
15
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
16
|
+
activate() {
|
|
17
|
+
this.#active = true;
|
|
18
|
+
}
|
|
19
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
20
|
+
cancel() {
|
|
21
|
+
this.#active = false;
|
|
22
|
+
this.#queued = false;
|
|
23
|
+
this.#generation += 1;
|
|
24
|
+
}
|
|
25
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
26
|
+
schedule() {
|
|
27
|
+
if (!this.#active || this.#queued) return;
|
|
28
|
+
this.#queued = true;
|
|
29
|
+
const generation = this.#generation;
|
|
30
|
+
queueMicrotask(() => {
|
|
31
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
32
|
+
this.#queued = false;
|
|
33
|
+
this.#run();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
3
38
|
// src/controllers/step_indicator_controller.ts
|
|
4
39
|
var StepIndicatorController = class extends Controller {
|
|
5
40
|
static targets = ["step"];
|
|
@@ -8,26 +43,63 @@ var StepIndicatorController = class extends Controller {
|
|
|
8
43
|
};
|
|
9
44
|
static actions = ["setCurrent"];
|
|
10
45
|
static events = ["change"];
|
|
46
|
+
/**
|
|
47
|
+
* Whether the target callbacks may render. Stimulus reports the authored steps
|
|
48
|
+
* as connected before `connect()` and the remaining ones as disconnected after
|
|
49
|
+
* `disconnect()`, so this keeps a connect at one render pass, not one per step.
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* Collapses a batch of step callbacks — and a morph that swaps `current` with
|
|
53
|
+
* them — into one repaint. Replacing a list of N steps delivers N callbacks, and
|
|
54
|
+
* each one would otherwise rewrite every step's state.
|
|
55
|
+
*/
|
|
56
|
+
#repaint = new MicrotaskCoalescer(() => this.#render());
|
|
11
57
|
/** Renders the initial state from the `current` value. */
|
|
12
58
|
connect() {
|
|
59
|
+
this.#repaint.activate();
|
|
13
60
|
this.#render();
|
|
14
61
|
}
|
|
62
|
+
/** Closes the window in which a queued repaint may still run. */
|
|
63
|
+
disconnect() {
|
|
64
|
+
this.#repaint.cancel();
|
|
65
|
+
}
|
|
66
|
+
/** Syncs a step appended or replaced at runtime (the consumer owns the list). */
|
|
67
|
+
stepTargetConnected() {
|
|
68
|
+
this.#repaint.schedule();
|
|
69
|
+
}
|
|
70
|
+
/** Re-derives the remaining steps when one is removed at runtime. */
|
|
71
|
+
stepTargetDisconnected() {
|
|
72
|
+
this.#repaint.schedule();
|
|
73
|
+
}
|
|
74
|
+
/** Repaints when application code (or a Turbo morph) changes `current` at runtime. */
|
|
75
|
+
currentValueChanged() {
|
|
76
|
+
this.#repaint.schedule();
|
|
77
|
+
}
|
|
15
78
|
/**
|
|
16
79
|
* Updates the current step from an external event (`detail.current`, 0-based)
|
|
17
|
-
* and dispatches `change`. Out-of-range indices are clamped to the step set
|
|
80
|
+
* and dispatches `change`. Out-of-range indices are clamped to the step set,
|
|
81
|
+
* and both sides of the no-op test are clamped, so moving onto the step an
|
|
82
|
+
* out-of-range `current` already renders is not reported as a change.
|
|
18
83
|
*/
|
|
19
84
|
setCurrent(event) {
|
|
20
85
|
const next = event.detail?.current;
|
|
21
86
|
if (typeof next !== "number" || !Number.isFinite(next)) return;
|
|
22
87
|
const clamped = this.#clamp(next);
|
|
23
|
-
|
|
88
|
+
const moved = clamped !== this.#clamp(this.currentValue);
|
|
24
89
|
this.currentValue = clamped;
|
|
90
|
+
if (!moved) return;
|
|
25
91
|
this.#render();
|
|
26
92
|
this.dispatch("change", {
|
|
27
93
|
detail: { current: clamped, total: this.stepTargets.length }
|
|
28
94
|
});
|
|
29
95
|
}
|
|
30
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Applies `data-state`, `aria-current`, and the progress ratio custom property.
|
|
98
|
+
*
|
|
99
|
+
* A pure function of the step set and `current`, so running it again writes the
|
|
100
|
+
* same values — which is what lets the action path paint synchronously (the event
|
|
101
|
+
* goes out after the DOM is updated) while a coalesced pass may still follow.
|
|
102
|
+
*/
|
|
31
103
|
#render() {
|
|
32
104
|
const total = this.stepTargets.length;
|
|
33
105
|
const current = this.#clamp(this.currentValue);
|
|
@@ -42,10 +114,15 @@ var StepIndicatorController = class extends Controller {
|
|
|
42
114
|
const ratio = total > 1 ? current / (total - 1) : 0;
|
|
43
115
|
this.element.style.setProperty("--stimeo-step-indicator-ratio", String(ratio));
|
|
44
116
|
}
|
|
45
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Constrains an index to `[0, total-1]` (or `0` when there are no steps). A
|
|
119
|
+
* non-finite index falls back to the first step: `current` is read from markup,
|
|
120
|
+
* so an unparsable attribute arrives as `NaN` and would otherwise propagate
|
|
121
|
+
* into every state hook.
|
|
122
|
+
*/
|
|
46
123
|
#clamp(index) {
|
|
47
124
|
const last = this.stepTargets.length - 1;
|
|
48
|
-
if (last < 0) return 0;
|
|
125
|
+
if (last < 0 || !Number.isFinite(index)) return 0;
|
|
49
126
|
return Math.min(last, Math.max(0, Math.trunc(index)));
|
|
50
127
|
}
|
|
51
128
|
};
|
|
@@ -17,14 +17,18 @@ var StickToBottomController = class extends Controller {
|
|
|
17
17
|
static targets = ["content"];
|
|
18
18
|
static values = {
|
|
19
19
|
threshold: { type: Number, default: 80 },
|
|
20
|
-
behavior: { type: String, default: "auto" }
|
|
20
|
+
behavior: { type: String, default: "auto" },
|
|
21
|
+
pinOnConnect: { type: Boolean, default: false }
|
|
21
22
|
};
|
|
22
23
|
static actions = ["scrollToBottom"];
|
|
23
24
|
static events = ["pin", "new"];
|
|
24
25
|
#observer = null;
|
|
26
|
+
/** Watches for the box a deferred `pinOnConnect` jump is still waiting on. */
|
|
27
|
+
#layout = null;
|
|
25
28
|
#pinned = false;
|
|
26
29
|
#onScroll = () => this.#updatePinned();
|
|
27
30
|
connect() {
|
|
31
|
+
if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
|
|
28
32
|
this.#pinned = this.#isPinned();
|
|
29
33
|
this.#reflectPinned();
|
|
30
34
|
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
@@ -32,21 +36,29 @@ var StickToBottomController = class extends Controller {
|
|
|
32
36
|
this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
|
|
33
37
|
this.#observer.observe(this.#watched(), { childList: true });
|
|
34
38
|
}
|
|
39
|
+
if (this.pinOnConnectValue && !this.#measurable()) this.#pinWhenLaidOut();
|
|
35
40
|
}
|
|
36
41
|
disconnect() {
|
|
37
42
|
this.element.removeEventListener("scroll", this.#onScroll);
|
|
38
43
|
this.#observer?.disconnect();
|
|
39
44
|
this.#observer = null;
|
|
45
|
+
this.#stopWaitingForLayout();
|
|
40
46
|
}
|
|
41
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Jumps to the bottom and re-pins (wired to a "new messages" button).
|
|
49
|
+
*
|
|
50
|
+
* The has-new flag clears on request — the user has acknowledged the arrival — while
|
|
51
|
+
* pinned is read back from where the scroll landed: a jump that arrives by the time
|
|
52
|
+
* this returns pins immediately, an animated one settles from its own scroll events,
|
|
53
|
+
* and a jump the engine cannot honor leaves the container unpinned, so the next append
|
|
54
|
+
* flags it again instead of being swallowed by a pinned state that does not hold.
|
|
55
|
+
*
|
|
56
|
+
* Which of those happens is not this method's to decide — see {@link behaviorValue}.
|
|
57
|
+
*/
|
|
42
58
|
scrollToBottom() {
|
|
43
59
|
this.#scrollToBottom();
|
|
44
60
|
this.element.removeAttribute("data-has-new");
|
|
45
|
-
|
|
46
|
-
this.#pinned = true;
|
|
47
|
-
this.element.setAttribute("data-pinned", "true");
|
|
48
|
-
this.dispatch("pin", { detail: { pinned: true } });
|
|
49
|
-
}
|
|
61
|
+
this.#updatePinned();
|
|
50
62
|
}
|
|
51
63
|
/** Follows appended children while pinned; otherwise flags new content. */
|
|
52
64
|
#onMutations(mutations) {
|
|
@@ -81,10 +93,43 @@ var StickToBottomController = class extends Controller {
|
|
|
81
93
|
const el = this.element;
|
|
82
94
|
return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;
|
|
83
95
|
}
|
|
84
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Whether the container has a box to scroll and to measure. One that is not rendered
|
|
98
|
+
* (inside a closed panel) reports every metric as 0, which reads as "already at the
|
|
99
|
+
* bottom" — a position describing no layout the user will ever see.
|
|
100
|
+
*/
|
|
101
|
+
#measurable() {
|
|
102
|
+
return this.element.clientHeight > 0;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Holds the `pinOnConnect` jump until the container is laid out, then runs it and
|
|
106
|
+
* re-reads the state — otherwise the panel opens at the top still claiming the bottom.
|
|
107
|
+
*/
|
|
108
|
+
#pinWhenLaidOut() {
|
|
109
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
110
|
+
this.#layout = new ResizeObserver(() => {
|
|
111
|
+
if (!this.#measurable()) return;
|
|
112
|
+
this.#stopWaitingForLayout();
|
|
113
|
+
this.#scrollToBottom("instant");
|
|
114
|
+
this.#updatePinned();
|
|
115
|
+
});
|
|
116
|
+
this.#layout.observe(this.element);
|
|
117
|
+
}
|
|
118
|
+
/** Releases the layout watch, whether or not the deferred jump ever ran. */
|
|
119
|
+
#stopWaitingForLayout() {
|
|
120
|
+
this.#layout?.disconnect();
|
|
121
|
+
this.#layout = null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
|
|
125
|
+
* 0 for a container tall enough to hold its whole content, so the jump moves nothing
|
|
126
|
+
* there. `behavior` defaults to the configured follow behavior; pass `"instant"` for a
|
|
127
|
+
* jump that must not animate.
|
|
128
|
+
*/
|
|
129
|
+
#scrollToBottom(behavior = this.#behavior()) {
|
|
85
130
|
const top = this.element.scrollHeight;
|
|
86
131
|
if (typeof this.element.scrollTo === "function") {
|
|
87
|
-
this.element.scrollTo({ top, behavior
|
|
132
|
+
this.element.scrollTo({ top, behavior });
|
|
88
133
|
} else {
|
|
89
134
|
this.element.scrollTop = top;
|
|
90
135
|
}
|
|
@@ -93,8 +138,14 @@ var StickToBottomController = class extends Controller {
|
|
|
93
138
|
#watched() {
|
|
94
139
|
return this.hasContentTarget ? this.contentTarget : this.element;
|
|
95
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
|
|
143
|
+
* once: it hands the decision to the element's computed `scroll-behavior`, so a
|
|
144
|
+
* consumer stylesheet saying `smooth` animates these scrolls too. Only `"instant"`
|
|
145
|
+
* overrides that CSS, which is why reduced motion and the `pinOnConnect` jump name it.
|
|
146
|
+
*/
|
|
96
147
|
#behavior() {
|
|
97
|
-
if (prefersReducedMotion()) return "
|
|
148
|
+
if (prefersReducedMotion()) return "instant";
|
|
98
149
|
return this.behaviorValue === "smooth" ? "smooth" : "auto";
|
|
99
150
|
}
|
|
100
151
|
};
|
|
@@ -13,6 +13,7 @@ var IntersectionWatcher = class {
|
|
|
13
13
|
#onEntries;
|
|
14
14
|
#observer = null;
|
|
15
15
|
#active = false;
|
|
16
|
+
#usingPlatformDefaults = false;
|
|
16
17
|
constructor(onEntries) {
|
|
17
18
|
this.#onEntries = onEntries;
|
|
18
19
|
}
|
|
@@ -20,15 +21,22 @@ var IntersectionWatcher = class {
|
|
|
20
21
|
get active() {
|
|
21
22
|
return this.#active;
|
|
22
23
|
}
|
|
24
|
+
/** Whether the live observer discarded configured options after construction failed. */
|
|
25
|
+
get usingPlatformDefaults() {
|
|
26
|
+
return this.#usingPlatformDefaults;
|
|
27
|
+
}
|
|
23
28
|
/**
|
|
24
29
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
25
30
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
26
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.
|
|
27
34
|
*
|
|
28
|
-
* @throws
|
|
29
|
-
* or
|
|
30
|
-
* the watcher rolls back first: every target observed
|
|
31
|
-
* `active` stays `false`, so a caller that retries
|
|
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.
|
|
32
40
|
*/
|
|
33
41
|
start(targets, options = {}) {
|
|
34
42
|
this.stop();
|
|
@@ -38,12 +46,23 @@ var IntersectionWatcher = class {
|
|
|
38
46
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
39
47
|
let observer = null;
|
|
40
48
|
try {
|
|
41
|
-
|
|
42
|
-
(entries)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
}
|
|
47
66
|
for (const target of list) observer.observe(target);
|
|
48
67
|
this.#observer = observer;
|
|
49
68
|
this.#active = true;
|
|
@@ -52,6 +71,7 @@ var IntersectionWatcher = class {
|
|
|
52
71
|
observer?.disconnect();
|
|
53
72
|
this.#observer = null;
|
|
54
73
|
this.#active = false;
|
|
74
|
+
this.#usingPlatformDefaults = false;
|
|
55
75
|
throw error;
|
|
56
76
|
}
|
|
57
77
|
}
|
|
@@ -78,6 +98,7 @@ var IntersectionWatcher = class {
|
|
|
78
98
|
this.#active = false;
|
|
79
99
|
this.#observer?.disconnect();
|
|
80
100
|
this.#observer = null;
|
|
101
|
+
this.#usingPlatformDefaults = false;
|
|
81
102
|
}
|
|
82
103
|
};
|
|
83
104
|
|
|
@@ -93,7 +114,7 @@ var StickyObserverController = class extends Controller {
|
|
|
93
114
|
#watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
|
|
94
115
|
/** Last reported stuck state, so `change` fires only on transitions. */
|
|
95
116
|
#stuck = null;
|
|
96
|
-
/** Target currently owned by the watcher
|
|
117
|
+
/** Target currently owned by the watcher; comparing it avoids duplicate restarts. */
|
|
97
118
|
#observedSentinel = null;
|
|
98
119
|
#connected = false;
|
|
99
120
|
#onIntersect(entries) {
|
|
@@ -27,6 +27,7 @@ var SwitchController = class extends Controller {
|
|
|
27
27
|
* which would otherwise toggle the switch twice.
|
|
28
28
|
*/
|
|
29
29
|
onKeydown(event) {
|
|
30
|
+
if (event.defaultPrevented) return;
|
|
30
31
|
if (this.element instanceof HTMLButtonElement) return;
|
|
31
32
|
if (event.repeat) return;
|
|
32
33
|
if (event.key === " " || event.key === "Enter") {
|
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/tabs_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function isReservedArrowChord(event, allow = []) {
|
|
12
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
13
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
14
|
+
}
|
|
15
|
+
|
|
3
16
|
// src/controllers/tabs_controller.ts
|
|
4
17
|
var TabsController = class extends Controller {
|
|
5
18
|
static targets = ["tab", "panel", "list"];
|
|
6
19
|
static actions = ["onKeydown", "select"];
|
|
7
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Selects the initially active tab: the pre-selected one, else the first.
|
|
22
|
+
*
|
|
23
|
+
* `findIndex` makes this first-wins when the author marked several — the first
|
|
24
|
+
* in DOM order is the only deterministic reading of "which one did they mean" —
|
|
25
|
+
* and `#selectIndex` then writes an explicit value onto every tab, so a
|
|
26
|
+
* forgotten `aria-selected` cannot leave a tab looking unselectable.
|
|
27
|
+
*/
|
|
8
28
|
connect() {
|
|
9
29
|
const preselected = this.tabTargets.findIndex(
|
|
10
30
|
(tab) => tab.getAttribute("aria-selected") === "true"
|
|
@@ -18,16 +38,19 @@ var TabsController = class extends Controller {
|
|
|
18
38
|
}
|
|
19
39
|
/** Implements arrow/Home/End navigation with automatic activation. */
|
|
20
40
|
onKeydown(event) {
|
|
41
|
+
if (event.defaultPrevented) return;
|
|
42
|
+
if (isReservedArrowChord(event)) return;
|
|
21
43
|
const tabs = this.tabTargets;
|
|
22
44
|
const currentIndex = tabs.indexOf(event.currentTarget);
|
|
23
45
|
if (currentIndex === -1) return;
|
|
24
46
|
let nextIndex = null;
|
|
47
|
+
const step = isRtl(this.element) ? -1 : 1;
|
|
25
48
|
switch (event.key) {
|
|
26
49
|
case "ArrowRight":
|
|
27
|
-
nextIndex = (currentIndex +
|
|
50
|
+
nextIndex = (currentIndex + step + tabs.length) % tabs.length;
|
|
28
51
|
break;
|
|
29
52
|
case "ArrowLeft":
|
|
30
|
-
nextIndex = (currentIndex -
|
|
53
|
+
nextIndex = (currentIndex - step + tabs.length) % tabs.length;
|
|
31
54
|
break;
|
|
32
55
|
case "Home":
|
|
33
56
|
nextIndex = 0;
|
|
@@ -2,6 +2,22 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/tags_input_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function logicalArrowKey(key, element) {
|
|
12
|
+
if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
|
|
13
|
+
if (!isRtl(element)) return key;
|
|
14
|
+
return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
|
|
15
|
+
}
|
|
16
|
+
function isReservedArrowChord(event, allow = []) {
|
|
17
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
18
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
// src/utils/composition_tracker.ts
|
|
6
22
|
var CompositionTracker = class {
|
|
7
23
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
@@ -119,6 +135,8 @@ var TagsInputController = class extends Controller {
|
|
|
119
135
|
}
|
|
120
136
|
/** Commits on `Enter`/delimiter and deletes the last tag on empty `Backspace`. */
|
|
121
137
|
onKeydown(event) {
|
|
138
|
+
if (event.defaultPrevented) return;
|
|
139
|
+
if (isReservedArrowChord(event)) return;
|
|
122
140
|
if (this.#composition.isComposing(event)) return;
|
|
123
141
|
if (event.key === "Enter" || event.key === this.delimiterValue) {
|
|
124
142
|
event.preventDefault();
|
|
@@ -133,7 +151,7 @@ var TagsInputController = class extends Controller {
|
|
|
133
151
|
}
|
|
134
152
|
return;
|
|
135
153
|
}
|
|
136
|
-
if (event.key === "ArrowLeft" && this.inputTarget.value === "") {
|
|
154
|
+
if (logicalArrowKey(event.key, this.element) === "ArrowLeft" && this.inputTarget.value === "") {
|
|
137
155
|
const buttons = this.#removeButtons;
|
|
138
156
|
if (buttons.length > 0) {
|
|
139
157
|
event.preventDefault();
|
|
@@ -206,12 +224,14 @@ var TagsInputController = class extends Controller {
|
|
|
206
224
|
}
|
|
207
225
|
/** Handles arrow navigation and deletion within the chip list (delegated). */
|
|
208
226
|
#onTagKeydown = (event) => {
|
|
227
|
+
if (event.defaultPrevented) return;
|
|
228
|
+
if (isReservedArrowChord(event)) return;
|
|
209
229
|
const button = event.target.closest("button");
|
|
210
230
|
if (!button) return;
|
|
211
231
|
const buttons = this.#removeButtons;
|
|
212
232
|
const index = buttons.indexOf(button);
|
|
213
233
|
if (index === -1) return;
|
|
214
|
-
switch (event.key) {
|
|
234
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
215
235
|
case "ArrowLeft":
|
|
216
236
|
if (index > 0) {
|
|
217
237
|
event.preventDefault();
|
|
@@ -2,6 +2,24 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/theme_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function logicalArrowStep(key, element) {
|
|
12
|
+
if (key === "ArrowDown") return 1;
|
|
13
|
+
if (key === "ArrowUp") return -1;
|
|
14
|
+
if (key !== "ArrowRight" && key !== "ArrowLeft") return 0;
|
|
15
|
+
const forward = isRtl(element) ? "ArrowLeft" : "ArrowRight";
|
|
16
|
+
return key === forward ? 1 : -1;
|
|
17
|
+
}
|
|
18
|
+
function isReservedArrowChord(event, allow = []) {
|
|
19
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
20
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
// src/utils/safe_storage.ts
|
|
6
24
|
function readLocalStorage(key) {
|
|
7
25
|
try {
|
|
@@ -41,6 +59,8 @@ var ThemeController = class extends Controller {
|
|
|
41
59
|
};
|
|
42
60
|
/** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
|
|
43
61
|
#onKeydown = (event) => {
|
|
62
|
+
if (event.defaultPrevented) return;
|
|
63
|
+
if (isReservedArrowChord(event)) return;
|
|
44
64
|
const options = this.optionTargets;
|
|
45
65
|
if (options.length === 0) return;
|
|
46
66
|
const target = event.target;
|
|
@@ -48,14 +68,13 @@ var ThemeController = class extends Controller {
|
|
|
48
68
|
if (current === -1) return;
|
|
49
69
|
const last = options.length - 1;
|
|
50
70
|
let next = current;
|
|
71
|
+
const step = logicalArrowStep(event.key, this.element);
|
|
51
72
|
switch (event.key) {
|
|
52
73
|
case "ArrowDown":
|
|
53
74
|
case "ArrowRight":
|
|
54
|
-
next = current === last ? 0 : current + 1;
|
|
55
|
-
break;
|
|
56
75
|
case "ArrowUp":
|
|
57
76
|
case "ArrowLeft":
|
|
58
|
-
next = current === 0 ? last : current - 1;
|
|
77
|
+
next = step === 1 ? current === last ? 0 : current + 1 : current === 0 ? last : current - 1;
|
|
59
78
|
break;
|
|
60
79
|
case "Home":
|
|
61
80
|
next = 0;
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/time_picker_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function logicalArrowKey(key, element) {
|
|
12
|
+
if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
|
|
13
|
+
if (!isRtl(element)) return key;
|
|
14
|
+
return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
|
|
15
|
+
}
|
|
16
|
+
function isReservedArrowChord(event, allow = []) {
|
|
17
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
18
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
|
+
}
|
|
20
|
+
|
|
3
21
|
// src/controllers/time_picker_controller.ts
|
|
4
22
|
var AM = 0;
|
|
5
23
|
var PM = 1;
|
|
@@ -34,12 +52,13 @@ var TimePickerController = class extends Controller {
|
|
|
34
52
|
}
|
|
35
53
|
/** Handles stepping, inter-segment focus moves, jumps, and direct entry. */
|
|
36
54
|
onKeydown(event) {
|
|
55
|
+
if (isReservedArrowChord(event)) return;
|
|
37
56
|
const segment = event.target?.closest(
|
|
38
57
|
"[data-stimeo--time-picker-target='segment']"
|
|
39
58
|
);
|
|
40
59
|
const kind = segment ? this.#kindOf(segment) : null;
|
|
41
60
|
if (!segment || !kind) return;
|
|
42
|
-
switch (event.key) {
|
|
61
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
43
62
|
case "ArrowUp":
|
|
44
63
|
event.preventDefault();
|
|
45
64
|
this.#step(kind, this.#delta(kind));
|
|
@@ -348,11 +348,10 @@ var ToastController = class extends Controller {
|
|
|
348
348
|
/**
|
|
349
349
|
* Removes the oldest toasts when the list exceeds `maxValue`.
|
|
350
350
|
*
|
|
351
|
-
* Public (not `#private`) as a deterministic
|
|
352
|
-
*
|
|
353
|
-
* MutationObserver
|
|
354
|
-
*
|
|
355
|
-
* NON_ACTION_ALLOWLIST (it is not a user-wired action).
|
|
351
|
+
* Public (not `#private`) as a deterministic seam: enforcement normally runs
|
|
352
|
+
* from `itemTargetConnected`, a Stimulus callback delivered through a
|
|
353
|
+
* MutationObserver, which a DOM-only environment does not reliably fire — so
|
|
354
|
+
* it can also be invoked directly. It is not a user-wired action.
|
|
356
355
|
*/
|
|
357
356
|
enforceMaxLimit() {
|
|
358
357
|
const currentItems = this.itemTargets;
|
|
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/toggle_group_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function isReservedArrowChord(event, allow = []) {
|
|
12
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
13
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
// src/utils/roving_tabindex.ts
|
|
6
17
|
var RovingTabindex = class {
|
|
7
18
|
/** Returns the current ordered item elements; called on every operation. */
|
|
@@ -60,17 +71,27 @@ var ToggleGroupController = class extends Controller {
|
|
|
60
71
|
}
|
|
61
72
|
/** Arrow/Home/End move focus only; Space/Enter toggle non-button hosts. */
|
|
62
73
|
onKeydown(event) {
|
|
74
|
+
if (event.defaultPrevented) return;
|
|
75
|
+
if (isReservedArrowChord(event)) return;
|
|
63
76
|
const current = this.itemTargets.indexOf(event.currentTarget);
|
|
64
77
|
if (current === -1) return;
|
|
65
78
|
let next = null;
|
|
79
|
+
const rtl = isRtl(this.element);
|
|
80
|
+
const horizontalStep = (key) => rtl ? key === "ArrowRight" ? -1 : 1 : key === "ArrowRight" ? 1 : -1;
|
|
66
81
|
switch (event.key) {
|
|
67
82
|
case "ArrowRight":
|
|
68
83
|
case "ArrowDown":
|
|
69
|
-
next = rovingMove(
|
|
84
|
+
next = rovingMove(
|
|
85
|
+
current,
|
|
86
|
+
this.itemTargets.length,
|
|
87
|
+
event.key === "ArrowDown" ? 1 : horizontalStep(event.key));
|
|
70
88
|
break;
|
|
71
89
|
case "ArrowLeft":
|
|
72
90
|
case "ArrowUp":
|
|
73
|
-
next = rovingMove(
|
|
91
|
+
next = rovingMove(
|
|
92
|
+
current,
|
|
93
|
+
this.itemTargets.length,
|
|
94
|
+
event.key === "ArrowUp" ? -1 : horizontalStep(event.key));
|
|
74
95
|
break;
|
|
75
96
|
case "Home":
|
|
76
97
|
next = 0;
|