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,541 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { portalContent, resolvePortalContainer, restoreContent } from "@poetry/controllers/helpers/portal"
3
+ import { exitPresence } from "@poetry/controllers/helpers/presence"
4
+ import { setState, stateOf } from "@poetry/controllers/helpers/state"
5
+
6
+ // The Tooltip controller (the popper-consumer trio's timing machine): open
7
+ // DELAYS (provider delay_duration, default 0), the provider-scoped
8
+ // WARM grace (one tooltip open - or closed less than skip_delay_duration ms
9
+ // ago - lets siblings in the same provider scope open instantly), ONE OPEN
10
+ // GLOBALLY (the document-level will-open event),
11
+ // close-on-scroll, and the pointer-vs-focus open paths with their latches.
12
+ //
13
+ // A11y is the strictest of the trio: the content is role=tooltip and the
14
+ // trigger's aria-describedby is set on open and REMOVED on close
15
+ // (describedby must never reference hidden content); focus-scope is NOT
16
+ // composed at all - focus never enters a tooltip; touch NEVER opens one
17
+ // (no long-press path, deliberately). Esc rides a token-activated
18
+ // dismissable layer while open, so a tooltip above a Dialog peels first.
19
+ //
20
+ // THE WARM REGISTRY (the provider mechanism): a module-level WeakMap keyed
21
+ // by the [data-slot=tooltip-provider] ancestor (document fallback) holding
22
+ // {openCount, warmUntil}. The DOM ancestor IS the provider scope; the
23
+ // WeakMap lets morphed/replaced providers garbage-collect (no Turbo leaks).
24
+ const PROVIDER_SELECTOR = '[data-slot="tooltip-provider"]'
25
+ const CONTENT_SELECTOR = '[data-slot="tooltip-content"]'
26
+ const TRIGGER_SELECTOR = '[data-slot="tooltip-trigger"]'
27
+
28
+ const EVENT_PREFIX = "poetry:tooltip"
29
+ const WILL_OPEN_EVENT = "poetry:tooltip:will-open"
30
+
31
+ const DISMISSABLE = "poetry--core--dismissable"
32
+ const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
33
+
34
+ // Provider defaults: delayDuration 0 (instant tooltips are the design
35
+ // call); skipDelayDuration 300.
36
+ const DEFAULT_DELAY_DURATION = 0
37
+ const DEFAULT_SKIP_DELAY_DURATION = 300
38
+
39
+ // Hoverable-content grace: the close-intent timer (entering the content
40
+ // cancels it) - the DropdownMenu grace-area reconciliation applied
41
+ // trio-wide; a pointer-transit polygon is the shared fast-follow.
42
+ const HOVERABLE_CLOSE_DELAY = 300
43
+
44
+ // scope element -> { openCount, warmUntil } (module-level, shared by every
45
+ // tooltip instance; WeakMap so detached providers garbage-collect).
46
+ const warmScopes = new WeakMap()
47
+
48
+ const scopeFor = (element) => {
49
+ if (!warmScopes.has(element)) warmScopes.set(element, { openCount: 0, warmUntil: 0 })
50
+
51
+ return warmScopes.get(element)
52
+ }
53
+
54
+ export default class TooltipController extends Controller {
55
+ // The events this controller dispatches (manifest surface;
56
+ // events_declaration.test.js enforces the list stays honest).
57
+ static events = ["poetry:tooltip:closed", "poetry:tooltip:open"]
58
+
59
+ static values = {
60
+ open: { type: Boolean, default: false },
61
+ delayDuration: { type: Number, default: -1 }, // -1 = inherit the provider (default 0)
62
+ disableHoverableContent: { type: Boolean, default: false } // unset = inherit the provider
63
+ }
64
+
65
+ #connected = false
66
+ #wired = []
67
+ #openTimer = null
68
+ #closeTimer = null
69
+ #cancelExit = null
70
+ #hasPointerMoveOpened = false
71
+ #isPointerDown = false
72
+ #emittingWillOpen = false
73
+ #scrollListening = false
74
+ #onScroll = (event) => this.#handleScroll(event)
75
+ #onWillOpen = (event) => this.#handleWillOpen(event)
76
+ #onPointerup = () => { this.#isPointerDown = false }
77
+
78
+ /**
79
+ * Wires the content listeners plus the document-level will-open
80
+ * listener, and reconciles a server-pinned tooltip (describedby, layer,
81
+ * scope count catch up; the DOM wins).
82
+ */
83
+ connect() {
84
+ const content = this.#content()
85
+
86
+ if (content) this.#wireContent(content)
87
+
88
+ this.#listen(document, WILL_OPEN_EVENT, this.#onWillOpen)
89
+
90
+ this.#connected = true
91
+
92
+ // Reconcile-on-connect: a server-rendered pinned tooltip (data-open on
93
+ // the content) activates describedby + the layer + the scope count.
94
+ if (this.#isOpen()) {
95
+ this.#activate(content)
96
+ this.openValue = true
97
+ } else if (this.openValue) {
98
+ this.#open({ instant: "delay" })
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Clears the timers, balances the warm-scope count for an open tooltip,
104
+ * drops the scroll listener, restores portaled content
105
+ * (drop-never-strand), and unwires everything.
106
+ */
107
+ disconnect() {
108
+ this.#connected = false
109
+ this.#clearOpenTimer()
110
+ this.#clearCloseTimer()
111
+ this.#cancelExit?.()
112
+ this.#cancelExit = null
113
+
114
+ // An open tooltip disconnecting must not strand the scope count.
115
+ if (this.#isOpen()) {
116
+ const scope = this.#scope()
117
+
118
+ scope.openCount = Math.max(0, scope.openCount - 1)
119
+ scope.warmUntil = Date.now() + this.#skipDelayDuration()
120
+ }
121
+
122
+ this.#dropScrollListener()
123
+
124
+ // Never leave content stranded at the container: if the root subtree
125
+ // was removed the placeholder is gone and restore DROPS the node.
126
+ const content = this.#content()
127
+
128
+ if (content) restoreContent(content)
129
+
130
+ for (const [target, type, listener] of this.#wired) target.removeEventListener(type, listener)
131
+
132
+ this.#wired = []
133
+ document.removeEventListener("pointerup", this.#onPointerup)
134
+ }
135
+
136
+ /**
137
+ * Stimulus value callback - controllable state: pinned tooltips (Turbo
138
+ * Stream / Outlet ownership).
139
+ *
140
+ * @param {boolean} value
141
+ */
142
+ openValueChanged(value) {
143
+ if (!this.#connected) return
144
+
145
+ if (value && !this.#isOpen()) this.#open({ instant: "delay" })
146
+ else if (!value && this.#isOpen()) this.#close("none")
147
+ }
148
+
149
+ // --- trigger actions: the pointer path ---
150
+
151
+ /**
152
+ * The trigger's pointermove action (not pointerenter, deliberately):
153
+ * opens
154
+ * once per hover via the latch - instantly when the provider scope is
155
+ * warm, else after the delay; touch pointerType is EXCLUDED entirely.
156
+ *
157
+ * @param {PointerEvent} event
158
+ */
159
+ pointerMove(event) {
160
+ if (event.pointerType === "touch") return
161
+
162
+ this.#clearCloseTimer() // re-entering the trigger cancels a close intent
163
+
164
+ if (this.#hasPointerMoveOpened || this.#isOpen() || this.#openTimer !== null) return
165
+ if (this.#isPointerDown) return
166
+
167
+ if (this.#isWarm()) {
168
+ this.#hasPointerMoveOpened = true
169
+ this.#open({ instant: "delay" })
170
+ return
171
+ }
172
+
173
+ const delay = this.#delayDuration()
174
+
175
+ if (delay <= 0) {
176
+ this.#hasPointerMoveOpened = true
177
+ this.#open()
178
+ return
179
+ }
180
+
181
+ this.#openTimer = window.setTimeout(() => {
182
+ this.#openTimer = null
183
+ this.#hasPointerMoveOpened = true
184
+ this.#open()
185
+ }, delay)
186
+ }
187
+
188
+ /**
189
+ * The trigger's pointerleave action: cancels a pending open; with
190
+ * hoverable content the close waits its grace, else it closes now.
191
+ *
192
+ * @param {PointerEvent} [event]
193
+ */
194
+ pointerLeave(event) {
195
+ if (event?.pointerType === "touch") return
196
+
197
+ this.#clearOpenTimer()
198
+ this.#hasPointerMoveOpened = false
199
+
200
+ if (!this.#isOpen()) return
201
+
202
+ // Traveling into hoverable content gets the close-intent grace; with
203
+ // hoverable content disabled the tooltip closes with the trigger leave.
204
+ if (this.#hoverableContentDisabled()) this.#close("trigger-hover")
205
+ else this.#scheduleClose()
206
+ }
207
+
208
+ /**
209
+ * The trigger's pointerdown action: activating the control dismisses
210
+ * its hint; the latch also suppresses the focus-open until pointerup
211
+ * (pointer users never get a focus-opened tooltip - contractual).
212
+ */
213
+ pointerDown() {
214
+ this.#isPointerDown = true
215
+ document.addEventListener("pointerup", this.#onPointerup, { once: true })
216
+ this.#clearOpenTimer()
217
+
218
+ if (this.#isOpen()) this.#close("trigger-press")
219
+ }
220
+
221
+ /** The trigger's click action: an activation dismisses its hint. */
222
+ clickClose() {
223
+ this.#clearOpenTimer()
224
+
225
+ if (this.#isOpen()) this.#close("trigger-press")
226
+ }
227
+
228
+ // --- trigger actions: the keyboard path ---
229
+
230
+ /**
231
+ * The trigger's focus action: opens INSTANTLY (skipping all delays) -
232
+ * unless the focus was caused by a pointerdown (the latch).
233
+ */
234
+ focusOpen() {
235
+ if (this.#isPointerDown) return
236
+ if (this.#isOpen()) return
237
+
238
+ this.#open({ instant: "focus" })
239
+ }
240
+
241
+ /** The trigger's blur action: closes (the focus interaction's exit). */
242
+ blurClose() {
243
+ this.#clearOpenTimer()
244
+
245
+ // The close reason for the focus path: trigger-focus.
246
+ if (this.#isOpen()) this.#close("trigger-focus")
247
+ }
248
+
249
+ // --- open / close ---
250
+
251
+ // instant: null (a delayed pointer open) | "delay" (warm registry /
252
+ // programmatic - the provider delay was skipped) | "focus" (keyboard) -
253
+ // the data-instant reason vocabulary.
254
+ #open({ instant = null } = {}) {
255
+ const content = this.#content()
256
+
257
+ if (!content || this.#isOpen()) return
258
+
259
+ this.#cancelExit?.()
260
+ this.#cancelExit = null
261
+ this.#clearCloseTimer()
262
+
263
+ // One tooltip page-wide: every other open tooltip hears this and closes
264
+ // (reason: superseded). Dispatched BEFORE this one opens.
265
+ this.#emittingWillOpen = true
266
+ document.dispatchEvent(new CustomEvent(WILL_OPEN_EVENT, { detail: {} }))
267
+ this.#emittingWillOpen = false
268
+
269
+ this.#scope().openCount += 1
270
+
271
+ // Portal-on-open: move BEFORE any
272
+ // visual state lands (reparenting later would restart the enter
273
+ // animation), then re-anchor absolute - static under compositor
274
+ // scroll, transformed-ancestor immune. The strategy attribute write
275
+ // re-arms popper's autoUpdate against the new ancestors.
276
+ portalContent(content, { container: resolvePortalContainer(this.element) })
277
+ this.element.setAttribute(POPPER_STRATEGY, "absolute")
278
+
279
+ const trigger = this.#trigger()
280
+
281
+ content.hidden = false
282
+ if (trigger) {
283
+ setState(trigger, "popup-open")
284
+ trigger.setAttribute("aria-describedby", content.id)
285
+ }
286
+ // The pair plus a reason: data-open via setState, and
287
+ // data-instant="delay|focus" on the content when the open skipped
288
+ // the delay (absent on a delayed open).
289
+ setState(content, "open")
290
+ if (instant) content.setAttribute("data-instant", instant)
291
+ else content.removeAttribute("data-instant")
292
+
293
+ // Token-activated dismissable only - NO focus-scope, focus never enters
294
+ // a tooltip. Esc anywhere peels the tooltip first (topmost layer).
295
+ content.setAttribute(`data-${DISMISSABLE}-disable-outside-pointer-events-value`, "false")
296
+ this.#addControllers(content, [DISMISSABLE])
297
+ this.#armScrollListener()
298
+ this.openValue = true
299
+
300
+ queueMicrotask(() => {
301
+ if (!this.#isOpen()) return
302
+
303
+ this.dispatch("open", { prefix: EVENT_PREFIX, detail: { state: "open", instant } })
304
+ })
305
+ }
306
+
307
+ #close(reason) {
308
+ const content = this.#content()
309
+
310
+ if (!content || !this.#isOpen()) return
311
+
312
+ this.#clearOpenTimer()
313
+ this.#clearCloseTimer()
314
+ this.#hasPointerMoveOpened = false
315
+
316
+ const scope = this.#scope()
317
+
318
+ scope.openCount = Math.max(0, scope.openCount - 1)
319
+ scope.warmUntil = Date.now() + this.#skipDelayDuration()
320
+
321
+ const trigger = this.#trigger()
322
+
323
+ if (trigger) setState(trigger, "popup-closed")
324
+ content.removeAttribute("data-instant")
325
+ this.openValue = false
326
+
327
+ this.#cancelExit = exitPresence(content, {
328
+ onRemove: () => {
329
+ this.#cancelExit = null
330
+ content.hidden = true
331
+ // Home AFTER the exit finished and hidden landed - never
332
+ // mid-animation, never a visible flash.
333
+ restoreContent(content)
334
+ this.element.setAttribute(POPPER_STRATEGY, "fixed")
335
+ trigger?.removeAttribute("aria-describedby")
336
+ this.#removeControllers(content, [DISMISSABLE])
337
+ this.#dropScrollListener()
338
+ this.dispatch("closed", { prefix: EVENT_PREFIX, detail: { reason } })
339
+ }
340
+ })
341
+ }
342
+
343
+ // Reconcile-on-connect for a server-rendered open tooltip: the DOM already
344
+ // says open; describedby, the layer, the scroll listener and the scope
345
+ // count catch up without re-running the open path.
346
+ #activate(content) {
347
+ if (!content) return
348
+
349
+ const trigger = this.#trigger()
350
+
351
+ if (trigger && content.id) trigger.setAttribute("aria-describedby", content.id)
352
+
353
+ content.setAttribute(`data-${DISMISSABLE}-disable-outside-pointer-events-value`, "false")
354
+ this.#addControllers(content, [DISMISSABLE])
355
+ this.#armScrollListener()
356
+ this.#scope().openCount += 1
357
+ this.#portalPinned(content)
358
+ }
359
+
360
+ // The reconcile path portals ONE FRAME LATE: connect order within a boot
361
+ // is unordered, and portaling before the sibling popper's connect would
362
+ // rob it of its content target before it could cache the node.
363
+ #portalPinned(content) {
364
+ window.requestAnimationFrame(() => {
365
+ if (!this.#connected || !this.#isOpen()) return
366
+
367
+ portalContent(content, { container: resolvePortalContainer(this.element) })
368
+ this.element.setAttribute(POPPER_STRATEGY, "absolute")
369
+ })
370
+ }
371
+
372
+ // --- the close-intent grace (hoverable content) ---
373
+
374
+ #scheduleClose() {
375
+ this.#clearCloseTimer()
376
+ this.#closeTimer = window.setTimeout(() => {
377
+ this.#closeTimer = null
378
+ this.#close("trigger-hover")
379
+ }, HOVERABLE_CLOSE_DELAY)
380
+ }
381
+
382
+ #clearOpenTimer() {
383
+ if (this.#openTimer === null) return
384
+
385
+ window.clearTimeout(this.#openTimer)
386
+ this.#openTimer = null
387
+ }
388
+
389
+ #clearCloseTimer() {
390
+ if (this.#closeTimer === null) return
391
+
392
+ window.clearTimeout(this.#closeTimer)
393
+ this.#closeTimer = null
394
+ }
395
+
396
+ // --- content wiring (programmatic: portal-safe) ---
397
+
398
+ #wireContent(content) {
399
+ this.#listen(content, "pointerenter", () => this.#clearCloseTimer())
400
+ this.#listen(content, "pointerleave", () => {
401
+ if (this.#isOpen()) this.#scheduleClose()
402
+ })
403
+ this.#listen(content, `${DISMISSABLE}:dismiss`, this.#onDismiss)
404
+ }
405
+
406
+ #listen(target, type, listener) {
407
+ target.addEventListener(type, listener)
408
+ this.#wired.push([target, type, listener])
409
+ }
410
+
411
+ #onDismiss = (event) => {
412
+ if (event.target !== this.#content()) return
413
+
414
+ const escaped = event.detail?.originalEvent?.type === "keydown"
415
+
416
+ // The outside-press close the dismissable brings along matches tooltip
417
+ // UX anyway (contract note); it reports as "outside-press".
418
+ this.#close(escaped ? "escape-key" : "outside-press")
419
+ }
420
+
421
+ // Another tooltip is about to open: one open page-wide.
422
+ #handleWillOpen(event) {
423
+ if (this.#emittingWillOpen) return
424
+ if (event.target === this.element) return
425
+
426
+ this.#clearOpenTimer()
427
+
428
+ // A sibling tooltip opening closes this one (reason: sibling-open).
429
+ if (this.#isOpen()) this.#close("sibling-open")
430
+ }
431
+
432
+ // --- close-on-scroll (capture-phase, armed only while open) ---
433
+
434
+ #armScrollListener() {
435
+ if (this.#scrollListening) return
436
+
437
+ window.addEventListener("scroll", this.#onScroll, { capture: true })
438
+ this.#scrollListening = true
439
+ }
440
+
441
+ #dropScrollListener() {
442
+ if (!this.#scrollListening) return
443
+
444
+ window.removeEventListener("scroll", this.#onScroll, { capture: true })
445
+ this.#scrollListening = false
446
+ }
447
+
448
+ #handleScroll(event) {
449
+ const trigger = this.#trigger()
450
+ const target = event.target
451
+
452
+ if (!trigger || !target || typeof target.contains !== "function") return
453
+ if (target.contains(trigger)) this.#close("scroll")
454
+ }
455
+
456
+ // --- the warm scope (the provider mechanism) ---
457
+
458
+ #provider() {
459
+ return this.element.closest(PROVIDER_SELECTOR)
460
+ }
461
+
462
+ #scope() {
463
+ return scopeFor(this.#provider() ?? document)
464
+ }
465
+
466
+ #isWarm() {
467
+ const scope = this.#scope()
468
+
469
+ return scope.openCount > 0 || Date.now() < scope.warmUntil
470
+ }
471
+
472
+ #providerNumber(attribute, fallback) {
473
+ const raw = this.#provider()?.getAttribute(attribute)
474
+ const parsed = raw === null || raw === undefined || raw === "" ? NaN : Number(raw)
475
+
476
+ return Number.isNaN(parsed) ? fallback : parsed
477
+ }
478
+
479
+ #delayDuration() {
480
+ if (this.delayDurationValue >= 0) return this.delayDurationValue
481
+
482
+ return this.#providerNumber("data-delay-duration", DEFAULT_DELAY_DURATION)
483
+ }
484
+
485
+ #skipDelayDuration() {
486
+ return this.#providerNumber("data-skip-delay-duration", DEFAULT_SKIP_DELAY_DURATION)
487
+ }
488
+
489
+ #hoverableContentDisabled() {
490
+ // Attribute presence, not hasDisableHoverableContentValue: an unset
491
+ // per-tooltip value INHERITS the provider setting.
492
+ const attribute = "data-poetry--core--tooltip-disable-hoverable-content-value"
493
+
494
+ if (this.element.hasAttribute(attribute)) return this.disableHoverableContentValue
495
+
496
+ return this.#provider()?.getAttribute("data-disable-hoverable-content") === "true"
497
+ }
498
+
499
+ // --- structural resolution (the server id pair, portal-safe) ---
500
+
501
+ #trigger() {
502
+ return this.element.querySelector(TRIGGER_SELECTOR)
503
+ }
504
+
505
+ #content() {
506
+ const triggerId = this.#trigger()?.id
507
+
508
+ if (triggerId?.endsWith("-trigger")) {
509
+ const byId = document.getElementById(triggerId.replace(/-trigger$/, "-content"))
510
+
511
+ if (byId) return byId
512
+ }
513
+
514
+ return this.element.querySelector(CONTENT_SELECTOR)
515
+ }
516
+
517
+ #isOpen() {
518
+ const content = this.#content()
519
+ const state = content ? stateOf(content) : undefined
520
+
521
+ return Boolean(state) && state !== "closed"
522
+ }
523
+
524
+ #addControllers(element, identifiers) {
525
+ const tokens = (element.getAttribute("data-controller") ?? "").split(/\s+/).filter(Boolean)
526
+
527
+ for (const identifier of identifiers) {
528
+ if (!tokens.includes(identifier)) tokens.push(identifier)
529
+ }
530
+
531
+ element.setAttribute("data-controller", tokens.join(" "))
532
+ }
533
+
534
+ #removeControllers(element, identifiers) {
535
+ const tokens = (element.getAttribute("data-controller") ?? "")
536
+ .split(/\s+/)
537
+ .filter((token) => token && !identifiers.includes(token))
538
+
539
+ element.setAttribute("data-controller", tokens.join(" "))
540
+ }
541
+ }