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
|
@@ -66,7 +66,7 @@ var SkeletonController = class extends Controller {
|
|
|
66
66
|
#timers = new SafeTimeout();
|
|
67
67
|
/** Pending min-duration reveal timer id, or `null` when none is scheduled. */
|
|
68
68
|
#revealTimerId = null;
|
|
69
|
-
/** Epoch ms when the loading state began
|
|
69
|
+
/** Epoch ms when the loading state began; `minDuration` is measured from it. */
|
|
70
70
|
#loadingSince = 0;
|
|
71
71
|
connect() {
|
|
72
72
|
if (this.#state !== "ready") {
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/slider_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/slider_controller.ts
|
|
4
22
|
var FRACTION_PROPERTY = "--stimeo--slider-fraction";
|
|
5
23
|
var SliderController = class extends Controller {
|
|
@@ -8,12 +26,17 @@ var SliderController = class extends Controller {
|
|
|
8
26
|
min: { type: Number, default: 0 },
|
|
9
27
|
max: { type: Number, default: 100 },
|
|
10
28
|
step: { type: Number, default: 1 },
|
|
11
|
-
value: { type: Number, default: 0 }
|
|
29
|
+
value: { type: Number, default: 0 },
|
|
30
|
+
logicalTrack: { type: Boolean, default: false }
|
|
12
31
|
};
|
|
13
32
|
static actions = ["onKeydown", "onPointerDown"];
|
|
14
33
|
static events = ["change"];
|
|
15
34
|
/** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
|
|
16
35
|
#dragAbort = null;
|
|
36
|
+
/** Whether the consumer declared a mirroring track and the direction mirrors it. */
|
|
37
|
+
get #mirrored() {
|
|
38
|
+
return this.logicalTrackValue && isRtl(this.element);
|
|
39
|
+
}
|
|
17
40
|
/** Clamps the initial value and renders the starting position. */
|
|
18
41
|
connect() {
|
|
19
42
|
this.#setValue(this.valueValue, { silent: true });
|
|
@@ -25,9 +48,10 @@ var SliderController = class extends Controller {
|
|
|
25
48
|
}
|
|
26
49
|
/** Handles keyboard stepping per the APG slider model. */
|
|
27
50
|
onKeydown(event) {
|
|
51
|
+
if (isReservedArrowChord(event)) return;
|
|
28
52
|
const big = this.stepValue * 10;
|
|
29
53
|
let next = null;
|
|
30
|
-
switch (event.key) {
|
|
54
|
+
switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
|
|
31
55
|
case "ArrowRight":
|
|
32
56
|
case "ArrowUp":
|
|
33
57
|
next = this.valueValue + this.stepValue;
|
|
@@ -58,12 +82,13 @@ var SliderController = class extends Controller {
|
|
|
58
82
|
onPointerDown(event) {
|
|
59
83
|
if (!this.hasTrackTarget) return;
|
|
60
84
|
event.preventDefault();
|
|
61
|
-
this.#
|
|
85
|
+
const mirrored = this.#mirrored;
|
|
86
|
+
this.#updateFromClientX(event.clientX, mirrored);
|
|
62
87
|
if (this.hasThumbTarget) this.thumbTarget.focus();
|
|
63
88
|
this.#dragAbort?.abort();
|
|
64
89
|
const abort = new AbortController();
|
|
65
90
|
this.#dragAbort = abort;
|
|
66
|
-
const onMove = (move) => this.#updateFromClientX(move.clientX);
|
|
91
|
+
const onMove = (move) => this.#updateFromClientX(move.clientX, mirrored);
|
|
67
92
|
const onUp = () => {
|
|
68
93
|
abort.abort();
|
|
69
94
|
this.#dragAbort = null;
|
|
@@ -73,10 +98,11 @@ var SliderController = class extends Controller {
|
|
|
73
98
|
document.addEventListener("pointercancel", onUp, { signal: abort.signal });
|
|
74
99
|
}
|
|
75
100
|
/** Maps a pointer X coordinate to a value using the track's geometry. */
|
|
76
|
-
#updateFromClientX(clientX) {
|
|
101
|
+
#updateFromClientX(clientX, mirrored) {
|
|
77
102
|
const rect = this.trackTarget.getBoundingClientRect();
|
|
78
103
|
if (rect.width === 0) return;
|
|
79
|
-
const
|
|
104
|
+
const offset = (clientX - rect.left) / rect.width;
|
|
105
|
+
const fraction = mirrored ? 1 - offset : offset;
|
|
80
106
|
this.#setValue(this.minValue + fraction * (this.maxValue - this.minValue));
|
|
81
107
|
}
|
|
82
108
|
/**
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/sortable_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
// src/controllers/sortable_controller.ts
|
|
4
11
|
var SortableController = class extends Controller {
|
|
5
12
|
static targets = ["list", "item", "status"];
|
|
@@ -67,6 +74,12 @@ var SortableController = class extends Controller {
|
|
|
67
74
|
* Keyboard stepping: `pointer-drag` reports *cumulative* synthetic deltas, so
|
|
68
75
|
* the difference from the last consumed value is one arrow press — its sign is
|
|
69
76
|
* the direction. Cross-axis arrows never change the primary delta (no move).
|
|
77
|
+
*
|
|
78
|
+
* The sign is physical (`ArrowRight` is always `+dx`), so a right-to-left row
|
|
79
|
+
* has to invert it: there, moving the item rightward means moving it *earlier*
|
|
80
|
+
* in the DOM. Skipping this would also split the two halves of one keypress —
|
|
81
|
+
* `roving` already moves focus logically, so the same arrow would send the
|
|
82
|
+
* focus and the grabbed item opposite ways.
|
|
70
83
|
*/
|
|
71
84
|
#stepFromKeyboard(session, detail) {
|
|
72
85
|
const primary = Number(this.#isVertical ? detail.dy : detail.dx) || 0;
|
|
@@ -75,7 +88,8 @@ var SortableController = class extends Controller {
|
|
|
75
88
|
if (delta === 0) return;
|
|
76
89
|
const items = this.#items();
|
|
77
90
|
const index = items.indexOf(session.item);
|
|
78
|
-
const
|
|
91
|
+
const step = (delta > 0 ? 1 : -1) * (this.#isReversed ? -1 : 1);
|
|
92
|
+
const next = Math.max(0, Math.min(index + step, items.length - 1));
|
|
79
93
|
if (next === index) return;
|
|
80
94
|
this.#moveTo(session.item, next);
|
|
81
95
|
this.#announce("moved", session.item);
|
|
@@ -91,11 +105,13 @@ var SortableController = class extends Controller {
|
|
|
91
105
|
if (others.length === 0) return;
|
|
92
106
|
let laidOut = false;
|
|
93
107
|
let target = 0;
|
|
108
|
+
const reversed = this.#isReversed;
|
|
94
109
|
for (const other of others) {
|
|
95
110
|
const rect = other.getBoundingClientRect();
|
|
96
111
|
if (rect.width > 0 || rect.height > 0) laidOut = true;
|
|
97
112
|
const midpoint = this.#isVertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
|
|
98
|
-
|
|
113
|
+
const precedes = reversed ? pointer < midpoint : pointer > midpoint;
|
|
114
|
+
if (precedes) target += 1;
|
|
99
115
|
}
|
|
100
116
|
if (!laidOut) return;
|
|
101
117
|
const current = this.#items().indexOf(session.item);
|
|
@@ -118,7 +134,7 @@ var SortableController = class extends Controller {
|
|
|
118
134
|
* Mirrors a step into the `status` live region. Copy is localizable through
|
|
119
135
|
* `data-grabbed` / `data-moved` / `data-dropped` / `data-canceled` templates on
|
|
120
136
|
* the status element (`%{name}` / `%{position}` / `%{total}` placeholders);
|
|
121
|
-
* terse English is the fallback
|
|
137
|
+
* terse English is the fallback.
|
|
122
138
|
*/
|
|
123
139
|
#announce(key, item) {
|
|
124
140
|
if (!this.hasStatusTarget) return;
|
|
@@ -160,6 +176,21 @@ var SortableController = class extends Controller {
|
|
|
160
176
|
get #isVertical() {
|
|
161
177
|
return this.orientationValue !== "horizontal";
|
|
162
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Whether DOM order runs opposite to the primary coordinate — true only for a
|
|
181
|
+
* horizontal row under `dir="rtl"`, where the first item sits at the largest
|
|
182
|
+
* `x`. Both drag paths reach this controller in physical terms (`pointer-drag`
|
|
183
|
+
* documents its deltas as physical and hands RTL to its consumer, which is
|
|
184
|
+
* this controller), so both have to be mapped back onto DOM order here.
|
|
185
|
+
*
|
|
186
|
+
* Read from the list, the element that lays the items out — never from the
|
|
187
|
+
* dragged item, which may carry its own `dir`. The list inherits the computed
|
|
188
|
+
* direction, so authoring `dir` on the root works too. A vertical list is
|
|
189
|
+
* unaffected: writing direction does not mirror the block axis.
|
|
190
|
+
*/
|
|
191
|
+
get #isReversed() {
|
|
192
|
+
return !this.#isVertical && isRtl(this.#list);
|
|
193
|
+
}
|
|
163
194
|
};
|
|
164
195
|
|
|
165
196
|
export { SortableController };
|
|
@@ -69,7 +69,7 @@ var SpinnerController = class extends Controller {
|
|
|
69
69
|
#delayTimerId = null;
|
|
70
70
|
/** Pending min-duration hide timer id, or `null` when none is scheduled. */
|
|
71
71
|
#hideTimerId = null;
|
|
72
|
-
/** Epoch ms when the spinner became visible
|
|
72
|
+
/** Epoch ms when the spinner became visible; `minDuration` is measured from it. */
|
|
73
73
|
#shownAt = 0;
|
|
74
74
|
connect() {
|
|
75
75
|
if (!this.element.hasAttribute("data-state")) {
|
|
@@ -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;
|
|
@@ -86,13 +93,11 @@ var StickToBottomController = class extends Controller {
|
|
|
86
93
|
#watched() {
|
|
87
94
|
return this.hasContentTarget ? this.contentTarget : this.element;
|
|
88
95
|
}
|
|
96
|
+
/** Forces reduced-motion jumps while preserving the configured normal behavior. */
|
|
89
97
|
#behavior() {
|
|
90
|
-
if (
|
|
98
|
+
if (prefersReducedMotion()) return "instant";
|
|
91
99
|
return this.behaviorValue === "smooth" ? "smooth" : "auto";
|
|
92
100
|
}
|
|
93
|
-
#prefersReducedMotion() {
|
|
94
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
95
|
-
}
|
|
96
101
|
};
|
|
97
102
|
|
|
98
103
|
export { StickToBottomController };
|
|
@@ -3,10 +3,17 @@ 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;
|
|
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
|
|
|
@@ -65,23 +114,63 @@ var StickyObserverController = class extends Controller {
|
|
|
65
114
|
#watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
|
|
66
115
|
/** Last reported stuck state, so `change` fires only on transitions. */
|
|
67
116
|
#stuck = null;
|
|
117
|
+
/** Target currently owned by the watcher; comparing it avoids duplicate restarts. */
|
|
118
|
+
#observedSentinel = null;
|
|
119
|
+
#connected = false;
|
|
68
120
|
#onIntersect(entries) {
|
|
69
|
-
const entry
|
|
70
|
-
|
|
71
|
-
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (!this.#connected || !this.#watcher.active) return;
|
|
123
|
+
this.#setStuck(!entry.isIntersecting && isBeforeRootStart(entry));
|
|
124
|
+
}
|
|
72
125
|
}
|
|
73
126
|
connect() {
|
|
74
|
-
|
|
75
|
-
this.#
|
|
76
|
-
|
|
77
|
-
rootMargin: `-${this.offsetValue}px 0px 0px 0px`,
|
|
78
|
-
threshold: [0]
|
|
79
|
-
});
|
|
127
|
+
this.#connected = true;
|
|
128
|
+
this.#stuck = null;
|
|
129
|
+
this.#syncObserver();
|
|
80
130
|
}
|
|
81
131
|
disconnect() {
|
|
132
|
+
this.#connected = false;
|
|
82
133
|
this.#watcher.stop();
|
|
134
|
+
this.#observedSentinel = null;
|
|
83
135
|
this.#stuck = null;
|
|
84
136
|
}
|
|
137
|
+
/** Starts observation when a sentinel is inserted after connection. */
|
|
138
|
+
sentinelTargetConnected() {
|
|
139
|
+
if (this.#connected) this.#syncObserver();
|
|
140
|
+
}
|
|
141
|
+
/** Stops or transfers observation when the current sentinel is removed. */
|
|
142
|
+
sentinelTargetDisconnected() {
|
|
143
|
+
if (this.#connected) this.#syncObserver();
|
|
144
|
+
}
|
|
145
|
+
/** Reflects the last snapshot onto an element inserted after that snapshot. */
|
|
146
|
+
elementTargetConnected(element) {
|
|
147
|
+
if (this.#stuck !== null) {
|
|
148
|
+
element.setAttribute("data-stuck", this.#stuck ? "true" : "false");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Rebuilds the observer when Turbo morphs the configured root. */
|
|
152
|
+
rootSelectorValueChanged() {
|
|
153
|
+
if (this.#connected) this.#syncObserver(true);
|
|
154
|
+
}
|
|
155
|
+
/** Rebuilds the observer when Turbo morphs the configured top offset. */
|
|
156
|
+
offsetValueChanged() {
|
|
157
|
+
if (this.#connected) this.#syncObserver(true);
|
|
158
|
+
}
|
|
159
|
+
#syncObserver(force = false) {
|
|
160
|
+
const sentinel = this.hasSentinelTarget ? this.sentinelTarget : null;
|
|
161
|
+
if (!force && sentinel === this.#observedSentinel && this.#watcher.active) return;
|
|
162
|
+
this.#watcher.stop();
|
|
163
|
+
this.#observedSentinel = null;
|
|
164
|
+
if (!sentinel) return;
|
|
165
|
+
const configuredOffset = this.offsetValue;
|
|
166
|
+
const offset = Number.isFinite(configuredOffset) ? configuredOffset : 0;
|
|
167
|
+
const started = this.#watcher.start(sentinel, {
|
|
168
|
+
rootSelector: this.rootSelectorValue,
|
|
169
|
+
rootMargin: `${-offset}px 0px 0px 0px`,
|
|
170
|
+
threshold: [0]
|
|
171
|
+
});
|
|
172
|
+
if (started) this.#observedSentinel = sentinel;
|
|
173
|
+
}
|
|
85
174
|
/** Reflects the stuck state onto the sticky element and emits `change`. */
|
|
86
175
|
#setStuck(next) {
|
|
87
176
|
if (next === this.#stuck) return;
|
|
@@ -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;
|