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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +170 -0
- data/app/javascript/phlex_forms/controllers/validations/acceptance_controller.js +23 -0
- data/app/javascript/phlex_forms/controllers/validations/base_controller.js +166 -0
- data/app/javascript/phlex_forms/controllers/validations/confirmation_controller.js +27 -0
- data/app/javascript/phlex_forms/controllers/validations/exclusion_controller.js +19 -0
- data/app/javascript/phlex_forms/controllers/validations/form_controller.js +43 -0
- data/app/javascript/phlex_forms/controllers/validations/format_controller.js +23 -0
- data/app/javascript/phlex_forms/controllers/validations/inclusion_controller.js +19 -0
- data/app/javascript/phlex_forms/controllers/validations/length_controller.js +95 -0
- data/app/javascript/phlex_forms/controllers/validations/numericality_controller.js +59 -0
- data/app/javascript/phlex_forms/controllers/validations/presence_controller.js +14 -0
- data/app/javascript/phlex_forms/i18n.js +40 -0
- data/app/javascript/phlex_forms/messages.js +100 -0
- data/config/importmap.rb +21 -0
- data/config/locales/de.yml +12 -0
- data/config/locales/en.yml +14 -0
- data/config/locales/sv.yml +12 -0
- data/config/rubocop.yml +20 -0
- data/lib/forms/checkbox.rb +39 -0
- data/lib/forms/choices_select.rb +132 -0
- data/lib/forms/collection_check_box.rb +26 -0
- data/lib/forms/collection_check_box_builder.rb +26 -0
- data/lib/forms/collection_label.rb +20 -0
- data/lib/forms/email_field.rb +11 -0
- data/lib/forms/field.rb +222 -0
- data/lib/forms/field_error.rb +26 -0
- data/lib/forms/field_hint.rb +24 -0
- data/lib/forms/fields_for_builder.rb +58 -0
- data/lib/forms/file_input.rb +29 -0
- data/lib/forms/form.rb +225 -0
- data/lib/forms/form_control.rb +42 -0
- data/lib/forms/input.rb +35 -0
- data/lib/forms/label.rb +32 -0
- data/lib/forms/password_field.rb +11 -0
- data/lib/forms/radio.rb +29 -0
- data/lib/forms/range.rb +33 -0
- data/lib/forms/rich_textarea.rb +43 -0
- data/lib/forms/select.rb +73 -0
- data/lib/forms/submit.rb +62 -0
- data/lib/forms/textarea.rb +27 -0
- data/lib/forms/time_zone_select.rb +52 -0
- data/lib/forms/toggle.rb +39 -0
- data/lib/forms/validations/introspector.rb +224 -0
- data/lib/forms/validations/manual_rules.rb +77 -0
- data/lib/forms/wrapped_input.rb +67 -0
- data/lib/phlex-forms.rb +4 -0
- data/lib/phlex_forms/builder.rb +153 -0
- data/lib/phlex_forms/class_merge.rb +56 -0
- data/lib/phlex_forms/configuration.rb +49 -0
- data/lib/phlex_forms/delegated_field.rb +51 -0
- data/lib/phlex_forms/engine.rb +35 -0
- data/lib/phlex_forms/inline_icons.rb +36 -0
- data/lib/phlex_forms/rubocop.rb +14 -0
- data/lib/phlex_forms/version.rb +5 -0
- data/lib/phlex_forms.rb +80 -0
- data/lib/rubocop/phlex_forms/cop/legacy_form_method.rb +103 -0
- data/lib/rubocop/phlex_forms/cop/raw_form.rb +51 -0
- 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
|
data/lib/phlex-forms.rb
ADDED
|
@@ -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"
|
data/lib/phlex_forms.rb
ADDED
|
@@ -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
|