stimeo-ui 0.3.0 → 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.
Files changed (82) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/controllers/alert_dialog_controller.js +32 -5
  3. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  4. package/dist/controllers/announcer_controller.d.ts +32 -6
  5. package/dist/controllers/announcer_controller.js +255 -20
  6. package/dist/controllers/announcer_controller.js.map +1 -1
  7. package/dist/controllers/color_picker_controller.d.ts +2 -1
  8. package/dist/controllers/color_picker_controller.js +46 -0
  9. package/dist/controllers/color_picker_controller.js.map +1 -1
  10. package/dist/controllers/command_palette_controller.js +32 -5
  11. package/dist/controllers/command_palette_controller.js.map +1 -1
  12. package/dist/controllers/confirm_controller.js +32 -5
  13. package/dist/controllers/confirm_controller.js.map +1 -1
  14. package/dist/controllers/countdown_controller.d.ts +19 -5
  15. package/dist/controllers/countdown_controller.js +112 -12
  16. package/dist/controllers/countdown_controller.js.map +1 -1
  17. package/dist/controllers/date_range_picker_controller.d.ts +4 -1
  18. package/dist/controllers/date_range_picker_controller.js +50 -0
  19. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  20. package/dist/controllers/dialog_controller.js +32 -5
  21. package/dist/controllers/dialog_controller.js.map +1 -1
  22. package/dist/controllers/dismissible_controller.js.map +1 -1
  23. package/dist/controllers/drawer_controller.js +32 -5
  24. package/dist/controllers/drawer_controller.js.map +1 -1
  25. package/dist/controllers/empty_state_controller.d.ts +9 -4
  26. package/dist/controllers/empty_state_controller.js +24 -10
  27. package/dist/controllers/empty_state_controller.js.map +1 -1
  28. package/dist/controllers/focus_controller.js +32 -5
  29. package/dist/controllers/focus_controller.js.map +1 -1
  30. package/dist/controllers/form_validation_controller.js.map +1 -1
  31. package/dist/controllers/frame_loading_controller.d.ts +14 -3
  32. package/dist/controllers/frame_loading_controller.js +177 -15
  33. package/dist/controllers/frame_loading_controller.js.map +1 -1
  34. package/dist/controllers/local_time_controller.d.ts +19 -3
  35. package/dist/controllers/local_time_controller.js +100 -6
  36. package/dist/controllers/local_time_controller.js.map +1 -1
  37. package/dist/controllers/meter_controller.d.ts +22 -2
  38. package/dist/controllers/meter_controller.js +145 -26
  39. package/dist/controllers/meter_controller.js.map +1 -1
  40. package/dist/controllers/network_status_controller.d.ts +13 -1
  41. package/dist/controllers/network_status_controller.js +28 -8
  42. package/dist/controllers/network_status_controller.js.map +1 -1
  43. package/dist/controllers/overflow_menu_controller.js +33 -6
  44. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  45. package/dist/controllers/progress_controller.d.ts +16 -1
  46. package/dist/controllers/progress_controller.js +116 -9
  47. package/dist/controllers/progress_controller.js.map +1 -1
  48. package/dist/controllers/range_slider_controller.d.ts +4 -1
  49. package/dist/controllers/range_slider_controller.js +68 -3
  50. package/dist/controllers/range_slider_controller.js.map +1 -1
  51. package/dist/controllers/rating_controller.d.ts +4 -1
  52. package/dist/controllers/rating_controller.js +53 -0
  53. package/dist/controllers/rating_controller.js.map +1 -1
  54. package/dist/controllers/relative_time_controller.d.ts +13 -0
  55. package/dist/controllers/relative_time_controller.js +133 -12
  56. package/dist/controllers/relative_time_controller.js.map +1 -1
  57. package/dist/controllers/sidebar_controller.d.ts +1 -11
  58. package/dist/controllers/sidebar_controller.js +37 -8
  59. package/dist/controllers/sidebar_controller.js.map +1 -1
  60. package/dist/controllers/skeleton_controller.d.ts +7 -2
  61. package/dist/controllers/skeleton_controller.js +73 -20
  62. package/dist/controllers/skeleton_controller.js.map +1 -1
  63. package/dist/controllers/slider_controller.js +17 -2
  64. package/dist/controllers/slider_controller.js.map +1 -1
  65. package/dist/controllers/spinner_controller.d.ts +37 -4
  66. package/dist/controllers/spinner_controller.js +228 -27
  67. package/dist/controllers/spinner_controller.js.map +1 -1
  68. package/dist/controllers/step_indicator_controller.d.ts +12 -1
  69. package/dist/controllers/step_indicator_controller.js +82 -5
  70. package/dist/controllers/step_indicator_controller.js.map +1 -1
  71. package/dist/controllers/stick_to_bottom_controller.d.ts +26 -2
  72. package/dist/controllers/stick_to_bottom_controller.js +60 -10
  73. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  74. package/dist/index.js +1056 -281
  75. package/dist/index.js.map +1 -1
  76. package/dist/inspector/cli.js +785 -76
  77. package/dist/inspector/cli.js.map +1 -1
  78. package/dist/inspector/cli_bin.js +785 -76
  79. package/dist/inspector/cli_bin.js.map +1 -1
  80. package/dist/inspector/examples.json +5 -5
  81. package/dist/inspector/manifest.json +21 -8
  82. package/package.json +1 -1
@@ -18,6 +18,22 @@ function isReservedArrowChord(event, allow = []) {
18
18
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
19
  }
20
20
 
21
+ // src/utils/range.ts
22
+ function rangeFraction(value, min, max) {
23
+ const span = max - min;
24
+ if (!(span > 0)) return 0;
25
+ const clamped = Math.min(max, Math.max(min, value));
26
+ let fraction;
27
+ if (Number.isFinite(span)) {
28
+ fraction = (clamped - min) / span;
29
+ } else {
30
+ const scale = Math.max(Math.abs(min), Math.abs(max));
31
+ fraction = (clamped / scale - min / scale) / (max / scale - min / scale);
32
+ }
33
+ if (!Number.isFinite(fraction)) return 0;
34
+ return Math.min(1, Math.max(0, fraction));
35
+ }
36
+
21
37
  // src/controllers/slider_controller.ts
22
38
  var FRACTION_PROPERTY = "--stimeo--slider-fraction";
23
39
  var SliderController = class extends Controller {
@@ -123,8 +139,7 @@ var SliderController = class extends Controller {
123
139
  this.thumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
124
140
  this.thumbTarget.setAttribute("aria-valuenow", String(value));
125
141
  }
126
- const span = this.maxValue - this.minValue;
127
- const fraction = span > 0 ? (value - this.minValue) / span : 0;
142
+ const fraction = rangeFraction(value, this.minValue, this.maxValue);
128
143
  this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));
129
144
  if (changed && !silent) this.dispatch("change", { detail: { value } });
