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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/DESIGN.md +145 -0
- data/LICENSE.txt +21 -0
- data/README.md +30 -0
- data/THIRD_PARTY_NOTICES.md +507 -0
- data/app/components/poetry/core/box/component.rb +82 -0
- data/app/components/poetry/core/component.rb +390 -0
- data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
- data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
- data/app/components/poetry/core/concerns/introspection.rb +533 -0
- data/app/components/poetry/core/concerns/options.rb +262 -0
- data/app/components/poetry/core/concerns/parts.rb +148 -0
- data/app/components/poetry/core/concerns/stimulus.rb +363 -0
- data/app/components/poetry/core/concerns/styles.rb +323 -0
- data/app/components/poetry/core/style.rb +150 -0
- data/app/components/poetry/core/wrapper/component.rb +90 -0
- data/app/javascript/poetry/core/accordion_controller.js +165 -0
- data/app/javascript/poetry/core/action_bar_controller.js +109 -0
- data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
- data/app/javascript/poetry/core/calendar_controller.js +406 -0
- data/app/javascript/poetry/core/carousel_controller.js +167 -0
- data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
- data/app/javascript/poetry/core/checked_controller.js +208 -0
- data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
- data/app/javascript/poetry/core/combobox_controller.js +1095 -0
- data/app/javascript/poetry/core/command_controller.js +510 -0
- data/app/javascript/poetry/core/context_menu_controller.js +204 -0
- data/app/javascript/poetry/core/date_field_controller.js +584 -0
- data/app/javascript/poetry/core/date_picker_controller.js +111 -0
- data/app/javascript/poetry/core/deferred_controller.js +111 -0
- data/app/javascript/poetry/core/dialog_controller.js +206 -0
- data/app/javascript/poetry/core/dismissable_controller.js +182 -0
- data/app/javascript/poetry/core/drawer_controller.js +365 -0
- data/app/javascript/poetry/core/file_input_controller.js +191 -0
- data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
- data/app/javascript/poetry/core/helpers/announce.js +228 -0
- data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
- data/app/javascript/poetry/core/helpers/collection.js +18 -0
- data/app/javascript/poetry/core/helpers/direction.js +15 -0
- data/app/javascript/poetry/core/helpers/escape.js +38 -0
- data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
- data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
- data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
- data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
- data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
- data/app/javascript/poetry/core/helpers/mask.js +283 -0
- data/app/javascript/poetry/core/helpers/portal.js +217 -0
- data/app/javascript/poetry/core/helpers/presence.js +210 -0
- data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
- data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
- data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
- data/app/javascript/poetry/core/helpers/state.js +80 -0
- data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
- data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
- data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
- data/app/javascript/poetry/core/hotkey_controller.js +55 -0
- data/app/javascript/poetry/core/hover_card_controller.js +431 -0
- data/app/javascript/poetry/core/index.js +218 -0
- data/app/javascript/poetry/core/mask_controller.js +500 -0
- data/app/javascript/poetry/core/menu_controller.js +1050 -0
- data/app/javascript/poetry/core/menubar_controller.js +329 -0
- data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
- data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
- data/app/javascript/poetry/core/number_field_controller.js +404 -0
- data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
- data/app/javascript/poetry/core/otp_controller.js +219 -0
- data/app/javascript/poetry/core/popover_controller.js +300 -0
- data/app/javascript/poetry/core/popper_controller.js +439 -0
- data/app/javascript/poetry/core/pressed_controller.js +74 -0
- data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
- data/app/javascript/poetry/core/radio_group_controller.js +197 -0
- data/app/javascript/poetry/core/resizable_controller.js +202 -0
- data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
- data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
- data/app/javascript/poetry/core/search_field_controller.js +95 -0
- data/app/javascript/poetry/core/select_controller.js +924 -0
- data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
- data/app/javascript/poetry/core/sheet_controller.js +24 -0
- data/app/javascript/poetry/core/sidebar_controller.js +272 -0
- data/app/javascript/poetry/core/slider_controller.js +419 -0
- data/app/javascript/poetry/core/state_controller.js +82 -0
- data/app/javascript/poetry/core/table_selection_controller.js +153 -0
- data/app/javascript/poetry/core/tabs_controller.js +151 -0
- data/app/javascript/poetry/core/tag_group_controller.js +134 -0
- data/app/javascript/poetry/core/toast_controller.js +228 -0
- data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
- data/app/javascript/poetry/core/toaster_controller.js +197 -0
- data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
- data/app/javascript/poetry/core/tooltip_controller.js +541 -0
- data/app/javascript/poetry/core/tree_controller.js +265 -0
- data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
- data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
- data/app/views/poetry/core/preview.html.erb +13 -0
- data/config/component_registry.yml +20 -0
- data/config/controllers_manifest.json +1528 -0
- data/config/importmap.rb +11 -0
- data/config/state_vocabulary.json +31 -0
- data/lib/active_model/type/list.rb +33 -0
- data/lib/active_model/type/symbol.rb +36 -0
- data/lib/poetry/core/check/stable_identity.rb +104 -0
- data/lib/poetry/core/check.rb +1445 -0
- data/lib/poetry/core/config.rb +286 -0
- data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
- data/lib/poetry/core/css/bem_merger.rb +45 -0
- data/lib/poetry/core/css/bem_reference.rb +87 -0
- data/lib/poetry/core/css/override_scan.rb +110 -0
- data/lib/poetry/core/css/resolver.rb +161 -0
- data/lib/poetry/core/css/safelist.rb +41 -0
- data/lib/poetry/core/css/tailwind_merger.rb +102 -0
- data/lib/poetry/core/css/template_classes.rb +102 -0
- data/lib/poetry/core/css/theme_coverage.rb +72 -0
- data/lib/poetry/core/css/var_coverage.rb +75 -0
- data/lib/poetry/core/css/verifier.rb +106 -0
- data/lib/poetry/core/design_lint.rb +720 -0
- data/lib/poetry/core/design_md/import.rb +250 -0
- data/lib/poetry/core/design_md.rb +461 -0
- data/lib/poetry/core/engine.rb +71 -0
- data/lib/poetry/core/errors.rb +53 -0
- data/lib/poetry/core/html/attributes.rb +667 -0
- data/lib/poetry/core/icons.rb +157 -0
- data/lib/poetry/core/llms_text.rb +354 -0
- data/lib/poetry/core/page_architectures.rb +304 -0
- data/lib/poetry/core/part_contract.rb +285 -0
- data/lib/poetry/core/preview/abstract.rb +121 -0
- data/lib/poetry/core/preview/base.rb +227 -0
- data/lib/poetry/core/preview/sidecarable.rb +136 -0
- data/lib/poetry/core/preview/template.rb +186 -0
- data/lib/poetry/core/recipe_items.rb +82 -0
- data/lib/poetry/core/registry.rb +363 -0
- data/lib/poetry/core/registry_address.rb +115 -0
- data/lib/poetry/core/registry_client.rb +217 -0
- data/lib/poetry/core/registry_installer.rb +234 -0
- data/lib/poetry/core/registry_items.rb +184 -0
- data/lib/poetry/core/skill_text.rb +288 -0
- data/lib/poetry/core/stable_id.rb +110 -0
- data/lib/poetry/core/stimulus/builder.rb +395 -0
- data/lib/poetry/core/stimulus/declarations.rb +444 -0
- data/lib/poetry/core/stimulus/manifest.rb +68 -0
- data/lib/poetry/core/stimulus/merger.rb +131 -0
- data/lib/poetry/core/stimulus_contract.rb +263 -0
- data/lib/poetry/core/tag_helper.rb +10 -0
- data/lib/poetry/core/template_compile.rb +74 -0
- data/lib/poetry/core/token_import.rb +317 -0
- data/lib/poetry/core/tokens/color.rb +215 -0
- data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
- data/lib/poetry/core/tokens/generator.rb +211 -0
- data/lib/poetry/core/tokens.rb +117 -0
- data/lib/poetry/core/version.rb +9 -0
- data/lib/poetry/core.rb +52 -0
- data/lib/poetry-core.rb +5 -0
- data/tokens/aliases.css +145 -0
- data/tokens/tailwind-theme.css +47 -0
- data/tokens/tokens.css +78 -0
- data/tokens/tokens.dtcg.json +779 -0
- data/vendor/floating-ui/LICENSE +20 -0
- data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
- data/vendor/shadcn-tailwind/LICENSE +21 -0
- data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
- data/vendor/shadcn-tailwind/tailwind.css +629 -0
- data/vendor/tw-animate-css/LICENSE +21 -0
- data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
- data/vendor/tw-animate-css/tw-animate.css +1 -0
- 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
|
+
}
|