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,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
|
+
}
|