velvetkit 0.1.0.beta → 0.1.0

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +143 -75
  3. data/lib/velvetkit/version.rb +1 -1
  4. data/registry/app/components/ui/checkbox.html.erb +2 -0
  5. data/registry/app/components/ui/checkbox.rb +11 -5
  6. data/registry/app/components/ui/collapsible.html.erb +7 -0
  7. data/registry/app/components/ui/collapsible.rb +67 -0
  8. data/registry/app/components/ui/date_picker.html.erb +4 -1
  9. data/registry/app/components/ui/date_picker.rb +10 -2
  10. data/registry/app/components/ui/form_field/messages.html.erb +6 -0
  11. data/registry/app/components/ui/form_field/messages.rb +11 -0
  12. data/registry/app/components/ui/form_field.html.erb +9 -0
  13. data/registry/app/components/ui/form_field.rb +105 -0
  14. data/registry/app/components/ui/input.html.erb +1 -8
  15. data/registry/app/components/ui/input.rb +12 -36
  16. data/registry/app/components/ui/input_group/addon.rb +4 -1
  17. data/registry/app/components/ui/input_group/field.rb +4 -1
  18. data/registry/app/components/ui/input_group.rb +12 -10
  19. data/registry/app/components/ui/native_select.html.erb +2 -9
  20. data/registry/app/components/ui/native_select.rb +17 -31
  21. data/registry/app/components/ui/password_input.html.erb +1 -8
  22. data/registry/app/components/ui/password_input.rb +9 -27
  23. data/registry/app/components/ui/select.html.erb +4 -0
  24. data/registry/app/components/ui/select.rb +12 -29
  25. data/registry/app/components/ui/table_of_contents/item.html.erb +8 -0
  26. data/registry/app/components/ui/table_of_contents/item.rb +41 -0
  27. data/registry/app/components/ui/table_of_contents.html.erb +13 -0
  28. data/registry/app/components/ui/table_of_contents.rb +58 -0
  29. data/registry/app/components/ui/textarea.html.erb +2 -0
  30. data/registry/app/components/ui/textarea.rb +101 -0
  31. data/registry/app/javascript/controllers/ui/table_of_contents_controller.ts +179 -0
  32. data/registry/components.yml +9 -1
  33. data/registry/config/locales/velvetkit.en.yml +2 -0
  34. data/registry/test/components/previews/ui/collapsible_preview/default.html.erb +18 -0
  35. data/registry/test/components/previews/ui/collapsible_preview/show_more.html.erb +7 -0
  36. data/registry/test/components/previews/ui/collapsible_preview.rb +19 -0
  37. data/registry/test/components/previews/ui/form_field_preview/custom_control.html.erb +6 -0
  38. data/registry/test/components/previews/ui/form_field_preview/default.html.erb +7 -0
  39. data/registry/test/components/previews/ui/form_field_preview/model_errors.html.erb +11 -0
  40. data/registry/test/components/previews/ui/form_field_preview.rb +12 -0
  41. data/registry/test/components/previews/ui/input_preview/hint_and_error.html.erb +4 -0
  42. data/registry/test/components/previews/ui/input_preview.rb +3 -0
  43. data/registry/test/components/previews/ui/sidebar_preview/_brand.html.erb +4 -4
  44. data/registry/test/components/previews/ui/sidebar_preview/with_dropdown_header.html.erb +1 -1
  45. data/registry/test/components/previews/ui/table_of_contents_preview/default.html.erb +41 -0
  46. data/registry/test/components/previews/ui/table_of_contents_preview/flat.html.erb +17 -0
  47. data/registry/test/components/previews/ui/table_of_contents_preview.rb +9 -0
  48. data/registry/test/components/previews/ui/textarea_preview/autogrow.html.erb +4 -0
  49. data/registry/test/components/previews/ui/textarea_preview/default.html.erb +3 -0
  50. data/registry/test/components/previews/ui/textarea_preview/sizes.html.erb +5 -0
  51. data/registry/test/components/previews/ui/textarea_preview/states.html.erb +7 -0
  52. data/registry/test/components/previews/ui/textarea_preview.rb +33 -0
  53. data/registry/test/components/ui/checkbox_test.rb +24 -0
  54. data/registry/test/components/ui/collapsible_test.rb +71 -0
  55. data/registry/test/components/ui/date_picker_test.rb +14 -0
  56. data/registry/test/components/ui/form_field/messages_test.rb +19 -0
  57. data/registry/test/components/ui/form_field_test.rb +122 -0
  58. data/registry/test/components/ui/input_group_test.rb +30 -2
  59. data/registry/test/components/ui/input_test.rb +37 -0
  60. data/registry/test/components/ui/native_select_test.rb +25 -0
  61. data/registry/test/components/ui/password_input_test.rb +9 -0
  62. data/registry/test/components/ui/select_test.rb +14 -0
  63. data/registry/test/components/ui/table_of_contents/item_test.rb +38 -0
  64. data/registry/test/components/ui/table_of_contents_test.rb +120 -0
  65. data/registry/test/components/ui/textarea_test.rb +178 -0
  66. metadata +39 -4
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class UI::FormFieldTest < ViewComponent::TestCase
6
+ def setup_fixtures(*); end
7
+ def teardown_fixtures(*); end
8
+
9
+ class Account
10
+ include ActiveModel::Model
11
+ attr_accessor :email, :name
12
+ validates :email, presence: true
13
+ validates :email, format: { with: /@/ }, allow_blank: true
14
+ end
15
+
16
+ def form_builder_for(record)
17
+ ActionView::Helpers::FormBuilder.new(:account, record, vc_test_controller.view_context, {})
18
+ end
19
+
20
+ def invalid_account
21
+ Account.new.tap(&:validate)
22
+ end
23
+
24
+ # ── Wrapping ─────────────────────────────────────────────────────────────────
25
+
26
+ def test_nothing_to_show_renders_the_control_alone
27
+ render_inline(UI::FormField.new) { "<input id='x'>".html_safe }
28
+ assert_selector "input#x"
29
+ refute_selector "div"
30
+ end
31
+
32
+ def test_label_hint_and_error_wrap_the_control
33
+ render_inline(UI::FormField.new(label: "Colour", hint: "For your avatar", error: "Pick a colour", for: "colour")) do
34
+ "<input id='colour'>".html_safe
35
+ end
36
+ assert_selector "div > label[for='colour'] + input#colour + p#colour-hint + p#colour-error"
37
+ assert_selector "p#colour-hint.text-muted-foreground", text: "For your avatar"
38
+ assert_selector "p#colour-error.text-destructive", text: "Pick a colour"
39
+ end
40
+
41
+ def test_block_receives_the_field_for_custom_controls
42
+ render_inline(UI::FormField.new(label: "Colour", hint: "For your avatar", error: "Pick a colour")) do |field|
43
+ ActionController::Base.helpers.tag.input(type: "color", **field.control_attributes)
44
+ end
45
+ input = page.find("input[type='color']")
46
+ assert_equal page.find("label")[:for], input[:id]
47
+ assert input[:id].start_with?("ui-field-")
48
+ assert_equal "#{input[:id]}-hint #{input[:id]}-error", input["aria-describedby"]
49
+ assert_equal "true", input["aria-invalid"]
50
+ end
51
+
52
+ def test_user_class_and_attrs_on_the_wrapper
53
+ render_inline(UI::FormField.new(label: "Name", class: "max-w-sm", data: { test_id: "f" })) { "x" }
54
+ wrapper = page.find("div[data-test-id='f']")
55
+ assert_includes wrapper[:class].split, "max-w-sm"
56
+ assert_includes wrapper[:class].split, "gap-2"
57
+ end
58
+
59
+ # ── Error states ─────────────────────────────────────────────────────────────
60
+
61
+ def test_error_true_is_invalid_without_a_message
62
+ field = UI::FormField.new(error: true)
63
+ assert field.error?
64
+ assert_nil field.error_message
65
+ refute field.wrap?
66
+ end
67
+
68
+ def test_no_hint_or_error_no_describedby
69
+ field = UI::FormField.new(label: "Name")
70
+ assert_nil field.describedby
71
+ assert_equal({ id: field.control_id }, field.control_attributes)
72
+ end
73
+
74
+ # ── Form binding ─────────────────────────────────────────────────────────────
75
+
76
+ def test_model_errors_become_the_message
77
+ field = UI::FormField.new(form: form_builder_for(invalid_account), field: :email)
78
+ assert field.error?
79
+ assert_equal "Email can't be blank", field.error_message
80
+ end
81
+
82
+ def test_several_model_errors_join_into_one_sentence
83
+ account = Account.new(email: "nope")
84
+ account.errors.add(:email, :taken)
85
+ account.validate
86
+ account.errors.add(:email, "is reserved")
87
+ field = UI::FormField.new(form: form_builder_for(account), field: :email)
88
+ assert_equal "Email is invalid and Email is reserved", field.error_message
89
+ end
90
+
91
+ def test_a_foreign_key_field_shows_its_association_errors
92
+ account = Account.new
93
+ account.errors.add(:team, "must exist")
94
+ field = UI::FormField.new(form: form_builder_for(account), field: :team_id)
95
+ assert_equal "Team must exist", field.error_message
96
+ end
97
+
98
+ def test_no_model_errors_no_message
99
+ field = UI::FormField.new(form: form_builder_for(Account.new(email: "a@b.c")), field: :email)
100
+ refute field.error?
101
+ end
102
+
103
+ def test_explicit_error_wins_over_model_errors
104
+ field = UI::FormField.new(form: form_builder_for(invalid_account), field: :email, error: "Use your work email")
105
+ assert_equal "Use your work email", field.error_message
106
+ end
107
+
108
+ def test_error_false_ignores_model_errors
109
+ field = UI::FormField.new(form: form_builder_for(invalid_account), field: :email, error: false)
110
+ refute field.error?
111
+ end
112
+
113
+ def test_form_without_a_model_has_no_errors
114
+ builder = ActionView::Helpers::FormBuilder.new(:search, nil, vc_test_controller.view_context, {})
115
+ refute UI::FormField.new(form: builder, field: :q).error?
116
+ end
117
+
118
+ def test_label_comes_from_the_attribute
119
+ render_inline(UI::FormField.new(form: form_builder_for(Account.new), field: :name)) { "x" }
120
+ assert_selector "label", text: "Name"
121
+ end
122
+ end
@@ -74,8 +74,17 @@ class UI::InputGroupTest < ViewComponent::TestCase
74
74
  render_inline(UI::InputGroup.new) do |g|
