stimeo-ui 0.4.0 → 0.5.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 (123) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/controllers/announcer_controller.js.map +1 -1
  3. package/dist/controllers/aspect_ratio_controller.d.ts +2 -2
  4. package/dist/controllers/aspect_ratio_controller.js +1 -1
  5. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  6. package/dist/controllers/breadcrumb_controller.js +5 -1
  7. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  8. package/dist/controllers/carousel_controller.js +5 -1
  9. package/dist/controllers/carousel_controller.js.map +1 -1
  10. package/dist/controllers/clipboard_controller.js +8 -3
  11. package/dist/controllers/clipboard_controller.js.map +1 -1
  12. package/dist/controllers/collapsible_controller.d.ts +1 -1
  13. package/dist/controllers/collapsible_controller.js +4 -1
  14. package/dist/controllers/collapsible_controller.js.map +1 -1
  15. package/dist/controllers/color_picker_controller.d.ts +2 -2
  16. package/dist/controllers/color_picker_controller.js +6 -2
  17. package/dist/controllers/color_picker_controller.js.map +1 -1
  18. package/dist/controllers/combobox_controller.js.map +1 -1
  19. package/dist/controllers/command_palette_controller.js.map +1 -1
  20. package/dist/controllers/context_menu_controller.d.ts +1 -1
  21. package/dist/controllers/context_menu_controller.js +2 -2
  22. package/dist/controllers/context_menu_controller.js.map +1 -1
  23. package/dist/controllers/countdown_controller.d.ts +5 -3
  24. package/dist/controllers/countdown_controller.js +5 -1
  25. package/dist/controllers/countdown_controller.js.map +1 -1
  26. package/dist/controllers/date_range_picker_controller.js +5 -1
  27. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  28. package/dist/controllers/direct_upload_controller.d.ts +1 -1
  29. package/dist/controllers/direct_upload_controller.js +3 -3
  30. package/dist/controllers/direct_upload_controller.js.map +1 -1
  31. package/dist/controllers/empty_state_controller.d.ts +27 -7
  32. package/dist/controllers/empty_state_controller.js +107 -16
  33. package/dist/controllers/empty_state_controller.js.map +1 -1
  34. package/dist/controllers/flash_controller.d.ts +25 -5
  35. package/dist/controllers/flash_controller.js +161 -21
  36. package/dist/controllers/flash_controller.js.map +1 -1
  37. package/dist/controllers/form_validation_controller.js +8 -2
  38. package/dist/controllers/form_validation_controller.js.map +1 -1
  39. package/dist/controllers/frame_loading_controller.d.ts +19 -2
  40. package/dist/controllers/frame_loading_controller.js +94 -22
  41. package/dist/controllers/frame_loading_controller.js.map +1 -1
  42. package/dist/controllers/highlight_controller.d.ts +8 -4
  43. package/dist/controllers/highlight_controller.js +38 -1
  44. package/dist/controllers/highlight_controller.js.map +1 -1
  45. package/dist/controllers/idle_controller.d.ts +2 -1
  46. package/dist/controllers/idle_controller.js +13 -2
  47. package/dist/controllers/idle_controller.js.map +1 -1
  48. package/dist/controllers/listbox_controller.js.map +1 -1
  49. package/dist/controllers/local_time_controller.js +2 -0
  50. package/dist/controllers/local_time_controller.js.map +1 -1
  51. package/dist/controllers/masonry_controller.d.ts +1 -1
  52. package/dist/controllers/masonry_controller.js +1 -1
  53. package/dist/controllers/masonry_controller.js.map +1 -1
  54. package/dist/controllers/meter_controller.js +3 -1
  55. package/dist/controllers/meter_controller.js.map +1 -1
  56. package/dist/controllers/multi_select_controller.js.map +1 -1
  57. package/dist/controllers/network_status_controller.d.ts +15 -13
  58. package/dist/controllers/network_status_controller.js +1 -3
  59. package/dist/controllers/network_status_controller.js.map +1 -1
  60. package/dist/controllers/number_input_controller.d.ts +8 -0
  61. package/dist/controllers/number_input_controller.js +191 -24
  62. package/dist/controllers/number_input_controller.js.map +1 -1
  63. package/dist/controllers/overflow_menu_controller.js +1 -1
  64. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  65. package/dist/controllers/pagination_controller.js +5 -1
  66. package/dist/controllers/pagination_controller.js.map +1 -1
  67. package/dist/controllers/password_strength_controller.d.ts +1 -1
  68. package/dist/controllers/password_strength_controller.js +1 -1
  69. package/dist/controllers/password_strength_controller.js.map +1 -1
  70. package/dist/controllers/pointer_drag_controller.js +10 -0
  71. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  72. package/dist/controllers/portal_controller.js +10 -0
  73. package/dist/controllers/portal_controller.js.map +1 -1
  74. package/dist/controllers/progress_controller.d.ts +1 -1
  75. package/dist/controllers/progress_controller.js +7 -3
  76. package/dist/controllers/progress_controller.js.map +1 -1
  77. package/dist/controllers/range_slider_controller.d.ts +23 -0
  78. package/dist/controllers/range_slider_controller.js +385 -94
  79. package/dist/controllers/range_slider_controller.js.map +1 -1
  80. package/dist/controllers/rating_controller.js +2 -0
  81. package/dist/controllers/rating_controller.js.map +1 -1
  82. package/dist/controllers/relative_time_controller.js +2 -0
  83. package/dist/controllers/relative_time_controller.js.map +1 -1
  84. package/dist/controllers/scroll_area_controller.d.ts +1 -1
  85. package/dist/controllers/scroll_area_controller.js +1 -1
  86. package/dist/controllers/scroll_area_controller.js.map +1 -1
  87. package/dist/controllers/separator_controller.js +13 -17
  88. package/dist/controllers/separator_controller.js.map +1 -1
  89. package/dist/controllers/skeleton_controller.d.ts +2 -2
  90. package/dist/controllers/skeleton_controller.js +71 -3
  91. package/dist/controllers/skeleton_controller.js.map +1 -1
  92. package/dist/controllers/slider_controller.d.ts +17 -1
  93. package/dist/controllers/slider_controller.js +325 -48
  94. package/dist/controllers/slider_controller.js.map +1 -1
  95. package/dist/controllers/spinner_controller.d.ts +15 -10
  96. package/dist/controllers/spinner_controller.js +18 -3
  97. package/dist/controllers/spinner_controller.js.map +1 -1
  98. package/dist/controllers/step_indicator_controller.d.ts +1 -1
  99. package/dist/controllers/step_indicator_controller.js +3 -1
  100. package/dist/controllers/step_indicator_controller.js.map +1 -1
  101. package/dist/controllers/stepper_controller.js +2 -0
  102. package/dist/controllers/stepper_controller.js.map +1 -1
  103. package/dist/controllers/switch_controller.d.ts +12 -8
  104. package/dist/controllers/switch_controller.js +162 -18
  105. package/dist/controllers/switch_controller.js.map +1 -1
  106. package/dist/controllers/textarea_autosize_controller.js +1 -1
  107. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  108. package/dist/controllers/time_picker_controller.d.ts +3 -0
  109. package/dist/controllers/time_picker_controller.js +6 -3
  110. package/dist/controllers/time_picker_controller.js.map +1 -1
  111. package/dist/controllers/tree_view_controller.d.ts +1 -2
  112. package/dist/controllers/tree_view_controller.js +19 -1
  113. package/dist/controllers/tree_view_controller.js.map +1 -1
  114. package/dist/index.js +1214 -307
  115. package/dist/index.js.map +1 -1
  116. package/dist/inspector/cli.d.ts +76 -1
  117. package/dist/inspector/cli.js +152 -0
  118. package/dist/inspector/cli.js.map +1 -1
  119. package/dist/inspector/cli_bin.js +212 -2
  120. package/dist/inspector/cli_bin.js.map +1 -1
  121. package/dist/inspector/examples.json +17 -17
  122. package/dist/inspector/manifest.json +408 -26
  123. package/package.json +3 -3
@@ -61,6 +61,7 @@ var SafeTimeout = class extends TimerRegistry {
61
61
  };
62
62
 
63
63
  // src/controllers/highlight_controller.ts
