stimeo-ui 0.1.0-beta.3 → 0.2.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 (67) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +6 -6
  3. package/dist/controllers/auto_submit_controller.js +56 -21
  4. package/dist/controllers/auto_submit_controller.js.map +1 -1
  5. package/dist/controllers/character_counter_controller.js +52 -15
  6. package/dist/controllers/character_counter_controller.js.map +1 -1
  7. package/dist/controllers/combobox_controller.d.ts +6 -2
  8. package/dist/controllers/combobox_controller.js +64 -3
  9. package/dist/controllers/combobox_controller.js.map +1 -1
  10. package/dist/controllers/command_palette_controller.d.ts +13 -6
  11. package/dist/controllers/command_palette_controller.js +243 -39
  12. package/dist/controllers/command_palette_controller.js.map +1 -1
  13. package/dist/controllers/confirm_controller.js +94 -8
  14. package/dist/controllers/confirm_controller.js.map +1 -1
  15. package/dist/controllers/context_menu_controller.d.ts +11 -6
  16. package/dist/controllers/context_menu_controller.js +191 -13
  17. package/dist/controllers/context_menu_controller.js.map +1 -1
  18. package/dist/controllers/countdown_controller.d.ts +2 -2
  19. package/dist/controllers/countdown_controller.js +2 -2
  20. package/dist/controllers/countdown_controller.js.map +1 -1
  21. package/dist/controllers/dialog_controller.js +94 -8
  22. package/dist/controllers/dialog_controller.js.map +1 -1
  23. package/dist/controllers/dropdown_controller.d.ts +9 -6
  24. package/dist/controllers/dropdown_controller.js +139 -16
  25. package/dist/controllers/dropdown_controller.js.map +1 -1
  26. package/dist/controllers/focus_controller.js +94 -8
  27. package/dist/controllers/focus_controller.js.map +1 -1
  28. package/dist/controllers/form_validation_controller.js +2 -2
  29. package/dist/controllers/form_validation_controller.js.map +1 -1
  30. package/dist/controllers/hover_card_controller.d.ts +15 -12
  31. package/dist/controllers/hover_card_controller.js +103 -27
  32. package/dist/controllers/hover_card_controller.js.map +1 -1
  33. package/dist/controllers/intersection_controller.js.map +1 -1
  34. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  35. package/dist/controllers/listbox_controller.js +1 -0
  36. package/dist/controllers/listbox_controller.js.map +1 -1
  37. package/dist/controllers/menu_controller.d.ts +18 -11
  38. package/dist/controllers/menu_controller.js +181 -8
  39. package/dist/controllers/menu_controller.js.map +1 -1
  40. package/dist/controllers/otp_controller.js +62 -22
  41. package/dist/controllers/otp_controller.js.map +1 -1
  42. package/dist/controllers/overflow_menu_controller.d.ts +3 -2
  43. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  44. package/dist/controllers/pointer_drag_controller.js +3 -3
  45. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  46. package/dist/controllers/popover_controller.d.ts +18 -10
  47. package/dist/controllers/popover_controller.js +118 -27
  48. package/dist/controllers/popover_controller.js.map +1 -1
  49. package/dist/controllers/scroll_area_controller.js.map +1 -1
  50. package/dist/controllers/scrollspy_controller.js.map +1 -1
  51. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  52. package/dist/controllers/tabs_controller.d.ts +1 -2
  53. package/dist/controllers/tabs_controller.js.map +1 -1
  54. package/dist/controllers/toast_controller.d.ts +7 -9
  55. package/dist/controllers/toast_controller.js +108 -29
  56. package/dist/controllers/toast_controller.js.map +1 -1
  57. package/dist/controllers/tooltip_controller.d.ts +15 -18
  58. package/dist/controllers/tooltip_controller.js +148 -42
  59. package/dist/controllers/tooltip_controller.js.map +1 -1
  60. package/dist/index.d.ts +40 -10
  61. package/dist/index.js +833 -348
  62. package/dist/index.js.map +1 -1
  63. package/dist/inspector/cli.js.map +1 -1
  64. package/dist/inspector/cli_bin.js.map +1 -1
  65. package/dist/inspector/examples.json +6 -6
  66. package/dist/inspector/manifest.json +16 -3
  67. package/package.json +7 -7
package/CHANGELOG.md CHANGED
@@ -7,6 +7,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
  While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
8
8
  change between releases.
9
9
 
