vident 0.13.0 → 1.0.0.alpha1

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.
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module StimulusAttributes
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ # Class methods for generating scoped event names
9
+ def stimulus_scoped_event(event)
10
+ "#{component_name}:#{stimulus_js_name(event)}"
11
+ end
12
+
13
+ def stimulus_scoped_event_on_window(event)
14
+ "#{component_name}:#{stimulus_js_name(event)}@window"
15
+ end
16
+
17
+ private
18
+
19
+ def stimulus_js_name(name)
20
+ name.to_s.camelize(:lower)
21
+ end
22
+ end
23
+
24
+ # Parse inputs to create a StimulusController instance representing a Stimulus controller attribute
25
+ # examples:
26
+ # stimulus_controller("my_controller") => StimulusController that converts to {"controller" => "my-controller"}
27
+ # stimulus_controller("path/to/controller") => StimulusController that converts to {"controller" => "path--to--controller"}
28
+ # stimulus_controller() => StimulusController that uses implied controller name
29
+ def stimulus_controller(*args)
30
+ return args.first if args.length == 1 && args.first.is_a?(StimulusController)
31
+ StimulusController.new(*args, implied_controller: implied_controller_path)
32
+ end
33
+
34
+ # Parse inputs to create a StimulusControllerCollection instance representing multiple Stimulus controllers
35
+ # examples:
36
+ # stimulus_controllers(:my_controller) => StimulusControllerCollection with one controller that converts to {"controller" => "my-controller"}
37
+ # stimulus_controllers(:my_controller, "path/to/another") => StimulusControllerCollection with two controllers that converts to {"controller" => "my-controller path--to--another"}
38
+ def stimulus_controllers(*controllers)
39
+ return StimulusControllerCollection.new if controllers.empty? || controllers.all?(&:blank?)
40
+
41
+ converted_controllers = controllers.map do |controller|
42
+ controller.is_a?(Array) ? stimulus_controller(*controller) : stimulus_controller(controller)
43
+ end
44
+ StimulusControllerCollection.new(converted_controllers)
45
+ end
46
+
47
+ # Parse inputs to create a StimulusAction instance representing a Stimulus action attribute
48
+ # examples:
49
+ # stimulus_action(:my_thing) => StimulusAction that converts to "current_controller#myThing"
50
+ # stimulus_action(:click, :my_thing) => StimulusAction that converts to "click->current_controller#myThing"
51
+ # stimulus_action("click->current_controller#myThing") => StimulusAction that converts to "click->current_controller#myThing"
52
+ # stimulus_action("path/to/current", :my_thing) => StimulusAction that converts to "path--to--current_controller#myThing"
53
+ # stimulus_action(:click, "path/to/current", :my_thing) => StimulusAction that converts to "click->path--to--current_controller#myThing"
54
+ def stimulus_action(*args)
55
+ return args.first if args.length == 1 && args.first.is_a?(StimulusAction)
56
+ StimulusAction.new(*args, implied_controller:)
57
+ end
58
+
59
+ # Parse inputs to create a StimulusActionCollection instance representing multiple Stimulus actions
60
+ def stimulus_actions(*actions)
61
+ return StimulusActionCollection.new if actions.empty? || actions.all?(&:blank?)
62
+
63
+ converted_actions = actions.map do |action|
64
+ action.is_a?(Array) ? stimulus_action(*action) : stimulus_action(action)
65
+ end
66
+ StimulusActionCollection.new(converted_actions)
67
+ end
68
+
69
+ # Parse inputs to create a StimulusTarget instance representing a Stimulus target attribute
70
+ # examples:
71
+ # stimulus_target(:my_target) => StimulusTarget that converts to {"current_controller-target" => "myTarget"}
72
+ # stimulus_target("path/to/current", :my_target) => StimulusTarget that converts to {"path--to--current-target" => "myTarget"}
73
+ def stimulus_target(*args)
74
+ return args.first if args.length == 1 && args.first.is_a?(StimulusTarget)
75
+ StimulusTarget.new(*args, implied_controller:)
76
+ end
77
+
78
+ # Parse inputs to create a StimulusTargetCollection instance representing multiple Stimulus targets
79
+ def stimulus_targets(*targets)
80
+ return StimulusTargetCollection.new if targets.empty? || targets.all?(&:blank?)
81
+
82
+ converted_targets = targets.map do |target|
83
+ target.is_a?(Array) ? stimulus_target(*target) : stimulus_target(target)
84
+ end
85
+ StimulusTargetCollection.new(converted_targets)
86
+ end
87
+
88
+ # Parse inputs to create a StimulusOutlet instance representing a Stimulus outlet attribute
89
+ # examples:
90
+ # stimulus_outlet(:user_status, ".online-user") => StimulusOutlet that converts to {"current_controller-user-status-outlet" => ".online-user"}
91
+ # stimulus_outlet("path/to/current", :user_status, ".online-user") => StimulusOutlet that converts to {"path--to--current-user-status-outlet" => ".online-user"}
92
+ # stimulus_outlet(:user_status) => StimulusOutlet with auto-generated selector
93
+ # stimulus_outlet(component_instance) => StimulusOutlet from component
94
+ def stimulus_outlet(*args)
95
+ return args.first if args.length == 1 && args.first.is_a?(StimulusOutlet)
96
+ StimulusOutlet.new(*args, implied_controller:, component_id: @id)
97
+ end
98
+
99
+ # Parse inputs to create a StimulusOutletCollection instance representing multiple Stimulus outlets
100
+ def stimulus_outlets(*outlets)
101
+ return StimulusOutletCollection.new if outlets.empty? || outlets.all?(&:blank?)
102
+
103
+ converted_outlets = []
104
+ outlets.each do |outlet|
105
+ if outlet.is_a?(Hash)
106
+ # Hash format: {name: selector, other_name: other_selector} - expands to multiple outlets
107
+ outlet.each { |name, selector| converted_outlets << stimulus_outlet(name, selector) }
108
+ elsif outlet.is_a?(Array)
109
+ # Array format: [name, selector] - splat into stimulus_outlet
110
+ converted_outlets << stimulus_outlet(*outlet)
111
+ else
112
+ converted_outlets << stimulus_outlet(outlet)
113
+ end
114
+ end
115
+ StimulusOutletCollection.new(converted_outlets)
116
+ end
117
+
118
+ # Parse inputs to create a StimulusValue instance representing a Stimulus value attribute
119
+ # examples:
120
+ # stimulus_value(:url, "https://example.com") => StimulusValue that converts to {"current_controller-url-value" => "https://example.com"}
121
+ # stimulus_value("path/to/current", :url, "https://example.com") => StimulusValue that converts to {"path--to--current-url-value" => "https://example.com"}
122
+ def stimulus_value(*args)
123
+ return args.first if args.length == 1 && args.first.is_a?(StimulusValue)
124
+ StimulusValue.new(*args, implied_controller:)
125
+ end
126
+
127
+ # Parse inputs to create a StimulusValueCollection instance representing multiple Stimulus values
128
+ def stimulus_values(*values)
129
+ return StimulusValueCollection.new if values.empty? || values.all?(&:blank?)
130
+
131
+ converted_values = []
132
+
133
+ values.each do |value|
134
+ if value.is_a?(Hash)
135
+ # Hash format: {name: value, other_name: other_value} - expands to multiple values
136
+ value.each { |name, val| converted_values << stimulus_value(name, val) }
137
+ elsif value.is_a?(Array)
138
+ # Array format: [controller, name, value] or [name, value] - splat into stimulus_value
139
+ converted_values << stimulus_value(*value)
140
+ else
141
+ converted_values << stimulus_value(value)
142
+ end
143
+ end
144
+
145
+ StimulusValueCollection.new(converted_values)
146
+ end
147
+
148
+ # Parse inputs to create a StimulusClass instance representing a Stimulus class attribute
149
+ # examples:
150
+ # stimulus_class(:loading, "spinner active") => StimulusClass that converts to {"current_controller-loading-class" => "spinner active"}
151
+ # stimulus_class("path/to/current", :loading, ["spinner", "active"]) => StimulusClass that converts to {"path--to--current-loading-class" => "spinner active"}
152
+ def stimulus_class(*args)
153
+ return args.first if args.length == 1 && args.first.is_a?(StimulusClass)
154
+ StimulusClass.new(*args, implied_controller:)
155
+ end
156
+
157
+ # Parse inputs to create a StimulusClassCollection instance representing multiple Stimulus classes
158
+ def stimulus_classes(*classes)
159
+ return StimulusClassCollection.new if classes.empty? || classes.all?(&:blank?)
160
+
161
+ converted_classes = []
162
+
163
+ classes.each do |cls|
164
+ if cls.is_a?(Hash)
165
+ # Hash format: {loading: "spinner active", error: "text-red-500"} - expands to multiple classes
166
+ cls.each { |name, class_list| converted_classes << stimulus_class(name, class_list) }
167
+ elsif cls.is_a?(Array)
168
+ # Array format: [controller, name, classes] or [name, classes] - splat into stimulus_class
169
+ converted_classes << stimulus_class(*cls)
170
+ else
171
+ converted_classes << stimulus_class(cls)
172
+ end
173
+ end
174
+
175
+ StimulusClassCollection.new(converted_classes)
176
+ end
177
+
178
+ # Methods to add to the stimulus collections
179
+
180
+ def add_stimulus_controllers(controllers)
181
+ s_controllers = stimulus_controllers(*Array.wrap(controllers))
182
+ @stimulus_controllers_collection = if @stimulus_controllers_collection
183
+ @stimulus_controllers_collection.merge(s_controllers)
184
+ else
185
+ s_controllers
186
+ end
187
+ end
188
+
189
+ def add_stimulus_actions(actions)
190
+ s_actions = stimulus_actions(*Array.wrap(actions))
191
+ @stimulus_actions_collection = if @stimulus_actions_collection
192
+ @stimulus_actions_collection.merge(s_actions)
193
+ else
194
+ s_actions
195
+ end
196
+ end
197
+
198
+ def add_stimulus_targets(targets)
199
+ s_targets = stimulus_targets(*Array.wrap(targets))
200
+ @stimulus_targets_collection = if @stimulus_targets_collection
201
+ @stimulus_targets_collection.merge(s_targets)
202
+ else
203
+ s_targets
204
+ end
205
+ end
206
+
207
+ def add_stimulus_outlets(outlets)
208
+ s_outlets = stimulus_outlets(*Array.wrap(outlets))
209
+ @stimulus_outlets_collection = if @stimulus_outlets_collection
210
+ @stimulus_outlets_collection.merge(s_outlets)
211
+ else
212
+ s_outlets
213
+ end
214
+ end
215
+
216
+ def add_stimulus_values(values)
217
+ s_values = stimulus_values(values)
218
+ @stimulus_values_collection = if @stimulus_values_collection
219
+ @stimulus_values_collection.merge(s_values)
220
+ else
221
+ s_values
222
+ end
223
+ end
224
+
225
+ def add_stimulus_classes(named_classes)
226
+ classes = stimulus_classes(named_classes)
227
+ @stimulus_classes_collection = if @stimulus_classes_collection
228
+ @stimulus_classes_collection.merge(classes)
229
+ else
230
+ classes
231
+ end
232
+ end
233
+
234
+ # Stimulus events name for this component
235
+ def stimulus_scoped_event(event)
236
+ self.class.stimulus_scoped_event(event)
237
+ end
238
+
239
+ def stimulus_scoped_event_on_window(event)
240
+ self.class.stimulus_scoped_event_on_window(event)
241
+ end
242
+
243
+ private
244
+
245
+ def implied_controller
246
+ StimulusController.new(implied_controller: implied_controller_path)
247
+ end
248
+
249
+ # When using the DSL if you dont specify, the first controller is implied
250
+ def implied_controller_path
251
+ return @implied_controller_path if defined?(@implied_controller_path)
252
+ path = Array.wrap(@stimulus_controllers).first
253
+ raise(StandardError, "No controllers have been specified") unless path
254
+ @implied_controller_path = path
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ class StimulusBuilder
5
+ def initialize
6
+ @actions = []
7
+ @targets = []
8
+ @values = {}
9
+ @values_from_props = []
10
+ @classes = {}
11
+ @outlets = {}
12
+ end
13
+
14
+ def merge_with(other_builder)
15
+ @actions.concat(other_builder.actions_list)
16
+ @targets.concat(other_builder.targets_list)
17
+ @values.merge!(other_builder.values_hash)
18
+ @values_from_props.concat(other_builder.values_from_props_list)
19
+ @classes.merge!(other_builder.classes_hash)
20
+ @outlets.merge!(other_builder.outlets_hash)
21
+ self
22
+ end
23
+
24
+ def actions(*action_names)
25
+ @actions.concat(action_names)
26
+ self
27
+ end
28
+
29
+ def targets(*target_names)
30
+ @targets.concat(target_names)
31
+ self
32
+ end
33
+
34
+ def values(**value_hash)
35
+ @values.merge!(value_hash) unless value_hash.empty?
36
+ self
37
+ end
38
+
39
+ def values_from_props(*prop_names)
40
+ @values_from_props.concat(prop_names)
41
+ self
42
+ end
43
+
44
+ def classes(**class_mappings)
45
+ @classes.merge!(class_mappings)
46
+ self
47
+ end
48
+
49
+ def outlets(**outlet_mappings)
50
+ @outlets.merge!(outlet_mappings) unless outlet_mappings.empty?
51
+ self
52
+ end
53
+
54
+ def to_attributes(component_instance)
55
+ attrs = {}
56
+ attrs[:stimulus_actions] = resolve_values(@actions, component_instance) unless @actions.empty?
57
+ attrs[:stimulus_targets] = resolve_values(@targets, component_instance) unless @targets.empty?
58
+ attrs[:stimulus_values] = resolve_hash_values(@values, component_instance) unless @values.empty?
59
+ attrs[:stimulus_values_from_props] = @values_from_props.dup unless @values_from_props.empty?
60
+ attrs[:stimulus_classes] = resolve_hash_values(@classes, component_instance) unless @classes.empty?
61
+ attrs[:stimulus_outlets] = @outlets.dup unless @outlets.empty?
62
+ attrs
63
+ end
64
+
65
+ def to_hash(component_instance)
66
+ to_attributes(component_instance)
67
+ end
68
+ alias_method :to_h, :to_hash
69
+
70
+ protected
71
+
72
+ def actions_list
73
+ @actions
74
+ end
75
+
76
+ def targets_list
77
+ @targets
78
+ end
79
+
80
+ def values_hash
81
+ @values
82
+ end
83
+
84
+ def values_from_props_list
85
+ @values_from_props
86
+ end
87
+
88
+ def classes_hash
89
+ @classes
90
+ end
91
+
92
+ def outlets_hash
93
+ @outlets
94
+ end
95
+
96
+ private
97
+
98
+ def resolve_values(array, component_instance)
99
+ array.map do |value|
100
+ if callable?(value)
101
+ component_instance.instance_exec(&value)
102
+ else
103
+ value
104
+ end
105
+ end
106
+ end
107
+
108
+ def resolve_hash_values(hash, component_instance)
109
+ hash.transform_values { |value| callable?(value) ? component_instance.instance_exec(&value) : value }
110
+ end
111
+
112
+ def callable?(value)
113
+ value.respond_to?(:call)
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ class StimulusClass < StimulusAttributeBase
5
+ attr_reader :controller, :class_name, :css_classes
6
+
7
+ def to_s
8
+ @css_classes.join(" ")
9
+ end
10
+
11
+ def data_attribute_name
12
+ "#{@controller}-#{@class_name}-class"
13
+ end
14
+
15
+ def data_attribute_value
16
+ to_s
17
+ end
18
+
19
+ private
20
+
21
+ def parse_arguments(*args)
22
+ case args.size
23
+ when 2
24
+ parse_two_arguments(args[0], args[1])
25
+ when 3
26
+ parse_three_arguments(args[0], args[1], args[2])
27
+ else
28
+ raise ArgumentError, "Invalid number of arguments: #{args.size}"
29
+ end
30
+ end
31
+
32
+ def parse_two_arguments(class_name, css_classes)
33
+ if class_name.is_a?(Symbol)
34
+ # class name on implied controller + css classes
35
+ @controller = implied_controller_name
36
+ @class_name = class_name.to_s.dasherize
37
+ @css_classes = normalize_css_classes(css_classes)
38
+ else
39
+ raise ArgumentError, "Invalid argument types: #{class_name.class}, #{css_classes.class}"
40
+ end
41
+ end
42
+
43
+ def parse_three_arguments(controller, class_name, css_classes)
44
+ if controller.is_a?(String) && class_name.is_a?(Symbol)
45
+ # controller + class name + css classes
46
+ @controller = stimulize_path(controller)
47
+ @class_name = class_name.to_s.dasherize
48
+ @css_classes = normalize_css_classes(css_classes)
49
+ else
50
+ raise ArgumentError, "Invalid argument types: #{controller.class}, #{class_name.class}, #{css_classes.class}"
51
+ end
52
+ end
53
+
54
+ def normalize_css_classes(css_classes)
55
+ case css_classes
56
+ when String
57
+ css_classes.split(/\s+/).reject(&:empty?)
58
+ when Array
59
+ css_classes.map(&:to_s).reject(&:empty?)
60
+ else
61
+ raise ArgumentError, "CSS classes must be a String or Array, got #{css_classes.class}"
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ class StimulusClassCollection < StimulusCollectionBase
5
+ def to_h
6
+ return {} if items.empty?
7
+
8
+ merged = {}
9
+ items.each do |css_class|
10
+ merged.merge!(css_class.to_h)
11
+ end
12
+ merged
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ class StimulusCollectionBase
5
+ def initialize(items = [])
6
+ @items = Array(items).flatten.compact
7
+ end
8
+
9
+ def <<(item)
10
+ @items << item
11
+ self
12
+ end
13
+
14
+ def to_h
15
+ raise NoMethodError, "Subclasses must implement to_h"
16
+ end
17
+
18
+ def to_a
19
+ @items.dup
20
+ end
21
+
22
+ def to_hash
23
+ to_h
24
+ end
25
+
26
+ def empty?
27
+ @items.empty?
28
+ end
29
+
30
+ def any?
31
+ !empty?
32
+ end
33
+
34
+ def merge(*other_collections)
35
+ merged = self.class.new
36
+ merged.instance_variable_set(:@items, @items.dup)
37
+
38
+ other_collections.each do |collection|
39
+ next unless collection.is_a?(self.class)
40
+ merged.instance_variable_get(:@items).concat(collection.instance_variable_get(:@items))
41
+ end
42
+
43
+ merged
44
+ end
45
+
46
+ def self.merge(*collections)
47
+ return new if collections.empty?
48
+
49
+ first_collection = collections.first
50
+ return first_collection if collections.size == 1
51
+
52
+ first_collection.merge(*collections[1..-1])
53
+ end
54
+
55
+ protected
56
+
57
+ attr_reader :items
58
+ end
59
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module StimulusComponent
5
+ extend ActiveSupport::Concern
6
+
7
+ include StimulusAttributes
8
+
9
+ # Module utilities for working with Stimulus identifiers
10
+
11
+ def stimulus_identifier_from_path(path)
12
+ path.split("/").map { |p| p.to_s.dasherize }.join("--")
13
+ end
14
+ module_function :stimulus_identifier_from_path
15
+
16
+ # Base class for all Vident components, which provides common functionality and properties.
17
+
18
+ class_methods do
19
+ def no_stimulus_controller
20
+ @no_stimulus_controller = true
21
+ end
22
+
23
+ def stimulus_controller? = !@no_stimulus_controller
24
+
25
+ # The "path" of the Stimulus controller, which is used to generate the controller name.
26
+ def stimulus_identifier_path = name&.underscore || "anonymous_component"
27
+
28
+ # Stimulus controller identifier
29
+ def stimulus_identifier = ::Vident::StimulusComponent.stimulus_identifier_from_path(stimulus_identifier_path)
30
+
31
+ # The "name" of the component from its class name and namespace. This is used to generate an HTML class name
32
+ # that can helps identify the component type in the DOM or for styling purposes.
33
+ def component_name
34
+ @component_name ||= stimulus_identifier
35
+ end
36
+ end
37
+
38
+ # Components have the following properties
39
+ included do
40
+ extend Literal::Properties
41
+
42
+ # StimulusJS support
43
+ # # TODO: revisit inputs and how many ways of specifying the same thing...
44
+ prop :stimulus_controllers, _Array(_Union(String, Symbol, StimulusController, StimulusControllerCollection)), default: -> do
45
+ if self.class.stimulus_controller?
46
+ [default_controller_path]
47
+ else
48
+ []
49
+ end
50
+ end
51
+ prop :stimulus_actions, _Array(_Union(String, Symbol, Array, Hash, StimulusAction, StimulusActionCollection)), default: -> { [] }
52
+ prop :stimulus_targets, _Array(_Union(String, Symbol, Hash, StimulusTarget, StimulusTargetCollection)), default: -> { [] }
53
+ prop :stimulus_outlets, _Array(_Union(String, Symbol, StimulusOutlet, StimulusOutletCollection)), default: -> { [] }
54
+ prop :stimulus_outlet_host, _Nilable(Vident::Component) # A component that will host this component as an outlet
55
+ prop :stimulus_values, _Union(_Hash(Symbol, _Any), StimulusValue, StimulusValueCollection), default: -> { {} } # TODO: instead of _Any, is it _Interface(:to_s)?
56
+ prop :stimulus_classes, _Union(_Hash(Symbol, String), StimulusClass, StimulusClassCollection), default: -> { {} }
57
+ end
58
+
59
+ # If connecting an outlet to this specific component instance, use this ID
60
+ def outlet_id
61
+ @outlet_id ||= [stimulus_identifier, "##{id}"]
62
+ end
63
+
64
+ # The Stimulus controller identifier for this component
65
+ def stimulus_identifier = self.class.stimulus_identifier
66
+
67
+ # An name that can helps identify the component type in the DOM or for styling purposes (its also used as a class name on the root element)
68
+ def component_name = self.class.component_name
69
+
70
+ # The `component` class name is used to create the controller name.
71
+ # The path of the Stimulus controller when none is explicitly set
72
+ def default_controller_path = self.class.stimulus_identifier_path
73
+ end
74
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ class StimulusController < StimulusAttributeBase
5
+ attr_reader :path, :name
6
+
7
+ def to_s
8
+ name
9
+ end
10
+
11
+ def data_attribute_name
12
+ "controller"
13
+ end
14
+
15
+ def data_attribute_value
16
+ name
17
+ end
18
+
19
+ private
20
+
21
+ def implied_controller_path
22
+ @implied_controller
23
+ end
24
+
25
+ def implied_controller_name
26
+ stimulize_path(@implied_controller)
27
+ end
28
+
29
+ def parse_arguments(*args)
30
+ case args.size
31
+ when 0
32
+ # No arguments: use implied controller path
33
+ @path = implied_controller_path
34
+ @name = implied_controller_name
35
+ when 1
36
+ # Single argument: controller path
37
+ @path = args[0]
38
+ @name = stimulize_path(args[0])
39
+ else
40
+ raise ArgumentError, "Invalid number of arguments: #{args.size}. Expected 0 or 1 argument."
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ class StimulusControllerCollection < StimulusCollectionBase
5
+ def to_h
6
+ return {} if items.empty?
7
+
8
+ controller_values = items.map(&:to_s).reject(&:empty?)
9
+ return {} if controller_values.empty?
10
+
11
+ {controller: controller_values.join(" ")}
12
+ end
13
+ end
14
+ end