vident 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module RootComponent
5
+ module Base
6
+ def initialize(
7
+ controllers: nil,
8
+ actions: nil,
9
+ targets: nil,
10
+ named_classes: nil, # https://stimulus.hotwired.dev/reference/css-classes
11
+ data_maps: nil,
12
+ element_tag: nil,
13
+ id: nil,
14
+ html_options: nil
15
+ )
16
+ @element_tag = element_tag
17
+ @html_options = html_options
18
+ @id = id
19
+ @controllers = Array.wrap(controllers)
20
+ @actions = actions
21
+ @targets = targets
22
+ @named_classes = named_classes
23
+ @data_map_kvs = {}
24
+ @data_maps = data_maps
25
+ end
26
+
27
+ # The view component's helpers for setting stimulus data-* attributes on this component.
28
+
29
+ # TODO: rename
30
+ # Create a Stimulus action string, and returns it
31
+ # examples:
32
+ # action(:my_thing) => "current_controller#myThing"
33
+ # action(:click, :my_thing) => "click->current_controller#myThing"
34
+ # action("click->current_controller#myThing") => "click->current_controller#myThing"
35
+ # action("path/to/current", :my_thing) => "path--to--current_controller#myThing"
36
+ # action(:click, "path/to/current", :my_thing) => "click->path--to--current_controller#myThing"
37
+ def action(*args)
38
+ part1, part2, part3 = args
39
+ (args.size == 1) ? parse_action_arg(part1) : parse_multiple_action_args(part1, part2, part3)
40
+ end
41
+
42
+ def action_data_attribute(*actions)
43
+ {action: parse_actions(actions).join(" ")}
44
+ end
45
+
46
+ # TODO: rename & make stimulus Target class instance and returns it, which can convert to String
47
+ # Create a Stimulus Target and returns it
48
+ # examples:
49
+ # target(:my_target) => {controller: 'current_controller' name: 'myTarget'}
50
+ # target("path/to/current", :my_target) => {controller: 'path--to--current_controller', name: 'myTarget'}
51
+ def target(name, part2 = nil)
52
+ if part2.nil?
53
+ {controller: implied_controller_name, name: js_name(name)}
54
+ else
55
+ {controller: stimulize_path(name), name: js_name(part2)}
56
+ end
57
+ end
58
+
59
+ def target_data_attribute(name)
60
+ build_target_data_attributes([target(name)])
61
+ end
62
+
63
+ # Getter for a named classes list so can be used in view to set initial state on SSR
64
+ # Returns a String of classes that can be used in a `class` attribute.
65
+ def named_classes(*names)
66
+ names.map { |name| convert_classes_list_to_string(@named_classes[name]) }.join(" ")
67
+ end
68
+
69
+ # Helpers for generating the Stimulus data-* attributes directly
70
+
71
+ # Return the HTML `data-controller` attribute
72
+ def with_controllers
73
+ "data-controller='#{controller_list}'".html_safe
74
+ end
75
+
76
+ # Return the HTML `data-target` attribute
77
+ def as_targets(*targets)
78
+ build_target_data_attributes(parse_targets(targets))
79
+ .map { |dt, n| "data-#{dt}=\"#{n}\"" }
80
+ .join(" ")
81
+ .html_safe
82
+ end
83
+ alias_method :as_target, :as_targets
84
+
85
+ # Return the HTML `data-action` attribute to add these actions
86
+ def with_actions(*actions)
87
+ "data-action='#{parse_actions(actions).join(" ")}'".html_safe
88
+ end
89
+ alias_method :with_action, :with_actions
90
+
91
+ private
92
+
93
+ # An implicit Stimulus controller name is built from the implicit controller path
94
+ def implied_controller_name
95
+ stimulize_path(implied_controller_path)
96
+ end
97
+
98
+ # When using the DSL if you dont specify, the first controller is implied
99
+ def implied_controller_path
100
+ @controllers&.first || raise(StandardError, "No controllers have been specified")
101
+ end
102
+
103
+ # A complete list of Stimulus controllers for this component
104
+ def controller_list
105
+ @controllers&.map { |c| stimulize_path(c) }&.join(" ")
106
+ end
107
+
108
+ # Complete list of actions ready to be use in the data-action attribute
109
+ def action_list
110
+ return nil unless @actions&.size&.positive?
111
+ parse_actions(@actions).join(" ")
112
+ end
113
+
114
+ # Complete list of targets ready to be use in the data attributes
115
+ def target_list
116
+ return {} unless @targets&.size&.positive?
117
+ build_target_data_attributes(parse_targets(@targets))
118
+ end
119
+
120
+ def named_classes_list
121
+ return {} unless @named_classes&.size&.positive?
122
+ build_named_classes_data_attributes(@named_classes)
123
+ end
124
+
125
+ # stimulus "data-*" attributes map for this component
126
+ def tag_data_attributes
127
+ {controller: controller_list, action: action_list}
128
+ .merge!(target_list)
129
+ .merge!(named_classes_list)
130
+ .merge!(data_map_attributes)
131
+ .compact_blank!
132
+ end
133
+
134
+ # Actions can be specified as a symbol, in which case they imply an action on the primary
135
+ # controller, or as a string in which case it implies an action that is already fully qualified
136
+ # stimulus action.
137
+ # 1 Symbol: :my_action => "my_controller#myAction"
138
+ # 1 String: "my_controller#myAction"
139
+ # 2 Symbols: [:click, :my_action] => "click->my_controller#myAction"
140
+ # 1 String, 1 Symbol: ["path/to/controller", :my_action] => "path--to--controller#myAction"
141
+ # 1 Symbol, 1 String, 1 Symbol: [:hover, "path/to/controller", :my_action] => "hover->path--to--controller#myAction"
142
+
143
+ def parse_action_arg(part1)
144
+ if part1.is_a?(Symbol)
145
+ # 1 symbol arg, name of method on this controller
146
+ "#{implied_controller_name}##{js_name(part1)}"
147
+ elsif part1.is_a?(String)
148
+ # 1 string arg, fully qualified action
149
+ part1
150
+ end
151
+ end
152
+
153
+ def parse_multiple_action_args(part1, part2, part3)
154
+ if part3.nil? && part1.is_a?(Symbol)
155
+ # 2 symbol args = event + action
156
+ "#{part1}->#{implied_controller_name}##{js_name(part2)}"
157
+ elsif part3.nil?
158
+ # 1 string arg, 1 symbol = controller + action
159
+ "#{stimulize_path(part1)}##{js_name(part2)}"
160
+ else
161
+ # 1 symbol, 1 string, 1 symbol = as above but with event
162
+ "#{part1}->#{stimulize_path(part2)}##{js_name(part3)}"
163
+ end
164
+ end
165
+
166
+ # Parse actions, targets and attributes that are passed in as symbols or strings
167
+
168
+ def parse_targets(targets)
169
+ targets.map { |n| parse_target(n) }
170
+ end
171
+
172
+ def parse_target(raw_target)
173
+ return raw_target if raw_target.is_a?(String)
174
+ return raw_target if raw_target.is_a?(Hash)
175
+ target(raw_target)
176
+ end
177
+
178
+ def build_target_data_attributes(targets)
179
+ targets.map { |t| ["#{t[:controller]}-target".to_sym, t[:name]] }.to_h
180
+ end
181
+
182
+ def parse_actions(actions)
183
+ actions.map! { |a| a.is_a?(String) ? a : action(*a) }
184
+ end
185
+
186
+ def parse_attributes(attrs, controller = nil)
187
+ attrs.transform_keys { |k| "#{controller || implied_controller_name}-#{k}" }
188
+ end
189
+
190
+ def data_map_attributes
191
+ return {} unless @data_maps
192
+ @data_maps.each_with_object({}) do |m, obj|
193
+ if m.is_a?(Hash)
194
+ obj.merge!(parse_attributes(m))
195
+ elsif m.is_a?(Array)
196
+ controller_path = m.first
197
+ data = m.last
198
+ obj.merge!(parse_attributes(data, stimulize_path(controller_path)))
199
+ end
200
+ end
201
+ end
202
+
203
+ def parse_named_classes_hash(named_classes)
204
+ named_classes.map do |name, classes|
205
+ logical_name = name.to_s.dasherize
206
+ classes_str = convert_classes_list_to_string(classes)
207
+ if classes.is_a?(Hash)
208
+ {controller: stimulize_path(classes[:controller_path]), name: logical_name, classes: classes_str}
209
+ else
210
+ {controller: implied_controller_name, name: logical_name, classes: classes_str}
211
+ end
212
+ end
213
+ end
214
+
215
+ def build_named_classes_data_attributes(named_classes)
216
+ parse_named_classes_hash(named_classes)
217
+ .map { |c| ["#{c[:controller]}-#{c[:name]}-class", c[:classes]] }
218
+ .to_h
219
+ end
220
+
221
+ def convert_classes_list_to_string(classes)
222
+ return "" if classes.nil?
223
+ return classes if classes.is_a?(String)
224
+ return classes.join(" ") if classes.is_a?(Array)
225
+ classes[:classes].is_a?(Array) ? classes[:classes].join(" ") : classes[:classes]
226
+ end
227
+
228
+ # Convert a file path to a stimulus controller name
229
+ def stimulize_path(path)
230
+ path.split("/").map { |p| p.to_s.dasherize }.join("--")
231
+ end
232
+
233
+ # Convert a Ruby 'snake case' string to a JavaScript camel case strings
234
+ def js_name(name)
235
+ name.to_s.camelize(:lower)
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ if Gem.loaded_specs.has_key? "phlex"
4
+ require "phlex"
5
+
6
+ module Vident
7
+ module RootComponent
8
+ class UsingPhlexHTML < Phlex::HTML
9
+ include Base
10
+
11
+ VALID_TAGS = Set[*(Phlex::HTML::VOID_ELEMENTS.keys + Phlex::HTML::STANDARD_ELEMENTS.keys)].freeze
12
+
13
+ # Create a tag for a target with a block containing content
14
+ def target_tag(tag_name, targets, **options, &block)
15
+ parsed = parse_targets(Array.wrap(targets))
16
+ options[:data] ||= {}
17
+ options[:data].merge!(build_target_data_attributes(parsed))
18
+ generate_tag(tag_name, **options, &block)
19
+ end
20
+
21
+ # Build a tag with the attributes determined by this components properties and stimulus
22
+ # data attributes.
23
+ def template(&block)
24
+ # Generate tag options and render
25
+ tag_type = @element_tag.presence&.to_sym || :div
26
+ raise ArgumentError, "Unsupported HTML tag name #{tag_type}" unless VALID_TAGS.include?(tag_type)
27
+ options = @html_options&.dup || {}
28
+ data_attrs = tag_data_attributes
29
+ data_attrs = options[:data].present? ? data_attrs.merge(options[:data]) : data_attrs
30
+ options = options.merge(id: @id) if @id
31
+ options.except!(:data)
32
+ options.merge!(data_attrs.transform_keys { |k| "data-#{k}" })
33
+ generate_tag(tag_type, **options, &block)
34
+ end
35
+
36
+ private
37
+
38
+ def generate_tag(tag_type, **options, &block)
39
+ send((tag_type == :template) ? :template_tag : tag_type, **options, &block)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ if Gem.loaded_specs.has_key? "view_component"
4
+ require "view_component"
5
+
6
+ module Vident
7
+ module RootComponent
8
+ class UsingViewComponent < ::ViewComponent::Base
9
+ include Base
10
+
11
+ SELF_CLOSING_TAGS = Set[:area, :base, :br, :col, :embed, :hr, :img, :input, :link, :meta, :param, :source, :track, :wbr].freeze
12
+
13
+ def target_tag(tag_name, targets, **options, &block)
14
+ parsed = parse_targets(Array.wrap(targets))
15
+ options[:data] ||= {}
16
+ options[:data].merge!(build_target_data_attributes(parsed))
17
+ content = view_context.capture(&block) if block
18
+ view_context.content_tag(tag_name, content, options)
19
+ end
20
+
21
+ def call
22
+ # Capture inner block content
23
+ # It's important that we capture the block content before generating the tag options.
24
+ # This is because the content could contain calls to `s.data_map`.
25
+ # These calls add key-value pairs to the internal data_map, which can then be translated into
26
+ # the correct `data-*` attrs by the tag options.
27
+ generated = content
28
+
29
+ # Generate outer tag options and render
30
+ tag_type = @element_tag.presence || :div
31
+ options = @html_options&.dup || {}
32
+ data_attrs = tag_data_attributes
33
+ options[:data] = options[:data].present? ? data_attrs.merge(options[:data]) : data_attrs
34
+ options = options.merge(id: @id) if @id
35
+ if SELF_CLOSING_TAGS.include?(tag_type)
36
+ view_context.tag(tag_type, options)
37
+ else
38
+ view_context.content_tag(tag_type, generated, options)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ class StableId
5
+ class << self
6
+ def set_current_sequence_generator
7
+ ::Thread.current[:vident_number_sequence_generator] = id_sequence_generator
8
+ end
9
+
10
+ def next_id_in_sequence
11
+ generator = ::Thread.current[:vident_number_sequence_generator]
12
+ return "?" unless generator
13
+ generator.next.join("-")
14
+ end
15
+
16
+ private
17
+
18
+ def id_sequence_generator
19
+ number_generator = Random.new(296_865_628_524)
20
+ Enumerator.produce { number_generator.rand(10_000_000) }.with_index
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ if Gem.loaded_specs.has_key? "dry-struct"
4
+ require_relative "./attributes/typed"
5
+
6
+ module Vident
7
+ module TypedComponent
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ include Vident::Base
12
+ include Vident::Attributes::Typed
13
+
14
+ attribute :id, String, delegates: false
15
+ attribute :html_options, Hash, delegates: false
16
+ attribute :element_tag, Symbol, delegates: false
17
+
18
+ # StimulusJS support
19
+ attribute :controllers, Array, default: [], delegates: false
20
+ attribute :actions, Array, default: [], delegates: false
21
+ attribute :targets, Array, default: [], delegates: false
22
+ attribute :data_maps, Array, default: [], delegates: false
23
+
24
+ # TODO normalise the syntax of defining actions, controllers, etc
25
+ attribute :named_classes, Hash, delegates: false
26
+ end
27
+
28
+ def initialize(attrs = {})
29
+ before_initialise(attrs)
30
+ prepare_attributes(attrs)
31
+ # The attributes need to also be set as ivars
32
+ attributes.each do |attr_name, attr_value|
33
+ instance_variable_set(self.class.attribute_ivar_names[attr_name], attr_value)
34
+ end
35
+ after_initialise
36
+ super()
37
+ end
38
+ end
39
+ end
40
+ else
41
+ module Vident
42
+ module TypedComponent
43
+ def self.included(base)
44
+ raise "Vident::TypedComponent requires dry-struct to be installed"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ VERSION = "0.1.0"
5
+ end
data/lib/vident.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "vident/version"
4
+ require_relative "vident/railtie"
5
+
6
+ module Vident
7
+ class << self
8
+ def configuration
9
+ @configuration ||= Configuration.new
10
+ end
11
+
12
+ def configure
13
+ yield(configuration) if block_given?
14
+ configuration
15
+ end
16
+ end
17
+
18
+ class Configuration
19
+ attr_accessor :include_i18n_helpers
20
+
21
+ def initialize
22
+ @include_i18n_helpers = true
23
+ end
24
+ end
25
+ end
26
+
27
+ require_relative "vident/stable_id"
28
+ require_relative "vident/root_component/base"
29
+ require_relative "vident/root_component/using_phlex_html"
30
+ require_relative "vident/root_component/using_view_component"
31
+ require_relative "vident/base"
32
+ require_relative "vident/component"
33
+ require_relative "vident/typed_component"
data/sig/vident.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Vident
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: vident
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stephen Ierodiaconou
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-12-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ description: Vident makes using Stimulus with your `ViewComponent` or `Phlex` view
28
+ components as easy as writing Ruby. It also provides a base class for your components
29
+ to inherit from.
30
+ email:
31
+ - stevegeek@gmail.com
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - ".standard.yml"
37
+ - CODE_OF_CONDUCT.md
38
+ - Gemfile
39
+ - LICENSE.txt
40
+ - README.md
41
+ - Rakefile
42
+ - examples/ex1.gif
43
+ - lib/tasks/vident.rake
44
+ - lib/vident.rb
45
+ - lib/vident/attributes/not_typed.rb
46
+ - lib/vident/attributes/typed.rb
47
+ - lib/vident/attributes/typed_niling_struct.rb
48
+ - lib/vident/attributes/types.rb
49
+ - lib/vident/base.rb
50
+ - lib/vident/component.rb
51
+ - lib/vident/railtie.rb
52
+ - lib/vident/root_component/base.rb
53
+ - lib/vident/root_component/using_phlex_html.rb
54
+ - lib/vident/root_component/using_view_component.rb
55
+ - lib/vident/stable_id.rb
56
+ - lib/vident/typed_component.rb
57
+ - lib/vident/version.rb
58
+ - sig/vident.rbs
59
+ homepage: https://github.com/stevegeek/vident
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ homepage_uri: https://github.com/stevegeek/vident
64
+ source_code_uri: https://github.com/stevegeek/vident
65
+ post_install_message:
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 3.0.0
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 3.3.7
81
+ signing_key:
82
+ specification_version: 4
83
+ summary: Vident is a view component base class for your design system implementation,
84
+ which provides helpers for working with Stimulus. For ViewComponent and Phlex.
85
+ test_files: []