poetry-ui 0.1.7 → 0.1.9

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/app/components/poetry/ui/accordion/component.html.erb +1 -1
  4. data/app/components/poetry/ui/alert/component.html.erb +1 -1
  5. data/app/components/poetry/ui/alert_dialog/component.html.erb +2 -2
  6. data/app/components/poetry/ui/alert_dialog/component.rb +14 -2
  7. data/app/components/poetry/ui/attachment/component.html.erb +1 -1
  8. data/app/components/poetry/ui/autocomplete/component.html.erb +4 -4
  9. data/app/components/poetry/ui/autocomplete/component.rb +35 -14
  10. data/app/components/poetry/ui/avatar/component.rb +12 -16
  11. data/app/components/poetry/ui/breadcrumb/component.html.erb +1 -1
  12. data/app/components/poetry/ui/bubble/component.html.erb +2 -2
  13. data/app/components/poetry/ui/button/component.html.erb +1 -1
  14. data/app/components/poetry/ui/button/component.rb +9 -5
  15. data/app/components/poetry/ui/calendar/component.html.erb +9 -9
  16. data/app/components/poetry/ui/card/component.html.erb +4 -4
  17. data/app/components/poetry/ui/card/component.rb +2 -1
  18. data/app/components/poetry/ui/carousel/component.html.erb +3 -3
  19. data/app/components/poetry/ui/checkbox/component.html.erb +5 -5
  20. data/app/components/poetry/ui/clipboard_text/component.html.erb +4 -4
  21. data/app/components/poetry/ui/code_block/component.html.erb +2 -2
  22. data/app/components/poetry/ui/collapsible/component.html.erb +2 -2
  23. data/app/components/poetry/ui/collapsible/component.rb +16 -9
  24. data/app/components/poetry/ui/collapsible/preview.rb +2 -2
  25. data/app/components/poetry/ui/combobox/component.html.erb +6 -6
  26. data/app/components/poetry/ui/command/component.html.erb +3 -3
  27. data/app/components/poetry/ui/command/dialog_component.html.erb +2 -2
  28. data/app/components/poetry/ui/context_menu/component.html.erb +2 -2
  29. data/app/components/poetry/ui/data_table/component.html.erb +5 -5
  30. data/app/components/poetry/ui/date_field/component.html.erb +3 -3
  31. data/app/components/poetry/ui/date_picker/component.html.erb +2 -2
  32. data/app/components/poetry/ui/deferred/component.html.erb +3 -3
  33. data/app/components/poetry/ui/dialog/component.html.erb +2 -2
  34. data/app/components/poetry/ui/drawer/component.html.erb +2 -2
  35. data/app/components/poetry/ui/dropdown_menu/component.html.erb +2 -2
  36. data/app/components/poetry/ui/empty/component.html.erb +2 -2
  37. data/app/components/poetry/ui/field/component.html.erb +1 -1
  38. data/app/components/poetry/ui/field/component.rb +1 -1
  39. data/app/components/poetry/ui/field_separator/component.rb +9 -7
  40. data/app/components/poetry/ui/file_input/component.html.erb +4 -4
  41. data/app/components/poetry/ui/hover_card/component.html.erb +2 -2
  42. data/app/components/poetry/ui/input_otp/component.html.erb +3 -3
  43. data/app/components/poetry/ui/input_otp/component.rb +12 -4
  44. data/app/components/poetry/ui/item/component.html.erb +1 -1
  45. data/app/components/poetry/ui/marker/component.html.erb +1 -1
  46. data/app/components/poetry/ui/menubar/component.html.erb +1 -1
  47. data/app/components/poetry/ui/message/component.html.erb +1 -1
  48. data/app/components/poetry/ui/message_scroller/component.html.erb +1 -1
  49. data/app/components/poetry/ui/message_scroller/component.rb +14 -8
  50. data/app/components/poetry/ui/native_select/component.rb +15 -6
  51. data/app/components/poetry/ui/navigation_menu/bar_preview.html.erb +1 -1
  52. data/app/components/poetry/ui/navigation_menu/component.html.erb +6 -6
  53. data/app/components/poetry/ui/navigation_menu/component.rb +26 -11
  54. data/app/components/poetry/ui/number_field/component.html.erb +6 -6
  55. data/app/components/poetry/ui/number_field/component.rb +17 -4
  56. data/app/components/poetry/ui/pagination/component.html.erb +1 -1
  57. data/app/components/poetry/ui/popover/component.html.erb +2 -2
  58. data/app/components/poetry/ui/questionnaire/component.html.erb +5 -5
  59. data/app/components/poetry/ui/radio_group/component.html.erb +1 -1
  60. data/app/components/poetry/ui/resizable/component.html.erb +3 -3
  61. data/app/components/poetry/ui/search_field/component.html.erb +5 -5
  62. data/app/components/poetry/ui/select/component.html.erb +3 -3
  63. data/app/components/poetry/ui/sensitive_input/component.html.erb +6 -6
  64. data/app/components/poetry/ui/sheet/component.html.erb +2 -2
  65. data/app/components/poetry/ui/sidebar/component.html.erb +5 -5
  66. data/app/components/poetry/ui/slider/component.html.erb +5 -5
  67. data/app/components/poetry/ui/stat/component.html.erb +1 -1
  68. data/app/components/poetry/ui/switch/component.html.erb +5 -5
  69. data/app/components/poetry/ui/table/component.html.erb +2 -2
  70. data/app/components/poetry/ui/tabs/component.html.erb +4 -4
  71. data/app/components/poetry/ui/tag_group/component.html.erb +3 -3
  72. data/app/components/poetry/ui/toast/component.html.erb +1 -1
  73. data/app/components/poetry/ui/toaster/component.html.erb +1 -1
  74. data/app/components/poetry/ui/toggle_group/component.html.erb +1 -1
  75. data/app/components/poetry/ui/tooltip/component.html.erb +3 -3
  76. data/app/components/poetry/ui/tree/component.html.erb +3 -3
  77. data/app/helpers/poetry/ui/components_helper.rb +28 -7
  78. data/app/helpers/poetry/ui/form_builder/type_inference.rb +17 -1
  79. data/app/helpers/poetry/ui/form_builder.rb +202 -80
  80. data/config/component_registry.yml +90 -46
  81. data/config/template_classes.txt +3 -0
  82. data/lib/generators/poetry/agents_section.rb +3 -1
  83. data/lib/generators/poetry/block/templates/destructive_panel.html.erb +13 -3
  84. data/lib/generators/poetry/block/templates/top_nav.html.erb +2 -1
  85. data/lib/generators/poetry/editor/editor_generator.rb +8 -12
  86. data/lib/generators/poetry/install/install_generator.rb +19 -3
  87. data/lib/generators/poetry/skill/templates/poetry-component/references/checklist.md +6 -0
  88. data/lib/poetry/ui/testing/checkbox.rb +114 -0
  89. data/lib/poetry/ui/testing.rb +12 -0
  90. data/lib/poetry/ui/version.rb +1 -1
  91. data/lib/tasks/poetry/check.rake +21 -2
  92. metadata +5 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf138359fce6d2db2f40a9e357c588b079dfae6a7ee9a15210c0caf30b9a306b
