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,217 @@
|
|
|
1
|
+
import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
|
|
2
|
+
|
|
3
|
+
// The portal-on-open mechanism: move popper
|
|
4
|
+
// content to a stable container (body by default) while open so it can
|
|
5
|
+
// position `absolute` - static under compositor scroll, immune to
|
|
6
|
+
// transformed ancestors - and return it HOME on close, exactly where a
|
|
7
|
+
// placeholder comment marks the origin.
|
|
8
|
+
//
|
|
9
|
+
// THE EVENT BRIDGE (the logical-tree trap): a component-tree portal
|
|
10
|
+
// re-bubbles events from its logical position; a DOM portal does not. A
|
|
11
|
+
// host's data-action on the component ROOT would go deaf to events
|
|
12
|
+
// rising out of portaled content. The bridge restores logical-tree
|
|
13
|
+
// bubbling for poetry's OWN
|
|
14
|
+
|
|
15
|
+
// CustomEvents (the registered list below - NEVER native events, which
|
|
16
|
+
// the dismissal/hotkey layers listen for at document and must see on the
|
|
17
|
+
// real path): propagation is CUT at the content boundary and a clone is
|
|
18
|
+
// re-dispatched from the home position, so above the content the event
|
|
19
|
+
// exists on exactly ONE path - the home one. Cancellation transfers back
|
|
20
|
+
// (the re-dispatch is synchronous, inside the original dispatch), and the
|
|
21
|
+
// real origin element rides the clone as `portalTarget` (the clone's own
|
|
22
|
+
// `target` is the home-side parent - a re-dispatched event cannot keep
|
|
23
|
+
// the original).
|
|
24
|
+
//
|
|
25
|
+
// Restore is guarded: if a Turbo morph replaced the origin while the
|
|
26
|
+
// popup was out, the content is DROPPED, never stranded at body. A lazy
|
|
27
|
+
// turbo:before-cache net force-restores everything still portaled so a
|
|
28
|
+
// snapshot never caches body-level popups (the zombie-popup class).
|
|
29
|
+
const portaled = new Map()
|
|
30
|
+
const bridgeEvents = new Set(["poetry:state-change"])
|
|
31
|
+
let cacheNetInstalled = false
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Adds event names to the bridge list. index.js registers the union of
|
|
35
|
+
* every controller's declared `static events` at boot - the bridge list
|
|
36
|
+
* stays honest against the manifest surface without portal.js importing
|
|
37
|
+
* the controllers (no cycle).
|
|
38
|
+
*
|
|
39
|
+
* @param {Iterable<string>} names - full event names as emitted
|
|
40
|
+
*/
|
|
41
|
+
export function registerBridgeEvents(names) {
|
|
42
|
+
for (const name of names) bridgeEvents.add(name)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The portal-container seam, attribute-shaped: a host scoping themes to
|
|
47
|
+
* a subtree points its overlays at a container inside that scope via
|
|
48
|
+
* data-poetry-portal-container="<element id>".
|
|
49
|
+
*
|
|
50
|
+
* @param {Element | null} root - the element carrying the attribute
|
|
51
|
+
* @returns {Element} the named container, or document.body
|
|
52
|
+
*/
|
|
53
|
+
export function resolvePortalContainer(root) {
|
|
54
|
+
const id = root?.getAttribute?.("data-poetry-portal-container")
|
|
55
|
+
|
|
56
|
+
return (id ? document.getElementById(id) : null) ?? document.body
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether `content` is currently portaled out (has a live placeholder).
|
|
61
|
+
*
|
|
62
|
+
* @param {Element} content
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
export function isPortaled(content) {
|
|
66
|
+
return portaled.has(content)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Containment that follows portals HOME: a node inside portaled content
|
|
71
|
+
* counts as inside `container` when the content's home position does.
|
|
72
|
+
* Focus-scope's trap keys on this - a portaled sub level is outside the
|
|
73
|
+
* root content's subtree but logically inside its tree, and the trap
|
|
74
|
+
* must follow the logical tree or every portaled level reads as outside.
|
|
75
|
+
*
|
|
76
|
+
* @param {Element} container
|
|
77
|
+
* @param {Node | null} node
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
export function logicallyContains(container, node) {
|
|
81
|
+
let current = node
|
|
82
|
+
|
|
83
|
+
while (current) {
|
|
84
|
+
if (container.contains(current)) return true
|
|
85
|
+
|
|
86
|
+
const content = portaledAncestorOf(current)
|
|
87
|
+
|
|
88
|
+
if (!content) return false
|
|
89
|
+
|
|
90
|
+
current = portaled.get(content).placeholder.parentNode
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function portaledAncestorOf(node) {
|
|
97
|
+
for (let el = node instanceof Element ? node : node?.parentElement; el; el = el.parentElement) {
|
|
98
|
+
if (portaled.has(el)) return el
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Moves `content` to `container`, leaving a placeholder comment at home
|
|
106
|
+
* and wiring the event bridge for the registered list. No-op (false) when
|
|
107
|
+
* already portaled or parentless. The home-effective `dir` is stamped
|
|
108
|
+
* onto undeclared content for the trip, so direction-dependent behavior
|
|
109
|
+
* survives the move (un-stamped on restore).
|
|
110
|
+
*
|
|
111
|
+
* @param {Element} content
|
|
112
|
+
* @param {Object} [options]
|
|
113
|
+
* @param {Element} [options.container=document.body]
|
|
114
|
+
* @returns {boolean} true when the move happened
|
|
115
|
+
*/
|
|
116
|
+
export function portalContent(content, { container = document.body } = {}) {
|
|
117
|
+
if (portaled.has(content) || !content.parentNode) return false
|
|
118
|
+
|
|
119
|
+
const placeholder = document.createComment("poetry-portal")
|
|
120
|
+
|
|
121
|
+
content.parentNode.insertBefore(placeholder, content)
|
|
122
|
+
|
|
123
|
+
// Direction survives the move: dir inherits through the DOM, so a body
|
|
124
|
+
// portal would silently flip a locally-RTL subtree back to the document
|
|
125
|
+
// direction - menu arrow semantics (directionOf walks closest [dir])
|
|
126
|
+
// and every CSS logical property inside the popup key on it. Stamp the
|
|
127
|
+
// home-effective dir when the content declares none; restore un-stamps.
|
|
128
|
+
let stampedDir = false
|
|
129
|
+
|
|
130
|
+
if (!content.hasAttribute("dir")) {
|
|
131
|
+
const dir = content.closest("[dir]")?.getAttribute("dir")
|
|
132
|
+
|
|
133
|
+
if (dir) {
|
|
134
|
+
content.setAttribute("dir", dir)
|
|
135
|
+
stampedDir = true
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
container.append(content)
|
|
140
|
+
|
|
141
|
+
const bridges = []
|
|
142
|
+
|
|
143
|
+
for (const type of bridgeEvents) {
|
|
144
|
+
const listener = (event) => bridge(event, placeholder)
|
|
145
|
+
|
|
146
|
+
content.addEventListener(type, listener)
|
|
147
|
+
bridges.push([type, listener])
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
portaled.set(content, { placeholder, bridges, stampedDir })
|
|
151
|
+
installCacheNet()
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Returns portaled `content` to its placeholder and unwires the bridge.
|
|
157
|
+
* When the origin is gone (a morph replaced it) the content is DROPPED,
|
|
158
|
+
* never stranded at the container.
|
|
159
|
+
*
|
|
160
|
+
* @param {Element} content
|
|
161
|
+
* @returns {boolean} true when the content went home; false when it was
|
|
162
|
+
* not portaled, or had to be dropped
|
|
163
|
+
*/
|
|
164
|
+
export function restoreContent(content) {
|
|
165
|
+
const state = portaled.get(content)
|
|
166
|
+
|
|
167
|
+
if (!state) return false
|
|
168
|
+
|
|
169
|
+
portaled.delete(content)
|
|
170
|
+
for (const [type, listener] of state.bridges) content.removeEventListener(type, listener)
|
|
171
|
+
if (state.stampedDir) content.removeAttribute("dir")
|
|
172
|
+
|
|
173
|
+
// Home-aliveness reads the parent ELEMENT, never the comment: dommy's
|
|
174
|
+
// QuickJS DOM has no Comment#isConnected (undefined reads as "origin
|
|
175
|
+
// gone" and silently DROPS live content - the dommy tier caught it).
|
|
176
|
+
// replaceChild over ChildNode.replaceWith for the same reason.
|
|
177
|
+
const home = state.placeholder.parentNode
|
|
178
|
+
|
|
179
|
+
if (home?.isConnected) {
|
|
180
|
+
home.replaceChild(content, state.placeholder)
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The origin is gone (a morph replaced it) - drop, never strand.
|
|
185
|
+
state.placeholder.parentNode?.removeChild(state.placeholder)
|
|
186
|
+
content.remove()
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Bubble-phase on the content node itself, so the consumer's own content
|
|
191
|
+
// listeners (wired at connect, before any portal) always run first.
|
|
192
|
+
function bridge(event, placeholder) {
|
|
193
|
+
const home = placeholder.parentNode
|
|
194
|
+
|
|
195
|
+
if (!home?.isConnected) return
|
|
196
|
+
|
|
197
|
+
event.stopPropagation()
|
|
198
|
+
|
|
199
|
+
const clone = new CustomEvent(event.type, {
|
|
200
|
+
detail: event.detail, bubbles: true,
|
|
201
|
+
cancelable: event.cancelable, composed: event.composed
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
clone.portalTarget = event.target
|
|
205
|
+
home.dispatchEvent(clone)
|
|
206
|
+
|
|
207
|
+
if (clone.defaultPrevented) event.preventDefault()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function installCacheNet() {
|
|
211
|
+
if (cacheNetInstalled) return
|
|
212
|
+
|
|
213
|
+
cacheNetInstalled = true
|
|
214
|
+
onBeforeCache(() => {
|
|
215
|
+
for (const content of [...portaled.keys()]) restoreContent(content)
|
|
216
|
+
})
|
|
217
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { setState } from "@poetry/controllers/helpers/state"
|
|
2
|
+
import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
|
|
3
|
+
|
|
4
|
+
// Presence: the mount/unmount animation convention - a helper, not a
|
|
5
|
+
// controller (no element ownership here). Exit flips the pair to
|
|
6
|
+
// data-closed and HOLDS the node in the DOM until its CSS exit animation /
|
|
7
|
+
// transition finishes, then hands removal back to the caller via onRemove
|
|
8
|
+
// (this module never removes DOM); enter just flips the pair to data-open
|
|
9
|
+
// so the data-open: animation runs. State writes go through setState so
|
|
10
|
+
// poetry:state-change fires like every other state flip.
|
|
11
|
+
|
|
12
|
+
// Grace (ms) added to the computed animation/transition time before the
|
|
13
|
+
// safety timeout fires - animationend can lag the declared duration.
|
|
14
|
+
const EXIT_TIMEOUT_GRACE = 100
|
|
15
|
+
|
|
16
|
+
// Safety net (ms) when a duration cannot be computed (an animation is
|
|
17
|
+
// declared but reports zero length): never strand a closed node in the DOM.
|
|
18
|
+
const EXIT_TIMEOUT_FALLBACK = 1000
|
|
19
|
+
|
|
20
|
+
// Exits waiting on their CSS animation. A Turbo snapshot must never
|
|
21
|
+
// capture one mid-flight: the owner's onRemove (hidden + layer-controller
|
|
22
|
+
// removal) would land AFTER the cache is taken, so the restored page
|
|
23
|
+
// resurrects a live layer - the click-dead restore class. Every pending
|
|
24
|
+
// exit is flushed synchronously at turbo:before-cache; the dismissable
|
|
25
|
+
// layer also flushes explicitly after dispatching its before-cache
|
|
26
|
+
// dismiss, so exits STARTED by that dismiss complete in the same tick
|
|
27
|
+
// regardless of listener order.
|
|
28
|
+
const pendingExits = new Set()
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Settles every exit still waiting on its CSS animation, synchronously
|
|
32
|
+
* (each runs its onRemove). Wired to turbo:before-cache at module load;
|
|
33
|
+
* the dismissable layer also calls it directly (see above).
|
|
34
|
+
*/
|
|
35
|
+
export function flushPendingExits() {
|
|
36
|
+
for (const flush of [...pendingExits]) flush()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof document !== "undefined") onBeforeCache(flushPendingExits)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The measured-entry/exit hook (the Accordion contract's height
|
|
43
|
+
* mechanism): height keyframes cannot animate to auto, so the keyframe
|
|
44
|
+
* chain reads a CSS var instead. This measures the settled box -
|
|
45
|
+
* temporarily unhiding the element and suppressing its animations so
|
|
46
|
+
* scrollHeight reports the real content height - writes "<n>px" to the
|
|
47
|
+
* custom property, then restores exactly what it changed.
|
|
48
|
+
*
|
|
49
|
+
* @param {HTMLElement} element
|
|
50
|
+
* @param {Object} [options]
|
|
51
|
+
* @param {string} [options.property="--poetry-presence-height"] -
|
|
52
|
+
* overridable because the vendored keyframes read
|
|
53
|
+
* --accordion-panel-height
|
|
54
|
+
* @returns {number} the measured height (px)
|
|
55
|
+
*/
|
|
56
|
+
export function measurePresence(element, { property = "--poetry-presence-height" } = {}) {
|
|
57
|
+
const wasHidden = element.hidden
|
|
58
|
+
const previousAnimation = element.style.animation
|
|
59
|
+
|
|
60
|
+
if (wasHidden) element.hidden = false
|
|
61
|
+
element.style.animation = "none" // a mid-flight keyframe must not skew the measure
|
|
62
|
+
|
|
63
|
+
const height = element.scrollHeight
|
|
64
|
+
element.style.setProperty(property, `${height}px`)
|
|
65
|
+
|
|
66
|
+
element.style.animation = previousAnimation
|
|
67
|
+
if (wasHidden) element.hidden = true
|
|
68
|
+
|
|
69
|
+
return height
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Runs the entry: flips the pair to data-open (through setState, so the
|
|
74
|
+
* state-change event fires) wearing data-starting-style for exactly one
|
|
75
|
+
* painted frame after the flip (the two-frame trick below), so CSS
|
|
76
|
+
* transitions can animate FROM the starting declarations. No poetry class
|
|
77
|
+
* consumes the attribute yet - it ships so a future theme layer can adopt
|
|
78
|
+
* the transition idiom without touching JS.
|
|
79
|
+
*
|
|
80
|
+
* @param {HTMLElement} element
|
|
81
|
+
* @param {Object} [options]
|
|
82
|
+
* @param {boolean} [options.measure=false] - measure BEFORE the pair
|
|
83
|
+
* flips, so the entry keyframe can consume the var from its first frame
|
|
84
|
+
* @param {string} [options.property] - forwarded to
|
|
85
|
+
* {@link measurePresence}
|
|
86
|
+
* @returns {HTMLElement} the element
|
|
87
|
+
*/
|
|
88
|
+
export function enterPresence(element, { measure = false, property } = {}) {
|
|
89
|
+
if (measure) measurePresence(element, { property })
|
|
90
|
+
|
|
91
|
+
element.removeAttribute("data-ending-style") // an interrupted exit must not linger
|
|
92
|
+
element.setAttribute("data-starting-style", "")
|
|
93
|
+
setState(element, "open")
|
|
94
|
+
|
|
95
|
+
if (typeof requestAnimationFrame === "function") {
|
|
96
|
+
// Two frames: the first paints WITH the attribute, the removal lands
|
|
97
|
+
// on the next - the transition sees both endpoints.
|
|
98
|
+
requestAnimationFrame(() => {
|
|
99
|
+
requestAnimationFrame(() => element.removeAttribute("data-starting-style"))
|
|
100
|
+
})
|
|
101
|
+
} else {
|
|
102
|
+
element.removeAttribute("data-starting-style") // no rAF (bare interpreter): inert either way
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return element
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Runs the exit: flips the pair to data-closed and HOLDS the node in the
|
|
110
|
+
* DOM until its CSS exit animation/transition finishes, then hands
|
|
111
|
+
* removal back to the caller (this module never removes DOM). onRemove
|
|
112
|
+
* runs at most once, on the first of animationend / transitionend (on the
|
|
113
|
+
* element itself, not a child) or the safety timeout - or synchronously
|
|
114
|
+
* when no exit animation exists. data-ending-style rides the whole exit.
|
|
115
|
+
*
|
|
116
|
+
* @param {HTMLElement} element
|
|
117
|
+
* @param {Object} [options]
|
|
118
|
+
* @param {() => void} [options.onRemove] - the caller's removal step
|
|
119
|
+
* @param {boolean} [options.measure=false] - measure while the element is
|
|
120
|
+
* STILL VISIBLE, before the flip to "closed" starts the exit keyframe
|
|
121
|
+
* @param {string} [options.property] - forwarded to
|
|
122
|
+
* {@link measurePresence}
|
|
123
|
+
* @returns {() => void} cancel() - abandons the wait WITHOUT calling
|
|
124
|
+
* onRemove (an exit interrupted by a re-open)
|
|
125
|
+
*/
|
|
126
|
+
export function exitPresence(element, { onRemove, measure = false, property } = {}) {
|
|
127
|
+
if (measure) measurePresence(element, { property })
|
|
128
|
+
|
|
129
|
+
// The exit-window hook: data-ending-style rides the whole exit
|
|
130
|
+
// and leaves with the node's removal (or an interrupting re-open).
|
|
131
|
+
element.setAttribute("data-ending-style", "")
|
|
132
|
+
setState(element, "closed")
|
|
133
|
+
|
|
134
|
+
if (!hasExitAnimation(element)) {
|
|
135
|
+
element.removeAttribute("data-ending-style")
|
|
136
|
+
onRemove?.()
|
|
137
|
+
return () => {}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let settled = false
|
|
141
|
+
let timeout = null
|
|
142
|
+
|
|
143
|
+
const cleanup = () => {
|
|
144
|
+
element.removeEventListener("animationend", settle)
|
|
145
|
+
element.removeEventListener("transitionend", settle)
|
|
146
|
+
if (timeout !== null) window.clearTimeout(timeout)
|
|
147
|
+
pendingExits.delete(flush)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const settle = (event) => {
|
|
151
|
+
if (event && event.target !== element) return // a child's animation is not ours
|
|
152
|
+
if (settled) return
|
|
153
|
+
|
|
154
|
+
settled = true
|
|
155
|
+
cleanup()
|
|
156
|
+
element.removeAttribute("data-ending-style")
|
|
157
|
+
onRemove?.()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const flush = () => settle()
|
|
161
|
+
|
|
162
|
+
element.addEventListener("animationend", settle)
|
|
163
|
+
element.addEventListener("transitionend", settle)
|
|
164
|
+
timeout = window.setTimeout(settle, exitTimeoutFor(element))
|
|
165
|
+
pendingExits.add(flush)
|
|
166
|
+
|
|
167
|
+
// getAnimations().finished is the robust end-detection: it resolves for
|
|
168
|
+
// transitions AND keyframes symmetrically and needs no duration math.
|
|
169
|
+
// Kept ALONGSIDE the listener+timeout paths - jsdom and the dommy tier
|
|
170
|
+
// don't implement getAnimations, and settle() is idempotent.
|
|
171
|
+
if (typeof element.getAnimations === "function") {
|
|
172
|
+
const animations = element.getAnimations()
|
|
173
|
+
if (animations.length > 0) {
|
|
174
|
+
Promise.allSettled(animations.map((animation) => animation.finished))
|
|
175
|
+
.then(() => settle())
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return () => {
|
|
180
|
+
settled = true
|
|
181
|
+
cleanup()
|
|
182
|
+
element.removeAttribute("data-ending-style")
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// jsdom (and a bare node) report animationName as "" - treat it as "none".
|
|
187
|
+
function hasExitAnimation(element) {
|
|
188
|
+
const style = getComputedStyle(element)
|
|
189
|
+
const animated = (style.animationName || "none") !== "none"
|
|
190
|
+
const transitioned = maxTimeMs(style.transitionDuration) > 0
|
|
191
|
+
return animated || transitioned
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function exitTimeoutFor(element) {
|
|
195
|
+
const style = getComputedStyle(element)
|
|
196
|
+
const total = Math.max(
|
|
197
|
+
maxTimeMs(style.animationDuration) + maxTimeMs(style.animationDelay),
|
|
198
|
+
maxTimeMs(style.transitionDuration) + maxTimeMs(style.transitionDelay)
|
|
199
|
+
)
|
|
200
|
+
return total > 0 ? total + EXIT_TIMEOUT_GRACE : EXIT_TIMEOUT_FALLBACK
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Longest entry of a comma-separated CSS time list: "0.2s, 50ms" -> 200.
|
|
204
|
+
function maxTimeMs(value = "") {
|
|
205
|
+
return Math.max(0, ...String(value).split(",").map((time) => {
|
|
206
|
+
const parsed = parseFloat(time)
|
|
207
|
+
if (Number.isNaN(parsed)) return 0
|
|
208
|
+
return time.trim().endsWith("ms") ? parsed : parsed * 1000
|
|
209
|
+
}))
|
|
210
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The registration guard: Stimulus never errors on a data-controller
|
|
2
|
+
// identifier nothing registered - the element simply stays inert - and one
|
|
3
|
+
// failed import in the host's controllers graph silently takes every poetry
|
|
4
|
+
// controller down with it. After the page is ready (and again on every
|
|
5
|
+
// Turbo navigation) this compares the poetry-prefixed identifiers on the
|
|
6
|
+
// page with the application's registry and warns ONCE per identifier. Host
|
|
7
|
+
// controllers are never inspected (they may lazy-load); poetry's cannot.
|
|
8
|
+
|
|
9
|
+
const PREFIX = "poetry--"
|
|
10
|
+
const warned = new Set()
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The poetry identifiers on the page that `application` has not
|
|
14
|
+
* registered.
|
|
15
|
+
*
|
|
16
|
+
* @param {import("@hotwired/stimulus").Application} application
|
|
17
|
+
* @param {ParentNode} [root=document] - the subtree to inspect
|
|
18
|
+
* @returns {string[]} sorted, deduplicated identifiers
|
|
19
|
+
*/
|
|
20
|
+
export function unregisteredPoetryControllers(application, root = document) {
|
|
21
|
+
const registry = application && application.router && application.router.modulesByIdentifier
|
|
22
|
+
if (!registry || !root || !root.querySelectorAll) return []
|
|
23
|
+
const missing = new Set()
|
|
24
|
+
for (const element of root.querySelectorAll("[data-controller]")) {
|
|
25
|
+
for (const identifier of element.getAttribute("data-controller").split(/\s+/)) {
|
|
26
|
+
if (identifier.startsWith(PREFIX) && !registry.has(identifier)) missing.add(identifier)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return Array.from(missing).sort()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Warns once per newly-seen unregistered poetry identifier.
|
|
34
|
+
*
|
|
35
|
+
* @param {import("@hotwired/stimulus").Application} application
|
|
36
|
+
* @param {ParentNode} [root=document]
|
|
37
|
+
* @returns {string[]} the identifiers warned about in this call
|
|
38
|
+
*/
|
|
39
|
+
export function checkPoetryRegistration(application, root = document) {
|
|
40
|
+
const fresh = unregisteredPoetryControllers(application, root).filter((identifier) => !warned.has(identifier))
|
|
41
|
+
if (fresh.length === 0) return fresh
|
|
42
|
+
fresh.forEach((identifier) => warned.add(identifier))
|
|
43
|
+
console.warn(
|
|
44
|
+
`[poetry] ${fresh.length} poetry controller(s) on this page are not registered on the Stimulus application: ` +
|
|
45
|
+
`${fresh.join(", ")}. The elements stay inert - check the importmap pins and that every poetry gem's ` +
|
|
46
|
+
`register call runs in controllers/index.js.`
|
|
47
|
+
)
|
|
48
|
+
return fresh
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let scheduled = false
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Runs the check once the DOM is parsed (registration usually happens
|
|
55
|
+
* while the document is still loading) and after every Turbo navigation.
|
|
56
|
+
* Idempotent: the first registrar to call it wires the listeners.
|
|
57
|
+
*
|
|
58
|
+
* @param {import("@hotwired/stimulus").Application} application
|
|
59
|
+
*/
|
|
60
|
+
export function guardPoetryRegistration(application) {
|
|
61
|
+
if (scheduled || typeof document === "undefined") return
|
|
62
|
+
scheduled = true
|
|
63
|
+
const run = () => checkPoetryRegistration(application)
|
|
64
|
+
if (document.readyState === "loading") {
|
|
65
|
+
document.addEventListener("DOMContentLoaded", run, { once: true })
|
|
66
|
+
} else {
|
|
67
|
+
setTimeout(run, 0)
|
|
68
|
+
}
|
|
69
|
+
document.addEventListener("turbo:load", run)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Test seam: forget what has been warned about and re-arm the scheduler. */
|
|
73
|
+
export function resetPoetryRegistrationGuard() {
|
|
74
|
+
warned.clear()
|
|
75
|
+
scheduled = false
|
|
76
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Body scroll-lock with scrollbar-width compensation: bare
|
|
2
|
+
// `overflow: hidden` shifts the whole layout by the scrollbar width the
|
|
3
|
+
// moment an overlay opens on a scrollable page.
|
|
4
|
+
// The gap is measured BEFORE locking and paid back as body padding-right.
|
|
5
|
+
// Refcounted so stacked overlays (a dialog opened from a sheet) lock once
|
|
6
|
+
// and restore only when the LAST one closes - per-instance saved values
|
|
7
|
+
// break on out-of-order closes.
|
|
8
|
+
//
|
|
9
|
+
// Why not scrollbar-gutter: stable (this helper's original primary)?
|
|
10
|
+
// Measured live with classic
|
|
11
|
+
// scrollbars: Chrome drops the viewport's rail AND its reserved gutter
|
|
12
|
+
// the moment the viewport's used overflow computes to hidden - whether
|
|
13
|
+
// the pair sits on the root, propagates from body, or the gutter was set
|
|
14
|
+
// permanently - so the page shifted by the scrollbar width anyway (the
|
|
15
|
+
// exact wiggle the strategy existed to stop). The body-padding payback
|
|
16
|
+
// is the only compensation the viewport honors; its known cost is that
|
|
17
|
+
// position:fixed elements aren't compensated - accepted deliberately,
|
|
18
|
+
// not an oversight.
|
|
19
|
+
let locks = 0
|
|
20
|
+
let previous = null
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Locks body scrolling, paying the measured scrollbar gap back as body
|
|
24
|
+
* padding-right so the layout never shifts. Refcounted: stacked overlays
|
|
25
|
+
* lock once; only the first call writes styles.
|
|
26
|
+
*/
|
|
27
|
+
export function lockScroll() {
|
|
28
|
+
locks += 1
|
|
29
|
+
if (locks > 1) return
|
|
30
|
+
|
|
31
|
+
const gap = window.innerWidth - document.documentElement.clientWidth
|
|
32
|
+
// Never save a value the lock itself writes: a Turbo-restored snapshot
|
|
33
|
+
// arrives with the serialized "hidden" already inline (no refcount
|
|
34
|
+
// behind it), and saving it would re-freeze scrolling on every later
|
|
35
|
+
// unlock (the poisoned-previous restore class).
|
|
36
|
+
const overflow = document.body.style.overflow
|
|
37
|
+
previous = {
|
|
38
|
+
overflow: overflow === "hidden" ? "" : overflow,
|
|
39
|
+
paddingRight: document.body.style.paddingRight
|
|
40
|
+
}
|
|
41
|
+
if (gap > 0) {
|
|
42
|
+
const current = parseFloat(getComputedStyle(document.body).paddingRight) || 0
|
|
43
|
+
document.body.style.paddingRight = `${current + gap}px`
|
|
44
|
+
}
|
|
45
|
+
document.body.style.overflow = "hidden"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Releases one scroll lock; the LAST release restores the body's saved
|
|
50
|
+
* overflow and padding-right.
|
|
51
|
+
*/
|
|
52
|
+
export function unlockScroll() {
|
|
53
|
+
if (locks === 0) return
|
|
54
|
+
|
|
55
|
+
locks -= 1
|
|
56
|
+
if (locks > 0) return
|
|
57
|
+
|
|
58
|
+
document.body.style.overflow = previous.overflow
|
|
59
|
+
document.body.style.paddingRight = previous.paddingRight
|
|
60
|
+
previous = null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Test seam: vitest suites run many overlays in one document. */
|
|
64
|
+
export function resetScrollLock() {
|
|
65
|
+
locks = 0
|
|
66
|
+
previous = null
|
|
67
|
+
}
|