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,59 @@
1
+ import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
+ import { t } from "phlex_forms/i18n"
3
+
4
+ // Mirrors ActiveModel::Validations::NumericalityValidator.
5
+ export default class extends FieldValidatorController {
6
+ static values = {
7
+ greaterThan: Number,
8
+ greaterThanOrEqualTo: Number,
9
+ lessThan: Number,
10
+ lessThanOrEqualTo: Number,
11
+ equalTo: Number,
12
+ otherThan: Number,
13
+ onlyInteger: { type: Boolean, default: false },
14
+ odd: { type: Boolean, default: false },
15
+ even: { type: Boolean, default: false },
16
+ }
17
+
18
+ check(value) {
19
+ const raw = String(value ?? "").trim()
20
+ if (raw === "") return null
21
+
22
+ const number = Number(raw)
23
+ if (Number.isNaN(number)) return t("js.forms.validations.numericality.not_a_number")
24
+
25
+ if (this.onlyIntegerValue && !Number.isInteger(number)) {
26
+ return t("js.forms.validations.numericality.not_an_integer")
27
+ }
28
+ if (this.hasGreaterThanValue && !(number > this.greaterThanValue)) {
29
+ return t("js.forms.validations.numericality.greater_than", { count: this.greaterThanValue })
30
+ }
31
+ if (this.hasGreaterThanOrEqualToValue && !(number >= this.greaterThanOrEqualToValue)) {
32
+ return t("js.forms.validations.numericality.greater_than_or_equal_to", {
33
+ count: this.greaterThanOrEqualToValue,
34
+ })
35
+ }
36
+ if (this.hasLessThanValue && !(number < this.lessThanValue)) {
37
+ return t("js.forms.validations.numericality.less_than", { count: this.lessThanValue })
38
+ }
39
+ if (this.hasLessThanOrEqualToValue && !(number <= this.lessThanOrEqualToValue)) {
40
+ return t("js.forms.validations.numericality.less_than_or_equal_to", {
41
+ count: this.lessThanOrEqualToValue,
42
+ })
43
+ }
44
+ if (this.hasEqualToValue && number !== this.equalToValue) {
45
+ return t("js.forms.validations.numericality.equal_to", { count: this.equalToValue })
46
+ }
47
+ if (this.hasOtherThanValue && number === this.otherThanValue) {
48
+ return t("js.forms.validations.numericality.other_than", { count: this.otherThanValue })
49
+ }
50
+ // Rails' NumericalityValidator coerces with `.to_i` before
51
+ // checking parity, so `2.5` is treated as `2` (even). JS `%` on
52
+ // a float diverges from that — truncate first to keep client and
53
+ // server in agreement.
54
+ const truncated = Math.trunc(number)
55
+ if (this.oddValue && truncated % 2 === 0) return t("js.forms.validations.numericality.odd")
56
+ if (this.evenValue && truncated % 2 !== 0) return t("js.forms.validations.numericality.even")
57
+ return null
58
+ }
59
+ }
@@ -0,0 +1,14 @@
1
+ import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
+ import { t } from "phlex_forms/i18n"
3
+
4
+ // Mirrors ActiveModel::Validations::PresenceValidator.
5
+ export default class extends FieldValidatorController {
6
+ static values = {
7
+ required: { type: Boolean, default: true },
8
+ }
9
+
10
+ check(value) {
11
+ if (!this.requiredValue) return null
12
+ return this.isBlank(value) ? t("js.forms.validations.presence.blank") : null
13
+ }
14
+ }
@@ -0,0 +1,40 @@
1
+ // Minimal, self-contained i18n for phlex-forms' client-side validation
2
+ // messages. Bundles the validation strings for every shipped locale so the
3
+ // gem needs no host i18n runtime. Host apps can override a message by defining
4
+ // window.PhlexForms.messages[locale] before the controllers connect.
5
+ //
6
+ // Locale is read from <html lang> (falling back to a <meta name="locale">, then
7
+ // "en"), matching the convention Rails apps already set.
8
+
9
+ import { messages } from "phlex_forms/messages"
10
+
11
+ function detectLocale() {
12
+ if (typeof document === "undefined") return "en"
13
+ const htmlLang = document.documentElement.lang
14
+ if (htmlLang) return htmlLang.split("-")[0]
15
+ const meta = document.querySelector('meta[name="locale"]')?.content
16
+ return meta ? meta.split("-")[0] : "en"
17
+ }
18
+
19
+ function overrides() {
20
+ if (typeof window === "undefined") return {}
21
+ return (window.PhlexForms && window.PhlexForms.messages) || {}
22
+ }
23
+
24
+ function lookup(scope, locale) {
25
+ const table = { ...(messages[locale] || {}), ...(overrides()[locale] || {}) }
26
+ return scope.split(".").reduce((node, key) => (node == null ? undefined : node[key]), table)
27
+ }
28
+
29
+ function interpolate(string, vars) {
30
+ return string.replace(/%\{(\w+)\}/g, (_, key) => (key in vars ? String(vars[key]) : `%{${key}}`))
31
+ }
32
+
33
+ // t("js.forms.validations.presence.blank", { count: 3 })
34
+ // Falls back to English, then to the raw key, so a missing translation never
35
+ // throws or renders blank.
36
+ export function t(scope, vars = {}) {
37
+ const locale = detectLocale()
38
+ const message = lookup(scope, locale) ?? lookup(scope, "en") ?? scope
39
+ return typeof message === "string" ? interpolate(message, vars) : scope
40
+ }
@@ -0,0 +1,100 @@
1
+ // Bundled validation messages for phlex-forms, keyed by locale then by the
2
+ // `js.forms.validations.*` dotted scope. Combined from the Cosmos (en/sv/de) and
3
+ // getzazu/app (en/fr/af) locale sets; sv/de start from English and are refined
4
+ // by the locallingo translation pass. Host apps override via
5
+ // window.PhlexForms.messages.
6
+ export const messages = {
7
+ en: {
8
+ js: {
9
+ forms: {
10
+ validations: {
11
+ acceptance: { accepted: "must be accepted" },
12
+ confirmation: { confirmation: "doesn't match confirmation" },
13
+ exclusion: { exclusion: "is reserved" },
14
+ format: { invalid: "is invalid" },
15
+ inclusion: { inclusion: "is not included in the list" },
16
+ length: {
17
+ too_long: "is too long (maximum is %{count} characters)",
18
+ too_short: "is too short (minimum is %{count} characters)",
19
+ wrong_length: "is the wrong length (should be %{count} characters)",
20
+ },
21
+ numericality: {
22
+ equal_to: "must be equal to %{count}",
23
+ even: "must be even",
24
+ greater_than: "must be greater than %{count}",
25
+ greater_than_or_equal_to: "must be greater than or equal to %{count}",
26
+ less_than: "must be less than %{count}",
27
+ less_than_or_equal_to: "must be less than or equal to %{count}",
28
+ not_a_number: "is not a number",
29
+ not_an_integer: "must be an integer",
30
+ odd: "must be odd",
31
+ other_than: "must be other than %{count}",
32
+ },
33
+ presence: { blank: "can't be blank" },
34
+ },
35
+ },
36
+ },
37
+ },
38
+ fr: {
39
+ js: {
40
+ forms: {
41
+ validations: {
42
+ acceptance: { accepted: "doit être accepté(e)" },
43
+ confirmation: { confirmation: "ne concorde pas avec la confirmation" },
44
+ exclusion: { exclusion: "n'est pas disponible" },
45
+ format: { invalid: "n'est pas valide" },
46
+ inclusion: { inclusion: "n'est pas inclus(e) dans la liste" },
47
+ length: {
48
+ too_long: "est trop long (pas plus de %{count} caractères)",
49
+ too_short: "est trop court (au moins %{count} caractères)",
50
+ wrong_length: "ne fait pas la bonne longueur (doit comporter %{count} caractères)",
51
+ },
52
+ numericality: {
53
+ equal_to: "doit être égal à %{count}",
54
+ even: "doit être pair",
55
+ greater_than: "doit être supérieur à %{count}",
56
+ greater_than_or_equal_to: "doit être supérieur ou égal à %{count}",
57
+ less_than: "doit être inférieur à %{count}",
58
+ less_than_or_equal_to: "doit être inférieur ou égal à %{count}",
59
+ not_a_number: "n'est pas un nombre",
60
+ not_an_integer: "doit être un nombre entier",
61
+ odd: "doit être impair",
62
+ other_than: "doit être différent de %{count}",
63
+ },
64
+ presence: { blank: "doit être rempli(e)" },
65
+ },
66
+ },
67
+ },
68
+ },
69
+ af: {
70
+ js: {
71
+ forms: {
72
+ validations: {
73
+ acceptance: { accepted: "moet aanvaar word" },
74
+ confirmation: { confirmation: "pas nie by bevestiging nie" },
75
+ exclusion: { exclusion: "is bespreek" },
76
+ format: { invalid: "is ongeldig" },
77
+ inclusion: { inclusion: "is nie by die lys ingesluit nie" },
78
+ length: {
79
+ too_long: "is te lank (maksimum is %{count} karakters)",
80
+ too_short: "is te kort (minimum is %{count} karakters)",
81
+ wrong_length: "is die verkeerde lengte (moet %{count} karakters wees)",
82
+ },
83
+ numericality: {
84
+ equal_to: "moet gelyk wees aan %{count}",
85
+ even: "moet ewe wees",
86
+ greater_than: "moet meer wees as %{count}",
87
+ greater_than_or_equal_to: "moet meer of gelykstaande wees aan %{count}",
88
+ less_than: "moet minder wees as %{count}",
89
+ less_than_or_equal_to: "moet minder of gelykstaande wees aan %{count}",
90
+ not_a_number: "is nie 'n getal nie",
91
+ not_an_integer: "moet 'n heelgetal wees",
92
+ odd: "moet onewe wees",
93
+ other_than: "moet anders wees as %{count}",
94
+ },
95
+ presence: { blank: "mag nie leeg wees nie" },
96
+ },
97
+ },
98
+ },
99
+ },
100
+ }
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Auto-pins the gem's bundled Stimulus controllers for importmap-rails consumers.
4
+ # Register them in the host app (lazy loading recommended):
5
+ #
6
+ # // app/javascript/controllers/index.js
7
+ # import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
8
+ # lazyLoadControllersFrom("phlex_forms/controllers", application)
9
+ #
10
+ # The `choices` controller expects the `choices.js` package to be available in
11
+ # the host importmap (peer dependency); document it in the host README.
12
+ pin_all_from PhlexForms::Engine.root.join("app/javascript/phlex_forms/controllers"),
13
+ under: "phlex_forms/controllers",
14
+ to: "phlex_forms/controllers"
15
+
16
+ # The client-side validation controllers import the gem's own i18n helper and
17
+ # bundled messages; pin those modules so importmap can resolve them.
18
+ pin "phlex_forms/i18n",
19
+ to: "phlex_forms/i18n.js"
20
+ pin "phlex_forms/messages",
21
+ to: "phlex_forms/messages.js"
@@ -0,0 +1,12 @@
1
+ de:
2
+ cmd:
3
+ create: Erstellen
4
+ create_model: "%{model} erstellen"
5
+ update: Aktualisieren
6
+ update_model: "%{model} aktualisieren"
7
+ components:
8
+ searchable_select:
9
+ no_results: Keine Ergebnisse gefunden
10
+ placeholder: Auswählen...
11
+ search_placeholder: Suchen...
12
+ no_choices: Keine Auswahl verfügbar
@@ -0,0 +1,14 @@
1
+ en:
2
+ # Default submit-button text for Forms::Submit. Host apps override by defining
3
+ # the same keys (their locale files load after the gem's).
4
+ cmd:
5
+ create: Create
6
+ create_model: Create %{model}
7
+ update: Update
8
+ update_model: Update %{model}
9
+ components:
10
+ searchable_select:
11
+ no_results: No results found
12
+ placeholder: Select...
13
+ search_placeholder: Search...
14
+ no_choices: No choices to choose from
@@ -0,0 +1,12 @@
1
+ sv:
2
+ cmd:
3
+ create: Skapa
4
+ create_model: Skapa %{model}
5
+ update: Uppdatera
6
+ update_model: Uppdatera %{model}
7
+ components:
8
+ searchable_select:
9
+ no_results: Inga resultat hittades
10
+ placeholder: Välj...
11
+ search_placeholder: Sök...
12
+ no_choices: Inga alternativ att välja
@@ -0,0 +1,20 @@
1
+ # Shareable RuboCop config for phlex-forms' cops. Inherit it in a host app:
2
+ #
3
+ # require:
4
+ # - phlex_forms/rubocop
5
+ # inherit_gem:
6
+ # phlex-forms: config/rubocop.yml
7
+ #
8
+ # Both cops target Phlex view/component files by default; adjust Include in the
9
+ # host if your components live elsewhere.
10
+ PhlexForms/RawForm:
11
+ Enabled: true
12
+ Include:
13
+ - "app/components/**/*.rb"
14
+ - "app/views/**/*.rb"
15
+
16
+ PhlexForms/LegacyFormMethod:
17
+ Enabled: true
18
+ Include:
19
+ - "app/components/**/*.rb"
20
+ - "app/views/**/*.rb"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A model-bound checkbox, delegating to DaisyUI::Checkbox. Emits a hidden field
5
+ # carrying the unchecked value so an unchecked box still submits (the Rails
6
+ # checkbox convention).
7
+ class Checkbox < Phlex::HTML
8
+ include PhlexForms::DelegatedField
9
+
10
+ def initialize(*modifiers, name: nil, id: nil, value: "1", unchecked_value: "0",
11
+ checked: false, error: false, disabled: false, required: false, **attributes)
12
+ @modifiers = normalize_modifiers(modifiers)
13
+ @name = name
14
+ @id = id
15
+ @value = value
16
+ @unchecked_value = unchecked_value
17
+ @checked = checked
18
+ @error = error
19
+ @disabled = disabled
20
+ @required = required
21
+ @full_width = false
22
+ @attributes = attributes
23
+ super()
24
+ end
25
+
26
+ def view_template
27
+ input(type: "hidden", name: @name, value: @unchecked_value) if @unchecked_value && @name
28
+ render DaisyUI::Checkbox.new(*daisy_modifiers, value: @value, **checkbox_attributes)
29
+ end
30
+
31
+ private
32
+
33
+ def checkbox_attributes
34
+ attrs = binding_attributes
35
+ attrs[:checked] = true if @checked
36
+ attrs
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A searchable/multi-select `<select>` enhanced by the choices.js Stimulus
5
+ # controller (shipped with the gem). Renders a normal `<select>` server-side;
6
+ # the `choices` controller upgrades it on connect. choices.js replaces the
7
+ # element, so size/color positional modifiers are passed as data values and
8
+ # applied to the choices.js wrapper by the controller (not as daisyui classes).
9
+ class ChoicesSelect < Phlex::HTML
10
+ SIZE_MODIFIERS = %i[xs sm md lg xl].freeze
11
+ COLOR_MODIFIERS = %i[primary secondary accent neutral info success warning error].freeze
12
+
13
+ def initialize(*modifiers, name: nil, id: nil, choices: [], selected: nil, multiple: false,
14
+ searchable: false, remove_item_button: nil, placeholder: nil,
15
+ include_blank: false, prompt: nil, error: false, disabled: false, required: false, **options)
16
+ @modifiers = modifiers
17
+ @name = name
18
+ @id = id
19
+ @choices = choices
20
+ @selected = selected
21
+ @multiple = multiple
22
+ @searchable = searchable
23
+ @remove_item_button = remove_item_button.nil? ? multiple : remove_item_button
24
+ @placeholder = placeholder
25
+ @include_blank = include_blank
26
+ @prompt = prompt
27
+ @error = error
28
+ @disabled = disabled
29
+ @required = required
30
+ @options = options
31
+ super()
32
+ end
33
+
34
+ def view_template
35
+ select(**attrs) do
36
+ render_prompt if @prompt || @include_blank
37
+ render_choices
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def attrs
44
+ a = {
45
+ name: select_name,
46
+ id: @id,
47
+ class: PhlexForms::ClassMerge.merge("choices-select w-full", @options[:class]),
48
+ data: stimulus_data,
49
+ **@options.except(:class, :value, :data)
50
+ }
51
+ a[:multiple] = true if @multiple
52
+ a[:disabled] = true if @disabled
53
+ a[:required] = true if @required
54
+ a.compact
55
+ end
56
+
57
+ def select_name
58
+ return nil unless @name
59
+
60
+ @multiple ? "#{@name}[]" : @name
61
+ end
62
+
63
+ def stimulus_data
64
+ base = @options[:data] || {}
65
+ {
66
+ controller: [base[:controller], "choices"].compact.join(" "),
67
+ choices_searchable_value: @searchable,
68
+ choices_remove_item_button_value: @remove_item_button,
69
+ choices_placeholder_value: @placeholder.to_s,
70
+ choices_size_value: size_value,
71
+ choices_color_value: color_value,
72
+ **base.except(:controller)
73
+ }
74
+ end
75
+
76
+ def size_value
77
+ (@modifiers & SIZE_MODIFIERS).first.to_s
78
+ end
79
+
80
+ def color_value
81
+ ((@modifiers & COLOR_MODIFIERS).first || :primary).to_s
82
+ end
83
+
84
+ def render_prompt
85
+ text = @prompt || (@include_blank.is_a?(String) ? @include_blank : "")
86
+ option(value: "", selected: blank_selected?) { text }
87
+ end
88
+
89
+ def blank_selected?
90
+ @multiple ? Array(@selected).empty? : @selected.blank?
91
+ end
92
+
93
+ def render_choices
94
+ case @choices
95
+ when Hash then render_hash_choices(@choices)
96
+ else render_array_choices(Array(@choices))
97
+ end
98
+ end
99
+
100
+ def render_array_choices(choices)
101
+ choices.each do |choice|
102
+ if choice.is_a?(Array)
103
+ render_option(choice[1], choice[0])
104
+ else
105
+ render_option(choice, choice)
106
+ end
107
+ end
108
+ end
109
+
110
+ def render_hash_choices(choices)
111
+ choices.each do |label, value|
112
+ if value.is_a?(Array) || value.is_a?(Hash)
113
+ optgroup(label:) { value.is_a?(Array) ? render_array_choices(value) : render_hash_choices(value) }
114
+ else
115
+ render_option(value, label)
116
+ end
117
+ end
118
+ end
119
+
120
+ def render_option(value, label)
121
+ option(value: value.to_s, selected: selected?(value) || nil) { label.to_s }
122
+ end
123
+
124
+ def selected?(value)
125
+ if @multiple
126
+ Array(@selected).map(&:to_s).include?(value.to_s)
127
+ else
128
+ @selected.to_s == value.to_s
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A single checkbox within a collection_check_boxes group.
5
+ class CollectionCheckBox < Phlex::HTML
6
+ def initialize(name:, id:, value:, checked:, **options)
7
+ @name = name
8
+ @id = id
9
+ @value = value
10
+ @checked = checked
11
+ @options = options
12
+ super()
13
+ end
14
+
15
+ def view_template
16
+ input(
17
+ type: "checkbox",
18
+ name: @name,
19
+ id: @id,
20
+ value: @value,
21
+ class: @options[:class] || "checkbox",
22
+ checked: @checked || nil
23
+ )
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # Yielded for each item by Form#collection_check_boxes. Provides check_box and
5
+ # label builders mirroring Rails' collection_check_boxes API.
6
+ class CollectionCheckBoxBuilder
7
+ attr_reader :object, :value, :text
8
+
9
+ def initialize(object:, value:, text:, checked:, name:, id:)
10
+ @object = object
11
+ @value = value
12
+ @text = text
13
+ @checked = checked
14
+ @name = name
15
+ @id = id
16
+ end
17
+
18
+ def check_box(options = {})
19
+ Forms::CollectionCheckBox.new(name: @name, id: @id, value: @value, checked: @checked, **options)
20
+ end
21
+
22
+ def label(options = {}, &)
23
+ Forms::CollectionLabel.new(for_id: @id, text: @text, **options, &)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A label for a single collection checkbox item.
5
+ class CollectionLabel < Phlex::HTML
6
+ def initialize(for_id:, text:, **options, &block)
7
+ @for_id = for_id
8
+ @text = text
9
+ @options = options
10
+ @block = block
11
+ super()
12
+ end
13
+
14
+ def view_template
15
+ label(for: @for_id, class: @options[:class]) do
16
+ @block ? yield_content(&@block) : plain(@text)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # Convenience: an Input pre-typed as email with a sensible default placeholder.
5
+ # EmailField(:primary, name: "user[email]")
6
+ class EmailField < Input
7
+ def initialize(*, placeholder: "email@example.com", **)
8
+ super(*, type: "email", placeholder:, **)
9
+ end
10
+ end
11
+ end