75
75
  g.with_field(error: true, placeholder: "x")
76
76
  end
77
- wrapper = page.find("div[data-slot='input-group']")
78
- assert_includes wrapper[:class], "border-destructive"
77
+ classes = page.find("div[data-slot='input-group']")[:class].split
78
+ # Driven by the field's aria-invalid, with a selector browsers accept:
79
+ # a :has() nested in another :has() is invalid and the rule is dropped.
80
+ assert_includes classes, "has-[[data-slot][aria-invalid=true]:enabled]:border-destructive"
81
+ assert classes.none? { |c| c.scan(":has(").size > 0 && c.start_with?("has-[") }, "nested :has() in #{classes.grep(/has\(/)}"
82
+ end
83
+
84
+ def test_field_never_renders_messages_inside_the_group
85
+ render_inline(UI::InputGroup.new) { |g| g.with_field(error: "Taken", hint: "Pick another") }
86
+ assert_selector "input[aria-invalid='true']"
87
+ refute_selector "p"
79
88
  end
80
89
 
81
90
  def test_field_disabled_sets_disabled_on_input
@@ -140,4 +149,23 @@ class UI::InputGroupTest < ViewComponent::TestCase
140
149
  end
141
150
  assert_selector "div#my-group[data-slot='input-group']"
142
151
  end
