Skip to content

Lesson 3 — Form primitives

As you add each form component, you should add it’s corresponding Lookbook preview so you can check the appearance. (The Lookbook samples are shown below).

Components::TextInput

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# app/components/text_input.rb
class Components::TextInput < Components::FormField
  prop :value,       _Nilable(String), default: -> { nil }
  prop :placeholder, _Nilable(String), default: -> { nil }
  prop :type,        String,           default: -> { "text" }

  private

  def render_input
    input(
      type:        @type,
      id:          @field,
      name:        @field,
      value:       @value.to_s,
      placeholder: @placeholder,
      required:    @required,
      class:       input_classes,
      aria:        aria_attrs
    )
  end
end

Usage:

1
2
3
4
5
6
7
8
TextInput(
  field:       :name,
  label:       "Board name",
  value:       @board.name,
  placeholder: "e.g. Marketing Q3",
  error:       @board.errors[:name].any?,
  required:    true
)

Components::Textarea

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# app/components/textarea.rb
class Components::Textarea < Components::FormField
  prop :value,       _Nilable(String), default: -> { nil }
  prop :placeholder, _Nilable(String), default: -> { nil }
  prop :rows,        Integer,          default: 4

  private

  def render_input
    textarea(
      id:          @field,
      name:        @field,
      rows:        @rows,
      placeholder: @placeholder,
      required:    @required,
      class:       "#{input_classes} resize-y",
      aria:        aria_attrs
    ) { @value.to_s }
  end
end

Components::Select

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# app/components/select.rb
class Components::Select < Components::FormField
  prop :options,  _Array(_Any)
  prop :selected, _Nilable(String), default: -> { nil }
  prop :prompt,   _Nilable(String), default: -> { nil }

  private

  def render_input
    div(class: "relative") do
      select(
        id:       @field,
        name:     @field,
        required: @required,
        class:    "#{input_classes} appearance-none pr-8",
        aria:     aria_attrs
      ) do
        if @prompt
          option(value: "", disabled: true, selected: @selected.nil?) { @prompt }
        end
        @options.each do |opt|
          label, value = opt.is_a?(Array) ? opt : [opt.to_s, opt.to_s]
          option(value: value, selected: value.to_s == @selected.to_s) { label }
        end
      end

      div(class: "pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-400") do
        span(class: "text-lg rotate-90 inline-block leading-none") { "›" }
      end
    end
  end
end

options accepts either a flat array ["Admin", "Member"] or a two-dimensional array [["Administrator", "admin"], ["Member", "member"]] — the same convention as Rails’ options_for_select.

Components::NumberInput

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# app/components/number_input.rb
class Components::NumberInput < Components::FormField
  prop :value, _Nilable(Integer), default: -> { nil }
  prop :min,   _Nilable(Integer), default: -> { nil }
  prop :max,   _Nilable(Integer), default: -> { nil }
  prop :step,  Integer,           default: 1

  private

  def render_input
    input(
      type:  "number",
      id:    @field,
      name:  @field,
      value: @value,
      min:   @min,
      max:   @max,
      step:  @step,
      class: input_classes,
      aria:  aria_attrs
    )
  end
end

Components::HiddenInput

HiddenInput is simpler — no label, hint, or error styling needed. It inherits from Components::Base directly rather than FormField:

1
2
3
4
5
6
7
8
9
# app/components/hidden_input.rb
class Components::HiddenInput < Components::Base
  prop :field, Symbol
  prop :value, String

  def view_template
    input(type: "hidden", id: @field, name: @field, value: @value)
  end
end

Components::Checkbox

Checkbox has a different layout — the label sits beside the input rather than above it. It inherits from FormField for the shared props but overrides view_template entirely:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# app/components/checkbox.rb
class Components::Checkbox < Components::FormField
  prop :checked, _Boolean, default: -> { false }
  prop :value, String, default: -> { "1" }

  def view_template
    div(class: "space-y-1") do
      input(
        type: "checkbox",
        id: @field,
        name: @field,
        value: @value,
        checked: @checked,
        class: "w-4 h-4 mr-2 rounded border-gray-300 text-blue-600 " \
          "focus:ring-blue-500 focus:ring-offset-1 cursor-pointer"
      )
      plain @label if @label
    end
    p(class: "text-xs text-gray-500 pl-6") { @hint } if @hint
  end
