From ac102e1ce9965d12305a4ae9f16038e183a02883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Busqu=C3=A9?= Date: Wed, 30 Aug 2023 16:12:55 +0200 Subject: [PATCH] Add select component As with the other form components, it's design to be used along a Rails' form builder instance. We need to use Javascript to style the select box differently when the prompt option is the one selected. Ref. #5329 --- .../ui/forms/select/component.js | 33 ++++ .../ui/forms/select/component.rb | 161 ++++++++++++++++++ .../ui/forms/select/component_preview.rb | 85 +++++++++ .../component_preview/overview.html.erb | 34 ++++ .../component_preview/playground.html.erb | 19 +++ .../ui/forms/select/component_spec.rb | 13 ++ 6 files changed, 345 insertions(+) create mode 100644 admin/app/components/solidus_admin/ui/forms/select/component.js create mode 100644 admin/app/components/solidus_admin/ui/forms/select/component.rb create mode 100644 admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview.rb create mode 100644 admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview/overview.html.erb create mode 100644 admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview/playground.html.erb create mode 100644 admin/spec/components/solidus_admin/ui/forms/select/component_spec.rb diff --git a/admin/app/components/solidus_admin/ui/forms/select/component.js b/admin/app/components/solidus_admin/ui/forms/select/component.js new file mode 100644 index 00000000000..b8e937cdfb5 --- /dev/null +++ b/admin/app/components/solidus_admin/ui/forms/select/component.js @@ -0,0 +1,33 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + static targets = ['select', 'arrow'] + static classes = ['regular', 'prompt', 'arrowPrompt'] + + connect () { + this.addClassToOptions() + this.refreshSelectClass() + } + + // Add class to all the options to avoid inheriting the select's styles + addClassToOptions () { + this.selectTarget.querySelectorAll('option').forEach((option) => { + if (option.value == '') { + option.classList.add(this.promptClass) + } else { + option.classList.add(this.regularClass) + } + }) + } + + // Make the select look like a placeholder when the prompt is selected + refreshSelectClass () { + if (this.selectTarget.options[this.selectTarget.selectedIndex].value == '') { + this.selectTarget.classList.add(this.promptClass) + this.arrowTarget.classList.add(this.arrowPromptClass) + } else { + this.selectTarget.classList.remove(this.promptClass) + this.arrowTarget.classList.remove(this.arrowPromptClass) + } + } +} diff --git a/admin/app/components/solidus_admin/ui/forms/select/component.rb b/admin/app/components/solidus_admin/ui/forms/select/component.rb new file mode 100644 index 00000000000..ea47c46578c --- /dev/null +++ b/admin/app/components/solidus_admin/ui/forms/select/component.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +class SolidusAdmin::UI::Forms::Select::Component < SolidusAdmin::BaseComponent + SIZES = { + s: { + select: %w[leading-4 body-small], + arrow: %w[w-4 h-4] + }, + m: { + select: %w[leading-6 body-small], + arrow: %w[w-5 h-5] + }, + l: { + select: %w[leading-9 body-text], + arrow: %w[w-6 h-6] + } + }.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 choices [Array] an array of choices for the select box. All the + # formats valid for Rails' `select` helper are supported. + # @param hint [String, null] helper text to display below the select box. + # @param errors [Hash, nil] a Hash of errors for the field. If `nil` and the + # builder is bound to a model instance, the component will automatically fetch + # the errors from the model. + # @param options [Hash] additional options to pass to Rails' `select` helper. + # @param attributes [Hash] additional HTML attributes to add to the select box. + # @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, + choices: [], + hint: nil, + errors: nil, + label_component: component("ui/forms/label"), + guidance_component: component("ui/forms/guidance"), + options: {}, + attributes: {} + ) + @field = field + @form = form + @size = size + @choices = choices + @hint = hint + @options = options + @attributes = HashWithIndifferentAccess.new(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, + disabled: @attributes[:disabled] + ) + + tag.div(class: "mb-6") do + label_tag + field_wrapper_tag(guidance) + guidance_tag(guidance) + end + end + + def field_wrapper_tag(guidance) + tag.div( + class: "relative", + "data-controller" => stimulus_id, + "data-#{stimulus_id}-regular-class" => "text-black", + "data-#{stimulus_id}-prompt-class" => "text-gray-400", + "data-#{stimulus_id}-arrow-prompt-class" => "!fill-gray-500" + ) do + field_tag(guidance) + arrow_tag(guidance) + end + end + + def field_tag(guidance) + @form.select( + @field, + @choices, + @options, + class: field_classes(guidance), + **field_aria_describedby_attribute(guidance), + **field_error_attributes(guidance), + **@attributes.except(:class).merge( + "data-target" => "#{stimulus_id}.select", + "data-action" => "#{stimulus_id}#refreshSelectClass" + ) + ) + end + + def field_classes(guidance) + %w[ + block px-3 py-1.5 w-full + appearance-none + text-black + bg-white border border-gray-300 rounded-sm + hover:border-gray-500 + 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)[:select] + end + + def field_error_classes(guidance) + return [] unless guidance.errors? + + %w[border-red-400 text-red-400] + end + + def field_aria_describedby_attribute(guidance) + return {} unless guidance.needed? + + { + "aria-describedby": guidance.aria_describedby + } + end + + def field_error_attributes(guidance) + return {} unless guidance.errors? + + { + "aria-invalid": true + } + end + + def arrow_tag(guidance) + icon_tag( + "arrow-down-s-fill", + class: SIZES.fetch(@size)[:arrow] + [arrow_color_class(guidance)] + + %w[absolute right-3 top-1/2 translate-y-[-50%] pointer-events-none], + "data-target" => "#{stimulus_id}.arrow" + ) + end + + def arrow_color_class(guidance) + if @attributes[:disabled] + "fill-gray-500" + elsif guidance.errors? + "fill-red-400" + else + "fill-gray-700" + end + 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/spec/components/previews/solidus_admin/ui/forms/select/component_preview.rb b/admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview.rb new file mode 100644 index 00000000000..ce0fb2b1b1d --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# @component "ui/forms/select" +class SolidusAdmin::UI::Forms::Select::ComponentPreview < ViewComponent::Preview + include SolidusAdmin::Preview + + # The select component is used to render a select box in a form. + # + # See the [`ui/forms/text_field`](../text_field) component for usage + # instructions. + def overview + choices = [ + ["Option 1", "option_1"], + ["Option 2", "option_2"], + ["Option 3", "option_3"] + ] + hint = "Select one of the options" + render_with_template( + locals: { + sizes: current_component::SIZES.keys, + choices: choices, + variants: { + "with_prompt" => { + hint: nil, errors: {}, options: { prompt: "Select" }, attributes: {} + }, + "selected" => { + hint: nil, errors: {}, options: {}, attributes: {} + }, + "with_hint" => { + hint: hint, errors: {}, options: {}, attributes: {} + }, + "with_prompt_and_error" => { + hint: nil, errors: { "with_prompt_and_error" => ["can't be blank"] }, options: { prompt: "Select" }, attributes: {} + }, + "selected_with_error" => { + hint: nil, errors: { "selected_with_error" => ["is invalid"] }, options: {}, attributes: {} + }, + "with_hint_and_error" => { + hint: hint, errors: { "with_hint_and_error" => ["is invalid"] }, options: {}, attributes: {} + }, + "with_prompt_disabled" => { + hint: nil, errors: {}, options: { prompt: "Select" }, attributes: { disabled: true } + }, + "selected_disabled" => { + hint: nil, errors: {}, options: {}, attributes: { disabled: true } + }, + "with_hint_disabled" => { + hint: hint, errors: {}, options: {}, attributes: { disabled: true } + } + } + } + ) + end + + # @param size select { choices: [s, m, l] } + # @param choices text "Separate multiple choices with a comma" + # @param label text + # @param selected text + # @param hint text + # @param errors text "Separate multiple errors with a comma" + # @param prompt text + # @param disabled toggle + def playground( + size: :m, + choices: "Option 1, Option 2, Option 3", + label: "Choose:", + selected: "Option 1", + hint: nil, errors: "", + prompt: "Select", + disabled: false + ) + render_with_template( + locals: { + size: size.to_sym, + choices: choices.split(",").map(&:strip).map { [_1, _1.parameterize] }, + field: label, + selected: selected&.parameterize, + hint: hint, + errors: { label.dasherize => (errors.blank? ? [] : errors.split(",").map(&:strip)) }, + prompt: prompt, + disabled: disabled + } + ) + end +end diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview/overview.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview/overview.html.erb new file mode 100644 index 00000000000..d49e78b5b41 --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/select/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, + choices: choices, + size: size, + errors: definition[:errors], + hint: definition[:hint], + options: definition[:options], + attributes: definition[:attributes] + ) + %> +
+<% end %> diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview/playground.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview/playground.html.erb new file mode 100644 index 00000000000..76585837d53 --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/select/component_preview/playground.html.erb @@ -0,0 +1,19 @@ +<%= form_with(url: "#", scope: :playground, method: :get, class: "w-60") do |form| %> + <%= + render current_component.new( + form: form, + size: size, + choices: choices, + field: field, + hint: hint, + errors: errors, + options: { + prompt: prompt, + selected: selected + }, + attributes: { + disabled: disabled + } + ) + %> +<% end %> diff --git a/admin/spec/components/solidus_admin/ui/forms/select/component_spec.rb b/admin/spec/components/solidus_admin/ui/forms/select/component_spec.rb new file mode 100644 index 00000000000..1cc630faec1 --- /dev/null +++ b/admin/spec/components/solidus_admin/ui/forms/select/component_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusAdmin::UI::Forms::Select::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