130
145
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/logical_scroll.ts","../../src/utils/arrow_step.ts","../../src/controllers/slider_controller.ts"],"names":[],"mappings":"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","file":"slider_controller.js","sourcesContent":["/** Normalized scroll position and maximum distance on one logical axis. */\nexport interface LogicalScrollMetrics {\n position: number;\n max: number;\n}\n\n/**\n * Whether horizontal scrolling on `element` follows right-to-left inline flow.\n *\n * Resolved from the **computed** `direction`, so the authoring contract is the\n * usual `dir=\"rtl\"` (or a stylesheet) on the element or any ancestor.\n *\n * Scope: horizontal writing modes. A vertical writing mode (`writing-mode:\n * vertical-rl`) also inverts the horizontal axis, which this check does not\n * model — vertical writing modes are out of scope for the scroll utilities\n * (their consumers describe axes as horizontal/vertical, not inline/block).\n */\nexport function isRtl(element: Element): boolean {\n return window.getComputedStyle(element).direction === \"rtl\";\n}\n\n/**\n * Returns scroll distance from the logical start edge.\n *\n * CSSOM View exposes standards-mode RTL horizontal offsets as `0` at the inline\n * start (right) and increasingly negative values toward the inline end (left).\n * The normalized position is always clamped to `[0, max]`, which also absorbs\n * Safari's elastic overscroll values.\n */\nexport function logicalScrollMetrics(\n element: HTMLElement,\n horizontal: boolean,\n): LogicalScrollMetrics {\n const max = Math.max(\n 0,\n horizontal\n ? element.scrollWidth - element.clientWidth\n : element.scrollHeight - element.clientHeight,\n );\n const raw = horizontal ? element.scrollLeft : element.scrollTop;\n const position = horizontal && isRtl(element) ? -raw : raw;\n return { position: Math.min(max, Math.max(0, position)), max };\n}\n\n/**\n * Converts a logical start/end delta to the physical value accepted by\n * `Element.scrollBy`.\n */\nexport function physicalScrollDelta(\n element: HTMLElement,\n horizontal: boolean,\n logicalDelta: number,\n): number {\n return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;\n}\n","import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord, logicalArrowKey } from \"../utils/arrow_step\";\nimport { isRtl } from \"../utils/logical_scroll\";\n\n/** Name of the CSS custom property exposing the thumb position (0..1). */\nconst FRACTION_PROPERTY = \"--stimeo--slider-fraction\";\n\n/**\n * Headless, accessible slider (single-thumb range) behavior.\n *\n * Markup contract (identifier: `stimeo--slider`):\n * <div data-controller=\"stimeo--slider\"\n * data-stimeo--slider-min-value=\"0\"\n * data-stimeo--slider-max-value=\"100\"\n * data-stimeo--slider-step-value=\"1\"\n * data-stimeo--slider-value-value=\"40\">\n * <div data-stimeo--slider-target=\"track\"\n * data-action=\"pointerdown->stimeo--slider#onPointerDown\">\n * <div data-stimeo--slider-target=\"thumb\" role=\"slider\" tabindex=\"0\"\n * aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"40\"\n * data-action=\"keydown->stimeo--slider#onKeydown\"></div>\n * </div>\n * </div>\n *\n * Implements the WAI-ARIA APG **Slider** pattern. The current value is exposed\n * to assistive tech via `aria-valuenow`/`aria-valuemin`/`aria-valuemax` on the\n * thumb, and to the consumer's CSS via the `--stimeo--slider-fraction` custom\n * property (a number in `[0, 1]`) set on the controller element — the library\n * positions nothing itself.\n *\n * @remarks\n * Behavior only. The consumer owns all layout (e.g. positioning the thumb from\n * the fraction). Only the horizontal orientation is handled.\n *\n * Behavior provided:\n * - `ArrowRight`/`ArrowUp` increase and `ArrowLeft`/`ArrowDown` decrease by one\n * step; `Home`/`End` jump to the min/max; `PageUp`/`PageDown` move by ten steps.\n * - Pointer press/drag on the track sets the value from the pointer position.\n *\n * The fraction is a value ratio, not a position, so only the consumer knows\n * whether their track mirrors under RTL. Set `logicalTrack` to declare that it\n * does: the pointer mapping and the horizontal arrow pair then follow the\n * writing direction. Left unset, nothing here reads `direction`.\n */\nexport class SliderController extends Controller<HTMLElement> {\n static override targets = [\"track\", \"thumb\"];\n static override values = {\n min: { type: Number, default: 0 },\n max: { type: Number, default: 100 },\n step: { type: Number, default: 1 },\n value: { type: Number, default: 0 },\n logicalTrack: { type: Boolean, default: false },\n };\n static actions = [\"onKeydown\", \"onPointerDown\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly trackTarget: HTMLElement;\n declare readonly thumbTarget: HTMLElement;\n declare readonly hasTrackTarget: boolean;\n declare readonly hasThumbTarget: boolean;\n declare minValue: number;\n declare maxValue: number;\n declare stepValue: number;\n declare valueValue: number;\n declare logicalTrackValue: boolean;\n\n /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */\n #dragAbort: AbortController | null = null;\n\n /** Whether the consumer declared a mirroring track and the direction mirrors it. */\n get #mirrored(): boolean {\n return this.logicalTrackValue && isRtl(this.element);\n }\n\n /** Clamps the initial value and renders the starting position. */\n override connect(): void {\n this.#setValue(this.valueValue, { silent: true });\n }\n\n /** Cancels any active pointer drag so document listeners never leak. */\n override disconnect(): void {\n this.#dragAbort?.abort();\n this.#dragAbort = null;\n }\n\n /** Handles keyboard stepping per the APG slider model. */\n onKeydown(event: KeyboardEvent): void {\n if (isReservedArrowChord(event)) return;\n const big = this.stepValue * 10;\n let next: number | null = null;\n // On a mirrored track the greater value sits at the visual left, so the\n // horizontal pair trades places; the vertical pair passes through.\n switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {\n case \"ArrowRight\":\n case \"ArrowUp\":\n next = this.valueValue + this.stepValue;\n break;\n case \"ArrowLeft\":\n case \"ArrowDown\":\n next = this.valueValue - this.stepValue;\n break;\n case \"PageUp\":\n next = this.valueValue + big;\n break;\n case \"PageDown\":\n next = this.valueValue - big;\n break;\n case \"Home\":\n next = this.minValue;\n break;\n case \"End\":\n next = this.maxValue;\n break;\n default:\n return;\n }\n event.preventDefault();\n this.#setValue(next);\n }\n\n /** Begins a pointer drag: sets the value and tracks subsequent movement. */\n onPointerDown(event: PointerEvent): void {\n if (!this.hasTrackTarget) return;\n event.preventDefault();\n // Resolve the direction once for the whole gesture: reading it per move\n // would query computed style on every frame, and a drag that flipped\n // mid-gesture would be incoherent anyway.\n const mirrored = this.#mirrored;\n this.#updateFromClientX(event.clientX, mirrored);\n if (this.hasThumbTarget) this.thumbTarget.focus();\n\n this.#dragAbort?.abort();\n const abort = new AbortController();\n this.#dragAbort = abort;\n const onMove = (move: PointerEvent): void => this.#updateFromClientX(move.clientX, mirrored);\n const onUp = (): void => {\n abort.abort();\n this.#dragAbort = null;\n };\n document.addEventListener(\"pointermove\", onMove, { signal: abort.signal });\n document.addEventListener(\"pointerup\", onUp, { signal: abort.signal });\n // pointercancel fires when the gesture is interrupted (OS gesture, scroll\n // takeover, device switch); clean up the same way so no listener leaks.\n document.addEventListener(\"pointercancel\", onUp, { signal: abort.signal });\n }\n\n /** Maps a pointer X coordinate to a value using the track's geometry. */\n #updateFromClientX(clientX: number, mirrored: boolean): void {\n const rect = this.trackTarget.getBoundingClientRect();\n if (rect.width === 0) return;\n const offset = (clientX - rect.left) / rect.width;\n const fraction = mirrored ? 1 - offset : offset;\n this.#setValue(this.minValue + fraction * (this.maxValue - this.minValue));\n }\n\n /**\n * Clamps `raw` to `[min, max]`, snaps it to the nearest step, stores it, and\n * reflects the new state on the thumb's ARIA attributes and the fraction\n * custom property. Dispatches `change` (detail `{ value }`) on a real value\n * change — symmetric with `range-slider` — unless `silent` (the initial\n * connect render, which is not a user edit).\n */\n #setValue(raw: number, { silent = false }: { silent?: boolean } = {}): void {\n const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));\n const stepped =\n this.stepValue > 0\n ? Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue\n : clamped;\n const value = Math.min(this.maxValue, Math.max(this.minValue, stepped));\n const changed = value !== this.valueValue;\n this.valueValue = value;\n\n if (this.hasThumbTarget) {\n this.thumbTarget.setAttribute(\"aria-valuemin\", String(this.minValue));\n this.thumbTarget.setAttribute(\"aria-valuemax\", String(this.maxValue));\n this.thumbTarget.setAttribute(\"aria-valuenow\", String(value));\n }\n\n const span = this.maxValue - this.minValue;\n const fraction = span > 0 ? (value - this.minValue) / span : 0;\n this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));\n\n if (changed && !silent) this.dispatch(\"change\", { detail: { value } });\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/logical_scroll.ts","../../src/utils/arrow_step.ts","../../src/utils/range.ts","../../src/controllers/slider_controller.ts"],"names":[],"mappings":";;;;;AAiBO,SAAS,MAAM,OAAA,EAA2B;AAC/C,EAAA,OAAO,MAAA,CAAO,gBAAA,CAAiB,OAAO,CAAA,CAAE,SAAA,KAAc,KAAA;AACxD;;;AC6CO,SAAS,eAAA,CAAgB,KAAa,OAAA,EAA0B;AACrE,EAAA,IAAI,GAAA,KAAQ,YAAA,IAAgB,GAAA,KAAQ,WAAA,EAAa,OAAO,GAAA;AACxD,EAAA,IAAI,CAAC,KAAA,CAAM,OAAO,CAAA,EAAG,OAAO,GAAA;AAC5B,EAAA,OAAO,GAAA,KAAQ,eAAe,WAAA,GAAc,YAAA;AAC9C;AA2BO,SAAS,oBAAA,CACd,KAAA,EACA,KAAA,GAAkC,EAAC,EAC1B;AACT,EAAA,IAAI,CAAC,KAAA,CAAM,GAAA,CAAI,UAAA,CAAW,OAAO,GAAG,OAAO,KAAA;AAC3C,EAAA,OACG,KAAA,CAAM,MAAA,IAAU,CAAC,KAAA,CAAM,QAAA,CAAS,KAAK,CAAA,IACrC,KAAA,CAAM,OAAA,IAAW,CAAC,KAAA,CAAM,QAAA,CAAS,MAAM,KACvC,KAAA,CAAM,OAAA,IAAW,CAAC,KAAA,CAAM,QAAA,CAAS,MAAM,CAAA,IACvC,KAAA,CAAM,QAAA,IAAY,CAAC,KAAA,CAAM,QAAA,CAAS,OAAO,CAAA;AAE9C;;;AClFO,SAAS,aAAA,CAAc,KAAA,EAAe,GAAA,EAAa,GAAA,EAAqB;AAC7E,EAAA,MAAM,OAAO,GAAA,GAAM,GAAA;AACnB,EAAA,IAAI,EAAE,IAAA,GAAO,CAAA,CAAA,EAAI,OAAO,CAAA;AAExB,EAAA,MAAM,OAAA,GAAU,KAAK,GAAA,CAAI,GAAA,EAAK,KAAK,GAAA,CAAI,GAAA,EAAK,KAAK,CAAC,CAAA;AAClD,EAAA,IAAI,QAAA;AACJ,EAAA,IAAI,MAAA,CAAO,QAAA,CAAS,IAAI,CAAA,EAAG;AACzB,IAAA,QAAA,GAAA,CAAY,UAAU,GAAA,IAAO,IAAA;AAAA,EAC/B,CAAA,MAAO;AAGL,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,GAAA,CAAI,GAAG,CAAA,EAAG,IAAA,CAAK,GAAA,CAAI,GAAG,CAAC,CAAA;AACnD,IAAA,QAAA,GAAA,CAAY,UAAU,KAAA,GAAQ,GAAA,GAAM,KAAA,KAAU,GAAA,GAAM,QAAQ,GAAA,GAAM,KAAA,CAAA;AAAA,EACpE;AAEA,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,QAAQ,GAAG,OAAO,CAAA;AACvC,EAAA,OAAO,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,GAAA,CAAI,CAAA,EAAG,QAAQ,CAAC,CAAA;AAC1C;;;ACnCA,IAAM,iBAAA,GAAoB,2BAAA;AAuCnB,IAAM,gBAAA,GAAN,cAA+B,UAAA,CAAwB;AAAA,EAC5D,OAAgB,OAAA,GAAU,CAAC,OAAA,EAAS,OAAO,CAAA;AAAA,EAC3C,OAAgB,MAAA,GAAS;AAAA,IACvB,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IAChC,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,GAAA,EAAI;AAAA,IAClC,IAAA,EAAM,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IACjC,KAAA,EAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IAClC,YAAA,EAAc,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,KAAA;AAAM,GAChD;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,WAAA,EAAa,eAAe,CAAA;AAAA,EAC9C,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA;AAAA,EAazB,UAAA,GAAqC,IAAA;AAAA;AAAA,EAGrC,IAAI,SAAA,GAAqB;AACvB,IAAA,OAAO,IAAA,CAAK,iBAAA,IAAqB,KAAA,CAAM,IAAA,CAAK,OAAO,CAAA;AAAA,EACrD;AAAA;AAAA,EAGS,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,UAAU,IAAA,CAAK,UAAA,EAAY,EAAE,MAAA,EAAQ,MAAM,CAAA;AAAA,EAClD;AAAA;AAAA,EAGS,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,YAAY,KAAA,EAAM;AACvB,IAAA,IAAA,CAAK,UAAA,GAAa,IAAA;AAAA,EACpB;AAAA;AAAA,EAGA,UAAU,KAAA,EAA4B;AACpC,IAAA,IAAI,oBAAA,CAAqB,KAAK,CAAA,EAAG;AACjC,IAAA,MAAM,GAAA,GAAM,KAAK,SAAA,GAAY,EAAA;AAC7B,IAAA,IAAI,IAAA,GAAsB,IAAA;AAG1B,IAAA,QAAQ,IAAA,CAAK,YAAY,eAAA,CAAgB,KAAA,CAAM,KAAK,IAAA,CAAK,OAAO,CAAA,GAAI,KAAA,CAAM,GAAA;AAAK,MAC7E,KAAK,YAAA;AAAA,MACL,KAAK,SAAA;AACH,QAAA,IAAA,GAAO,IAAA,CAAK,aAAa,IAAA,CAAK,SAAA;AAC9B,QAAA;AAAA,MACF,KAAK,WAAA;AAAA,MACL,KAAK,WAAA;AACH,QAAA,IAAA,GAAO,IAAA,CAAK,aAAa,IAAA,CAAK,SAAA;AAC9B,QAAA;AAAA,MACF,KAAK,QAAA;AACH,QAAA,IAAA,GAAO,KAAK,UAAA,GAAa,GAAA;AACzB,QAAA;AAAA,MACF,KAAK,UAAA;AACH,QAAA,IAAA,GAAO,KAAK,UAAA,GAAa,GAAA;AACzB,QAAA;AAAA,MACF,KAAK,MAAA;AACH,QAAA,IAAA,GAAO,IAAA,CAAK,QAAA;AACZ,QAAA;AAAA,MACF,KAAK,KAAA;AACH,QAAA,IAAA,GAAO,IAAA,CAAK,QAAA;AACZ,QAAA;AAAA,MACF;AACE,QAAA;AAAA;AAEJ,IAAA,KAAA,CAAM,cAAA,EAAe;AACrB,IAAA,IAAA,CAAK,UAAU,IAAI,CAAA;AAAA,EACrB;AAAA;AAAA,EAGA,cAAc,KAAA,EAA2B;AACvC,IAAA,IAAI,CAAC,KAAK,cAAA,EAAgB;AAC1B,IAAA,KAAA,CAAM,cAAA,EAAe;AAIrB,IAAA,MAAM,WAAW,IAAA,CAAK,SAAA;AACtB,IAAA,IAAA,CAAK,kBAAA,CAAmB,KAAA,CAAM,OAAA,EAAS,QAAQ,CAAA;AAC/C,IAAA,IAAI,IAAA,CAAK,cAAA,EAAgB,IAAA,CAAK,WAAA,CAAY,KAAA,EAAM;AAEhD,IAAA,IAAA,CAAK,YAAY,KAAA,EAAM;AACvB,IAAA,MAAM,KAAA,GAAQ,IAAI,eAAA,EAAgB;AAClC,IAAA,IAAA,CAAK,UAAA,GAAa,KAAA;AAClB,IAAA,MAAM,SAAS,CAAC,IAAA,KAA6B,KAAK,kBAAA,CAAmB,IAAA,CAAK,SAAS,QAAQ,CAAA;AAC3F,IAAA,MAAM,OAAO,MAAY;AACvB,MAAA,KAAA,CAAM,KAAA,EAAM;AACZ,MAAA,IAAA,CAAK,UAAA,GAAa,IAAA;AAAA,IACpB,CAAA;AACA,IAAA,QAAA,CAAS,iBAAiB,aAAA,EAAe,MAAA,EAAQ,EAAE,MAAA,EAAQ,KAAA,CAAM,QAAQ,CAAA;AACzE,IAAA,QAAA,CAAS,iBAAiB,WAAA,EAAa,IAAA,EAAM,EAAE,MAAA,EAAQ,KAAA,CAAM,QAAQ,CAAA;AAGrE,IAAA,QAAA,CAAS,iBAAiB,eAAA,EAAiB,IAAA,EAAM,EAAE,MAAA,EAAQ,KAAA,CAAM,QAAQ,CAAA;AAAA,EAC3E;AAAA;AAAA,EAGA,kBAAA,CAAmB,SAAiB,QAAA,EAAyB;AAC3D,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,WAAA,CAAY,qBAAA,EAAsB;AACpD,IAAA,IAAI,IAAA,CAAK,UAAU,CAAA,EAAG;AACtB,IAAA,MAAM,MAAA,GAAA,CAAU,OAAA,GAAU,IAAA,CAAK,IAAA,IAAQ,IAAA,CAAK,KAAA;AAC5C,IAAA,MAAM,QAAA,GAAW,QAAA,GAAW,CAAA,GAAI,MAAA,GAAS,MAAA;AACzC,IAAA,IAAA,CAAK,UAAU,IAAA,CAAK,QAAA,GAAW,YAAY,IAAA,CAAK,QAAA,GAAW,KAAK,QAAA,CAAS,CAAA;AAAA,EAC3E;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,UAAU,GAAA,EAAa,EAAE,SAAS,KAAA,EAAM,GAA0B,EAAC,EAAS;AAC1E,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,QAAA,EAAU,KAAK,GAAA,CAAI,IAAA,CAAK,QAAA,EAAU,GAAG,CAAC,CAAA;AACpE,IAAA,MAAM,OAAA,GACJ,IAAA,CAAK,SAAA,GAAY,CAAA,GACb,KAAK,KAAA,CAAA,CAAO,OAAA,GAAU,IAAA,CAAK,QAAA,IAAY,KAAK,SAAS,CAAA,GAAI,IAAA,CAAK,SAAA,GAAY,KAAK,QAAA,GAC/E,OAAA;AACN,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,QAAA,EAAU,KAAK,GAAA,CAAI,IAAA,CAAK,QAAA,EAAU,OAAO,CAAC,CAAA;AACtE,IAAA,MAAM,OAAA,GAAU,UAAU,IAAA,CAAK,UAAA;AAC/B,IAAA,IAAA,CAAK,UAAA,GAAa,KAAA;AAElB,IAAA,IAAI,KAAK,cAAA,EAAgB;AACvB,MAAA,IAAA,CAAK,YAAY,YAAA,CAAa,eAAA,EAAiB,MAAA,CAAO,IAAA,CAAK,QAAQ,CAAC,CAAA;AACpE,MAAA,IAAA,CAAK,YAAY,YAAA,CAAa,eAAA,EAAiB,MAAA,CAAO,IAAA,CAAK,QAAQ,CAAC,CAAA;AACpE,MAAA,IAAA,CAAK,WAAA,CAAY,YAAA,CAAa,eAAA,EAAiB,MAAA,CAAO,KAAK,CAAC,CAAA;AAAA,IAC9D;AAEA,IAAA,MAAM,WAAW,aAAA,CAAc,KAAA,EAAO,IAAA,CAAK,QAAA,EAAU,KAAK,QAAQ,CAAA;AAClE,IAAA,IAAA,CAAK,QAAQ,KAAA,CAAM,WAAA,CAAY,iBAAA,EAAmB,MAAA,CAAO,QAAQ,CAAC,CAAA;AAElE,IAAA,IAAI,OAAA,IAAW,CAAC,MAAA,EAAQ,IAAA,CAAK,QAAA,CAAS,QAAA,EAAU,EAAE,MAAA,EAAQ,EAAE,KAAA,EAAM,EAAG,CAAA;AAAA,EACvE;AACF","file":"slider_controller.js","sourcesContent":["/** Normalized scroll position and maximum distance on one logical axis. */\nexport interface LogicalScrollMetrics {\n position: number;\n max: number;\n}\n\n/**\n * Whether horizontal scrolling on `element` follows right-to-left inline flow.\n *\n * Resolved from the **computed** `direction`, so the authoring contract is the\n * usual `dir=\"rtl\"` (or a stylesheet) on the element or any ancestor.\n *\n * Scope: horizontal writing modes. A vertical writing mode (`writing-mode:\n * vertical-rl`) also inverts the horizontal axis, which this check does not\n * model — vertical writing modes are out of scope for the scroll utilities\n * (their consumers describe axes as horizontal/vertical, not inline/block).\n */\nexport function isRtl(element: Element): boolean {\n return window.getComputedStyle(element).direction === \"rtl\";\n}\n\n/**\n * Returns scroll distance from the logical start edge.\n *\n * CSSOM View exposes standards-mode RTL horizontal offsets as `0` at the inline\n * start (right) and increasingly negative values toward the inline end (left).\n * The normalized position is always clamped to `[0, max]`, which also absorbs\n * Safari's elastic overscroll values.\n */\nexport function logicalScrollMetrics(\n element: HTMLElement,\n horizontal: boolean,\n): LogicalScrollMetrics {\n const max = Math.max(\n 0,\n horizontal\n ? element.scrollWidth - element.clientWidth\n : element.scrollHeight - element.clientHeight,\n );\n const raw = horizontal ? element.scrollLeft : element.scrollTop;\n const position = horizontal && isRtl(element) ? -raw : raw;\n return { position: Math.min(max, Math.max(0, position)), max };\n}\n\n/**\n * Converts a logical start/end delta to the physical value accepted by\n * `Element.scrollBy`.\n */\nexport function physicalScrollDelta(\n element: HTMLElement,\n horizontal: boolean,\n logicalDelta: number,\n): number {\n return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;\n}\n","import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n","/**\n * Range normalization shared by the value-bearing controllers (progress, meter,\n * slider, range-slider).\n *\n * Each of them publishes \"where the value sits inside `[min, max]`\" as a CSS\n * custom property the consumer multiplies a track by, and each has to answer the\n * same degenerate cases: empty and inverted ranges cannot express progress, and\n * malformed Number Values can produce `NaN`. Keeping the rule in one place is\n * what makes those answers identical across the four.\n */\n\n/**\n * Fraction of `[min, max]` that `value` occupies, always within `[0, 1]`.\n *\n * `value` is clamped into the range first, so a value outside it reports a full\n * or empty track rather than pushing the fraction past the ends.\n *\n * An empty, inverted, or non-numeric range yields `0`. That is a deliberate\n * floor rather than a computed result: `min === max` would produce `NaN`, and\n * `min > max` would report a full track for a value that is really out of range.\n * Non-finite results are floored for the same reason. These results reach\n * assistive tech, because the same fraction drives the percentage substituted\n * into `aria-valuetext`.\n */\nexport function rangeFraction(value: number, min: number, max: number): number {\n const span = max - min;\n if (!(span > 0)) return 0;\n\n const clamped = Math.min(max, Math.max(min, value));\n let fraction: number;\n if (Number.isFinite(span)) {\n fraction = (clamped - min) / span;\n } else {\n // Scaling preserves the ratio when two finite endpoints straddle zero so\n // widely that their subtraction overflows to Infinity.\n const scale = Math.max(Math.abs(min), Math.abs(max));\n fraction = (clamped / scale - min / scale) / (max / scale - min / scale);\n }\n\n if (!Number.isFinite(fraction)) return 0;\n return Math.min(1, Math.max(0, fraction));\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord, logicalArrowKey } from \"../utils/arrow_step\";\nimport { isRtl } from \"../utils/logical_scroll\";\nimport { rangeFraction } from \"../utils/range\";\n\n/** Name of the CSS custom property exposing the thumb position (0..1). */\nconst FRACTION_PROPERTY = \"--stimeo--slider-fraction\";\n\n/**\n * Headless, accessible slider (single-thumb range) behavior.\n *\n * Markup contract (identifier: `stimeo--slider`):\n * <div data-controller=\"stimeo--slider\"\n * data-stimeo--slider-min-value=\"0\"\n * data-stimeo--slider-max-value=\"100\"\n * data-stimeo--slider-step-value=\"1\"\n * data-stimeo--slider-value-value=\"40\">\n * <div data-stimeo--slider-target=\"track\"\n * data-action=\"pointerdown->stimeo--slider#onPointerDown\">\n * <div data-stimeo--slider-target=\"thumb\" role=\"slider\" tabindex=\"0\"\n * aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"40\"\n * data-action=\"keydown->stimeo--slider#onKeydown\"></div>\n * </div>\n * </div>\n *\n * Implements the WAI-ARIA APG **Slider** pattern. The current value is exposed\n * to assistive tech via `aria-valuenow`/`aria-valuemin`/`aria-valuemax` on the\n * thumb, and to the consumer's CSS via the `--stimeo--slider-fraction` custom\n * property (a number in `[0, 1]`) set on the controller element — the library\n * positions nothing itself.\n *\n * @remarks\n * Behavior only. The consumer owns all layout (e.g. positioning the thumb from\n * the fraction). Only the horizontal orientation is handled.\n *\n * Behavior provided:\n * - `ArrowRight`/`ArrowUp` increase and `ArrowLeft`/`ArrowDown` decrease by one\n * step; `Home`/`End` jump to the min/max; `PageUp`/`PageDown` move by ten steps.\n * - Pointer press/drag on the track sets the value from the pointer position.\n *\n * The fraction is a value ratio, not a position, so only the consumer knows\n * whether their track mirrors under RTL. Set `logicalTrack` to declare that it\n * does: the pointer mapping and the horizontal arrow pair then follow the\n * writing direction. Left unset, nothing here reads `direction`.\n */\nexport class SliderController extends Controller<HTMLElement> {\n static override targets = [\"track\", \"thumb\"];\n static override values = {\n min: { type: Number, default: 0 },\n max: { type: Number, default: 100 },\n step: { type: Number, default: 1 },\n value: { type: Number, default: 0 },\n logicalTrack: { type: Boolean, default: false },\n };\n static actions = [\"onKeydown\", \"onPointerDown\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly trackTarget: HTMLElement;\n declare readonly thumbTarget: HTMLElement;\n declare readonly hasTrackTarget: boolean;\n declare readonly hasThumbTarget: boolean;\n declare minValue: number;\n declare maxValue: number;\n declare stepValue: number;\n declare valueValue: number;\n declare logicalTrackValue: boolean;\n\n /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */\n #dragAbort: AbortController | null = null;\n\n /** Whether the consumer declared a mirroring track and the direction mirrors it. */\n get #mirrored(): boolean {\n return this.logicalTrackValue && isRtl(this.element);\n }\n\n /** Clamps the initial value and renders the starting position. */\n override connect(): void {\n this.#setValue(this.valueValue, { silent: true });\n }\n\n /** Cancels any active pointer drag so document listeners never leak. */\n override disconnect(): void {\n this.#dragAbort?.abort();\n this.#dragAbort = null;\n }\n\n /** Handles keyboard stepping per the APG slider model. */\n onKeydown(event: KeyboardEvent): void {\n if (isReservedArrowChord(event)) return;\n const big = this.stepValue * 10;\n let next: number | null = null;\n // On a mirrored track the greater value sits at the visual left, so the\n // horizontal pair trades places; the vertical pair passes through.\n switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {\n case \"ArrowRight\":\n case \"ArrowUp\":\n next = this.valueValue + this.stepValue;\n break;\n case \"ArrowLeft\":\n case \"ArrowDown\":\n next = this.valueValue - this.stepValue;\n break;\n case \"PageUp\":\n next = this.valueValue + big;\n break;\n case \"PageDown\":\n next = this.valueValue - big;\n break;\n case \"Home\":\n next = this.minValue;\n break;\n case \"End\":\n next = this.maxValue;\n break;\n default:\n return;\n }\n event.preventDefault();\n this.#setValue(next);\n }\n\n /** Begins a pointer drag: sets the value and tracks subsequent movement. */\n onPointerDown(event: PointerEvent): void {\n if (!this.hasTrackTarget) return;\n event.preventDefault();\n // Resolve the direction once for the whole gesture: reading it per move\n // would query computed style on every frame, and a drag that flipped\n // mid-gesture would be incoherent anyway.\n const mirrored = this.#mirrored;\n this.#updateFromClientX(event.clientX, mirrored);\n if (this.hasThumbTarget) this.thumbTarget.focus();\n\n this.#dragAbort?.abort();\n const abort = new AbortController();\n this.#dragAbort = abort;\n const onMove = (move: PointerEvent): void => this.#updateFromClientX(move.clientX, mirrored);\n const onUp = (): void => {\n abort.abort();\n this.#dragAbort = null;\n };\n document.addEventListener(\"pointermove\", onMove, { signal: abort.signal });\n document.addEventListener(\"pointerup\", onUp, { signal: abort.signal });\n // pointercancel fires when the gesture is interrupted (OS gesture, scroll\n // takeover, device switch); clean up the same way so no listener leaks.\n document.addEventListener(\"pointercancel\", onUp, { signal: abort.signal });\n }\n\n /** Maps a pointer X coordinate to a value using the track's geometry. */\n #updateFromClientX(clientX: number, mirrored: boolean): void {\n const rect = this.trackTarget.getBoundingClientRect();\n if (rect.width === 0) return;\n const offset = (clientX - rect.left) / rect.width;\n const fraction = mirrored ? 1 - offset : offset;\n this.#setValue(this.minValue + fraction * (this.maxValue - this.minValue));\n }\n\n /**\n * Clamps `raw` to `[min, max]`, snaps it to the nearest step, stores it, and\n * reflects the new state on the thumb's ARIA attributes and the fraction\n * custom property. Dispatches `change` (detail `{ value }`) on a real value\n * change — symmetric with `range-slider` — unless `silent` (the initial\n * connect render, which is not a user edit).\n */\n #setValue(raw: number, { silent = false }: { silent?: boolean } = {}): void {\n const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));\n const stepped =\n this.stepValue > 0\n ? Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue\n : clamped;\n const value = Math.min(this.maxValue, Math.max(this.minValue, stepped));\n const changed = value !== this.valueValue;\n this.valueValue = value;\n\n if (this.hasThumbTarget) {\n this.thumbTarget.setAttribute(\"aria-valuemin\", String(this.minValue));\n this.thumbTarget.setAttribute(\"aria-valuemax\", String(this.maxValue));\n this.thumbTarget.setAttribute(\"aria-valuenow\", String(value));\n }\n\n const fraction = rangeFraction(value, this.minValue, this.maxValue);\n this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));\n\n if (changed && !silent) this.dispatch(\"change\", { detail: { value } });\n }\n}\n"]}
@@ -8,6 +8,7 @@ import { Controller } from '@hotwired/stimulus';
8
8
  * <div data-controller="stimeo--spinner"
