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,151 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { setState } from "@poetry/controllers/helpers/state"
3
+
4
+ // The Tabs activation machine: this controller owns ONLY the
5
+ // active-value state + attribute writes; the shared poetry--core--roving-focus
6
+ // on the tablist owns the keyboard (default tabindex-managing mode - one Tab
7
+ // stop). Triggers are DUMB buttons (click -> tabs#activate; with automatic
8
+ // activation - the APG default for tabs - focusin activates too, so arrow
9
+ // keys both move focus AND switch panels).
10
+ //
11
+ // The state vocabulary: the active trigger carries data-active
12
+ // (the styled token) + aria-selected; inactive panels carry the hidden
13
+ // property + data-hidden. data-activation-direction is deliberately NOT
14
+ // emitted - no shipped class consumes it (add it with the animated
15
+ // indicator, when something does).
16
+ //
17
+ // Panels are scoped to THIS root (a nested Tabs inside a panel owns its own
18
+ // triggers/panels - the DOM is the registry, same rule as roving-focus).
19
+ const TRIGGER_SELECTOR = '[data-slot="tabs-trigger"]'
20
+ const PANEL_SELECTOR = '[data-slot="tabs-content"]'
21
+
22
+ // The component-facing event namespace (the poetry:<component> rule).
23
+ const EVENT_PREFIX = "poetry:tabs"
24
+
25
+ export default class TabsController extends Controller {
26
+ // The events this controller dispatches (manifest surface;
27
+ // events_declaration.test.js enforces the list stays honest).
28
+ static events = ["poetry:tabs:change"]
29
+
30
+ static values = {
31
+ // false = manual activation (arrows only move focus; Enter/Space - the
32
+ // native button click - activates). true is the APG-recommended default.
33
+ activateOnFocus: { type: Boolean, default: true }
34
+ }
35
+
36
+ /**
37
+ * Reconcile-on-connect: derives the full vocabulary from the
38
+ * server-rendered data-active truth (the body comment holds the rules).
39
+ */
40
+ connect() {
41
+ // Reconcile-on-connect: the server-rendered data-active trigger is the
42
+ // truth; aria-selected/tabindex/hidden are (re)derived from it, so a
43
+ // Turbo re-render can never leave mixed vocabularies. No data-active
44
+ // anywhere -> the first enabled trigger becomes active.
45
+ const active = this.#triggers().find((trigger) => trigger.hasAttribute("data-active"))
46
+ const fallback = this.#triggers().find((trigger) => !this.#disabled(trigger))
47
+ const value = (active ?? fallback)?.dataset.value
48
+
49
+ if (value !== undefined) this.#apply(value, { silent: true })
50
+ }
51
+
52
+ /**
53
+ * Each trigger's click action: activates the pressed trigger's value.
54
+ *
55
+ * @param {MouseEvent} event
56
+ */
57
+ activate(event) {
58
+ const trigger = this.#triggerFrom(event)
59
+
60
+ if (!trigger || this.#disabled(trigger)) return
61
+
62
+ this.#apply(trigger.dataset.value)
63
+ }
64
+
65
+ /**
66
+ * The tablist's roving-focus entry action - automatic activation
67
+ * follows the roving focus via the roving controller's entry event
68
+ * (deterministic: never depends on the platform firing focusin for a
69
+ * programmatic .focus()). A raw focusin routes here too, so hand-wired
70
+ * hosts get the same behavior.
71
+ *
72
+ * @param {CustomEvent | FocusEvent} event
73
+ */
74
+ focusActivate(event) {
75
+ if (!this.activateOnFocusValue) return
76
+
77
+ const trigger = (event.detail && event.detail.item) || this.#triggerFrom(event)
78
+
79
+ if (!trigger || this.#disabled(trigger)) return
80
+ if (trigger.hasAttribute("data-active")) return // already there - no re-fire
81
+
82
+ this.#apply(trigger.dataset.value)
83
+ }
84
+
85
+ /**
86
+ * The programmatic controllable-state surface: setValue("account").
87
+ * Unknown values are ignored (the contract's guard). Returns the
88
+ * resulting state - what an agent tool reports back - so a no-op is
89
+ * visible as changed: false.
90
+ *
91
+ * @param {string} value - a trigger's data-value (stringified)
92
+ * @returns {{ value: string, changed: boolean }} the active value after the call
93
+ */
94
+ setValue(value) {
95
+ const next = String(value)
96
+ const known = this.#triggers().some((trigger) => trigger.dataset.value === next)
97
+ const before = this.#activeValue()
98
+
99
+ if (known) this.#apply(next)
100
+
101
+ return { value: known ? next : before, changed: known && next !== before }
102
+ }
103
+
104
+ #activeValue() {
105
+ return this.#triggers().find((trigger) => trigger.getAttribute("aria-selected") === "true")?.dataset.value ?? ""
106
+ }
107
+
108
+ #apply(value, { silent = false } = {}) {
109
+ for (const trigger of this.#triggers()) {
110
+ const active = trigger.dataset.value === value
111
+
112
+ setState(trigger, active ? "active" : "inactive")
113
+ trigger.setAttribute("aria-selected", active ? "true" : "false")
114
+ // The active tab is the roving tab stop (re-entering the tablist
115
+ // lands on the selection) - the stamp roving-focus adopts.
116
+ trigger.setAttribute("tabindex", active ? "0" : "-1")
117
+ }
118
+
119
+ for (const panel of this.#panels()) {
120
+ const active = panel.dataset.value === value
121
+
122
+ panel.hidden = !active
123
+ if (active) panel.removeAttribute("data-hidden")
124
+ else panel.setAttribute("data-hidden", "")
125
+ }
126
+
127
+ if (!silent) this.dispatch("change", { prefix: EVENT_PREFIX, detail: { value } })
128
+ }
129
+
130
+ #triggerFrom(event) {
131
+ const origin = event.target instanceof Element ? event.target : null
132
+
133
+ return origin?.closest(TRIGGER_SELECTOR) ?? null
134
+ }
135
+
136
+ #disabled(trigger) {
137
+ return trigger.hasAttribute("disabled") || trigger.hasAttribute("data-disabled")
138
+ }
139
+
140
+ #triggers() {
141
+ return [...this.element.querySelectorAll(TRIGGER_SELECTOR)].filter((el) => this.#owns(el))
142
+ }
143
+
144
+ #panels() {
145
+ return [...this.element.querySelectorAll(PANEL_SELECTOR)].filter((el) => this.#owns(el))
146
+ }
147
+
148
+ #owns(el) {
149
+ return el.closest(`[data-controller~="${this.identifier}"]`) === this.element
150
+ }
151
+ }
@@ -0,0 +1,134 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The TagGroup removal engine (the tag-group contract):
4
+ // navigation itself rides the roving-focus controller (the toolbar
5
+ // precedent) - this controller owns what tags add on top:
6
+ //
7
+ // - Delete/Backspace on a focused tag removes it (row-origin keys only;
8
+ // keys from a tag's inner remove button must not drive the grid).
9
+ // - The remove button removes exactly its own tag.
10
+ // - Focus recovery after removal is the reference walk: FORWARD through
11
+ // the pre-removal order to the first surviving enabled tag, then
12
+ // backward; when the last tag goes, the CONTAINER takes focus, flips
13
+ // role grid->group, and becomes the tab stop.
14
+ // - The container is a live region ONLY while focus is within (polite,
15
+ // additions) - SRs hear tags added while working in the group without
16
+ // spam from elsewhere.
17
+ //
18
+ // Removal is CANCELABLE (poetry:tag-group:remove): a Turbo-driven host
19
+ // preventDefault()s and re-renders; otherwise this controller removes the
20
+ // row (and the hidden input riding it - form mode serializes name[] per
21
+ // tag).
22
+ const EVENT_PREFIX = "poetry:tag-group"
23
+
24
+ export default class TagGroupController extends Controller {
25
+ // The events this controller dispatches (manifest surface;
26
+ // events_declaration.test.js enforces the list stays honest).
27
+ static events = ["poetry:tag-group:remove"]
28
+
29
+ #onFocusin = () => this.element.setAttribute("aria-live", "polite")
30
+ #onFocusout = (event) => {
31
+ if (this.element.contains(event.relatedTarget)) return
32
+
33
+ this.element.setAttribute("aria-live", "off")
34
+ }
35
+
36
+ /**
37
+ * Wires the focus-scoped live-region toggling and reflects emptiness
38
+ * (role, tab stop, data-empty).
39
+ */
40
+ connect() {
41
+ this.element.addEventListener("focusin", this.#onFocusin)
42
+ this.element.addEventListener("focusout", this.#onFocusout)
43
+ this.#reflectEmpty()
44
+ }
45
+
46
+ /** Unwires the focus listeners. */
47
+ disconnect() {
48
+ this.element.removeEventListener("focusin", this.#onFocusin)
49
+ this.element.removeEventListener("focusout", this.#onFocusout)
50
+ }
51
+
52
+ /**
53
+ * The container's keydown action: Delete/Backspace on a focused tag ROW
54
+ * removes it - row-origin keys only (keys from a tag's inner remove
55
+ * button must not drive the grid).
56
+ *
57
+ * @param {KeyboardEvent} event
58
+ */
59
+ keydown(event) {
60
+ if (event.key !== "Delete" && event.key !== "Backspace") return
61
+
62
+ const row = event.target.closest("[data-slot='tag-group-tag']")
63
+
64
+ // Row-origin only: the remove button's own keys stay its own.
65
+ if (!row || event.target !== row) return
66
+ if (row.hasAttribute("data-disabled")) return
67
+
68
+ event.preventDefault()
69
+ this.#remove(row)
70
+ }
71
+
72
+ /**
73
+ * The remove button's click action: removes exactly its own tag.
74
+ *
75
+ * @param {MouseEvent} event
76
+ */
77
+ remove(event) {
78
+ event.preventDefault()
79
+ const row = event.target.closest("[data-slot='tag-group-tag']")
80
+
81
+ if (!row || row.hasAttribute("data-disabled")) return
82
+
83
+ this.#remove(row)
84
+ }
85
+
86
+ #remove(row) {
87
+ const removal = this.dispatch("remove", {
88
+ prefix: EVENT_PREFIX,
89
+ detail: { value: row.getAttribute("data-value"), id: row.id },
90
+ cancelable: true
91
+ })
92
+
93
+ if (removal.defaultPrevented) return // the host re-renders instead
94
+
95
+ const target = this.#recoveryTarget(row)
96
+
97
+ row.remove()
98
+ this.#reflectEmpty()
99
+
100
+ if (target) {
101
+ target.setAttribute("tabindex", "0")
102
+ target.focus()
103
+ } else if (this.element.contains(document.activeElement) ||
104
+ document.activeElement === document.body) {
105
+ this.element.focus()
106
+ }
107
+ }
108
+
109
+ // The reference walk, in pre-removal DOM order: forward to the first
110
+ // surviving enabled tag, then backward.
111
+ #recoveryTarget(row) {
112
+ const rows = this.#rows()
113
+ const index = rows.indexOf(row)
114
+ const enabled = (candidate) => candidate !== row && !candidate.hasAttribute("data-disabled")
115
+
116
+ return rows.slice(index + 1).find(enabled) ??
117
+ rows.slice(0, index).reverse().find(enabled) ??
118
+ null
119
+ }
120
+
121
+ #reflectEmpty() {
122
+ const empty = this.#rows().length === 0
123
+
124
+ this.element.toggleAttribute("data-empty", empty)
125
+ this.element.setAttribute("role", empty ? "group" : "grid")
126
+
127
+ if (empty) this.element.setAttribute("tabindex", "0")
128
+ else this.element.removeAttribute("tabindex")
129
+ }
130
+
131
+ #rows() {
132
+ return Array.from(this.element.querySelectorAll("[data-slot='tag-group-tag']"))
133
+ }
134
+ }
@@ -0,0 +1,228 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { announce } from "@poetry/controllers/helpers/announce"
3
+ import { isImeKeydown } from "@poetry/controllers/helpers/escape"
4
+ import { exitPresence } from "@poetry/controllers/helpers/presence"
5
+ import { setState, stateOf } from "@poetry/controllers/helpers/state"
6
+
7
+ // One toast item (poetry's own Toast - the stacked-toaster genre with
8
+ // strict a11y semantics). The item is role=status aria-live=off: it
9
+ // never announces itself - on connect it speaks ONCE through the
10
+ // announce singleton at its politeness (destructive -> assertive,
11
+ // wired server-side via the
12
+ // politeness value). The auto-dismiss timer follows APG/WCAG 2.2.1 timing:
13
+ // it PAUSES on hover, focus-within, window blur and tab-hidden (reasons are
14
+ // refcounted so overlapping pauses cannot resume early), and duration <= 0
15
+ // means persistent (required for undo/action toasts). Dismiss flips
16
+ // data-open -> data-closed, dispatches poetry:toast:dismiss {id, reason} (the
17
+ // toaster's reflow + focus-return seam), then presence holds the node until
18
+ // its exit animation finishes before removal.
19
+ //
20
+ // Swipe-to-dismiss is a browser-verification-GATED enhancement (contract) -
21
+ // it does not ship in this pass; the swipe reason is reserved.
22
+ const EVENT_PREFIX = "poetry:toast"
23
+
24
+ const ACTION_SELECTOR = '[data-slot="toast-action"]'
25
+ const TITLE_SELECTOR = '[data-slot="toast-title"]'
26
+ const DESCRIPTION_SELECTOR = '[data-slot="toast-description"]'
27
+
28
+ // event.type -> pause/resume reason, so each pause source releases only
29
+ // its own hold (hover out must not resume a focus hold).
30
+ const PAUSE_REASONS = {
31
+ mouseenter: "hover",
32
+ mouseleave: "hover",
33
+ focusin: "focus",
34
+ focusout: "focus"
35
+ }
36
+
37
+ let toastSequence = 0
38
+
39
+ export default class ToastController extends Controller {
40
+ // The events this controller dispatches (manifest surface;
41
+ // events_declaration.test.js enforces the list stays honest).
42
+ static events = ["poetry:toast:dismiss", "poetry:toast:show"]
43
+
44
+ static targets = ["action", "close"]
45
+ static values = {
46
+ duration: { type: Number, default: 5000 },
47
+ politeness: { type: String, default: "polite" }
48
+ }
49
+
50
+ #wired = []
51
+ #timer = null
52
+ #remaining = 0
53
+ #startedAt = null
54
+ #pauseReasons = new Set()
55
+ #dismissed = false
56
+ #onVisibilityChange = () => {
57
+ if (document.hidden) this.pause("visibility")
58
+ else this.resume("visibility")
59
+ }
60
+ #onWindowBlur = () => this.pause("window")
61
+ #onWindowFocus = () => this.resume("window")
62
+
63
+ /**
64
+ * Assigns an id when missing, adopts the open pair, announces ONCE
65
+ * through the singleton, wires the pause sources and the toast-local
66
+ * Escape, and starts the auto-dismiss timer (queued/hidden toasts hold
67
+ * theirs).
68
+ */
69
+ connect() {
70
+ if (!this.element.id) this.element.id = `poetry-toast-${(toastSequence += 1).toString(16)}`
71
+ if (!stateOf(this.element)) setState(this.element, "open")
72
+
73
+ // Announce ONCE through the singleton (the item is aria-live=off).
74
+ // Consumers must never announce() toast content themselves.
75
+ announce(this.#message(), this.politenessValue)
76
+
77
+ // APG timing: pause while the tab is hidden or the window is blurred.
78
+ this.#listen(document, "visibilitychange", this.#onVisibilityChange)
79
+ this.#listen(window, "blur", this.#onWindowBlur)
80
+ this.#listen(window, "focus", this.#onWindowFocus)
81
+ // Esc while focus is inside dismisses this toast (it reports as the
82
+ // close affordance: close-press).
83
+ this.#listen(this.element, "keydown", (event) => {
84
+ if (event.key !== "Escape" || isImeKeydown(event)) return
85
+
86
+ event.stopPropagation()
87
+ this.dismiss("close-press")
88
+ })
89
+
90
+ this.#remaining = this.durationValue
91
+
92
+ // A toast queued by the toaster (hidden at connect) holds its timer
93
+ // until promotion; the toaster resumes it with the "queued" reason.
94
+ if (this.element.hidden) this.#pauseReasons.add("queued")
95
+ if (document.hidden) this.#pauseReasons.add("visibility")
96
+
97
+ this.#startTimer()
98
+
99
+ this.dispatch("show", {
100
+ prefix: EVENT_PREFIX,
101
+ detail: { id: this.element.id, variant: this.element.dataset.variant ?? "default" }
102
+ })
103
+ }
104
+
105
+ /** Stops the timer and unwires every listener. */
106
+ disconnect() {
107
+ this.#stopTimer()
108
+
109
+ for (const [target, type, listener] of this.#wired) target.removeEventListener(type, listener)
110
+
111
+ this.#wired = []
112
+ this.#pauseReasons.clear()
113
+ }
114
+
115
+ // --- timer pause/resume (markup: mouseenter/focusin -> pause,
116
+ // mouseleave/focusout -> resume; the toaster + window paths call with
117
+ // string reasons) ---
118
+
119
+ /**
120
+ * Holds the auto-dismiss timer under a reason (hover/focus derive from
121
+ * events; the toaster and window paths pass strings). Reasons pool in a
122
+ * set, so overlapping pauses cannot resume early.
123
+ *
124
+ * @param {Event | string} [eventOrReason]
125
+ */
126
+ pause(eventOrReason) {
127
+ this.#pauseReasons.add(this.#reasonFor(eventOrReason))
128
+ this.#stopTimer()
129
+ }
130
+
131
+ /**
132
+ * Releases one pause reason; the timer restarts when none remain.
133
+ *
134
+ * @param {Event | string} [eventOrReason]
135
+ */
136
+ resume(eventOrReason) {
137
+ this.#pauseReasons.delete(this.#reasonFor(eventOrReason))
138
+ this.#startTimer()
139
+ }
140
+
141
+ // --- dismissal ---
142
+
143
+ /**
144
+ * Dismisses the toast. Reasons: timeout | close-press | action |
145
+ * swipe(reserved) | manual - the family reason vocabulary plus
146
+ * poetry's own timeout/action/queued/manual extensions. A click on the
147
+ * action slot reports "action"; the close
148
+ * button "close-press". The dismiss event goes out BEFORE removal (the
149
+ * toaster's reflow + focus-return seam); presence then holds the node
150
+ * through its exit animation.
151
+ *
152
+ * @param {Event | string} [eventOrReason] - the triggering event (the
153
+ * reason derives from its origin) or an explicit reason string
154
+ */
155
+ dismiss(eventOrReason) {
156
+ if (this.#dismissed) return
157
+
158
+ this.#dismissed = true
159
+ this.#stopTimer()
160
+ this.#pauseReasons.clear()
161
+
162
+ const reason = typeof eventOrReason === "string"
163
+ ? eventOrReason
164
+ : this.#reasonFromEvent(eventOrReason)
165
+
166
+ // Dispatched BEFORE removal so it bubbles to the toaster (reflow +
167
+ // focus return); presence then holds the node through its exit.
168
+ this.dispatch("dismiss", {
169
+ prefix: EVENT_PREFIX,
170
+ detail: { id: this.element.id, reason }
171
+ })
172
+
173
+ exitPresence(this.element, { onRemove: () => this.element.remove() })
174
+ }
175
+
176
+ // --- internals ---
177
+
178
+ #startTimer() {
179
+ if (this.#timer !== null || this.#dismissed) return
180
+ if (this.#pauseReasons.size > 0) return
181
+ if (this.durationValue <= 0) return // persistent (undo toasts)
182
+ if (this.#remaining <= 0) return
183
+
184
+ this.#startedAt = Date.now()
185
+ this.#timer = window.setTimeout(() => {
186
+ this.#timer = null
187
+ this.dismiss("timeout")
188
+ }, this.#remaining)
189
+ }
190
+
191
+ #stopTimer() {
192
+ if (this.#timer === null) return
193
+
194
+ window.clearTimeout(this.#timer)
195
+ this.#timer = null
196
+ this.#remaining = Math.max(0, this.#remaining - (Date.now() - this.#startedAt))
197
+ this.#startedAt = null
198
+ }
199
+
200
+ #reasonFor(eventOrReason) {
201
+ if (typeof eventOrReason === "string") return eventOrReason
202
+ if (eventOrReason?.type) return PAUSE_REASONS[eventOrReason.type] ?? eventOrReason.type
203
+
204
+ return "manual"
205
+ }
206
+
207
+ #reasonFromEvent(event) {
208
+ const origin = event?.currentTarget instanceof Element ? event.currentTarget : event?.target
209
+
210
+ if (origin instanceof Element && origin.closest(ACTION_SELECTOR)) return "action"
211
+
212
+ return "close-press"
213
+ }
214
+
215
+ // Title + description text, the announced payload (textContent only -
216
+ // the singleton never sees markup).
217
+ #message() {
218
+ const title = this.element.querySelector(TITLE_SELECTOR)?.textContent?.trim() ?? ""
219
+ const description = this.element.querySelector(DESCRIPTION_SELECTOR)?.textContent?.trim() ?? ""
220
+
221
+ return [title, description].filter(Boolean).join(" ")
222
+ }
223
+
224
+ #listen(target, type, listener) {
225
+ target.addEventListener(type, listener)
226
+ this.#wired.push([target, type, listener])
227
+ }
228
+ }
@@ -0,0 +1,26 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The client-side toast delivery trigger (poetry's no-round-trip path -
4
+ // what a toast() JS factory does elsewhere, done with server-rendered
5
+ // markup): press -> dispatch poetry:toaster:stamp, and the toaster clones
6
+ // the addressed <template>'s toast into its region. The toast inside the
7
+ // template is byte-for-byte what a Turbo Stream would deliver.
8
+ export default class ToastTriggerController extends Controller {
9
+ static events = ["poetry:toaster:stamp"]
10
+ static values = {
11
+ // The <template> element id holding the rendered toast.
12
+ template: String,
13
+ // Optional toaster region id - omit for the page's toaster.
14
+ toaster: String
15
+ }
16
+
17
+ /**
18
+ * The click action: stamps the addressed template's toast into the
19
+ * toaster region (the no-round-trip delivery the header describes).
20
+ */
21
+ fire() {
22
+ window.dispatchEvent(new CustomEvent("poetry:toaster:stamp", {
23
+ detail: { template: this.templateValue, toaster: this.toasterValue || null }
24
+ }))
25
+ }
26
+ }