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,404 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // NumberField: a formatted
4
+ // visible <input type=text> over a hidden <input type=number> that is the
5
+ // form/validation truth - the spinbutton ARIA pattern is deliberately
6
+ // not used. The controller owns stepping (arrows, steppers with
7
+ // press-and-hold, opt-in wheel), parse/clamp/format, and the two-input
8
+ // sync. Number | null value model: empty is null, never NaN.
9
+ //
10
+ // Deliberate v1 boundaries (documented in the component):
11
+ // no scrub area, Latin-digit parsing only (locale separators and
12
+ // currency/percent symbols ARE handled via Intl.formatToParts), and the
13
+ // server renders the raw number - the display formats on connect.
14
+ const START_AUTO_CHANGE_DELAY = 400
15
+ const CHANGE_VALUE_TICK_DELAY = 60
16
+
17
+ export default class extends Controller {
18
+ static targets = ["input", "hidden", "increment", "decrement"]
19
+
20
+ static values = {
21
+ min: Number,
22
+ max: Number,
23
+ step: { type: Number, default: 1 },
24
+ largeStep: { type: Number, default: 10 },
25
+ smallStep: { type: Number, default: 0.1 },
26
+ snap: Boolean,
27
+ wheel: Boolean,
28
+ format: Object,
29
+ locale: String
30
+ }
31
+
32
+ static events = ["poetry:number-field:change", "poetry:number-field:commit"]
33
+
34
+ #value = null
35
+ #focusedOnce = false
36
+ #holdTimer = null
37
+ #holdInterval = null
38
+ #holdChanged = false
39
+ #pressTicked = false
40
+ #onWheel = null
41
+ #onRelease = null
42
+
43
+ /**
44
+ * Adopts the hidden value, paints the formatted display, reflects the
45
+ * boundaries, and wires the opt-in wheel plus the window-level release
46
+ * listeners.
47
+ */
48
+ connect() {
49
+ this.#value = this.#fromHidden()
50
+ if (this.#value !== null) this.inputTarget.value = this.#format(this.#value)
51
+ this.#reflect()
52
+ if (this.wheelValue) {
53
+ this.#onWheel = (event) => this.#wheel(event)
54
+ this.inputTarget.addEventListener("wheel", this.#onWheel, { passive: false })
55
+ }
56
+ // Releases outside the button (or after a boundary disables it) must
57
+ // still stop the repeat and commit - window-level, like slider drags.
58
+ this.#onRelease = () => this.#endHold()
59
+ window.addEventListener("pointerup", this.#onRelease)
60
+ window.addEventListener("pointercancel", this.#onRelease)
61
+ }
62
+
63
+ /** Unwires the wheel/release listeners and stops any repeat. */
64
+ disconnect() {
65
+ if (this.#onWheel) this.inputTarget.removeEventListener("wheel", this.#onWheel)
66
+ window.removeEventListener("pointerup", this.#onRelease)
67
+ window.removeEventListener("pointercancel", this.#onRelease)
68
+ this.#stopRepeat()
69
+ }
70
+
71
+ // --- input events -------------------------------------------------------
72
+
73
+ /**
74
+ * The visible input's keydown action: ArrowUp/Down step (Shift = large
75
+ * step, Alt = small), Home/End jump to the bounds, and printable keys
76
+ * pass the character gate.
77
+ *
78
+ * @param {KeyboardEvent} event
79
+ */
80
+ keydown(event) {
81
+ if (this.inputTarget.readOnly) return
82
+
83
+ switch (event.key) {
84
+ case "ArrowUp":
85
+ case "ArrowDown":
86
+ event.preventDefault()
87
+ this.#step(event.key === "ArrowUp" ? 1 : -1, event)
88
+ this.#commit()
89
+ return
90
+ case "Home":
91
+ if (!this.hasMinValue) return
92
+ event.preventDefault()
93
+ this.#apply(this.minValue, { display: true })
94
+ this.#commit()
95
+ return
96
+ case "End":
97
+ if (!this.hasMaxValue) return
98
+ event.preventDefault()
99
+ this.#apply(this.maxValue, { display: true })
100
+ this.#commit()
101
+ return
102
+ default:
103
+ this.#gate(event)
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The input action: text updates freely; the value goes live only
109
+ * while parseable. The display is NEVER rewritten mid-typing
110
+ * (dirty-text authority).
111
+ */
112
+ input() {
113
+ const text = this.inputTarget.value
114
+ if (text.trim() === "") {
115
+ this.#apply(null, { display: false })
116
+ return
117
+ }
118
+ const parsed = this.#parse(text)
119
+ if (parsed !== null) this.#apply(parsed, { display: false })
120
+ }
121
+
122
+ /** The focus action: the first focus parks the caret at the end. */
123
+ focus() {
124
+ if (this.#focusedOnce) return
125
+
126
+ this.#focusedOnce = true
127
+ const end = this.inputTarget.value.length
128
+ this.inputTarget.setSelectionRange(end, end)
129
+ }
130
+
131
+ /**
132
+ * The blur action - the text commit point: empty clears, unparseable
133
+ * text is left as typed with no commit, parseable text clamps and
134
+ * normalizes to the canonical formatted display (never snapped to
135
+ * step - blur is not a correction gesture).
136
+ */
137
+ blur() {
138
+ const text = this.inputTarget.value
139
+ if (text.trim() === "") {
140
+ if (this.#value !== null || text !== "") this.#apply(null, { display: true })
141
+ this.#commit()
142
+ return
143
+ }
144
+ const parsed = this.#parse(text)
145
+ if (parsed === null) return
146
+
147
+ this.#apply(this.#clamp(parsed), { display: true })
148
+ this.#commit()
149
+ }
150
+
151
+ // --- steppers -----------------------------------------------------------
152
+
153
+ /**
154
+ * Each stepper's pointerdown action: steps once, then repeats after the
155
+ * hold delay (mouse focuses the input; touch would pop the software
156
+ * keyboard).
157
+ *
158
+ * @param {PointerEvent} event
159
+ */
160
+ press(event) {
161
+ if (event.button !== 0 || this.inputTarget.readOnly) return
162
+
163
+ // Mouse focuses the input (touch would pop the software keyboard).
164
+ if (event.pointerType !== "touch") this.inputTarget.focus()
165
+ this.#pressTicked = true
166
+ this.#holdChanged = false
167
+ const direction = this.#directionFor(event.currentTarget)
168
+ if (!this.#tick(direction, event)) return
169
+
170
+ this.#holdTimer = setTimeout(() => {
171
+ this.#holdInterval = setInterval(() => {
172
+ if (!this.#tick(direction, event)) this.#stopRepeat()
173
+ }, CHANGE_VALUE_TICK_DELAY)
174
+ }, START_AUTO_CHANGE_DELAY)
175
+ }
176
+
177
+ /**
178
+ * The stepper's click action: quick touch taps synthesize click
179
+ * without a held pointer - step once, but never double-step after a
180
+ * handled pointerdown.
181
+ *
182
+ * @param {MouseEvent} event
183
+ */
184
+ tap(event) {
185
+ if (this.#pressTicked) {
186
+ this.#pressTicked = false
187
+ return
188
+ }
189
+ if (this.inputTarget.readOnly) return
190
+
191
+ this.#step(this.#directionFor(event.currentTarget), event)
192
+ this.#commit()
193
+ }
194
+
195
+ /** The stepper's pointerleave action: stops the repeat. */
196
+ leave() {
197
+ this.#stopRepeat()
198
+ }
199
+
200
+ // --- mechanics ----------------------------------------------------------
201
+
202
+ #endHold() {
203
+ this.#stopRepeat()
204
+ if (this.#holdChanged) this.#commit()
205
+ this.#holdChanged = false
206
+ }
207
+
208
+ #stopRepeat() {
209
+ clearTimeout(this.#holdTimer)
210
+ clearInterval(this.#holdInterval)
211
+ this.#holdTimer = null
212
+ this.#holdInterval = null
213
+ }
214
+
215
+ #tick(direction, event) {
216
+ // Disabled/readonly mid-hold (a server morph can do this): stop the
217
+ // repeat rather than mutating a control the user can no longer reach.
218
+ if (this.inputTarget.disabled || this.inputTarget.readOnly) return false
219
+
220
+ const before = this.#value
221
+ this.#step(direction, event)
222
+ const changed = this.#value !== before
223
+ this.#holdChanged ||= changed
224
+ return changed
225
+ }
226
+
227
+ #directionFor(button) {
228
+ return this.hasIncrementTarget && button === this.incrementTarget ? 1 : -1
229
+ }
230
+
231
+ #step(direction, event) {
232
+ const amount = event.shiftKey ? this.largeStepValue : (event.altKey ? this.smallStepValue : this.stepValue)
233
+ // Stepping starts from the dirty typed text when parseable (what the
234
+ // user sees), else the committed value, else seeds 0 clamped in range
235
+ // (an all-negative range seeds at max - the nearest representable).
236
+ const base = this.#parse(this.inputTarget.value) ?? this.#value
237
+ const candidate = base === null
238
+ ? this.#clamp(0)
239
+ : this.#clamp(this.#maybeSnap(this.#clean(base + direction * amount), direction, event))
240
+ this.#apply(candidate, { display: true })
241
+ }
242
+
243
+ #wheel(event) {
244
+ if (event.ctrlKey || document.activeElement !== this.inputTarget) return
245
+
246
+ event.preventDefault()
247
+ if (event.deltaY === 0) return
248
+
249
+ this.#step(event.deltaY > 0 ? -1 : 1, event)
250
+ this.#commit()
251
+ }
252
+
253
+ // Snap to step multiples from base min (or 0): directional for normal /
254
+ // large steps, nearest for the Alt small step.
255
+ #maybeSnap(value, direction, event) {
256
+ if (!this.snapValue) return value
257
+
258
+ const base = this.hasMinValue ? this.minValue : 0
259
+ const steps = (value - base) / this.stepValue
260
+ const snapped = event.altKey
261
+ ? Math.round(steps)
262
+ : (direction > 0 ? Math.floor(steps) : Math.ceil(steps))
263
+ return this.#clean(base + snapped * this.stepValue)
264
+ }
265
+
266
+ #clamp(value) {
267
+ let clamped = value
268
+ if (this.hasMinValue) clamped = Math.max(clamped, this.minValue)
269
+ if (this.hasMaxValue) clamped = Math.min(clamped, this.maxValue)
270
+ return clamped
271
+ }
272
+
273
+ // Binary float noise cleanup (0.1 + 0.2 -> 0.3), bounded so genuine
274
+ // digits in large values are never rewritten (toPrecision(15) plus an
275
+ // absolute 1e-10 delta cap).
276
+ #clean(value) {
277
+ const cleaned = Number(value.toPrecision(15))
278
+ return Math.abs(cleaned - value) < 1e-10 ? cleaned : value
279
+ }
280
+
281
+ #apply(value, { display }) {
282
+ const changed = value !== this.#value
283
+ this.#value = value
284
+ if (display) this.inputTarget.value = value === null ? "" : this.#format(value)
285
+ this.hiddenTarget.value = value === null ? "" : String(value)
286
+ this.#reflect()
287
+ if (changed) {
288
+ this.dispatch("change", { prefix: "poetry:number-field", detail: { value } })
289
+ }
290
+ }
291
+
292
+ #commit() {
293
+ this.dispatch("commit", { prefix: "poetry:number-field", detail: { value: this.#value } })
294
+ }
295
+
296
+ // Boundary + filled reflection: steppers disable at their bound (native
297
+ // disabled + data-disabled, written together); the root mirrors filled.
298
+ #reflect() {
299
+ const filled = this.#value !== null
300
+ this.element.toggleAttribute("data-filled", filled)
301
+ this.#reflectButton(this.hasIncrementTarget && this.incrementTarget,
302
+ filled && this.hasMaxValue && this.#value >= this.maxValue)
303
+ this.#reflectButton(this.hasDecrementTarget && this.decrementTarget,
304
+ filled && this.hasMinValue && this.#value <= this.minValue)
305
+ }
306
+
307
+ #reflectButton(button, atBoundary) {
308
+ if (!button) return
309
+
310
+ const off = atBoundary || this.element.hasAttribute("data-disabled")
311
+ button.disabled = off
312
+ button.toggleAttribute("data-disabled", off)
313
+ }
314
+
315
+ // --- parse / format -----------------------------------------------------
316
+
317
+ #fromHidden() {
318
+ const raw = this.hiddenTarget.value.trim()
319
+ if (raw === "") return null
320
+
321
+ const parsed = Number(raw)
322
+ return Number.isFinite(parsed) ? parsed : null
323
+ }
324
+
325
+ #formatter() {
326
+ if (!this.hasFormatValue || Object.keys(this.formatValue).length === 0) return null
327
+
328
+ this.cachedFormatter ||= new Intl.NumberFormat(
329
+ this.hasLocaleValue && this.localeValue !== "" ? this.localeValue : undefined,
330
+ this.formatValue
331
+ )
332
+ return this.cachedFormatter
333
+ }
334
+
335
+ #format(value) {
336
+ const formatter = this.#formatter()
337
+ return formatter ? formatter.format(value) : String(value)
338
+ }
339
+
340
+ // The format's own emitted characters (group/decimal separators,
341
+ // currency/percent symbols) - derived once from formatToParts so parsing
342
+ // and keydown gating accept exactly what formatting produces.
343
+ #formatChars() {
344
+ if (this.cachedFormatChars) return this.cachedFormatChars
345
+
346
+ const chars = { group: new Set(), decimal: ".", symbols: new Set(), percent: false }
347
+ const formatter = this.#formatter()
348
+ if (formatter) {
349
+ chars.percent = this.formatValue.style === "percent"
350
+ for (const sample of [-11222333.45, 11222333.45]) {
351
+ for (const part of formatter.formatToParts(sample)) {
352
+ if (part.type === "group") [...part.value].forEach((c) => chars.group.add(c))
353
+ if (part.type === "decimal") chars.decimal = part.value
354
+ if (["currency", "percentSign", "literal", "unit"].includes(part.type)) {
355
+ [...part.value].forEach((c) => chars.symbols.add(c))
356
+ }
357
+ }
358
+ }
359
+ }
360
+ this.cachedFormatChars = chars
361
+ return chars
362
+ }
363
+
364
+ #parse(text) {
365
+ const chars = this.#formatChars()
366
+ let cleaned = ""
367
+ for (const c of text.trim()) {
368
+ if (chars.group.has(c) || chars.symbols.has(c)) continue
369
+ cleaned += c === chars.decimal ? "." : c
370
+ }
371
+ cleaned = cleaned.replace(/[−‒–]/g, "-").replace(/\s/g, "")
372
+ if (cleaned === "" || cleaned === "-") return null
373
+
374
+ const parsed = Number(cleaned)
375
+ if (!Number.isFinite(parsed)) return null
376
+
377
+ return chars.percent ? this.#clean(parsed / 100) : parsed
378
+ }
379
+
380
+ // Keydown character gate: digits, the format's own symbols, minus only
381
+ // when the range admits negatives, one decimal separator. Navigation
382
+ // keys, shortcuts, and IME (229) always pass.
383
+ #gate(event) {
384
+ if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey || event.which === 229) return
385
+
386
+ const c = event.key
387
+ const chars = this.#formatChars()
388
+ if (/[0-9]/.test(c) || chars.group.has(c) || chars.symbols.has(c)) return
389
+ if ((c === chars.decimal || c === ".") && !this.inputTarget.value.includes(chars.decimal)) return
390
+ if (c === "-" && (!this.hasMinValue || this.minValue < 0) && !this.inputTarget.value.includes("-")) return
391
+
392
+ event.preventDefault()
393
+ }
394
+
395
+ /**
396
+ * The hidden input's change action: browser autofill lands there -
397
+ * adopt, clamp, commit.
398
+ */
399
+ hiddenChanged() {
400
+ const adopted = this.#fromHidden()
401
+ this.#apply(adopted === null ? null : this.#clamp(adopted), { display: true })
402
+ this.#commit()
403
+ }
404
+ }
@@ -0,0 +1,56 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Optimistic UI for Turbo forms, adapted from an MIT-licensed source
4
+ // (source and license in THIRD_PARTY_NOTICES.md).
5
+ // The optimistic update is AUTHORED AS A TURBO STREAM inside a <template>
6
+ // target - the same vocabulary the server answers in, so prediction and
7
+ // truth share one mental model and there is no bespoke DOM patching. On
8
+ // turbo:submit-start the template contents are cloned into the document
9
+ // and Turbo paints the predicted state immediately; on turbo:submit-end
10
+ // the controller reconciles ONLY when the submission failed, by
11
+ // appending <turbo-stream action="refresh"> - which must morph (the
12
+ // helper documents the turbo-refresh-method=morph meta) so the
13
+ // correction is seamless.
14
+ //
15
+ // THE SERVER CONTRACT (enforced nowhere, so stated everywhere): success
16
+ // answers 204 (or a targeted stream for authoritative correction under
17
+ // contention) - NEVER a redirect, because a redirect under morph
18
+ // refreshes is itself a full reload and defeats the optimism. Failure
19
+ // answers 4xx so event.detail.success is false and the refresh restores
20
+ // authoritative truth. Full story: the Optimistic Forms guide on the poetry docs site.
21
+ export default class OptimisticFormController extends Controller {
22
+ static targets = ["template"]
23
+
24
+ /**
25
+ * Applies every template's stream(s) - the turbo:submit-start action.
26
+ * Rapid resubmits inside the window cannot stack duplicate clones; the
27
+ * first paint is still immediate.
28
+ */
29
+ apply() {
30
+ const now = Date.now()
31
+ if (this.lastAppliedAt && now - this.lastAppliedAt < 200) return
32
+ this.lastAppliedAt = now
33
+
34
+ this.templateTargets.forEach((template) => {
35
+ document.body.appendChild(template.content.cloneNode(true))
36
+ })
37
+ }
38
+
39
+ /**
40
+ * The turbo:submit-end action: appends the authoritative refresh stream
41
+ * ONLY when the submission failed (the body comment holds the rule).
42
+ *
43
+ * @param {CustomEvent} event - detail.success decides
44
+ */
45
+ reconcile(event) {
46
+ // On success the optimistic paint already reflects the new state (or
47
+ // the server's targeted stream corrected it); only a rejection needs
48
+ // the authoritative refresh.
49
+ if (event.detail?.success) return
50
+
51
+ document.body.insertAdjacentHTML(
52
+ "beforeend",
53
+ '<turbo-stream action="refresh"></turbo-stream>'
54
+ )
55
+ }
56
+ }
@@ -0,0 +1,219 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The InputOTP projection (the single-input architecture, poetry's own
4
+ // build - no npm dependency): ONE real native <input> holds the whole
5
+ // value, stretched invisibly over the slot row, so paste, SMS autofill,
6
+ // IME, constraint validation and form serialization are all native and AT
7
+ // sees ONE text field. The n slot cells are an aria-hidden MIRROR painted
8
+ // here - this controller contains ZERO editing logic:
9
+ //
10
+ // - #sync filters input.value per-character through the pattern + truncates
11
+ // to length (writing back only when filtering changed it - that one line
12
+ // IS paste splitting: "123-456" under digits becomes "123456"), paints
13
+ // slot[i] with value[i], and projects the caret: the active cell is
14
+ // min(selectionStart, length - 1) while focused (data-active), with the
15
+ // fake blinking caret element visible only on the active EMPTY cell.
16
+ // - Auto-advance and backspace-retreat are not features: typing moves the
17
+ // native caret forward, Backspace moves it back - the active cell is a
18
+ // PROJECTION of selectionStart. Arrows/Home/End/IME: native, re-projected
19
+ // via the document-level selectionchange listener (bound on focus,
20
+ // unbound on blur - n OTP fields must not all re-project on every caret
21
+ // move anywhere).
22
+ // - poetry:otp:change fires per accepted mutation; poetry:otp:complete
23
+ // fires once when the value reaches length and re-arms below it (the
24
+ // enable-the-submit-button hook - it never submits).
25
+ export default class OtpController extends Controller {
26
+ // The events this controller dispatches (manifest surface;
27
+ // events_declaration.test.js enforces the list stays honest).
28
+ static events = ["poetry:otp:change", "poetry:otp:complete"]
29
+
30
+ static values = {
31
+ length: { type: Number, default: 6 },
32
+ pattern: { type: String, default: "\\d" }
33
+ }
34
+
35
+ static targets = ["input", "slot"]
36
+
37
+ #regex = null
38
+ #lastValue = null
39
+ #completed = false
40
+ #selectionListener = null
41
+
42
+ /**
43
+ * Compiles the pattern, adopts the input's value, and paints the
44
+ * initial projection (no events dispatched).
45
+ */
46
+ connect() {
47
+ this.#regex = this.#compile()
48
+ this.#lastValue = this.inputTarget.value
49
+ this.#completed = this.inputTarget.value.length >= this.lengthValue
50
+ this.#sync({ dispatch: false })
51
+ }
52
+
53
+ /** Unbinds the document-level selectionchange listener. */
54
+ disconnect() {
55
+ this.#unbindSelection()
56
+ }
57
+
58
+ /**
59
+ * The native input's input / focus / blur action (one handler - the
60
+ * projection is idempotent; blur clears the active cell; focus binds
61
+ * the selectionchange re-projection).
62
+ *
63
+ * @param {Event} [event]
64
+ */
65
+ sync(event) {
66
+ if (event?.type === "focus") this.#bindSelection()
67
+ if (event?.type === "blur") this.#unbindSelection()
68
+
69
+ this.#sync()
70
+ }
71
+
72
+ /**
73
+ * The paste action. Paste is the one native path maxlength breaks: the
74
+ * browser truncates the RAW clipboard text to maxlength before any
75
+ * input event, so "123-456" loses its tail before the sync pass could
76
+ * filter the dashes (real Chrome; jsdom doesn't enforce maxlength,
77
+ * which is why the unit tier never saw it). Intercept, filter FIRST,
78
+ * splice at the selection.
79
+ *
80
+ * @param {ClipboardEvent} event
81
+ */
82
+ paste(event) {
83
+ const text = event.clipboardData?.getData("text") ?? ""
84
+
85
+ event.preventDefault()
86
+
87
+ const input = this.inputTarget
88
+ const accepted = Array.from(text).filter((char) => this.#regex.test(char)).join("")
89
+ const start = input.selectionStart ?? input.value.length
90
+ const end = input.selectionEnd ?? start
91
+ const next = (input.value.slice(0, start) + accepted + input.value.slice(end))
92
+ .slice(0, this.lengthValue)
93
+
94
+ input.value = next
95
+
96
+ const caret = Math.min(start + accepted.length, this.lengthValue)
97
+
98
+ input.setSelectionRange?.(caret, caret)
99
+ this.#sync()
100
+ }
101
+
102
+ /**
103
+ * The container's click action (gaps, separators - the input already
104
+ * covers the cells at z-20): focuses the real control.
105
+ */
106
+ focusInput() {
107
+ if (this.inputTarget.disabled) return
108
+
109
+ this.inputTarget.focus()
110
+ }
111
+
112
+ // --- the projection ---
113
+
114
+ #sync({ dispatch = true } = {}) {
115
+ const input = this.inputTarget
116
+ const raw = input.value
117
+ const filtered = Array.from(raw)
118
+ .filter((char) => this.#regex.test(char))
119
+ .join("")
120
+ .slice(0, this.lengthValue)
121
+
122
+ // Write back only when filtering changed it (paste splitting +
123
+ // pattern rejection); the caret lands after the accepted text.
124
+ if (filtered !== raw) {
125
+ input.value = filtered
126
+
127
+ const caret = Math.min(input.selectionStart ?? filtered.length, filtered.length)
128
+
129
+ input.setSelectionRange?.(caret, caret)
130
+ }
131
+
132
+ this.#paint(filtered)
133
+ this.#projectCaret(filtered)
134
+
135
+ if (!dispatch) return
136
+
137
+ if (filtered !== this.#lastValue) {
138
+ this.#lastValue = filtered
139
+
140
+ const complete = filtered.length >= this.lengthValue
141
+
142
+ this.dispatch("change", { prefix: "poetry:otp", detail: { value: filtered, complete } })
143
+
144
+ // complete fires ONCE per rise to full length; editing below re-arms.
145
+ if (complete && !this.#completed) {
146
+ this.#completed = true
147
+ this.dispatch("complete", { prefix: "poetry:otp", detail: { value: filtered } })
148
+ } else if (!complete) {
149
+ this.#completed = false
150
+ }
151
+ }
152
+ }
153
+
154
+ // slot[i] shows value[i] - painted as the slot's leading text node so the
155
+ // caret element child survives (textContent would wipe it).
156
+ #paint(value) {
157
+ this.slotTargets.forEach((slot, index) => {
158
+ const char = value[index] ?? ""
159
+ let node = slot.firstChild
160
+
161
+ if (!node || node.nodeType !== Node.TEXT_NODE) {
162
+ node = document.createTextNode("")
163
+ slot.prepend(node)
164
+ }
165
+
166
+ node.data = char
167
+ })
168
+ }
169
+
170
+ // Exactly one data-active cell while focused: the caret's cell, clamped
171
+ // to the last slot when the value is complete. The fake caret is visible
172
+ // only when the active cell is EMPTY (a filled active cell shows the
173
+ // ring alone).
174
+ #projectCaret(value) {
175
+ const focused = document.activeElement === this.inputTarget
176
+ const active = focused
177
+ ? Math.min(this.inputTarget.selectionStart ?? value.length, this.lengthValue - 1)
178
+ : -1
179
+
180
+ this.slotTargets.forEach((slot, index) => {
181
+ const isActive = index === active
182
+
183
+ slot.setAttribute("data-active", String(isActive))
184
+
185
+ const caret = slot.querySelector("[data-slot=input-otp-caret]")
186
+
187
+ if (caret) caret.hidden = !(isActive && (value[index] ?? "") === "")
188
+ })
189
+ }
190
+
191
+ // selectionchange is DOCUMENT-level: bind on focus, unbind on blur (the
192
+ // leak guard) - arrows/Home/End re-project without any input event.
193
+ #bindSelection() {
194
+ if (this.#selectionListener) return
195
+
196
+ this.#selectionListener = () => {
197
+ if (document.activeElement === this.inputTarget) this.#sync()
198
+ }
199
+ document.addEventListener("selectionchange", this.#selectionListener)
200
+ }
201
+
202
+ #unbindSelection() {
203
+ if (!this.#selectionListener) return
204
+
205
+ document.removeEventListener("selectionchange", this.#selectionListener)
206
+ this.#selectionListener = null
207
+ }
208
+
209
+ // The per-character filter: the pattern value is a regex SOURCE tested
210
+ // one char at a time (anchored so multi-char patterns can't smuggle).
211
+ #compile() {
212
+ try {
213
+ return new RegExp(`^(?:${this.patternValue})$`)
214
+ } catch {
215
+ console.warn(`poetry--core--otp: invalid pattern ${this.patternValue}; falling back to digits`)
216
+ return /^\d$/
217
+ }
218
+ }
219
+ }