poetry-ui 0.1.6 → 0.1.8

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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/app/components/poetry/ui/alert_dialog/component.rb +14 -2
  4. data/app/components/poetry/ui/autocomplete/component.rb +35 -14
  5. data/app/components/poetry/ui/avatar/component.rb +12 -16
  6. data/app/components/poetry/ui/button/component.rb +9 -5
  7. data/app/components/poetry/ui/card/component.html.erb +1 -1
  8. data/app/components/poetry/ui/card/component.rb +2 -1
  9. data/app/components/poetry/ui/collapsible/component.rb +16 -9
  10. data/app/components/poetry/ui/collapsible/preview.rb +2 -2
  11. data/app/components/poetry/ui/field_separator/component.rb +9 -7
  12. data/app/components/poetry/ui/input_otp/component.rb +12 -4
  13. data/app/components/poetry/ui/message_scroller/component.rb +14 -8
  14. data/app/components/poetry/ui/native_select/component.rb +15 -6
  15. data/app/components/poetry/ui/navigation_menu/bar_preview.html.erb +1 -1
  16. data/app/components/poetry/ui/navigation_menu/component.html.erb +1 -1
  17. data/app/components/poetry/ui/navigation_menu/component.rb +26 -11
  18. data/app/helpers/poetry/ui/components_helper.rb +27 -6
  19. data/app/helpers/poetry/ui/form_builder/type_inference.rb +17 -1
  20. data/app/helpers/poetry/ui/form_builder.rb +202 -80
  21. data/config/component_registry.yml +90 -46
  22. data/config/template_classes.txt +3 -0
  23. data/lib/generators/poetry/block/templates/destructive_panel.html.erb +13 -3
  24. data/lib/generators/poetry/block/templates/top_nav.html.erb +2 -1
  25. data/lib/poetry/ui/testing/checkbox.rb +114 -0
  26. data/lib/poetry/ui/testing.rb +12 -0
  27. data/lib/poetry/ui/version.rb +1 -1
  28. metadata +5 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83490085dc76d9571592fc20f909ed21c4dc36625df781b284e552396ed39db9
4
- data.tar.gz: 92251c4128e98d2d1d4ab452cd8723fe722c30ae4cd97d8f1aafb4b1968c8ef4
3
+ metadata.gz: 27935417c8f6663b28bc05dc0d7dabbd326996ff8fd7e36437a069114c1bf7a3
4
+ data.tar.gz: 4249407b7b6e91b02a38846b8927e6a50cc13897a15626f55312974bc54915f2
5
5
  SHA512:
6
- metadata.gz: 321ecf105f65b599312096b0d77b54ee9bc829ed86ff44f96f71e660bc1de0b64e2432b21567a978fb05e2beab37a31a188c9b8c012ac85b628d39687880e401
7
- data.tar.gz: 474c0a4c380e214e2b98452105f0859ddaac4385480170a647d1fd556b8a01d25cb40955666bfcadde26c568bed8cefafba746eda7468ab38d9c9f6d70ffdeda
6
+ metadata.gz: 927db8c50cc562398078117ec76828112eae5618fef74e33813b710c3b1ee49d3d6e40c1f2b372663891259f43520f894c19797dab9b299611dbde6fc224df52
7
+ data.tar.gz: b12b930bb1ada7389f82366d6a3864d908021ee152e6fc018216ed8dfa157dbff7c096ad1c3d72dd94e0bb1b3031b6e8947464462a406a2fd47807d1fcb2ec06
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.8] - 2026-09-22
4
+
5
+ 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.
6
+
7
+ ### Added
8
+
9
+ - `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.
10
+ - `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.
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+ - 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.
18
+ - 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.
19
+
20
+ ### Changed
21
+
22
+ - `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.
23
+ - `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.
24
+ - 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.
25
+ - 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`.
26
+ - 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`.
27
+ - 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.
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+ - FieldSeparator is the labelled rule anywhere ("Or continue with" under a sign-in form, a date break), not only between fields in a FieldGroup.
32
+
33
+ ## [0.1.7] - 2026-09-20
34
+
35
+ ### Changed
36
+
37
+ - Version bump with the family; no changes in this gem.
38
+
3
39
  ## [0.1.6] - 2026-09-20
4
40
 
5
41
  ### Changed
@@ -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,
@@ -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
@@ -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?
@@ -6,6 +6,6 @@
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>
9
+ <% if content.present? %><div data-slot="card-content" class="<%= css(:content, class: content_class) %>"><%= content %></div><% end %>
10
10
  <% if footer? %><%= content_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.
@@ -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
@@ -4,19 +4,21 @@ module Poetry
4
4
  module Ui
5
5
  # The FieldSeparator family - the divider row between stacked fields.
6
6
  module FieldSeparator
7
- # The FieldSeparator - the divider between stacked fields inside a
8
- # FieldGroup: a Separator drawn across the
9
- # row, with an optional inline caption riding on top ("Or continue
10
- # with"). The caption is visual chrome on a decorative rule - the
11
- # Separator inside stays aria-hidden either way.
7
+ # The FieldSeparator - the labelled rule: a Separator drawn across
8
+ # the row with an optional inline caption riding on top ("Or
9
+ # continue with"). Between stacked fields in a FieldGroup, under a
10
+ # sign-in form before the provider buttons, at a date break in a
11
+ # list - anywhere a rule needs a caption. The caption is visual
12
+ # chrome on a decorative rule - the Separator inside stays
13
+ # aria-hidden either way.
12
14
  #
