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,468 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The Questionnaire machine, adapted from an MIT-licensed source (source
4
+ // and license in THIRD_PARTY_NOTICES.md): a native <form> of fieldset
5
+ // items shown ONE at a
6
+ // time. The server renders the complete initial state (active item,
7
+ // statuses, shortcuts, button visibility); this controller owns the
8
+ // runtime transitions - navigation (validate-gated Next, Skip for
9
+ // optional items, submit validates every item and jumps to the first
10
+ // invalid), answer tracking (choice change / text input -> status), the
11
+ // keyboard map (Cmd/Ctrl+Enter confirm, ArrowUp/Down answer focus,
12
+ // ArrowLeft/Right item navigation, Enter-on-filled-answer confirm,
13
+ // letter/number shortcuts), and the data-attribute stamps the styling
14
+ // contract reads. Answers are native radio/checkbox/text inputs - the
15
+ // form serializes with zero JS.
16
+ const ITEM = '[data-slot="questionnaire-item"]'
17
+ const CHOICE = '[data-slot="questionnaire-choice"]'
18
+ const CHOICE_INPUT = '[data-slot="questionnaire-choice-input"]'
19
+ const TEXT_INPUT = '[data-slot="questionnaire-input"]'
20
+ const ERROR = '[data-slot="questionnaire-error"]'
21
+ const DESCRIPTION = '[data-slot="questionnaire-description"]'
22
+
23
+ export default class QuestionnaireController extends Controller {
24
+ // The events this controller dispatches (manifest surface).
25
+ static events = [
26
+ "poetry:questionnaire:item-change",
27
+ "poetry:questionnaire:status-change",
28
+ ]
29
+
30
+ static targets = ["progress", "previous", "skip", "next", "submit"]
31
+
32
+ static values = {
33
+ // "letters" | "numbers" | "" - gates the shortcut KEY HANDLING; the
34
+ // key labels themselves are server-rendered (data-shortcut).
35
+ shortcuts: { type: String, default: "" },
36
+ }
37
+
38
+ /**
39
+ * Derives every stamp (checked/filled/status/progress/buttons) from the
40
+ * server-rendered DOM.
41
+ */
42
+ connect() {
43
+ this.#sync()
44
+ }
45
+
46
+ // --- navigation actions -------------------------------------------------
47
+
48
+ /** The previous button's click action: back one item (no validation). */
49
+ previous() {
50
+ const items = this.#enabledItems()
51
+ const index = this.#currentIndex(items)
52
+ if (index <= 0) return
53
+
54
+ this.#setItem(items[index - 1])
55
+ }
56
+
57
+ /**
58
+ * The next button's click action: validate-gated advance - an invalid
59
+ * active item takes focus instead.
60
+ */
61
+ next() {
62
+ const items = this.#enabledItems()
63
+ const index = this.#currentIndex(items)
64
+ const active = items[index]
65
+ if (!active || index >= items.length - 1) return
66
+
67
+ if (!this.#validate(active)) return this.#focusInvalid(active)
68
+
69
+ this.#setItem(items[index + 1])
70
+ }
71
+
72
+ /**
73
+ * The skip button's click action (optional items only): stamps skipped,
74
+ * then advances - or submits from the last item.
75
+ */
76
+ skip() {
77
+ const items = this.#enabledItems()
78
+ const index = this.#currentIndex(items)
79
+ const active = items[index]
80
+ if (!active || this.#required(active)) return
81
+
82
+ active.dataset.skipped = ""
83
+ this.#setStatus(active)
84
+ if (index >= items.length - 1) return this.element.requestSubmit()
85
+
86
+ this.#setItem(items[index + 1])
87
+ }
88
+
89
+ /**
90
+ * The form's submit action: every enabled item must validate; the first
91
+ * invalid one becomes active with its error focused.
92
+ *
93
+ * @param {SubmitEvent} event
94
+ */
95
+ submit(event) {
96
+ const invalid = this.#enabledItems().find((item) => !this.#validate(item))
97
+ if (!invalid) return
98
+
99
+ event.preventDefault()
100
+ this.#setItem(invalid)
101
+ this.#focusInvalid(invalid)
102
+ }
103
+
104
+ /**
105
+ * The form's reset action: native reset restores the inputs; re-derive
106
+ * every stamp from the restored DOM and return to the first item.
107
+ */
108
+ reset() {
109
+ requestAnimationFrame(() => {
110
+ this.#items().forEach((item) => {
111
+ delete item.dataset.skipped
112
+ delete item.dataset.validated
113
+ item.querySelectorAll(TEXT_INPUT).forEach((input) => this.#stampFilled(input))
114
+ this.#setStatus(item)
115
+ this.#syncInvalid(item)
116
+ })
117
+ const first = this.#enabledItems()[0]
118
+ if (first) this.#setItem(first)
119
+ })
120
+ }
121
+
122
+ // --- answer tracking ----------------------------------------------------
123
+
124
+ /**
125
+ * The choice inputs' change action: re-stamps the item's choices,
126
+ * status, and validity.
127
+ *
128
+ * @param {Event} event
129
+ */
130
+ change(event) {
131
+ const input = event.target.closest(CHOICE_INPUT)
132
+ if (!input) return
133
+ const item = input.closest(ITEM)
134
+ if (!item) return
135
+
136
+ delete item.dataset.skipped
137
+ item.querySelectorAll(CHOICE).forEach((choice) => {
138
+ const choiceInput = choice.querySelector(CHOICE_INPUT)
139
+ this.#stampChecked(choice, choiceInput)
140
+ })
141
+ this.#setStatus(item)
142
+ this.#syncInvalid(item)
143
+ }
144
+
145
+ /**
146
+ * The text inputs' input action: re-stamps filled/status/validity.
147
+ *
148
+ * @param {Event} event
149
+ */
150
+ input(event) {
151
+ const input = event.target.closest(TEXT_INPUT)
152
+ if (!input) return
153
+ const item = input.closest(ITEM)
154
+ if (!item) return
155
+
156
+ delete item.dataset.skipped
157
+ this.#stampFilled(input)
158
+ this.#setStatus(item)
159
+ this.#syncInvalid(item)
160
+ }
161
+
162
+ // --- keyboard (the primitive's full map) --------------------------------
163
+
164
+ /**
165
+ * The form's keydown action (the primitive's full map): Cmd/Ctrl+Enter
166
+ * confirms, ArrowUp/Down move answer focus, ArrowLeft/Right navigate
167
+ * items (outside text entry and radios), Enter on a filled answer
168
+ * confirms, and letter/number shortcuts pick choices.
169
+ *
170
+ * @param {KeyboardEvent} event
171
+ */
172
+ keydown(event) {
173
+ if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return
174
+ const active = this.#activeItem()
175
+ if (!active || !(event.target instanceof Element)) return
176
+
177
+ const meta = event.metaKey || event.ctrlKey
178
+ if (event.key === "Enter" && meta && !event.altKey && !event.shiftKey) {
179
+ event.preventDefault()
180
+ if (!event.repeat) this.#confirm()
181
+ return
182
+ }
183
+ if (meta || event.altKey) return
184
+
185
+ if (event.key === "ArrowUp" || event.key === "ArrowDown") {
186
+ if (this.#moveAnswerFocus(active, event.target, event.key === "ArrowDown" ? 1 : -1)) {
187
+ event.preventDefault()
188
+ }
189
+ return
190
+ }
191
+
192
+ if (
193
+ (event.key === "ArrowLeft" || event.key === "ArrowRight") &&
194
+ !this.#textEntryTarget(event.target) &&
195
+ !this.#radioTarget(event.target)
196
+ ) {
197
+ event.preventDefault()
198
+ if (event.repeat) return
199
+ if (event.key === "ArrowLeft") this.previous()
200
+ else if (this.#status(active) !== "unanswered") this.next()
201
+ return
202
+ }
203
+
204
+ if (event.key === "Enter") {
205
+ const answer = event.target.closest(`${CHOICE_INPUT}, ${TEXT_INPUT}`)
206
+ if (!answer) return
207
+ event.preventDefault()
208
+ if (!event.repeat && this.#answerFilled(answer)) this.#confirm()
209
+ return
210
+ }
211
+
212
+ if (!this.shortcutsValue || this.#textEntryTarget(event.target)) return
213
+
214
+ const key = event.key.length === 1 ? event.key.toUpperCase() : event.key
215
+ const choice = [...active.querySelectorAll(CHOICE)].find(
216
+ (candidate) => candidate.dataset.shortcut === key && !this.#choiceDisabled(candidate)
217
+ )
218
+ if (!choice) return
219
+
220
+ event.preventDefault()
221
+ if (event.repeat) return
222
+ const input = choice.querySelector(CHOICE_INPUT)
223
+ input?.focus()
224
+ input?.click()
225
+ }
226
+
227
+ // --- internals ----------------------------------------------------------
228
+
229
+ // Cmd/Ctrl+Enter and Enter-on-a-filled-answer: validate, then submit on
230
+ // the last item or advance.
231
+ #confirm() {
232
+ const items = this.#enabledItems()
233
+ const index = this.#currentIndex(items)
234
+ const active = items[index]
235
+ if (!active) return
236
+
237
+ if (!this.#validate(active)) return this.#focusInvalid(active)
238
+ if (index >= items.length - 1) return this.element.requestSubmit()
239
+
240
+ this.#setItem(items[index + 1])
241
+ }
242
+
243
+ #items() {
244
+ return [...this.element.querySelectorAll(ITEM)]
245
+ }
246
+
247
+ #enabledItems() {
248
+ return this.#items().filter((item) => !item.disabled)
249
+ }
250
+
251
+ #activeItem() {
252
+ return this.#enabledItems().find((item) => item.hasAttribute("data-active"))
253
+ }
254
+
255
+ #currentIndex(items) {
256
+ return items.findIndex((item) => item.hasAttribute("data-active"))
257
+ }
258
+
259
+ #required(item) {
260
+ return item.hasAttribute("data-required")
261
+ }
262
+
263
+ #answerControls(item) {
264
+ return [...item.querySelectorAll(`${CHOICE_INPUT}, ${TEXT_INPUT}`)].filter(
265
+ (control) => !control.disabled
266
+ )
267
+ }
268
+
269
+ #answerFilled(control) {
270
+ if (control.matches(CHOICE_INPUT)) return control.checked
271
+
272
+ return control.value.trim() !== ""
273
+ }
274
+
275
+ #answered(item) {
276
+ return this.#answerControls(item).some((control) => this.#answerFilled(control))
277
+ }
278
+
279
+ #status(item) {
280
+ if ("skipped" in item.dataset && !this.#answered(item)) return "skipped"
281
+
282
+ return this.#answered(item) ? "answered" : "unanswered"
283
+ }
284
+
285
+ #setStatus(item) {
286
+ const status = this.#status(item)
287
+ if (item.dataset.status === status) return
288
+
289
+ item.dataset.status = status
290
+ this.dispatch("status-change", {
291
+ prefix: "poetry:questionnaire",
292
+ detail: { item: item.dataset.name, status },
293
+ })
294
+ if (item.hasAttribute("data-active")) this.#syncButtons()
295
+ }
296
+
297
+ // validate() marks the attempt; invalid derives from it (the error
298
+ // stays hidden until a navigation actually requires the answer).
299
+ #validate(item) {
300
+ item.dataset.validated = ""
301
+ const valid =
302
+ item.disabled ||
303
+ (this.#status(item) === "skipped" && !this.#required(item)) ||
304
+ this.#answered(item)
305
+ this.#syncInvalid(item)
306
+ return valid
307
+ }
308
+
309
+ #invalid(item) {
310
+ if (item.disabled) return false
311
+ if (!("validated" in item.dataset)) return false
312
+ if (this.#status(item) === "skipped" && !this.#required(item)) return false
313
+
314
+ return !this.#answered(item)
315
+ }
316
+
317
+ #syncInvalid(item) {
318
+ const invalid = this.#invalid(item)
319
+ const error = item.querySelector(ERROR)
320
+ item.toggleAttribute("data-invalid", invalid)
321
+ if (error) {
322
+ error.hidden = !invalid
323
+ if (invalid) error.setAttribute("role", "alert")
324
+ else error.removeAttribute("role")
325
+ }
326
+ const description = item.querySelector(DESCRIPTION)
327
+ const describedby = [description?.id, invalid ? error?.id : null]
328
+ .filter(Boolean)
329
+ .join(" ")
330
+ if (describedby) item.setAttribute("aria-describedby", describedby)
331
+ else item.removeAttribute("aria-describedby")
332
+ this.#answerControls(item).forEach((control) => {
333
+ if (invalid) control.setAttribute("aria-invalid", "true")
334
+ else control.removeAttribute("aria-invalid")
335
+ })
336
+ }
337
+
338
+ #stampChecked(choice, input) {
339
+ if (!choice || !input) return
340
+ choice.toggleAttribute("data-checked", input.checked)
341
+ choice.toggleAttribute("data-unchecked", !input.checked)
342
+ input.toggleAttribute("data-checked", input.checked)
343
+ input.toggleAttribute("data-unchecked", !input.checked)
344
+ }
345
+
346
+ #stampFilled(input) {
347
+ const filled = input.value.trim() !== ""
348
+ input.toggleAttribute("data-filled", filled)
349
+ input.toggleAttribute("data-empty", !filled)
350
+ }
351
+
352
+ #setItem(item, { focus = true } = {}) {
353
+ const previous = this.#activeItem()
354
+ if (previous === item) {
355
+ if (focus) this.#focusFirstAnswer(item)
356
+ return
357
+ }
358
+
359
+ this.#items().forEach((candidate) => {
360
+ const active = candidate === item
361
+ candidate.toggleAttribute("data-active", active)
362
+ candidate.hidden = !active
363
+ candidate.inert = !active
364
+ })
365
+ this.#syncProgress()
366
+ this.#syncButtons()
367
+ this.dispatch("item-change", {
368
+ prefix: "poetry:questionnaire",
369
+ detail: { item: item.dataset.name },
370
+ })
371
+ if (focus) this.#focusFirstAnswer(item)
372
+ }
373
+
374
+ #focusFirstAnswer(item) {
375
+ this.#answerControls(item)[0]?.focus()
376
+ }
377
+
378
+ #focusInvalid(item) {
379
+ this.#focusFirstAnswer(item)
380
+ }
381
+
382
+ #moveAnswerFocus(item, from, direction) {
383
+ const controls = this.#answerControls(item)
384
+ if (controls.length === 0) return false
385
+
386
+ const target = from.closest(`${CHOICE_INPUT}, ${TEXT_INPUT}`)
387
+ const index = controls.indexOf(target)
388
+ const nextIndex = index === -1 ? (direction > 0 ? 0 : controls.length - 1) : index + direction
389
+ const next = controls[nextIndex]
390
+ if (!next) return false
391
+
392
+ next.focus()
393
+ return true
394
+ }
395
+
396
+ #textEntryTarget(target) {
397
+ return target.matches?.("input[type=text], input[type=email], input[type=number], " +
398
+ "input[type=search], input[type=tel], input[type=url], textarea")
399
+ }
400
+
401
+ #radioTarget(target) {
402
+ return target.matches?.("input[type=radio]")
403
+ }
404
+
405
+ #choiceDisabled(choice) {
406
+ const input = choice.querySelector(CHOICE_INPUT)
407
+ return !input || input.disabled
408
+ }
409
+
410
+ #sync() {
411
+ this.#items().forEach((item) => {
412
+ item.querySelectorAll(CHOICE).forEach((choice) => {
413
+ this.#stampChecked(choice, choice.querySelector(CHOICE_INPUT))
414
+ })
415
+ item.querySelectorAll(TEXT_INPUT).forEach((input) => this.#stampFilled(input))
416
+ this.#setStatus(item)
417
+ })
418
+ this.#syncProgress()
419
+ this.#syncButtons()
420
+ }
421
+
422
+ #syncProgress() {
423
+ if (!this.hasProgressTarget) return
424
+ const items = this.#enabledItems()
425
+ const total = items.length
426
+ const current = Math.max(this.#currentIndex(items) + 1, 1)
427
+ const label = total ? `Question ${current} of ${total}` : ""
428
+
429
+ if (!("custom" in this.progressTarget.dataset)) this.progressTarget.textContent = label
430
+ // Custom progress hooks: data-current/data-total drive CSS (segment
431
+ // bars via data-[current=N] variants), and a [data-progress-count]
432
+ // child gets the live "X of Y" text.
433
+ this.progressTarget.dataset.current = String(current)
434
+ this.progressTarget.dataset.total = String(total)
435
+ const count = this.progressTarget.querySelector("[data-progress-count]")
436
+ if (count) count.textContent = `${current} of ${total}`
437
+ if (total) {
438
+ this.progressTarget.setAttribute("aria-valuemax", String(total))
439
+ this.progressTarget.setAttribute("aria-valuemin", "1")
440
+ this.progressTarget.setAttribute("aria-valuenow", String(current))
441
+ this.progressTarget.setAttribute("aria-valuetext", label)
442
+ }
443
+ }
444
+
445
+ #syncButtons() {
446
+ const items = this.#enabledItems()
447
+ const index = this.#currentIndex(items)
448
+ const active = items[index]
449
+ const first = index <= 0
450
+ const last = index >= items.length - 1
451
+
452
+ this.#stampButton(this.hasPreviousTarget && this.previousTarget, items.length > 1 && !first)
453
+ this.#stampButton(this.hasSkipTarget && this.skipTarget, !!active && !this.#required(active))
454
+ this.#stampButton(this.hasNextTarget && this.nextTarget, items.length > 1 && !last)
455
+ this.#stampButton(this.hasSubmitTarget && this.submitTarget, items.length > 0 && last)
456
+ }
457
+
458
+ #stampButton(button, visible) {
459
+ if (!button) return
460
+ button.hidden = !visible
461
+ button.inert = !visible
462
+ button.tabIndex = visible ? 0 : -1
463
+ button.toggleAttribute("data-visible", visible)
464
+ button.toggleAttribute("data-hidden", !visible)
465
+ if (visible) button.removeAttribute("aria-hidden")
466
+ else button.setAttribute("aria-hidden", "true")
467
+ }
468
+ }
@@ -0,0 +1,197 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { setState } from "@poetry/controllers/helpers/state"
3
+
4
+ // The RadioGroup checked-value machine (the Accordion composition shape):
5
+ // this controller owns ONLY the value + attribute/input writes - ZERO
6
+ // keyboard code. The shared poetry--core--roving-focus runs on the same
7
+ // root in its DEFAULT tabindex-managing mode (one Tab stop) with the
8
+ // orientation: "both" extension (all four arrows, APG radio), and this
9
+ // controller consumes its cancelable entry event to implement SELECTION
10
+ // FOLLOWS FOCUS: entry fires only on arrow/Home/End navigation - never on
11
+ // Tab or click-focus - so checking on entry is exactly the APG contract
12
+ // (Tab into the group never changes the value; contractual).
13
+ //
14
+ // The form story is the hidden-native-input rule: one <input type=radio>
15
+ // per item, shared name (aria-hidden, tabindex=-1) - native radio
16
+ // serialization, byte-identical to collection_radio_buttons. check() writes
17
+ // every item's aria-checked/checked pair/indicator, sets the hidden input's
18
+ // .checked (the native group unchecks siblings; written explicitly anyway -
19
+ // belt and braces), moves the roving tab stop to the checked item, and
20
+ // dispatches poetry:radio-group:change + native input/change on the newly
21
+ // checked hidden input (Rails autosave/change-tracking listeners fire).
22
+ const ITEM_SELECTOR = '[data-slot="radio-group-item"]'
23
+ const INDICATOR_SELECTOR = '[data-slot="radio-group-indicator"]'
24
+
25
+ export default class RadioGroupController extends Controller {
26
+ // The events this controller dispatches (manifest surface;
27
+ // events_declaration.test.js enforces the list stays honest).
28
+ static events = ["poetry:radio-group:change"]
29
+
30
+ static values = {
31
+ value: { type: String, default: "" }
32
+ }
33
+
34
+ static targets = ["input"]
35
+
36
+ #connected = false
37
+
38
+ /**
39
+ * Reconcile-on-connect: adopts the server-checked item into the Value
40
+ * when none was given, else normalizes the DOM to the Value (the body
41
+ * comment holds the rules).
42
+ */
43
+ connect() {
44
+ // Reconcile-on-connect: the server renders the checked state (aria +
45
+ // the checked pair + input checked + the tab stop); adopt it into the Value
46
+ // when the Value was not given, else normalize the DOM to the Value.
47
+ if (this.valueValue === "") {
48
+ const checked = this.#items().find((item) => item.getAttribute("aria-checked") === "true")
49
+
50
+ if (checked) this.valueValue = checked.dataset.value ?? ""
51
+ } else {
52
+ this.#write(this.valueValue)
53
+ }
54
+
55
+ this.#connected = true
56
+ }
57
+
58
+ // --- actions ---
59
+
60
+ /**
61
+ * Each item's click action (Space arrives here too, via native button
62
+ * activation): checks the pressed item.
63
+ *
64
+ * @param {MouseEvent} event
65
+ */
66
+ check(event) {
67
+ const origin = event.currentTarget instanceof Element ? event.currentTarget : event.target
68
+ const item = origin instanceof Element ? origin.closest(ITEM_SELECTOR) : null
69
+
70
+ if (item) this.#check(item)
71
+ }
72
+
73
+ /**
74
+ * The roving-focus entry action - the entry event fires ONLY on
75
+ * arrow/Home/End navigation (never on Tab), so this IS
76
+ * selection-follows-focus (the APG contract).
77
+ *
78
+ * @param {CustomEvent} event - detail.item is the entered radio
79
+ */
80
+ entryCheck(event) {
81
+ const item = event.detail?.item
82
+
83
+ if (item instanceof Element && item.matches(ITEM_SELECTOR)) this.#check(item)
84
+ }
85
+
86
+ // --- the programmatic controllable-state surface ---
87
+
88
+ /**
89
+ * The programmatic controllable-state surface: checks the item carrying
90
+ * `value`. Unknown values log a debug line and change nothing.
91
+ *
92
+ * @param {string} value - an item's data-value
93
+ */
94
+ setValue(value) {
95
+ const item = this.#itemFor(value)
96
+
97
+ if (!item) {
98
+ console.debug(`poetry--core--radio-group: unknown value "${value}"`)
99
+ return
100
+ }
101
+
102
+ this.#check(item)
103
+ }
104
+
105
+ /**
106
+ * Stimulus value callback: re-writes the DOM when the Value changes
107
+ * after connect (an Outlet or a host writing the attribute directly).
108
+ *
109
+ * @param {string} value
110
+ * @param {string} previous
111
+ */
112
+ valueValueChanged(value, previous) {
113
+ if (!this.#connected || value === previous) return
114
+
115
+ this.#write(value)
116
+ }
117
+
118
+ // --- the checked-value machine ---
119
+
120
+ #check(item) {
121
+ if (this.#disabled(item)) return
122
+
123
+ const value = item.dataset.value ?? ""
124
+
125
+ if (value === this.valueValue) return // re-check no-ops (radios never uncheck)
126
+
127
+ const previous = this.valueValue
128
+
129
+ // Write synchronously (the Value's changed callback arrives on the
130
+ // MutationObserver microtask - too late for the machine); the Value is
131
+ // updated for the controllable-state surface and re-writes idempotently.
132
+ this.valueValue = value
133
+ this.#write(value)
134
+ this.dispatch("change", { prefix: "poetry:radio-group", detail: { value, previous } })
135
+
136
+ // The native mirror on the newly checked hidden input - the Rails
137
+ // ecosystem's change/input listeners work with zero shims.
138
+ const input = this.#inputFor(item)
139
+
140
+ input?.dispatchEvent(new Event("input", { bubbles: true }))
141
+ input?.dispatchEvent(new Event("change", { bubbles: true }))
142
+ }
143
+
144
+ // The one write path: aria-checked + the checked pair written together on every
145
+ // item, indicator visibility, hidden input .checked, and the roving tab
146
+ // stop moved to the checked item.
147
+ #write(value) {
148
+ const items = this.#items()
149
+ const checkedItem = items.find((item) => (item.dataset.value ?? "") === value) ?? null
150
+
151
+ for (const item of items) {
152
+ const checked = item === checkedItem
153
+
154
+ item.setAttribute("aria-checked", String(checked))
155
+ setState(item, checked ? "checked" : "unchecked")
156
+ item.querySelector(INDICATOR_SELECTOR)?.toggleAttribute("hidden", !checked)
157
+
158
+ const input = this.#inputFor(item)
159
+
160
+ if (input) input.checked = checked
161
+ }
162
+
163
+ this.#moveTabStop(items, checkedItem)
164
+ }
165
+
166
+ // One tabindex=0 - the checked item (or the first enabled when nothing is
167
+ // checked); roving-focus adopts the stamp (its current-stop scan reads
168
+ // tabindex="0").
169
+ #moveTabStop(items, checkedItem) {
170
+ const target = (checkedItem && !this.#disabled(checkedItem) ? checkedItem : null) ??
171
+ items.find((item) => !this.#disabled(item))
172
+
173
+ if (!target) return
174
+
175
+ for (const item of items) item.setAttribute("tabindex", item === target ? "0" : "-1")
176
+ }
177
+
178
+ #items() {
179
+ return Array.from(this.element.querySelectorAll(ITEM_SELECTOR))
180
+ }
181
+
182
+ #itemFor(value) {
183
+ return this.#items().find((item) => (item.dataset.value ?? "") === value) ?? null
184
+ }
185
+
186
+ // The hidden native radio rides inside its item (the skeleton's shape);
187
+ // the target scope is the fallback for hosts that render inputs elsewhere.
188
+ #inputFor(item) {
189
+ return item.querySelector('input[type="radio"]') ??
190
+ this.inputTargets.find((input) => input.value === item.dataset.value) ?? null
191
+ }
192
+
193
+ #disabled(item) {
194
+ return item.hasAttribute("disabled") || item.hasAttribute("data-disabled") ||
195
+ this.element.hasAttribute("data-disabled")
196
+ }
197
+ }