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,167 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The Carousel engine, decided NATIVE: no carousel library - the platform's
4
+ // scroll-snap owns the physics (touch, momentum, snapping, overscroll),
5
+ // and this controller adds only what CSS can't: prev/next paging, button
6
+ // state, and arrow keys. Navigation scrolls the VIEWPORT by a bounding-
7
+ // rect delta (RTL- and transform-safe: never scrollLeft sign
8
+ // conventions) - not scrollIntoView, whose alignment bubbles to
9
+ // scrollable ancestors and whose "nearest" no-ops when several slides
10
+ // fit the viewport at once (the vertical stack). Deferred with the
11
+ // library-grade machinery: loop (it clones slides), autoplay, and a plugin API.
12
+ const SLIDE_SELECTOR = '[data-slot="carousel-item"]'
13
+
14
+ // The component-facing event namespace (the poetry:<component> rule).
15
+ const EVENT_PREFIX = "poetry:carousel"
16
+
17
+ export default class CarouselController extends Controller {
18
+ // The events this controller dispatches (manifest surface;
19
+ // events_declaration.test.js enforces the list stays honest).
20
+ static events = ["poetry:carousel:select"]
21
+
22
+ static targets = ["viewport", "previous", "next"]
23
+ static values = {
24
+ orientation: { type: String, default: "horizontal" }
25
+ }
26
+
27
+ #raf = null
28
+
29
+ /** Syncs button state to the initial scroll position. */
30
+ connect() {
31
+ this.#sync()
32
+ }
33
+
34
+ /** Cancels any pending sync frame. */
35
+ disconnect() {
36
+ if (this.#raf !== null) cancelAnimationFrame(this.#raf)
37
+ }
38
+
39
+ /**
40
+ * The viewport's scroll action - button state follows the SCROLL
41
+ * (finger, wheel, keyboard PageDown), not just our own paging.
42
+ * rAF-coalesced: scroll fires in bursts.
43
+ */
44
+ scrolled() {
45
+ if (this.#raf !== null) return
46
+
47
+ this.#raf = requestAnimationFrame(() => {
48
+ this.#raf = null
49
+ this.#sync()
50
+ })
51
+ }
52
+
53
+ /** The previous button's click action: pages one slide back. */
54
+ previous() {
55
+ this.#scrollTo(this.#index() - 1)
56
+ }
57
+
58
+ /** The next button's click action: pages one slide forward. */
59
+ next() {
60
+ this.#scrollTo(this.#index() + 1)
61
+ }
62
+
63
+ /**
64
+ * The region root's keydown action:
65
+ * arrows page the carousel, orientation-aware.
66
+ *
67
+ * @param {KeyboardEvent} event
68
+ */
69
+ keydown(event) {
70
+ const [prevKey, nextKey] =
71
+ this.orientationValue === "vertical" ? ["ArrowUp", "ArrowDown"] : ["ArrowLeft", "ArrowRight"]
72
+
73
+ if (event.key === prevKey) {
74
+ event.preventDefault()
75
+ this.previous()
76
+ } else if (event.key === nextKey) {
77
+ event.preventDefault()
78
+ this.next()
79
+ }
80
+ }
81
+
82
+ /**
83
+ * The programmatic surface: scrolls to a slide by index (clamped).
84
+ *
85
+ * @param {number | string} index - stringified numbers accepted
86
+ */
87
+ scrollTo(index) {
88
+ this.#scrollTo(Number(index))
89
+ }
90
+
91
+ #scrollTo(index) {
92
+ const slides = this.#slides()
93
+ const target = slides[Math.max(0, Math.min(slides.length - 1, index))]
94
+ if (!target) return
95
+
96
+ // Rect delta start-aligns the target inside the viewport alone.
97
+ // Subtracting scroll-margin lands on the slide's SNAP position (the
98
+ // items carry negative scroll-margin to cancel their gutter from the
99
+ // snap area), so the smooth scroll and the CSS snap agree.
100
+ const vertical = this.orientationValue === "vertical"
101
+ const rect = target.getBoundingClientRect()
102
+ const viewport = this.viewportTarget.getBoundingClientRect()
103
+ const style = getComputedStyle(target)
104
+ const delta = vertical
105
+ ? rect.top - (parseFloat(style.scrollMarginTop) || 0) - viewport.top
106
+ : rect.left - (parseFloat(style.scrollMarginLeft) || 0) - viewport.left
107
+
108
+ this.viewportTarget.scrollBy?.({
109
+ behavior: "smooth",
110
+ left: vertical ? 0 : delta,
111
+ top: vertical ? delta : 0
112
+ })
113
+ // Button state also syncs from the scroll events the smooth scroll
114
+ // emits; this immediate pass covers environments without them.
115
+ this.#sync(index)
116
+ }
117
+
118
+ // The slide nearest the viewport's start edge - bounding rects, so RTL
119
+ // and transforms cost nothing.
120
+ #index() {
121
+ const viewport = this.viewportTarget.getBoundingClientRect()
122
+ let nearest = 0
123
+ let nearestDistance = Infinity
124
+
125
+ this.#slides().forEach((slide, index) => {
126
+ const rect = slide.getBoundingClientRect()
127
+ const distance = this.orientationValue === "vertical"
128
+ ? Math.abs(rect.top - viewport.top)
129
+ : Math.abs(rect.left - viewport.left)
130
+
131
+ if (distance < nearestDistance) {
132
+ nearest = index
133
+ nearestDistance = distance
134
+ }
135
+ })
136
+
137
+ return nearest
138
+ }
139
+
140
+ #sync(index = this.#index()) {
141
+ const last = this.#slides().length - 1
142
+
143
+ if (this.hasPreviousTarget) this.previousTarget.disabled = index <= 0
144
+ if (this.hasNextTarget) this.nextTarget.disabled = index >= last || this.#atEnd()
145
+
146
+ this.dispatch("select", { prefix: EVENT_PREFIX, detail: { index } })
147
+ }
148
+
149
+ // Snap points the scroller cannot reach still exist under native
150
+ // scroll, so next must ALSO disable at max scroll -
151
+ // otherwise trailing slides that cannot start-align (multi-visible
152
+ // layouts) leave a live button that does nothing. Math.abs keeps the
153
+ // check RTL-safe (scrollLeft runs negative there).
154
+ #atEnd() {
155
+ const viewport = this.viewportTarget
156
+ const vertical = this.orientationValue === "vertical"
157
+ const max = vertical
158
+ ? viewport.scrollHeight - viewport.clientHeight
159
+ : viewport.scrollWidth - viewport.clientWidth
160
+
161
+ return max > 0 && Math.abs(vertical ? viewport.scrollTop : viewport.scrollLeft) >= max - 1
162
+ }
163
+
164
+ #slides() {
165
+ return [...this.viewportTarget.querySelectorAll(SLIDE_SELECTOR)]
166
+ }
167
+ }
@@ -0,0 +1,97 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { stateOf } from "@poetry/controllers/helpers/state"
3
+
4
+ // The select-all recipe (a parent checkbox running the APG
5
+ // mixed-state pattern) over the checked family. Wrap the parent and its
6
+ // rows, route the bubbling per-checkbox observe event at #changed, and
7
+ // mark the parent (target: all) plus each row (target: item):
8
+ //
9
+ // <div data-controller="poetry--core--checkbox-group"
10
+ // data-action="poetry:checkbox:change->poetry--core--checkbox-group#changed">
11
+ //
12
+ // A parent toggle fans its new state out to every enabled row; a row
13
+ // toggle re-derives the parent (all -> checked, none -> unchecked, some ->
14
+ // indeterminate, re-entered via the checked controller's set() - the only
15
+ // path back to mixed). Every state write goes THROUGH each box's checked
16
+ // controller (input first, real change event, aria-checked + the checked
17
+ // triple together) - this controller never touches attributes itself.
18
+ // Disabled rows are skipped by fan-out and excluded from the derivation
19
+ // (the DataTable disabledBehavior 'selection' doctrine;
20
+ // table_selection_controller is this same recipe for NATIVE checkboxes).
21
+ const EVENT_PREFIX = "poetry:checkbox-group"
22
+
23
+ export default class CheckboxGroupController extends Controller {
24
+ // The events this controller dispatches (manifest surface;
25
+ // events_declaration.test.js enforces the list stays honest).
26
+ static events = ["poetry:checkbox-group:change"]
27
+
28
+ static targets = ["all", "item"]
29
+
30
+ #applying = false
31
+
32
+ /**
33
+ * The wrapper's data-action route for every bubbling checkbox change: a
34
+ * parent toggle fans out, a row toggle re-derives the parent, and the
35
+ * group's own change event reports the enabled count/total. The
36
+ * applying flag mutes the echoes our own set() calls dispatch (a
37
+ * fan-out must not re-derive per row; a parent reflection must not fan
38
+ * out).
39
+ *
40
+ * @param {CustomEvent} event - the per-checkbox change
41
+ */
42
+ changed(event) {
43
+ if (this.#applying) return
44
+
45
+ const fromParent = this.hasAllTarget && event.target === this.allTarget
46
+
47
+ if (!fromParent && !this.itemTargets.includes(event.target)) return
48
+ if (fromParent) this.#fanOut(event.detail.checked)
49
+
50
+ // After a fan-out too: rows that refused (all disabled) snap the
51
+ // parent back to what the roster actually holds.
52
+ this.#reflectParent()
53
+
54
+ const enabled = this.#enabled()
55
+
56
+ this.dispatch("change", {
57
+ prefix: EVENT_PREFIX,
58
+ detail: { count: this.#checked(enabled).length, total: enabled.length }
59
+ })
60
+ }
61
+
62
+ #fanOut(checked) {
63
+ this.#applying = true
64
+ for (const item of this.#enabled()) this.#checkedController(item)?.set(checked)
65
+ this.#applying = false
66
+ }
67
+
68
+ #reflectParent() {
69
+ if (!this.hasAllTarget) return
70
+
71
+ const enabled = this.#enabled()
72
+ const checked = this.#checked(enabled).length
73
+ const state = checked === 0 ? "unchecked" : (checked === enabled.length ? "checked" : "indeterminate")
74
+
75
+ // Dedupe: a row toggle that leaves the parent where it was (one of
76
+ // several rows unchecked) must not re-dispatch the parent's change.
77
+ if (stateOf(this.allTarget) === state) return
78
+
79
+ this.#applying = true
80
+ this.#checkedController(this.allTarget)?.set(state === "indeterminate" ? state : state === "checked")
81
+ this.#applying = false
82
+ }
83
+
84
+ #checked(items) {
85
+ return items.filter((item) => stateOf(item) === "checked")
86
+ }
87
+
88
+ #enabled() {
89
+ return this.itemTargets.filter(
90
+ (item) => !item.hasAttribute("disabled") && item.getAttribute("aria-disabled") !== "true"
91
+ )
92
+ }
93
+
94
+ #checkedController(element) {
95
+ return this.application.getControllerForElementAndIdentifier(element, "poetry--core--checked")
96
+ }
97
+ }
@@ -0,0 +1,208 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { setState, stateOf } from "@poetry/controllers/helpers/state"
3
+
4
+ // The toggle family's checked-state owner (Checkbox introduces it; Switch
5
+ // reuses it VERBATIM - zero fork, CI-asserted). The architecture is the
6
+ // STORE INVERSION: the hidden native <input type=checkbox> is the form
7
+ // participant AND the store - the visual button[role=checkbox|switch] only
8
+ // REFLECTS it (aria-checked incl. "mixed" + the data-checked/data-unchecked/
9
+ // data-indeterminate attributes on the control and every part that carries
10
+ // them: the checkbox indicator, the switch thumb). Every transition writes the input FIRST, dispatches a REAL
11
+ // bubbling change event (no synthetic prototype-setter dance - the
12
+ // input LEADS and the visual follows), then reflects attributes.
13
+ //
14
+ // The three states: checked / unchecked / indeterminate. Indeterminate is
15
+ // server/programmatic only (aria-checked=mixed, input.indeterminate - a
16
+ // JS-only property re-derived from the checked attributes on connect); the first user
17
+ // toggle resolves it to CHECKED - contractual, do not loosen. A Switch
18
+ // never renders it (the Ruby component raises), so that branch is
19
+ // simply dormant there.
20
+ //
21
+ // Enter is suppressed on role=checkbox ONLY (WAI-ARIA: checkboxes activate
22
+ // on Space alone); role=switch has no keydown
23
+ // handler, so Enter toggles via the native button click (a deliberate
24
+ // asymmetry, keyed off the role - no controller fork).
25
+ //
26
+ // No inputId value -> pure visual mode: state lives on the button's
27
+ // checked attributes alone (controlled-UI cases like DataTable row selection).
28
+ // The component-flavored prefixes the dynamic dispatch below emits under
29
+ // (data-component on the host: checkbox / switch, with the bare fallback) -
30
+ // the events declaration, the portal bridge, and the manifest enumerate
31
+ // these REAL names; the identifier-default name never fires here.
32
+ const EVENT_PREFIXES = ["poetry:checkbox", "poetry:switch", "poetry:checked"]
33
+
34
+ export default class CheckedController extends Controller {
35
+ // The events this controller dispatches (manifest surface;
36
+ // events_declaration.test.js enforces the list stays honest).
37
+ static events = EVENT_PREFIXES.map((prefix) => `${prefix}:change`)
38
+
39
+ static values = {
40
+ inputId: { type: String, default: "" }
41
+ }
42
+
43
+ #keydown = null
44
+ #reset = null
45
+ #form = null
46
+ #initialIndeterminate = false
47
+
48
+ /**
49
+ * Reconcile-on-connect: derives input.checked/indeterminate from the
50
+ * checked attributes (the server truth), arms the form-reset restore,
51
+ * and installs the role-keyed Enter suppression.
52
+ */
53
+ connect() {
54
+ const input = this.#input()
55
+
56
+ // Reconcile-on-connect: the checked attributes are the server truth
57
+ // (Turbo Stream re-render safe); input.indeterminate has no attribute -
58
+ // derive it.
59
+ if (input) {
60
+ const state = this.#state()
61
+
62
+ this.#initialIndeterminate = state === "indeterminate"
63
+ input.checked = state === "checked"
64
+ input.indeterminate = this.#initialIndeterminate
65
+
66
+ // Native form reset restores the server-rendered checked attribute -
67
+ // initial-state restore for free; rAF because
68
+ // reset fires BEFORE the browser restores input values.
69
+ this.#form = input.form
70
+
71
+ if (this.#form) {
72
+ this.#reset = () => window.requestAnimationFrame(() => {
73
+ input.indeterminate = this.#initialIndeterminate
74
+ this.#reflect(input.indeterminate ? "indeterminate" : (input.checked ? "checked" : "unchecked"))
75
+ })
76
+ this.#form.addEventListener("reset", this.#reset)
77
+ }
78
+ }
79
+
80
+ // WAI-ARIA: a checkbox does not activate on Enter (Space only - the
81
+ // form's Enter-to-submit is unaffected). Keyed off the role so the
82
+ // Switch reuse keeps its native Enter-toggles behavior.
83
+ if (this.element.getAttribute("role") === "checkbox") {
84
+ this.#keydown = (event) => {
85
+ if (event.key === "Enter") event.preventDefault()
86
+ }
87
+ this.element.addEventListener("keydown", this.#keydown)
88
+ }
89
+ }
90
+
91
+ /** Unwires the keydown and form-reset listeners. */
92
+ disconnect() {
93
+ if (this.#keydown) this.element.removeEventListener("keydown", this.#keydown)
94
+ if (this.#reset && this.#form) this.#form.removeEventListener("reset", this.#reset)
95
+
96
+ this.#keydown = null
97
+ this.#reset = null
98
+ this.#form = null
99
+ }
100
+
101
+ /**
102
+ * Control activation (click / Space / label-for): indeterminate
103
+ * resolves to checked (contractual), else flip.
104
+ */
105
+ toggle() {
106
+ if (this.#disabled()) return
107
+
108
+ const state = this.#state()
109
+ const wasIndeterminate = state === "indeterminate"
110
+
111
+ this.#commit(wasIndeterminate ? true : state !== "checked", { wasIndeterminate })
112
+ }
113
+
114
+ // --- the programmatic controllable-state surface ---
115
+
116
+ /** Programmatically checks (see set). */
117
+ check() {
118
+ this.set(true)
119
+ }
120
+
121
+ /** Programmatically unchecks (see set). */
122
+ uncheck() {
123
+ this.set(false)
124
+ }
125
+
126
+ /**
127
+ * The programmatic controllable-state surface: set(true | false |
128
+ * "indeterminate") reaches all three states (the select-all recipe -
129
+ * checkbox_group_controller - re-enters indeterminate this way).
130
+ *
131
+ * @param {boolean | "indeterminate"} state
132
+ */
133
+ set(state) {
134
+ if (this.#disabled()) return
135
+
136
+ if (state === "indeterminate") this.#commitIndeterminate()
137
+ else this.#commit(Boolean(state), { wasIndeterminate: this.#state() === "indeterminate" })
138
+ }
139
+
140
+ // --- the one write path: input first, real change event, then reflect ---
141
+
142
+ #commit(checked, { wasIndeterminate = false } = {}) {
143
+ const input = this.#input()
144
+
145
+ if (input) {
146
+ input.indeterminate = false
147
+ input.checked = checked
148
+ input.dispatchEvent(new Event("change", { bubbles: true }))
149
+ }
150
+
151
+ this.#reflect(checked ? "checked" : "unchecked")
152
+
153
+ // The component-flavored observe surface (poetry:checkbox:change /
154
+ // poetry:switch:change) without forking the shared controller: the
155
+ // prefix derives from the host's data-component self-id.
156
+ this.dispatch("change", {
157
+ prefix: `poetry:${this.element.dataset.component ?? "checked"}`,
158
+ detail: { checked, was_indeterminate: wasIndeterminate }
159
+ })
160
+ }
161
+
162
+ #commitIndeterminate() {
163
+ const input = this.#input()
164
+
165
+ if (input) {
166
+ input.checked = false
167
+ input.indeterminate = true
168
+ input.dispatchEvent(new Event("change", { bubbles: true }))
169
+ }
170
+
171
+ this.#reflect("indeterminate")
172
+ this.dispatch("change", {
173
+ prefix: `poetry:${this.element.dataset.component ?? "checked"}`,
174
+ detail: { checked: false, was_indeterminate: false, indeterminate: true }
175
+ })
176
+ }
177
+
178
+ // aria-checked (mixed mapping) and the checked attributes are written
179
+ // TOGETHER, on the control AND every part that mirrors the checked state
180
+ // (indicator / thumb) - recognized by wearing any of the family's pair
181
+ // attributes (data-checked / data-unchecked / data-indeterminate).
182
+ #reflect(state) {
183
+ this.element.setAttribute("aria-checked", state === "indeterminate" ? "mixed" : String(state === "checked"))
184
+ setState(this.element, state)
185
+
186
+ const parts = this.element.querySelectorAll("[data-checked], [data-unchecked], [data-indeterminate]")
187
+
188
+ for (const part of parts) setState(part, state)
189
+ }
190
+
191
+ #state() {
192
+ const state = stateOf(this.element)
193
+
194
+ if (state) return state
195
+
196
+ const aria = this.element.getAttribute("aria-checked")
197
+
198
+ return aria === "mixed" ? "indeterminate" : (aria === "true" ? "checked" : "unchecked")
199
+ }
200
+
201
+ #input() {
202
+ return this.inputIdValue ? document.getElementById(this.inputIdValue) : null
203
+ }
204
+
205
+ #disabled() {
206
+ return this.element.hasAttribute("disabled") || this.element.getAttribute("aria-disabled") === "true"
207
+ }
208
+ }
@@ -0,0 +1,111 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { announce } from "@poetry/controllers/helpers/announce"
3
+
4
+ // ClipboardText, adapted from an MIT-licensed source (source and license
5
+ // in THIRD_PARTY_NOTICES.md): a read-only value with one
6
+ // copy affordance. navigator.clipboard is the primary path; the
7
+ // execCommand fallback SAVES AND RESTORES the user's own selection and
8
+ // focus (copying a field must never eat a selection made elsewhere on the
9
+ // page). Success stamps data-copied on
10
+ // the root for a beat (CSS swaps the copy/check glyphs off it), announces
11
+ // through the live-region singleton, and dispatches the copied event.
12
+ const EVENT_PREFIX = "poetry:clipboard-text"
13
+ const COPIED_MS = 1500
14
+
15
+ export default class ClipboardTextController extends Controller {
16
+ // The events this controller dispatches (manifest surface;
17
+ // events_declaration.test.js enforces the list stays honest).
18
+ static events = ["poetry:clipboard-text:copied"]
19
+
20
+ // input: a field whose value is the text. source: any element whose
21
+ // textContent is the text (CodeBlock's rendered code - CSS-counter line
22
+ // numbers live in ::before and are excluded by construction).
23
+ static targets = ["input", "source"]
24
+
25
+ static values = {
26
+ // Copy override: the full value when the display truncates.
27
+ // Empty means "copy what the input/source shows".
28
+ text: String,
29
+ // The announcement, localized server-side.
30
+ message: String
31
+ }
32
+
33
+ /** Clears the copied-beat timer. */
34
+ disconnect() {
35
+ clearTimeout(this.#timer)
36
+ }
37
+
38
+ /**
39
+ * The copy action: writes the text (navigator.clipboard first, then the
40
+ * selection-preserving execCommand fallback), stamps data-copied for a
41
+ * beat, announces, and dispatches the copied event with the text in the
42
+ * detail. A failed write changes nothing.
43
+ *
44
+ * @returns {Promise<void>}
45
+ */
46
+ async copy() {
47
+ const text = this.#text
48
+ if (!(await this.#write(text))) return
49
+
50
+ this.element.toggleAttribute("data-copied", true)
51
+ clearTimeout(this.#timer)
52
+ this.#timer = setTimeout(() => this.element.removeAttribute("data-copied"), COPIED_MS)
53
+
54
+ if (this.messageValue !== "") announce(this.messageValue)
55
+ this.dispatch("copied", { prefix: EVENT_PREFIX, detail: { text } })
56
+ }
57
+
58
+ #timer
59
+
60
+ get #text() {
61
+ if (this.textValue !== "") return this.textValue
62
+ if (this.hasSourceTarget) return this.sourceTarget.textContent
63
+
64
+ return this.inputTarget.value
65
+ }
66
+
67
+ async #write(text) {
68
+ try {
69
+ await navigator.clipboard.writeText(text)
70
+ return true
71
+ } catch {
72
+ return this.#execCommandFallback(text)
73
+ }
74
+ }
75
+
76
+ // Legacy path: an offscreen textarea receives the value and the
77
+ // selection, execCommand copies the LIVE selection - so the user's own
78
+ // selection is cloned first and restored after, and focus goes back
79
+ // where it was.
80
+ #execCommandFallback(text) {
81
+ const selection = document.getSelection()
82
+ const savedRanges = []
83
+ for (let index = 0; index < selection.rangeCount; index += 1) {
84
+ savedRanges.push(selection.getRangeAt(index).cloneRange())
85
+ }
86
+ const savedFocus = document.activeElement
87
+
88
+ const textarea = document.createElement("textarea")
89
+ textarea.value = text
90
+ textarea.setAttribute("readonly", "")
91
+ textarea.setAttribute("aria-hidden", "true")
92
+ textarea.style.position = "fixed"
93
+ textarea.style.opacity = "0"
94
+ this.element.appendChild(textarea)
95
+ textarea.select()
96
+
97
+ let copied = false
98
+ try {
99
+ copied = document.execCommand("copy")
100
+ } catch {
101
+ copied = false
102
+ }
103
+
104
+ textarea.remove()
105
+ selection.removeAllRanges()
106
+ for (const range of savedRanges) selection.addRange(range)
107
+ if (savedFocus && savedFocus.isConnected) savedFocus.focus()
108
+
109
+ return copied
110
+ }
111
+ }