phlexi-display 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.ruby-version +1 -0
  4. data/Appraisals +8 -0
  5. data/CHANGELOG.md +5 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +13 -0
  8. data/Rakefile +14 -0
  9. data/TODO +0 -0
  10. data/config.ru +6 -0
  11. data/gemfiles/default.gemfile +5 -0
  12. data/gemfiles/rails_7.gemfile +8 -0
  13. data/lib/phlexi/display/base.rb +243 -0
  14. data/lib/phlexi/display/components/base.rb +51 -0
  15. data/lib/phlexi/display/components/checkbox.rb +48 -0
  16. data/lib/phlexi/display/components/collection_checkboxes.rb +44 -0
  17. data/lib/phlexi/display/components/collection_radio_buttons.rb +35 -0
  18. data/lib/phlexi/display/components/concerns/handles_array_input.rb +21 -0
  19. data/lib/phlexi/display/components/concerns/handles_input.rb +53 -0
  20. data/lib/phlexi/display/components/concerns/has_options.rb +37 -0
  21. data/lib/phlexi/display/components/concerns/submits_form.rb +39 -0
  22. data/lib/phlexi/display/components/error.rb +21 -0
  23. data/lib/phlexi/display/components/file_input.rb +32 -0
  24. data/lib/phlexi/display/components/full_error.rb +21 -0
  25. data/lib/phlexi/display/components/hint.rb +21 -0
  26. data/lib/phlexi/display/components/input.rb +84 -0
  27. data/lib/phlexi/display/components/input_array.rb +45 -0
  28. data/lib/phlexi/display/components/label.rb +27 -0
  29. data/lib/phlexi/display/components/radio_button.rb +41 -0
  30. data/lib/phlexi/display/components/select.rb +69 -0
  31. data/lib/phlexi/display/components/submit_button.rb +41 -0
  32. data/lib/phlexi/display/components/textarea.rb +34 -0
  33. data/lib/phlexi/display/components/wrapper.rb +31 -0
  34. data/lib/phlexi/display/field_options/associations.rb +21 -0
  35. data/lib/phlexi/display/field_options/autofocus.rb +18 -0
  36. data/lib/phlexi/display/field_options/collection.rb +54 -0
  37. data/lib/phlexi/display/field_options/disabled.rb +18 -0
  38. data/lib/phlexi/display/field_options/errors.rb +92 -0
  39. data/lib/phlexi/display/field_options/hints.rb +22 -0
  40. data/lib/phlexi/display/field_options/inferred_types.rb +155 -0
  41. data/lib/phlexi/display/field_options/labels.rb +28 -0
  42. data/lib/phlexi/display/field_options/length.rb +53 -0
  43. data/lib/phlexi/display/field_options/limit.rb +66 -0
  44. data/lib/phlexi/display/field_options/min_max.rb +92 -0
  45. data/lib/phlexi/display/field_options/multiple.rb +65 -0
  46. data/lib/phlexi/display/field_options/pattern.rb +38 -0
  47. data/lib/phlexi/display/field_options/placeholder.rb +18 -0
  48. data/lib/phlexi/display/field_options/readonly.rb +18 -0
  49. data/lib/phlexi/display/field_options/required.rb +37 -0
  50. data/lib/phlexi/display/field_options/themes.rb +207 -0
  51. data/lib/phlexi/display/field_options/validators.rb +48 -0
  52. data/lib/phlexi/display/option_mapper.rb +154 -0
  53. data/lib/phlexi/display/structure/dom.rb +62 -0
  54. data/lib/phlexi/display/structure/field_builder.rb +236 -0
  55. data/lib/phlexi/display/structure/field_collection.rb +54 -0
  56. data/lib/phlexi/display/structure/namespace.rb +135 -0
  57. data/lib/phlexi/display/structure/namespace_collection.rb +48 -0
  58. data/lib/phlexi/display/structure/node.rb +18 -0
  59. data/lib/phlexi/display/version.rb +7 -0
  60. data/lib/phlexi/display.rb +31 -0
  61. data/lib/phlexi-display.rb +3 -0
  62. data/sig/phlexi/display.rbs +6 -0
  63. metadata +262 -0
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Display
5
+ module FieldOptions
6
+ module Themes
7
+ # Resolves theme classes for components based on their type and the validity state of the field.
8
+ #
9
+ # This method is responsible for determining the appropriate CSS classes for a given form component.
10
+ # It considers both the base theme for the component type and any additional theming based on the
11
+ # component's validity state (valid, invalid, or neutral). The method supports a hierarchical
12
+ # theming system, allowing for cascading themes and easy customization.
13
+ #
14
+ # @param component [Symbol, String] The type of form component (e.g., :input, :label, :wrapper)
15
+ #
16
+ # @return [String, nil] A string of CSS classes for the component, or nil if no theme is applied
17
+ #
18
+ # @example Basic usage
19
+ # themed(:input)
20
+ # # => "w-full p-2 border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700"
21
+ #
22
+ # @example Usage with validity state
23
+ # # Assuming the field has errors
24
+ # themed(:input)
25
+ # # => "w-full p-2 border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700 bg-red-50 border-red-500 text-red-900"
26
+ #
27
+ # @example Cascading themes
28
+ # # Assuming textarea inherits from input in the theme definition
29
+ # themed(:textarea)
30
+ # # => "w-full p-2 border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700"
31
+ #
32
+ # @note The actual CSS classes returned will depend on the theme definitions in the `theme` hash
33
+ # and any overrides specified in the `options` hash.
34
+ #
35
+ # @see #resolve_theme
36
+ # @see #resolve_validity_theme
37
+ # @see #theme
38
+ def themed(component)
39
+ return unless component
40
+
41
+ tokens(resolve_theme(component), resolve_validity_theme(component)).presence
42
+ end
43
+
44
+ protected
45
+
46
+ # Recursively resolves the theme for a given property, handling nested symbol references
47
+ #
48
+ # @param property [Symbol, String] The theme property to resolve
49
+ # @param visited [Set] Set of already visited properties to prevent infinite recursion
50
+ # @return [String, nil] The resolved theme value or nil if not found
51
+ #
52
+ # @example Resolving a nested theme
53
+ # # Assuming the theme is: { input: :base_input, base_input: "some-class" }
54
+ # resolve_theme(:input)
55
+ # # => "some-class"
56
+ def resolve_theme(property, visited = Set.new)
57
+ return nil if !property.present? || visited.include?(property)
58
+ visited.add(property)
59
+
60
+ result = theme[property]
61
+ if result.is_a?(Symbol)
62
+ resolve_theme(result, visited)
63
+ else
64
+ result
65
+ end
66
+ end
67
+
68
+ # Resolves the theme for a component based on its current validity state
69
+ #
70
+ # This method determines the validity state of the field (valid, invalid, or neutral)
71
+ # and returns the corresponding theme by prepending the state to the component name.
72
+ #
73
+ # @param property [Symbol, String] The base theme property to resolve
74
+ # @return [String, nil] The resolved validity-specific theme or nil if not found
75
+ #
76
+ # @example Resolving a validity theme
77
+ # # Assuming the field has errors and the theme includes { invalid_input: "error-class" }
78
+ # resolve_validity_theme(:input)
79
+ # # => "error-class"
80
+ def resolve_validity_theme(property)
81
+ validity_property = if has_errors?
82
+ :"invalid_#{property}"
83
+ elsif object_valid?
84
+ :"valid_#{property}"
85
+ else
86
+ :"neutral_#{property}"
87
+ end
88
+
89
+ resolve_theme(validity_property)
90
+ end
91
+
92
+ # Retrieves or initializes the theme hash for the form builder.
93
+ #
94
+ # This method returns a hash containing theme definitions for various form components.
95
+ # If a theme has been explicitly set in the options, it returns that. Otherwise, it
96
+ # initializes and returns a default theme.
97
+ #
98
+ # The theme hash defines CSS classes or references to other theme keys for different
99
+ # components and their states (e.g., valid, invalid, neutral).
100
+ #
101
+ # @return [Hash] A hash containing theme definitions for form components
102
+ #
103
+ # @example Accessing the theme
104
+ # theme[:input]
105
+ # # => "w-full p-2 border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700"
106
+ #
107
+ # @example Accessing a validity-specific theme
108
+ # theme[:invalid_input]
109
+ # # => "bg-red-50 border-red-500 text-red-900 placeholder-red-700 focus:ring-red-500 focus:border-red-500"
110
+ #
111
+ # @example Theme inheritance
112
+ # theme[:textarea] # Returns :input, indicating textarea inherits input's theme
113
+ # theme[:valid_textarea] # Returns :valid_input
114
+ #
115
+ # @note The actual content of the theme hash depends on the default_theme method
116
+ # and any theme overrides specified in the options when initializing the field builder.
117
+ #
118
+ # @see #default_theme
119
+ def theme
120
+ @theme ||= options[:theme] || default_theme
121
+ end
122
+
123
+ # Defines and returns the default theme hash for the field builder.
124
+ #
125
+ # This method returns a hash containing the base theme definitions for various components.
126
+ # It sets up the default styling and relationships between different components and their states.
127
+ # The theme uses a combination of explicit CSS classes and symbolic references to other theme keys,
128
+ # allowing for a flexible and inheritance-based theming system.
129
+ #
130
+ # @return [Hash] A frozen hash containing default theme definitions for components
131
+ #
132
+ # @example Accessing the default theme
133
+ # default_theme[:input]
134
+ # # => nil (indicates that :input doesn't have a default and should be defined by the user)
135
+ #
136
+ # @example Theme inheritance
137
+ # default_theme[:textarea]
138
+ # # => :input (indicates that :textarea inherits from :input)
139
+ #
140
+ # @example Validity state theming
141
+ # default_theme[:valid_textarea]
142
+ # # => :valid_input (indicates that :valid_textarea inherits from :valid_input)
143
+ #
144
+ # @note This method returns a frozen hash to prevent accidental modifications.
145
+ # To customize the theme, users should provide their own theme hash when initializing the field builder.
146
+ # @note Most theme values are set to nil or commented out in the default theme to encourage users
147
+ # to define their own styles while maintaining the relationships between components and states.
148
+ #
149
+ # @see #theme
150
+ def default_theme
151
+ {
152
+ # # input
153
+ # input: nil,
154
+ # valid_input: nil,
155
+ # invalid_input: nil,
156
+ # neutral_input: nil,
157
+
158
+ # textarea
159
+ textarea: :input,
160
+ valid_textarea: :valid_input,
161
+ invalid_textarea: :invalid_input,
162
+ neutral_textarea: :neutral_input,
163
+
164
+ # select
165
+ select: :input,
166
+ valid_select: :valid_input,
167
+ invalid_select: :invalid_input,
168
+ neutral_select: :neutral_input,
169
+
170
+ # file
171
+ file: :input,
172
+ valid_file: :valid_input,
173
+ invalid_file: :invalid_input,
174
+ neutral_file: :neutral_input,
175
+
176
+ # misc
177
+ # label: nil,
178
+ # hint: nil,
179
+ # error: nil,
180
+ full_error: :error,
181
+ # wrapper: nil,
182
+ # inner_wrapper: nil,
183
+ submit_button: :button
184
+
185
+ # # label themes
186
+ # label: "md:w-1/6 mt-2 block mb-2 text-sm font-medium",
187
+ # invalid_label: "text-red-700 dark:text-red-500",
188
+ # valid_label: "text-green-700 dark:text-green-500",
189
+ # neutral_label: "text-gray-700 dark:text-white",
190
+ # # input themes
191
+ # input: "w-full p-2 border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700",
192
+ # invalid_input: "bg-red-50 border-red-500 dark:border-red-500 text-red-900 dark:text-red-500 placeholder-red-700 dark:placeholder-red-500 focus:ring-red-500 focus:border-red-500",
193
+ # valid_input: "bg-green-50 border-green-500 dark:border-green-500 text-green-900 dark:text-green-400 placeholder-green-700 dark:placeholder-green-500 focus:ring-green-500 focus:border-green-500",
194
+ # neutral_input: "border-gray-300 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white focus:ring-primary-500 focus:border-primary-500",
195
+ # # hint themes
196
+ # hint: "mt-2 text-sm text-gray-500 dark:text-gray-200",
197
+ # # error themes
198
+ # error: "mt-2 text-sm text-red-600 dark:text-red-500",
199
+ # # wrapper themes
200
+ # wrapper: "flex flex-col md:flex-row items-start space-y-2 md:space-y-0 md:space-x-2 mb-4",
201
+ # inner_wrapper: "md:w-5/6 w-full",
202
+ }.freeze
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Display
5
+ module FieldOptions
6
+ module Validators
7
+ private
8
+
9
+ def has_validators?
10
+ @has_validators ||= object.class.respond_to?(:validators_on)
11
+ end
12
+
13
+ def attribute_validators
14
+ object.class.validators_on(key)
15
+ end
16
+
17
+ def reflection_validators
18
+ reflection ? object.class.validators_on(reflection.name) : []
19
+ end
20
+
21
+ def valid_validator?(validator)
22
+ !conditional_validators?(validator) && action_validator_match?(validator)
23
+ end
24
+
25
+ def conditional_validators?(validator)
26
+ validator.options.include?(:if) || validator.options.include?(:unless)
27
+ end
28
+
29
+ def action_validator_match?(validator)
30
+ return true unless validator.options.include?(:on)
31
+
32
+ case validator.options[:on]
33
+ when :save
34
+ true
35
+ when :create
36
+ !object.persisted?
37
+ when :update
38
+ object.persisted?
39
+ end
40
+ end
41
+
42
+ def find_validator(kind)
43
+ attribute_validators.find { |v| v.kind == kind && valid_validator?(v) } if has_validators?
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Display
5
+ # OptionMapper is responsible for converting a collection of objects into a hash of options
6
+ # suitable for form controls, such as `select > options`.
7
+ # Both values and labels are converted to strings.
8
+ #
9
+ # @example Basic usage
10
+ # collection = [["First", 1], ["Second", 2]]
11
+ # mapper = OptionMapper.new(collection)
12
+ # mapper.each { |value, label| puts "#{value}: #{label}" }
13
+ #
14
+ # @example Using with ActiveRecord objects
15
+ # users = User.all
16
+ # mapper = OptionMapper.new(users)
17
+ # mapper.each { |id, name| puts "#{id}: #{name}" }
18
+ #
19
+ # @example Array access with different value types
20
+ # mapper = OptionMapper.new([["Integer", 1], ["String", "2"], ["Symbol", :three]])
21
+ # puts mapper["1"] # Output: "Integer"
22
+ # puts mapper["2"] # Output: "String"
23
+ # puts mapper["three"] # Output: "Symbol"
24
+ #
25
+ # @note This class is thread-safe as it doesn't maintain mutable state.
26
+ class OptionMapper
27
+ include Enumerable
28
+
29
+ # Initializes a new OptionMapper instance.
30
+ #
31
+ # @param collection [#call, #to_a] The collection to be mapped.
32
+ # @param label_method [Symbol, nil] The method to call on each object to get the label.
33
+ # @param value_method [Symbol, nil] The method to call on each object to get the value.
34
+ def initialize(collection, label_method: nil, value_method: nil)
35
+ @raw_collection = collection
36
+ @label_method = label_method
37
+ @value_method = value_method
38
+ end
39
+
40
+ # Iterates over the collection, yielding value-label pairs.
41
+ #
42
+ # @yieldparam value [String] The string value for the current item.
43
+ # @yieldparam label [String] The string label for the current item.
44
+ # @return [Enumerator] If no block is given.
45
+ def each(&)
46
+ collection.each(&)
47
+ end
48
+
49
+ # @return [Array<String>] An array of all labels in the collection.
50
+ def labels
51
+ collection.values
52
+ end
53
+
54
+ # @return [Array<String>] An array of all values in the collection.
55
+ def values
56
+ collection.keys
57
+ end
58
+
59
+ # Retrieves the label for a given value.
60
+ #
61
+ # @param value [#to_s] The value to look up.
62
+ # @return [String, nil] The label corresponding to the value, or nil if not found.
63
+ def [](value)
64
+ collection[value.to_s]
65
+ end
66
+
67
+ private
68
+
69
+ # @return [Hash<String, String>] The materialized collection as a hash of string value => string label.
70
+ def collection
71
+ @collection ||= materialize_collection(@raw_collection)
72
+ end
73
+
74
+ # Converts the raw collection into a materialized hash.
75
+ #
76
+ # @param collection [#call, #to_a] The collection to be materialized.
77
+ # @return [Hash<String, String>] The materialized collection as a hash of string value => string label.
78
+ # @raise [ArgumentError] If the collection cannot be materialized into an enumerable.
79
+ def materialize_collection(collection)
80
+ case collection
81
+ in Hash => hash
82
+ hash.transform_keys(&:to_s).transform_values(&:to_s)
83
+ in Array => arr
84
+ array_to_hash(arr)
85
+ in Range => range
86
+ range_to_hash(range)
87
+ in Proc => proc
88
+ materialize_collection(proc.call)
89
+ in Symbol
90
+ raise ArgumentError, "Symbol collections are not supported in this context"
91
+ in Set => set
92
+ array_to_hash(set.to_a)
93
+ else
94
+ array_to_hash(Array(collection))
95
+ end
96
+ rescue ArgumentError
97
+ # Rails.logger.warn("Unhandled inclusion collection type: #{e}")
98
+ {}
99
+ end
100
+
101
+ # Converts an array to a hash using detected or specified methods.
102
+ #
103
+ # @param array [Array] The array to convert.
104
+ # @return [Hash<String, String>] The resulting hash of string value => string label.
105
+ def array_to_hash(array)
106
+ sample = array.first || array.last
107
+ methods = detect_methods_for_sample(sample)
108
+
109
+ array.each_with_object({}) do |item, hash|
110
+ value = item.public_send(methods[:value]).to_s
111
+ label = item.public_send(methods[:label]).to_s
112
+ hash[value] = label
113
+ end
114
+ end
115
+
116
+ # Converts a range to a hash.
117
+ #
118
+ # @param range [Range] The range to convert.
119
+ # @return [Hash<String, String>] The range converted to a hash of string value => string label.
120
+ # @raise [ArgumentError] If the range is unbounded.
121
+ def range_to_hash(range)
122
+ raise ArgumentError, "Cannot safely materialize an unbounded range" if range.begin.nil? || range.end.nil?
123
+
124
+ range.each_with_object({}) { |value, hash| hash[value.to_s] = value.to_s }
125
+ end
126
+
127
+ # Detects suitable methods for label and value from a sample object.
128
+ #
129
+ # @param sample [Object] A sample object from the collection.
130
+ # @return [Hash{Symbol => Symbol}] A hash containing :label and :value keys with corresponding method names.
131
+ def detect_methods_for_sample(sample)
132
+ case sample
133
+ when Array
134
+ {value: :last, label: :first}
135
+ else
136
+ {
137
+ value: @value_method || collection_value_methods.find { |m| sample.respond_to?(m) },
138
+ label: @label_method || collection_label_methods.find { |m| sample.respond_to?(m) }
139
+ }
140
+ end
141
+ end
142
+
143
+ # @return [Array<Symbol>] An array of method names to try for collection values.
144
+ def collection_value_methods
145
+ @collection_value_methods ||= %i[id to_s].freeze
146
+ end
147
+
148
+ # @return [Array<Symbol>] An array of method names to try for collection labels.
149
+ def collection_label_methods
150
+ @collection_label_methods ||= %i[to_label name title to_s].freeze
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Display
5
+ module Structure
6
+ # Generates DOM IDs, names, etc. for a Field, Namespace, or Node based on
7
+ # norms that were established by Rails. These can be used outsidef or Rails in
8
+ # other Ruby web frameworks since it has now dependencies on Rails.
9
+ class DOM
10
+ def initialize(field:)
11
+ @field = field
12
+ end
13
+
14
+ # Converts the value of the field to a String, which is required to work
15
+ # with Phlex. Assumes that `Object#to_s` emits a format suitable for the web form.
16
+ def value
17
+ @field.value.to_s
18
+ end
19
+
20
+ # Walks from the current node to the parent node, grabs the names, and seperates
21
+ # them with a `_` for a DOM ID.
22
+ def id
23
+ @id ||= begin
24
+ root, *rest = lineage
25
+ root_key = root.respond_to?(:dom_id) ? root.dom_id : root.key
26
+ rest.map(&:key).unshift(root_key).join("_")
27
+ end
28
+ end
29
+
30
+ # The `name` attribute of a node, which is influenced by Rails.
31
+ # All node names, except the parent node, are wrapped in a `[]` and collections
32
+ # are left empty. For example, `user[addresses][][street]` would be created for a form with
33
+ # data shaped like `{user: {addresses: [{street: "Sesame Street"}]}}`.
34
+ def name
35
+ @name ||= begin
36
+ root, *names = keys
37
+ names.map { |name| "[#{name}]" }.unshift(root).join
38
+ end
39
+ end
40
+
41
+ # One-liner way of walking from the current node all the way up to the parent.
42
+ def lineage
43
+ @lineage ||= Enumerator.produce(@field, &:parent).take_while(&:itself).reverse
44
+ end
45
+
46
+ # Emit the id, name, and value in an HTML tag-ish that doesnt have an element.
47
+ def inspect
48
+ "<#{self.class.name} id=#{id.inspect} name=#{name.inspect} value=#{value.inspect}/>"
49
+ end
50
+
51
+ private
52
+
53
+ def keys
54
+ @keys ||= lineage.map do |node|
55
+ # If the parent of a field is a field, the name should be nil.
56
+ node.key unless node.parent.is_a? FieldBuilder
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end