9
9
  * data-stimeo--spinner-delay-value="150"
10
10
  * data-stimeo--spinner-min-duration-value="500"
11
+ * data-stimeo--spinner-timeout-value="0"
11
12
  * data-action="loading:start->stimeo--spinner#start
12
13
  * loading:stop->stimeo--spinner#stop">
13
14
  * <div role="status" aria-live="polite" hidden
@@ -21,17 +22,33 @@ import { Controller } from '@hotwired/stimulus';
21
22
  * alone) so screen readers announce loading; the controlled `region` mirrors the
22
23
  * busy state via `aria-busy`. Two timers tame flicker: `delay` suppresses the
23
24
  * spinner for fast operations, and `minDuration` keeps it visible long enough to
24
- * be perceived once shown.
25
+ * be perceived once shown. `timeout` is the opt-in safety net for the case the
26
+ * consumer's `stop` never arrives.
27
+ *
28
+ * Events (all with an empty `detail`):
29
+ * - `stimeo--spinner:show` — the indicator became visible, after `delay`.
30
+ * - `stimeo--spinner:hide` — the indicator went away, after `minDuration`.
31
+ * - `stimeo--spinner:timeout` — `timeout` elapsed with the load still running;
32
+ * the controller then ends it exactly as `stop` would, so a `hide` follows.
25
33
  *
