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
|
@@ -42,7 +42,7 @@ class UI::Input < UI::Base
|
|
|
42
42
|
|
|
43
43
|
def initialize(type: :text, size: :default, variant: :default,
|
|
44
44
|
label: nil, label_size: :default, label_weight: :medium,
|
|
45
|
-
required: false, error:
|
|
45
|
+
hint: nil, required: false, error: nil, disabled: false,
|
|
46
46
|
form: nil, field: nil,
|
|
47
47
|
class: nil, **html_attrs)
|
|
48
48
|
@form = form
|
|
@@ -58,13 +58,20 @@ class UI::Input < UI::Base
|
|
|
58
58
|
@disabled = disabled
|
|
59
59
|
@user_class = binding.local_variable_get(:class)
|
|
60
60
|
@html_attrs = html_attrs
|
|
61
|
-
@
|
|
61
|
+
@wrapper = UI::FormField.new(label: label, hint: hint, error: error,
|
|
62
|
+
required: required, disabled: disabled,
|
|
63
|
+
label_size: label_size, label_weight: label_weight,
|
|
64
|
+
form: form, field: field)
|
|
65
|
+
@input_id = @html_attrs[:id] || (@wrapper.wrap? ? "ui-input-#{SecureRandom.hex(4)}" : nil)
|
|
62
66
|
@html_attrs[:id] = @input_id if @input_id
|
|
67
|
+
@wrapper.for = @input_id
|
|
63
68
|
end
|
|
64
69
|
|
|
70
|
+
attr_reader :wrapper
|
|
71
|
+
|
|
65
72
|
def classes
|
|
66
73
|
cn(
|
|
67
|
-
variant_classes(size: @size, variant: @variant),
|
|
74
|
+
variant_classes(size: @size, variant: wrapper.error? ? :error : @variant),
|
|
68
75
|
@user_class
|
|
69
76
|
)
|
|
70
77
|
end
|
|
@@ -76,7 +83,8 @@ class UI::Input < UI::Base
|
|
|
76
83
|
opts[:class] = classes
|
|
77
84
|
opts[:disabled] = true if @disabled
|
|
78
85
|
opts[:required] = true if @required
|
|
79
|
-
opts[:"aria-invalid"] = "true" if
|
|
86
|
+
opts[:"aria-invalid"] = "true" if wrapper.error?
|
|
87
|
+
opts[:"aria-describedby"] = wrapper.describedby if wrapper.describedby
|
|
80
88
|
opts
|
|
81
89
|
end
|
|
82
90
|
|
|
@@ -92,36 +100,4 @@ class UI::Input < UI::Base
|
|
|
92
100
|
def form_builder_method
|
|
93
101
|
BUILDER_METHODS.fetch(@type.to_sym, :text_field)
|
|
94
102
|
end
|
|
95
|
-
|
|
96
|
-
# Labels follow Rails' own convention: bound to a form object, the attribute
|
|
97
|
-
# name supplies the text (via human_attribute_name, so i18n and
|
|
98
|
-
# `activerecord.attributes.*` overrides apply) with no per-call-site string.
|
|
99
|
-
# Pass `label:` to override the text, or `label: false` to opt out entirely.
|
|
100
|
-
def render_label?
|
|
101
|
-
return false if @label == false
|
|
102
|
-
|
|
103
|
-
@label.present? || form_field?
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def label_text
|
|
107
|
-
return @label if @label.is_a?(String) && @label.present?
|
|
108
|
-
return nil unless form_field?
|
|
109
|
-
|
|
110
|
-
object = @form.object
|
|
111
|
-
if object.respond_to?(:class) && object.class.respond_to?(:human_attribute_name)
|
|
112
|
-
object.class.human_attribute_name(@field)
|
|
113
|
-
else
|
|
114
|
-
@field.to_s.humanize
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def label_component
|
|
119
|
-
UI::Label.new(
|
|
120
|
-
for: @input_id,
|
|
121
|
-
size: @label_size,
|
|
122
|
-
weight: @label_weight,
|
|
123
|
-
required: @required,
|
|
124
|
-
disabled: @disabled
|
|
125
|
-
)
|
|
126
|
-
end
|
|
127
103
|
end
|
|
@@ -33,7 +33,10 @@ class UI::InputGroup::Addon < UI::Base
|
|
|
33
33
|
class: cn(
|
|
34
34
|
"text-sm flex gap-2 items-center h-6 gap-1 px-2",
|
|
35
35
|
"rounded-[calc(var(--radius)-5px)]",
|
|
36
|
-
"[&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2"
|
|
36
|
+
"[&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
|
37
|
+
# Inside a field a click shouldn't ring the button (it reads as a
|
|
38
|
+
# second ring on the field); keyboard focus still does.
|
|
39
|
+
"focus:ring-0 focus:ring-offset-0 focus-visible:ring-2 focus-visible:ring-offset-1"
|
|
37
40
|
),
|
|
38
41
|
**opts
|
|
39
42
|
)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# UI::InputGroup::Field — a UI::Input variant stripped of its own
|
|
4
4
|
# border/shadow/ring so the parent wrapper owns those styles.
|
|
5
5
|
#
|
|
6
|
-
# The wrapper owns the focus treatment
|
|
6
|
+
# The wrapper owns the focus treatment (lit while this field has focus); the field strips
|
|
7
7
|
# its own outline + ring so there is no double indicator on focus.
|
|
8
8
|
class UI::InputGroup::Field < UI::Input
|
|
9
9
|
STRIPPED_CLASSES =
|
|
@@ -16,6 +16,9 @@ class UI::InputGroup::Field < UI::Input
|
|
|
16
16
|
|
|
17
17
|
def initialize(**args)
|
|
18
18
|
super(**args)
|
|
19
|
+
# Inside the group's border there is no room for a label or messages: the
|
|
20
|
+
# component composing the group shows those. Keep only the invalid state.
|
|
21
|
+
@wrapper = UI::FormField.new(label: false, error: @wrapper.error?)
|
|
19
22
|
@html_attrs[:data] ||= {}
|
|
20
23
|
@html_attrs[:data][:slot] = "input-group-control"
|
|
21
24
|
end
|
|
@@ -13,8 +13,11 @@
|
|
|
13
13
|
# <%= g.with_field(type: :email, placeholder: "you@example.com") %>
|
|
14
14
|
# <% end %>
|
|
15
15
|
class UI::InputGroup < UI::Base
|
|
16
|
-
# Wrapper owns the focus ring
|
|
17
|
-
#
|
|
16
|
+
# Wrapper owns the control's focus ring, lit when the control itself has
|
|
17
|
+
# focus (mouse or keyboard). Not :focus-within: an addon button has its own
|
|
18
|
+
# ring, and the two would stack.
|
|
19
|
+
# An invalid, enabled control turns the border red: [aria-invalid]:enabled,
|
|
20
|
+
# because a :has() nested inside has-[] is invalid CSS and drops the rule.
|
|
18
21
|
WRAPPER_BASE =
|
|
19
22
|
"group/input-group relative flex w-full items-center rounded-md border " \
|
|
20
23
|
"border-input bg-background " \
|
|
@@ -28,12 +31,12 @@ class UI::InputGroup < UI::Base
|
|
|
28
31
|
"has-[>[data-align=block-end]]:h-auto " \
|
|
29
32
|
"has-[>[data-align=block-end]]:flex-col " \
|
|
30
33
|
"has-[>[data-align=block-end]]:[&>input]:pt-3 " \
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
+
"has-[[data-slot]:focus]:ring-2 " \
|
|
35
|
+
"has-[[data-slot]:focus]:ring-primary " \
|
|
36
|
+
"has-[[data-slot]:focus]:ring-offset-1 " \
|
|
34
37
|
"ring-offset-background " \
|
|
35
|
-
"has-[[data-slot][aria-invalid=true]:
|
|
36
|
-
"has-[[data-slot][aria-invalid=true]:
|
|
38
|
+
"has-[[data-slot][aria-invalid=true]:enabled]:border-destructive " \
|
|
39
|
+
"has-[[data-slot][aria-invalid=true]:enabled]:ring-destructive " \
|
|
37
40
|
"has-[input:disabled]:border-input " \
|
|
38
41
|
"has-[input:disabled]:bg-muted " \
|
|
39
42
|
"has-[input:disabled]:opacity-50"
|
|
@@ -47,9 +50,8 @@ class UI::InputGroup < UI::Base
|
|
|
47
50
|
@html_attrs = html_attrs
|
|
48
51
|
end
|
|
49
52
|
|
|
53
|
+
# The invalid state comes from the field's aria-invalid, via WRAPPER_BASE.
|
|
50
54
|
def wrapper_classes
|
|
51
|
-
|
|
52
|
-
error = field&.instance_variable_get(:@error)
|
|
53
|
-
cn(WRAPPER_BASE, { "border-destructive" => error }, @user_class)
|
|
55
|
+
cn(WRAPPER_BASE, @user_class)
|
|
54
56
|
end
|
|
55
57
|
end
|
|
@@ -2,15 +2,8 @@
|
|
|
2
2
|
is drawn back with a positioned icon over the control. %>
|
|
3
3
|
<% control = capture do %>
|
|
4
4
|
<div class="relative w-full">
|
|
5
|
-
<% if form_field? %><%= @form.select(@field, normalized_options,
|
|
5
|
+
<% if form_field? %><%= @form.select(@field, normalized_options, builder_select_options, form_select_options) %><% else %><%= tag.select(**select_options) do %><% if prompt.present? %><option value=""><%= prompt %></option><% end %><% normalized_options.each do |label, value| %><option value="<%= value %>" <%= "selected" if selected?(value) %>><%= label %></option><% end %><% end %><% end %>
|
|
6
6
|
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground"><%= render(UI::Icon.new(name: "chevron-down", size: :sm, aria: { hidden: true })) %></span>
|
|
7
7
|
</div>
|
|
8
8
|
<% end %>
|
|
9
|
-
|
|
10
|
-
<div class="flex flex-col gap-2 w-full">
|
|
11
|
-
<%= render(label_component) { label_text } %>
|
|
12
|
-
<%= control %>
|
|
13
|
-
</div>
|
|
14
|
-
<% else %>
|
|
15
|
-
<%= control %>
|
|
16
|
-
<% end %>
|
|
9
|
+
<%= render(wrapper) { control } %>
|
|
@@ -30,8 +30,8 @@ class UI::NativeSelect < UI::Base
|
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
def initialize(name: nil, options: [], selected: nil, prompt: nil,
|
|
33
|
-
label: nil, size: :default,
|
|
34
|
-
required: false, error:
|
|
33
|
+
label: nil, hint: nil, size: :default,
|
|
34
|
+
required: false, error: nil, disabled: false,
|
|
35
35
|
form: nil, field: nil,
|
|
36
36
|
class: nil, **html_attrs)
|
|
37
37
|
@name = name
|
|
@@ -47,52 +47,32 @@ class UI::NativeSelect < UI::Base
|
|
|
47
47
|
@field = field
|
|
48
48
|
@user_class = binding.local_variable_get(:class)
|
|
49
49
|
@html_attrs = html_attrs
|
|
50
|
-
@
|
|
50
|
+
@wrapper = UI::FormField.new(label: label, hint: hint, error: error,
|
|
51
|
+
required: required, disabled: disabled,
|
|
52
|
+
form: form, field: field)
|
|
53
|
+
@select_id = @html_attrs[:id] || (@wrapper.wrap? ? "ui-native-select-#{SecureRandom.hex(4)}" : nil)
|
|
51
54
|
@html_attrs[:id] = @select_id if @select_id
|
|
55
|
+
@wrapper.for = @select_id
|
|
52
56
|
end
|
|
53
57
|
|
|
54
|
-
attr_reader :options, :prompt
|
|
58
|
+
attr_reader :options, :prompt, :wrapper
|
|
55
59
|
|
|
56
60
|
def classes
|
|
57
61
|
cn(variant_classes(size: @size),
|
|
58
|
-
{ "border-destructive focus:ring-destructive" =>
|
|
62
|
+
{ "border-destructive focus:ring-destructive" => wrapper.error? },
|
|
59
63
|
@user_class)
|
|
60
64
|
end
|
|
61
65
|
|
|
62
66
|
def form_field? = @form.present? && @field.present?
|
|
63
67
|
|
|
64
|
-
# Same convention as UI::Input: bound to a form object the attribute name
|
|
65
|
-
# supplies the label; `label:` overrides, `label: false` opts out.
|
|
66
|
-
def render_label?
|
|
67
|
-
return false if @label == false
|
|
68
|
-
|
|
69
|
-
@label.present? || form_field?
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def label_text
|
|
73
|
-
return nil unless render_label?
|
|
74
|
-
return @label if @label.is_a?(String) && @label.present?
|
|
75
|
-
return nil unless form_field?
|
|
76
|
-
|
|
77
|
-
object = @form.object
|
|
78
|
-
if object.respond_to?(:class) && object.class.respond_to?(:human_attribute_name)
|
|
79
|
-
object.class.human_attribute_name(@field)
|
|
80
|
-
else
|
|
81
|
-
@field.to_s.humanize
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def label_component
|
|
86
|
-
UI::Label.new(for: @select_id, required: @required, disabled: @disabled)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
68
|
def select_options
|
|
90
69
|
opts = @html_attrs.dup
|
|
91
70
|
opts[:class] = classes
|
|
92
71
|
opts[:name] = @name if @name
|
|
93
72
|
opts[:disabled] = true if @disabled
|
|
94
73
|
opts[:required] = true if @required
|
|
95
|
-
opts[:"aria-invalid"] = "true" if
|
|
74
|
+
opts[:"aria-invalid"] = "true" if wrapper.error?
|
|
75
|
+
opts[:"aria-describedby"] = wrapper.describedby if wrapper.describedby
|
|
96
76
|
opts
|
|
97
77
|
end
|
|
98
78
|
|
|
@@ -100,6 +80,12 @@ class UI::NativeSelect < UI::Base
|
|
|
100
80
|
# to avoid overriding the scoped name with a bare one.
|
|
101
81
|
def form_select_options = select_options.except(:name)
|
|
102
82
|
|
|
83
|
+
# Without an explicit `selected:` the builder selects the model's value;
|
|
84
|
+
# passing `selected: nil` would select nothing, losing it on a re-render.
|
|
85
|
+
def builder_select_options
|
|
86
|
+
{ include_blank: prompt.presence, selected: @selected }.compact
|
|
87
|
+
end
|
|
88
|
+
|
|
103
89
|
# Accepts [["Label", value], ...] or a plain list of scalars.
|
|
104
90
|
def normalized_options
|
|
105
91
|
options.map { |option| option.is_a?(Array) ? option : [ option, option ] }
|
|
@@ -10,11 +10,4 @@
|
|
|
10
10
|
<% addon.with_icon_button(**toggle_button_options) do %><span class="inline-flex" data-ui--password-visibility-target="iconShow"><%= render(UI::Icon.new(name: "eye", size: :sm)) %></span><span class="inline-flex" style="display: none" data-ui--password-visibility-target="iconHide"><%= render(UI::Icon.new(name: "eye-off", size: :sm)) %></span><% end %>
|
|
11
11
|
<% end %>
|
|
12
12
|
<% end %><% end %>
|
|
13
|
-
|
|
14
|
-
<div class="flex flex-col gap-2 w-full">
|
|
15
|
-
<%= render(label_component) { label_text } %>
|
|
16
|
-
<%= group %>
|
|
17
|
-
</div>
|
|
18
|
-
<% else %>
|
|
19
|
-
<%= group %>
|
|
20
|
-
<% end %>
|
|
13
|
+
<%= render(wrapper) { group } %>
|
|
@@ -16,8 +16,8 @@ require "securerandom"
|
|
|
16
16
|
# <%= render(UI::PasswordInput.new(form: f, field: :password, required: true)) %>
|
|
17
17
|
# <%= render(UI::PasswordInput.new(name: "user[password]")) %>
|
|
18
18
|
class UI::PasswordInput < UI::Base
|
|
19
|
-
def initialize(form: nil, field: nil, label: nil, required: false,
|
|
20
|
-
disabled: false, error:
|
|
19
|
+
def initialize(form: nil, field: nil, label: nil, hint: nil, required: false,
|
|
20
|
+
disabled: false, error: nil, class: nil, **html_attrs)
|
|
21
21
|
@form = form
|
|
22
22
|
@field = field
|
|
23
23
|
@label = label
|
|
@@ -29,8 +29,13 @@ class UI::PasswordInput < UI::Base
|
|
|
29
29
|
@error = error
|
|
30
30
|
@user_class = binding.local_variable_get(:class)
|
|
31
31
|
@html_attrs = html_attrs
|
|
32
|
+
@wrapper = UI::FormField.new(label: label, hint: hint, error: error,
|
|
33
|
+
required: required, disabled: disabled,
|
|
34
|
+
form: form, field: field, for: @input_id)
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
attr_reader :wrapper
|
|
38
|
+
|
|
34
39
|
def group_component
|
|
35
40
|
UI::InputGroup.new(
|
|
36
41
|
class: @user_class,
|
|
@@ -38,34 +43,10 @@ class UI::PasswordInput < UI::Base
|
|
|
38
43
|
)
|
|
39
44
|
end
|
|
40
45
|
|
|
41
|
-
# Same convention as UI::Input: bound to a form object the attribute name
|
|
42
|
-
# supplies the label text; `label:` overrides it, `label: false` opts out.
|
|
43
|
-
def render_label?
|
|
44
|
-
return false if @label == false
|
|
45
|
-
|
|
46
|
-
@label.present? || form_field?
|
|
47
|
-
end
|
|
48
|
-
|
|
49
46
|
def form_field?
|
|
50
47
|
@form.present? && @field.present?
|
|
51
48
|
end
|
|
52
49
|
|
|
53
|
-
def label_text
|
|
54
|
-
return @label if @label.is_a?(String) && @label.present?
|
|
55
|
-
return nil unless form_field?
|
|
56
|
-
|
|
57
|
-
object = @form.object
|
|
58
|
-
if object.respond_to?(:class) && object.class.respond_to?(:human_attribute_name)
|
|
59
|
-
object.class.human_attribute_name(@field)
|
|
60
|
-
else
|
|
61
|
-
@field.to_s.humanize
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def label_component
|
|
66
|
-
UI::Label.new(for: @input_id, required: @required, disabled: @disabled)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
50
|
def field_options
|
|
70
51
|
{
|
|
71
52
|
type: :password,
|
|
@@ -75,7 +56,8 @@ class UI::PasswordInput < UI::Base
|
|
|
75
56
|
label: false,
|
|
76
57
|
required: @required,
|
|
77
58
|
disabled: @disabled,
|
|
78
|
-
error:
|
|
59
|
+
error: wrapper.error?,
|
|
60
|
+
"aria-describedby": wrapper.describedby,
|
|
79
61
|
data: { "ui--password-visibility-target" => "input" },
|
|
80
62
|
**@html_attrs
|
|
81
63
|
}
|
|
@@ -48,6 +48,10 @@
|
|
|
48
48
|
<% end %>
|
|
49
49
|
<% end %>
|
|
50
50
|
|
|
51
|
+
<% if wrapper.hint.present? || wrapper.error_message.present? %>
|
|
52
|
+
<div class="mt-2 flex flex-col gap-2"><%= render(UI::FormField::Messages.new(wrapper)) %></div>
|
|
53
|
+
<% end %>
|
|
54
|
+
|
|
51
55
|
<% if form_field? %>
|
|
52
56
|
<%= @form.select(@field, native_choices, { selected: current_values }, native_options) %>
|
|
53
57
|
<% else %>
|
|
@@ -42,8 +42,8 @@ class UI::Select < UI::Base
|
|
|
42
42
|
)
|
|
43
43
|
|
|
44
44
|
def initialize(name: nil, options: [], selected: nil, prompt: nil,
|
|
45
|
-
label: nil, size: :default, searchable: false, multiple: false,
|
|
46
|
-
required: false, error:
|
|
45
|
+
label: nil, hint: nil, size: :default, searchable: false, multiple: false,
|
|
46
|
+
required: false, error: nil, disabled: false,
|
|
47
47
|
form: nil, field: nil,
|
|
48
48
|
class: nil, **html_attrs)
|
|
49
49
|
@name = name
|
|
@@ -62,9 +62,15 @@ class UI::Select < UI::Base
|
|
|
62
62
|
@user_class = binding.local_variable_get(:class)
|
|
63
63
|
@html_attrs = html_attrs
|
|
64
64
|
@uid = SecureRandom.hex(4)
|
|
65
|
+
@wrapper = UI::FormField.new(label: label, hint: hint, error: error,
|
|
66
|
+
required: required, disabled: disabled,
|
|
67
|
+
form: form, field: field, for: trigger_id)
|
|
65
68
|
end
|
|
66
69
|
|
|
67
|
-
attr_reader :prompt, :size
|
|
70
|
+
attr_reader :prompt, :size, :wrapper
|
|
71
|
+
|
|
72
|
+
# Lays out its own label, so it takes FormField's parts rather than its wrapper.
|
|
73
|
+
delegate :render_label?, :label_text, :label_component, to: :wrapper
|
|
68
74
|
|
|
69
75
|
def searchable? = @searchable
|
|
70
76
|
def multiple? = @multiple
|
|
@@ -76,7 +82,7 @@ class UI::Select < UI::Base
|
|
|
76
82
|
|
|
77
83
|
def trigger_classes
|
|
78
84
|
cn(variant_classes(size: @size),
|
|
79
|
-
{ "border-destructive focus:ring-destructive" =>
|
|
85
|
+
{ "border-destructive focus:ring-destructive" => wrapper.error? },
|
|
80
86
|
@user_class)
|
|
81
87
|
end
|
|
82
88
|
|
|
@@ -87,7 +93,8 @@ class UI::Select < UI::Base
|
|
|
87
93
|
class: trigger_classes,
|
|
88
94
|
role: "combobox",
|
|
89
95
|
disabled: @disabled,
|
|
90
|
-
aria: { haspopup: "listbox", expanded: "false", controls: listbox_id,
|
|
96
|
+
aria: { haspopup: "listbox", expanded: "false", controls: listbox_id,
|
|
97
|
+
invalid: ("true" if wrapper.error?), describedby: wrapper.describedby },
|
|
91
98
|
data: { "ui--select-target": "trigger",
|
|
92
99
|
action: "click->ui--select#toggle keydown->ui--select#keydown" }
|
|
93
100
|
}
|
|
@@ -95,30 +102,6 @@ class UI::Select < UI::Base
|
|
|
95
102
|
|
|
96
103
|
def form_field? = @form.present? && @field.present?
|
|
97
104
|
|
|
98
|
-
# Same convention as UI::Input: bound to a form object the attribute name
|
|
99
|
-
# supplies the label; `label:` overrides, `label: false` opts out.
|
|
100
|
-
def render_label?
|
|
101
|
-
return false if @label == false
|
|
102
|
-
|
|
103
|
-
@label.present? || form_field?
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def label_text
|
|
107
|
-
return @label if @label.is_a?(String) && @label.present?
|
|
108
|
-
return nil unless form_field?
|
|
109
|
-
|
|
110
|
-
object = @form.object
|
|
111
|
-
if object.respond_to?(:class) && object.class.respond_to?(:human_attribute_name)
|
|
112
|
-
object.class.human_attribute_name(@field)
|
|
113
|
-
else
|
|
114
|
-
@field.to_s.humanize
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def label_component
|
|
119
|
-
UI::Label.new(for: trigger_id, required: @required, disabled: @disabled)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
105
|
# Accepts [["Label", value], ...] or a plain list of scalars. A prompt
|
|
123
106
|
# becomes a first, empty option, as `include_blank` makes it natively;
|
|
124
107
|
# a multiple select has no "nothing" option, since nothing is unticking.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class UI::TableOfContents::Item < UI::Base
|
|
4
|
+
variants(
|
|
5
|
+
base: "block rounded-md px-3 text-muted-foreground hover:text-foreground " \
|
|
6
|
+
"data-[state=ancestor]:text-foreground " \
|
|
7
|
+
"data-[state=active]:bg-muted data-[state=active]:font-medium data-[state=active]:text-foreground " \
|
|
8
|
+
"#{FOCUS_RING}",
|
|
9
|
+
variants: {
|
|
10
|
+
level: {
|
|
11
|
+
top: "py-1.5",
|
|
12
|
+
nested: "py-1"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
defaults: { level: :top }
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
renders_many :items, ->(href:, text:, **opts) {
|
|
19
|
+
UI::TableOfContents::Item.new(href: href, text: text, depth: @depth + 1, **opts)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
def initialize(href:, text:, depth: 0, class: nil, **html_attrs)
|
|
23
|
+
@href = href
|
|
24
|
+
@text = text
|
|
25
|
+
@depth = depth
|
|
26
|
+
@user_class = binding.local_variable_get(:class)
|
|
27
|
+
@html_attrs = html_attrs
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def link_options
|
|
31
|
+
opts = @html_attrs.except(:data)
|
|
32
|
+
opts[:href] = @href
|
|
33
|
+
opts[:class] = cn(variant_classes(level: @depth.zero? ? :top : :nested), @user_class)
|
|
34
|
+
opts[:data] = {
|
|
35
|
+
state: "inactive",
|
|
36
|
+
"ui--table-of-contents-target": "link",
|
|
37
|
+
action: "click->ui--table-of-contents#select"
|
|
38
|
+
}.merge(@html_attrs[:data] || {})
|
|
39
|
+
opts
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%= tag.nav(**html_options) do %>
|
|
2
|
+
<% if @title %>
|
|
3
|
+
<%= tag.p(@title, id: title_id, class: "mb-3 font-medium text-foreground") %>
|
|
4
|
+
<% end %>
|
|
5
|
+
<div class="relative border-s border-border ps-3" data-ui--table-of-contents-target="rail">
|
|
6
|
+
<%# Slides to the current link; positioned by the controller. %>
|
|
7
|
+
<span aria-hidden="true" data-state="hidden" data-ui--table-of-contents-target="indicator"
|
|
8
|
+
class="absolute -start-px top-0 h-0 w-0.5 rounded-full bg-primary opacity-0 data-[state=visible]:opacity-100 transition-[transform,height,opacity] duration-200 ease-out motion-reduce:transition-none"></span>
|
|
9
|
+
<ul class="space-y-1">
|
|
10
|
+
<% items.each do |item| %><%= item %><% end %>
|
|
11
|
+
</ul>
|
|
12
|
+
</div>
|
|
13
|
+
<% end %>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# "On this page" navigation with scrollspy. As the reader scrolls, the link to
|
|
4
|
+
# the section they are reading is marked current (aria-current="location") and
|
|
5
|
+
# an indicator slides beside it. A link jumps to its section like any anchor.
|
|
6
|
+
#
|
|
7
|
+
# Each item links to an element id on the page, and items nest:
|
|
8
|
+
#
|
|
9
|
+
# <%= render(UI::TableOfContents.new(title: "On this page")) do |toc| %>
|
|
10
|
+
# <% toc.with_item(href: "#install", text: "Install") do |item| %>
|
|
11
|
+
# <% item.with_item(href: "#requirements", text: "Requirements") %>
|
|
12
|
+
# <% end %>
|
|
13
|
+
# <% toc.with_item(href: "#usage", text: "Usage") %>
|
|
14
|
+
# <% end %>
|
|
15
|
+
#
|
|
16
|
+
# A section counts as reached once its top passes `offset:` pixels below the
|
|
17
|
+
# top of the scroll area; raise it to clear a sticky header. When the content
|
|
18
|
+
# scrolls inside an element rather than the window, pass that element's CSS
|
|
19
|
+
# selector as `container:`.
|
|
20
|
+
class UI::TableOfContents < UI::Base
|
|
21
|
+
renders_many :items, ->(href:, text:, **opts) {
|
|
22
|
+
UI::TableOfContents::Item.new(href: href, text: text, depth: 0, **opts)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def initialize(title: nil, label: nil, offset: 80, container: nil, class: nil, **html_attrs)
|
|
26
|
+
@title = title
|
|
27
|
+
@label = label
|
|
28
|
+
@offset = offset
|
|
29
|
+
@container = container
|
|
30
|
+
@user_class = binding.local_variable_get(:class)
|
|
31
|
+
@html_attrs = html_attrs
|
|
32
|
+
@id = @html_attrs[:id] || "ui-toc-#{SecureRandom.hex(4)}"
|
|
33
|
+
@html_attrs[:id] = @id
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def title_id = "#{@id}-title"
|
|
37
|
+
|
|
38
|
+
def html_options
|
|
39
|
+
data = {
|
|
40
|
+
controller: "ui--table-of-contents",
|
|
41
|
+
# Until the controller has marked the current link, nothing animates:
|
|
42
|
+
# otherwise every page load fades the highlight in.
|
|
43
|
+
settling: "",
|
|
44
|
+
"ui--table-of-contents-offset-value": @offset,
|
|
45
|
+
"ui--table-of-contents-container-value": @container
|
|
46
|
+
}.compact
|
|
47
|
+
|
|
48
|
+
opts = @html_attrs.except(:data)
|
|
49
|
+
opts[:class] = cn("text-sm data-[settling]:[&_*]:transition-none", @user_class)
|
|
50
|
+
opts[:data] = data.merge(@html_attrs[:data] || {})
|
|
51
|
+
if @title
|
|
52
|
+
opts[:"aria-labelledby"] = title_id
|
|
53
|
+
else
|
|
54
|
+
opts[:"aria-label"] = @label || I18n.t("ui.table_of_contents.label", default: "On this page")
|
|
55
|
+
end
|
|
56
|
+
opts
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
# A multi-line text field, with the same label, hint, required, error and
|
|
6
|
+
# form-builder options as UI::Input.
|
|
7
|
+
#
|
|
8
|
+
# `autogrow: true` grows the box with its text (CSS field-sizing, no
|
|
9
|
+
# JavaScript); browsers without it keep `rows:` lines and scroll. Cap the
|
|
10
|
+
# height with a class such as `max-h-60`.
|
|
11
|
+
class UI::Textarea < UI::Base
|
|
12
|
+
variants(
|
|
13
|
+
base: "flex w-full rounded-md border border-input bg-background " \
|
|
14
|
+
"text-foreground " \
|
|
15
|
+
"placeholder:text-muted-foreground " \
|
|
16
|
+
"#{UI::Base::FOCUS_RING} " \
|
|
17
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
18
|
+
variants: {
|
|
19
|
+
size: {
|
|
20
|
+
sm: "px-2.5 py-1.5 text-xs",
|
|
21
|
+
default: "px-3 py-2 text-sm",
|
|
22
|
+
lg: "px-4 py-2.5 text-base"
|
|
23
|
+
},
|
|
24
|
+
resize: {
|
|
25
|
+
vertical: "resize-y",
|
|
26
|
+
none: "resize-none",
|
|
27
|
+
both: "resize"
|
|
28
|
+
},
|
|
29
|
+
autogrow: {
|
|
30
|
+
true => "field-sizing-content min-h-16 resize-none",
|
|
31
|
+
false => ""
|
|
32
|
+
},
|
|
33
|
+
error: {
|
|
34
|
+
true => "border-destructive focus:ring-destructive",
|
|
35
|
+
false => ""
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
defaults: { size: :default, resize: :vertical, autogrow: false, error: false }
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# `form:` + `field:` render through the form builder's text_area, so the
|
|
42
|
+
# field gets a scoped name like `post[body]` and its value from the model.
|
|
43
|
+
# Without them a plain <textarea> is rendered; pass `name:` and `value:`.
|
|
44
|
+
def initialize(size: :default, rows: 3, resize: :vertical, autogrow: false,
|
|
45
|
+
label: nil, label_size: :default, label_weight: :medium,
|
|
46
|
+
hint: nil, required: false, error: nil, disabled: false,
|
|
47
|
+
value: nil, form: nil, field: nil,
|
|
48
|
+
class: nil, **html_attrs)
|
|
49
|
+
@form = form
|
|
50
|
+
@field = field
|
|
51
|
+
@size = size
|
|
52
|
+
@rows = rows
|
|
53
|
+
@resize = resize
|
|
54
|
+
@autogrow = autogrow
|
|
55
|
+
@label = label
|
|
56
|
+
@label_size = label_size
|
|
57
|
+
@label_weight = label_weight
|
|
58
|
+
@required = required
|
|
59
|
+
@error = error
|
|
60
|
+
@disabled = disabled
|
|
61
|
+
@value = value
|
|
62
|
+
@user_class = binding.local_variable_get(:class)
|
|
63
|
+
@html_attrs = html_attrs
|
|
64
|
+
@wrapper = UI::FormField.new(label: label, hint: hint, error: error,
|
|
65
|
+
required: required, disabled: disabled,
|
|
66
|
+
label_size: label_size, label_weight: label_weight,
|
|
67
|
+
form: form, field: field)
|
|
68
|
+
@textarea_id = @html_attrs[:id] || (@wrapper.wrap? ? "ui-textarea-#{SecureRandom.hex(4)}" : nil)
|
|
69
|
+
@html_attrs[:id] = @textarea_id if @textarea_id
|
|
70
|
+
@wrapper.for = @textarea_id
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
attr_reader :value, :wrapper
|
|
74
|
+
|
|
75
|
+
def classes
|
|
76
|
+
cn(
|
|
77
|
+
variant_classes(size: @size, resize: @resize, autogrow: @autogrow, error: wrapper.error?),
|
|
78
|
+
@user_class
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def html_options
|
|
83
|
+
opts = @html_attrs.dup
|
|
84
|
+
opts[:rows] = @rows
|
|
85
|
+
opts[:class] = classes
|
|
86
|
+
opts[:disabled] = true if @disabled
|
|
87
|
+
opts[:required] = true if @required
|
|
88
|
+
opts[:"aria-invalid"] = "true" if wrapper.error?
|
|
89
|
+
opts[:"aria-describedby"] = wrapper.describedby if wrapper.describedby
|
|
90
|
+
opts
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def form_field?
|
|
94
|
+
@form.present? && @field.present?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The builder derives the name and value itself.
|
|
98
|
+
def form_field_options
|
|
99
|
+
html_options.except(:name)
|
|
100
|
+
end
|
|
101
|
+
end
|