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,584 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import {
3
+ IncompleteDate, PAGE_STEP, resolveHourCycle
4
+ } from "@poetry/controllers/helpers/incomplete_date"
5
+
6
+ // The segmented date/time editor. The component renders a real native
7
+ // <input type=date|time> that IS the form value (no JS = native pickers;
8
+ // its ISO value format is exactly the wire contract), plus an empty group.
9
+ // This controller builds per-segment role=spinbutton spans from
10
+ // Intl.DateTimeFormat.formatToParts - so the LOCALE decides segment order
11
+ // and literals - and syncs segments -> native input eagerly whenever the
12
+ // value is complete and valid, constraining on blur (February 31st is
13
+ // representable mid-edit; commit clamps).
14
+ //
15
+ // Hour is stored in the locale's hour cycle with a separate dayPeriod
16
+ // segment; the cycle itself is INFERRED by formatting (two Intl bugs make
17
+ // resolvedOptions() untrustworthy - helpers/incomplete_date.js).
18
+ //
19
+ // Latin-digit typing only (the NumberField documented divergence); the
20
+ // DISPLAY uses Intl.NumberFormat, so locales that render non-latin digits
21
+ // still see their own numerals.
22
+ const EVENT_PREFIX = "poetry:date-field"
23
+
24
+ // iPadOS 13+ reports as Mac; maxTouchPoints tells them apart.
25
+ const isApplePlatform = () => /iP(hone|ad|od)/.test(navigator.userAgent) ||
26
+ (/Mac/.test(navigator.userAgent) && navigator.maxTouchPoints > 1)
27
+
28
+ const EDITABLE = {
29
+ date: ["year", "month", "day"],
30
+ time: ["hour", "minute", "second", "dayPeriod"]
31
+ }
32
+
33
+ export default class DateFieldController extends Controller {
34
+ // The events this controller dispatches (manifest surface;
35
+ // events_declaration.test.js enforces the list stays honest).
36
+ static events = ["poetry:date-field:change"]
37
+
38
+ static targets = ["input", "group"]
39
+ static values = {
40
+ locale: { type: String, default: "" },
41
+ hourCycle: { type: String, default: "" }, // optional pin: h11|h12|h23|h24
42
+ seconds: { type: Boolean, default: false },
43
+ placeholder: { type: String, default: "" }, // ISO seed for arrow fills
44
+ labels: { type: Object, default: {} }, // segment-name fallbacks (i18n)
45
+ placeholders: { type: Object, default: {} } // segment placeholder text (i18n)
46
+ }
47
+
48
+ #value = null // IncompleteDate
49
+ #segments = [] // [{ element, type }]
50
+ #segmentDigits = {} // per-type display width, measured from the locale
51
+ #entered = "" // digit accumulator for the focused segment
52
+ #compositionSnapshot = null
53
+ #dayPeriodNames = null // { am, pm }
54
+ #onSelectionChange = () => this.#collapseSelectionInSegment()
55
+
56
+ /**
57
+ * Builds the locale-driven segment row from formatToParts, marks the
58
+ * field enhanced, retires the native input from the tab order (it stays
59
+ * the form value and the constraint-validation carrier), and routes
60
+ * native invalid to the first empty segment.
61
+ */
62
+ connect() {
63
+ if (!this.hasInputTarget || !this.hasGroupTarget) return
64
+
65
+ this.#value = new IncompleteDate(this.#resolvedHourCycle())
66
+ this.#readInput()
67
+ this.#buildSegments()
68
+ this.element.setAttribute("data-enhanced", "")
69
+ // The native input leaves the tab order (segments are the surface)
70
+ // but STAYS the form value and the constraint-validation carrier.
71
+ this.inputTarget.setAttribute("tabindex", "-1")
72
+ this.inputTarget.setAttribute("aria-hidden", "true")
73
+
74
+ // Native required validation fires on the (visually hidden) input;
75
+ // route the browser's focus request to the first empty segment.
76
+ this.inputTarget.addEventListener("invalid", this.#onInvalid)
77
+ document.addEventListener("selectionchange", this.#onSelectionChange)
78
+ }
79
+
80
+ /**
81
+ * Restores the native input's tab order / AT visibility and unwires the
82
+ * document listeners.
83
+ */
84
+ disconnect() {
85
+ this.inputTarget?.removeEventListener("invalid", this.#onInvalid)
86
+ document.removeEventListener("selectionchange", this.#onSelectionChange)
87
+ this.element.removeAttribute("data-enhanced")
88
+ this.inputTarget?.removeAttribute("tabindex")
89
+ this.inputTarget?.removeAttribute("aria-hidden")
90
+ this.#segments = []
91
+ this.#segmentDigits = {}
92
+ }
93
+
94
+ /**
95
+ * The group's click action: clicking blank space lands on the earliest
96
+ * unfilled segment (the focusLast heuristic, simplified - segments
97
+ * themselves stop propagation by matching first).
98
+ *
99
+ * @param {MouseEvent} event
100
+ */
101
+ focusGap(event) {
102
+ if (event.target !== this.groupTarget) return
103
+
104
+ const target = this.#segments.find(({ element }) => element.hasAttribute("data-placeholder")) ??
105
+ this.#segments[0]
106
+
107
+ target?.element.focus()
108
+ }
109
+
110
+ // --- build ---
111
+
112
+ get #kind() {
113
+ return this.inputTarget.type === "time" ? "time" : "date"
114
+ }
115
+
116
+ #resolvedHourCycle() {
117
+ return resolveHourCycle(this.#locale(), this.hourCycleValue || null)
118
+ }
119
+
120
+ #locale() {
121
+ return this.localeValue || document.documentElement.lang || undefined
122
+ }
123
+
124
+ #formatterOptions() {
125
+ if (this.#kind === "date") return { year: "numeric", month: "numeric", day: "numeric" }
126
+
127
+ const options = { hour: "numeric", minute: "2-digit", hourCycle: this.#value.hourCycle }
128
+
129
+ if (this.secondsValue) options.second = "2-digit"
130
+
131
+ return options
132
+ }
133
+
134
+ #buildSegments() {
135
+ const formatter = new Intl.DateTimeFormat(this.#locale(), this.#formatterOptions())
136
+ // A SINGLE-DIGIT probe (March 3rd, 9:45:05): each part's rendered
137
+ // length IS the locale's digit style - "3" under en-US, "03" under
138
+ // en-CA - so the display padding below can mirror it. A two-digit
139
+ // probe would read every locale as 2-digit.
140
+ const parts = formatter.formatToParts(new Date(2020, 2, 3, 9, 45, 5))
141
+ const editable = EDITABLE[this.#kind]
142
+ const fragment = document.createDocumentFragment()
143
+
144
+ this.#resolveDayPeriodNames()
145
+
146
+ // RTL correctness: the time run is wrapped in LRI/PDI isolates so
147
+ // minute:hour never renders reversed (harmless in LTR).
148
+ if (this.#kind === "time") fragment.appendChild(this.#literal("⁦"))
149
+
150
+ for (const part of parts) {
151
+ const type = part.type === "dayperiod" ? "dayPeriod" : part.type
152
+
153
+ if (editable.includes(type)) {
154
+ const segment = this.#createSegment(type)
155
+
156
+ // The locale's own width for the padded types (year stays
157
+ // unpadded - "y" numeric everywhere real years are 4 digits).
158
+ if (type === "month" || type === "day" || type === "hour") {
159
+ this.#segmentDigits[type] = part.value.length
160
+ }
161
+
162
+ this.#segments.push({ element: segment, type })
163
+ fragment.appendChild(segment)
164
+ } else {
165
+ fragment.appendChild(this.#literal(part.value))
166
+ }
167
+ }
168
+
169
+ if (this.#kind === "time") fragment.appendChild(this.#literal("⁩"))
170
+
171
+ this.groupTarget.replaceChildren(fragment)
172
+ this.#labelSegments()
173
+ this.#segments.forEach(({ element, type }) => this.#renderSegment(element, type))
174
+ }
175
+
176
+ #literal(text) {
177
+ const span = document.createElement("span")
178
+
179
+ span.setAttribute("aria-hidden", "true")
180
+ span.setAttribute("data-slot", "date-field-literal")
181
+ span.textContent = text
182
+ return span
183
+ }
184
+
185
+ #createSegment(type) {
186
+ const span = document.createElement("span")
187
+ const disabled = this.inputTarget.disabled
188
+
189
+ span.setAttribute("data-slot", "date-field-segment")
190
+ span.setAttribute("data-type", type)
191
+
192
+ // iOS VoiceOver cannot focus spinbuttons - segments fall back to
193
+ // role=textbox there, with the aria-value* surface stripped.
194
+ if (isApplePlatform() && navigator.maxTouchPoints > 0) span.setAttribute("role", "textbox")
195
+ else span.setAttribute("role", "spinbutton")
196
+
197
+ if (disabled) {
198
+ span.setAttribute("aria-disabled", "true")
199
+ } else {
200
+ span.setAttribute("tabindex", "0")
201
+ span.setAttribute("contenteditable", "true")
202
+ span.setAttribute("spellcheck", "false")
203
+ span.setAttribute("autocorrect", "off")
204
+ span.setAttribute("autocapitalize", "off")
205
+ span.setAttribute("enterkeyhint", "next")
206
+
207
+ if (type !== "dayPeriod") span.setAttribute("inputmode", "numeric")
208
+ }
209
+
210
+ if (type !== "dayPeriod") {
211
+ // The bidi algorithm treats placeholder text and digits differently;
212
+ // without the embed, segments shift while deleting in RTL.
213
+ span.style.unicodeBidi = "embed"
214
+ span.style.direction = "ltr"
215
+ }
216
+
217
+ span.style.caretColor = "transparent"
218
+
219
+ span.addEventListener("keydown", (event) => this.#onKeydown(event, span, type))
220
+ span.addEventListener("beforeinput", (event) => this.#onBeforeinput(event, span, type))
221
+ span.addEventListener("input", (event) => this.#onInput(event, span, type))
222
+ span.addEventListener("focus", () => { this.#entered = "" })
223
+
224
+ return span
225
+ }
226
+
227
+ // Segment names come from Intl.DisplayNames where available, the i18n
228
+ // fallback table otherwise - with the FIELD's label appended, because
229
+ // iOS VoiceOver does not announce group labels.
230
+ #labelSegments() {
231
+ let names = null
232
+
233
+ try {
234
+ names = new Intl.DisplayNames(this.#locale(), { type: "dateTimeField" })
235
+ } catch { /* fallback table below */ }
236
+
237
+ const fieldLabel = this.#fieldLabelText()
238
+
239
+ this.#segments.forEach(({ element, type }, index) => {
240
+ const key = type === "dayPeriod" ? "dayPeriod" : type
241
+ let name = this.labelsValue[key] || key
242
+
243
+ if (names && type !== "dayPeriod") {
244
+ try { name = names.of(type) ?? name } catch { /* keep fallback */ }
245
+ }
246
+
247
+ element.setAttribute("aria-label", fieldLabel ? `${name}, ${fieldLabel}` : name)
248
+
249
+ // Only the FIRST segment carries the description - anything more
250
+ // re-announces on every arrow press.
251
+ if (index === 0 && this.inputTarget.getAttribute("aria-describedby")) {
252
+ element.setAttribute("aria-describedby", this.inputTarget.getAttribute("aria-describedby"))
253
+ }
254
+ })
255
+
256
+ if (fieldLabel) this.groupTarget.setAttribute("aria-label", fieldLabel)
257
+ }
258
+
259
+ #fieldLabelText() {
260
+ const id = this.inputTarget.id
261
+
262
+ if (!id) return null
263
+
264
+ const escaped = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(id) : id
265
+
266
+ return document.querySelector(`label[for="${escaped}"]`)?.textContent?.trim() ||
267
+ this.inputTarget.getAttribute("aria-label") || null
268
+ }
269
+
270
+ #resolveDayPeriodNames() {
271
+ if (this.#kind !== "time" || !this.#value.twelveHour) return
272
+
273
+ const formatter = new Intl.DateTimeFormat(this.#locale(), { hour: "numeric", hourCycle: this.#value.hourCycle })
274
+ const nameAt = (hour) =>
275
+ formatter.formatToParts(new Date(2020, 0, 1, hour)).find((part) => part.type === "dayPeriod")?.value
276
+
277
+ this.#dayPeriodNames = { am: nameAt(9) ?? "AM", pm: nameAt(21) ?? "PM" }
278
+ }
279
+
280
+ // --- input reflection ---
281
+
282
+ #readInput() {
283
+ const raw = this.inputTarget.value
284
+
285
+ if (this.#kind === "date") this.#value.setFromISODate(raw)
286
+ else this.#value.setFromISOTime(raw)
287
+ }
288
+
289
+ #placeholderFor(type) {
290
+ const seed = new IncompleteDate(this.#value.hourCycle)
291
+ const iso = this.placeholderValue
292
+
293
+ if (this.#kind === "date") {
294
+ if (!seed.setFromISODate(iso)) { seed.year = 2020; seed.month = 1; seed.day = 1 }
295
+ } else if (!seed.setFromISOTime(iso)) {
296
+ seed.setFromH23(12)
297
+ seed.minute = 0
298
+ seed.second = 0
299
+ }
300
+
301
+ return seed[type] ?? seed.limits(type).min
302
+ }
303
+
304
+ #renderSegment(element, type) {
305
+ const value = this.#value[type]
306
+ const filled = value !== null
307
+
308
+ element.toggleAttribute("data-placeholder", !filled)
309
+
310
+ if (type === "dayPeriod") {
311
+ element.textContent = filled
312
+ ? (value === 0 ? this.#dayPeriodNames.am : this.#dayPeriodNames.pm)
313
+ : (this.placeholdersValue.dayPeriod || this.#dayPeriodNames?.am || "AM")
314
+ } else if (filled) {
315
+ element.textContent = this.#displayNumber(type, value)
316
+ } else {
317
+ element.textContent = this.placeholdersValue[type] || "––"
318
+ }
319
+
320
+ if (element.getAttribute("role") !== "spinbutton") return
321
+
322
+ const { min, max } = this.#value.limits(type)
323
+
324
+ element.setAttribute("aria-valuemin", String(min))
325
+ element.setAttribute("aria-valuemax", String(max))
326
+
327
+ if (filled) {
328
+ element.setAttribute("aria-valuenow", String(value))
329
+ element.setAttribute("aria-valuetext", this.#valueText(type, value))
330
+ } else {
331
+ element.removeAttribute("aria-valuenow")
332
+ element.setAttribute("aria-valuetext", this.labelsValue.empty || "Empty")
333
+ }
334
+ }
335
+
336
+ #displayNumber(type, value) {
337
+ const digits = this.#segmentDigits[type] ?? (type === "minute" || type === "second" ? 2 : 1)
338
+
339
+ return new Intl.NumberFormat(this.#locale(), {
340
+ minimumIntegerDigits: digits, useGrouping: false
341
+ }).format(value)
342
+ }
343
+
344
+ // Month announces "3 - March" (the screen-reader enrichment).
345
+ #valueText(type, value) {
346
+ if (type === "month") {
347
+ const name = new Intl.DateTimeFormat(this.#locale(), { month: "long" })
348
+ .format(new Date(2020, value - 1, 1))
349
+
350
+ return `${value} – ${name}`
351
+ }
352
+
353
+ return this.#displayNumber(type, value)
354
+ }
355
+
356
+ // --- keyboard ---
357
+
358
+ #onKeydown(event, element, type) {
359
+ if (event.isComposing || this.inputTarget.readOnly) return
360
+
361
+ switch (event.key) {
362
+ case "ArrowUp": this.#step(event, element, type, 1); break
363
+ case "ArrowDown": this.#step(event, element, type, -1); break
364
+ case "PageUp": this.#step(event, element, type, PAGE_STEP[type] ?? 1, { round: true }); break
365
+ case "PageDown": this.#step(event, element, type, -(PAGE_STEP[type] ?? 1), { round: true }); break
366
+ case "Home": this.#jump(event, element, type, "min"); break
367
+ case "End": this.#jump(event, element, type, "max"); break
368
+ case "ArrowLeft": event.preventDefault(); this.#moveGeometric(element, -1); break
369
+ case "ArrowRight": event.preventDefault(); this.#moveGeometric(element, 1); break
370
+ case "Backspace":
371
+ case "Delete": event.preventDefault(); this.#erase(element, type); break
372
+ case "Enter": event.preventDefault(); this.#advanceFrom(element); break
373
+ default: this.#maybeDayPeriodKey(event, element, type)
374
+ }
375
+ }
376
+
377
+ #step(event, element, type, amount, options = {}) {
378
+ event.preventDefault()
379
+ this.#entered = ""
380
+ this.#value.cycle(type, amount, this.#placeholderFor(type), options)
381
+ this.#renderSegment(element, type)
382
+ this.#commitIfComplete()
383
+ }
384
+
385
+ #jump(event, element, type, edge) {
386
+ event.preventDefault()
387
+ this.#entered = ""
388
+ this.#value.set(type, this.#value.limits(type)[edge])
389
+ this.#renderSegment(element, type)
390
+ this.#commitIfComplete()
391
+ }
392
+
393
+ #erase(element, type) {
394
+ if (this.#value[type] === null) {
395
+ this.#moveGeometric(element, -1)
396
+ return
397
+ }
398
+
399
+ const chopped = this.#entered.length > 1
400
+ ? this.#entered.slice(0, -1)
401
+ : String(this.#value[type]).slice(0, -1)
402
+
403
+ this.#entered = chopped
404
+
405
+ if (chopped === "" || Number(chopped) === 0 || type === "dayPeriod") {
406
+ this.#value.clear(type)
407
+ this.#entered = ""
408
+ } else {
409
+ this.#value.set(type, Number(chopped))
410
+ }
411
+
412
+ this.#renderSegment(element, type)
413
+ this.#syncInput() // an erase can make the value incomplete - clear the wire
414
+ }
415
+
416
+ #maybeDayPeriodKey(event, element, type) {
417
+ if (type !== "dayPeriod" || !this.#dayPeriodNames) return
418
+ if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return
419
+
420
+ const typed = event.key.toLowerCase()
421
+ const { am, pm } = this.#dayPeriodNames
422
+
423
+ if (am.toLowerCase().startsWith(typed)) this.#value.set("dayPeriod", 0)
424
+ else if (pm.toLowerCase().startsWith(typed)) this.#value.set("dayPeriod", 1)
425
+ else return
426
+
427
+ event.preventDefault()
428
+ this.#renderSegment(element, "dayPeriod")
429
+ this.#commitIfComplete()
430
+ this.#advanceFrom(element)
431
+ }
432
+
433
+ // --- text input (beforeinput is the interception point: mobile numeric
434
+ // keyboards, swipe input, and per-segment paste all arrive here) ---
435
+
436
+ #onBeforeinput(event, element, type) {
437
+ if (this.inputTarget.readOnly) { event.preventDefault(); return }
438
+
439
+ // insertCompositionText cannot be canceled - snapshot, restore on
440
+ // input, then route the composed data (Android IME otherwise leaves
441
+ // real text inside the segment and breaks the DOM).
442
+ if (event.inputType === "insertCompositionText") {
443
+ this.#compositionSnapshot = element.textContent
444
+ return
445
+ }
446
+
447
+ event.preventDefault()
448
+
449
+ if (event.inputType.startsWith("delete")) {
450
+ this.#erase(element, type)
451
+ return
452
+ }
453
+
454
+ this.#routeText(event.data, element, type)
455
+ }
456
+
457
+ #onInput(event, element, type) {
458
+ if (this.#compositionSnapshot === null) return
459
+
460
+ element.textContent = this.#compositionSnapshot
461
+ this.#compositionSnapshot = null
462
+ this.#routeText(event.data, element, type)
463
+ }
464
+
465
+ #routeText(text, element, type) {
466
+ if (!text) return
467
+
468
+ for (const char of text) {
469
+ if (type === "dayPeriod") {
470
+ this.#maybeDayPeriodKey(
471
+ new KeyboardEvent("keydown", { key: char, cancelable: true }), element, type
472
+ )
473
+ } else if (/\d/.test(char)) {
474
+ this.#applyDigit(char, element, type)
475
+ }
476
+ }
477
+ }
478
+
479
+ #applyDigit(digit, element, type) {
480
+ const { min, max } = this.#value.limits(type)
481
+ let candidate = this.#entered + digit
482
+
483
+ // Typing past the maximum restarts from the last key:
484
+ // "3" then "5" in a month is 3, then 5 - never 35.
485
+ if (Number(candidate) > max) candidate = digit
486
+
487
+ this.#entered = candidate
488
+ const number = Number(candidate)
489
+
490
+ if (number >= min) this.#value.set(type, number)
491
+ else this.#value.clear(type)
492
+
493
+ this.#renderSegment(element, type)
494
+
495
+ // A pending sub-minimum entry ("0" in a month) shows what was typed.
496
+ if (number < min) element.textContent = candidate
497
+
498
+ this.#commitIfComplete()
499
+
500
+ // Auto-advance once no further digit can fit.
501
+ if (Number(candidate + "0") > max || candidate.length >= String(max).length) {
502
+ this.#advanceFrom(element)
503
+ }
504
+ }
505
+
506
+ // --- segment navigation (GEOMETRIC, not DOM order: numeric runs are
507
+ // LTR-embedded inside an RTL line, so arrows follow visual order) ---
508
+
509
+ #moveGeometric(element, direction) {
510
+ this.#entered = ""
511
+
512
+ const ordered = this.#segments
513
+ .map(({ element: seg }) => seg)
514
+ .filter((seg) => seg.hasAttribute("tabindex"))
515
+ .sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left)
516
+ const index = ordered.indexOf(element)
517
+ const next = ordered[index + direction]
518
+
519
+ next?.focus()
520
+ }
521
+
522
+ #advanceFrom(element) {
523
+ this.#entered = ""
524
+ this.#moveGeometric(element, 1)
525
+ }
526
+
527
+ // --- commit ---
528
+
529
+ /**
530
+ * The group's focusout action: leaving the whole group constrains
531
+ * (Feb 31 -> Feb 29) and commits.
532
+ *
533
+ * @param {FocusEvent} event
534
+ */
535
+ settle(event) {
536
+ if (this.groupTarget.contains(event.relatedTarget)) return
537
+
538
+ this.#entered = ""
539
+ this.#value.constrain(EDITABLE[this.#kind])
540
+ this.#segments.forEach(({ element, type }) => this.#renderSegment(element, type))
541
+ this.#commitIfComplete()
542
+ }
543
+
544
+ #commitIfComplete() {
545
+ if (this.#kind === "date" && this.#value.isComplete(["year", "month", "day"]) &&
546
+ !this.#value.isValidDate()) return // Feb 31 mid-edit: hold the wire
547
+
548
+ this.#syncInput()
549
+ }
550
+
551
+ #syncInput() {
552
+ const iso = this.#kind === "date"
553
+ ? this.#value.toISODate()
554
+ : this.#value.toISOTime(this.secondsValue)
555
+ const next = iso ?? ""
556
+
557
+ if (this.inputTarget.value === next) return
558
+
559
+ this.inputTarget.value = next
560
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
561
+ this.dispatch("change", { prefix: EVENT_PREFIX, detail: { value: next } })
562
+ }
563
+
564
+ #onInvalid = (event) => {
565
+ event.preventDefault()
566
+ const target = this.#segments.find(({ element }) => element.hasAttribute("data-placeholder")) ??
567
+ this.#segments[0]
568
+
569
+ target?.element.focus()
570
+ }
571
+
572
+ // Android Chrome composition breaks if a selection RANGE lives inside a
573
+ // segment - collapse any selection that lands in one.
574
+ #collapseSelectionInSegment() {
575
+ const selection = document.getSelection?.()
576
+
577
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return
578
+
579
+ const anchor = selection.anchorNode
580
+ const inSegment = anchor && this.#segments.some(({ element }) => element.contains(anchor))
581
+
582
+ if (inSegment) selection.collapseToStart()
583
+ }
584
+ }
@@ -0,0 +1,111 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The DatePicker glue: the thin coordinator between a Popover and
4
+ // the Calendar it wraps. The Calendar owns selection + the form value (its
5
+ // name: hidden input); this controller only reacts to the calendar's change
6
+ // event to (a) write the human-formatted date into the trigger label and
7
+ // (b) close the popover so the pick feels complete. Everything hard is
8
+ // already done by poetry--core--calendar and poetry--core--popover.
9
+ export default class DatePickerController extends Controller {
10
+ static targets = ["label", "input"]
11
+ static values = {
12
+ placeholder: { type: String, default: "Pick a date" },
13
+ mode: { type: String, default: "single" },
14
+ locale: { type: String, default: "en-US" }
15
+ }
16
+
17
+ /**
18
+ * The calendar-change action on the wrapper: writes the formatted date
19
+ * into the trigger label (and input, when present) and closes the
20
+ * popover so the pick feels complete. Range mode: the label joins the
21
+ * pair, a start-only pick shows one date, and the popover closes only
22
+ * once the range COMPLETES - never mid-range.
23
+ *
24
+ * @param {CustomEvent} event - detail carries value (single) or
25
+ * start/end (range) as ISO strings
26
+ */
27
+ picked(event) {
28
+ if (this.modeValue === "range") {
29
+ const { start, end } = event.detail ?? {}
30
+ if (this.hasLabelTarget) {
31
+ this.labelTarget.textContent = start
32
+ ? [start, end].filter(Boolean).map((iso) => this.#format(iso)).join(" – ")
33
+ : this.placeholderValue
34
+ }
35
+ if (start && end) this.#closePopover()
36
+ return
37
+ }
38
+
39
+ const iso = event.detail?.value
40
+
41
+ if (this.hasLabelTarget) {
42
+ this.labelTarget.textContent = iso ? this.#format(iso) : this.placeholderValue
43
+ }
44
+ if (this.hasInputTarget && iso) this.inputTarget.value = this.#format(iso)
45
+ if (iso) this.#closePopover()
46
+ }
47
+
48
+ // --- the input variant (typed entry beside the calendar) ---
49
+
50
+ /**
51
+ * The input's change action: a parseable date re-selects and re-months
52
+ * the calendar through its reactive values (the DOM is the store), and
53
+ * the calendar's own render refreshes the hidden ISO form value.
54
+ * Unparseable text changes nothing.
55
+ */
56
+ inputChanged() {
57
+ if (!this.hasInputTarget) return
58
+
59
+ const parsed = new Date(this.inputTarget.value)
60
+
61
+ if (Number.isNaN(parsed.getTime())) return
62
+
63
+ const pad = (n) => String(n).padStart(2, "0")
64
+ const iso = `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`
65
+ const calendar = this.#calendar()
66
+
67
+ if (!calendar) return
68
+
69
+ calendar.monthValue = iso.slice(0, 7)
70
+ calendar.selectedValue = iso
71
+ }
72
+
73
+ /**
74
+ * The input's keydown action: ArrowDown opens the calendar.
75
+ *
76
+ * @param {KeyboardEvent} event
77
+ */
78
+ inputKeydown(event) {
79
+ if (event.key !== "ArrowDown") return
80
+
81
+ event.preventDefault()
82
+ this.#popover()?.open()
83
+ }
84
+
85
+ #calendar() {
86
+ const root = this.element.querySelector('[data-controller~="poetry--core--calendar"]')
87
+
88
+ return root && this.application.getControllerForElementAndIdentifier(root, "poetry--core--calendar")
89
+ }
90
+
91
+ #popover() {
92
+ const root = this.element.querySelector('[data-controller~="poetry--core--popover"]')
93
+
94
+ return root && this.application.getControllerForElementAndIdentifier(root, "poetry--core--popover")
95
+ }
96
+
97
+ #format(iso) {
98
+ // Parse as UTC noon so the local-timezone render never rolls the day.
99
+ // Range labels use SHORT month names (the server formats %b for the
100
+ // same reason: two long-month dates outgrow the trigger).
101
+ const date = new Date(`${iso}T12:00:00Z`)
102
+ return date.toLocaleDateString(this.localeValue, {
103
+ year: "numeric", month: this.modeValue === "range" ? "short" : "long",
104
+ day: "numeric", timeZone: "UTC"
105
+ })
106
+ }
107
+
108
+ #closePopover() {
109
+ this.#popover()?.close("item-press")
110
+ }
111
+ }