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,195 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { announce } from "@poetry/controllers/helpers/announce"
|
|
3
|
+
import { isImeKeydown } from "@poetry/controllers/helpers/escape"
|
|
4
|
+
|
|
5
|
+
// The SensitiveInput machine, adapted from an MIT-licensed source
|
|
6
|
+
// (source and license in THIRD_PARTY_NOTICES.md): a secret field in
|
|
7
|
+
// three states - masked | revealed | empty - where data-state on the root
|
|
8
|
+
// carries the truth and CSS renders it. Masked-with-value turns the MASK
|
|
9
|
+
// OVERLAY into the reveal affordance (role=button + label + sr-hint; only
|
|
10
|
+
// text spans inside - a role on the surrounding group would trip axe
|
|
11
|
+
// nested-interactive around the inert input)
|
|
12
|
+
// while the real input stays rendered for layout but goes inert
|
|
13
|
+
// (aria-hidden, tabindex -1, readonly, transparent). Reveal: click
|
|
14
|
+
// anywhere on the group (mask clicks bubble) or Enter/Space on the mask,
|
|
15
|
+
// focus moves into the input. Re-mask: Escape (focus returns to the mask
|
|
16
|
+
// - the input just lost its tab stop), leaving the component, or the eye.
|
|
17
|
+
// Typing into an empty field auto-reveals so composition happens in
|
|
18
|
+
// type=text. The no-JS story is a plain password input.
|
|
19
|
+
const EVENT_PREFIX = "poetry:sensitive-input"
|
|
20
|
+
|
|
21
|
+
export default class SensitiveInputController extends Controller {
|
|
22
|
+
// The events this controller dispatches (manifest surface;
|
|
23
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
24
|
+
static events = ["poetry:sensitive-input:reveal", "poetry:sensitive-input:mask"]
|
|
25
|
+
|
|
26
|
+
static targets = ["mask", "input", "toggle", "hint"]
|
|
27
|
+
|
|
28
|
+
static values = {
|
|
29
|
+
// "{label}, masked." - built server-side so i18n stays in Rails.
|
|
30
|
+
maskedLabel: String,
|
|
31
|
+
// The re-mask announcement ("Value hidden").
|
|
32
|
+
hiddenMessage: String,
|
|
33
|
+
// The host-declared readonly (the controller borrows the input's
|
|
34
|
+
// readOnly while masked, so the option needs its own home).
|
|
35
|
+
readOnly: Boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Adopts the server-rendered data-state and re-derives the reflection. */
|
|
39
|
+
connect() {
|
|
40
|
+
this.#visible = this.element.getAttribute("data-state") === "revealed"
|
|
41
|
+
this.#reflect()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The group's click action: click anywhere on the bordered group
|
|
46
|
+
* reveals (the mask button's own clicks bubble here too). Addon-cell
|
|
47
|
+
* clicks and synthetic label clicks are filtered (the body comments).
|
|
48
|
+
*
|
|
49
|
+
* @param {MouseEvent} event
|
|
50
|
+
*/
|
|
51
|
+
reveal(event) {
|
|
52
|
+
if (this.#disabled || this.#state !== "masked") return
|
|
53
|
+
// The addon cell holds its own actions (copy/eye) - their clicks
|
|
54
|
+
// bubble here and must never reveal.
|
|
55
|
+
if (event.target instanceof Element && event.target.closest("[data-slot=input-group-addon]")) return
|
|
56
|
+
// Synthetic label-click guard (the coordinate check): a <label for=>
|
|
57
|
+
// click re-dispatches on the input with no gesture behind it.
|
|
58
|
+
if (event.detail === 0 && event.clientX === 0 && event.clientY === 0) return
|
|
59
|
+
|
|
60
|
+
this.#reveal()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The mask overlay's keydown action: Enter/Space reveals (the overlay
|
|
65
|
+
* is the reveal affordance while masked).
|
|
66
|
+
*
|
|
67
|
+
* @param {KeyboardEvent} event
|
|
68
|
+
*/
|
|
69
|
+
maskKeydown(event) {
|
|
70
|
+
if (this.#disabled || this.#state !== "masked") return
|
|
71
|
+
if (event.target !== this.maskTarget) return
|
|
72
|
+
if (event.key !== "Enter" && event.key !== " ") return
|
|
73
|
+
|
|
74
|
+
event.preventDefault()
|
|
75
|
+
this.#reveal()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The input's keydown action: Escape re-masks and is consumed - the
|
|
80
|
+
* NEXT press reaches the dismissal layer.
|
|
81
|
+
*
|
|
82
|
+
* @param {KeyboardEvent} event
|
|
83
|
+
*/
|
|
84
|
+
inputKeydown(event) {
|
|
85
|
+
if (event.key !== "Escape" || isImeKeydown(event)) return
|
|
86
|
+
if (this.#state !== "revealed") return
|
|
87
|
+
|
|
88
|
+
// Consumed: this press masks; the NEXT one reaches the dismissal layer.
|
|
89
|
+
event.preventDefault()
|
|
90
|
+
event.stopPropagation()
|
|
91
|
+
this.#mask({ focusMask: true })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The root's focusout action: leaving the component with a value
|
|
96
|
+
* re-masks.
|
|
97
|
+
*
|
|
98
|
+
* @param {FocusEvent} event
|
|
99
|
+
*/
|
|
100
|
+
blurred(event) {
|
|
101
|
+
if (event.relatedTarget instanceof Node && this.element.contains(event.relatedTarget)) return
|
|
102
|
+
if (this.#state !== "revealed") return
|
|
103
|
+
|
|
104
|
+
this.#mask()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The input action: emptiness drives the state; the first character
|
|
109
|
+
* typed into an empty field reveals (composition belongs in type=text).
|
|
110
|
+
*/
|
|
111
|
+
changed() {
|
|
112
|
+
if (this.#state === "empty" && this.inputTarget.value !== "") this.#visible = true
|
|
113
|
+
|
|
114
|
+
this.#reflect()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The eye's click action: re-masks and hands focus to the mask button -
|
|
119
|
+
* the eye is about to hide (it only exists while revealed).
|
|
120
|
+
*
|
|
121
|
+
* @param {MouseEvent} event
|
|
122
|
+
*/
|
|
123
|
+
toggle(event) {
|
|
124
|
+
event.stopPropagation()
|
|
125
|
+
if (this.#state !== "revealed") return
|
|
126
|
+
|
|
127
|
+
this.#mask({ focusMask: true })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#visible = false
|
|
131
|
+
|
|
132
|
+
get #state() {
|
|
133
|
+
return this.element.getAttribute("data-state")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
get #disabled() {
|
|
137
|
+
return this.element.hasAttribute("data-disabled")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#reveal() {
|
|
141
|
+
this.#visible = true
|
|
142
|
+
// Move focus off the mask BEFORE #reflect hides it: hiding the
|
|
143
|
+
// focused element drops focus to body, blurred() reads that as
|
|
144
|
+
// leaving the field, and the re-mask beats the reveal (the
|
|
145
|
+
// takes-two-clicks bug). The input accepts .focus() even while
|
|
146
|
+
// masked (tabindex=-1 only skips Tab); read-only hands focus on
|
|
147
|
+
// to the eye once #reflect has shown it.
|
|
148
|
+
this.inputTarget.focus()
|
|
149
|
+
this.#reflect()
|
|
150
|
+
if (this.readOnlyValue && this.hasToggleTarget) this.toggleTarget.focus()
|
|
151
|
+
this.dispatch("reveal", { prefix: EVENT_PREFIX })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#mask({ focusMask = false } = {}) {
|
|
155
|
+
this.#visible = false
|
|
156
|
+
this.#reflect()
|
|
157
|
+
if (this.hiddenMessageValue !== "") announce(this.hiddenMessageValue)
|
|
158
|
+
if (focusMask) this.maskTarget.focus()
|
|
159
|
+
this.dispatch("mask", { prefix: EVENT_PREFIX })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#reflect() {
|
|
163
|
+
const empty = this.inputTarget.value === ""
|
|
164
|
+
const state = empty ? "empty" : this.#visible ? "revealed" : "masked"
|
|
165
|
+
this.element.setAttribute("data-state", state)
|
|
166
|
+
|
|
167
|
+
const masked = state === "masked"
|
|
168
|
+
|
|
169
|
+
if (masked) {
|
|
170
|
+
this.maskTarget.setAttribute("role", "button")
|
|
171
|
+
this.maskTarget.setAttribute("tabindex", this.#disabled ? "-1" : "0")
|
|
172
|
+
this.maskTarget.setAttribute("aria-label", this.maskedLabelValue)
|
|
173
|
+
if (this.hasHintTarget) this.maskTarget.setAttribute("aria-describedby", this.hintTarget.id)
|
|
174
|
+
} else {
|
|
175
|
+
this.maskTarget.removeAttribute("role")
|
|
176
|
+
this.maskTarget.removeAttribute("tabindex")
|
|
177
|
+
this.maskTarget.removeAttribute("aria-label")
|
|
178
|
+
this.maskTarget.removeAttribute("aria-describedby")
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The real input: rendered for layout, inert while masked; native
|
|
182
|
+
// password whenever the value is not shown.
|
|
183
|
+
this.inputTarget.type = state === "revealed" ? "text" : "password"
|
|
184
|
+
this.inputTarget.readOnly = masked || this.readOnlyValue
|
|
185
|
+
if (masked) {
|
|
186
|
+
this.inputTarget.setAttribute("aria-hidden", "true")
|
|
187
|
+
this.inputTarget.setAttribute("tabindex", "-1")
|
|
188
|
+
} else {
|
|
189
|
+
this.inputTarget.removeAttribute("aria-hidden")
|
|
190
|
+
this.inputTarget.removeAttribute("tabindex")
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (this.hasToggleTarget) this.toggleTarget.hidden = state !== "revealed"
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import DialogController from "@poetry/controllers/dialog_controller"
|
|
2
|
+
import { enterPresence } from "@poetry/controllers/helpers/presence"
|
|
3
|
+
|
|
4
|
+
// The Sheet: the dialog machinery, animated end to end. Everything hard
|
|
5
|
+
// about the overlay is inherited (native <dialog> platform trap,
|
|
6
|
+
// backdrop-click discrimination, scroll lock, hotkey) - including the
|
|
7
|
+
// presence-hold close, which the base controller owns (exit flips the
|
|
8
|
+
// pair to data-closed and holds the dialog through the slide-out before
|
|
9
|
+
// the native close()). This subclass only upgrades the ENTER: open rides
|
|
10
|
+
// enterPresence so the data-starting-style hook fires like every other
|
|
11
|
+
// presence consumer.
|
|
12
|
+
export default class SheetController extends DialogController {
|
|
13
|
+
/**
|
|
14
|
+
* Opens with the animated entry: showModal(), then the presence enter
|
|
15
|
+
* so the data-starting-style hook fires like every other presence
|
|
16
|
+
* consumer (the exit half is inherited - the base close() already holds
|
|
17
|
+
* through the slide-out).
|
|
18
|
+
*/
|
|
19
|
+
open() {
|
|
20
|
+
this.dialogTarget.showModal()
|
|
21
|
+
enterPresence(this.dialogTarget)
|
|
22
|
+
this.lockScroll()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { watchMobile } from "@poetry/controllers/helpers/breakpoint"
|
|
3
|
+
import { matchesHotkey } from "@poetry/controllers/helpers/hotkey"
|
|
4
|
+
import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
|
|
5
|
+
import { lockScroll, unlockScroll } from "@poetry/controllers/helpers/scroll_lock"
|
|
6
|
+
import { setState } from "@poetry/controllers/helpers/state"
|
|
7
|
+
import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
|
|
8
|
+
|
|
9
|
+
// The Sidebar state machine (desktop plus the mobile mode): expand/collapse
|
|
10
|
+
// coordination for the app shell. The COLLAPSE itself is pure CSS - the
|
|
11
|
+
// peer sidebar carries data-state=expanded|collapsed and the dictionary's
|
|
12
|
+
// group-data-[state=collapsed] classes do all the width/transform work;
|
|
13
|
+
// this controller only flips that attribute (plus data-collapsible, which
|
|
14
|
+
// the source sets to the mode WHILE collapsed and "" while expanded),
|
|
15
|
+
// persists the choice to a cookie (so the SERVER can read it and render
|
|
16
|
+
// the right initial state - poetry's server-first angle), and binds the
|
|
17
|
+
// Cmd/Ctrl+B shortcut.
|
|
18
|
+
//
|
|
19
|
+
// MOBILE (DOM-move): below md the trigger routes to a separate
|
|
20
|
+
// never-persisted openMobile state (only desktop
|
|
21
|
+
// toggles write the cookie). Opening ADOPTS the server-rendered nav
|
|
22
|
+
// children from the desktop inner into the mobile <dialog> (one render,
|
|
23
|
+
// no duplicate ids - the render-twice rejection) and shows it through the
|
|
24
|
+
// sheet presence path; closing holds through the slide-out, then moves
|
|
25
|
+
// the children back. Crossing to desktop while open restores INSTANTLY.
|
|
26
|
+
// The component-facing event namespace (the poetry:<component> rule).
|
|
27
|
+
const EVENT_PREFIX = "poetry:sidebar"
|
|
28
|
+
|
|
29
|
+
export default class SidebarController extends Controller {
|
|
30
|
+
// The events this controller dispatches (manifest surface;
|
|
31
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
32
|
+
static events = ["poetry:sidebar:mobile-toggle", "poetry:sidebar:toggle"]
|
|
33
|
+
|
|
34
|
+
static targets = ["sidebar", "inner", "mobileDialog", "mobileInner"]
|
|
35
|
+
static values = {
|
|
36
|
+
open: { type: Boolean, default: true },
|
|
37
|
+
// The collapse mode written to data-collapsible while collapsed
|
|
38
|
+
// (offcanvas | icon); "none" means the shortcut/trigger are inert.
|
|
39
|
+
collapsible: { type: String, default: "offcanvas" },
|
|
40
|
+
cookieName: { type: String, default: "sidebar_state" },
|
|
41
|
+
cookieMaxAge: { type: Number, default: 604800 }, // 7 days
|
|
42
|
+
shortcut: { type: String, default: "b" }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#onKeydown = null
|
|
46
|
+
#unwatchMobile = null
|
|
47
|
+
#unsubscribeBeforeCache = null
|
|
48
|
+
#isMobile = false
|
|
49
|
+
#mobileOpen = false
|
|
50
|
+
#closingMobile = false
|
|
51
|
+
#locked = false
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Heals a restored zombie snapshot, reflects the server value once (the
|
|
55
|
+
* body comment explains why not openValueChanged), starts the
|
|
56
|
+
* breakpoint watcher, subscribes the before-cache close, and binds the
|
|
57
|
+
* shortcut.
|
|
58
|
+
*/
|
|
59
|
+
connect() {
|
|
60
|
+
this.#healRestoredSnapshot()
|
|
61
|
+
// Reflect the server value to the DOM once. We do NOT drive reflection
|
|
62
|
+
// off openValueChanged - Stimulus fires value callbacks asynchronously
|
|
63
|
+
// (MutationObserver), so a click's DOM update would lag a frame; the
|
|
64
|
+
// mutators below reflect synchronously instead.
|
|
65
|
+
this.#reflect()
|
|
66
|
+
|
|
67
|
+
this.#unwatchMobile = watchMobile((mobile) => this.#mobileChanged(mobile))
|
|
68
|
+
|
|
69
|
+
// Close before Turbo snapshots (instantly - the page is being torn
|
|
70
|
+
// down anyway): an open mobile sheet serialized into the cache
|
|
71
|
+
// restores as a de-modalized zombie holding the nav children hostage
|
|
72
|
+
// over a frozen scroll lock.
|
|
73
|
+
this.#unsubscribeBeforeCache = onBeforeCache(() => {
|
|
74
|
+
if (!this.#mobileOpen) return
|
|
75
|
+
this.#closingMobile = false
|
|
76
|
+
this.mobileDialogTarget.removeAttribute("data-ending-style")
|
|
77
|
+
setState(this.mobileDialogTarget, "closed")
|
|
78
|
+
this.#restoreMobile()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
this.#onKeydown = (event) => {
|
|
82
|
+
// The full descriptor grammar (the dialog idiom): a bare metaKey||
|
|
83
|
+
// ctrlKey check also fires on stray-modifier chords (Cmd+Shift+B).
|
|
84
|
+
if (event.defaultPrevented || !matchesHotkey(event, `meta+${this.shortcutValue}`)) return
|
|
85
|
+
|
|
86
|
+
event.preventDefault()
|
|
87
|
+
this.toggle()
|
|
88
|
+
}
|
|
89
|
+
window.addEventListener("keydown", this.#onKeydown)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Unwires the shortcut / watcher / before-cache subscriptions and
|
|
94
|
+
* balances the scroll lock.
|
|
95
|
+
*/
|
|
96
|
+
disconnect() {
|
|
97
|
+
if (this.#onKeydown) window.removeEventListener("keydown", this.#onKeydown)
|
|
98
|
+
this.#onKeydown = null
|
|
99
|
+
this.#unwatchMobile?.()
|
|
100
|
+
this.#unwatchMobile = null
|
|
101
|
+
this.#unsubscribeBeforeCache?.()
|
|
102
|
+
this.#unsubscribeBeforeCache = null
|
|
103
|
+
this.#unlock()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The trigger's (and rail's) click action - also the shortcut's
|
|
108
|
+
* landing. On mobile the SAME trigger routes to the sheet; on desktop
|
|
109
|
+
* it flips the collapse (inert when collapsible is "none").
|
|
110
|
+
*/
|
|
111
|
+
toggle() {
|
|
112
|
+
if (this.#isMobile && this.hasMobileDialogTarget) {
|
|
113
|
+
if (this.#mobileOpen) this.closeMobile()
|
|
114
|
+
else this.#openMobile()
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
if (this.collapsibleValue === "none") return
|
|
118
|
+
|
|
119
|
+
this.#set(!this.openValue)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// -- the mobile sheet -------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The mobile dialog's cancel action (and the close affordances): closes
|
|
126
|
+
* through the sheet exit, then moves the nav children home.
|
|
127
|
+
*
|
|
128
|
+
* @param {Event} [event] - the native cancel event, when Esc drove it
|
|
129
|
+
*/
|
|
130
|
+
closeMobile(event) {
|
|
131
|
+
if (event?.type === "cancel") event.preventDefault() // route Esc through the animated path
|
|
132
|
+
if (!this.#mobileOpen || this.#closingMobile) return
|
|
133
|
+
|
|
134
|
+
this.#closingMobile = true
|
|
135
|
+
exitPresence(this.mobileDialogTarget, {
|
|
136
|
+
onRemove: () => {
|
|
137
|
+
this.#closingMobile = false
|
|
138
|
+
this.#restoreMobile()
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The mobile dialog's click action - the dialog's coordinate
|
|
145
|
+
* discrimination (a backdrop click targets the <dialog> itself AND
|
|
146
|
+
* lands outside its bounding rect).
|
|
147
|
+
*
|
|
148
|
+
* @param {MouseEvent} event
|
|
149
|
+
*/
|
|
150
|
+
mobileBackdropClose(event) {
|
|
151
|
+
if (event.target !== this.mobileDialogTarget) return
|
|
152
|
+
|
|
153
|
+
const rect = this.mobileDialogTarget.getBoundingClientRect()
|
|
154
|
+
const inside = rect.top <= event.clientY && event.clientY <= rect.bottom &&
|
|
155
|
+
rect.left <= event.clientX && event.clientX <= rect.right
|
|
156
|
+
if (!inside) this.closeMobile()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// DOM-move: adopt the server-rendered nav into the mobile
|
|
160
|
+
// dialog - one render, no duplicate ids.
|
|
161
|
+
#openMobile() {
|
|
162
|
+
if (this.#mobileOpen || !this.hasMobileInnerTarget) return
|
|
163
|
+
|
|
164
|
+
while (this.innerTarget.firstChild) this.mobileInnerTarget.appendChild(this.innerTarget.firstChild)
|
|
165
|
+
this.mobileDialogTarget.showModal()
|
|
166
|
+
enterPresence(this.mobileDialogTarget)
|
|
167
|
+
this.#lock()
|
|
168
|
+
this.#mobileOpen = true
|
|
169
|
+
this.dispatch("mobile-toggle", { prefix: EVENT_PREFIX, detail: { open: true } })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Close the native dialog and move the nav children HOME.
|
|
173
|
+
#restoreMobile() {
|
|
174
|
+
this.mobileDialogTarget.close()
|
|
175
|
+
while (this.mobileInnerTarget.firstChild) this.innerTarget.appendChild(this.mobileInnerTarget.firstChild)
|
|
176
|
+
this.#unlock()
|
|
177
|
+
this.#mobileOpen = false
|
|
178
|
+
this.dispatch("mobile-toggle", { prefix: EVENT_PREFIX, detail: { open: false } })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Crossing to desktop while the sheet is open restores INSTANTLY (no
|
|
182
|
+
// exit animation - the layout is changing wholesale anyway).
|
|
183
|
+
#mobileChanged(mobile) {
|
|
184
|
+
this.#isMobile = mobile
|
|
185
|
+
if (!mobile && this.#mobileOpen && !this.#closingMobile) {
|
|
186
|
+
this.mobileDialogTarget.removeAttribute("data-ending-style")
|
|
187
|
+
setState(this.mobileDialogTarget, "closed")
|
|
188
|
+
this.#restoreMobile()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Programmatically expands the desktop sidebar (persisted). */
|
|
193
|
+
open() {
|
|
194
|
+
this.#set(true)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Programmatically collapses the desktop sidebar (persisted). */
|
|
198
|
+
close() {
|
|
199
|
+
this.#set(false)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The single mutator: update the value, reflect synchronously, persist.
|
|
203
|
+
// The cookie is written for genuine toggles only - the server already
|
|
204
|
+
// knows the initial value (it read the cookie to render it), so connect
|
|
205
|
+
// reflects without persisting.
|
|
206
|
+
#set(open) {
|
|
207
|
+
this.openValue = open
|
|
208
|
+
this.#reflect()
|
|
209
|
+
this.#persist()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#reflect() {
|
|
213
|
+
const collapsed = !this.openValue
|
|
214
|
+
|
|
215
|
+
for (const sidebar of this.sidebarTargets) {
|
|
216
|
+
sidebar.setAttribute("data-state", collapsed ? "collapsed" : "expanded")
|
|
217
|
+
sidebar.setAttribute("data-collapsible", collapsed ? this.collapsibleValue : "")
|
|
218
|
+
}
|
|
219
|
+
this.dispatch("toggle", { prefix: EVENT_PREFIX, detail: { open: this.openValue } })
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#persist() {
|
|
223
|
+
if (typeof document === "undefined") return
|
|
224
|
+
|
|
225
|
+
document.cookie =
|
|
226
|
+
`${this.cookieNameValue}=${this.openValue}; path=/; max-age=${this.cookieMaxAgeValue}; samesite=lax`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// A mobile sheet restored from a PRE-FIX cached snapshot: the open
|
|
230
|
+
// attribute survived serialization (the nav children with it), and the
|
|
231
|
+
// body's inline lock styles came back with no refcount behind them.
|
|
232
|
+
// Normalize to closed, move the children home, and clear the orphaned
|
|
233
|
+
// lock styles directly (the refcounted helper is at zero on a fresh
|
|
234
|
+
// page and must not be decremented).
|
|
235
|
+
#healRestoredSnapshot() {
|
|
236
|
+
if (!this.hasMobileDialogTarget || !this.mobileDialogTarget.open) return
|
|
237
|
+
// A genuinely modal dialog only reconnects mid-flight when its subtree
|
|
238
|
+
// is MOVED while open - leave those alone (the dialog heal's rule).
|
|
239
|
+
let modal = false
|
|
240
|
+
try {
|
|
241
|
+
modal = this.mobileDialogTarget.matches(":modal")
|
|
242
|
+
} catch {
|
|
243
|
+
modal = false
|
|
244
|
+
}
|
|
245
|
+
if (modal) return
|
|
246
|
+
|
|
247
|
+
this.mobileDialogTarget.close()
|
|
248
|
+
setState(this.mobileDialogTarget, "closed")
|
|
249
|
+
if (this.hasMobileInnerTarget && this.hasInnerTarget) {
|
|
250
|
+
while (this.mobileInnerTarget.firstChild) this.innerTarget.appendChild(this.mobileInnerTarget.firstChild)
|
|
251
|
+
}
|
|
252
|
+
document.body.style.overflow = ""
|
|
253
|
+
document.body.style.paddingRight = ""
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Shared refcounted lock with scrollbar-gutter compensation (the dialog
|
|
257
|
+
// idiom) - the instance flag keeps double-unlocks (before-cache close,
|
|
258
|
+
// then disconnect) balanced.
|
|
259
|
+
#lock() {
|
|
260
|
+
if (this.#locked) return
|
|
261
|
+
|
|
262
|
+
this.#locked = true
|
|
263
|
+
lockScroll()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#unlock() {
|
|
267
|
+
if (!this.#locked) return
|
|
268
|
+
|
|
269
|
+
this.#locked = false
|
|
270
|
+
unlockScroll()
|
|
271
|
+
}
|
|
272
|
+
}
|