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.
@@ -15,7 +15,7 @@ module Vident
15
15
  def with_cache_key(*attrs, name: :_collection)
16
16
  # Add view file to cache key
17
17
  attrs << :component_modified_time
18
- attrs << :attributes
18
+ attrs << :to_h if respond_to?(:to_h)
19
19
  named_cache_key_includes(name, *attrs.uniq)
20
20
  end
21
21
 
@@ -46,11 +46,11 @@ module Vident
46
46
  def component_modified_time
47
47
  return @component_modified_time if Rails.env.production? && @component_modified_time
48
48
 
49
- raise StandardError, "Must implement current_component_modified_time" unless respond_to?(:current_component_modified_time)
49
+ raise StandardError, "Must implement cache_component_modified_time" unless respond_to?(:cache_component_modified_time)
50
50
 
51
51
  # FIXME: This could stack overflow if there are circular dependencies
52
52
  deps = component_dependencies&.map(&:component_modified_time)&.join("-") || ""
53
- @component_modified_time = deps + current_component_modified_time
53
+ @component_modified_time = deps + cache_component_modified_time
54
54
  end
55
55
 
56
56
  private
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Vident
6
+ class ClassListBuilder
7
+ CLASSNAME_SEPARATOR = " "
8
+
9
+ def initialize(tailwind_merger: nil, component_name: nil, element_classes: nil, html_class: nil, additional_classes: nil)
10
+ @class_list = component_name ? [component_name] : []
11
+ @class_list.concat(Array.wrap(element_classes)) if element_classes
12
+ @class_list.concat(Array.wrap(html_class)) if html_class
13
+ @class_list.concat(Array.wrap(additional_classes)) if additional_classes
14
+ @class_list.compact!
15
+ @tailwind_merger = tailwind_merger
16
+
17
+ if @tailwind_merger && !defined?(::TailwindMerge::Merger)
18
+ raise LoadError, "TailwindMerge gem is required when using tailwind_merger:. Add 'gem \"tailwind_merge\"' to your Gemfile."
19
+ end
20
+ end
21
+
22
+ # Main method to build a final class list from multiple sources
23
+ # @param class_lists [Array<String, Array, StimulusClass, nil>] Multiple class sources to merge
24
+ # @param stimulus_class_names [Array<Symbol, String>] Optional names of stimulus classes to include
25
+ # @return [String, nil] Final space-separated class string or nil if no classes
26
+ def build(extra_classes = nil, stimulus_class_names: [])
27
+ class_list = @class_list + Array.wrap(extra_classes).compact
28
+ flattened_classes = flatten_and_normalize_classes(class_list, stimulus_class_names)
29
+ return nil if flattened_classes.empty?
30
+
31
+ deduplicated_classes = dedupe_classes(flattened_classes)
32
+ return nil if deduplicated_classes.blank?
33
+
34
+ class_string = deduplicated_classes.join(CLASSNAME_SEPARATOR)
35
+
36
+ if @tailwind_merger
37
+ dedupe_with_tailwind(class_string)
38
+ else
39
+ class_string
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # Flatten and normalize all input class sources
46
+ def flatten_and_normalize_classes(class_lists, stimulus_class_names)
47
+ stimulus_class_names_set = stimulus_class_names.map { |name| name.to_s.dasherize }.to_set
48
+
49
+ class_lists.compact.flat_map do |class_source|
50
+ case class_source
51
+ when String
52
+ class_source.split(CLASSNAME_SEPARATOR).reject(&:empty?)
53
+ when Array
54
+ class_source.flat_map { |item| normalize_single_class_item(item, stimulus_class_names_set) }
55
+ else
56
+ normalize_single_class_item(class_source, stimulus_class_names_set)
57
+ end
58
+ end.compact
59
+ end
60
+
61
+ # Normalize a single class item (could be string, StimulusClass, object with to_s, etc.)
62
+ def normalize_single_class_item(item, stimulus_class_names_set)
63
+ return [] if item.blank?
64
+
65
+ # Handle StimulusClass instances
66
+ if stimulus_class_instance?(item)
67
+ # Only include if the class name matches one of the requested names
68
+ # If stimulus_class_names_set is empty, exclude all stimulus classes
69
+ if stimulus_class_names_set.present? && stimulus_class_names_set.include?(item.class_name)
70
+ class_value = item.to_s
71
+ class_value.include?(CLASSNAME_SEPARATOR) ?
72
+ class_value.split(CLASSNAME_SEPARATOR).reject(&:empty?) :
73
+ [class_value]
74
+ else
75
+ []
76
+ end
77
+ else
78
+ # Handle regular strings and other objects
79
+ item_string = item.to_s
80
+ item_string.include?(CLASSNAME_SEPARATOR) ?
81
+ item_string.split(CLASSNAME_SEPARATOR).reject(&:empty?) :
82
+ [item_string]
83
+ end
84
+ end
85
+
86
+ # Check if an item is a StimulusClass instance
87
+ def stimulus_class_instance?(item)
88
+ item.respond_to?(:class_name) && item.respond_to?(:to_s)
89
+ end
90
+
91
+ # Deduplicate classes while preserving order (first occurrence wins)
92
+ def dedupe_classes(class_array)
93
+ class_array.reject(&:blank?).uniq
94
+ end
95
+
96
+ # Merge classes using Tailwind CSS merge
97
+ def dedupe_with_tailwind(class_string)
98
+ @tailwind_merger.merge(class_string)
99
+ end
100
+ end
101
+ end
@@ -4,29 +4,83 @@ module Vident
4
4
  module Component
