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,111 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
3
+
4
+ // Error isolation for deferred turbo-frames. Turbo owns the
5
+ // loading physics (loading="lazy" fetches on visibility, "eager" after
6
+ // paint) but leaves failure INVISIBLE: a missing frame logs "Content
7
+ // missing" or - Turbo 8 - promotes the error response to a full-page
8
+ // visit, and a network error leaves the placeholder spinning forever.
9
+ // This controller makes failure a state: data-error reflected on the
10
+ // frame (a bespoke boolean, deliberately not the shared open/closed
11
+ // vocabulary), the placeholder hidden, and the slotted <template> error
12
+ // content stamped so the region shows a real, retryable message.
13
+ //
14
+ // THE BOOT CONTRACT: the frame renders with NO src - the URL rides
15
+ // srcValue and connect() arms it. A visible lazy frame with src in
16
+ // markup can complete its fetch before the controllers module graph
17
+ // finishes loading (caught live: a local 404 beat Stimulus and Turbo 8
18
+ // navigated the whole page to the error response); with src armed at
19
+ // connect, no fetch - and so no failure - can predate the instance.
20
+ //
21
+ // retry() prefers Turbo's FrameElement#reload(); environments without
22
+ // the Turbo runtime (jsdom, dommy) fall back to re-setting src, the same
23
+ // signal Turbo reacts to.
24
+ export default class DeferredController extends Controller {
25
+ static targets = ["placeholder", "error"]
26
+ static values = { src: String }
27
+
28
+ /**
29
+ * Wires the three failure listeners, subscribes the before-cache reset,
30
+ * and arms src LAST (the boot contract above: no fetch can predate the
31
+ * instance).
32
+ */
33
+ connect() {
34
+ // Three failure paths, one state. turbo:before-fetch-response is the
35
+ // load-bearing one: for 4xx/5xx frame responses Turbo 8 fires NO
36
+ // frame-missing - it promotes the error response straight to a
37
+ // full-page visit (caught live: the docs page navigated to a bare
38
+ // 404). Cancelling the response event here stops that promotion cold.
39
+ // frame-missing still covers 2xx-without-a-matching-frame, and
40
+ // fetch-request-error covers the network layer.
41
+ this.element.addEventListener("turbo:before-fetch-response", this.#onResponse)
42
+ this.element.addEventListener("turbo:frame-missing", this.#failed)
43
+ this.element.addEventListener("turbo:fetch-request-error", this.#failed)
44
+
45
+ // Reset before Turbo snapshots: a stamped error message serialized
46
+ // into the cache restores as a permanent failure - the placeholder
47
+ // posture retries the (transient) fetch on the restoration visit.
48
+ this.#unsubscribeBeforeCache = onBeforeCache(() => this.#reset())
49
+
50
+ if (!this.element.getAttribute("src")) this.element.setAttribute("src", this.srcValue)
51
+ }
52
+
53
+ /** Unwires the listeners and the before-cache subscription. */
54
+ disconnect() {
55
+ this.element.removeEventListener("turbo:before-fetch-response", this.#onResponse)
56
+ this.element.removeEventListener("turbo:frame-missing", this.#failed)
57
+ this.element.removeEventListener("turbo:fetch-request-error", this.#failed)
58
+ this.#unsubscribeBeforeCache?.()
59
+ this.#unsubscribeBeforeCache = null
60
+ }
61
+
62
+ /**
63
+ * The retry action: back to the placeholder posture, then reload -
64
+ * Turbo's FrameElement#reload when the runtime is present, else the
65
+ * re-set-src fallback (the same signal Turbo reacts to).
66
+ */
67
+ retry() {
68
+ this.#reset()
69
+
70
+ if (typeof this.element.reload === "function") {
71
+ this.element.reload()
72
+ } else {
73
+ const src = this.element.getAttribute("src") || this.srcValue
74
+ this.element.removeAttribute("src")
75
+ this.element.setAttribute("src", src)
76
+ }
77
+ }
78
+
79
+ #onResponse = (event) => {
80
+ if (event.detail?.fetchResponse?.succeeded !== false) return
81
+
82
+ event.preventDefault()
83
+ this.#failed(event)
84
+ }
85
+
86
+ #failed = (event) => {
87
+ // Keep Turbo from acting on the failure itself (frame-missing default
88
+ // = promote the response to a page visit) - the error template below
89
+ // is the user-visible state.
90
+ if (event.type === "turbo:frame-missing") event.preventDefault()
91
+
92
+ this.element.setAttribute("data-error", "")
93
+ if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = true
94
+ if (this.hasErrorTarget && !this.#stamped) {
95
+ this.#stamped = this.errorTarget.content.firstElementChild?.cloneNode(true)
96
+ if (this.#stamped) this.element.appendChild(this.#stamped)
97
+ }
98
+ }
99
+
100
+ // Back to the placeholder posture (shared by retry and the
101
+ // before-cache teardown).
102
+ #reset() {
103
+ this.#stamped?.remove()
104
+ this.#stamped = null
105
+ this.element.removeAttribute("data-error")
106
+ if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = false
107
+ }
108
+
109
+ #stamped = null
110
+ #unsubscribeBeforeCache = null
111
+ }
@@ -0,0 +1,206 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { setState } from "@poetry/controllers/helpers/state"
3
+ import { matchesHotkey } from "@poetry/controllers/helpers/hotkey"
4
+ import { lockScroll, unlockScroll } from "@poetry/controllers/helpers/scroll_lock"
5
+ import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
6
+ import { exitPresence, flushPendingExits } from "@poetry/controllers/helpers/presence"
7
+
8
+ // The native-dialog primitive: borrow the PLATFORM overlay -
9
+ // showModal() gives the focus trap, Esc handling, top-layer stacking, and
10
+ // focus return for free; this controller adds what the platform doesn't:
11
+ // the data-open/data-closed pair for CSS variants, backdrop-click
12
+ // dismissal, a body scroll-lock, and the presence-hold close - exit flips
13
+ // the pair to data-closed and HOLDS the dialog through its CSS exit
14
+ // animation before the native close() (synchronous when no exit animation
15
+ // applies, so reduced-motion and unthemed hosts close instantly).
16
+ // Consumed by Dialog (and AlertDialog / CommandDialog / Sheet).
17
+ export default class extends Controller {
18
+ static targets = ["dialog"]
19
+ static values = {
20
+ // Set false for AlertDialog-style confirmations: backdrop clicks stop dismissing.
21
+ dismissible: { type: Boolean, default: true },
22
+ // OPT-IN global shortcut ("meta+k") toggling the dialog - the
23
+ // command-palette ⌘K affordance, shipped here because every
24
+ // consumer would otherwise write the same ten lines. "meta" matches metaKey OR
25
+ // ctrlKey (⌘K on mac, ^K elsewhere - the command-palette convention).
26
+ hotkey: { type: String, default: "" }
27
+ }
28
+
29
+ #onHotkey = null
30
+ #unsubscribeBeforeCache = null
31
+
32
+ /**
33
+ * Heals a restored zombie snapshot, subscribes the before-cache close,
34
+ * and arms the opt-in global hotkey.
35
+ */
36
+ connect() {
37
+ this.#healRestoredSnapshot()
38
+ // Close before Turbo snapshots: an open dialog serialized into the
39
+ // cache restores as a de-modalized zombie over a frozen scroll lock.
40
+ // The close is animated now, so the exit it starts must settle in
41
+ // this same tick - flush regardless of listener order (the presence
42
+ // module's own before-cache flush may already have run).
43
+ this.#unsubscribeBeforeCache = onBeforeCache(() => {
44
+ if (this.hasDialogTarget && this.dialogTarget.open) {
45
+ this.close()
46
+ flushPendingExits()
47
+ }
48
+ })
49
+
50
+ if (this.hotkeyValue === "") return
51
+
52
+ // defaultPrevented gate: two dialogs bound to the same descriptor
53
+ // degrade to first-registered-wins instead of both toggling open.
54
+ this.#onHotkey = (event) => {
55
+ if (event.defaultPrevented || !this.#matchesHotkey(event)) return
56
+
57
+ event.preventDefault()
58
+ this.toggle()
59
+ }
60
+ window.addEventListener("keydown", this.#onHotkey)
61
+ }
62
+
63
+ /**
64
+ * Balances the scroll lock and unwires the hotkey and before-cache
65
+ * subscriptions.
66
+ */
67
+ disconnect() {
68
+ this.unlockScroll()
69
+ this.#unsubscribeBeforeCache?.()
70
+ this.#unsubscribeBeforeCache = null
71
+
72
+ if (this.#onHotkey) {
73
+ window.removeEventListener("keydown", this.#onHotkey)
74
+ this.#onHotkey = null
75
+ }
76
+ }
77
+
78
+ // A dialog restored from a PRE-FIX cached snapshot: the open attribute
79
+ // survived serialization but :modal did not, and the body's inline
80
+ // scroll-lock styles came back with no refcount behind them. Normalize
81
+ // to closed and clear the orphaned lock styles directly (the refcounted
82
+ // helper is at zero on a fresh page and must not be decremented).
83
+ #healRestoredSnapshot() {
84
+ if (!this.hasDialogTarget) return
85
+
86
+ const dialog = this.dialogTarget
87
+
88
+ if (!dialog.open) return
89
+ // A genuinely modal dialog only reconnects mid-flight when its subtree
90
+ // is MOVED while open (the sidebar DOM-move class) - leave those alone.
91
+ // Engines without :modal support (happy-dom) treat open-at-connect as
92
+ // the zombie it almost certainly is.
93
+ let modal = false
94
+ try {
95
+ modal = dialog.matches(":modal")
96
+ } catch {
97
+ modal = false
98
+ }
99
+ if (modal) return
100
+
101
+ dialog.close()
102
+ setState(dialog, "closed")
103
+ document.body.style.overflow = ""
104
+ document.body.style.paddingRight = ""
105
+ document.documentElement.style.scrollbarGutter = ""
106
+ }
107
+
108
+ /** The toggle action (and the hotkey's landing): open <-> close. */
109
+ toggle() {
110
+ if (this.dialogTarget.open) this.close()
111
+ else this.open()
112
+ }
113
+
114
+ /**
115
+ * Opens modally: showModal() (the platform trap, top-layer and focus
116
+ * return), the pair flip, and the body scroll lock. Returns the
117
+ * resulting state (what an agent tool reports back).
118
+ *
119
+ * @returns {{ open: boolean, changed: boolean }}
120
+ */
121
+ open() {
122
+ if (this.dialogTarget.open) return { open: true, changed: false }
123
+
124
+ this.dialogTarget.showModal()
125
+ setState(this.dialogTarget, "open")
126
+ this.lockScroll()
127
+
128
+ return { open: true, changed: true }
129
+ }
130
+
131
+ /**
132
+ * Closes with the presence hold: flips the pair to data-closed and
133
+ * holds the dialog through its CSS exit animation before the native
134
+ * close() and the unlock (synchronous when no exit animation applies).
135
+ * The native cancel event (Esc) routes through here so state stays in
136
+ * sync.
137
+ *
138
+ * @param {Event} [event] - the native cancel event, when Esc drove it
139
+ * @returns {{ open: boolean, changed: boolean }} the resulting state (what an agent tool reports back)
140
+ */
141
+ close(event) {
142
+ if (event?.type === "cancel") event.preventDefault() // route Esc through close() so state stays in sync
143
+ if (this.#closing || !this.dialogTarget.open) return { open: false, changed: false }
144
+
145
+ this.#closing = true
146
+ exitPresence(this.dialogTarget, {
147
+ onRemove: () => {
148
+ this.#closing = false
149
+ this.dialogTarget.close()
150
+ this.unlockScroll()
151
+ }
152
+ })
153
+
154
+ return { open: false, changed: true }
155
+ }
156
+
157
+ /**
158
+ * The click action discriminating backdrop presses: a backdrop click
159
+ * targets the <dialog> element itself AND lands outside its bounding
160
+ * rect (the backdrop is rendered by the dialog). The target check alone
161
+ * is not enough: clicks on the dialog's own padding / grid gaps also
162
+ * target the element (found in a live browser pass - they were
163
+ * incorrectly dismissing).
164
+ *
165
+ * @param {MouseEvent} event
166
+ */
167
+ backdropClose(event) {
168
+ if (!this.dismissibleValue) return
169
+ if (event.target !== this.dialogTarget) return
170
+
171
+ const rect = this.dialogTarget.getBoundingClientRect()
172
+ const inside = rect.top <= event.clientY && event.clientY <= rect.bottom &&
173
+ rect.left <= event.clientX && event.clientX <= rect.right
174
+ if (!inside) this.close()
175
+ }
176
+
177
+ // The descriptor grammar lives in helpers/hotkey.js (shared with the
178
+ // generic hotkey controller).
179
+ #matchesHotkey(event) {
180
+ return matchesHotkey(event, this.hotkeyValue)
181
+ }
182
+
183
+ /**
184
+ * Takes the shared refcounted body scroll lock (scrollbar-width
185
+ * compensated) - subclasses (sheet/drawer/sidebar) inherit these entry
186
+ * points unchanged; the instance flag keeps double-unlocks (disconnect
187
+ * after close) balanced.
188
+ */
189
+ lockScroll() {
190
+ if (this.#locked) return
191
+
192
+ this.#locked = true
193
+ lockScroll()
194
+ }
195
+
196
+ /** Balances {@link lockScroll}; safe when already unlocked. */
197
+ unlockScroll() {
198
+ if (!this.#locked) return
199
+
200
+ this.#locked = false
201
+ unlockScroll()
202
+ }
203
+
204
+ #locked = false
205
+ #closing = false
206
+ }
@@ -0,0 +1,182 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { onEscapeKeydown } from "@poetry/controllers/helpers/escape"
3
+ import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
4
+ import { flushPendingExits } from "@poetry/controllers/helpers/presence"
5
+
6
+ // The dismissal layer: Escape + pointerdown-outside for every
7
+ // overlay. This controller NEVER removes DOM - it dispatches "dismiss" and
8
+ // the consumer closes itself (removes the node, collapses a disclosure,
9
+ // navigates a frame). The class-level stack makes Esc topmost-only, so
10
+ // stacked overlays peel one at a time.
11
+ export default class DismissableController extends Controller {
12
+ // The events this controller dispatches (manifest surface;
13
+ // events_declaration.test.js enforces the list stays honest).
14
+ static events = [
15
+ "poetry--core--dismissable:dismiss", "poetry--core--dismissable:interact-outside"
16
+ ]
17
+
18
+ static stack = []
19
+
20
+ // The body pointer-events scrim (the CSS-only modal): refcounted across
21
+ // layers that request it, each such layer opting its own element back in.
22
+ static #scrimCount = 0
23
+ static #previousBodyPointerEvents = null
24
+
25
+ static values = {
26
+ disableOutsidePointerEvents: { type: Boolean, default: false }
27
+ }
28
+
29
+ // Pages cached BEFORE the before-cache teardown shipped carry a poisoned
30
+ // body (inline pointer-events:none serialized with no live scrim behind
31
+ // it) - and no layer connects on the restored page to release it. Heal
32
+ // on every Turbo render: with zero scrim holders, the style can only be
33
+ // a serialized leftover.
34
+ static {
35
+ if (typeof document !== "undefined") {
36
+ document.addEventListener("turbo:load", () => {
37
+ if (DismissableController.#scrimCount === 0 && document.body?.style.pointerEvents === "none") {
38
+ document.body.style.pointerEvents = ""
39
+ }
40
+ })
41
+ }
42
+ }
43
+
44
+ #unsubscribeEscape = null
45
+ #unsubscribeBeforeCache = null
46
+ #onPointerdown = (event) => this.#handlePointerdown(event)
47
+
48
+ /**
49
+ * Registers on the layer stack, wires the capture-phase Escape and
50
+ * pointerdown listeners, subscribes the before-cache dismissal, and
51
+ * takes the body scrim when configured.
52
+ */
53
+ connect() {
54
+ DismissableController.stack.push(this)
55
+
56
+ // Both capture-phase: the layer must see the interaction before any
57
+ // inner handler can swallow it (the escape helper defaults to capture).
58
+ this.#unsubscribeEscape = onEscapeKeydown((event) => this.#handleEscape(event))
59
+ document.addEventListener("pointerdown", this.#onPointerdown, { capture: true })
60
+ // A layer still open when Turbo snapshots serializes its scrim's
61
+ // inline body pointer-events into the cache - a restored page is
62
+ // click-dead. Dismiss synchronously so the snapshot is clean, and
63
+ // release the scrim HERE too: the owner's close resets its own
64
+ // attributes in the same tick, but its unmount (which normally
65
+ // releases the scrim via disconnect) can ride an exit transition -
66
+ // after the snapshot is already taken.
67
+ this.#unsubscribeBeforeCache = onBeforeCache(() => {
68
+ this.#dismiss(null)
69
+ // The dismiss just started the owner's exit presence - flush it in
70
+ // the same tick so hidden + layer-controller removal land BEFORE
71
+ // the snapshot (a serialized live layer resurrects on restore).
72
+ flushPendingExits()
73
+ this.#disableScrim()
74
+ })
75
+
76
+ if (this.disableOutsidePointerEventsValue) this.#enableScrim()
77
+ }
78
+
79
+ /**
80
+ * Leaves the stack and unwires everything, releasing the scrim when
81
+ * held.
82
+ */
83
+ disconnect() {
84
+ const index = DismissableController.stack.indexOf(this)
85
+
86
+ if (index !== -1) DismissableController.stack.splice(index, 1)
87
+
88
+ this.#unsubscribeEscape?.()
89
+ this.#unsubscribeEscape = null
90
+ document.removeEventListener("pointerdown", this.#onPointerdown, { capture: true })
91
+ this.#unsubscribeBeforeCache?.()
92
+ this.#unsubscribeBeforeCache = null
93
+
94
+ if (this.disableOutsidePointerEventsValue) this.#disableScrim()
95
+ }
96
+
97
+ #handleEscape(event) {
98
+ if (!this.#topmost()) return
99
+
100
+ this.#dismiss(event)
101
+ }
102
+
103
+ // pointerdown, not click: dismissal happens on press, so a drag
104
+ // that starts inside can never end up counting as outside.
105
+ #handlePointerdown(event) {
106
+ // A target already unhooked from the document (a Turbo morph or an
107
+ // earlier handler removed it mid-gesture) says nothing about WHERE the
108
+ // press landed - dismissing on it is the false-dismiss class.
109
+ if (!(event.target instanceof Element) || !event.target.isConnected) return
110
+
111
+ // composedPath, not contains(): the path is fixed at dispatch, so a
112
+ // press on a node an inner handler removes (a chip, a swapped row)
113
+ // still counts as inside - and shadow-DOM retargeting can't hide it.
114
+ const path = event.composedPath()
115
+
116
+ if (path.includes(this.element)) return
117
+ // A press inside a HIGHER layer (a nested overlay, often portaled out
118
+ // of this subtree) is inside the stack, not "outside" this layer.
119
+ if (this.#layersAbove().some((layer) => path.includes(layer.element))) return
120
+ // The top layer (toasts) sits above EVERY dismissal layer by fiat:
121
+ // pressing a toast must never dismiss the overlay under it.
122
+ if (event.target.closest("[data-poetry-top-layer]")) return
123
+
124
+ const interactOutside = this.dispatch("interact-outside", {
125
+ detail: { originalEvent: event },
126
+ cancelable: true
127
+ })
128
+
129
+ if (interactOutside.defaultPrevented) return // consumer veto: no dismiss
130
+
131
+ this.#dismiss(event)
132
+ }
133
+
134
+ #dismiss(originalEvent) {
135
+ this.dispatch("dismiss", { detail: { originalEvent } })
136
+ }
137
+
138
+ #topmost() {
139
+ return DismissableController.stack.at(-1) === this
140
+ }
141
+
142
+ #layersAbove() {
143
+ return DismissableController.stack.slice(DismissableController.stack.indexOf(this) + 1)
144
+ }
145
+
146
+ // Per-instance flag so enable/disable pair exactly once: the
147
+ // before-cache release and the later disconnect (after the owner's
148
+ // deferred unmount) must not double-decrement.
149
+ #scrimEnabled = false
150
+
151
+ #enableScrim() {
152
+ if (this.#scrimEnabled) return
153
+
154
+ this.#scrimEnabled = true
155
+
156
+ if (DismissableController.#scrimCount === 0) {
157
+ // Never save a value this scrim itself writes: a page restored from
158
+ // a cached snapshot arrives with the serialized "none" already on
159
+ // the body, and saving it would make every later restore re-freeze
160
+ // the page (the poisoned-previous restore class).
161
+ const current = document.body.style.pointerEvents
162
+ DismissableController.#previousBodyPointerEvents = current === "none" ? "" : current
163
+ document.body.style.pointerEvents = "none"
164
+ }
165
+
166
+ DismissableController.#scrimCount += 1
167
+ this.element.style.pointerEvents = "auto"
168
+ }
169
+
170
+ #disableScrim() {
171
+ if (!this.#scrimEnabled) return
172
+
173
+ this.#scrimEnabled = false
174
+ DismissableController.#scrimCount -= 1
175
+ this.element.style.pointerEvents = ""
176
+
177
+ if (DismissableController.#scrimCount === 0) {
178
+ document.body.style.pointerEvents = DismissableController.#previousBodyPointerEvents ?? ""
179
+ DismissableController.#previousBodyPointerEvents = null
180
+ }
181
+ }
182
+ }