4
- data.tar.gz: 9d0fad8f5c02bc302ecfef5ec213986300e64a9b66290e4e1e1c8868f46e3505
3
+ metadata.gz: 311078c0215e2e504b32301efa8e51f0e7e108d8788587337689f356e72423e0
4
+ data.tar.gz: 1d58b4704974eeb18b59a1f7a32f8d51fcb0598f846ba1d5864507b482d875eb
5
5
  SHA512:
6
- metadata.gz: d6c8a18d198775d026f6c86e4a0fde403eae9ef65148471efb88b9c834583a8650f60511dcee1df8fa58560ee18b4161b4e4020f7cac86d194c545b2623cc0a2
7
- data.tar.gz: 9934f30c07106f4500160b125ea3d48b6f8d62482d40164e064214ea4f42c697daedba4efde17625d520c9d2bcec059222972cd395343fa69a4a33793076bbfc
6
+ metadata.gz: 1f8a5636a3b349a718912cc30df3675b45858c9fcb0f15c50772e578f6c7c35fefa5729717207ecc0daa05dbf5aac44688896369001ac19b93e6430e5e2fab12
7
+ data.tar.gz: 6d587c9fa1a023a7ced7a1bf176c6b775198e0acb684781ae868321175ad91199f644e3f12081cf9624dc8dc19a1cd3489daf06f2933b5fdcb24b57b40714288
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.9] - 2026-09-30
4
+
5
+ ### Changed
6
+
7
+ - `bin/rails poetry:check` compiles the templates that sit beside components, which `bin/rails herb:check` never reaches: the app's own under `app/components/`, and the ones the gems in the boot ship. A template Herb refuses is a `herb-compile` finding, an error when the app renders through Herb (Rails' 8.2 defaults, or ReActionView) and a warning until then. A finding on a template a gem ships names what fixes it: the gem, or the herb the bundle resolves. A herb older than 0.11.0 leaves the compile out and says so on stderr, and the rest of the check runs as before.
8
+ - `rails g poetry:install` adds no `herb` line when Action View depends on the gem, as it does from Rails 8.2, and says where the parser comes from. On an earlier Rails it adds the line as before, and a `herb` the Gemfile declares is left alone on either.
9
+ - `rails g poetry:editor` writes a `.herb.yml` pinned to Herb 0.11.0. The two rules it used to switch off for slot setters (`erb-no-unused-expressions`, `actionview-no-silent-helper`) are fixed in 0.11 and stay on. One rule starts off instead: `html-no-nested-forms` reports a form inside a block helper, a `form_with` inside `poetry_card do`, as a form nested in another form. An existing `.herb.yml` is kept as it is; move its `version` to 0.11.0 and drop the two entries to get the same.
10
+ - Every component template builds its elements through `element_tag` and hands `tag.attributes` its hash as an argument, never as a splat. A component now renders the same in a host that compiles its templates with Herb's slots (ReActionView's `config.slots`) as in one that does not; before, such a host rendered most components without their root attributes and could not compile Button, Marker or Item at all. What a component renders without slots is unchanged, byte for byte: the suite, the behaviour tier and the 455 visual baselines hold.
11
+
12
+ ### Fixed
13
+
14
+ - NumberField with `format:` renders on Rails 8.2, and through the renderer on any Rails. Rails reads `format` off what it renders and takes the answer for the format of the template. The option's reader answered with its Hash, so the render raised `Invalid formats`. A render from a controller asked already; from Rails 8.2 a render in a view asks too, which is every `poetry_number_field(format: ...)`. The option keeps its name and the component leaves Rails with its default.
15
+
16
+ ## [0.1.8] - 2026-09-22
17
+
18
+ The robosite proof: a full Jumpstart Pro application re-skinned on Poetry surfaced 28 findings; the 20 that were Poetry's to fix land here, with the form builder taking most of them.
19
+
20
+ ### Added
21
+
22
+ - `form.checkbox_group` maps records through `value_method:` and `label_method:` (a symbol sent to the item, or a callable), and `checked:` names the checked values explicitly for a collection the object has no reader for.
23
+ - `form.file_input` flips the form to multipart (Rails' own `file_field` line), and a block given to it renders beside the control inside the Field: the seat for the current attachment's preview.
24
+ - Every builder method takes `label:` for the visible Field label (`label: false` drops it) and `aria_label:` for an accessible name alone; `label:` on a control used to become that control's `aria-label`, invisible.
25
+ - Model-less forms: `form_with(url:, scope:)` renders every builder method, with labels from the scope's i18n or the humanized method and empty values, where the object-less call raised before.
26
+ - NavigationMenu `with_link(title, href:, active: true)` marks the current page's top-level link (`data-active` plus `aria-current="page"`, the trigger's open treatment); the top-nav block marks its Docs link.
27
+ - AlertDialog `with_action(submit: "form-id")` makes the action that form's submit button (`type="submit"` plus the `form` attribute), so a confirmation submits a form on the page without nesting one inside the dialog; the destructive-panel block adopts it.
28
+ - MessageScroller `content_id:` names the content element's dom id directly (the default stays `"<id>-messages"`), and `poetry_message_scroller_item` takes `dom_id:` for a row's own DOM id beside its message id.
29
+ - NativeSelect routes `id:` and every `aria-*` attribute (flat, or nested `aria:`) onto the `<select>` itself, so a Field's `control_attributes` splat straight in; `described_by:` stays as the older spelling. The builder's `native_select` now hands the select `aria-required` too.
30
+ - Autocomplete lands the given `id:` on its `<input>` (the Field label's `for=` target) and derives the root, list and item ids from it; `aria-*` attributes move to the input the same way.
31
+ - The table part helpers consume `key:` (it never renders as an attribute): `poetry_table_row(key: record)` derives `id="table-row-<dom_id>"` for a morph to follow; the other parts drop it. The registry declares the seven parts `identity: false`, so `poetry check` stops asking them for a key.
32
+
33
+ ### Changed
34
+
35
+ - `form.submit` renders the Button named `commit` with the label as its value, so `params[:commit]` reads as it does under Rails' own submit; `form.button` is named `button`, Rails' own name for it. Pass `name:` or `value:` to override either.
36
+ - `form.check_box` and `form.switch` render inside the horizontal Field with the visible label, the shape boolean `f.input` already had; the Rails arity (`checked_value`, `unchecked_value`) is unchanged.
37
+ - The builder reads `false` and `0` as values: a tristate select shows No and a zero count shows 0, where `.presence` had blanked both. A virtual attribute with no reader renders empty instead of raising.
38
+ - Labels resolve from Rails' `helpers.label.<object_name>.<method>` first, the key `f.label` reads, then `poetry_form.labels` / `simple_form.labels`, then `human_attribute_name`; placeholders read `helpers.placeholder.*` first. Hints and placeholders now resolve on direct control calls (`form.field`, `form.native_select`), not only through `f.input`.
39
+ - Collapsible's `with_trigger` composes a poetry Button, the way Popover's does: keywords are Button props (`variant:`, `size:`), and the trigger wears Button's treatment. The previews use `variant: :ghost, size: :sm`.
40
+ - Button's anchor form (`tag: :a`, or `href:`) renders no `role="button"`: a link stays a link, announced by its destination and activated by Enter. A caller's `role:` still lands.
41
+ - Avatar's `label:` is no longer required: without one the avatar renders decorative (`aria-hidden`, no `role="img"`) with its initials fallback, the right shape beside a visible name; the content block is still required.
42
+ - InputOTP stamps its default `aria-label` only when nothing names the input already: an `aria-label`, an `aria-labelledby`, or an `id` a Field label points at.
43
+ - Card renders its `card-content` cell only when the content block gives content; a header-and-footer card no longer carries an empty body cell.
44
+ - FieldSeparator is the labelled rule anywhere ("Or continue with" under a sign-in form, a date break), not only between fields in a FieldGroup.
45
+
3
46
  ## [0.1.7] - 2026-09-20
4
47
 
5
48
  ### Changed
@@ -1,3 +1,3 @@
1
- <%= content_tag(:div, **root_attributes) do %>
1
+ <%= element_tag(:div, **root_attributes) do %>
2
2
  <% items.each do |item| %><%= item %><% end %>
3
3
  <% end %>
@@ -1,4 +1,4 @@
1
- <%= content_tag(:div, **root_attributes) do %>
1
+ <%= element_tag(:div, **root_attributes) do %>
2
2
  <%= icon %>
3
3
  <% if title? %><div data-slot="alert-title" class="<%= css(:title) %>"><%= title %></div><% end %>
4
4
  <div data-slot="alert-description" class="<%= css(:description) %>"><%= content %></div>
@@ -1,6 +1,6 @@
1
- <%= content_tag(:div, **root_attributes) do %>
1
+ <%= element_tag(:div, **root_attributes) do %>
2
2
  <%= trigger %>
3
- <%= content_tag(:dialog, **dialog_attributes) do %>
3
+ <%= element_tag(:dialog, **dialog_attributes) do %>
4
4
  <div data-slot="alert-dialog-header" class="<%= header_classes %>">
5
5
  <% if media? %>
6
6
  <div data-slot="alert-dialog-media" class="<%= media_classes %>"><%= media %></div>
@@ -36,6 +36,8 @@ module Poetry
36
36
  "never a bare Dialog, never data-turbo-confirm.",
37
37
  "with_title AND with_description are REQUIRED (both raise).",
38
38
  "The action must be an explicit user activation - agents NEVER auto-submit the action.",
39
+ "with_action(submit: \"form-id\") makes the action that form's submit button (type=submit plus the " \
40
+ "form attribute - no form nests inside the dialog); the activation stays the user's.",
39
41
  "No extra form fields inside an AlertDialog - if input is needed, use a Dialog.",
40
42
  "Cancel keeps variant: :outline; do not make cancel visually primary."
41
43
  ].freeze
