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,667 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # HTML attribute plumbing: the merge-aware attributes hash.
6
+ module HTML
7
+ # A specialized hash for managing HTML attributes with intelligent merging capabilities.
8
+ #
9
+ # This class extends ActiveSupport::HashWithIndifferentAccess to provide enhanced
10
+ # functionality for handling HTML attributes, particularly CSS classes, Stimulus
11
+ # controllers/actions, data attributes, and ARIA attributes.
12
+ #
13
+ # Features:
14
+ # - Smart merging of CSS classes without duplication
15
+ # - Intelligent merging of Stimulus controllers and actions
16
+ # - Automatic flattening of nested data and aria attributes
17
+ # - Proper handling of HTML boolean attributes
18
+ # - Both mutating (!) and non-mutating versions of merge methods
19
+ #
20
+ # @example Basic usage
21
+ # attrs = Poetry::Core::HTML::Attributes.new(class: "btn", disabled: true)
22
+ # attrs.merge_classes!("btn-primary")
23
+ # attrs.to_attributes # => { "class" => "btn btn-primary", "disabled" => "disabled" }
24
+ #
25
+ # @example Merging Stimulus controllers
26
+ # attrs = Poetry::Core::HTML::Attributes.new
27
+ # attrs.merge_stimulus_controllers!("dropdown", "modal")
28
+ # attrs.to_attributes # => { "data-controller" => "dropdown modal" }
29
+ #
30
+ # @example Working with data attributes
31
+ # attrs = Poetry::Core::HTML::Attributes.new(data: { id: 1, name: "test" })
32
+ # attrs.to_attributes # => { "data-id" => "1", "data-name" => "test" }
33
+ class Attributes < ActiveSupport::HashWithIndifferentAccess
34
+ # The safe way to combine component wiring with caller-supplied
35
+ # attributes into a plain hash for content_tag / button_to: every
36
+ # hash flows through one Attributes instance, so stimulus keys
37
+ # (data-controller / data-action, either spelling) concatenate
38
+ # instead of clobbering, classes tailwind-merge, and to_attributes
39
+ # unifies double-spelled slots deterministically. Plain Hash#merge
40
+ # of wiring with caller options silently drops one side's wiring -
41
+ # never do that; call this.
42
+ #
43
+ # @param hashes [Array<Hash, nil>] wiring first, caller last
44
+ # @return [Hash] flat, render-ready attributes
45
+ def self.merged(*hashes)
46
+ hashes.compact.inject(new) { |merged, hash| merged.merge!(hash) }.to_attributes
47
+ end
48
+
49
+ # List of HTML boolean attributes that should be rendered as attribute name only
50
+ # when truthy, or omitted when falsy.
51
+ #
52
+ # @example
53
+ # attrs = Poetry::Core::HTML::Attributes.new(disabled: true, hidden: false)
54
+ # attrs.to_attributes # => { "disabled" => "disabled" }
55
+ # # Note: hidden is omitted because it's false
56
+ BOOLEAN_ATTRIBUTES = %w[
57
+ allowfullscreen allowpaymentrequest async autofocus autoplay checked compact controls declare default
58
+ defaultchecked defaultmuted defaultselected defer disabled enabled formnovalidate hidden indeterminate
59
+ inert ismap itemscope loop multiple muted nohref nomodule noresize noshade novalidate nowrap open
60
+ pauseonexit playsinline readonly required reversed scoped seamless selected sortable truespeed
61
+ typemustmatch visible
62
+ ].freeze
63
+
64
+ # Merges CSS classes into the attributes, returning a new instance.
65
+ #
66
+ # This non-mutating method creates a deep copy of the attributes and merges
67
+ # the provided classnames intelligently, avoiding duplicates and handling
68
+ # conditional classes.
69
+ #
70
+ # @param classnames [Array<String, Hash, Array>] One or more classnames to merge.
71
+ # Can be strings, hashes with conditional classes, or arrays.
72
+ # @return [Poetry::Core::HTML::Attributes] A new instance with merged classes
73
+ #
74
+ # @example Merge simple classes
75
+ # attrs = Poetry::Core::HTML::Attributes.new(class: "btn")
76
+ # new_attrs = attrs.merge_classes("btn-primary", "btn-lg")
77
+ # new_attrs["class"] # => "btn btn-primary btn-lg"
78
+ # attrs["class"] # => "btn" (original unchanged)
79
+ def merge_classes(*classnames)
80
+ deep_dup.merge_classes!(*classnames)
81
+ end
82
+
83
+ # Merges CSS classes into the attributes, mutating the current instance.
84
+ #
85
+ # This mutating method modifies the current attributes object by merging
86
+ # the provided classnames.
87
+ #
88
+ # @param classnames [Array<String, Hash, Array>] One or more classnames to merge
89
+ # @return [Poetry::Core::HTML::Attributes] Self for method chaining
90
+ #
91
+ # @example Merge classes in place
92
+ # attrs = Poetry::Core::HTML::Attributes.new(class: "btn")
93
+ # attrs.merge_classes!("btn-primary")
94
+ # attrs["class"] # => "btn btn-primary"
95
+ def merge_classes!(*classnames)
96
+ self["class"] = config.classname_merger.merge(self["class"], *classnames)
97
+ self
98
+ end
99
+
100
+ # Merges Stimulus controllers into the data-controller attribute, returning a new instance.
101
+ #
102
+ # @param controllers [Array<String, Hash>] One or more controller names or
103
+ # hashes with controller names and options
104
+ # @return [Poetry::Core::HTML::Attributes] A new instance with merged controllers
105
+ #
106
+ # @example Merge controllers
107
+ # attrs = Poetry::Core::HTML::Attributes.new
108
+ # new_attrs = attrs.merge_stimulus_controllers("dropdown", "modal")
109
+ # new_attrs["data"]["controller"] # => "dropdown modal"
110
+ def merge_stimulus_controllers(*controllers)
111
+ deep_dup.merge_stimulus_controllers!(*controllers)
112
+ end
113
+
114
+ # Merges Stimulus controllers into the data-controller attribute, mutating the current instance.
115
+ #
116
+ # @param controllers [Array<String, Hash>] One or more controller names
117
+ # @return [Poetry::Core::HTML::Attributes] Self for method chaining
118
+ #
119
+ # @example Merge controllers in place
120
+ # attrs = Poetry::Core::HTML::Attributes.new(data: { controller: "dropdown" })
121
+ # attrs.merge_stimulus_controllers!("modal")
122
+ # attrs["data"]["controller"] # => "dropdown modal"
123
+ def merge_stimulus_controllers!(*controllers)
124
+ self["data"] ||= {}
125
+ self["data"]["controller"] = config.stimulus_merger.merge_controllers(dig("data", "controller"), *controllers)
126
+ self
127
+ end
128
+
129
+ # Merges Stimulus actions into the data-action attribute, returning a new instance.
130
+ #
131
+ # @param actions [Array<String, Hash>] One or more action strings or hashes
132
+ # @return [Poetry::Core::HTML::Attributes] A new instance with merged actions
133
+ #
134
+ # @example Merge actions
135
+ # attrs = Poetry::Core::HTML::Attributes.new
136
+ # new_attrs = attrs.merge_stimulus_actions("click->modal#open", "keydown->modal#close")
137
+ # new_attrs["data"]["action"] # => "click->modal#open keydown->modal#close"
138
+ def merge_stimulus_actions(*actions)
139
+ deep_dup.merge_stimulus_actions!(*actions)
140
+ end
141
+
142
+ # Merges Stimulus actions into the data-action attribute, mutating the current instance.
143
+ #
144
+ # @param actions [Array<String, Hash>] One or more action strings
145
+ # @return [Poetry::Core::HTML::Attributes] Self for method chaining
146
+ #
147
+ # @example Merge actions in place
148
+ # attrs = Poetry::Core::HTML::Attributes.new(data: { action: "click->modal#open" })
149
+ # attrs.merge_stimulus_actions!("keydown->modal#close")
150
+ # attrs["data"]["action"] # => "click->modal#open keydown->modal#close"
151
+ def merge_stimulus_actions!(*actions)
152
+ self["data"] ||= {}
153
+ self["data"]["action"] = config.stimulus_merger.merge_actions(dig("data", "action"), *actions)
154
+ self
155
+ end
156
+
157
+ # Merges Stimulus data attributes, returning a new instance.
158
+ #
159
+ # This method intelligently merges data attributes, handling controllers,
160
+ # actions, and other data attributes appropriately.
161
+ #
162
+ # @param stimulus_hash [Array<Hash>] One or more hashes of Stimulus data attributes
163
+ # @yield [key, old_value, new_value] Optional block for custom merge logic
164
+ # @return [Poetry::Core::HTML::Attributes] A new instance with merged Stimulus data
165
+ #
166
+ # @example Merge Stimulus attributes
167
+ # attrs = Poetry::Core::HTML::Attributes.new(data: { controller: "dropdown" })
168
+ # new_attrs = attrs.merge_stimulus({ action: "click->modal#open", target: "output" })
169
+ # new_attrs["data"] # => { "controller" => "dropdown", "action" => "click->modal#open", "target" => "output" }
170
+ def merge_stimulus(*stimulus_hash, &)
171
+ deep_dup.merge_stimulus!(*stimulus_hash, &)
172
+ end
173
+
174
+ # Merges Stimulus data attributes, mutating the current instance.
175
+ #
176
+ # @param stimulus_hash [Array<Hash>] One or more hashes of Stimulus data attributes
177
+ # @yield [key, old_value, new_value] Optional block for custom merge logic
178
+ # @return [Poetry::Core::HTML::Attributes] Self for method chaining
179
+ #
180
+ # @example Merge Stimulus attributes in place
181
+ # attrs = Poetry::Core::HTML::Attributes.new
182
+ # attrs.merge_stimulus!({ controller: "dropdown", action: "click->dropdown#toggle" })
183
+ # attrs["data"] # => { "controller" => "dropdown", "action" => "click->dropdown#toggle" }
184
+ def merge_stimulus!(*stimulus_hash, &)
185
+ self["data"] = config.stimulus_merger.merge(self["data"], *stimulus_hash, &)
186
+ self
187
+ end
188
+
189
+ # Merges attributes only if they are not already set, returning a new instance.
190
+ #
191
+ # This method intelligently merges attributes by only adding keys that don't exist
192
+ # in the current attributes. It handles both flat and nested data attributes:
193
+ # - "data-controller" and data: { controller: "..." } are treated as the same
194
+ # - "aria-label" and aria: { label: "..." } are treated as the same
195
+ #
196
+ # @param other_hash [Hash] Hash of attributes to merge if not set
197
+ # @return [Poetry::Core::HTML::Attributes] A new instance with conditionally merged attributes
198
+ #
199
+ # @example Merge defaults that won't override user values
200
+ # attrs = Poetry::Core::HTML::Attributes.new(class: "btn", data: { controller: "dropdown" })
201
+ # defaults = { class: "btn-default", data: { action: "click->modal#open" }, id: "my-btn" }
202
+ # new_attrs = attrs.merge_if_not_set(defaults)
203
+ # # Result: class: "btn", data: { controller: "dropdown", action: "click->modal#open" }, id: "my-btn"
204
+ # # Note: "btn-default" not added because class was already set
205
+ # # Note: action added because it wasn't set, but controller kept original value
206
+ def merge_if_not_set(other_hash)
207
+ deep_dup.merge_if_not_set!(other_hash)
208
+ end
209
+
210
+ # Merges attributes only if they are not already set, mutating the current instance.
211
+ #
212
+ # @param other_hash [Hash] Hash of attributes to merge if not set
213
+ # @return [Poetry::Core::HTML::Attributes] Self for method chaining
214
+ #
215
+ # @example Set defaults without overriding
216
+ # attrs = Poetry::Core::HTML::Attributes.new(data: { controller: "dropdown" })
217
+ # attrs.merge_if_not_set!(class: "btn", data: { controller: "modal", action: "click->dropdown#toggle" })
218
+ # # Result: class: "btn", data: { controller: "dropdown", action: "click->dropdown#toggle" }
219
+ def merge_if_not_set!(other_hash)
220
+ other_hash = convert_value(other_hash)
221
+
222
+ # First, normalize any flat data/aria attributes in self to nested format
223
+ normalize_flat_attributes!
224
+
225
+ other_hash.each do |key, value|
226
+ if key.to_s == "data" && value.is_a?(Hash)
227
+ # Handle nested data attributes
228
+ merge_data_if_not_set!(value)
229
+ elsif key.to_s == "aria" && value.is_a?(Hash)
230
+ # Handle nested aria attributes
231
+ merge_aria_if_not_set!(value)
232
+ elsif key.to_s.start_with?("data-")
233
+ # Handle flat data attributes like "data-controller"
234
+ nested_key = key.to_s.delete_prefix("data-").underscore
235
+ merge_data_key_if_not_set!(nested_key, value)
236
+ elsif key.to_s.start_with?("aria-")
237
+ # Handle flat aria attributes like "aria-label"
238
+ nested_key = key.to_s.delete_prefix("aria-").underscore
239
+ set_if_not_present!("aria", nested_key, value)
240
+ elsif !has_attribute?(key)
241
+ # Only set if the attribute doesn't exist at all
242
+ self[key] = value
243
+ end
244
+ end
245
+
246
+ self
247
+ end
248
+
249
+ # Checks if an attribute is set, handling both flat and nested formats.
250
+ #
251
+ # This method is smart about data and aria attributes, checking both
252
+ # the nested and flat formats.
253
+ #
254
+ # @param key [String, Symbol] The attribute key to check
255
+ # @param nested_key [String, Symbol, nil] Optional nested key for data/aria attributes
256
+ # @return [Boolean] True if the attribute exists
257
+ #
258
+ # @example Check for regular attributes
259
+ # attrs = Poetry::Core::HTML::Attributes.new(class: "btn", id: "my-btn")
260
+ # attrs.has_attribute?(:class) # => true
261
+ # attrs.has_attribute?(:disabled) # => false
262
+ #
263
+ # @example Check for data attributes (nested format)
264
+ # attrs = Poetry::Core::HTML::Attributes.new(data: { controller: "dropdown" })
265
+ # attrs.has_attribute?("data", "controller") # => true
266
+ # attrs.has_attribute?("data", "action") # => false
267
+ #
268
+ # @example Check for data attributes (flat format)
269
+ # attrs = Poetry::Core::HTML::Attributes.new("data-controller" => "dropdown")
270
+ # attrs.has_attribute?("data", "controller") # => true
271
+ def has_attribute?(key, nested_key = nil)
272
+ if nested_key
273
+ # Check nested format: data: { controller: "..." }
274
+ return true if dig(key, nested_key).present?
275
+
276
+ # Check flat format: "data-controller" => "..."
277
+ flat_key = "#{key}-#{nested_key.to_s.dasherize}"
278
+ return true if self[flat_key].present?
279
+
280
+ false
281
+ else
282
+ # A nil simple attribute is UNSET, not set: to_attributes drops
283
+ # nils, so a nil key must not block merge_if_not_set defaults.
284
+ # (Component#html_attributes always seeds class: - nil when the
285
+ # dictionary base is empty - which silently swallowed Sidebar's
286
+ # wrapper layout classes; the poetry-docs shell caught it.)
287
+ key?(key) && !self[key].nil?
288
+ end
289
+ end
290
+
291
+ # Gets an attribute value, handling both flat and nested formats.
292
+ #
293
+ # @param key [String, Symbol] The attribute key
294
+ # @param nested_key [String, Symbol, nil] Optional nested key for data/aria attributes
295
+ # @return [Object, nil] The attribute value or nil if not found
296
+ #
297
+ # @example Get nested data attribute
298
+ # attrs = Poetry::Core::HTML::Attributes.new(data: { controller: "dropdown" })
299
+ # attrs.get_attribute("data", "controller") # => "dropdown"
300
+ #
301
+ # @example Get flat data attribute
302
+ # attrs = Poetry::Core::HTML::Attributes.new("data-controller" => "dropdown")
303
+ # attrs.get_attribute("data", "controller") # => "dropdown"
304
+ def get_attribute(key, nested_key = nil)
305
+ if nested_key
306
+ # Try nested format first
307
+ value = dig(key, nested_key)
308
+ return value if value.present?
309
+
310
+ # Try flat format
311
+ flat_key = "#{key}-#{nested_key.to_s.dasherize}"
312
+ self[flat_key]
313
+ else
314
+ self[key]
315
+ end
316
+ end
317
+
318
+ # Converts the attributes hash to a flat hash suitable for HTML rendering.
319
+ #
320
+ # This method performs several transformations:
321
+ # - Flattens nested data attributes (data: { id: 1 } => "data-id" => "1")
322
+ # - Flattens nested aria attributes (aria: { label: "Close" } => "aria-label" => "Close")
323
+ # - Handles boolean attributes (disabled: true => "disabled" => "disabled")
324
+ # - Skips nil values for all attributes
325
+ # - Converts complex values to JSON strings when appropriate
326
+ #
327
+ # @return [Hash<String, String>] A flat hash of HTML attribute names to values
328
+ #
329
+ # @example Convert to HTML attributes
330
+ # attrs = Poetry::Core::HTML::Attributes.new(
331
+ # class: "btn btn-primary",
332
+ # disabled: true,
333
+ # id: nil,
334
+ # data: { id: 1, controller: "dropdown", target: nil },
335
+ # aria: { label: "Close", hidden: nil }
336
+ # )
337
+ # attrs.to_attributes
338
+ # # => {
339
+ # # "class" => "btn btn-primary",
340
+ # # "disabled" => "disabled",
341
+ # # "data-id" => "1",
342
+ # # "data-controller" => "dropdown",
343
+ # # "aria-label" => "Close"
344
+ # # }
345
+ # When both spellings of the same attribute coexist in the store
346
+ # (flat "data-x" AND nested data: { x: }), the output is
347
+ # deterministic instead of insertion-order roulette: stimulus keys
348
+ # (data-controller / data-action) CONCATENATE so wiring is never
349
+ # lost, and every other duplicate resolves flat-spelling-wins (the
350
+ # normalize_flat_attributes! convention).
351
+ def to_attributes
352
+ each_with_object({}) do |(key, value), hash|
353
+ next if value.nil?
354
+
355
+ if key == "data" && value.is_a?(Hash)
356
+ data_attribute(hash, value)
357
+ elsif key == "aria" && value.is_a?(Hash)
358
+ aria_attribute(hash, value)
359
+ elsif BOOLEAN_ATTRIBUTES.include?(key)
360
+ hash[key] = key if value
361
+ elsif key == "data-controller"
362
+ hash[key] = config.stimulus_merger.merge_controllers(hash[key], value)
363
+ elsif key == "data-action"
364
+ hash[key] = config.stimulus_merger.merge_actions(hash[key], value)
365
+ else
366
+ hash[key] = format_attribute_value(value)
367
+ end
368
+ end
369
+ end
370
+
371
+ private
372
+
373
+ # Returns the configuration object for mergers and other settings.
374
+ #
375
+ # @return [Poetry::Core::Config] A deep duplicate of the current configuration
376
+ # @api private
377
+ def config
378
+ @config ||= Poetry::Core::Config.current.deep_dup
379
+ end
380
+
381
+ # Normalizes flat data-* and aria-* attributes to nested format.
382
+ #
383
+ # Converts "data-controller" => "dropdown" to data: { controller: "dropdown" }
384
+ # Converts "aria-label" => "Close" to aria: { label: "Close" }
385
+ #
386
+ # @return [void]
387
+ # @api private
388
+ def normalize_flat_attributes!
389
+ flat_data = {}
390
+ flat_aria = {}
391
+
392
+ # Find all flat data-* and aria-* attributes
393
+ keys_to_delete = []
394
+ each do |key, value|
395
+ if key.to_s.start_with?("data-")
396
+ nested_key = key.to_s.delete_prefix("data-").underscore
397
+ flat_data[nested_key] = value
398
+ keys_to_delete << key
399
+ elsif key.to_s.start_with?("aria-")
400
+ nested_key = key.to_s.delete_prefix("aria-").underscore
401
+ flat_aria[nested_key] = value
402
+ keys_to_delete << key
403
+ end
404
+ end
405
+
406
+ # Remove flat attributes
407
+ keys_to_delete.each { |key| delete(key) }
408
+
409
+ # Merge into nested format. Flat-spelling-wins on collision,
410
+ # except stimulus wiring, which concatenates so a double-spelled
411
+ # controller/action never silently drops tokens.
412
+ if flat_data.any?
413
+ self["data"] ||= {}
414
+ flat_data.each do |key, value|
415
+ self["data"][key] = case key.to_s
416
+ when "controller"
417
+ config.stimulus_merger.merge_controllers(self["data"][key], value)
418
+ when "action"
419
+ config.stimulus_merger.merge_actions(self["data"][key], value)
420
+ else
421
+ value
422
+ end
423
+ end
424
+ end
425
+
426
+ return unless flat_aria.any?
427
+
428
+ self["aria"] ||= {}
429
+ self["aria"].merge!(flat_aria)
430
+ end
431
+
432
+ # Merges data attributes only if they're not already set.
433
+ #
434
+ # @param data_hash [Hash] Hash of data attributes to merge
435
+ # @return [void]
436
+ # @api private
437
+ def merge_data_if_not_set!(data_hash)
438
+ self["data"] ||= {}
439
+
440
+ data_hash.each do |key, value|
441
+ merge_data_key_if_not_set!(key, value)
442
+ end
443
+ end
444
+
445
+ # Stimulus wiring keys concatenate instead of first-wins: a caller
446
+ # attaching their own data-controller / data-action must not
447
+ # disconnect the wiring the component declares (first-wins here
448
+ # silently dropped the component's controller and broke its JS).
449
+ # Caller tokens stay first (the merge_stimulus_* convention);
450
+ # every other data key remains strictly first-wins.
451
+ #
452
+ # @param key [String, Symbol] The nested data key
453
+ # @param value [Object] The incoming (default) value
454
+ # @return [void]
455
+ # @api private
456
+ def merge_data_key_if_not_set!(key, value)
457
+ case key.to_s
458
+ when "controller"
459
+ merged = config.stimulus_merger.merge_controllers(dig("data", "controller"), value)
460
+ self["data"] ||= {}
461
+ self["data"]["controller"] = merged if merged
462
+ when "action"
463
+ merged = config.stimulus_merger.merge_actions(dig("data", "action"), value)
464
+ self["data"] ||= {}
465
+ self["data"]["action"] = merged if merged
466
+ else
467
+ set_if_not_present!("data", key, value)
468
+ end
469
+ end
470
+
471
+ # Merges aria attributes only if they're not already set.
472
+ #
473
+ # @param aria_hash [Hash] Hash of aria attributes to merge
474
+ # @return [void]
475
+ # @api private
476
+ def merge_aria_if_not_set!(aria_hash)
477
+ self["aria"] ||= {}
478
+
479
+ aria_hash.each do |key, value|
480
+ set_if_not_present!("aria", key, value)
481
+ end
482
+ end
483
+
484
+ # Sets a nested attribute only if it's not already present in either format.
485
+ #
486
+ # Checks both nested format (e.g., data: { controller: "..." }) and
487
+ # flat format (e.g., "data-controller" => "...").
488
+ #
489
+ # @param prefix [String] The prefix (e.g., "data", "aria")
490
+ # @param key [String, Symbol] The nested key
491
+ # @param value [Object] The value to set
492
+ # @return [void]
493
+ # @api private
494
+ def set_if_not_present!(prefix, key, value)
495
+ return if has_attribute?(prefix, key)
496
+
497
+ # Set in nested format
498
+ self[prefix] ||= {}
499
+ self[prefix][key] = value
500
+ end
501
+
502
+ # Flattens a nested data hash into prefixed HTML data attributes.
503
+ #
504
+ # @param attributes [Hash] The target hash to populate with flattened attributes
505
+ # @param data [Hash] The nested data hash to flatten
506
+ # @return [void]
507
+ # @api private
508
+ #
509
+ # @example
510
+ # attributes = {}
511
+ # data_attribute(attributes, { id: 1, user_name: "John" })
512
+ # attributes # => { "data-id" => "1", "data-user-name" => "John" }
513
+ def data_attribute(attributes, data)
514
+ data.each_pair do |key, value|
515
+ next if value.nil?
516
+
517
+ slot = prefixed_attribute(:data, key)
518
+
519
+ # Slot-collision rules (both spellings in one store): stimulus
520
+ # wiring concatenates, everything else is flat-spelling-wins -
521
+ # the nested expansion never overwrites an already-written flat
522
+ # value.
523
+ case slot
524
+ when "data-controller"
525
+ attributes[slot] = config.stimulus_merger.merge_controllers(attributes[slot], value)
526
+ when "data-action"
527
+ attributes[slot] = config.stimulus_merger.merge_actions(attributes[slot], value)
528
+ else
529
+ attributes[slot] = format_attribute_value(value) unless attributes.key?(slot)
530
+ end
531
+ end
532
+ end
533
+
534
+ # Flattens a nested aria hash into prefixed HTML aria attributes.
535
+ #
536
+ # Handles special cases for Hash and Array values by flattening them
537
+ # into space-separated strings.
538
+ #
539
+ # @param attributes [Hash] The target hash to populate with flattened attributes
540
+ # @param aria [Hash] The nested aria hash to flatten
541
+ # @return [void]
542
+ # @api private
543
+ #
544
+ # @example
545
+ # attributes = {}
546
+ # aria_attribute(attributes, { label: "Close", hidden: true })
547
+ # attributes # => { "aria-label" => "Close", "aria-hidden" => "true" }
548
+ def aria_attribute(attributes, aria)
549
+ aria.each_pair do |key, value|
550
+ next if value.nil?
551
+
552
+ if value.is_a?(Hash) || value.is_a?(Array)
553
+ values = flatten_attribute_values(value)
554
+ next if values.empty?
555
+
556
+ value = values.join(" ")
557
+ end
558
+
559
+ slot = prefixed_attribute(:aria, key)
560
+ # Flat-spelling-wins on collision, matching data_attribute.
561
+ attributes[slot] = format_attribute_value(value) unless attributes.key?(slot)
562
+ end
563
+ end
564
+
565
+ # Creates a prefixed and dasherized attribute name.
566
+ #
567
+ # @param prefix [Symbol, String] The prefix (e.g., :data, :aria)
568
+ # @param key [Symbol, String] The attribute key
569
+ # @return [String] The prefixed and dasherized attribute name
570
+ # @api private
571
+ #
572
+ # @example
573
+ # prefixed_attribute(:data, :user_name) # => "data-user-name"
574
+ # prefixed_attribute(:aria, :labelledby) # => "aria-labelledby"
575
+ def prefixed_attribute(prefix, key)
576
+ "#{prefix}-#{key.to_s.dasherize}"
577
+ end
578
+
579
+ # Formats an attribute value for HTML output.
580
+ #
581
+ # Simple types (String, Symbol, BigDecimal) are converted to strings.
582
+ # Complex types are converted to JSON.
583
+ #
584
+ # @param value [Object] The value to format
585
+ # @return [String] The formatted value
586
+ # @api private
587
+ #
588
+ # @example
589
+ # format_attribute_value("hello") # => "hello"
590
+ # format_attribute_value(123) # => "123"
591
+ # format_attribute_value({ a: 1 }) # => '{"a":1}'
592
+ def format_attribute_value(value)
593
+ value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(BigDecimal) ? value.to_s : value.to_json
594
+ end
595
+
596
+ # Recursively flattens attribute values from hashes and arrays.
597
+ #
598
+ # - Hash values: includes keys where values are truthy
599
+ # - Array values: recursively flattens nested arrays
600
+ # - Other values: includes if present
601
+ #
602
+ # @param args [Array] Values to flatten
603
+ # @return [Array<String>] Flattened array of string values
604
+ # @api private
605
+ #
606
+ # @example
607
+ # flatten_attribute_values({ active: true, hidden: false }, "visible")
608
+ # # => ["active", "visible"]
609
+ def flatten_attribute_values(*args)
610
+ args.each_with_object([]) do |value, tags|
611
+ case value
612
+ when Hash
613
+ value.each { |k, v| tags << k.to_s if v }
614
+ when Array
615
+ tags.concat flatten_attribute_values(*value)
616
+ else
617
+ tags << value.to_s if value.present?
618
+ end
619
+ end
620
+ end
621
+
622
+ # Overrides HashWithIndifferentAccess merge behavior for special attributes.
623
+ #
624
+ # Provides custom merge logic for "class" and "data" attributes.
625
+ #
626
+ # @param other_hash [Hash] The hash to merge
627
+ # @param block [Proc] Optional block for custom merge logic
628
+ # @return [Poetry::Core::HTML::Attributes] The merged result
629
+ # @api private
630
+ def update_with_single_argument(other_hash, block)
631
+ update_block = ->(key, old_value, new_value) { update_single_attribute(key, old_value, new_value, &block) }
632
+ super(other_hash, update_block)
633
+ end
634
+
635
+ # Handles merging of a single attribute with special logic for certain keys.
636
+ #
637
+ # - "class": Uses classname merger for intelligent class merging
638
+ # - "data": Uses stimulus merger for Stimulus attribute merging
639
+ # - Other keys: Uses provided block or defaults to new value
640
+ #
641
+ # @param key [String] The attribute key
642
+ # @param old_value [Object] The existing value
643
+ # @param new_value [Object] The new value to merge
644
+ # @yield [key, old_value, new_value] Optional block for custom merge logic
645
+ # @return [Object] The merged value
646
+ # @api private
647
+ def update_single_attribute(key, old_value, new_value, &block)
648
+ if key == "class"
649
+ config.classname_merger.merge(old_value, new_value)
650
+ elsif key == "data"
651
+ config.stimulus_merger.merge(old_value, convert_value(new_value), &block)
652
+ elsif key == "data-controller"
653
+ # The flat spelling gets the same never-clobber treatment as the
654
+ # nested data: branch - stimulus wiring concatenates on conflict.
655
+ config.stimulus_merger.merge_controllers(old_value, new_value)
656
+ elsif key == "data-action"
657
+ config.stimulus_merger.merge_actions(old_value, new_value)
658
+ elsif block
659
+ yield(key, old_value, new_value)
660
+ else
661
+ new_value
662
+ end
663
+ end
664
+ end
665
+ end
666
+ end
667
+ end