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,419 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { directionOf } from "@poetry/controllers/helpers/direction"
3
+
4
+ // The Slider machine - the only form control with real math. Three concerns,
5
+ // nothing else:
6
+ //
7
+ // 1. THE VALUE MATH (the pure core, unit-tested exhaustively): snap to the
8
+ // step grid with decimal-precision rounding (a 0.1 grid lands on
9
+ // 0.3, never 0.30000000000000004), clamp to
10
+ // [min, max], and clamp against neighbor thumbs +- the min gap
11
+ // (minStepsBetweenThumbs * step) so range thumbs can never cross.
12
+ //
13
+ // 2. TWO INPUT PATHS feeding it: the APG keyboard map per thumb (arrows
14
+ // +-step, Shift+Arrow / PageUp / PageDown +-step*10, Home/End to the
15
+ // thumb's EFFECTIVE min/max) with the orientation x RTL x inverted
16
+ // resolution - horizontal RTL swaps Left/Right only, inverted flips the
17
+ // whole axis, both compose (rtl + inverted = ltr math); and pointer
18
+ // capture on the root - pointerdown jumps the NEAREST thumb (ties to
19
+ // the later index, so stacked thumbs stay separable), the track
20
+ // box is read ONCE at pointerdown (no per-frame layout), pointermove
21
+ // projects the value ABSOLUTELY from the pointer position (overshoot
22
+ // past a neighbor clamps, never swaps), pointerup commits.
23
+ //
24
+ // 3. THE DOM PROJECTION: aria-valuemin/max/now per role=slider thumb - the
25
+ // range bounds are DYNAMIC (neighbor-clamped, rewritten on every
26
+ // neighbor move: APG multithumb), the --slider-start/--slider-end
27
+ // geometry vars consumed by the calc() rules, and one hidden native
28
+ // input per thumb. poetry:slider:change fires per mutation; commit
29
+ // (pointerup / each keydown) syncs the
30
+ // hidden inputs + dispatches native input/change so Rails listeners get
31
+ // one event per gesture, not per frame.
32
+ const LARGE_STEP_MULTIPLIER = 10
33
+
34
+ export default class SliderController extends Controller {
35
+ // The events this controller dispatches (manifest surface;
36
+ // events_declaration.test.js enforces the list stays honest).
37
+ static events = ["poetry:slider:change", "poetry:slider:commit"]
38
+
39
+ static values = {
40
+ min: { type: Number, default: 0 },
41
+ max: { type: Number, default: 100 },
42
+ step: { type: Number, default: 1 },
43
+ value: { type: Array, default: [] },
44
+ minStepsBetweenThumbs: { type: Number, default: 0 },
45
+ orientation: { type: String, default: "horizontal" },
46
+ inverted: { type: Boolean, default: false }
47
+ }
48
+
49
+ static targets = ["track", "range", "thumb", "input"]
50
+
51
+ #values = []
52
+ #connected = false
53
+ #dragIndex = null
54
+ #trackRect = null
55
+ #gestureChanged = false
56
+
57
+ /**
58
+ * Adopts the value (the Value, else the server-rendered aria-valuenow),
59
+ * projects, and primes the hidden inputs silently.
60
+ */
61
+ connect() {
62
+ this.#values = this.valueValue.length > 0
63
+ ? this.valueValue.map(Number)
64
+ : this.#serverValues()
65
+
66
+ this.#project()
67
+ this.#syncInputs({ dispatch: false })
68
+ this.#connected = true
69
+ }
70
+
71
+ /** Ends any in-flight gesture without committing. */
72
+ disconnect() {
73
+ this.#connected = false
74
+ this.#endGesture({ commit: false })
75
+ }
76
+
77
+ /**
78
+ * Stimulus value callback - controllable state: a host (outlet / Turbo
79
+ * Stream) may own the value.
80
+ *
81
+ * @param {number[]} value
82
+ */
83
+ valueValueChanged(value) {
84
+ if (!this.#connected || this.#same(value)) return
85
+
86
+ this.#values = value.map(Number)
87
+ this.#project()
88
+ this.#syncInputs({ dispatch: false })
89
+ }
90
+
91
+ // --- the keyboard path (action: keydown->...#keydown on each thumb) ---
92
+
93
+ /**
94
+ * Each thumb's keydown action: the APG map (arrows step, Shift/Page =
95
+ * large step, Home/End to the thumb's EFFECTIVE bounds) under the
96
+ * orientation x RTL x inverted resolution; every keyboard change
97
+ * commits.
98
+ *
99
+ * @param {KeyboardEvent} event
100
+ */
101
+ keydown(event) {
102
+ if (this.#disabled()) return
103
+
104
+ const index = this.thumbTargets.indexOf(event.currentTarget)
105
+
106
+ if (index === -1) return
107
+
108
+ const candidate = this.#candidateFor(event, index)
109
+
110
+ if (candidate === null) return
111
+
112
+ event.preventDefault() // handled keys must not scroll the page
113
+
114
+ const changed = this.#update(index, candidate)
115
+
116
+ if (changed) this.#commit() // keyboard commits per keydown, contractual
117
+ }
118
+
119
+ // The APG map with the orientation x RTL x inverted resolution.
120
+ #candidateFor(event, index) {
121
+ switch (event.key) {
122
+ case "Home":
123
+ return this.#lowerBound(index)
124
+ case "End":
125
+ return this.#upperBound(index)
126
+ case "ArrowRight":
127
+ case "ArrowLeft":
128
+ case "ArrowUp":
129
+ case "ArrowDown":
130
+ case "PageUp":
131
+ case "PageDown": {
132
+ let sign = (event.key === "ArrowRight" || event.key === "ArrowUp" || event.key === "PageUp") ? 1 : -1
133
+
134
+ // Horizontal RTL swaps ONLY the Left/Right pair.
135
+ const horizontalPair = event.key === "ArrowRight" || event.key === "ArrowLeft"
136
+
137
+ if (horizontalPair && this.orientationValue === "horizontal" && directionOf(this.element) === "rtl") {
138
+ sign = -sign
139
+ }
140
+
141
+ // inverted flips the whole axis (increment keys decrement);
142
+ // composed with the RTL swap above, rtl + inverted cancels.
143
+ if (this.invertedValue) sign = -sign
144
+
145
+ const page = event.key === "PageUp" || event.key === "PageDown"
146
+ const multiplier = (page || event.shiftKey) ? LARGE_STEP_MULTIPLIER : 1
147
+
148
+ return this.#values[index] + sign * this.stepValue * multiplier
149
+ }
150
+ default:
151
+ return null
152
+ }
153
+ }
154
+
155
+ // --- the pointer path (action: pointerdown->...#pointerdown on the root) ---
156
+
157
+ /**
158
+ * The root's pointerdown action: jumps the NEAREST thumb to the pointer
159
+ * (ties to the later index), reads the track box once, and starts the
160
+ * window-level drag; pointerup commits.
161
+ *
162
+ * @param {PointerEvent} event
163
+ */
164
+ pointerdown(event) {
165
+ if (this.#disabled() || this.thumbTargets.length === 0) return
166
+
167
+ // The track box is read ONCE per gesture - no per-frame layout.
168
+ this.#trackRect = this.trackTarget.getBoundingClientRect()
169
+
170
+ const candidate = this.#valueAtPointer(event)
171
+ const index = this.#nearestThumb(candidate)
172
+
173
+ this.#dragIndex = index
174
+ this.#gestureChanged = false
175
+ this.element.setAttribute("data-dragging", "")
176
+ this.thumbTargets[index].setAttribute("data-dragging", "")
177
+ this.thumbTargets[index].focus() // drags announce on the focused thumb
178
+
179
+ if (typeof this.element.setPointerCapture === "function" && event.pointerId !== undefined) {
180
+ // NotFoundError when the pointer is already gone (fast-tap touch race:
181
+ // pointerup can beat the pointerdown action) - capture is an
182
+ // enhancement, the window listeners below carry the drag without it.
183
+ try { this.element.setPointerCapture(event.pointerId) } catch { /* stale pointerId */ }
184
+ }
185
+
186
+ window.addEventListener("pointermove", this.#onPointermove)
187
+ window.addEventListener("pointerup", this.#onPointerup)
188
+
189
+ event.preventDefault()
190
+
191
+ if (this.#update(index, candidate)) this.#gestureChanged = true
192
+ }
193
+
194
+ #onPointermove = (event) => {
195
+ if (this.#dragIndex === null) return
196
+
197
+ // Absolute projection each frame (no delta accumulation): overshooting
198
+ // a neighbor clamps at the gap and never swaps thumbs.
199
+ if (this.#update(this.#dragIndex, this.#valueAtPointer(event))) this.#gestureChanged = true
200
+ }
201
+
202
+ #onPointerup = () => {
203
+ this.#endGesture({ commit: true })
204
+ }
205
+
206
+ #endGesture({ commit }) {
207
+ window.removeEventListener("pointermove", this.#onPointermove)
208
+ window.removeEventListener("pointerup", this.#onPointerup)
209
+
210
+ if (this.#dragIndex === null) return
211
+
212
+ this.element.removeAttribute("data-dragging")
213
+ this.thumbTargets[this.#dragIndex]?.removeAttribute("data-dragging")
214
+
215
+ const changed = this.#gestureChanged
216
+
217
+ this.#dragIndex = null
218
+ this.#trackRect = null
219
+ this.#gestureChanged = false
220
+
221
+ if (commit && changed) this.#commit()
222
+ }
223
+
224
+ // Value at the pointer position: axis + direction + inversion aware.
225
+ #valueAtPointer(event) {
226
+ const rect = this.#trackRect ?? this.trackTarget.getBoundingClientRect()
227
+ let ratio
228
+
229
+ if (this.orientationValue === "vertical") {
230
+ // Vertical grows BOTTOM-up (APG); direction-neutral.
231
+ ratio = rect.height > 0 ? (rect.bottom - event.clientY) / rect.height : 0
232
+ } else {
233
+ ratio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0
234
+ if (directionOf(this.element) === "rtl") ratio = 1 - ratio
235
+ }
236
+
237
+ if (this.invertedValue) ratio = 1 - ratio
238
+
239
+ ratio = Math.min(Math.max(ratio, 0), 1)
240
+
241
+ return this.minValue + ratio * (this.maxValue - this.minValue)
242
+ }
243
+
244
+ // Nearest thumb; ties resolve to the LATER index (contractual - two
245
+ // thumbs stacked at min can both be picked up).
246
+ #nearestThumb(value) {
247
+ let nearest = 0
248
+
249
+ for (let index = 1; index < this.#values.length; index++) {
250
+ if (Math.abs(this.#values[index] - value) <= Math.abs(this.#values[nearest] - value)) nearest = index
251
+ }
252
+
253
+ return nearest
254
+ }
255
+
256
+ // --- the programmatic controllable-state surface ---
257
+
258
+ /**
259
+ * The programmatic controllable-state surface: must keep the thumb
260
+ * count; changes commit.
261
+ *
262
+ * @param {number | number[]} value
263
+ */
264
+ setValue(value) {
265
+ const values = (Array.isArray(value) ? value : [value]).map(Number)
266
+
267
+ if (values.length !== this.#values.length) {
268
+ console.warn("poetry--core--slider: setValue must keep the thumb count")
269
+ return
270
+ }
271
+
272
+ let changed = false
273
+
274
+ for (let index = 0; index < values.length; index++) {
275
+ if (this.#update(index, values[index])) changed = true
276
+ }
277
+
278
+ if (changed) this.#commit()
279
+ }
280
+
281
+ // --- the pure math core ---
282
+
283
+ // snap -> clamp [min, max] -> clamp against neighbors +- the min gap.
284
+ #update(index, candidate) {
285
+ const next = this.#clampToBounds(index, this.#snap(candidate))
286
+
287
+ if (next === this.#values[index]) return false
288
+
289
+ this.#values[index] = next
290
+ this.#project()
291
+ this.dispatch("change", { prefix: "poetry:slider", detail: { value: [...this.#values], index } })
292
+
293
+ return true
294
+ }
295
+
296
+ // Snap to the step grid anchored at min, with decimal-precision
297
+ // rounding: count the step's decimals, round to
298
+ // that precision - floating-point accumulation never reaches the DOM.
299
+ #snap(value) {
300
+ const steps = Math.round((value - this.minValue) / this.stepValue)
301
+
302
+ return this.#round(this.minValue + steps * this.stepValue)
303
+ }
304
+
305
+ #round(value) {
306
+ return Number(value.toFixed(this.#precision()))
307
+ }
308
+
309
+ #precision() {
310
+ return Math.max(this.#decimals(this.stepValue), this.#decimals(this.minValue))
311
+ }
312
+
313
+ #decimals(value) {
314
+ const text = String(value)
315
+ const point = text.indexOf(".")
316
+
317
+ return point === -1 ? 0 : text.length - point - 1
318
+ }
319
+
320
+ #clampToBounds(index, value) {
321
+ return Math.min(Math.max(value, this.#lowerBound(index)), this.#upperBound(index))
322
+ }
323
+
324
+ // The thumb's EFFECTIVE bounds (APG multithumb: the high thumb's min is
325
+ // the low thumb's value + the gap).
326
+ #lowerBound(index) {
327
+ if (index === 0) return this.minValue
328
+
329
+ return this.#round(this.#values[index - 1] + this.#gap())
330
+ }
331
+
332
+ #upperBound(index) {
333
+ if (index === this.#values.length - 1) return this.maxValue
334
+
335
+ return this.#round(this.#values[index + 1] - this.#gap())
336
+ }
337
+
338
+ #gap() {
339
+ return this.minStepsBetweenThumbsValue * this.stepValue
340
+ }
341
+
342
+ // --- the DOM projection ---
343
+
344
+ // aria-value* per thumb (range bounds neighbor-clamped, rewritten on
345
+ // EVERY move - the cross-thumb rewrite AT depends on) + the geometry vars.
346
+ #project() {
347
+ this.thumbTargets.forEach((thumb, index) => {
348
+ if (index >= this.#values.length) return
349
+
350
+ thumb.setAttribute("aria-valuemin", String(this.#lowerBound(index)))
351
+ thumb.setAttribute("aria-valuemax", String(this.#upperBound(index)))
352
+ thumb.setAttribute("aria-valuenow", String(this.#values[index]))
353
+ })
354
+
355
+ const start = this.#values.length > 1 ? this.#percent(this.#values[0]) : 0
356
+ const end = this.#percent(this.#values[this.#values.length - 1])
357
+
358
+ this.element.style.setProperty("--slider-start", `${start}%`)
359
+ this.element.style.setProperty("--slider-end", `${end}%`)
360
+
361
+ // Middle thumbs (N-thumb sliders) position via their own anchor var.
362
+ const anchors = this.element.querySelectorAll('[data-slot="slider-anchor"]')
363
+ this.#values.forEach((value, index) => {
364
+ if (index === 0 || index === this.#values.length - 1) return
365
+
366
+ anchors[index]?.style.setProperty("--slider-mid", `${this.#percent(value)}%`)
367
+ })
368
+ }
369
+
370
+ #percent(value) {
371
+ const span = this.maxValue - this.minValue
372
+
373
+ return span > 0 ? ((value - this.minValue) / span) * 100 : 0
374
+ }
375
+
376
+ // Commit (once per gesture / per keydown): hidden inputs sync + native
377
+ // input/change dispatch + the commit event; the Value mirrors for the
378
+ // controllable-state surface.
379
+ #commit() {
380
+ this.#syncInputs({ dispatch: true })
381
+ this.valueValue = [...this.#values] // #same() guards the changed callback
382
+ this.dispatch("commit", { prefix: "poetry:slider", detail: { value: [...this.#values] } })
383
+ }
384
+
385
+ #syncInputs({ dispatch }) {
386
+ this.inputTargets.forEach((input, index) => {
387
+ if (index >= this.#values.length) return
388
+
389
+ const next = String(this.#values[index])
390
+
391
+ if (input.value === next) return
392
+
393
+ input.value = next
394
+
395
+ if (dispatch) {
396
+ input.dispatchEvent(new Event("input", { bubbles: true }))
397
+ input.dispatchEvent(new Event("change", { bubbles: true }))
398
+ }
399
+ })
400
+ }
401
+
402
+ // Server-rendered fallback: thumbs carry aria-valuenow; else one thumb at min.
403
+ #serverValues() {
404
+ const values = this.thumbTargets
405
+ .map((thumb) => Number(thumb.getAttribute("aria-valuenow")))
406
+ .filter((value) => !Number.isNaN(value))
407
+
408
+ return values.length > 0 ? values : [this.minValue]
409
+ }
410
+
411
+ #same(values) {
412
+ return values.length === this.#values.length &&
413
+ values.every((value, index) => Number(value) === this.#values[index])
414
+ }
415
+
416
+ #disabled() {
417
+ return this.element.hasAttribute("data-disabled")
418
+ }
419
+ }
@@ -0,0 +1,82 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { setState, stateOf } from "@poetry/controllers/helpers/state"
3
+ import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
4
+
5
+ // The controllable-state controller: seeds the data-open/
6
+ // data-closed pair from a Value default when no other layer owns it, and
7
+ // exposes toggle/open/close actions. "Controlled vs uncontrolled" is just which layer wrote the
8
+ // attribute - a server re-render, the URL, an Outlet, or this default -
9
+ // the controller code is identical either way.
10
+ //
11
+ // Optional reflection targets (the Collapsible contract): a trigger
12
+ // target mirrors aria-expanded; a content target rides the presence
13
+ // helper (the pair flip deferred through animationend, hidden applied
14
+ // only after the exit animation finishes).
15
+ export default class extends Controller {
16
+ static targets = ["trigger", "content"]
17
+ static values = { state: { type: String, default: "closed" } }
18
+
19
+ /**
20
+ * Seeds the pair from the Value default when no other layer wrote it,
21
+ * then reflects the current state onto the optional targets.
22
+ */
23
+ connect() {
24
+ if (!stateOf(this.element)) setState(this.element, this.stateValue)
25
+ this.#reflect(stateOf(this.element))
26
+ }
27
+
28
+ /** Abandons any exit animation still holding the content. */
29
+ disconnect() {
30
+ this.#cancelExit?.()
31
+ }
32
+
33
+ /** The toggle action: open when closed, close when open. */
34
+ toggle() {
35
+ stateOf(this.element) === "open" ? this.close() : this.open()
36
+ }
37
+
38
+ /**
39
+ * Opens: flips the pair, unhides the content target (when present) and
40
+ * runs its entry presence, mirrors aria-expanded onto the trigger.
41
+ */
42
+ open() {
43
+ setState(this.element, "open")
44
+ this.#cancelExit?.()
45
+ if (this.hasContentTarget) {
46
+ this.contentTarget.hidden = false
47
+ enterPresence(this.contentTarget)
48
+ }
49
+ this.#reflect("open")
50
+ }
51
+
52
+ /**
53
+ * Closes: flips the pair and holds the content target through its exit
54
+ * animation before hiding it (the presence contract).
55
+ */
56
+ close() {
57
+ setState(this.element, "closed")
58
+ if (this.hasContentTarget) {
59
+ this.#cancelExit = exitPresence(this.contentTarget, {
60
+ onRemove: () => {
61
+ this.contentTarget.hidden = true
62
+ this.#cancelExit = null
63
+ }
64
+ })
65
+ }
66
+ this.#reflect("closed")
67
+ }
68
+
69
+ #cancelExit
70
+
71
+ #reflect(state) {
72
+ const open = state === "open"
73
+ if (this.hasTriggerTarget) {
74
+ this.triggerTarget.setAttribute("aria-expanded", String(open))
75
+ // Disclosure-trigger reflection: the trigger wears bare
76
+ // data-panel-open while its panel is open (no CSS consumes it yet -
77
+ // the aria-expanded selector styles the chevron).
78
+ setState(this.triggerTarget, open ? "panel-open" : "panel-closed")
79
+ }
80
+ if (this.hasContentTarget && !open && !this.#cancelExit) this.contentTarget.hidden = true
81
+ }
82
+ }
@@ -0,0 +1,153 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { announce } from "@poetry/controllers/helpers/announce"
3
+
4
+ // The table row-selection engine (the SelectionManager
5
+ // contract, checkbox-flavored): per-row checkboxes are the form value
6
+ // (selected_ids[] - no JS means plain checkboxes in a form, the honest
7
+ // fallback), this controller adds what HTML cannot:
8
+ //
9
+ // - select-all with a real INDETERMINATE middle state (a JS property,
10
+ // never an attribute), computed over ENABLED rows only (disabled rows
11
+ // are skipped by select-all and ranges - disabledBehavior 'selection').
12
+ // - Shift-click range selection off the ANCHOR model: the
13
+ // anchor is the last plainly-toggled row; Shift sets every row in
14
+ // anchor..target to the ANCHOR row's state (the checkbox idiom).
15
+ // - aria-selected + data-selected mirrored onto rows, count announcements
16
+ // through the announce singleton, and a bubbling selection-change event
17
+ // the ActionBar block feeds on.
18
+ // - poetry:data-table:clear-selection (dispatched by the ActionBar's
19
+ // Escape) clears everything from anywhere inside the wrapper.
20
+ const EVENT_PREFIX = "poetry:data-table"
21
+
22
+ export default class TableSelectionController extends Controller {
23
+ // The events this controller dispatches (manifest surface;
24
+ // events_declaration.test.js enforces the list stays honest).
25
+ static events = ["poetry:data-table:selection-change"]
26
+
27
+ static targets = ["all"]
28
+ static values = {
29
+ label: { type: String, default: "%{count} selected" }
30
+ }
31
+
32
+ #anchor = null
33
+ #onClear = () => this.#clearAll()
34
+
35
+ /** Wires the ActionBar's clear-selection listener and reflects silently. */
36
+ connect() {
37
+ this.element.addEventListener("poetry:data-table:clear-selection", this.#onClear)
38
+ this.#reflect({ announceCount: false })
39
+ }
40
+
41
+ /** Unwires the clear-selection listener. */
42
+ disconnect() {
43
+ this.element.removeEventListener("poetry:data-table:clear-selection", this.#onClear)
44
+ }
45
+
46
+ /**
47
+ * Each row checkbox's change action. Shift ranges ride the click that
48
+ * produced the change (change events carry no modifiers - press
49
+ * captures the gesture): a shift-toggle sets anchor..target to the
50
+ * ANCHOR row's state; a plain toggle re-anchors.
51
+ *
52
+ * @param {Event} event
53
+ */
54
+ toggled(event) {
55
+ const checkbox = event.target
56
+
57
+ if (this.#shiftRange && this.#anchor && this.#anchor !== checkbox) {
58
+ this.#applyRange(this.#anchor, checkbox)
59
+ } else {
60
+ this.#anchor = checkbox
61
+ }
62
+
63
+ this.#shiftRange = false
64
+ this.#reflect()
65
+ }
66
+
67
+ /**
68
+ * The pointerdown/keydown seam: remembers whether the NEXT change was
69
+ * a shift-gesture (change events themselves carry no modifiers).
70
+ *
71
+ * @param {PointerEvent | KeyboardEvent} event
72
+ */
73
+ press(event) {
74
+ this.#shiftRange = event.shiftKey === true
75
+ }
76
+
77
+ /**
78
+ * The select-all checkbox's change action: fans its new state out to
79
+ * every ENABLED row and clears the anchor.
80
+ *
81
+ * @param {Event} event
82
+ */
83
+ toggleAll(event) {
84
+ const target = event.target.checked
85
+
86
+ for (const checkbox of this.#rowCheckboxes()) {
87
+ if (checkbox.disabled) continue
88
+
89
+ checkbox.checked = target
90
+ }
91
+
92
+ this.#anchor = null
93
+ this.#reflect()
94
+ }
95
+
96
+ #shiftRange = false
97
+
98
+ #applyRange(anchor, target) {
99
+ const boxes = this.#rowCheckboxes()
100
+ const from = boxes.indexOf(anchor)
101
+ const to = boxes.indexOf(target)
102
+
103
+ if (from === -1 || to === -1) return
104
+
105
+ const state = anchor.checked
106
+
107
+ for (const checkbox of boxes.slice(Math.min(from, to), Math.max(from, to) + 1)) {
108
+ if (checkbox.disabled) continue
109
+
110
+ checkbox.checked = state
111
+ }
112
+ }
113
+
114
+ #clearAll() {
115
+ for (const checkbox of this.#rowCheckboxes()) checkbox.checked = false
116
+
117
+ this.#anchor = null
118
+ this.#reflect()
119
+ }
120
+
121
+ #reflect({ announceCount = true } = {}) {
122
+ const boxes = this.#rowCheckboxes()
123
+ const enabled = boxes.filter((checkbox) => !checkbox.disabled)
124
+ const selected = boxes.filter((checkbox) => checkbox.checked)
125
+
126
+ for (const checkbox of boxes) {
127
+ const row = checkbox.closest("tr")
128
+
129
+ if (!row) continue
130
+
131
+ row.toggleAttribute("data-selected", checkbox.checked)
132
+ row.setAttribute("aria-selected", String(checkbox.checked))
133
+ }
134
+
135
+ if (this.hasAllTarget) {
136
+ this.allTarget.checked = enabled.length > 0 && selected.length === enabled.length
137
+ this.allTarget.indeterminate = selected.length > 0 && selected.length < enabled.length
138
+ }
139
+
140
+ const count = selected.length
141
+
142
+ if (announceCount) announce(this.labelValue.replace("%{count}", String(count)))
143
+
144
+ this.dispatch("selection-change", {
145
+ prefix: EVENT_PREFIX,
146
+ detail: { count, values: selected.map((checkbox) => checkbox.value) }
147
+ })
148
+ }
149
+
150
+ #rowCheckboxes() {
151
+ return Array.from(this.element.querySelectorAll("[data-slot='data-table-select-row']"))
152
+ }
153
+ }