@@ -66,10 +68,20 @@ module Poetry
66
68
  renders_one :media, doc: "Optional icon/illustration well above the title."
67
69
  renders_one :action,
68
70
  doc: "The confirming choice (required) - a Button; pass variant: :destructive for deletes. " \
69
- "Activating it also closes the dialog (a caller-supplied data-action opts out).",
70
- renders: lambda { |**options, &block|
71
+ "Activating it also closes the dialog (a caller-supplied data-action opts out). " \
72
+ "submit: names the form it submits (type=submit plus the form attribute).",
73
+ renders: lambda { |submit: nil, **options, &block|
74
+ if submit && options[:href]
75
+ raise ArgumentError,
76
+ "AlertDialog with_action(submit:) is a form's submit button - it takes no href:"
77
+ end
78
+
71
79
  options[:data] =
72
80
  { slot: "alert-dialog-action", action: stimulus_action(:close) }.merge(options[:data] || {})
81
+ if submit
82
+ options[:type] = :submit
83
+ options[:form] = submit
84
+ end
73
85
  Button::Component.new(**options, &block)
74
86
  }
75
87
  renders_one :cancel,
@@ -1,4 +1,4 @@
1
- <%= content_tag(:div, **root_attributes) do %>
1
+ <%= element_tag(:div, **root_attributes) do %>
2
2
  <%= trigger %>
3
3
  <%= media %>
4
4
  <% if title? || description? %>
@@ -1,11 +1,11 @@
1
- <%= content_tag(:div, **root_attributes) do %>
2
- <%= tag.input(**input_attributes, class: Poetry::Ui::Input::Style.css) %>
3
- <%= tag.div(**content_attributes) do %>
1
+ <%= element_tag(:div, **root_attributes) do %>
2
+ <%= element_tag(:input, **input_attributes, class: Poetry::Ui::Input::Style.css) %>
3
+ <%= element_tag(:div, **content_attributes) do %>
4
4
  <div data-slot="autocomplete-list" role="listbox" id="<%= list_id %>"
5
5
  class="<%= Poetry::Ui::Command::Style.css(:list) %>"
6
6
  <%= tag.attributes(stimulus_attributes_for(:list)) %>>
7
7
  <% item_models.each_with_index do |item, index| %>
8
- <%= tag.div(**item_attributes(item, index)) do %>
8
+ <%= element_tag(:div, **item_attributes(item, index)) do %>
9
9
  <span><%= item.label %></span>
10
10
  <% end %>
11
11
  <% end %>
@@ -25,9 +25,16 @@ module Poetry
25
25
  "empty_text: renders the no-matches state (hidden while anything matches).",
26
26
  "open_on_focus: false waits for typing before suggesting.",
27
27
  "Server-side filtering stays yours: render fewer items on re-render - the client " \
28
- "filter only narrows what the server sent."
28
+ "filter only narrows what the server sent.",
29
+ "id: is the INPUT's id (a Field's label for= target); the root, list and item ids derive " \
30
+ "from it, and every aria-* attribute lands on the input (Field control_attributes splat in)."
29
31
  ].freeze
