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.
- checksums.yaml +7 -0
- data/README.md +75 -0
- data/app/assets/javascripts/stimulus-plumbers/.keep +0 -0
- data/app/assets/stylesheets/stimulus_plumbers/tokens.css +83 -0
- data/lib/stimulus_plumbers/components/action_list/renderer.rb +47 -0
- data/lib/stimulus_plumbers/components/avatar/renderer.rb +74 -0
- data/lib/stimulus_plumbers/components/button/renderer.rb +33 -0
- data/lib/stimulus_plumbers/components/calendar/month/turbo/days_of_month.rb +124 -0
- data/lib/stimulus_plumbers/components/calendar/month/turbo/days_of_week.rb +36 -0
- data/lib/stimulus_plumbers/components/calendar/month/turbo/renderer.rb +57 -0
- data/lib/stimulus_plumbers/components/calendar/renderer.rb +35 -0
- data/lib/stimulus_plumbers/components/card/renderer.rb +41 -0
- data/lib/stimulus_plumbers/components/date_picker/navigation.rb +49 -0
- data/lib/stimulus_plumbers/components/date_picker/navigator.rb +31 -0
- data/lib/stimulus_plumbers/components/date_picker/renderer.rb +82 -0
- data/lib/stimulus_plumbers/components/icon/renderer.rb +51 -0
- data/lib/stimulus_plumbers/components/plumber/base.rb +22 -0
- data/lib/stimulus_plumbers/components/plumber/dispatcher.rb +113 -0
- data/lib/stimulus_plumbers/components/plumber/html_options.rb +34 -0
- data/lib/stimulus_plumbers/components/plumber/renderer.rb +91 -0
- data/lib/stimulus_plumbers/components/popover/renderer.rb +46 -0
- data/lib/stimulus_plumbers/configuration.rb +39 -0
- data/lib/stimulus_plumbers/engine.rb +21 -0
- data/lib/stimulus_plumbers/form/builder.rb +67 -0
- data/lib/stimulus_plumbers/form/field_component.rb +80 -0
- data/lib/stimulus_plumbers/form/fields/choice.rb +25 -0
- data/lib/stimulus_plumbers/form/fields/file.rb +16 -0
- data/lib/stimulus_plumbers/form/fields/renderer.rb +57 -0
- data/lib/stimulus_plumbers/form/fields/select.rb +27 -0
- data/lib/stimulus_plumbers/form/fields/text.rb +25 -0
- data/lib/stimulus_plumbers/form/fields/text_area.rb +16 -0
- data/lib/stimulus_plumbers/helpers/action_list_helper.rb +25 -0
- data/lib/stimulus_plumbers/helpers/avatar_helper.rb +17 -0
- data/lib/stimulus_plumbers/helpers/button_helper.rb +25 -0
- data/lib/stimulus_plumbers/helpers/calendar_helper.rb +26 -0
- data/lib/stimulus_plumbers/helpers/calendar_turbo_helper.rb +31 -0
- data/lib/stimulus_plumbers/helpers/card_helper.rb +21 -0
- data/lib/stimulus_plumbers/helpers/date_picker_helper.rb +17 -0
- data/lib/stimulus_plumbers/helpers/plumber_helper.rb +15 -0
- data/lib/stimulus_plumbers/helpers/popover_helper.rb +17 -0
- data/lib/stimulus_plumbers/helpers.rb +25 -0
- data/lib/stimulus_plumbers/logger.rb +20 -0
- data/lib/stimulus_plumbers/themes/action_list.rb +14 -0
- data/lib/stimulus_plumbers/themes/avatar.rb +14 -0
- data/lib/stimulus_plumbers/themes/base.rb +73 -0
- data/lib/stimulus_plumbers/themes/button.rb +18 -0
- data/lib/stimulus_plumbers/themes/calendar.rb +15 -0
- data/lib/stimulus_plumbers/themes/card.rb +12 -0
- data/lib/stimulus_plumbers/themes/form.rb +30 -0
- data/lib/stimulus_plumbers/themes/layout.rb +12 -0
- data/lib/stimulus_plumbers/themes/schema/ranges.rb +15 -0
- data/lib/stimulus_plumbers/themes/tailwind/action_list.rb +33 -0
- data/lib/stimulus_plumbers/themes/tailwind/avatar.rb +52 -0
- data/lib/stimulus_plumbers/themes/tailwind/button.rb +89 -0
- data/lib/stimulus_plumbers/themes/tailwind/calendar.rb +34 -0
- data/lib/stimulus_plumbers/themes/tailwind/card.rb +24 -0
- data/lib/stimulus_plumbers/themes/tailwind/form.rb +104 -0
- data/lib/stimulus_plumbers/themes/tailwind/layout.rb +25 -0
- data/lib/stimulus_plumbers/themes/tailwind_theme.rb +29 -0
- data/lib/stimulus_plumbers/version.rb +5 -0
- data/lib/stimulus_plumbers.rb +48 -0
- 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
|