stimeo-ui 0.2.1 → 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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/breadcrumb_controller.js +225 -13
  5. data/dist/controllers/calendar_controller.js +89 -22
  6. data/dist/controllers/carousel_controller.js +47 -6
  7. data/dist/controllers/collapsible_controller.js +2 -2
  8. data/dist/controllers/color_picker_controller.js +46 -7
  9. data/dist/controllers/combobox_controller.js +162 -23
  10. data/dist/controllers/command_palette_controller.js +194 -17
  11. data/dist/controllers/context_menu_controller.js +32 -10
  12. data/dist/controllers/data_grid_controller.js +82 -4
  13. data/dist/controllers/date_range_picker_controller.js +27 -3
  14. data/dist/controllers/editable_controller.js +1 -0
  15. data/dist/controllers/form_validation_controller.js +1 -1
  16. data/dist/controllers/intersection_controller.js +36 -11
  17. data/dist/controllers/lazy_frame_controller.js +31 -10
  18. data/dist/controllers/listbox_controller.js +257 -53
  19. data/dist/controllers/local_time_controller.js +2 -2
  20. data/dist/controllers/menu_controller.js +104 -17
  21. data/dist/controllers/menubar_controller.js +415 -63
  22. data/dist/controllers/multi_select_controller.js +312 -29
  23. data/dist/controllers/navigation_menu_controller.js +154 -27
  24. data/dist/controllers/number_input_controller.js +7 -0
  25. data/dist/controllers/otp_controller.js +18 -1
  26. data/dist/controllers/overflow_indicator_controller.js +81 -13
  27. data/dist/controllers/overflow_menu_controller.js +381 -57
  28. data/dist/controllers/pagination_controller.js +163 -32
  29. data/dist/controllers/persist_controller.js +6 -6
  30. data/dist/controllers/pointer_drag_controller.js +9 -1
  31. data/dist/controllers/popover_controller.js +2 -2
  32. data/dist/controllers/radio_group_controller.js +22 -3
  33. data/dist/controllers/range_slider_controller.js +32 -6
  34. data/dist/controllers/rating_controller.js +16 -2
  35. data/dist/controllers/read_more_controller.js +63 -19
  36. data/dist/controllers/resizable_controller.js +65 -1
  37. data/dist/controllers/roving_controller.js +17 -2
  38. data/dist/controllers/scroll_area_controller.js +86 -12
  39. data/dist/controllers/scroll_restore_controller.js +1 -1
  40. data/dist/controllers/scroll_visibility_controller.js +33 -3
  41. data/dist/controllers/scrollspy_controller.js +346 -73
  42. data/dist/controllers/separator_controller.js +9 -0
  43. data/dist/controllers/skeleton_controller.js +1 -1
  44. data/dist/controllers/slider_controller.js +32 -6
  45. data/dist/controllers/sortable_controller.js +34 -3
  46. data/dist/controllers/spinner_controller.js +1 -1
  47. data/dist/controllers/stick_to_bottom_controller.js +2 -1
  48. data/dist/controllers/sticky_observer_controller.js +32 -11
  49. data/dist/controllers/switch_controller.js +1 -0
  50. data/dist/controllers/tabs_controller.js +26 -3
  51. data/dist/controllers/tags_input_controller.js +22 -2
  52. data/dist/controllers/theme_controller.js +22 -3
  53. data/dist/controllers/time_picker_controller.js +20 -1
  54. data/dist/controllers/toast_controller.js +4 -5
  55. data/dist/controllers/toggle_group_controller.js +23 -2
  56. data/dist/controllers/toolbar_controller.js +230 -31
  57. data/dist/controllers/tree_view_controller.js +467 -51
  58. data/dist/index.js +3514 -689
  59. data/lib/stimeo/ui/version.rb +2 -3
  60. metadata +2 -2
