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,419 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { directionOf } from "@poetry/controllers/helpers/direction"
|
|
3
|
+
|
|
4
|
+
// The Slider machine - the only form control with real math. Three concerns,
|
|
5
|
+
// nothing else:
|
|
6
|
+
//
|
|
7
|
+
// 1. THE VALUE MATH (the pure core, unit-tested exhaustively): snap to the
|
|
8
|
+
// step grid with decimal-precision rounding (a 0.1 grid lands on
|
|
9
|
+
// 0.3, never 0.30000000000000004), clamp to
|
|
10
|
+
// [min, max], and clamp against neighbor thumbs +- the min gap
|
|
11
|
+
// (minStepsBetweenThumbs * step) so range thumbs can never cross.
|
|
12
|
+
//
|
|
13
|
+
// 2. TWO INPUT PATHS feeding it: the APG keyboard map per thumb (arrows
|
|
14
|
+
// +-step, Shift+Arrow / PageUp / PageDown +-step*10, Home/End to the
|
|
15
|
+
// thumb's EFFECTIVE min/max) with the orientation x RTL x inverted
|
|
16
|
+
// resolution - horizontal RTL swaps Left/Right only, inverted flips the
|
|
17
|
+
// whole axis, both compose (rtl + inverted = ltr math); and pointer
|
|
18
|
+
// capture on the root - pointerdown jumps the NEAREST thumb (ties to
|
|
19
|
+
// the later index, so stacked thumbs stay separable), the track
|
|
20
|
+
// box is read ONCE at pointerdown (no per-frame layout), pointermove
|
|
21
|
+
// projects the value ABSOLUTELY from the pointer position (overshoot
|
|
22
|
+
// past a neighbor clamps, never swaps), pointerup commits.
|
|
23
|
+
//
|
|
24
|
+
// 3. THE DOM PROJECTION: aria-valuemin/max/now per role=slider thumb - the
|
|
25
|
+
// range bounds are DYNAMIC (neighbor-clamped, rewritten on every
|
|
26
|
+
// neighbor move: APG multithumb), the --slider-start/--slider-end
|
|
27
|
+
// geometry vars consumed by the calc() rules, and one hidden native
|
|
28
|
+
// input per thumb. poetry:slider:change fires per mutation; commit
|
|
29
|
+
// (pointerup / each keydown) syncs the
|
|
30
|
+
// hidden inputs + dispatches native input/change so Rails listeners get
|
|
31
|
+
// one event per gesture, not per frame.
|
|
32
|
+
const LARGE_STEP_MULTIPLIER = 10
|
|
33
|
+
|
|
34
|
+
export default class SliderController extends Controller {
|
|
35
|
+
// The events this controller dispatches (manifest surface;
|
|
36
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
37
|
+
static events = ["poetry:slider:change", "poetry:slider:commit"]
|
|
38
|
+
|
|
39
|
+
static values = {
|
|
40
|
+
min: { type: Number, default: 0 },
|
|
41
|
+
max: { type: Number, default: 100 },
|
|
42
|
+
step: { type: Number, default: 1 },
|
|
43
|
+
value: { type: Array, default: [] },
|
|
44
|
+
minStepsBetweenThumbs: { type: Number, default: 0 },
|
|
45
|
+
orientation: { type: String, default: "horizontal" },
|
|
46
|
+
inverted: { type: Boolean, default: false }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static targets = ["track", "range", "thumb", "input"]
|
|
50
|
+
|
|
51
|
+
#values = []
|
|
52
|
+
#connected = false
|
|
53
|
+
#dragIndex = null
|
|
54
|
+
#trackRect = null
|
|
55
|
+
#gestureChanged = false
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Adopts the value (the Value, else the server-rendered aria-valuenow),
|
|
59
|
+
* projects, and primes the hidden inputs silently.
|
|
60
|
+
*/
|
|
61
|
+
connect() {
|
|
62
|
+
this.#values = this.valueValue.length > 0
|
|
63
|
+
? this.valueValue.map(Number)
|
|
64
|
+
: this.#serverValues()
|
|
65
|
+
|
|
66
|
+
this.#project()
|
|
67
|
+
this.#syncInputs({ dispatch: false })
|
|
68
|
+
this.#connected = true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Ends any in-flight gesture without committing. */
|
|
72
|
+
disconnect() {
|
|
73
|
+
this.#connected = false
|
|
74
|
+
this.#endGesture({ commit: false })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Stimulus value callback - controllable state: a host (outlet / Turbo
|
|
79
|
+
* Stream) may own the value.
|
|
80
|
+
*
|
|
81
|
+
* @param {number[]} value
|
|
82
|
+
*/
|
|
83
|
+
valueValueChanged(value) {
|
|
84
|
+
if (!this.#connected || this.#same(value)) return
|
|
85
|
+
|
|
86
|
+
this.#values = value.map(Number)
|
|
87
|
+
this.#project()
|
|
88
|
+
this.#syncInputs({ dispatch: false })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- the keyboard path (action: keydown->...#keydown on each thumb) ---
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Each thumb's keydown action: the APG map (arrows step, Shift/Page =
|
|
95
|
+
* large step, Home/End to the thumb's EFFECTIVE bounds) under the
|
|
96
|
+
* orientation x RTL x inverted resolution; every keyboard change
|
|
97
|
+
* commits.
|
|
98
|
+
*
|
|
99
|
+
* @param {KeyboardEvent} event
|
|
100
|
+
*/
|
|
101
|
+
keydown(event) {
|
|
102
|
+
if (this.#disabled()) return
|
|
103
|
+
|
|
104
|
+
const index = this.thumbTargets.indexOf(event.currentTarget)
|
|
105
|
+
|
|
106
|
+
if (index === -1) return
|
|
107
|
+
|
|
108
|
+
const candidate = this.#candidateFor(event, index)
|
|
109
|
+
|
|
110
|
+
if (candidate === null) return
|
|
111
|
+
|
|
112
|
+
event.preventDefault() // handled keys must not scroll the page
|
|
113
|
+
|
|
114
|
+
const changed = this.#update(index, candidate)
|
|
115
|
+
|
|
116
|
+
if (changed) this.#commit() // keyboard commits per keydown, contractual
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The APG map with the orientation x RTL x inverted resolution.
|
|
120
|
+
#candidateFor(event, index) {
|
|
121
|
+
switch (event.key) {
|
|
122
|
+
case "Home":
|
|
123
|
+
return this.#lowerBound(index)
|
|
124
|
+
case "End":
|
|
125
|
+
return this.#upperBound(index)
|
|
126
|
+
case "ArrowRight":
|
|
127
|
+
case "ArrowLeft":
|
|
128
|
+
case "ArrowUp":
|
|
129
|
+
case "ArrowDown":
|
|
130
|
+
case "PageUp":
|
|
131
|
+
case "PageDown": {
|
|
132
|
+
let sign = (event.key === "ArrowRight" || event.key === "ArrowUp" || event.key === "PageUp") ? 1 : -1
|
|
133
|
+
|
|
134
|
+
// Horizontal RTL swaps ONLY the Left/Right pair.
|
|
135
|
+
const horizontalPair = event.key === "ArrowRight" || event.key === "ArrowLeft"
|
|
136
|
+
|
|
137
|
+
if (horizontalPair && this.orientationValue === "horizontal" && directionOf(this.element) === "rtl") {
|
|
138
|
+
sign = -sign
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// inverted flips the whole axis (increment keys decrement);
|
|
142
|
+
// composed with the RTL swap above, rtl + inverted cancels.
|
|
143
|
+
if (this.invertedValue) sign = -sign
|
|
144
|
+
|
|
145
|
+
const page = event.key === "PageUp" || event.key === "PageDown"
|
|
146
|
+
const multiplier = (page || event.shiftKey) ? LARGE_STEP_MULTIPLIER : 1
|
|
147
|
+
|
|
148
|
+
return this.#values[index] + sign * this.stepValue * multiplier
|
|
149
|
+
}
|
|
150
|
+
default:
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- the pointer path (action: pointerdown->...#pointerdown on the root) ---
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The root's pointerdown action: jumps the NEAREST thumb to the pointer
|
|
159
|
+
* (ties to the later index), reads the track box once, and starts the
|
|
160
|
+
* window-level drag; pointerup commits.
|
|
161
|
+
*
|
|
162
|
+
* @param {PointerEvent} event
|
|
163
|
+
*/
|
|
164
|
+
pointerdown(event) {
|
|
165
|
+
if (this.#disabled() || this.thumbTargets.length === 0) return
|
|
166
|
+
|
|
167
|
+
// The track box is read ONCE per gesture - no per-frame layout.
|
|
168
|
+
this.#trackRect = this.trackTarget.getBoundingClientRect()
|
|
169
|
+
|
|
170
|
+
const candidate = this.#valueAtPointer(event)
|
|
171
|
+
const index = this.#nearestThumb(candidate)
|
|
172
|
+
|
|
173
|
+
this.#dragIndex = index
|
|
174
|
+
this.#gestureChanged = false
|
|
175
|
+
this.element.setAttribute("data-dragging", "")
|
|
176
|
+
this.thumbTargets[index].setAttribute("data-dragging", "")
|
|
177
|
+
this.thumbTargets[index].focus() // drags announce on the focused thumb
|
|
178
|
+
|
|
179
|
+
if (typeof this.element.setPointerCapture === "function" && event.pointerId !== undefined) {
|
|
180
|
+
// NotFoundError when the pointer is already gone (fast-tap touch race:
|
|
181
|
+
// pointerup can beat the pointerdown action) - capture is an
|
|
182
|
+
// enhancement, the window listeners below carry the drag without it.
|
|
183
|
+
try { this.element.setPointerCapture(event.pointerId) } catch { /* stale pointerId */ }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
window.addEventListener("pointermove", this.#onPointermove)
|
|
187
|
+
window.addEventListener("pointerup", this.#onPointerup)
|
|
188
|
+
|
|
189
|
+
event.preventDefault()
|
|
190
|
+
|
|
191
|
+
if (this.#update(index, candidate)) this.#gestureChanged = true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#onPointermove = (event) => {
|
|
195
|
+
if (this.#dragIndex === null) return
|
|
196
|
+
|
|
197
|
+
// Absolute projection each frame (no delta accumulation): overshooting
|
|
198
|
+
// a neighbor clamps at the gap and never swaps thumbs.
|
|
199
|
+
if (this.#update(this.#dragIndex, this.#valueAtPointer(event))) this.#gestureChanged = true
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#onPointerup = () => {
|
|
203
|
+
this.#endGesture({ commit: true })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#endGesture({ commit }) {
|
|
207
|
+
window.removeEventListener("pointermove", this.#onPointermove)
|
|
208
|
+
window.removeEventListener("pointerup", this.#onPointerup)
|
|
209
|
+
|
|
210
|
+
if (this.#dragIndex === null) return
|
|
211
|
+
|
|
212
|
+
this.element.removeAttribute("data-dragging")
|
|
213
|
+
this.thumbTargets[this.#dragIndex]?.removeAttribute("data-dragging")
|
|
214
|
+
|
|
215
|
+
const changed = this.#gestureChanged
|
|
216
|
+
|
|
217
|
+
this.#dragIndex = null
|
|
218
|
+
this.#trackRect = null
|
|
219
|
+
this.#gestureChanged = false
|
|
220
|
+
|
|
221
|
+
if (commit && changed) this.#commit()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Value at the pointer position: axis + direction + inversion aware.
|
|
225
|
+
#valueAtPointer(event) {
|
|
226
|
+
const rect = this.#trackRect ?? this.trackTarget.getBoundingClientRect()
|
|
227
|
+
let ratio
|
|
228
|
+
|
|
229
|
+
if (this.orientationValue === "vertical") {
|
|
230
|
+
// Vertical grows BOTTOM-up (APG); direction-neutral.
|
|
231
|
+
ratio = rect.height > 0 ? (rect.bottom - event.clientY) / rect.height : 0
|
|
232
|
+
} else {
|
|
233
|
+
ratio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0
|
|
234
|
+
if (directionOf(this.element) === "rtl") ratio = 1 - ratio
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (this.invertedValue) ratio = 1 - ratio
|
|
238
|
+
|
|
239
|
+
ratio = Math.min(Math.max(ratio, 0), 1)
|
|
240
|
+
|
|
241
|
+
return this.minValue + ratio * (this.maxValue - this.minValue)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Nearest thumb; ties resolve to the LATER index (contractual - two
|
|
245
|
+
// thumbs stacked at min can both be picked up).
|
|
246
|
+
#nearestThumb(value) {
|
|
247
|
+
let nearest = 0
|
|
248
|
+
|
|
249
|
+
for (let index = 1; index < this.#values.length; index++) {
|
|
250
|
+
if (Math.abs(this.#values[index] - value) <= Math.abs(this.#values[nearest] - value)) nearest = index
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return nearest
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- the programmatic controllable-state surface ---
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The programmatic controllable-state surface: must keep the thumb
|
|
260
|
+
* count; changes commit.
|
|
261
|
+
*
|
|
262
|
+
* @param {number | number[]} value
|
|
263
|
+
*/
|
|
264
|
+
setValue(value) {
|
|
265
|
+
const values = (Array.isArray(value) ? value : [value]).map(Number)
|
|
266
|
+
|
|
267
|
+
if (values.length !== this.#values.length) {
|
|
268
|
+
console.warn("poetry--core--slider: setValue must keep the thumb count")
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let changed = false
|
|
273
|
+
|
|
274
|
+
for (let index = 0; index < values.length; index++) {
|
|
275
|
+
if (this.#update(index, values[index])) changed = true
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (changed) this.#commit()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- the pure math core ---
|
|
282
|
+
|
|
283
|
+
// snap -> clamp [min, max] -> clamp against neighbors +- the min gap.
|
|
284
|
+
#update(index, candidate) {
|
|
285
|
+
const next = this.#clampToBounds(index, this.#snap(candidate))
|
|
286
|
+
|
|
287
|
+
if (next === this.#values[index]) return false
|
|
288
|
+
|
|
289
|
+
this.#values[index] = next
|
|
290
|
+
this.#project()
|
|
291
|
+
this.dispatch("change", { prefix: "poetry:slider", detail: { value: [...this.#values], index } })
|
|
292
|
+
|
|
293
|
+
return true
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Snap to the step grid anchored at min, with decimal-precision
|
|
297
|
+
// rounding: count the step's decimals, round to
|
|
298
|
+
// that precision - floating-point accumulation never reaches the DOM.
|
|
299
|
+
#snap(value) {
|
|
300
|
+
const steps = Math.round((value - this.minValue) / this.stepValue)
|
|
301
|
+
|
|
302
|
+
return this.#round(this.minValue + steps * this.stepValue)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#round(value) {
|
|
306
|
+
return Number(value.toFixed(this.#precision()))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#precision() {
|
|
310
|
+
return Math.max(this.#decimals(this.stepValue), this.#decimals(this.minValue))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#decimals(value) {
|
|
314
|
+
const text = String(value)
|
|
315
|
+
const point = text.indexOf(".")
|
|
316
|
+
|
|
317
|
+
return point === -1 ? 0 : text.length - point - 1
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#clampToBounds(index, value) {
|
|
321
|
+
return Math.min(Math.max(value, this.#lowerBound(index)), this.#upperBound(index))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The thumb's EFFECTIVE bounds (APG multithumb: the high thumb's min is
|
|
325
|
+
// the low thumb's value + the gap).
|
|
326
|
+
#lowerBound(index) {
|
|
327
|
+
if (index === 0) return this.minValue
|
|
328
|
+
|
|
329
|
+
return this.#round(this.#values[index - 1] + this.#gap())
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#upperBound(index) {
|
|
333
|
+
if (index === this.#values.length - 1) return this.maxValue
|
|
334
|
+
|
|
335
|
+
return this.#round(this.#values[index + 1] - this.#gap())
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
#gap() {
|
|
339
|
+
return this.minStepsBetweenThumbsValue * this.stepValue
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- the DOM projection ---
|
|
343
|
+
|
|
344
|
+
// aria-value* per thumb (range bounds neighbor-clamped, rewritten on
|
|
345
|
+
// EVERY move - the cross-thumb rewrite AT depends on) + the geometry vars.
|
|
346
|
+
#project() {
|
|
347
|
+
this.thumbTargets.forEach((thumb, index) => {
|
|
348
|
+
if (index >= this.#values.length) return
|
|
349
|
+
|
|
350
|
+
thumb.setAttribute("aria-valuemin", String(this.#lowerBound(index)))
|
|
351
|
+
thumb.setAttribute("aria-valuemax", String(this.#upperBound(index)))
|
|
352
|
+
thumb.setAttribute("aria-valuenow", String(this.#values[index]))
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
const start = this.#values.length > 1 ? this.#percent(this.#values[0]) : 0
|
|
356
|
+
const end = this.#percent(this.#values[this.#values.length - 1])
|
|
357
|
+
|
|
358
|
+
this.element.style.setProperty("--slider-start", `${start}%`)
|
|
359
|
+
this.element.style.setProperty("--slider-end", `${end}%`)
|
|
360
|
+
|
|
361
|
+
// Middle thumbs (N-thumb sliders) position via their own anchor var.
|
|
362
|
+
const anchors = this.element.querySelectorAll('[data-slot="slider-anchor"]')
|
|
363
|
+
this.#values.forEach((value, index) => {
|
|
364
|
+
if (index === 0 || index === this.#values.length - 1) return
|
|
365
|
+
|
|
366
|
+
anchors[index]?.style.setProperty("--slider-mid", `${this.#percent(value)}%`)
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#percent(value) {
|
|
371
|
+
const span = this.maxValue - this.minValue
|
|
372
|
+
|
|
373
|
+
return span > 0 ? ((value - this.minValue) / span) * 100 : 0
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Commit (once per gesture / per keydown): hidden inputs sync + native
|
|
377
|
+
// input/change dispatch + the commit event; the Value mirrors for the
|
|
378
|
+
// controllable-state surface.
|
|
379
|
+
#commit() {
|
|
380
|
+
this.#syncInputs({ dispatch: true })
|
|
381
|
+
this.valueValue = [...this.#values] // #same() guards the changed callback
|
|
382
|
+
this.dispatch("commit", { prefix: "poetry:slider", detail: { value: [...this.#values] } })
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#syncInputs({ dispatch }) {
|
|
386
|
+
this.inputTargets.forEach((input, index) => {
|
|
387
|
+
if (index >= this.#values.length) return
|
|
388
|
+
|
|
389
|
+
const next = String(this.#values[index])
|
|
390
|
+
|
|
391
|
+
if (input.value === next) return
|
|
392
|
+
|
|
393
|
+
input.value = next
|
|
394
|
+
|
|
395
|
+
if (dispatch) {
|
|
396
|
+
input.dispatchEvent(new Event("input", { bubbles: true }))
|
|
397
|
+
input.dispatchEvent(new Event("change", { bubbles: true }))
|
|
398
|
+
}
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Server-rendered fallback: thumbs carry aria-valuenow; else one thumb at min.
|
|
403
|
+
#serverValues() {
|
|
404
|
+
const values = this.thumbTargets
|
|
405
|
+
.map((thumb) => Number(thumb.getAttribute("aria-valuenow")))
|
|
406
|
+
.filter((value) => !Number.isNaN(value))
|
|
407
|
+
|
|
408
|
+
return values.length > 0 ? values : [this.minValue]
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
#same(values) {
|
|
412
|
+
return values.length === this.#values.length &&
|
|
413
|
+
values.every((value, index) => Number(value) === this.#values[index])
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
#disabled() {
|
|
417
|
+
return this.element.hasAttribute("data-disabled")
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { setState, stateOf } from "@poetry/controllers/helpers/state"
|
|
3
|
+
import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
|
|
4
|
+
|
|
5
|
+
// The controllable-state controller: seeds the data-open/
|
|
6
|
+
// data-closed pair from a Value default when no other layer owns it, and
|
|
7
|
+
// exposes toggle/open/close actions. "Controlled vs uncontrolled" is just which layer wrote the
|
|
8
|
+
// attribute - a server re-render, the URL, an Outlet, or this default -
|
|
9
|
+
// the controller code is identical either way.
|
|
10
|
+
//
|
|
11
|
+
// Optional reflection targets (the Collapsible contract): a trigger
|
|
12
|
+
// target mirrors aria-expanded; a content target rides the presence
|
|
13
|
+
// helper (the pair flip deferred through animationend, hidden applied
|
|
14
|
+
// only after the exit animation finishes).
|
|
15
|
+
export default class extends Controller {
|
|
16
|
+
static targets = ["trigger", "content"]
|
|
17
|
+
static values = { state: { type: String, default: "closed" } }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Seeds the pair from the Value default when no other layer wrote it,
|
|
21
|
+
* then reflects the current state onto the optional targets.
|
|
22
|
+
*/
|
|
23
|
+
connect() {
|
|
24
|
+
if (!stateOf(this.element)) setState(this.element, this.stateValue)
|
|
25
|
+
this.#reflect(stateOf(this.element))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Abandons any exit animation still holding the content. */
|
|
29
|
+
disconnect() {
|
|
30
|
+
this.#cancelExit?.()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The toggle action: open when closed, close when open. */
|
|
34
|
+
toggle() {
|
|
35
|
+
stateOf(this.element) === "open" ? this.close() : this.open()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Opens: flips the pair, unhides the content target (when present) and
|
|
40
|
+
* runs its entry presence, mirrors aria-expanded onto the trigger.
|
|
41
|
+
*/
|
|
42
|
+
open() {
|
|
43
|
+
setState(this.element, "open")
|
|
44
|
+
this.#cancelExit?.()
|
|
45
|
+
if (this.hasContentTarget) {
|
|
46
|
+
this.contentTarget.hidden = false
|
|
47
|
+
enterPresence(this.contentTarget)
|
|
48
|
+
}
|
|
49
|
+
this.#reflect("open")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Closes: flips the pair and holds the content target through its exit
|
|
54
|
+
* animation before hiding it (the presence contract).
|
|
55
|
+
*/
|
|
56
|
+
close() {
|
|
57
|
+
setState(this.element, "closed")
|
|
58
|
+
if (this.hasContentTarget) {
|
|
59
|
+
this.#cancelExit = exitPresence(this.contentTarget, {
|
|
60
|
+
onRemove: () => {
|
|
61
|
+
this.contentTarget.hidden = true
|
|
62
|
+
this.#cancelExit = null
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
this.#reflect("closed")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#cancelExit
|
|
70
|
+
|
|
71
|
+
#reflect(state) {
|
|
72
|
+
const open = state === "open"
|
|
73
|
+
if (this.hasTriggerTarget) {
|
|
74
|
+
this.triggerTarget.setAttribute("aria-expanded", String(open))
|
|
75
|
+
// Disclosure-trigger reflection: the trigger wears bare
|
|
76
|
+
// data-panel-open while its panel is open (no CSS consumes it yet -
|
|
77
|
+
// the aria-expanded selector styles the chevron).
|
|
78
|
+
setState(this.triggerTarget, open ? "panel-open" : "panel-closed")
|
|
79
|
+
}
|
|
80
|
+
if (this.hasContentTarget && !open && !this.#cancelExit) this.contentTarget.hidden = true
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { announce } from "@poetry/controllers/helpers/announce"
|
|
3
|
+
|
|
4
|
+
// The table row-selection engine (the SelectionManager
|
|
5
|
+
// contract, checkbox-flavored): per-row checkboxes are the form value
|
|
6
|
+
// (selected_ids[] - no JS means plain checkboxes in a form, the honest
|
|
7
|
+
// fallback), this controller adds what HTML cannot:
|
|
8
|
+
//
|
|
9
|
+
// - select-all with a real INDETERMINATE middle state (a JS property,
|
|
10
|
+
// never an attribute), computed over ENABLED rows only (disabled rows
|
|
11
|
+
// are skipped by select-all and ranges - disabledBehavior 'selection').
|
|
12
|
+
// - Shift-click range selection off the ANCHOR model: the
|
|
13
|
+
// anchor is the last plainly-toggled row; Shift sets every row in
|
|
14
|
+
// anchor..target to the ANCHOR row's state (the checkbox idiom).
|
|
15
|
+
// - aria-selected + data-selected mirrored onto rows, count announcements
|
|
16
|
+
// through the announce singleton, and a bubbling selection-change event
|
|
17
|
+
// the ActionBar block feeds on.
|
|
18
|
+
// - poetry:data-table:clear-selection (dispatched by the ActionBar's
|
|
19
|
+
// Escape) clears everything from anywhere inside the wrapper.
|
|
20
|
+
const EVENT_PREFIX = "poetry:data-table"
|
|
21
|
+
|
|
22
|
+
export default class TableSelectionController extends Controller {
|
|
23
|
+
// The events this controller dispatches (manifest surface;
|
|
24
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
25
|
+
static events = ["poetry:data-table:selection-change"]
|
|
26
|
+
|
|
27
|
+
static targets = ["all"]
|
|
28
|
+
static values = {
|
|
29
|
+
label: { type: String, default: "%{count} selected" }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#anchor = null
|
|
33
|
+
#onClear = () => this.#clearAll()
|
|
34
|
+
|
|
35
|
+
/** Wires the ActionBar's clear-selection listener and reflects silently. */
|
|
36
|
+
connect() {
|
|
37
|
+
this.element.addEventListener("poetry:data-table:clear-selection", this.#onClear)
|
|
38
|
+
this.#reflect({ announceCount: false })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Unwires the clear-selection listener. */
|
|
42
|
+
disconnect() {
|
|
43
|
+
this.element.removeEventListener("poetry:data-table:clear-selection", this.#onClear)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Each row checkbox's change action. Shift ranges ride the click that
|
|
48
|
+
* produced the change (change events carry no modifiers - press
|
|
49
|
+
* captures the gesture): a shift-toggle sets anchor..target to the
|
|
50
|
+
* ANCHOR row's state; a plain toggle re-anchors.
|
|
51
|
+
*
|
|
52
|
+
* @param {Event} event
|
|
53
|
+
*/
|
|
54
|
+
toggled(event) {
|
|
55
|
+
const checkbox = event.target
|
|
56
|
+
|
|
57
|
+
if (this.#shiftRange && this.#anchor && this.#anchor !== checkbox) {
|
|
58
|
+
this.#applyRange(this.#anchor, checkbox)
|
|
59
|
+
} else {
|
|
60
|
+
this.#anchor = checkbox
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.#shiftRange = false
|
|
64
|
+
this.#reflect()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The pointerdown/keydown seam: remembers whether the NEXT change was
|
|
69
|
+
* a shift-gesture (change events themselves carry no modifiers).
|
|
70
|
+
*
|
|
71
|
+
* @param {PointerEvent | KeyboardEvent} event
|
|
72
|
+
*/
|
|
73
|
+
press(event) {
|
|
74
|
+
this.#shiftRange = event.shiftKey === true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The select-all checkbox's change action: fans its new state out to
|
|
79
|
+
* every ENABLED row and clears the anchor.
|
|
80
|
+
*
|
|
81
|
+
* @param {Event} event
|
|
82
|
+
*/
|
|
83
|
+
toggleAll(event) {
|
|
84
|
+
const target = event.target.checked
|
|
85
|
+
|
|
86
|
+
for (const checkbox of this.#rowCheckboxes()) {
|
|
87
|
+
if (checkbox.disabled) continue
|
|
88
|
+
|
|
89
|
+
checkbox.checked = target
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.#anchor = null
|
|
93
|
+
this.#reflect()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#shiftRange = false
|
|
97
|
+
|
|
98
|
+
#applyRange(anchor, target) {
|
|
99
|
+
const boxes = this.#rowCheckboxes()
|
|
100
|
+
const from = boxes.indexOf(anchor)
|
|
101
|
+
const to = boxes.indexOf(target)
|
|
102
|
+
|
|
103
|
+
if (from === -1 || to === -1) return
|
|
104
|
+
|
|
105
|
+
const state = anchor.checked
|
|
106
|
+
|
|
107
|
+
for (const checkbox of boxes.slice(Math.min(from, to), Math.max(from, to) + 1)) {
|
|
108
|
+
if (checkbox.disabled) continue
|
|
109
|
+
|
|
110
|
+
checkbox.checked = state
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#clearAll() {
|
|
115
|
+
for (const checkbox of this.#rowCheckboxes()) checkbox.checked = false
|
|
116
|
+
|
|
117
|
+
this.#anchor = null
|
|
118
|
+
this.#reflect()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#reflect({ announceCount = true } = {}) {
|
|
122
|
+
const boxes = this.#rowCheckboxes()
|
|
123
|
+
const enabled = boxes.filter((checkbox) => !checkbox.disabled)
|
|
124
|
+
const selected = boxes.filter((checkbox) => checkbox.checked)
|
|
125
|
+
|
|
126
|
+
for (const checkbox of boxes) {
|
|
127
|
+
const row = checkbox.closest("tr")
|
|
128
|
+
|
|
129
|
+
if (!row) continue
|
|
130
|
+
|
|
131
|
+
row.toggleAttribute("data-selected", checkbox.checked)
|
|
132
|
+
row.setAttribute("aria-selected", String(checkbox.checked))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (this.hasAllTarget) {
|
|
136
|
+
this.allTarget.checked = enabled.length > 0 && selected.length === enabled.length
|
|
137
|
+
this.allTarget.indeterminate = selected.length > 0 && selected.length < enabled.length
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const count = selected.length
|
|
141
|
+
|
|
142
|
+
if (announceCount) announce(this.labelValue.replace("%{count}", String(count)))
|
|
143
|
+
|
|
144
|
+
this.dispatch("selection-change", {
|
|
145
|
+
prefix: EVENT_PREFIX,
|
|
146
|
+
detail: { count, values: selected.map((checkbox) => checkbox.value) }
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#rowCheckboxes() {
|
|
151
|
+
return Array.from(this.element.querySelectorAll("[data-slot='data-table-select-row']"))
|
|
152
|
+
}
|
|
153
|
+
}
|