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,202 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// The Resizable engine, decided NATIVE: no panel library -
|
|
4
|
+
// panels are flex children whose flex-grow IS the percentage, and this
|
|
5
|
+
// controller implements the APG window-splitter on the handles: pointer
|
|
6
|
+
// drag redistributes the two adjacent panels (clamped to each panel's
|
|
7
|
+
// min/max), arrows step, Home/End jump the range, and every move writes
|
|
8
|
+
// aria-valuenow (the preceding panel's size).
|
|
9
|
+
// Deferred with the library's machinery: persistence (autoSaveId),
|
|
10
|
+
// collapsible panels, and the imperative API.
|
|
11
|
+
const PANEL_SELECTOR = '[data-slot="resizable-panel"]'
|
|
12
|
+
const HANDLE_SELECTOR = '[data-slot="resizable-handle"]'
|
|
13
|
+
|
|
14
|
+
const KEY_STEP = 5 // percent per arrow press (the APG "larger than one pixel" step)
|
|
15
|
+
const DEFAULT_MIN = 10
|
|
16
|
+
const DEFAULT_MAX = 90
|
|
17
|
+
|
|
18
|
+
// The component-facing event namespace (the poetry:<component> rule).
|
|
19
|
+
const EVENT_PREFIX = "poetry:resizable"
|
|
20
|
+
|
|
21
|
+
export default class ResizableController extends Controller {
|
|
22
|
+
// The events this controller dispatches (manifest surface;
|
|
23
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
24
|
+
static events = ["poetry:resizable:resize"]
|
|
25
|
+
|
|
26
|
+
static values = {
|
|
27
|
+
orientation: { type: String, default: "horizontal" }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#drag = null
|
|
31
|
+
|
|
32
|
+
/** Writes the initial aria-value* reflection onto every handle. */
|
|
33
|
+
connect() {
|
|
34
|
+
this.#reflectAll()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Each handle's pointerdown action: begins a drag between the two
|
|
39
|
+
* adjacent panels (pointer capture holds the gesture).
|
|
40
|
+
*
|
|
41
|
+
* @param {PointerEvent} event
|
|
42
|
+
*/
|
|
43
|
+
dragStart(event) {
|
|
44
|
+
if (event.button !== 0 && event.pointerType === "mouse") return
|
|
45
|
+
|
|
46
|
+
const handle = this.#handleFrom(event)
|
|
47
|
+
if (!handle) return
|
|
48
|
+
|
|
49
|
+
const [before, after] = this.#panelsAround(handle)
|
|
50
|
+
if (!before || !after) return
|
|
51
|
+
|
|
52
|
+
this.#drag = {
|
|
53
|
+
pointerId: event.pointerId, handle, before, after,
|
|
54
|
+
start: this.#along(event),
|
|
55
|
+
beforeSize: this.#sizeOf(before), afterSize: this.#sizeOf(after)
|
|
56
|
+
}
|
|
57
|
+
handle.setPointerCapture(event.pointerId)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The captured pointermove action: redistributes the two panels by the
|
|
62
|
+
* drag delta, clamped to both panels' min/max.
|
|
63
|
+
*
|
|
64
|
+
* @param {PointerEvent} event
|
|
65
|
+
*/
|
|
66
|
+
dragMove(event) {
|
|
67
|
+
const drag = this.#drag
|
|
68
|
+
if (!drag || event.pointerId !== drag.pointerId) return
|
|
69
|
+
|
|
70
|
+
const groupSize = this.#groupSize()
|
|
71
|
+
const deltaPercent = ((this.#along(event) - drag.start) / groupSize) * 100
|
|
72
|
+
|
|
73
|
+
this.#apply(drag.before, drag.after, drag.beforeSize + deltaPercent, drag.beforeSize + drag.afterSize)
|
|
74
|
+
this.#reflect(drag.handle)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The pointerup / pointercancel action: ends the drag.
|
|
79
|
+
*
|
|
80
|
+
* @param {PointerEvent} event
|
|
81
|
+
*/
|
|
82
|
+
dragEnd(event) {
|
|
83
|
+
if (!this.#drag || event.pointerId !== this.#drag.pointerId) return
|
|
84
|
+
|
|
85
|
+
this.#drag = null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Each handle's keydown action (the APG window splitter): arrows step
|
|
90
|
+
* by 5%, Home/End jump the range.
|
|
91
|
+
*
|
|
92
|
+
* @param {KeyboardEvent} event
|
|
93
|
+
*/
|
|
94
|
+
keydown(event) {
|
|
95
|
+
const handle = this.#handleFrom(event)
|
|
96
|
+
if (!handle) return
|
|
97
|
+
|
|
98
|
+
const [before, after] = this.#panelsAround(handle)
|
|
99
|
+
if (!before || !after) return
|
|
100
|
+
|
|
101
|
+
const [decrease, increase] = this.orientationValue === "vertical"
|
|
102
|
+
? ["ArrowUp", "ArrowDown"]
|
|
103
|
+
: ["ArrowLeft", "ArrowRight"]
|
|
104
|
+
const pool = this.#sizeOf(before) + this.#sizeOf(after)
|
|
105
|
+
let target = null
|
|
106
|
+
|
|
107
|
+
if (event.key === decrease) target = this.#sizeOf(before) - KEY_STEP
|
|
108
|
+
else if (event.key === increase) target = this.#sizeOf(before) + KEY_STEP
|
|
109
|
+
else if (event.key === "Home") target = this.#minOf(before)
|
|
110
|
+
else if (event.key === "End") target = pool - this.#minOf(after)
|
|
111
|
+
else return
|
|
112
|
+
|
|
113
|
+
event.preventDefault()
|
|
114
|
+
this.#apply(before, after, target, pool)
|
|
115
|
+
this.#reflect(handle)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Redistribute the shared pool between two adjacent panels, honoring
|
|
119
|
+
// BOTH panels' min/max.
|
|
120
|
+
#apply(before, after, requestedBefore, pool) {
|
|
121
|
+
const beforeSize = Math.min(
|
|
122
|
+
Math.max(requestedBefore, this.#minOf(before), pool - this.#maxOf(after)),
|
|
123
|
+
this.#maxOf(before), pool - this.#minOf(after)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
this.#write(before, beforeSize)
|
|
127
|
+
this.#write(after, pool - beforeSize)
|
|
128
|
+
this.dispatch("resize", { prefix: EVENT_PREFIX, detail: { sizes: this.#panels().map((panel) => this.#sizeOf(panel)) } })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#write(panel, size) {
|
|
132
|
+
panel.style.flexGrow = String(Math.round(size * 100) / 100)
|
|
133
|
+
panel.style.flexShrink = "1"
|
|
134
|
+
panel.style.flexBasis = "0px"
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// aria-valuenow = the PRECEDING panel's size.
|
|
138
|
+
#reflect(handle) {
|
|
139
|
+
const [before] = this.#panelsAround(handle)
|
|
140
|
+
if (!before) return
|
|
141
|
+
|
|
142
|
+
handle.setAttribute("aria-valuenow", String(Math.round(this.#sizeOf(before))))
|
|
143
|
+
handle.setAttribute("aria-valuemin", String(Math.round(this.#minOf(before))))
|
|
144
|
+
handle.setAttribute("aria-valuemax", String(Math.round(this.#maxOf(before))))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#reflectAll() {
|
|
148
|
+
for (const handle of this.#handles()) this.#reflect(handle)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#handleFrom(event) {
|
|
152
|
+
const origin = event.target instanceof Element ? event.target : null
|
|
153
|
+
const handle = origin?.closest(HANDLE_SELECTOR)
|
|
154
|
+
|
|
155
|
+
return handle && this.#owns(handle) ? handle : null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The panels immediately around a handle, in DOM order.
|
|
159
|
+
#panelsAround(handle) {
|
|
160
|
+
let before = handle.previousElementSibling
|
|
161
|
+
while (before && !before.matches(PANEL_SELECTOR)) before = before.previousElementSibling
|
|
162
|
+
let after = handle.nextElementSibling
|
|
163
|
+
while (after && !after.matches(PANEL_SELECTOR)) after = after.nextElementSibling
|
|
164
|
+
|
|
165
|
+
return [before, after]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#sizeOf(panel) {
|
|
169
|
+
return parseFloat(panel.style.flexGrow || "1") || 1
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#minOf(panel) {
|
|
173
|
+
return parseFloat(panel.dataset.minSize ?? "") || DEFAULT_MIN
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#maxOf(panel) {
|
|
177
|
+
return parseFloat(panel.dataset.maxSize ?? "") || DEFAULT_MAX
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#along(event) {
|
|
181
|
+
return this.orientationValue === "vertical" ? event.clientY : event.clientX
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#groupSize() {
|
|
185
|
+
const rect = this.element.getBoundingClientRect()
|
|
186
|
+
|
|
187
|
+
return Math.max(1, this.orientationValue === "vertical" ? rect.height : rect.width)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#panels() {
|
|
191
|
+
return [...this.element.querySelectorAll(PANEL_SELECTOR)].filter((el) => this.#owns(el))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#handles() {
|
|
195
|
+
return [...this.element.querySelectorAll(HANDLE_SELECTOR)].filter((el) => this.#owns(el))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Nested groups self-scope (the DOM is the registry).
|
|
199
|
+
#owns(el) {
|
|
200
|
+
return el.closest(`[data-controller~="${this.identifier}"]`) === this.element
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { COLLECTION_ITEM_SELECTOR, collectionItems } from "@poetry/controllers/helpers/collection"
|
|
3
|
+
import { directionOf } from "@poetry/controllers/helpers/direction"
|
|
4
|
+
|
|
5
|
+
// The roving-tabindex engine: the group is ONE Tab stop -
|
|
6
|
+
// exactly one item holds tabindex=0, the rest -1, and arrow keys move focus
|
|
7
|
+
// among the collection items in DOM order. Orientation gates which arrows
|
|
8
|
+
// are live (horizontal flips Left/Right under RTL via the direction
|
|
9
|
+
// helper); Home/End jump to the edges; loop wraps. Backs Tabs, Toolbar,
|
|
10
|
+
// RadioGroup, Menus, ToggleGroup, and the Select/Command list.
|
|
11
|
+
//
|
|
12
|
+
// Dynamic items: NAVIGATION recomputes the collection on every keydown (the
|
|
13
|
+
// DOM is the registry - always fresh, zero bookkeeping), but that alone is
|
|
14
|
+
// not correct: an item appended between keystrokes would sit at its natural
|
|
15
|
+
// tabindex and grow the group a SECOND Tab stop before any arrow is
|
|
16
|
+
// pressed. The MutationObserver exists for that one job - re-stamping the
|
|
17
|
+
// roving tabindex the moment items enter or leave.
|
|
18
|
+
export default class RovingFocusController extends Controller {
|
|
19
|
+
// The events this controller dispatches (manifest surface;
|
|
20
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
21
|
+
static events = ["poetry--core--roving-focus:entry"]
|
|
22
|
+
|
|
23
|
+
static values = {
|
|
24
|
+
orientation: { type: String, default: "vertical" }, // horizontal | vertical | both
|
|
25
|
+
loop: { type: Boolean, default: true },
|
|
26
|
+
// false = focus-nav-only mode (the APG accordion contract): every
|
|
27
|
+
// trigger stays tabbable and arrows are convenience navigation, not a
|
|
28
|
+
// roving tab stop. The controller then NEVER writes tabindex - not on
|
|
29
|
+
// connect, not on mutation, not on focus moves.
|
|
30
|
+
manageTabindex: { type: Boolean, default: true }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#observer = null
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Stamps the initial tab stops and starts the item-mutation
|
|
37
|
+
* re-stamping (the header's second-Tab-stop hazard).
|
|
38
|
+
*/
|
|
39
|
+
connect() {
|
|
40
|
+
this.#syncItems()
|
|
41
|
+
|
|
42
|
+
this.#observer = new MutationObserver(() => this.#syncItems())
|
|
43
|
+
this.#observer.observe(this.element, { childList: true, subtree: true })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Stops the mutation observer. */
|
|
47
|
+
disconnect() {
|
|
48
|
+
this.#observer?.disconnect()
|
|
49
|
+
this.#observer = null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The group root's keydown action: arrows/Home/End move the roving
|
|
54
|
+
* focus - orientation-gated, RTL-aware, loop-wrapping. The caret guard
|
|
55
|
+
* leaves text-entry controls their keys until the caret reaches the
|
|
56
|
+
* travel-direction boundary.
|
|
57
|
+
*
|
|
58
|
+
* @param {KeyboardEvent} event
|
|
59
|
+
*/
|
|
60
|
+
keydown(event) {
|
|
61
|
+
if (event.defaultPrevented) return // a nested group (an open menu level) already moved focus
|
|
62
|
+
if (this.#caretTrapped(event)) return // the caret owns this key inside a text control
|
|
63
|
+
|
|
64
|
+
const items = this.#items()
|
|
65
|
+
|
|
66
|
+
if (items.length === 0) return
|
|
67
|
+
|
|
68
|
+
const nextIndex = this.#nextIndex(event.key, this.#indexFor(event.target, items), items.length)
|
|
69
|
+
|
|
70
|
+
if (nextIndex === null) return
|
|
71
|
+
|
|
72
|
+
event.preventDefault() // a handled arrow must not also scroll the page
|
|
73
|
+
this.#focusItem(items[nextIndex], items)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The caret guard: when the keydown originates inside a text-entry
|
|
77
|
+
// control (a toolbar's search field, a menu's filter input), the caret
|
|
78
|
+
// owns horizontal arrows and Home/End until it reaches the boundary in
|
|
79
|
+
// the travel direction - only then does the group rove. Any selection
|
|
80
|
+
// means the key collapses it (caret work, never roving); contenteditable
|
|
81
|
+
// and controls without a selection API (type=number steps on arrows)
|
|
82
|
+
// are never stolen from. Vertical arrows inside a textarea stay with the
|
|
83
|
+
// caret too; a single-line input has no vertical caret motion, so
|
|
84
|
+
// Up/Down rove as usual.
|
|
85
|
+
#caretTrapped(event) {
|
|
86
|
+
const target = event.target
|
|
87
|
+
|
|
88
|
+
if (!(target instanceof HTMLElement)) return false
|
|
89
|
+
if (target.isContentEditable) return true
|
|
90
|
+
|
|
91
|
+
const isTextarea = target instanceof HTMLTextAreaElement
|
|
92
|
+
const isInput = target instanceof HTMLInputElement
|
|
93
|
+
|
|
94
|
+
if (!isTextarea && !isInput) return false
|
|
95
|
+
if (isTextarea && event.key.startsWith("Arrow")) return true
|
|
96
|
+
|
|
97
|
+
const horizontal = ["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)
|
|
98
|
+
|
|
99
|
+
if (!horizontal) return false
|
|
100
|
+
|
|
101
|
+
const start = target.selectionStart
|
|
102
|
+
const end = target.selectionEnd
|
|
103
|
+
|
|
104
|
+
if (start === null || end === null) return true // no selection API: never steal
|
|
105
|
+
if (start !== end) return true // a selection collapses first
|
|
106
|
+
|
|
107
|
+
const length = target.value.length
|
|
108
|
+
const rtl = directionOf(target) === "rtl"
|
|
109
|
+
const towardStart = event.key === "Home" || (event.key === (rtl ? "ArrowRight" : "ArrowLeft"))
|
|
110
|
+
|
|
111
|
+
return towardStart ? start > 0 : end < length
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The group's OWN items only: a hidden subtree (a closed submenu level) is
|
|
115
|
+
// not navigable, data-disabled items are filtered at query time (the menu
|
|
116
|
+
// contract), and an item claimed by a DESCENDANT roving group (an open
|
|
117
|
+
// submenu's content) roves there, not here - nested groups self-scope
|
|
118
|
+
// without any registration bookkeeping (the DOM is the registry).
|
|
119
|
+
#items() {
|
|
120
|
+
return collectionItems(this.element).filter((item) => this.#owns(item))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#owns(item) {
|
|
124
|
+
if (item.closest("[hidden]")) return false
|
|
125
|
+
if (item.hasAttribute("data-disabled")) return false
|
|
126
|
+
|
|
127
|
+
const scope = item.parentElement?.closest(`[data-controller~="${this.identifier}"]`)
|
|
128
|
+
|
|
129
|
+
return !scope || scope === this.element
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#indexFor(target, items) {
|
|
133
|
+
const item = target instanceof Element ? target.closest(COLLECTION_ITEM_SELECTOR) : null
|
|
134
|
+
const index = items.indexOf(item)
|
|
135
|
+
|
|
136
|
+
if (index !== -1) return index
|
|
137
|
+
|
|
138
|
+
// Keydown from the group itself (entry): start from the current tab stop.
|
|
139
|
+
return Math.max(this.#currentIndex(items), 0)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#currentIndex(items) {
|
|
143
|
+
return items.findIndex((item) => item.getAttribute("tabindex") === "0")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#nextIndex(key, index, count) {
|
|
147
|
+
if (key === "Home") return 0
|
|
148
|
+
if (key === "End") return count - 1
|
|
149
|
+
|
|
150
|
+
const delta = this.#arrowDelta(key)
|
|
151
|
+
|
|
152
|
+
if (delta === null) return null
|
|
153
|
+
|
|
154
|
+
const next = index + delta
|
|
155
|
+
|
|
156
|
+
if (this.loopValue) return (next + count) % count
|
|
157
|
+
|
|
158
|
+
return Math.min(Math.max(next, 0), count - 1) // no loop: the edges clamp
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Orientation gates the axis; the cross-axis arrows fall through untouched
|
|
162
|
+
// (a vertical menu must not swallow Left/Right). "both" activates all four
|
|
163
|
+
// arrows (the APG radio contract): Down/Right advance,
|
|
164
|
+
// Up/Left retreat, with ONLY the horizontal pair RTL-flipped. RTL is read
|
|
165
|
+
// per keystroke from the closest [dir] ancestor, so a dir flip needs no
|
|
166
|
+
// reconnect.
|
|
167
|
+
#arrowDelta(key) {
|
|
168
|
+
const axis = this.orientationValue
|
|
169
|
+
|
|
170
|
+
if (axis !== "vertical" && (key === "ArrowRight" || key === "ArrowLeft")) {
|
|
171
|
+
const rtl = directionOf(this.element) === "rtl"
|
|
172
|
+
|
|
173
|
+
if (key === "ArrowRight") return rtl ? -1 : 1
|
|
174
|
+
|
|
175
|
+
return rtl ? 1 : -1
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (axis !== "horizontal") {
|
|
179
|
+
if (key === "ArrowDown") return 1
|
|
180
|
+
if (key === "ArrowUp") return -1
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Cancelable entry: a consumer preventDefault()s to keep focus where it
|
|
187
|
+
// is. The tab stop only moves when focus actually does.
|
|
188
|
+
#focusItem(item, items = this.#items()) {
|
|
189
|
+
const entry = this.dispatch("entry", { detail: { item }, cancelable: true })
|
|
190
|
+
|
|
191
|
+
if (entry.defaultPrevented) return
|
|
192
|
+
|
|
193
|
+
this.#writeTabStops(items, item)
|
|
194
|
+
item.focus()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// One 0, rest -1. The current stop survives re-syncs; if it was removed
|
|
198
|
+
// (or never existed - fresh connect), the first item takes it.
|
|
199
|
+
#syncItems() {
|
|
200
|
+
const items = this.#items()
|
|
201
|
+
|
|
202
|
+
this.element.dataset.orientation = this.orientationValue
|
|
203
|
+
|
|
204
|
+
const currentIndex = this.#currentIndex(items)
|
|
205
|
+
this.#writeTabStops(items, items[Math.max(currentIndex, 0)])
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#writeTabStops(items, current) {
|
|
209
|
+
for (const item of items) {
|
|
210
|
+
if (this.manageTabindexValue) item.setAttribute("tabindex", item === current ? "0" : "-1")
|
|
211
|
+
|
|
212
|
+
item.dataset.orientation = this.orientationValue
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Scroll-spy: marks the
|
|
4
|
+
// nav link whose section is currently active while the page scrolls - the
|
|
5
|
+
// docs-TOC pattern. Put the controller on the nav; every link target's
|
|
6
|
+
// href="#id" names its section:
|
|
7
|
+
//
|
|
8
|
+
// <nav data-controller="poetry--core--scroll-spy">
|
|
9
|
+
// <a href="#usage" data-poetry--core--scroll-spy-target="link">Usage</a>
|
|
10
|
+
//
|
|
11
|
+
// The active section is the LAST one whose top sits above the offset line
|
|
12
|
+
// (a closest-heading reduce); its link gains data-active and a
|
|
13
|
+
// poetry--core--scroll-spy:changed event carries the id. rAF-coalesced
|
|
14
|
+
// passive scroll + resize listeners; call refresh() after content changes.
|
|
15
|
+
export default class extends Controller {
|
|
16
|
+
static targets = ["link"]
|
|
17
|
+
static values = {
|
|
18
|
+
// Viewport y (px) a section must cross to count as active.
|
|
19
|
+
offset: { type: Number, default: 96 }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static events = ["poetry--core--scroll-spy:changed"]
|
|
23
|
+
|
|
24
|
+
#onScroll = null
|
|
25
|
+
#frame = null
|
|
26
|
+
#sections = []
|
|
27
|
+
#activeId = null
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the sections and wires the rAF-coalesced passive
|
|
31
|
+
* scroll/resize listeners.
|
|
32
|
+
*/
|
|
33
|
+
connect() {
|
|
34
|
+
this.refresh()
|
|
35
|
+
this.#onScroll = () => {
|
|
36
|
+
if (this.#frame !== null) return
|
|
37
|
+
|
|
38
|
+
this.#frame = requestAnimationFrame(() => {
|
|
39
|
+
this.#frame = null
|
|
40
|
+
this.#update()
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
window.addEventListener("scroll", this.#onScroll, { passive: true })
|
|
44
|
+
window.addEventListener("resize", this.#onScroll, { passive: true })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Unwires the listeners and cancels any pending frame. */
|
|
48
|
+
disconnect() {
|
|
49
|
+
window.removeEventListener("scroll", this.#onScroll)
|
|
50
|
+
window.removeEventListener("resize", this.#onScroll)
|
|
51
|
+
if (this.#frame !== null) cancelAnimationFrame(this.#frame)
|
|
52
|
+
this.#frame = null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Re-resolves sections from the links' hashes (content changed, Turbo
|
|
57
|
+
* morph, tab switch) and recomputes immediately.
|
|
58
|
+
*/
|
|
59
|
+
refresh() {
|
|
60
|
+
this.#sections = this.linkTargets
|
|
61
|
+
.map((link) => {
|
|
62
|
+
const hash = link.getAttribute("href") || ""
|
|
63
|
+
const id = hash.startsWith("#") ? hash.slice(1) : null
|
|
64
|
+
return id ? { link, section: document.getElementById(id) } : null
|
|
65
|
+
})
|
|
66
|
+
.filter((entry) => entry && entry.section)
|
|
67
|
+
this.#update()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#update() {
|
|
71
|
+
let active = null
|
|
72
|
+
for (const entry of this.#sections) {
|
|
73
|
+
if (entry.section.getBoundingClientRect().top <= this.offsetValue) active = entry
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const id = active ? active.section.id : null
|
|
77
|
+
if (id === this.#activeId) return
|
|
78
|
+
|
|
79
|
+
this.#activeId = id
|
|
80
|
+
this.linkTargets.forEach((link) => link.removeAttribute("data-active"))
|
|
81
|
+
if (active) active.link.setAttribute("data-active", "")
|
|
82
|
+
this.dispatch("changed", { detail: { id } })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { isImeKeydown } from "@poetry/controllers/helpers/escape"
|
|
3
|
+
|
|
4
|
+
// The SearchField seams: Escape CLEARS a
|
|
5
|
+
// non-empty field and is consumed - the NEXT press reaches the dismissal
|
|
6
|
+
// layer and closes a parent overlay; an already-empty field lets Escape
|
|
7
|
+
// propagate untouched. Emptiness is checked against the RAW input value
|
|
8
|
+
// (autofill and scripts poke the DOM directly). The clear button keeps
|
|
9
|
+
// focus in the input by preventing the press's focus steal at
|
|
10
|
+
// pointerdown - on mobile that is what keeps the virtual keyboard up.
|
|
11
|
+
// Enter is never intercepted: native form submission is the Rails path.
|
|
12
|
+
const EVENT_PREFIX = "poetry:search-field"
|
|
13
|
+
|
|
14
|
+
export default class SearchFieldController extends Controller {
|
|
15
|
+
// The events this controller dispatches (manifest surface;
|
|
16
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
17
|
+
static events = ["poetry:search-field:clear"]
|
|
18
|
+
|
|
19
|
+
static targets = ["input", "clear"]
|
|
20
|
+
|
|
21
|
+
/** Reflects the initial emptiness (data-empty + the clear affordance). */
|
|
22
|
+
connect() {
|
|
23
|
+
this.#reflect()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The input action: keeps data-empty and the clear affordance honest. */
|
|
27
|
+
changed() {
|
|
28
|
+
this.#reflect()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The input's keydown action: Escape CLEARS a non-empty field and is
|
|
33
|
+
* consumed; an already-empty field lets it propagate to the dismissal
|
|
34
|
+
* layer (the seam the header documents).
|
|
35
|
+
*
|
|
36
|
+
* @param {KeyboardEvent} event
|
|
37
|
+
*/
|
|
38
|
+
keydown(event) {
|
|
39
|
+
if (event.key !== "Escape" || isImeKeydown(event)) return
|
|
40
|
+
if (this.inputTarget.disabled || this.inputTarget.readOnly) return
|
|
41
|
+
if (this.inputTarget.value === "") return // propagate: the dismissal layer's turn
|
|
42
|
+
|
|
43
|
+
event.preventDefault()
|
|
44
|
+
event.stopPropagation()
|
|
45
|
+
this.#clear()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The clear button's pointerdown action: keeps focus (and the mobile
|
|
50
|
+
* keyboard) in the input - the later click acts.
|
|
51
|
+
*
|
|
52
|
+
* @param {PointerEvent} event
|
|
53
|
+
*/
|
|
54
|
+
holdFocus(event) {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
this.inputTarget.focus()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The clear button's click action: empties the field (dispatching a
|
|
61
|
+
* REAL input event so live-search listeners react) and refocuses it.
|
|
62
|
+
* No-op while disabled or readonly.
|
|
63
|
+
*
|
|
64
|
+
* @param {MouseEvent} event
|
|
65
|
+
*/
|
|
66
|
+
clear(event) {
|
|
67
|
+
event.preventDefault()
|
|
68
|
+
if (this.inputTarget.disabled || this.inputTarget.readOnly) return
|
|
69
|
+
|
|
70
|
+
this.#clear()
|
|
71
|
+
this.inputTarget.focus()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#clear() {
|
|
75
|
+
if (this.inputTarget.value !== "") {
|
|
76
|
+
this.inputTarget.value = ""
|
|
77
|
+
// A REAL input event: live-search listeners (and Turbo forms) react
|
|
78
|
+
// to programmatic clearing exactly like typing.
|
|
79
|
+
this.inputTarget.dispatchEvent(new Event("input", { bubbles: true }))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.#reflect()
|
|
83
|
+
this.dispatch("clear", { prefix: EVENT_PREFIX })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#reflect() {
|
|
87
|
+
const empty = this.inputTarget.value === ""
|
|
88
|
+
|
|
89
|
+
this.element.toggleAttribute("data-empty", empty)
|
|
90
|
+
|
|
91
|
+
if (this.hasClearTarget) {
|
|
92
|
+
this.clearTarget.hidden = empty || this.inputTarget.readOnly || this.inputTarget.disabled
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|