stimeo-ui 0.1.0.pre.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -0
- data/README.md +6 -6
- data/dist/controllers/auto_submit_controller.js +56 -21
- data/dist/controllers/character_counter_controller.js +52 -15
- data/dist/controllers/combobox_controller.js +64 -3
- data/dist/controllers/command_palette_controller.js +256 -39
- data/dist/controllers/confirm_controller.js +107 -8
- data/dist/controllers/context_menu_controller.js +191 -13
- data/dist/controllers/countdown_controller.js +2 -2
- data/dist/controllers/dialog_controller.js +107 -8
- data/dist/controllers/dropdown_controller.js +139 -16
- data/dist/controllers/focus_controller.js +107 -8
- data/dist/controllers/form_validation_controller.js +2 -2
- data/dist/controllers/hover_card_controller.js +103 -27
- data/dist/controllers/listbox_controller.js +1 -0
- data/dist/controllers/menu_controller.js +181 -8
- data/dist/controllers/otp_controller.js +62 -22
- data/dist/controllers/pointer_drag_controller.js +3 -3
- data/dist/controllers/popover_controller.js +118 -27
- data/dist/controllers/sortable_controller.js +1 -1
- data/dist/controllers/toast_controller.js +108 -29
- data/dist/controllers/tooltip_controller.js +148 -42
- data/dist/index.js +847 -349
- data/lib/stimeo/ui/version.rb +5 -4
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10b597acf4a7ae2ea8c06755f65219ebcd6cf8620dada3700e196d75991c5544
|
|
4
|
+
data.tar.gz: d0cabdded21420aa90c4eb4eb439588ab7a0bfeff3d4f9fe83b179197de9acb2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66afe27aa72dcbd893b2e2f76525bfddc6bd55788b15ee531cbad6357342ec0bf4d3c2ada2e3cd3dad7dfa37ff9306356b866c1a6e7bdde2445cb103ef944119
|
|
7
|
+
data.tar.gz: 616de9b15f081a503d907cb468a3c6ad77a74a6773e9677f3e1b9fb28baad505d5bfff6d588c66b48ed965a5acd275f5d8cc1209d69bac2aa7a89153e143be54
|
data/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
|
data/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. */
|
|
@@ -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`)
|
|
@@ -2,6 +2,53 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/combobox_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/option_scroll.ts
|
|
6
53
|
function scrollOptionIntoView(list, option) {
|
|
7
54
|
if (list.scrollHeight <= list.clientHeight) return;
|
|
@@ -27,17 +74,30 @@ var ComboboxController = class extends Controller {
|
|
|
27
74
|
* does not immediately re-open the listbox via a `focus`-bound action.
|
|
28
75
|
*/
|
|
29
76
|
#suppressOpen = false;
|
|
77
|
+
/** Owns IME lifecycle state; confirmed text re-filters the list once. */
|
|
78
|
+
#composition = new CompositionTracker({ onEnd: () => this.filter() });
|
|
30
79
|
/** Starts closed with no active option and registers the outside-click listener. */
|
|
31
80
|
connect() {
|
|
81
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
32
82
|
this.close();
|
|
33
83
|
document.addEventListener("click", this.#onOutsideClick);
|
|
34
84
|
}
|
|
35
85
|
/** Removes the document-level listener registered in {@link connect}. */
|
|
36
86
|
disconnect() {
|
|
87
|
+
this.#composition.disconnect();
|
|
37
88
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
38
89
|
}
|
|
39
|
-
/**
|
|
40
|
-
|
|
90
|
+
/** Tracks an input added initially or after connect. */
|
|
91
|
+
inputTargetConnected(input) {
|
|
92
|
+
this.#composition.observe(input);
|
|
93
|
+
}
|
|
94
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
95
|
+
inputTargetDisconnected(input) {
|
|
96
|
+
this.#composition.unobserve(input);
|
|
97
|
+
}
|
|
98
|
+
/** Filters confirmed input text and opens the listbox. */
|
|
99
|
+
filter(event) {
|
|
100
|
+
if (this.#composition.isComposing(event)) return;
|
|
41
101
|
this.open();
|
|
42
102
|
}
|
|
43
103
|
/**
|
|
@@ -74,7 +134,7 @@ var ComboboxController = class extends Controller {
|
|
|
74
134
|
}
|
|
75
135
|
/** Routes keyboard interaction per the APG combobox model. */
|
|
76
136
|
onKeydown(event) {
|
|
77
|
-
if (
|
|
137
|
+
if (this.#composition.isComposing(event)) return;
|
|
78
138
|
switch (event.key) {
|
|
79
139
|
case "ArrowDown": {
|
|
80
140
|
event.preventDefault();
|
|
@@ -120,6 +180,7 @@ var ComboboxController = class extends Controller {
|
|
|
120
180
|
break;
|
|
121
181
|
}
|
|
122
182
|
case "Escape":
|
|
183
|
+
if (event.defaultPrevented || this.#isClosed) break;
|
|
123
184
|
event.preventDefault();
|
|
124
185
|
this.close();
|
|
125
186
|
break;
|