stimeo-ui 0.9.0 → 0.11.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/dist/cable/index.js +226 -43
  4. data/dist/controllers/alert_dialog_controller.js +17 -7
  5. data/dist/controllers/announcer_controller.js +1 -1
  6. data/dist/controllers/bulk_select_controller.js +2 -2
  7. data/dist/controllers/collapsible_controller.js +1 -1
  8. data/dist/controllers/command_palette_controller.js +15 -5
  9. data/dist/controllers/confirm_controller.js +15 -5
  10. data/dist/controllers/countdown_controller.js +1 -1
  11. data/dist/controllers/data_grid_controller.js +1 -1
  12. data/dist/controllers/dialog_controller.js +15 -5
  13. data/dist/controllers/direct_upload_controller.js +1 -5
  14. data/dist/controllers/drawer_controller.js +17 -7
  15. data/dist/controllers/focus_controller.js +45 -7
  16. data/dist/controllers/form_field_controller.js +1 -1
  17. data/dist/controllers/menubar_controller.js +2 -2
  18. data/dist/controllers/multi_select_controller.js +1 -1
  19. data/dist/controllers/overflow_menu_controller.js +2 -2
  20. data/dist/controllers/password_reveal_controller.js +102 -3
  21. data/dist/controllers/password_strength_controller.js +287 -42
  22. data/dist/controllers/pointer_drag_controller.js +64 -18
  23. data/dist/controllers/portal_controller.js +32 -11
  24. data/dist/controllers/preview_guard_controller.js +180 -20
  25. data/dist/controllers/resizable_controller.js +1 -1
  26. data/dist/controllers/roving_controller.js +127 -14
  27. data/dist/controllers/scroll_restore_controller.js +127 -27
  28. data/dist/controllers/scroll_visibility_controller.js +167 -14
  29. data/dist/controllers/sidebar_controller.js +15 -5
  30. data/dist/controllers/step_indicator_controller.js +0 -5
  31. data/dist/controllers/stick_to_bottom_controller.js +149 -28
  32. data/dist/controllers/theme_controller.js +151 -40
  33. data/dist/controllers/toast_controller.js +2 -2
  34. data/dist/controllers/transition_controller.js +79 -15
  35. data/dist/index.js +1188 -318
  36. data/dist/positioning/index.js +84 -25
  37. data/lib/stimeo/ui/version.rb +1 -1
  38. metadata +2 -2
@@ -19,6 +19,49 @@ function isReservedArrowChord(event, allow = []) {
19
19
  if (!event.key.startsWith("Arrow")) return false;
20
20
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
21
  }
22
+ function hasModifierChord(event) {
23
+ return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
24
+ }
25
+
26
+ // src/utils/roving_tabindex.ts
27
+ var RovingTabindex = class {
28
+ /** Returns the current ordered item elements; called on every operation. */
29
+ #getItems;
30
+ /**
31
+ * @param getItems - Returns the current ordered item elements. Called on every
32
+ * operation so the live target list is always used.
33
+ */
34
+ constructor(getItems) {
35
+ this.#getItems = getItems;
36
+ }
37
+ /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
38
+ get activeIndex() {
39
+ return this.#getItems().findIndex((item) => item.tabIndex === 0);
40
+ }
41
+ /**
42
+ * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
43
+ * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
44
+ * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
45
+ * "nothing is currently tabbable".
46
+ *
47
+ * @param index - Position of the item to make tabbable.
48
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
49
+ * and `items` to reuse an event-scoped collection snapshot.
50
+ */
51
+ setActive(index, options = {}) {
52
+ const { focus = false } = options;
53
+ const items = options.items ?? this.#getItems();
54
+ items.forEach((item, i) => {
55
+ item.tabIndex = i === index ? 0 : -1;
56
+ });
57
+ if (focus) items[index]?.focus();
58
+ }
59
+ };
60
+ function rovingMove(current, length, delta, wrap) {
61
+ if (length === 0) return -1;
62
+ const next = current + delta;
63
+ return (next + length) % length;
64
+ }
22
65
 
23
66
  // src/utils/safe_storage.ts