5
5
  extend ActiveSupport::Concern
6
6
 
7
+ # Base class for all Vident components, which provides common functionality and properties.
7
8
  included do
8
- include Vident::Base
9
- include Vident::Attributes::NotTyped
10
-
11
- attribute :id, delegates: false
12
- attribute :html_options, delegates: false
13
- attribute :element_tag, delegates: false
14
-
15
- # StimulusJS support
16
- attribute :controllers, default: [], delegates: false
17
- attribute :actions, default: [], delegates: false
18
- attribute :targets, default: [], delegates: false
19
- attribute :outlets, default: [], delegates: false
20
- attribute :outlet_host, delegates: false
21
- attribute :values, default: [], delegates: false
22
- attribute :named_classes, delegates: false
23
- end
24
-
25
- def initialize(attrs = {})
26
- before_initialise(attrs)
27
- prepare_attributes(attrs)
28
- after_initialise
29
- super()
9
+ # The HTML tag to use for the root element of the component, defaults to `:div`.
10
+ prop :element_tag, Symbol, default: :div
11
+ # ID of the component. If not set, a random ID is generated.
12
+ prop :id, _Nilable(String)
13
+ # Classes to apply to the root element (they add to the `class` attribute)
14
+ prop :classes, _Union(String, _Array(String)), default: -> { [] }
15
+ # HTML options to apply to the root element (will merge into and potentially override html_options of the element)
16
+ prop :html_options, Hash, default: -> { {} }
17
+ end
18
+
19
+ include StimulusComponent
20
+ include ComponentClassLists
21
+ include ComponentAttributeResolver
22
+
23
+ include TagHelper
24
+ include Tailwind
25
+ include StimulusDSL
26
+
27
+ # Override this method to perform any initialisation after attributes are set
28
+ def after_component_initialize
29
+ end
30
+
31
+ # This can be overridden to return an array of extra class names, or a string of class names.
32
+ def element_classes
33
+ end
34
+
35
+ # Properties/attributes passed to the "root" element of the component. You normally override this method to
36
+ # return a hash of attributes that should be applied to the root element of your component.
37
+ def root_element_attributes
38
+ {}
39
+ end
40
+
41
+ # Create a new component instance with optional overrides for properties.
42
+ def clone(overrides = {}) = self.class.new(**to_h.merge(**overrides))
43
+
44
+ def inspect(klass_name = "Component")
45
+ attr_text = to_h.map { |k, v| "#{k}=#{v.inspect}" }.join(", ")
46
+ "#<#{self.class.name}<Vident::#{klass_name}> #{attr_text}>"
47
+ end
48
+
49
+ # Generate a unique ID for a component, can be overridden as required. Makes it easier to setup things like ARIA
50
+ # attributes which require elements to reference by ID. Note this overrides the `id` accessor
51
+ def id = @id.presence || random_id
52
+
53
+ private
54
+
55
+ # Called by Literal::Properties after the component is initialized.
56
+ def after_initialize
57
+ prepare_component_attributes
58
+ after_component_initialize if respond_to?(:after_component_initialize)
59
+ end
60
+
61
+ def root_element(&block)
62
+ raise NoMethodError, "You must implement the `root_element` method in your component"
63
+ end
64
+
65
+ def root(...)
66
+ root_element(...)
67
+ end
68
+
69
+ def root_element_tag_options
70
+ options = @html_options&.dup || {}
71
+ data_attrs = stimulus_data_attributes
72
+ options[:data] = options[:data].present? ? data_attrs.merge(options[:data]) : data_attrs
73
+ return options unless @id
74
+ options.merge(id: @id)
75
+ end
76
+
77
+ def root_element_tag_type
78
+ @element_tag.presence&.to_sym || :div
79
+ end
80
+
81
+ # Generate a random ID for the component, which is used to ensure uniqueness in the DOM.
82
+ def random_id
83
+ @random_id ||= "#{component_name}-#{StableId.next_id_in_sequence}"
30
84
  end