30
32
 
33
+ # The aria attributes a caller (or a Field's control_attributes)
34
+ # passes name the INPUT, not the root: pulled off the root in
35
+ # #initialize and merged into the input's attributes.
36
+ INPUT_ARIA = /\Aaria-/
37
+
31
38
  use_stimulus do
32
39
  on :root do
33
40
  controller :autocomplete do
@@ -69,7 +76,8 @@ module Poetry
69
76
 
70
77
  option :name, :string, required: true, doc: "The form param key; the input's text submits under it as-is."
71
78
  option :value, :string, doc: "The initial input text."
72
- option :id, :string, doc: "Stable DOM id token for the root and list ids."
79
+ option :id, :string, doc: "The input's DOM id (the Field label target); the root, list and item ids " \
80
+ "derive from it (-root, -list, -item-n)."
73
81
  option :placeholder, :string, doc: "Placeholder text shown while the input is empty."
74
82
  option :label, :string, doc: "The accessible name (or wire aria-labelledby via html attrs)."
75
83
  option :empty_text, :string, default: "No results.",
@@ -125,19 +133,19 @@ module Poetry
125
133
  # @api private
126
134
  def item_models = (@item_models ||= [])
127
135
 
128
- # The root id: the given id as a token, else a stable instance id.
136
+ # The input's id: the given id, else a stable instance id.
129
137
  # @api private
130
- def autocomplete_id
131
- @autocomplete_id ||= if (token = dom_id_token(id))
132
- "poetry-autocomplete-#{token}"
133
- else
134
- poetry_instance_id("poetry-autocomplete")
135
- end
138
+ def input_id
139
+ @input_id ||= id.presence || poetry_instance_id("poetry-autocomplete")
136
140
  end
137
141
 
138
- # The listbox's id.
142
+ # The root id, derived from the input's.
143
+ # @api private
144
+ def autocomplete_id = "#{input_id}-root"
145
+
146
+ # The listbox's id, derived from the input's.
139
147
  # @api private
140
- def list_id = "#{autocomplete_id}-list"
148
+ def list_id = "#{input_id}-list"
141
149
 
142
150
  # The root's attributes: this component's markup over the core default.
143
151
  def root_attributes
@@ -148,11 +156,12 @@ module Poetry
148
156
  # @api private
149
157
  def input_attributes
150
158
  attrs = {
151
- name: name, value: value, placeholder: placeholder, type: :text,
159
+ name: name, value: value, placeholder: placeholder, type: :text, id: input_id,
152
160
  autocomplete: "off", role: "combobox",
153
161
  "aria-autocomplete": "list", "aria-expanded": open.to_s, "aria-controls": list_id,
154
162
  "data-slot": "autocomplete-input"
155
163
  }
