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.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. 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
+ }