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
@@ -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: false, disabled: false,
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
- @input_id = @html_attrs[:id] || (render_label? ? "ui-input-#{SecureRandom.hex(4)}" : nil)
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 @error
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 via :focus-within; the field strips
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 via :focus-within so it lights up on any
17
- # descendant focus (mouse click or keyboard). focus-visible is too narrow.
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
- "focus-within:ring-2 " \
32
- "focus-within:ring-primary " \
33
- "focus-within:ring-offset-1 " \
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]:not(:has(input:disabled))]:border-destructive " \
36
- "has-[[data-slot][aria-invalid=true]:not(:has(input:disabled))]:ring-destructive " \
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
- # Propagate error styling to the wrapper when the field is in error state.
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, { include_blank: prompt.presence, selected: @selected }, 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 %>
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
- <% if render_label? %>
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: false, disabled: false,
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
- @select_id = @html_attrs[:id] || (render_label? ? "ui-native-select-#{SecureRandom.hex(4)}" : nil)
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" => @error },
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 @error
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
- <% if render_label? %>
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: false, class: nil, **html_attrs)
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: @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: false, disabled: false,
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" => @error },
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, invalid: (@error ? "true" : nil) },
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,8 @@
1
+ <li>
2
+ <%= tag.a(@text, **link_options) %>
3
+ <% if items? %>
4
+ <ul class="ms-4 mt-1 space-y-1">
5
+ <% items.each do |item| %><%= item %><% end %>
6
+ </ul>
7
+ <% end %>
8
+ </li>
@@ -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,2 @@
1
+ <%# frozen_string_literal: true %>
2
+ <%= render(wrapper) do %><% if form_field? %><%= @form.text_area(@field, **form_field_options) %><% else %><%= tag.textarea(value, **html_options) %><% end %><% 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