phlex-forms 0.1.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.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +170 -0
  5. data/app/javascript/phlex_forms/controllers/validations/acceptance_controller.js +23 -0
  6. data/app/javascript/phlex_forms/controllers/validations/base_controller.js +166 -0
  7. data/app/javascript/phlex_forms/controllers/validations/confirmation_controller.js +27 -0
  8. data/app/javascript/phlex_forms/controllers/validations/exclusion_controller.js +19 -0
  9. data/app/javascript/phlex_forms/controllers/validations/form_controller.js +43 -0
  10. data/app/javascript/phlex_forms/controllers/validations/format_controller.js +23 -0
  11. data/app/javascript/phlex_forms/controllers/validations/inclusion_controller.js +19 -0
  12. data/app/javascript/phlex_forms/controllers/validations/length_controller.js +95 -0
  13. data/app/javascript/phlex_forms/controllers/validations/numericality_controller.js +59 -0
  14. data/app/javascript/phlex_forms/controllers/validations/presence_controller.js +14 -0
  15. data/app/javascript/phlex_forms/i18n.js +40 -0
  16. data/app/javascript/phlex_forms/messages.js +100 -0
  17. data/config/importmap.rb +21 -0
  18. data/config/locales/de.yml +12 -0
  19. data/config/locales/en.yml +14 -0
  20. data/config/locales/sv.yml +12 -0
  21. data/config/rubocop.yml +20 -0
  22. data/lib/forms/checkbox.rb +39 -0
  23. data/lib/forms/choices_select.rb +132 -0
  24. data/lib/forms/collection_check_box.rb +26 -0
  25. data/lib/forms/collection_check_box_builder.rb +26 -0
  26. data/lib/forms/collection_label.rb +20 -0
  27. data/lib/forms/email_field.rb +11 -0
  28. data/lib/forms/field.rb +222 -0
  29. data/lib/forms/field_error.rb +26 -0
  30. data/lib/forms/field_hint.rb +24 -0
  31. data/lib/forms/fields_for_builder.rb +58 -0
  32. data/lib/forms/file_input.rb +29 -0
  33. data/lib/forms/form.rb +225 -0
  34. data/lib/forms/form_control.rb +42 -0
  35. data/lib/forms/input.rb +35 -0
  36. data/lib/forms/label.rb +32 -0
  37. data/lib/forms/password_field.rb +11 -0
  38. data/lib/forms/radio.rb +29 -0
  39. data/lib/forms/range.rb +33 -0
  40. data/lib/forms/rich_textarea.rb +43 -0
  41. data/lib/forms/select.rb +73 -0
  42. data/lib/forms/submit.rb +62 -0
  43. data/lib/forms/textarea.rb +27 -0
  44. data/lib/forms/time_zone_select.rb +52 -0
  45. data/lib/forms/toggle.rb +39 -0
  46. data/lib/forms/validations/introspector.rb +224 -0
  47. data/lib/forms/validations/manual_rules.rb +77 -0
  48. data/lib/forms/wrapped_input.rb +67 -0
  49. data/lib/phlex-forms.rb +4 -0
  50. data/lib/phlex_forms/builder.rb +153 -0
  51. data/lib/phlex_forms/class_merge.rb +56 -0
  52. data/lib/phlex_forms/configuration.rb +49 -0
  53. data/lib/phlex_forms/delegated_field.rb +51 -0
  54. data/lib/phlex_forms/engine.rb +35 -0
  55. data/lib/phlex_forms/inline_icons.rb +36 -0
  56. data/lib/phlex_forms/rubocop.rb +14 -0
  57. data/lib/phlex_forms/version.rb +5 -0
  58. data/lib/phlex_forms.rb +80 -0
  59. data/lib/rubocop/phlex_forms/cop/legacy_form_method.rb +103 -0
  60. data/lib/rubocop/phlex_forms/cop/raw_form.rb +51 -0
  61. metadata +181 -0
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # The daisyui v5 "text/icon inside the field" pattern: a `<label class="input">`
5
+ # wrapper that owns the visual styling, with leading/trailing content (icons,
6
+ # kbd hints, a text prefix) rendered as siblings of a bare `<input>`.
7
+ #
8
+ # f.field(:search).wrapped_input(:primary) do
9
+ # _lucide("search") # or any leading content
10
+ # end
11
+ #
12
+ # The yielded block renders *before* the input (leading slot); pass `trailing:`
13
+ # for content after it. The input itself is bare (no `input` class) since the
14
+ # wrapping label carries it — this is the daisyui v5 convention.
15
+ class WrappedInput < Phlex::HTML
16
+ include PhlexForms::DelegatedField
17
+
18
+ def initialize(*modifiers, type: "text", name: nil, id: nil, value: nil,
19
+ placeholder: nil, error: false, disabled: false, required: false,
20
+ trailing: nil, **attributes)
21
+ @modifiers = normalize_modifiers(modifiers)
22
+ @type = type
23
+ @name = name
24
+ @id = id
25
+ @value = value
26
+ @placeholder = placeholder
27
+ @error = error
28
+ @disabled = disabled
29
+ @required = required
30
+ @trailing = trailing
31
+ @full_width = true
32
+ @attributes = attributes
33
+ super()
34
+ end
35
+
36
+ def view_template(&leading)
37
+ render DaisyUI::Label.new(:input, *daisy_modifiers, class: width_class) do
38
+ leading&.call
39
+ input(**bare_input_attributes)
40
+ render_trailing
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def bare_input_attributes
47
+ attrs = {
48
+ type: @type,
49
+ name: @name,
50
+ id: @id,
51
+ value: @value.to_s,
52
+ placeholder: @placeholder,
53
+ class: "grow",
54
+ **@attributes.except(:error, :value, :class)
55
+ }
56
+ attrs[:disabled] = true if @disabled
57
+ attrs[:required] = true if @required
58
+ attrs.compact
59
+ end
60
+
61
+ def render_trailing
62
+ return unless @trailing
63
+
64
+ @trailing.respond_to?(:call) ? @trailing.call : plain(@trailing)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Allow `require "phlex-forms"` (dashed) as well as `require "phlex_forms"`.
4
+ require_relative "phlex_forms"
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
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.
6
+ #
7
+ # Two layers:
8
+ #
9
+ # * The Control-first primary verb `field` — renders label + input +
10
+ # error/hint in one call, inferring the input type and the `required` flag
11
+ # from the model.
12
+ #
13
+ # * Lower-level escape hatches (`Input`, `Select`, `Textarea`, `Checkbox`,
14
+ # `Toggle`, `FileInput`, `Hidden`, `Label`, `Control`, `submit`) with the
15
+ # same signatures they had before extraction, for when a caller needs full
16
+ # control (custom Stimulus wiring, bespoke layout, ...).
17
+ #
18
+ # Hosts mix this in and provide: `render`, `model`, `scope`, `errors`, and a
19
+ # `field(name)`-style `field_object` that returns a Forms::Field for a name.
20
+ module Builder
21
+ # Positional symbols that name an input type (so `f.Input(:x, :email)` works).
22
+ INPUT_TYPE_MODIFIERS = %i[
23
+ text email password tel url search number date time datetime
24
+ color range file hidden month week
25
+ ].freeze
26
+
27
+ # Attribute-name -> input type inference. One canonical map (previously
28
+ # duplicated and divergent across Form and Field).
29
+ INPUT_TYPE_INFERENCE = {
30
+ email: :email,
31
+ password: :password, password_confirmation: :password,
32
+ current_password: :password, new_password: :password,
33
+ new_password_confirmation: :password,
34
+ phone: :tel, telephone: :tel, tel: :tel, mobile: :tel,
35
+ url: :url, website: :url, homepage: :url,
36
+ search: :search, query: :search,
37
+ date: :date, birthday: :date, birthdate: :date, born_on: :date,
38
+ started_at: :date, ended_at: :date,
39
+ time: :time,
40
+ datetime: :datetime, timestamp: :datetime,
41
+ color: :color, colour: :color
42
+ }.freeze
43
+
44
+ # ------------------------------------------------------------------
45
+ # Control-first primary API
46
+ # ------------------------------------------------------------------
47
+
48
+ # Render a complete field: label + control-wrapped input + error/hint.
49
+ #
50
+ # 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
54
+ # f.field :name, :primary, label: "Full name", hint: "As on your ID"
55
+ #
56
+ # Options:
57
+ # label: label text (defaults to the model's humanized attribute name;
58
+ # pass false to omit the label)
59
+ # hint: help text shown when there is no error
60
+ # as: override the rendered control (:select, :textarea, :toggle,
61
+ # :checkbox, :file, :hidden, and text-like types)
62
+ # required: force the required flag (otherwise inferred from validations)
63
+ # choices: choices for :select
64
+ # Remaining positional modifiers/keywords pass through to the inner input.
65
+ def field(name, *modifiers, label: nil, hint: nil, as: nil, required: nil,
66
+ 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)
70
+ options = fo.apply_validations(options)
71
+
72
+ render fo.control(label: label_text, hint:, required: req) do
73
+ render_field_input(fo, name, as, modifiers, choices:, required: req, **options)
74
+ end
75
+ end
76
+
77
+ # ------------------------------------------------------------------
78
+ # Escape-hatch component API (stable signatures)
79
+ # ------------------------------------------------------------------
80
+
81
+ def Input(name, *modifiers, **options)
82
+ fo = field_object(name)
83
+ type = resolve_input_type(name, modifiers)
84
+ type = :"datetime-local" if type == :datetime
85
+ remaining = modifiers - INPUT_TYPE_MODIFIERS
86
+ render fo.input(*remaining, type:, **fo.apply_validations(options))
87
+ end
88
+
89
+ def Select(name, *modifiers, choices: nil, **options)
90
+ fo = field_object(name)
91
+ render fo.choices_select(choices, *modifiers, **fo.apply_validations(options))
92
+ end
93
+
94
+ def Textarea(name, *modifiers, **options)
95
+ fo = field_object(name)
96
+ render fo.textarea(*modifiers, **fo.apply_validations(options))
97
+ end
98
+
99
+ def Checkbox(name, *modifiers, **options)
100
+ fo = field_object(name)
101
+ render fo.checkbox(*modifiers, **fo.apply_validations(options))
102
+ end
103
+
104
+ def Radio(name, value, *modifiers, **)
105
+ render field_object(name).radio(value, *modifiers, **)
106
+ end
107
+
108
+ def Toggle(name, *modifiers, **options)
109
+ fo = field_object(name)
110
+ render fo.toggle(*modifiers, **fo.apply_validations(options))
111
+ end
112
+
113
+ def FileInput(name, *modifiers, **options)
114
+ fo = field_object(name)
115
+ render fo.file(*modifiers, **fo.apply_validations(options))
116
+ end
117
+
118
+ def Hidden(name, **)
119
+ render field_object(name).hidden(**)
120
+ end
121
+
122
+ def Label(name, text = nil, *modifiers, **, &)
123
+ render field_object(name).label(text, *modifiers, **, &)
124
+ end
125
+
126
+ def Control(name, **, &)
127
+ render field_object(name).control(**, &)
128
+ end
129
+
130
+ private
131
+
132
+ # Dispatch the inner input for `field` based on `as:` / inferred type.
133
+ def render_field_input(fo, name, as, modifiers, choices:, required:, **)
134
+ kind = as || resolve_input_type(name, modifiers)
135
+ 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(**)
142
+ else
143
+ type = kind == :datetime ? :"datetime-local" : kind
144
+ render fo.input(*(modifiers - INPUT_TYPE_MODIFIERS), type:, required:, **)
145
+ end
146
+ end
147
+
148
+ def resolve_input_type(name, modifiers)
149
+ explicit = modifiers.find { |m| INPUT_TYPE_MODIFIERS.include?(m) }
150
+ explicit || INPUT_TYPE_INFERENCE[name.to_sym] || :text
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PhlexForms
4
+ # Class-string merging for phlex-forms components.
5
+ #
6
+ # DaisyUI component-class families are mutually exclusive: an element can't be
7
+ # both `input-sm` and `input-lg`, or both `input-primary` and `input-error`.
8
+ # When a component's default classes and a caller's `class:` override both name
9
+ # the same family, the LAST occurrence wins — matching Tailwind/daisyui
10
+ # intuition (the later class overrides the earlier).
11
+ #
12
+ # We deliberately do NOT depend on tailwind_merge: on form fields, callers pass
13
+ # daisyui modifiers (which this handles) rather than conflicting core Tailwind
14
+ # utilities, so tailwind_merge would add a runtime dependency for a conflict
15
+ # that does not occur here. Non-conflicting utilities (`w-full`, `py-0`, ...)
16
+ # simply pass through in order.
17
+ module ClassMerge
18
+ SIZE = /-(xs|sm|md|lg|xl)\z/
19
+ COLOR = /-(primary|secondary|accent|neutral|info|success|warning|error)\z/
20
+
21
+ module_function
22
+
23
+ # Merge any number of class strings/nil into one deduped string. Preserves
24
+ # order; within a daisyui size/color family only the last token survives.
25
+ def merge(*parts)
26
+ tokens = parts.flat_map { |p| p.to_s.split }.reject(&:empty?)
27
+ dedupe_daisyui_families(tokens).join(" ")
28
+ end
29
+
30
+ # For each (component-prefix, family) pair, keep only the last token.
31
+ def dedupe_daisyui_families(tokens)
32
+ last_index = {}
33
+ tokens.each_with_index do |tok, idx|
34
+ key = family_key(tok)
35
+ last_index[key] = idx if key
36
+ end
37
+ tokens.each_with_index.filter_map do |tok, idx|
38
+ key = family_key(tok)
39
+ next tok if key.nil? # not a daisyui family token — keep
40
+ next tok if last_index[key] == idx # last in its family — keep
41
+
42
+ nil # earlier duplicate in the family — drop
43
+ end
44
+ end
45
+
46
+ # A stable bucket key like "input:size" / "select:color", or nil if the token
47
+ # is not a recognized daisyui size/color modifier.
48
+ def family_key(token)
49
+ if (m = token.match(SIZE))
50
+ "#{token[0...m.begin(0)]}:size"
51
+ elsif (m = token.match(COLOR))
52
+ "#{token[0...m.begin(0)]}:color"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PhlexForms
4
+ # Central configuration for phlex-forms. Set via `PhlexForms.configure`.
5
+ #
6
+ # PhlexForms.configure do |c|
7
+ # c.icon_renderer = ->(name, **opts) { MyIcons::Icon.new(name, **opts) }
8
+ # end
9
+ #
10
+ # The icon renderer is a callable `->(name, **opts) { renderable }` returning
11
+ # something a Phlex component can emit: a Phlex component instance OR a raw SVG
12
+ # String.
13
+ #
14
+ # The DEFAULT is a bundled inline SVG so phlex-forms is self-contained and
15
+ # renders identically with no host configuration. To use the glyphs gem (which
16
+ # resolves a full icon set from the host app's rails_icons asset tree), opt in:
17
+ #
18
+ # PhlexForms.configure do |c|
19
+ # c.icon_renderer = PhlexForms::Configuration.glyphs_renderer
20
+ # end
21
+ #
22
+ # glyphs is intentionally NOT the default: rails_icons reads SVGs from the host
23
+ # app's asset paths, which a minimal host (or the gem's own test suite) has not
24
+ # set up — so defaulting to glyphs would raise Icons::IconNotFound.
25
+ class Configuration
26
+ # A ready-made renderer that delegates to glyphs' LucideIcon. Raises a clear
27
+ # error if glyphs is not loaded.
28
+ def self.glyphs_renderer
29
+ unless defined?(Glyphs::LucideIcon)
30
+ raise PhlexForms::FeatureUnavailable,
31
+ "PhlexForms glyphs_renderer requires the `glyphs` gem to be loaded"
32
+ end
33
+
34
+ ->(name, **opts) { Glyphs::LucideIcon.new(name, **opts) }
35
+ end
36
+
37
+ attr_writer :icon_renderer
38
+
39
+ def icon_renderer
40
+ @icon_renderer ||= ->(name, **opts) { InlineIcons.render(name, **opts) }
41
+ end
42
+
43
+ # Returns a renderable for the named icon. Components pass the result to Phlex
44
+ # `render`/`raw` as appropriate.
45
+ def render_icon(name, **)
46
+ icon_renderer.call(name.to_s, **)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PhlexForms
4
+ # Shared helpers for the model-bound leaf components that delegate their markup
5
+ # and variants to a daisyui gem component (DaisyUI::Input/Textarea/Select/...).
6
+ #
7
+ # A host component sets @modifiers, @error, @disabled, @required, @full_width,
8
+ # and @attributes in its initializer, then calls #daisy_modifiers and
9
+ # #binding_attributes to assemble the delegated component's arguments.
10
+ module DelegatedField
11
+ # daisyui v4 `:bordered` is a no-op in v5 (base component class has the
12
+ # border); accept it silently so v4-era call sites don't break.
13
+ IGNORED_MODIFIERS = %i[bordered].freeze
14
+
15
+ private
16
+
17
+ def normalize_modifiers(modifiers)
18
+ modifiers - IGNORED_MODIFIERS
19
+ end
20
+
21
+ # Append :error when the field is invalid and the caller didn't already pass
22
+ # a color modifier, so daisyui emits the *-error class.
23
+ def daisy_modifiers
24
+ return @modifiers if !@error || @modifiers.include?(:error)
25
+
26
+ @modifiers + [:error]
27
+ end
28
+
29
+ # name/id + disabled/required booleans + a w-full class (unless full_width is
30
+ # false), merged with the caller's passthrough attributes. Excludes internal
31
+ # keys (:error, :value, :class, :placeholder handled per-component).
32
+ def binding_attributes(**extra)
33
+ attrs = {
34
+ name: @name,
35
+ id: @id,
36
+ class: width_class,
37
+ **@attributes.except(:error, :value, :class),
38
+ **extra
39
+ }
40
+ attrs[:disabled] = true if @disabled
41
+ attrs[:required] = true if @required
42
+ attrs.compact
43
+ end
44
+
45
+ def width_class
46
+ return @attributes[:class] unless @full_width
47
+
48
+ ["w-full", @attributes[:class]].compact.join(" ")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PhlexForms
4
+ # Optional Rails integration. Loaded only when Rails::Engine is defined (see
5
+ # lib/phlex_forms.rb), so the gem stays a plain Phlex library outside Rails.
6
+ #
7
+ # It wires up:
8
+ # * the bundled Stimulus controllers (choices / searchable-select / tz) via
9
+ # importmap-rails + the asset load path;
10
+ # * the gem's default locale files (en/sv/de) prepended to I18n.load_path so
11
+ # host apps can override any key.
12
+ #
13
+ # Asset-and-locale-only engine: no isolate_namespace, since it exposes no
14
+ # routes, models, or helpers.
15
+ class Engine < ::Rails::Engine
16
+ JAVASCRIPT_PATH = root.join("app/javascript")
17
+
18
+ initializer "phlex_forms.assets" do |app|
19
+ app.config.assets.paths << JAVASCRIPT_PATH.to_s if app.config.respond_to?(:assets)
20
+ end
21
+
22
+ initializer "phlex_forms.importmap", before: "importmap" do |app|
23
+ next unless app.config.respond_to?(:importmap)
24
+
25
+ importmap = app.config.importmap
26
+ importmap.paths << root.join("config/importmap.rb") if importmap.respond_to?(:paths)
27
+ importmap.cache_sweepers << JAVASCRIPT_PATH if importmap.respond_to?(:cache_sweepers)
28
+ end
29
+
30
+ initializer "phlex_forms.i18n" do |app|
31
+ locales = Dir[root.join("config/locales/*.yml")].map(&:to_s)
32
+ app.config.i18n.load_path.unshift(*locales)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PhlexForms
4
+ # The gem's zero-dependency fallback icon set: a small map of inline SVGs for
5
+ # the handful of icons phlex-forms itself renders. Swap the whole renderer via
6
+ # `PhlexForms.configure { |c| c.icon_renderer = ... }` (e.g. to use glyps).
7
+ module InlineIcons
8
+ # Lucide "chevron-down", 24x24, currentColor stroke — matches the icon the
9
+ # select trigger used before extraction.
10
+ CHEVRON_DOWN = <<~SVG
11
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
12
+ SVG
13
+
14
+ ICONS = {
15
+ "chevron-down" => CHEVRON_DOWN
16
+ }.freeze
17
+
18
+ module_function
19
+
20
+ # Returns a raw SVG string with any `class:` applied to the root element.
21
+ # The caller marks it safe for its render context (Phlex `raw(safe(...))`),
22
+ # so this stays a plain String and the gem needs no ActiveSupport. Unknown
23
+ # icon names return an empty string rather than raising, so a missing glyph
24
+ # degrades gracefully.
25
+ def render(name, **opts)
26
+ svg = ICONS[name.to_s]
27
+ return "" if svg.nil?
28
+
29
+ opts[:class] ? apply_class(svg, opts[:class]) : svg
30
+ end
31
+
32
+ def apply_class(svg, klass)
33
+ svg.sub("<svg ", %(<svg class="#{klass}" ))
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for phlex-forms' RuboCop cops. A host app enables them by adding
4
+ # to its .rubocop.yml:
5
+ #
6
+ # require:
7
+ # - phlex_forms/rubocop
8
+ # inherit_gem:
9
+ # phlex-forms: config/rubocop.yml
10
+ #
11
+ require "rubocop"
12
+
13
+ require_relative "../rubocop/phlex_forms/cop/raw_form"
14
+ require_relative "../rubocop/phlex_forms/cop/legacy_form_method"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PhlexForms
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date" # Phlex's attribute serializer references Date; ensure it's loaded.
4
+ require "active_support/core_ext/object/blank" # blank?/present?/presence
5
+ require "phlex"
6
+ require "daisy_ui"
7
+ require "glyphs"
8
+ require "zeitwerk"
9
+
10
+ require_relative "phlex_forms/version"
11
+
12
+ # phlex-forms exposes its components under the `Forms::` namespace (Form, Input,
13
+ # Select, Submit, Label, ...) so that consuming apps can `include Forms` and call
14
+ # `Form(model:) { |f| ... }` as a Phlex::Kit helper. Gem-internal machinery
15
+ # (configuration, the icon renderer) lives under `PhlexForms::` to keep the
16
+ # `Forms::` namespace clean for components only.
17
+ module PhlexForms
18
+ class Error < StandardError; end
19
+
20
+ # Raised when an optional feature (e.g. rich text via ActionText/Lexxy) is
21
+ # used but its backing dependency is not available.
22
+ class FeatureUnavailable < Error; end
23
+
24
+ class << self
25
+ def configuration
26
+ @configuration ||= Configuration.new
27
+ end
28
+ alias config configuration
29
+
30
+ def configure
31
+ yield(configuration)
32
+ end
33
+
34
+ # Reset configuration to defaults. Intended for test isolation.
35
+ def reset_configuration!
36
+ @configuration = Configuration.new
37
+ end
38
+ end
39
+ end
40
+
41
+ # The component namespace. Extending Phlex::Kit makes every `Forms::Foo`
42
+ # component callable as a bare `Foo(...)` helper wherever `Forms` is included.
43
+ module Forms
44
+ extend Phlex::Kit
45
+ end
46
+
47
+ # Two non-overlapping autoload roots:
48
+ # lib/phlex_forms/** (minus components) -> PhlexForms:: (gem internals)
49
+ # lib/phlex_forms/components -> Forms:: (components)
50
+ # We drive the loader manually (not `for_gem`) so the component root can map
51
+ # onto the top-level `Forms` module while gem internals stay under `PhlexForms`.
52
+ loader = Zeitwerk::Loader.new
53
+ loader.tag = "phlex-forms"
54
+ loader.inflector.inflect(
55
+ "phlex_forms" => "PhlexForms",
56
+ "phlex-forms" => "PhlexForms"
57
+ )
58
+
59
+ # Two sibling (non-nested) autoload roots:
60
+ # lib/phlex_forms -> PhlexForms:: (gem internals: config, class_merge, ...)
61
+ # lib/forms -> Forms:: (the component kit)
62
+ # Keeping them as separate top-level dirs avoids the ambiguity of nesting one
63
+ # namespace root inside another.
64
+ loader.push_dir("#{__dir__}/phlex_forms", namespace: PhlexForms)
65
+ loader.push_dir("#{__dir__}/forms", namespace: Forms)
66
+
67
+ # Files required explicitly above, or loaded only in specific contexts.
68
+ loader.ignore("#{__dir__}/phlex_forms/version.rb") # required explicitly above
69
+ loader.ignore("#{__dir__}/rubocop") # cops load on demand via the host .rubocop.yml
70
+ loader.ignore("#{__dir__}/phlex_forms/rubocop.rb") # cop entry point (RuboCop:: namespace)
71
+ # The engine is conditionally required at the bottom of this file (only under
72
+ # Rails), so it must not be autoloaded/eager-loaded by Zeitwerk.
73
+ loader.ignore("#{__dir__}/phlex_forms/engine.rb")
74
+
75
+ loader.setup
76
+
77
+ # Optional Rails integration (Stimulus controllers via importmap/assets, and
78
+ # the gem's default locale files). Loaded only under Rails; the gem stays pure
79
+ # Phlex otherwise.
80
+ require_relative "phlex_forms/engine" if defined?(Rails::Engine)
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module PhlexForms
6
+ # Flags Rails-style legacy form-builder methods (text_field, select, ...) in
7
+ # favor of the phlex-forms API. The preferred primary path is the
8
+ # Control-first `form.field(:name, ...)`; the PascalCase component methods
9
+ # (form.Input/Select/Textarea/...) remain as escape hatches.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # form.text_field(:name, :primary)
14
+ # form.select(:role, choices: roles)
15
+ #
16
+ # # good (primary)
17
+ # form.field(:name, :primary)
18
+ # form.field(:role, as: :select, choices: roles)
19
+ #
20
+ # # good (escape hatch)
21
+ # form.Input(:name, :primary)
22
+ # form.Select(:role, choices: roles)
23
+ class LegacyFormMethod < Base
24
+ INPUT_TYPE_METHODS = {
25
+ text_field: :text,
26
+ email_field: :email,
27
+ password_field: :password,
28
+ number_field: :number,
29
+ date_field: :date,
30
+ time_field: :time,
31
+ datetime_field: :datetime,
32
+ url_field: :url,
33
+ tel_field: :tel,
34
+ search_field: :search,
35
+ range_field: :range,
36
+ color_field: :color
37
+ }.freeze
38
+
39
+ OTHER_LEGACY_METHODS = {
40
+ textarea: "Textarea",
41
+ text_area: "Textarea",
42
+ select: "Select",
43
+ checkbox: "Checkbox",
44
+ check_box: "Checkbox",
45
+ radio_button: "Radio",
46
+ toggle: "Toggle",
47
+ file_field: "FileInput",
48
+ hidden_field: "Hidden",
49
+ form_control: "Control",
50
+ label: "Label"
51
+ }.freeze
52
+
53
+ FORM_RECEIVERS = %i[form f af].freeze
54
+ FORM_SUFFIX = /_form\z/
55
+
56
+ def on_send(node)
57
+ return unless form_receiver?(node)
58
+
59
+ method_name = node.method_name
60
+ return unless legacy_method?(method_name)
61
+
62
+ add_offense(node.loc.selector, message: build_message(method_name, node))
63
+ end
64
+
65
+ private
66
+
67
+ def form_receiver?(node)
68
+ receiver = node.receiver
69
+ return false unless receiver
70
+ return false unless receiver.send_type? || receiver.lvar_type?
71
+
72
+ receiver_name = receiver.send_type? ? receiver.method_name : receiver.children.first
73
+ FORM_RECEIVERS.include?(receiver_name) || receiver_name.to_s.match?(FORM_SUFFIX)
74
+ end
75
+
76
+ def legacy_method?(method_name)
77
+ INPUT_TYPE_METHODS.key?(method_name) || OTHER_LEGACY_METHODS.key?(method_name)
78
+ end
79
+
80
+ def build_message(method_name, node)
81
+ replacement =
82
+ if INPUT_TYPE_METHODS.key?(method_name)
83
+ "form.Input(#{format_args_for_input(node, INPUT_TYPE_METHODS[method_name])})"
84
+ else
85
+ "form.#{OTHER_LEGACY_METHODS[method_name]}(#{format_args_passthrough(node)})"
86
+ end
87
+
88
+ "Use `form.field(...)` (or `#{replacement}`) instead of legacy " \
89
+ "`#{node.receiver.source}.#{method_name}`."
90
+ end
91
+
92
+ def format_args_for_input(node, type_modifier)
93
+ args = node.arguments
94
+ [args.first&.source, ":#{type_modifier}", *args.drop(1).map(&:source)].compact.join(", ")
95
+ end
96
+
97
+ def format_args_passthrough(node)
98
+ node.arguments.map(&:source).join(", ")
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end