13
15
  # @example A captioned divider between stacked fields
14
16
  # render Poetry::Ui::FieldSeparator::Component.new { "Or continue with" }
15
17
  class Component < Poetry::Core::Component
16
18
  # Projected into the registry, llms.txt, and the agent surface.
17
19
  AGENT_RULES = [
18
- "Divides stacked fields inside a poetry_field_group - not a general-purpose rule " \
19
- "(that is poetry_separator).",
20
+ "The labelled rule, anywhere: between stacked fields in a poetry_field_group, under a sign-in " \
21
+ "form (\"Or continue with\"), at a date break; poetry_separator is the bare rule.",
20
22
  "Pass a block for the inline caption form (\"Or continue with\") - the caption sits " \
21
23
  "on the line, backed by the page background."
22
24
  ].freeze
@@ -33,7 +33,7 @@ module Poetry
33
33
  }.freeze
34
34
  # Field control_attributes land on the INPUT (the real control);
35
35
  # everything else the caller passes styles the container.
36
- INPUT_FACING = %w[id aria-label aria-describedby aria-invalid aria-required].freeze
36
+ INPUT_FACING = %w[id aria-label aria-labelledby aria-describedby aria-invalid aria-required].freeze
37
37
 
38
38
  # Projected into the registry, llms.txt, and the agent surface.
39
39
  AGENT_RULES = [
@@ -147,6 +147,13 @@ module Poetry
147
147
  @input_id ||= poetry_instance_id("poetry-input-otp")
148
148
  end
149
149
 
150
+ # Whether the caller named the input already - an aria-label, an
151
+ # aria-labelledby, or an id a Field label points at.
152
+ # @api private
153
+ def named_by_caller?
154
+ html_attributes.slice("id", "aria-label", "aria-labelledby").values.any?(&:present?)
155
+ end
156
+
150
157
  # Whether the rendered value already fills every cell.
151
158
  # @api private
152
159
  def complete?
@@ -177,9 +184,10 @@ module Poetry
177
184
  "data-slot" => "input-otp", "class" => css(:input)
178
185
  }
179
186
  # The invisible input is the ONLY AT surface - it must always
180
- # carry a name (the axe label rule); callers override via
181
- # aria-label / Field labelling.
182
- attrs["aria-label"] = t("poetry.input_otp.label")
187
+ # carry a name (the axe label rule): the caller's aria-label or
188
+ # aria-labelledby, a Field's label through the id (label for=),
189
+ # else the translated default.
190
+ attrs["aria-label"] = t("poetry.input_otp.label") unless named_by_caller?
183
191
  attrs["value"] = display_value if display_value.present?
184
192
  attrs["disabled"] = true if disabled
185
193
  attrs["aria-required"] = true if required
@@ -20,10 +20,11 @@ module Poetry
20
20
  # (a scripting: none media rule), so the region stays readable.
21
21
  #
22
22
  # The content element is the Turbo Stream append target (stable
23
- # dom id "<id>-messages"); rows are poetry_message_scroller_item
24
- # wrappers keyed by message id. History prepends keep the reading
25
- # position; a row rendered with anchor: true becomes the held
26
- # reading line.
23
+ # dom id "<id>-messages", or content_id:); rows are
24
+ # poetry_message_scroller_item wrappers keyed by message id (dom_id:
25
+ # gives a row its own DOM id for Streams). History prepends keep
26
+ # the reading position; a row rendered with anchor: true becomes the
27
+ # held reading line.
27
28
  #
28
29
  # @example A chat transcript
29
30
  # render Poetry::Ui::MessageScroller::Component.new(id: "chat") do
@@ -36,8 +37,10 @@ module Poetry
36
37
  # Projected into the registry, llms.txt, and the agent surface.
37
38
  AGENT_RULES = [
38
39
  "Stream by UPDATING a row's text (morph/replace) - appending nodes per token re-announces the row to AT.",
39
- "Rows are poetry_message_scroller_item(id: message.id) - the id is how anchoring and Streams find them.",
40
- "Append new turns with a Turbo Stream targeting the content element's dom id.",
40
+ "Rows are poetry_message_scroller_item(id: message.id) - the id is how anchoring and Streams find " \
41
+ "them; dom_id: gives the row a DOM id (dom_id(message)) for replace/morph streams.",
42
+ "Append new turns with a Turbo Stream targeting the content element's dom id - content_id:, " \
43
+ "\"<id>-messages\" by default.",
41
44
  "History loads PREPEND into the content element - the controller preserves the reading position.",
42
45
  "Never nest a second scroll container inside the viewport."
43
46
  ].freeze
@@ -73,7 +76,10 @@ module Poetry
73
76
 
74
77
  option :id, :string, required: true,
75
78
  doc: "The transcript's stable identifier - the content element renders dom id " \
