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,220 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The Autocomplete: a REAL text
4
+ // input that IS the form value, suggesting from a server-rendered list
5
+ // that filters as you type. This is the input-is-the-value semantic -
6
+ // the sibling of Combobox, whose value is a SELECTED ITEM behind a
7
+ // native <select>. Selecting a suggestion writes the input and closes;
8
+ // the text always submits as ordinary params.
9
+ //
10
+ // The contract's fixed behaviors: locale-aware filtering
11
+ // (toLocaleLowerCase), list scroll reset on every filter pass, and
12
+ // change-reason details on the commit event (item-press | enter-key).
13
+ // Positioning rides the shared popper controller (input = anchor).
14
+ const ITEM_SELECTOR = '[data-slot="autocomplete-item"]'
15
+ const EVENT_PREFIX = "poetry:autocomplete"
16
+
17
+ export default class AutocompleteController extends Controller {
18
+ static events = ["poetry:autocomplete:commit", "poetry:autocomplete:open", "poetry:autocomplete:closed"]
19
+
20
+ static targets = ["input", "content", "list", "empty"]
21
+
22
+ static values = {
23
+ open: { type: Boolean, default: false },
24
+ // Open the popup on focus even before any typing.
25
+ openOnFocus: { type: Boolean, default: true }
26
+ }
27
+
28
+ /** Closes an open popup so its state never outlives the controller. */
29
+ disconnect() {
30
+ if (this.openValue) this.#close()
31
+ }
32
+
33
+ // --- input events --------------------------------------------------------
34
+
35
+ /** The input action: re-filters the list and opens. */
36
+ input() {
37
+ this.#filter()
38
+ if (!this.openValue) this.#open()
39
+ }
40
+
41
+ /** The focus action: opens pre-typing when openOnFocus allows. */
42
+ focus() {
43
+ if (this.openOnFocusValue && !this.openValue) {
44
+ this.#filter()
45
+ this.#open()
46
+ }
47
+ }
48
+
49
+ /**
50
+ * The focusout action: closes, unless focus moved into the popup (an
51
+ * item click commits first - the body comment).
52
+ *
53
+ * @param {FocusEvent} event
54
+ */
55
+ blurred(event) {
56
+ // Focus moving into the popup (a click on an item) must not close
57
+ // before the click commits - the item's pointerdown commits first.
58
+ if (event.relatedTarget instanceof Element && this.element.contains(event.relatedTarget)) return
59
+
60
+ if (this.openValue) this.#close()
61
+ }
62
+
63
+ /**
64
+ * The input's keydown action: arrows open / move the highlight, Enter
65
+ * commits it, Escape closes (consumed - the next press reaches the
66
+ * layer above), Tab closes and passes through. IME keydowns are
67
+ * ignored.
68
+ *
69
+ * @param {KeyboardEvent} event
70
+ */
71
+ keydown(event) {
72
+ if (event.isComposing || event.keyCode === 229) return
73
+
74
+ switch (event.key) {
75
+ case "ArrowDown":
76
+ case "ArrowUp": {
77
+ event.preventDefault()
78
+ if (!this.openValue) return this.#open()
79
+
80
+ this.#moveHighlight(event.key === "ArrowDown" ? 1 : -1)
81
+ break
82
+ }
83
+ case "Enter": {
84
+ const highlighted = this.#highlighted()
85
+ if (!this.openValue || !highlighted) return
86
+
87
+ event.preventDefault()
88
+ this.#commit(highlighted, "enter-key")
89
+ break
90
+ }
91
+ case "Escape": {
92
+ if (!this.openValue) return
93
+
94
+ event.preventDefault()
95
+ event.stopPropagation() // consumed: this press closes; the next reaches the layer above
96
+ this.#close()
97
+ break
98
+ }
99
+ case "Tab": {
100
+ if (this.openValue) this.#close()
101
+ break
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Each item's pointerdown action (not click): commits BEFORE the
108
+ * input's blur closes the popup.
109
+ *
110
+ * @param {PointerEvent} event
111
+ */
112
+ itemPress(event) {
113
+ const item = event.target.closest(ITEM_SELECTOR)
114
+ if (!item || item.hasAttribute("data-disabled")) return
115
+
116
+ event.preventDefault() // keep focus on the input
117
+ this.#commit(item, "item-press")
118
+ }
119
+
120
+ /**
121
+ * Each item's pointerenter action: moves the highlight under the
122
+ * pointer.
123
+ *
124
+ * @param {PointerEvent} event
125
+ */
126
+ itemEnter(event) {
127
+ const item = event.target.closest(ITEM_SELECTOR)
128
+ if (!item || item.hasAttribute("data-disabled")) return
129
+
130
+ this.#setHighlight(item)
131
+ }
132
+
133
+ // --- internals -----------------------------------------------------------
134
+
135
+ #items() {
136
+ return [...this.listTarget.querySelectorAll(ITEM_SELECTOR)]
137
+ }
138
+
139
+ #visibleItems() {
140
+ return this.#items().filter((item) => !item.hidden && !item.hasAttribute("data-disabled"))
141
+ }
142
+
143
+ #highlighted() {
144
+ return this.#items().find((item) => item.hasAttribute("data-highlighted") && !item.hidden)
145
+ }
146
+
147
+ #filter() {
148
+ const query = this.inputTarget.value.trim().toLocaleLowerCase()
149
+ let any = false
150
+
151
+ this.#items().forEach((item) => {
152
+ const label = (item.dataset.label || item.textContent).trim().toLocaleLowerCase()
153
+ const match = query === "" || label.includes(query)
154
+ item.hidden = !match
155
+ any ||= match
156
+ })
157
+
158
+ // The list scroll resets on every filter pass - a stale scroll
159
+ // position over fresh results misleads.
160
+ this.listTarget.scrollTop = 0
161
+ this.#syncEmpty(any)
162
+ this.#clearHighlight()
163
+ }
164
+
165
+ #syncEmpty(any) {
166
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = any
167
+ this.contentTarget.toggleAttribute("data-empty", !any)
168
+ }
169
+
170
+ #moveHighlight(direction) {
171
+ const items = this.#visibleItems()
172
+ if (items.length === 0) return
173
+
174
+ const current = items.indexOf(this.#highlighted())
175
+ const next = current === -1
176
+ ? (direction > 0 ? 0 : items.length - 1)
177
+ : (current + direction + items.length) % items.length
178
+ this.#setHighlight(items[next])
179
+ }
180
+
181
+ #setHighlight(item) {
182
+ this.#clearHighlight()
183
+ item.setAttribute("data-highlighted", "")
184
+ this.inputTarget.setAttribute("aria-activedescendant", item.id)
185
+ item.scrollIntoView({ block: "nearest" })
186
+ }
187
+
188
+ #clearHighlight() {
189
+ this.#items().forEach((item) => item.removeAttribute("data-highlighted"))
190
+ this.inputTarget.removeAttribute("aria-activedescendant")
191
+ }
192
+
193
+ #commit(item, reason) {
194
+ this.inputTarget.value = item.dataset.value ?? (item.dataset.label || item.textContent.trim())
195
+ this.inputTarget.dispatchEvent(new Event("input", { bubbles: true }))
196
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
197
+ this.dispatch("commit", { prefix: EVENT_PREFIX, detail: { value: this.inputTarget.value, reason } })
198
+ this.#close()
199
+ this.inputTarget.focus()
200
+ }
201
+
202
+ #open() {
203
+ this.openValue = true
204
+ this.contentTarget.hidden = false
205
+ this.contentTarget.setAttribute("data-open", "")
206
+ this.contentTarget.removeAttribute("data-closed")
207
+ this.inputTarget.setAttribute("aria-expanded", "true")
208
+ this.dispatch("open", { prefix: EVENT_PREFIX })
209
+ }
210
+
211
+ #close() {
212
+ this.openValue = false
213
+ this.contentTarget.hidden = true
214
+ this.contentTarget.removeAttribute("data-open")
215
+ this.contentTarget.setAttribute("data-closed", "")
216
+ this.inputTarget.setAttribute("aria-expanded", "false")
217
+ this.#clearHighlight()
218
+ this.dispatch("closed", { prefix: EVENT_PREFIX })
219
+ }
220
+ }
@@ -0,0 +1,406 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The Calendar engine, decided OWN-THE-ENGINE: no date-picker library
4
+ // (a heavy JS dep). The month grid is SERVER-RENDERED (Ruby Date math) so a
5
+ // no-JS page shows a valid calendar; this controller adds month navigation
6
+ // (regenerating the 42-cell grid from plain Date math - reusing the day
7
+ // button DOM, no innerHTML churn), single-date selection (writing the
8
+ // hidden input + the data-selected/aria vocabulary), and roving arrow-key
9
+ // focus over the days. Selection is a real form value; the DatePicker
10
+ // composes this inside a Popover.
11
+ //
12
+ // Range mode: mode="range" swaps the click path for a
13
+ // transcription of an MIT-licensed range-selection algorithm (source and
14
+ // license in THIRD_PARTY_NOTICES.md) and the reflection
15
+ // for the range vocabulary (data-range-start/middle/end - dictionary
16
+ // classes that shipped inert until range mode). The form value is TWO
17
+ // hidden inputs (name[start]/name[end] - the wire shape is poetry's).
18
+ // The transcribed semantics hold exactly: an incomplete
19
+ // start-only pick renders as a plain selected single day; the range
20
+ // vocabulary appears only once the range completes.
21
+ //
22
+ // Dropdown caption (caption_layout: :dropdown), the invisible-select overlay
23
+ // pattern: each [data-calendar-unit=month|year] wrapper holds a visible
24
+ // text label (calendar-dropdown-value) with the real select stretched
25
+ // invisibly over it - #jump reads the selects, #render reflects
26
+ // navigation back into both select values AND label text (no caption
27
+ // target in that mode). Week numbers: one role=rowheader per week; each
28
+ // row's Thursday decides the ISO number (matches Ruby Date#cweek under
29
+ // any week_start).
30
+ //
31
+ // Still deferred: multiple months.
32
+ const DAY_SELECTOR = '[data-slot="calendar-day"]'
33
+ const MS_PER_DAY = 86400000
34
+
35
+ // The component-facing event namespace (the poetry:<component> rule).
36
+ const EVENT_PREFIX = "poetry:calendar"
37
+
38
+ export default class CalendarController extends Controller {
39
+ // The events this controller dispatches (manifest surface;
40
+ // events_declaration.test.js enforces the list stays honest).
41
+ static events = ["poetry:calendar:change"]
42
+
43
+ static targets = ["grid", "caption", "input", "startInput", "endInput", "day"]
44
+ static values = {
45
+ month: String, // the visible month, "YYYY-MM"
46
+ selected: String, // the chosen date, "YYYY-MM-DD" (or "")
47
+ mode: { type: String, default: "single" }, // "single" | "range"
48
+ rangeStart: String, // "YYYY-MM-DD" (or "")
49
+ rangeEnd: String,
50
+ weekStart: { type: Number, default: 0 }, // 0 = Sunday
51
+ min: String,
52
+ max: String,
53
+ // The localized month names, handed down by the SERVER (Ruby I18n) so
54
+ // the JS caption needs no Intl - correct under app locale AND in the
55
+ // Intl-less dommy engine. English is the fallback.
56
+ monthNames: {
57
+ type: Array,
58
+ default: ["January", "February", "March", "April", "May", "June",
59
+ "July", "August", "September", "October", "November", "December"]
60
+ }
61
+ }
62
+
63
+ #today = null
64
+ #booted = false
65
+
66
+ /** Syncs the selected/today vocabulary over the server-rendered grid. */
67
+ connect() {
68
+ // The server rendered the initial month correctly; just sync the
69
+ // selected/today vocabulary in case selected was set programmatically.
70
+ this.#reflectSelection()
71
+ this.#booted = true
72
+ }
73
+
74
+ /** The previous-month button's click action. */
75
+ previousMonth() {
76
+ this.#goTo(this.#shiftMonth(-1))
77
+ }
78
+
79
+ /** The next-month button's click action. */
80
+ nextMonth() {
81
+ this.#goTo(this.#shiftMonth(1))
82
+ }
83
+
84
+ /**
85
+ * The dropdown caption's change action (the month/year select
86
+ * wrappers): navigates to the selects' month.
87
+ */
88
+ jump() {
89
+ const read = (unit) => this.element.querySelector(`[data-calendar-unit="${unit}"] select`)?.value
90
+ const month = read("month")
91
+ const year = read("year")
92
+
93
+ if (!month || !year) return
94
+
95
+ this.#goTo(`${year}-${String(month).padStart(2, "0")}`)
96
+ }
97
+
98
+ /**
99
+ * Each day button's click action: single mode writes the selection;
100
+ * range mode runs the addToRange transcription. The change event
101
+ * carries value (single) or start/end (range) as ISO strings.
102
+ *
103
+ * @param {MouseEvent} event
104
+ */
105
+ select(event) {
106
+ const button = event.target.closest(DAY_SELECTOR)
107
+ if (!button || button.disabled) return
108
+
109
+ if (this.modeValue === "range") {
110
+ this.#addToRange(button.dataset.date)
111
+ this.#reflectSelection()
112
+ this.dispatch("change", { prefix: EVENT_PREFIX, detail: { start: this.rangeStartValue, end: this.rangeEndValue } })
113
+ return
114
+ }
115
+
116
+ this.selectedValue = button.dataset.date
117
+ this.#reflectSelection()
118
+ this.dispatch("change", { prefix: EVENT_PREFIX, detail: { value: this.selectedValue } })
119
+ }
120
+
121
+ // The transcribed range-selection algorithm (ISO strings compare
122
+ // lexicographically, so <,> are date order).
123
+ #addToRange(clicked) {
124
+ const start = this.rangeStartValue
125
+ const end = this.rangeEndValue
126
+
127
+ if (!start) {
128
+ this.rangeStartValue = clicked
129
+ this.rangeEndValue = ""
130
+ return
131
+ }
132
+ if (!end) {
133
+ if (clicked === start) this.rangeEndValue = clicked // re-click completes the single-day range
134
+ else if (clicked < start) {
135
+ this.rangeStartValue = clicked
136
+ this.rangeEndValue = start
137
+ } else this.rangeEndValue = clicked
138
+ return
139
+ }
140
+ // A complete range:
141
+ if (clicked === start && clicked === end) {
142
+ this.rangeStartValue = "" // re-click the single-day range clears it
143
+ this.rangeEndValue = ""
144
+ } else if (clicked === start) {
145
+ this.rangeEndValue = clicked // collapse onto the start
146
+ } else if (clicked === end) {
147
+ this.rangeStartValue = clicked // restart from the end
148
+ this.rangeEndValue = ""
149
+ } else if (clicked < start) {
150
+ this.rangeStartValue = clicked // extend backward
151
+ } else {
152
+ this.rangeEndValue = clicked // move the end
153
+ }
154
+ }
155
+
156
+ /**
157
+ * The grid's keydown action: arrows move focus by day/week, PageUp/Down
158
+ * by month, Home/End to the week edges.
159
+ *
160
+ * @param {KeyboardEvent} event
161
+ */
162
+ keydown(event) {
163
+ const current = event.target.closest(DAY_SELECTOR)
164
+ if (!current) return
165
+
166
+ const delta = {
167
+ ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7,
168
+ Home: "week-start", End: "week-end"
169
+ }[event.key]
170
+
171
+ if (event.key === "PageUp") { event.preventDefault(); return this.previousMonth() }
172
+ if (event.key === "PageDown") { event.preventDefault(); return this.nextMonth() }
173
+ if (delta === undefined) return
174
+
175
+ event.preventDefault()
176
+ const date = this.#parse(current.dataset.date)
177
+
178
+ let target
179
+ if (delta === "week-start") target = this.#addDays(date, -this.#weekdayIndex(date))
180
+ else if (delta === "week-end") target = this.#addDays(date, 6 - this.#weekdayIndex(date))
181
+ else target = this.#addDays(date, delta)
182
+
183
+ this.#focusDate(target)
184
+ }
185
+
186
+ // Move focus to a date, crossing a month boundary by re-rendering first.
187
+ // A disabled (out-of-range) target is a WALL, not a landing spot: min/max
188
+ // is the only disabled source and it is contiguous, so navigation simply
189
+ // refuses - focus() on a disabled button is a silent no-op, and stamping
190
+ // it as the roving stop would leave the grid with no working Tab entry.
191
+ #focusDate(date) {
192
+ const iso = this.#iso(date)
193
+
194
+ if (this.#outOfRange(iso)) return
195
+
196
+ if (this.#monthOf(date) !== this.monthValue) {
197
+ this.monthValue = this.#monthOf(date)
198
+ this.#render()
199
+ }
200
+ const button = this.dayTargets.find((day) => day.dataset.date === iso)
201
+ button?.focus()
202
+ this.#rovingStop(button)
203
+ }
204
+
205
+ #goTo(month) {
206
+ this.monthValue = month
207
+ this.#render()
208
+ }
209
+
210
+ /**
211
+ * Stimulus value callback. The DOM is the store (the anchor-point
212
+ * precedent): external writes to selected/month - the DatePicker input
213
+ * variant syncs typed dates this way - re-render on change. The booted
214
+ * gate skips the init echo (the server's own paint - Stimulus fires
215
+ * initial value callbacks BEFORE connect, and a connect-era render
216
+ * would restamp data-today with the client clock over the server's
217
+ * pinned one). Internal writes re-render once more; the in-place cell
218
+ * diff makes that free.
219
+ */
220
+ selectedValueChanged() {
221
+ if (this.#booted) this.#render()
222
+ }
223
+
224
+ /** Stimulus value callback: re-renders on external month writes (see above). */
225
+ monthValueChanged() {
226
+ if (this.#booted) this.#render()
227
+ }
228
+
229
+ // Rewrite the day buttons for the visible month IN PLACE (42 cells,
230
+ // stable layout) - date/label/outside/today/disabled/selected. Reuses
231
+ // the existing button DOM; only cells that changed are touched.
232
+ #render() {
233
+ const cells = this.#buildMonth()
234
+
235
+ this.dayTargets.forEach((button, index) => {
236
+ const cell = cells[index]
237
+ const iso = this.#iso(cell.date)
238
+
239
+ button.dataset.date = iso
240
+ button.querySelector("[data-slot=calendar-day-label]").textContent = String(cell.date.getUTCDate())
241
+ this.#toggle(button, "data-outside", cell.outside)
242
+ this.#toggle(button, "data-today", iso === this.#todayIso())
243
+ button.disabled = this.#outOfRange(iso)
244
+ })
245
+
246
+ if (this.hasCaptionTarget) this.captionTarget.textContent = this.#captionText()
247
+ this.#reflectDropdowns()
248
+ this.#reflectWeekNumbers(cells)
249
+ this.#reflectSelection()
250
+ }
251
+
252
+ // The dropdown caption follows navigation (prev/next must move the
253
+ // selects too, not just the grid). The visible label span mirrors the
254
+ // select (the overlay pattern: the select is invisible on top).
255
+ #reflectDropdowns() {
256
+ const [year, month] = this.monthValue.split("-").map(Number)
257
+
258
+ for (const wrapper of this.element.querySelectorAll("[data-calendar-unit]")) {
259
+ const monthly = wrapper.dataset.calendarUnit === "month"
260
+ const select = wrapper.querySelector("select")
261
+ const label = wrapper.querySelector("[data-slot=\"calendar-dropdown-value\"]")
262
+
263
+ if (select) select.value = String(monthly ? month : year)
264
+ if (label) label.textContent = monthly ? this.monthNamesValue[month - 1] : String(year)
265
+ }
266
+ }
267
+
268
+ // One rowheader per week; the row's Thursday decides the ISO number
269
+ // (mirrors Ruby's Date#cweek, stable under any week_start).
270
+ #reflectWeekNumbers(cells) {
271
+ const numbers = this.element.querySelectorAll('[role="rowheader"][data-slot="calendar-week-number"]')
272
+
273
+ numbers.forEach((cell, row) => {
274
+ const thursday = cells.slice(row * 7, row * 7 + 7).find((c) => c.date.getUTCDay() === 4)
275
+
276
+ cell.textContent = String(this.#isoWeek(thursday.date))
277
+ })
278
+ }
279
+
280
+ #isoWeek(thursday) {
281
+ const yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1)
282
+ const dayOfYear = (thursday.getTime() - yearStart) / MS_PER_DAY + 1
283
+
284
+ return Math.ceil(dayOfYear / 7)
285
+ }
286
+
287
+ #reflectSelection() {
288
+ if (this.modeValue === "range") {
289
+ this.#reflectRange()
290
+ return
291
+ }
292
+ for (const button of this.dayTargets) {
293
+ const selected = button.dataset.date === this.selectedValue && this.selectedValue !== ""
294
+
295
+ this.#toggle(button, "data-selected", selected)
296
+ // aria-selected belongs on the role=gridcell parent, not the button
297
+ // (the ARIA grid contract; a plain button can't carry aria-selected).
298
+ button.closest('[role="gridcell"]')?.setAttribute("aria-selected", selected ? "true" : "false")
299
+ this.#reflectToday(button)
300
+ }
301
+ if (this.hasInputTarget) this.inputTarget.value = this.selectedValue ?? ""
302
+ }
303
+
304
+ // The range vocabulary: the span wears range-start /
305
+ // range-middle / range-end only once COMPLETE; a start-only pick is a
306
+ // plain selected single day. Survives month nav because it re-derives
307
+ // from every visible button's date (a span may extend offscreen).
308
+ #reflectRange() {
309
+ const start = this.rangeStartValue || ""
310
+ const end = this.rangeEndValue || ""
311
+ const complete = start !== "" && end !== ""
312
+
313
+ for (const button of this.dayTargets) {
314
+ const iso = button.dataset.date
315
+ const inSpan = complete ? iso >= start && iso <= end : start !== "" && iso === start
316
+
317
+ this.#toggle(button, "data-selected", !complete && start !== "" && iso === start)
318
+ this.#toggle(button, "data-range-start", complete && iso === start)
319
+ this.#toggle(button, "data-range-end", complete && iso === end)
320
+ this.#toggle(button, "data-range-middle", complete && iso > start && iso < end)
321
+ button.closest('[role="gridcell"]')?.setAttribute("aria-selected", inSpan ? "true" : "false")
322
+ this.#reflectToday(button)
323
+ }
324
+ if (this.hasStartInputTarget) this.startInputTarget.value = start
325
+ if (this.hasEndInputTarget) this.endInputTarget.value = end
326
+ }
327
+
328
+ #reflectToday(button) {
329
+ if (button.dataset.date === this.#todayIso()) button.setAttribute("aria-current", "date")
330
+ else button.removeAttribute("aria-current")
331
+ }
332
+
333
+ // Exactly one day is the tab stop (the roving contract): the selected
334
+ // day, else today in view, else the first enabled day.
335
+ #rovingStop(preferred) {
336
+ const anchor = this.modeValue === "range" ? this.rangeStartValue : this.selectedValue
337
+ const stop = preferred
338
+ || this.dayTargets.find((d) => d.dataset.date === anchor)
339
+ || this.dayTargets.find((d) => d.dataset.date === this.#todayIso())
340
+ || this.dayTargets.find((d) => !d.disabled)
341
+
342
+ for (const day of this.dayTargets) day.setAttribute("tabindex", day === stop ? "0" : "-1")
343
+ }
344
+
345
+ // --- date math (UTC, so DST never shifts a day) --------------------------
346
+
347
+ #buildMonth() {
348
+ const [year, month] = this.monthValue.split("-").map(Number)
349
+ const first = new Date(Date.UTC(year, month - 1, 1))
350
+ const lead = (first.getUTCDay() - this.weekStartValue + 7) % 7
351
+ const start = this.#addDays(first, -lead)
352
+
353
+ return Array.from({ length: 42 }, (_, index) => {
354
+ const date = this.#addDays(start, index)
355
+ return { date, outside: date.getUTCMonth() !== month - 1 }
356
+ })
357
+ }
358
+
359
+ #captionText() {
360
+ const [year, month] = this.monthValue.split("-").map(Number)
361
+ return `${this.monthNamesValue[month - 1]} ${year}`
362
+ }
363
+
364
+ #shiftMonth(by) {
365
+ const [year, month] = this.monthValue.split("-").map(Number)
366
+ const date = new Date(Date.UTC(year, month - 1 + by, 1))
367
+ return this.#monthOf(date)
368
+ }
369
+
370
+ #outOfRange(iso) {
371
+ if (this.minValue && iso < this.minValue) return true
372
+ if (this.maxValue && iso > this.maxValue) return true
373
+ return false
374
+ }
375
+
376
+ #addDays(date, days) {
377
+ return new Date(date.getTime() + days * MS_PER_DAY)
378
+ }
379
+
380
+ #weekdayIndex(date) {
381
+ return (date.getUTCDay() - this.weekStartValue + 7) % 7
382
+ }
383
+
384
+ #parse(iso) {
385
+ const [year, month, day] = iso.split("-").map(Number)
386
+ return new Date(Date.UTC(year, month - 1, day))
387
+ }
388
+
389
+ #iso(date) {
390
+ return date.toISOString().slice(0, 10)
391
+ }
392
+
393
+ #monthOf(date) {
394
+ return this.#iso(date).slice(0, 7)
395
+ }
396
+
397
+ #todayIso() {
398
+ this.#today ??= this.#iso(new Date())
399
+ return this.#today
400
+ }
401
+
402
+ #toggle(element, attribute, on) {
403
+ if (on) element.setAttribute(attribute, "")
404
+ else element.removeAttribute(attribute)
405
+ }
406
+ }