31
85
  end
32
86
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module ComponentAttributeResolver
5
+ private
6
+
7
+ # FIXME: in a view_component the parsing of html_options might have to be in `before_render`
8
+ def prepare_component_attributes
9
+ prepare_stimulus_collections
10
+
11
+ # Add stimulus attributes from DSL first (lower precedence)
12
+ add_stimulus_attributes_from_dsl
13
+
14
+ # Process root_element_attributes (higher precedence)
15
+ extra = root_element_attributes
16
+ @html_options = (extra[:html_options] || {}).merge(@html_options) if extra.key?(:html_options)
17
+ @html_options[:class] = render_classes(extra[:classes])
18
+ @html_options[:id] = (extra[:id] || id) unless @html_options.key?(:id)
19
+ @element_tag = extra[:element_tag] if extra.key?(:element_tag)
20
+
21
+ add_stimulus_controllers(extra[:stimulus_controllers]) if extra.key?(:stimulus_controllers)
22
+ add_stimulus_actions(extra[:stimulus_actions]) if extra.key?(:stimulus_actions)
23
+ add_stimulus_targets(extra[:stimulus_targets]) if extra.key?(:stimulus_targets)
24
+ add_stimulus_outlets(extra[:stimulus_outlets]) if extra.key?(:stimulus_outlets)
25
+ add_stimulus_values(extra[:stimulus_values]) if extra.key?(:stimulus_values)
26
+ add_stimulus_classes(extra[:stimulus_classes]) if extra.key?(:stimulus_classes)
27
+ end
28
+
29
+ # Add stimulus attributes from DSL declarations using existing add_* methods
30
+ def add_stimulus_attributes_from_dsl
31
+ dsl_attrs = self.class.stimulus_dsl_attributes(self)
32
+ return if dsl_attrs.empty?
33
+
34
+ # Use existing add_* methods to integrate DSL attributes
35
+ add_stimulus_controllers(dsl_attrs[:stimulus_controllers]) if dsl_attrs[:stimulus_controllers]
36
+ add_stimulus_actions(dsl_attrs[:stimulus_actions]) if dsl_attrs[:stimulus_actions]
37
+ add_stimulus_targets(dsl_attrs[:stimulus_targets]) if dsl_attrs[:stimulus_targets]
38
+ add_stimulus_outlets(dsl_attrs[:stimulus_outlets]) if dsl_attrs[:stimulus_outlets]
39
+
40
+ # Add static values (now includes resolved proc values)
41
+ add_stimulus_values(dsl_attrs[:stimulus_values]) if dsl_attrs[:stimulus_values]
42
+
43
+ # Resolve and add values from props
44
+ if dsl_attrs[:stimulus_values_from_props]
45
+ resolved_values = resolve_values_from_props(dsl_attrs[:stimulus_values_from_props])
46
+ add_stimulus_values(resolved_values) unless resolved_values.empty?
47
+ end
48
+
49
+ add_stimulus_classes(dsl_attrs[:stimulus_classes]) if dsl_attrs[:stimulus_classes]
50
+ end
51
+
52
+
53
+ # Prepare stimulus collections and implied controller path from the given attributes, called after initialization
54
+ def prepare_stimulus_collections # Convert raw attributes to stimulus attribute collections
55
+ @stimulus_controllers_collection = stimulus_controllers(*Array.wrap(@stimulus_controllers))
56
+ @stimulus_actions_collection = stimulus_actions(*Array.wrap(@stimulus_actions))
57
+ @stimulus_targets_collection = stimulus_targets(*Array.wrap(@stimulus_targets))
58
+ @stimulus_outlets_collection = stimulus_outlets(*Array.wrap(@stimulus_outlets))
59
+ @stimulus_values_collection = stimulus_values(*Array.wrap(@stimulus_values))
60
+ @stimulus_classes_collection = stimulus_classes(*Array.wrap(@stimulus_classes))
61
+
62
+ @stimulus_outlet_host.add_stimulus_outlets(self) if @stimulus_outlet_host
63
+ end
64
+
65
+ # Build stimulus data attributes using collection splat
66
+ def stimulus_data_attributes
67
+ StimulusDataAttributeBuilder.new(
68
+ controllers: @stimulus_controllers_collection,
69
+ actions: @stimulus_actions_collection,
70
+ targets: @stimulus_targets_collection,
71
+ outlets: @stimulus_outlets_collection,
72
+ values: @stimulus_values_collection,
73
+ classes: @stimulus_classes_collection
74
+ ).build
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module ComponentClassLists
5
+ # Generates the full list of HTML classes for the component
6
+ def render_classes(extra_classes = nil) = class_list_builder.build(extra_classes)
7
+
8
+ # Getter for a stimulus classes list so can be used in view to set initial state on SSR
9
+ # Returns a String of classes that can be used in a `class` attribute.
10
+ def class_list_for_stimulus_classes(*names)
11
+ class_list_builder.build(@stimulus_classes_collection&.to_a, stimulus_class_names: names) || ""
12
+ end
13
+
14
+ private
15
+
16
+ # Get or create a class list builder instance
17
+ # Automatically detects if Tailwind module is included and TailwindMerge gem is available
18
+ def class_list_builder
19
+ @class_list_builder ||= ClassListBuilder.new(
20
+ tailwind_merger:,
21
+ component_name:,
22
+ element_classes:,
23
+ additional_classes: @classes,
24
+ html_class: @html_options&.fetch(:class, nil)
25
+ )
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ class StimulusAction < StimulusAttributeBase
5
+ attr_reader :event, :controller, :action
6
+
7
+ def to_s
8
+ if @event
9
+ "#{@event}->#{@controller}##{@action}"
10
+ else
11
+ "#{@controller}##{@action}"
12
+ end
13
+ end
14
+
15
+ def data_attribute_name
16
+ "action"
17
+ end
18
+
19
+ def data_attribute_value
20
+ to_s
21
+ end
22
+
23
+ private
24
+
25
+ def parse_arguments(*args)
26
+ part1, part2, part3 = args
27
+
28
+ case args.size
29
+ when 1
30
+ parse_single_argument(part1)
31
+ when 2
32
+ parse_two_arguments(part1, part2)
33
+ when 3
34
+ parse_three_arguments(part1, part2, part3)
35
+ else
36
+ raise ArgumentError, "Invalid number of 'action' arguments: #{args.size}"
37
+ end
38
+ end
39
+
40
+ def parse_single_argument(arg)
41
+ if arg.is_a?(Symbol)
42
+ # 1 symbol arg, name of method on implied controller
43
+ @event = nil
44
+ @controller = implied_controller_name
45
+ @action = js_name(arg)
46
+ elsif arg.is_a?(String)
47
+ # 1 string arg, fully qualified action - parse it
48
+ parse_qualified_action_string(arg)
49
+ else
50
+ raise ArgumentError, "Invalid 'action' argument types (1): #{arg.class}"
51
+ end
52
+ end
53
+
54
+ def parse_two_arguments(part1, part2)
55
+ if part1.is_a?(Symbol) && part2.is_a?(Symbol)
56
+ # 2 symbol args = event + action
57
+ @event = part1.to_s
58
+ @controller = implied_controller_name
59
+ @action = js_name(part2)
60
+ elsif part1.is_a?(String) && part2.is_a?(Symbol)
61
+ # 1 string arg, 1 symbol = controller + action
62
+ @event = nil
63
+ @controller = stimulize_path(part1)
64
+ @action = js_name(part2)
65
+ else
66
+ raise ArgumentError, "Invalid 'action' argument types (2): #{part1.class}, #{part2.class}"
67
+ end
68
+ end
69
+
70
+ def parse_three_arguments(part1, part2, part3)
71
+ if part1.is_a?(Symbol) && part2.is_a?(String) && part3.is_a?(Symbol)
72
+ # 1 symbol, 1 string, 1 symbol = event + controller + action
73
+ @event = part1.to_s
74
+ @controller = stimulize_path(part2)
75
+ @action = js_name(part3)
76
+ else
77
+ raise ArgumentError, "Invalid 'action' argument types (3): #{part1.class}, #{part2.class}, #{part3.class}"
78
+ end
79
+ end
80
+
81
+ def parse_qualified_action_string(action_string)
82
+ if action_string.include?("->")
83
+ # Has event: "click->controller#action"
84
+ event_part, controller_action = action_string.split("->", 2)
85
+ @event = event_part
86
+ controller_part, action_part = controller_action.split("#", 2)
87
+ @controller = controller_part
88
+ @action = action_part
89
+ else
90
+ # No event: "controller#action"
91
+ @event = nil
92
+ controller_part, action_part = action_string.split("#", 2)
93
+ @controller = controller_part
94
+ @action = action_part
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ class StimulusActionCollection < StimulusCollectionBase
5
+ def to_h
6
+ return {} if items.empty?
7
+
8
+ {action: items.map(&:to_s).join(" ")}
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ module Vident
6
+ class StimulusAttributeBase
7
+ attr_reader :implied_controller
8
+
9
+ def initialize(*args, implied_controller: nil)
10
+ @implied_controller = implied_controller
11
+ parse_arguments(*args)
12
+ end
13
+
14
+ def inspect
15
+ "#<#{self.class.name} #{to_h}>"
16
+ end
17
+
18
+ def to_s
19
+ raise NoMethodError, "Subclasses must implement to_s"
20
+ end
21
+
22
+ def to_h
23
+ {data_attribute_name => data_attribute_value}
24
+ end
25
+
26
+ alias_method :to_hash, :to_h
27
+
28
+ def data_attribute_name
29
+ raise NoMethodError, "Subclasses must implement data_attribute_name"
30
+ end
31
+
32
+ def data_attribute_value
33
+ raise NoMethodError, "Subclasses must implement data_attribute_value"
34
+ end
35
+
36
+ def implied_controller_path
37
+ raise ArgumentError, "implied_controller is required to get implied controller path" unless implied_controller
38
+ implied_controller.path
39
+ end
40
+
41
+ def implied_controller_name
42
+ raise ArgumentError, "implied_controller is required to get implied controller name" unless implied_controller
43
+ implied_controller.name
44
+ end
45
+
46
+ private
47
+
48
+ # Convert a file path to a stimulus controller name
49
+ def stimulize_path(path)
50
+ path.split("/").map { |p| p.to_s.dasherize }.join("--")
51
+ end
52
+
53
+ # Convert a Ruby 'snake case' string to a JavaScript camel case strings
54
+ def js_name(name)
55
+ name.to_s.camelize(:lower)
56
+ end
57
+
58
+ # Subclasses must implement this method
59
+ def parse_arguments(*args)
60
+ raise NotImplementedError, "Subclasses must implement parse_arguments"
61
+ end
62
+ end
63
+ end