Skip to content

Lesson 2 — FormField: a base class for all form primitives

Every input primitive shares the same set of concerns:

  • A field identifier linking the input to its label
  • An optional label rendered above the input
  • An optional hint rendered below the input
  • An error boolean that switches styling to danger colours
  • A required boolean that adds a visual indicator and the HTML required attribute

Rather than repeating these props and the label/hint rendering in every primitive, we define them once in a base class:

 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
# app/components/form_field.rb

class Components::FormField < Components::Base
  prop :field,    Symbol
  prop :label,    _Nilable(String), default: -> { nil }
  prop :hint,     _Nilable(String), default: -> { nil }
  prop :error,    _Nilable(String), default: -> { nil }
  prop :required, _Boolean,         default: -> { false }

  def view_template
    div(class: "space-y-1") do
      render_label         if @label
      render_input
      render_error_message if show_error_message?
      render_hint          if @hint
    end
  end

  private

  def error?
    !@error.nil?
  end

  def show_error_message?
    error? && !@error.empty?
  end

  def render_label
    label(for: @field, class: "block text-sm font-medium text-gray-700") do
      plain @label
      abbr(title: "required", class: "ml-1 text-red-500") { "*" } if @required
    end
  end

  def render_error_message
    p(class: "text-xs text-red-600",
      id:    "#{@field}_error",
      role:  "alert") { @error }
  end

  def render_hint
    p(class: "text-xs text-gray-500") { @hint }
  end

  def render_input
    raise NotImplementedError, "#{self.class} must implement render_input"
  end

  def base_input_classes
    "w-full rounded-md border px-3 py-2 text-sm bg-white " \
      "text-gray-900 placeholder:text-gray-400 " \
      "focus:outline-none focus:ring-2 focus:ring-offset-1"
  end

  def error_classes
    error? ? "border-red-300 focus:ring-red-500"
      : "border-gray-300 focus:ring-blue-500"
  end

  def input_classes
    "#{base_input_classes} #{error_classes}"
  end

  def aria_attrs
    { invalid: error?.to_s }
  end
end

Each subclass inherits all of this and only needs to:

  1. Declare its own specific props
  2. Implement render_input

Any future change to label rendering, hint styling, or ARIA attributes happens in one place and applies to every primitive automatically.