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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/app/components/poetry/ui/alert_dialog/component.rb +14 -2
- data/app/components/poetry/ui/autocomplete/component.rb +35 -14
- data/app/components/poetry/ui/avatar/component.rb +12 -16
- data/app/components/poetry/ui/button/component.rb +9 -5
- data/app/components/poetry/ui/card/component.html.erb +1 -1
- data/app/components/poetry/ui/card/component.rb +2 -1
- data/app/components/poetry/ui/collapsible/component.rb +16 -9
- data/app/components/poetry/ui/collapsible/preview.rb +2 -2
- data/app/components/poetry/ui/field_separator/component.rb +9 -7
- data/app/components/poetry/ui/input_otp/component.rb +12 -4
- data/app/components/poetry/ui/message_scroller/component.rb +14 -8
- data/app/components/poetry/ui/native_select/component.rb +15 -6
- data/app/components/poetry/ui/navigation_menu/bar_preview.html.erb +1 -1
- data/app/components/poetry/ui/navigation_menu/component.html.erb +1 -1
- data/app/components/poetry/ui/navigation_menu/component.rb +26 -11
- data/app/helpers/poetry/ui/components_helper.rb +27 -6
- data/app/helpers/poetry/ui/form_builder/type_inference.rb +17 -1
- data/app/helpers/poetry/ui/form_builder.rb +202 -80
- data/config/component_registry.yml +90 -46
- data/config/template_classes.txt +3 -0
- data/lib/generators/poetry/block/templates/destructive_panel.html.erb +13 -3
- data/lib/generators/poetry/block/templates/top_nav.html.erb +2 -1
- data/lib/poetry/ui/testing/checkbox.rb +114 -0
- data/lib/poetry/ui/testing.rb +12 -0
- data/lib/poetry/ui/version.rb +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27935417c8f6663b28bc05dc0d7dabbd326996ff8fd7e36437a069114c1bf7a3
|
|
4
|
+
data.tar.gz: 4249407b7b6e91b02a38846b8927e6a50cc13897a15626f55312974bc54915f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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: "
|
|
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
|
|
136
|
+
# The input's id: the given id, else a stable instance id.
|
|
129
137
|
# @api private
|
|
130
|
-
def
|
|
131
|
-
@
|
|
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
|
|
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 = "#{
|
|
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" => "#{
|
|
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
|
|
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
|
|
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,
|
|
35
|
-
|
|
36
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
143
|
-
#
|
|
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
|
|
152
|
+
# The anchor's attributes: the href or the disabled state.
|
|
149
153
|
def link_attributes
|
|
150
|
-
attrs = {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
36
|
-
"
|
|
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
|
-
|
|
39
|
-
|
|
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(
|
|
43
|
-
|
|
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(
|
|
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(
|
|
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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
"
|
|
19
|
-
"(
|
|
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)
|
|
181
|
-
# aria-
|
|
182
|
-
|
|
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
|
|
24
|
-
# wrappers keyed by message id
|
|
25
|
-
#
|
|
26
|
-
# reading
|
|
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
|
|
40
|
-
"
|
|
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" =>
|
|
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
|
|
40
|
-
"
|
|
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
|
|
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.
|
|
@@ -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) %>"
|
|
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 %>
|