Created
July 3, 2024 17:19
-
-
Save searls/90bab1239e88fe9f86278cea481303e7 to your computer and use it in GitHub Desktop.
Here's a custom Tailwind FormBuilder for Rails. To set this up, just set ` ActionView::Base.default_form_builder = FormBuilders::TailwindFormBuilder` somewhere (and customize all the CSS classes)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class TailwindClassBuilder | |
include ActionView::Helpers::TagHelper | |
def button_classes(options) | |
button_type = options.delete(:button_type) { :button } | |
class_names( | |
# general classes | |
"mt-4 px-1 sm:px-3 py-sm sm:py-1 font-semibold bg-transparent border rounded", | |
case button_type | |
when :button then "enabled:cursor-pointer disabled:cursor-not-allowed" | |
when :link then "no-link-style cursor-pointer whitespace-nowrap inline-block h-fit " | |
end, | |
# color classes | |
case options.delete(:variant) { :commit } | |
when :success, :commit, :notice | |
["text-success border-success", (button_type == :button) ? | |
"enabled:hover:bg-success enabled:hover:text-success-background" : | |
"hover:bg-success hover:text-success-background"] | |
when :info | |
["text-info border-info", (button_type == :button) ? | |
"enabled:hover:bg-info enabled:hover:text-info-background" : | |
"hover:bg-info hover:text-info-background"] | |
when :warn, :alert | |
["text-warn border-warn", (button_type == :button) ? | |
"enabled:hover:bg-warn enabled:hover:text-warn-background" : | |
"hover:bg-warn hover:text-warn-background"] | |
when :danger, :error | |
["text-danger border-danger", (button_type == :button) ? | |
"enabled:hover:bg-danger enabled:hover:text-danger-background" : | |
"hover:bg-danger hover:text-danger-background"] | |
when :plain | |
["text-secondary border-secondary", (button_type == :button) ? | |
"enabled:hover:bg-text-secondary enabled:hover:text-inverted" : | |
"hover:bg-text-secondary hover:text-inverted"] | |
end | |
) | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module FormBuilders | |
class TailwindFormBuilder < ActionView::Helpers::FormBuilder | |
include ActionView::Helpers::TagHelper | |
include TailwindFormClasses | |
def initialize(...) | |
@tailwind_class_builder = TailwindClassBuilder.new | |
super | |
end | |
# Same list of dynamically-generated field helpers as in Rails: | |
# actionview/lib/action_view/helpers/form_helper.rb | |
[:text_field, | |
:password_field, | |
:text_area, | |
:color_field, | |
:search_field, | |
:telephone_field, | |
:phone_field, | |
:date_field, | |
:time_field, | |
:datetime_field, | |
:datetime_local_field, | |
:month_field, | |
:week_field, | |
:url_field, | |
:email_field, | |
:number_field, | |
:range_field, | |
:file_field].each do |field_method| | |
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 | |
def #{field_method}(method, options = {}) | |
if options.delete(:tailwindified) | |
super | |
else | |
text_like_field(#{field_method.inspect}, method, options) | |
end | |
end | |
RUBY_EVAL | |
end | |
def button(value = nil, options = {}, &block) | |
super(value, {class: button_classes(options)}.merge(options)) | |
end | |
def submit(value = nil, options = {}) | |
value, options = nil, value if value.is_a?(Hash) | |
value ||= submit_default_value | |
super(value, {class: button_classes(options)}.merge(options)) | |
end | |
def button_classes(options) | |
@tailwind_class_builder.button_classes(options) | |
end | |
def check_box(method, options = {}, checked_value = "1", unchecked_value = "0") | |
custom_opts, opts = partition_custom_opts(options) | |
field = super(method, { | |
class: check_box_classes(method, custom_opts[:field_classes]) | |
}.merge(opts), checked_value, unchecked_value) | |
label = tailwind_label(method, { | |
class: "block pt-xs" | |
}.merge(custom_opts[:label] || {}), opts) | |
@template.content_tag("div", field + label, {class: custom_opts[:wrapper_classes] || "mt-2 flex items-center gap-x-1"}) | |
end | |
def collection_check_boxes(method, collection, value_method, text_method, options = {}, html_options = {}, &) | |
extra_html_options = { | |
class: check_box_classes(method) | |
}.merge(html_options) | |
super(method, collection, value_method, text_method, options, extra_html_options, &) | |
end | |
def label(method, text = nil, options = {}, &) | |
super(method, text, options.merge(class: class_names( | |
(options[:class] || "mt-4 block w-full font-bold after:content-[':']"), | |
(options.delete(:extra_label_classes) if options.key?(:extra_label_classes)) | |
)), &) | |
end | |
def tailwind_label(method, label_options, field_options) | |
custom_label_opts, label_opts = partition_custom_label_opts(label_options) | |
return if custom_label_opts[:skip] | |
label(method, custom_label_opts[:text], { | |
class: custom_label_opts[:class], | |
extra_label_classes: class_names( | |
("text-disabled" if field_options[:disabled]), | |
("hidden" if custom_label_opts[:hidden]), | |
("!inline-block !w-fit" if custom_label_opts[:inline]), | |
(custom_label_opts[:extra_classes]) | |
) | |
}.merge(label_opts)) | |
end | |
def select_or_collection_select(method, *args, html_options, &block) | |
custom_opts, html_opts = partition_custom_opts(html_options) | |
label = tailwind_label(method, custom_opts[:label], html_options) | |
[label, {class: class_names( | |
"mt-1 rounded-md shadow-sm focus:ring focus:ring-success focus:ring-opacity-50", | |
(custom_opts[:field_classes] if custom_opts[:field_classes].present?), | |
("block w-full" unless custom_opts[:inline]), | |
border_color_classes(method) | |
)}.merge(html_opts)] | |
end | |
def select(method, choices = nil, options = {}, html_options = {}, &) | |
label, html_opts = select_or_collection_select(method, choices, options, html_options, &) | |
field = super(method, choices, options, html_opts, &) | |
if label.present? | |
label + field | |
else | |
field | |
end | |
end | |
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {}) | |
label, html_opts = select_or_collection_select(method, collection, value_method, text_method, options, html_options) | |
field = super(method, collection, value_method, text_method, options, html_opts) | |
if label.present? | |
label + field | |
else | |
field | |
end | |
end | |
def combobox(method, options_or_src, **kwargs, &) | |
custom_opts, html_opts = partition_custom_opts(kwargs) | |
# Looking for CSS classes? They're overridden in app/assets/stylesheets/application.tailwind.css | |
label = tailwind_label(method, custom_opts[:label], html_opts) | |
field = super(method, options_or_src, **html_opts.merge( | |
dialog_label: custom_opts.dig(:label, :text) || method.to_s.humanize, | |
input: html_opts.without(:include_blank, :render_in), | |
include_blank: (html_opts[:include_blank] == true) ? "None" : html_opts[:include_blank] | |
), &) | |
if label.present? | |
label + field | |
else | |
field | |
end | |
end | |
private | |
def text_like_field(field_method, object_method, options = {}) | |
custom_opts, opts = partition_custom_opts(options) | |
label = tailwind_label(object_method, custom_opts[:label], options) | |
field = send(field_method, object_method, { | |
class: class_names( | |
text_like_field_classes, | |
(custom_opts[:field_classes] if custom_opts[:field_classes].present?), | |
("mt-1 block w-full" unless custom_opts[:inline]), | |
border_color_classes(object_method) | |
), | |
title: errors_for(object_method)&.join(" ") | |
}.compact.merge(opts).merge({tailwindified: true})) | |
if label.present? | |
label + field | |
else | |
field | |
end | |
end | |
def border_color_classes(object_method) | |
if errors_for(object_method).present? | |
error_border_color_classes | |
else | |
success_border_color_classes | |
end | |
end | |
CUSTOM_OPTS = [:inline, :label, :field_classes, :wrapper_classes].freeze | |
def partition_custom_opts(opts) | |
(opts || {}).partition { |k, v| CUSTOM_OPTS.include?(k) }.map(&:to_h) | |
end | |
CUSTOM_LABEL_OPTS = [:text, :class, :inline, :extra_classes, :hidden, :skip] | |
def partition_custom_label_opts(opts) | |
(opts || {}).partition { |k, v| CUSTOM_LABEL_OPTS.include?(k) }.map(&:to_h) | |
end | |
def errors_for(object_method) | |
return if @object.blank? | |
# The story here is that Rails adds association errors onto the | |
# association by name, not "_id" So errors may be on :client but the form | |
# field has to be :client_id | |
if /_id$/.match?(object_method.to_s) | |
@object.errors[object_method].presence || @object.errors[object_method.to_s.gsub(/_id$/, "").to_sym] | |
else | |
@object.errors[object_method] | |
end | |
end | |
def check_box_classes(method, field_classes = nil) | |
classes = <<~CLASSES.strip | |
block rounded size-3.5 focus:ring focus:ring-success checked:bg-success checked:hover:bg-success/90 cursor-pointer focus:ring-opacity-50 | |
CLASSES | |
"#{classes} #{field_classes} #{border_color_classes(method)}" | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
class_names
handles conditionals.Instead of
(custom_opts[:field_classes] if custom_opts[:field_classes].present?)
you can do{custom_opts[:field_classes] => custom_opts[:field_classes].present?}
.