stimeo-ui 0.1.0-beta.2 → 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.
- package/CHANGELOG.md +107 -0
- package/README.md +6 -6
- package/dist/controllers/auto_submit_controller.js +56 -21
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/character_counter_controller.js +52 -15
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.d.ts +6 -2
- package/dist/controllers/combobox_controller.js +64 -3
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +13 -6
- package/dist/controllers/command_palette_controller.js +256 -39
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +107 -8
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +11 -6
- package/dist/controllers/context_menu_controller.js +191 -13
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +2 -2
- package/dist/controllers/countdown_controller.js +2 -2
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.js +107 -8
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +9 -6
- package/dist/controllers/dropdown_controller.js +139 -16
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/focus_controller.js +107 -8
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +2 -2
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +15 -12
- package/dist/controllers/hover_card_controller.js +103 -27
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.js +1 -0
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/menu_controller.d.ts +18 -11
- package/dist/controllers/menu_controller.js +181 -8
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/otp_controller.js +62 -22
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +3 -2
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.js +3 -3
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +18 -10
- package/dist/controllers/popover_controller.js +118 -27
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.d.ts +2 -2
- package/dist/controllers/sortable_controller.js +1 -1
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +1 -2
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +7 -9
- package/dist/controllers/toast_controller.js +108 -29
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +15 -18
- package/dist/controllers/tooltip_controller.js +148 -42
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/index.d.ts +44 -14
- package/dist/index.js +847 -349
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +6 -6
- package/dist/inspector/manifest.json +16 -3
- package/package.json +7 -9
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,111 @@ 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
|
+
|
|
99
|
+
## [0.1.0-beta.3] - 2026-07-18
|
|
100
|
+
|
|
101
|
+
Maintenance release: one bug fix in the focus-trap primitive shared by the
|
|
102
|
+
modal overlay controllers.
|
|
103
|
+
|
|
104
|
+
### Fixed
|
|
105
|
+
|
|
106
|
+
- dialog, alert-dialog, confirm, drawer, command-palette, sidebar: navigating
|
|
107
|
+
away while the overlay is open no longer bakes the background scroll lock
|
|
108
|
+
into Turbo's page cache. A restored page previously treated the locked
|
|
109
|
+
`<body>` as its baseline, so closing the overlay could leave the page
|
|
110
|
+
unscrollable; the shared focus trap now reverts its side effects on
|
|
111
|
+
`turbo:before-cache`.
|
|
112
|
+
- drawer: the usage example in the API reference now shows the backdrop
|
|
113
|
+
overlay `hidden` at rest and a neutral `<div>` as the panel element.
|
|
114
|
+
|
|
10
115
|
## [0.1.0-beta.2] - 2026-07-12
|
|
11
116
|
|
|
12
117
|
The Inspector grows two new faces: an MCP server for AI coding agents and a
|
|
@@ -70,6 +175,8 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
70
175
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
71
176
|
RubyGems.
|
|
72
177
|
|
|
178
|
+
[0.2.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.0
|
|
179
|
+
[0.1.0-beta.3]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.3
|
|
73
180
|
[0.1.0-beta.2]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.2
|
|
74
181
|
[0.1.0-beta.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.1
|
|
75
182
|
[0.1.0-alpha.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-alpha.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
|
|
8
|
+
<p align="center"><a href="https://stimeo-labs.com"><strong>Live demo →</strong></a></p>
|
|
9
9
|
|
|
10
|
-
[](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml) [](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml) [](https://www.npmjs.com/package/stimeo-ui) [](https://rubygems.org/gems/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: **
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.#
|
|
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.#
|
|
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" &&
|
|
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
|
-
/**
|
|
73
|
-
#
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
61
|
-
|
|
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.
|