tailmix 0.2.0 → 0.4.6

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/README.md +147 -85
  4. data/app/javascript/tailmix/finder.js +15 -0
  5. data/app/javascript/tailmix/index.js +7 -0
  6. data/app/javascript/tailmix/mutator.js +28 -0
  7. data/app/javascript/tailmix/runner.js +7 -0
  8. data/app/javascript/tailmix/stimulus_adapter.js +37 -0
  9. data/examples/_modal_component.arb +36 -0
  10. data/examples/modal_component.rb +209 -0
  11. data/lib/generators/tailmix/install_generator.rb +19 -0
  12. data/lib/tailmix/configuration.rb +13 -0
  13. data/lib/tailmix/definition/context_builder.rb +39 -0
  14. data/lib/tailmix/definition/contexts/action_builder.rb +31 -0
  15. data/lib/tailmix/definition/contexts/actions/element_builder.rb +30 -0
  16. data/lib/tailmix/definition/contexts/attribute_builder.rb +21 -0
  17. data/lib/tailmix/definition/contexts/dimension_builder.rb +34 -0
  18. data/lib/tailmix/definition/contexts/element_builder.rb +41 -0
  19. data/lib/tailmix/definition/contexts/stimulus_builder.rb +101 -0
  20. data/lib/tailmix/definition/contexts/variant_builder.rb +35 -0
  21. data/lib/tailmix/definition/merger.rb +86 -0
  22. data/lib/tailmix/definition/result.rb +78 -0
  23. data/lib/tailmix/definition.rb +11 -0
  24. data/lib/tailmix/dev/docs.rb +88 -0
  25. data/lib/tailmix/dev/stimulus_generator.rb +124 -0
  26. data/lib/tailmix/dev/tools.rb +30 -0
  27. data/lib/tailmix/dsl.rb +35 -0
  28. data/lib/tailmix/engine.rb +17 -0
  29. data/lib/tailmix/html/attributes.rb +94 -0
  30. data/lib/tailmix/html/class_list.rb +79 -0
  31. data/lib/tailmix/html/data_map.rb +97 -0
  32. data/lib/tailmix/html/selector.rb +19 -0
  33. data/lib/tailmix/html/stimulus_builder.rb +65 -0
  34. data/lib/tailmix/runtime/action.rb +51 -0
  35. data/lib/tailmix/runtime/context.rb +74 -0
  36. data/lib/tailmix/runtime/facade_builder.rb +23 -0
  37. data/lib/tailmix/runtime/stimulus/compiler.rb +59 -0
  38. data/lib/tailmix/runtime.rb +14 -0
  39. data/lib/tailmix/version.rb +1 -1
  40. data/lib/tailmix.rb +18 -23
  41. metadata +37 -12
  42. data/examples/interactive_component.rb +0 -42
  43. data/examples/status_badge_component.rb +0 -44
  44. data/lib/tailmix/action.rb +0 -27
  45. data/lib/tailmix/dimension.rb +0 -18
  46. data/lib/tailmix/element.rb +0 -24
  47. data/lib/tailmix/manager.rb +0 -86
  48. data/lib/tailmix/part.rb +0 -39
  49. data/lib/tailmix/resolver.rb +0 -28
  50. data/lib/tailmix/schema.rb +0 -41
  51. data/lib/tailmix/utils.rb +0 -15
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ module Dev
5
+ class StimulusGenerator
6
+ def initialize(definition, component_name)
7
+ @definition = definition
8
+ @component_name = component_name
9
+ @stimulus_defs = definition.elements.values.flat_map(&:stimulus).flat_map(&:definitions)
10
+ end
11
+
12
+ def scaffold(controller_name = nil, show_docs: false)
13
+ controllers_to_generate = controller_name ? [ controller_name.to_s ] : all_controllers
14
+
15
+ output = controllers_to_generate.map do |name|
16
+ defs = @stimulus_defs.select { |d| d[:controller] == name }
17
+
18
+ show_docs ? generate_docs_for(name, defs) + "\n" : generate_js_for(name, defs) + ("-" * 60) + "\n"
19
+ end
20
+ output.join("\n")
21
+ end
22
+
23
+ private
24
+
25
+ def generate_docs_for(controller_name, defs)
26
+ output = [ "Stimulus:" ]
27
+ output << " - on `#{controller_name}` controller:"
28
+ targets = defs.select { |d| d[:type] == :target }.map { |d| d[:name] }
29
+ output << " - Targets: #{targets.join(', ')}" if targets.any?
30
+
31
+ actions = action_methods(defs)
32
+ output << " - Actions: #{actions.join(', ')}" if actions.any?
33
+
34
+ values = defs.select { |d| d[:type] == :value }.map { |d| d[:name] }
35
+ output << " - Values: #{values.join(', ')}" if values.any?
36
+
37
+ output.join("\n")
38
+ end
39
+
40
+ def generate_js_for(controller_name, defs)
41
+ targets = defs.select { |d| d[:type] == :target }.map { |d| "'#{d[:name]}'" }.uniq.join(", ")
42
+
43
+ payload_actions = defs.select { |d| d[:type] == :action_payload }
44
+ simple_values = defs.select { |d| d[:type] == :value }
45
+
46
+ value_names = (payload_actions.map { |d| d[:value_name] } + simple_values.map { |d| d[:name] })
47
+ .uniq.map { |name| "#{snake_to_camel(name.to_s)}: Object" }.join(", ")
48
+
49
+ isomorphic_methods = payload_actions.map do |payload_def|
50
+ action_name_camel = snake_to_camel(payload_def[:action_name].to_s)
51
+ value_name_camel = snake_to_camel(payload_def[:value_name].to_s)
52
+
53
+ " #{action_name_camel}(event) {\n if (event) event.preventDefault();\n Tailmix.run({ config: this.#{value_name_camel}Value, controller: this });\n }"
54
+ end.join.strip
55
+
56
+ standard_action_names = defs.select { |d| d[:type] == :action }
57
+ .flat_map { |d| extract_action_methods(d[:data]) }
58
+ .uniq
59
+
60
+ implemented_action_names = payload_actions.map { |d| d[:action_name].to_s }
61
+ stub_methods = (standard_action_names - implemented_action_names).map do |method_name|
62
+ method_name_camel = snake_to_camel(method_name)
63
+ " #{method_name_camel}() {\n console.log('#{controller_name}##{method_name_camel} fired');\n }"
64
+ end.join("\n\n")
65
+
66
+ js_methods = [isomorphic_methods, stub_methods].reject(&:empty?).join("\n\n")
67
+
68
+ <<~JAVASCRIPT
69
+ // Generated by Tailmix for the "#{controller_name}" controller
70
+ // Path: app/javascript/controllers/#{controller_name.tr('_', '-')}_controller.js
71
+ import { Controller } from "@hotwired/stimulus"
72
+ import Tailmix from "tailmix"
73
+
74
+ export default class extends Controller {
75
+ static targets = [#{targets}]
76
+ static values = { #{value_names} }
77
+
78
+ connect() {
79
+ console.log("#{controller_name} controller connected to", this.element);
80
+ }
81
+ #{js_methods}
82
+ }
83
+ JAVASCRIPT
84
+ end
85
+
86
+ def extract_action_methods(action_data)
87
+ case action_data[:type]
88
+ when :raw
89
+ action_data[:content].to_s.scan(/#(\w+)/).flatten
90
+ when :hash
91
+ action_data[:content].values.map(&:to_s)
92
+ when :tuple
93
+ [action_data[:content][1].to_s]
94
+ else
95
+ []
96
+ end
97
+ end
98
+
99
+ def all_controllers
100
+ @stimulus_defs.map { |d| d[:controller] }.compact.uniq
101
+ end
102
+
103
+ def action_methods(defs)
104
+ defs.select { |d| d[:type] == :action }.flat_map do |action_definition|
105
+ data = action_definition[:data]
106
+ case data[:type]
107
+ when :raw
108
+ data[:content].to_s.scan(/#(\w+)/).flatten
109
+ when :hash
110
+ data[:content].values.map(&:to_s)
111
+ when :tuple
112
+ [ data[:content][1].to_s ]
113
+ else
114
+ []
115
+ end
116
+ end.uniq
117
+ end
118
+
119
+ def snake_to_camel(str)
120
+ str.split("_").map.with_index { |word, i| i.zero? ? word : word.capitalize }.join
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stimulus_generator"
4
+ require_relative "docs"
5
+
6
+ module Tailmix
7
+ module Dev
8
+ class Tools
9
+ attr_reader :definition, :component_class
10
+
11
+ def initialize(component_class)
12
+ @component_class = component_class
13
+ @definition = component_class.tailmix_definition
14
+ end
15
+
16
+ def docs
17
+ Dev::Docs.new(self).generate
18
+ end
19
+ alias_method :help, :docs
20
+
21
+ def stimulus
22
+ StimulusGenerator.new(@definition, @component_class.name)
23
+ end
24
+
25
+ def elements
26
+ @definition.elements.values.map(&:name)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "definition/context_builder"
4
+ require_relative "definition/merger"
5
+ require_relative "dev/tools"
6
+
7
+ module Tailmix
8
+ # The main DSL for defining component styles and behaviors.
9
+ # This module is extended into any class that includes Tailmix.
10
+ module DSL
11
+ def tailmix(&block)
12
+ child_context = Definition::ContextBuilder.new
13
+ child_context.instance_eval(&block)
14
+ child_definition = child_context.build_definition
15
+
16
+ if superclass.respond_to?(:tailmix_definition) && (parent_definition = superclass.tailmix_definition)
17
+ @tailmix_definition = Definition::Merger.call(parent_definition, child_definition)
18
+ else
19
+ @tailmix_definition = child_definition
20
+ end
21
+ end
22
+
23
+ def tailmix_definition
24
+ @tailmix_definition || raise(Error, "Tailmix definition not found in #{name}")
25
+ end
26
+
27
+ def tailmix_facade_class
28
+ @_tailmix_facade_class ||= Runtime::FacadeBuilder.build(tailmix_definition)
29
+ end
30
+
31
+ def dev
32
+ Dev::Tools.new(self)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ class Engine < ::Rails::Engine
5
+ config.before_initialize do
6
+ Rails.application.config.assets.paths << Engine.root.join("app/javascript")
7
+ end
8
+
9
+ PRECOMPILE_ASSETS = %w[ index.js runner.js finder.js mutator.js stimulus_adapter.js ]
10
+
11
+ initializer "tailmix.assets" do
12
+ if Rails.application.config.respond_to?(:assets)
13
+ Rails.application.config.assets.precompile += PRECOMPILE_ASSETS
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require_relative "class_list"
5
+ require_relative "data_map"
6
+ require_relative "selector"
7
+
8
+ module Tailmix
9
+ module HTML
10
+ class Attributes < Hash
11
+ attr_reader :element_name, :variant_string
12
+
13
+ def initialize(initial_hash = {}, element_name: nil, variant_string: "")
14
+ @element_name = element_name
15
+ @variant_string = variant_string
16
+ super()
17
+
18
+ attrs_to_merge = initial_hash.dup
19
+
20
+ initial_classes = attrs_to_merge.delete(:class)
21
+ initial_data = attrs_to_merge.delete(:data)
22
+ initial_aria = attrs_to_merge.delete(:aria)
23
+
24
+ self[:class] = ClassList.new(initial_classes)
25
+ self[:data] = DataMap.new("data", initial_data || {})
26
+ self[:aria] = DataMap.new("aria", initial_aria || {})
27
+ self[:tailmix] = Selector.new(element_name, variant_string)
28
+
29
+ merge!(attrs_to_merge)
30
+ end
31
+
32
+ def each(&block)
33
+ to_h.each(&block)
34
+ end
35
+
36
+ def to_h
37
+ final_attrs = select { |k, _| !%i[class data aria tailmix].include?(k.to_sym) }
38
+
39
+ class_string = self[:class].to_s
40
+ final_attrs[:class] = class_string unless class_string.empty?
41
+
42
+ final_attrs.merge!(self[:data].to_h)
43
+ final_attrs.merge!(self[:aria].to_h)
44
+ final_attrs.merge!(self[:tailmix].to_h)
45
+
46
+ final_attrs
47
+ end
48
+ alias_method :to_hash, :to_h
49
+
50
+ def to_s
51
+ classes.to_s
52
+ end
53
+
54
+ def classes
55
+ self[:class]
56
+ end
57
+
58
+ def data
59
+ self[:data]
60
+ end
61
+
62
+ def aria
63
+ self[:aria]
64
+ end
65
+
66
+ def stimulus
67
+ data.stimulus
68
+ end
69
+
70
+ def tailmix
71
+ self[:tailmix]
72
+ end
73
+
74
+ def toggle(class_names)
75
+ classes.toggle(class_names)
76
+ self
77
+ end
78
+
79
+ def add(class_names)
80
+ classes.add(class_names)
81
+ self
82
+ end
83
+
84
+ def remove(class_names)
85
+ classes.remove(class_names)
86
+ self
87
+ end
88
+
89
+ def each_attribute(&block)
90
+ [ classes: classes, data: data.to_h, aria: aria.to_h, tailmix: tailmix.to_h ].each(&block)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Tailmix
6
+ module HTML
7
+ # Manages a set of CSS classes with a fluent, chainable API.
8
+ # Inherits from Set to ensure uniqueness and leverage its performance.
9
+ class ClassList < Set
10
+ # Initializes a new ClassList.
11
+ # @param initial_classes [String, Array, Set, nil] The initial classes to add.
12
+ def initialize(initial_classes = nil)
13
+ super()
14
+ add(initial_classes) if initial_classes
15
+ end
16
+
17
+ # Adds one or more classes. Handles strings, arrays, or other sets.
18
+ # This method is MUTABLE and chainable.
19
+ # @param class_names [String, Array, Set, nil]
20
+ # @return [self]
21
+ def add(class_names)
22
+ each_token(class_names) { |token| super(token) }
23
+ self
24
+ end
25
+ alias << add
26
+
27
+ # Removes one or more classes.
28
+ # This method is MUTABLE and chainable.
29
+ # @param class_names [String, Array, Set, nil]
30
+ # @return [self]
31
+ def remove(class_names)
32
+ each_token(class_names) { |token| delete(token) }
33
+ self
34
+ end
35
+
36
+ # Toggles one or more classes.
37
+ # This method is MUTABLE and chainable.
38
+ # @param class_names [String, Array, Set, nil]
39
+ # @return [self]
40
+ def toggle(class_names)
41
+ each_token(class_names) { |token| include?(token) ? delete(token) : add(token) }
42
+ self
43
+ end
44
+
45
+ # Returns a new ClassList with the given classes added. IMMUTABLE.
46
+ def added(class_names)
47
+ dup.add(class_names)
48
+ end
49
+
50
+ # Returns a new ClassList with the given classes removed. IMMUTABLE.
51
+ def removed(class_names)
52
+ dup.remove(class_names)
53
+ end
54
+
55
+ # Returns a new ClassList with the given classes toggled. IMMUTABLE.
56
+ def toggled(class_names)
57
+ dup.toggle(class_names)
58
+ end
59
+
60
+ # Renders the set of classes to a space-separated string for HTML.
61
+ # @return [String]
62
+ def to_s
63
+ to_a.join(" ")
64
+ end
65
+
66
+ private
67
+
68
+ # A robust way to iterate over tokens from various input types.
69
+ def each_token(input)
70
+ return unless input
71
+ # Convert Set/ClassList to array before splitting strings inside
72
+ items = input.is_a?(Set) ? input.to_a : Array(input)
73
+ items.each do |item|
74
+ item.to_s.split.each { |token| yield token unless token.empty? }
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "json"
5
+ require_relative "stimulus_builder"
6
+
7
+ module Tailmix
8
+ module HTML
9
+ class DataMap
10
+ MERGEABLE_LIST_ATTRIBUTES = %i[controller action target].freeze
11
+
12
+ def initialize(prefix, initial_data = {})
13
+ @prefix = prefix
14
+ @data = {}
15
+ merge!(initial_data)
16
+ end
17
+
18
+ def stimulus
19
+ raise "Stimulus builder is only available for data attributes" unless @prefix == "data"
20
+ StimulusBuilder.new(self)
21
+ end
22
+
23
+ def merge!(other_data)
24
+ return self unless other_data
25
+ data_to_merge = other_data.is_a?(DataMap) ? other_data.instance_variable_get(:@data) : other_data
26
+
27
+ (data_to_merge || {}).each do |key, value|
28
+ key = key.to_sym
29
+ if value.is_a?(Hash) && @data[key].is_a?(Hash)
30
+ @data[key].merge!(value)
31
+ elsif @prefix == "data" && MERGEABLE_LIST_ATTRIBUTES.include?(key)
32
+ add_to_set(key, value)
33
+ else
34
+ @data[key] = value
35
+ end
36
+ end
37
+ self
38
+ end
39
+ alias_method :add, :merge!
40
+
41
+ def merge(other_data)
42
+ dup.merge!(other_data)
43
+ end
44
+
45
+ def add_to_set(key, value)
46
+ @data[key] ||= Set.new
47
+ return unless value
48
+ items_to_process = value.is_a?(Set) ? value.to_a : Array(value)
49
+ items_to_process.each do |item|
50
+ item.to_s.split.each do |token|
51
+ @data[key].add(token) unless token.empty?
52
+ end
53
+ end
54
+ end
55
+
56
+ def remove(other_data)
57
+ (other_data || {}).each do |key, _|
58
+ @data.delete(key.to_sym)
59
+ end
60
+ self
61
+ end
62
+
63
+ def toggle(other_data)
64
+ (other_data || {}).each do |key, value|
65
+ key = key.to_sym
66
+ @data[key] == value ? @data.delete(key) : @data[key] = value
67
+ end
68
+ self
69
+ end
70
+
71
+ def to_h
72
+ flatten_data_hash(@data, @prefix)
73
+ end
74
+
75
+ private
76
+
77
+ def flatten_data_hash(hash, prefix, accumulator = {})
78
+ hash.each do |key, value|
79
+ current_key = "#{prefix}-#{key.to_s.tr('_', '-')}"
80
+ if @prefix == "data" && key.to_s.end_with?("_value")
81
+ serialized_value = case value
82
+ when Hash, Array then value.to_json
83
+ else value
84
+ end
85
+ accumulator[current_key] = serialized_value
86
+ elsif value.is_a?(Hash)
87
+ flatten_data_hash(value, current_key, accumulator)
88
+ else
89
+ serialized_value = value.is_a?(Set) ? value.to_a.join(" ") : value
90
+ accumulator[current_key] = serialized_value unless serialized_value.to_s.empty?
91
+ end
92
+ end
93
+ accumulator
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ module HTML
5
+ class Selector
6
+ def initialize(element_name, variant_string)
7
+ @element_name = element_name
8
+ @variant_string = variant_string
9
+ end
10
+
11
+ def to_h
12
+ return {} unless @element_name
13
+
14
+ key = "data-tailmix-#{@element_name}"
15
+ { key => @variant_string }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ module HTML
5
+ # A fluent DSL (builder) for constructing Stimulus data attributes.
6
+ # It acts as a proxy, modifying a DataMap instance directly.
7
+ class StimulusBuilder
8
+ def initialize(data_map)
9
+ @data_map = data_map
10
+ @context = nil # For context-aware attributes like targets and values
11
+ end
12
+
13
+ # Defines a controller and sets it as the current context.
14
+ # @return [self] for chaining.
15
+ def controller(controller_name)
16
+ @data_map.add_to_set(:controller, controller_name)
17
+ @context = controller_name.to_s
18
+ self
19
+ end
20
+
21
+ # Sets the controller context for subsequent calls.
22
+ def context(controller_name)
23
+ @context = controller_name.to_s
24
+ self
25
+ end
26
+
27
+ # Adds an action.
28
+ # @example
29
+ # .action("click->modal#open")
30
+ # @return [self]
31
+ def action(action_string)
32
+ @data_map.add_to_set(:action, action_string)
33
+ self
34
+ end
35
+
36
+ # Adds a target, scoped to the current controller context.
37
+ # @return [self]
38
+ def target(target_name)
39
+ ensure_context!
40
+ # `target` is a shared attribute, but names are scoped to a controller.
41
+ # So we add to the common `target` set.
42
+ @data_map.add_to_set(:"#{@context}-target", target_name)
43
+ self
44
+ end
45
+
46
+ # Adds a value, scoped to the current controller context.
47
+ # @return [self]
48
+ def value(value_name, value)
49
+ ensure_context!
50
+ @data_map.merge!("#{context_key(value_name)}_value" => value)
51
+ self
52
+ end
53
+
54
+ private
55
+
56
+ def ensure_context!
57
+ raise "A controller context must be set via .controller() or .context() before this call." unless @context
58
+ end
59
+
60
+ def context_key(name)
61
+ "#{@context}-#{name.to_s.tr('_', '-')}"
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ module Runtime
5
+ # Represents a callable action at runtime that can apply a set of
6
+ # predefined mutations to its context.
7
+ class Action
8
+ attr_reader :context, :definition
9
+
10
+ def initialize(context, action_name)
11
+ @context = context
12
+ @action_name = action_name.to_sym
13
+ @definition = context.definition.actions[@action_name]
14
+ raise Error, "Action `#{@action_name}` not found." unless @definition
15
+ end
16
+
17
+ # Applies the mutations to the context immutably, returning a new context.
18
+ # @return [Context] A new, modified context instance.
19
+ def apply
20
+ new_context = context.dup
21
+
22
+ action_on_clone = self.class.new(new_context, @action_name)
23
+
24
+ action_on_clone.apply!
25
+ end
26
+
27
+ def apply!
28
+ # `definition.mutations` { element_name => [commands] }
29
+ definition.mutations.each do |element_name, commands|
30
+ attributes_object = context.live_attributes_for(element_name)
31
+ next unless attributes_object
32
+
33
+ commands.each do |command|
34
+ target_field = attributes_object.public_send(command[:field])
35
+ target_field.public_send(command[:method], command[:payload])
36
+ end
37
+ end
38
+ context
39
+ end
40
+
41
+ # Serializes the action's definition into a hash for the JS bridge.
42
+ # @return [Hash]
43
+ def to_h
44
+ {
45
+ method: definition.action,
46
+ mutations: definition.mutations
47
+ }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ module Runtime
5
+ class Context
6
+ attr_reader :component_instance, :definition, :dimensions
7
+
8
+ def initialize(component_instance, definition, dimensions)
9
+ @component_instance = component_instance
10
+ @definition = definition
11
+ @dimensions = dimensions
12
+ @attributes_cache = {}
13
+ end
14
+
15
+ def initialize_copy(source)
16
+ super
17
+ @attributes_cache = source.instance_variable_get(:@attributes_cache).transform_values(&:dup)
18
+ end
19
+
20
+ def live_attributes_for(element_name)
21
+ @attributes_cache[element_name] ||= build_attributes_for(element_name, @dimensions)
22
+ end
23
+
24
+ def attributes_for(element_name, runtime_dimensions = {})
25
+ merged_dimensions = @dimensions.merge(runtime_dimensions)
26
+ return @attributes_cache[element_name] if merged_dimensions == @dimensions && @attributes_cache[element_name]
27
+
28
+ attributes_object = build_attributes_for(element_name, merged_dimensions)
29
+ @attributes_cache[element_name] = attributes_object if merged_dimensions == @dimensions
30
+ attributes_object
31
+ end
32
+
33
+ def action(name)
34
+ Action.new(self, name)
35
+ end
36
+
37
+ private
38
+
39
+ def build_attributes_for(element_name, dimensions)
40
+ element_def = @definition.elements.fetch(element_name)
41
+
42
+ active_dimensions = dimensions.slice(*element_def.dimensions.keys)
43
+ variant_string = active_dimensions.map { |k, v| "#{k}:#{v}" }.join(",")
44
+
45
+ attributes = HTML::Attributes.new(
46
+ { class: element_def.attributes.classes },
47
+ element_name: element_def.name,
48
+ variant_string: variant_string,
49
+ )
50
+
51
+ element_def.dimensions.each do |name, dim_def|
52
+ value = dimensions.fetch(name, dim_def[:default])
53
+ next if value.nil?
54
+
55
+ variant_def = dim_def.fetch(:variants, {}).fetch(value, nil)
56
+ next unless variant_def
57
+
58
+ attributes.classes.add(variant_def.classes)
59
+ attributes.data.merge!(variant_def.data)
60
+ attributes.aria.merge!(variant_def.aria)
61
+ end
62
+
63
+ Stimulus::Compiler.call(
64
+ definition: element_def.stimulus,
65
+ data_map: attributes.data,
66
+ root_definition: @definition,
67
+ component: @component_instance
68
+ )
69
+
70
+ attributes
71
+ end
72
+ end
73
+ end
74
+ end