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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1037 -0
- data/Rakefile +14 -0
- data/lib/pom/component.rb +30 -0
- data/lib/pom/configuration.rb +25 -0
- data/lib/pom/engine.rb +15 -0
- data/lib/pom/helpers/option_helper.rb +70 -0
- data/lib/pom/helpers/stimulus_helper.rb +114 -0
- data/lib/pom/helpers/view_helper.rb +59 -0
- data/lib/pom/option_dsl.rb +183 -0
- data/lib/pom/styleable.rb +153 -0
- data/lib/pom/version.rb +5 -0
- data/lib/pom-component.rb +3 -0
- data/lib/pom.rb +19 -0
- metadata +145 -0
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
|
data/lib/pom/version.rb
ADDED