@@ -1,7 +1,53 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/pagination_controller.ts
4
- var PaginationController = class extends Controller {
4
+
5
+ // src/utils/focus_candidate.ts
6
+ function inheritsFieldsetDisabled(control) {
7
+ let fieldset = control.closest("fieldset[disabled]");
8
+ while (fieldset) {
9
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
10
+ if (!legend?.contains(control)) return true;
11
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
12
+ }
13
+ return false;
14
+ }
15
+ function canTakeFocus(element) {
16
+ if (element.closest("[hidden]")) return false;
17
+ if (!("disabled" in element)) return true;
18
+ if (element.disabled) return false;
19
+ return !inheritsFieldsetDisabled(element);
20
+ }
21
+
22
+ // src/utils/tabindex_loan.ts
23
+ var TabindexLoan = class {
24
+ #value;
25
+ #lent = /* @__PURE__ */ new Set();
26
+ /**
27
+ * @param value - the `tabindex` to lend. `"-1"` (the default) is
28
+ * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
29
+ * which a scroll region with no focusable content of its own needs.
30
+ */
31
+ constructor(value = "-1") {
32
+ this.#value = value;
33
+ }
34
+ /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
35
+ lend(element) {
36
+ if (element.hasAttribute("tabindex")) return;
37
+ element.setAttribute("tabindex", this.#value);
38
+ this.#lent.add(element);
39
+ }
40
+ /** Takes back every loan whose value is still the one that was lent. */
41
+ returnAll() {
42
+ for (const element of this.#lent) {
43
+ if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
44
+ }
45
+ this.#lent.clear();
46
+ }
47
+ };
48
+
49
+ // src/controllers/pagination_controller.ts
50
+ var PaginationController = class _PaginationController extends Controller {
5
51
  static targets = ["page", "prev", "next"];
6
52
  static values = {
7
53
  page: { type: Number, default: 1 },
@@ -9,79 +55,164 @@ var PaginationController = class extends Controller {
9
55
  };
10
56
  static actions = ["next", "prev", "select"];
11
57
  static events = ["change"];
58
+ /** Marks a `disabled` this controller applied at a boundary (ownership flag). */
59
+ static #BOUNDARY_ATTR = "data-stimeo--pagination-boundary-disabled";
60
+ #isConnected = false;
61
+ /** The `tabindex` this instance lends the root for the focus fallback. */
62
+ #tabindex = new TabindexLoan();
12
63
  /** Normalizes out-of-range initial values and renders the initial state. */
13
64
  connect() {
14
- this.totalValue = Math.max(1, Math.trunc(this.totalValue));
15
- this.pageValue = Math.min(this.totalValue, Math.max(1, Math.trunc(this.pageValue)));
65
+ this.#isConnected = true;
66
+ this.#normalizeAndRender();
67
+ }
68
+ /** Reverts the one attribute the controller adds outside its state hooks. */
69
+ disconnect() {
70
+ this.#isConnected = false;
71
+ this.#tabindex.returnAll();
72
+ }
73
+ /** Re-renders when application code (or a Turbo morph) changes `page` at runtime. */
74
+ pageValueChanged() {
75
+ if (!this.#isConnected) return;
76
+ this.#normalizeAndRender();
77
+ }
78
+ /** Re-renders when application code (or a Turbo morph) changes `total` at runtime. */
79
+ totalValueChanged() {
80
+ if (!this.#isConnected) return;
81
+ this.#normalizeAndRender();
82
+ }
83
+ /** Syncs a page button appended/replaced at runtime (the consumer owns the list). */
84
+ pageTargetConnected() {
85
+ if (!this.#isConnected) return;
86
+ this.#render();
87
+ }
88
+ /** Syncs a `prev` button appended/replaced at runtime. */
89
+ prevTargetConnected() {
90
+ if (!this.#isConnected) return;
91
+ this.#render();
92
+ }
93
+ /** Syncs a `next` button appended/replaced at runtime. */
94
+ nextTargetConnected() {
95
+ if (!this.#isConnected) return;
16
96
  this.#render();
17
97
  }
18
98
  /** Makes the clicked page button (its `data-page`) current. */
19
99
  select(event) {
20
100
  const button = event.currentTarget;
21
- const page = Number(button.dataset.page);
22
- if (!Number.isFinite(page)) return;
101
+ const raw = button.dataset.page;
102
+ if (raw === void 0 || raw.trim() === "") return;
103
+ const page = Number(raw);
104
+ if (!Number.isInteger(page)) return;
23
105
  this.#goto(page);
24
106
  }
25
107
  /** Steps to the previous page. */
26
108
  prev() {
27
- this.#goto(this.pageValue - 1);
109
+ this.#goto(this.#page - 1);
28
110
  }
29
111
  /** Steps to the next page. */
30
112
  next() {
31
- this.#goto(this.pageValue + 1);
113
+ this.#goto(this.#page + 1);
32
114
  }
33
115
  /** Moves to `page` (clamped to `[1, total]`), re-renders, and dispatches `change`. */
34
116
  #goto(page) {
35
- const total = Math.max(1, Math.trunc(this.totalValue));
36
- const target = Math.min(total, Math.max(1, Math.trunc(page)));
37
- if (target === this.pageValue) return;
38
- const previous = this.pageValue;
117
+ if (!Number.isFinite(page)) return;
118
+ const previous = this.#page;
119
+ const target = this.#clamp(page);
120
+ if (target === previous) return;
39
121
  this.pageValue = target;
40
122
  this.#render();
41
123
  this.dispatch("change", {
42
- detail: { page: target, total: this.totalValue, previous }
124
+ detail: { page: target, total: this.#total, previous }
43
125
  });
44
126
  }
127
+ /** Writes the normalized Values back (only when they differ) and renders. */
128
+ #normalizeAndRender() {
129
+ const total = this.#total;
130
+ if (!Object.is(total, this.totalValue)) this.totalValue = total;
131
+ const page = this.#page;
132
+ if (!Object.is(page, this.pageValue)) this.pageValue = page;
133
+ this.#render();
134
+ }
45
135
  /** Syncs `aria-current` on the page buttons and the prev/next `disabled` state. */
46
136
  #render() {
137
+ const page = this.#page;
47
138
  for (const button of this.pageTargets) {
48
- if (Number(button.dataset.page) === this.pageValue) {
139
+ if (Number(button.dataset.page) === page) {
49
140
  button.setAttribute("aria-current", "page");
50
141
  } else {
51
142
  button.removeAttribute("aria-current");
52
143
  }
53
144
  }
54
- this.#setDisabled(this.hasPrevTarget ? this.prevTarget : null, this.pageValue <= 1);
55
- this.#setDisabled(
56
- this.hasNextTarget ? this.nextTarget : null,
57
- this.pageValue >= this.totalValue
58
- );
145
+ const prev = this.hasPrevTarget ? this.prevTarget : null;
146
+ const next = this.hasNextTarget ? this.nextTarget : null;
147
+ const atStart = page <= 1;
148
+ const atEnd = page >= this.#total;
149
+ const prevStaysDisabled = this.#disabledAfter(prev, atStart);
150
+ const nextStaysDisabled = this.#disabledAfter(next, atEnd);
151
+ this.#release(prev, atStart);
152
+ this.#release(next, atEnd);
153
+ this.#disable(prev, atStart, nextStaysDisabled ? null : next);
154
+ this.#disable(next, atEnd, prevStaysDisabled ? null : prev);
155
+ }
156
+ /** Whether a boundary button will still be `disabled` once this render applies. */
157
+ #disabledAfter(button, atBoundary) {
158
+ if (!button) return true;
159
+ if (atBoundary) return true;
160
+ return button.disabled && !this.#owns(button);
161
+ }
162
+ /** Releases the boundary `disabled` this controller owns, once away from it. */
163
+ #release(button, atBoundary) {
164
+ if (!button || atBoundary || !this.#owns(button)) return;
165
+ button.disabled = false;
166
+ button.removeAttribute(_PaginationController.#BOUNDARY_ATTR);
59
167
  }
60
168
  /**
61
- * Sets `disabled` on a boundary button, first moving focus off it if it is the
62
- * active element so disabling never strands focus.
169
+ * Disables a boundary button, first moving focus off it when it is the active
170
+ * element so disabling never strands focus.
63
171
  */
64
- #setDisabled(button, disabled) {
65
- if (!button) return;
66
- if (disabled && button === document.activeElement) {
67
- this.#moveFocusAwayFrom(button);
68
- }
69
- button.disabled = disabled;
172
+ #disable(button, atBoundary, opposite) {
173
+ if (!button || !atBoundary) return;
174
+ if (button.disabled && !this.#owns(button)) return;
175
+ if (button === document.activeElement) this.#moveFocusAwayFrom(opposite);
176
+ button.disabled = true;
177
+ button.setAttribute(_PaginationController.#BOUNDARY_ATTR, "");
70
178
  }
71
- /** Moves focus to the opposite enabled control, falling back to the current page. */
72
- #moveFocusAwayFrom(button) {
73
- const opposite = button === (this.hasPrevTarget ? this.prevTarget : null) ? this.hasNextTarget ? this.nextTarget : null : this.hasPrevTarget ? this.prevTarget : null;
179
+ /**
180
+ * Moves focus to `opposite` (already resolved to `null` when it will stay
181
+ * disabled), else to the current page button, else to the landmark itself.
182
+ */
183
+ #moveFocusAwayFrom(opposite) {
184
+ const page = this.#page;
74
185
  const currentPage = this.pageTargets.find(
75
- (candidate) => Number(candidate.dataset.page) === this.pageValue
186
+ (candidate) => Number(candidate.dataset.page) === page
187
+ );
188
+ const destination = [opposite, currentPage].find(
189
+ (candidate) => candidate != null && canTakeFocus(candidate)
76
190
  );
77
- const destination = opposite && !opposite.disabled ? opposite : currentPage;
78
191
  if (destination) {
79
192
  destination.focus();
80
193
  return;
81
194
  }
82
- if (!this.element.hasAttribute("tabindex")) this.element.setAttribute("tabindex", "-1");
195
+ this.#tabindex.lend(this.element);
83
196
  this.element.focus();
84
197
  }
198
+ /** Total pages, normalized to a finite integer >= 1. */
199
+ get #total() {
200
+ const total = this.totalValue;
201
+ return Number.isFinite(total) ? Math.max(1, Math.trunc(total)) : 1;
202
+ }
203
+ /** The current page, normalized into `[1, total]`. */
204
+ get #page() {
205
+ return this.#clamp(this.pageValue);
206
+ }
207
+ /** Constrains `page` to `[1, total]`; a non-finite page falls back to page 1. */
208
+ #clamp(page) {
209
+ if (!Number.isFinite(page)) return 1;
210
+ return Math.min(this.#total, Math.max(1, Math.trunc(page)));
211
+ }
212
+ /** Whether the button's current `disabled` was applied by boundary control. */
213
+ #owns(button) {
214
+ return button.hasAttribute(_PaginationController.#BOUNDARY_ATTR);
215
+ }
85
216
  };
86
217
 
87
218
  export { PaginationController };
@@ -182,12 +182,12 @@ var PersistController = class extends Controller {
182
182
  }
183
183
  /**
184
184
  * Persistable fields paired with a stable storage key. Uniquely-named fields key
185
- * by their name (unchanged, backward-compatible). Repeated same-name fields
186
- * (e.g. a `tags[]` checkbox group or array text inputs) are disambiguated by
187
- * DOM-order occurrence the first keeps its plain `name` (backward-
188
- * compatible), later ones get a NUL-separated index suffix so each is stored and
189
- * restored individually instead of the last one clobbering the rest. Radios are
190
- * the exception: a group intentionally shares one key (one value per name).
185
+ * by their name. Repeated same-name fields (e.g. a `tags[]` checkbox group or
186
+ * array text inputs) are disambiguated by DOM-order occurrence — the first keeps
187
+ * its plain `name`, later ones get a NUL-separated index suffix — so each is
188
+ * stored and restored individually instead of the last one clobbering the rest.
189
+ * Radios are the exception: a group intentionally shares one key (one value per
190
+ * name).
191
191
  */
192
192
  #fieldEntries() {
193
193
  const entries = [];
@@ -2,6 +2,12 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/pointer_drag_controller.ts
4
4
 
5
+ // src/utils/arrow_step.ts
6
+ function isReservedArrowChord(event, allow = []) {
7
+ if (!event.key.startsWith("Arrow")) return false;
8
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
+ }
10
+
5
11
  // src/utils/detach_gate.ts
6
12
  var DetachGate = class _DetachGate {
7
13
  /** Set while a probe is queued, waiting for a reconnect to cancel it. */
@@ -186,8 +192,10 @@ var PointerDragController = class _PointerDragController extends Controller {
186
192
  if (this.disabledValue) return;
187
193
  const handle = this.#handleFor(event.target);
188
194
  if (!handle) return;
195
+ if (event.defaultPrevented) return;
196
+ if (isReservedArrowChord(event)) return;
189
197
  if (event.key === "Escape") {
190
- if (event.defaultPrevented || event.isComposing) return;
198
+ if (event.isComposing) return;
191
199
  if (this.#pointer?.started) {
192
200
  const { pointerType } = this.#pointer;
193
201
  this.#teardownSessions();
@@ -133,7 +133,7 @@ var PopoverController = class _PopoverController extends Controller {
133
133
  /** Starts closed and registers the standing dismissal listeners. */
134
134
  connect() {
135
135
  this.close();
136
- document.addEventListener("click", this.#onOutsideClick);
136
+ document.addEventListener("click", this.#onOutsideClick, true);
137
137
  this.element.addEventListener("focusout", this.#onFocusOut);
138
138
  }
139
139
  /**
@@ -144,7 +144,7 @@ var PopoverController = class _PopoverController extends Controller {
144
144
  */
145
145
  disconnect() {
146
146
  this.#escapeLayer.deactivate();
147
- document.removeEventListener("click", this.#onOutsideClick);
147
+ document.removeEventListener("click", this.#onOutsideClick, true);
148
148
  this.element.removeEventListener("focusout", this.#onFocusOut);
149
149
  this.#stopScrollDismiss?.();
150
150
  this.#stopScrollDismiss = null;
@@ -2,6 +2,24 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/radio_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 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/roving_tabindex.ts
6
24
  var RovingTabindex = class {
7
25
  /** Returns the current ordered item elements; called on every operation. */
@@ -59,17 +77,18 @@ var RadioGroupController = class extends Controller {
59
77
  }
60
78
  /** Arrow/Home/End/Space navigation with selection-follows-focus. */
61
79
  onKeydown(event) {
80
+ if (event.defaultPrevented) return;
81
+ if (isReservedArrowChord(event)) return;
62
82
  const current = this.radioTargets.indexOf(event.currentTarget);
63
83
  if (current === -1) return;
64
84
  let next = null;
85
+ const step = logicalArrowStep(event.key, this.element);
65
86
  switch (event.key) {
66
87
  case "ArrowDown":
67
88
  case "ArrowRight":
68
- next = rovingMove(current, this.radioTargets.length, 1);
69
- break;
70
89
  case "ArrowUp":
71
90
  case "ArrowLeft":
72
- next = rovingMove(current, this.radioTargets.length, -1);
91
+ next = rovingMove(current, this.radioTargets.length, step);
73
92
  break;
74
93
  case "Home":
75
94
  next = 0;
@@ -1,5 +1,23 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/range_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/range_slider_controller.ts
4
22
  var START_PROPERTY = "--stimeo-range-start";
5
23
  var END_PROPERTY = "--stimeo-range-end";
@@ -10,12 +28,17 @@ var RangeSliderController = class extends Controller {
10
28
  max: { type: Number, default: 100 },
11
29
  step: { type: Number, default: 1 },
12
30
  start: { type: Number, default: 0 },
13
- end: { type: Number, default: 100 }
31
+ end: { type: Number, default: 100 },
32
+ logicalTrack: { type: Boolean, default: false }
14
33
  };
15
34
  static actions = ["onKeydown", "onPointerDown"];
16
35
  static events = ["change"];
17
36
  /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
18
37
  #dragAbort = null;
38
+ /** Whether the consumer declared a mirroring track and the direction mirrors it. */
39
+ get #mirrored() {
40
+ return this.logicalTrackValue && isRtl(this.element);
41
+ }
19
42
  /** Normalizes the initial pair (clamped, snapped, ordered) and renders. */
20
43
  connect() {
21
44
  const lo = Math.min(this.startValue, this.endValue);
@@ -29,6 +52,7 @@ var RangeSliderController = class extends Controller {
29
52
  }
30
53
  /** Keyboard stepping for whichever thumb is focused (the action's element). */
31
54
  onKeydown(event) {
55
+ if (isReservedArrowChord(event)) return;
32
56
  const thumb = event.currentTarget;
33
57
  const isStart = this.hasStartThumbTarget && thumb === this.startThumbTarget;
34
58
  const current = isStart ? this.startValue : this.endValue;
@@ -36,7 +60,7 @@ var RangeSliderController = class extends Controller {
36
60
  const upper = isStart ? this.endValue : this.maxValue;
37
61
  const big = this.stepValue * 10;
38
62
  let next = null;
39
- switch (event.key) {
63
+ switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
40
64
  case "ArrowRight":
41
65
  case "ArrowUp":
42
66
  next = current + this.stepValue;
@@ -66,7 +90,8 @@ var RangeSliderController = class extends Controller {
66
90
  /** Begins a pointer drag on the track, moving the thumb nearest the press. */
67
91
  onPointerDown(event) {
68
92
  if (!this.hasTrackTarget) return;
69
- const value = this.#valueFromClientX(event.clientX);
93
+ const mirrored = this.#mirrored;
94
+ const value = this.#valueFromClientX(event.clientX, mirrored);
70
95
  if (value === null) return;
71
96
  event.preventDefault();
72
97
  const useStart = Math.abs(value - this.startValue) <= Math.abs(value - this.endValue);
@@ -80,7 +105,7 @@ var RangeSliderController = class extends Controller {
80
105
  const abort = new AbortController();
81
106
  this.#dragAbort = abort;
82
107
  const onMove = (move) => {
83
- const moved = this.#valueFromClientX(move.clientX);
108
+ const moved = this.#valueFromClientX(move.clientX, mirrored);
84
109
  if (moved !== null) this.#moveThumb(useStart, moved);
85
110
  };
86
111
  const onUp = () => {
@@ -92,10 +117,11 @@ var RangeSliderController = class extends Controller {
92
117
  document.addEventListener("pointercancel", onUp, { signal: abort.signal });
93
118
  }
94
119
  /** Maps a pointer X coordinate to a raw value using the track geometry. */
95
- #valueFromClientX(clientX) {
120
+ #valueFromClientX(clientX, mirrored) {
96
121
  const rect = this.trackTarget.getBoundingClientRect();
97
122
  if (rect.width === 0) return null;
98
- const fraction = (clientX - rect.left) / rect.width;
123
+ const offset = (clientX - rect.left) / rect.width;
124
+ const fraction = mirrored ? 1 - offset : offset;
99
125
  return this.minValue + fraction * (this.maxValue - this.minValue);
100
126
  }
101
127
  /** Moves one thumb to a new raw value, keeping the pair ordered. */
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/rating_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. */
@@ -77,16 +88,19 @@ var RatingController = class extends Controller {
77
88
  }
78
89
  /** Arrow/Home/End/Space keyboard control, clamped (no wrap). */
79
90
  onKeydown(event) {
91
+ if (event.defaultPrevented) return;
92
+ if (isReservedArrowChord(event)) return;
80
93
  if (this.readonlyValue) return;
81
94
  let next = null;
95
+ const rtl = isRtl(this.element);
82
96
  switch (event.key) {
83
97
  case "ArrowRight":
84
98
  case "ArrowUp":
85
- next = this.valueValue + 1;
99
+ next = this.valueValue + (event.key === "ArrowRight" && rtl ? -1 : 1);
86
100
  break;
87
101
  case "ArrowLeft":
88
102
  case "ArrowDown":
89
- next = this.valueValue - 1;
103
+ next = this.valueValue - (event.key === "ArrowLeft" && rtl ? -1 : 1);
90
104
  break;
91
105
  case "Home":
92
106
  next = this.#minValue;
@@ -2,6 +2,61 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/read_more_controller.ts
4
4
 
5
+ // src/utils/blur_deferral.ts
6
+ var BlurDeferral = class {
7
+ /** Elements currently holding an update back, mapped to their `blur` listener. */
8
+ #pending = /* @__PURE__ */ new Map();
9
+ /** Called after a pending element blurs and has been detached. */
10
+ #onRelease;
11
+ /** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
12
+ constructor(onRelease) {
13
+ this.#onRelease = onRelease;
14
+ }
15
+ /** Number of elements currently holding an update back. */
16
+ get size() {
17
+ return this.#pending.size;
18
+ }
19
+ /** Snapshot of the pending elements, safe to iterate while releasing them. */
20
+ get elements() {
21
+ return [...this.#pending.keys()];
22
+ }
23
+ /** Whether `element` is currently holding an update back. */
24
+ has(element) {
25
+ return this.#pending.has(element);
26
+ }
27
+ /** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
28
+ defer(element) {
29
+ if (this.#pending.has(element)) return;
30
+ const onBlur = () => {
31
+ this.#detach(element);
32
+ this.#onRelease(element);
33
+ };
34
+ this.#pending.set(element, onBlur);
35
+ element.addEventListener("blur", onBlur);
36
+ }
37
+ /** Defers `element` as the only pending entry, cancelling any others. */
38
+ deferOnly(element) {
39
+ for (const pending of this.elements) {
40
+ if (pending !== element) this.#detach(pending);
41
+ }
42
+ this.defer(element);
43
+ }
44
+ /** Cancels `element`'s deferral without completing it; no-ops when not pending. */
45
+ release(element) {
46
+ this.#detach(element);
47
+ }
48
+ /** Cancels every deferral without completing any of them. */
49
+ releaseAll() {
50
+ for (const element of this.elements) this.#detach(element);
51
+ }
52
+ /** Removes the `blur` listener for `element` and forgets it. */
53
+ #detach(element) {
54
+ const onBlur = this.#pending.get(element);
55
+ if (onBlur) element.removeEventListener("blur", onBlur);
56
+ this.#pending.delete(element);
57
+ }
58
+ };
59
+
5
60
  // src/utils/layout_observer.ts
6
61
  var LayoutObserver = class {
7
62
  #callback;
@@ -69,15 +124,14 @@ var ReadMoreController = class extends Controller {
69
124
  #collapsed = true;
70
125
  #observedContent = null;
71
126
  #contentMutationObserver = null;
72
- #deferredHideTrigger = null;
73
127
  #update = () => {
74
128
  if (this.#connected) this.#evaluateOverflow();
75
129
  };
76
130
  #layout = new LayoutObserver(this.#update);
77
- #onDeferredHideBlur = () => {
78
- this.#clearDeferredHide();
131
+ /** Holds the trigger's hide back while it has focus; re-evaluates on blur. */
132
+ #deferredHide = new BlurDeferral(() => {
79
133
  this.#update();
80
- };
134
+ });
81
135
  connect() {
82
136
  this.#connected = true;
83
137
  this.#collapsed = this.#initialCollapsed();
@@ -98,7 +152,7 @@ var ReadMoreController = class extends Controller {
98
152
  this.#syncTargets();
99
153
  }
100
154
  triggerTargetDisconnected(trigger) {
101
- if (this.#deferredHideTrigger === trigger) this.#clearDeferredHide();
155
+ this.#deferredHide.release(trigger);
102
156
  this.#syncTargets();
103
157
  }
104
158
  /** Toggles between the collapsed (clamped) and expanded states. */
@@ -149,7 +203,7 @@ var ReadMoreController = class extends Controller {
149
203
  }
150
204
  }
151
205
  #stopObservingContent() {
152
- this.#clearDeferredHide();
206
+ this.#deferredHide.releaseAll();
153
207
  if (this.#observedContent) {
154
208
  this.#layout.unobserve(this.#observedContent);
155
209
  this.#observedContent.removeEventListener("load", this.#update, true);
@@ -159,32 +213,22 @@ var ReadMoreController = class extends Controller {
159
213
  this.#contentMutationObserver = null;
160
214
  this.#layout.unobserveViewport();
161
215
  }
162
- #deferHide(trigger) {
163
- if (this.#deferredHideTrigger === trigger) return;
164
- this.#clearDeferredHide();
165
- this.#deferredHideTrigger = trigger;
166
- trigger.addEventListener("blur", this.#onDeferredHideBlur);
167
- }
168
- #clearDeferredHide() {
169
- this.#deferredHideTrigger?.removeEventListener("blur", this.#onDeferredHideBlur);
170
- this.#deferredHideTrigger = null;
171
- }
172
216
  #evaluateOverflow() {
173
217
  if (!this.hasTriggerTarget || !this.hasContentTarget) return;
174
218
  const trigger = this.triggerTarget;
175
219
  const content = this.contentTarget;
176
220
  const useful = !this.#collapsed || content.scrollHeight > content.clientHeight;
177
221
  if (useful) {
178
- this.#clearDeferredHide();
222
+ this.#deferredHide.releaseAll();
179
223
  trigger.hidden = false;
180
224
  return;
181
225
  }
182
226
  if (document.activeElement === trigger) {
183
227
  trigger.hidden = false;
184
- this.#deferHide(trigger);
228
+ this.#deferredHide.deferOnly(trigger);
185
229
  return;
186
230
  }
187
- this.#clearDeferredHide();
231
+ this.#deferredHide.releaseAll();
188
232
  trigger.hidden = true;
189
233
  }
190
234
  };