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,439 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import {
3
+ arrow,
4
+ autoUpdate,
5
+ computePosition,
6
+ flip,
7
+ hide,
8
+ limitShift,
9
+ offset,
10
+ shift,
11
+ size
12
+ } from "@poetry/controllers/vendor/floating_ui_dom"
13
+
14
+ // The anchored-positioning primitive: one
15
+ // controller over the VENDORED @floating-ui/dom. A fixed middleware
16
+ // pipeline - offset -> shift -> flip (both gated on avoidCollisions)
17
+ // -> size -> arrow (when an arrow target exists) -> hide - and a fixed
18
+ // output contract: data-side / data-align mirror the RESOLVED placement
19
+ // (after flip), and the content gets the sizing CSS vars
20
+ // (--transform-origin / -available-width / -available-height /
21
+ // -anchor-width / -anchor-height) so ported Tailwind classes like
22
+ // data-[side=top]:slide-in-from-bottom-2 and
23
+ // max-h-[var(--available-height)] work unchanged.
24
+ //
25
+ // The Arrow stays a markup primitive: any [data-slot=popper-arrow]
26
+ // marked as the arrow target is positioned + given data-side here; the SVG
27
+ // itself is poetry-ui's business.
28
+ //
29
+ // VIRTUAL-ANCHOR mode (the ContextMenu contract): a non-empty anchorPoint
30
+ // value - canonically the data-poetry--core--popper-anchor-point="x,y"
31
+ // attribute on the controller root (the DOM is the store) - floats against a
32
+ // floating-ui VirtualElement: a zero-size rect at (x,y) client coords.
33
+ // The resolved ELEMENT anchor rides along as
34
+ // contextElement so autoUpdate still tracks ancestor scroll/resize.
35
+ // setAnchor(x, y) is the ergonomics wrapper ContextMenu calls from its
36
+ // contextmenu / long-press handlers; clearing the value returns to plain
37
+ // element anchoring.
38
+ //
39
+ // Consumers: Popover, Dropdown/Context Menu, Tooltip, HoverCard, Select,
40
+ // Combobox, Menubar.
41
+ const SIDES = ["top", "right", "bottom", "left"]
42
+ const ALIGNS = ["start", "center", "end"]
43
+
44
+ // The side the arrow sits on / the transform-origin edge is the OPPOSITE of
45
+ // where the content was placed.
46
+ const OPPOSITE_SIDE = { top: "bottom", right: "left", bottom: "top", left: "right" }
47
+
48
+ // The arrow rotation table: the arrow component points down by default;
49
+ // these transforms swing it toward the anchor for each resolved side.
50
+ const ARROW_TRANSFORM = {
51
+ top: "translateY(100%)",
52
+ right: "translateY(50%) rotate(90deg) translateX(-50%)",
53
+ bottom: "rotate(180deg)",
54
+ left: "translateY(50%) rotate(-90deg) translateX(50%)"
55
+ }
56
+
57
+ const ARROW_TRANSFORM_ORIGIN = { top: "", right: "0 0", bottom: "center 0", left: "100% 0" }
58
+
59
+ const NO_ARROW_ALIGN_ORIGIN = { start: "0%", center: "50%", end: "100%" }
60
+
61
+ export default class PopperController extends Controller {
62
+ static targets = ["anchor", "content", "arrow"]
63
+ static values = {
64
+ // Anchor fallback chain: anchor target -> this selector -> the element's
65
+ // previousElementSibling (the natural trigger-then-content markup shape).
66
+ anchor: { type: String, default: "" },
67
+ // "x,y" client coords -> virtual-anchor mode (see the header). Empty =
68
+ // element anchoring. Reactive: changing it re-arms autoUpdate.
69
+ anchorPoint: { type: String, default: "" },
70
+ side: { type: String, default: "bottom" }, // top | right | bottom | left
71
+ align: { type: String, default: "center" }, // start | center | end
72
+ sideOffset: { type: Number, default: 0 },
73
+ alignOffset: { type: Number, default: 0 },
74
+ avoidCollisions: { type: Boolean, default: true },
75
+ strategy: { type: String, default: "fixed" } // fixed | absolute
76
+ }
77
+
78
+ #stopAutoUpdate = null
79
+ #anchorElement = null
80
+ #contentElement = null
81
+ #arrowElement = null
82
+ #generation = 0
83
+ #connected = false
84
+ #hidPointerEvents = false
85
+ #pointerEventsBeforeHide = ""
86
+
87
+ /** Arms autoUpdate (which fires one immediate position pass). */
88
+ connect() {
89
+ this.#connected = true
90
+ this.#start()
91
+ }
92
+
93
+ /** Disarms, stranding any in-flight update (the generation bump). */
94
+ disconnect() {
95
+ this.#connected = false
96
+ this.#generation += 1 // strand any in-flight #update await
97
+ this.#stop()
98
+ }
99
+
100
+ /**
101
+ * Stimulus value callback - reactive re-anchor: switching between
102
+ * element and virtual anchoring (or moving the point) re-arms
103
+ * autoUpdate against the new reference. Fires during initialization
104
+ * too, before connect - the connected guard skips that first call.
105
+ */
106
+ anchorPointValueChanged() {
107
+ if (!this.#connected) return
108
+
109
+ this.#stop()
110
+ this.#start()
111
+ }
112
+
113
+ /**
114
+ * Stimulus value callback - reactive strategy (the portal seam): a
115
+ * consumer flips fixed <-> absolute when it portals/restores content;
116
+ * re-arming repositions in the new coordinate space immediately.
117
+ */
118
+ strategyValueChanged() {
119
+ if (!this.#connected) return
120
+
121
+ this.#stop()
122
+ this.#start()
123
+ }
124
+
125
+ /**
126
+ * Manual re-run for consumers whose geometry changed outside
127
+ * autoUpdate's sensors (e.g. content swapped by a Turbo Stream).
128
+ *
129
+ * @returns {Promise<void>} the update promise, so callers can await
130
+ * settlement
131
+ */
132
+ reposition() {
133
+ return this.#update()
134
+ }
135
+
136
+ /**
137
+ * The ContextMenu entry point: stores the pointer coords (the ATTRIBUTE
138
+ * is canonical - inspectable, server-settable) and repositions
139
+ * immediately; the value-changed callback then re-arms autoUpdate's
140
+ * tracking.
141
+ *
142
+ * @param {number} x - client coords
143
+ * @param {number} y
144
+ * @returns {Promise<void>}
145
+ */
146
+ setAnchor(x, y) {
147
+ this.anchorPointValue = `${x},${y}`
148
+
149
+ return this.reposition()
150
+ }
151
+
152
+ /**
153
+ * The NavigationMenu viewport entry point: floats against a
154
+ * caller-supplied element (the active trigger) instead of a
155
+ * target/selector. Re-arms autoUpdate so the new reference's ancestors
156
+ * are tracked, then repositions - the positioner's inset transition
157
+ * turns that write into the position morph.
158
+ *
159
+ * @param {Element} element
160
+ * @returns {Promise<void>}
161
+ */
162
+ setAnchorElement(element) {
163
+ this.#anchorElement = element
164
+ this.#stop()
165
+ this.#start()
166
+
167
+ return this.reposition()
168
+ }
169
+
170
+ // --- positioning ---
171
+
172
+ #start() {
173
+ const reference = this.#reference()
174
+ const content = this.#content()
175
+
176
+ if (!reference || !content) return
177
+
178
+ // autoUpdate re-runs positioning on scroll / resize / anchor movement and
179
+ // fires once immediately, so start needs no separate first update.
180
+ this.#stopAutoUpdate = autoUpdate(reference, content, () => this.#update())
181
+ }
182
+
183
+ #stop() {
184
+ this.#stopAutoUpdate?.()
185
+ this.#stopAutoUpdate = null
186
+ }
187
+
188
+ async #update() {
189
+ const anchor = this.#reference()
190
+ const content = this.#content()
191
+
192
+ if (!anchor || !content) return
193
+ // Select's alignItemWithTrigger mode fixed-positions the
194
+ // content itself; while the attribute is on, popper must not fight it.
195
+ if (content.hasAttribute("data-align-item-with-trigger")) return
196
+
197
+ const arrowElement = this.#arrow()
198
+ const generation = this.#generation
199
+
200
+ const { x, y, placement, strategy, middlewareData } = await computePosition(anchor, content, {
201
+ strategy: this.strategyValue,
202
+ placement: this.#placement(),
203
+ middleware: this.#middleware(arrowElement)
204
+ })
205
+
206
+ // Disconnected (or superseded) while awaiting: never write to a dead node.
207
+ if (generation !== this.#generation) return
208
+ // Re-check after the await: aligned mode can engage DURING
209
+ // computePosition (open -> update starts -> select fixed-positions
210
+ // the content -> compute resolves), and a stale write here clobbers
211
+ // the fixed placement with absolute popper coordinates.
212
+ if (content.hasAttribute("data-align-item-with-trigger")) return
213
+
214
+ const [side, align = "center"] = placement.split("-")
215
+
216
+ content.style.position = strategy
217
+ content.style.left = `${x}px`
218
+ content.style.top = `${y}px`
219
+
220
+ // The RESOLVED side/align (after flip) - what the ported data-[side=...]
221
+ // Tailwind variants key off.
222
+ content.setAttribute("data-side", side)
223
+ content.setAttribute("data-align", align)
224
+
225
+ content.style.setProperty(
226
+ "--transform-origin",
227
+ this.#transformOrigin({ side, align, content, arrowElement, middlewareData })
228
+ )
229
+
230
+ if (middlewareData.hide) {
231
+ // Never write the hide verdict onto CLOSED content: a closed-era
232
+ // pass measures a display:none box (garbage geometry), and its
233
+ // stale visibility:hidden lingers into the open MICROTASK, where
234
+ // Chrome silently refuses focus-scope's mount focus (the popover
235
+ // first-open race - pre-existing, surfaced by the portal
236
+ // proofs). While content is hidden the verdict resolves to
237
+ // visible, which routes through the clear/restore branch below so
238
+ // every open starts clean.
239
+ const hidden = !content.hidden && Boolean(middlewareData.hide.referenceHidden)
240
+
241
+ content.style.visibility = hidden ? "hidden" : ""
242
+
243
+ // Save/restore, never clear: the dismissable scrim opts this SAME
244
+ // element back in (pointer-events: auto) while body is none -
245
+ // blanking it on every position pass left modal popovers click-dead.
246
+ // Found by the real-pointer tester proofs (the first real-pointer gate;
247
+ // dommy dispatches synthetically, so pointer-events was invisible
248
+ // to it).
249
+ if (hidden && !this.#hidPointerEvents) {
250
+ this.#hidPointerEvents = true
251
+ this.#pointerEventsBeforeHide = content.style.pointerEvents
252
+ content.style.pointerEvents = "none"
253
+ } else if (!hidden && this.#hidPointerEvents) {
254
+ this.#hidPointerEvents = false
255
+ content.style.pointerEvents = this.#pointerEventsBeforeHide
256
+ }
257
+ }
258
+
259
+ if (arrowElement) this.#positionArrow(arrowElement, side, middlewareData)
260
+ }
261
+
262
+ // The pipeline order is contractual. offset folds the arrow's
263
+ // height into the main axis, so the gap accounts for
264
+ // the arrow between anchor and content.
265
+ #middleware(arrowElement) {
266
+ const { height: arrowHeight } = this.#arrowSize(arrowElement)
267
+ const middleware = [
268
+ offset({ mainAxis: this.sideOffsetValue + arrowHeight, alignmentAxis: this.alignOffsetValue })
269
+ ]
270
+
271
+ if (this.avoidCollisionsValue) {
272
+ middleware.push(shift({ mainAxis: true, crossAxis: false, limiter: limitShift() }))
273
+ middleware.push(flip())
274
+ }
275
+
276
+ middleware.push(
277
+ size({
278
+ apply: ({ elements, rects, availableWidth, availableHeight }) => {
279
+ // The sizing vars, written from inside the pipeline so
280
+ // they reflect post-flip geometry.
281
+ const style = elements.floating.style
282
+
283
+ style.setProperty("--available-width", `${availableWidth}px`)
284
+ style.setProperty("--available-height", `${availableHeight}px`)
285
+ style.setProperty("--anchor-width", `${rects.reference.width}px`)
286
+ style.setProperty("--anchor-height", `${rects.reference.height}px`)
287
+ }
288
+ })
289
+ )
290
+
291
+ if (arrowElement) middleware.push(arrow({ element: arrowElement }))
292
+
293
+ middleware.push(hide({ strategy: "referenceHidden" }))
294
+
295
+ return middleware
296
+ }
297
+
298
+ #placement() {
299
+ const side = SIDES.includes(this.sideValue) ? this.sideValue : "bottom"
300
+ const align = ALIGNS.includes(this.alignValue) ? this.alignValue : "center"
301
+
302
+ return align === "center" ? side : `${side}-${align}`
303
+ }
304
+
305
+ // The transform-origin rule, applied post-compute: the origin sits
306
+ // on the anchor-facing edge, at the arrow's center when there is an arrow,
307
+ // else at the resolved align fraction.
308
+ #transformOrigin({ side, align, content, arrowElement, middlewareData }) {
309
+ const { width: arrowWidth, height: arrowHeight } = this.#arrowSize(arrowElement)
310
+ const alignOrigin = NO_ARROW_ALIGN_ORIGIN[align] ?? "50%"
311
+ const arrowX = (middlewareData.arrow?.x ?? 0) + arrowWidth / 2
312
+ const arrowY = (middlewareData.arrow?.y ?? 0) + arrowHeight / 2
313
+ const across = arrowElement ? `${arrowX}px` : alignOrigin
314
+ const down = arrowElement ? `${arrowY}px` : alignOrigin
315
+ const rect = typeof content.getBoundingClientRect === "function"
316
+ ? content.getBoundingClientRect()
317
+ : { width: 0, height: 0 }
318
+
319
+ switch (side) {
320
+ case "top": return `${across} ${rect.height + arrowHeight}px`
321
+ case "bottom": return `${across} ${-arrowHeight}px`
322
+ case "left": return `${rect.width + arrowHeight}px ${down}`
323
+ case "right": return `${-arrowHeight}px ${down}`
324
+ default: return `${alignOrigin} ${alignOrigin}`
325
+ }
326
+ }
327
+
328
+ #positionArrow(arrowElement, side, middlewareData) {
329
+ const { x, y } = middlewareData.arrow ?? {}
330
+
331
+ arrowElement.style.position = "absolute"
332
+ // Clear EVERY edge before pinning: after a flip the stale opposite pin
333
+ // survives (top:0 + bottom:0 stretches the arrow to the bubble's full
334
+ // height, and that inflated box feeds the arrow-height gap offset).
335
+ arrowElement.style.top = ""
336
+ arrowElement.style.right = ""
337
+ arrowElement.style.bottom = ""
338
+ arrowElement.style.left = ""
339
+ arrowElement.style.left = x != null ? `${x}px` : ""
340
+ arrowElement.style.top = y != null ? `${y}px` : ""
341
+ // Pin the arrow to the content's anchor-facing edge, then rotate it to
342
+ // point at the anchor (the table above); data-side is ALSO written so
343
+ // ported CSS can restyle per side.
344
+ arrowElement.style[OPPOSITE_SIDE[side]] = "0"
345
+ arrowElement.style.transformOrigin = ARROW_TRANSFORM_ORIGIN[side]
346
+ arrowElement.style.transform = ARROW_TRANSFORM[side]
347
+ arrowElement.setAttribute("data-side", side)
348
+ }
349
+
350
+ #arrowSize(arrowElement) {
351
+ if (!arrowElement || typeof arrowElement.getBoundingClientRect !== "function") {
352
+ return { width: 0, height: 0 }
353
+ }
354
+
355
+ const { width, height } = arrowElement.getBoundingClientRect()
356
+
357
+ return { width, height }
358
+ }
359
+
360
+ // --- elements ---
361
+
362
+ // What computePosition / autoUpdate float against: with a stored anchor
363
+ // point, a VirtualElement whose rect is zero-size at (x,y) - live-read, so
364
+ // a moved point is picked up on every autoUpdate re-read - else the
365
+ // element anchor itself.
366
+ #reference() {
367
+ const anchor = this.#anchor()
368
+ const point = this.#anchorPoint()
369
+
370
+ if (!point) return anchor
371
+
372
+ const reference = {
373
+ getBoundingClientRect: () => {
374
+ const { x, y } = this.#anchorPoint() ?? point
375
+
376
+ return { x, y, width: 0, height: 0, top: y, right: x, bottom: y, left: x }
377
+ }
378
+ }
379
+
380
+ // The element anchor rides along so autoUpdate observes scroll / resize
381
+ // of the trigger surface's ancestors (the ContextMenu contract).
382
+ if (anchor) reference.contextElement = anchor
383
+
384
+ return reference
385
+ }
386
+
387
+ #anchorPoint() {
388
+ const raw = this.anchorPointValue.trim()
389
+
390
+ if (!raw) return null
391
+
392
+ const [x, y] = raw.split(",").map((part) => Number.parseFloat(part))
393
+
394
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null
395
+
396
+ return { x, y }
397
+ }
398
+
399
+ #anchor() {
400
+ if (this.#anchorElement) return this.#anchorElement
401
+ if (this.hasAnchorTarget) return this.anchorTarget
402
+ if (this.anchorValue) return document.querySelector(this.anchorValue)
403
+
404
+ return this.element.previousElementSibling
405
+ }
406
+
407
+ #content() {
408
+ if (this.hasContentTarget) {
409
+ this.#contentElement = this.contentTarget
410
+ return this.contentTarget
411
+ }
412
+
413
+ // The portal seam: while content is portaled
414
+ // out, the Stimulus target no longer matches (targets scope to the
415
+ // controller's subtree) - the cached node keeps being positioned.
416
+ // Without the cache the fallback would position the ROOT.
417
+ if (this.#contentElement?.isConnected) return this.#contentElement
418
+
419
+ return this.element
420
+ }
421
+
422
+ // The Tier-4 Arrow is markup-only: an explicit arrow target wins, else any
423
+ // [data-slot=popper-arrow] inside the content is positioned.
424
+ #arrow() {
425
+ if (this.hasArrowTarget) {
426
+ this.#arrowElement = this.arrowTarget
427
+ return this.arrowTarget
428
+ }
429
+
430
+ // The portal seam, arrow edition (#content's cache, same reason):
431
+ // portaled content takes the arrow out of target scope, and the
432
+ // fallback selector can't see a component-slotted arrow
433
+ // (tooltip-arrow, not popper-arrow) - without the cache the arrow
434
+ // freezes at its first written position forever.
435
+ if (this.#arrowElement?.isConnected) return this.#arrowElement
436
+
437
+ return this.#content()?.querySelector("[data-slot=popper-arrow]") ?? null
438
+ }
439
+ }
@@ -0,0 +1,74 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { setState } from "@poetry/controllers/helpers/state"
3
+
4
+ // The Toggle micro-machine (the smallest controller in the suite): flip
5
+ // aria-pressed and mirror the bare data-pressed presence boolean -
6
+ // WRITTEN TOGETHER, never separately. The DOM is the store: no Values, no internal state. This is
7
+ // deliberately NOT poetry--core--checked (different ARIA vocabulary, no
8
+ // input to sync) and not poetry--core--state (that owns aria-expanded
9
+ // disclosure) - three micro-controllers, three vocabularies.
10
+ //
11
+ // No keydown code: Space AND Enter both activate a native button
12
+ // (click) - the platform already implements the keyboard.
13
+ //
14
+ // poetry:toggle:change is CANCELABLE and fires BEFORE the flip renders:
15
+ // preventDefault vetoes it (hosts that must confirm). The pressed value in
16
+ // the detail is the state the toggle is ABOUT to enter.
17
+ export default class PressedController extends Controller {
18
+ // The events this controller dispatches (manifest surface;
19
+ // events_declaration.test.js enforces the list stays honest).
20
+ static events = ["poetry:toggle:change"]
21
+
22
+ /**
23
+ * The click action: flips to the opposite pressed state. No-op while
24
+ * disabled; a listener that vetoes the (cancelable) change event stops
25
+ * the flip.
26
+ */
27
+ toggle() {
28
+ if (this.#disabled()) return
29
+
30
+ this.#commit(this.element.getAttribute("aria-pressed") !== "true")
31
+ }
32
+
33
+ // --- the programmatic controllable-state surface (the rollback recipe:
34
+ // hosts revert a failed effect via set(false)) ---
35
+
36
+ /** Programmatically presses the toggle (see set). */
37
+ press() {
38
+ this.set(true)
39
+ }
40
+
41
+ /** Programmatically releases the toggle (see set). */
42
+ unpress() {
43
+ this.set(false)
44
+ }
45
+
46
+ /**
47
+ * Programmatically writes a pressed state through the same veto path
48
+ * the click takes. No-op while disabled.
49
+ *
50
+ * @param {boolean} pressed
51
+ */
52
+ set(pressed) {
53
+ if (this.#disabled()) return
54
+
55
+ this.#commit(Boolean(pressed))
56
+ }
57
+
58
+ #commit(pressed) {
59
+ const change = this.dispatch("change", {
60
+ prefix: "poetry:toggle",
61
+ cancelable: true,
62
+ detail: { pressed }
63
+ })
64
+
65
+ if (change.defaultPrevented) return
66
+
67
+ this.element.setAttribute("aria-pressed", String(pressed))
68
+ setState(this.element, pressed ? "pressed" : "unpressed")
69
+ }
70
+
71
+ #disabled() {
72
+ return this.element.hasAttribute("disabled") || this.element.getAttribute("aria-disabled") === "true"
73
+ }
74
+ }