152
+
153
+ def test_ring_follows_the_control_not_any_focused_addon
154
+ render_inline(UI::InputGroup.new) { |g| g.with_field(placeholder: "x") }
155
+ classes = page.find("div[data-slot='input-group']")[:class].split
156
+ assert_includes classes, "has-[[data-slot]:focus]:ring-2"
157
+ refute classes.any? { |c| c.start_with?("focus-within:") }
158
+ end
159
+
160
+ def test_addon_button_rings_on_keyboard_focus_only
161
+ render_inline(UI::InputGroup.new) do |g|
162
+ g.with_field(placeholder: "x")
163
+ g.with_addon(align: "inline-end") { |a| a.with_icon_button(aria: { label: "Show" }) { "o" } }
164
+ end
165
+ classes = page.find("button[aria-label='Show']")[:class].split
166
+ assert_includes classes, "focus:ring-0"
167
+ assert_includes classes, "focus:ring-offset-0"
168
+ assert_includes classes, "focus-visible:ring-2"
169
+ refute_includes classes, "focus:ring-2"
170
+ end
143
171
  end
@@ -248,4 +248,41 @@ class UI::InputTest < ViewComponent::TestCase
248
248
  render_inline(UI::Input.new(name: "user[email]"))
249
249
  assert_no_selector "label"
