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.
- checksums.yaml +4 -4
- data/README.md +143 -75
- data/lib/velvetkit/version.rb +1 -1
- data/registry/app/components/ui/checkbox.html.erb +2 -0
- data/registry/app/components/ui/checkbox.rb +11 -5
- data/registry/app/components/ui/collapsible.html.erb +7 -0
- data/registry/app/components/ui/collapsible.rb +67 -0
- data/registry/app/components/ui/date_picker.html.erb +4 -1
- data/registry/app/components/ui/date_picker.rb +10 -2
- data/registry/app/components/ui/form_field/messages.html.erb +6 -0
- data/registry/app/components/ui/form_field/messages.rb +11 -0
- data/registry/app/components/ui/form_field.html.erb +9 -0
- data/registry/app/components/ui/form_field.rb +105 -0
- data/registry/app/components/ui/input.html.erb +1 -8
- data/registry/app/components/ui/input.rb +12 -36
- data/registry/app/components/ui/input_group/addon.rb +4 -1
- data/registry/app/components/ui/input_group/field.rb +4 -1
- data/registry/app/components/ui/input_group.rb +12 -10
- data/registry/app/components/ui/native_select.html.erb +2 -9
- data/registry/app/components/ui/native_select.rb +17 -31
- data/registry/app/components/ui/password_input.html.erb +1 -8
- data/registry/app/components/ui/password_input.rb +9 -27
- data/registry/app/components/ui/select.html.erb +4 -0
- data/registry/app/components/ui/select.rb +12 -29
- data/registry/app/components/ui/table_of_contents/item.html.erb +8 -0
- data/registry/app/components/ui/table_of_contents/item.rb +41 -0
- data/registry/app/components/ui/table_of_contents.html.erb +13 -0
- data/registry/app/components/ui/table_of_contents.rb +58 -0
- data/registry/app/components/ui/textarea.html.erb +2 -0
- data/registry/app/components/ui/textarea.rb +101 -0
- data/registry/app/javascript/controllers/ui/table_of_contents_controller.ts +179 -0
- data/registry/components.yml +9 -1
- data/registry/config/locales/velvetkit.en.yml +2 -0
- data/registry/test/components/previews/ui/collapsible_preview/default.html.erb +18 -0
- data/registry/test/components/previews/ui/collapsible_preview/show_more.html.erb +7 -0
- data/registry/test/components/previews/ui/collapsible_preview.rb +19 -0
- data/registry/test/components/previews/ui/form_field_preview/custom_control.html.erb +6 -0
- data/registry/test/components/previews/ui/form_field_preview/default.html.erb +7 -0
- data/registry/test/components/previews/ui/form_field_preview/model_errors.html.erb +11 -0
- data/registry/test/components/previews/ui/form_field_preview.rb +12 -0
- data/registry/test/components/previews/ui/input_preview/hint_and_error.html.erb +4 -0
- data/registry/test/components/previews/ui/input_preview.rb +3 -0
- data/registry/test/components/previews/ui/sidebar_preview/_brand.html.erb +4 -4
- data/registry/test/components/previews/ui/sidebar_preview/with_dropdown_header.html.erb +1 -1
- data/registry/test/components/previews/ui/table_of_contents_preview/default.html.erb +41 -0
- data/registry/test/components/previews/ui/table_of_contents_preview/flat.html.erb +17 -0
- data/registry/test/components/previews/ui/table_of_contents_preview.rb +9 -0
- data/registry/test/components/previews/ui/textarea_preview/autogrow.html.erb +4 -0
- data/registry/test/components/previews/ui/textarea_preview/default.html.erb +3 -0
- data/registry/test/components/previews/ui/textarea_preview/sizes.html.erb +5 -0
- data/registry/test/components/previews/ui/textarea_preview/states.html.erb +7 -0
- data/registry/test/components/previews/ui/textarea_preview.rb +33 -0
- data/registry/test/components/ui/checkbox_test.rb +24 -0
- data/registry/test/components/ui/collapsible_test.rb +71 -0
- data/registry/test/components/ui/date_picker_test.rb +14 -0
- data/registry/test/components/ui/form_field/messages_test.rb +19 -0
- data/registry/test/components/ui/form_field_test.rb +122 -0
- data/registry/test/components/ui/input_group_test.rb +30 -2
- data/registry/test/components/ui/input_test.rb +37 -0
- data/registry/test/components/ui/native_select_test.rb +25 -0
- data/registry/test/components/ui/password_input_test.rb +9 -0
- data/registry/test/components/ui/select_test.rb +14 -0
- data/registry/test/components/ui/table_of_contents/item_test.rb +38 -0
- data/registry/test/components/ui/table_of_contents_test.rb +120 -0
- data/registry/test/components/ui/textarea_test.rb +178 -0
- 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
|
-
|
|
78
|
-
|
|
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
|