164
+ attrs.merge!(@input_aria)
156
165
  attrs[:"aria-label"] = label if label.present?
157
166
  attrs.merge(stimulus_attributes_for(:input).transform_keys(&:to_sym))
158
167
  end
@@ -177,7 +186,7 @@ module Poetry
177
186
  def item_attributes(item, index)
178
187
  attrs = {
179
188
  "class" => Poetry::Ui::Command::Style.css(:item), "data-slot" => "autocomplete-item",
180
- "id" => "#{autocomplete_id}-item-#{index}", "role" => "option",
189
+ "id" => "#{input_id}-item-#{index}", "role" => "option",
181
190
  "data-label" => item.label
182
191
  }
183
192
  attrs["data-value"] = item.value if item.value.present?
@@ -191,7 +200,19 @@ module Poetry
191
200
  # @api private
192
201
  Item = Struct.new(:label, :value, :disabled, :highlighted, keyword_init: true)
193
202
 
194
- private :item_models, :autocomplete_id, :list_id, :input_attributes, :content_attributes
203
+ private
204
+
205
+ # Pulls the aria-* attributes (flat, or nested aria: {}) off the
206
+ # root for the input - the Select trigger pattern, so a Field's
207
+ # control_attributes name the real control.
208
+ def initialize(**)
209
+ super
210
+ @input_aria = @html_attributes.keys.grep(INPUT_ARIA).to_h { |key| [key.to_sym, @html_attributes.delete(key)] }
211
+ nested = @html_attributes.delete("aria")
212
+ nested.each { |key, value| @input_aria[:"aria-#{key}"] = value } if nested.is_a?(Hash)
213
+ end
214
+
215
+ private :item_models, :input_id, :autocomplete_id, :list_id, :input_attributes, :content_attributes
195
216
  private :item_attributes
196
217
  end
197
218
  end
@@ -20,7 +20,8 @@ module Poetry
20
20
 
21
21
  # Projected into the registry, llms.txt, and the agent surface.
