diff --git a/admin/app/components/solidus_admin/ui/forms/guidance/component.rb b/admin/app/components/solidus_admin/ui/forms/guidance/component.rb new file mode 100644 index 00000000000..3756a83b38d --- /dev/null +++ b/admin/app/components/solidus_admin/ui/forms/guidance/component.rb @@ -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 diff --git a/admin/app/components/solidus_admin/ui/forms/label/component.rb b/admin/app/components/solidus_admin/ui/forms/label/component.rb new file mode 100644 index 00000000000..cab416168b0 --- /dev/null +++ b/admin/app/components/solidus_admin/ui/forms/label/component.rb @@ -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 diff --git a/admin/app/components/solidus_admin/ui/forms/text_area/component.rb b/admin/app/components/solidus_admin/ui/forms/text_area/component.rb new file mode 100644 index 00000000000..a79b441be2c --- /dev/null +++ b/admin/app/components/solidus_admin/ui/forms/text_area/component.rb @@ -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 diff --git a/admin/app/components/solidus_admin/ui/forms/text_field/component.rb b/admin/app/components/solidus_admin/ui/forms/text_field/component.rb index 1c9558018fa..164bd7f8063 100644 --- a/admin/app/components/solidus_admin/ui/forms/text_field/component.rb +++ b/admin/app/components/solidus_admin/ui/forms/text_field/component.rb @@ -35,7 +35,17 @@ 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 @@ -43,41 +53,36 @@ def initialize(field:, form:, type: :text, size: :m, hint: nil, errors: nil, **a @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 @@ -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 @@ -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 diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview.rb b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview.rb new file mode 100644 index 00000000000..cb7ce0cc416 --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# @component "ui/forms/text_area" +class SolidusAdmin::UI::Forms::TextArea::ComponentPreview < ViewComponent::Preview + include SolidusAdmin::Preview + + # The text area component is used to render a textarea in a form. + # + # See the [`ui/forms/text_field`](../text_field) component for usage + # instructions. + def overview + dummy_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu aliquam ultricies, urna elit aliquam urna, eu aliquam urna elit euismod urna." + render_with_template( + locals: { + sizes: current_component::SIZES.keys, + variants: { + "empty" => { + value: nil, disabled: false, hint: nil, errors: {} + }, + "filled" => { + value: dummy_text, disabled: false, hint: nil, errors: {} + }, + "with_hint" => { + value: dummy_text, disabled: false, hint: "Max. 400 characters", errors: {} + }, + "empty_with_error" => { + value: nil, disabled: false, hint: nil, errors: { "empty_with_error" => ["can't be blank"] } + }, + "filled_with_error" => { + value: dummy_text, disabled: false, hint: nil, errors: { "filled_with_error" => ["is invalid"] } + }, + "with_hint_and_error" => { + value: dummy_text, disabled: false, hint: "Max. 400 characters", errors: { "with_hint_and_error" => ["is invalid"] } + }, + "empty_disabled" => { + value: nil, disabled: true, hint: nil, errors: {} + }, + "filled_disabled" => { + value: dummy_text, disabled: true, hint: nil, errors: {} + }, + "with_hint_disabled" => { + value: dummy_text, disabled: true, hint: "Max. 400 characters", errors: {} + } + } + } + ) + end + + # @param size select { choices: [s, m, l] } + # @param label text + # @param value text + # @param hint text + # @param errors text "Separate multiple errors with a comma" + # @param placeholder text + # @param disabled toggle + def playground(size: :m, label: "Description", value: nil, hint: nil, errors: "", placeholder: "Placeholder", disabled: false) + render_with_template( + locals: { + size: size.to_sym, + field: label, + value: value, + hint: hint, + errors: { label.dasherize => (errors.blank? ? [] : errors.split(",").map(&:strip)) }, + placeholder: placeholder, + disabled: disabled + } + ) + end +end diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/overview.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/overview.html.erb new file mode 100644 index 00000000000..56bd273fe2a --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/overview.html.erb @@ -0,0 +1,34 @@ +<%= form_with(url: "#", scope: :overview, method: :get, class: "w-full") do |form| %> + + + + <% sizes.each do |size| %> + + <% end %> + + + + <% + variants.each_pair do |name, definition| %> + + <% sizes.each do |size| %> + + <% end %> + + <% end %> + +
<%= size.to_s.humanize %>
+ <%= + render current_component.new( + form: form, + field: name, + size: size, + errors: definition[:errors], + hint: definition[:hint], + disabled: definition[:disabled], + placeholder: "Placeholder", + value: definition[:value] + ) + %> +
+<% end %> diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/playground.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/playground.html.erb new file mode 100644 index 00000000000..f2e792fdea0 --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/playground.html.erb @@ -0,0 +1,14 @@ +<%= form_with(url: "#", scope: :playground, method: :get, class: "w-60") do |form| %> + <%= + render current_component.new( + form: form, + size: size, + field: field, + value: value, + hint: hint, + errors: errors, + placeholder: placeholder, + disabled: disabled + ) + %> +<% end %> diff --git a/admin/spec/components/solidus_admin/ui/forms/guidance/component_spec.rb b/admin/spec/components/solidus_admin/ui/forms/guidance/component_spec.rb new file mode 100644 index 00000000000..daf45f92d51 --- /dev/null +++ b/admin/spec/components/solidus_admin/ui/forms/guidance/component_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusAdmin::UI::Forms::Guidance::Component, type: :component do + describe "#initialize" do + it "uses given errors when form is bound to a model" do + form = double("form", object: double("model", errors: {})) + + component = described_class.new(form: form, field: :name, hint: nil, errors: { name: ["can't be blank"] }) + + expect(component.errors?).to be(true) + end + + it "uses model errors when form is bound to a model and they are not given" do + form = double("form", object: double("model", errors: { name: ["can't be blank"] })) + + component = described_class.new(form: form, field: :name, hint: nil, errors: nil) + + expect(component.errors?).to be(true) + end + + it "uses given errors when form is not bound to a model" do + form = double("form", object: nil) + + component = described_class.new(form: form, field: :name, hint: nil, errors: { name: ["can't be blank"] }) + + expect(component.errors?).to be(true) + end + + it "raises an error when form is not bound to a model and errors are not given" do + form = double("form", object: nil) + + expect { described_class.new(form: form, field: :name, errors: nil) }.to raise_error(ArgumentError) + end + end +end diff --git a/admin/spec/components/solidus_admin/ui/forms/text_area/component_spec.rb b/admin/spec/components/solidus_admin/ui/forms/text_area/component_spec.rb new file mode 100644 index 00000000000..af5e6f1bf09 --- /dev/null +++ b/admin/spec/components/solidus_admin/ui/forms/text_area/component_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusAdmin::UI::Forms::TextArea::Component, type: :component do + it "renders the overview preview" do + render_preview(:overview) + end + + it "renders the playground preview" do + render_preview(:playground) + end +end diff --git a/admin/spec/components/solidus_admin/ui/forms/text_field/component_spec.rb b/admin/spec/components/solidus_admin/ui/forms/text_field/component_spec.rb index 67cb3d25d6d..2d89dabfe54 100644 --- a/admin/spec/components/solidus_admin/ui/forms/text_field/component_spec.rb +++ b/admin/spec/components/solidus_admin/ui/forms/text_field/component_spec.rb @@ -10,36 +10,4 @@ it "renders the playground preview" do render_preview(:playground) end - - describe "#initialize" do - it "uses given errors when form is bound to a model" do - form = double("form", object: double("model", errors: {})) - - component = described_class.new(form: form, field: :name, errors: { name: ["can't be blank"] }) - - expect(component.errors?).to be(true) - end - - it "uses model errors when form is bound to a model and they are not given" do - form = double("form", object: double("model", errors: { name: ["can't be blank"] })) - - component = described_class.new(form: form, field: :name) - - expect(component.errors?).to be(true) - end - - it "uses given errors when form is not bound to a model" do - form = double("form", object: nil) - - component = described_class.new(form: form, field: :name, errors: { name: ["can't be blank"] }) - - expect(component.errors?).to be(true) - end - - it "raises an error when form is not bound to a model and errors are not given" do - form = double("form", object: nil) - - expect { described_class.new(form: form, field: :name) }.to raise_error(ArgumentError) - end - end end