end

Components::RadioGroup

Can be oriented horizontally or vertically.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# app/components/radio_group.rb

class Components::RadioGroup < Components::FormField
  prop :options,     _Array(_Any)
  prop :selected,    _Nilable(String), default: -> { nil }
  prop :orientation, Symbol,           default: -> { :vertical }

  def view_template
    fieldset do
      legend(class: "block text-sm font-medium text-gray-700 mb-2") { @label } if @label
      div(class: option_wrapper_classes) do
        @options.each do |opt|
          label_text, value = opt.is_a?(Array) ? opt : [opt.to_s, opt.to_s]
          label(class: "flex items-center gap-2 text-sm text-gray-700 cursor-pointer") do
            input(
              type:    "radio",
              name:    @field,
              value:   value,
              checked: value.to_s == @selected.to_s,
              class:   "w-4 h-4 border-gray-300 text-blue-600 " \
                "focus:ring-blue-500 focus:ring-offset-1 cursor-pointer"
            )
            plain label_text
          end
        end
      end
      p(class: "text-xs text-gray-500 mt-1") { @hint } if @hint

      if @error
        msg = @error.strip.length > 0 ? @error : "Please select an option"
        p(class: "text-xs text-red-600 mt-1", role:  "alert") { msg }
      end
    end
  end

  private

  def option_wrapper_classes
    @orientation == :horizontal ? "flex flex-wrap gap-4" : "space-y-2"
  end
end

