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,404 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// NumberField: a formatted
|
|
4
|
+
// visible <input type=text> over a hidden <input type=number> that is the
|
|
5
|
+
// form/validation truth - the spinbutton ARIA pattern is deliberately
|
|
6
|
+
// not used. The controller owns stepping (arrows, steppers with
|
|
7
|
+
// press-and-hold, opt-in wheel), parse/clamp/format, and the two-input
|
|
8
|
+
// sync. Number | null value model: empty is null, never NaN.
|
|
9
|
+
//
|
|
10
|
+
// Deliberate v1 boundaries (documented in the component):
|
|
11
|
+
// no scrub area, Latin-digit parsing only (locale separators and
|
|
12
|
+
// currency/percent symbols ARE handled via Intl.formatToParts), and the
|
|
13
|
+
// server renders the raw number - the display formats on connect.
|
|
14
|
+
const START_AUTO_CHANGE_DELAY = 400
|
|
15
|
+
const CHANGE_VALUE_TICK_DELAY = 60
|
|
16
|
+
|
|
17
|
+
export default class extends Controller {
|
|
18
|
+
static targets = ["input", "hidden", "increment", "decrement"]
|
|
19
|
+
|
|
20
|
+
static values = {
|
|
21
|
+
min: Number,
|
|
22
|
+
max: Number,
|
|
23
|
+
step: { type: Number, default: 1 },
|
|
24
|
+
largeStep: { type: Number, default: 10 },
|
|
25
|
+
smallStep: { type: Number, default: 0.1 },
|
|
26
|
+
snap: Boolean,
|
|
27
|
+
wheel: Boolean,
|
|
28
|
+
format: Object,
|
|
29
|
+
locale: String
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static events = ["poetry:number-field:change", "poetry:number-field:commit"]
|
|
33
|
+
|
|
34
|
+
#value = null
|
|
35
|
+
#focusedOnce = false
|
|
36
|
+
#holdTimer = null
|
|
37
|
+
#holdInterval = null
|
|
38
|
+
#holdChanged = false
|
|
39
|
+
#pressTicked = false
|
|
40
|
+
#onWheel = null
|
|
41
|
+
#onRelease = null
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Adopts the hidden value, paints the formatted display, reflects the
|
|
45
|
+
* boundaries, and wires the opt-in wheel plus the window-level release
|
|
46
|
+
* listeners.
|
|
47
|
+
*/
|
|
48
|
+
connect() {
|
|
49
|
+
this.#value = this.#fromHidden()
|
|
50
|
+
if (this.#value !== null) this.inputTarget.value = this.#format(this.#value)
|
|
51
|
+
this.#reflect()
|
|
52
|
+
if (this.wheelValue) {
|
|
53
|
+
this.#onWheel = (event) => this.#wheel(event)
|
|
54
|
+
this.inputTarget.addEventListener("wheel", this.#onWheel, { passive: false })
|
|
55
|
+
}
|
|
56
|
+
// Releases outside the button (or after a boundary disables it) must
|
|
57
|
+
// still stop the repeat and commit - window-level, like slider drags.
|
|
58
|
+
this.#onRelease = () => this.#endHold()
|
|
59
|
+
window.addEventListener("pointerup", this.#onRelease)
|
|
60
|
+
window.addEventListener("pointercancel", this.#onRelease)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Unwires the wheel/release listeners and stops any repeat. */
|
|
64
|
+
disconnect() {
|
|
65
|
+
if (this.#onWheel) this.inputTarget.removeEventListener("wheel", this.#onWheel)
|
|
66
|
+
window.removeEventListener("pointerup", this.#onRelease)
|
|
67
|
+
window.removeEventListener("pointercancel", this.#onRelease)
|
|
68
|
+
this.#stopRepeat()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- input events -------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The visible input's keydown action: ArrowUp/Down step (Shift = large
|
|
75
|
+
* step, Alt = small), Home/End jump to the bounds, and printable keys
|
|
76
|
+
* pass the character gate.
|
|
77
|
+
*
|
|
78
|
+
* @param {KeyboardEvent} event
|
|
79
|
+
*/
|
|
80
|
+
keydown(event) {
|
|
81
|
+
if (this.inputTarget.readOnly) return
|
|
82
|
+
|
|
83
|
+
switch (event.key) {
|
|
84
|
+
case "ArrowUp":
|
|
85
|
+
case "ArrowDown":
|
|
86
|
+
event.preventDefault()
|
|
87
|
+
this.#step(event.key === "ArrowUp" ? 1 : -1, event)
|
|
88
|
+
this.#commit()
|
|
89
|
+
return
|
|
90
|
+
case "Home":
|
|
91
|
+
if (!this.hasMinValue) return
|
|
92
|
+
event.preventDefault()
|
|
93
|
+
this.#apply(this.minValue, { display: true })
|
|
94
|
+
this.#commit()
|
|
95
|
+
return
|
|
96
|
+
case "End":
|
|
97
|
+
if (!this.hasMaxValue) return
|
|
98
|
+
event.preventDefault()
|
|
99
|
+
this.#apply(this.maxValue, { display: true })
|
|
100
|
+
this.#commit()
|
|
101
|
+
return
|
|
102
|
+
default:
|
|
103
|
+
this.#gate(event)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The input action: text updates freely; the value goes live only
|
|
109
|
+
* while parseable. The display is NEVER rewritten mid-typing
|
|
110
|
+
* (dirty-text authority).
|
|
111
|
+
*/
|
|
112
|
+
input() {
|
|
113
|
+
const text = this.inputTarget.value
|
|
114
|
+
if (text.trim() === "") {
|
|
115
|
+
this.#apply(null, { display: false })
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
const parsed = this.#parse(text)
|
|
119
|
+
if (parsed !== null) this.#apply(parsed, { display: false })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The focus action: the first focus parks the caret at the end. */
|
|
123
|
+
focus() {
|
|
124
|
+
if (this.#focusedOnce) return
|
|
125
|
+
|
|
126
|
+
this.#focusedOnce = true
|
|
127
|
+
const end = this.inputTarget.value.length
|
|
128
|
+
this.inputTarget.setSelectionRange(end, end)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The blur action - the text commit point: empty clears, unparseable
|
|
133
|
+
* text is left as typed with no commit, parseable text clamps and
|
|
134
|
+
* normalizes to the canonical formatted display (never snapped to
|
|
135
|
+
* step - blur is not a correction gesture).
|
|
136
|
+
*/
|
|
137
|
+
blur() {
|
|
138
|
+
const text = this.inputTarget.value
|
|
139
|
+
if (text.trim() === "") {
|
|
140
|
+
if (this.#value !== null || text !== "") this.#apply(null, { display: true })
|
|
141
|
+
this.#commit()
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const parsed = this.#parse(text)
|
|
145
|
+
if (parsed === null) return
|
|
146
|
+
|
|
147
|
+
this.#apply(this.#clamp(parsed), { display: true })
|
|
148
|
+
this.#commit()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- steppers -----------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Each stepper's pointerdown action: steps once, then repeats after the
|
|
155
|
+
* hold delay (mouse focuses the input; touch would pop the software
|
|
156
|
+
* keyboard).
|
|
157
|
+
*
|
|
158
|
+
* @param {PointerEvent} event
|
|
159
|
+
*/
|
|
160
|
+
press(event) {
|
|
161
|
+
if (event.button !== 0 || this.inputTarget.readOnly) return
|
|
162
|
+
|
|
163
|
+
// Mouse focuses the input (touch would pop the software keyboard).
|
|
164
|
+
if (event.pointerType !== "touch") this.inputTarget.focus()
|
|
165
|
+
this.#pressTicked = true
|
|
166
|
+
this.#holdChanged = false
|
|
167
|
+
const direction = this.#directionFor(event.currentTarget)
|
|
168
|
+
if (!this.#tick(direction, event)) return
|
|
169
|
+
|
|
170
|
+
this.#holdTimer = setTimeout(() => {
|
|
171
|
+
this.#holdInterval = setInterval(() => {
|
|
172
|
+
if (!this.#tick(direction, event)) this.#stopRepeat()
|
|
173
|
+
}, CHANGE_VALUE_TICK_DELAY)
|
|
174
|
+
}, START_AUTO_CHANGE_DELAY)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The stepper's click action: quick touch taps synthesize click
|
|
179
|
+
* without a held pointer - step once, but never double-step after a
|
|
180
|
+
* handled pointerdown.
|
|
181
|
+
*
|
|
182
|
+
* @param {MouseEvent} event
|
|
183
|
+
*/
|
|
184
|
+
tap(event) {
|
|
185
|
+
if (this.#pressTicked) {
|
|
186
|
+
this.#pressTicked = false
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (this.inputTarget.readOnly) return
|
|
190
|
+
|
|
191
|
+
this.#step(this.#directionFor(event.currentTarget), event)
|
|
192
|
+
this.#commit()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The stepper's pointerleave action: stops the repeat. */
|
|
196
|
+
leave() {
|
|
197
|
+
this.#stopRepeat()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- mechanics ----------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
#endHold() {
|
|
203
|
+
this.#stopRepeat()
|
|
204
|
+
if (this.#holdChanged) this.#commit()
|
|
205
|
+
this.#holdChanged = false
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#stopRepeat() {
|
|
209
|
+
clearTimeout(this.#holdTimer)
|
|
210
|
+
clearInterval(this.#holdInterval)
|
|
211
|
+
this.#holdTimer = null
|
|
212
|
+
this.#holdInterval = null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#tick(direction, event) {
|
|
216
|
+
// Disabled/readonly mid-hold (a server morph can do this): stop the
|
|
217
|
+
// repeat rather than mutating a control the user can no longer reach.
|
|
218
|
+
if (this.inputTarget.disabled || this.inputTarget.readOnly) return false
|
|
219
|
+
|
|
220
|
+
const before = this.#value
|
|
221
|
+
this.#step(direction, event)
|
|
222
|
+
const changed = this.#value !== before
|
|
223
|
+
this.#holdChanged ||= changed
|
|
224
|
+
return changed
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#directionFor(button) {
|
|
228
|
+
return this.hasIncrementTarget && button === this.incrementTarget ? 1 : -1
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#step(direction, event) {
|
|
232
|
+
const amount = event.shiftKey ? this.largeStepValue : (event.altKey ? this.smallStepValue : this.stepValue)
|
|
233
|
+
// Stepping starts from the dirty typed text when parseable (what the
|
|
234
|
+
// user sees), else the committed value, else seeds 0 clamped in range
|
|
235
|
+
// (an all-negative range seeds at max - the nearest representable).
|
|
236
|
+
const base = this.#parse(this.inputTarget.value) ?? this.#value
|
|
237
|
+
const candidate = base === null
|
|
238
|
+
? this.#clamp(0)
|
|
239
|
+
: this.#clamp(this.#maybeSnap(this.#clean(base + direction * amount), direction, event))
|
|
240
|
+
this.#apply(candidate, { display: true })
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#wheel(event) {
|
|
244
|
+
if (event.ctrlKey || document.activeElement !== this.inputTarget) return
|
|
245
|
+
|
|
246
|
+
event.preventDefault()
|
|
247
|
+
if (event.deltaY === 0) return
|
|
248
|
+
|
|
249
|
+
this.#step(event.deltaY > 0 ? -1 : 1, event)
|
|
250
|
+
this.#commit()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Snap to step multiples from base min (or 0): directional for normal /
|
|
254
|
+
// large steps, nearest for the Alt small step.
|
|
255
|
+
#maybeSnap(value, direction, event) {
|
|
256
|
+
if (!this.snapValue) return value
|
|
257
|
+
|
|
258
|
+
const base = this.hasMinValue ? this.minValue : 0
|
|
259
|
+
const steps = (value - base) / this.stepValue
|
|
260
|
+
const snapped = event.altKey
|
|
261
|
+
? Math.round(steps)
|
|
262
|
+
: (direction > 0 ? Math.floor(steps) : Math.ceil(steps))
|
|
263
|
+
return this.#clean(base + snapped * this.stepValue)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#clamp(value) {
|
|
267
|
+
let clamped = value
|
|
268
|
+
if (this.hasMinValue) clamped = Math.max(clamped, this.minValue)
|
|
269
|
+
if (this.hasMaxValue) clamped = Math.min(clamped, this.maxValue)
|
|
270
|
+
return clamped
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Binary float noise cleanup (0.1 + 0.2 -> 0.3), bounded so genuine
|
|
274
|
+
// digits in large values are never rewritten (toPrecision(15) plus an
|
|
275
|
+
// absolute 1e-10 delta cap).
|
|
276
|
+
#clean(value) {
|
|
277
|
+
const cleaned = Number(value.toPrecision(15))
|
|
278
|
+
return Math.abs(cleaned - value) < 1e-10 ? cleaned : value
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#apply(value, { display }) {
|
|
282
|
+
const changed = value !== this.#value
|
|
283
|
+
this.#value = value
|
|
284
|
+
if (display) this.inputTarget.value = value === null ? "" : this.#format(value)
|
|
285
|
+
this.hiddenTarget.value = value === null ? "" : String(value)
|
|
286
|
+
this.#reflect()
|
|
287
|
+
if (changed) {
|
|
288
|
+
this.dispatch("change", { prefix: "poetry:number-field", detail: { value } })
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#commit() {
|
|
293
|
+
this.dispatch("commit", { prefix: "poetry:number-field", detail: { value: this.#value } })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Boundary + filled reflection: steppers disable at their bound (native
|
|
297
|
+
// disabled + data-disabled, written together); the root mirrors filled.
|
|
298
|
+
#reflect() {
|
|
299
|
+
const filled = this.#value !== null
|
|
300
|
+
this.element.toggleAttribute("data-filled", filled)
|
|
301
|
+
this.#reflectButton(this.hasIncrementTarget && this.incrementTarget,
|
|
302
|
+
filled && this.hasMaxValue && this.#value >= this.maxValue)
|
|
303
|
+
this.#reflectButton(this.hasDecrementTarget && this.decrementTarget,
|
|
304
|
+
filled && this.hasMinValue && this.#value <= this.minValue)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#reflectButton(button, atBoundary) {
|
|
308
|
+
if (!button) return
|
|
309
|
+
|
|
310
|
+
const off = atBoundary || this.element.hasAttribute("data-disabled")
|
|
311
|
+
button.disabled = off
|
|
312
|
+
button.toggleAttribute("data-disabled", off)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- parse / format -----------------------------------------------------
|
|
316
|
+
|
|
317
|
+
#fromHidden() {
|
|
318
|
+
const raw = this.hiddenTarget.value.trim()
|
|
319
|
+
if (raw === "") return null
|
|
320
|
+
|
|
321
|
+
const parsed = Number(raw)
|
|
322
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#formatter() {
|
|
326
|
+
if (!this.hasFormatValue || Object.keys(this.formatValue).length === 0) return null
|
|
327
|
+
|
|
328
|
+
this.cachedFormatter ||= new Intl.NumberFormat(
|
|
329
|
+
this.hasLocaleValue && this.localeValue !== "" ? this.localeValue : undefined,
|
|
330
|
+
this.formatValue
|
|
331
|
+
)
|
|
332
|
+
return this.cachedFormatter
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#format(value) {
|
|
336
|
+
const formatter = this.#formatter()
|
|
337
|
+
return formatter ? formatter.format(value) : String(value)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The format's own emitted characters (group/decimal separators,
|
|
341
|
+
// currency/percent symbols) - derived once from formatToParts so parsing
|
|
342
|
+
// and keydown gating accept exactly what formatting produces.
|
|
343
|
+
#formatChars() {
|
|
344
|
+
if (this.cachedFormatChars) return this.cachedFormatChars
|
|
345
|
+
|
|
346
|
+
const chars = { group: new Set(), decimal: ".", symbols: new Set(), percent: false }
|
|
347
|
+
const formatter = this.#formatter()
|
|
348
|
+
if (formatter) {
|
|
349
|
+
chars.percent = this.formatValue.style === "percent"
|
|
350
|
+
for (const sample of [-11222333.45, 11222333.45]) {
|
|
351
|
+
for (const part of formatter.formatToParts(sample)) {
|
|
352
|
+
if (part.type === "group") [...part.value].forEach((c) => chars.group.add(c))
|
|
353
|
+
if (part.type === "decimal") chars.decimal = part.value
|
|
354
|
+
if (["currency", "percentSign", "literal", "unit"].includes(part.type)) {
|
|
355
|
+
[...part.value].forEach((c) => chars.symbols.add(c))
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
this.cachedFormatChars = chars
|
|
361
|
+
return chars
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
#parse(text) {
|
|
365
|
+
const chars = this.#formatChars()
|
|
366
|
+
let cleaned = ""
|
|
367
|
+
for (const c of text.trim()) {
|
|
368
|
+
if (chars.group.has(c) || chars.symbols.has(c)) continue
|
|
369
|
+
cleaned += c === chars.decimal ? "." : c
|
|
370
|
+
}
|
|
371
|
+
cleaned = cleaned.replace(/[−‒–]/g, "-").replace(/\s/g, "")
|
|
372
|
+
if (cleaned === "" || cleaned === "-") return null
|
|
373
|
+
|
|
374
|
+
const parsed = Number(cleaned)
|
|
375
|
+
if (!Number.isFinite(parsed)) return null
|
|
376
|
+
|
|
377
|
+
return chars.percent ? this.#clean(parsed / 100) : parsed
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Keydown character gate: digits, the format's own symbols, minus only
|
|
381
|
+
// when the range admits negatives, one decimal separator. Navigation
|
|
382
|
+
// keys, shortcuts, and IME (229) always pass.
|
|
383
|
+
#gate(event) {
|
|
384
|
+
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey || event.which === 229) return
|
|
385
|
+
|
|
386
|
+
const c = event.key
|
|
387
|
+
const chars = this.#formatChars()
|
|
388
|
+
if (/[0-9]/.test(c) || chars.group.has(c) || chars.symbols.has(c)) return
|
|
389
|
+
if ((c === chars.decimal || c === ".") && !this.inputTarget.value.includes(chars.decimal)) return
|
|
390
|
+
if (c === "-" && (!this.hasMinValue || this.minValue < 0) && !this.inputTarget.value.includes("-")) return
|
|
391
|
+
|
|
392
|
+
event.preventDefault()
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The hidden input's change action: browser autofill lands there -
|
|
397
|
+
* adopt, clamp, commit.
|
|
398
|
+
*/
|
|
399
|
+
hiddenChanged() {
|
|
400
|
+
const adopted = this.#fromHidden()
|
|
401
|
+
this.#apply(adopted === null ? null : this.#clamp(adopted), { display: true })
|
|
402
|
+
this.#commit()
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Optimistic UI for Turbo forms, adapted from an MIT-licensed source
|
|
4
|
+
// (source and license in THIRD_PARTY_NOTICES.md).
|
|
5
|
+
// The optimistic update is AUTHORED AS A TURBO STREAM inside a <template>
|
|
6
|
+
// target - the same vocabulary the server answers in, so prediction and
|
|
7
|
+
// truth share one mental model and there is no bespoke DOM patching. On
|
|
8
|
+
// turbo:submit-start the template contents are cloned into the document
|
|
9
|
+
// and Turbo paints the predicted state immediately; on turbo:submit-end
|
|
10
|
+
// the controller reconciles ONLY when the submission failed, by
|
|
11
|
+
// appending <turbo-stream action="refresh"> - which must morph (the
|
|
12
|
+
// helper documents the turbo-refresh-method=morph meta) so the
|
|
13
|
+
// correction is seamless.
|
|
14
|
+
//
|
|
15
|
+
// THE SERVER CONTRACT (enforced nowhere, so stated everywhere): success
|
|
16
|
+
// answers 204 (or a targeted stream for authoritative correction under
|
|
17
|
+
// contention) - NEVER a redirect, because a redirect under morph
|
|
18
|
+
// refreshes is itself a full reload and defeats the optimism. Failure
|
|
19
|
+
// answers 4xx so event.detail.success is false and the refresh restores
|
|
20
|
+
// authoritative truth. Full story: the Optimistic Forms guide on the poetry docs site.
|
|
21
|
+
export default class OptimisticFormController extends Controller {
|
|
22
|
+
static targets = ["template"]
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Applies every template's stream(s) - the turbo:submit-start action.
|
|
26
|
+
* Rapid resubmits inside the window cannot stack duplicate clones; the
|
|
27
|
+
* first paint is still immediate.
|
|
28
|
+
*/
|
|
29
|
+
apply() {
|
|
30
|
+
const now = Date.now()
|
|
31
|
+
if (this.lastAppliedAt && now - this.lastAppliedAt < 200) return
|
|
32
|
+
this.lastAppliedAt = now
|
|
33
|
+
|
|
34
|
+
this.templateTargets.forEach((template) => {
|
|
35
|
+
document.body.appendChild(template.content.cloneNode(true))
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The turbo:submit-end action: appends the authoritative refresh stream
|
|
41
|
+
* ONLY when the submission failed (the body comment holds the rule).
|
|
42
|
+
*
|
|
43
|
+
* @param {CustomEvent} event - detail.success decides
|
|
44
|
+
*/
|
|
45
|
+
reconcile(event) {
|
|
46
|
+
// On success the optimistic paint already reflects the new state (or
|
|
47
|
+
// the server's targeted stream corrected it); only a rejection needs
|
|
48
|
+
// the authoritative refresh.
|
|
49
|
+
if (event.detail?.success) return
|
|
50
|
+
|
|
51
|
+
document.body.insertAdjacentHTML(
|
|
52
|
+
"beforeend",
|
|
53
|
+
'<turbo-stream action="refresh"></turbo-stream>'
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// The InputOTP projection (the single-input architecture, poetry's own
|
|
4
|
+
// build - no npm dependency): ONE real native <input> holds the whole
|
|
5
|
+
// value, stretched invisibly over the slot row, so paste, SMS autofill,
|
|
6
|
+
// IME, constraint validation and form serialization are all native and AT
|
|
7
|
+
// sees ONE text field. The n slot cells are an aria-hidden MIRROR painted
|
|
8
|
+
// here - this controller contains ZERO editing logic:
|
|
9
|
+
//
|
|
10
|
+
// - #sync filters input.value per-character through the pattern + truncates
|
|
11
|
+
// to length (writing back only when filtering changed it - that one line
|
|
12
|
+
// IS paste splitting: "123-456" under digits becomes "123456"), paints
|
|
13
|
+
// slot[i] with value[i], and projects the caret: the active cell is
|
|
14
|
+
// min(selectionStart, length - 1) while focused (data-active), with the
|
|
15
|
+
// fake blinking caret element visible only on the active EMPTY cell.
|
|
16
|
+
// - Auto-advance and backspace-retreat are not features: typing moves the
|
|
17
|
+
// native caret forward, Backspace moves it back - the active cell is a
|
|
18
|
+
// PROJECTION of selectionStart. Arrows/Home/End/IME: native, re-projected
|
|
19
|
+
// via the document-level selectionchange listener (bound on focus,
|
|
20
|
+
// unbound on blur - n OTP fields must not all re-project on every caret
|
|
21
|
+
// move anywhere).
|
|
22
|
+
// - poetry:otp:change fires per accepted mutation; poetry:otp:complete
|
|
23
|
+
// fires once when the value reaches length and re-arms below it (the
|
|
24
|
+
// enable-the-submit-button hook - it never submits).
|
|
25
|
+
export default class OtpController extends Controller {
|
|
26
|
+
// The events this controller dispatches (manifest surface;
|
|
27
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
28
|
+
static events = ["poetry:otp:change", "poetry:otp:complete"]
|
|
29
|
+
|
|
30
|
+
static values = {
|
|
31
|
+
length: { type: Number, default: 6 },
|
|
32
|
+
pattern: { type: String, default: "\\d" }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static targets = ["input", "slot"]
|
|
36
|
+
|
|
37
|
+
#regex = null
|
|
38
|
+
#lastValue = null
|
|
39
|
+
#completed = false
|
|
40
|
+
#selectionListener = null
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Compiles the pattern, adopts the input's value, and paints the
|
|
44
|
+
* initial projection (no events dispatched).
|
|
45
|
+
*/
|
|
46
|
+
connect() {
|
|
47
|
+
this.#regex = this.#compile()
|
|
48
|
+
this.#lastValue = this.inputTarget.value
|
|
49
|
+
this.#completed = this.inputTarget.value.length >= this.lengthValue
|
|
50
|
+
this.#sync({ dispatch: false })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Unbinds the document-level selectionchange listener. */
|
|
54
|
+
disconnect() {
|
|
55
|
+
this.#unbindSelection()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The native input's input / focus / blur action (one handler - the
|
|
60
|
+
* projection is idempotent; blur clears the active cell; focus binds
|
|
61
|
+
* the selectionchange re-projection).
|
|
62
|
+
*
|
|
63
|
+
* @param {Event} [event]
|
|
64
|
+
*/
|
|
65
|
+
sync(event) {
|
|
66
|
+
if (event?.type === "focus") this.#bindSelection()
|
|
67
|
+
if (event?.type === "blur") this.#unbindSelection()
|
|
68
|
+
|
|
69
|
+
this.#sync()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The paste action. Paste is the one native path maxlength breaks: the
|
|
74
|
+
* browser truncates the RAW clipboard text to maxlength before any
|
|
75
|
+
* input event, so "123-456" loses its tail before the sync pass could
|
|
76
|
+
* filter the dashes (real Chrome; jsdom doesn't enforce maxlength,
|
|
77
|
+
* which is why the unit tier never saw it). Intercept, filter FIRST,
|
|
78
|
+
* splice at the selection.
|
|
79
|
+
*
|
|
80
|
+
* @param {ClipboardEvent} event
|
|
81
|
+
*/
|
|
82
|
+
paste(event) {
|
|
83
|
+
const text = event.clipboardData?.getData("text") ?? ""
|
|
84
|
+
|
|
85
|
+
event.preventDefault()
|
|
86
|
+
|
|
87
|
+
const input = this.inputTarget
|
|
88
|
+
const accepted = Array.from(text).filter((char) => this.#regex.test(char)).join("")
|
|
89
|
+
const start = input.selectionStart ?? input.value.length
|
|
90
|
+
const end = input.selectionEnd ?? start
|
|
91
|
+
const next = (input.value.slice(0, start) + accepted + input.value.slice(end))
|
|
92
|
+
.slice(0, this.lengthValue)
|
|
93
|
+
|
|
94
|
+
input.value = next
|
|
95
|
+
|
|
96
|
+
const caret = Math.min(start + accepted.length, this.lengthValue)
|
|
97
|
+
|
|
98
|
+
input.setSelectionRange?.(caret, caret)
|
|
99
|
+
this.#sync()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The container's click action (gaps, separators - the input already
|
|
104
|
+
* covers the cells at z-20): focuses the real control.
|
|
105
|
+
*/
|
|
106
|
+
focusInput() {
|
|
107
|
+
if (this.inputTarget.disabled) return
|
|
108
|
+
|
|
109
|
+
this.inputTarget.focus()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- the projection ---
|
|
113
|
+
|
|
114
|
+
#sync({ dispatch = true } = {}) {
|
|
115
|
+
const input = this.inputTarget
|
|
116
|
+
const raw = input.value
|
|
117
|
+
const filtered = Array.from(raw)
|
|
118
|
+
.filter((char) => this.#regex.test(char))
|
|
119
|
+
.join("")
|
|
120
|
+
.slice(0, this.lengthValue)
|
|
121
|
+
|
|
122
|
+
// Write back only when filtering changed it (paste splitting +
|
|
123
|
+
// pattern rejection); the caret lands after the accepted text.
|
|
124
|
+
if (filtered !== raw) {
|
|
125
|
+
input.value = filtered
|
|
126
|
+
|
|
127
|
+
const caret = Math.min(input.selectionStart ?? filtered.length, filtered.length)
|
|
128
|
+
|
|
129
|
+
input.setSelectionRange?.(caret, caret)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.#paint(filtered)
|
|
133
|
+
this.#projectCaret(filtered)
|
|
134
|
+
|
|
135
|
+
if (!dispatch) return
|
|
136
|
+
|
|
137
|
+
if (filtered !== this.#lastValue) {
|
|
138
|
+
this.#lastValue = filtered
|
|
139
|
+
|
|
140
|
+
const complete = filtered.length >= this.lengthValue
|
|
141
|
+
|
|
142
|
+
this.dispatch("change", { prefix: "poetry:otp", detail: { value: filtered, complete } })
|
|
143
|
+
|
|
144
|
+
// complete fires ONCE per rise to full length; editing below re-arms.
|
|
145
|
+
if (complete && !this.#completed) {
|
|
146
|
+
this.#completed = true
|
|
147
|
+
this.dispatch("complete", { prefix: "poetry:otp", detail: { value: filtered } })
|
|
148
|
+
} else if (!complete) {
|
|
149
|
+
this.#completed = false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// slot[i] shows value[i] - painted as the slot's leading text node so the
|
|
155
|
+
// caret element child survives (textContent would wipe it).
|
|
156
|
+
#paint(value) {
|
|
157
|
+
this.slotTargets.forEach((slot, index) => {
|
|
158
|
+
const char = value[index] ?? ""
|
|
159
|
+
let node = slot.firstChild
|
|
160
|
+
|
|
161
|
+
if (!node || node.nodeType !== Node.TEXT_NODE) {
|
|
162
|
+
node = document.createTextNode("")
|
|
163
|
+
slot.prepend(node)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
node.data = char
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Exactly one data-active cell while focused: the caret's cell, clamped
|
|
171
|
+
// to the last slot when the value is complete. The fake caret is visible
|
|
172
|
+
// only when the active cell is EMPTY (a filled active cell shows the
|
|
173
|
+
// ring alone).
|
|
174
|
+
#projectCaret(value) {
|
|
175
|
+
const focused = document.activeElement === this.inputTarget
|
|
176
|
+
const active = focused
|
|
177
|
+
? Math.min(this.inputTarget.selectionStart ?? value.length, this.lengthValue - 1)
|
|
178
|
+
: -1
|
|
179
|
+
|
|
180
|
+
this.slotTargets.forEach((slot, index) => {
|
|
181
|
+
const isActive = index === active
|
|
182
|
+
|
|
183
|
+
slot.setAttribute("data-active", String(isActive))
|
|
184
|
+
|
|
185
|
+
const caret = slot.querySelector("[data-slot=input-otp-caret]")
|
|
186
|
+
|
|
187
|
+
if (caret) caret.hidden = !(isActive && (value[index] ?? "") === "")
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// selectionchange is DOCUMENT-level: bind on focus, unbind on blur (the
|
|
192
|
+
// leak guard) - arrows/Home/End re-project without any input event.
|
|
193
|
+
#bindSelection() {
|
|
194
|
+
if (this.#selectionListener) return
|
|
195
|
+
|
|
196
|
+
this.#selectionListener = () => {
|
|
197
|
+
if (document.activeElement === this.inputTarget) this.#sync()
|
|
198
|
+
}
|
|
199
|
+
document.addEventListener("selectionchange", this.#selectionListener)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#unbindSelection() {
|
|
203
|
+
if (!this.#selectionListener) return
|
|
204
|
+
|
|
205
|
+
document.removeEventListener("selectionchange", this.#selectionListener)
|
|
206
|
+
this.#selectionListener = null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The per-character filter: the pattern value is a regex SOURCE tested
|
|
210
|
+
// one char at a time (anchored so multi-char patterns can't smuggle).
|
|
211
|
+
#compile() {
|
|
212
|
+
try {
|
|
213
|
+
return new RegExp(`^(?:${this.patternValue})$`)
|
|
214
|
+
} catch {
|
|
215
|
+
console.warn(`poetry--core--otp: invalid pattern ${this.patternValue}; falling back to digits`)
|
|
216
|
+
return /^\d$/
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|