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,55 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { isEditingTarget, matchesHotkey } from "@poetry/controllers/helpers/hotkey"
3
+
4
+ // Declarative global shortcut:
5
+ // put the controller on any clickable element and declare the descriptor -
6
+ //
7
+ // <a href="/inbox" data-controller="poetry--core--hotkey"
8
+ // data-poetry--core--hotkey-keys-value="g+i">
9
+ //
10
+ // On match the controller dispatches a cancelable poetry--core--hotkey:pressed
11
+ // event, then clicks the host element (buttons, links, summary - anything
12
+ // click-activatable). Unmodified single-key descriptors stay inert while
13
+ // the user is typing in an input/textarea/select/contentEditable; combos
14
+ // carrying meta/ctrl/alt fire everywhere (the ⌘K convention).
15
+ export default class extends Controller {
16
+ static values = {
17
+ keys: { type: String, default: "" }
18
+ }
19
+
20
+ static events = ["poetry--core--hotkey:pressed"]
21
+
22
+ #onKeydown = null
23
+
24
+ /**
25
+ * Arms the window keydown listener when a descriptor is declared (an
26
+ * empty keys value stays inert).
27
+ */
28
+ connect() {
29
+ if (this.keysValue === "") return
30
+
31
+ // defaultPrevented gate: a key another hotkey consumer (dialog
32
+ // hotkey, another instance) already claimed stays claimed.
33
+ this.#onKeydown = (event) => {
34
+ if (event.defaultPrevented || !matchesHotkey(event, this.keysValue)) return
35
+ if (isEditingTarget(event) && !this.#modified(event)) return
36
+
37
+ event.preventDefault()
38
+ const pressed = this.dispatch("pressed", { cancelable: true })
39
+ if (!pressed.defaultPrevented) this.element.click()
40
+ }
41
+ window.addEventListener("keydown", this.#onKeydown)
42
+ }
43
+
44
+ /** Disarms the listener. */
45
+ disconnect() {
46
+ if (!this.#onKeydown) return
47
+
48
+ window.removeEventListener("keydown", this.#onKeydown)
49
+ this.#onKeydown = null
50
+ }
51
+
52
+ #modified(event) {
53
+ return event.metaKey || event.ctrlKey || event.altKey
54
+ }
55
+ }
@@ -0,0 +1,431 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { portalContent, resolvePortalContainer, restoreContent } from "@poetry/controllers/helpers/portal"
3
+ import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
4
+ import { setState, stateOf } from "@poetry/controllers/helpers/state"
5
+ import { tabbableWithin } from "@poetry/controllers/helpers/tabbable"
6
+
7
+ // The HoverCard controller (the popper-consumer trio's thinnest machine):
8
+ // pointer-only enrichment behind a LINK. Two timers (open 600 / close
9
+ // 300, over the trigger+content
10
+ // pair, re-enter cancels - the grace window, no polygon), the touch double-guard
11
+ // (pointerType 'touch' no-ops AND touchstart preventDefaults so a tap can
12
+ // never synthesize a focus-open - a tap just navigates the link), the focus
13
+ // mirror (trigger focus opens immediately / blur closes - a keyboard user
14
+ // SEES the card), the per-open TABINDEX STRIP (every tabbable inside is
15
+ // forced tabindex=-1: keyboard and touch users never reach inside,
16
+ // intentional and contractual - the reachable-elsewhere rule), and the
17
+ // SELECTION HOLD (text selection started in the card keeps it open and
18
+ // suppresses body user-select while dragging, so previews are copyable).
19
+ //
20
+ // NO focus-scope anywhere in the lifecycle: focus never moves in, so there
21
+ // is nothing to trap or restore - the trio's simplest teardown. The
22
+ // token-activated dismissable delivers Esc (topmost-only) + outside press
23
+ // while open. No aria surface is added (no haspopup/expanded/describedby):
24
+ // advertising a keyboard-unreachable surface to AT is worse than silence.
25
+ const TRIGGER_SELECTOR = '[data-slot="hover-card-trigger"]'
26
+ const CONTENT_SELECTOR = '[data-slot="hover-card-content"]'
27
+
28
+ const EVENT_PREFIX = "poetry:hover-card"
29
+
30
+ const DISMISSABLE = "poetry--core--dismissable"
31
+ const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
32
+
33
+ export default class HoverCardController extends Controller {
34
+ // The events this controller dispatches (manifest surface;
35
+ // events_declaration.test.js enforces the list stays honest).
36
+ static events = ["poetry:hover-card:closed", "poetry:hover-card:open"]
37
+
38
+ static values = {
39
+ open: { type: Boolean, default: false },
40
+ openDelay: { type: Number, default: 600 },
41
+ closeDelay: { type: Number, default: 300 }
42
+ }
43
+
44
+ #connected = false
45
+ #wired = []
46
+ #openTimer = null
47
+ #closeTimer = null
48
+ #cancelExit = null
49
+ #containSelection = false
50
+ #hasSelection = false
51
+ #previousBodyUserSelect = null
52
+ #onPointerup = () => this.#handlePointerup()
53
+
54
+ /**
55
+ * Wires the content listeners (portal-safe) and reconciles a
56
+ * server-pinned card (layer + tabindex strip catch up; the DOM wins).
57
+ */
58
+ connect() {
59
+ const content = this.#content()
60
+
61
+ if (content) this.#wireContent(content)
62
+
63
+ this.#connected = true
64
+
65
+ // Reconcile-on-connect: a server-pinned card (open: true) activates the
66
+ // layer + the tabindex strip; the DOM attributes win.
67
+ if (this.#isOpen()) {
68
+ this.#activate(content)
69
+ this.openValue = true
70
+ } else if (this.openValue) {
71
+ this.#show()
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Clears every timer, restores suppressed selection and portaled
77
+ * content (drop-never-strand), and unwires the listeners.
78
+ */
79
+ disconnect() {
80
+ this.#connected = false
81
+ this.#clearOpenTimer()
82
+ this.#clearCloseTimer()
83
+ this.#cancelExit?.()
84
+ this.#cancelExit = null
85
+ this.#restoreBodyUserSelect() // never strand suppressed selection (teardown contract)
86
+
87
+ // Never leave content stranded at the container (drop-never-strand).
88
+ const content = this.#content()
89
+
90
+ if (content) restoreContent(content)
91
+
92
+ for (const [target, type, listener] of this.#wired) target.removeEventListener(type, listener)
93
+
94
+ this.#wired = []
95
+ document.removeEventListener("pointerup", this.#onPointerup)
96
+ }
97
+
98
+ /**
99
+ * Stimulus value callback - controllable state: pinned previews (Turbo
100
+ * Stream / Outlet ownership).
101
+ *
102
+ * @param {boolean} value
103
+ */
104
+ openValueChanged(value) {
105
+ if (!this.#connected) return
106
+
107
+ if (value && !this.#isOpen()) this.#show()
108
+ else if (!value && this.#isOpen()) this.#hide("none")
109
+ }
110
+
111
+ // --- trigger actions ---
112
+
113
+ /**
114
+ * The trigger's pointerenter action: arms the open timer; touch
115
+ * pointerType is EXCLUDED - a tap navigates the
116
+ * link instead.
117
+ *
118
+ * @param {PointerEvent} event
119
+ */
120
+ pointerEnter(event) {
121
+ if (event.pointerType === "touch") return
122
+
123
+ this.#hasSelection = false // the next pointer cycle clears the hold
124
+ this.#clearCloseTimer()
125
+
126
+ if (this.#isOpen() || this.#openTimer !== null) return
127
+
128
+ this.#openTimer = window.setTimeout(() => {
129
+ this.#openTimer = null
130
+ this.#show()
131
+ }, this.openDelayValue)
132
+ }
133
+
134
+ /**
135
+ * The trigger's pointerleave action: cancels a pending open; leaving
136
+ * toward the content keeps the card, anywhere else arms the close
137
+ * grace.
138
+ *
139
+ * @param {PointerEvent} event
140
+ */
141
+ pointerLeave(event) {
142
+ if (event.pointerType === "touch") return
143
+
144
+ this.#clearOpenTimer()
145
+
146
+ const related = event.relatedTarget instanceof Element ? event.relatedTarget : null
147
+
148
+ if (related && this.#content()?.contains(related)) return
149
+ if (this.#isOpen()) this.#scheduleClose()
150
+ }
151
+
152
+ /**
153
+ * The trigger's focus action: opens IMMEDIATELY, skipping the timers -
154
+ * a keyboard user sees the preview even though they
155
+ * cannot enter it.
156
+ */
157
+ focusOpen() {
158
+ this.#clearCloseTimer()
159
+ this.#show()
160
+ }
161
+
162
+ /** The trigger's blur action: closes immediately. */
163
+ blurClose() {
164
+ this.#clearOpenTimer()
165
+
166
+ // Blur closes immediately - the selection hold defers only the
167
+ // pointer-leave path. The close reason for the focus path: trigger-focus.
168
+ if (this.#isOpen()) this.#hide("trigger-focus")
169
+ }
170
+
171
+ /**
172
+ * The trigger's touchstart action - the touch guard: preventDefault so
173
+ * a tap can never synthesize a focus event (a focus-opened card on
174
+ * touch would be unreachable). The tap still navigates the
175
+ * link.
176
+ *
177
+ * @param {TouchEvent} event
178
+ */
179
+ touchGuard(event) {
180
+ event.preventDefault()
181
+ }
182
+
183
+ // --- open / close ---
184
+
185
+ #show() {
186
+ const content = this.#content()
187
+
188
+ if (!content || this.#isOpen()) return
189
+
190
+ this.#cancelExit?.()
191
+ this.#cancelExit = null
192
+ this.#clearCloseTimer()
193
+
194
+ const trigger = this.#trigger()
195
+
196
+ // Portal-on-open: move BEFORE the
197
+ // enter presence (reparenting mid-animation restarts it), re-anchor
198
+ // absolute - static under compositor scroll, transform-immune.
199
+ portalContent(content, { container: resolvePortalContainer(this.element) })
200
+ this.element.setAttribute(POPPER_STRATEGY, "absolute")
201
+
202
+ content.hidden = false
203
+ if (trigger) setState(trigger, "popup-open")
204
+ enterPresence(content)
205
+ this.#activateLayer(content)
206
+ this.#stripTabbables(content)
207
+ this.openValue = true
208
+
209
+ queueMicrotask(() => {
210
+ if (!this.#isOpen()) return
211
+
212
+ this.dispatch("open", { prefix: EVENT_PREFIX, detail: {} })
213
+ })
214
+ }
215
+
216
+ #hide(reason) {
217
+ const content = this.#content()
218
+
219
+ if (!content || !this.#isOpen()) return
220
+
221
+ this.#clearOpenTimer()
222
+ this.#clearCloseTimer()
223
+ this.#hasSelection = false
224
+ this.#restoreBodyUserSelect()
225
+
226
+ const trigger = this.#trigger()
227
+
228
+ if (trigger) setState(trigger, "popup-closed")
229
+ this.openValue = false
230
+
231
+ this.#cancelExit = exitPresence(content, {
232
+ onRemove: () => {
233
+ this.#cancelExit = null
234
+ content.hidden = true
235
+ // Home AFTER the exit finished and hidden landed.
236
+ restoreContent(content)
237
+ this.element.setAttribute(POPPER_STRATEGY, "fixed")
238
+ this.#removeControllers(content, [DISMISSABLE])
239
+ this.dispatch("closed", { prefix: EVENT_PREFIX, detail: { reason } })
240
+ }
241
+ })
242
+ }
243
+
244
+ // Reconcile-on-connect for a server-pinned card: layer + strip catch up.
245
+ #activate(content) {
246
+ if (!content) return
247
+
248
+ this.#activateLayer(content)
249
+ this.#stripTabbables(content)
250
+
251
+ // ONE FRAME LATE (the tooltip's reconcile rule): portaling before the
252
+ // sibling popper's connect would rob it of its content target before
253
+ // it could cache the node.
254
+ window.requestAnimationFrame(() => {
255
+ if (!this.#connected || !this.#isOpen()) return
256
+
257
+ portalContent(content, { container: resolvePortalContainer(this.element) })
258
+ this.element.setAttribute(POPPER_STRATEGY, "absolute")
259
+ })
260
+ }
261
+
262
+ #activateLayer(content) {
263
+ content.setAttribute(`data-${DISMISSABLE}-disable-outside-pointer-events-value`, "false")
264
+ this.#addControllers(content, [DISMISSABLE])
265
+ }
266
+
267
+ // The tabindex strip (contractual; per-open so stream-appended content
268
+ // re-strips on the next open): the card is sighted-pointer-only BY
269
+ // DESIGN - Tab passes straight over its contents. The shared tabbable
270
+ // walk already skips disabled/hidden/-1 elements.
271
+ #stripTabbables(content) {
272
+ for (const element of tabbableWithin(content)) element.setAttribute("tabindex", "-1")
273
+ }
274
+
275
+ // --- the close grace (the trigger+content pair) ---
276
+
277
+ #scheduleClose() {
278
+ this.#clearCloseTimer()
279
+ this.#closeTimer = window.setTimeout(() => {
280
+ this.#closeTimer = null
281
+
282
+ // The selection hold: while a selection made from the card exists,
283
+ // pointer-leave does NOT close (the user is reading/copying).
284
+ if (this.#hasSelection) return
285
+
286
+ this.#hide("trigger-hover")
287
+ }, this.closeDelayValue)
288
+ }
289
+
290
+ #clearOpenTimer() {
291
+ if (this.#openTimer === null) return
292
+
293
+ window.clearTimeout(this.#openTimer)
294
+ this.#openTimer = null
295
+ }
296
+
297
+ #clearCloseTimer() {
298
+ if (this.#closeTimer === null) return
299
+
300
+ window.clearTimeout(this.#closeTimer)
301
+ this.#closeTimer = null
302
+ }
303
+
304
+ // --- content wiring (programmatic: portal-safe) ---
305
+
306
+ #wireContent(content) {
307
+ this.#listen(content, "pointerenter", (event) => {
308
+ if (event.pointerType === "touch") return
309
+
310
+ this.#clearCloseTimer()
311
+ })
312
+ this.#listen(content, "pointerleave", (event) => {
313
+ if (event.pointerType === "touch") return
314
+
315
+ const related = event.relatedTarget instanceof Element ? event.relatedTarget : null
316
+
317
+ if (related && (this.#trigger()?.contains(related) || content.contains(related))) return
318
+ if (this.#isOpen()) this.#scheduleClose()
319
+ })
320
+ this.#listen(content, "pointerdown", (event) => this.#handleContentPointerdown(event))
321
+ this.#listen(content, `${DISMISSABLE}:dismiss`, this.#onDismiss)
322
+ }
323
+
324
+ #listen(target, type, listener) {
325
+ target.addEventListener(type, listener)
326
+ this.#wired.push([target, type, listener])
327
+ }
328
+
329
+ // Esc (topmost-only) + pointerdown-outside arrive as the dismissable's
330
+ // dismiss event. Esc/outside ALWAYS close - the selection hold defers
331
+ // only the pointer-leave path. Focus never moved in, so there is nothing
332
+ // to restore.
333
+ #onDismiss = (event) => {
334
+ if (event.target !== this.#content()) return
335
+
336
+ const escaped = event.detail?.originalEvent?.type === "keydown"
337
+
338
+ this.#hide(escaped ? "escape-key" : "outside-press")
339
+ }
340
+
341
+ // --- the selection hold (adapted - source and license in
342
+ // THIRD_PARTY_NOTICES.md) ---
343
+
344
+ // pointerdown ON the content: suppress body user-select (webkit prefix
345
+ // included) so the drag selects only card text; pointerup restores it
346
+ // and, one frame later, records whether a selection exists.
347
+ #handleContentPointerdown(event) {
348
+ if (event.pointerType === "touch") return
349
+
350
+ this.#containSelection = true
351
+ this.#suppressBodyUserSelect()
352
+ document.addEventListener("pointerup", this.#onPointerup, { once: true })
353
+ }
354
+
355
+ #handlePointerup() {
356
+ if (!this.#containSelection) return
357
+
358
+ this.#containSelection = false
359
+ this.#restoreBodyUserSelect()
360
+
361
+ window.setTimeout(() => {
362
+ this.#hasSelection = (document.getSelection()?.toString() ?? "") !== ""
363
+ }, 0)
364
+ }
365
+
366
+ #suppressBodyUserSelect() {
367
+ if (this.#previousBodyUserSelect !== null) return
368
+
369
+ const { body } = document
370
+
371
+ this.#previousBodyUserSelect = {
372
+ userSelect: body.style.userSelect ?? "",
373
+ webkitUserSelect: body.style.webkitUserSelect ?? ""
374
+ }
375
+ body.style.userSelect = "none"
376
+ body.style.webkitUserSelect = "none"
377
+ }
378
+
379
+ #restoreBodyUserSelect() {
380
+ if (this.#previousBodyUserSelect === null) return
381
+
382
+ const { body } = document
383
+
384
+ body.style.userSelect = this.#previousBodyUserSelect.userSelect
385
+ body.style.webkitUserSelect = this.#previousBodyUserSelect.webkitUserSelect
386
+ this.#previousBodyUserSelect = null
387
+ this.#containSelection = false
388
+ }
389
+
390
+ // --- structural resolution (the server id pair, portal-safe) ---
391
+
392
+ #trigger() {
393
+ return this.element.querySelector(TRIGGER_SELECTOR)
394
+ }
395
+
396
+ #content() {
397
+ const triggerId = this.#trigger()?.id
398
+
399
+ if (triggerId?.endsWith("-trigger")) {
400
+ const byId = document.getElementById(triggerId.replace(/-trigger$/, "-content"))
401
+
402
+ if (byId) return byId
403
+ }
404
+
405
+ return this.element.querySelector(CONTENT_SELECTOR)
406
+ }
407
+
408
+ #isOpen() {
409
+ const content = this.#content()
410
+
411
+ return Boolean(content) && stateOf(content) === "open"
412
+ }
413
+
414
+ #addControllers(element, identifiers) {
415
+ const tokens = (element.getAttribute("data-controller") ?? "").split(/\s+/).filter(Boolean)
416
+
417
+ for (const identifier of identifiers) {
418
+ if (!tokens.includes(identifier)) tokens.push(identifier)
419
+ }
420
+
421
+ element.setAttribute("data-controller", tokens.join(" "))
422
+ }
423
+
424
+ #removeControllers(element, identifiers) {
425
+ const tokens = (element.getAttribute("data-controller") ?? "")
426
+ .split(/\s+/)
427
+ .filter((token) => token && !identifiers.includes(token))
428
+
429
+ element.setAttribute("data-controller", tokens.join(" "))
430
+ }
431
+ }