Skip to content

Commit

Permalink
Add text_area component for the admin
Browse files Browse the repository at this point in the history
As the logic and styling for the associated label, hint and error
messages are the same as for the text_field component, we extract it
into a couple of new components: one for the label and one for the
guidance (hint and error messages). These new components are marked as
private, as they are not meant to be used directly (at least for now).

The original text_field component and the new text_area compose the new
components but keep the responsibility of how to render the form
inputs and which classes or attributes to use. We consider this
approach more flexible than, for instance, using inheritance. Although
it requires more code than just sharing a bunch of common styles, we
keep them decoupled so they can change independently.

We stop short of creating another form element component wrapping new
extracted pure input and textarea components. We can do that in the
future if we need to.

Ref. #5329
  • Loading branch information
waiting-for-dev authored and elia committed Sep 28, 2023
1 parent 827c559 commit f5c8d94
Show file tree
Hide file tree
Showing 10 changed files with 396 additions and 95 deletions.
65 changes: 65 additions & 0 deletions admin/app/components/solidus_admin/ui/forms/guidance/component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

# @api private
class SolidusAdmin::UI::Forms::Guidance::Component < SolidusAdmin::BaseComponent
def initialize(field:, form:, hint:, errors:)
@field = field
@form = form
@hint = hint
@errors = errors || @form.object&.errors || raise(ArgumentError, <<~MSG
When the form builder is not bound to a model instance, you must pass an
errors Hash (`{ field_name: [errors] }`) to the component.
MSG
)
end

def call
return "" unless needed?

tag.div(class: "mt-2") do
hint_tag + error_tag
end
end

def hint_tag
return "".html_safe unless @hint

tag.p(id: hint_id, class: "body-tiny text-gray-500 peer-disabled:text-gray-300") do
@hint
end
end

def hint_id
"#{prefix}_hint"
end

def error_tag
return "".html_safe unless errors?

tag.p(id: error_id, class: "body-tiny text-red-400") do
@errors[@field].map do |error|
tag.span(class: "block") { error.capitalize }
end.reduce(&:+)
end
end

def errors?
@errors[@field].present?
end

def error_id
"#{prefix}_error"
end

def prefix
"#{@form.object_name}_#{@field}"
end

def aria_describedby
"#{hint_id if @hint} #{error_id if errors?}"
end

def needed?
@hint || errors?
end
end
13 changes: 13 additions & 0 deletions admin/app/components/solidus_admin/ui/forms/label/component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

# @api private
class SolidusAdmin::UI::Forms::Label::Component < SolidusAdmin::BaseComponent
def initialize(field:, form:)
@field = field
@form = form
end

def call
@form.label(@field, class: "block mb-0.5 body-tiny-bold")
end
end
109 changes: 109 additions & 0 deletions admin/app/components/solidus_admin/ui/forms/text_area/component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

class SolidusAdmin::UI::Forms::TextArea::Component < SolidusAdmin::BaseComponent
SIZES = {
s: %w[h-20 body-small],
m: %w[h-28 body-small],
l: %w[h-36 body-text]
}.freeze

# @param field [Symbol] the name of the field. Usually a model attribute.
# @param form [ActionView::Helpers::FormBuilder] the form builder instance.
# @param size [Symbol] the size of the field: `:s`, `:m` or `:l`.
# @param hint [String, null] helper text to display below the field.
# @param errors [Hash, nil] a Hash of errors for the field. If `nil` and the
# form is bound to a model instance, the component will automatically fetch
# the errors from the model.
# @param attributes [Hash] additional HTML attributes to add to the field.
# @raise [ArgumentError] when the form builder is not bound to a model
# instance and no `errors` Hash is passed to the component.
def initialize(
field:,
form:,
size: :m,
hint: nil,
errors: nil,
label_component: component("ui/forms/label"),
guidance_component: component("ui/forms/guidance"),
**attributes
)
@field = field
@form = form
@size = size
@hint = hint
@attributes = attributes
@errors = errors
@label_component = label_component
@guidance_component = guidance_component
end

def call
guidance = @guidance_component.new(
field: @field,
form: @form,
hint: @hint,
errors: @errors
)

tag.div(class: "mb-6") do
label_tag + field_tag(guidance) + guidance_tag(guidance)
end
end

def field_tag(guidance)
@form.text_area(
@field,
class: field_classes(guidance),
**field_aria_describedby_attribute(guidance),
**field_error_attributes(guidance),
**@attributes.except(:class)
)
end