250
250
  end
251
+
252
+ # ── Hint and error message ───────────────────────────────────────────────────
253
+
254
+ def test_hint_describes_the_input
255
+ render_inline(UI::Input.new(hint: "We never share it"))
256
+ input = page.find("input")
257
+ assert input[:id].start_with?("ui-input-")
258
+ assert_selector "div > input + p##{input[:id]}-hint", text: "We never share it"
259
+ assert_equal "#{input[:id]}-hint", input["aria-describedby"]
260
+ end
261
+
262
+ def test_error_message_marks_invalid_and_describes
263
+ render_inline(UI::Input.new(label: "Email", id: "email", error: "Enter an email"))
264
+ input = page.find("input#email[aria-invalid='true']")
265
+ assert_includes input[:class].split, "border-destructive"
266
+ assert_selector "p#email-error.text-destructive", text: "Enter an email"
267
+ assert_equal "email-error", input["aria-describedby"]
268
+ end
269
+
270
+ def test_error_true_styles_without_variant
271
+ render_inline(UI::Input.new(error: true))
272
+ assert_includes page.find("input[aria-invalid='true']")[:class].split, "border-destructive"
273
+ refute_selector "p"
274
+ end
275
+
276
+ class Signup
277
+ include ActiveModel::Model
278
+ attr_accessor :email
279
+ validates :email, presence: true
280
+ end
281
+
282
+ def test_model_errors_show_under_a_bound_input
283
+ signup = Signup.new.tap(&:validate)
284
+ render_inline(UI::Input.new(form: ActionView::Helpers::FormBuilder.new(:signup, signup, vc_test_controller.view_context, {}), field: :email))
285
+ input = page.find("input[name='signup[email]'][aria-invalid='true']")
286
+ assert_selector "p##{input[:id]}-error", text: "Email can't be blank"
287
+ end
251
288
  end
@@ -17,6 +17,20 @@ class UI::NativeSelectTest < ViewComponent::TestCase
17
17
 
18
18
  # Native rather than a custom listbox: it gets the platform picker on a
19
19
  # phone, keyboard support and screen-reader behaviour for free.
20
+ def test_hint_and_error_describe_the_select
21
+ render_inline(UI::NativeSelect.new(options: %w[a b], label: "Team", id: "team", hint: "You can change it later", error: "Choose a team"))
22
+ select = page.find("select#team[aria-invalid='true']")
23
+ assert_equal "team-hint team-error", select["aria-describedby"]
24
+ assert_selector "p#team-hint", text: "You can change it later"
25
+ assert_selector "p#team-error.text-destructive", text: "Choose a team"
26
+ end
27
+
28
+ def test_no_label_or_messages_renders_no_wrapper
29
+ render_inline(UI::NativeSelect.new(options: %w[a b]))
30
+ refute_selector "div.flex-col"
31
+ refute_selector "select[aria-describedby]"
32
+ end
33
+
20
34
  def test_accepts_a_plain_array_of_strings
21
35
  render_inline(UI::NativeSelect.new(name: "board", options: %w[cbse icse]))
22
36
  assert_selector "option[value='cbse']", text: "cbse"
@@ -115,4 +129,15 @@ class UI::NativeSelectTest < ViewComponent::TestCase
115
129
  assert_equal "", page.all("option").first[:value]