64
+ var hookOwners = /* @__PURE__ */ new WeakMap();
64
65
  var HighlightController = class extends Controller {
65
66
  static values = {
66
67
  duration: { type: Number, default: 1500 },
@@ -68,9 +69,18 @@ var HighlightController = class extends Controller {
68
69
  };
69
70
  static events = ["start", "end"];
70
71
  #timeouts = new SafeTimeout();
72
+ /**
73
+ * The removal timer this connection has outstanding for an element. Held weakly so
74
+ * a row that leaves the DOM is not retained, and dropped wholesale on `disconnect()`
75
+ * so a cleared id can never be matched against a recycled one. Which connection owns
76
+ * an element's hook is answered by the shared owner registry above.
77
+ */
78
+ #pending = /* @__PURE__ */ new WeakMap();
71
79
  #observer = null;
72
80
  connect() {
81
+ this.#clearArrivedHook(this.element);
73
82
  if (this.observeValue) {
83
+ for (const child of this.element.children) this.#clearArrivedHook(child);
74
84
  if (typeof MutationObserver !== "undefined") {
75
85
  this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
76
86
  this.#observer.observe(this.element, { childList: true });
@@ -83,6 +93,12 @@ var HighlightController = class extends Controller {
83
93
  this.#observer?.disconnect();
84
94
  this.#observer = null;
85
95
  this.#timeouts.clearAll();
96
+ this.#pending = /* @__PURE__ */ new WeakMap();
97
+ }
98
+ /** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
99
+ #clearArrivedHook(el) {
100
+ if (hookOwners.get(el) === this) hookOwners.delete(el);
101
+ el.removeAttribute("data-highlight");
86
102
  }
87
103
  /** Highlights every element child added by a childList mutation. */
88
104
  #onMutations(mutations) {
@@ -95,12 +111,33 @@ var HighlightController = class extends Controller {
95
111
  /** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
96
112
  #highlight(el) {
97
113
  if (prefersReducedMotion()) return;
114
+ this.#releasePending(el);
98
115
  el.setAttribute("data-highlight", "true");
99
116
  this.dispatch("start", { target: el, detail: { element: el } });
100
- this.#timeouts.set(() => {
117
+ const id = this.#timeouts.set(() => {
118
+ this.#pending.delete(el);
119
+ hookOwners.delete(el);
101
120
  el.removeAttribute("data-highlight");
102
121
  this.dispatch("end", { target: el, detail: { element: el } });
103
122
  }, this.durationValue);
123
+ this.#pending.set(el, id);
124
+ hookOwners.set(el, this);
125
+ }
126
+ /**
127
+ * Releases whichever removal timer holds `el`'s hook. The row may have been
128
+ * highlighted inside a different watched container before it moved here, and that
129
+ * container's timer is reachable only through the shared owner registry.
130
+ */
131
+ #releasePending(el) {
132
+ const owner = hookOwners.get(el);
133
+ if (owner !== void 0 && owner !== this) owner.#cancelPending(el);
134
+ this.#cancelPending(el);
135
+ }
136
+ /** Releases `el`'s pending removal timer, if it has one. */
137
+ #cancelPending(el) {
138
+ this.#timeouts.clear(this.#pending.get(el) ?? -1);
139
+ this.#pending.delete(el);
140
+ hookOwners.delete(el);
104
141
  }
105
142
  };
106
143
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/reduced_motion.ts","../../src/utils/safe_timeout.ts","../../src/controllers/highlight_controller.ts"],"names":[],"mappings":"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","file":"highlight_controller.js","sourcesContent":["/**\n * Shared `prefers-reduced-motion` lookup for the motion-aware controllers\n * (count-up, highlight, overflow-indicator, scroll-visibility, stick-to-bottom,\n * transition).\n *\n * This one-liner keeps the media query string and the environment guard\n * single-sourced across them. The preference is intentionally re-read on every\n * call — the controllers check it at each animation/scroll start (WCAG 2.2\n * **2.3.3**), so flipping the OS setting takes effect immediately without any\n * listener or cache bookkeeping here.\n */\n\n/**\n * Whether the user currently requests reduced motion.\n *\n * @returns `true` when `(prefers-reduced-motion: reduce)` matches; `false`\n * otherwise, including environments without `window.matchMedia` (treated as\n * \"no preference\").\n */\nexport function prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { prefersReducedMotion } from \"../utils/reduced_motion\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless \"highlight on insert\" behavior: briefly flags a freshly inserted element\n * with `data-highlight` so CSS can flash / fade it in, then removes the flag after\n * `duration` ms (no dedicated APG pattern; a purely visual emphasis that honors the\n * WCAG \"animation from interactions\" practice via `prefers-reduced-motion`).\n *\n * Markup contract (identifier: `stimeo--highlight`):\n * <!-- self-highlight: put it on the inserted row itself -->\n * <li data-controller=\"stimeo--highlight\">New item</li>\n *\n * <!-- container mode: watch for added children and highlight each -->\n * <ul data-controller=\"stimeo--highlight\" data-stimeo--highlight-observe-value=\"true\"></ul>\n *\n * In the default (self) mode the controller element is highlighted once on\n * `connect()`. With `observe`, a `MutationObserver` instead highlights every element\n * child added to the container (e.g. Turbo Stream appends). Each highlight sets\n * `data-highlight=\"true\"`, dispatches `start`, and after `duration` removes it and\n * dispatches `end` — both carry `detail.element`.\n *\n * @remarks\n * Behavior only — it ships no color or animation, just the hook (pair with CSS, and\n * with Announcer / Flash for non-visual notice). Under `prefers-reduced-motion: reduce`\n * the emphasis is suppressed entirely (the element simply appears), so no hook or\n * event is emitted. The self hook is added on `connect()` and removed before its\n * `duration` elapses, so it does not linger into a `turbo:before-cache` snapshot; the\n * observer and pending timers are torn down on `disconnect()` (Turbo navigation\n * included).\n */\nexport class HighlightController extends Controller<HTMLElement> {\n static override values = {\n duration: { type: Number, default: 1500 },\n observe: { type: Boolean, default: false },\n };\n static events = [\"start\", \"end\"] as const;\n\n declare durationValue: number;\n declare observeValue: boolean;\n\n readonly #timeouts = new SafeTimeout();\n #observer: MutationObserver | null = null;\n\n override connect(): void {\n if (this.observeValue) {\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));\n this.#observer.observe(this.element, { childList: true });\n }\n return;\n }\n this.#highlight(this.element);\n }\n\n override disconnect(): void {\n this.#observer?.disconnect();\n this.#observer = null;\n this.#timeouts.clearAll();\n }\n\n /** Highlights every element child added by a childList mutation. */\n #onMutations(mutations: MutationRecord[]): void {\n for (const mutation of mutations) {\n for (const node of mutation.addedNodes) {\n if (node instanceof HTMLElement) this.#highlight(node);\n }\n }\n }\n\n /** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */\n #highlight(el: HTMLElement): void {\n // Reduced motion: suppress the emphasis so the element just appears — no hook,\n // no timer, no events, nothing to transition.\n if (prefersReducedMotion()) return;\n\n el.setAttribute(\"data-highlight\", \"true\");\n this.dispatch(\"start\", { target: el, detail: { element: el } });\n this.#timeouts.set(() => {\n el.removeAttribute(\"data-highlight\");\n this.dispatch(\"end\", { target: el, detail: { element: el } });\n }, this.durationValue);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/reduced_motion.ts","../../src/utils/safe_timeout.ts","../../src/controllers/highlight_controller.ts"],"names":[],"mappings":"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","file":"highlight_controller.js","sourcesContent":["/**\n * Shared `prefers-reduced-motion` lookup for the motion-aware controllers\n * (count-up, highlight, overflow-indicator, scroll-visibility, stick-to-bottom,\n * transition).\n *\n * This one-liner keeps the media query string and the environment guard\n * single-sourced across them. The preference is intentionally re-read on every\n * call — the controllers check it at each animation/scroll start (WCAG 2.2\n * **2.3.3**), so flipping the OS setting takes effect immediately without any\n * listener or cache bookkeeping here.\n */\n\n/**\n * Whether the user currently requests reduced motion.\n *\n * @returns `true` when `(prefers-reduced-motion: reduce)` matches; `false`\n * otherwise, including environments without `window.matchMedia` (treated as\n * \"no preference\").\n */\nexport function prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { prefersReducedMotion } from \"../utils/reduced_motion\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * The connection whose removal timer currently owns an element's `data-highlight`\n * hook. Shared by every instance, so a row moved from one watched container into\n * another still has exactly one timer deciding when its emphasis ends.\n *\n * Only the keys are weak: an entry keeps its controller — and through it that\n * controller's element — reachable for as long as the row is in the DOM. So an entry\n * is dropped as soon as the timer that owns it is released or fires, and `connect()`\n * drops the claims the connection before it left behind.\n */\nconst hookOwners = new WeakMap<Element, HighlightController>();\n\n/**\n * Headless \"highlight on insert\" behavior: briefly flags a freshly inserted element\n * with `data-highlight` so CSS can flash / fade it in, then removes the flag after\n * `duration` ms (no dedicated APG pattern; a purely visual emphasis that honors the\n * WCAG \"animation from interactions\" practice via `prefers-reduced-motion`).\n *\n * Markup contract (identifier: `stimeo--highlight`):\n * <!-- self-highlight: put it on the inserted row itself -->\n * <li data-controller=\"stimeo--highlight\">New item</li>\n *\n * <!-- container mode: watch for added children and highlight each -->\n * <ul data-controller=\"stimeo--highlight\" data-stimeo--highlight-observe-value=\"true\"></ul>\n *\n * In the default (self) mode the controller element is highlighted once on\n * `connect()`. With `observe`, a `MutationObserver` instead highlights every element\n * child added to the container (e.g. Turbo Stream appends). Each highlight sets\n * `data-highlight=\"true\"`, dispatches `start`, and after `duration` removes it and\n * dispatches `end` — both carry `detail.element`.\n *\n * @remarks\n * Behavior only — it ships no color or animation, just the hook (pair with CSS, and\n * with Announcer / Flash for non-visual notice). Under `prefers-reduced-motion: reduce`\n * the emphasis is suppressed entirely (the element simply appears), so no hook or\n * event is emitted. A hook never outlives the connection that set it: the observer and\n * pending timers are torn down on `disconnect()` (Turbo navigation included), and\n * `connect()` clears any hook that arrived with the DOM — a restored `turbo:before-cache`\n * snapshot, an in-page move — because the timer that would have removed it is gone.\n * An element carries at most one emphasis at a time: highlighting it again — a reorder\n * inside one container, or a move into another watched one — takes the hook over and\n * releases the timer that held it, so `duration` is measured from the latest highlight\n * and `end` fires once per emphasis.\n */\nexport class HighlightController extends Controller<HTMLElement> {\n static override values = {\n duration: { type: Number, default: 1500 },\n observe: { type: Boolean, default: false },\n };\n static events = [\"start\", \"end\"] as const;\n\n declare durationValue: number;\n declare observeValue: boolean;\n\n readonly #timeouts = new SafeTimeout();\n /**\n * The removal timer this connection has outstanding for an element. Held weakly so\n * a row that leaves the DOM is not retained, and dropped wholesale on `disconnect()`\n * so a cleared id can never be matched against a recycled one. Which connection owns\n * an element's hook is answered by the shared owner registry above.\n */\n #pending = new WeakMap<HTMLElement, number>();\n #observer: MutationObserver | null = null;\n\n override connect(): void {\n // A hook that arrived with the DOM was written by an earlier connection, whose\n // removal timer is gone; this connection owns no timer for it either, so nothing\n // would ever take it off. Drop it before anything else — in self mode\n // `#highlight()` re-adds it with a fresh timer below, except under reduced\n // motion, where the element must carry no hook at all.\n this.#clearArrivedHook(this.element);\n if (this.observeValue) {\n // A container highlights its children, never itself, so the same reasoning\n // applies to every child present before the observer starts watching.\n for (const child of this.element.children) this.#clearArrivedHook(child);\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));\n this.#observer.observe(this.element, { childList: true });\n }\n return;\n }\n this.#highlight(this.element);\n }\n\n override disconnect(): void {\n this.#observer?.disconnect();\n this.#observer = null;\n this.#timeouts.clearAll();\n this.#pending = new WeakMap();\n }\n\n /** Drops a hook that arrived with the DOM, along with this connection's claim on it. */\n #clearArrivedHook(el: Element): void {\n // Another connection may still hold a live timer for this element, and that timer\n // has to stay reachable so the next highlight can release it. Only our own claim,\n // whose timer went down with the previous disconnect, is dropped here.\n if (hookOwners.get(el) === this) hookOwners.delete(el);\n el.removeAttribute(\"data-highlight\");\n }\n\n /** Highlights every element child added by a childList mutation. */\n #onMutations(mutations: MutationRecord[]): void {\n for (const mutation of mutations) {\n for (const node of mutation.addedNodes) {\n if (node instanceof HTMLElement) this.#highlight(node);\n }\n }\n }\n\n /** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */\n #highlight(el: HTMLElement): void {\n // Reduced motion: suppress the emphasis so the element just appears — no hook,\n // no timer, no events, nothing to transition.\n if (prefersReducedMotion()) return;\n\n // A second highlight of the same element — a re-insert, which is the shape a\n // reorder takes — owns the hook from here on. Release the first timer, or it\n // ends the new emphasis early and then reports an `end` for a hook already gone.\n this.#releasePending(el);\n el.setAttribute(\"data-highlight\", \"true\");\n this.dispatch(\"start\", { target: el, detail: { element: el } });\n const id = this.#timeouts.set(() => {\n this.#pending.delete(el);\n hookOwners.delete(el);\n el.removeAttribute(\"data-highlight\");\n this.dispatch(\"end\", { target: el, detail: { element: el } });\n }, this.durationValue);\n this.#pending.set(el, id);\n hookOwners.set(el, this);\n }\n\n /**\n * Releases whichever removal timer holds `el`'s hook. The row may have been\n * highlighted inside a different watched container before it moved here, and that\n * container's timer is reachable only through the shared owner registry.\n */\n #releasePending(el: HTMLElement): void {\n const owner = hookOwners.get(el);\n if (owner !== undefined && owner !== this) owner.#cancelPending(el);\n this.#cancelPending(el);\n }\n\n /** Releases `el`'s pending removal timer, if it has one. */\n #cancelPending(el: HTMLElement): void {\n // `SafeTimeout.clear` ignores an id it does not own, so the \"no pending timer\"\n // case needs no branch of its own.\n this.#timeouts.clear(this.#pending.get(el) ?? -1);\n this.#pending.delete(el);\n hookOwners.delete(el);\n }\n}\n"]}
@@ -22,7 +22,8 @@ import { Controller } from '@hotwired/stimulus';
22
22
  * Behavior only — it renders no warning UI (pair with Dialog/Confirm) and never
23
23
  * touches the server session. Timers are owned by {@link SafeTimeout} and the
24
24
  * listeners are removed on `disconnect()` (Turbo navigation included). Place one on
25
- * the root element; `data-turbo-permanent` keeps the count running across visits.
25
+ * the root element. Every visit reconnects the controller and re-arms the timeout
26
+ * from that moment — `data-turbo-permanent` keeps the element, not the elapsed count.
26
27
  */
27
28
  declare class IdleController extends Controller<HTMLElement> {
28
29
  #private;
@@ -71,6 +71,12 @@ var IdleController = class extends Controller {
71
71
  #prompted = false;
72
72
  /** Timestamp of the last activity; the timers self-reschedule against it. */
73
73
  #lastActivity = 0;
74
+ /**
75
+ * Activity types actually registered on `document`, so `disconnect()` unbinds the
76
+ * same set even when `events` changed while connected (a Turbo morph can rewrite
77
+ * the Value in place, and the removal must match the registration, not the Value).
78
+ */
79
+ #boundEvents = [];
74
80
  #onActivity = () => {
75
81
  this.#lastActivity = Date.now();
76
82
  if (this.#idle || this.#prompted) {
@@ -85,16 +91,21 @@ var IdleController = class extends Controller {
85
91
  if (document.visibilityState === "visible") this.#onActivity();
86
92
  };
87
93
  connect() {
88
- for (const type of this.eventsValue) {
94
+ this.#idle = false;
95
+ this.#prompted = false;
96
+ this.element.removeAttribute("data-idle");
97
+ this.#boundEvents = [...this.eventsValue];
98
+ for (const type of this.#boundEvents) {
89
99
  document.addEventListener(type, this.#onActivity, { passive: true, capture: true });
90
100
  }
91
101
  document.addEventListener("visibilitychange", this.#onVisibility);
92
102
  this.#arm();
93
103
  }
94
104
  disconnect() {
95
- for (const type of this.eventsValue) {
105
+ for (const type of this.#boundEvents) {
96
106
  document.removeEventListener(type, this.#onActivity, { capture: true });
97
107
  }
108
+ this.#boundEvents = [];
98
109
  document.removeEventListener("visibilitychange", this.#onVisibility);
99
110
  this.#timeouts.clearAll();
100
111
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/idle_controller.ts"],"names":[],"mappings":"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","file":"idle_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless inactivity / session-timeout detector: fires `idle` after `timeout` ms\n * with no user activity, an optional `prompt` `promptBefore` ms earlier, and\n * `active` when the user returns (no dedicated APG pattern; supports WCAG 2.2.1 by\n * giving the app a warning hook before a timeout).\n *\n * Markup contract (identifier: `stimeo--idle`):\n * <body data-controller=\"stimeo--idle\"\n * data-stimeo--idle-timeout-value=\"900000\"\n * data-stimeo--idle-prompt-before-value=\"60000\"\n * data-action=\"stimeo--idle:prompt->session#warn\n * stimeo--idle:idle->session#logout\"></body>\n *\n * Activity events (`events`, passive) are watched on `document` with capture so\n * non-bubbling ones like `scroll` are seen anywhere; returning to a hidden tab\n * (`visibilitychange` → visible) counts as activity too. While idle the controller\n * element carries `data-idle`.\n *\n * @remarks\n * Behavior only — it renders no warning UI (pair with Dialog/Confirm) and never\n * touches the server session. Timers are owned by {@link SafeTimeout} and the\n * listeners are removed on `disconnect()` (Turbo navigation included). Place one on\n * the root element; `data-turbo-permanent` keeps the count running across visits.\n */\nexport class IdleController extends Controller<HTMLElement> {\n static override values = {\n timeout: { type: Number, default: 900_000 },\n promptBefore: { type: Number, default: 0 },\n events: {\n type: Array,\n default: [\"mousemove\", \"mousedown\", \"keydown\", \"wheel\", \"touchstart\", \"scroll\"],\n },\n };\n static events = [\"prompt\", \"idle\", \"active\"] as const;\n\n declare timeoutValue: number;\n declare promptBeforeValue: number;\n declare eventsValue: string[];\n\n readonly #timeouts = new SafeTimeout();\n #idle = false;\n #prompted = false;\n /** Timestamp of the last activity; the timers self-reschedule against it. */\n #lastActivity = 0;\n\n readonly #onActivity = (): void => {\n // Hot path (fires on every mousemove/scroll/wheel): just record the time. The\n // prompt/idle timers re-check this when they fire and reschedule if needed, so we\n // never tear down and re-create timers on each event (no per-event timer churn).\n this.#lastActivity = Date.now();\n if (this.#idle || this.#prompted) {\n // We were already idle/prompted, so the timers have lapsed — wake and re-arm.\n this.#idle = false;\n this.#prompted = false;\n this.element.removeAttribute(\"data-idle\");\n this.dispatch(\"active\", { detail: {} });\n this.#arm();\n }\n };\n\n readonly #onVisibility = (): void => {\n // Returning to the tab is activity; leaving it keeps the clock running (being\n // away counts toward the timeout).\n if (document.visibilityState === \"visible\") this.#onActivity();\n };\n\n override connect(): void {\n for (const type of this.eventsValue) {\n document.addEventListener(type, this.#onActivity, { passive: true, capture: true });\n }\n document.addEventListener(\"visibilitychange\", this.#onVisibility);\n this.#arm();\n }\n\n override disconnect(): void {\n for (const type of this.eventsValue) {\n document.removeEventListener(type, this.#onActivity, { capture: true });\n }\n document.removeEventListener(\"visibilitychange\", this.#onVisibility);\n this.#timeouts.clearAll();\n }\n\n /** Schedules the prompt and idle checks from the current activity baseline. */\n #arm(): void {\n this.#timeouts.clearAll();\n this.#lastActivity = Date.now();\n const { promptBeforeValue: prompt, timeoutValue: timeout } = this;\n if (prompt > 0 && prompt < timeout) {\n this.#timeouts.set(() => this.#checkPrompt(), timeout - prompt);\n }\n this.#timeouts.set(() => this.#checkIdle(), timeout);\n }\n\n /**\n * Idle-timer callback: go idle only if there has genuinely been no activity for\n * `timeout`; otherwise reschedule for the remaining time. This lets activity events\n * stay O(1) (a timestamp write) while the deadline still tracks the last activity.\n */\n #checkIdle(): void {\n const remaining = this.timeoutValue - (Date.now() - this.#lastActivity);\n if (remaining > 0) {\n this.#timeouts.set(() => this.#checkIdle(), remaining);\n return;\n }\n this.#idle = true;\n this.#prompted = false;\n this.element.setAttribute(\"data-idle\", \"true\");\n this.dispatch(\"idle\", { detail: {} });\n }\n\n /** Prompt-timer callback: warn at `promptBefore` before the idle deadline. */\n #checkPrompt(): void {\n const remaining =\n this.timeoutValue - this.promptBeforeValue - (Date.now() - this.#lastActivity);\n if (remaining > 0) {\n this.#timeouts.set(() => this.#checkPrompt(), remaining);\n return;\n }\n this.#prompted = true;\n this.dispatch(\"prompt\", { detail: { remaining: this.promptBeforeValue } });\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/idle_controller.ts"],"names":[],"mappings":"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","file":"idle_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless inactivity / session-timeout detector: fires `idle` after `timeout` ms\n * with no user activity, an optional `prompt` `promptBefore` ms earlier, and\n * `active` when the user returns (no dedicated APG pattern; supports WCAG 2.2.1 by\n * giving the app a warning hook before a timeout).\n *\n * Markup contract (identifier: `stimeo--idle`):\n * <body data-controller=\"stimeo--idle\"\n * data-stimeo--idle-timeout-value=\"900000\"\n * data-stimeo--idle-prompt-before-value=\"60000\"\n * data-action=\"stimeo--idle:prompt->session#warn\n * stimeo--idle:idle->session#logout\"></body>\n *\n * Activity events (`events`, passive) are watched on `document` with capture so\n * non-bubbling ones like `scroll` are seen anywhere; returning to a hidden tab\n * (`visibilitychange` → visible) counts as activity too. While idle the controller\n * element carries `data-idle`.\n *\n * @remarks\n * Behavior only — it renders no warning UI (pair with Dialog/Confirm) and never\n * touches the server session. Timers are owned by {@link SafeTimeout} and the\n * listeners are removed on `disconnect()` (Turbo navigation included). Place one on\n * the root element. Every visit reconnects the controller and re-arms the timeout\n * from that moment — `data-turbo-permanent` keeps the element, not the elapsed count.\n */\nexport class IdleController extends Controller<HTMLElement> {\n static override values = {\n timeout: { type: Number, default: 900_000 },\n promptBefore: { type: Number, default: 0 },\n events: {\n type: Array,\n default: [\"mousemove\", \"mousedown\", \"keydown\", \"wheel\", \"touchstart\", \"scroll\"],\n },\n };\n static events = [\"prompt\", \"idle\", \"active\"] as const;\n\n declare timeoutValue: number;\n declare promptBeforeValue: number;\n declare eventsValue: string[];\n\n readonly #timeouts = new SafeTimeout();\n #idle = false;\n #prompted = false;\n /** Timestamp of the last activity; the timers self-reschedule against it. */\n #lastActivity = 0;\n /**\n * Activity types actually registered on `document`, so `disconnect()` unbinds the\n * same set even when `events` changed while connected (a Turbo morph can rewrite\n * the Value in place, and the removal must match the registration, not the Value).\n */\n #boundEvents: string[] = [];\n\n readonly #onActivity = (): void => {\n // Hot path (fires on every mousemove/scroll/wheel): just record the time. The\n // prompt/idle timers re-check this when they fire and reschedule if needed, so we\n // never tear down and re-create timers on each event (no per-event timer churn).\n this.#lastActivity = Date.now();\n if (this.#idle || this.#prompted) {\n // We were already idle/prompted, so the timers have lapsed — wake and re-arm.\n this.#idle = false;\n this.#prompted = false;\n this.element.removeAttribute(\"data-idle\");\n this.dispatch(\"active\", { detail: {} });\n this.#arm();\n }\n };\n\n readonly #onVisibility = (): void => {\n // Returning to the tab is activity; leaving it keeps the clock running (being\n // away counts toward the timeout).\n if (document.visibilityState === \"visible\") this.#onActivity();\n };\n\n override connect(): void {\n // Connecting always starts a fresh cycle (#arm() re-bases the clock), so an idle\n // marker that arrived with the DOM — a restored Turbo snapshot, a moved element —\n // describes a period this instance is not in. Drop it, or `data-idle` claims the\n // user is idle for the whole next active window with no `active` to correct it.\n this.#idle = false;\n this.#prompted = false;\n this.element.removeAttribute(\"data-idle\");\n this.#boundEvents = [...this.eventsValue];\n for (const type of this.#boundEvents) {\n document.addEventListener(type, this.#onActivity, { passive: true, capture: true });\n }\n document.addEventListener(\"visibilitychange\", this.#onVisibility);\n this.#arm();\n }\n\n override disconnect(): void {\n for (const type of this.#boundEvents) {\n document.removeEventListener(type, this.#onActivity, { capture: true });\n }\n this.#boundEvents = [];\n document.removeEventListener(\"visibilitychange\", this.#onVisibility);\n this.#timeouts.clearAll();\n }\n\n /** Schedules the prompt and idle checks from the current activity baseline. */\n #arm(): void {\n this.#timeouts.clearAll();\n this.#lastActivity = Date.now();\n const { promptBeforeValue: prompt, timeoutValue: timeout } = this;\n if (prompt > 0 && prompt < timeout) {\n this.#timeouts.set(() => this.#checkPrompt(), timeout - prompt);\n }\n this.#timeouts.set(() => this.#checkIdle(), timeout);\n }\n\n /**\n * Idle-timer callback: go idle only if there has genuinely been no activity for\n * `timeout`; otherwise reschedule for the remaining time. This lets activity events\n * stay O(1) (a timestamp write) while the deadline still tracks the last activity.\n */\n #checkIdle(): void {\n const remaining = this.timeoutValue - (Date.now() - this.#lastActivity);\n if (remaining > 0) {\n this.#timeouts.set(() => this.#checkIdle(), remaining);\n return;\n }\n this.#idle = true;\n this.#prompted = false;\n this.element.setAttribute(\"data-idle\", \"true\");\n this.dispatch(\"idle\", { detail: {} });\n }\n\n /** Prompt-timer callback: warn at `promptBefore` before the idle deadline. */\n #checkPrompt(): void {\n const remaining =\n this.timeoutValue - this.promptBeforeValue - (Date.now() - this.#lastActivity);\n if (remaining > 0) {\n this.#timeouts.set(() => this.#checkPrompt(), remaining);\n return;\n }\n this.#prompted = true;\n this.dispatch(\"prompt\", { detail: { remaining: this.promptBeforeValue } });\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/arrow_step.ts","../../src/utils/microtask_coalescer.ts","../../src/utils/option_scroll.ts","../../src/utils/safe_timeout.ts","../../src/utils/typeahead.ts","../../src/controllers/listbox_controller.ts"],"names":[],"mappings":"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","file":"listbox_controller.js","sourcesContent":["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 * Collapses many target callbacks from one DOM mutation into a single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element, so\n * replacing a list of N options delivers N callbacks — but the useful unit of\n * work is \"reconcile against the DOM that resulted\", once, after the batch has\n * settled. Every controller that owns a reconcilable target set needs the same\n * shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers the *initial* target callbacks\n * ahead of `connect()`. Reconciling there would compute a fallback against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","/**\n * Scroll-follow for virtually-focused listbox options.\n *\n * Listbox-style widgets that track their active option with\n * `aria-activedescendant` keep DOM focus on the input/trigger, so the\n * browser's native \"scroll the focused element into view\" never runs — in a\n * scrollable list (`max-height` + `overflow`) the active option can walk right\n * out of sight on ArrowDown/ArrowUp. This helper keeps it visible.\n *\n * It adjusts the LIST's own `scrollTop` only — deliberately not\n * `scrollIntoView({ block: \"nearest\" })`, which may also scroll ancestor\n * scrolling boxes (the page) when a floating popup pokes past a viewport\n * edge, desyncing anchored/marker-composed placements. (Command-palette uses\n * `scrollIntoView` because its page is scroll-locked behind the modal;\n * page-floating popups must use this helper instead.)\n */\n\n/**\n * Scrolls `option` into view within `list` by minimally adjusting\n * `list.scrollTop`. No-ops when the list does not actually scroll. Never\n * touches any other scrolling box.\n *\n * @param list - The scrollable listbox container.\n * @param option - The (virtually focused) option to keep visible.\n */\nexport function scrollOptionIntoView(list: HTMLElement, option: HTMLElement): void {\n if (list.scrollHeight <= list.clientHeight) return;\n const listRect = list.getBoundingClientRect();\n const optionRect = option.getBoundingClientRect();\n if (optionRect.top < listRect.top) {\n list.scrollTop -= listRect.top - optionRect.top;\n } else if (optionRect.bottom > listRect.bottom) {\n list.scrollTop += optionRect.bottom - listRect.bottom;\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\n * Shared type-ahead (first-letter navigation) primitive for composite widgets.\n *\n * The APG asks list-like widgets to move focus by typed characters: one\n * character jumps to the next item starting with it, and several characters\n * typed in quick succession narrow the match to that prefix. Getting that right\n * needs three pieces of bookkeeping — accumulating the query, dropping it after\n * an idle window, and resolving the label each candidate is matched against —\n * and single-sourcing them here keeps every widget on the same rules instead of\n * letting them drift apart.\n *\n * The helpers here own *only* that mechanical part. They are deliberately\n * **policy-free** in the same sense as `RovingTabindex`: which elements are\n * candidates, where the search starts, and what happens on a match (real DOM\n * focus, a roving tab stop, or a virtual `aria-activedescendant`) differ per APG\n * pattern, so each controller keeps those decisions and calls\n * {@link findTypeaheadMatch} with a candidate list it assembled itself.\n *\n * Three decisions are worth stating up front:\n *\n * - **A repeated character collapses the stored query**, rather than deriving a\n * shorter one at search time. That is what lets a follow-up character resume\n * narrowing: `s`, `s`, `e` searches `\"se\"`, not the dead `\"sse\"` that no label\n * can match. Growing the query instead leaves the widget frozen on exactly\n * that sequence, which is why the choice is made here and not per widget.\n * - **`aria-label` wins over text**, so type-ahead matches what a screen reader\n * announces — but only when it contributes a name. A blank (empty or\n * whitespace-only) one is skipped, because accname skips it too: honoring it\n * would leave an element that AT announces by its text unreachable by that\n * text. This mirrors the accessible-name computation rather than the raw text.\n * - **`Space` and composition input are never type-ahead.** `Space` natively\n * activates a `<button>`-based item, and characters still being composed are\n * not a committed query.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and contracts belong on the members.\n */\n\nimport { SafeTimeout } from \"./safe_timeout\";\n\n/** Idle window after which an accumulated query is dropped, in milliseconds. */\nexport const TYPEAHEAD_RESET_MS = 500;\n\n/** Construction options for {@link Typeahead}. */\nexport interface TypeaheadOptions {\n /** Idle window before the query resets. Defaults to {@link TYPEAHEAD_RESET_MS}. */\n resetMs?: number;\n}\n\n/**\n * Accumulates typed characters into a search query and drops it when idle.\n *\n * @remarks\n * Owns a {@link SafeTimeout} of its own, so a consumer's `#timers.clearAll()` does\n * **not** reach the pending reset — call {@link Typeahead.reset} from `disconnect()`.\n */\nexport class Typeahead {\n /** Timer registry for the pending idle reset; private so `reset()` is the only exit. */\n readonly #timers = new SafeTimeout();\n /** Idle window before the query resets, in milliseconds. */\n readonly #resetMs: number;\n /** The accumulated lowercase query, empty when idle. */\n #query = \"\";\n /** Id of the pending reset timer, `0` when none is scheduled. */\n #timerId = 0;\n\n /** @param options - Overrides for the idle window. */\n constructor({ resetMs = TYPEAHEAD_RESET_MS }: TypeaheadOptions = {}) {\n this.#resetMs = resetMs;\n }\n\n /** The query a search would currently run with; empty while idle. */\n get query(): string {\n return this.#query;\n }\n\n /**\n * Folds `key` into the query, restarts the idle window, and returns the query to\n * search with. A repeated character collapses the query to that one character.\n */\n push(key: string): string {\n const char = key.toLowerCase();\n const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);\n this.#query = repeated ? char : this.#query + char;\n this.#timers.clear(this.#timerId);\n this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);\n return this.#query;\n }\n\n /** Clears the query and cancels the pending idle reset. */\n reset(): void {\n this.#query = \"\";\n this.#timers.clear(this.#timerId);\n this.#timerId = 0;\n }\n}\n\n/** Whether `event` is a bare printable character usable for type-ahead. */\nexport function isTypeaheadKey(event: KeyboardEvent): boolean {\n return (\n event.key.length === 1 &&\n event.key !== \" \" &&\n !event.ctrlKey &&\n !event.metaKey &&\n !event.altKey &&\n !event.isComposing\n );\n}\n\n/**\n * The name `element` is matched under, normalized for comparison.\n *\n * @param fallbackText - Produces the name when `aria-label` contributes none.\n * Defaults to the element's `textContent`; pass a narrower source when part of\n * the subtree is not part of the name (a tree item's nested child group, say).\n * Called lazily, so an `aria-label` costs no subtree walk.\n */\nexport function typeaheadLabel(element: HTMLElement, fallbackText?: () => string): string {\n // Blank means absent, matching accname: a whitespace-only `aria-label` is\n // skipped there too, so honoring it here would make an element that a screen\n // reader announces by its text unreachable by that text.\n const label = element.getAttribute(\"aria-label\")?.trim();\n if (label) return label.toLowerCase();\n const text = fallbackText ? fallbackText() : (element.textContent ?? \"\");\n return text.trim().toLowerCase();\n}\n\n/**\n * Index of the first item after `from` whose label starts with `query`, wrapping\n * and evaluating `from` itself last, or `-1` when nothing matches.\n *\n * @param items - Candidates in navigation order, already filtered by the caller.\n * @param from - Index the search moves on from; `-1` starts at the first item. Any\n * out-of-range value is folded back in, so `indexOf` results pass straight through.\n * @param query - Lowercase query, normally the return of {@link Typeahead.push}.\n * @param label - Resolves a candidate's name. Defaults to {@link typeaheadLabel}.\n */\nexport function findTypeaheadMatch(\n items: readonly HTMLElement[],\n from: number,\n query: string,\n label: (item: HTMLElement) => string = (item) => typeaheadLabel(item),\n): number {\n // An empty query would prefix-match everything; an empty list never enters the\n // loop, so it needs no guard of its own.\n if (query === \"\") return -1;\n const count = items.length;\n for (let step = 1; step <= count; step += 1) {\n // Folded twice so a negative `from` still lands on a real slot: `%` keeps the\n // sign of its left operand, and a negative index would read `undefined`.\n const index = (((from + step) % count) + count) % count;\n const candidate = items[index];\n if (candidate && label(candidate).startsWith(query)) return index;\n }\n return -1;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord } from \"../utils/arrow_step\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\nimport { scrollOptionIntoView } from \"../utils/option_scroll\";\nimport { findTypeaheadMatch, isTypeaheadKey, Typeahead } from \"../utils/typeahead\";\n\n/**\n * Headless, accessible select-only listbox behavior.\n *\n * Markup contract (identifier: `stimeo--listbox`):\n * <div data-controller=\"stimeo--listbox\">\n * <span id=\"lb-label\">Favorite fruit</span>\n * <button type=\"button\" role=\"combobox\" aria-haspopup=\"listbox\"\n * aria-expanded=\"false\" aria-controls=\"lb-list\"\n * aria-labelledby=\"lb-label lb-value\"\n * data-stimeo--listbox-target=\"trigger\"\n * data-action=\"click->stimeo--listbox#toggle\n * keydown->stimeo--listbox#onTriggerKeydown\">\n * <span id=\"lb-value\" data-stimeo--listbox-target=\"value\">Choose…</span>\n * </button>\n * <ul id=\"lb-list\" role=\"listbox\" aria-label=\"Options\" hidden\n * data-stimeo--listbox-target=\"list\">\n * <li id=\"opt-1\" role=\"option\" aria-selected=\"false\" data-value=\"1\"\n * data-stimeo--listbox-target=\"option\"\n * data-action=\"click->stimeo--listbox#select\">Option 1</li>\n * <!-- more options -->\n * </ul>\n * <input type=\"hidden\" data-stimeo--listbox-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Listbox** pattern in its collapsed\n * (Select-Only Combobox) form. Focus stays on the trigger; the active option is\n * tracked with `aria-activedescendant` rather than by moving DOM focus. For a\n * text-filtered popup use {@link ComboboxController | Combobox}; for multiple\n * selection use Multi-Select.\n *\n * @remarks\n * Behavior only. Static placement is the consumer's CSS; dynamic placement is\n * delegated to the opt-in `stimeo-ui/positioning` module. The look is keyed off\n * `aria-selected` / `data-active`. Because `role=\"combobox\"` is not named by its\n * contents, give the trigger an accessible name via `aria-labelledby` (a visible\n * label plus the value span) or `aria-label`.\n *\n * Behavior provided:\n * - Open/close the list, syncing `aria-expanded` and the list's `hidden`.\n * - `ArrowDown`/`ArrowUp` (wrapping), `Home`/`End`, and printable-character\n * typeahead move the active option; opening picks the selected option (else\n * the first).\n * - Single selection syncs `aria-selected`, reflects the label into the trigger\n * value and the field's value, and dispatches `stimeo--listbox:change` plus a\n * native bubbling `change` on the field when its value actually changed.\n * Making the field a validatable mirror (`<input type=\"text\" hidden required>`\n * instead of `type=\"hidden\"`) lets `stimeo--form-validation` enforce native\n * constraints on the committed value with no extra JavaScript.\n * - `Enter`/`Space` select and close; `Escape` and outside click / `Tab` close;\n * closing via select/Escape returns focus to the trigger.\n */\nexport class ListboxController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"value\", \"list\", \"option\", \"field\"];\n static actions = [\"close\", \"onTriggerKeydown\", \"open\", \"select\", \"toggle\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly valueTarget: HTMLElement;\n declare readonly listTarget: HTMLElement;\n declare readonly optionTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasValueTarget: boolean;\n declare readonly hasListTarget: boolean;\n declare readonly hasFieldTarget: boolean;\n\n /** Stable ID of the active option; DOM targets are resolved afresh before use. */\n #activeId: string | null = null;\n /** Target ID order captured while an option is active, used only for removal fallback. */\n #activeOrder: string[] = [];\n #connected = false;\n /** Collapses one mutation batch of target callbacks into a single pass. */\n readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());\n /** Accumulated typeahead query and its idle-reset timer. */\n readonly #typeahead = new Typeahead();\n\n /** Establishes the ARIA baseline, starts closed, and listens for outside clicks. */\n override connect(): void {\n this.#normalizeSelection();\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick, true);\n this.#connected = true;\n this.#reconcile.activate();\n }\n\n /**\n * Establishes an inactive baseline for a late option, re-resolves active\n * identity, and re-applies the selection baseline.\n */\n optionTargetConnected(option: HTMLElement): void {\n option.removeAttribute(\"data-active\");\n if (!this.#connected) return;\n this.#normalizeSelection();\n this.#queueOptionReconciliation();\n }\n\n /** Removes controller-owned active state and reconciles the surviving targets. */\n optionTargetDisconnected(option: HTMLElement): void {\n option.removeAttribute(\"data-active\");\n if (this.#connected) this.#queueOptionReconciliation();\n }\n\n /**\n * Brings the authored DOM to the shape the APG requires, and derives the state\n * that follows from the initial selection.\n *\n * Three things happen, and only these three — which option is chosen is the\n * author's, and is never changed:\n *\n * 1. Every option gets an explicit value. An absent `aria-selected` means \"not\n * selectable\" in ARIA, so a forgotten attribute hides a selectable option\n * from assistive technology.\n * 2. At most one stays `true`. The first in DOM order wins, since that is the\n * only deterministic reading of \"which one did the author mean\".\n * 3. The trigger label and the hidden field are derived from that selection.\n * Without this the widget announces a choice it does not submit: the popup\n * says \"Banana\", the trigger still says \"Choose…\", and the form posts \"\".\n *\n * No `change` fires — nothing changed, this is the initial state being told\n * properly. The scan is the `option` target set: a `role=\"option\"` without the\n * target is outside the contract and is neither counted nor written.\n */\n #normalizeSelection(): void {\n const options = this.optionTargets;\n const selected = options.find((option) => option.getAttribute(\"aria-selected\") === \"true\");\n for (const option of options) {\n option.setAttribute(\"aria-selected\", option === selected ? \"true\" : \"false\");\n }\n if (selected) this.#applySelection(selected);\n }\n\n /** Removes the document listener and clears the typeahead timer. */\n override disconnect(): void {\n this.#connected = false;\n this.#reconcile.cancel();\n document.removeEventListener(\"click\", this.#onOutsideClick, true);\n // The typeahead keeps its idle reset in a `SafeTimeout` of its own, so this is\n // the only teardown that reaches it — there is no controller-level registry to\n // fall back on. Every timer this controller can schedule lives in there.\n this.#typeahead.reset();\n }\n\n /**\n * Toggles the list on a real mouse click. Keyboard activation of the\n * `<button>` also fires a click (`detail === 0`); the keydown handler already\n * drives that, so the synthetic click is ignored to avoid double-toggling.\n */\n toggle(event: MouseEvent): void {\n if (event.detail === 0) return;\n if (this.#isClosed) {\n this.open();\n } else {\n this.close();\n }\n }\n\n /** Yields claimed keys; otherwise routes the APG select-only keyboard model. */\n onTriggerKeydown(event: KeyboardEvent): void {\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n if (!this.#isClosed) this.#reconcileActive();\n const options = this.optionTargets;\n const length = options.length;\n const activeIndex = this.#findActiveIndex(options);\n if (this.#isClosed) {\n switch (event.key) {\n case \"Enter\":\n case \" \":\n case \"ArrowDown\":\n case \"ArrowUp\":\n event.preventDefault();\n this.open();\n break;\n default:\n break;\n }\n return;\n }\n\n // With no options, only Escape/Tab are meaningful; ignore navigation and\n // typeahead so the active index can never become NaN (`% 0`).\n if (length === 0 && event.key !== \"Escape\" && event.key !== \"Tab\") {\n return;\n }\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n this.#setActive(activeIndex < 0 ? 0 : (activeIndex + 1) % length);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.#setActive(activeIndex < 0 ? length - 1 : (activeIndex - 1 + length) % length);\n break;\n case \"Home\":\n event.preventDefault();\n this.#setActive(0);\n break;\n case \"End\":\n event.preventDefault();\n this.#setActive(length - 1);\n break;\n case \"Enter\":\n case \" \":\n event.preventDefault();\n this.#commitActive();\n break;\n case \"Escape\":\n // The entry guard already yields a press another handler owned. A press\n // during IME composition never dismisses, keeping one rule across the\n // widgets (the trigger is a button, so composition does not start here).\n if (event.isComposing) break;\n event.preventDefault();\n this.close();\n this.triggerTarget.focus();\n break;\n case \"Tab\":\n // Let focus leave naturally; just don't keep a stale popup open.\n this.close();\n break;\n default:\n if (isTypeaheadKey(event)) {\n event.preventDefault();\n this.#typeaheadTo(options, activeIndex, event.key);\n }\n break;\n }\n }\n\n /** Selects the clicked option and closes, returning focus to the trigger. */\n select(event: Event): void {\n const option = (event.currentTarget as HTMLElement).closest<HTMLElement>('[role=\"option\"]');\n if (!option || !this.optionTargets.includes(option)) return;\n this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Opens the list and activates the selected option (else the first). */\n open(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = false;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n if (this.optionTargets.length === 0) {\n // An empty listbox has nothing to activate; leave activedescendant cleared.\n this.#setActive(-1);\n return;\n }\n const selected = this.optionTargets.findIndex(\n (option) => option.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#setActive(selected === -1 ? 0 : selected);\n }\n\n /** Closes the list, clears the active option, and resets the typeahead buffer. */\n close(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = true;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n this.#setActive(-1);\n this.#typeahead.reset();\n }\n\n /** Commits the active option (keyboard) and closes, returning focus. */\n #commitActive(): void {\n this.#reconcileActive();\n const options = this.optionTargets;\n const activeIndex = this.#findActiveIndex(options);\n const option = activeIndex < 0 ? undefined : options[activeIndex];\n if (option) this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */\n #selectOption(option: HTMLElement): void {\n const { value, fieldChanged } = this.#applySelection(option);\n if (fieldChanged) {\n // A native bubbling change (matching <select> semantics: only on an actual\n // value change) so form-level behaviors — validation re-checks, auto-submit\n // — hear the commit without knowing this widget.\n this.fieldTarget.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n this.dispatch(\"change\", { detail: { value, option } });\n }\n\n /**\n * Writes `option` into `aria-selected`, the trigger label and the hidden field.\n * Emits nothing — {@link #normalizeSelection} reuses this at connect, where the\n * state is being described rather than changed.\n */\n #applySelection(option: HTMLElement): { value: string; fieldChanged: boolean } {\n for (const candidate of this.optionTargets) {\n candidate.setAttribute(\"aria-selected\", candidate === option ? \"true\" : \"false\");\n }\n const label = (option.textContent ?? \"\").trim();\n const value = option.dataset.value ?? label;\n if (this.hasValueTarget) this.valueTarget.textContent = label;\n const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;\n if (fieldChanged) this.fieldTarget.value = value;\n return { value, fieldChanged };\n }\n\n /**\n * Marks the option at `index` active via `data-active` and the trigger's\n * `aria-activedescendant`. Pass `-1` to clear it (the attribute is removed, not\n * set to empty, per the APG).\n */\n #setActive(index: number): void {\n const options = this.optionTargets;\n const active = index < 0 ? null : (options[index] ?? null);\n // Only the options whose marker actually changes are written, so a held arrow\n // key costs two attribute writes rather than one per option. The whole set is\n // still read: that is what makes a stray marker — one a morph left behind on\n // an element that never re-connected as a target — heal on the next move.\n for (const option of options) {\n const marked = option.hasAttribute(\"data-active\");\n if (option === active) {\n if (!marked) option.setAttribute(\"data-active\", \"\");\n } else if (marked) {\n option.removeAttribute(\"data-active\");\n }\n }\n if (active?.id) {\n this.#activeId = active.id;\n this.triggerTarget.setAttribute(\"aria-activedescendant\", active.id);\n } else {\n this.#activeId = null;\n this.triggerTarget.removeAttribute(\"aria-activedescendant\");\n }\n this.#activeOrder = active ? options.map((option) => option.id).filter(Boolean) : [];\n // Virtual focus never triggers the browser's native focus-scrolling, so a\n // scrollable list must follow the active option itself (list-only scroll).\n if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);\n }\n\n /** Resolves active state against the current target collection. */\n #reconcileActive(): void {\n if (!this.hasTriggerTarget || this.#isClosed) {\n if (this.hasTriggerTarget) this.#setActive(-1);\n return;\n }\n\n const options = this.optionTargets;\n const currentIndex = this.#findActiveIndex(options);\n if (currentIndex >= 0) {\n const active = options[currentIndex] ?? null;\n const marked = options.filter((option) => option.hasAttribute(\"data-active\"));\n const stateMatches =\n this.#activeId === (active?.id || null) &&\n marked.length === 1 &&\n marked[0] === active &&\n this.triggerTarget.getAttribute(\"aria-activedescendant\") === (active?.id || null);\n\n if (stateMatches) {\n // Keep the deletion fallback snapshot current without re-scrolling the\n // already-active option before every keyboard command.\n this.#activeOrder = options.map((option) => option.id).filter(Boolean);\n } else {\n this.#setActive(currentIndex);\n }\n return;\n }\n\n const activeId = this.triggerTarget.getAttribute(\"aria-activedescendant\") ?? this.#activeId;\n this.#setActive(activeId ? this.#findFallbackIndex(options, activeId) : -1);\n }\n\n /** Finds the live target carrying the stable ID, or the active marker for an ID-less option. */\n #findActiveIndex(options: readonly HTMLElement[]): number {\n const activeId =\n (this.hasTriggerTarget ? this.triggerTarget.getAttribute(\"aria-activedescendant\") : null) ??\n this.#activeId;\n if (activeId) return options.findIndex((option) => option.id === activeId);\n return options.findIndex((option) => option.hasAttribute(\"data-active\"));\n }\n\n /** Chooses a surviving former successor, then a former predecessor. */\n #findFallbackIndex(options: readonly HTMLElement[], activeId: string): number {\n const oldIndex = this.#activeOrder.indexOf(activeId);\n if (oldIndex < 0) return -1;\n const indexesById = new Map(options.map((option, index) => [option.id, index]));\n for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {\n const fallback = indexesById.get(this.#activeOrder[index] ?? \"\");\n if (fallback !== undefined) return fallback;\n }\n for (let index = oldIndex - 1; index >= 0; index -= 1) {\n const fallback = indexesById.get(this.#activeOrder[index] ?? \"\");\n if (fallback !== undefined) return fallback;\n }\n return -1;\n }\n\n /** Coalesces all target callbacks from one MutationObserver batch. */\n #queueOptionReconciliation(): void {\n this.#reconcile.schedule();\n }\n\n /**\n * Advances the typeahead query and activates the next matching option.\n *\n * The search resumes just after the active option so repeating a character\n * cycles through the options starting with it, rather than re-activating the\n * same first match on every press.\n */\n #typeaheadTo(options: HTMLElement[], activeIndex: number, char: string): void {\n const index = findTypeaheadMatch(options, activeIndex, this.#typeahead.push(char));\n if (index !== -1) this.#setActive(index);\n }\n\n /** Closes on an outside click before an inside handler can detach its target. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (!this.#isClosed && !this.element.contains(event.target as Node)) this.close();\n };\n\n /** Whether the list is currently hidden. */\n get #isClosed(): boolean {\n return !this.hasListTarget || this.listTarget.hidden !== false;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/arrow_step.ts","../../src/utils/microtask_coalescer.ts","../../src/utils/option_scroll.ts","../../src/utils/safe_timeout.ts","../../src/utils/typeahead.ts","../../src/controllers/listbox_controller.ts"],"names":[],"mappings":"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","file":"listbox_controller.js","sourcesContent":["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 * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","/**\n * Scroll-follow for virtually-focused listbox options.\n *\n * Listbox-style widgets that track their active option with\n * `aria-activedescendant` keep DOM focus on the input/trigger, so the\n * browser's native \"scroll the focused element into view\" never runs — in a\n * scrollable list (`max-height` + `overflow`) the active option can walk right\n * out of sight on ArrowDown/ArrowUp. This helper keeps it visible.\n *\n * It adjusts the LIST's own `scrollTop` only — deliberately not\n * `scrollIntoView({ block: \"nearest\" })`, which may also scroll ancestor\n * scrolling boxes (the page) when a floating popup pokes past a viewport\n * edge, desyncing anchored/marker-composed placements. (Command-palette uses\n * `scrollIntoView` because its page is scroll-locked behind the modal;\n * page-floating popups must use this helper instead.)\n */\n\n/**\n * Scrolls `option` into view within `list` by minimally adjusting\n * `list.scrollTop`. No-ops when the list does not actually scroll. Never\n * touches any other scrolling box.\n *\n * @param list - The scrollable listbox container.\n * @param option - The (virtually focused) option to keep visible.\n */\nexport function scrollOptionIntoView(list: HTMLElement, option: HTMLElement): void {\n if (list.scrollHeight <= list.clientHeight) return;\n const listRect = list.getBoundingClientRect();\n const optionRect = option.getBoundingClientRect();\n if (optionRect.top < listRect.top) {\n list.scrollTop -= listRect.top - optionRect.top;\n } else if (optionRect.bottom > listRect.bottom) {\n list.scrollTop += optionRect.bottom - listRect.bottom;\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\n * Shared type-ahead (first-letter navigation) primitive for composite widgets.\n *\n * The APG asks list-like widgets to move focus by typed characters: one\n * character jumps to the next item starting with it, and several characters\n * typed in quick succession narrow the match to that prefix. Getting that right\n * needs three pieces of bookkeeping — accumulating the query, dropping it after\n * an idle window, and resolving the label each candidate is matched against —\n * and single-sourcing them here keeps every widget on the same rules instead of\n * letting them drift apart.\n *\n * The helpers here own *only* that mechanical part. They are deliberately\n * **policy-free** in the same sense as `RovingTabindex`: which elements are\n * candidates, where the search starts, and what happens on a match (real DOM\n * focus, a roving tab stop, or a virtual `aria-activedescendant`) differ per APG\n * pattern, so each controller keeps those decisions and calls\n * {@link findTypeaheadMatch} with a candidate list it assembled itself.\n *\n * Three decisions are worth stating up front:\n *\n * - **A repeated character collapses the stored query**, rather than deriving a\n * shorter one at search time. That is what lets a follow-up character resume\n * narrowing: `s`, `s`, `e` searches `\"se\"`, not the dead `\"sse\"` that no label\n * can match. Growing the query instead leaves the widget frozen on exactly\n * that sequence, which is why the choice is made here and not per widget.\n * - **`aria-label` wins over text**, so type-ahead matches what a screen reader\n * announces — but only when it contributes a name. A blank (empty or\n * whitespace-only) one is skipped, because accname skips it too: honoring it\n * would leave an element that AT announces by its text unreachable by that\n * text. This mirrors the accessible-name computation rather than the raw text.\n * - **`Space` and composition input are never type-ahead.** `Space` natively\n * activates a `<button>`-based item, and characters still being composed are\n * not a committed query.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and contracts belong on the members.\n */\n\nimport { SafeTimeout } from \"./safe_timeout\";\n\n/** Idle window after which an accumulated query is dropped, in milliseconds. */\nexport const TYPEAHEAD_RESET_MS = 500;\n\n/** Construction options for {@link Typeahead}. */\nexport interface TypeaheadOptions {\n /** Idle window before the query resets. Defaults to {@link TYPEAHEAD_RESET_MS}. */\n resetMs?: number;\n}\n\n/**\n * Accumulates typed characters into a search query and drops it when idle.\n *\n * @remarks\n * Owns a {@link SafeTimeout} of its own, so a consumer's `#timers.clearAll()` does\n * **not** reach the pending reset — call {@link Typeahead.reset} from `disconnect()`.\n */\nexport class Typeahead {\n /** Timer registry for the pending idle reset; private so `reset()` is the only exit. */\n readonly #timers = new SafeTimeout();\n /** Idle window before the query resets, in milliseconds. */\n readonly #resetMs: number;\n /** The accumulated lowercase query, empty when idle. */\n #query = \"\";\n /** Id of the pending reset timer, `0` when none is scheduled. */\n #timerId = 0;\n\n /** @param options - Overrides for the idle window. */\n constructor({ resetMs = TYPEAHEAD_RESET_MS }: TypeaheadOptions = {}) {\n this.#resetMs = resetMs;\n }\n\n /** The query a search would currently run with; empty while idle. */\n get query(): string {\n return this.#query;\n }\n\n /**\n * Folds `key` into the query, restarts the idle window, and returns the query to\n * search with. A repeated character collapses the query to that one character.\n */\n push(key: string): string {\n const char = key.toLowerCase();\n const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);\n this.#query = repeated ? char : this.#query + char;\n this.#timers.clear(this.#timerId);\n this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);\n return this.#query;\n }\n\n /** Clears the query and cancels the pending idle reset. */\n reset(): void {\n this.#query = \"\";\n this.#timers.clear(this.#timerId);\n this.#timerId = 0;\n }\n}\n\n/** Whether `event` is a bare printable character usable for type-ahead. */\nexport function isTypeaheadKey(event: KeyboardEvent): boolean {\n return (\n event.key.length === 1 &&\n event.key !== \" \" &&\n !event.ctrlKey &&\n !event.metaKey &&\n !event.altKey &&\n !event.isComposing\n );\n}\n\n/**\n * The name `element` is matched under, normalized for comparison.\n *\n * @param fallbackText - Produces the name when `aria-label` contributes none.\n * Defaults to the element's `textContent`; pass a narrower source when part of\n * the subtree is not part of the name (a tree item's nested child group, say).\n * Called lazily, so an `aria-label` costs no subtree walk.\n */\nexport function typeaheadLabel(element: HTMLElement, fallbackText?: () => string): string {\n // Blank means absent, matching accname: a whitespace-only `aria-label` is\n // skipped there too, so honoring it here would make an element that a screen\n // reader announces by its text unreachable by that text.\n const label = element.getAttribute(\"aria-label\")?.trim();\n if (label) return label.toLowerCase();\n const text = fallbackText ? fallbackText() : (element.textContent ?? \"\");\n return text.trim().toLowerCase();\n}\n\n/**\n * Index of the first item after `from` whose label starts with `query`, wrapping\n * and evaluating `from` itself last, or `-1` when nothing matches.\n *\n * @param items - Candidates in navigation order, already filtered by the caller.\n * @param from - Index the search moves on from; `-1` starts at the first item. Any\n * out-of-range value is folded back in, so `indexOf` results pass straight through.\n * @param query - Lowercase query, normally the return of {@link Typeahead.push}.\n * @param label - Resolves a candidate's name. Defaults to {@link typeaheadLabel}.\n */\nexport function findTypeaheadMatch(\n items: readonly HTMLElement[],\n from: number,\n query: string,\n label: (item: HTMLElement) => string = (item) => typeaheadLabel(item),\n): number {\n // An empty query would prefix-match everything; an empty list never enters the\n // loop, so it needs no guard of its own.\n if (query === \"\") return -1;\n const count = items.length;\n for (let step = 1; step <= count; step += 1) {\n // Folded twice so a negative `from` still lands on a real slot: `%` keeps the\n // sign of its left operand, and a negative index would read `undefined`.\n const index = (((from + step) % count) + count) % count;\n const candidate = items[index];\n if (candidate && label(candidate).startsWith(query)) return index;\n }\n return -1;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord } from \"../utils/arrow_step\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\nimport { scrollOptionIntoView } from \"../utils/option_scroll\";\nimport { findTypeaheadMatch, isTypeaheadKey, Typeahead } from \"../utils/typeahead\";\n\n/**\n * Headless, accessible select-only listbox behavior.\n *\n * Markup contract (identifier: `stimeo--listbox`):\n * <div data-controller=\"stimeo--listbox\">\n * <span id=\"lb-label\">Favorite fruit</span>\n * <button type=\"button\" role=\"combobox\" aria-haspopup=\"listbox\"\n * aria-expanded=\"false\" aria-controls=\"lb-list\"\n * aria-labelledby=\"lb-label lb-value\"\n * data-stimeo--listbox-target=\"trigger\"\n * data-action=\"click->stimeo--listbox#toggle\n * keydown->stimeo--listbox#onTriggerKeydown\">\n * <span id=\"lb-value\" data-stimeo--listbox-target=\"value\">Choose…</span>\n * </button>\n * <ul id=\"lb-list\" role=\"listbox\" aria-label=\"Options\" hidden\n * data-stimeo--listbox-target=\"list\">\n * <li id=\"opt-1\" role=\"option\" aria-selected=\"false\" data-value=\"1\"\n * data-stimeo--listbox-target=\"option\"\n * data-action=\"click->stimeo--listbox#select\">Option 1</li>\n * <!-- more options -->\n * </ul>\n * <input type=\"hidden\" data-stimeo--listbox-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Listbox** pattern in its collapsed\n * (Select-Only Combobox) form. Focus stays on the trigger; the active option is\n * tracked with `aria-activedescendant` rather than by moving DOM focus. For a\n * text-filtered popup use {@link ComboboxController | Combobox}; for multiple\n * selection use Multi-Select.\n *\n * @remarks\n * Behavior only. Static placement is the consumer's CSS; dynamic placement is\n * delegated to the opt-in `stimeo-ui/positioning` module. The look is keyed off\n * `aria-selected` / `data-active`. Because `role=\"combobox\"` is not named by its\n * contents, give the trigger an accessible name via `aria-labelledby` (a visible\n * label plus the value span) or `aria-label`.\n *\n * Behavior provided:\n * - Open/close the list, syncing `aria-expanded` and the list's `hidden`.\n * - `ArrowDown`/`ArrowUp` (wrapping), `Home`/`End`, and printable-character\n * typeahead move the active option; opening picks the selected option (else\n * the first).\n * - Single selection syncs `aria-selected`, reflects the label into the trigger\n * value and the field's value, and dispatches `stimeo--listbox:change` plus a\n * native bubbling `change` on the field when its value actually changed.\n * Making the field a validatable mirror (`<input type=\"text\" hidden required>`\n * instead of `type=\"hidden\"`) lets `stimeo--form-validation` enforce native\n * constraints on the committed value with no extra JavaScript.\n * - `Enter`/`Space` select and close; `Escape` and outside click / `Tab` close;\n * closing via select/Escape returns focus to the trigger.\n */\nexport class ListboxController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"value\", \"list\", \"option\", \"field\"];\n static actions = [\"close\", \"onTriggerKeydown\", \"open\", \"select\", \"toggle\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly valueTarget: HTMLElement;\n declare readonly listTarget: HTMLElement;\n declare readonly optionTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasValueTarget: boolean;\n declare readonly hasListTarget: boolean;\n declare readonly hasFieldTarget: boolean;\n\n /** Stable ID of the active option; DOM targets are resolved afresh before use. */\n #activeId: string | null = null;\n /** Target ID order captured while an option is active, used only for removal fallback. */\n #activeOrder: string[] = [];\n #connected = false;\n /** Collapses one mutation batch of target callbacks into a single pass. */\n readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());\n /** Accumulated typeahead query and its idle-reset timer. */\n readonly #typeahead = new Typeahead();\n\n /** Establishes the ARIA baseline, starts closed, and listens for outside clicks. */\n override connect(): void {\n this.#normalizeSelection();\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick, true);\n this.#connected = true;\n this.#reconcile.activate();\n }\n\n /**\n * Establishes an inactive baseline for a late option, re-resolves active\n * identity, and re-applies the selection baseline.\n */\n optionTargetConnected(option: HTMLElement): void {\n option.removeAttribute(\"data-active\");\n if (!this.#connected) return;\n this.#normalizeSelection();\n this.#queueOptionReconciliation();\n }\n\n /** Removes controller-owned active state and reconciles the surviving targets. */\n optionTargetDisconnected(option: HTMLElement): void {\n option.removeAttribute(\"data-active\");\n if (this.#connected) this.#queueOptionReconciliation();\n }\n\n /**\n * Brings the authored DOM to the shape the APG requires, and derives the state\n * that follows from the initial selection.\n *\n * Three things happen, and only these three — which option is chosen is the\n * author's, and is never changed:\n *\n * 1. Every option gets an explicit value. An absent `aria-selected` means \"not\n * selectable\" in ARIA, so a forgotten attribute hides a selectable option\n * from assistive technology.\n * 2. At most one stays `true`. The first in DOM order wins, since that is the\n * only deterministic reading of \"which one did the author mean\".\n * 3. The trigger label and the hidden field are derived from that selection.\n * Without this the widget announces a choice it does not submit: the popup\n * says \"Banana\", the trigger still says \"Choose…\", and the form posts \"\".\n *\n * No `change` fires — nothing changed, this is the initial state being told\n * properly. The scan is the `option` target set: a `role=\"option\"` without the\n * target is outside the contract and is neither counted nor written.\n */\n #normalizeSelection(): void {\n const options = this.optionTargets;\n const selected = options.find((option) => option.getAttribute(\"aria-selected\") === \"true\");\n for (const option of options) {\n option.setAttribute(\"aria-selected\", option === selected ? \"true\" : \"false\");\n }\n if (selected) this.#applySelection(selected);\n }\n\n /** Removes the document listener and clears the typeahead timer. */\n override disconnect(): void {\n this.#connected = false;\n this.#reconcile.cancel();\n document.removeEventListener(\"click\", this.#onOutsideClick, true);\n // The typeahead keeps its idle reset in a `SafeTimeout` of its own, so this is\n // the only teardown that reaches it — there is no controller-level registry to\n // fall back on. Every timer this controller can schedule lives in there.\n this.#typeahead.reset();\n }\n\n /**\n * Toggles the list on a real mouse click. Keyboard activation of the\n * `<button>` also fires a click (`detail === 0`); the keydown handler already\n * drives that, so the synthetic click is ignored to avoid double-toggling.\n */\n toggle(event: MouseEvent): void {\n if (event.detail === 0) return;\n if (this.#isClosed) {\n this.open();\n } else {\n this.close();\n }\n }\n\n /** Yields claimed keys; otherwise routes the APG select-only keyboard model. */\n onTriggerKeydown(event: KeyboardEvent): void {\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n if (!this.#isClosed) this.#reconcileActive();\n const options = this.optionTargets;\n const length = options.length;\n const activeIndex = this.#findActiveIndex(options);\n if (this.#isClosed) {\n switch (event.key) {\n case \"Enter\":\n case \" \":\n case \"ArrowDown\":\n case \"ArrowUp\":\n event.preventDefault();\n this.open();\n break;\n default:\n break;\n }\n return;\n }\n\n // With no options, only Escape/Tab are meaningful; ignore navigation and\n // typeahead so the active index can never become NaN (`% 0`).\n if (length === 0 && event.key !== \"Escape\" && event.key !== \"Tab\") {\n return;\n }\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n this.#setActive(activeIndex < 0 ? 0 : (activeIndex + 1) % length);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.#setActive(activeIndex < 0 ? length - 1 : (activeIndex - 1 + length) % length);\n break;\n case \"Home\":\n event.preventDefault();\n this.#setActive(0);\n break;\n case \"End\":\n event.preventDefault();\n this.#setActive(length - 1);\n break;\n case \"Enter\":\n case \" \":\n event.preventDefault();\n this.#commitActive();\n break;\n case \"Escape\":\n // The entry guard already yields a press another handler owned. A press\n // during IME composition never dismisses, keeping one rule across the\n // widgets (the trigger is a button, so composition does not start here).\n if (event.isComposing) break;\n event.preventDefault();\n this.close();\n this.triggerTarget.focus();\n break;\n case \"Tab\":\n // Let focus leave naturally; just don't keep a stale popup open.\n this.close();\n break;\n default:\n if (isTypeaheadKey(event)) {\n event.preventDefault();\n this.#typeaheadTo(options, activeIndex, event.key);\n }\n break;\n }\n }\n\n /** Selects the clicked option and closes, returning focus to the trigger. */\n select(event: Event): void {\n const option = (event.currentTarget as HTMLElement).closest<HTMLElement>('[role=\"option\"]');\n if (!option || !this.optionTargets.includes(option)) return;\n this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Opens the list and activates the selected option (else the first). */\n open(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = false;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n if (this.optionTargets.length === 0) {\n // An empty listbox has nothing to activate; leave activedescendant cleared.\n this.#setActive(-1);\n return;\n }\n const selected = this.optionTargets.findIndex(\n (option) => option.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#setActive(selected === -1 ? 0 : selected);\n }\n\n /** Closes the list, clears the active option, and resets the typeahead buffer. */\n close(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = true;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n this.#setActive(-1);\n this.#typeahead.reset();\n }\n\n /** Commits the active option (keyboard) and closes, returning focus. */\n #commitActive(): void {\n this.#reconcileActive();\n const options = this.optionTargets;\n const activeIndex = this.#findActiveIndex(options);\n const option = activeIndex < 0 ? undefined : options[activeIndex];\n if (option) this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */\n #selectOption(option: HTMLElement): void {\n const { value, fieldChanged } = this.#applySelection(option);\n if (fieldChanged) {\n // A native bubbling change (matching <select> semantics: only on an actual\n // value change) so form-level behaviors — validation re-checks, auto-submit\n // — hear the commit without knowing this widget.\n this.fieldTarget.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n this.dispatch(\"change\", { detail: { value, option } });\n }\n\n /**\n * Writes `option` into `aria-selected`, the trigger label and the hidden field.\n * Emits nothing — {@link #normalizeSelection} reuses this at connect, where the\n * state is being described rather than changed.\n */\n #applySelection(option: HTMLElement): { value: string; fieldChanged: boolean } {\n for (const candidate of this.optionTargets) {\n candidate.setAttribute(\"aria-selected\", candidate === option ? \"true\" : \"false\");\n }\n const label = (option.textContent ?? \"\").trim();\n const value = option.dataset.value ?? label;\n if (this.hasValueTarget) this.valueTarget.textContent = label;\n const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;\n if (fieldChanged) this.fieldTarget.value = value;\n return { value, fieldChanged };\n }\n\n /**\n * Marks the option at `index` active via `data-active` and the trigger's\n * `aria-activedescendant`. Pass `-1` to clear it (the attribute is removed, not\n * set to empty, per the APG).\n */\n #setActive(index: number): void {\n const options = this.optionTargets;\n const active = index < 0 ? null : (options[index] ?? null);\n // Only the options whose marker actually changes are written, so a held arrow\n // key costs two attribute writes rather than one per option. The whole set is\n // still read: that is what makes a stray marker — one a morph left behind on\n // an element that never re-connected as a target — heal on the next move.\n for (const option of options) {\n const marked = option.hasAttribute(\"data-active\");\n if (option === active) {\n if (!marked) option.setAttribute(\"data-active\", \"\");\n } else if (marked) {\n option.removeAttribute(\"data-active\");\n }\n }\n if (active?.id) {\n this.#activeId = active.id;\n this.triggerTarget.setAttribute(\"aria-activedescendant\", active.id);\n } else {\n this.#activeId = null;\n this.triggerTarget.removeAttribute(\"aria-activedescendant\");\n }\n this.#activeOrder = active ? options.map((option) => option.id).filter(Boolean) : [];\n // Virtual focus never triggers the browser's native focus-scrolling, so a\n // scrollable list must follow the active option itself (list-only scroll).\n if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);\n }\n\n /** Resolves active state against the current target collection. */\n #reconcileActive(): void {\n if (!this.hasTriggerTarget || this.#isClosed) {\n if (this.hasTriggerTarget) this.#setActive(-1);\n return;\n }\n\n const options = this.optionTargets;\n const currentIndex = this.#findActiveIndex(options);\n if (currentIndex >= 0) {\n const active = options[currentIndex] ?? null;\n const marked = options.filter((option) => option.hasAttribute(\"data-active\"));\n const stateMatches =\n this.#activeId === (active?.id || null) &&\n marked.length === 1 &&\n marked[0] === active &&\n this.triggerTarget.getAttribute(\"aria-activedescendant\") === (active?.id || null);\n\n if (stateMatches) {\n // Keep the deletion fallback snapshot current without re-scrolling the\n // already-active option before every keyboard command.\n this.#activeOrder = options.map((option) => option.id).filter(Boolean);\n } else {\n this.#setActive(currentIndex);\n }\n return;\n }\n\n const activeId = this.triggerTarget.getAttribute(\"aria-activedescendant\") ?? this.#activeId;\n this.#setActive(activeId ? this.#findFallbackIndex(options, activeId) : -1);\n }\n\n /** Finds the live target carrying the stable ID, or the active marker for an ID-less option. */\n #findActiveIndex(options: readonly HTMLElement[]): number {\n const activeId =\n (this.hasTriggerTarget ? this.triggerTarget.getAttribute(\"aria-activedescendant\") : null) ??\n this.#activeId;\n if (activeId) return options.findIndex((option) => option.id === activeId);\n return options.findIndex((option) => option.hasAttribute(\"data-active\"));\n }\n\n /** Chooses a surviving former successor, then a former predecessor. */\n #findFallbackIndex(options: readonly HTMLElement[], activeId: string): number {\n const oldIndex = this.#activeOrder.indexOf(activeId);\n if (oldIndex < 0) return -1;\n const indexesById = new Map(options.map((option, index) => [option.id, index]));\n for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {\n const fallback = indexesById.get(this.#activeOrder[index] ?? \"\");\n if (fallback !== undefined) return fallback;\n }\n for (let index = oldIndex - 1; index >= 0; index -= 1) {\n const fallback = indexesById.get(this.#activeOrder[index] ?? \"\");\n if (fallback !== undefined) return fallback;\n }\n return -1;\n }\n\n /** Coalesces all target callbacks from one MutationObserver batch. */\n #queueOptionReconciliation(): void {\n this.#reconcile.schedule();\n }\n\n /**\n * Advances the typeahead query and activates the next matching option.\n *\n * The search resumes just after the active option so repeating a character\n * cycles through the options starting with it, rather than re-activating the\n * same first match on every press.\n */\n #typeaheadTo(options: HTMLElement[], activeIndex: number, char: string): void {\n const index = findTypeaheadMatch(options, activeIndex, this.#typeahead.push(char));\n if (index !== -1) this.#setActive(index);\n }\n\n /** Closes on an outside click before an inside handler can detach its target. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (!this.#isClosed && !this.element.contains(event.target as Node)) this.close();\n };\n\n /** Whether the list is currently hidden. */\n get #isClosed(): boolean {\n return !this.hasListTarget || this.listTarget.hidden !== false;\n }\n}\n"]}
@@ -94,6 +94,8 @@ var LocalTimeController = class extends Controller {
94
94
  * its condition is that formatting was applied, and a repaint applies it with a
95
95
  * new result. A pass that cannot format writes nothing and emits nothing, so the
96
96
  * authored absolute text stays as the fallback.
97
+ *
98
+ * @stimeoRenderRoot
97
99
  */
98
100
  #render() {
99
101
  const date = this.#parse();