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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43ec968d086165fbfd9daeda12d003a6cf0cb9e8375a9abfb34924aab3383959
4
- data.tar.gz: 8c1fde6bff018d4f47ac745df3e18b2eaaa95d3658ea1ce05fd03bda5b35dc46
3
+ metadata.gz: 10b597acf4a7ae2ea8c06755f65219ebcd6cf8620dada3700e196d75991c5544
4
+ data.tar.gz: d0cabdded21420aa90c4eb4eb439588ab7a0bfeff3d4f9fe83b179197de9acb2
5
5
  SHA512:
6
- metadata.gz: 0ab3a420ebc50b56abce77cd1a4b36be4c3891d4495bd4c2ab8961a42978dc6f79443822ab389dea100b308d236064e76c2e3a0445e0daeb260e7ce4653c71d1
7
- data.tar.gz: 1ffc55e37df21a692a21da69f4e98b303ca1600bc57286a132584425392be40f654ba6570227c70436934f5b0ca2bf3d0a5dee9c39c2f24f61e70632e2992751
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 (beta) →</strong></a></p>
8
+ <p align="center"><a href="https://stimeo-labs.com"><strong>Live demo →</strong></a></p>
9
9
 
10
- [![CI](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/stimeo-ui/beta)](https://www.npmjs.com/package/stimeo-ui) [![gem](https://img.shields.io/gem/v/stimeo-ui)](https://rubygems.org/gems/stimeo-ui) [![License: MIT](https://img.shields.io/github/license/taiyaky/stimeo-ui)](LICENSE)
10
+ [![CI](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/stimeo-ui)](https://www.npmjs.com/package/stimeo-ui) [![gem](https://img.shields.io/gem/v/stimeo-ui)](https://rubygems.org/gems/stimeo-ui) [![License: MIT](https://img.shields.io/github/license/taiyaky/stimeo-ui)](LICENSE)
11
11
 
12
12
  **Headless Stimulus UI framework for Ruby on Rails.** Stimeo UI ships *behavior*
13
13
  — ARIA state, keyboard interaction, focus management, Turbo resilience — as
@@ -23,15 +23,15 @@ owns the look entirely.
23
23
  - Public controller identifiers use the `stimeo--` namespace (e.g.
24
24
  `stimeo--dropdown`).
25
25
 
26
- > Status: **beta** (`0.x`). The `stimeo--*` attribute API may still change before
27
- > 1.0 — pin your version.
26
+ > Status: **pre-release** (`0.x`). The `stimeo--*` attribute API may still
27
+ > change before 1.0 — pin your version.
28
28
 
29
29
  ## Install
30
30
 
31
31
  ### Rails with importmap (recommended)
32
32
 
33
33
  ```bash
34
- bundle add stimeo-ui --version "0.1.0.pre.beta.2"
34
+ bundle add stimeo-ui
35
35
  bin/rails generate stimeo:install
36
36
  ```
37
37
 
@@ -50,7 +50,7 @@ Stimulus application. Then drive components from HTML alone:
50
50
  ### npm (jsbundling or any bundler)
51
51
 
52
52
  ```bash
53
- npm install stimeo-ui@beta @hotwired/stimulus
53
+ npm install stimeo-ui @hotwired/stimulus
54
54
  ```
55
55
 
56
56
  ```js
@@ -2,6 +2,53 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/auto_submit_controller.ts
4
4
 
5
+ // src/utils/composition_tracker.ts
6
+ var CompositionTracker = class {
7
+ #observedTargets = /* @__PURE__ */ new Set();
8
+ #activeTargets = /* @__PURE__ */ new Set();
9
+ #onStart;
10
+ #onEnd;
11
+ constructor(options = {}) {
12
+ this.#onStart = options.onStart;
13
+ this.#onEnd = options.onEnd;
14
+ }
15
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
16
+ observe(target) {
17
+ if (this.#observedTargets.has(target)) return;
18
+ target.addEventListener("compositionstart", this.#handleStart);
19
+ target.addEventListener("compositionend", this.#handleEnd);
20
+ this.#observedTargets.add(target);
21
+ }
22
+ /** Stops tracking one target and clears any active composition it owned. */
23
+ unobserve(target) {
24
+ if (!this.#observedTargets.delete(target)) return;
25
+ target.removeEventListener("compositionstart", this.#handleStart);
26
+ target.removeEventListener("compositionend", this.#handleEnd);
27
+ this.#activeTargets.delete(target);
28
+ }
29
+ /** Releases every listener and clears state so reconnect starts cleanly. */
30
+ disconnect() {
31
+ for (const target of this.#observedTargets) {
32
+ target.removeEventListener("compositionstart", this.#handleStart);
33
+ target.removeEventListener("compositionend", this.#handleEnd);
34
+ }
35
+ this.#observedTargets.clear();
36
+ this.#activeTargets.clear();
37
+ }
38
+ /** True when lifecycle tracking or the current event reports composition. */
39
+ isComposing(event) {
40
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
41
+ }
42
+ #handleStart = (event) => {
43
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
+ this.#onStart?.(event);
45
+ };
46
+ #handleEnd = (event) => {
47
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
48
+ this.#onEnd?.(event);
49
+ };
50
+ };
51
+
5
52
  // src/utils/safe_timeout.ts
6
53
  var TimerRegistry = class {
7
54
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -79,34 +126,22 @@ var AutoSubmitController = class extends Controller {
79
126
  window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
80
127
  }
81
128
  };
82
- /** True while an IME composition is in progress on one of the form's fields. */
83
- #composing = false;
84
- /** Marks composition active so `input` events mid-conversion don't submit. */
85
- #onCompositionStart = () => {
86
- this.#composing = true;
87
- };
88
- /**
89
- * Composition finished (the IME conversion is confirmed): clear the flag and
90
- * schedule a submit as if `input` fired, so the settled text triggers a submit
91
- * even on browsers whose post-composition `input` still reads `isComposing`.
92
- */
93
- #onCompositionEnd = (event) => {
94
- this.#composing = false;
95
- if (this.#triggers("input")) this.#schedule(event.target ?? null);
96
- };
129
+ /** Owns delegated IME lifecycle state and submits confirmed input text. */
130
+ #composition = new CompositionTracker({
131
+ onEnd: (event) => {
132
+ if (this.#triggers("input")) this.#schedule(event.target ?? null);
133
+ }
134
+ });
97
135
  connect() {
98
136
  this.#form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
99
- this.#form.addEventListener("compositionstart", this.#onCompositionStart);
100
- this.#form.addEventListener("compositionend", this.#onCompositionEnd);
137
+ this.#composition.observe(this.#form);
101
138
  }
102
139
  disconnect() {
103
140
  this.#timers.clearAll();
104
141
  this.#pendingId = 0;
105
- this.#composing = false;
142
+ this.#composition.disconnect();
106
143
  this.#form.removeAttribute("data-auto-submit-pending");
107
144
  this.#form.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
108
- this.#form.removeEventListener("compositionstart", this.#onCompositionStart);
109
- this.#form.removeEventListener("compositionend", this.#onCompositionEnd);
110
145
  }
111
146
  /**
112
147
  * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
@@ -115,7 +150,7 @@ var AutoSubmitController = class extends Controller {
115
150
  */
116
151
  submit(event) {
117
152
  if (!this.#triggers(event.type)) return;
118
- if (event.type === "input" && (this.#composing || event.isComposing)) return;
153
+ if (event.type === "input" && this.#composition.isComposing(event)) return;
119
154
  this.#schedule(event.target ?? null);
120
155
  }
121
156
  /** Schedules (and coalesces) the debounced submit for the given trigger. */
@@ -2,6 +2,53 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/character_counter_controller.ts
4
4
 
5
+ // src/utils/composition_tracker.ts
6
+ var CompositionTracker = class {
7
+ #observedTargets = /* @__PURE__ */ new Set();
8
+ #activeTargets = /* @__PURE__ */ new Set();
9
+ #onStart;
10
+ #onEnd;
11
+ constructor(options = {}) {
12
+ this.#onStart = options.onStart;
13
+ this.#onEnd = options.onEnd;
14
+ }
15
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
16
+ observe(target) {
17
+ if (this.#observedTargets.has(target)) return;
18
+ target.addEventListener("compositionstart", this.#handleStart);
19
+ target.addEventListener("compositionend", this.#handleEnd);
20
+ this.#observedTargets.add(target);
21
+ }
22
+ /** Stops tracking one target and clears any active composition it owned. */
23
+ unobserve(target) {
24
+ if (!this.#observedTargets.delete(target)) return;
25
+ target.removeEventListener("compositionstart", this.#handleStart);
26
+ target.removeEventListener("compositionend", this.#handleEnd);
27
+ this.#activeTargets.delete(target);
28
+ }
29
+ /** Releases every listener and clears state so reconnect starts cleanly. */
30
+ disconnect() {
31
+ for (const target of this.#observedTargets) {
32
+ target.removeEventListener("compositionstart", this.#handleStart);
33
+ target.removeEventListener("compositionend", this.#handleEnd);
34
+ }
35
+ this.#observedTargets.clear();
36
+ this.#activeTargets.clear();
37
+ }
38
+ /** True when lifecycle tracking or the current event reports composition. */
39
+ isComposing(event) {
40
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
41
+ }
42
+ #handleStart = (event) => {
43
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
+ this.#onStart?.(event);
45
+ };
46
+ #handleEnd = (event) => {
47
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
48
+ this.#onEnd?.(event);
49
+ };
50
+ };
51
+
5
52
  // src/utils/safe_timeout.ts
6
53
  var TimerRegistry = class {
7
54
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -69,35 +116,25 @@ var CharacterCounterController = class _CharacterCounterController extends Contr
69
116
  static #announceDelay = 200;
70
117
  #timeouts = new SafeTimeout();
71
118
  #announceId = null;
72
- /** True while an IME composition is active; intermediate input is skipped. */
73
- #composing = false;
119
+ /** Owns IME lifecycle state and applies the confirmed count once. */
120
+ #composition = new CompositionTracker({ onEnd: () => this.#update() });
74
121
  #onInput = (event) => {
75
- if (this.#composing || event.isComposing) return;
76
- this.#update();
77
- };
78
- #onCompositionStart = () => {
79
- this.#composing = true;
80
- };
81
- #onCompositionEnd = () => {
82
- this.#composing = false;
122
+ if (this.#composition.isComposing(event)) return;
83
123
  this.#update();
84
124
  };
85
125
  connect() {
86
126
  const field = this.#field;
87
127
  if (!field) return;
88
128
  field.addEventListener("input", this.#onInput);
89
- field.addEventListener("compositionstart", this.#onCompositionStart);
90
- field.addEventListener("compositionend", this.#onCompositionEnd);
129
+ this.#composition.observe(field);
91
130
  this.#update({ announce: false });
92
131
  }
93
132
  disconnect() {
94
133
  const field = this.#field;
95
134
  field?.removeEventListener("input", this.#onInput);
96
- field?.removeEventListener("compositionstart", this.#onCompositionStart);
97
- field?.removeEventListener("compositionend", this.#onCompositionEnd);
135
+ this.#composition.disconnect();
98
136
  this.#timeouts.clearAll();
99
137
  this.#announceId = null;
100
- this.#composing = false;
101
138
  }
102
139
  /**
103
140
  * Recomputes length-derived state. Non-text state (data hooks, `aria-invalid`)
@@ -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
- /** Filters options by the current input value and opens the listbox. */
40
- filter() {
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 (event.isComposing || event.keyCode === 229) return;
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;