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,924 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { portalContent, resolvePortalContainer, restoreContent } from "@poetry/controllers/helpers/portal"
3
+ import { collectionItems } from "@poetry/controllers/helpers/collection"
4
+ import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
5
+ import { setState, stateOf } from "@poetry/controllers/helpers/state"
6
+ import { createTypeahead, typeaheadLabel } from "@poetry/controllers/helpers/typeahead"
7
+
8
+ // The Select listbox controller - the APG select-only combobox on the menus
9
+ // MACHINERY (popper markup-owned + focus-scope/dismissable/roving-focus
10
+ // activated as layers + the shared typeahead helper) but deliberately NOT a
11
+ // mode of poetry--core--menu: everything menu-specific (submenus,
12
+ // close_on_select, checkbox/radio aria-checked, edge-navigate, Tab-closes,
13
+ // pointer-open-no-focus) is wrong for a listbox, and everything here is
14
+ // dead weight for menus.
15
+ //
16
+ // THE SYNC INVARIANT (the component): native_select.value, the value Value,
17
+ // aria-selected/data-selected on options, and the display text never diverge -
18
+ // every write path (commit, closed-trigger typeahead, autofill adoption,
19
+ // programmatic setValue) funnels through #apply, which writes the NATIVE
20
+ // SELECT FIRST (serialization truth is never behind the facade), dispatches
21
+ // real bubbling change/input on it (Turbo auto-submit and friends work
22
+ // unmodified), flips aria-selected + data-selected TOGETHER on every option,
23
+ // syncs the value display from the option's item-text (data-text-value
24
+ // override), toggles trigger[data-placeholder], then fires
25
+ // poetry:select:change.
26
+ //
27
+ // Three deltas vs the menu family, all deliberate and contractual:
28
+ // - Tab while open is INERT (a value picker resolves by commit or Esc);
29
+ // - open focuses the SELECTED option for EVERY reason, pointer included;
30
+ // - typeahead on the CLOSED trigger COMMITS the match without opening
31
+ // (native <select> behavior), while open typeahead only moves focus.
32
+ const TRIGGER_SELECTOR = '[data-slot="select-trigger"]'
33
+ const ITEM_SELECTOR = '[data-slot="select-item"]'
34
+ const ITEM_TEXT_SELECTOR = '[data-slot="select-item-text"]'
35
+ const NATIVE_SELECTOR = '[data-slot="select-native"]'
36
+ const VALUE_SELECTOR = '[data-slot="select-value"]'
37
+ const VIEWPORT_SELECTOR = '[data-slot="select-viewport"]'
38
+ const SCROLL_UP_SELECTOR = '[data-slot="select-scroll-up-button"]'
39
+ const SCROLL_DOWN_SELECTOR = '[data-slot="select-scroll-down-button"]'
40
+
41
+ const EVENT_PREFIX = "poetry:select"
42
+
43
+ const ROVING = "poetry--core--roving-focus"
44
+ const ROVING_ACTION = `keydown->${ROVING}#keydown`
45
+ const CONTENT_LAYER_CONTROLLERS = ["poetry--core--focus-scope", "poetry--core--dismissable", ROVING]
46
+ const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
47
+
48
+ const SCROLL_HOLD_STEP = 4 // px per frame while hovering a scroll button
49
+
50
+ export default class SelectController extends Controller {
51
+ // The events this controller dispatches (manifest surface;
52
+ // events_declaration.test.js enforces the list stays honest).
53
+ static events = [
54
+ "poetry:select:change", "poetry:select:closed", "poetry:select:open",
55
+ "poetry:select:select"
56
+ ]
57
+
58
+ static values = {
59
+ open: { type: Boolean, default: false },
60
+ value: { type: String, default: "" },
61
+ modal: { type: Boolean, default: true },
62
+ loop: { type: Boolean, default: false },
63
+ typeaheadTimeout: { type: Number, default: 1000 },
64
+ alignItemWithTrigger: { type: Boolean, default: false }
65
+ }
66
+
67
+ #connected = false
68
+ #wired = []
69
+ #claimed = new WeakSet()
70
+ #suppressRestore = false
71
+ #cancelExit = null
72
+ #typeahead = createTypeahead()
73
+ #applied = ""
74
+ #placeholder = ""
75
+ #scrollFrame = null
76
+
77
+ /**
78
+ * Captures the placeholder, reconciles silently from the native select
79
+ * (the serialization truth) or the given Value, wires the content
80
+ * listeners, and catches a server-open listbox up (layers + the late
81
+ * portal).
82
+ */
83
+ connect() {
84
+ const content = this.#content()
85
+
86
+ if (content) this.#wireContent(content)
87
+
88
+ // The placeholder: the value display's data-placeholder attribute when
89
+ // given (survives a valued server render), else the empty-value display
90
+ // text - captured so a later clear can restore it.
91
+ const display = this.#display()
92
+
93
+ this.#placeholder = display?.dataset.placeholder ??
94
+ (this.#trigger()?.hasAttribute("data-placeholder") ? (display?.textContent ?? "").trim() : "")
95
+
96
+ // Reconcile-on-connect (Turbo Stream re-render safe): the native select
97
+ // is the serialization truth - adopt its value when the Value was not
98
+ // given, else normalize the DOM to the Value. Both paths are silent
99
+ // (no events for state the server already declared).
100
+ const serverValue = this.valueValue !== "" ? this.valueValue : (this.#native()?.value ?? "")
101
+
102
+ this.#applied = serverValue
103
+ this.#apply(serverValue, { silent: true, force: true })
104
+
105
+ this.#connected = true
106
+
107
+ if (this.#isOpen()) {
108
+ if (content) {
109
+ this.#activateLayers(content)
110
+ this.#portalPinned(content)
111
+ }
112
+ this.openValue = true
113
+ } else if (this.openValue) {
114
+ this.#show("trigger-press")
115
+ }
116
+ }
117
+
118
+ // The reconcile path portals ONE FRAME LATE: connect order within a boot
119
+ // is unordered, and portaling before the sibling popper's connect would
120
+ // rob it of its content target before it could cache the node.
121
+ #portalPinned(content) {
122
+ window.requestAnimationFrame(() => {
123
+ if (!this.#connected || !this.#isOpen()) return
124
+
125
+ portalContent(content, { container: resolvePortalContainer(this.element) })
126
+ this.element.setAttribute(POPPER_STRATEGY, "absolute")
127
+ })
128
+ }
129
+
130
+ /**
131
+ * Restores portaled content (drop-never-strand), unwires the content
132
+ * listeners, resets typeahead and the scroll hold, and abandons any
133
+ * exit.
134
+ */
135
+ disconnect() {
136
+ this.#connected = false
137
+
138
+ // Never leave content stranded at the container (drop-never-strand).
139
+ const content = this.#content()
140
+
141
+ if (content) restoreContent(content)
142
+
143
+ for (const [target, type, listener] of this.#wired) target.removeEventListener(type, listener)
144
+
145
+ this.#wired = []
146
+ this.#typeahead.reset()
147
+ this.scrollHoldStop()
148
+ this.#cancelExit?.()
149
+ this.#cancelExit = null
150
+ }
151
+
152
+ // --- controllable state ---
153
+
154
+ /**
155
+ * Stimulus value callback - controllable open state.
156
+ *
157
+ * @param {boolean} value
158
+ */
159
+ openValueChanged(value) {
160
+ if (!this.#connected) return
161
+
162
+ if (value && !this.#isOpen()) this.#show("trigger-press")
163
+ else if (!value && this.#isOpen()) this.#hide("none")
164
+ }
165
+
166
+ /**
167
+ * Stimulus value callback - controllable value (no-ops on the applied
168
+ * echo).
169
+ *
170
+ * @param {string} value
171
+ */
172
+ valueValueChanged(value) {
173
+ if (!this.#connected || value === this.#applied) return
174
+
175
+ this.#apply(value)
176
+ }
177
+
178
+ // --- trigger actions ---
179
+
180
+ /** The trigger's click action: open <-> close. */
181
+ toggle() {
182
+ if (this.#isOpen()) this.#hide("trigger-press")
183
+ else this.#show("trigger-press")
184
+ }
185
+
186
+ /**
187
+ * The trigger's keydown action: Enter / Space / ArrowDown / ArrowUp
188
+ * open and focus the SELECTED option (closed arrows never step
189
+ * the value). A printable key on the CLOSED trigger commits the
190
+ * typeahead match WITHOUT opening (native <select> behavior - the full
191
+ * commit pipeline minus open/close).
192
+ *
193
+ * @param {KeyboardEvent} event
194
+ */
195
+ triggerKeydown(event) {
196
+ if (this.#isOpen()) return
197
+
198
+ if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown" || event.key === "ArrowUp") {
199
+ event.preventDefault()
200
+ this.#show("list-navigation", { seed: this.#applied === "" ? "first" : "selected" })
201
+ return
202
+ }
203
+
204
+ if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
205
+ event.preventDefault()
206
+
207
+ const match = this.#search(event.key)
208
+
209
+ if (match) this.#commitItem(match, { close: false })
210
+ }
211
+ }
212
+
213
+ // --- programmatic API ---
214
+
215
+ /**
216
+ * Programmatically opens.
217
+ *
218
+ * @param {string | Event} [reason="trigger-press"] - a family reason
219
+ * string (an Event, from data-action use, reads as trigger-press)
220
+ * @param {Object} [options]
221
+ * @param {"first" | "selected" | null} [options.seed=null] - the
222
+ * initial-focus seed stamped onto the content for this open
223
+ */
224
+ open(reason = "trigger-press", { seed = null } = {}) {
225
+ if (reason instanceof Event) this.#show("trigger-press")
226
+ else this.#show(reason, { seed })
227
+ }
228
+
229
+ /**
230
+ * Programmatically closes.
231
+ *
232
+ * @param {string | Event} [reason="none"] - a family reason string; an
233
+ * Event reads as "none"
234
+ */
235
+ close(reason = "none") {
236
+ this.#hide(reason instanceof Event ? "none" : reason)
237
+ }
238
+
239
+ /**
240
+ * The programmatic controllable-state surface: applies a value through
241
+ * the full sync pipeline.
242
+ *
243
+ * @param {string} value - stringified; "" clears to the placeholder
244
+ */
245
+ setValue(value) {
246
+ if (value === this.#applied) return
247
+
248
+ this.#apply(String(value ?? ""))
249
+ }
250
+
251
+ // --- option activation ---
252
+
253
+ /**
254
+ * Each option's click action (markup-declared); the delegated content
255
+ * click claims first, so both paths never double-commit.
256
+ *
257
+ * @param {MouseEvent} event
258
+ */
259
+ commit(event) {
260
+ if (this.#claim(event)) return
261
+
262
+ const origin = event.currentTarget instanceof Element ? event.currentTarget : event.target
263
+ const item = origin instanceof Element ? origin.closest(ITEM_SELECTOR) : null
264
+
265
+ if (!item || this.#isDisabled(item)) return
266
+
267
+ this.#commitItem(item)
268
+ }
269
+
270
+ // --- content keydown (commit keys, typeahead, the Tab-inert delta) ---
271
+
272
+ /**
273
+ * The content's keydown route (also wired programmatically): Enter and
274
+ * Space commit the focused option (Space defers to a live typeahead),
275
+ * Tab is INERT while open (a value picker resolves by commit or Esc -
276
+ * contractual), Left/Right no-op (flat listbox), and printable
277
+ * keys move focus via typeahead - selection never follows focus.
278
+ *
279
+ * @param {KeyboardEvent} event
280
+ */
281
+ keydown(event) {
282
+ if (this.#claim(event)) return
283
+ if (!this.#isOpen()) return
284
+
285
+ const target = event.target instanceof Element ? event.target : null
286
+ const item = target?.closest(ITEM_SELECTOR) ?? null
287
+
288
+ switch (event.key) {
289
+ case "Tab":
290
+ // THE delta vs the menu family: Tab is INERT while open - a value
291
+ // picker resolves by commit or Esc (contractual).
292
+ event.preventDefault()
293
+ event.stopImmediatePropagation()
294
+ return
295
+ case "Enter":
296
+ case " ":
297
+ if (event.key === " " && this.#typeahead.pending()) break // space extends a live search
298
+
299
+ event.preventDefault()
300
+
301
+ if (item && !this.#isDisabled(item)) this.#commitItem(item)
302
+
303
+ return
304
+ case "ArrowRight":
305
+ case "ArrowLeft":
306
+ return // flat listbox: no submenu meaning, no edge seam - no-ops
307
+ default:
308
+ break
309
+ }
310
+
311
+ if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
312
+ event.preventDefault()
313
+
314
+ // Open typeahead moves focus (highlight) ONLY - selection does not
315
+ // follow focus; commit stays explicit.
316
+ this.#search(event.key, item)?.focus()
317
+ }
318
+ }
319
+
320
+ // --- autofill adoption ---
321
+
322
+ /**
323
+ * The hidden native select's change action: browser autofill (or any
324
+ * programmatic write) fires change there - the UI adopts it without
325
+ * re-writing the native (no loop).
326
+ */
327
+ nativeChanged() {
328
+ const value = this.#native()?.value ?? ""
329
+
330
+ if (value === this.#applied) return
331
+
332
+ this.#apply(value, { fromNative: true })
333
+ }
334
+
335
+ // --- scroll buttons ---
336
+
337
+ /**
338
+ * The viewport's scroll action (also run on open): shows each scroll
339
+ * button only while scroll room remains on its side.
340
+ */
341
+ syncScrollButtons() {
342
+ const content = this.#content()
343
+ const viewport = content?.querySelector(VIEWPORT_SELECTOR)
344
+
345
+ if (!content || !viewport) return
346
+
347
+ const up = content.querySelector(SCROLL_UP_SELECTOR)
348
+ const down = content.querySelector(SCROLL_DOWN_SELECTOR)
349
+
350
+ if (up) up.hidden = viewport.scrollTop <= 0
351
+ if (down) down.hidden = viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight
352
+ }
353
+
354
+ /**
355
+ * The scroll buttons' pointerenter action: scrolls the viewport a few
356
+ * px per frame while hovered (hold-to-scroll).
357
+ *
358
+ * @param {PointerEvent} event
359
+ */
360
+ scrollHoldStart(event) {
361
+ const origin = event.currentTarget instanceof Element ? event.currentTarget : null
362
+ const direction = origin?.matches(SCROLL_UP_SELECTOR) ? -1 : 1
363
+ const viewport = this.#content()?.querySelector(VIEWPORT_SELECTOR)
364
+
365
+ if (!viewport) return
366
+
367
+ this.scrollHoldStop()
368
+
369
+ const step = () => {
370
+ viewport.scrollTop += direction * SCROLL_HOLD_STEP
371
+ this.syncScrollButtons()
372
+ this.#scrollFrame = window.requestAnimationFrame(step)
373
+ }
374
+
375
+ this.#scrollFrame = window.requestAnimationFrame(step)
376
+ }
377
+
378
+ /** The scroll buttons' pointerleave action: stops the held scroll. */
379
+ scrollHoldStop() {
380
+ if (this.#scrollFrame === null) return
381
+
382
+ window.cancelAnimationFrame(this.#scrollFrame)
383
+ this.#scrollFrame = null
384
+ }
385
+
386
+ // --- open / close ---
387
+
388
+ #show(reason, { seed = null } = {}) {
389
+ const content = this.#content()
390
+
391
+ if (!content || this.#isOpen()) return
392
+
393
+ this.#cancelExit?.()
394
+ this.#cancelExit = null
395
+ this.#suppressRestore = false
396
+
397
+ const trigger = this.#trigger()
398
+
399
+ // Portal-on-open: move BEFORE the
400
+ // enter presence (reparenting mid-animation restarts it), re-anchor
401
+ // absolute - static under compositor scroll, transform-immune. In
402
+ // aligned mode the content fixed-positions ITSELF (viewport coords,
403
+ // location-independent - the math is written in viewport
404
+ // coordinates) and popper's writes stay bailed either way.
405
+ portalContent(content, { container: resolvePortalContainer(this.element) })
406
+ this.element.setAttribute(POPPER_STRATEGY, "absolute")
407
+
408
+ content.hidden = false
409
+ content.setAttribute("data-open-reason", reason)
410
+ if (seed) content.setAttribute("data-open-seed", seed)
411
+ else content.removeAttribute("data-open-seed")
412
+ trigger?.setAttribute("aria-expanded", "true")
413
+ if (trigger) setState(trigger, "popup-open")
414
+ enterPresence(content)
415
+ this.#activateLayers(content)
416
+ this.openValue = true
417
+
418
+ // The layer controllers connect on the attribute-mutation microtask;
419
+ // initial focus rides one microtask behind so focus-scope has already
420
+ // snapshotted the trigger (its focus-return target).
421
+ queueMicrotask(() => {
422
+ if (!this.#isOpen()) return
423
+
424
+ this.#focusSelected(content)
425
+ // AFTER #focusSelected: the aligned scroll write must win over the
426
+ // focus scroll-into-view.
427
+ if (this.#alignEligible()) this.#alignWithTrigger(content)
428
+ this.syncScrollButtons()
429
+ this.dispatch("open", { prefix: EVENT_PREFIX, detail: seed ? { reason, seed } : { reason } })
430
+ })
431
+ }
432
+
433
+ // --- alignItemWithTrigger (the native-select-feel placement) ---
434
+ //
435
+ // The popup opens OVER the trigger with the selected item's TEXT center
436
+ // aligned to the trigger's value-text center (native <select> feel): the
437
+ // content stretches from the trigger line to the viewport bottom (or top,
438
+ // when the selection sits deep in a long list) and the LIST SCROLLS so
439
+ // the item lands on the trigger. Not a popper placement: the content is
440
+ // fixed-positioned by this routine and popper's writes are suppressed by
441
+ // the data-align-item-with-trigger attribute (its #update bails). The
442
+ // fallback rules: touch environments fall back (approximated by
443
+ // pointer:coarse), trigger within 20px of a viewport
444
+ // edge falls back, a popup squeezed under min(scrollHeight, 100px) falls
445
+ // back - all to plain popper positioning for THAT open. Skipped
446
+ // deliberately: WebKit pinch-zoom detection and scale normalization.
447
+
448
+ #alignEligible() {
449
+ return this.alignItemWithTriggerValue && !window.matchMedia?.("(pointer: coarse)")?.matches
450
+ }
451
+
452
+ #alignWithTrigger(content) {
453
+ const trigger = this.#trigger()
454
+
455
+ if (!trigger) return
456
+
457
+ // Aligned mode has no side: kill popper's writes for this open (the
458
+ // attribute gates popper#update) and the data-side entrance variants.
459
+ content.setAttribute("data-align-item-with-trigger", "")
460
+ content.removeAttribute("data-side")
461
+
462
+ const placed = this.#placeAligned(content, trigger)
463
+
464
+ if (!placed) {
465
+ content.removeAttribute("data-align-item-with-trigger")
466
+ this.#clearAlignedStyles(content)
467
+ }
468
+ }
469
+
470
+ #placeAligned(content, trigger) {
471
+ const scroller = content.querySelector(VIEWPORT_SELECTOR) ?? content
472
+ const selected =
473
+ content.querySelector(`${ITEM_SELECTOR}[data-selected]`) ?? content.querySelector(ITEM_SELECTOR)
474
+ const textElement = selected?.querySelector(ITEM_TEXT_SELECTOR) ?? selected
475
+ const valueElement = trigger.querySelector(VALUE_SELECTOR)
476
+
477
+ if (!selected) return false
478
+
479
+ // Aligned mode ships NO entrance animation, and
480
+ // measuring demands it: the presence starting-style is a STATIC
481
+ // transform (scale) that animation/transition suppression cannot
482
+ // remove - it distorts every rect read this routine makes (caught
483
+ // live: the correction pass measured dy~0 through a scale(.95) and
484
+ // the popup settled 100px low). Suppress all three for the whole
485
+ // open; the fallback path restores them for popper mode, and
486
+ // #clearAlignedStyles clears them on close.
487
+ const previousAnimation = content.style.animation
488
+ const previousTransition = content.style.transition
489
+ const previousTransform = content.style.transform
490
+ content.style.animation = "none"
491
+ content.style.transition = "none"
492
+ content.style.transform = "none"
493
+
494
+ const styles = getComputedStyle(content)
495
+ const marginTop = parseFloat(styles.marginTop) || 10
496
+ const marginBottom = parseFloat(styles.marginBottom) || 10
497
+ const minHeight = parseFloat(styles.minHeight) || 100
498
+ const borderBottom = parseFloat(styles.borderBottomWidth) || 0
499
+ const collisionThreshold = 20
500
+ const paddingX = 5
501
+
502
+ const triggerRect = trigger.getBoundingClientRect()
503
+ const contentRect = content.getBoundingClientRect()
504
+ const viewportHeight = document.documentElement.clientHeight - marginTop - marginBottom
505
+ const viewportWidth = document.documentElement.clientWidth
506
+ const availableBeneathTrigger = viewportHeight - triggerRect.bottom + triggerRect.height
507
+
508
+ let alignedLeft = triggerRect.left
509
+ let offsetY = 0
510
+
511
+ if (textElement && valueElement) {
512
+ const valueRect = valueElement.getBoundingClientRect()
513
+ const textRect = textElement.getBoundingClientRect()
514
+
515
+ alignedLeft = contentRect.left + (valueRect.left - textRect.left)
516
+ const valueCenterFromTriggerTop = valueRect.top - triggerRect.top + valueRect.height / 2
517
+ const textCenterFromContentTop = textRect.top - contentRect.top + textRect.height / 2
518
+
519
+ offsetY = textCenterFromContentTop - valueCenterFromTriggerTop
520
+ }
521
+
522
+ const idealHeight = availableBeneathTrigger + offsetY + marginBottom + borderBottom
523
+ let height = Math.min(viewportHeight, idealHeight)
524
+ const scrollTop = idealHeight - height
525
+ const maxScrollTop = scroller.scrollHeight - scroller.clientHeight
526
+
527
+ const fallback =
528
+ triggerRect.top < collisionThreshold ||
529
+ triggerRect.bottom > viewportHeight - collisionThreshold ||
530
+ Math.ceil(height) < Math.min(scroller.scrollHeight, minHeight)
531
+
532
+ if (fallback) {
533
+ content.style.animation = previousAnimation
534
+ content.style.transition = previousTransition
535
+ content.style.transform = previousTransform
536
+ return false
537
+ }
538
+
539
+ // The dictionary's cn-select-viewport consumes these (the --radix-*
540
+ // names are a compatibility surface for ported CSS) - aligned mode
541
+ // is what they exist for.
542
+ content.style.setProperty("--radix-select-trigger-height", `${triggerRect.height}px`)
543
+ content.style.setProperty("--radix-select-trigger-width", `${triggerRect.width}px`)
544
+
545
+ content.style.position = "fixed"
546
+ content.style.left = `${Math.min(Math.max(alignedLeft, paddingX), viewportWidth - paddingX - contentRect.width)}px`
547
+ content.style.maxHeight = "none"
548
+ content.style.marginTop = `${marginTop}px`
549
+ content.style.marginBottom = `${marginBottom}px`
550
+
551
+ if (scrollTop >= maxScrollTop - 1) {
552
+ // Selection deep in the list: anchor to the top edge instead and pin
553
+ // the list at its end (the top-anchored branch).
554
+ const topOffset = Math.max(0, viewportHeight - idealHeight)
555
+ height = Math.min(viewportHeight, contentRect.height) - (scrollTop - maxScrollTop)
556
+ content.style.top = `${contentRect.height >= viewportHeight - marginTop ? 0 : topOffset}px`
557
+ content.style.bottom = ""
558
+ content.style.height = `${Math.max(minHeight, height)}px`
559
+ scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight
560
+ } else {
561
+ content.style.top = ""
562
+ content.style.bottom = "0"
563
+ content.style.height = `${Math.max(minHeight, height)}px`
564
+ scroller.scrollTop = scrollTop
565
+ }
566
+
567
+ // Second pass: the placement math assumes fixed coordinates ARE
568
+ // viewport coordinates. poetry keeps the content in
569
+ // place, so a transformed/filtered ancestor becomes the fixed
570
+ // containing block and every coordinate above lands shifted by that
571
+ // box's offset (caught live: a docs example frame put the item 112px
572
+ // under the trigger). Measure the ACTUAL text/value deltas after
573
+ // placement and correct both axes - containing-block-proof by
574
+ // construction.
575
+ if (textElement && valueElement) {
576
+ const valueRect = valueElement.getBoundingClientRect()
577
+ const placedText = textElement.getBoundingClientRect()
578
+ const dy = (placedText.top + placedText.height / 2) - (valueRect.top + valueRect.height / 2)
579
+ const dx = placedText.left - valueRect.left
580
+
581
+ if (Math.abs(dy) > 0.5) {
582
+ if (content.style.bottom === "0px" || content.style.bottom === "0") {
583
+ content.style.bottom = `${parseFloat(content.style.bottom || "0") + dy}px`
584
+ } else {
585
+ content.style.top = `${parseFloat(content.style.top || "0") - dy}px`
586
+ }
587
+ }
588
+ if (Math.abs(dx) > 0.5) {
589
+ content.style.left = `${parseFloat(content.style.left || "0") - dx}px`
590
+ }
591
+ }
592
+
593
+ return true
594
+ }
595
+
596
+ #clearAlignedStyles(content) {
597
+ for (const property of ["position", "left", "top", "bottom", "height", "max-height",
598
+ "margin-top", "margin-bottom", "animation", "transition", "transform"]) {
599
+ content.style.removeProperty(property)
600
+ }
601
+ content.style.removeProperty("--radix-select-trigger-height")
602
+ content.style.removeProperty("--radix-select-trigger-width")
603
+ }
604
+
605
+ #hide(reason, { restoreFocus = true } = {}) {
606
+ const content = this.#content()
607
+
608
+ if (!content || !this.#isOpen()) return
609
+
610
+ this.#typeahead.reset()
611
+ this.scrollHoldStop()
612
+ this.#suppressRestore = !restoreFocus || (reason === "outside-press" && !this.modalValue)
613
+
614
+ const trigger = this.#trigger()
615
+
616
+ trigger?.setAttribute("aria-expanded", "false")
617
+ if (trigger) setState(trigger, "popup-closed")
618
+ content.removeAttribute("data-open-reason")
619
+ content.removeAttribute("data-open-seed")
620
+ if (content.hasAttribute("data-align-item-with-trigger")) {
621
+ content.removeAttribute("data-align-item-with-trigger")
622
+ this.#clearAlignedStyles(content)
623
+ }
624
+ this.openValue = false
625
+
626
+ this.#cancelExit = exitPresence(content, {
627
+ onRemove: () => {
628
+ this.#cancelExit = null
629
+ content.hidden = true
630
+ // Home AFTER the exit finished and hidden landed; focus
631
+ // return is focus-scope's ref-based job, indifferent to the move.
632
+ restoreContent(content)
633
+ this.element.setAttribute(POPPER_STRATEGY, "fixed")
634
+ this.#removeControllers(content, CONTENT_LAYER_CONTROLLERS)
635
+ this.dispatch("closed", { prefix: EVENT_PREFIX, detail: { reason } })
636
+ }
637
+ })
638
+ }
639
+
640
+ // Initial focus: the SELECTED option for EVERY reason, pointer included
641
+ // (the listbox exists to show you where you are);
642
+ // first enabled option when the value is empty.
643
+ #focusSelected(content) {
644
+ const items = this.#enabledItems(content)
645
+ const target = items.find((item) => item.getAttribute("aria-selected") === "true") ?? items[0]
646
+
647
+ if (!target) {
648
+ content.focus()
649
+ return
650
+ }
651
+
652
+ target.focus()
653
+ target.scrollIntoView?.({ block: "nearest" })
654
+ }
655
+
656
+ // --- the commit pipeline ---
657
+
658
+ #commitItem(item, { close = true } = {}) {
659
+ const value = item.dataset.value ?? ""
660
+
661
+ // Cancelable BEFORE the value commits: preventDefault vetoes the commit
662
+ // and keeps the listbox open (the family select-event shape).
663
+ const select = this.dispatch("select", {
664
+ prefix: EVENT_PREFIX,
665
+ target: item,
666
+ cancelable: true,
667
+ detail: { item, value, label: this.#labelOf(item) }
668
+ })
669
+
670
+ if (select.defaultPrevented) return
671
+
672
+ if (value !== this.#applied) this.#apply(value)
673
+ if (close) this.#hide("item-press")
674
+ }
675
+
676
+ // THE single sync path, native-first. silent skips events (reconcile);
677
+ // fromNative skips step 1's write-back (the value came FROM the native).
678
+ #apply(value, { silent = false, fromNative = false, force = false } = {}) {
679
+ if (!force && value === this.#applied) return
680
+
681
+ const previous = this.#applied
682
+
683
+ this.#applied = value
684
+ this.valueValue = value // the attribute mirror; the changed callback no-ops on #applied
685
+
686
+ // 1. The native select FIRST - serialization truth is never behind the
687
+ // display - with real bubbling change/input (Turbo/analytics fire).
688
+ const native = this.#native()
689
+
690
+ if (native && !fromNative) {
691
+ native.value = value
692
+
693
+ if (!silent) {
694
+ native.dispatchEvent(new Event("input", { bubbles: true }))
695
+ native.dispatchEvent(new Event("change", { bubbles: true }))
696
+ }
697
+ }
698
+
699
+ // 2. aria-selected + data-selected flipped TOGETHER on every option.
700
+ let selected = null
701
+
702
+ for (const item of this.#items()) {
703
+ const match = value !== "" && (item.dataset.value ?? "") === value
704
+
705
+ item.setAttribute("aria-selected", String(match))
706
+ setState(item, match ? "selected" : "unselected")
707
+
708
+ if (match) selected = item
709
+ }
710
+
711
+ // 3 + 4. The value display syncs from the option's item-text (or its
712
+ // data-text-value); an empty value restores the placeholder + the
713
+ // trigger's data-placeholder dimming.
714
+ const display = this.#display()
715
+ const label = selected ? this.#labelOf(selected) : null
716
+
717
+ if (display) display.textContent = label ?? this.#placeholder
718
+
719
+ this.#trigger()?.toggleAttribute("data-placeholder", !selected)
720
+
721
+ // 5. The poetry change event.
722
+ if (!silent) {
723
+ this.dispatch("change", { prefix: EVENT_PREFIX, detail: { value, label, previous } })
724
+ }
725
+ }
726
+
727
+ // --- typeahead (the shared helper; anchor = focused option when open,
728
+ // the selected option when closed) ---
729
+
730
+ #search(key, activeItem = null) {
731
+ const content = this.#content()
732
+
733
+ if (!content) return null
734
+
735
+ const items = this.#enabledItems(content)
736
+ const active = activeItem ??
737
+ (this.#isOpen()
738
+ ? (document.activeElement instanceof Element ? document.activeElement.closest(ITEM_SELECTOR) : null)
739
+ : items.find((item) => item.getAttribute("aria-selected") === "true") ?? null)
740
+
741
+ return this.#typeahead.search(key, items, { active, timeout: this.typeaheadTimeoutValue })
742
+ }
743
+
744
+ #labelOf(item) {
745
+ return (item.dataset.textValue ??
746
+ item.querySelector(ITEM_TEXT_SELECTOR)?.textContent ??
747
+ typeaheadLabel(item)).trim()
748
+ }
749
+
750
+ // --- content wiring (programmatic: portal-safe) ---
751
+
752
+ #wireContent(content) {
753
+ this.#listen(content, "keydown", (event) => this.keydown(event))
754
+ this.#listen(content, "click", this.#onClick)
755
+ this.#listen(content, "scroll", () => this.syncScrollButtons())
756
+ this.#listen(content, "pointerover", this.#onPointerover)
757
+ this.#listen(content, "pointerout", this.#onPointerout)
758
+ this.#listen(content, "poetry--core--dismissable:dismiss", this.#onDismiss)
759
+ this.#listen(content, "poetry--core--dismissable:interact-outside", this.#onInteractOutside)
760
+ this.#listen(content, "poetry--core--focus-scope:mount-auto-focus", this.#onMountAutoFocus)
761
+ this.#listen(content, "poetry--core--focus-scope:unmount-auto-focus", this.#onUnmountAutoFocus)
762
+ }
763
+
764
+ // Focus follows the pointer (the menu family's hover-highlight rule):
765
+ // themes style option highlight through focus:
766
+ // alone, so hover highlight IS this focus move. Disabled options clear
767
+ // it; touch pointers never hover-highlight.
768
+ #onPointerover = (event) => {
769
+ if (event.pointerType === "touch") return
770
+
771
+ const target = event.target instanceof Element ? event.target : null
772
+ const item = target?.closest(ITEM_SELECTOR)
773
+
774
+ if (!item) return
775
+
776
+ if (this.#isDisabled(item)) this.#content()?.focus()
777
+ else if (document.activeElement !== item) item.focus()
778
+ }
779
+
780
+ // Leaving an option for blank space hands focus back to the content so
781
+ // the highlight clears; option-to-option travel refocuses via pointerover.
782
+ #onPointerout = (event) => {
783
+ const target = event.target instanceof Element ? event.target : null
784
+ const item = target?.closest(ITEM_SELECTOR)
785
+ const related = event.relatedTarget instanceof Element ? event.relatedTarget : null
786
+
787
+ if (item && document.activeElement === item && (!related || !related.closest(ITEM_SELECTOR))) {
788
+ this.#content()?.focus()
789
+ }
790
+ }
791
+
792
+ #listen(target, type, listener) {
793
+ target.addEventListener(type, listener)
794
+ this.#wired.push([target, type, listener])
795
+ }
796
+
797
+ #onClick = (event) => {
798
+ if (this.#claim(event)) return
799
+
800
+ const item = event.target instanceof Element ? event.target.closest(ITEM_SELECTOR) : null
801
+
802
+ if (!item || this.#isDisabled(item)) return
803
+
804
+ this.#commitItem(item)
805
+ }
806
+
807
+ // A press on the select's OWN trigger is the toggle's job, not an
808
+ // outside dismissal: without the veto the pointerdown closes and the
809
+ // trailing click re-opens (the popover trigger-press rule).
810
+ #onInteractOutside = (event) => {
811
+ if (event.target !== this.#content()) return
812
+
813
+ const origin = event.detail?.originalEvent?.target
814
+
815
+ if (origin instanceof Element && this.#trigger()?.contains(origin)) event.preventDefault()
816
+ }
817
+
818
+ // Esc / outside press arrive as the dismissable layer's dismiss event.
819
+ // Esc NEVER commits - the value is untouched.
820
+ #onDismiss = (event) => {
821
+ if (event.target !== this.#content()) return
822
+
823
+ const escaped = event.detail?.originalEvent?.type === "keydown"
824
+
825
+ this.#hide(escaped ? "escape-key" : "outside-press")
826
+ }
827
+
828
+ // The select owns initial focus (the selected option), not focus-scope.
829
+ #onMountAutoFocus = (event) => {
830
+ if (event.target === this.#content()) event.preventDefault()
831
+ }
832
+
833
+ #onUnmountAutoFocus = (event) => {
834
+ if (event.target === this.#content() && this.#suppressRestore) event.preventDefault()
835
+ }
836
+
837
+ // --- the layer stack (the menu controller's proven mechanism) ---
838
+
839
+ #activateLayers(content) {
840
+ content.setAttribute("data-poetry--core--focus-scope-trapped-value", String(this.modalValue))
841
+ content.setAttribute(
842
+ "data-poetry--core--dismissable-disable-outside-pointer-events-value", String(this.modalValue)
843
+ )
844
+ content.setAttribute(`data-${ROVING}-orientation-value`, "vertical")
845
+ content.setAttribute(`data-${ROVING}-manage-tabindex-value`, "true")
846
+ content.setAttribute(`data-${ROVING}-loop-value`, String(this.loopValue))
847
+
848
+ const action = content.getAttribute("data-action") ?? ""
849
+
850
+ if (!action.includes(ROVING_ACTION)) {
851
+ content.setAttribute("data-action", `${action} ${ROVING_ACTION}`.trim())
852
+ }
853
+
854
+ this.#addControllers(content, CONTENT_LAYER_CONTROLLERS)
855
+ }
856
+
857
+ #addControllers(element, identifiers) {
858
+ const tokens = (element.getAttribute("data-controller") ?? "").split(/\s+/).filter(Boolean)
859
+
860
+ for (const identifier of identifiers) {
861
+ if (!tokens.includes(identifier)) tokens.push(identifier)
862
+ }
863
+
864
+ element.setAttribute("data-controller", tokens.join(" "))
865
+ }
866
+
867
+ #removeControllers(element, identifiers) {
868
+ const tokens = (element.getAttribute("data-controller") ?? "")
869
+ .split(/\s+/)
870
+ .filter((token) => token && !identifiers.includes(token))
871
+
872
+ element.setAttribute("data-controller", tokens.join(" "))
873
+ }
874
+
875
+ // --- structural resolution (ids are the seams; portal-safe) ---
876
+
877
+ #trigger() {
878
+ return this.element.querySelector(TRIGGER_SELECTOR)
879
+ }
880
+
881
+ #content() {
882
+ const id = this.#trigger()?.getAttribute("aria-controls")
883
+
884
+ return id ? document.getElementById(id) : null
885
+ }
886
+
887
+ #native() {
888
+ return this.element.querySelector(NATIVE_SELECTOR)
889
+ }
890
+
891
+ #display() {
892
+ return this.element.querySelector(VALUE_SELECTOR)
893
+ }
894
+
895
+ #isOpen() {
896
+ const content = this.#content()
897
+
898
+ return Boolean(content) && stateOf(content) === "open"
899
+ }
900
+
901
+ #items() {
902
+ const content = this.#content()
903
+
904
+ return content ? collectionItems(content, ITEM_SELECTOR) : []
905
+ }
906
+
907
+ #enabledItems(content = this.#content()) {
908
+ if (!content) return []
909
+
910
+ return collectionItems(content, ITEM_SELECTOR).filter((item) => !this.#isDisabled(item))
911
+ }
912
+
913
+ #isDisabled(item) {
914
+ return item.hasAttribute("data-disabled") || item.getAttribute("aria-disabled") === "true"
915
+ }
916
+
917
+ #claim(event) {
918
+ if (this.#claimed.has(event)) return true
919
+
920
+ this.#claimed.add(event)
921
+
922
+ return false
923
+ }
924
+ }