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,365 @@
|
|
|
1
|
+
import DialogController from "@poetry/controllers/dialog_controller"
|
|
2
|
+
import { isImeKeydown } from "@poetry/controllers/helpers/escape"
|
|
3
|
+
import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
|
|
4
|
+
|
|
5
|
+
// The Drawer: the dialog machinery + the swipe-to-dismiss gesture.
|
|
6
|
+
// Everything hard about the OVERLAY is inherited (native <dialog> platform
|
|
7
|
+
// trap, backdrop-click discrimination, scroll lock, hotkey); this subclass
|
|
8
|
+
// adds the two things a drawer is:
|
|
9
|
+
//
|
|
10
|
+
// * ANIMATED presence - the first consumer of the presence helpers:
|
|
11
|
+
// enter rides the data-starting-style two-frame trick (a transition
|
|
12
|
+
// from --closed-transform), exit HOLDS the dialog through the
|
|
13
|
+
// data-ending-style transition before the native close() (the
|
|
14
|
+
// presence-hold close the sheet styling depends on).
|
|
15
|
+
// * The SWIPE - pointer-captured drag along the dismiss direction,
|
|
16
|
+
// writing the swipe CSS-var contract onto the <dialog> (::backdrop
|
|
17
|
+
// inherits from its originating element, so the overlay fade rides the
|
|
18
|
+
// same vars): --drawer-swipe-movement-x/y (px toward dismissal),
|
|
19
|
+
// --drawer-swipe-progress (0..1), data-swiping while tracking
|
|
20
|
+
// (duration-0 - the drawer follows the finger), and on release either
|
|
21
|
+
// a snap-back or a dismissal whose exit duration scales with
|
|
22
|
+
// --drawer-swipe-strength (mostly-swiped closes fast).
|
|
23
|
+
//
|
|
24
|
+
// Deferred with the rest of the stack machinery (see the Drawer style):
|
|
25
|
+
// snap points, nested-drawer stacking, and bleed.
|
|
26
|
+
const SWIPE_SLOP = 4 // px before a drag counts (clicks stay clicks)
|
|
27
|
+
const DISMISS_PROGRESS = 0.5 // released past halfway -> dismiss
|
|
28
|
+
const DISMISS_VELOCITY = 0.5 // px/ms toward dismissal -> flick-dismiss
|
|
29
|
+
const MIN_STRENGTH = 0.25 // even a full swipe animates the remainder briefly
|
|
30
|
+
|
|
31
|
+
const INTERACTIVE = "button, a[href], input, select, textarea, [contenteditable], [role=button]"
|
|
32
|
+
const HANDLE = '[data-slot="drawer-swipe-handle"]'
|
|
33
|
+
|
|
34
|
+
export default class DrawerController extends DialogController {
|
|
35
|
+
static values = {
|
|
36
|
+
// The dismissal direction.
|
|
37
|
+
direction: { type: String, default: "down" },
|
|
38
|
+
// modal: false opens with show() instead of
|
|
39
|
+
// showModal() - no top layer, no ::backdrop, no focus trap, no
|
|
40
|
+
// scroll lock. The page behind stays fully interactive.
|
|
41
|
+
modal: { type: Boolean, default: true },
|
|
42
|
+
// snapPoints: preset resting heights for a bottom
|
|
43
|
+
// sheet (direction down only), ascending: fractions of the full
|
|
44
|
+
// height (0..1] or CSS lengths ("31rem", "400px"). The popup runs
|
|
45
|
+
// full-height (the dictionary's data-snap-points sizing) and
|
|
46
|
+
// --drawer-snap-point-offset hides the rest; opens at the first
|
|
47
|
+
// point, drags move between points, below the first dismisses.
|
|
48
|
+
snapPoints: { type: Array, default: [] }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#swipe = null
|
|
52
|
+
#closing = false
|
|
53
|
+
#snapIndex = 0
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Opens per the modal value: showModal() plus the scroll lock, or the
|
|
57
|
+
* non-modal show() (no top layer - the component pins the panel; the
|
|
58
|
+
* body comment). The first snap offset lands BEFORE the enter
|
|
59
|
+
* transition starts, then the animated entry runs.
|
|
60
|
+
*/
|
|
61
|
+
open() {
|
|
62
|
+
if (this.modalValue) {
|
|
63
|
+
this.dialogTarget.showModal()
|
|
64
|
+
this.lockScroll()
|
|
65
|
+
} else {
|
|
66
|
+
// show() skips the top layer, so the UA :modal positioning is
|
|
67
|
+
// gone too - the component pins the panel with fixed/inset
|
|
68
|
+
// classes instead. close() stays balanced: unlockScroll no-ops
|
|
69
|
+
// through the instance #locked flag when nothing was locked.
|
|
70
|
+
this.dialogTarget.show()
|
|
71
|
+
}
|
|
72
|
+
// The first snap offset must be in place BEFORE the enter transition
|
|
73
|
+
// starts, so the sheet slides up to its compact peek, not to full.
|
|
74
|
+
if (this.#snapping()) this.#applySnap(0)
|
|
75
|
+
enterPresence(this.dialogTarget)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The non-modal Escape exit (the component wires this keydown action
|
|
80
|
+
* only when modal is false): a non-modal dialog never fires cancel, so
|
|
81
|
+
* Esc needs its own path while focus is inside; modal drawers ride the
|
|
82
|
+
* native cancel.
|
|
83
|
+
*
|
|
84
|
+
* @param {KeyboardEvent} event
|
|
85
|
+
*/
|
|
86
|
+
escapeClose(event) {
|
|
87
|
+
if (this.modalValue) return
|
|
88
|
+
// isImeKeydown: an Escape canceling IME composition must never dismiss.
|
|
89
|
+
if (event.key !== "Escape" || event.defaultPrevented || isImeKeydown(event)) return
|
|
90
|
+
|
|
91
|
+
event.preventDefault()
|
|
92
|
+
this.close()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Closes through the animated exit (data-ending-style), then the
|
|
97
|
+
* native close(), the unlock, and the swipe-var reset. The native
|
|
98
|
+
* cancel event (Esc) routes through here so state stays in sync.
|
|
99
|
+
*
|
|
100
|
+
* @param {Event} [event] - the native cancel event, when Esc drove it
|
|
101
|
+
*/
|
|
102
|
+
close(event) {
|
|
103
|
+
if (event?.type === "cancel") event.preventDefault() // route Esc through the animated path
|
|
104
|
+
if (this.#closing) return
|
|
105
|
+
|
|
106
|
+
this.#closing = true
|
|
107
|
+
exitPresence(this.dialogTarget, {
|
|
108
|
+
onRemove: () => {
|
|
109
|
+
this.#closing = false
|
|
110
|
+
this.#resetSwipeVars()
|
|
111
|
+
this.dialogTarget.close()
|
|
112
|
+
this.unlockScroll()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- the swipe -----------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The <dialog>'s pointerdown action: arms the swipe - always from the
|
|
121
|
+
* handle; elsewhere only when the press is not on a control and no
|
|
122
|
+
* scrollable child still scrolls toward the gesture.
|
|
123
|
+
*
|
|
124
|
+
* @param {PointerEvent} event
|
|
125
|
+
*/
|
|
126
|
+
swipeStart(event) {
|
|
127
|
+
if (event.button !== 0 && event.pointerType === "mouse") return
|
|
128
|
+
if (this.#closing) return
|
|
129
|
+
|
|
130
|
+
const target = event.target instanceof Element ? event.target : null
|
|
131
|
+
if (!target) return
|
|
132
|
+
|
|
133
|
+
// A drag may start on the handle ALWAYS; elsewhere it must not steal
|
|
134
|
+
// from controls or from content that still scrolls toward the gesture.
|
|
135
|
+
if (!target.closest(HANDLE)) {
|
|
136
|
+
if (target.closest(INTERACTIVE)) return
|
|
137
|
+
if (this.#scrollBlocks(target)) return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.#swipe = {
|
|
141
|
+
pointerId: event.pointerId,
|
|
142
|
+
startX: event.clientX, startY: event.clientY,
|
|
143
|
+
lastPosition: this.#along(event), lastTime: event.timeStamp,
|
|
144
|
+
velocity: 0, movement: 0, dragging: false
|
|
145
|
+
}
|
|
146
|
+
this.dialogTarget.setPointerCapture(event.pointerId)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The captured pointermove action: past the slop, tracks the drag -
|
|
151
|
+
* writing the swipe CSS vars (the finger-following, duration-0 window)
|
|
152
|
+
* and the release velocity.
|
|
153
|
+
*
|
|
154
|
+
* @param {PointerEvent} event
|
|
155
|
+
*/
|
|
156
|
+
swipeMove(event) {
|
|
157
|
+
const swipe = this.#swipe
|
|
158
|
+
if (!swipe || event.pointerId !== swipe.pointerId) return
|
|
159
|
+
|
|
160
|
+
const start = this.#axis() === "y" ? swipe.startY : swipe.startX
|
|
161
|
+
// Snap-pointed sheets drag BOTH ways: negative movement (up to the
|
|
162
|
+
// current offset) expands toward fuller points. Plain drawers keep
|
|
163
|
+
// the toward-dismissal clamp.
|
|
164
|
+
const floor = this.#snapping() ? -this.#currentSnapOffset() : 0
|
|
165
|
+
const movement = Math.max(floor, this.#sign() * (this.#along(event) - start))
|
|
166
|
+
|
|
167
|
+
if (!swipe.dragging) {
|
|
168
|
+
if (Math.abs(movement) < SWIPE_SLOP) return
|
|
169
|
+
|
|
170
|
+
swipe.dragging = true
|
|
171
|
+
this.dialogTarget.setAttribute("data-swiping", "")
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const elapsed = event.timeStamp - swipe.lastTime
|
|
175
|
+
if (elapsed > 0) {
|
|
176
|
+
swipe.velocity = (this.#sign() * (this.#along(event) - swipe.lastPosition)) / elapsed
|
|
177
|
+
swipe.lastPosition = this.#along(event)
|
|
178
|
+
swipe.lastTime = event.timeStamp
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
swipe.movement = movement
|
|
182
|
+
this.#writeSwipeVars(movement)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The pointerup action - release physics: dismiss past half the travel
|
|
187
|
+
* or on a flick (exit duration scaled by the remaining travel), else
|
|
188
|
+
* snap back; snap-pointed sheets settle between their points instead.
|
|
189
|
+
*
|
|
190
|
+
* @param {PointerEvent} event
|
|
191
|
+
*/
|
|
192
|
+
swipeEnd(event) {
|
|
193
|
+
const swipe = this.#swipe
|
|
194
|
+
if (!swipe || event.pointerId !== swipe.pointerId) return
|
|
195
|
+
|
|
196
|
+
this.#swipe = null
|
|
197
|
+
if (!swipe.dragging) return
|
|
198
|
+
if (this.#snapping()) return this.#settleSnap(swipe)
|
|
199
|
+
|
|
200
|
+
const progress = swipe.movement / this.#size()
|
|
201
|
+
|
|
202
|
+
if (progress >= DISMISS_PROGRESS || swipe.velocity >= DISMISS_VELOCITY) {
|
|
203
|
+
// The exit transition covers the REMAINING travel - scale it so a
|
|
204
|
+
// mostly-swiped drawer closes fast (the strength contract).
|
|
205
|
+
const strength = Math.max(MIN_STRENGTH, Math.min(1, 1 - progress))
|
|
206
|
+
this.dialogTarget.style.setProperty("--drawer-swipe-strength", String(strength))
|
|
207
|
+
this.dialogTarget.removeAttribute("data-swiping")
|
|
208
|
+
this.close()
|
|
209
|
+
} else {
|
|
210
|
+
// Snap back: re-enable the transition FIRST, then move home.
|
|
211
|
+
this.dialogTarget.removeAttribute("data-swiping")
|
|
212
|
+
requestAnimationFrame(() => this.#writeSwipeVars(0))
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The pointercancel action: abandons the drag and snaps home.
|
|
218
|
+
*
|
|
219
|
+
* @param {PointerEvent} event
|
|
220
|
+
*/
|
|
221
|
+
swipeCancel(event) {
|
|
222
|
+
const swipe = this.#swipe
|
|
223
|
+
if (!swipe || event.pointerId !== swipe.pointerId) return
|
|
224
|
+
|
|
225
|
+
this.#swipe = null
|
|
226
|
+
this.dialogTarget.removeAttribute("data-swiping")
|
|
227
|
+
this.#writeSwipeVars(0)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- snap points -----------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
#snapping() {
|
|
233
|
+
return this.directionValue === "down" && this.snapPointsValue.length > 0
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// A point is a fraction of the full (100dvh) height, or a CSS length
|
|
237
|
+
// in px/rem (e.g. ["31rem", 1]).
|
|
238
|
+
#snapVisible(point, height) {
|
|
239
|
+
if (typeof point === "number") return point * height
|
|
240
|
+
|
|
241
|
+
const value = parseFloat(point)
|
|
242
|
+
if (String(point).endsWith("rem")) {
|
|
243
|
+
const rootSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
|
|
244
|
+
return value * rootSize
|
|
245
|
+
}
|
|
246
|
+
return value
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Hidden-height offsets, one per point: index 0 (the compact peek)
|
|
250
|
+
// hides the most; the last point shows the most.
|
|
251
|
+
#snapOffsets() {
|
|
252
|
+
const height = this.#size()
|
|
253
|
+
return this.snapPointsValue.map((point) => {
|
|
254
|
+
return Math.max(0, height - Math.min(height, this.#snapVisible(point, height)))
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#currentSnapOffset() {
|
|
259
|
+
return parseFloat(this.dialogTarget.style.getPropertyValue("--drawer-snap-point-offset")) || 0
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#applySnap(index) {
|
|
263
|
+
this.#snapIndex = index
|
|
264
|
+
this.dialogTarget.style.setProperty("--drawer-snap-point-offset", `${this.#snapOffsets()[index]}px`)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Release physics between points: a flick moves one point in its
|
|
268
|
+
// direction (past the first point -> dismiss); a slow release settles
|
|
269
|
+
// on the nearest point, or dismisses once it sits past half the
|
|
270
|
+
// compact peek's visible height (the plain drawer's threshold, taken
|
|
271
|
+
// against the peek rather than the full sheet).
|
|
272
|
+
#settleSnap(swipe) {
|
|
273
|
+
const height = this.#size()
|
|
274
|
+
const offsets = this.#snapOffsets()
|
|
275
|
+
const position = this.#currentSnapOffset() + swipe.movement
|
|
276
|
+
const peekVisible = Math.max(1, height - offsets[0])
|
|
277
|
+
let target
|
|
278
|
+
|
|
279
|
+
if (Math.abs(swipe.velocity) >= DISMISS_VELOCITY) {
|
|
280
|
+
target = this.#snapIndex + (swipe.velocity > 0 ? -1 : 1)
|
|
281
|
+
} else {
|
|
282
|
+
target = offsets.reduce((best, offset, index) => {
|
|
283
|
+
return Math.abs(position - offset) < Math.abs(position - offsets[best]) ? index : best
|
|
284
|
+
}, 0)
|
|
285
|
+
if (position - offsets[0] >= peekVisible * DISMISS_PROGRESS) target = -1
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.dialogTarget.removeAttribute("data-swiping")
|
|
289
|
+
if (target < 0) {
|
|
290
|
+
const progress = Math.min(1, Math.max(0, (position - offsets[0]) / peekVisible))
|
|
291
|
+
const strength = Math.max(MIN_STRENGTH, Math.min(1, 1 - progress))
|
|
292
|
+
this.dialogTarget.style.setProperty("--drawer-swipe-strength", String(strength))
|
|
293
|
+
this.close()
|
|
294
|
+
} else {
|
|
295
|
+
const index = Math.min(target, offsets.length - 1)
|
|
296
|
+
requestAnimationFrame(() => {
|
|
297
|
+
this.#applySnap(index)
|
|
298
|
+
this.#writeSwipeVars(0)
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- geometry --------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
#axis() {
|
|
306
|
+
return this.directionValue === "left" || this.directionValue === "right" ? "x" : "y"
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// +1 when dismissal increases the coordinate (down/right), -1 otherwise.
|
|
310
|
+
#sign() {
|
|
311
|
+
return this.directionValue === "down" || this.directionValue === "right" ? 1 : -1
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#along(event) {
|
|
315
|
+
return this.#axis() === "y" ? event.clientY : event.clientX
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
#size() {
|
|
319
|
+
const dialog = this.dialogTarget
|
|
320
|
+
return Math.max(1, this.#axis() === "y" ? dialog.offsetHeight : dialog.offsetWidth)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#writeSwipeVars(movement) {
|
|
324
|
+
const dialog = this.dialogTarget
|
|
325
|
+
dialog.style.setProperty(`--drawer-swipe-movement-${this.#axis()}`, `${movement}px`)
|
|
326
|
+
// Progress measures travel toward DISMISSAL only: for snap-pointed
|
|
327
|
+
// sheets that travel starts at the compact peek (expanded positions
|
|
328
|
+
// clamp to 0, so the backdrop never fades while snapping fuller);
|
|
329
|
+
// for plain drawers the base is 0 and this stays movement / size.
|
|
330
|
+
const dismissBase = this.#snapping() ? this.#snapOffsets()[0] : 0
|
|
331
|
+
const travel = Math.max(1, this.#size() - dismissBase)
|
|
332
|
+
const progress = (this.#currentSnapOffset() + movement - dismissBase) / travel
|
|
333
|
+
dialog.style.setProperty("--drawer-swipe-progress", String(Math.min(1, Math.max(0, progress))))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#resetSwipeVars() {
|
|
337
|
+
const dialog = this.dialogTarget
|
|
338
|
+
dialog.style.removeProperty("--drawer-swipe-movement-x")
|
|
339
|
+
dialog.style.removeProperty("--drawer-swipe-movement-y")
|
|
340
|
+
dialog.style.removeProperty("--drawer-swipe-progress")
|
|
341
|
+
dialog.style.removeProperty("--drawer-swipe-strength")
|
|
342
|
+
dialog.style.removeProperty("--drawer-snap-point-offset")
|
|
343
|
+
dialog.removeAttribute("data-swiping")
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Content that can still scroll TOWARD the gesture owns the pointer: a
|
|
347
|
+
// down-swipe must not fire while a scrollable child is scrolled down
|
|
348
|
+
// (the user is scrolling back up), and symmetrically for the others.
|
|
349
|
+
#scrollBlocks(target) {
|
|
350
|
+
for (let node = target; node && node !== this.dialogTarget; node = node.parentElement) {
|
|
351
|
+
if (this.#axis() === "y") {
|
|
352
|
+
if (node.scrollHeight > node.clientHeight) {
|
|
353
|
+
if (this.directionValue === "down" && node.scrollTop > 0) return true
|
|
354
|
+
if (this.directionValue === "up" &&
|
|
355
|
+
node.scrollTop + node.clientHeight < node.scrollHeight) return true
|
|
356
|
+
}
|
|
357
|
+
} else if (node.scrollWidth > node.clientWidth) {
|
|
358
|
+
if (this.directionValue === "right" && node.scrollLeft > 0) return true
|
|
359
|
+
if (this.directionValue === "left" &&
|
|
360
|
+
node.scrollLeft + node.clientWidth < node.scrollWidth) return true
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return false
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// The FileInput dropzone engine: the
|
|
4
|
+
// component renders a <label> wrapping a visually-hidden native
|
|
5
|
+
// <input type=file>, so click-to-browse and keyboard access are the
|
|
6
|
+
// PLATFORM's - this controller adds only what HTML cannot: drag-and-drop
|
|
7
|
+
// onto the label, the selected-file list, and the clear affordance. The
|
|
8
|
+
// native input stays the single source of truth (its FileList is the
|
|
9
|
+
// form value; ActiveStorage direct upload rides it untouched).
|
|
10
|
+
//
|
|
11
|
+
// Drag state uses an enter/leave COUNTER: dragenter/dragleave fire per
|
|
12
|
+
// descendant crossed, so a boolean flickers over child elements - the
|
|
13
|
+
// counter nets out to zero only when the pointer truly leaves the zone.
|
|
14
|
+
const EVENT_PREFIX = "poetry:file-input"
|
|
15
|
+
|
|
16
|
+
const UNITS = ["B", "KB", "MB", "GB"]
|
|
17
|
+
|
|
18
|
+
export default class FileInputController extends Controller {
|
|
19
|
+
// The events this controller dispatches (manifest surface;
|
|
20
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
21
|
+
static events = ["poetry:file-input:change"]
|
|
22
|
+
|
|
23
|
+
static targets = ["input", "list", "clear"]
|
|
24
|
+
static values = {
|
|
25
|
+
multiple: { type: Boolean, default: false }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#dragDepth = 0
|
|
29
|
+
|
|
30
|
+
/** Reflects the input's current FileList (list, clear, data-populated). */
|
|
31
|
+
connect() {
|
|
32
|
+
this.#reflect()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Actions on the dropzone label: dragenter/dragover/dragleave/drop.
|
|
36
|
+
/**
|
|
37
|
+
* File drags flip the zone into its accepting state - depth-counted
|
|
38
|
+
* (the header explains the counter).
|
|
39
|
+
*
|
|
40
|
+
* @param {DragEvent} event
|
|
41
|
+
*/
|
|
42
|
+
dragenter(event) {
|
|
43
|
+
if (!this.#accepting(event)) return
|
|
44
|
+
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
this.#dragDepth += 1
|
|
47
|
+
this.element.setAttribute("data-dragging", "")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The required preventDefault - without it the browser navigates to
|
|
52
|
+
* the dropped file.
|
|
53
|
+
*
|
|
54
|
+
* @param {DragEvent} event
|
|
55
|
+
*/
|
|
56
|
+
dragover(event) {
|
|
57
|
+
if (!this.#accepting(event)) return
|
|
58
|
+
|
|
59
|
+
event.preventDefault() // required, or the browser navigates to the file
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Nets the depth counter down; at zero the drag styling clears. */
|
|
63
|
+
dragleave() {
|
|
64
|
+
this.#dragDepth = Math.max(0, this.#dragDepth - 1)
|
|
65
|
+
if (this.#dragDepth === 0) this.element.removeAttribute("data-dragging")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Assigns the dropped files to the native input (single-file zones take
|
|
70
|
+
* the FIRST - the native picker's rule) and dispatches a real change so
|
|
71
|
+
* every listener sees a picker-identical selection.
|
|
72
|
+
*
|
|
73
|
+
* @param {DragEvent} event
|
|
74
|
+
*/
|
|
75
|
+
drop(event) {
|
|
76
|
+
if (!this.#accepting(event)) return
|
|
77
|
+
|
|
78
|
+
event.preventDefault()
|
|
79
|
+
this.#dragDepth = 0
|
|
80
|
+
this.element.removeAttribute("data-dragging")
|
|
81
|
+
|
|
82
|
+
const files = event.dataTransfer?.files
|
|
83
|
+
|
|
84
|
+
if (!files || files.length === 0) return
|
|
85
|
+
|
|
86
|
+
this.inputTarget.files = this.#normalized(files)
|
|
87
|
+
// The programmatic assignment fires no native change - dispatch it so
|
|
88
|
+
// form frameworks (and this controller's own change action) see the
|
|
89
|
+
// selection exactly as a picker selection.
|
|
90
|
+
this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The native input's change action (picker AND drop land here):
|
|
95
|
+
* reflects the selection and reports the file names.
|
|
96
|
+
*/
|
|
97
|
+
changed() {
|
|
98
|
+
this.#reflect()
|
|
99
|
+
this.dispatch("change", {
|
|
100
|
+
prefix: EVENT_PREFIX,
|
|
101
|
+
detail: { names: this.#files().map((file) => file.name) }
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The clear button's click action: empties the input and dispatches a
|
|
107
|
+
* real change.
|
|
108
|
+
*
|
|
109
|
+
* @param {MouseEvent} event
|
|
110
|
+
*/
|
|
111
|
+
clear(event) {
|
|
112
|
+
// The clear button sits INSIDE the label: without this, the click
|
|
113
|
+
// also re-opens the file picker.
|
|
114
|
+
event.preventDefault()
|
|
115
|
+
event.stopPropagation()
|
|
116
|
+
this.inputTarget.value = ""
|
|
117
|
+
this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#files() {
|
|
121
|
+
return Array.from(this.inputTarget.files ?? [])
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A drag from another application may carry no files (text, links) -
|
|
125
|
+
// only file drags flip the zone into its accepting state.
|
|
126
|
+
#accepting(event) {
|
|
127
|
+
if (this.inputTarget.disabled) return false
|
|
128
|
+
|
|
129
|
+
const types = event.dataTransfer?.types
|
|
130
|
+
|
|
131
|
+
return Boolean(types && Array.from(types).includes("Files"))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Single-file zones take the FIRST dropped file (the native picker's
|
|
135
|
+
// rule). Built through a fresh DataTransfer where the platform provides
|
|
136
|
+
// one; without it (older engines, some test DOMs) the multi-drop falls
|
|
137
|
+
// back to the full list rather than throwing.
|
|
138
|
+
#normalized(files) {
|
|
139
|
+
if (this.multipleValue || files.length <= 1) return files
|
|
140
|
+
if (typeof DataTransfer !== "function") return files
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const transfer = new DataTransfer()
|
|
144
|
+
transfer.items.add(files[0])
|
|
145
|
+
return transfer.files
|
|
146
|
+
} catch {
|
|
147
|
+
return files
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The selected-file list is BUILT, never innerHTML'd (names are user
|
|
152
|
+
// input); data-populated on the root is the styling hook for the
|
|
153
|
+
// empty/filled swap.
|
|
154
|
+
#reflect() {
|
|
155
|
+
const files = this.#files()
|
|
156
|
+
|
|
157
|
+
this.element.toggleAttribute("data-populated", files.length > 0)
|
|
158
|
+
if (this.hasClearTarget) this.clearTarget.hidden = files.length === 0
|
|
159
|
+
if (!this.hasListTarget) return
|
|
160
|
+
|
|
161
|
+
this.listTarget.textContent = ""
|
|
162
|
+
for (const file of files) {
|
|
163
|
+
const item = document.createElement("li")
|
|
164
|
+
|
|
165
|
+
item.setAttribute("data-slot", "file-input-item")
|
|
166
|
+
const name = document.createElement("span")
|
|
167
|
+
|
|
168
|
+
name.setAttribute("data-slot", "file-input-item-name")
|
|
169
|
+
name.textContent = file.name
|
|
170
|
+
const size = document.createElement("span")
|
|
171
|
+
|
|
172
|
+
size.setAttribute("data-slot", "file-input-item-size")
|
|
173
|
+
size.textContent = this.#formatSize(file.size)
|
|
174
|
+
item.append(name, size)
|
|
175
|
+
this.listTarget.append(item)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#formatSize(bytes) {
|
|
180
|
+
if (!Number.isFinite(bytes) || bytes < 0) return ""
|
|
181
|
+
|
|
182
|
+
let value = bytes
|
|
183
|
+
let unit = 0
|
|
184
|
+
|
|
185
|
+
while (value >= 1024 && unit < UNITS.length - 1) {
|
|
186
|
+
value /= 1024
|
|
187
|
+
unit += 1
|
|
188
|
+
}
|
|
189
|
+
return `${unit === 0 ? value : value.toFixed(1)} ${UNITS[unit]}`
|
|
190
|
+
}
|
|
191
|
+
}
|