10
+ ## [0.2.0] - 2026-07-22
11
+
12
+ First release off the beta channel: `npm install stimeo-ui` now resolves
13
+ this version as `latest`, and the gem no longer needs a prerelease pin. The
14
+ library is still `0.x`, so the `stimeo--*` attribute API may change before
15
+ 1.0. The changes below come from a component-by-component hardening review
16
+ of the catalog, centered on layered Escape handling, IME-safe input, and
17
+ focus/dismissal correctness in the overlay controllers.
18
+
19
+ ### Added
20
+
21
+ - dropdown: the trigger is now associated with its menu — on connect the
22
+ controller sets `aria-controls`, minting a menu id when needed. Authored
23
+ markup is never overwritten.
24
+ - toast: item interaction (dismiss click, Escape, hover/focus pause) is now
25
+ delegated from the list container, so dynamically appended toasts work
26
+ without per-item `data-action` attributes; the dismiss button is the
27
+ element carrying the new `data-toast-dismiss` attribute. Existing per-item
28
+ markup keeps working.
29
+ - Inspector: new checks — the menu target of `stimeo--menu` must carry an
30
+ accessible name, and every command-palette option needs a unique `id`.
31
+
32
+ ### Changed
33
+
34
+ - tooltip, hover-card: Escape dismissal is now owned by the shared layer
35
+ stack, so the redundant `onKeydown` action was removed from the
36
+ controllers and the documented trigger markup. Markup still binding it
37
+ logs a Stimulus missing-action warning; dismissal keeps working.
38
+ - toast: the live-region role (`status`/`alert`) now applies to the body
39
+ slot instead of the toast item, so screen readers announce only the
40
+ message text.
41
+ - menu, overflow-menu: the documented markup contract now requires an
42
+ accessible name on the `role="menu"` element (`aria-labelledby` or
43
+ `aria-label`). Existing markup keeps working; the new Inspector check
44
+ flags the missing name.
45
+ - command-palette: option ARIA is now controller-managed — `data-disabled`
46
+ and authored `aria-disabled` stay in sync, and every option gets a
47
+ baseline `aria-selected="false"`.
48
+
49
+ ### Fixed
50
+
51
+ - Escape now dismisses exactly one overlay layer per press — the most
52
+ recently opened one — when overlays are stacked: nested dialogs no longer
53
+ close together, a tooltip or hover card shown above an open menu or dialog
54
+ no longer closes the layer beneath it, a press already handled by a nested
55
+ component (an inline edit, a listbox, a toast) is never acted on twice,
56
+ and an Escape aimed at another layer no longer closes an overlay
57
+ underneath or yanks focus back to its trigger. This covers dropdown,
58
+ popover, menu, menubar, context-menu, navigation-menu, hover-card,
59
+ tooltip, and the modal overlays; a combobox with its list closed now lets
60
+ Escape reach the enclosing dialog.
61
+ - Escape or Enter pressed during an IME composition now steers the
62
+ composition only: cancelling a Japanese/Chinese/Korean conversion no
63
+ longer closes overlays, dismisses a toast or dismissible, or cancels an
64
+ inline edit, a drag, or a pending date-range start — and a
65
+ conversion-confirming Enter no longer saves an edit, commits a tag, or
66
+ selects a command. Filtering inputs (combobox, multi-select,
67
+ command-palette) apply the confirmed text once on `compositionend` instead
68
+ of filtering intermediate text. The guards track the composition lifecycle
69
+ instead of the deprecated `keyCode === 229` heuristic, and otp ignores
70
+ events flagged `isComposing` even without a preceding `compositionstart`.
71
+ - command-palette: the open/close hotkey no longer misfires when Shift or
72
+ Alt is held or when both Cmd and Ctrl are down, an unsupported hotkey
73
+ value now does nothing, and stale `aria-activedescendant`/active-option
74
+ state is cleaned up correctly.
75
+ - menu, context-menu: `aria-disabled="true"` items are truly inert —
76
+ activating one runs no consumer handlers and the menu stays open — and
77
+ Tab now lets the browser move focus before the menu closes. context-menu
78
+ also closes on an outside right-click without stealing focus from the
79
+ new target.
80
+ - popover, navigation-menu, dropdown: outside-interaction handling
81
+ corrected — an outside click closes without refocusing the trigger,
82
+ clicking non-focusable panel content or deactivating the browser window
83
+ no longer dismisses an open panel, and dropdown's outside-click listener
84
+ runs in the capture phase so a widget that stops click propagation can no
85
+ longer leave a stuck menu.
86
+ - tooltip, hover-card: keyboard focus and pointer hover are now tracked as
87
+ separate reasons to stay open (per WCAG 1.4.13), and a pending show/hide
88
+ delay no longer leaves the element permanently stuck after a
89
+ disconnect/reconnect cycle (e.g. a Turbo restore). The documented
90
+ hover-card markup now also closes the card after keyboard focus leaves it.
91
+ - toast: runtime `duration` changes now take effect (0 or below makes
92
+ toasts persistent), a toast paused after its time fully elapsed dismisses
93
+ immediately, and dismissal is idempotent — the dismiss event fires exactly
94
+ once per removal.
95
+ - dismissible: the `closeOnEscape` value is live after connect, and focus
96
+ retreat now skips unfocusable candidates instead of silently dropping
97
+ focus to `<body>`.
98
+
10
99
  ## [0.1.0-beta.3] - 2026-07-18
11
100
 
12
101
  Maintenance release: one bug fix in the focus-trap primitive shared by the
@@ -86,6 +175,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
86
175
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
87
176
  RubyGems.
88
177
 