def field_classes(guidance)
%w[
peer
block px-3 py-4 w-full
text-black
bg-white border border-gray-300 rounded-sm
hover:border-gray-500
placeholder:text-gray-400
focus:border-gray-500 focus:shadow-[0_0_0_2px_#bbb] focus-visible:outline-none
disabled:bg-gray-50 disabled:text-gray-300
] + field_size_classes + field_error_classes(guidance) + Array(@attributes[:class]).compact
end

def field_size_classes
SIZES.fetch(@size)
end

def field_aria_describedby_attribute(guidance)
return {} unless guidance.needed?

{
"aria-describedby": guidance.aria_describedby
}
end

def field_error_classes(guidance)
return [] unless guidance.errors?

%w[border-red-400 text-red-400]
end

def field_error_attributes(guidance)
return {} unless guidance.errors?

{
"aria-invalid": true
}
end

def label_tag
render @label_component.new(field: @field, form: @form)
end

def guidance_tag(guidance)
render guidance
end
end
105 changes: 42 additions & 63 deletions admin/app/components/solidus_admin/ui/forms/text_field/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,49 +35,54 @@ class SolidusAdmin::UI::Forms::TextField::Component < SolidusAdmin::BaseComponen
# @param attributes [Hash] additional HTML attributes to add to the field.
# @raise [ArgumentError] when the form builder is not bound to a model
# instance and no `errors` Hash is passed to the component.
def initialize(field:, form:, type: :text, size: :m, hint: nil, errors: nil, **attributes)
def initialize(
field:,
form:,
type: :text,
size: :m,
hint: nil,
errors: nil,
label_component: component("ui/forms/label"),
guidance_component: component("ui/forms/guidance"),
**attributes
)
@field = field
@form = form
@type = type
@size = size
@hint = hint
@type = type
@attributes = attributes
@errors = errors || @form.object&.errors || raise(ArgumentError, <<~MSG
When the form builder is not bound to a model instance, you must pass an
errors Hash (`field_name: [errors]`) to the component.
MSG
)
@errors = errors
@label_component = label_component
@guidance_component = guidance_component
end

def call
tag.div(class: "mb-6") do
label_tag + field_tag + info_wrapper
end
end
guidance = @guidance_component.new(
field: @field,
form: @form,
hint: @hint,
errors: @errors
)

def info_wrapper
tag.div(class: "mt-2") do
hint_tag + error_tag
tag.div(class: "mb-6") do
label_tag + field_tag(guidance) + guidance_tag(guidance)
end
end

def label_tag
@form.label(@field, class: "block mb-0.5 body-tiny-bold")
end

def field_tag
def field_tag(guidance)
@form.send(
field_helper,
@field,
class: field_classes,
**field_aria_describedby_attribute,
**field_error_attributes,
class: field_classes(guidance),
**field_aria_describedby_attribute(guidance),
**field_error_attributes(guidance),
**@attributes.except(:class)
)
end

def field_classes
def field_classes(guidance)
%w[
peer
block px-3 py-1.5 w-full
Expand All @@ -87,7 +92,7 @@ def field_classes
placeholder:text-gray-400
focus:border-gray-500 focus:shadow-[0_0_0_2px_#bbb] focus-visible:outline-none
disabled:bg-gray-50 disabled:text-gray-300
] + field_size_classes + field_error_classes + Array(@attributes[:class]).compact
] + field_size_classes + field_error_classes(guidance) + Array(@attributes[:class]).compact
end

def field_helper
Expand All @@ -98,59 +103,33 @@ def field_size_classes
SIZES.fetch(@size)
end

def field_error_classes
return [] unless errors?
def field_aria_describedby_attribute(guidance)
return {} unless guidance.needed?

%w[border-red-400 text-red-400]
{
"aria-describedby": guidance.aria_describedby
}
end

def field_aria_describedby_attribute
return {} unless @hint || errors?
def field_error_classes(guidance)
return [] unless guidance.errors?

{
"aria-describedby": "#{hint_id if @hint} #{error_id if errors?}"
}
%w[border-red-400 text-red-400]
end

def field_error_attributes
return {} unless errors?
def field_error_attributes(guidance)
return {} unless guidance.errors?

{
"aria-invalid": true
}
end

def hint_tag
return "".html_safe unless @hint

tag.p(id: hint_id, class: "body-tiny text-gray-500 peer-disabled:text-gray-300") do
@hint
end
end

def hint_id
"#{id_prefix}_hint"
end

def error_tag
return "".html_safe unless errors?

tag.p(id: error_id, class: "body-tiny text-red-400") do
@errors[@field].map do |error|
tag.span(class: "block") { error.capitalize }
end.reduce(&:+)
end
end

def errors?
@errors[@field].present?
end

def error_id
"#{id_prefix}_error"
def label_tag
render @label_component.new(field: @field, form: @form)
end

def id_prefix
"#{@form.object_name}_#{@field}"
def guidance_tag(guidance)
render guidance
end
end

0 comments on commit f5c8d94

Please sign in to comment.