poetry-core 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. metadata +267 -0
@@ -0,0 +1,1095 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { collectionItems } from "@poetry/controllers/helpers/collection"
3
+ import { isImeKeydown } from "@poetry/controllers/helpers/escape"
4
+ import { isPortaled, portalContent, resolvePortalContainer, restoreContent } from "@poetry/controllers/helpers/portal"
5
+ import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
6
+ import { setState, stateOf } from "@poetry/controllers/helpers/state"
7
+ import { tabbableWithin } from "@poetry/controllers/helpers/tabbable"
8
+
9
+ // The Combobox ORCHESTRATOR: Select's shell
10
+ // x Command's engine, composed VIA THE EVENT CONTRACT ONLY - this thin
11
+ // controller owns open/close + the commit pipeline + autofill adoption and
12
+ // listens for the embedded engine's poetry:command:select; it contains NO
13
+ // filter/highlight/scoring code (Command owns those) and Command gained no
14
+ // combobox code (engine purity, fenced both directions by the conformance
15
+ // greps). Zero code is shared with SelectController - the PATTERNS are
16
+ // (the layer mechanism, the native-first 5-step pipeline, the adoption
17
+ // path), re-instantiated here because a facade extraction was explicitly
18
+ // declined (the Select contract's open question, answered by this build).
19
+ //
20
+ // THE THREE DELIBERATE DELTAS vs Select, each pinned by tests so neither
21
+ // sibling's rules leak:
22
+ // - open focuses the COMMAND INPUT for every reason (a combobox session is
23
+ // a TYPING session - APG editable combobox); the selected option gets the
24
+ // HIGHLIGHT (activedescendant, via the Command controller) + scroll, not
25
+ // DOM focus;
26
+ // - Tab while open CLOSES WITHOUT COMMIT and lets focus proceed (Popover
27
+ // semantics, modal:false default; modal:true restores the trap) - Select
28
+ // is Tab-inert;
29
+ // - a printable key on the CLOSED trigger OPENS and SEEDS the filter
30
+ // (typing filters, never blind-commits) - Select's closed-trigger
31
+ // typeahead-commit does NOT port.
32
+ //
33
+ // TWO MEANINGS, TWO ATTRIBUTES, ONE LIST: data-highlighted +
34
+ // aria-activedescendant = position (Command's, never aria-selected);
35
+ // aria-selected + data-selected + the indicator = the COMMITTED value
36
+ // (Select's twin-write, written only here, in the pipeline).
37
+ //
38
+ // THE SYNC INVARIANT (Select's, inherited): native_select.value, the value
39
+ // Value, the twin-write, and the display never diverge - every write path
40
+ // funnels through #apply, NATIVE FIRST (serialization truth is never
41
+ // behind the facade), with real bubbling change/input on the native so
42
+ // Turbo auto-submit and form listeners work unmodified.
43
+ //
44
+ // MULTIPLE (the input-inside layout): the value is a
45
+ // LIST (the value Value carries a JSON array over the same String seam),
46
+ // the native is a <select multiple> posting name[], and the trigger is
47
+ // replaced by the chips FIELD - one chip per committed value IN VALUE
48
+ // ORDER with the filter input inline after them (data-slot=combobox-chip-input,
49
+ // which the engine resolves too; the engine itself rides the ROOT).
50
+ // Selection TOGGLES and the popup STAYS OPEN; chips take REAL DOM focus
51
+ // (:focus-visible styles it - never data-highlighted) and focusing a chip
52
+ // closes the popup; Backspace on the empty input removes the last chip;
53
+ // Escape on the CLOSED popup clears the query and wipes the selection.
54
+ // The input NEVER mirrors selection text; single-mode paths are
55
+ // behavior-identical.
56
+ const TRIGGER_SELECTOR = '[data-slot="combobox-trigger"]'
57
+ const NATIVE_SELECTOR = '[data-slot="combobox-native"]'
58
+ const VALUE_SELECTOR = '[data-slot="combobox-value"]'
59
+ const CONTENT_SELECTOR = '[data-slot="combobox-content"]'
60
+ const CLEAR_SELECTOR = '[data-slot="combobox-clear"]'
61
+ const CHIPS_SELECTOR = '[data-slot="combobox-chips"]'
62
+ const CHIP_SELECTOR = '[data-slot="combobox-chip"]'
63
+ const CHIP_REMOVE_SELECTOR = '[data-slot="combobox-chip-remove"]'
64
+ const COMMAND_SELECTOR = '[data-slot="combobox-command"]'
65
+ const INPUT_SELECTOR = '[data-slot="combobox-input"], [data-slot="combobox-chip-input"]'
66
+ const ITEM_SELECTOR = '[data-slot="combobox-item"]'
67
+ const ITEM_TEXT_SELECTOR = '[data-slot="combobox-item-text"]'
68
+
69
+ const EVENT_PREFIX = "poetry:combobox"
70
+ const COMMAND_IDENTIFIER = "poetry--core--command"
71
+
72
+ // NO roving-focus (the popup is Command's activedescendant session - the
73
+ // family's first popup without it).
74
+ const CONTENT_LAYER_CONTROLLERS = ["poetry--core--focus-scope", "poetry--core--dismissable"]
75
+ const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
76
+
77
+ export default class ComboboxController extends Controller {
78
+ // The events this controller dispatches (manifest surface;
79
+ // events_declaration.test.js enforces the list stays honest).
80
+ static events = [
81
+ "poetry:combobox:change", "poetry:combobox:closed", "poetry:combobox:open",
82
+ "poetry:combobox:select"
83
+ ]
84
+
85
+ static values = {
86
+ open: { type: Boolean, default: false },
87
+ // Single: the committed value. multiple: a JSON array over the same
88
+ // String seam (parsed by #listValues, serialized by #applyValues).
89
+ value: { type: String, default: "" },
90
+ // DEFAULT FALSE - Popover semantics (Tab-out closes, no scrim); true
91
+ // restores the focus-scope trap for dialog-critical pickers.
92
+ modal: { type: Boolean, default: false },
93
+ // multiple: the value is a LIST, the trigger is the chips
94
+ // field, selection toggles, the popup stays open on select.
95
+ multiple: { type: Boolean, default: false }
96
+ }
97
+
98
+ #connected = false
99
+ #wired = []
100
+ #suppressRestore = false
101
+ #cancelExit = null
102
+ #applied = ""
103
+ #placeholder = ""
104
+ #dismissedEvent = null
105
+
106
+ /**
107
+ * Wires the content (and chips-field) listeners, captures the
108
+ * placeholder, reconciles silently from the native select / the Value
109
+ * (multiple parses the JSON-array seam), and catches a server-open
110
+ * popup up (layers + the late portal).
111
+ */
112
+ connect() {
113
+ const content = this.#content()
114
+
115
+ if (content) this.#wireContent(content)
116
+
117
+ const chips = this.#chips()
118
+
119
+ // The chips FIELD is a second wired keyboard surface in multiple
120
+ // (Tab-out closes from the inline input exactly like from the popup).
121
+ if (chips) this.#listen(chips, "keydown", this.#onKeydown)
122
+
123
+ const display = this.#display()
124
+
125
+ this.#placeholder = display?.dataset.placeholder ??
126
+ (this.#trigger()?.hasAttribute("data-placeholder") ? (display?.textContent ?? "").trim() : "")
127
+
128
+ // Reconcile-on-connect (Turbo Stream re-render safe): the native select
129
+ // is the serialization truth (Select-exact). multiple reads the value
130
+ // Value's JSON array, falling back to the native's selectedOptions.
131
+ const serverValue = this.multipleValue
132
+ ? this.#reconciledValues()
133
+ : (this.valueValue !== "" ? this.valueValue : (this.#native()?.value ?? ""))
134
+
135
+ this.#applied = serverValue
136
+ this.#apply(serverValue, { silent: true, force: true })
137
+
138
+ this.#connected = true
139
+
140
+ if (this.#isOpen()) {
141
+ if (content) {
142
+ this.#activateLayers(content)
143
+ this.#portalPinned(content)
144
+ }
145
+ this.openValue = true
146
+ } else if (this.openValue) {
147
+ this.#show("trigger-press")
148
+ }
149
+ }
150
+
151
+ // The reconcile path portals ONE FRAME LATE: connect order within a boot
152
+ // is unordered, and portaling before the sibling popper's connect would
153
+ // rob it of its content target before it could cache the node.
154
+ #portalPinned(content) {
155
+ window.requestAnimationFrame(() => {
156
+ if (!this.#connected || !this.#isOpen()) return
157
+
158
+ portalContent(content, { container: resolvePortalContainer(this.element) })
159
+ this.element.setAttribute(POPPER_STRATEGY, "absolute")
160
+ })
161
+ }
162
+
163
+ /**
164
+ * Restores portaled content (drop-never-strand) and unwires everything.
165
+ */
166
+ disconnect() {
167
+ this.#connected = false
168
+
169
+ // Never leave content stranded at the container (drop-never-strand).
170
+ const content = this.#content()
171
+
172
+ if (content) restoreContent(content)
173
+
174
+ for (const [target, type, listener] of this.#wired) target.removeEventListener(type, listener)
175
+
176
+ this.#wired = []
177
+ this.#cancelExit?.()
178
+ this.#cancelExit = null
179
+ }
180
+
181
+ // --- controllable state ---
182
+
183
+ /**
184
+ * Stimulus value callback - controllable open state.
185
+ *
186
+ * @param {boolean} value
187
+ */
188
+ openValueChanged(value) {
189
+ if (!this.#connected) return
190
+
191
+ if (value && !this.#isOpen()) this.#show("trigger-press")
192
+ else if (!value && this.#isOpen()) this.#hide("none")
193
+ }
194
+
195
+ /**
196
+ * Stimulus value callback - controllable value (multiple parses the
197
+ * JSON-array seam; both modes no-op on the applied echo).
198
+ *
199
+ * @param {string} value
200
+ */
201
+ valueValueChanged(value) {
202
+ if (!this.#connected) return
203
+
204
+ if (this.multipleValue) {
205
+ const values = this.#listValues(value)
206
+
207
+ if (this.#sameValues(values, this.#applied)) return
208
+
209
+ this.#apply(values)
210
+ return
211
+ }
212
+
213
+ if (value === this.#applied) return
214
+
215
+ this.#apply(value)
216
+ }
217
+
218
+ // --- trigger actions ---
219
+
220
+ /** The trigger's click action: open <-> close. */
221
+ toggle() {
222
+ if (this.#isOpen()) this.#hide("trigger-press")
223
+ else this.#show("trigger-press")
224
+ }
225
+
226
+ /**
227
+ * The trigger's keydown action: Enter / Space / ArrowDown / ArrowUp
228
+ * open (reason: list-navigation). A PRINTABLE key opens AND seeds the
229
+ * filter input with the char (reason: keyboard + the char as
230
+ * data-open-seed, a poetry extension - the char is never lost, the
231
+ * filter pass runs immediately). There is deliberately NO
232
+ * closed-trigger typeahead-commit here.
233
+ *
234
+ * @param {KeyboardEvent} event
235
+ */
236
+ triggerKeydown(event) {
237
+ if (this.#isOpen()) return
238
+
239
+ if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown" || event.key === "ArrowUp") {
240
+ event.preventDefault()
241
+ this.#show("list-navigation")
242
+ return
243
+ }
244
+
245
+ if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
246
+ event.preventDefault()
247
+ this.#show("keyboard", { seed: event.key })
248
+ }
249
+ }
250
+
251
+ // --- the chips field (multiple) ---
252
+
253
+ /**
254
+ * The chips frame's pointerdown action (multiple): a press anywhere in
255
+ * the FRAME focuses the input and opens the popup (the whole frame IS
256
+ * the field) - except a chip-remove press, which is a removal,
257
+ * never a chips-area press.
258
+ *
259
+ * @param {PointerEvent} event
260
+ */
261
+ chipsPointerdown(event) {
262
+ const input = this.#input()
263
+
264
+ if (!input || input.disabled) return
265
+ if (event.target instanceof Element && event.target.closest(CHIP_REMOVE_SELECTOR)) return
266
+
267
+ // Keep the press from landing focus on the frame/chip; a press on the
268
+ // input itself keeps native caret placement.
269
+ if (event.target !== input) event.preventDefault()
270
+
271
+ input.focus()
272
+
273
+ if (!this.#isOpen()) this.#show("trigger-press")
274
+ }
275
+
276
+ /**
277
+ * The inline input's OWN keyboard map (multiple; the engine's map rides
278
+ * the same keydown): Backspace on an empty input removes the LAST chip
279
+ * (focus stays here), ArrowLeft at caret 0 walks into the chips,
280
+ * ArrowDown/Up reopen the popup, Enter with no highlight closes, and
281
+ * Escape on the CLOSED popup clears the query AND wipes the selection
282
+ * to [] (contractual; readOnly blocks every mutation).
283
+ *
284
+ * @param {KeyboardEvent} event
285
+ */
286
+ inputKeydown(event) {
287
+ if (!this.multipleValue) return
288
+
289
+ const input = event.target
290
+
291
+ switch (event.key) {
292
+ case "Backspace": {
293
+ if (input.value !== "" || input.readOnly) return
294
+ if (this.#applied.length === 0) return
295
+
296
+ this.#apply(this.#applied.slice(0, -1))
297
+ return
298
+ }
299
+ case "ArrowLeft": {
300
+ if (input.selectionStart !== 0 || input.selectionEnd !== 0) return
301
+
302
+ const chips = this.#chipElements()
303
+
304
+ if (chips.length > 0) {
305
+ event.preventDefault()
306
+ this.#focusChip(chips[chips.length - 1])
307
+ }
308
+ return
309
+ }
310
+ case "ArrowDown":
311
+ case "ArrowUp":
312
+ if (!this.#isOpen()) this.#show("list-navigation")
313
+ return
314
+ case "Enter":
315
+ if (this.#isOpen() && !this.#highlightedOption()) this.#hide("none")
316
+ return
317
+ case "Escape": {
318
+ // The press that just dismissed the popup must not ALSO wipe - and
319
+ // neither may an IME composition-cancel Escape (the user is dropping
320
+ // a composition, not asking to clear the field).
321
+ if (event === this.#dismissedEvent || this.#isOpen() || input.readOnly) return
322
+ if (isImeKeydown(event)) return
323
+
324
+ this.#command()?.reset()
325
+ if (this.#applied.length > 0) this.#apply([])
326
+ return
327
+ }
328
+ default:
329
+ // Everything else belongs to the input or the engine's map.
330
+ }
331
+ }
332
+
333
+ /**
334
+ * A focused chip's keyboard map: Left/Right walk the chips
335
+ * (off either end -> back to the input), Backspace/Delete remove (next
336
+ * highlight: same index, step back at the tail, the input once
337
+ * emptied), Enter/Space are no-ops returning to the input, ArrowDown/Up
338
+ * reopen the popup, and a printable char resumes the typing session.
339
+ *
340
+ * @param {KeyboardEvent} event
341
+ */
342
+ chipKeydown(event) {
343
+ const origin = event.currentTarget instanceof Element ? event.currentTarget : event.target
344
+ const chip = origin instanceof Element ? origin.closest(CHIP_SELECTOR) : null
345
+
346
+ if (!chip) return
347
+
348
+ const chips = this.#chipElements()
349
+ const index = chips.indexOf(chip)
350
+
351
+ switch (event.key) {
352
+ case "ArrowLeft":
353
+ case "ArrowRight": {
354
+ event.preventDefault()
355
+
356
+ const next = index + (event.key === "ArrowRight" ? 1 : -1)
357
+
358
+ if (next >= 0 && next < chips.length) this.#focusChip(chips[next])
359
+ else this.#input()?.focus()
360
+ return
361
+ }
362
+ case "Backspace":
363
+ case "Delete":
364
+ event.preventDefault()
365
+
366
+ if (this.#input()?.readOnly) return
367
+
368
+ this.#removeAt(index)
369
+ return
370
+ case "Enter":
371
+ case " ":
372
+ event.preventDefault()
373
+ this.#input()?.focus()
374
+ return
375
+ case "ArrowDown":
376
+ case "ArrowUp":
377
+ event.preventDefault()
378
+ this.#input()?.focus()
379
+ if (!this.#isOpen()) this.#show("list-navigation")
380
+ return
381
+ default:
382
+ // A printable char refocuses the input (the key lands there).
383
+ if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
384
+ this.#input()?.focus()
385
+ }
386
+ }
387
+ }
388
+
389
+ /**
390
+ * The chip-remove button's click action: removes the chip's value;
391
+ * focus returns to the input WITHOUT opening (not a chips-area press).
392
+ *
393
+ * @param {MouseEvent} event
394
+ */
395
+ removeChip(event) {
396
+ if (this.#input()?.readOnly) return
397
+
398
+ const origin = event.currentTarget instanceof Element ? event.currentTarget : event.target
399
+ const chip = origin instanceof Element ? origin.closest(CHIP_SELECTOR) : null
400
+ const index = chip ? this.#chipElements().indexOf(chip) : -1
401
+
402
+ if (index === -1) return
403
+
404
+ const values = [...this.#applied]
405
+
406
+ values.splice(index, 1)
407
+ this.#apply(values)
408
+ this.#input()?.focus()
409
+ }
410
+
411
+ // --- the show_clear X (its click action) ---
412
+
413
+ /**
414
+ * The show_clear X's click action - the trigger-side deselection
415
+ * surface: commits the blank value through the
416
+ * pipeline, then hands focus to the trigger (the X hides itself once
417
+ * the value empties, and a focused hidden button would drop focus to
418
+ * body). Single mode only (the component raises on multiple; the guard
419
+ * here is belt and braces).
420
+ */
421
+ clear() {
422
+ if (this.multipleValue) return { value: [...this.#applied], changed: false }
423
+
424
+ const changed = this.#applied !== ""
425
+
426
+ if (changed) this.#apply("")
427
+ this.#trigger()?.focus()
428
+
429
+ return { value: "", changed }
430
+ }
431
+
432
+ // --- programmatic API ---
433
+
434
+ /**
435
+ * Programmatically opens.
436
+ *
437
+ * @param {string | Event} [reason="trigger-press"] - a family reason
438
+ * string; an Event reads as trigger-press
439
+ * @param {Object} [options]
440
+ * @param {string} [options.seed=""] - a printable char pre-seeding the
441
+ * filter input
442
+ */
443
+ open(reason = "trigger-press", { seed = "" } = {}) {
444
+ if (reason instanceof Event) this.#show("trigger-press")
445
+ else this.#show(reason, { seed })
446
+ }
447
+
448
+ /**
449
+ * Programmatically closes.
450
+ *
451
+ * @param {string | Event} [reason="none"]
452
+ */
453
+ close(reason = "none") {
454
+ this.#hide(reason instanceof Event ? "none" : reason)
455
+ }
456
+
457
+ /**
458
+ * The programmatic controllable-state surface (multiple accepts an
459
+ * array). Returns the resulting state - what an agent tool reports
460
+ * back - so an unknown value (not committed) reads as changed: false.
461
+ *
462
+ * @param {string | string[] | null} value
463
+ * @returns {{ value: string | string[], changed: boolean }}
464
+ */
465
+ setValue(value) {
466
+ if (this.multipleValue) {
467
+ const values = this.#listValues(value)
468
+
469
+ if (this.#sameValues(values, this.#applied)) return { value: [...this.#applied], changed: false }
470
+
471
+ this.#apply(values)
472
+
473
+ return { value: [...this.#applied], changed: true }
474
+ }
475
+
476
+ const next = String(value ?? "")
477
+
478
+ if (next === this.#applied) return { value: this.#applied, changed: false }
479
+ if (next !== "" && !this.#items().some((item) => (item.dataset.value ?? "") === next)) {
480
+ return { value: this.#applied, changed: false }
481
+ }
482
+
483
+ this.#apply(next)
484
+
485
+ return { value: this.#applied, changed: true }
486
+ }
487
+
488
+ // --- autofill adoption (Select's path verbatim) ---
489
+
490
+ /**
491
+ * The hidden native's change action: browser autofill (or any
492
+ * programmatic write) adopts - multiple compares MEMBERSHIP, not order
493
+ * (the body comment holds the reorder rule).
494
+ */
495
+ nativeChanged() {
496
+ if (this.multipleValue) {
497
+ const values = this.#nativeValues()
498
+
499
+ // MEMBERSHIP comparison, not order: a <select multiple> only knows
500
+ // DOM order, so the pipeline's own bubbling change (value order
501
+ // preserved) must never round-trip into a reorder.
502
+ if (this.#sameMembers(values, this.#applied)) return
503
+
504
+ this.#apply(values, { fromNative: true })
505
+ return
506
+ }
507
+
508
+ const value = this.#native()?.value ?? ""
509
+
510
+ if (value === this.#applied) return
511
+
512
+ this.#apply(value, { fromNative: true })
513
+ }
514
+
515
+ // --- open / close ---
516
+
517
+ #show(reason, { seed = "" } = {}) {
518
+ const content = this.#content()
519
+
520
+ if (!content || this.#isOpen()) return
521
+
522
+ this.#cancelExit?.()
523
+ this.#cancelExit = null
524
+ this.#suppressRestore = false
525
+
526
+ const expander = this.#expander()
527
+
528
+ // Portal-on-open: move BEFORE the
529
+ // enter presence (reparenting mid-animation restarts it), re-anchor
530
+ // absolute - static under compositor scroll, transform-immune. In
531
+ // multiple mode only the popup (listbox) moves; the chips field with
532
+ // its inline input stays home as the popper anchor.
533
+ portalContent(content, { container: resolvePortalContainer(this.element) })
534
+ this.element.setAttribute(POPPER_STRATEGY, "absolute")
535
+
536
+ content.hidden = false
537
+ content.setAttribute("data-open-reason", reason)
538
+ if (seed) content.setAttribute("data-open-seed", seed)
539
+ else content.removeAttribute("data-open-seed")
540
+ expander?.setAttribute("aria-expanded", "true")
541
+ if (expander) setState(expander, "popup-open")
542
+ enterPresence(content)
543
+ this.#activateLayers(content)
544
+ this.openValue = true
545
+
546
+ // Layer controllers connect on the attribute-mutation microtask; focus
547
+ // rides one behind so focus-scope has snapshotted the trigger.
548
+ queueMicrotask(() => {
549
+ if (!this.#isOpen()) return
550
+
551
+ const input = this.#input()
552
+
553
+ // FOCUS GOES TO THE INPUT for every reason (the typing-session call);
554
+ // the selected option is communicated by highlight, not focus.
555
+ input?.focus()
556
+
557
+ if (seed && input) {
558
+ input.value = seed
559
+ // The engine's own action runs the pass (and re-seats highlight).
560
+ input.dispatchEvent(new Event("input", { bubbles: true }))
561
+ } else {
562
+ // Seed the highlight on the COMMITTED option via the Command
563
+ // controller (activedescendant + scrollIntoView) - first enabled
564
+ // when no value (the engine cannot seat while the popup was
565
+ // hidden, so the shell seeds every open).
566
+ const items = this.#items()
567
+ const target = items.find((item) => item.getAttribute("aria-selected") === "true") ??
568
+ items.find((item) => !item.hasAttribute("data-disabled") && !item.closest("[hidden]"))
569
+
570
+ if (target) this.#command()?.highlightItem(target)
571
+ }
572
+
573
+ this.dispatch("open", { prefix: EVENT_PREFIX, detail: seed ? { reason, seed } : { reason } })
574
+ })
575
+ }
576
+
577
+ #hide(reason, { restoreFocus = true } = {}) {
578
+ const content = this.#content()
579
+
580
+ if (!content || !this.#isOpen()) return
581
+
582
+ this.#suppressRestore = !restoreFocus || (reason === "outside-press" && !this.modalValue)
583
+
584
+ const expander = this.#expander()
585
+
586
+ expander?.setAttribute("aria-expanded", "false")
587
+ if (expander) setState(expander, "popup-closed")
588
+ content.removeAttribute("data-open-reason")
589
+ content.removeAttribute("data-open-seed")
590
+ this.openValue = false
591
+
592
+ this.#cancelExit = exitPresence(content, {
593
+ onRemove: () => {
594
+ this.#cancelExit = null
595
+ content.hidden = true
596
+ // Home AFTER the exit finished and hidden landed; focus
597
+ // return is focus-scope's ref-based job, indifferent to the move.
598
+ restoreContent(content)
599
+ this.element.setAttribute(POPPER_STRATEGY, "fixed")
600
+ this.#removeControllers(content, CONTENT_LAYER_CONTROLLERS)
601
+ // Reset the query so reopen starts clean (a remount would get
602
+ // this for free; persistent DOM does it deliberately).
603
+ this.#command()?.reset()
604
+ this.dispatch("closed", { prefix: EVENT_PREFIX, detail: { reason } })
605
+ }
606
+ })
607
+ }
608
+
609
+ // --- the commit pipeline ---
610
+
611
+ // Command's select event is the ONLY commit trigger (no click handling
612
+ // here - the composition boundary). Cancelable BEFORE the value commits;
613
+ // vetoing keeps the popup open. Committing the already-selected value is
614
+ // IDEMPOTENT: close, value unchanged, no change events (deselection is a
615
+ // form affordance - include_blank - not a hidden toggle gesture).
616
+ // multiple INVERTS both rules: selection TOGGLES membership
617
+ // (appended at the array END) and the popup STAYS OPEN; a typed query
618
+ // clears immediately so the full list is restored for the next pick.
619
+ #onCommandSelect = (event) => {
620
+ const { item, value = "", label = "" } = event.detail ?? {}
621
+
622
+ const select = this.dispatch("select", {
623
+ prefix: EVENT_PREFIX,
624
+ target: item instanceof Element ? item : this.element,
625
+ cancelable: true,
626
+ detail: { item, value, label }
627
+ })
628
+
629
+ if (select.defaultPrevented) return
630
+
631
+ if (this.multipleValue) {
632
+ const input = this.#input()
633
+
634
+ if (input?.readOnly) return
635
+
636
+ const values = [...this.#applied]
637
+ const index = values.indexOf(value)
638
+
639
+ if (index === -1) values.push(value)
640
+ else values.splice(index, 1)
641
+
642
+ this.#apply(values)
643
+
644
+ if (input && input.value !== "") {
645
+ this.#command()?.reset()
646
+ if (item instanceof Element) this.#command()?.highlightItem(item)
647
+ }
648
+
649
+ input?.focus()
650
+ return
651
+ }
652
+
653
+ if (value !== this.#applied) this.#apply(value)
654
+
655
+ this.#hide("item-press")
656
+ }
657
+
658
+ // THE single sync path, NATIVE FIRST (Select's 5 steps re-instantiated):
659
+ // 1. native_select.value + real bubbling change/input; 2. aria-selected
660
+ // + data-selected twin-flipped on every option; 3+4. display synced from
661
+ // the option's item-text (data-text-value override) + the trigger's
662
+ // data-placeholder; 5. poetry:combobox:change. multiple routes to the
663
+ // set-shaped twin below.
664
+ #apply(value, { silent = false, fromNative = false, force = false } = {}) {
665
+ if (this.multipleValue) {
666
+ this.#applyValues(this.#listValues(value), { silent, fromNative, force })
667
+ return
668
+ }
669
+
670
+ if (!force && value === this.#applied) return
671
+
672
+ const previous = this.#applied
673
+
674
+ this.#applied = value
675
+ this.valueValue = value
676
+
677
+ const native = this.#native()
678
+
679
+ if (native && !fromNative) {
680
+ native.value = value
681
+
682
+ if (!silent) {
683
+ native.dispatchEvent(new Event("input", { bubbles: true }))
684
+ native.dispatchEvent(new Event("change", { bubbles: true }))
685
+ }
686
+ }
687
+
688
+ let selected = null
689
+
690
+ for (const item of this.#items()) {
691
+ const match = value !== "" && (item.dataset.value ?? "") === value
692
+
693
+ item.setAttribute("aria-selected", String(match))
694
+ setState(item, match ? "selected" : "unselected")
695
+
696
+ if (match) selected = item
697
+ }
698
+
699
+ const display = this.#display()
700
+ const label = selected ? this.#labelOf(selected) : null
701
+
702
+ if (display) display.textContent = label ?? this.#placeholder
703
+
704
+ this.#trigger()?.toggleAttribute("data-placeholder", !selected)
705
+ // The show_clear X follows the VALUE (not the matched item - the async
706
+ // recipe can commit values whose item is not rendered); the chevron
707
+ // swap derives from this one flip in CSS.
708
+ this.#clearButton()?.toggleAttribute("hidden", value === "")
709
+
710
+ if (!silent) {
711
+ this.dispatch("change", { prefix: EVENT_PREFIX, detail: { value, label, previous } })
712
+ }
713
+ }
714
+
715
+ #labelOf(item) {
716
+ return (item.dataset.textValue ??
717
+ item.querySelector(ITEM_TEXT_SELECTOR)?.textContent ??
718
+ item.textContent ?? "").trim()
719
+ }
720
+
721
+ // The multiple twin of #apply, set-shaped (the same 5 steps over a value
722
+ // LIST): 1. the native <select multiple>'s selectedOptions + real
723
+ // bubbling change/input; 2. aria-selected + data-selected twin-flipped
724
+ // by ARRAY INCLUSION; 3+4. chips rebuilt IN VALUE ORDER + the frame's
725
+ // data-placeholder / role=toolbar flips (the input NEVER mirrors
726
+ // selection text); 5. poetry:combobox:change with array-shaped detail.
727
+ #applyValues(values, { silent = false, fromNative = false, force = false } = {}) {
728
+ if (!force && this.#sameValues(values, this.#applied)) return
729
+
730
+ const previous = this.#applied
731
+
732
+ this.#applied = values
733
+ this.valueValue = JSON.stringify(values)
734
+
735
+ const native = this.#native()
736
+
737
+ if (native && !fromNative) {
738
+ for (const option of this.#nativeOptions()) option.selected = values.includes(option.value)
739
+
740
+ if (!silent) {
741
+ native.dispatchEvent(new Event("input", { bubbles: true }))
742
+ native.dispatchEvent(new Event("change", { bubbles: true }))
743
+ }
744
+ }
745
+
746
+ for (const item of this.#items()) {
747
+ const match = values.includes(item.dataset.value ?? "")
748
+
749
+ item.setAttribute("aria-selected", String(match))
750
+ setState(item, match ? "selected" : "unselected")
751
+ }
752
+
753
+ this.#renderChips(values)
754
+
755
+ if (!silent) {
756
+ this.dispatch("change", {
757
+ prefix: EVENT_PREFIX,
758
+ detail: { value: values, label: values.map((value) => this.#labelForValue(value)), previous }
759
+ })
760
+ }
761
+ }
762
+
763
+ // Chips are SERVER markup: the frame's <template> skeleton (byte-what
764
+ // the server renders for a committed value) is cloned per value rather
765
+ // than composing DOM of this controller's own.
766
+ #renderChips(values) {
767
+ const chips = this.#chips()
768
+
769
+ if (!chips) return
770
+
771
+ for (const chip of this.#chipElements()) chip.remove()
772
+
773
+ const template = chips.querySelector("template")
774
+ const input = chips.querySelector(INPUT_SELECTOR)
775
+ const removeTemplate = chips.dataset.removeLabel ?? "Remove %{label}"
776
+
777
+ if (template?.content?.firstElementChild) {
778
+ for (const value of values) {
779
+ const chip = template.content.firstElementChild.cloneNode(true)
780
+ const label = this.#labelForValue(value)
781
+
782
+ chip.setAttribute("data-value", value)
783
+ chip.setAttribute("aria-label", label)
784
+ chip.querySelector(CHIP_REMOVE_SELECTOR)
785
+ ?.setAttribute("aria-label", removeTemplate.replace("%{label}", label))
786
+ chip.insertBefore(document.createTextNode(label), chip.firstChild)
787
+ chips.insertBefore(chip, input)
788
+ }
789
+ }
790
+
791
+ // role=toolbar rides the frame ONLY while it holds chips;
792
+ // an empty selection wears the data-placeholder styling hook instead.
793
+ if (values.length > 0) chips.setAttribute("role", "toolbar")
794
+ else chips.removeAttribute("role")
795
+ chips.toggleAttribute("data-placeholder", values.length === 0)
796
+ }
797
+
798
+ // Chip keyboard removal: next highlight lands on the SAME index, steps
799
+ // back at the tail, and returns to the input once the list empties.
800
+ #removeAt(index) {
801
+ const values = [...this.#applied]
802
+
803
+ if (index < 0 || index >= values.length) return
804
+
805
+ values.splice(index, 1)
806
+ this.#apply(values)
807
+
808
+ const chips = this.#chipElements()
809
+
810
+ if (chips.length === 0) this.#input()?.focus()
811
+ else this.#focusChip(chips[Math.min(index, chips.length - 1)])
812
+ }
813
+
814
+ // Real DOM focus IS the chip highlight (:focus-visible/:focus-within
815
+ // style it; chips never wear data-highlighted) - and focusing a chip
816
+ // CLOSES the popup (the typing session is suspended). disabled blocks
817
+ // chip focus entirely.
818
+ #focusChip(chip) {
819
+ if (!chip || chip.hasAttribute("data-disabled")) return
820
+
821
+ if (this.#isOpen()) this.#hide("chip-focus", { restoreFocus: false })
822
+
823
+ chip.focus()
824
+ }
825
+
826
+ #labelForValue(value) {
827
+ const item = this.#items().find((candidate) => (candidate.dataset.value ?? "") === value)
828
+
829
+ if (item) return this.#labelOf(item)
830
+
831
+ const option = this.#nativeOptions().find((candidate) => candidate.value === value)
832
+
833
+ return (option?.textContent ?? value).trim()
834
+ }
835
+
836
+ // --- content wiring (programmatic: portal-safe) ---
837
+
838
+ #wireContent(content) {
839
+ this.#listen(content, "keydown", this.#onKeydown)
840
+ this.#listen(content, "poetry:command:select", this.#onCommandSelect)
841
+ this.#listen(content, "poetry--core--dismissable:interact-outside", this.#onInteractOutside)
842
+ this.#listen(content, "poetry--core--dismissable:dismiss", this.#onDismiss)
843
+ this.#listen(content, "poetry--core--focus-scope:mount-auto-focus", this.#onMountAutoFocus)
844
+ this.#listen(content, "poetry--core--focus-scope:unmount-auto-focus", this.#onUnmountAutoFocus)
845
+ }
846
+
847
+ #listen(target, type, listener) {
848
+ target.addEventListener(type, listener)
849
+ this.#wired.push([target, type, listener])
850
+ }
851
+
852
+ // Tab while open CLOSES WITHOUT COMMIT and lets focus proceed (Popover
853
+ // semantics - THE delta vs Select's Tab-inert). modal:true leaves Tab to
854
+ // the focus-scope trap instead. Everything else inside the popup is
855
+ // Command's activedescendant map - no second keyboard map lives here.
856
+ //
857
+ // The Tab seam: a Tab originating INSIDE the portaled
858
+ // popup would proceed from body's end, not from the combobox - close
859
+ // (unchanged) and place focus where the un-portaled DOM would have
860
+ // landed it: the trigger on Shift+Tab, the next tabbable after it on
861
+ // Tab (the un-portaled tab-order rule). Multiple mode's typing surface is the
862
+ // chips input at HOME, so its Tab-out proceeds naturally, untouched.
863
+ #onKeydown = (event) => {
864
+ if (event.key !== "Tab" || this.modalValue || !this.#isOpen()) return
865
+
866
+ const content = this.#content()
867
+ const fromPortaled = Boolean(content) && isPortaled(content) &&
868
+ event.target instanceof Element && content.contains(event.target)
869
+
870
+ this.#hide("focus-out", { restoreFocus: false })
871
+
872
+ if (!fromPortaled) return
873
+
874
+ const anchor = this.#trigger()
875
+
876
+ if (!anchor) return
877
+
878
+ event.preventDefault()
879
+
880
+ if (event.shiftKey) {
881
+ anchor.focus()
882
+ return
883
+ }
884
+
885
+ const tabbables = tabbableWithin(document.body)
886
+ const next = tabbables
887
+ .slice(tabbables.indexOf(anchor) + 1)
888
+ .find((element) => !content.contains(element))
889
+
890
+ ;(next ?? anchor).focus()
891
+ }
892
+
893
+ // A press on the combobox's OWN trigger is the toggle's job (the popover
894
+ // trigger-press rule: without the veto, pointerdown closes and the
895
+ // trailing click re-opens). In multiple, a press in the chips FIELD is
896
+ // an anchor press too - vetoing the layer keeps the popup open while
897
+ // chips mutate under the pointer.
898
+ #onInteractOutside = (event) => {
899
+ if (event.target !== this.#content()) return
900
+
901
+ const origin = event.detail?.originalEvent?.target
902
+
903
+ if (!(origin instanceof Element)) return
904
+ if (this.#trigger()?.contains(origin)) return event.preventDefault()
905
+ if (this.multipleValue && this.#chips()?.contains(origin)) event.preventDefault()
906
+ }
907
+
908
+ // Esc / outside press arrive as the dismissable layer's dismiss event.
909
+ // Neither EVER commits - the value is untouched (family rule).
910
+ #onDismiss = (event) => {
911
+ if (event.target !== this.#content()) return
912
+
913
+ const escaped = event.detail?.originalEvent?.type === "keydown"
914
+
915
+ // Remembered so the SAME keypress cannot double as the closed-popup
916
+ // Escape wipe in the multiple input map.
917
+ this.#dismissedEvent = event.detail?.originalEvent ?? null
918
+ this.#hide(escaped ? "escape-key" : "outside-press")
919
+ }
920
+
921
+ // The combobox owns initial focus (the command input), not focus-scope.
922
+ #onMountAutoFocus = (event) => {
923
+ if (event.target === this.#content()) event.preventDefault()
924
+ }
925
+
926
+ #onUnmountAutoFocus = (event) => {
927
+ if (event.target === this.#content() && this.#suppressRestore) event.preventDefault()
928
+ }
929
+
930
+ // --- the layer stack (Select's proven mechanism, MINUS roving-focus) ---
931
+
932
+ #activateLayers(content) {
933
+ content.setAttribute("data-poetry--core--focus-scope-trapped-value", String(this.modalValue))
934
+ // Non-modal Tab must DEPART (closing the popup - the Select delta);
935
+ // focus-scope's edge-loop default would swallow it. modal:true keeps
936
+ // the trap+loop pair.
937
+ content.setAttribute("data-poetry--core--focus-scope-loop-value", String(this.modalValue))
938
+ content.setAttribute(
939
+ "data-poetry--core--dismissable-disable-outside-pointer-events-value", String(this.modalValue)
940
+ )
941
+
942
+ this.#addControllers(content, CONTENT_LAYER_CONTROLLERS)
943
+ }
944
+
945
+ #addControllers(element, identifiers) {
946
+ const tokens = (element.getAttribute("data-controller") ?? "").split(/\s+/).filter(Boolean)
947
+
948
+ for (const identifier of identifiers) {
949
+ if (!tokens.includes(identifier)) tokens.push(identifier)
950
+ }
951
+
952
+ element.setAttribute("data-controller", tokens.join(" "))
953
+ }
954
+
955
+ #removeControllers(element, identifiers) {
956
+ const tokens = (element.getAttribute("data-controller") ?? "")
957
+ .split(/\s+/)
958
+ .filter((token) => token && !identifiers.includes(token))
959
+
960
+ element.setAttribute("data-controller", tokens.join(" "))
961
+ }
962
+
963
+ // --- structural resolution (ids are the seams; portal/stream-safe) ---
964
+
965
+ #trigger() {
966
+ return this.element.querySelector(TRIGGER_SELECTOR)
967
+ }
968
+
969
+ // The trigger's aria-controls points at the LISTBOX (the a11y-true
970
+ // relationship); the popup container is its closest content wrapper.
971
+ // Multiple has no trigger - the chips frame's inline input carries the
972
+ // same aria-controls (and sits HOME, so this read survives the portal).
973
+ #listbox() {
974
+ const id = this.#trigger()?.getAttribute("aria-controls") ??
975
+ this.#chips()?.querySelector(INPUT_SELECTOR)?.getAttribute("aria-controls")
976
+
977
+ return id ? document.getElementById(id) : null
978
+ }
979
+
980
+ #content() {
981
+ return this.#listbox()?.closest(CONTENT_SELECTOR) ??
982
+ this.element.querySelector(CONTENT_SELECTOR)
983
+ }
984
+
985
+ #command() {
986
+ // multiple mounts the engine on the ROOT (the input sits outside the
987
+ // popup); single keeps it on the popup's command part.
988
+ const root = this.#content()?.querySelector(COMMAND_SELECTOR) ??
989
+ (this.multipleValue ? this.element : null)
990
+
991
+ return root
992
+ ? this.application.getControllerForElementAndIdentifier(root, COMMAND_IDENTIFIER)
993
+ : null
994
+ }
995
+
996
+ #input() {
997
+ // multiple: the ONE input lives inline in the chips frame (the
998
+ // input-inside layout); single keeps it in the popup.
999
+ return this.#content()?.querySelector(INPUT_SELECTOR) ??
1000
+ this.#chips()?.querySelector(INPUT_SELECTOR) ?? null
1001
+ }
1002
+
1003
+ #native() {
1004
+ return this.element.querySelector(NATIVE_SELECTOR)
1005
+ }
1006
+
1007
+ #display() {
1008
+ return this.element.querySelector(VALUE_SELECTOR)
1009
+ }
1010
+
1011
+ // The open-state carrier: the trigger button (single) or the inline
1012
+ // input (multiple stamps aria-expanded/data-popup-open there).
1013
+ #expander() {
1014
+ return this.#trigger() ?? (this.multipleValue ? this.#input() : null)
1015
+ }
1016
+
1017
+ #chips() {
1018
+ return this.element.querySelector(CHIPS_SELECTOR)
1019
+ }
1020
+
1021
+ #clearButton() {
1022
+ return this.element.querySelector(CLEAR_SELECTOR)
1023
+ }
1024
+
1025
+ // Live chips only - the <template> skeleton's content is inert and
1026
+ // never matches a querySelectorAll over the frame.
1027
+ #chipElements() {
1028
+ const chips = this.#chips()
1029
+
1030
+ return chips ? Array.from(chips.querySelectorAll(CHIP_SELECTOR)) : []
1031
+ }
1032
+
1033
+ #highlightedOption() {
1034
+ return (this.#listbox() ?? this.#content())?.querySelector("[data-highlighted]") ?? null
1035
+ }
1036
+
1037
+ #reconciledValues() {
1038
+ const declared = this.#listValues(this.valueValue)
1039
+
1040
+ return declared.length > 0 ? declared : this.#nativeValues()
1041
+ }
1042
+
1043
+ // selectedOptions derived by hand (options + the selected property):
1044
+ // identical semantics, and it holds in every DOM this runs against.
1045
+ #nativeValues() {
1046
+ return this.#nativeOptions()
1047
+ .filter((option) => option.selected)
1048
+ .map((option) => option.value)
1049
+ .filter((value) => value !== "")
1050
+ }
1051
+
1052
+ #nativeOptions() {
1053
+ return Array.from(this.#native()?.querySelectorAll("option") ?? [])
1054
+ }
1055
+
1056
+ // The value Value stays a String seam in both modes; multiple carries a
1057
+ // JSON array through it (a bare scalar adopts as a one-element list).
1058
+ #listValues(raw) {
1059
+ if (Array.isArray(raw)) return raw.map(String)
1060
+ if (typeof raw !== "string" || raw === "") return []
1061
+
1062
+ try {
1063
+ const parsed = JSON.parse(raw)
1064
+
1065
+ return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]
1066
+ } catch {
1067
+ return [raw]
1068
+ }
1069
+ }
1070
+
1071
+ // Order-sensitive on purpose: chips render IN VALUE ORDER, so a reorder
1072
+ // IS a change.
1073
+ #sameValues(values, applied) {
1074
+ return Array.isArray(applied) && values.length === applied.length &&
1075
+ values.every((value, index) => value === applied[index])
1076
+ }
1077
+
1078
+ // Order-blind twin for the native adoption seam only.
1079
+ #sameMembers(values, applied) {
1080
+ return Array.isArray(applied) && values.length === applied.length &&
1081
+ values.every((value) => applied.includes(value))
1082
+ }
1083
+
1084
+ #isOpen() {
1085
+ const content = this.#content()
1086
+
1087
+ return Boolean(content) && stateOf(content) === "open"
1088
+ }
1089
+
1090
+ #items() {
1091
+ const listbox = this.#listbox() ?? this.#content()
1092
+
1093
+ return listbox ? collectionItems(listbox, ITEM_SELECTOR) : []
1094
+ }
1095
+ }