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 new file mode 100644 index 00000000000..5a1cc380b0f --- /dev/null +++ b/admin/app/components/solidus_admin/ui/forms/text_field/component.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +class SolidusAdmin::UI::Forms::TextField::Component < SolidusAdmin::BaseComponent + SIZES = { + s: 'leading-4 body-small', + m: 'leading-6 body-small', + l: 'leading-9 body-text' + }.freeze + + TYPES = { + color: :color_field, + date: :date_field, + datetime: :datetime_field, + email: :email_field, + month: :month_field, + number: :number_field, + password: :password_field, + phone: :phone_field, + range: :range_field, + search: :search_field, + text: :text_field, + time: :time_field, + url: :url_field, + week: :week_field + }.freeze + + # @param field [Symbol] the name of the field. Usually a model attribute. + # @param form [ActionView::Helpers::FormBuilder] the form builder instance. + # @param type [Symbol] the type of the field. Defaults to `:text`. + # @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:, type: :text, size: :m, hint: nil, errors: nil, **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 + ) + end + + def call + tag.div do + label_tag + field_tag + hint_tag + error_tag + end + end + + def label_tag + @form.label(@field, class: "block mb-0.5 body-tiny-bold") + end + + def field_tag + @form.send( + field_helper, + @field, + class: " + peer + block px-3 py-1.5 w-full + text-black 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} + #{@attributes[:class]} + ", + **field_aria_describedby_attribute, + **field_error_attributes, + **@attributes.except(:class) + ) + end + + def field_helper + TYPES.fetch(@type) + end + + def field_size_classes + SIZES.fetch(@size) + end + + def field_error_classes + return "" unless errors? + + "border-red-400 text-red-400" + end + + def field_aria_describedby_attribute + return {} unless @hint || errors? + + { + "aria-describedby": "#{hint_id if @hint} #{error_id if errors?}" + } + end + + def field_error_attributes + return {} unless errors? + + { + "aria-invalid": true + } + end + + def hint_tag + return unless @hint + + tag.p(id: hint_id, class: "mt-2 body-tiny text-gray-500 peer-disabled:text-gray-300 #{hint_error_classes}") do + @hint + end + end + + def hint_error_classes + return {} unless errors? + + "text-red-400" + end + + def hint_id + "#{id_prefix}_hint" + end + + def error_tag + return unless errors? + + tag.p(id: error_id, class: "mt-2 body-tiny text-red-400") do + @errors[@field].map do |error| + tag.span(class: "block") { error } + end.reduce(&:+) + end + end + + def errors? + @errors[@field].present? + end + + def error_id + "#{id_prefix}_error" + end + + def id_prefix + "#{@form.object_name}_#{@field}" + end +end diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview.rb b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview.rb new file mode 100644 index 00000000000..d6a1ba76a9f --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# @component "ui/forms/text_field" +class SolidusAdmin::UI::Forms::TextField::ComponentPreview < ViewComponent::Preview + include SolidusAdmin::Preview + + # The text field component is used to render a text field in a form. + # + # It must be used within the block context yielded in the [`form_with` + # ](https://api.rubyonrails.org/v5.1/classes/ActionView/Helpers/FormHelper.html#method-i-form_with) + # or + # [`form_for`](https://api.rubyonrails.org/v5.1/classes/ActionView/Helpers/FormHelper.html#method-i-form_for) + # helpers. + # + # When the form builder is not bound to a model instance, you must pass an + # errors Hash to the component. For example: + # + # ```erb + # <%= form_with(url: search_path, method: :get) do |form| %> + # <%= render components('ui/forms/text_field').new( + # form: form, + # field: :q, + # errors: params[:q].present? ? {} : { + # q: ["can't be blank"] + # } + # ) %> + # <%= form.submit "Search" %> + # <% end %> + # ``` + # + # When the form builder is bound to a model instance, the component will + # automatically fetch the errors from the model. + # + # ```erb + # <%= form_with(model: @user) do |form| %> + # <%= render components('ui/forms/text_field').new( + # form: form, + # field: :name + # ) %> + # <%= form.submit "Save" %> + # <% end %> + def overview + render_with_template( + locals: { + sizes: current_component::SIZES.keys, + variants: { + "empty" => { + value: nil, disabled: false, hint: nil, errors: {} + }, + "filled" => { + value: "Alice", disabled: false, hint: nil, errors: {} + }, + "with_hint" => { + value: "Alice", disabled: false, hint: "Your name", errors: {} + }, + "empty_with_error" => { + value: nil, disabled: false, hint: nil, errors: { "empty_with_error" => ["can't be blank"] } + }, + "filled_with_error" => { + value: "Alice", disabled: false, hint: nil, errors: { "filled_with_error" => ["is invalid"] } + }, + "with_hint_and_error" => { + value: "Alice", disabled: false, hint: "Your name", errors: { "with_hint_and_error" => ["is invalid"] } + }, + "empty_disabled" => { + value: nil, disabled: true, hint: nil, errors: {} + }, + "filled_disabled" => { + value: "Alice", disabled: true, hint: nil, errors: {} + }, + "with_hint_disabled" => { + value: "Alice", disabled: true, hint: "Your name", errors: {} + } + } + } + ) + end + + # @param size select { choices: [s, m, l] } + # @param type select { choices: [color, date, datetime, email, month, number, password, phone, range, search, text, time, url, week] } + # @param label text + # @param value text + # @param hint text + # @param errors text (comma separated) + # @param placeholder text + # @param disabled toggle + def playground(size: :m, type: :text, label: "Name", value: nil, hint: nil, errors: "", placeholder: "Placeholder", disabled: false) + render_with_template( + locals: { + size: size.to_sym, + type: type.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_field/component_preview/overview.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/overview.html.erb new file mode 100644 index 00000000000..815e5474172 --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/overview.html.erb @@ -0,0 +1,34 @@ +<%= form_with(url: "#", method: :get) do |form| %> +
<%= size.to_s.humanize %> | + <% end %> +
+ <%= + 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 %> +