76
- "\"<id>-messages\" for Turbo Streams to target."
79
+ "\"<id>-messages\" for Turbo Streams to target (content_id: overrides it)."
80
+ option :content_id, :string, default: -> { "#{id}-messages" },
81
+ doc: "The content element's dom id - the Turbo Stream append target; " \
82
+ "defaults to \"<id>-messages\"."
77
83
  option :auto_scroll, :boolean, default: true,
78
84
  doc: "Follows the newest message while the reader sits at the bottom; " \
79
85
  "scrolling up releases the follow."
@@ -155,7 +161,7 @@ module Poetry
155
161
  # @api private
156
162
  def content_attributes
157
163
  {
158
- "id" => "#{id}-messages", "class" => css(:content),
164
+ "id" => content_id, "class" => css(:content),
159
165
  "data-slot" => "message-scroller-content",
160
166
  "role" => "log", "aria-relevant" => "additions"
161
167
  }.merge(stimulus_attributes_for(:content))
@@ -23,7 +23,8 @@ module Poetry
23
23
  # Projected into the registry, llms.txt, and the agent surface.
24
24
  AGENT_RULES = [
25
25
  "This is a REAL <select> - use it for plain picking; the JS Select is for styled options.",
26
- "Pair it with a Label (for_id: its id) or a Field - a bare select has no accessible name.",
26
+ "Pair it with a Label (for_id: its id) or a Field - a bare select has no accessible name; id: and " \
27
+ "every aria-* attribute land on the <select> itself (a Field's control_attributes splat straight in).",
27
28
  "The fast path is options: [[label, value], ...] + selected:; a content block overrides it."
28
29
  ].freeze
29
30
 
@@ -36,8 +37,8 @@ module Poetry
36
37
  option :disabled, :boolean, default: false, doc: "Disables the native select; the wrapper dims the whole pair."
37
38
  option :invalid, :boolean, default: false, doc: "Marks the select invalid (aria-invalid on the element itself)."
38
39
  option :described_by, :string,
39
- doc: "Space-separated hint/error ids wired to the SELECT itself - a raw aria-describedby in " \
40
- "html_attributes would land on the wrapper div, unassociated for assistive technology."
40
+ doc: "Space-separated hint/error ids wired to the SELECT itself - the older spelling of a raw " \
41
+ "aria-describedby, which lands on the select too (every aria-* attribute does)."
41
42
 
42
43
  validates :size, inclusion: { in: SIZES }
43
44
 
@@ -77,10 +78,12 @@ module Poetry
77
78
  )
78
79
  end
79
80
 
80
- # The select's attributes: name, id, label, described-by, disabled and invalid state.
81
+ # The select's attributes: name, id, label, described-by, disabled
82
+ # and invalid state, over the caller's aria-* (pulled off the root
83
+ # in #initialize).
81
84
  # @api private
82
85
  def select_attributes
83
- attrs = { "data-slot" => "native-select", "data-size" => size, "class" => css(:select) }
86
+ attrs = { "data-slot" => "native-select", "data-size" => size, "class" => css(:select) }.merge(@select_aria)
84
87
  attrs["name"] = name if name.present?
85
88
  attrs["id"] = id if id.present?
86
89
  attrs["aria-label"] = label if label.present?
@@ -92,11 +95,17 @@ module Poetry
92
95
 
93
96
  private
94
97
 
95
- # options: pairs are structural data, not a typed option.
98
+ # options: pairs are structural data, not a typed option. The
99
+ # aria-* attributes (flat, or nested aria: {}) come off the root for
100
+ # the select - the Select trigger pattern, so a Field's
101
+ # control_attributes name the real control.
96
102
  def initialize(options: nil, selected: nil, **)
97
103
  super(**)
98
104
  @options = options
99
105
  @selected = selected
106
+ @select_aria = @html_attributes.keys.grep(/\Aaria-/).to_h { |key| [key, @html_attributes.delete(key)] }
107
+ nested = @html_attributes.delete("aria")
108
+ nested.each { |key, value| @select_aria["aria-#{key}"] = value } if nested.is_a?(Hash)
100
109
  end
101
110
 
102
111
  # The select element with its content.
@@ -9,6 +9,6 @@
9
9
  </div>
10
10
  <% end %>
11
11
  <% nav.with_link("Pricing", href: "#") %>
12
- <% nav.with_link("Docs", href: "#") %>
12
+ <% nav.with_link("Docs", href: "#", active: true) %>
13
13
  <% end %>
14
14
  </div>
@@ -10,7 +10,7 @@
10
10
  <% end %>
11
11
  <%= content_tag(:div, capture(&entry.panel), panel_attributes(entry)) %>
12
12
  <% else %>
13
- <a href="<%= entry.href %>" data-slot="navigation-menu-link" class="<%= css(:trigger) %>"><%= entry.title %></a>
13
+ <a href="<%= entry.href %>" data-slot="navigation-menu-link" class="<%= css(:trigger) %> data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted" <%= tag.attributes(active_attributes(entry)) %>><%= entry.title %></a>
14
14
  <% end %>
15
15
  <% end %>
16
16
  <% end %>