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,1050 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { collectionItems } from "@poetry/controllers/helpers/collection"
3
+ import { directionOf } from "@poetry/controllers/helpers/direction"
4
+ import { 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 { createTypeahead } from "@poetry/controllers/helpers/typeahead"
8
+
9
+ // The menus-family controller (the DropdownMenu contract's ANCHOR - ONE
10
+ // menu engine behind DropdownMenu / ContextMenu / Menubar).
11
+ // This owns ONLY what is menu-specific: open/close with the data-open-reason
12
+ // initial-focus contract, item activation (the cancelable poetry:menu:select),
13
+ // checkbox/radio state (poetry:menu:change), the APG typeahead buffer,
14
+ // submenu open/close with hover intent + sibling exclusivity, and the
15
+ // cancelable poetry:menu:edge-navigate seam a Menubar coordinator consumes.
16
+ // Everything else is composed BY REFERENCE: focus-scope (trap + focus return
17
+ // to the trigger), dismissable (topmost-only Esc + outside press, arriving
18
+ // here as its "dismiss" event), roving-focus (arrows/Home/End per menu
19
+ // level), popper (positioning - markup-owned, untouched here), presence
20
+ // (data-open/data-closed flip -> animationend -> hidden).
21
+ //
22
+ // STRUCTURAL RESOLUTION, no targets: the content is found via the trigger's
23
+ // aria-controls id (portal-safe - a Stimulus target cannot cross a portal
24
+ // move), items via the collection helper over the family's data-slot suffix
25
+ // selectors ([data-slot$=menu-item] etc., so dropdown-menu-*, context-menu-*
26
+ // and menubar-* anatomies all resolve), subs via their own aria-controls
27
+ // pairs. Content-level listeners are wired programmatically in connect for
28
+ // the same portal-safety reason.
29
+ //
30
+ // The layer stack is ACTIVATED on open: the focus-scope / dismissable /
31
+ // roving-focus identifiers are appended to the content's data-controller (a
32
+ // statically-connected trap or dismiss layer on a hidden menu would steal
33
+ // focus at page load and swallow topmost-Esc). Close reverses: presence exit
34
+ // -> hidden -> tokens removed -> focus-scope's disconnect restores focus to
35
+ // the trigger. Each open sub level adds its own dismissable layer (the
36
+ // close-one-level-at-a-time Esc chain for free) + its own roving group; subs
37
+ // join the ROOT focus scope (no nested traps, contractual).
38
+ // Both family spellings resolve: dropdown-menu-* / context-menu-* share the
39
+ // "menu-" suffix; menubar-* is its own word (menubar-item does NOT end with
40
+ // "menu-item"), so every part selector carries the pair.
41
+ const menuSlot = (suffix) => `[data-slot$="menu-${suffix}"], [data-slot$="menubar-${suffix}"]`
42
+ const MENU_SELECTOR = '[role="menu"]'
43
+ const TRIGGER_SELECTOR = menuSlot("trigger")
44
+ const SUB_SELECTOR = menuSlot("sub")
45
+ const SUB_TRIGGER_SELECTOR = menuSlot("sub-trigger")
46
+ const SUB_CONTENT_SELECTOR = menuSlot("sub-content")
47
+ const CHECKBOX_ITEM_SELECTOR = menuSlot("checkbox-item")
48
+ const RADIO_ITEM_SELECTOR = menuSlot("radio-item")
49
+ const RADIO_GROUP_SELECTOR = menuSlot("radio-group")
50
+ const ITEM_SELECTOR = [
51
+ menuSlot("item"),
52
+ CHECKBOX_ITEM_SELECTOR,
53
+ RADIO_ITEM_SELECTOR,
54
+ SUB_TRIGGER_SELECTOR
55
+ ].join(", ")
56
+
57
+ // The family event namespace (poetry:menu:open|closed|select|change|edge-navigate).
58
+ const EVENT_PREFIX = "poetry:menu"
59
+
60
+ const ROVING = "poetry--core--roving-focus"
61
+ const ROVING_ACTION = `keydown->${ROVING}#keydown`
62
+ const CONTENT_LAYER_CONTROLLERS = ["poetry--core--focus-scope", "poetry--core--dismissable", ROVING]
63
+ const SUB_LAYER_CONTROLLERS = ["poetry--core--dismissable", ROVING]
64
+ const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
65
+
66
+ // Hover intent (the contract's spec): open after 100ms of rest on a
67
+ // sub-trigger; close 300ms after the pointer leaves the sub pair - entering
68
+ // the sub-content (or the trigger again) within the window cancels it, which
69
+ // is the simple-closeDelay answer to the grace-area open question.
70
+ const SUB_OPEN_DELAY = 100
71
+ const SUB_CLOSE_DELAY = 300
72
+
73
+ export default class MenuController extends Controller {
74
+ // The events this controller dispatches (manifest surface;
75
+ // events_declaration.test.js enforces the list stays honest).
76
+ static events = [
77
+ "poetry:menu:change", "poetry:menu:closed", "poetry:menu:edge-navigate",
78
+ "poetry:menu:open", "poetry:menu:select"
79
+ ]
80
+
81
+ static values = {
82
+ open: { type: Boolean, default: false },
83
+ modal: { type: Boolean, default: true },
84
+ loop: { type: Boolean, default: false },
85
+ typeaheadTimeout: { type: Number, default: 1000 },
86
+ closeOnSelect: { type: Boolean, default: true }
87
+ }
88
+
89
+ #connected = false
90
+ #wired = []
91
+ #claimed = new WeakSet() // events already handled once (data-action + delegation both firing)
92
+ #suppressRestore = false
93
+ #cancelExit = null
94
+ #typeahead = createTypeahead()
95
+ #subOpenTimers = new Map()
96
+ #subCloseTimers = new Map()
97
+ #portaledSubs = new Set()
98
+
99
+ /**
100
+ * Wires the content listeners (portal-safe) and reconciles: server-open
101
+ * content adopts the layer stack and re-portals one frame late.
102
+ */
103
+ connect() {
104
+ const content = this.#content()
105
+
106
+ if (content) this.#wireContent(content)
107
+
108
+ this.#connected = true
109
+
110
+ // Reconcile-on-connect: the server may own the open state (Turbo Stream
111
+ // re-render). DOM attributes win; the layer stack catches up.
112
+ if (this.#isOpen()) {
113
+ if (content) {
114
+ this.#activateLayers(content)
115
+ this.#portalPinned(content)
116
+ }
117
+ this.openValue = true
118
+ } else if (this.openValue) {
119
+ this.#show("trigger-press", { focus: false })
120
+ }
121
+ }
122
+
123
+ // The reconcile path portals ONE FRAME LATE: connect order within a boot
124
+ // is unordered, and portaling before the sibling popper's connect would
125
+ // rob it of its content target before it could cache the node.
126
+ #portalPinned(content) {
127
+ window.requestAnimationFrame(() => {
128
+ if (!this.#connected || !this.#isOpen()) return
129
+
130
+ portalContent(content, { container: resolvePortalContainer(this.element) })
131
+ this.#setStrategy(content, "absolute")
132
+ })
133
+ }
134
+
135
+ /**
136
+ * Restores portaled subs then the content (drop-never-strand), unwires
137
+ * everything, and clears the sub hover-intent timers.
138
+ */
139
+ disconnect() {
140
+ this.#connected = false
141
+
142
+ // Never leave content stranded at the container (drop-never-strand) -
143
+ // portaled subs first, their placeholders live inside the root content.
144
+ for (const subContent of this.#portaledSubs) {
145
+ restoreContent(subContent)
146
+ subContent.style.pointerEvents = ""
147
+ }
148
+
149
+ this.#portaledSubs.clear()
150
+
151
+ const content = this.#content()
152
+
153
+ if (content) restoreContent(content)
154
+
155
+ for (const [target, type, listener] of this.#wired) target.removeEventListener(type, listener)
156
+
157
+ this.#wired = []
158
+
159
+ for (const timer of this.#subOpenTimers.values()) window.clearTimeout(timer)
160
+ for (const timer of this.#subCloseTimers.values()) window.clearTimeout(timer)
161
+
162
+ this.#subOpenTimers.clear()
163
+ this.#subCloseTimers.clear()
164
+ this.#typeahead.reset()
165
+ this.#cancelExit?.()
166
+ this.#cancelExit = null
167
+ }
168
+
169
+ /**
170
+ * Stimulus value callback - controllable state: a host (outlet / Turbo
171
+ * Stream / URL param) may own the open value; flipping the attribute
172
+ * drives the same machine.
173
+ *
174
+ * @param {boolean} value
175
+ */
176
+ openValueChanged(value) {
177
+ if (!this.#connected) return
178
+
179
+ if (value && !this.#isOpen()) this.#show("trigger-press")
180
+ else if (!value && this.#isOpen()) this.#hide("none")
181
+ }
182
+
183
+ // --- trigger actions ---
184
+
185
+ /** The trigger's click action: open <-> close. */
186
+ toggle() {
187
+ if (this.#isOpen()) this.#hide("trigger-press")
188
+ else this.#show("trigger-press")
189
+ }
190
+
191
+ /**
192
+ * The trigger's keydown action: Enter / Space / ArrowDown open + focus
193
+ * the FIRST enabled item; ArrowUp opens + focuses the LAST (the APG
194
+ * menu-button map). preventDefault also suppresses the button's
195
+ * synthetic click, so toggle cannot double-fire.
196
+ *
197
+ * @param {KeyboardEvent} event
198
+ */
199
+ triggerKeydown(event) {
200
+ if (this.#isOpen()) return
201
+
202
+ if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
203
+ event.preventDefault()
204
+ this.#show("list-navigation", { seed: "first" })
205
+ } else if (event.key === "ArrowUp") {
206
+ event.preventDefault()
207
+ this.#show("list-navigation", { seed: "last" })
208
+ }
209
+ }
210
+
211
+ // --- programmatic API (the family surface Menubar's coordinator calls) ---
212
+
213
+ /**
214
+ * Programmatically opens (the family surface Menubar's coordinator
215
+ * calls).
216
+ *
217
+ * @param {string | Event} [reason="trigger-press"] - a family reason
218
+ * string; an Event (data-action use) reads as trigger-press
219
+ * @param {Object} [options]
220
+ * @param {boolean} [options.focus=true] - false is the coordinator's
221
+ * pointer-toggle contract (a menubar pointer-open leaves focus on the
222
+ * trigger; keyboard-open focuses an item)
223
+ * @param {"first" | "last" | null} [options.seed=null] - the
224
+ * initial-focus seed
225
+ */
226
+ open(reason = "trigger-press", { focus = true, seed = null } = {}) {
227
+ if (reason instanceof Event) this.#show("trigger-press")
228
+ else this.#show(reason, { focus, seed })
229
+ }
230
+
231
+ /**
232
+ * Programmatically closes.
233
+ *
234
+ * @param {string | Event} [reason="none"]
235
+ * @param {Object} [options]
236
+ * @param {boolean} [options.restoreFocus=true] - false is the
237
+ * hover-slide/edge-navigate contract (the outgoing menu must not yank
238
+ * focus back mid-swap)
239
+ */
240
+ close(reason = "none", { restoreFocus = true } = {}) {
241
+ if (reason instanceof Event) this.#hide("none")
242
+ else this.#hide(reason, { restoreFocus })
243
+ }
244
+
245
+ // --- item activation ---
246
+
247
+ /**
248
+ * Item activation - click/Enter/Space unified. Kept as a public action
249
+ * for markup-declared data-action; the delegated content listener
250
+ * claims the event first, so both paths never double-activate.
251
+ *
252
+ * @param {Event} event
253
+ */
254
+ activate(event) {
255
+ if (this.#claim(event)) return
256
+
257
+ const origin = event.currentTarget instanceof Element ? event.currentTarget : event.target
258
+ const item = origin instanceof Element ? origin.closest(ITEM_SELECTOR) : null
259
+
260
+ if (!item || this.#isDisabled(item)) return
261
+
262
+ if (item.matches(SUB_TRIGGER_SELECTOR)) this.#showSub(item, { focusFirst: false })
263
+ else this.#activate(item)
264
+ }
265
+
266
+ // --- content keydown (Enter/Space, submenu arrows, edges, Tab, typeahead) ---
267
+
268
+ /**
269
+ * The content's keydown route (also wired programmatically): Tab closes
270
+ * and lets focus move on, Enter/Space activate (Space defers to a live
271
+ * typeahead; native link/submit items act through the element itself),
272
+ * the direction-aware horizontal pair opens/closes submenus or fires
273
+ * the Menubar edge seam, and printable keys run typeahead per level.
274
+ *
275
+ * @param {KeyboardEvent} event
276
+ */
277
+ keydown(event) {
278
+ if (this.#claim(event)) return
279
+ if (!this.#isOpen()) return
280
+
281
+ const content = this.#content()
282
+
283
+ if (!content) return
284
+
285
+ const target = event.target instanceof Element ? event.target : content
286
+ const menu = target.closest(MENU_SELECTOR) ?? content
287
+ const item = target.closest(ITEM_SELECTOR)
288
+
289
+ switch (event.key) {
290
+ case "Tab":
291
+ // Menus are at most ONE Tab stop: close and let focus move on
292
+ // naturally. The still-attached focus trap must not see this Tab.
293
+ event.stopImmediatePropagation()
294
+ this.#hide("none", { restoreFocus: false })
295
+ return
296
+ case "Enter":
297
+ case " ":
298
+ if (event.key === " " && this.#typeahead.pending()) break // space extends a live search
299
+
300
+ event.preventDefault()
301
+
302
+ if (!item || this.#isDisabled(item)) return
303
+ if (item.matches(SUB_TRIGGER_SELECTOR)) this.#showSub(item, { focusFirst: true })
304
+ else if (this.#isNativeItem(item)) item.click() // link/submit items act through the element itself; the synthetic click also closes the menu
305
+ else this.#activate(item)
306
+
307
+ return
308
+ case "ArrowRight":
309
+ case "ArrowLeft":
310
+ this.#horizontalKeydown(event, { content, menu, item })
311
+ return
312
+ default:
313
+ break
314
+ }
315
+
316
+ if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
317
+ event.preventDefault()
318
+ this.#search(event.key, menu)
319
+ }
320
+ }
321
+
322
+ // --- submenu actions (pointer + markup-declared) ---
323
+
324
+ /**
325
+ * Each sub-trigger's pointerenter action: hover intent toward opening
326
+ * its sub level.
327
+ *
328
+ * @param {PointerEvent} event
329
+ */
330
+ subEnter(event) {
331
+ const subTrigger = this.#subTriggerFrom(event)
332
+
333
+ if (subTrigger) this.#subPointerEnter(subTrigger)
334
+ }
335
+
336
+ /**
337
+ * Each sub-trigger's pointerleave action: hover intent toward closing
338
+ * (entering the sub pair within the grace cancels it).
339
+ *
340
+ * @param {PointerEvent} event
341
+ */
342
+ subLeave(event) {
343
+ const subTrigger = this.#subTriggerFrom(event)
344
+ const related = event.relatedTarget instanceof Element ? event.relatedTarget : null
345
+
346
+ if (subTrigger) this.#subPointerLeave(subTrigger, related)
347
+ }
348
+
349
+ /**
350
+ * The sub-trigger's click action: opens its sub level (the delegated
351
+ * click claims first).
352
+ *
353
+ * @param {MouseEvent} event
354
+ */
355
+ openSub(event) {
356
+ if (this.#claim(event)) return
357
+
358
+ const subTrigger = this.#subTriggerFrom(event)
359
+
360
+ if (subTrigger && !this.#isDisabled(subTrigger)) this.#showSub(subTrigger, { focusFirst: false })
361
+ }
362
+
363
+ /**
364
+ * Closes a sub-trigger's whole subtree (markup-declared affordances).
365
+ *
366
+ * @param {Event} event
367
+ */
368
+ closeSub(event) {
369
+ const subTrigger = this.#subTriggerFrom(event)
370
+
371
+ if (subTrigger) this.#closeSubTree(subTrigger, { focusTrigger: false })
372
+ }
373
+
374
+ // --- open / close ---
375
+
376
+ #show(reason, { focus = true, seed = null } = {}) {
377
+ const content = this.#content()
378
+
379
+ if (!content || this.#isOpen()) return
380
+
381
+ this.#cancelExit?.()
382
+ this.#cancelExit = null
383
+ this.#suppressRestore = false
384
+
385
+ const trigger = this.#trigger()
386
+
387
+ // Portal-on-open: move BEFORE the
388
+ // enter presence (reparenting mid-animation restarts it), re-anchor
389
+ // absolute - static under compositor scroll, transform-immune. Each
390
+ // SUB level portals on its own open (#showSub) - kept inside, the
391
+ // absolute sub is clipped by the content's overflow-y-auto scroller.
392
+ portalContent(content, { container: resolvePortalContainer(this.element) })
393
+ this.#setStrategy(content, "absolute")
394
+
395
+ content.hidden = false
396
+ content.setAttribute("data-open-reason", reason)
397
+ if (seed) content.setAttribute("data-open-seed", seed)
398
+ else content.removeAttribute("data-open-seed")
399
+ // ContextMenu's trigger SURFACE is not a widget: aria-expanded is only
400
+ // flipped where the server declared it (DropdownMenu button, Menubar
401
+ // menuitem) - never introduced onto a role-less span.
402
+ if (trigger?.hasAttribute("aria-expanded")) trigger.setAttribute("aria-expanded", "true")
403
+ if (trigger) setState(trigger, "popup-open")
404
+ enterPresence(content)
405
+ this.#activateLayers(content)
406
+ this.openValue = true
407
+
408
+ // The layer controllers connect on the attribute-mutation microtask;
409
+ // initial focus must land AFTER focus-scope snapshots the trigger (its
410
+ // focus-return target), so it rides one microtask behind.
411
+ queueMicrotask(() => {
412
+ if (!this.#isOpen()) return
413
+ if (focus) this.#applyInitialFocus(content, seed)
414
+
415
+ this.dispatch("open", { prefix: EVENT_PREFIX, detail: seed ? { reason, seed } : { reason } })
416
+ })
417
+ }
418
+
419
+ #hide(reason, { restoreFocus = true } = {}) {
420
+ const content = this.#content()
421
+
422
+ if (!content || !this.#isOpen()) return
423
+
424
+ // The whole chain closes: subs first (no focus juggling on the way down).
425
+ for (const subTrigger of this.#openSubTriggersIn(content)) {
426
+ this.#closeSubTree(subTrigger, { focusTrigger: false })
427
+ }
428
+
429
+ this.#typeahead.reset()
430
+ // Focus return to the trigger is focus-scope's disconnect job - vetoed
431
+ // for Tab-out and for outside interaction on a non-modal menu
432
+ // (non-modal semantics: focus follows the click).
433
+ this.#suppressRestore = !restoreFocus || (reason === "outside-press" && !this.modalValue)
434
+
435
+ const trigger = this.#trigger()
436
+
437
+ if (trigger?.hasAttribute("aria-expanded")) trigger.setAttribute("aria-expanded", "false")
438
+ if (trigger) setState(trigger, "popup-closed")
439
+ content.removeAttribute("data-open-reason")
440
+ content.removeAttribute("data-open-seed")
441
+ this.openValue = false
442
+
443
+ this.#cancelExit = exitPresence(content, {
444
+ onRemove: () => {
445
+ this.#cancelExit = null
446
+ content.hidden = true
447
+ // Home AFTER the exit finished and hidden landed; focus
448
+ // return is focus-scope's ref-based job, indifferent to the move.
449
+ restoreContent(content)
450
+ this.#setStrategy(content, "fixed")
451
+ this.#removeControllers(content, CONTENT_LAYER_CONTROLLERS)
452
+ this.dispatch("closed", { prefix: EVENT_PREFIX, detail: { reason } })
453
+ }
454
+ })
455
+ }
456
+
457
+ // The root popper rides this.element; each sub hosts its OWN popper on
458
+ // its wrapper inside the content - one coordinate space for the family.
459
+ #setStrategy(content, strategy) {
460
+ this.element.setAttribute(POPPER_STRATEGY, strategy)
461
+
462
+ for (const sub of content.querySelectorAll(SUB_SELECTOR)) {
463
+ sub.setAttribute(POPPER_STRATEGY, strategy)
464
+ }
465
+ }
466
+
467
+ // Initial focus per the family data-open-reason contract: trigger-press ->
468
+ // the content itself (roving arms on the first arrow);
469
+ // list-navigation seeds via data-open-seed: "first" / "last" -> first /
470
+ // last enabled item.
471
+ #applyInitialFocus(content, seed) {
472
+ if (seed === "first") this.#enabledItems(content)[0]?.focus()
473
+ else if (seed === "last") this.#enabledItems(content).at(-1)?.focus()
474
+ else content.focus()
475
+ }
476
+
477
+ // --- activation ---
478
+
479
+ #activate(item) {
480
+ const kind = item.matches(CHECKBOX_ITEM_SELECTOR)
481
+ ? "checkbox"
482
+ : item.matches(RADIO_ITEM_SELECTOR) ? "radio" : "item"
483
+ const value = item.dataset.value ?? null
484
+
485
+ // Checkbox/radio state commits BEFORE the select event, so a canceled
486
+ // close still keeps the new state (the contract's activation semantics).
487
+ if (kind === "checkbox") {
488
+ const checked = item.getAttribute("aria-checked") !== "true"
489
+
490
+ this.#writeChecked(item, checked)
491
+ this.dispatch("change", {
492
+ prefix: EVENT_PREFIX, target: item, detail: { kind, value, checked, group_value: null }
493
+ })
494
+ } else if (kind === "radio") {
495
+ const group = item.closest(RADIO_GROUP_SELECTOR)
496
+ const radios = group ? Array.from(group.querySelectorAll(RADIO_ITEM_SELECTOR)) : [item]
497
+
498
+ for (const radio of radios) this.#writeChecked(radio, radio === item)
499
+
500
+ group?.setAttribute("data-value", value ?? "")
501
+ this.dispatch("change", {
502
+ prefix: EVENT_PREFIX, target: item, detail: { kind, value, checked: true, group_value: value }
503
+ })
504
+ }
505
+
506
+ // The cancelable select (veto by preventDefault):
507
+ // a canceled select keeps the menu open.
508
+ const select = this.dispatch("select", {
509
+ prefix: EVENT_PREFIX,
510
+ target: item,
511
+ cancelable: true,
512
+ detail: { item, value, variant: item.dataset.variant ?? "default", kind }
513
+ })
514
+
515
+ if (select.defaultPrevented) return
516
+ if (!this.#closeOnSelectFor(item)) return
517
+
518
+ this.#hide("item-press")
519
+ }
520
+
521
+ // A NATIVE item is an anchor (link) or a button (form submit) wearing
522
+ // role=menuitem: click and Enter/Space act through the element itself - the
523
+ // browser navigates / submits (Turbo-intercepted), never a JS-only activate.
524
+ // Keyboard routes through a synthetic item.click() so the same path fires -
525
+ // no recursion, since activate() never calls click().
526
+ #isNativeItem(item) {
527
+ return item.matches("a[href], button")
528
+ }
529
+
530
+ // aria-checked and data-checked/data-unchecked are written TOGETHER, never separately.
531
+ #writeChecked(item, checked) {
532
+ item.setAttribute("aria-checked", String(checked))
533
+ setState(item, checked ? "checked" : "unchecked")
534
+ }
535
+
536
+ // Per-item close_on_select overrides the menu-level default (checkbox /
537
+ // radio preference toggles commonly declare data-close-on-select="false").
538
+ #closeOnSelectFor(item) {
539
+ const override = item.dataset.closeOnSelect
540
+
541
+ if (override !== undefined) return override !== "false"
542
+
543
+ return this.closeOnSelectValue
544
+ }
545
+
546
+ // --- horizontal arrows: submenus + the Menubar edge seam ---
547
+
548
+ #horizontalKeydown(event, { content, menu, item }) {
549
+ const rtl = directionOf(menu) === "rtl"
550
+ const openKey = rtl ? "ArrowLeft" : "ArrowRight"
551
+
552
+ if (event.key === openKey) {
553
+ if (item?.matches(SUB_TRIGGER_SELECTOR) && !this.#isDisabled(item)) {
554
+ event.preventDefault()
555
+ this.#showSub(item, { focusFirst: true })
556
+ return
557
+ }
558
+
559
+ // No submenu meaning at the ROOT level only -> the Menubar seam.
560
+ if (menu === content) this.#edgeNavigate(event)
561
+ return
562
+ }
563
+
564
+ // The close arrow: inside a sub it closes one level back to its
565
+ // sub-trigger; at the root there is nothing to close -> the edge seam.
566
+ // NEVER fired from inside a sub (ArrowLeft there means close-sub).
567
+ if (menu !== content) {
568
+ event.preventDefault()
569
+
570
+ const subTrigger = this.#subTriggerFor(menu)
571
+
572
+ if (subTrigger) this.#closeSubTree(subTrigger, { focusTrigger: true })
573
+ } else {
574
+ this.#edgeNavigate(event)
575
+ }
576
+ }
577
+
578
+ // Cancelable, bubbling, from the ROOT content only. direction is the
579
+ // PHYSICAL key; the consumer (the Menubar coordinator) applies RTL
580
+ // mapping. Unconsumed (standalone DropdownMenu) -> no-op.
581
+ #edgeNavigate(event) {
582
+ const direction = event.key === "ArrowRight" ? "right" : "left"
583
+ const dispatched = this.dispatch("edge-navigate", {
584
+ prefix: EVENT_PREFIX,
585
+ target: this.#content(),
586
+ cancelable: true,
587
+ detail: { direction }
588
+ })
589
+
590
+ if (dispatched.defaultPrevented) event.preventDefault()
591
+ }
592
+
593
+ // --- typeahead (APG: 1s buffer, wrap, same-letter cycling, per level) ---
594
+ // The algorithm lives in helpers/typeahead.js (shared with Select); the
595
+ // menu keeps only its own wiring: enabled items per level, the focused
596
+ // item as the search anchor, and focus as the match action.
597
+
598
+ #search(key, menu) {
599
+ const items = this.#enabledItems(menu)
600
+ const active = document.activeElement instanceof Element
601
+ ? document.activeElement.closest(ITEM_SELECTOR)
602
+ : null
603
+ const match = this.#typeahead.search(key, items, { active, timeout: this.typeaheadTimeoutValue })
604
+
605
+ match?.focus()
606
+ }
607
+
608
+ // --- submenus ---
609
+
610
+ #showSub(subTrigger, { focusFirst = false } = {}) {
611
+ if (this.#isDisabled(subTrigger)) return
612
+
613
+ const subContent = this.#subContentFor(subTrigger)
614
+
615
+ if (!subContent) return
616
+
617
+ if (!subTrigger.hasAttribute("data-popup-open")) {
618
+ // Sibling exclusivity: at most ONE open sub per level - the open-sub
619
+ // chain is a path.
620
+ const menu = subTrigger.closest(MENU_SELECTOR)
621
+
622
+ for (const sibling of this.#openSubTriggersIn(menu ?? this.#content(), { level: menu })) {
623
+ if (sibling !== subTrigger) this.#closeSubTree(sibling, { focusTrigger: false })
624
+ }
625
+
626
+ // Portal the sub level like the root: left
627
+ // absolute INSIDE the content it is clipped by the menu's own
628
+ // overflow-y-auto scroller - the flyout opens invisible (every sub
629
+ // level must escape that clip). Native events no
630
+ // longer bubble through the root content from the container, so the
631
+ // delegated listeners ride the portaled node too - the bridge
632
+ // carries only poetry events home.
633
+ portalContent(subContent, { container: resolvePortalContainer(this.element) })
634
+ this.#portaledSubs.add(subContent)
635
+ this.#wireSub(subContent)
636
+ // The modal scrim is body pointer-events none + per-layer auto; the
637
+ // sub inherited the root layer's auto while inside it - at the
638
+ // container it must carry its own.
639
+ subContent.style.pointerEvents = "auto"
640
+
641
+ subContent.hidden = false
642
+ subTrigger.setAttribute("aria-expanded", "true")
643
+ setState(subTrigger, "popup-open")
644
+ enterPresence(subContent)
645
+
646
+ // Each sub level = its own dismissable layer (topmost-only Esc closes
647
+ // just the deepest level) + its own roving group; NO focus-scope (subs
648
+ // join the root trap) and the sub's popper root is markup-owned.
649
+ subContent.setAttribute("data-poetry--core--dismissable-disable-outside-pointer-events-value", "false")
650
+ this.#wireRoving(subContent)
651
+ this.#addControllers(subContent, SUB_LAYER_CONTROLLERS)
652
+ }
653
+
654
+ if (focusFirst) this.#enabledItems(subContent)[0]?.focus()
655
+ }
656
+
657
+ #closeSubTree(subTrigger, { focusTrigger = false } = {}) {
658
+ this.#cancelSubOpen(subTrigger)
659
+ this.#cancelSubClose(subTrigger)
660
+
661
+ if (!subTrigger.hasAttribute("data-popup-open")) return
662
+
663
+ const subContent = this.#subContentFor(subTrigger)
664
+
665
+ if (subContent) {
666
+ for (const nested of this.#openSubTriggersIn(subContent)) {
667
+ this.#closeSubTree(nested, { focusTrigger: false })
668
+ }
669
+ }
670
+
671
+ subTrigger.setAttribute("aria-expanded", "false")
672
+ setState(subTrigger, "popup-closed")
673
+
674
+ if (subContent) {
675
+ exitPresence(subContent, {
676
+ onRemove: () => {
677
+ subContent.hidden = true
678
+ // Home AFTER the exit finished and hidden landed.
679
+ restoreContent(subContent)
680
+ subContent.style.pointerEvents = ""
681
+ this.#portaledSubs.delete(subContent)
682
+ this.#unwireSub(subContent)
683
+ this.#removeControllers(subContent, SUB_LAYER_CONTROLLERS)
684
+ }
685
+ })
686
+ }
687
+
688
+ if (focusTrigger) subTrigger.focus()
689
+ }
690
+
691
+ // --- submenu hover intent ---
692
+
693
+ #subPointerEnter(subTrigger) {
694
+ this.#cancelSubClose(subTrigger)
695
+
696
+ for (const ancestor of this.#ancestorSubTriggers(subTrigger)) this.#cancelSubClose(ancestor)
697
+
698
+ if (this.#isDisabled(subTrigger) || subTrigger.hasAttribute("data-popup-open")) return
699
+ if (this.#subOpenTimers.has(subTrigger)) return
700
+
701
+ this.#subOpenTimers.set(subTrigger, window.setTimeout(() => {
702
+ this.#subOpenTimers.delete(subTrigger)
703
+ this.#showSub(subTrigger, { focusFirst: false }) // hover-open never moves focus
704
+ }, SUB_OPEN_DELAY))
705
+ }
706
+
707
+ #subPointerLeave(subTrigger, related) {
708
+ this.#cancelSubOpen(subTrigger)
709
+
710
+ const subContent = this.#subContentFor(subTrigger)
711
+
712
+ if (related && (subTrigger.contains(related) || subContent?.contains(related))) return
713
+ if (subTrigger.hasAttribute("data-popup-open")) this.#scheduleSubClose(subTrigger)
714
+ }
715
+
716
+ #scheduleSubClose(subTrigger) {
717
+ this.#cancelSubClose(subTrigger)
718
+ this.#subCloseTimers.set(subTrigger, window.setTimeout(() => {
719
+ this.#subCloseTimers.delete(subTrigger)
720
+ this.#closeSubTree(subTrigger, { focusTrigger: false })
721
+ }, SUB_CLOSE_DELAY))
722
+ }
723
+
724
+ #cancelSubOpen(subTrigger) {
725
+ const timer = this.#subOpenTimers.get(subTrigger)
726
+
727
+ if (timer === undefined) return
728
+
729
+ window.clearTimeout(timer)
730
+ this.#subOpenTimers.delete(subTrigger)
731
+ }
732
+
733
+ #cancelSubClose(subTrigger) {
734
+ const timer = this.#subCloseTimers.get(subTrigger)
735
+
736
+ if (timer === undefined) return
737
+
738
+ window.clearTimeout(timer)
739
+ this.#subCloseTimers.delete(subTrigger)
740
+ }
741
+
742
+ // --- content wiring (programmatic: portal-safe, no data-action required) ---
743
+
744
+ #wireContent(content) {
745
+ this.#listen(content, "keydown", (event) => this.keydown(event))
746
+ this.#listen(content, "click", this.#onClick)
747
+ this.#listen(content, "pointerover", this.#onPointerover)
748
+ this.#listen(content, "pointerout", this.#onPointerout)
749
+ this.#listen(content, "poetry--core--dismissable:dismiss", this.#onDismiss)
750
+ this.#listen(content, "poetry--core--dismissable:interact-outside", this.#onInteractOutside)
751
+ this.#listen(content, "poetry--core--focus-scope:mount-auto-focus", this.#onMountAutoFocus)
752
+ this.#listen(content, "poetry--core--focus-scope:unmount-auto-focus", this.#onUnmountAutoFocus)
753
+ }
754
+
755
+ #listen(target, type, listener) {
756
+ target.addEventListener(type, listener)
757
+ this.#wired.push([target, type, listener])
758
+ }
759
+
760
+ // The portaled sub is self-sufficient: while at the container it no
761
+ // longer bubbles through the root content, and item data-actions are out
762
+ // of scope - so the native delegation AND the sub's own
763
+ // dismissable events ride the portaled node. The bridge still re-emits
764
+ // the dismissable pair from home (the wrapper), where level resolution
765
+ // finds no sub content and the duplicate no-ops.
766
+ #wireSub(subContent) {
767
+ this.#listen(subContent, "keydown", (event) => this.keydown(event))
768
+ this.#listen(subContent, "click", this.#onClick)
769
+ this.#listen(subContent, "pointerover", this.#onPointerover)
770
+ this.#listen(subContent, "pointerout", this.#onPointerout)
771
+ this.#listen(subContent, "poetry--core--dismissable:dismiss", this.#onDismiss)
772
+ this.#listen(subContent, "poetry--core--dismissable:interact-outside", this.#onInteractOutside)
773
+ }
774
+
775
+ #unwireSub(subContent) {
776
+ this.#wired = this.#wired.filter(([target, type, listener]) => {
777
+ if (target !== subContent) return true
778
+
779
+ target.removeEventListener(type, listener)
780
+ return false
781
+ })
782
+ }
783
+
784
+ #onClick = (event) => {
785
+ if (this.#claim(event)) return
786
+
787
+ const item = event.target instanceof Element ? event.target.closest(ITEM_SELECTOR) : null
788
+
789
+ if (!item || this.#isDisabled(item)) return
790
+
791
+ if (item.matches(SUB_TRIGGER_SELECTOR)) this.#showSub(item, { focusFirst: false })
792
+ else this.#activate(item)
793
+ }
794
+
795
+ // Esc / outside-press arrive as the dismissable layer's dismiss event: the
796
+ // root content's layer closes the menu; a sub-content's layer (the topmost
797
+ // while a sub is open) closes just that level - Esc focuses ITS sub-trigger.
798
+ // A press on the menu's OWN root trigger is the toggle's job, not an
799
+ // outside dismissal: without the veto the pointerdown closes and the
800
+ // trailing click re-opens (the popover trigger-press rule; the menubar
801
+ // coordinator vetoes the same way for bar-wide triggers).
802
+ #onInteractOutside = (event) => {
803
+ if (event.target !== this.#content()) return
804
+
805
+ const origin = event.detail?.originalEvent?.target
806
+
807
+ if (origin instanceof Element && this.#trigger()?.contains(origin)) event.preventDefault()
808
+ }
809
+
810
+ #onDismiss = (event) => {
811
+ const target = event.target instanceof Element ? event.target : null
812
+
813
+ if (!target) return
814
+
815
+ const escaped = event.detail?.originalEvent?.type === "keydown"
816
+
817
+ if (target === this.#content()) {
818
+ this.#hide(escaped ? "escape-key" : "outside-press")
819
+ return
820
+ }
821
+
822
+ const subContent = target.closest(SUB_CONTENT_SELECTOR)
823
+ const subTrigger = subContent && this.#subTriggerFor(subContent)
824
+
825
+ if (subTrigger) this.#closeSubTree(subTrigger, { focusTrigger: escaped })
826
+ }
827
+
828
+ // The menu owns initial focus (per data-open-reason), 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
+ #onPointerover = (event) => {
838
+ const target = event.target instanceof Element ? event.target : null
839
+
840
+ if (!target) return
841
+
842
+ // Travel into an open sub keeps its whole chain alive (the grace window).
843
+ for (const subTrigger of this.#ancestorSubTriggers(target)) this.#cancelSubClose(subTrigger)
844
+
845
+ const related = event.relatedTarget instanceof Element ? event.relatedTarget : null
846
+ const subTrigger = target.closest(SUB_TRIGGER_SELECTOR)
847
+
848
+ if (subTrigger) {
849
+ if (!related || !subTrigger.contains(related)) this.#subPointerEnter(subTrigger)
850
+
851
+ this.#pointerHighlight(subTrigger, event)
852
+
853
+ return
854
+ }
855
+
856
+ // Hovering a plain sibling item schedules the open sub at that level to close.
857
+ const item = target.closest(ITEM_SELECTOR)
858
+
859
+ if (!item) return
860
+
861
+ const menu = item.closest(MENU_SELECTOR)
862
+
863
+ if (!menu) return
864
+
865
+ for (const open of this.#openSubTriggersIn(menu, { level: menu })) this.#scheduleSubClose(open)
866
+
867
+ this.#pointerHighlight(item, event, menu)
868
+ }
869
+
870
+ // Focus follows the pointer (menu semantics): the themes
871
+ // style item highlight through focus: alone - no :hover rule - so hover
872
+ // highlight IS this focus move. A disabled item clears the highlight
873
+ // instead (the menu takes focus back), and touch pointers never
874
+ // hover-highlight (tap semantics).
875
+ #pointerHighlight(item, event, menu = item.closest(MENU_SELECTOR)) {
876
+ if (event.pointerType === "touch") return
877
+
878
+ if (this.#isDisabled(item)) menu?.focus()
879
+ else if (document.activeElement !== item) item.focus()
880
+ }
881
+
882
+ #onPointerout = (event) => {
883
+ const target = event.target instanceof Element ? event.target : null
884
+
885
+ if (!target) return
886
+
887
+ const related = event.relatedTarget instanceof Element ? event.relatedTarget : null
888
+ const subTrigger = target.closest(SUB_TRIGGER_SELECTOR)
889
+
890
+ if (subTrigger && (!related || !subTrigger.contains(related))) {
891
+ this.#subPointerLeave(subTrigger, related)
892
+ }
893
+
894
+ const subContent = target.closest(SUB_CONTENT_SELECTOR)
895
+
896
+ if (subContent && (!related || !subContent.contains(related))) {
897
+ const owner = this.#subTriggerFor(subContent)
898
+
899
+ if (owner && !(related && owner.contains(related)) && owner.hasAttribute("data-popup-open")) {
900
+ this.#scheduleSubClose(owner)
901
+ }
902
+ }
903
+
904
+ // Leaving a plain item for blank space hands focus back to its menu so
905
+ // the hover highlight clears. Item-to-item travel skips
906
+ // this - the entering item's pointerover refocuses anyway - and an open
907
+ // sub-trigger keeps its data-popup-open styling regardless.
908
+ const item = target.closest(ITEM_SELECTOR)
909
+
910
+ if (item && !item.matches(SUB_TRIGGER_SELECTOR) && document.activeElement === item &&
911
+ (!related || !related.closest(ITEM_SELECTOR))) {
912
+ item.closest(MENU_SELECTOR)?.focus()
913
+ }
914
+ }
915
+
916
+ // --- the layer stack ---
917
+
918
+ #activateLayers(content) {
919
+ content.setAttribute("data-poetry--core--focus-scope-trapped-value", String(this.modalValue))
920
+ content.setAttribute(
921
+ "data-poetry--core--dismissable-disable-outside-pointer-events-value", String(this.modalValue)
922
+ )
923
+ this.#wireRoving(content)
924
+ this.#addControllers(content, CONTENT_LAYER_CONTROLLERS)
925
+ }
926
+
927
+ // Menus are roving-focus's DEFAULT mode: vertical, manageTabindex TRUE
928
+ // (items tabindex=-1, real focus roves - the mode Accordion's
929
+ // focus-nav-only flag contrasted against). loop forwards the menu value
930
+ // (default false).
931
+ #wireRoving(element) {
932
+ element.setAttribute(`data-${ROVING}-orientation-value`, "vertical")
933
+ element.setAttribute(`data-${ROVING}-manage-tabindex-value`, "true")
934
+ element.setAttribute(`data-${ROVING}-loop-value`, String(this.loopValue))
935
+
936
+ const action = element.getAttribute("data-action") ?? ""
937
+
938
+ if (!action.includes(ROVING_ACTION)) {
939
+ element.setAttribute("data-action", `${action} ${ROVING_ACTION}`.trim())
940
+ }
941
+ }
942
+
943
+ #addControllers(element, identifiers) {
944
+ const tokens = (element.getAttribute("data-controller") ?? "").split(/\s+/).filter(Boolean)
945
+
946
+ for (const identifier of identifiers) {
947
+ if (!tokens.includes(identifier)) tokens.push(identifier)
948
+ }
949
+
950
+ element.setAttribute("data-controller", tokens.join(" "))
951
+ }
952
+
953
+ #removeControllers(element, identifiers) {
954
+ const tokens = (element.getAttribute("data-controller") ?? "")
955
+ .split(/\s+/)
956
+ .filter((token) => token && !identifiers.includes(token))
957
+
958
+ element.setAttribute("data-controller", tokens.join(" "))
959
+ }
960
+
961
+ // --- structural resolution (the DOM is the registry; ids are the seams) ---
962
+
963
+ #trigger() {
964
+ for (const candidate of this.element.querySelectorAll(TRIGGER_SELECTOR)) {
965
+ if (!candidate.closest(MENU_SELECTOR)) return candidate
966
+ }
967
+
968
+ return null
969
+ }
970
+
971
+ #content() {
972
+ const id = this.#trigger()?.getAttribute("aria-controls")
973
+
974
+ return id ? document.getElementById(id) : null
975
+ }
976
+
977
+ #isOpen() {
978
+ const content = this.#content()
979
+
980
+ return Boolean(content) && stateOf(content) === "open"
981
+ }
982
+
983
+ #subContentFor(subTrigger) {
984
+ const id = subTrigger.getAttribute("aria-controls")
985
+ const byId = id ? document.getElementById(id) : null
986
+
987
+ return byId ?? subTrigger.closest(SUB_SELECTOR)?.querySelector(SUB_CONTENT_SELECTOR) ?? null
988
+ }
989
+
990
+ #subTriggerFor(subContent) {
991
+ if (subContent.id) {
992
+ for (const candidate of document.querySelectorAll(SUB_TRIGGER_SELECTOR)) {
993
+ if (candidate.getAttribute("aria-controls") === subContent.id) return candidate
994
+ }
995
+ }
996
+
997
+ return subContent.closest(SUB_SELECTOR)?.querySelector(SUB_TRIGGER_SELECTOR) ?? null
998
+ }
999
+
1000
+ #subTriggerFrom(event) {
1001
+ const origin = event?.currentTarget instanceof Element ? event.currentTarget : event?.target
1002
+
1003
+ return origin instanceof Element ? origin.closest(SUB_TRIGGER_SELECTOR) : null
1004
+ }
1005
+
1006
+ // Open sub-triggers within a scope; level restricts to one menu level.
1007
+ #openSubTriggersIn(scope, { level = null } = {}) {
1008
+ if (!scope) return []
1009
+
1010
+ return Array.from(scope.querySelectorAll(SUB_TRIGGER_SELECTOR)).filter((subTrigger) =>
1011
+ subTrigger.hasAttribute("data-popup-open") && (!level || subTrigger.closest(MENU_SELECTOR) === level))
1012
+ }
1013
+
1014
+ #ancestorSubTriggers(node) {
1015
+ const ancestors = []
1016
+ let subContent = node.closest(SUB_CONTENT_SELECTOR)
1017
+
1018
+ while (subContent) {
1019
+ const subTrigger = this.#subTriggerFor(subContent)
1020
+
1021
+ if (!subTrigger) break
1022
+
1023
+ ancestors.push(subTrigger)
1024
+ subContent = subTrigger.closest(SUB_CONTENT_SELECTOR)
1025
+ }
1026
+
1027
+ return ancestors
1028
+ }
1029
+
1030
+ // Items of ONE menu level, in DOM order, disabled filtered at query time.
1031
+ #itemsOf(menu) {
1032
+ return collectionItems(menu, ITEM_SELECTOR).filter((item) => item.closest(MENU_SELECTOR) === menu)
1033
+ }
1034
+
1035
+ #enabledItems(menu) {
1036
+ return this.#itemsOf(menu).filter((item) => !this.#isDisabled(item))
1037
+ }
1038
+
1039
+ #isDisabled(item) {
1040
+ return item.hasAttribute("data-disabled") || item.getAttribute("aria-disabled") === "true"
1041
+ }
1042
+
1043
+ #claim(event) {
1044
+ if (this.#claimed.has(event)) return true
1045
+
1046
+ this.#claimed.add(event)
1047
+
1048
+ return false
1049
+ }
1050
+ }