phlex-forms 0.1.0 → 0.2.1

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.
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Bare radio input.
6
+ class Radio < Forms::Radio
7
+ def view_template
8
+ attrs = unstyled_attributes
9
+ attrs[:checked] = true if @checked
10
+ input(type: "radio", value: @value, **attrs)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Row without grid classes: a bare <div> with a data hook so host CSS can
6
+ # lay it out (columns: is accepted and ignored).
7
+ class Row < Forms::Row
8
+ def view_template(&)
9
+ div(data: { form_row: true }, class: @options[:class], **@options.except(:class), &)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Bare native <select>. Reuses Forms::Select's prompt/choice rendering
6
+ # (arrays, pairs, hashes/optgroups, selected comparison). Also fills the
7
+ # :choices_select theme role, so it swallows searchable: — there is no
8
+ # choices.js in the plain theme.
9
+ class Select < Forms::Select
10
+ def initialize(*modifiers, searchable: false, **) # rubocop:disable Lint/UnusedMethodArgument
11
+ super(*modifiers, **)
12
+ end
13
+
14
+ def view_template
15
+ select(**unstyled_attributes) do
16
+ render_prompt(self) if @prompt || @include_blank
17
+ render_choices(self)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Bare <button type="submit">, reusing Forms::Submit's Create/Update label
6
+ # derivation.
7
+ class Submit < Forms::Submit
8
+ def view_template(&block)
9
+ attrs = { type: "submit", **@attributes }
10
+ attrs[:disabled] = true if @disabled
11
+ button(**attrs) { block ? yield : button_text }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Bare <textarea>. Also fills the :rich_textarea theme role (documented
6
+ # degradation — rich text needs an editor integration the plain theme
7
+ # doesn't ship), so it swallows the rich-text-only value handling.
8
+ class Textarea < Forms::Textarea
9
+ def view_template
10
+ textarea(rows: @rows, **unstyled_attributes) { @value }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # The icon/text-inside-the-field pattern without daisyui: a bare <label>
6
+ # wrapping the leading block, the input, and any trailing content.
7
+ class WrappedInput < Forms::WrappedInput
8
+ def view_template(&leading)
9
+ label do
10
+ leading&.call
11
+ input(**plain_input_attributes)
12
+ render_trailing
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def plain_input_attributes
19
+ attrs = {
20
+ type: @type,
21
+ name: @name,
22
+ id: @id,
23
+ value: @value.to_s,
24
+ placeholder: @placeholder,
25
+ **@attributes.except(:error, :value, :class)
26
+ }
27
+ attrs[:disabled] = true if @disabled
28
+ attrs[:required] = true if @required
29
+ attrs[:"aria-invalid"] = true if @error
30
+ attrs.compact
31
+ end
32
+ end
33
+ end
34
+ end
data/lib/forms/row.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # Side-by-side fields in a responsive grid: stacked on mobile, `columns`
5
+ # across from the sm breakpoint. The class strings are literals (not
6
+ # interpolated) so host Tailwind scanners pick them up.
7
+ #
8
+ # f.row { f.field :first_name; f.field :last_name }
9
+ # f.row(columns: 3) { ... }
10
+ class Row < Phlex::HTML
11
+ COLUMN_CLASSES = {
12
+ 2 => "sm:grid-cols-2",
13
+ 3 => "sm:grid-cols-3",
14
+ 4 => "sm:grid-cols-4"
15
+ }.freeze
16
+
17
+ def initialize(columns: 2, **options)
18
+ @columns = columns
19
+ @options = options
20
+ super()
21
+ end
22
+
23
+ def view_template(&)
24
+ div(class: row_classes, **@options.except(:class), &)
25
+ end
26
+
27
+ private
28
+
29
+ def row_classes
30
+ base = ["grid grid-cols-1 gap-4", COLUMN_CLASSES[@columns]].compact.join(" ")
31
+ PhlexForms::ClassMerge.merge(base, @options[:class])
32
+ end
33
+ end
34
+ end
@@ -1,14 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexForms
4
- # The shared form-builder surface, included by Forms::Form, Forms::Field, and
5
- # Forms::FieldsForBuilder so the three no longer copy-paste the API three times.
4
+ # The shared form-builder surface, included by Forms::Form and
5
+ # Forms::FieldsForBuilder so the two no longer copy-paste the API.
6
6
  #
7
7
  # Two layers:
8
8
  #
9
9
  # * The Control-first primary verb `field` — renders label + input +
10
10
  # error/hint in one call, inferring the input type and the `required` flag
11
- # from the model.
11
+ # from the model (structure, column types, validators — see
12
+ # PhlexForms::Inference) with the attribute-name map as tiebreaker.
12
13
  #
13
14
  # * Lower-level escape hatches (`Input`, `Select`, `Textarea`, `Checkbox`,
14
15
  # `Toggle`, `FileInput`, `Hidden`, `Label`, `Control`, `submit`) with the
@@ -48,9 +49,10 @@ module PhlexForms
48
49
  # Render a complete field: label + control-wrapped input + error/hint.
49
50
  #
50
51
  # f.field :email # type=email, label auto, required inferred
51
- # f.field :role, as: :select, choices: roles
52
- # f.field :bio, as: :textarea, rows: 6
53
- # f.field :accept, as: :toggle
52
+ # f.field :notify # boolean column -> toggle
53
+ # f.field :role # AR enum -> select, humanized
54
+ # f.field :country # belongs_to -> select over the association
55
+ # f.field :bio, as: :textarea, rows: 6 # explicit as: always wins
54
56
  # f.field :name, :primary, label: "Full name", hint: "As on your ID"
55
57
  #
56
58
  # Options:
@@ -58,22 +60,48 @@ module PhlexForms
58
60
  # pass false to omit the label)
59
61
  # hint: help text shown when there is no error
60
62
  # as: override the rendered control (:select, :textarea, :toggle,
61
- # :checkbox, :file, :hidden, and text-like types)
63
+ # :checkbox, :file, :hidden, :rich_textarea, and text-like types)
62
64
  # required: force the required flag (otherwise inferred from validations)
63
- # choices: choices for :select
65
+ # choices: choices for :select (implies as: :select when given)
64
66
  # Remaining positional modifiers/keywords pass through to the inner input.
65
67
  def field(name, *modifiers, label: nil, hint: nil, as: nil, required: nil,
66
68
  choices: nil, **options)
67
- fo = field_object(name)
68
- req = required.nil? ? fo.required? : required
69
- label_text = label == false ? nil : (label || fo.field_label)
69
+ modifiers = default_field_variants + modifiers
70
+ inferred = PhlexForms::Inference.resolve(model:, name:, as:, modifiers:, choices:)
71
+ # error_name: for a rewritten field (:country -> :country_id) errors still
72
+ # live on the association name.
73
+ fo = field_object(inferred.name, error_name: name)
74
+ req = required.nil? ? (fo.required? || inferred.required == true) : required
75
+ label_text = label == false ? nil : (label || inferred.label || fo.field_label)
76
+ options = inferred.attributes.merge(options)
77
+ if inferred.multiple
78
+ options[:multiple] = true unless options.key?(:multiple)
79
+ options[:name] ||= "#{fo.field_name}[]"
80
+ end
70
81
  options = fo.apply_validations(options)
82
+ choices ||= materialize_choices(inferred.choices)
71
83
 
72
84
  render fo.control(label: label_text, hint:, required: req) do
73
- render_field_input(fo, name, as, modifiers, choices:, required: req, **options)
85
+ render_field_input(fo, inferred.name, inferred.as, modifiers, choices:, required: req, **options)
74
86
  end
75
87
  end
76
88
 
89
+ # ------------------------------------------------------------------
90
+ # Layout helpers
91
+ # ------------------------------------------------------------------
92
+
93
+ # Side-by-side fields in a responsive grid (stacked on mobile).
94
+ # f.row { f.field :first_name; f.field :last_name }
95
+ def row(columns: 2, **, &)
96
+ render theme[:row].new(columns:, **), &
97
+ end
98
+
99
+ # A fieldset with a legend for sectioning related fields.
100
+ # f.group(legend: "Address") { f.field :street }
101
+ def group(legend: nil, **, &)
102
+ render theme[:group].new(legend:, **), &
103
+ end
104
+
77
105
  # ------------------------------------------------------------------
78
106
  # Escape-hatch component API (stable signatures)
79
107
  # ------------------------------------------------------------------
@@ -133,18 +161,23 @@ module PhlexForms
133
161
  def render_field_input(fo, name, as, modifiers, choices:, required:, **)
134
162
  kind = as || resolve_input_type(name, modifiers)
135
163
  case kind
136
- when :select then render fo.choices_select(choices, *modifiers, required:, **)
137
- when :textarea then render fo.textarea(*modifiers, required:, **)
138
- when :toggle then render fo.toggle(*modifiers, required:, **)
139
- when :checkbox then render fo.checkbox(*modifiers, required:, **)
140
- when :file then render fo.file(*modifiers, required:, **)
141
- when :hidden then render fo.hidden(**)
164
+ when :select then render fo.choices_select(choices, *modifiers, required:, **)
165
+ when :textarea then render fo.textarea(*modifiers, required:, **)
166
+ when :toggle then render fo.toggle(*modifiers, required:, **)
167
+ when :checkbox then render fo.checkbox(*modifiers, required:, **)
168
+ when :file then render fo.file(*modifiers, required:, **)
169
+ when :hidden then render fo.hidden(**)
170
+ when :rich_textarea then render fo.rich_textarea(*modifiers, **)
142
171
  else
143
172
  type = kind == :datetime ? :"datetime-local" : kind
144
173
  render fo.input(*(modifiers - INPUT_TYPE_MODIFIERS), type:, required:, **)
145
174
  end
146
175
  end
147
176
 
177
+ def materialize_choices(choices)
178
+ choices.respond_to?(:call) ? choices.call : choices
179
+ end
180
+
148
181
  def resolve_input_type(name, modifiers)
149
182
  explicit = modifiers.find { |m| INPUT_TYPE_MODIFIERS.include?(m) }
150
183
  explicit || INPUT_TYPE_INFERENCE[name.to_sym] || :text
@@ -36,6 +36,34 @@ module PhlexForms
36
36
 
37
37
  attr_writer :icon_renderer
38
38
 
39
+ # Kill-switch for model-driven inference (columns/enums/associations/
40
+ # validator attributes). Defaults to on; set false to restore pure
41
+ # attribute-name inference for `field`.
42
+ attr_writer :infer_from_model
43
+
44
+ # Global daisyui variants prepended to every `field`'s inner input, e.g.
45
+ # `c.field_variants = [:primary]`. Form-level `field_variants:` and
46
+ # call-site modifiers stack after (later wins).
47
+ attr_writer :field_variants
48
+
49
+ def field_variants
50
+ @field_variants ||= []
51
+ end
52
+
53
+ # The default theme: :daisy, :plain, or a PhlexForms::Theme instance.
54
+ # Defaults to daisy when the daisyui gem is loaded, plain otherwise.
55
+ attr_writer :theme
56
+
57
+ def theme
58
+ @theme ||= defined?(DaisyUI) ? Theme.daisy : Theme.plain
59
+ end
60
+
61
+ def infer_from_model
62
+ return @infer_from_model if defined?(@infer_from_model) && !@infer_from_model.nil?
63
+
64
+ true
65
+ end
66
+
39
67
  def icon_renderer
40
68
  @icon_renderer ||= ->(name, **opts) { InlineIcons.render(name, **opts) }
41
69
  end
@@ -47,5 +47,22 @@ module PhlexForms
47
47
 
48
48
  ["w-full", @attributes[:class]].compact.join(" ")
49
49
  end
50
+
51
+ # Unstyled variant of binding_attributes for the Plain theme: caller classes
52
+ # pass through verbatim, no width class, and an invalid field is flagged via
53
+ # aria-invalid instead of an error variant class.
54
+ def unstyled_attributes(**extra)
55
+ attrs = {
56
+ name: @name,
57
+ id: @id,
58
+ class: @attributes[:class],
59
+ **@attributes.except(:error, :value, :class),
60
+ **extra
61
+ }
62
+ attrs[:disabled] = true if @disabled
63
+ attrs[:required] = true if @required
64
+ attrs[:"aria-invalid"] = true if @error
65
+ attrs.compact
66
+ end
50
67
  end
51
68
  end
@@ -31,5 +31,12 @@ module PhlexForms
31
31
  locales = Dir[root.join("config/locales/*.yml")].map(&:to_s)
32
32
  app.config.i18n.load_path.unshift(*locales)
33
33
  end
34
+
35
+ # The raw-hash param type Forms::Live's :validate action schema declares.
36
+ # Registered from an initializer because phlex-reactive freezes its
37
+ # param-type registry after boot.
38
+ initializer "phlex_forms.live_param_type" do
39
+ Forms::Live.register_param_type! if defined?(Phlex::Reactive)
40
+ end
34
41
  end
35
42
  end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PhlexForms
4
+ # Model-driven input inference for Builder#field. Pure introspection: every
5
+ # model touch sits behind respond_to? guards and a StandardError rescue (the
6
+ # same posture as Forms::Field#required?), so plain objects, Structs, and
7
+ # POROs fall through to the attribute-name map exactly as before. No
8
+ # ActiveRecord dependency.
9
+ #
10
+ # Precedence (first hit wins):
11
+ # 1. explicit as: (caller always wins)
12
+ # 2. positional type modifier (f.field :price, :number)
13
+ # 3. explicit choices: -> :select
14
+ # 4. model structure: rich text, attachment, enum, belongs_to
15
+ # 5. non-string column type (COLUMN_TYPE_MAP)
16
+ # 6. attribute-name map (Builder::INPUT_TYPE_INFERENCE)
17
+ # 7. :text
18
+ #
19
+ # Validator-derived attributes (maxlength/min/max) merge orthogonally and
20
+ # always lose to caller-passed options. Steps 4-5 and the validator attrs are
21
+ # gated by `PhlexForms.config.infer_from_model` (default on).
22
+ module Inference
23
+ Result = Data.define(:as, :name, :label, :choices, :attributes, :multiple, :required) do
24
+ def self.blank(as:, name:)
25
+ new(as:, name:, label: nil, choices: nil, attributes: {}, multiple: false, required: nil)
26
+ end
27
+ end
28
+
29
+ # Option-text methods tried in order when building association choices.
30
+ LABEL_METHODS = %i[name title label to_s].freeze
31
+
32
+ # Column type -> control kind, for non-string columns only (the name map
33
+ # disambiguates strings; the column type is ground truth for other shapes).
34
+ COLUMN_TYPE_MAP = {
35
+ boolean: :toggle,
36
+ text: :textarea,
37
+ date: :date,
38
+ datetime: :datetime,
39
+ timestamptz: :datetime,
40
+ time: :time,
41
+ integer: :number,
42
+ decimal: :number,
43
+ float: :number
44
+ }.freeze
45
+
46
+ # Kinds that accept a maxlength attribute.
47
+ TEXT_LIKE = %i[text email password tel url search textarea].freeze
48
+
49
+ module_function
50
+
51
+ def resolve(model:, name:, as: nil, modifiers: [], choices: nil)
52
+ result = base_result(model:, name:, as:, modifiers:, choices:)
53
+ return result unless infer_from_model?
54
+
55
+ validator_attrs = validator_attributes(model, result.name, result.as)
56
+ return result if validator_attrs.empty?
57
+
58
+ result.with(attributes: validator_attrs.merge(result.attributes))
59
+ end
60
+
61
+ def base_result(model:, name:, as:, modifiers:, choices:)
62
+ return Result.blank(as:, name:) if as
63
+
64
+ if (explicit = modifiers.find { |m| Builder::INPUT_TYPE_MODIFIERS.include?(m) })
65
+ return Result.blank(as: explicit, name:)
66
+ end
67
+ return Result.blank(as: :select, name:) if choices
68
+ return name_map_result(name) unless infer_from_model?
69
+
70
+ structural(model, name) || from_column(model, name) || name_map_result(name)
71
+ end
72
+
73
+ def infer_from_model?
74
+ PhlexForms.config.infer_from_model
75
+ end
76
+
77
+ def name_map_result(name)
78
+ Result.blank(as: Builder::INPUT_TYPE_INFERENCE[name.to_sym] || :text, name:)
79
+ end
80
+
81
+ def structural(model, name)
82
+ return nil unless model
83
+
84
+ rich_text(model, name) || attachment(model, name) ||
85
+ enum(model, name) || association(model, name)
86
+ rescue StandardError
87
+ nil
88
+ end
89
+
90
+ # ActionText defines a has_one :rich_text_<name> association.
91
+ def rich_text(model, name)
92
+ return nil unless reflect(model, :"rich_text_#{name}")
93
+
94
+ Result.blank(as: :rich_textarea, name:)
95
+ end
96
+
97
+ def attachment(model, name)
98
+ klass = model.class
99
+ return nil unless klass.respond_to?(:reflect_on_attachment)
100
+
101
+ reflection = klass.reflect_on_attachment(name)
102
+ return nil unless reflection
103
+
104
+ Result.blank(as: :file, name:).with(multiple: reflection.macro == :has_many_attached)
105
+ end
106
+
107
+ def enum(model, name)
108
+ klass = model.class
109
+ return nil unless klass.respond_to?(:defined_enums) && klass.defined_enums.key?(name.to_s)
110
+
111
+ pairs = klass.defined_enums[name.to_s].keys.map { |key| [key.humanize, key] }
112
+ Result.blank(as: :select, name:).with(choices: pairs)
113
+ end
114
+
115
+ # A non-polymorphic belongs_to, matched by the association name (:country)
116
+ # or its foreign key (:country_id). The field name is rewritten to the
117
+ # foreign key; the label and required flag come from the association.
118
+ def association(model, name)
119
+ assoc_name = name.to_s.delete_suffix("_id").to_sym
120
+ reflection = reflect(model, name) || reflect(model, assoc_name)
121
+ return nil unless reflection && reflection.macro == :belongs_to && !reflection.polymorphic?
122
+
123
+ Result.blank(as: :select, name: reflection.foreign_key.to_sym).with(
124
+ label: association_label(model, reflection),
125
+ choices: -> { association_choices(reflection.klass) }, # lazy: only called without a choices: override
126
+ required: presence_validated?(model, reflection.name)
127
+ )
128
+ end
129
+
130
+ def from_column(model, name)
131
+ type = column_type(model, name)
132
+ kind = COLUMN_TYPE_MAP[type&.type]
133
+ return nil unless kind
134
+
135
+ attrs = {}
136
+ if kind == :number && (step = step_for(type))
137
+ attrs[:step] = step
138
+ end
139
+ Result.blank(as: kind, name:).with(attributes: attrs)
140
+ rescue StandardError
141
+ nil
142
+ end
143
+
144
+ def column_type(model, name)
145
+ return nil unless model
146
+
147
+ klass = model.class
148
+ if klass.respond_to?(:type_for_attribute)
149
+ klass.type_for_attribute(name.to_s)
150
+ elsif klass.respond_to?(:attribute_types)
151
+ klass.attribute_types[name.to_s]
152
+ end
153
+ end
154
+
155
+ def step_for(type)
156
+ case type.type
157
+ when :integer then 1
158
+ when :decimal, :float
159
+ scale = type.respond_to?(:scale) ? type.scale : nil
160
+ scale ? (10**-scale).to_f : "any"
161
+ end
162
+ end
163
+
164
+ def association_label(model, reflection)
165
+ klass = model.class
166
+ return nil unless klass.respond_to?(:human_attribute_name)
167
+
168
+ klass.human_attribute_name(reflection.name)
169
+ end
170
+
171
+ def association_choices(klass)
172
+ label_method = nil
173
+ klass.all.map do |record|
174
+ label_method ||= LABEL_METHODS.find { |m| record.respond_to?(m) }
175
+ [record.public_send(label_method), record.id]
176
+ end
177
+ end
178
+
179
+ def presence_validated?(model, attr)
180
+ klass = model.class
181
+ return false unless klass.respond_to?(:validators_on)
182
+
183
+ klass.validators_on(attr).any? do |v|
184
+ v.is_a?(ActiveModel::Validations::PresenceValidator) && !conditional?(v)
185
+ end
186
+ end
187
+
188
+ # Length maximum -> maxlength (text-like kinds); numericality bounds ->
189
+ # min/max (number kind only; exclusive bounds map +/-1 only for
190
+ # only_integer, otherwise skipped).
191
+ def validator_attributes(model, name, kind)
192
+ return {} unless model.respond_to?(:class) && model.class.respond_to?(:validators_on)
193
+
194
+ attrs = {}
195
+ model.class.validators_on(name).each do |validator|
196
+ next if conditional?(validator)
197
+
198
+ case validator
199
+ when ActiveModel::Validations::LengthValidator
200
+ max = validator.options[:maximum]
201
+ attrs[:maxlength] = max if max && TEXT_LIKE.include?(kind)
202
+ when ActiveModel::Validations::NumericalityValidator
203
+ attrs.merge!(numericality_attributes(validator.options)) if kind == :number
204
+ end
205
+ end
206
+ attrs
207
+ rescue StandardError
208
+ {}
209
+ end
210
+
211
+ def numericality_attributes(options)
212
+ attrs = {}
213
+ integer = options[:only_integer]
214
+ if (min = options[:greater_than_or_equal_to])
215
+ attrs[:min] = min
216
+ elsif integer && (gt = options[:greater_than])
217
+ attrs[:min] = gt + 1
218
+ end
219
+ if (max = options[:less_than_or_equal_to])
220
+ attrs[:max] = max
221
+ elsif integer && (lt = options[:less_than])
222
+ attrs[:max] = lt - 1
223
+ end
224
+ attrs
225
+ end
226
+
227
+ def reflect(model, name)
228
+ klass = model.class
229
+ return nil unless klass.respond_to?(:reflect_on_association)
230
+
231
+ klass.reflect_on_association(name)
232
+ end
233
+
234
+ def conditional?(validator)
235
+ validator.options.key?(:if) || validator.options.key?(:unless) || validator.options.key?(:on)
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PhlexForms
4
+ # A theme maps component roles (:input, :select, :control, ...) to component
5
+ # classes. The binding contract — the leaf initializer signatures
6
+ # (*modifiers, name:, id:, value:, error:, required:, ...) — is the stable
7
+ # interface, so the same form class renders under any theme.
8
+ #
9
+ # Built-ins:
10
+ # Theme.daisy — the DaisyUI-delegating Forms::* leaves (default when the
11
+ # daisyui gem is loaded)
12
+ # Theme.plain — bare semantic HTML (Forms::Plain::*): variants accepted and
13
+ # ignored, zero styling classes, aria/data hooks only
14
+ #
15
+ # Select per form (`Form(model:, theme: :plain)`), per class
16
+ # (`form_options theme: :plain`), or globally
17
+ # (`PhlexForms.configure { |c| c.theme = :plain }`). Override single roles:
18
+ #
19
+ # PhlexForms::Theme.resolve(:plain).with(input: MyInput)
20
+ class Theme
21
+ def initialize(components)
22
+ @components = components.freeze
23
+ end
24
+
25
+ def [](role)
26
+ @components.fetch(role) do
27
+ raise KeyError, "unknown theme role #{role.inspect} (roles: #{@components.keys.join(', ')})"
28
+ end
29
+ end
30
+
31
+ def with(**overrides)
32
+ self.class.new(@components.merge(overrides))
33
+ end
34
+
35
+ class << self
36
+ def resolve(value)
37
+ value = PhlexForms.config.theme if value.nil?
38
+ case value
39
+ when Theme then value
40
+ when :daisy then daisy
41
+ when :plain then plain
42
+ else
43
+ raise ArgumentError, "unknown theme #{value.inspect} (use :daisy, :plain, or a PhlexForms::Theme)"
44
+ end
45
+ end
46
+
47
+ def daisy
48
+ unless defined?(DaisyUI)
49
+ raise PhlexForms::FeatureUnavailable,
50
+ "the daisy theme requires the daisyui gem. Add `gem \"daisyui\"` to your " \
51
+ "Gemfile, or use the plain theme."
52
+ end
53
+
54
+ @daisy ||= new(
55
+ input: Forms::Input, select: Forms::Select, choices_select: Forms::ChoicesSelect,
56
+ textarea: Forms::Textarea, rich_textarea: Forms::RichTextarea,
57
+ checkbox: Forms::Checkbox, toggle: Forms::Toggle, radio: Forms::Radio,
58
+ file: Forms::FileInput, wrapped_input: Forms::WrappedInput,
59
+ control: Forms::FormControl, label: Forms::Label,
60
+ field_error: Forms::FieldError, field_hint: Forms::FieldHint,
61
+ submit: Forms::Submit, row: Forms::Row, group: Forms::Group
62
+ )
63
+ end
64
+
65
+ # choices_select degrades to the native plain select (no choices.js) and
66
+ # rich_textarea to a plain textarea; toggle renders as a checkbox.
67
+ def plain
68
+ @plain ||= new(
69
+ input: Forms::Plain::Input, select: Forms::Plain::Select, choices_select: Forms::Plain::Select,
70
+ textarea: Forms::Plain::Textarea, rich_textarea: Forms::Plain::Textarea,
71
+ checkbox: Forms::Plain::Checkbox, toggle: Forms::Plain::Checkbox, radio: Forms::Plain::Radio,
72
+ file: Forms::Plain::FileInput, wrapped_input: Forms::Plain::WrappedInput,
73
+ control: Forms::Plain::Control, label: Forms::Plain::Label,
74
+ field_error: Forms::Plain::FieldError, field_hint: Forms::Plain::FieldHint,
75
+ submit: Forms::Plain::Submit, row: Forms::Plain::Row, group: Forms::Plain::Group
76
+ )
77
+ end
78
+ end
79
+ end
80
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexForms
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end