22
22
  AGENT_RULES = [
23
- "label: (the person's name) is REQUIRED - it is the avatar's accessible name (role=img).",
23
+ "label: (the person's name) is the avatar's accessible name (role=img); without one the avatar " \
24
+ "renders decorative (aria-hidden) - right beside the visible name, wrong on its own.",
24
25
  "The content block is the fallback (initials) and is also required - it is what shows " \
25
26
  "while the image loads or when it fails.",
26
27
  "The badge slot is decorative (a presence dot); put the status meaning in label:, " \
@@ -31,15 +32,15 @@ module Poetry
31
32
  renders_one :badge, doc: "Decorative presence dot, bottom-right; keep the status meaning in label:."
32
33
 
33
34
  option :src, :string, doc: "The image URL; without it only the initials fallback shows."
34
- option :label, :string, required: true,
35
- doc: "The person's name - the avatar's accessible name (blank raises). The required " \
36
- "flag also carries the fact to the registry so static checks see it."
35
+ option :label, :string, doc: "The person's name - the avatar's accessible name (role=img). Without one " \
36
+ "the avatar is decorative (aria-hidden, no role): use that beside the " \
37
+ "visible name, never for an avatar that stands alone."
37
38
  option :size, :symbol, default: :default, doc: "The diameter axis."
38
39
 
39
40
  validates :size, inclusion: { in: SIZES }
40
41
 
41
- part "avatar", "Root span (role=img carrying the accessible name) - fallback, image, " \
42
- "and badge layer inside it",
42
+ part "avatar", "Root span (role=img carrying the accessible name, or aria-hidden without label:) - " \
43
+ "fallback, image, and badge layer inside it",
43
44
  states: {
44
45
  "data-size" => { condition: "always - the resolved size", values: SIZES.map(&:to_s) }
45
46
  }
@@ -49,11 +50,9 @@ module Poetry
49
50
  "is given; a failed load paints nothing"
50
51
  part "avatar-badge", "The decorative presence dot (the badge slot), bottom-right"
51
52
 
52
- # Enforces label: and the initials content block.
53
+ # Enforces the initials content block.
53
54
  # @api private
54
55
  def before_render
55
- raise ArgumentError, "Avatar requires label: (the person's name - its accessible name)" if label.blank?
56
-
57
56
  ensure_content!
58
57
  end
59
58
 
@@ -65,14 +64,11 @@ module Poetry
65
64
  end
66
65
  end
67
66
 
68
- # The root's attributes: this component's markup over the core default.
67
+ # The root's attributes: this component's markup over the core
68
+ # default - named (role=img) with a label, decorative without.
69
69
  def root_attributes
70
- super(
71
- {
72
- "data-size" => size,
73
- "role" => "img", "aria-label" => label
74
- }
75
- )
70
+ naming = label.present? ? { "role" => "img", "aria-label" => label } : { "aria-hidden" => "true" }
71
+ super({ "data-size" => size }.merge(naming))
76
72
  end
77
73
 
78
74
  private
@@ -1,4 +1,4 @@
1
- <%= content_tag(:nav, **root_attributes) do %>
1
+ <%= element_tag(:nav, **root_attributes) do %>
2
2
  <ol data-slot="breadcrumb-list" class="<%= css(:list) %>">
3
3
  <% entries.each_with_index do |entry, index| %>
4
4
  <li data-slot="breadcrumb-item" class="<%= css(:item) %>">
@@ -1,4 +1,4 @@
1
- <%= content_tag(:div, **root_attributes) do %>
2
- <%= content_tag(content_tag_name, content, **content_attributes) %>
1
+ <%= element_tag(:div, **root_attributes) do %>
2
+ <%= element_tag(content_tag_name, content, **content_attributes) %>
3
3
  <%= reactions %>
4
4
  <% end %>
@@ -1,4 +1,4 @@
1
- <%= content_tag(root_tag, **root_attributes) do %>
1
+ <%= element_tag(root_tag, **root_attributes) do %>
2
2
  <% if loading %>
3
3
  <span data-slot="spinner" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg></span>
4
4
  <% end %>
@@ -54,7 +54,8 @@ module Poetry
54
54
  option :type, :symbol, default: :button,
55
55
  doc: "The native button type; ignored when the button renders as an anchor."
56
56
  option :tag, :symbol, default: :button,
57
- doc: "Renders the same styling on an <a> when :a - navigation wearing button clothes."
57
+ doc: "Renders the same styling on an <a> when :a - navigation wearing button clothes; " \
58
+ "the anchor keeps its link semantics (no role)."
58
59
  option :disabled, :boolean, default: false,
59
60
  doc: "Disables the control (native disabled; aria-disabled on the anchor form)."
60
61
  option :loading, :boolean, default: false,
@@ -139,15 +140,18 @@ module Poetry
139
140
  end
140
141
 
141
142
  # Native <button> gets native semantics (type, real disabled);
142
- # `tag: :a` is navigation-styled-as-button: role="button" + the
143
- # faux-disabled convention (aria-disabled, href withheld).
143
+ # `tag: :a` is navigation-styled-as-button: a link that stays a
144
+ # link (no role - assistive tech announces the destination, and
145
+ # Enter, not Space, activates it) plus the faux-disabled
146
+ # convention (aria-disabled, href withheld). A caller's role:
147
+ # still lands through the passthrough.
144
148
  def tag_attributes
145
149
  link_tag? ? link_attributes : button_attributes
146
150
  end
147
151
 
148
- # The anchor's attributes: the button role, and the href or the disabled state.
152
+ # The anchor's attributes: the href or the disabled state.
149
153
  def link_attributes
150
- attrs = { "role" => "button" }
154
+ attrs = {}
151
155
  if disabled || loading
152
156
  attrs["aria-disabled"] = true
153
157
  elsif href.present?
@@ -1,4 +1,4 @@
1
- <%= content_tag(:div, **root_attributes) do %>
1
+ <%= element_tag(:div, **root_attributes) do %>
2
2
  <div data-slot="calendar-nav" class="<%= css(:nav) %>">
3
3
  <%= render Poetry::Ui::Button::Component.new(**previous_options) do |button| %>
4
4
  <% button.with_leading { render Poetry::Ui::Icon::Component.new(name: :"chevron-left") } %>
@@ -13,7 +13,7 @@
13
13
  <% { "month" => [month_options, @month.month, t("poetry.calendar.month"), month_names_list[@month.month - 1]],
14
14
  "year" => [year_options, @month.year, t("poetry.calendar.year"), @month.year.to_s] }.each do |unit, (options, current, label, text)| %>
15
15
  <span data-slot="calendar-dropdown" data-calendar-unit="<%= unit %>" class="<%= css(:dropdown_root) %>"
16
- <%= tag.attributes(**stimulus_attributes_for(:dropdown)) %>>
16
+ <%= tag.attributes(stimulus_attributes_for(:dropdown)) %>>
17
17
  <span data-slot="calendar-caption-label" aria-hidden="true" class="<%= css(:dropdown_label) %>">
18
18
  <span data-slot="calendar-dropdown-value"><%= text %></span>
19
19
  <%= render Poetry::Ui::Icon::Component.new(name: :"chevron-down") %>
@@ -27,14 +27,14 @@
27
27
  <% end %>
28
28
  </div>
29
29
  <% else %>
30
- <div data-slot="calendar-caption" class="<%= css(:caption) %>" <%= tag.attributes(**stimulus_attributes_for(:caption)) %>><%= caption %></div>
30
+ <div data-slot="calendar-caption" class="<%= css(:caption) %>" <%= tag.attributes(stimulus_attributes_for(:caption)) %>><%= caption %></div>
31
31
  <% end %>
32
32
  <%= render Poetry::Ui::Button::Component.new(**next_options) do |button| %>
33
33
  <% button.with_leading { render Poetry::Ui::Icon::Component.new(name: :"chevron-right") } %>
34
34
  <% end %>
35
35
  </div>
36
36
  <div role="grid" data-slot="calendar-grid" class="<%= css(:grid) %>"
37
- <%= tag.attributes(**stimulus_attributes_for(:grid)) %>>
37
+ <%= tag.attributes(stimulus_attributes_for(:grid)) %>>
38
38
  <div role="row" data-slot="calendar-weekdays" class="<%= css(:weekdays) %>">
39
39
  <% if week_numbers %>
40
40
  <div role="columnheader" data-slot="calendar-week-number" class="<%= css(:weekday) %>"><span class="sr-only">Week</span></div>
@@ -49,8 +49,8 @@
49
49
  <div role="rowheader" data-slot="calendar-week-number" class="<%= css(:weekday) %>"><%= iso_week(week) %></div>
50
50
  <% end %>
51
51
  <% week.each do |date| %>
52
- <%= content_tag(:div, cell_attributes(date)) do %>
53
- <%= content_tag(:button, day_attributes(date)) do %>
52
+ <%= element_tag(:div, cell_attributes(date)) do %>
53
+ <%= element_tag(:button, day_attributes(date)) do %>
54
54
  <span data-slot="calendar-day-label"><%= date.day %></span>
55
55
  <% end %>
56
56
  <% end %>
@@ -61,12 +61,12 @@
61
61
  <% if name.present? %>
62
62
  <% if range? %>
63
63
  <input type="hidden" name="<%= name %>[start]" value="<%= range_start&.iso8601 %>"
64
- <%= tag.attributes(**stimulus_attributes_for(:start_input)) %>>
64
+ <%= tag.attributes(stimulus_attributes_for(:start_input)) %>>
65
65
  <input type="hidden" name="<%= name %>[end]" value="<%= range_end&.iso8601 %>"
66
- <%= tag.attributes(**stimulus_attributes_for(:end_input)) %>>
66
+ <%= tag.attributes(stimulus_attributes_for(:end_input)) %>>
67
67
  <% else %>
68
68
  <input type="hidden" name="<%= name %>" value="<%= selected&.iso8601 %>"
69
- <%= tag.attributes(**stimulus_attributes_for(:hidden_input)) %>>
69
+ <%= tag.attributes(stimulus_attributes_for(:hidden_input)) %>>
70
70
  <% end %>
71
71
  <% end %>
72
72
  <% end %>
@@ -1,11 +1,11 @@
1
- <%= content_tag(:div, **root_attributes) do %>
1
+ <%= element_tag(:div, **root_attributes) do %>
2
2
  <% if header? %>
3
3
  <div data-slot="card-header" class="<%= css(:header, class: header_class) %>">
4
- <% if title? %><%= content_tag(title_tag, title, "data-slot" => "card-title", class: css(:title)) %><% end %>
4
+ <% if title? %><%= element_tag(title_tag, title, "data-slot" => "card-title", class: css(:title)) %><% end %>
5
5
  <% if description? %><div data-slot="card-description" class="<%= css(:description) %>"><%= description %></div><% end %>
6
6
  <% if action? %><div data-slot="card-action" class="<%= css(:action) %>"><%= action %></div><% end %>
7
7
  </div>
8
8
  <% end %>
9
- <div data-slot="card-content" class="<%= css(:content, class: content_class) %>"><%= content %></div>
10
- <% if footer? %><%= content_tag(:div, footer_attributes) do %><%= capture(&footer_block) if footer_block %><% end %><% end %>
9
+ <% if content.present? %><div data-slot="card-content" class="<%= css(:content, class: content_class) %>"><%= content %></div><% end %>
10
+ <% if footer? %><%= element_tag(:div, footer_attributes) do %><%= capture(&footer_block) if footer_block %><% end %><% end %>
11
11
  <% end %>
@@ -56,7 +56,8 @@ module Poetry
56
56
  part "card-title", "The heading (title_tag, h3 by default)"
57
57
  part "card-description", "Muted one-liner under the title"
58
58
  part "card-action", "The header's trailing corner control"
59
- part "card-content", "The body - the content block renders here"
59
+ part "card-content", "The body - renders when the content block gives content (a header-and-footer " \
60
+ "card has no empty body cell)"
60
61
  part "card-footer", "The bottom row (actions/meta)"
61
62
 
62
63
  # The caller's footer block, captured by with_footer.
@@ -1,8 +1,8 @@
1
- <%= content_tag(:div, **root_attributes) do %>
2
- <%= content_tag(:div, viewport_attributes) do %>
1
+ <%= element_tag(:div, **root_attributes) do %>
2
+ <%= element_tag(:div, viewport_attributes) do %>
3
3
  <div class="<%= css(:track, class: [css(vertical? ? :track_vertical : :track_horizontal), track_classes].compact) %>">
4
4
  <% slides.each do |slide| %>
5
- <%= content_tag(:div, capture(&slide.block), item_attributes(slide)) %>
5
+ <%= element_tag(:div, capture(&slide.block), item_attributes(slide)) %>
6
6
  <% end %>
7
7
  </div>
8
8
  <% end %>
@@ -1,7 +1,7 @@
1
1
  <%# Fragment - button + input(s) as SIBLINGS, no wrapper: the source's
2
2
  `peer` class contract (labels style via peer-*) breaks if poetry wraps.
3
3
  The controller lives on the button; the input resolves by stable id. %>
4
- <%= content_tag(:button, **root_attributes) do %>
4
+ <%= element_tag(:button, **root_attributes) do %>
5
5
  <span data-slot="checkbox-indicator" <% if indeterminate? %>data-indeterminate=""<% elsif checked? %>data-checked=""<% else %>data-unchecked=""<% end %> aria-hidden="true" class="<%= css(:indicator) %>">
6
6
  <%# MinusIcon on indeterminate is a POETRY ADDITION (shadcn renders a
7
7
  check for mixed state - a real gap). %>