Lookbook previews

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# test/components/previews/text_input_preview.rb
class TextInputPreview < Lookbook::Preview
  def default
    render Components::TextInput.new(
      field:       :name,
      label:       "Board name",
      placeholder: "e.g. Marketing Q3"
    )
  end

  def with_value
    render Components::TextInput.new(
      field: :name,
      label: "Board name",
      value: "KanbanFlow"
    )
  end

  def with_hint
    render Components::TextInput.new(
      field: :name,
      label: "Board name",
      hint:  "Choose a short, descriptive name."
    )
  end

  # error: nil — no error, normal styling
  def no_error
    render Components::TextInput.new(
      field: :name,
      label: "Board name",
      error: nil
    )
  end

  # error: "" — red border only, no message
  # use this when ErrorSummary handles the message at the top of the form
  def error_highlight_only
    render Components::TextInput.new(
      field: :name,
      label: "Board name",
      value: "",
      error: ""
    )
  end

  # error: "message" — red border plus inline message
  def error_with_message
    render Components::TextInput.new(
      field: :name,
      label: "Board name",
      value: "",
      error: "can't be blank"
    )
  end

  def required
    render Components::TextInput.new(
      field:    :name,
      label:    "Board name",
      required: true
    )
  end

  def password
    render Components::TextInput.new(
      field: :password,
      label: "Password",
      type:  "password"
    )
  end

  def no_label
    render Components::TextInput.new(
      field:       :search,
      placeholder: "Search..."
    )
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# test/components/previews/text_area_preview.rb
class TextAreaPreview < Lookbook::Preview
  def default
    render Components::TextArea.new(
      field:       :description,
      label:       "Description",
      placeholder: "Enter a description..."
    )
  end

  def with_value
    render Components::TextArea.new(
      field: :description,
      label: "Description",
      value: "This is an existing description that spans\nmultiple lines."
    )
  end

  def with_hint
    render Components::TextArea.new(
      field: :description,
      label: "Description",
      hint:  "Markdown is supported."
    )
  end

  def with_error
    render Components::TextArea.new(
      field: :description,
      label: "Description",
      error: true,
      value: ""
    )
  end

  def required
    render Components::TextArea.new(
      field:    :description,
      label:    "Description",
      required: "description needed"
    )
  end

  def no_label
    render Components::TextArea.new(
      field:       :description,
      placeholder: "Add a note...",
      rows:        3
    )
  end

  def tall
    render Components::TextArea.new(
      field: :description,
      label: "Description",
      rows:  8
    )
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# test/components/previews/select_preview.rb
class SelectPreview < Lookbook::Preview
  def default
    render Components::Select.new(
      field:   :role,
      label:   "Role",
      options: [["Administrator", "admin"], ["Member", "member"]],
      prompt:  "Select a role"
    )
  end

  def with_selection
    render Components::Select.new(
      field:    :role,
      label:    "Role",
      options:  [["Administrator", "admin"], ["Member", "member"]],
      selected: "member"
    )
  end

  def with_error
    render Components::Select.new(
      field:   :role,
      label:   "Role",
      options: [["Administrator", "admin"], ["Member", "member"]],
      prompt:  "Select a role",
      error:   ""
    )
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# test/components/previews/number_input_preview.rb
class NumberInputPreview < Lookbook::Preview
  def default
    render Components::NumberInput.new(
      field: :quantity,
      label: "Quantity"
    )
  end

  def with_value
    render Components::NumberInput.new(
      field: :quantity,
      label: "Quantity",
      value: 5
    )
  end

  def with_min_max
    render Components::NumberInput.new(
      field: :position,
      label: "Position",
      value: 1,
      min:   1,
      max:   10
    )
  end

  def with_step
    render Components::NumberInput.new(
      field: :price,
      label: "Price",
      value: 9,
      min:   0,
      step:  3
    )
  end

  def with_hint
    render Components::NumberInput.new(
      field: :quantity,
      label: "Quantity",
      hint:  "Must be between 1 and 100.",
      min:   1,
      max:   100
    )
  end

  def with_error
    render Components::NumberInput.new(
      field: :quantity,
      label: "Quantity",
      error: "",
      value: 0
    )
  end

  def no_label
    render Components::NumberInput.new(
      field: :count,
      min:   0
    )
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# test/components/previews/checkbox_preview.rb
class CheckboxPreview < Lookbook::Preview
  def unchecked
    render Components::Checkbox.new(
      field: :notifications,
      label: "Email notifications"
    )
  end

  def checked
    render Components::Checkbox.new(
      field:   :notifications,
      label:   "Email notifications",
      checked: true
    )
  end

  def with_hint
    render Components::Checkbox.new(
      field: :notifications,
      label: "Email notifications",
      hint:  "We'll only send important updates."
    )
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# test/components/previews/radio_group.rb

class RadioGroupPreview < Lookbook::Preview
  def default
    render Components::RadioGroup.new(
      field:   :role,
      label:   "Role",
      options: [["Administrator", "admin"], ["Member", "member"]]
    )
  end

  def flat_options
    render Components::RadioGroup.new(
      field:   :status,
      label:   "Status",
      options: ["Active", "Inactive"]
    )
  end

  def horizontal
    render Components::RadioGroup.new(
      field:       :priority,
      label:       "Priority",
      options:     ["Low", "Medium", "High"],
      selected:    "Medium",
      orientation: :horizontal
    )
  end

  def with_selection
    render Components::RadioGroup.new(
      field:    :role,
      label:    "Role",
      options:  [["Administrator", "admin"], ["Member", "member"]],
      selected: "member"
    )
  end

  def with_hint
    render Components::RadioGroup.new(
      field:   :role,
      label:   "Role",
      options: [["Administrator", "admin"], ["Member", "member"]],
      hint:    "Administrators can manage members and delete the board."
    )
  end

  def with_error_message
    render Components::RadioGroup.new(
      field:   :role,
      label:   "Role",
      options: [["Administrator", "admin"], ["Member", "member"]],
      error:   "No role selected"
    )
  end

  def with_error
    render Components::RadioGroup.new(
      field:   :role,
      label:   "Role",
      options: [["Administrator", "admin"], ["Member", "member"]],
      error:   ""
    )
  end
end