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.
- checksums.yaml +4 -4
- data/.rubocop.yml +3 -0
- data/README.md +147 -85
- data/app/javascript/tailmix/finder.js +15 -0
- data/app/javascript/tailmix/index.js +7 -0
- data/app/javascript/tailmix/mutator.js +28 -0
- data/app/javascript/tailmix/runner.js +7 -0
- data/app/javascript/tailmix/stimulus_adapter.js +37 -0
- data/examples/_modal_component.arb +36 -0
- data/examples/modal_component.rb +209 -0
- data/lib/generators/tailmix/install_generator.rb +19 -0
- data/lib/tailmix/configuration.rb +13 -0
- data/lib/tailmix/definition/context_builder.rb +39 -0
- data/lib/tailmix/definition/contexts/action_builder.rb +31 -0
- data/lib/tailmix/definition/contexts/actions/element_builder.rb +30 -0
- data/lib/tailmix/definition/contexts/attribute_builder.rb +21 -0
- data/lib/tailmix/definition/contexts/dimension_builder.rb +34 -0
- data/lib/tailmix/definition/contexts/element_builder.rb +41 -0
- data/lib/tailmix/definition/contexts/stimulus_builder.rb +101 -0
- data/lib/tailmix/definition/contexts/variant_builder.rb +35 -0
- data/lib/tailmix/definition/merger.rb +86 -0
- data/lib/tailmix/definition/result.rb +78 -0
- data/lib/tailmix/definition.rb +11 -0
- data/lib/tailmix/dev/docs.rb +88 -0
- data/lib/tailmix/dev/stimulus_generator.rb +124 -0
- data/lib/tailmix/dev/tools.rb +30 -0
- data/lib/tailmix/dsl.rb +35 -0
- data/lib/tailmix/engine.rb +17 -0
- data/lib/tailmix/html/attributes.rb +94 -0
- data/lib/tailmix/html/class_list.rb +79 -0
- data/lib/tailmix/html/data_map.rb +97 -0
- data/lib/tailmix/html/selector.rb +19 -0
- data/lib/tailmix/html/stimulus_builder.rb +65 -0
- data/lib/tailmix/runtime/action.rb +51 -0
- data/lib/tailmix/runtime/context.rb +74 -0
- data/lib/tailmix/runtime/facade_builder.rb +23 -0
- data/lib/tailmix/runtime/stimulus/compiler.rb +59 -0
- data/lib/tailmix/runtime.rb +14 -0
- data/lib/tailmix/version.rb +1 -1
- data/lib/tailmix.rb +18 -23
- metadata +37 -12
- data/examples/interactive_component.rb +0 -42
- data/examples/status_badge_component.rb +0 -44
- data/lib/tailmix/action.rb +0 -27
- data/lib/tailmix/dimension.rb +0 -18
- data/lib/tailmix/element.rb +0 -24
- data/lib/tailmix/manager.rb +0 -86
- data/lib/tailmix/part.rb +0 -39
- data/lib/tailmix/resolver.rb +0 -28
- data/lib/tailmix/schema.rb +0 -41
- 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
|
data/lib/tailmix/dsl.rb
ADDED
|
@@ -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
|