@@ -12,11 +12,11 @@
12
12
  <%# Rails parity hidden FIRST (last-wins ordering, Tags::CheckBox-exact)
13
13
  so unchecked SUBMITS unchecked_value; disabled fields submit nothing. %>
14
14
  <% if unchecked_value %>
15
- <%= tag.input(type: "hidden", name: name, value: unchecked_value, disabled: disabled) %>
15
+ <%= element_tag(:input, type: "hidden", name: name, value: unchecked_value, disabled: disabled) %>
16
16
  <% end %>
17
17
  <%# THE form participant + the store: input.indeterminate is a JS-only
18
18
  property - the controller derives it from the checked attributes on connect. %>
19
- <%= tag.input(type: "checkbox", id: input_id, name: name, value: value, checked: checked?,
20
- disabled: disabled, "data-slot": "checkbox-input", "aria-hidden": true,
21
- tabindex: "-1", class: css(:input)) %>
19
+ <%= element_tag(:input, type: "checkbox", id: input_id, name: name, value: value, checked: checked?,
20
+ disabled: disabled, "data-slot": "checkbox-input", "aria-hidden": true,
21
+ tabindex: "-1", class: css(:input)) %>
22
22
  <% end %>
@@ -1,9 +1,9 @@
1
1
  <%# InputGroup anatomy: the readonly mono control, trailing copy
2
2
  affordance with stacked copy/check glyphs swapping on data-copied. %>
