Skip to content

Commit

Permalink
Add select component
Browse files Browse the repository at this point in the history
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
  • Loading branch information
waiting-for-dev authored and elia committed Sep 27, 2023
1 parent 6abf016 commit ac102e1
Show file tree
Hide file tree
Showing 6 changed files with 345 additions and 0 deletions.
33 changes: 33 additions & 0 deletions admin/app/components/solidus_admin/ui/forms/select/component.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
161 changes: 161 additions & 0 deletions admin/app/components/solidus_admin/ui/forms/select/component.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<%= form_with(url: "#", scope: :overview, method: :get, class: "w-full") do |form| %>
<table>
<thead>
<tr>
<% sizes.each do |size| %>
<td class="px-3 py-1 text-gray-500 text-center body-text"><%= size.to_s.humanize %></td>
<% end %>
</tr>
</thead>
<tbody>
<%
variants.each_pair do |name, definition| %>
<tr>
<% sizes.each do |size| %>
<td class="px-3 py-1">
<%=
render current_component.new(
form: form,
field: name,
choices: choices,
size: size,
errors: definition[:errors],
hint: definition[:hint],
options: definition[:options],
attributes: definition[:attributes]
)
%>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<% end %>
Original file line number Diff line number Diff line change
@@ -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 %>
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit ac102e1

Please sign in to comment.