24
67
  function readLocalStorage(key) {
@@ -40,35 +83,48 @@ function writeLocalStorage(key, value) {
40
83
  // src/controllers/theme_controller.ts
41
84
  var MODES = ["light", "dark", "system"];
42
85
  var isMode = (value) => typeof value === "string" && MODES.includes(value);
86
+ var DEFAULT_MODE = "system";
87
+ var DEFAULT_TARGET = "html";
43
88
  var ThemeController = class extends Controller {
44
89
  static targets = ["option"];
45
90
  static values = {
46
- mode: { type: String, default: "system" },
91
+ mode: { type: String, default: DEFAULT_MODE },
47
92
  storageKey: { type: String, default: "stimeo-theme" },
48
- target: { type: String, default: "html" }
93
+ target: { type: String, default: DEFAULT_TARGET }
49
94
  };
50
95
  static actions = ["set", "toggle"];
51
96
  static events = ["change"];
52
97
  /** The OS dark-mode query, watched so `system` tracks live changes. */
53
98
  #media = null;
99
+ /** Gate for the target callbacks, which Stimulus runs before `connect()`. */
100
+ #connected = false;
101
+ /** The `target` declaration after validation; the default when unparsable. */
102
+ #targetSelector = DEFAULT_TARGET;
103
+ /** Owns the single Tab stop across the option set (APG radiogroup). */
104
+ #roving = new RovingTabindex(() => this.optionTargets);
105
+ /**
106
+ * The pair last reported, so a move can be told from a repeat. Neither side is
107
+ * readable after the fact — assigning the Value updates the mode before any
108
+ * comparison, and the OS query has already flipped by the time it notifies —
109
+ * so what was reported has to be kept rather than recomputed.
110
+ */
111
+ #published = {
112
+ mode: DEFAULT_MODE,
113
+ resolved: "light"
114
+ };
54
115
  /** Re-resolves while in `system` mode when the OS preference flips. */
55
116
  #onMediaChange = () => {
56
- if (this.modeValue === "system") {
57
- this.#applyTheme();
58
- this.#syncControls();
59
- this.#dispatchChange();
60
- }
117
+ if (this.#mode !== "system") return;
118
+ this.#commit();
61
119
  };
62
120
  /** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
63
121
  #onKeydown = (event) => {
64
122
  if (event.defaultPrevented) return;
65
123
  if (isReservedArrowChord(event)) return;
66
124
  const options = this.optionTargets;
67
- if (options.length === 0) return;
68
125
  const target = event.target;
69
126
  const current = options.indexOf(target);
70
127
  if (current === -1) return;
71
- const last = options.length - 1;
72
128
  let next = current;
73
129
  const step = logicalArrowStep(event.key, this.element);
74
130
  switch (event.key) {
@@ -76,13 +132,12 @@ var ThemeController = class extends Controller {
76
132
  case "ArrowRight":
77
133
  case "ArrowUp":
78
134
  case "ArrowLeft":
79
- next = step === 1 ? current === last ? 0 : current + 1 : current === 0 ? last : current - 1;
135
+ next = rovingMove(current, options.length, step);
80
136
  break;
81
137
  case "Home":
82
- next = 0;
83
- break;
84
138
  case "End":
85
- next = last;
139
+ if (hasModifierChord(event)) return;
140
+ next = event.key === "Home" ? 0 : options.length - 1;
86
141
  break;
87
142
  default:
88
143
  return;
@@ -91,25 +146,58 @@ var ThemeController = class extends Controller {
91
146
  const option = options[next];
92
147
  if (!option) return;
93
148
  option.focus();
94
- this.#setMode(this.#optionMode(option));
149
+ const mode = this.#optionMode(option);
150
+ if (mode) this.#setMode(mode);
95
151
  };
96
152
  connect() {
97
153
  const stored = this.#readStored();
98
154
  if (stored) this.modeValue = stored;
99
155
  this.#media = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
100
156
  this.#media?.addEventListener("change", this.#onMediaChange);
101
- if (this.hasOptionTarget) this.element.addEventListener("keydown", this.#onKeydown);
157
+ this.element.addEventListener("keydown", this.#onKeydown);
158
+ this.#connected = true;
102
159
  this.#applyTheme();
103
160
  this.#syncControls();
161
+ this.#published = this.#current;
104
162
  }
105
163
  disconnect() {
164
+ this.#connected = false;
106
165
  this.#media?.removeEventListener("change", this.#onMediaChange);
107
166
  this.element.removeEventListener("keydown", this.#onKeydown);
108
167
  }
109
- /** Selects an explicit mode from the `mode` action param (radiogroup option). */
168
+ /** Validates the `target` declaration once, so the render path never parses. */
169
+ targetValueChanged() {
170
+ const selector = this.targetValue;
171
+ if (selector.length > 0) {
172
+ try {
173
+ this.element.matches(selector);
174
+ this.#targetSelector = selector;
175
+ return;
176
+ } catch {
177
+ }
178
+ }
179
+ this.#targetSelector = DEFAULT_TARGET;
180
+ }
181
+ /** Re-derives the single Tab stop and ARIA for an option set that changed. */
182
+ optionTargetConnected() {
183
+ if (this.#connected) this.#syncControls();
184
+ }
185
+ /** Re-derives them again when an option leaves, so a Tab stop always remains. */
186
+ optionTargetDisconnected() {
187
+ if (this.#connected) this.#syncControls();
188
+ }
189
+ /**
190
+ * Selects the mode the activated option declares.
191
+ *
192
+ * Read through {@link ThemeController.#optionMode}, the same lane that decides
193
+ * which option is checked, so the two can never disagree about what an option
194
+ * declares.
195
+ */
110
196
  set(event) {
111
- const mode = event.params?.mode;
112
- if (isMode(mode)) this.#setMode(mode);
197
+ const option = event.currentTarget;
198
+ if (!(option instanceof HTMLElement)) return;
199
+ const mode = this.#optionMode(option);
200
+ if (mode) this.#setMode(mode);
113
201
  }
114
202
  /** Toggles light↔dark for the 2-value single-button contract. */
115
203
  toggle() {
@@ -119,9 +207,26 @@ var ThemeController = class extends Controller {
119
207
  #setMode(mode) {
120
208
  this.modeValue = mode;
121
209
  this.#writeStored(mode);
210
+ this.#commit();
211
+ }
212
+ /**
213
+ * Applies the current mode and reports it, but reports only a real move: the
214
+ * event means "the selection or the effective theme moved", so re-choosing the
215
+ * option already chosen is not one.
216
+ */
217
+ #commit() {
122
218
  this.#applyTheme();
123
219
  this.#syncControls();
124
- this.#dispatchChange();
220
+ const next = this.#current;
221
+ const last = this.#published;
222
+ this.#published = next;
223
+ if (last.mode !== next.mode || last.resolved !== next.resolved) {
224
+ this.dispatch("change", { detail: { ...next } });
225
+ }
226
+ }
227
+ /** The pair the `change` detail carries, read from current state. */
228
+ get #current() {
229
+ return { mode: this.#mode, resolved: this.#resolved() };
125
230
  }
126
231
  /** Writes `data-theme` + `color-scheme` (the resolved theme) onto the target. */
127
232
  #applyTheme() {
@@ -135,39 +240,45 @@ var ThemeController = class extends Controller {
135
240
  #syncControls() {
136
241
  const options = this.optionTargets;
137
242
  if (options.length > 0) {
138
- let hasTabbable = false;
139
- for (const option of options) {
140
- const selected = this.#optionMode(option) === this.modeValue;
141
- option.setAttribute("aria-checked", String(selected));
142
- option.tabIndex = selected ? 0 : -1;
143
- hasTabbable ||= selected;
144
- }
145
- const first = options[0];
146
- if (!hasTabbable && first) first.tabIndex = 0;
243
+ const mode = this.#mode;
244
+ let selected = -1;
245
+ options.forEach((option, index) => {
246
+ const isSelected = this.#optionMode(option) === mode;
247
+ option.setAttribute("aria-checked", String(isSelected));
248
+ if (isSelected && selected === -1) selected = index;
249
+ });
250
+ this.#roving.setActive(selected === -1 ? 0 : selected, { items: options });
147
251
  return;
148
252
  }
149
- this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
253
+ if (this.#isToggleButton) {
254
+ this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
255
+ }
256
+ }
257
+ /** Whether the controller element is the button of the 2-value contract. */
258
+ get #isToggleButton() {
259
+ return this.element.tagName === "BUTTON" || this.element.getAttribute("role") === "button";
150
260
  }
151
- /** Emits `change` with the selected mode and the resolved theme. */
152
- #dispatchChange() {
153
- this.dispatch("change", { detail: { mode: this.modeValue, resolved: this.#resolved() } });
261
+ /** The selected mode after validation; an unreadable declaration is the default. */
262
+ get #mode() {
263
+ return isMode(this.modeValue) ? this.modeValue : DEFAULT_MODE;
154
264
  }
155
265
  /** The effective theme: the OS preference when `system`, else the mode itself. */
156
266
  #resolved() {
157
- if (this.modeValue === "dark") return "dark";
158
- if (this.modeValue === "light") return "light";
267
+ const mode = this.#mode;
268
+ if (mode === "dark") return "dark";
269
+ if (mode === "light") return "light";
159
270
  return this.#media?.matches ? "dark" : "light";
160
271
  }
161
- /** Reads an option's mode from its action param attribute. */
272
+ /** An option's mode from its `data-value`, or `null` when that is not one of the three. */
162
273
  #optionMode(option) {
163
- const mode = option.getAttribute("data-stimeo--theme-mode-param");
164
- return isMode(mode) ? mode : "system";
274
+ const mode = option.getAttribute("data-value");
275
+ return isMode(mode) ? mode : null;
165
276
  }
166
277
  /** Resolves the state-hook target (`<html>` by default). */
167
278
  #targetElement() {
168
- if (this.targetValue === "html" || this.targetValue === ":root")
169
- return document.documentElement;
170
- return document.querySelector(this.targetValue);
279
+ const selector = this.#targetSelector;
280
+ if (selector === DEFAULT_TARGET || selector === ":root") return document.documentElement;
281
+ return document.querySelector(selector);
171
282
  }
172
283
  /** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
173
284
  #readStored() {
@@ -170,8 +170,8 @@ var ToastController = class extends Controller {
170
170
  }
171
171
  /**
172
172
  * Stimulus lifecycle callback triggered automatically when a new item target
173
- * enters the DOM. Perfectly handles dynamic client-side injections and server-side
174
- * Turbo Stream appends alike.
173
+ * enters the DOM, from a client-side injection or a Turbo Stream append alike. An
174
+ * item already leaving, or parented outside `list`, is skipped.
175
175
  */
176
176
  itemTargetConnected(element) {
177
177
  this.enforceMaxLimit();
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/transition_controller.ts
4
4
 
5
+ // src/utils/before_cache_reset.ts
6
+ var BeforeCacheReset = class _BeforeCacheReset {
7
+ /** Every subscribed instance, iterated by the one shared document listener. */
8
+ static #subscribers = /* @__PURE__ */ new Set();
9
+ /** The shared listener; installed while at least one instance is subscribed. */
10
+ static #onBeforeCache = () => {
11
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
+ };
13
+ #rewind;
14
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
15
+ constructor(rewind) {
16
+ this.#rewind = rewind;
17
+ }
18
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
19
+ activate() {
20
+ const first = _BeforeCacheReset.#subscribers.size === 0;
21
+ _BeforeCacheReset.#subscribers.add(this);
22
+ if (first) {
23
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
24
+ }
25
+ }
26
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
27
+ deactivate() {
28
+ _BeforeCacheReset.#subscribers.delete(this);
29
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
30
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
31
+ }
32
+ };
33
+
5
34
  // src/utils/reduced_motion.ts
6
35
  function prefersReducedMotion() {
7
36
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -217,12 +246,21 @@ var TransitionController = class extends Controller {
217
246
  static events = ["entered", "left"];
218
247
  /** Owns the cancellable completion wait (terminal events + bounded fallback). */
219
248
  #transition = new TransitionCompletion();
249
+ /** Rewinds a half-applied stage before Turbo copies the page into its snapshot. */
250
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
220
251
  #rafId = null;
252
+ /**
253
+ * Stage classes this controller put on the element. Removing by declaration
254
+ * instead would take a token the consumer also authored, and would strand the
255
+ * token that was applied when a Value changes mid-transition.
256
+ */
257
+ #staged = /* @__PURE__ */ new Set();
221
258
  connect() {
222
- this.#strip();
223
- this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
259
+ this.#beforeCache.activate();
260
+ this.#settleState();
224
261
  }
225
262
  disconnect() {
263
+ this.#beforeCache.deactivate();
226
264
  this.#cancel();
227
265
  }
228
266
  /** Shows the element with the enter transition. */
@@ -252,6 +290,7 @@ var TransitionController = class extends Controller {
252
290
  const from = isEnter ? this.enterFromValue : this.leaveFromValue;
253
291
  const to = isEnter ? this.enterToValue : this.leaveToValue;
254
292
  this.#add(base, from);
293
+ void this.element.offsetWidth;
255
294
  this.#rafId = this.#raf(() => {
256
295
  this.#rafId = null;
257
296
  this.#remove(from);
@@ -273,6 +312,21 @@ var TransitionController = class extends Controller {
273
312
  this.dispatch("left", { detail: {} });
274
313
  }
275
314
  }
315
+ /** Writes the state hook the element's visibility implies. */
316
+ #settleState() {
317
+ this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
318
+ }
319
+ /**
320
+ * Returns the element to a settled state before Turbo copies the page.
321
+ *
322
+ * The snapshot is taken while the controller is still connected, so stripping on
323
+ * the next `connect()` would only repair the page after it has been painted from
324
+ * the cache. The pass is silent: `connect()` derives the state again on restore.
325
+ */
326
+ #rewindForCache() {
327
+ this.#cancel();
328
+ this.#settleState();
329
+ }
276
330
  /** Cancels any in-flight transition (interruption / teardown). */
277
331
  #cancel() {
278
332
  if (this.#rafId !== null) {
@@ -282,24 +336,34 @@ var TransitionController = class extends Controller {
282
336
  this.#transition.cancel();
283
337
  this.#strip();
284
338
  }
339
+ /**
340
+ * Applies the stage tokens this controller does not already find on the
341
+ * element, and claims exactly those.
342
+ *
343
+ * A token already on the element is left unclaimed: it is either the consumer's
344
+ * standing class or one an earlier stage of this transition already claimed, and
345
+ * in neither case may this call take ownership of it. That is what keeps a
346
+ * standing class the consumer also named as a stage Value; the cost is that such
347
+ * a token cannot be staged, so the property it drives resolves from their CSS.
348
+ */
285
349
  #add(...lists) {
286
- const tokens = lists.flatMap(tokensOf);
287
- if (tokens.length > 0) this.element.classList.add(...tokens);
350
+ for (const token of lists.flatMap(tokensOf)) {
351
+ if (this.element.classList.contains(token)) continue;
352
+ this.element.classList.add(token);
353
+ this.#staged.add(token);
354
+ }
288
355
  }
356
+ /** Drops the named tokens that are this controller's to drop. */
289
357
  #remove(...lists) {
290
- const tokens = lists.flatMap(tokensOf);
291
- if (tokens.length > 0) this.element.classList.remove(...tokens);
358
+ for (const token of lists.flatMap(tokensOf)) {
359
+ if (!this.#staged.delete(token)) continue;
360
+ this.element.classList.remove(token);
361
+ }
292
362
  }
293
- /** Removes every stage class so no half-applied state lingers. */
363
+ /** Removes every stage class this controller applied, so none lingers. */
294
364
  #strip() {
295
- this.#remove(
296
- this.enterValue,
297
- this.enterFromValue,
298
- this.enterToValue,
299
- this.leaveValue,
300
- this.leaveFromValue,
301
- this.leaveToValue
302
- );
365
+ this.element.classList.remove(...this.#staged);
366
+ this.#staged.clear();
303
367
  }
304
368
  #raf(callback) {
305
369
  if (typeof window.requestAnimationFrame === "function") {