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,111 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
|
|
3
|
+
|
|
4
|
+
// Error isolation for deferred turbo-frames. Turbo owns the
|
|
5
|
+
// loading physics (loading="lazy" fetches on visibility, "eager" after
|
|
6
|
+
// paint) but leaves failure INVISIBLE: a missing frame logs "Content
|
|
7
|
+
// missing" or - Turbo 8 - promotes the error response to a full-page
|
|
8
|
+
// visit, and a network error leaves the placeholder spinning forever.
|
|
9
|
+
// This controller makes failure a state: data-error reflected on the
|
|
10
|
+
// frame (a bespoke boolean, deliberately not the shared open/closed
|
|
11
|
+
// vocabulary), the placeholder hidden, and the slotted <template> error
|
|
12
|
+
// content stamped so the region shows a real, retryable message.
|
|
13
|
+
//
|
|
14
|
+
// THE BOOT CONTRACT: the frame renders with NO src - the URL rides
|
|
15
|
+
// srcValue and connect() arms it. A visible lazy frame with src in
|
|
16
|
+
// markup can complete its fetch before the controllers module graph
|
|
17
|
+
// finishes loading (caught live: a local 404 beat Stimulus and Turbo 8
|
|
18
|
+
// navigated the whole page to the error response); with src armed at
|
|
19
|
+
// connect, no fetch - and so no failure - can predate the instance.
|
|
20
|
+
//
|
|
21
|
+
// retry() prefers Turbo's FrameElement#reload(); environments without
|
|
22
|
+
// the Turbo runtime (jsdom, dommy) fall back to re-setting src, the same
|
|
23
|
+
// signal Turbo reacts to.
|
|
24
|
+
export default class DeferredController extends Controller {
|
|
25
|
+
static targets = ["placeholder", "error"]
|
|
26
|
+
static values = { src: String }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wires the three failure listeners, subscribes the before-cache reset,
|
|
30
|
+
* and arms src LAST (the boot contract above: no fetch can predate the
|
|
31
|
+
* instance).
|
|
32
|
+
*/
|
|
33
|
+
connect() {
|
|
34
|
+
// Three failure paths, one state. turbo:before-fetch-response is the
|
|
35
|
+
// load-bearing one: for 4xx/5xx frame responses Turbo 8 fires NO
|
|
36
|
+
// frame-missing - it promotes the error response straight to a
|
|
37
|
+
// full-page visit (caught live: the docs page navigated to a bare
|
|
38
|
+
// 404). Cancelling the response event here stops that promotion cold.
|
|
39
|
+
// frame-missing still covers 2xx-without-a-matching-frame, and
|
|
40
|
+
// fetch-request-error covers the network layer.
|
|
41
|
+
this.element.addEventListener("turbo:before-fetch-response", this.#onResponse)
|
|
42
|
+
this.element.addEventListener("turbo:frame-missing", this.#failed)
|
|
43
|
+
this.element.addEventListener("turbo:fetch-request-error", this.#failed)
|
|
44
|
+
|
|
45
|
+
// Reset before Turbo snapshots: a stamped error message serialized
|
|
46
|
+
// into the cache restores as a permanent failure - the placeholder
|
|
47
|
+
// posture retries the (transient) fetch on the restoration visit.
|
|
48
|
+
this.#unsubscribeBeforeCache = onBeforeCache(() => this.#reset())
|
|
49
|
+
|
|
50
|
+
if (!this.element.getAttribute("src")) this.element.setAttribute("src", this.srcValue)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Unwires the listeners and the before-cache subscription. */
|
|
54
|
+
disconnect() {
|
|
55
|
+
this.element.removeEventListener("turbo:before-fetch-response", this.#onResponse)
|
|
56
|
+
this.element.removeEventListener("turbo:frame-missing", this.#failed)
|
|
57
|
+
this.element.removeEventListener("turbo:fetch-request-error", this.#failed)
|
|
58
|
+
this.#unsubscribeBeforeCache?.()
|
|
59
|
+
this.#unsubscribeBeforeCache = null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The retry action: back to the placeholder posture, then reload -
|
|
64
|
+
* Turbo's FrameElement#reload when the runtime is present, else the
|
|
65
|
+
* re-set-src fallback (the same signal Turbo reacts to).
|
|
66
|
+
*/
|
|
67
|
+
retry() {
|
|
68
|
+
this.#reset()
|
|
69
|
+
|
|
70
|
+
if (typeof this.element.reload === "function") {
|
|
71
|
+
this.element.reload()
|
|
72
|
+
} else {
|
|
73
|
+
const src = this.element.getAttribute("src") || this.srcValue
|
|
74
|
+
this.element.removeAttribute("src")
|
|
75
|
+
this.element.setAttribute("src", src)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#onResponse = (event) => {
|
|
80
|
+
if (event.detail?.fetchResponse?.succeeded !== false) return
|
|
81
|
+
|
|
82
|
+
event.preventDefault()
|
|
83
|
+
this.#failed(event)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#failed = (event) => {
|
|
87
|
+
// Keep Turbo from acting on the failure itself (frame-missing default
|
|
88
|
+
// = promote the response to a page visit) - the error template below
|
|
89
|
+
// is the user-visible state.
|
|
90
|
+
if (event.type === "turbo:frame-missing") event.preventDefault()
|
|
91
|
+
|
|
92
|
+
this.element.setAttribute("data-error", "")
|
|
93
|
+
if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = true
|
|
94
|
+
if (this.hasErrorTarget && !this.#stamped) {
|
|
95
|
+
this.#stamped = this.errorTarget.content.firstElementChild?.cloneNode(true)
|
|
96
|
+
if (this.#stamped) this.element.appendChild(this.#stamped)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Back to the placeholder posture (shared by retry and the
|
|
101
|
+
// before-cache teardown).
|
|
102
|
+
#reset() {
|
|
103
|
+
this.#stamped?.remove()
|
|
104
|
+
this.#stamped = null
|
|
105
|
+
this.element.removeAttribute("data-error")
|
|
106
|
+
if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#stamped = null
|
|
110
|
+
#unsubscribeBeforeCache = null
|
|
111
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { setState } from "@poetry/controllers/helpers/state"
|
|
3
|
+
import { matchesHotkey } from "@poetry/controllers/helpers/hotkey"
|
|
4
|
+
import { lockScroll, unlockScroll } from "@poetry/controllers/helpers/scroll_lock"
|
|
5
|
+
import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
|
|
6
|
+
import { exitPresence, flushPendingExits } from "@poetry/controllers/helpers/presence"
|
|
7
|
+
|
|
8
|
+
// The native-dialog primitive: borrow the PLATFORM overlay -
|
|
9
|
+
// showModal() gives the focus trap, Esc handling, top-layer stacking, and
|
|
10
|
+
// focus return for free; this controller adds what the platform doesn't:
|
|
11
|
+
// the data-open/data-closed pair for CSS variants, backdrop-click
|
|
12
|
+
// dismissal, a body scroll-lock, and the presence-hold close - exit flips
|
|
13
|
+
// the pair to data-closed and HOLDS the dialog through its CSS exit
|
|
14
|
+
// animation before the native close() (synchronous when no exit animation
|
|
15
|
+
// applies, so reduced-motion and unthemed hosts close instantly).
|
|
16
|
+
// Consumed by Dialog (and AlertDialog / CommandDialog / Sheet).
|
|
17
|
+
export default class extends Controller {
|
|
18
|
+
static targets = ["dialog"]
|
|
19
|
+
static values = {
|
|
20
|
+
// Set false for AlertDialog-style confirmations: backdrop clicks stop dismissing.
|
|
21
|
+
dismissible: { type: Boolean, default: true },
|
|
22
|
+
// OPT-IN global shortcut ("meta+k") toggling the dialog - the
|
|
23
|
+
// command-palette ⌘K affordance, shipped here because every
|
|
24
|
+
// consumer would otherwise write the same ten lines. "meta" matches metaKey OR
|
|
25
|
+
// ctrlKey (⌘K on mac, ^K elsewhere - the command-palette convention).
|
|
26
|
+
hotkey: { type: String, default: "" }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#onHotkey = null
|
|
30
|
+
#unsubscribeBeforeCache = null
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Heals a restored zombie snapshot, subscribes the before-cache close,
|
|
34
|
+
* and arms the opt-in global hotkey.
|
|
35
|
+
*/
|
|
36
|
+
connect() {
|
|
37
|
+
this.#healRestoredSnapshot()
|
|
38
|
+
// Close before Turbo snapshots: an open dialog serialized into the
|
|
39
|
+
// cache restores as a de-modalized zombie over a frozen scroll lock.
|
|
40
|
+
// The close is animated now, so the exit it starts must settle in
|
|
41
|
+
// this same tick - flush regardless of listener order (the presence
|
|
42
|
+
// module's own before-cache flush may already have run).
|
|
43
|
+
this.#unsubscribeBeforeCache = onBeforeCache(() => {
|
|
44
|
+
if (this.hasDialogTarget && this.dialogTarget.open) {
|
|
45
|
+
this.close()
|
|
46
|
+
flushPendingExits()
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (this.hotkeyValue === "") return
|
|
51
|
+
|
|
52
|
+
// defaultPrevented gate: two dialogs bound to the same descriptor
|
|
53
|
+
// degrade to first-registered-wins instead of both toggling open.
|
|
54
|
+
this.#onHotkey = (event) => {
|
|
55
|
+
if (event.defaultPrevented || !this.#matchesHotkey(event)) return
|
|
56
|
+
|
|
57
|
+
event.preventDefault()
|
|
58
|
+
this.toggle()
|
|
59
|
+
}
|
|
60
|
+
window.addEventListener("keydown", this.#onHotkey)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Balances the scroll lock and unwires the hotkey and before-cache
|
|
65
|
+
* subscriptions.
|
|
66
|
+
*/
|
|
67
|
+
disconnect() {
|
|
68
|
+
this.unlockScroll()
|
|
69
|
+
this.#unsubscribeBeforeCache?.()
|
|
70
|
+
this.#unsubscribeBeforeCache = null
|
|
71
|
+
|
|
72
|
+
if (this.#onHotkey) {
|
|
73
|
+
window.removeEventListener("keydown", this.#onHotkey)
|
|
74
|
+
this.#onHotkey = null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A dialog restored from a PRE-FIX cached snapshot: the open attribute
|
|
79
|
+
// survived serialization but :modal did not, and the body's inline
|
|
80
|
+
// scroll-lock styles came back with no refcount behind them. Normalize
|
|
81
|
+
// to closed and clear the orphaned lock styles directly (the refcounted
|
|
82
|
+
// helper is at zero on a fresh page and must not be decremented).
|
|
83
|
+
#healRestoredSnapshot() {
|
|
84
|
+
if (!this.hasDialogTarget) return
|
|
85
|
+
|
|
86
|
+
const dialog = this.dialogTarget
|
|
87
|
+
|
|
88
|
+
if (!dialog.open) return
|
|
89
|
+
// A genuinely modal dialog only reconnects mid-flight when its subtree
|
|
90
|
+
// is MOVED while open (the sidebar DOM-move class) - leave those alone.
|
|
91
|
+
// Engines without :modal support (happy-dom) treat open-at-connect as
|
|
92
|
+
// the zombie it almost certainly is.
|
|
93
|
+
let modal = false
|
|
94
|
+
try {
|
|
95
|
+
modal = dialog.matches(":modal")
|
|
96
|
+
} catch {
|
|
97
|
+
modal = false
|
|
98
|
+
}
|
|
99
|
+
if (modal) return
|
|
100
|
+
|
|
101
|
+
dialog.close()
|
|
102
|
+
setState(dialog, "closed")
|
|
103
|
+
document.body.style.overflow = ""
|
|
104
|
+
document.body.style.paddingRight = ""
|
|
105
|
+
document.documentElement.style.scrollbarGutter = ""
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The toggle action (and the hotkey's landing): open <-> close. */
|
|
109
|
+
toggle() {
|
|
110
|
+
if (this.dialogTarget.open) this.close()
|
|
111
|
+
else this.open()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Opens modally: showModal() (the platform trap, top-layer and focus
|
|
116
|
+
* return), the pair flip, and the body scroll lock. Returns the
|
|
117
|
+
* resulting state (what an agent tool reports back).
|
|
118
|
+
*
|
|
119
|
+
* @returns {{ open: boolean, changed: boolean }}
|
|
120
|
+
*/
|
|
121
|
+
open() {
|
|
122
|
+
if (this.dialogTarget.open) return { open: true, changed: false }
|
|
123
|
+
|
|
124
|
+
this.dialogTarget.showModal()
|
|
125
|
+
setState(this.dialogTarget, "open")
|
|
126
|
+
this.lockScroll()
|
|
127
|
+
|
|
128
|
+
return { open: true, changed: true }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Closes with the presence hold: flips the pair to data-closed and
|
|
133
|
+
* holds the dialog through its CSS exit animation before the native
|
|
134
|
+
* close() and the unlock (synchronous when no exit animation applies).
|
|
135
|
+
* The native cancel event (Esc) routes through here so state stays in
|
|
136
|
+
* sync.
|
|
137
|
+
*
|
|
138
|
+
* @param {Event} [event] - the native cancel event, when Esc drove it
|
|
139
|
+
* @returns {{ open: boolean, changed: boolean }} the resulting state (what an agent tool reports back)
|
|
140
|
+
*/
|
|
141
|
+
close(event) {
|
|
142
|
+
if (event?.type === "cancel") event.preventDefault() // route Esc through close() so state stays in sync
|
|
143
|
+
if (this.#closing || !this.dialogTarget.open) return { open: false, changed: false }
|
|
144
|
+
|
|
145
|
+
this.#closing = true
|
|
146
|
+
exitPresence(this.dialogTarget, {
|
|
147
|
+
onRemove: () => {
|
|
148
|
+
this.#closing = false
|
|
149
|
+
this.dialogTarget.close()
|
|
150
|
+
this.unlockScroll()
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
return { open: false, changed: true }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The click action discriminating backdrop presses: a backdrop click
|
|
159
|
+
* targets the <dialog> element itself AND lands outside its bounding
|
|
160
|
+
* rect (the backdrop is rendered by the dialog). The target check alone
|
|
161
|
+
* is not enough: clicks on the dialog's own padding / grid gaps also
|
|
162
|
+
* target the element (found in a live browser pass - they were
|
|
163
|
+
* incorrectly dismissing).
|
|
164
|
+
*
|
|
165
|
+
* @param {MouseEvent} event
|
|
166
|
+
*/
|
|
167
|
+
backdropClose(event) {
|
|
168
|
+
if (!this.dismissibleValue) return
|
|
169
|
+
if (event.target !== this.dialogTarget) return
|
|
170
|
+
|
|
171
|
+
const rect = this.dialogTarget.getBoundingClientRect()
|
|
172
|
+
const inside = rect.top <= event.clientY && event.clientY <= rect.bottom &&
|
|
173
|
+
rect.left <= event.clientX && event.clientX <= rect.right
|
|
174
|
+
if (!inside) this.close()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The descriptor grammar lives in helpers/hotkey.js (shared with the
|
|
178
|
+
// generic hotkey controller).
|
|
179
|
+
#matchesHotkey(event) {
|
|
180
|
+
return matchesHotkey(event, this.hotkeyValue)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Takes the shared refcounted body scroll lock (scrollbar-width
|
|
185
|
+
* compensated) - subclasses (sheet/drawer/sidebar) inherit these entry
|
|
186
|
+
* points unchanged; the instance flag keeps double-unlocks (disconnect
|
|
187
|
+
* after close) balanced.
|
|
188
|
+
*/
|
|
189
|
+
lockScroll() {
|
|
190
|
+
if (this.#locked) return
|
|
191
|
+
|
|
192
|
+
this.#locked = true
|
|
193
|
+
lockScroll()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Balances {@link lockScroll}; safe when already unlocked. */
|
|
197
|
+
unlockScroll() {
|
|
198
|
+
if (!this.#locked) return
|
|
199
|
+
|
|
200
|
+
this.#locked = false
|
|
201
|
+
unlockScroll()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#locked = false
|
|
205
|
+
#closing = false
|
|
206
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { onEscapeKeydown } from "@poetry/controllers/helpers/escape"
|
|
3
|
+
import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
|
|
4
|
+
import { flushPendingExits } from "@poetry/controllers/helpers/presence"
|
|
5
|
+
|
|
6
|
+
// The dismissal layer: Escape + pointerdown-outside for every
|
|
7
|
+
// overlay. This controller NEVER removes DOM - it dispatches "dismiss" and
|
|
8
|
+
// the consumer closes itself (removes the node, collapses a disclosure,
|
|
9
|
+
// navigates a frame). The class-level stack makes Esc topmost-only, so
|
|
10
|
+
// stacked overlays peel one at a time.
|
|
11
|
+
export default class DismissableController extends Controller {
|
|
12
|
+
// The events this controller dispatches (manifest surface;
|
|
13
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
14
|
+
static events = [
|
|
15
|
+
"poetry--core--dismissable:dismiss", "poetry--core--dismissable:interact-outside"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
static stack = []
|
|
19
|
+
|
|
20
|
+
// The body pointer-events scrim (the CSS-only modal): refcounted across
|
|
21
|
+
// layers that request it, each such layer opting its own element back in.
|
|
22
|
+
static #scrimCount = 0
|
|
23
|
+
static #previousBodyPointerEvents = null
|
|
24
|
+
|
|
25
|
+
static values = {
|
|
26
|
+
disableOutsidePointerEvents: { type: Boolean, default: false }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pages cached BEFORE the before-cache teardown shipped carry a poisoned
|
|
30
|
+
// body (inline pointer-events:none serialized with no live scrim behind
|
|
31
|
+
// it) - and no layer connects on the restored page to release it. Heal
|
|
32
|
+
// on every Turbo render: with zero scrim holders, the style can only be
|
|
33
|
+
// a serialized leftover.
|
|
34
|
+
static {
|
|
35
|
+
if (typeof document !== "undefined") {
|
|
36
|
+
document.addEventListener("turbo:load", () => {
|
|
37
|
+
if (DismissableController.#scrimCount === 0 && document.body?.style.pointerEvents === "none") {
|
|
38
|
+
document.body.style.pointerEvents = ""
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#unsubscribeEscape = null
|
|
45
|
+
#unsubscribeBeforeCache = null
|
|
46
|
+
#onPointerdown = (event) => this.#handlePointerdown(event)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Registers on the layer stack, wires the capture-phase Escape and
|
|
50
|
+
* pointerdown listeners, subscribes the before-cache dismissal, and
|
|
51
|
+
* takes the body scrim when configured.
|
|
52
|
+
*/
|
|
53
|
+
connect() {
|
|
54
|
+
DismissableController.stack.push(this)
|
|
55
|
+
|
|
56
|
+
// Both capture-phase: the layer must see the interaction before any
|
|
57
|
+
// inner handler can swallow it (the escape helper defaults to capture).
|
|
58
|
+
this.#unsubscribeEscape = onEscapeKeydown((event) => this.#handleEscape(event))
|
|
59
|
+
document.addEventListener("pointerdown", this.#onPointerdown, { capture: true })
|
|
60
|
+
// A layer still open when Turbo snapshots serializes its scrim's
|
|
61
|
+
// inline body pointer-events into the cache - a restored page is
|
|
62
|
+
// click-dead. Dismiss synchronously so the snapshot is clean, and
|
|
63
|
+
// release the scrim HERE too: the owner's close resets its own
|
|
64
|
+
// attributes in the same tick, but its unmount (which normally
|
|
65
|
+
// releases the scrim via disconnect) can ride an exit transition -
|
|
66
|
+
// after the snapshot is already taken.
|
|
67
|
+
this.#unsubscribeBeforeCache = onBeforeCache(() => {
|
|
68
|
+
this.#dismiss(null)
|
|
69
|
+
// The dismiss just started the owner's exit presence - flush it in
|
|
70
|
+
// the same tick so hidden + layer-controller removal land BEFORE
|
|
71
|
+
// the snapshot (a serialized live layer resurrects on restore).
|
|
72
|
+
flushPendingExits()
|
|
73
|
+
this.#disableScrim()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
if (this.disableOutsidePointerEventsValue) this.#enableScrim()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Leaves the stack and unwires everything, releasing the scrim when
|
|
81
|
+
* held.
|
|
82
|
+
*/
|
|
83
|
+
disconnect() {
|
|
84
|
+
const index = DismissableController.stack.indexOf(this)
|
|
85
|
+
|
|
86
|
+
if (index !== -1) DismissableController.stack.splice(index, 1)
|
|
87
|
+
|
|
88
|
+
this.#unsubscribeEscape?.()
|
|
89
|
+
this.#unsubscribeEscape = null
|
|
90
|
+
document.removeEventListener("pointerdown", this.#onPointerdown, { capture: true })
|
|
91
|
+
this.#unsubscribeBeforeCache?.()
|
|
92
|
+
this.#unsubscribeBeforeCache = null
|
|
93
|
+
|
|
94
|
+
if (this.disableOutsidePointerEventsValue) this.#disableScrim()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#handleEscape(event) {
|
|
98
|
+
if (!this.#topmost()) return
|
|
99
|
+
|
|
100
|
+
this.#dismiss(event)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// pointerdown, not click: dismissal happens on press, so a drag
|
|
104
|
+
// that starts inside can never end up counting as outside.
|
|
105
|
+
#handlePointerdown(event) {
|
|
106
|
+
// A target already unhooked from the document (a Turbo morph or an
|
|
107
|
+
// earlier handler removed it mid-gesture) says nothing about WHERE the
|
|
108
|
+
// press landed - dismissing on it is the false-dismiss class.
|
|
109
|
+
if (!(event.target instanceof Element) || !event.target.isConnected) return
|
|
110
|
+
|
|
111
|
+
// composedPath, not contains(): the path is fixed at dispatch, so a
|
|
112
|
+
// press on a node an inner handler removes (a chip, a swapped row)
|
|
113
|
+
// still counts as inside - and shadow-DOM retargeting can't hide it.
|
|
114
|
+
const path = event.composedPath()
|
|
115
|
+
|
|
116
|
+
if (path.includes(this.element)) return
|
|
117
|
+
// A press inside a HIGHER layer (a nested overlay, often portaled out
|
|
118
|
+
// of this subtree) is inside the stack, not "outside" this layer.
|
|
119
|
+
if (this.#layersAbove().some((layer) => path.includes(layer.element))) return
|
|
120
|
+
// The top layer (toasts) sits above EVERY dismissal layer by fiat:
|
|
121
|
+
// pressing a toast must never dismiss the overlay under it.
|
|
122
|
+
if (event.target.closest("[data-poetry-top-layer]")) return
|
|
123
|
+
|
|
124
|
+
const interactOutside = this.dispatch("interact-outside", {
|
|
125
|
+
detail: { originalEvent: event },
|
|
126
|
+
cancelable: true
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
if (interactOutside.defaultPrevented) return // consumer veto: no dismiss
|
|
130
|
+
|
|
131
|
+
this.#dismiss(event)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#dismiss(originalEvent) {
|
|
135
|
+
this.dispatch("dismiss", { detail: { originalEvent } })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#topmost() {
|
|
139
|
+
return DismissableController.stack.at(-1) === this
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#layersAbove() {
|
|
143
|
+
return DismissableController.stack.slice(DismissableController.stack.indexOf(this) + 1)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Per-instance flag so enable/disable pair exactly once: the
|
|
147
|
+
// before-cache release and the later disconnect (after the owner's
|
|
148
|
+
// deferred unmount) must not double-decrement.
|
|
149
|
+
#scrimEnabled = false
|
|
150
|
+
|
|
151
|
+
#enableScrim() {
|
|
152
|
+
if (this.#scrimEnabled) return
|
|
153
|
+
|
|
154
|
+
this.#scrimEnabled = true
|
|
155
|
+
|
|
156
|
+
if (DismissableController.#scrimCount === 0) {
|
|
157
|
+
// Never save a value this scrim itself writes: a page restored from
|
|
158
|
+
// a cached snapshot arrives with the serialized "none" already on
|
|
159
|
+
// the body, and saving it would make every later restore re-freeze
|
|
160
|
+
// the page (the poisoned-previous restore class).
|
|
161
|
+
const current = document.body.style.pointerEvents
|
|
162
|
+
DismissableController.#previousBodyPointerEvents = current === "none" ? "" : current
|
|
163
|
+
document.body.style.pointerEvents = "none"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
DismissableController.#scrimCount += 1
|
|
167
|
+
this.element.style.pointerEvents = "auto"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#disableScrim() {
|
|
171
|
+
if (!this.#scrimEnabled) return
|
|
172
|
+
|
|
173
|
+
this.#scrimEnabled = false
|
|
174
|
+
DismissableController.#scrimCount -= 1
|
|
175
|
+
this.element.style.pointerEvents = ""
|
|
176
|
+
|
|
177
|
+
if (DismissableController.#scrimCount === 0) {
|
|
178
|
+
document.body.style.pointerEvents = DismissableController.#previousBodyPointerEvents ?? ""
|
|
179
|
+
DismissableController.#previousBodyPointerEvents = null
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|