116
130
  assert_equal "selected", page.find("option[value='2']")[:selected]
117
131
  end
132
+
133
+ def test_a_bound_select_keeps_the_models_value
134
+ record = Struct.new(:team_id) do
135
+ def to_model = self
136
+ def model_name = ActiveModel::Name.new(self.class, nil, "Member")
137
+ def persisted? = false
138
+ end.new(2)
139
+ form = ActionView::Helpers::FormBuilder.new(:member, record, vc_test_controller.view_context, {})
140
+ render_inline(UI::NativeSelect.new(form: form, field: :team_id, options: [ [ "Design", 1 ], [ "Engineering", 2 ] ], prompt: "Choose"))
141
+ assert_selector "option[value='2'][selected]", text: "Engineering"
142
+ end
118
143
  end
@@ -95,4 +95,13 @@ class UI::PasswordInputTest < ViewComponent::TestCase
95
95
  render_inline(UI::PasswordInput.new(form: form_builder_for(User.new), field: :password, label: false))
96
96
  assert_no_selector "label"
97
97
  end
98
+
99
+ def test_hint_and_error_sit_outside_the_group_and_describe_the_input
100
+ render_inline(UI::PasswordInput.new(label: "Password", id: "pw", hint: "At least 12 characters", error: "Too short"))
101
+ input = page.find("input#pw[aria-invalid='true']")
102
+ assert_equal "pw-hint pw-error", input["aria-describedby"]
103
+ assert_selector "p#pw-hint", text: "At least 12 characters"
104
+ assert_selector "p#pw-error", text: "Too short"
105
+ assert_selector "p", count: 2
106
+ end
98
107
  end
@@ -178,4 +178,18 @@ class UI::SelectTest < ViewComponent::TestCase
178
178
  assert_equal "true", root["data-ui--select-multiple-value"]
179
179
  assert_equal "%{count} selected", root["data-ui--select-summary-value"]
180
180
  end