3
- <%= content_tag(:div, **root_attributes) do %>
4
- <%= content_tag(:div, **group_attributes) do %>
5
- <%= tag.input(**input_attributes) %>
6
- <%= content_tag(:div, **addon_attributes) do %>
3
+ <%= element_tag(:div, **root_attributes) do %>
4
+ <%= element_tag(:div, **group_attributes) do %>
5
+ <%= element_tag(:input, **input_attributes) %>
6
+ <%= element_tag(:div, **addon_attributes) do %>
7
7
  <%= render copy_button do %>
8
8
  <span class="<%= css(:icon_stack) %>">
9
9
  <%= render Poetry::Ui::Icon::Component.new(name: :copy, class: css(:icon_copy)) %>
@@ -1,7 +1,7 @@
1
1
  <%# The pre/code nesting stays on one line - literal template whitespace
2
2
  inside <pre> would render. %>
3
- <%= content_tag(:div, **root_attributes) do %>
4
- <%= content_tag(:pre, content_tag(:code, highlighted || code, **code_attributes), **pre_attributes) %>
3
+ <%= element_tag(:div, **root_attributes) do %>
4
+ <%= element_tag(:pre, element_tag(:code, highlighted || code, **code_attributes), **pre_attributes) %>
5
5
  <% if copy %>
6
6
  <%= render copy_button do %>
7
7
  <span class="<%= css(:icon_stack) %>">
@@ -1,4 +1,4 @@
1
- <%= content_tag(:div, **root_attributes) do %>
1
+ <%= element_tag(:div, **root_attributes) do %>
2
2
  <%= trigger %>
3
- <%= content_tag(:div, content, **content_attributes) %>
3
+ <%= element_tag(:div, content, **content_attributes) %>
4
4
  <% end %>
@@ -21,7 +21,8 @@ module Poetry
21
21
  # Projected into the registry, llms.txt, and the agent surface.
22
22
  AGENT_RULES = [
23
23
  ComposableTrigger::AGENT_RULE,
24
- "The trigger is with_trigger { \"label\" } - a real button, wired for you (aria-expanded/controls).",
24
+ "The trigger is with_trigger { \"label\" } - a poetry Button (keywords are Button props: variant:, " \
25
+ "size:), wired for you (aria-expanded/controls).",
25
26
  "Server-render the initial state via open: - never toggle data-open/data-closed by hand.",
26
27
  "Content stays in the DOM when closed (hidden) - do not conditionally render it.",
27
28
  "For URL-controlled disclosure without JS, render open: from params - the same markup serves both."
@@ -31,16 +32,20 @@ module Poetry
31
32
  # a missing trigger without rendering.
32
33
  REQUIRED_SLOTS = { trigger: "the disclosure control" }.freeze
33
34
 
35
+ # The class the trigger slot renders - with_trigger composes a
36
+ # Button, so callers get Button's full option contract.
37
+ SLOT_RENDERS = { trigger: Button::Component }.freeze
38
+
34
39
  renders_one :trigger,
35
- doc: "The disclosure control - a real button, wired for you (aria-expanded, aria-controls); " \
36
- "options merge onto it.",
40
+ doc: "The disclosure control - a composed Button (keywords are Button props), wired for you " \
41
+ "(aria-expanded, aria-controls).",
37
42
  renders: lambda { |**options, &block|
38
- attrs = {
39
- type: "button", "data-slot" => "collapsible-trigger",
43
+ wiring = {
44
+ "data-slot" => "collapsible-trigger",
40
45
  "aria-expanded" => open.to_s, "aria-controls" => content_id
41
46
  }.merge(stimulus_attributes_for(:trigger))
42
- composed_trigger(attrs, options, &block) ||
43
- content_tag(:button, Poetry::Core::HTML::Attributes.merged(attrs, options), &block)
47
+ composed_trigger(wiring, options, &block) ||
48
+ Button::Component.new(**wiring, **options, &block)
44
49
  }
45
50
 
46
51
  use_stimulus do
@@ -66,10 +71,12 @@ module Poetry
66
71
  "data-open" => "expanded (server-rendered from open:; the controller flips the pair at runtime)",
67
72
  "data-closed" => "collapsed (the server-rendered default)"
68
73
  }
69
- part "collapsible-trigger", "The disclosure button - mirrors aria-expanded",
74
+ part "collapsible-trigger", "The disclosure button - a composed Button - mirrors aria-expanded",
70
75
  states: {
71
76
  "data-panel-open" => "its content is open (controller-written; " \
72
- "absent while closed)"
77
+ "absent while closed)",
78
+ "data-variant" => "always - the composed Button's variant axis",
79
+ "data-size" => "always - the composed Button's size axis"
73
80
  }
74
81
  part "collapsible-content", "The disclosure panel - stays in the DOM when closed (hidden) " \
75
82
  "and rides the presence helper on exit",
@@ -7,14 +7,14 @@ module Poetry
7
7
  class Preview < Poetry::Core::Preview::Base
8
8
  def default
9
9
  render_component do |collapsible|
10
- collapsible.with_trigger(class: "text-sm font-medium underline-offset-4 hover:underline") { "Show details" }
10
+ collapsible.with_trigger(variant: :ghost, size: :sm) { "Show details" }
11
11
  tag.div("Hidden until disclosed.", class: "pt-2 text-sm text-muted-foreground")
12
12
  end
13
13
  end
14
14
 
15
15
  def initially_open
16
16
  render_component(open: true) do |collapsible|
17
- collapsible.with_trigger(class: "text-sm font-medium") { "Hide details" }
17
+ collapsible.with_trigger(variant: :ghost, size: :sm) { "Hide details" }
18
18
  tag.div("Server-rendered open.", class: "pt-2 text-sm text-muted-foreground")
19
19
  end
20
20
  end