26
34
  * @remarks
27
35
  * Behavior only — the visual spinner is the consumer's, alongside the text and
28
- * `aria-hidden="true"`. Both timers are owned by {@link SafeTimeout} and torn
29
- * down on `disconnect()` (Turbo navigation included).
36
+ * `aria-hidden="true"`. Both timers are owned by {@link SafeTimeout}, kept across
37
+ * an in-page move and dropped on a real detach via {@link DetachGate}, while the
38
+ * loading state a cached page would freeze is rewound by {@link BeforeCacheReset}.
30
39
  */
31
40
  declare class SpinnerController extends Controller<HTMLElement> {
32
41
  #private;
33
42
  static targets: string[];
34
43
  static values: {
44
+ announceText: {
45
+ type: StringConstructor;
46
+ default: string;
47
+ };
48
+ announceReadyText: {
49
+ type: StringConstructor;
50
+ default: string;
51
+ };
35
52
  delay: {
36
53
  type: NumberConstructor;
37
54
  default: number;
@@ -40,9 +57,13 @@ declare class SpinnerController extends Controller<HTMLElement> {
40
57
  type: NumberConstructor;
41
58
  default: number;
42
59
  };
60
+ timeout: {
61
+ type: NumberConstructor;
62
+ default: number;
63
+ };
43
64
  };
44
65
  static actions: readonly ["start", "stop"];
45
- static events: readonly ["hide", "show"];
66
+ static events: readonly ["hide", "show", "timeout"];
46
67
  readonly indicatorTarget: HTMLElement;
47
68
  readonly regionTarget: HTMLElement;
48
69
  readonly messageTarget: HTMLElement;
@@ -51,7 +72,19 @@ declare class SpinnerController extends Controller<HTMLElement> {
51
72
  readonly hasMessageTarget: boolean;
52
73
  delayValue: number;
53
74
  minDurationValue: number;
75
+ timeoutValue: number;
76
+ announceTextValue: string;
77
+ announceReadyTextValue: string;
54
78
  connect(): void;
79
+ /**
80
+ * Re-applies the current phase to an indicator that arrived after `connect()`.
81
+ *
82
+ * A Turbo Stream can swap the indicator for a fresh node mid-load, and that node
83
+ * carries the markup contract's `hidden`. Without this the spinner would vanish
84
+ * while `data-state` still says `loading`, and nothing but the next cycle would
85
+ * bring it back.
86
+ */
87
+ indicatorTargetConnected(target: HTMLElement): void;
55
88
  disconnect(): void;
56
89
  /** Begins loading. Honors `delay` before the spinner actually appears. */
57
90
  start(): void;
@@ -2,6 +2,145 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/spinner_controller.ts
4
4
 
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+ function fillTemplate(template, values) {
16
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
+ const replacement = values[name];
18
+ return replacement === void 0 ? match : String(replacement);
19
+ });
20
+ }
21
+
22
+ // src/utils/before_cache_reset.ts
23
+ var BeforeCacheReset = class _BeforeCacheReset {
24
+ /** Every subscribed instance, iterated by the one shared document listener. */
25
+ static #subscribers = /* @__PURE__ */ new Set();
26
+ /** The shared listener; installed while at least one instance is subscribed. */
27
+ static #onBeforeCache = () => {
28
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
29
+ };
30
+ #rewind;
31
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
32
+ constructor(rewind) {
33
+ this.#rewind = rewind;
34
+ }
35
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
36
+ activate() {
37
+ const first = _BeforeCacheReset.#subscribers.size === 0;
38
+ _BeforeCacheReset.#subscribers.add(this);
39
+ if (first) {
40
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
41
+ }
42
+ }
43
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
44
+ deactivate() {
45
+ _BeforeCacheReset.#subscribers.delete(this);
46
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
47
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
48
+ }
49
+ };
50
+
51
+ // src/utils/detach_gate.ts
52
+ var DetachGate = class _DetachGate {
53
+ /** Set while a probe is queued, waiting for a reconnect to cancel it. */
54
+ #pending = false;
55
+ /**
56
+ * True when the disconnect is definitely a real detach — the element left
57
+ * the document, or `data-controller` no longer lists the identifier. False
58
+ * means ambiguous (in-page move or observed-root exit), NOT "alive".
59
+ */
60
+ static isDetached(host) {
61
+ if (!host.element.isConnected) return true;
62
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
63
+ return !tokens.includes(host.identifier);
64
+ }
65
+ /**
66
+ * Call from `disconnect()`: runs `teardown` synchronously on a definite
67
+ * detach (fast path), otherwise defers it one microtask — a reconnect
68
+ * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
69
+ * One microtask is the whole probe window: Stimulus reconnects a moved
70
+ * element within the same mutation batch, before the checkpoint drains.
71
+ */
72
+ disconnected(host, teardown) {
73
+ if (_DetachGate.isDetached(host)) {
74
+ this.#pending = false;
75
+ teardown();
76
+ return;
77
+ }
78
+ this.#pending = true;
79
+ queueMicrotask(() => {
80
+ if (!this.#pending) return;
81
+ this.#pending = false;
82
+ teardown();
83
+ });
84
+ }
85
+ /**
86
+ * Disarms a pending probe. Call from `connect()` (the reconnect that proves
87
+ * an in-page move) and from the head of any teardown path not routed through
88
+ * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
89
+ * never run the teardown a second time.
90
+ */
91
+ cancel() {
92
+ this.#pending = false;
93
+ }
94
+ };
95
+
96
+ // src/utils/min_duration_floor.ts
97
+ var MinDurationFloor = class {
98
+ #timers;
99
+ /** Pending finish timer id, or `null` when nothing is held back. */
100
+ #timerId = null;
101
+ /** Epoch ms the floor is measured from. */
102
+ #since = 0;
103
+ /** @param timers - the controller's registry; the floor schedules into it. */
104
+ constructor(timers) {
105
+ this.#timers = timers;
106
+ }
107
+ /** Starts the floor: call when the state being held becomes visible. */
108
+ begin() {
109
+ this.#since = Date.now();
110
+ }
111
+ /** True while a finish is held back waiting for the floor to elapse. */
112
+ get pending() {
113
+ return this.#timerId !== null;
114
+ }
115
+ /**
116
+ * Runs `finish` once the floor has elapsed, immediately when it already has.
117
+ *
118
+ * A held-back finish is **replaced**, never stacked: only the most recently
119
+ * queued id is cancellable, so a second timer would outlive every cancel and
120
+ * end a state that has since restarted. Controllers that want the first signal
121
+ * to win guard on {@link pending} before calling.
122
+ */
123
+ schedule(minDuration, finish) {
124
+ this.cancel();
125
+ const remaining = minDuration - (Date.now() - this.#since);
126
+ if (remaining > 0) {
127
+ this.#timerId = this.#timers.set(() => {
128
+ this.#timerId = null;
129
+ finish();
130
+ }, remaining);
131
+ } else {
132
+ finish();
133
+ }
134
+ }
135
+ /** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
136
+ cancel() {
137
+ if (this.#timerId !== null) {
138
+ this.#timers.clear(this.#timerId);
139
+ this.#timerId = null;
140
+ }
141
+ }
142
+ };
143
+
5
144
  // src/utils/safe_timeout.ts
6
145
  var TimerRegistry = class {
7
146
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -59,38 +198,61 @@ var SafeTimeout = class extends TimerRegistry {
59
198
  var SpinnerController = class extends Controller {
60
199
  static targets = ["indicator", "region", "message"];
61
200
  static values = {
201
+ announceText: { type: String, default: "" },
202
+ announceReadyText: { type: String, default: "" },
62
203
  delay: { type: Number, default: 0 },
63
- minDuration: { type: Number, default: 0 }
204
+ minDuration: { type: Number, default: 0 },
205
+ timeout: { type: Number, default: 0 }
64
206
  };
65
207
  static actions = ["start", "stop"];
66
- static events = ["hide", "show"];
208
+ static events = ["hide", "show", "timeout"];
67
209
  #timers = new SafeTimeout();
210
+ #floor = new MinDurationFloor(this.#timers);
211
+ #gate = new DetachGate();
212
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
68
213
  /** Pending show-delay timer id, or `null` when no start is awaiting its delay. */
69
214
  #delayTimerId = null;
70
- /** Pending min-duration hide timer id, or `null` when none is scheduled. */
71
- #hideTimerId = null;
72
- /** Epoch ms when the spinner became visible; `minDuration` is measured from it. */
73
- #shownAt = 0;
215
+ /** Pending safety-net timer id, or `null` when `timeout` is off or not armed. */
216
+ #timeoutTimerId = null;
74
217
  connect() {
218
+ this.#gate.cancel();
219
+ this.#beforeCache.activate();
220
+ if (this.#state === "pending" && this.#delayTimerId === null) {
221
+ this.#setBusy(false);
222
+ this.element.setAttribute("data-state", "idle");
223
+ return;
224
+ }
75
225
  if (!this.element.hasAttribute("data-state")) {
76
226
  this.element.setAttribute("data-state", "idle");
77
227
  }
78
228
  }
229
+ /**
230
+ * Re-applies the current phase to an indicator that arrived after `connect()`.
231
+ *
232
+ * A Turbo Stream can swap the indicator for a fresh node mid-load, and that node
233
+ * carries the markup contract's `hidden`. Without this the spinner would vanish
234
+ * while `data-state` still says `loading`, and nothing but the next cycle would
235
+ * bring it back.
236
+ */
237
+ indicatorTargetConnected(target) {
238
+ target.hidden = this.#state !== "loading";
239
+ }
79
240
  disconnect() {
80
- this.#timers.clearAll();
81
- this.#delayTimerId = null;
82
- this.#hideTimerId = null;
241
+ this.#beforeCache.deactivate();
242
+ this.#gate.disconnected(this, () => this.#teardown());
83
243
  }
84
244
  /** Begins loading. Honors `delay` before the spinner actually appears. */
85
245
  start() {
86
246
  if (this.#state === "loading") {
87
247
  this.#setBusy(true);
88
- this.#cancelHide();
248
+ this.#floor.cancel();
249
+ this.#armTimeout();
89
250
  return;
90
251
  }
91
252
  if (this.#state !== "idle") return;
92
253
  this.#setBusy(true);
93
- this.#cancelHide();
254
+ this.#floor.cancel();
255
+ this.#armTimeout();
94
256
  if (this.delayValue > 0) {
95
257
  this.element.setAttribute("data-state", "pending");
96
258
  this.#delayTimerId = this.#timers.set(() => {
@@ -104,6 +266,7 @@ var SpinnerController = class extends Controller {
104
266
  /** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */
105
267
  stop() {
106
268
  const state = this.#state;
269
+ this.#cancelTimeout();
107
270
  if (state === "pending") {
108
271
  this.#cancelDelay();
109
272
  this.#setBusy(false);
@@ -112,28 +275,52 @@ var SpinnerController = class extends Controller {
112
275
  }
113
276
  if (state !== "loading") return;
114
277
  this.#setBusy(false);
115
- const remaining = this.minDurationValue - (Date.now() - this.#shownAt);
116
- if (remaining > 0) {
117
- this.#hideTimerId = this.#timers.set(() => {
118
- this.#hideTimerId = null;
119
- this.#hide();
120
- }, remaining);
121
- } else {
122
- this.#hide();
123
- }
278
+ this.#floor.schedule(this.minDurationValue, () => this.#hide());
124
279
  }
125
280
  /** Reveals the indicator, marks the moment shown, and announces via the live region. */
126
281
  #show() {
127
- this.#shownAt = Date.now();
282
+ this.#floor.begin();
283
+ this.#setBusy(true);
128
284
  if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;
129
285
  this.element.setAttribute("data-state", "loading");
130
286
  this.dispatch("show", { detail: {} });
287
+ announce(fillTemplate(this.announceTextValue, {}));
131
288
  }
132
289
  /** Hides the indicator and returns to the idle state. */
133
290
  #hide() {
134
291
  if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
135
292
  this.element.setAttribute("data-state", "idle");
136
293
  this.dispatch("hide", { detail: {} });
294
+ announce(fillTemplate(this.announceReadyTextValue, {}));
295
+ }
296
+ /**
297
+ * Drops both timers on a real detach. The markup keeps whatever it last held: an
298
+ * element on its way out of the document has no reader left, and one whose
299
+ * `data-controller` dropped the identifier no longer resolves its own targets, so
300
+ * the rollback could only ever be partial. The snapshot is rewound where it is
301
+ * still whole, on `turbo:before-cache`.
302
+ */
303
+ #teardown() {
304
+ this.#gate.cancel();
305
+ this.#timers.clearAll();
306
+ this.#delayTimerId = null;
307
+ this.#timeoutTimerId = null;
308
+ this.#floor.cancel();
309
+ }
310
+ /**
311
+ * Returns the loading state to idle for the snapshot Turbo is about to take,
312
+ * so a page reached with the Back button is not restored mid-load with a
313
+ * spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
314
+ * and `aria-busy`. No `hide` is dispatched — the load was never observed to
315
+ * finish, and a snapshot rewind is not a lifecycle event the consumer can act
316
+ * on. The live page keeps its timers, so a navigation that never completes
317
+ * leaves the running cycle intact.
318
+ */
319
+ #rewindForCache() {
320
+ this.#cancelTimeout();
321
+ this.#setBusy(false);
322
+ if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
323
+ this.element.setAttribute("data-state", "idle");
137
324
  }
138
325
  /** Reflects busy state onto the controlled region (if present). */
139
326
  #setBusy(busy) {
@@ -141,18 +328,32 @@ var SpinnerController = class extends Controller {
141
328
  this.regionTarget.setAttribute("aria-busy", String(busy));
142
329
  }
143
330
  }