181
+
182
+ def test_hint_and_error_describe_the_trigger
183
+ render_inline(UI::Select.new(options: %w[Free Pro], label: "Plan", hint: "Billed monthly", error: "Choose a plan"))
184
+ trigger = page.find("button[role='combobox'][aria-invalid='true']")
185
+ assert_equal "#{trigger[:id]}-hint #{trigger[:id]}-error", trigger["aria-describedby"]
186
+ assert_selector "p##{trigger[:id]}-hint", text: "Billed monthly"
187
+ assert_selector "p##{trigger[:id]}-error.text-destructive", text: "Choose a plan"
188
+ end
189
+
190
+ def test_no_messages_no_describedby
191
+ render_inline(UI::Select.new(options: %w[Free Pro]))
192
+ refute_selector "button[aria-describedby]"
193
+ refute_selector "p.text-destructive"
194
+ end
181
195
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class UI::TableOfContents::ItemTest < ViewComponent::TestCase
6
+ def setup_fixtures(*); end
7
+ def teardown_fixtures(*); end
8
+
9
+ def test_renders_list_item_with_link
10
+ render_inline(UI::TableOfContents::Item.new(href: "#usage", text: "Usage"))
11
+ assert_selector "li > a[href='#usage']", text: "Usage"
12
+ end
13
+
14
+ def test_user_class_merge
15
+ render_inline(UI::TableOfContents::Item.new(href: "#usage", text: "Usage", class: "italic"))
16
+ classes = page.find("a")[:class].split
17
+ assert_includes classes, "italic"
18
+ assert_includes classes, "rounded-md"
19
+ end
20
+
21
+ def test_html_attrs_and_data_merge_onto_link
22
+ render_inline(UI::TableOfContents::Item.new(href: "#usage", text: "Usage", title: "Usage", data: { turbo: false }))
23
+ link = page.find("a")
24
+ assert_equal "Usage", link[:title]
25
+ assert_equal "false", link["data-turbo"]
26
+ assert_equal "link", link["data-ui--table-of-contents-target"]
27
+ end
28
+
29
+ def test_children_nest_deeper
30
+ render_inline(UI::TableOfContents::Item.new(href: "#a", text: "A")) do |item|
31
+ item.with_item(href: "#b", text: "B") do |child|
32
+ child.with_item(href: "#c", text: "C")
33
+ end
34
+ end
35
+ assert_selector "li > ul > li > ul > li > a[href='#c']"
36
+ assert_includes page.find("a[href='#c']")[:class].split, "py-1"
37
+ end
38
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class UI::TableOfContentsTest < ViewComponent::TestCase
6
+ def setup_fixtures(*); end
7
+ def teardown_fixtures(*); end
8
+
9
+ def render_toc(**opts)
10
+ render_inline(UI::TableOfContents.new(**opts)) do |toc|
11
+ toc.with_item(href: "#process", text: "Contribution process") do |item|
12
+ item.with_item(href: "#issue", text: "Create an issue")
13
+ item.with_item(href: "#merge", text: "Merge your changes")
14
+ end
15
+ toc.with_item(href: "#resources", text: "Resources")
16
+ end
17
+ end
18
+
19
+ # Structure
20
+
21
+ def test_renders_nav_landmark_with_default_label
22
+ render_toc
23
+ assert_selector "nav[aria-label='On this page']"
24
+ end
25
+
26
+ def test_custom_label
27
+ render_toc(label: "Sections")
28
+ assert_selector "nav[aria-label='Sections']"
29
+ end
30
+
31
+ def test_title_labels_the_nav
32
+ render_toc(title: "On this page", id: "toc")
33
+ assert_selector "nav#toc[aria-labelledby='toc-title']:not([aria-label])"
34
+ assert_selector "p#toc-title", text: "On this page"
35
+ end
36
+
37
+ def test_renders_top_level_links
38
+ render_toc
39
+ assert_selector "nav > div > ul > li > a[href='#process']", text: "Contribution process"
40
+ assert_selector "nav > div > ul > li > a[href='#resources']", text: "Resources"
41
+ end
42
+
43
+ def test_nested_items_render_in_a_nested_list
44
+ render_toc
45
+ assert_selector "li > ul > li > a[href='#issue']", text: "Create an issue"
46
+ assert_selector "li > ul > li > a[href='#merge']"
47
+ end
48
+
49
+ def test_nested_links_are_tighter_than_top_level
50
+ render_toc
51
+ assert_includes page.find("a[href='#process']")[:class].split, "py-1.5"
52
+ assert_includes page.find("a[href='#issue']")[:class].split, "py-1"
53
+ end
54
+
55
+ def test_leaf_items_have_no_nested_list
56
+ render_toc
57
+ assert_no_selector "a[href='#resources'] + ul"
58
+ end
59
+
60
+ # Controller wiring
61
+
62
+ def test_root_carries_controller_and_values
63
+ render_toc(offset: 120, container: "#main")
64
+ nav = page.find("nav[data-controller~='ui--table-of-contents']")
65
+ assert_equal "120", nav["data-ui--table-of-contents-offset-value"]
66
+ assert_equal "#main", nav["data-ui--table-of-contents-container-value"]
67
+ end
68
+
69
+ def test_container_value_omitted_by_default
70
+ render_toc
71
+ nav = page.find("nav")
72
+ assert_nil nav["data-ui--table-of-contents-container-value"]
73
+ end
74
+
75
+ def test_links_are_targets_with_click_action
76
+ render_toc
77
+ links = page.all("a[data-ui--table-of-contents-target='link']")
78
+ assert_equal 4, links.size
79
+ links.each do |link|
80
+ assert_equal "click->ui--table-of-contents#select", link["data-action"]
81
+ assert_equal "inactive", link["data-state"]
82
+ end
83
+ end
84
+
85
+ def test_rail_and_indicator_targets
86
+ render_toc
87
+ assert_selector "[data-ui--table-of-contents-target='rail'] > [data-ui--table-of-contents-target='indicator'][aria-hidden='true']", visible: :all
88
+ end
89
+
90
+ def test_no_link_is_current_before_the_controller_runs
91
+ render_toc
92
+ assert_no_selector "a[aria-current]"
93
+ end
94
+
95
+ # Attributes
96
+
97
+ def test_root_has_auto_generated_id
98
+ render_toc
99
+ assert page.find("nav")[:id].start_with?("ui-toc-")
100
+ end
101
+
102
+ def test_user_class_merge
103
+ render_toc(class: "sticky top-20")
104
+ classes = page.find("nav")[:class].split
105
+ assert_includes classes, "sticky"
106
+ assert_includes classes, "text-sm"
107
+ end
108
+
109
+ def test_html_attrs_passthrough_keeps_controller
110
+ render_toc(data: { test_id: "toc" })
111
+ assert_selector "nav[data-test-id='toc'][data-controller~='ui--table-of-contents']"
112
+ end
113
+
114
+ def test_starts_settling_so_the_first_state_does_not_animate
115
+ render_toc
116
+ nav = page.find("nav")
117
+ assert nav.matches_css?("[data-settling]")
118
+ assert_includes nav[:class].split, "data-[settling]:[&_*]:transition-none"
119
+ end
120
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class UI::TextareaTest < ViewComponent::TestCase
6
+ def setup_fixtures(*); end
7
+ def teardown_fixtures(*); end
8
+
9
+ # ── Default render ───────────────────────────────────────────────────────────
10
+
11
+ def test_renders_textarea_tag
12
+ render_inline(UI::Textarea.new)
13
+ assert_selector "textarea"
14
+ refute_selector "div"
15
+ end
16
+
17
+ def test_default_rows
18
+ render_inline(UI::Textarea.new)
19
+ assert_selector "textarea[rows='3']"
20
+ end
21
+
22
+ def test_custom_rows
23
+ render_inline(UI::Textarea.new(rows: 6))
24
+ assert_selector "textarea[rows='6']"
25
+ end
26
+
27
+ def test_default_classes
28
+ render_inline(UI::Textarea.new)
29
+ classes = page.find("textarea")[:class].split
30
+ assert_includes classes, "border-input"
31
+ assert_includes classes, "text-sm"
32
+ assert_includes classes, "resize-y"
33
+ end
34
+
35
+ # ── Sizes and resizing ───────────────────────────────────────────────────────
36
+
37
+ def test_size_sm
38
+ render_inline(UI::Textarea.new(size: :sm))
39
+ assert_includes page.find("textarea")[:class].split, "text-xs"
40
+ end
41
+
42
+ def test_size_lg
43
+ render_inline(UI::Textarea.new(size: :lg))
44
+ assert_includes page.find("textarea")[:class].split, "text-base"
45
+ end
46
+
47
+ def test_resize_none
48
+ render_inline(UI::Textarea.new(resize: :none))
49
+ classes = page.find("textarea")[:class].split
50
+ assert_includes classes, "resize-none"
51
+ refute_includes classes, "resize-y"
52
+ end
53
+
54
+ def test_autogrow_sizes_to_content_and_cannot_be_dragged
55
+ render_inline(UI::Textarea.new(autogrow: true))
56
+ classes = page.find("textarea")[:class].split
57
+ assert_includes classes, "field-sizing-content"
58
+ assert_includes classes, "resize-none"
59
+ refute_includes classes, "resize-y"
60
+ end
61
+
62
+ # ── Value ────────────────────────────────────────────────────────────────────
63
+
64
+ def test_value_renders_as_content
65
+ render_inline(UI::Textarea.new(value: "Hello\nworld"))
66
+ assert_equal "Hello\nworld", page.find("textarea").value
67
+ end
68
+
69
+ def test_value_is_escaped
70
+ render_inline(UI::Textarea.new(value: "</textarea><script>x</script>"))
71
+ assert_selector "textarea", count: 1
72
+ refute_selector "script"
73
+ end
74
+
75
+ # ── States ───────────────────────────────────────────────────────────────────
76
+
77
+ def test_error_sets_aria_invalid_and_destructive_border
78
+ render_inline(UI::Textarea.new(error: true))
79
+ textarea = page.find("textarea[aria-invalid='true']")
80
+ assert_includes textarea[:class].split, "border-destructive"
81
+ end
82
+
83
+ def test_no_error_no_aria_invalid
84
+ render_inline(UI::Textarea.new)
85
+ refute_selector "textarea[aria-invalid]"
86
+ end
87
+
88
+ def test_disabled
89
+ render_inline(UI::Textarea.new(disabled: true))
90
+ assert_selector "textarea[disabled]"
91
+ end
92
+
93
+ def test_required
94
+ render_inline(UI::Textarea.new(required: true))
95
+ assert_selector "textarea[required]"
96
+ end
97
+
98
+ # ── Attributes ───────────────────────────────────────────────────────────────
99
+
100
+ def test_html_attrs_pass_through
101
+ render_inline(UI::Textarea.new(name: "post[body]", id: "body", placeholder: "Write…", maxlength: 500))
102
+ assert_selector "textarea#body[name='post[body]'][placeholder='Write…'][maxlength='500']"
103
+ end
104
+
105
+ def test_user_class_merges_via_tailwind_merge
106
+ render_inline(UI::Textarea.new(class: "bg-muted max-h-60"))
107
+ classes = page.find("textarea")[:class].split
108
+ refute_includes classes, "bg-background"
109
+ assert_includes classes, "bg-muted"
110
+ assert_includes classes, "max-h-60"
111
+ end
112
+
113
+ # ── Label ────────────────────────────────────────────────────────────────────
114
+
115
+ def test_label_is_a_linked_sibling
116
+ render_inline(UI::Textarea.new(label: "Message"))
117
+ assert_selector "div > label + textarea"
118
+ assert_equal page.find("textarea")[:id], page.find("label")[:for]
119
+ assert page.find("textarea")[:id].start_with?("ui-textarea-")
120
+ end
121
+
122
+ def test_explicit_id_is_linked
123
+ render_inline(UI::Textarea.new(label: "Message", id: "msg"))
124
+ assert_selector "label[for='msg'] + textarea#msg"
125
+ end
126
+
127
+ def test_required_label_shows_asterisk
128
+ render_inline(UI::Textarea.new(label: "Message", required: true))
129
+ assert_selector "label span.text-destructive", text: "*"
130
+ end
131
+
132
+ # ── Form builder ─────────────────────────────────────────────────────────────
133
+
134
+ class Post
135
+ include ActiveModel::Model
136
+ attr_accessor :body
137
+ end
138
+
139
+ def form_builder_for(record)
140
+ ActionView::Helpers::FormBuilder.new(:post, record, vc_test_controller.view_context, {})
141
+ end
142
+
143
+ def test_form_binding_scopes_name_and_fills_value
144
+ render_inline(UI::Textarea.new(form: form_builder_for(Post.new(body: "Draft")), field: :body))
145
+ textarea = page.find("textarea[name='post[body]']")
146
+ assert_equal "Draft", textarea.value
147
+ assert_includes textarea[:class].split, "border-input"
148
+ end
149
+
150
+ def test_form_binding_derives_linked_label
151
+ render_inline(UI::Textarea.new(form: form_builder_for(Post.new), field: :body))
152
+ assert_selector "label", text: "Body"
153
+ assert_equal page.find("label")[:for], page.find("textarea")[:id]
154
+ end
155
+
156
+ def test_label_false_opts_out
157
+ render_inline(UI::Textarea.new(form: form_builder_for(Post.new), field: :body, label: false))
158
+ assert_no_selector "label"
159
+ end
160
+
161
+ # ── Hint and error message ───────────────────────────────────────────────────
162
+
163
+ def test_hint_and_error_describe_the_textarea
164
+ render_inline(UI::Textarea.new(label: "Bio", id: "bio", hint: "Markdown works", error: "Too long"))
165
+ textarea = page.find("textarea#bio[aria-invalid='true']")
166
+ assert_equal "bio-hint bio-error", textarea["aria-describedby"]
167
+ assert_includes textarea[:class].split, "border-destructive"
168
+ assert_selector "textarea + p#bio-hint + p#bio-error"
169
+ end
170
+
171
+ def test_model_errors_show_under_a_bound_textarea
172
+ post = Post.new
173
+ post.errors.add(:body, :blank)
174
+ render_inline(UI::Textarea.new(form: form_builder_for(post), field: :body))
175
+ assert_selector "textarea[aria-invalid='true']"
176
+ assert_selector "p.text-destructive", text: "Body can't be blank"
177
+ end
178
+ end