stimulus_plumbers 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +75 -0
  3. data/app/assets/javascripts/stimulus-plumbers/.keep +0 -0
  4. data/app/assets/stylesheets/stimulus_plumbers/tokens.css +83 -0
  5. data/lib/stimulus_plumbers/components/action_list/renderer.rb +47 -0
  6. data/lib/stimulus_plumbers/components/avatar/renderer.rb +74 -0
  7. data/lib/stimulus_plumbers/components/button/renderer.rb +33 -0
  8. data/lib/stimulus_plumbers/components/calendar/month/turbo/days_of_month.rb +124 -0
  9. data/lib/stimulus_plumbers/components/calendar/month/turbo/days_of_week.rb +36 -0
  10. data/lib/stimulus_plumbers/components/calendar/month/turbo/renderer.rb +57 -0
  11. data/lib/stimulus_plumbers/components/calendar/renderer.rb +35 -0
  12. data/lib/stimulus_plumbers/components/card/renderer.rb +41 -0
  13. data/lib/stimulus_plumbers/components/date_picker/navigation.rb +49 -0
  14. data/lib/stimulus_plumbers/components/date_picker/navigator.rb +31 -0
  15. data/lib/stimulus_plumbers/components/date_picker/renderer.rb +82 -0
  16. data/lib/stimulus_plumbers/components/icon/renderer.rb +51 -0
  17. data/lib/stimulus_plumbers/components/plumber/base.rb +22 -0
  18. data/lib/stimulus_plumbers/components/plumber/dispatcher.rb +113 -0
  19. data/lib/stimulus_plumbers/components/plumber/html_options.rb +34 -0
  20. data/lib/stimulus_plumbers/components/plumber/renderer.rb +91 -0
  21. data/lib/stimulus_plumbers/components/popover/renderer.rb +46 -0
  22. data/lib/stimulus_plumbers/configuration.rb +39 -0
  23. data/lib/stimulus_plumbers/engine.rb +21 -0
  24. data/lib/stimulus_plumbers/form/builder.rb +67 -0
  25. data/lib/stimulus_plumbers/form/field_component.rb +80 -0
  26. data/lib/stimulus_plumbers/form/fields/choice.rb +25 -0
  27. data/lib/stimulus_plumbers/form/fields/file.rb +16 -0
  28. data/lib/stimulus_plumbers/form/fields/renderer.rb +57 -0
  29. data/lib/stimulus_plumbers/form/fields/select.rb +27 -0
  30. data/lib/stimulus_plumbers/form/fields/text.rb +25 -0
  31. data/lib/stimulus_plumbers/form/fields/text_area.rb +16 -0
  32. data/lib/stimulus_plumbers/helpers/action_list_helper.rb +25 -0
  33. data/lib/stimulus_plumbers/helpers/avatar_helper.rb +17 -0
  34. data/lib/stimulus_plumbers/helpers/button_helper.rb +25 -0
  35. data/lib/stimulus_plumbers/helpers/calendar_helper.rb +26 -0
  36. data/lib/stimulus_plumbers/helpers/calendar_turbo_helper.rb +31 -0
  37. data/lib/stimulus_plumbers/helpers/card_helper.rb +21 -0
  38. data/lib/stimulus_plumbers/helpers/date_picker_helper.rb +17 -0
  39. data/lib/stimulus_plumbers/helpers/plumber_helper.rb +15 -0
  40. data/lib/stimulus_plumbers/helpers/popover_helper.rb +17 -0
  41. data/lib/stimulus_plumbers/helpers.rb +25 -0
  42. data/lib/stimulus_plumbers/logger.rb +20 -0
  43. data/lib/stimulus_plumbers/themes/action_list.rb +14 -0
  44. data/lib/stimulus_plumbers/themes/avatar.rb +14 -0
  45. data/lib/stimulus_plumbers/themes/base.rb +73 -0
  46. data/lib/stimulus_plumbers/themes/button.rb +18 -0
  47. data/lib/stimulus_plumbers/themes/calendar.rb +15 -0
  48. data/lib/stimulus_plumbers/themes/card.rb +12 -0
  49. data/lib/stimulus_plumbers/themes/form.rb +30 -0
  50. data/lib/stimulus_plumbers/themes/layout.rb +12 -0
  51. data/lib/stimulus_plumbers/themes/schema/ranges.rb +15 -0
  52. data/lib/stimulus_plumbers/themes/tailwind/action_list.rb +33 -0
  53. data/lib/stimulus_plumbers/themes/tailwind/avatar.rb +52 -0
  54. data/lib/stimulus_plumbers/themes/tailwind/button.rb +89 -0
  55. data/lib/stimulus_plumbers/themes/tailwind/calendar.rb +34 -0
  56. data/lib/stimulus_plumbers/themes/tailwind/card.rb +24 -0
  57. data/lib/stimulus_plumbers/themes/tailwind/form.rb +104 -0
  58. data/lib/stimulus_plumbers/themes/tailwind/layout.rb +25 -0
  59. data/lib/stimulus_plumbers/themes/tailwind_theme.rb +29 -0
  60. data/lib/stimulus_plumbers/version.rb +5 -0
  61. data/lib/stimulus_plumbers.rb +48 -0
  62. metadata +129 -0
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Components
5
+ module DatePicker
6
+ class Navigator < Plumber::Base
7
+ def render(icon_options: nil, **kwargs)
8
+ html_options = merge_html_options(
9
+ { classes: theme.resolve(:calendar_navigation_navigator).fetch(:classes, "") },
10
+ kwargs
11
+ )
12
+
13
+ if icon_options.nil?
14
+ template.content_tag(:button, nil, **html_options)
15
+ else
16
+ template.content_tag(:button, icon(icon_options), **html_options)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def icon(icon_options)
23
+ Icon::Renderer.new(template).icon(
24
+ classes: theme.resolve(:calendar_navigation_navigator_icon).fetch(:classes, ""),
25
+ **icon_options
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Components
5
+ module DatePicker
6
+ class Renderer < Plumber::Base
7
+ STIMULUS_CONTROLLER = "datepicker"
8
+ POPOVER_CONTROLLER = "popover"
9
+ CALENDAR_CONTROLLER = Calendar::Renderer::OBSERVER_STIMULUS_CONTROLLER
10
+ CALENDAR_OUTLET = "#{STIMULUS_CONTROLLER}_#{Calendar::Renderer::STIMULUS_CONTROLLER}_outlet".freeze
11
+ STIMULUS_DATA = {
12
+ controller: "#{STIMULUS_CONTROLLER} #{POPOVER_CONTROLLER}",
13
+ action: "#{CALENDAR_CONTROLLER}:selected->#{STIMULUS_CONTROLLER}#selected " \
14
+ "#{CALENDAR_CONTROLLER}:selected->#{POPOVER_CONTROLLER}#hide"
15
+ }.freeze
16
+
17
+ def render(calendar_id: nil, calendar_dialog_id: nil, **kwargs)
18
+ data = calendar_id ? STIMULUS_DATA.merge(CALENDAR_OUTLET => "##{calendar_id}") : STIMULUS_DATA
19
+ html_options = merge_html_options(
20
+ { classes: theme.resolve(:datepicker).fetch(:classes, ""), data: data },
21
+ kwargs
22
+ )
23
+
24
+ template.content_tag(:div, **html_options) do
25
+ template.safe_join([display_input(calendar_dialog_id), hidden_input, popover(calendar_id, calendar_dialog_id)])
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def display_input(calendar_dialog_id)
32
+ template.tag.input(
33
+ type: "text",
34
+ role: "combobox",
35
+ aria: { label: "Date", haspopup: "dialog", controls: calendar_dialog_id },
36
+ data: {
37
+ "#{STIMULUS_CONTROLLER}_target": "display",
38
+ "#{POPOVER_CONTROLLER}_target": "activator",
39
+ action: [
40
+ "focus->#{POPOVER_CONTROLLER}#show",
41
+ "click->#{POPOVER_CONTROLLER}#show"
42
+ ].join(" ")
43
+ }
44
+ )
45
+ end
46
+
47
+ def hidden_input
48
+ template.tag.input(
49
+ type: "hidden",
50
+ data: { "#{STIMULUS_CONTROLLER}_target": "input" }
51
+ )
52
+ end
53
+
54
+ def popover(calendar_id, calendar_dialog_id)
55
+ template.content_tag(
56
+ :div,
57
+ id: calendar_dialog_id,
58
+ role: "dialog",
59
+ aria: { label: "Date picker" },
60
+ data: { "#{POPOVER_CONTROLLER}_target": "content" },
61
+ hidden: ""
62
+ ) do
63
+ template.safe_join(
64
+ [
65
+ navigation(stimulus_controller: STIMULUS_CONTROLLER, step: "month"),
66
+ calendar_month(id: calendar_id)
67
+ ]
68
+ )
69
+ end
70
+ end
71
+
72
+ def navigation(**kwargs)
73
+ Navigation.new(template).render(stimulus_controller: STIMULUS_CONTROLLER, step: "month", **kwargs)
74
+ end
75
+
76
+ def calendar_month(**kwargs)
77
+ Calendar::Renderer.new(template).month(**kwargs)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Components
5
+ module Icon
6
+ class Renderer < Plumber::Base
7
+ ICONS = {
8
+ "arrow-left" => "M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18",
9
+ "arrow-right" => "M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3",
10
+ "arrow-up" => "M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18",
11
+ "arrow-down" => "M19.5 13.5 12 21m0 0-7.5-7.5M12 21V3"
12
+ }.freeze
13
+
14
+ def icon(name:, **kwargs)
15
+ html_options = merge_html_options(
16
+ { classes: theme.resolve(:icon).fetch(:classes, "") },
17
+ kwargs
18
+ )
19
+
20
+ if ICONS[name]
21
+ svg_icon(ICONS[name], html_options)
22
+ else
23
+ template.content_tag(:span, nil, **html_options)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def svg_icon(path, html_options)
30
+ template.content_tag(
31
+ :svg,
32
+ xmlns: "http://www.w3.org/2000/svg",
33
+ fill: "none",
34
+ viewBox: "0 0 24 24",
35
+ width: "24",
36
+ height: "24",
37
+ "stroke-width": "1.5",
38
+ stroke: "currentColor",
39
+ **html_options
40
+ ) do
41
+ template.tag.path(
42
+ "stroke-linecap": "round",
43
+ "stroke-linejoin": "round",
44
+ d: path
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Components
5
+ module Plumber
6
+ class Base
7
+ include HtmlOptions
8
+ include Renderer
9
+
10
+ attr_reader :template
11
+
12
+ def initialize(template)
13
+ @template = template
14
+ end
15
+
16
+ def theme
17
+ StimulusPlumbers.config.theme
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module StimulusPlumbers
6
+ module Components
7
+ module Plumber
8
+ module Dispatcher
9
+ class MethodCall
10
+ attr_reader :method_name, :args, :kwargs
11
+
12
+ def initialize(method_name, *args, **kwargs)
13
+ @method_name = method_name
14
+ @args = args
15
+ @kwargs = kwargs
16
+ validate!
17
+ end
18
+
19
+ def call(target)
20
+ raise NotImplementedError, "#{method_name.inspect} not implemented" unless target.respond_to?(method_name, true)
21
+
22
+ method_call = target.method(method_name)
23
+ accepts_args = method_call.arity.negative? ? args : args.take(method_call.arity)
24
+ accepts_kwargs = method_call.parameters.any? { |type, _| %i[key keyreq keyrest].include?(type) }
25
+ accepts_kwargs ? method_call.call(*accepts_args, **kwargs) : method_call.call(*accepts_args)
26
+ end
27
+
28
+ private
29
+
30
+ def validate!
31
+ return if method_name.is_a?(String) || method_name.is_a?(Symbol)
32
+
33
+ raise ArgumentError, "invalid method name: #{method_name.inspect}"
34
+ end
35
+ end
36
+
37
+ class InstanceExec
38
+ attr_reader :block, :args, :kwargs
39
+
40
+ def initialize(block, *args, **kwargs)
41
+ @block = block
42
+ @args = args
43
+ @kwargs = kwargs
44
+ validate!
45
+ end
46
+
47
+ def call(target)
48
+ accepts_args = block.arity.negative? ? args : args.take(block.arity)
49
+ accepts_kwargs = block.parameters.any? { |type, _| %i[key keyreq keyrest].include?(type) }
50
+ if accepts_kwargs
51
+ target.instance_exec(
52
+ *accepts_args,
53
+ **kwargs,
54
+ &block
55
+ )
56
+ else
57
+ target.instance_exec(*accepts_args, &block)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def validate!
64
+ raise ArgumentError, "invalid block: #{block.inspect}" unless block.is_a?(Proc)
65
+ end
66
+ end
67
+
68
+ class KlassProxy
69
+ attr_reader :klass, :method_name, :args, :kwargs, :init_args, :init_kwargs
70
+
71
+ def initialize(klass, method_name, *args, init_args: [], init_kwargs: {}, **kwargs)
72
+ @klass = klass
73
+ @method_name = method_name
74
+ @args = args
75
+ @kwargs = kwargs
76
+ @init_args = init_args
77
+ @init_kwargs = init_kwargs
78
+ validate!
79
+ end
80
+
81
+ def call(_target)
82
+ klass.new(*init_args, **init_kwargs).public_send(method_name, *args, **kwargs)
83
+ end
84
+
85
+ private
86
+
87
+ def validate!
88
+ raise ArgumentError, "invalid class: #{klass.inspect}" unless klass.is_a?(Module)
89
+ return if method_name.is_a?(String) || method_name.is_a?(Symbol)
90
+
91
+ raise ArgumentError, "invalid method name: #{method_name.inspect}"
92
+ end
93
+ end
94
+
95
+ def self.build(callable, *args, method_name: nil, init_args: [], init_kwargs: {}, **kwargs)
96
+ case callable
97
+ when Symbol
98
+ MethodCall.new(callable, *args, **kwargs)
99
+ when Proc
100
+ InstanceExec.new(callable, *args, **kwargs)
101
+ when Module
102
+ KlassProxy.new(callable, method_name, *args, init_args: init_args, init_kwargs: init_kwargs, **kwargs)
103
+ when String
104
+ klass = callable.safe_constantize
105
+ raise ArgumentError, "could not resolve class from: #{callable.inspect}" unless klass
106
+
107
+ KlassProxy.new(klass, method_name, *args, init_args: init_args, init_kwargs: init_kwargs, **kwargs)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module StimulusPlumbers
6
+ module Components
7
+ module Plumber
8
+ module HtmlOptions
9
+ extend ActiveSupport::Concern
10
+
11
+ def merge_html_options(*hashes)
12
+ classes = hashes.flat_map { |h| [h[:class], h[:classes]] }
13
+ rest = hashes.map { |h| h.except(:class, :classes) }.reduce({}, :deep_merge)
14
+ class_value = merge_string_option(*classes).presence
15
+ class_value ? rest.merge(class: class_value) : rest
16
+ end
17
+
18
+ def merge_string_option(*parts, delimiter: " ")
19
+ tokens = parts.flat_map { |part| normalize_part(part, delimiter) }
20
+ tokens.compact.uniq.join(delimiter)
21
+ end
22
+
23
+ def normalize_part(value, delimiter)
24
+ case value
25
+ when String then value.present? ? value.split(delimiter) : []
26
+ when Hash then value.filter_map { |key, val| key if val }
27
+ when Array then [merge_string_option(*value).presence]
28
+ else []
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module StimulusPlumbers
6
+ module Components
7
+ module Plumber
8
+ module Renderer
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ class_attribute :renderers, instance_writer: false, default: {}
13
+ end
14
+
15
+ module ClassMethods
16
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
17
+ def renders(method_name, with: nil, &block)
18
+ raise ArgumentError, "method_name must be Symbol" unless method_name.is_a?(Symbol)
19
+ raise ArgumentError, "provide either with: or a block" if !with.nil? && block_given?
20
+
21
+ with = block if block_given?
22
+
23
+ with_proc_or_symbol = with.is_a?(Proc) || with.is_a?(Symbol)
24
+ with_klazz = with.is_a?(Module) || with.is_a?(String)
25
+ raise ArgumentError, "with: must be a Symbol/Proc/Class" unless with_proc_or_symbol || with_klazz
26
+
27
+ self.renderers = renderers.merge(method_name => with)
28
+ ActiveSupport.version >= "7.2" ? generate_renderer_method(method_name) : eval_renderer_method(method_name)
29
+ end
30
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
31
+
32
+ private
33
+
34
+ def generated_renderer_methods
35
+ @generated_renderer_methods ||= Module.new.tap { |mod| prepend mod }
36
+ end
37
+
38
+ def eval_renderer_method(method_name)
39
+ generated_renderer_methods.module_eval(<<-RUBY, __FILE__, __LINE__ + 1)
40
+ # def method_name(*args, **kwargs)
41
+ # renderer = renderers.fetch(:method_name, {})
42
+ #
43
+ # unless renderer.present?
44
+ # raise ArgumentError, "#method_name not found in renderer" unless defined?(super)
45
+ # super
46
+ # end
47
+ #
48
+ # dispatcher = StimulusPlumbers::Components::Plumber::Dispatcher.build(
49
+ # renderer, *args, method_name: :#{method_name}, init_args: [template], **kwargs
50
+ # )
51
+ # raise ArgumentError, "invalid renderer, got: \#{renderer.inspect}" unless dispatcher
52
+ #
53
+ # dispatcher.call(self)
54
+ # end
55
+ #{renderer_method_template(method_name)}
56
+ RUBY
57
+ end
58
+
59
+ def generate_renderer_method(method_name)
60
+ require "active_support/code_generator"
61
+ ActiveSupport::CodeGenerator.batch(generated_renderer_methods, __FILE__, __LINE__) do |owner|
62
+ owner.define_cached_method(method_name, namespace: :plumber_renderers) do |batch|
63
+ batch << renderer_method_template(method_name)
64
+ end
65
+ end
66
+ end
67
+
68
+ def renderer_method_template(method_name)
69
+ <<-RUBY
70
+ def #{method_name}(*args, **kwargs)
71
+ renderer = renderers.fetch(:#{method_name}, {})
72
+
73
+ unless renderer.present?
74
+ raise ArgumentError, "##{method_name} not found in renderer" unless defined?(super)
75
+ super
76
+ end
77
+
78
+ dispatcher = StimulusPlumbers::Components::Plumber::Dispatcher.build(
79
+ renderer, *args, method_name: :#{method_name}, init_args: [template], **kwargs
80
+ )
81
+ raise ArgumentError, "invalid renderer, got: \#{renderer.inspect}" unless dispatcher
82
+
83
+ dispatcher.call(self)
84
+ end
85
+ RUBY
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Components
5
+ module Popover
6
+ class Builder
7
+ attr_reader :activator_html, :content_html
8
+
9
+ def initialize(template)
10
+ @template = template
11
+ @activator_html = "".html_safe
12
+ @content_html = "".html_safe
13
+ end
14
+
15
+ def activator(&block)
16
+ @activator_html = @template.capture(&block)
17
+ end
18
+
19
+ def content(&block)
20
+ @content_html = @template.capture(&block)
21
+ end
22
+ end
23
+
24
+ class Renderer < Plumber::Base
25
+ def popover(interactive: true, **kwargs, &block)
26
+ html_options = merge_html_options(
27
+ { classes: theme.resolve(:popover).fetch(:classes, "") },
28
+ kwargs
29
+ )
30
+
31
+ builder = Builder.new(template)
32
+ template.capture(builder, &block)
33
+
34
+ template.content_tag(:div, **html_options) do
35
+ wrapped_content = if interactive
36
+ template.content_tag(:template, builder.content_html)
37
+ else
38
+ builder.content_html
39
+ end
40
+ template.safe_join([builder.activator_html, wrapped_content])
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "themes/base"
4
+ require_relative "themes/tailwind_theme"
5
+
6
+ module StimulusPlumbers
7
+ class Configuration
8
+ DEFAULT_LOG_FORMATTER = ->(message) { "[StimulusPlumbers] #{message}" }
9
+ THEME_KLASS_FORMATTER = ->(type) { "StimulusPlumbers::Themes::#{type.to_s.classify}Theme" }
10
+
11
+ def theme
12
+ @theme ||= build_theme(:tailwind)
13
+ end
14
+
15
+ def theme=(value)
16
+ @theme = build_theme(value)
17
+ end
18
+
19
+ def log_formatter
20
+ @log_formatter ||= DEFAULT_LOG_FORMATTER
21
+ end
22
+
23
+ def log_formatter=(callable)
24
+ raise ArgumentError, "log_formatter must respond to #call" unless callable.respond_to?(:call)
25
+
26
+ @log_formatter = callable
27
+ end
28
+
29
+ private
30
+
31
+ def build_theme(type)
32
+ return type if type.is_a?(Themes::Base)
33
+
34
+ klass_name = THEME_KLASS_FORMATTER.call(type)
35
+ klass_name.safe_constantize&.new or
36
+ raise ArgumentError, "Unknown theme #{type.inspect}: #{klass_name} is not defined."
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module StimulusPlumbers
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace StimulusPlumbers
8
+
9
+ config.autoload_paths << File.expand_path("../stimulus-plumbers", __dir__)
10
+
11
+ initializer "stimulus_plumbers.assets" do |app|
12
+ app.config.assets.paths << root.join("app/assets/javascripts")
13
+ end
14
+
15
+ initializer "stimulus_plumbers.helpers" do
16
+ ActiveSupport.on_load(:action_view) do
17
+ include StimulusPlumbers::Helpers
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view/version"
4
+
5
+ require_relative "field_component"
6
+ require_relative "fields/renderer"
7
+ require_relative "fields/text"
8
+ require_relative "fields/text_area"
9
+ require_relative "fields/file"
10
+ require_relative "fields/select"
11
+ require_relative "fields/choice"
12
+ require_relative "../components/plumber/html_options"
13
+
14
+ module StimulusPlumbers
15
+ module Form
16
+ class Builder < ActionView::Helpers::FormBuilder
17
+ include Components::Plumber::HtmlOptions
18
+ include Fields::Text
19
+ include Fields::TextArea
20
+ include Fields::File
21
+ include Fields::Select
22
+ include Fields::Choice
23
+
24
+ private
25
+
26
+ def build_field(attribute, form_field_opts, input_id: field_id(attribute))
27
+ FieldComponent.new(
28
+ object: object,
29
+ attribute: attribute,
30
+ input_id: input_id,
31
+ label: form_field_opts[:label],
32
+ details: form_field_opts[:details],
33
+ error: form_field_opts[:error],
34
+ required: form_field_opts.fetch(:required, false),
35
+ label_visibility: form_field_opts.fetch(:label_visibility, :visible),
36
+ layout: form_field_opts.fetch(:layout, :stacked)
37
+ )
38
+ end
39
+
40
+ def render_field(field, input_html)
41
+ Fields::Renderer.new(@template, theme, field).call(input_html)
42
+ end
43
+
44
+ def extract_options(options)
45
+ [options.except(*FieldComponent::OPTIONS), options.slice(*FieldComponent::OPTIONS)]
46
+ end
47
+
48
+ def field_theme(key, **variants)
49
+ { class: theme.resolve(key, **variants).fetch(:classes, "") }
50
+ end
51
+
52
+ def theme
53
+ StimulusPlumbers.config.theme
54
+ end
55
+
56
+ # field_id was added in Rails 7.0. Provide a compatible implementation for Rails 6.1.
57
+ if ActionView.version < "7.0"
58
+ def field_id(attribute, *suffixes, index: @index, namespace: @options[:namespace])
59
+ tokens = [namespace, @object_name, index, attribute, *suffixes]
60
+ tokens.select!(&:present?)
61
+ tokens.map! { |t| t.to_s.delete("]").tr("^-a-zA-Z0-9:.", "_") }
62
+ tokens.join("_")
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Form
5
+ class FieldComponent
6
+ OPTIONS = %i[label details error required label_visibility layout].freeze
7
+
8
+ attr_reader :object,
9
+ :attribute,
10
+ :input_id,
11
+ :label_text,
12
+ :details,
13
+ :required,
14
+ :label_visibility,
15
+ :layout
16
+
17
+ def initialize(object:,
18
+ attribute:,
19
+ input_id:,
20
+ label: nil,
21
+ details: nil,
22
+ error: nil,
23
+ required: false,
24
+ label_visibility: :visible,
25
+ layout: :stacked)
26
+ @object = object
27
+ @attribute = attribute
28
+ @input_id = input_id
29
+ @label_text = label || attribute.to_s.humanize
30
+ @details = details
31
+ @error_override = error
32
+ @required = required
33
+ @label_visibility = label_visibility.to_sym
34
+ @layout = layout.to_sym
35
+ end
36
+
37
+ def errors
38
+ if @error_override
39
+ Array(@error_override)
40
+ elsif object.respond_to?(:errors)
41
+ object.errors[@attribute]
42
+ else
43
+ []
44
+ end
45
+ end
46
+
47
+ def error?
48
+ errors.any?
49
+ end
50
+
51
+ def html_opts
52
+ attrs = { id: input_id }
53
+ attrs[:"aria-describedby"] = described_by if described_by
54
+ attrs[:"aria-invalid"] = "true" if error?
55
+ attrs[:required] = true if required
56
+ attrs[:"aria-required"] = "true" if required
57
+ attrs
58
+ end
59
+
60
+ def hint_id
61
+ "#{input_id}_hint"
62
+ end
63
+
64
+ def error_id
65
+ "#{input_id}_error"
66
+ end
67
+
68
+ def described_by
69
+ ids = []
70
+ ids << hint_id if details.present?
71
+ ids << error_id if errors.any?
72
+ ids.join(" ").presence
73
+ end
74
+
75
+ def label_hidden?
76
+ label_visibility == :exclusive
77
+ end
78
+ end
79
+ end
80
+ end