331
+ /**
332
+ * Arms the safety net so a `stop` that never arrives cannot strand the spinner.
333
+ * Off by default: the consumer owns the async work, so only it knows whether a
334
+ * ceiling makes sense. Re-arming on a restart measures from the newest start.
335
+ */
336
+ #armTimeout() {
337
+ this.#cancelTimeout();
338
+ if (this.timeoutValue <= 0) return;
339
+ this.#timeoutTimerId = this.#timers.set(() => {
340
+ this.#timeoutTimerId = null;
341
+ this.dispatch("timeout", { detail: {} });
342
+ this.stop();
343
+ }, this.timeoutValue);
344
+ }
345
+ #cancelTimeout() {
346
+ if (this.#timeoutTimerId !== null) {
347
+ this.#timers.clear(this.#timeoutTimerId);
348
+ this.#timeoutTimerId = null;
349
+ }
350
+ }
144
351
  #cancelDelay() {
145
352
  if (this.#delayTimerId !== null) {
146
353
  this.#timers.clear(this.#delayTimerId);
147
354
  this.#delayTimerId = null;
148
355
  }
149
356
  }
150
- #cancelHide() {
151
- if (this.#hideTimerId !== null) {
152
- this.#timers.clear(this.#hideTimerId);
153
- this.#hideTimerId = null;
154
- }
155
- }
156
357
  /** Current lifecycle phase as reflected on `data-state`. */
157
358
  get #state() {
158
359
  return this.element.getAttribute("data-state") ?? "idle";