pom-component 1.0.0

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.
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
6
+ load "rails/tasks/engine.rake"
7
+
8
+ load "rails/tasks/statistics.rake"
9
+
10
+ require "bundler/gem_tasks"
11
+
12
+ # Add a convenient test task alias
13
+ task test: "app:test"
14
+ task default: :test
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pom
4
+ # Base Rails ViewComponent class with DSL for options, enum validation, extra options capture, and required options enforcement
5
+ class Component < ViewComponent::Base
6
+ include Pom::OptionDsl
7
+ include Pom::Styleable
8
+ include Pom::Helpers::OptionHelper
9
+ include Pom::Helpers::ViewHelper
10
+ include Pom::Helpers::StimulusHelper
11
+
12
+ # Initialize component with options, applying defaults, validation, capturing extra options, and enforcing required options
13
+ def initialize(**kwargs)
14
+ super()
15
+ initialize_options(**kwargs)
16
+ end
17
+
18
+ def component_name
19
+ self.class.name.demodulize.chomp("Component").underscore.dasherize
20
+ end
21
+
22
+ def auto_id
23
+ [component_name, uid].compact_blank.join("-")
24
+ end
25
+
26
+ def uid
27
+ @uid ||= SecureRandom.hex(8 / 2)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pom
4
+ class Configuration
5
+ attr_accessor :component_prefixes
6
+
7
+ def initialize
8
+ @component_prefixes = ["pom"]
9
+ end
10
+ end
11
+
12
+ class << self
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield(configuration)
19
+ end
20
+
21
+ def reset_configuration!
22
+ @configuration = Configuration.new
23
+ end
24
+ end
25
+ end
data/lib/pom/engine.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pom
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Pom
6
+
7
+ initializer "pom.helpers" do
8
+ ActiveSupport.on_load(:action_controller_base) do
9
+ helper Pom::Helpers::OptionHelper
10
+ helper Pom::Helpers::ViewHelper
11
+ helper Pom::Helpers::StimulusHelper
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pom
4
+ module Helpers
5
+ module OptionHelper
6
+ # Merges multiple option hashes from right to left, handling CSS classes and data attributes.
7
+ # @param options [Array<Hash>] List of option hashes to merge
8
+ # @return [Hash] Merged options hash
9
+ def merge_options(*options)
10
+ options.reduce({}) do |merged, opts|
11
+ next merged if opts.nil? || opts.empty?
12
+
13
+ merged.merge(opts) do |key, old_val, new_val|
14
+ case key
15
+ when :class
16
+ merge_classes(old_val, new_val)
17
+ when :data
18
+ merge_data(old_val, new_val)
19
+ else
20
+ new_val
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ # Merges CSS classes using tailwind_merge gem, handling strings, arrays, or nil.
29
+ # @param old_val [String, Array, nil] Existing class value
30
+ # @param new_val [String, Array, nil] New class value
31
+ # @return [String] Merged class string
32
+ def merge_classes(old_val, new_val)
33
+ old_classes = normalize_classes(old_val)
34
+ new_classes = normalize_classes(new_val)
35
+ TailwindMerge::Merger.new.merge([old_classes, new_classes].join(" "))
36
+ end
37
+
38
+ # Normalizes class values to a string, handling strings, arrays, or nil.
39
+ # @param value [String, Array, nil] Class value
40
+ # @return [String] Normalized class string
41
+ def normalize_classes(value)
42
+ case value
43
+ when String
44
+ value.strip
45
+ when Array
46
+ value.flatten.map(&:to_s).reject(&:empty?).join(" ").strip
47
+ else
48
+ ""
49
+ end
50
+ end
51
+
52
+ # Merges data attribute hashes, with special handling for data-action and data-controller.
53
+ # @param old_val [Hash, nil] Existing data hash
54
+ # @param new_val [Hash, nil] New data hash
55
+ # @return [Hash] Merged data hash
56
+ def merge_data(old_val, new_val)
57
+ old_data = old_val || {}
58
+ new_data = new_val || {}
59
+
60
+ old_data.merge(new_data) do |key, old_data_val, new_data_val|
61
+ if ["action", "controller"].include?(key.to_s)
62
+ [old_data_val, new_data_val].uniq.compact.join(" ").strip
63
+ else
64
+ new_data_val
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ module Pom
6
+ module Helpers
7
+ # Provides helper methods for generating Stimulus.js data attributes in Rails views or ViewComponents.
8
+ module StimulusHelper
9
+ # Generates a Stimulus data value attribute.
10
+ # @param name [Symbol, String] The name of the value.
11
+ # @param value [Object] The value to assign. Complex objects (Array, Hash) are JSON-encoded.
12
+ # @param stimulus [String, Symbol, nil] The Stimulus controller name (defaults to `stimulus_controller`).
13
+ # @return [Hash] A hash with the attribute key and value.
14
+ def stimulus_value(name, value, stimulus: nil)
15
+ raise ArgumentError, "Name cannot be blank" if name.to_s.strip.empty?
16
+
17
+ controller_name = stimulus ? stimulus.to_s.underscore.dasherize : stimulus_controller
18
+ key = "data-#{controller_name}-#{name.to_s.underscore.dasherize}-value"
19
+
20
+ # Stimulus expects JSON for complex values (arrays, hashes)
21
+ serialized_value = case value
22
+ when Array, Hash
23
+ value.to_json
24
+ else
25
+ value
26
+ end
27
+
28
+ { key => serialized_value }
29
+ end
30
+
31
+ # Generates a Stimulus target attribute.
32
+ # @param name [Symbol, String, Array] The target name(s). Can be a single target or array of targets.
33
+ # @param stimulus [String, Symbol, nil] The Stimulus controller name (defaults to `stimulus_controller`).
34
+ # @return [Hash] A hash with the attribute key and target name(s).
35
+ #
36
+ # @example Single target
37
+ # stimulus_target(:menu)
38
+ # # => { "data-dropdown-target" => "menu" }
39
+ #
40
+ # @example Multiple targets
41
+ # stimulus_target([:menu, :button])
42
+ # # => { "data-dropdown-target" => "menu button" }
43
+ def stimulus_target(name, stimulus: nil)
44
+ names = Array(name)
45
+ raise ArgumentError, "Name cannot be blank" if names.empty? || names.any? { |n| n.to_s.strip.empty? }
46
+
47
+ controller_name = stimulus ? stimulus.to_s.underscore.dasherize : stimulus_controller
48
+ key = "data-#{controller_name}-target"
49
+ target_value = names.map(&:to_s).join(" ")
50
+
51
+ { key => target_value }
52
+ end
53
+
54
+ # Generates a Stimulus class attribute.
55
+ # @param name [Symbol, String] The class name.
56
+ # @param value [String] The CSS class value.
57
+ # @param stimulus [String, Symbol, nil] The Stimulus controller name (defaults to `stimulus_controller`).
58
+ # @return [Hash] A hash with the attribute key and class value.
59
+ def stimulus_class(name, value, stimulus: nil)
60
+ raise ArgumentError, "Name cannot be blank" if name.to_s.strip.empty?
61
+
62
+ controller_name = stimulus ? stimulus.to_s.underscore.dasherize : stimulus_controller
63
+ key = "data-#{controller_name}-#{name.to_s.underscore.dasherize}-class"
64
+ { key => value }
65
+ end
66
+
67
+ # Generates a Stimulus action attribute.
68
+ # @param action_map [Hash, Symbol, String] The action definition (e.g., `{ click: :toggle }` or `:toggle`).
69
+ # @param stimulus [String, Symbol, nil] The Stimulus controller name (defaults to `stimulus_controller`).
70
+ # @return [Hash] A hash with the action attribute key and value.
71
+ #
72
+ # @example Hash format with multiple events
73
+ # stimulus_action({ click: :toggle, mouseenter: :show })
74
+ # # => { "data-action" => "click->dropdown#toggle mouseenter->dropdown#show" }
75
+ #
76
+ # @example Symbol/String format
77
+ # stimulus_action(:toggle)
78
+ # # => { "data-action" => "dropdown#toggle" }
79
+ def stimulus_action(action_map, stimulus: nil)
80
+ controller_name = stimulus ? stimulus.to_s.underscore.dasherize : stimulus_controller
81
+
82
+ action_string = case action_map
83
+ when Hash
84
+ return { "data-action" => "" } if action_map.empty?
85
+
86
+ action_map.map { |event, method| "#{event}->#{controller_name}##{method}" }.join(" ")
87
+ when Symbol, String
88
+ str = action_map.to_s
89
+ if str.include?("->")
90
+ raise ArgumentError,
91
+ "Do not include controller name manually. Use `stimulus:` or `stimulus_controller` to set the controller."
92
+ end
93
+ "#{controller_name}##{str}"
94
+ else
95
+ raise ArgumentError, "Invalid format for stimulus_action: must be a Hash, Symbol, or String"
96
+ end
97
+
98
+ { "data-action" => action_string }
99
+ end
100
+
101
+ # Retrieves the Stimulus controller name in dasherized style.
102
+ # @return [String] The controller name.
103
+ # @raise [ArgumentError] If no controller is defined or if the controller name is blank.
104
+ def stimulus_controller
105
+ controller = respond_to?(:stimulus) ? stimulus : nil
106
+ controller ||= raise ArgumentError,
107
+ "No Stimulus controller available. Provide one explicitly via `stimulus:` or define a `stimulus` method."
108
+ raise ArgumentError, "Stimulus controller cannot be blank" if controller.to_s.strip.empty?
109
+
110
+ controller.to_s.underscore.dasherize
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pom
4
+ module Helpers
5
+ module ViewHelper
6
+ class UndefinedComponentError < StandardError; end
7
+
8
+ def method_missing(method_name, *args, **kwargs, &block)
9
+ prefix_match = component_prefixes.find { |prefix| method_name.to_s.start_with?("#{prefix}_") }
10
+
11
+ if prefix_match
12
+ class_name = component_class_name(method_name, prefix_match)
13
+
14
+ begin
15
+ component_class = class_name.constantize
16
+ rescue NameError => e
17
+ if e.message.include?(class_name)
18
+ raise UndefinedComponentError, "Component class '#{class_name}' is not defined"
19
+ else
20
+ raise e
21
+ end
22
+ end
23
+
24
+ render(component_class.new(*args, **kwargs), &block)
25
+ else
26
+ super
27
+ end
28
+ end
29
+
30
+ def respond_to_missing?(method_name, include_private = false)
31
+ prefix_match = component_prefixes.find { |prefix| method_name.to_s.start_with?("#{prefix}_") }
32
+
33
+ if prefix_match
34
+ class_name = component_class_name(method_name, prefix_match)
35
+ # Check if the constant exists by attempting to constantize it
36
+ begin
37
+ class_name.constantize
38
+ true
39
+ rescue NameError
40
+ false
41
+ end
42
+ else
43
+ super
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def component_prefixes
50
+ Pom.configuration.component_prefixes
51
+ end
52
+
53
+ def component_class_name(method_name, prefix)
54
+ component_name = method_name.to_s.sub(/^#{prefix}_/, "").camelize
55
+ "#{prefix.camelize}::#{component_name}Component"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pom
4
+ module OptionDsl
5
+ # Sentinel object to distinguish between nil and no default
6
+ NO_DEFAULT = Object.new.freeze
7
+ private_constant :NO_DEFAULT
8
+
9
+ class << self
10
+ def included(base)
11
+ base.extend(ClassMethods)
12
+ base.class_eval do
13
+ # Instance variable to store extra options that aren't defined
14
+ attr_reader(:extra_options)
15
+ end
16
+ end
17
+ end
18
+
19
+ module ClassMethods
20
+ attr_reader :options
21
+
22
+ # Helper methods for accessing option metadata
23
+ def enum_values_for(option_name)
24
+ meta = @options&.[](option_name.to_sym)
25
+ meta&.dig(:enums)
26
+ end
27
+
28
+ def default_value_for(option_name)
29
+ meta = @options&.[](option_name.to_sym)
30
+ return unless meta
31
+
32
+ default_val = meta[:default]
33
+ return if default_val.equal?(NO_DEFAULT)
34
+
35
+ default_val.respond_to?(:call) ? default_val.call : default_val
36
+ end
37
+
38
+ def required_options
39
+ return [] unless @options
40
+
41
+ @options.filter_map do |name, config|
42
+ name if config[:required] && config[:default].equal?(NO_DEFAULT)
43
+ end
44
+ end
45
+
46
+ def optional_options
47
+ return [] unless @options
48
+
49
+ @options.filter_map do |name, config|
50
+ name unless config[:required] && config[:default].equal?(NO_DEFAULT)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ # Initialize options hash for the class, inheriting from parent if applicable
57
+ def inherited(subclass)
58
+ super
59
+ subclass.instance_variable_set(:@options, (@options || {}).dup)
60
+ end
61
+
62
+ # DSL for defining options with enums, defaults, and required flag
63
+ def option(name, enums: nil, default: NO_DEFAULT, required: false)
64
+ @options ||= {}
65
+ @options[name.to_sym] = {
66
+ enums: enums&.map(&:to_sym),
67
+ default: default,
68
+ required: required,
69
+ }
70
+
71
+ # Define getter method for the option
72
+ define_method(name) do
73
+ value = instance_variable_get(:"@#{name}")
74
+ return value unless value.nil?
75
+
76
+ default_val = self.class.options[name.to_sym][:default]
77
+ return if default_val.equal?(NO_DEFAULT)
78
+
79
+ default_val.respond_to?(:call) ? default_val.call : default_val
80
+ end
81
+
82
+ # Define setter method with validation for enums
83
+ define_method(:"#{name}=") do |value|
84
+ option_config = self.class.options[name.to_sym]
85
+ enums = option_config[:enums]
86
+
87
+ if enums && !value.nil?
88
+ # Convert value to symbol if it's a string for comparison
89
+ sym_value = value.is_a?(String) ? value.to_sym : value
90
+
91
+ # Validate against enums
92
+ if enums.exclude?(sym_value)
93
+ raise ArgumentError, "Invalid value for #{name}: #{value}. Must be one of #{enums.join(", ")}"
94
+ end
95
+
96
+ # Store the converted symbol value for consistency
97
+ value = sym_value
98
+ end
99
+
100
+ instance_variable_set(:"@#{name}", value)
101
+ end
102
+
103
+ # Define predicate method for boolean-style checking
104
+ define_method(:"#{name}?") do
105
+ send(name).present?
106
+ end
107
+ end
108
+ end
109
+
110
+ # Initialize options processing - call this from your initialize method
111
+ def initialize_options(**kwargs)
112
+ @extra_options = {}
113
+ defined_options = self.class.options || {}
114
+
115
+ # Validate required options first
116
+ validate_required_options!(defined_options, kwargs)
117
+
118
+ # Process provided options
119
+ process_provided_options!(defined_options, kwargs)
120
+
121
+ # Set defaults for options not provided in kwargs
122
+ set_default_values!(defined_options, kwargs)
123
+ end
124
+
125
+ # Utility method to get all option values as a hash
126
+ def option_values
127
+ return {} unless self.class.options
128
+
129
+ self.class.options.keys.index_with do |name|
130
+ send(name)
131
+ end
132
+ end
133
+
134
+ # Check if an option is set (not nil and not default)
135
+ def option_set?(name)
136
+ return false unless self.class.options&.key?(name.to_sym)
137
+
138
+ value = instance_variable_get(:"@#{name}")
139
+ !value.nil?
140
+ end
141
+
142
+ # Reset an option to its default value
143
+ def reset_option(name)
144
+ name = name.to_sym
145
+ return unless self.class.options&.key?(name)
146
+
147
+ remove_instance_variable(:"@#{name}") if instance_variable_defined?(:"@#{name}")
148
+ end
149
+
150
+ private
151
+
152
+ def validate_required_options!(defined_options, kwargs)
153
+ defined_options.each do |name, config|
154
+ next unless config[:required]
155
+ next unless config[:default].equal?(NO_DEFAULT)
156
+ next if kwargs.key?(name) || kwargs.key?(name.to_s)
157
+
158
+ raise ArgumentError, "Missing required option: #{name}"
159
+ end
160
+ end
161
+
162
+ def process_provided_options!(defined_options, kwargs)
163
+ kwargs.each do |name, value|
164
+ sym_name = name.to_sym
165
+
166
+ if defined_options.key?(sym_name)
167
+ send(:"#{sym_name}=", value)
168
+ else
169
+ @extra_options[sym_name] = value
170
+ end
171
+ end
172
+ end
173
+
174
+ def set_default_values!(defined_options, kwargs)
175
+ defined_options.each do |name, config|
176
+ next if kwargs.key?(name) || kwargs.key?(name.to_s)
177
+ next if config[:default].equal?(NO_DEFAULT)
178
+
179
+ send(:"#{name}=", config[:default])
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pom
4
+ module Styleable
5
+ class << self
6
+ def included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+ end
10
+
11
+ module ClassMethods
12
+ # Define styles for a component
13
+ # @param group [Symbol] The style group name (defaults to :default)
14
+ # @param styles [Hash] The styles definition
15
+ #
16
+ # Examples:
17
+ # define_styles(base: "btn")
18
+ # define_styles(:root, base: "container")
19
+ # define_styles(
20
+ # base: { default: "btn", hover: "hover:opacity-80" },
21
+ # variant: { solid: "bg-blue-500", outline: "border" }
22
+ # )
23
+ def define_styles(group = :default, **styles)
24
+ # If first argument is a hash, it's actually the styles with default group
25
+ if group.is_a?(Hash)
26
+ styles = group
27
+ group = :default
28
+ end
29
+
30
+ @style_definitions ||= {}
31
+ @style_definitions[group] ||= {}
32
+
33
+ # Merge with existing styles for this group (supports inheritance)
34
+ styles.each do |style_key, style_value|
35
+ @style_definitions[group][style_key] = if @style_definitions[group][style_key].is_a?(Hash) && style_value.is_a?(Hash)
36
+ @style_definitions[group][style_key].merge(style_value)
37
+ else
38
+ style_value
39
+ end
40
+ end
41
+ end
42
+
43
+ # Get style definitions for this class and its ancestors
44
+ def style_definitions
45
+ definitions = {}
46
+
47
+ # Collect style definitions from ancestors (inheritance support)
48
+ ancestors.reverse.each do |ancestor|
49
+ next unless ancestor.respond_to?(:style_definitions_without_inheritance, true)
50
+
51
+ ancestor_defs = ancestor.send(:style_definitions_without_inheritance)
52
+ ancestor_defs&.each do |group, styles|
53
+ definitions[group] ||= {}
54
+ styles.each do |style_key, style_value|
55
+ definitions[group][style_key] = if definitions[group][style_key].is_a?(Hash) && style_value.is_a?(Hash)
56
+ definitions[group][style_key].merge(style_value)
57
+ else
58
+ style_value
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ definitions
65
+ end
66
+
67
+ private
68
+
69
+ def style_definitions_without_inheritance
70
+ @style_definitions
71
+ end
72
+ end
73
+
74
+ # Compose styles based on provided keys and values
75
+ # @param group [Symbol] The style group to use (defaults to :default)
76
+ # @param options [Hash] Style keys and their values, plus any extra params
77
+ # @return [String] The composed Tailwind class string merged using TailwindMerge
78
+ #
79
+ # Examples:
80
+ # styles_for(variant: :solid, color: :red)
81
+ # styles_for(:root, variant: :outline)
82
+ # styles_for(variant: :solid, custom: true)
83
+ def styles_for(group = :default, **options)
84
+ # If first argument is a hash, it's actually the options with default group
85
+ if group.is_a?(Hash)
86
+ options = group
87
+ group = :default
88
+ end
89
+
90
+ definitions = self.class.style_definitions[group]
91
+ return "" unless definitions
92
+
93
+ result_classes = []
94
+
95
+ # Process base styles first
96
+ if definitions[:base]
97
+ base_styles = resolve_style_value(definitions[:base], options)
98
+ result_classes << base_styles if base_styles
99
+ end
100
+
101
+ # Process other style keys
102
+ definitions.each do |style_key, style_config|
103
+ next if style_key == :base # Already processed
104
+ next unless options.key?(style_key) # Only process if key is provided
105
+
106
+ option_value = options[style_key]
107
+ next if option_value.nil? # Skip nil values
108
+
109
+ if style_config.is_a?(Hash)
110
+ # Style config is a hash of variant values
111
+ # Support boolean values, symbol values, and string values
112
+ style_value = if option_value.is_a?(TrueClass) || option_value.is_a?(FalseClass)
113
+ # For boolean values, convert to symbol (:true or :false)
114
+ # because { true: "..." } in Ruby creates a symbol key :true, not boolean key true
115
+ style_config[option_value.to_s.to_sym]
116
+ else
117
+ # For other values, try symbol and string conversions
118
+ style_config[option_value.to_sym] || style_config[option_value.to_s]
119
+ end
120
+ resolved = resolve_style_value(style_value, options)
121
+ else
122
+ # Style config is a direct value (string or lambda)
123
+ resolved = resolve_style_value(style_config, options)
124
+ end
125
+ result_classes << resolved if resolved
126
+ end
127
+
128
+ # Use TailwindMerge to merge classes and resolve conflicts
129
+ merged_classes = result_classes.compact.join(" ")
130
+ merged_classes.empty? ? "" : TailwindMerge::Merger.new.merge(merged_classes)
131
+ end
132
+
133
+ private
134
+
135
+ # Resolve a style value to a string
136
+ # Handles strings, hashes, and lambdas
137
+ def resolve_style_value(value, options)
138
+ case value
139
+ when String
140
+ value
141
+ when Hash
142
+ # Hash of sub-styles (like { default: "...", hover: "..." })
143
+ value.values.map { |v| resolve_style_value(v, options) }.compact.join(" ")
144
+ when Proc
145
+ # Lambda that receives all options for dynamic computation
146
+ result = value.call(**options)
147
+ result.is_a?(String) ? result : result.to_s
148
+ else
149
+ value&.to_s
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pom
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pom"