178
+ [0.2.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.0
89
179
  [0.1.0-beta.3]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.3
90
180
  [0.1.0-beta.2]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.2
91
181
  [0.1.0-beta.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.1
package/README.md CHANGED
@@ -5,9 +5,9 @@
5
5
  </picture>
6
6
  </h1>
7
7
 
8
- <p align="center"><a href="https://stimeo-labs.com"><strong>Live demo (beta) →</strong></a></p>
8
+ <p align="center"><a href="https://stimeo-labs.com"><strong>Live demo →</strong></a></p>
9
9
 
10
- [![CI](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/stimeo-ui/beta)](https://www.npmjs.com/package/stimeo-ui) [![gem](https://img.shields.io/gem/v/stimeo-ui)](https://rubygems.org/gems/stimeo-ui) [![License: MIT](https://img.shields.io/github/license/taiyaky/stimeo-ui)](LICENSE)
10
+ [![CI](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/stimeo-ui)](https://www.npmjs.com/package/stimeo-ui) [![gem](https://img.shields.io/gem/v/stimeo-ui)](https://rubygems.org/gems/stimeo-ui) [![License: MIT](https://img.shields.io/github/license/taiyaky/stimeo-ui)](LICENSE)
11
11
 
12
12
  **Headless Stimulus UI framework for Ruby on Rails.** Stimeo UI ships *behavior*
13
13
  — ARIA state, keyboard interaction, focus management, Turbo resilience — as
@@ -23,15 +23,15 @@ owns the look entirely.
23
23
  - Public controller identifiers use the `stimeo--` namespace (e.g.
24
24
  `stimeo--dropdown`).
25
25
 
26
- > Status: **beta** (`0.x`). The `stimeo--*` attribute API may still change before
27
- > 1.0 — pin your version.
26
+ > Status: **pre-release** (`0.x`). The `stimeo--*` attribute API may still
27
+ > change before 1.0 — pin your version.
28
28
 
29
29
  ## Install
30
30
 
31
31
  ### Rails with importmap (recommended)
32
32
 
33
33
  ```bash
34
- bundle add stimeo-ui --version "0.1.0.pre.beta.3"
34
+ bundle add stimeo-ui
35
35
  bin/rails generate stimeo:install
36
36
  ```
37
37
 
@@ -50,7 +50,7 @@ Stimulus application. Then drive components from HTML alone:
50
50
  ### npm (jsbundling or any bundler)
51
51
 
52
52
  ```bash
53
- npm install stimeo-ui@beta @hotwired/stimulus
53
+ npm install stimeo-ui @hotwired/stimulus
54
54
  ```
55
55
 
56
56
  ```js
@@ -2,6 +2,53 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/auto_submit_controller.ts
4
4
 
5
+ // src/utils/composition_tracker.ts
6
+ var CompositionTracker = class {
7
+ #observedTargets = /* @__PURE__ */ new Set();
8
+ #activeTargets = /* @__PURE__ */ new Set();
9
+ #onStart;
10
+ #onEnd;
11
+ constructor(options = {}) {
12
+ this.#onStart = options.onStart;
13
+ this.#onEnd = options.onEnd;
14
+ }
15
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
16
+ observe(target) {
17
+ if (this.#observedTargets.has(target)) return;
18
+ target.addEventListener("compositionstart", this.#handleStart);
19
+ target.addEventListener("compositionend", this.#handleEnd);
20
+ this.#observedTargets.add(target);
21
+ }
22
+ /** Stops tracking one target and clears any active composition it owned. */
23
+ unobserve(target) {
24
+ if (!this.#observedTargets.delete(target)) return;
25
+ target.removeEventListener("compositionstart", this.#handleStart);
26
+ target.removeEventListener("compositionend", this.#handleEnd);
27
+ this.#activeTargets.delete(target);
28
+ }
29
+ /** Releases every listener and clears state so reconnect starts cleanly. */
30
+ disconnect() {
31
+ for (const target of this.#observedTargets) {
32
+ target.removeEventListener("compositionstart", this.#handleStart);
33
+ target.removeEventListener("compositionend", this.#handleEnd);
34
+ }
35
+ this.#observedTargets.clear();
36
+ this.#activeTargets.clear();
37
+ }
38
+ /** True when lifecycle tracking or the current event reports composition. */
39
+ isComposing(event) {
40
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
41
+ }
42
+ #handleStart = (event) => {
43
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
+ this.#onStart?.(event);
45
+ };
46
+ #handleEnd = (event) => {
47
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
48
+ this.#onEnd?.(event);
49
+ };
50
+ };
51
+
5
52
  // src/utils/safe_timeout.ts
6
53
  var TimerRegistry = class {
7
54
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -79,34 +126,22 @@ var AutoSubmitController = class extends Controller {
79
126
  window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
80
127
  }
81
128
  };
82
- /** True while an IME composition is in progress on one of the form's fields. */
83
- #composing = false;
84
- /** Marks composition active so `input` events mid-conversion don't submit. */
85
- #onCompositionStart = () => {
86
- this.#composing = true;
87
- };
88
- /**
89
- * Composition finished (the IME conversion is confirmed): clear the flag and
90
- * schedule a submit as if `input` fired, so the settled text triggers a submit
91
- * even on browsers whose post-composition `input` still reads `isComposing`.
92
- */
93
- #onCompositionEnd = (event) => {
94
- this.#composing = false;
95
- if (this.#triggers("input")) this.#schedule(event.target ?? null);
96
- };
129
+ /** Owns delegated IME lifecycle state and submits confirmed input text. */
130
+ #composition = new CompositionTracker({
131
+ onEnd: (event) => {
132
+ if (this.#triggers("input")) this.#schedule(event.target ?? null);
133
+ }
134
+ });
97
135
  connect() {
98
136
  this.#form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
99
- this.#form.addEventListener("compositionstart", this.#onCompositionStart);
100
- this.#form.addEventListener("compositionend", this.#onCompositionEnd);
137
+ this.#composition.observe(this.#form);
101
138
  }
102
139
  disconnect() {
103
140
  this.#timers.clearAll();
104
141
  this.#pendingId = 0;
105
- this.#composing = false;
142
+ this.#composition.disconnect();
106
143
  this.#form.removeAttribute("data-auto-submit-pending");
107
144
  this.#form.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
108
- this.#form.removeEventListener("compositionstart", this.#onCompositionStart);
109
- this.#form.removeEventListener("compositionend", this.#onCompositionEnd);
110
145
  }
111
146
  /**
112
147
  * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
@@ -115,7 +150,7 @@ var AutoSubmitController = class extends Controller {
115
150
  */
116
151
  submit(event) {
117
152
  if (!this.#triggers(event.type)) return;
118
- if (event.type === "input" && (this.#composing || event.isComposing)) return;
153
+ if (event.type === "input" && this.#composition.isComposing(event)) return;
119
154
  this.#schedule(event.target ?? null);
120
155
  }
121
156
  /** Schedules (and coalesces) the debounced submit for the given trigger. */
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/auto_submit_controller.ts"],"names":[],"mappings":"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","file":"auto_submit_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 **debounced auto-submit** for forms (no dedicated APG pattern). Submits\n * the form a configurable delay after `input`/`change`, so Rails search/filter\n * forms refresh via Turbo without a submit button.\n *\n * Markup contract (identifier: `stimeo--auto-submit`):\n * <form data-controller=\"stimeo--auto-submit\"\n * data-stimeo--auto-submit-debounce-value=\"300\"\n * data-action=\"input->stimeo--auto-submit#submit\n * change->stimeo--auto-submit#submit\">\n * <input type=\"search\" name=\"q\">\n * </form>\n *\n * <!-- Or with the form as a target nested under the controller element: -->\n * <div data-controller=\"stimeo--auto-submit\">\n * <form data-stimeo--auto-submit-target=\"form\"> … </form>\n * </div>\n *\n * @remarks\n * Behavior only — it owns *triggering* the submit (debounce + `requestSubmit`),\n * never the submit itself (Turbo / native form submission) or validation. It\n * **never moves focus** (WCAG 2.2 3.2.2 / 4.1.3): auto-submitting must not yank the\n * caret out of the field. While a result swap is silent for screen-reader users,\n * setting `announce` bridges the completion to the shared `stimeo--announcer`\n * as a safety net; apps can also listen for `stimeo--auto-submit:done`\n * and announce richer text themselves. `aria-busy` marks the in-flight window and\n * `data-auto-submit-pending` the debounce window, for consumer CSS. During IME\n * composition it holds the submit until `compositionend` (the confirmed\n * conversion) so it does not fire on each intermediate keystroke. The debounce\n * timer and the `turbo:submit-end`/composition listeners are torn down on\n * `disconnect()`.\n */\nexport class AutoSubmitController extends Controller<HTMLElement> {\n static override targets = [\"form\"];\n static override values = {\n debounce: { type: Number, default: 300 },\n on: { type: String, default: \"input change\" },\n announce: { type: Boolean, default: false },\n message: { type: String, default: \"\" },\n };\n static actions = [\"submit\"] as const;\n static events = [\"submit\", \"done\"] as const;\n\n declare readonly formTarget: HTMLFormElement;\n declare readonly hasFormTarget: boolean;\n\n declare debounceValue: number;\n declare onValue: string;\n declare announceValue: boolean;\n declare messageValue: string;\n\n /** Debounce timer registry; one `clearAll()` in disconnect tears it down. */\n readonly #timers = new SafeTimeout();\n /** Id of the pending debounce timer, so a new keystroke can reset it. */\n #pendingId = 0;\n\n /** Clears `aria-busy` and emits completion once Turbo finishes the submit. */\n readonly #onSubmitEnd = (): void => {\n this.#form.removeAttribute(\"aria-busy\");\n const message = this.messageValue;\n this.dispatch(\"done\", { detail: { message: message || undefined } });\n // Bridge the silent result swap to the shared Announcer so SR users hear it.\n if (this.announceValue && message) {\n window.dispatchEvent(new CustomEvent(\"stimeo--announcer:announce\", { detail: { message } }));\n }\n };\n\n /** True while an IME composition is in progress on one of the form's fields. */\n #composing = false;\n\n /** Marks composition active so `input` events mid-conversion don't submit. */\n readonly #onCompositionStart = (): void => {\n this.#composing = true;\n };\n\n /**\n * Composition finished (the IME conversion is confirmed): clear the flag and\n * schedule a submit as if `input` fired, so the settled text triggers a submit\n * even on browsers whose post-composition `input` still reads `isComposing`.\n */\n readonly #onCompositionEnd = (event: Event): void => {\n this.#composing = false;\n if (this.#triggers(\"input\")) this.#schedule((event.target as HTMLElement | null) ?? null);\n };\n\n override connect(): void {\n this.#form.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#form.addEventListener(\"compositionstart\", this.#onCompositionStart);\n this.#form.addEventListener(\"compositionend\", this.#onCompositionEnd);\n }\n\n override disconnect(): void {\n this.#timers.clearAll();\n this.#pendingId = 0;\n // Reset so a same-instance reconnect (e.g. Turbo cache restore mid-composition)\n // never starts with submits suppressed.\n this.#composing = false;\n this.#form.removeAttribute(\"data-auto-submit-pending\");\n this.#form.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#form.removeEventListener(\"compositionstart\", this.#onCompositionStart);\n this.#form.removeEventListener(\"compositionend\", this.#onCompositionEnd);\n }\n\n /**\n * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an\n * allowlist so a configured subset (e.g. only `change`) is honored even when both\n * are bound in markup. Coalesces rapid events into a single `requestSubmit`.\n */\n submit(event: Event): void {\n if (!this.#triggers(event.type)) return;\n // Ignore `input` events fired mid-IME-composition (e.g. typing kana before the\n // Japanese conversion is confirmed); the confirmed text submits on\n // `compositionend` and the browser's final post-composition `input`.\n if (event.type === \"input\" && (this.#composing || (event as InputEvent).isComposing)) return;\n this.#schedule((event.target as HTMLElement | null) ?? null);\n }\n\n /** Schedules (and coalesces) the debounced submit for the given trigger. */\n #schedule(trigger: HTMLElement | null): void {\n this.#form.setAttribute(\"data-auto-submit-pending\", \"true\");\n if (this.#pendingId) this.#timers.clear(this.#pendingId);\n\n this.#pendingId = this.#timers.set(() => {\n this.#pendingId = 0;\n this.#form.removeAttribute(\"data-auto-submit-pending\");\n this.dispatch(\"submit\", { detail: { trigger } });\n // `requestSubmit()` runs native constraint validation. If the form is\n // invalid the actual submit never happens — the browser blocks it (or, when\n // a `stimeo--form-validation` set `novalidate`, that controller cancels the\n // submit) — so no `turbo:submit-end` arrives to clear `aria-busy`. Only mark\n // the form busy when the submit will really proceed; still call\n // `requestSubmit()` either way so the validation surfaces to the user.\n if (this.#form.checkValidity()) {\n this.#form.setAttribute(\"aria-busy\", \"true\");\n }\n this.#form.requestSubmit();\n }, this.debounceValue);\n }\n\n /** Resolves the form element (explicit `form` target, else the controller root). */\n get #form(): HTMLFormElement {\n return this.hasFormTarget ? this.formTarget : (this.element as HTMLFormElement);\n }\n\n /** Whether `type` is one of the whitespace-separated event types in `on`. */\n #triggers(type: string): boolean {\n return this.onValue.split(/\\s+/).filter(Boolean).includes(type);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/utils/safe_timeout.ts","../../src/controllers/auto_submit_controller.ts"],"names":[],"mappings":"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","file":"auto_submit_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\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 { CompositionTracker } from \"../utils/composition_tracker\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless **debounced auto-submit** for forms (no dedicated APG pattern). Submits\n * the form a configurable delay after `input`/`change`, so Rails search/filter\n * forms refresh via Turbo without a submit button.\n *\n * Markup contract (identifier: `stimeo--auto-submit`):\n * <form data-controller=\"stimeo--auto-submit\"\n * data-stimeo--auto-submit-debounce-value=\"300\"\n * data-action=\"input->stimeo--auto-submit#submit\n * change->stimeo--auto-submit#submit\">\n * <input type=\"search\" name=\"q\">\n * </form>\n *\n * <!-- Or with the form as a target nested under the controller element: -->\n * <div data-controller=\"stimeo--auto-submit\">\n * <form data-stimeo--auto-submit-target=\"form\"> … </form>\n * </div>\n *\n * @remarks\n * Behavior only — it owns *triggering* the submit (debounce + `requestSubmit`),\n * never the submit itself (Turbo / native form submission) or validation. It\n * **never moves focus** (WCAG 2.2 3.2.2 / 4.1.3): auto-submitting must not yank the\n * caret out of the field. While a result swap is silent for screen-reader users,\n * setting `announce` bridges the completion to the shared `stimeo--announcer`\n * as a safety net; apps can also listen for `stimeo--auto-submit:done`\n * and announce richer text themselves. `aria-busy` marks the in-flight window and\n * `data-auto-submit-pending` the debounce window, for consumer CSS. During IME\n * composition it holds the submit until `compositionend` (the confirmed\n * conversion) so it does not fire on each intermediate keystroke. The debounce\n * timer and the `turbo:submit-end`/composition listeners are torn down on\n * `disconnect()`.\n */\nexport class AutoSubmitController extends Controller<HTMLElement> {\n static override targets = [\"form\"];\n static override values = {\n debounce: { type: Number, default: 300 },\n on: { type: String, default: \"input change\" },\n announce: { type: Boolean, default: false },\n message: { type: String, default: \"\" },\n };\n static actions = [\"submit\"] as const;\n static events = [\"submit\", \"done\"] as const;\n\n declare readonly formTarget: HTMLFormElement;\n declare readonly hasFormTarget: boolean;\n\n declare debounceValue: number;\n declare onValue: string;\n declare announceValue: boolean;\n declare messageValue: string;\n\n /** Debounce timer registry; one `clearAll()` in disconnect tears it down. */\n readonly #timers = new SafeTimeout();\n /** Id of the pending debounce timer, so a new keystroke can reset it. */\n #pendingId = 0;\n\n /** Clears `aria-busy` and emits completion once Turbo finishes the submit. */\n readonly #onSubmitEnd = (): void => {\n this.#form.removeAttribute(\"aria-busy\");\n const message = this.messageValue;\n this.dispatch(\"done\", { detail: { message: message || undefined } });\n // Bridge the silent result swap to the shared Announcer so SR users hear it.\n if (this.announceValue && message) {\n window.dispatchEvent(new CustomEvent(\"stimeo--announcer:announce\", { detail: { message } }));\n }\n };\n\n /** Owns delegated IME lifecycle state and submits confirmed input text. */\n readonly #composition = new CompositionTracker({\n onEnd: (event) => {\n if (this.#triggers(\"input\")) this.#schedule((event.target as HTMLElement | null) ?? null);\n },\n });\n\n override connect(): void {\n this.#form.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#composition.observe(this.#form);\n }\n\n override disconnect(): void {\n this.#timers.clearAll();\n this.#pendingId = 0;\n this.#composition.disconnect();\n this.#form.removeAttribute(\"data-auto-submit-pending\");\n this.#form.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n }\n\n /**\n * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an\n * allowlist so a configured subset (e.g. only `change`) is honored even when both\n * are bound in markup. Coalesces rapid events into a single `requestSubmit`.\n */\n submit(event: Event): void {\n if (!this.#triggers(event.type)) return;\n // Ignore `input` events fired mid-IME-composition (e.g. typing kana before the\n // Japanese conversion is confirmed); the confirmed text submits on\n // `compositionend` and the browser's final post-composition `input`.\n if (event.type === \"input\" && this.#composition.isComposing(event as InputEvent)) return;\n this.#schedule((event.target as HTMLElement | null) ?? null);\n }\n\n /** Schedules (and coalesces) the debounced submit for the given trigger. */\n #schedule(trigger: HTMLElement | null): void {\n this.#form.setAttribute(\"data-auto-submit-pending\", \"true\");\n if (this.#pendingId) this.#timers.clear(this.#pendingId);\n\n this.#pendingId = this.#timers.set(() => {\n this.#pendingId = 0;\n this.#form.removeAttribute(\"data-auto-submit-pending\");\n this.dispatch(\"submit\", { detail: { trigger } });\n // `requestSubmit()` runs native constraint validation. If the form is\n // invalid the actual submit never happens — the browser blocks it (or, when\n // a `stimeo--form-validation` set `novalidate`, that controller cancels the\n // submit) — so no `turbo:submit-end` arrives to clear `aria-busy`. Only mark\n // the form busy when the submit will really proceed; still call\n // `requestSubmit()` either way so the validation surfaces to the user.\n if (this.#form.checkValidity()) {\n this.#form.setAttribute(\"aria-busy\", \"true\");\n }\n this.#form.requestSubmit();\n }, this.debounceValue);\n }\n\n /** Resolves the form element (explicit `form` target, else the controller root). */\n get #form(): HTMLFormElement {\n return this.hasFormTarget ? this.formTarget : (this.element as HTMLFormElement);\n }\n\n /** Whether `type` is one of the whitespace-separated event types in `on`. */\n #triggers(type: string): boolean {\n return this.onValue.split(/\\s+/).filter(Boolean).includes(type);\n }\n}\n"]}
@@ -2,6 +2,53 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/character_counter_controller.ts
4
4
 
5
+ // src/utils/composition_tracker.ts
6
+ var CompositionTracker = class {
7
+ #observedTargets = /* @__PURE__ */ new Set();
8
+ #activeTargets = /* @__PURE__ */ new Set();
9
+ #onStart;
10
+ #onEnd;
11
+ constructor(options = {}) {
12
+ this.#onStart = options.onStart;
13
+ this.#onEnd = options.onEnd;
14
+ }
15
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
16
+ observe(target) {
17
+ if (this.#observedTargets.has(target)) return;
18
+ target.addEventListener("compositionstart", this.#handleStart);
19
+ target.addEventListener("compositionend", this.#handleEnd);
20
+ this.#observedTargets.add(target);
21
+ }
22
+ /** Stops tracking one target and clears any active composition it owned. */
23
+ unobserve(target) {
24
+ if (!this.#observedTargets.delete(target)) return;
25
+ target.removeEventListener("compositionstart", this.#handleStart);
26
+ target.removeEventListener("compositionend", this.#handleEnd);
27
+ this.#activeTargets.delete(target);
28
+ }
29
+ /** Releases every listener and clears state so reconnect starts cleanly. */
30
+ disconnect() {
31
+ for (const target of this.#observedTargets) {
32
+ target.removeEventListener("compositionstart", this.#handleStart);
33
+ target.removeEventListener("compositionend", this.#handleEnd);
34
+ }
35
+ this.#observedTargets.clear();
36
+ this.#activeTargets.clear();
37
+ }
38
+ /** True when lifecycle tracking or the current event reports composition. */
39
+ isComposing(event) {
40
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
41
+ }
42
+ #handleStart = (event) => {
43
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
+ this.#onStart?.(event);
45
+ };
46
+ #handleEnd = (event) => {
47
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
48
+ this.#onEnd?.(event);
49
+ };
50
+ };
51
+
5
52
  // src/utils/safe_timeout.ts
6
53
  var TimerRegistry = class {
7
54
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -69,35 +116,25 @@ var CharacterCounterController = class _CharacterCounterController extends Contr
69
116
  static #announceDelay = 200;
70
117
  #timeouts = new SafeTimeout();
71
118
  #announceId = null;
72
- /** True while an IME composition is active; intermediate input is skipped. */
73
- #composing = false;
119
+ /** Owns IME lifecycle state and applies the confirmed count once. */
120
+ #composition = new CompositionTracker({ onEnd: () => this.#update() });
74
121
  #onInput = (event) => {
75
- if (this.#composing || event.isComposing) return;
76
- this.#update();
77
- };
78
- #onCompositionStart = () => {
79
- this.#composing = true;
80
- };
81
- #onCompositionEnd = () => {
82
- this.#composing = false;
122
+ if (this.#composition.isComposing(event)) return;
83
123
  this.#update();
84
124
  };
85
125
  connect() {
86
126
  const field = this.#field;
87
127
  if (!field) return;
88
128
  field.addEventListener("input", this.#onInput);
89
- field.addEventListener("compositionstart", this.#onCompositionStart);
90
- field.addEventListener("compositionend", this.#onCompositionEnd);
129
+ this.#composition.observe(field);
91
130
  this.#update({ announce: false });
92
131
  }
93
132
  disconnect() {
94
133
  const field = this.#field;
95
134
  field?.removeEventListener("input", this.#onInput);
96
- field?.removeEventListener("compositionstart", this.#onCompositionStart);
97
- field?.removeEventListener("compositionend", this.#onCompositionEnd);
135
+ this.#composition.disconnect();
98
136
  this.#timeouts.clearAll();
99
137
  this.#announceId = null;
100
- this.#composing = false;
101
138
  }
102
139
  /**
103
140
  * Recomputes length-derived state. Non-text state (data hooks, `aria-invalid`)
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/character_counter_controller.ts"],"names":[],"mappings":"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","file":"character_counter_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/** Parks the field's original `aria-invalid` while we override it for over-limit. */\nconst ORIGINAL_INVALID = \"data-character-counter-original-invalid\";\n\n/**\n * Headless character-counter behavior for a text field (no dedicated APG\n * pattern; follows the WCAG 2.2 \"status messages\" practice, 4.1.3).\n *\n * Markup contract (identifier: `stimeo--character-counter`):\n * <div data-controller=\"stimeo--character-counter\"\n * data-stimeo--character-counter-max-value=\"140\">\n * <textarea data-stimeo--character-counter-target=\"input\"\n * aria-describedby=\"cc\"></textarea>\n * <span id=\"cc\" data-stimeo--character-counter-target=\"output\"\n * aria-live=\"polite\"></span>\n * </div>\n *\n * Watches the field's length, writes the remaining/used count into `output`, and\n * toggles the `data-near-limit` / `data-over-limit` state hooks (plus\n * `aria-invalid` on the field once the limit is exceeded). The field is the\n * `input` target, or — when the controller is attached straight onto an\n * `<input>`/`<textarea>` — the controller element itself.\n *\n * @remarks\n * Behavior only — the count text is updated, not styled. Outside IME composition\n * the non-text state (`data-*` hooks, `aria-invalid`, and the `change` event)\n * updates **immediately** on every input so styling and consumers stay responsive,\n * while the visible count — in the `aria-live=\"polite\"` region — is written on a\n * short debounce so a screen reader is not flooded during fast typing (it hears the\n * settled count when the user pauses). During IME composition both are deferred to\n * `compositionend`, so they reflect the confirmed characters rather than the\n * intermediate (pre-conversion) kana. The debounce timer is owned by\n * {@link SafeTimeout} and torn down on `disconnect()` (Turbo navigation included);\n * `connect()` re-reads the field so the count is correct after a cache restore.\n */\nexport class CharacterCounterController extends Controller<HTMLElement> {\n static override targets = [\"input\", \"output\"];\n static override values = {\n max: { type: Number, default: 0 },\n warnAt: { type: Number, default: 0 },\n mode: { type: String, default: \"remaining\" },\n };\n static events = [\"change\"] as const;\n\n declare readonly inputTarget: HTMLInputElement | HTMLTextAreaElement;\n declare readonly outputTarget: HTMLElement;\n declare readonly hasInputTarget: boolean;\n declare readonly hasOutputTarget: boolean;\n\n declare maxValue: number;\n declare warnAtValue: number;\n declare modeValue: string;\n\n /** Delay (ms) before the live-region count is written, to throttle SR flooding. */\n static readonly #announceDelay = 200;\n\n readonly #timeouts = new SafeTimeout();\n #announceId: number | null = null;\n\n /** True while an IME composition is active; intermediate input is skipped. */\n #composing = false;\n\n readonly #onInput = (event: Event): void => {\n // During IME composition the field holds unconverted text; defer the count to\n // `compositionend` so it reflects the confirmed characters, not each keystroke.\n if (this.#composing || (event as InputEvent).isComposing) return;\n this.#update();\n };\n\n readonly #onCompositionStart = (): void => {\n this.#composing = true;\n };\n\n readonly #onCompositionEnd = (): void => {\n this.#composing = false;\n this.#update();\n };\n\n override connect(): void {\n const field = this.#field;\n if (!field) return;\n field.addEventListener(\"input\", this.#onInput);\n field.addEventListener(\"compositionstart\", this.#onCompositionStart);\n field.addEventListener(\"compositionend\", this.#onCompositionEnd);\n // Initial render is synchronous (no announce debounce): reflect the current\n // value on connect/cache-restore without queueing a screen-reader message.\n this.#update({ announce: false });\n }\n\n override disconnect(): void {\n const field = this.#field;\n field?.removeEventListener(\"input\", this.#onInput);\n field?.removeEventListener(\"compositionstart\", this.#onCompositionStart);\n field?.removeEventListener(\"compositionend\", this.#onCompositionEnd);\n this.#timeouts.clearAll();\n this.#announceId = null;\n // Reset so a same-instance reconnect (e.g. Turbo cache restore mid-composition)\n // never starts with input suppressed.\n this.#composing = false;\n }\n\n /**\n * Recomputes length-derived state. Non-text state (data hooks, `aria-invalid`)\n * and the `change` event apply immediately; the live-region count text is\n * debounced unless `announce` is `false` (initial render).\n */\n #update(options: { announce?: boolean } = {}): void {\n const field = this.#field;\n if (!field) return;\n\n const length = field.value.length;\n const hasLimit = this.maxValue > 0;\n const remaining = hasLimit ? this.maxValue - length : null;\n const over = hasLimit && length > this.maxValue;\n const near =\n hasLimit &&\n this.warnAtValue > 0 &&\n !over &&\n remaining !== null &&\n remaining <= this.warnAtValue;\n\n this.#toggle(\"data-over-limit\", over);\n this.#toggle(\"data-near-limit\", near);\n // `aria-invalid` is a shared attribute (Form Field / server validation also use\n // it), so we never clobber an authored value: park the original when flagging\n // over-limit and restore it once back within the limit.\n if (hasLimit && over) {\n if (!field.hasAttribute(ORIGINAL_INVALID)) {\n field.setAttribute(ORIGINAL_INVALID, field.getAttribute(\"aria-invalid\") ?? \"\");\n }\n field.setAttribute(\"aria-invalid\", \"true\");\n } else if (field.hasAttribute(ORIGINAL_INVALID)) {\n const original = field.getAttribute(ORIGINAL_INVALID);\n if (original) {\n field.setAttribute(\"aria-invalid\", original);\n } else {\n field.removeAttribute(\"aria-invalid\");\n }\n field.removeAttribute(ORIGINAL_INVALID);\n }\n\n const text = this.#format(length, remaining);\n if (options.announce === false) {\n // Initial render (connect / cache-restore): reflect the current state without\n // emitting a change event or queueing a screen-reader announcement.\n this.#writeOutput(text);\n return;\n }\n\n this.dispatch(\"change\", { detail: { length, remaining, over } });\n if (this.#announceId !== null) this.#timeouts.clear(this.#announceId);\n this.#announceId = this.#timeouts.set(() => {\n this.#writeOutput(text);\n this.#announceId = null;\n }, CharacterCounterController.#announceDelay);\n }\n\n /** Builds the count text for the active `mode`. */\n #format(length: number, remaining: number | null): string {\n if (remaining === null) return String(length); // no limit → used count only\n switch (this.modeValue) {\n case \"used\":\n return String(length);\n case \"both\":\n return `${length}/${this.maxValue}`;\n default:\n return String(remaining); // \"remaining\"\n }\n }\n\n #writeOutput(text: string): void {\n if (this.hasOutputTarget) this.outputTarget.textContent = text;\n }\n\n /** Sets a presence-style boolean data hook (attribute present when `on`). */\n #toggle(name: string, on: boolean): void {\n if (on) {\n this.element.setAttribute(name, \"true\");\n } else {\n this.element.removeAttribute(name);\n }\n }\n\n /** The watched field: the `input` target, or the element itself when it is one. */\n get #field(): HTMLInputElement | HTMLTextAreaElement | null {\n if (this.hasInputTarget) return this.inputTarget;\n const el: HTMLElement = this.element;\n if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {\n return el;\n }\n return null;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/utils/safe_timeout.ts","../../src/controllers/character_counter_controller.ts"],"names":[],"mappings":"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","file":"character_counter_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\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 { CompositionTracker } from \"../utils/composition_tracker\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/** Parks the field's original `aria-invalid` while we override it for over-limit. */\nconst ORIGINAL_INVALID = \"data-character-counter-original-invalid\";\n\n/**\n * Headless character-counter behavior for a text field (no dedicated APG\n * pattern; follows the WCAG 2.2 \"status messages\" practice, 4.1.3).\n *\n * Markup contract (identifier: `stimeo--character-counter`):\n * <div data-controller=\"stimeo--character-counter\"\n * data-stimeo--character-counter-max-value=\"140\">\n * <textarea data-stimeo--character-counter-target=\"input\"\n * aria-describedby=\"cc\"></textarea>\n * <span id=\"cc\" data-stimeo--character-counter-target=\"output\"\n * aria-live=\"polite\"></span>\n * </div>\n *\n * Watches the field's length, writes the remaining/used count into `output`, and\n * toggles the `data-near-limit` / `data-over-limit` state hooks (plus\n * `aria-invalid` on the field once the limit is exceeded). The field is the\n * `input` target, or — when the controller is attached straight onto an\n * `<input>`/`<textarea>` — the controller element itself.\n *\n * @remarks\n * Behavior only — the count text is updated, not styled. Outside IME composition\n * the non-text state (`data-*` hooks, `aria-invalid`, and the `change` event)\n * updates **immediately** on every input so styling and consumers stay responsive,\n * while the visible count — in the `aria-live=\"polite\"` region — is written on a\n * short debounce so a screen reader is not flooded during fast typing (it hears the\n * settled count when the user pauses). During IME composition both are deferred to\n * `compositionend`, so they reflect the confirmed characters rather than the\n * intermediate (pre-conversion) kana. The debounce timer is owned by\n * {@link SafeTimeout} and torn down on `disconnect()` (Turbo navigation included);\n * `connect()` re-reads the field so the count is correct after a cache restore.\n */\nexport class CharacterCounterController extends Controller<HTMLElement> {\n static override targets = [\"input\", \"output\"];\n static override values = {\n max: { type: Number, default: 0 },\n warnAt: { type: Number, default: 0 },\n mode: { type: String, default: \"remaining\" },\n };\n static events = [\"change\"] as const;\n\n declare readonly inputTarget: HTMLInputElement | HTMLTextAreaElement;\n declare readonly outputTarget: HTMLElement;\n declare readonly hasInputTarget: boolean;\n declare readonly hasOutputTarget: boolean;\n\n declare maxValue: number;\n declare warnAtValue: number;\n declare modeValue: string;\n\n /** Delay (ms) before the live-region count is written, to throttle SR flooding. */\n static readonly #announceDelay = 200;\n\n readonly #timeouts = new SafeTimeout();\n #announceId: number | null = null;\n\n /** Owns IME lifecycle state and applies the confirmed count once. */\n readonly #composition = new CompositionTracker({ onEnd: () => this.#update() });\n\n readonly #onInput = (event: Event): void => {\n // During IME composition the field holds unconverted text; defer the count to\n // `compositionend` so it reflects the confirmed characters, not each keystroke.\n if (this.#composition.isComposing(event as InputEvent)) return;\n this.#update();\n };\n\n override connect(): void {\n const field = this.#field;\n if (!field) return;\n field.addEventListener(\"input\", this.#onInput);\n this.#composition.observe(field);\n // Initial render is synchronous (no announce debounce): reflect the current\n // value on connect/cache-restore without queueing a screen-reader message.\n this.#update({ announce: false });\n }\n\n override disconnect(): void {\n const field = this.#field;\n field?.removeEventListener(\"input\", this.#onInput);\n this.#composition.disconnect();\n this.#timeouts.clearAll();\n this.#announceId = null;\n }\n\n /**\n * Recomputes length-derived state. Non-text state (data hooks, `aria-invalid`)\n * and the `change` event apply immediately; the live-region count text is\n * debounced unless `announce` is `false` (initial render).\n */\n #update(options: { announce?: boolean } = {}): void {\n const field = this.#field;\n if (!field) return;\n\n const length = field.value.length;\n const hasLimit = this.maxValue > 0;\n const remaining = hasLimit ? this.maxValue - length : null;\n const over = hasLimit && length > this.maxValue;\n const near =\n hasLimit &&\n this.warnAtValue > 0 &&\n !over &&\n remaining !== null &&\n remaining <= this.warnAtValue;\n\n this.#toggle(\"data-over-limit\", over);\n this.#toggle(\"data-near-limit\", near);\n // `aria-invalid` is a shared attribute (Form Field / server validation also use\n // it), so we never clobber an authored value: park the original when flagging\n // over-limit and restore it once back within the limit.\n if (hasLimit && over) {\n if (!field.hasAttribute(ORIGINAL_INVALID)) {\n field.setAttribute(ORIGINAL_INVALID, field.getAttribute(\"aria-invalid\") ?? \"\");\n }\n field.setAttribute(\"aria-invalid\", \"true\");\n } else if (field.hasAttribute(ORIGINAL_INVALID)) {\n const original = field.getAttribute(ORIGINAL_INVALID);\n if (original) {\n field.setAttribute(\"aria-invalid\", original);\n } else {\n field.removeAttribute(\"aria-invalid\");\n }\n field.removeAttribute(ORIGINAL_INVALID);\n }\n\n const text = this.#format(length, remaining);\n if (options.announce === false) {\n // Initial render (connect / cache-restore): reflect the current state without\n // emitting a change event or queueing a screen-reader announcement.\n this.#writeOutput(text);\n return;\n }\n\n this.dispatch(\"change\", { detail: { length, remaining, over } });\n if (this.#announceId !== null) this.#timeouts.clear(this.#announceId);\n this.#announceId = this.#timeouts.set(() => {\n this.#writeOutput(text);\n this.#announceId = null;\n }, CharacterCounterController.#announceDelay);\n }\n\n /** Builds the count text for the active `mode`. */\n #format(length: number, remaining: number | null): string {\n if (remaining === null) return String(length); // no limit → used count only\n switch (this.modeValue) {\n case \"used\":\n return String(length);\n case \"both\":\n return `${length}/${this.maxValue}`;\n default:\n return String(remaining); // \"remaining\"\n }\n }\n\n #writeOutput(text: string): void {\n if (this.hasOutputTarget) this.outputTarget.textContent = text;\n }\n\n /** Sets a presence-style boolean data hook (attribute present when `on`). */\n #toggle(name: string, on: boolean): void {\n if (on) {\n this.element.setAttribute(name, \"true\");\n } else {\n this.element.removeAttribute(name);\n }\n }\n\n /** The watched field: the `input` target, or the element itself when it is one. */\n get #field(): HTMLInputElement | HTMLTextAreaElement | null {\n if (this.hasInputTarget) return this.inputTarget;\n const el: HTMLElement = this.element;\n if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {\n return el;\n }\n return null;\n }\n}\n"]}
@@ -57,8 +57,12 @@ declare class ComboboxController extends Controller<HTMLElement> {
57
57
  connect(): void;
58
58
  /** Removes the document-level listener registered in {@link connect}. */
59
59
  disconnect(): void;
60
- /** Filters options by the current input value and opens the listbox. */
61
- filter(): void;
60
+ /** Tracks an input added initially or after connect. */
61
+ inputTargetConnected(input: HTMLInputElement): void;
62
+ /** Removes composition listeners when the active input is replaced or removed. */
63
+ inputTargetDisconnected(input: HTMLInputElement): void;
64
+ /** Filters confirmed input text and opens the listbox. */
65
+ filter(event?: InputEvent): void;
62
66
  /**
63
67
  * Opens the listbox, re-filtering the options against the current input value
64
68
  * so the visible options and empty state always match what is typed (e.g.