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,365 @@
1
+ import DialogController from "@poetry/controllers/dialog_controller"
2
+ import { isImeKeydown } from "@poetry/controllers/helpers/escape"
3
+ import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
4
+
5
+ // The Drawer: the dialog machinery + the swipe-to-dismiss gesture.
6
+ // Everything hard about the OVERLAY is inherited (native <dialog> platform
7
+ // trap, backdrop-click discrimination, scroll lock, hotkey); this subclass
8
+ // adds the two things a drawer is:
9
+ //
10
+ // * ANIMATED presence - the first consumer of the presence helpers:
11
+ // enter rides the data-starting-style two-frame trick (a transition
12
+ // from --closed-transform), exit HOLDS the dialog through the
13
+ // data-ending-style transition before the native close() (the
14
+ // presence-hold close the sheet styling depends on).
15
+ // * The SWIPE - pointer-captured drag along the dismiss direction,
16
+ // writing the swipe CSS-var contract onto the <dialog> (::backdrop
17
+ // inherits from its originating element, so the overlay fade rides the
18
+ // same vars): --drawer-swipe-movement-x/y (px toward dismissal),
19
+ // --drawer-swipe-progress (0..1), data-swiping while tracking
20
+ // (duration-0 - the drawer follows the finger), and on release either
21
+ // a snap-back or a dismissal whose exit duration scales with
22
+ // --drawer-swipe-strength (mostly-swiped closes fast).
23
+ //
24
+ // Deferred with the rest of the stack machinery (see the Drawer style):
25
+ // snap points, nested-drawer stacking, and bleed.
26
+ const SWIPE_SLOP = 4 // px before a drag counts (clicks stay clicks)
27
+ const DISMISS_PROGRESS = 0.5 // released past halfway -> dismiss
28
+ const DISMISS_VELOCITY = 0.5 // px/ms toward dismissal -> flick-dismiss
29
+ const MIN_STRENGTH = 0.25 // even a full swipe animates the remainder briefly
30
+
31
+ const INTERACTIVE = "button, a[href], input, select, textarea, [contenteditable], [role=button]"
32
+ const HANDLE = '[data-slot="drawer-swipe-handle"]'
33
+
34
+ export default class DrawerController extends DialogController {
35
+ static values = {
36
+ // The dismissal direction.
37
+ direction: { type: String, default: "down" },
38
+ // modal: false opens with show() instead of
39
+ // showModal() - no top layer, no ::backdrop, no focus trap, no
40
+ // scroll lock. The page behind stays fully interactive.
41
+ modal: { type: Boolean, default: true },
42
+ // snapPoints: preset resting heights for a bottom
43
+ // sheet (direction down only), ascending: fractions of the full
44
+ // height (0..1] or CSS lengths ("31rem", "400px"). The popup runs
45
+ // full-height (the dictionary's data-snap-points sizing) and
46
+ // --drawer-snap-point-offset hides the rest; opens at the first
47
+ // point, drags move between points, below the first dismisses.
48
+ snapPoints: { type: Array, default: [] }
49
+ }
50
+
51
+ #swipe = null
52
+ #closing = false
53
+ #snapIndex = 0
54
+
55
+ /**
56
+ * Opens per the modal value: showModal() plus the scroll lock, or the
57
+ * non-modal show() (no top layer - the component pins the panel; the
58
+ * body comment). The first snap offset lands BEFORE the enter
59
+ * transition starts, then the animated entry runs.
60
+ */
61
+ open() {
62
+ if (this.modalValue) {
63
+ this.dialogTarget.showModal()
64
+ this.lockScroll()
65
+ } else {
66
+ // show() skips the top layer, so the UA :modal positioning is
67
+ // gone too - the component pins the panel with fixed/inset
68
+ // classes instead. close() stays balanced: unlockScroll no-ops
69
+ // through the instance #locked flag when nothing was locked.
70
+ this.dialogTarget.show()
71
+ }
72
+ // The first snap offset must be in place BEFORE the enter transition
73
+ // starts, so the sheet slides up to its compact peek, not to full.
74
+ if (this.#snapping()) this.#applySnap(0)
75
+ enterPresence(this.dialogTarget)
76
+ }
77
+
78
+ /**
79
+ * The non-modal Escape exit (the component wires this keydown action
80
+ * only when modal is false): a non-modal dialog never fires cancel, so
81
+ * Esc needs its own path while focus is inside; modal drawers ride the
82
+ * native cancel.
83
+ *
84
+ * @param {KeyboardEvent} event
85
+ */
86
+ escapeClose(event) {
87
+ if (this.modalValue) return
88
+ // isImeKeydown: an Escape canceling IME composition must never dismiss.
89
+ if (event.key !== "Escape" || event.defaultPrevented || isImeKeydown(event)) return
90
+
91
+ event.preventDefault()
92
+ this.close()
93
+ }
94
+
95
+ /**
96
+ * Closes through the animated exit (data-ending-style), then the
97
+ * native close(), the unlock, and the swipe-var reset. The native
98
+ * cancel event (Esc) routes through here so state stays in sync.
99
+ *
100
+ * @param {Event} [event] - the native cancel event, when Esc drove it
101
+ */
102
+ close(event) {
103
+ if (event?.type === "cancel") event.preventDefault() // route Esc through the animated path
104
+ if (this.#closing) return
105
+
106
+ this.#closing = true
107
+ exitPresence(this.dialogTarget, {
108
+ onRemove: () => {
109
+ this.#closing = false
110
+ this.#resetSwipeVars()
111
+ this.dialogTarget.close()
112
+ this.unlockScroll()
113
+ }
114
+ })
115
+ }
116
+
117
+ // --- the swipe -----------------------------------------------------------
118
+
119
+ /**
120
+ * The <dialog>'s pointerdown action: arms the swipe - always from the
121
+ * handle; elsewhere only when the press is not on a control and no
122
+ * scrollable child still scrolls toward the gesture.
123
+ *
124
+ * @param {PointerEvent} event
125
+ */
126
+ swipeStart(event) {
127
+ if (event.button !== 0 && event.pointerType === "mouse") return
128
+ if (this.#closing) return
129
+
130
+ const target = event.target instanceof Element ? event.target : null
131
+ if (!target) return
132
+
133
+ // A drag may start on the handle ALWAYS; elsewhere it must not steal
134
+ // from controls or from content that still scrolls toward the gesture.
135
+ if (!target.closest(HANDLE)) {
136
+ if (target.closest(INTERACTIVE)) return
137
+ if (this.#scrollBlocks(target)) return
138
+ }
139
+
140
+ this.#swipe = {
141
+ pointerId: event.pointerId,
142
+ startX: event.clientX, startY: event.clientY,
143
+ lastPosition: this.#along(event), lastTime: event.timeStamp,
144
+ velocity: 0, movement: 0, dragging: false
145
+ }
146
+ this.dialogTarget.setPointerCapture(event.pointerId)
147
+ }
148
+
149
+ /**
150
+ * The captured pointermove action: past the slop, tracks the drag -
151
+ * writing the swipe CSS vars (the finger-following, duration-0 window)
152
+ * and the release velocity.
153
+ *
154
+ * @param {PointerEvent} event
155
+ */
156
+ swipeMove(event) {
157
+ const swipe = this.#swipe
158
+ if (!swipe || event.pointerId !== swipe.pointerId) return
159
+
160
+ const start = this.#axis() === "y" ? swipe.startY : swipe.startX
161
+ // Snap-pointed sheets drag BOTH ways: negative movement (up to the
162
+ // current offset) expands toward fuller points. Plain drawers keep
163
+ // the toward-dismissal clamp.
164
+ const floor = this.#snapping() ? -this.#currentSnapOffset() : 0
165
+ const movement = Math.max(floor, this.#sign() * (this.#along(event) - start))
166
+
167
+ if (!swipe.dragging) {
168
+ if (Math.abs(movement) < SWIPE_SLOP) return
169
+
170
+ swipe.dragging = true
171
+ this.dialogTarget.setAttribute("data-swiping", "")
172
+ }
173
+
174
+ const elapsed = event.timeStamp - swipe.lastTime
175
+ if (elapsed > 0) {
176
+ swipe.velocity = (this.#sign() * (this.#along(event) - swipe.lastPosition)) / elapsed
177
+ swipe.lastPosition = this.#along(event)
178
+ swipe.lastTime = event.timeStamp
179
+ }
180
+
181
+ swipe.movement = movement
182
+ this.#writeSwipeVars(movement)
183
+ }
184
+
185
+ /**
186
+ * The pointerup action - release physics: dismiss past half the travel
187
+ * or on a flick (exit duration scaled by the remaining travel), else
188
+ * snap back; snap-pointed sheets settle between their points instead.
189
+ *
190
+ * @param {PointerEvent} event
191
+ */
192
+ swipeEnd(event) {
193
+ const swipe = this.#swipe
194
+ if (!swipe || event.pointerId !== swipe.pointerId) return
195
+
196
+ this.#swipe = null
197
+ if (!swipe.dragging) return
198
+ if (this.#snapping()) return this.#settleSnap(swipe)
199
+
200
+ const progress = swipe.movement / this.#size()
201
+
202
+ if (progress >= DISMISS_PROGRESS || swipe.velocity >= DISMISS_VELOCITY) {
203
+ // The exit transition covers the REMAINING travel - scale it so a
204
+ // mostly-swiped drawer closes fast (the strength contract).
205
+ const strength = Math.max(MIN_STRENGTH, Math.min(1, 1 - progress))
206
+ this.dialogTarget.style.setProperty("--drawer-swipe-strength", String(strength))
207
+ this.dialogTarget.removeAttribute("data-swiping")
208
+ this.close()
209
+ } else {
210
+ // Snap back: re-enable the transition FIRST, then move home.
211
+ this.dialogTarget.removeAttribute("data-swiping")
212
+ requestAnimationFrame(() => this.#writeSwipeVars(0))
213
+ }
214
+ }
215
+
216
+ /**
217
+ * The pointercancel action: abandons the drag and snaps home.
218
+ *
219
+ * @param {PointerEvent} event
220
+ */
221
+ swipeCancel(event) {
222
+ const swipe = this.#swipe
223
+ if (!swipe || event.pointerId !== swipe.pointerId) return
224
+
225
+ this.#swipe = null
226
+ this.dialogTarget.removeAttribute("data-swiping")
227
+ this.#writeSwipeVars(0)
228
+ }
229
+
230
+ // --- snap points -----------------------------------------------------------
231
+
232
+ #snapping() {
233
+ return this.directionValue === "down" && this.snapPointsValue.length > 0
234
+ }
235
+
236
+ // A point is a fraction of the full (100dvh) height, or a CSS length
237
+ // in px/rem (e.g. ["31rem", 1]).
238
+ #snapVisible(point, height) {
239
+ if (typeof point === "number") return point * height
240
+
241
+ const value = parseFloat(point)
242
+ if (String(point).endsWith("rem")) {
243
+ const rootSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
244
+ return value * rootSize
245
+ }
246
+ return value
247
+ }
248
+
249
+ // Hidden-height offsets, one per point: index 0 (the compact peek)
250
+ // hides the most; the last point shows the most.
251
+ #snapOffsets() {
252
+ const height = this.#size()
253
+ return this.snapPointsValue.map((point) => {
254
+ return Math.max(0, height - Math.min(height, this.#snapVisible(point, height)))
255
+ })
256
+ }
257
+
258
+ #currentSnapOffset() {
259
+ return parseFloat(this.dialogTarget.style.getPropertyValue("--drawer-snap-point-offset")) || 0
260
+ }
261
+
262
+ #applySnap(index) {
263
+ this.#snapIndex = index
264
+ this.dialogTarget.style.setProperty("--drawer-snap-point-offset", `${this.#snapOffsets()[index]}px`)
265
+ }
266
+
267
+ // Release physics between points: a flick moves one point in its
268
+ // direction (past the first point -> dismiss); a slow release settles
269
+ // on the nearest point, or dismisses once it sits past half the
270
+ // compact peek's visible height (the plain drawer's threshold, taken
271
+ // against the peek rather than the full sheet).
272
+ #settleSnap(swipe) {
273
+ const height = this.#size()
274
+ const offsets = this.#snapOffsets()
275
+ const position = this.#currentSnapOffset() + swipe.movement
276
+ const peekVisible = Math.max(1, height - offsets[0])
277
+ let target
278
+
279
+ if (Math.abs(swipe.velocity) >= DISMISS_VELOCITY) {
280
+ target = this.#snapIndex + (swipe.velocity > 0 ? -1 : 1)
281
+ } else {
282
+ target = offsets.reduce((best, offset, index) => {
283
+ return Math.abs(position - offset) < Math.abs(position - offsets[best]) ? index : best
284
+ }, 0)
285
+ if (position - offsets[0] >= peekVisible * DISMISS_PROGRESS) target = -1
286
+ }
287
+
288
+ this.dialogTarget.removeAttribute("data-swiping")
289
+ if (target < 0) {
290
+ const progress = Math.min(1, Math.max(0, (position - offsets[0]) / peekVisible))
291
+ const strength = Math.max(MIN_STRENGTH, Math.min(1, 1 - progress))
292
+ this.dialogTarget.style.setProperty("--drawer-swipe-strength", String(strength))
293
+ this.close()
294
+ } else {
295
+ const index = Math.min(target, offsets.length - 1)
296
+ requestAnimationFrame(() => {
297
+ this.#applySnap(index)
298
+ this.#writeSwipeVars(0)
299
+ })
300
+ }
301
+ }
302
+
303
+ // --- geometry --------------------------------------------------------------
304
+
305
+ #axis() {
306
+ return this.directionValue === "left" || this.directionValue === "right" ? "x" : "y"
307
+ }
308
+
309
+ // +1 when dismissal increases the coordinate (down/right), -1 otherwise.
310
+ #sign() {
311
+ return this.directionValue === "down" || this.directionValue === "right" ? 1 : -1
312
+ }
313
+
314
+ #along(event) {
315
+ return this.#axis() === "y" ? event.clientY : event.clientX
316
+ }
317
+
318
+ #size() {
319
+ const dialog = this.dialogTarget
320
+ return Math.max(1, this.#axis() === "y" ? dialog.offsetHeight : dialog.offsetWidth)
321
+ }
322
+
323
+ #writeSwipeVars(movement) {
324
+ const dialog = this.dialogTarget
325
+ dialog.style.setProperty(`--drawer-swipe-movement-${this.#axis()}`, `${movement}px`)
326
+ // Progress measures travel toward DISMISSAL only: for snap-pointed
327
+ // sheets that travel starts at the compact peek (expanded positions
328
+ // clamp to 0, so the backdrop never fades while snapping fuller);
329
+ // for plain drawers the base is 0 and this stays movement / size.
330
+ const dismissBase = this.#snapping() ? this.#snapOffsets()[0] : 0
331
+ const travel = Math.max(1, this.#size() - dismissBase)
332
+ const progress = (this.#currentSnapOffset() + movement - dismissBase) / travel
333
+ dialog.style.setProperty("--drawer-swipe-progress", String(Math.min(1, Math.max(0, progress))))
334
+ }
335
+
336
+ #resetSwipeVars() {
337
+ const dialog = this.dialogTarget
338
+ dialog.style.removeProperty("--drawer-swipe-movement-x")
339
+ dialog.style.removeProperty("--drawer-swipe-movement-y")
340
+ dialog.style.removeProperty("--drawer-swipe-progress")
341
+ dialog.style.removeProperty("--drawer-swipe-strength")
342
+ dialog.style.removeProperty("--drawer-snap-point-offset")
343
+ dialog.removeAttribute("data-swiping")
344
+ }
345
+
346
+ // Content that can still scroll TOWARD the gesture owns the pointer: a
347
+ // down-swipe must not fire while a scrollable child is scrolled down
348
+ // (the user is scrolling back up), and symmetrically for the others.
349
+ #scrollBlocks(target) {
350
+ for (let node = target; node && node !== this.dialogTarget; node = node.parentElement) {
351
+ if (this.#axis() === "y") {
352
+ if (node.scrollHeight > node.clientHeight) {
353
+ if (this.directionValue === "down" && node.scrollTop > 0) return true
354
+ if (this.directionValue === "up" &&
355
+ node.scrollTop + node.clientHeight < node.scrollHeight) return true
356
+ }
357
+ } else if (node.scrollWidth > node.clientWidth) {
358
+ if (this.directionValue === "right" && node.scrollLeft > 0) return true
359
+ if (this.directionValue === "left" &&
360
+ node.scrollLeft + node.clientWidth < node.scrollWidth) return true
361
+ }
362
+ }
363
+ return false
364
+ }
365
+ }
@@ -0,0 +1,191 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The FileInput dropzone engine: the
4
+ // component renders a <label> wrapping a visually-hidden native
5
+ // <input type=file>, so click-to-browse and keyboard access are the
6
+ // PLATFORM's - this controller adds only what HTML cannot: drag-and-drop
7
+ // onto the label, the selected-file list, and the clear affordance. The
8
+ // native input stays the single source of truth (its FileList is the
9
+ // form value; ActiveStorage direct upload rides it untouched).
10
+ //
11
+ // Drag state uses an enter/leave COUNTER: dragenter/dragleave fire per
12
+ // descendant crossed, so a boolean flickers over child elements - the
13
+ // counter nets out to zero only when the pointer truly leaves the zone.
14
+ const EVENT_PREFIX = "poetry:file-input"
15
+
16
+ const UNITS = ["B", "KB", "MB", "GB"]
17
+
18
+ export default class FileInputController extends Controller {
19
+ // The events this controller dispatches (manifest surface;
20
+ // events_declaration.test.js enforces the list stays honest).
21
+ static events = ["poetry:file-input:change"]
22
+
23
+ static targets = ["input", "list", "clear"]
24
+ static values = {
25
+ multiple: { type: Boolean, default: false }
26
+ }
27
+
28
+ #dragDepth = 0
29
+
30
+ /** Reflects the input's current FileList (list, clear, data-populated). */
31
+ connect() {
32
+ this.#reflect()
33
+ }
34
+
35
+ // Actions on the dropzone label: dragenter/dragover/dragleave/drop.
36
+ /**
37
+ * File drags flip the zone into its accepting state - depth-counted
38
+ * (the header explains the counter).
39
+ *
40
+ * @param {DragEvent} event
41
+ */
42
+ dragenter(event) {
43
+ if (!this.#accepting(event)) return
44
+
45
+ event.preventDefault()
46
+ this.#dragDepth += 1
47
+ this.element.setAttribute("data-dragging", "")
48
+ }
49
+
50
+ /**
51
+ * The required preventDefault - without it the browser navigates to
52
+ * the dropped file.
53
+ *
54
+ * @param {DragEvent} event
55
+ */
56
+ dragover(event) {
57
+ if (!this.#accepting(event)) return
58
+
59
+ event.preventDefault() // required, or the browser navigates to the file
60
+ }
61
+
62
+ /** Nets the depth counter down; at zero the drag styling clears. */
63
+ dragleave() {
64
+ this.#dragDepth = Math.max(0, this.#dragDepth - 1)
65
+ if (this.#dragDepth === 0) this.element.removeAttribute("data-dragging")
66
+ }
67
+
68
+ /**
69
+ * Assigns the dropped files to the native input (single-file zones take
70
+ * the FIRST - the native picker's rule) and dispatches a real change so
71
+ * every listener sees a picker-identical selection.
72
+ *
73
+ * @param {DragEvent} event
74
+ */
75
+ drop(event) {
76
+ if (!this.#accepting(event)) return
77
+
78
+ event.preventDefault()
79
+ this.#dragDepth = 0
80
+ this.element.removeAttribute("data-dragging")
81
+
82
+ const files = event.dataTransfer?.files
83
+
84
+ if (!files || files.length === 0) return
85
+
86
+ this.inputTarget.files = this.#normalized(files)
87
+ // The programmatic assignment fires no native change - dispatch it so
88
+ // form frameworks (and this controller's own change action) see the
89
+ // selection exactly as a picker selection.
90
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
91
+ }
92
+
93
+ /**
94
+ * The native input's change action (picker AND drop land here):
95
+ * reflects the selection and reports the file names.
96
+ */
97
+ changed() {
98
+ this.#reflect()
99
+ this.dispatch("change", {
100
+ prefix: EVENT_PREFIX,
101
+ detail: { names: this.#files().map((file) => file.name) }
102
+ })
103
+ }
104
+
105
+ /**
106
+ * The clear button's click action: empties the input and dispatches a
107
+ * real change.
108
+ *
109
+ * @param {MouseEvent} event
110
+ */
111
+ clear(event) {
112
+ // The clear button sits INSIDE the label: without this, the click
113
+ // also re-opens the file picker.
114
+ event.preventDefault()
115
+ event.stopPropagation()
116
+ this.inputTarget.value = ""
117
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
118
+ }
119
+
120
+ #files() {
121
+ return Array.from(this.inputTarget.files ?? [])
122
+ }
123
+
124
+ // A drag from another application may carry no files (text, links) -
125
+ // only file drags flip the zone into its accepting state.
126
+ #accepting(event) {
127
+ if (this.inputTarget.disabled) return false
128
+
129
+ const types = event.dataTransfer?.types
130
+
131
+ return Boolean(types && Array.from(types).includes("Files"))
132
+ }
133
+
134
+ // Single-file zones take the FIRST dropped file (the native picker's
135
+ // rule). Built through a fresh DataTransfer where the platform provides
136
+ // one; without it (older engines, some test DOMs) the multi-drop falls
137
+ // back to the full list rather than throwing.
138
+ #normalized(files) {
139
+ if (this.multipleValue || files.length <= 1) return files
140
+ if (typeof DataTransfer !== "function") return files
141
+
142
+ try {
143
+ const transfer = new DataTransfer()
144
+ transfer.items.add(files[0])
145
+ return transfer.files
146
+ } catch {
147
+ return files
148
+ }
149
+ }
150
+
151
+ // The selected-file list is BUILT, never innerHTML'd (names are user
152
+ // input); data-populated on the root is the styling hook for the
153
+ // empty/filled swap.
154
+ #reflect() {
155
+ const files = this.#files()
156
+
157
+ this.element.toggleAttribute("data-populated", files.length > 0)
158
+ if (this.hasClearTarget) this.clearTarget.hidden = files.length === 0
159
+ if (!this.hasListTarget) return
160
+
161
+ this.listTarget.textContent = ""
162
+ for (const file of files) {
163
+ const item = document.createElement("li")
164
+
165
+ item.setAttribute("data-slot", "file-input-item")
166
+ const name = document.createElement("span")
167
+
168
+ name.setAttribute("data-slot", "file-input-item-name")
169
+ name.textContent = file.name
170
+ const size = document.createElement("span")
171
+
172
+ size.setAttribute("data-slot", "file-input-item-size")
173
+ size.textContent = this.#formatSize(file.size)
174
+ item.append(name, size)
175
+ this.listTarget.append(item)
176
+ }
177
+ }
178
+
179
+ #formatSize(bytes) {
180
+ if (!Number.isFinite(bytes) || bytes < 0) return ""
181
+
182
+ let value = bytes
183
+ let unit = 0
184
+
185
+ while (value >= 1024 && unit < UNITS.length - 1) {
186
+ value /= 1024
187
+ unit += 1
188
+ }
189
+ return `${unit === 0 ? value : value.toFixed(1)} ${UNITS[unit]}`
190
+ }
191
+ }