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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0c3cb45e86fb22d3240e3d9b0441476376004c2996c2b08e682c00e86ad51fd7
4
+ data.tar.gz: d5abd208265060aade20c4b70a834c3404124dec41534892e243babae60f19b0
5
+ SHA512:
6
+ metadata.gz: 42908177da2fdddef940576259b03ede3a07b7e561704d4eaa4d30dd5b3768d163b02cb03a0ec2c98fb9912923043f55fe41aaae64fc34709318329703c2961e
7
+ data.tar.gz: db8f2f000cfe38ceb447f016b39aac2f3fc9a48c8749aab309f48e56019060b8516707fd43dfd726338a683ffc441e65c2b926e09bf9c797acca1feb959f843f
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - Initial extraction of the `Forms::` Phlex form builder from the Cosmos apps.
13
+ - Control-first builder API: `f.field :email, label:, hint:, as:, choices:`
14
+ renders label + input + error/hint in one call, inferring input type and the
15
+ `required` flag from the model.
16
+ - Escape-hatch component API preserved: `f.Input`, `f.Select`, `f.Textarea`,
17
+ `f.Checkbox`, `f.Toggle`, `f.FileInput`, `f.Hidden`, `f.Label`, `f.Control`,
18
+ `f.submit`.
19
+ - Configurable icon renderer (`PhlexForms.configure`) with a zero-dependency
20
+ inline-SVG default and optional `glyps` auto-detection.
21
+ - Polymorphic array model support in `Form(model: [parent, child])`.
22
+ - Bundled Stimulus controllers (choices, searchable-select, time zone) and
23
+ default `en`/`sv`/`de` locales, wired through an optional Rails engine.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Mikael Henriksson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # phlex-forms
2
+
3
+ A model-bound, DaisyUI-styled form builder for [Phlex](https://www.phlex.fun).
4
+
5
+ `Form(model:) { |f| f.field :email }` renders a label, an input, and an
6
+ error/hint in **one call** — inferring the input type from the attribute name and
7
+ the `required` flag from the model's validations. It's built on the
8
+ [`daisyui`](https://github.com/mhenrixon/daisyui) gem, so every field is a thin
9
+ binding layer over a real DaisyUI component: positional variants stack, blocks
10
+ pass through, and the markup stays DaisyUI v5-correct.
11
+
12
+ ```ruby
13
+ Form(:spaced, model: @user) do |f|
14
+ f.field :email, hint: t("users.email_hint") # type=email, required inferred
15
+ f.field :role, as: :select, choices: roles
16
+ f.field :bio, as: :textarea, rows: 6
17
+ f.field :notify, as: :toggle
18
+ f.submit :primary
19
+ end
20
+ ```
21
+
22
+ ## Installation
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ gem "phlex-forms"
27
+ ```
28
+
29
+ phlex-forms exposes its components under the `Forms::` namespace as a
30
+ [Phlex::Kit](https://www.phlex.fun/kits.html). Include it wherever you render
31
+ components (typically your `ApplicationComponent` / base view):
32
+
33
+ ```ruby
34
+ class ApplicationComponent < Phlex::HTML
35
+ include Forms
36
+ end
37
+ ```
38
+
39
+ Now `Form(...)`, `Submit(...)`, and every other `Forms::*` component are available
40
+ as bare kit helpers.
41
+
42
+ ## The `field` API
43
+
44
+ `f.field(name, *modifiers, **options)` is the primary verb. It renders a
45
+ `form-control` wrapping a label, the input, and an error (or hint):
46
+
47
+ | Option | Effect |
48
+ | --- | --- |
49
+ | `label:` | Label text. Defaults to the model's humanized attribute name. `label: false` omits it. |
50
+ | `hint:` | Help text shown when there is no error. |
51
+ | `as:` | Override the control: `:select`, `:textarea`, `:toggle`, `:checkbox`, `:file`, `:radio`, `:hidden`, or any text-like type. |
52
+ | `required:` | Force the required flag. Otherwise inferred from the model's presence validators. |
53
+ | `choices:` | Choices for `as: :select`. |
54
+ | positional modifiers | daisyui variants — `:primary`, `:lg`, `:ghost`, … — stacked onto the input. |
55
+
56
+ Type is inferred from the attribute name (`email` → `type=email`, `password` →
57
+ `type=password`, `phone` → `type=tel`, …).
58
+
59
+ ### Escape hatches
60
+
61
+ When you need full control (custom Stimulus wiring, bespoke layout), the
62
+ lower-level component methods are always available with stable signatures:
63
+
64
+ ```ruby
65
+ f.Control(:email, label: "Email") do
66
+ f.Input(:email, :primary, data: { controller: "autocomplete" })
67
+ end
68
+
69
+ f.Input(:name, :primary, :lg) # bare input, variants stacked
70
+ f.Select(:role, choices: roles) # native <select>
71
+ f.Textarea(:bio, :ghost)
72
+ f.Checkbox(:terms) ; f.Toggle(:notify) ; f.FileInput(:avatar)
73
+ f.Label(:email) ; f.Hidden(:token) ; f.submit("Save", :primary)
74
+ ```
75
+
76
+ ### Icons inside a field (DaisyUI v5)
77
+
78
+ `WrappedInput` renders the `<label class="input">{icon}{input}` pattern:
79
+
80
+ ```ruby
81
+ f.field(:search).wrapped_input(:primary) do
82
+ LucideIcon("search", class: "opacity-50") # leading content
83
+ end
84
+ ```
85
+
86
+ ## Nested attributes & collections
87
+
88
+ ```ruby
89
+ f.fields_for(:line_items) do |item| # single assoc or has_many
90
+ item.field :description
91
+ end
92
+
93
+ f.collection_check_boxes(:role_ids, Role.all, :id, :name) do |b|
94
+ render b.check_box
95
+ render b.label
96
+ end
97
+
98
+ f.collection_select(:country_id, Country.all, :id, :name, prompt: "Select…")
99
+ ```
100
+
101
+ ## Client-side validation
102
+
103
+ phlex-forms ships a Stimulus validation framework that mirrors your ActiveModel
104
+ validators — no ugly, browser-inconsistent native validation bubbles.
105
+
106
+ ```ruby
107
+ Form(model: @partner, validate: true) do |f|
108
+ f.field :title # every validator on :title
109
+ f.field :slug, validate: false # opt this field out
110
+ f.field :note, validate: { length: { maximum: 30 } } # explicit rules
111
+ end
112
+ ```
113
+
114
+ When `validate: true`, the form gets `novalidate` and a submit coordinator; each
115
+ field emits `data-forms--validations--*` bindings introspected from the model.
116
+ Supported: presence, length (with a live counter), format, numericality,
117
+ inclusion, exclusion, confirmation, acceptance. Validators with `:if` / `:unless`
118
+ / `:on` are skipped (they need server context); the server stays authoritative.
119
+
120
+ Register the controllers in your Stimulus setup:
121
+
122
+ ```js
123
+ // app/javascript/controllers/index.js
124
+ import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
125
+ lazyLoadControllersFrom("phlex_forms/controllers", application)
126
+ ```
127
+
128
+ Validation messages ship for `en` / `fr` / `af`; override any string via
129
+ `window.PhlexForms.messages`.
130
+
131
+ ## Icons
132
+
133
+ Icons default to a bundled inline SVG so the gem is self-contained. To use
134
+ [`glyphs`](https://rubygems.org/gems/glyphs) (rails-icons under the hood):
135
+
136
+ ```ruby
137
+ PhlexForms.configure do |c|
138
+ c.icon_renderer = PhlexForms::Configuration.glyphs_renderer
139
+ end
140
+ ```
141
+
142
+ ## RuboCop cops
143
+
144
+ Two cops nudge call sites toward the phlex-forms API. Enable them in your
145
+ `.rubocop.yml`:
146
+
147
+ ```yaml
148
+ require:
149
+ - phlex_forms/rubocop
150
+ inherit_gem:
151
+ phlex-forms: config/rubocop.yml
152
+ ```
153
+
154
+ - `PhlexForms/RawForm` — use `Form()` over `form_with` / raw `form()` (autocorrects).
155
+ - `PhlexForms/LegacyFormMethod` — use `form.field(...)` / the PascalCase methods
156
+ over Rails-style `text_field` / `select` / etc.
157
+
158
+ ## JavaScript peer dependencies
159
+
160
+ - `@hotwired/stimulus` — required.
161
+ - `choices.js` — only if you use searchable/multi selects (`searchable: true`).
162
+
163
+ ## Companion
164
+
165
+ Form-level error summaries (`FormErrors`) live in your app's UI kit, not here —
166
+ pair them with `Form()` as you like.
167
+
168
+ ## License
169
+
170
+ MIT © Mikael Henriksson
@@ -0,0 +1,23 @@
1
+ import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
+ import { t } from "phlex_forms/i18n"
3
+
4
+ // Mirrors ActiveModel::Validations::AcceptanceValidator. For
5
+ // checkboxes, asserts `.checked`. For other inputs, asserts the
6
+ // value is in the accepted list.
7
+ export default class extends FieldValidatorController {
8
+ static values = {
9
+ accept: { type: String, default: '["1","true"]' },
10
+ }
11
+
12
+ check(value) {
13
+ if (this.element.type === "checkbox") {
14
+ return this.element.checked ? null : t("js.forms.validations.acceptance.accepted")
15
+ }
16
+ try {
17
+ const list = JSON.parse(this.acceptValue)
18
+ return list.map(String).includes(String(value ?? "")) ? null : t("js.forms.validations.acceptance.accepted")
19
+ } catch (_e) {
20
+ return null
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,166 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Shared base for every field-level validator. Subclasses implement
4
+ // `check(value)` and return either `null` (valid) or a translation
5
+ // key / message string (invalid). The base class wires up the DOM
6
+ // hooks (blur, validate event), error rendering, and the message
7
+ // the form coordinator collects on submit.
8
+ //
9
+ // Each subclass declares its own `static values` for the rules it
10
+ // reads from data attributes; the base class only knows about the
11
+ // `allowBlank` / `allowNil` short-circuits.
12
+ export class FieldValidatorController extends Controller {
13
+ // `error` is opt-in: callers that pre-render a `<p data-forms--validations--error-target="error">`
14
+ // get a stable slot the controller toggles. Inputs without an
15
+ // explicit target still work — the controller lazily creates one
16
+ // adjacent to the input below.
17
+ static targets = ["error"]
18
+ static values = {
19
+ allowBlank: { type: Boolean, default: false },
20
+ allowNil: { type: Boolean, default: false },
21
+ }
22
+
23
+ // Element must be the input itself — these controllers are attached
24
+ // directly to <input>, <textarea>, <select> via the form builder.
25
+ connect() {
26
+ this.element.addEventListener("blur", this.onBlur)
27
+ this.element.addEventListener("invalidate:forms--validations", this.onValidate)
28
+ }
29
+
30
+ disconnect() {
31
+ this.element.removeEventListener("blur", this.onBlur)
32
+ this.element.removeEventListener("invalidate:forms--validations", this.onValidate)
33
+ }
34
+
35
+ onBlur = () => {
36
+ this.runCheck({ silent: false })
37
+ }
38
+
39
+ onValidate = (event) => {
40
+ const result = this.runCheck({ silent: false })
41
+ if (result) {
42
+ event.detail.errors.push({ element: this.element, message: result })
43
+ }
44
+ }
45
+
46
+ runCheck({ silent }) {
47
+ const value = this.fieldValue()
48
+ if (this.shouldSkip(value)) {
49
+ this.clearError()
50
+ return null
51
+ }
52
+
53
+ const error = this.check(value)
54
+ if (error) {
55
+ if (!silent) this.renderError(error)
56
+ return error
57
+ }
58
+ this.clearError()
59
+ return null
60
+ }
61
+
62
+ // Subclasses override.
63
+ check(_value) {
64
+ return null
65
+ }
66
+
67
+ shouldSkip(value) {
68
+ if (this.allowNilValue && (value === null || value === undefined)) return true
69
+ if (this.allowBlankValue && this.isBlank(value)) return true
70
+ return false
71
+ }
72
+
73
+ isBlank(value) {
74
+ if (value === null || value === undefined) return true
75
+ if (typeof value === "string") return value.trim().length === 0
76
+ return false
77
+ }
78
+
79
+ fieldValue() {
80
+ if (this.element.type === "checkbox") return this.element.checked
81
+ return this.element.value
82
+ }
83
+
84
+ // Stores this validator's error state on the input element and
85
+ // re-renders the consolidated message. Multiple validators share
86
+ // a single container per field but track their errors separately
87
+ // so one validator's "valid" doesn't blow away another's "invalid".
88
+ renderError(message) {
89
+ this.setValidatorError(message)
90
+ this.renderConsolidated()
91
+ }
92
+
93
+ clearError() {
94
+ this.setValidatorError(null)
95
+ this.renderConsolidated()
96
+ }
97
+
98
+ setValidatorError(message) {
99
+ if (!this.element.__formsValidationErrors) {
100
+ this.element.__formsValidationErrors = {}
101
+ }
102
+ this.element.__formsValidationErrors[this.identifier] = message
103
+ }
104
+
105
+ renderConsolidated() {
106
+ const errors = this.element.__formsValidationErrors || {}
107
+ // Stable order so the displayed message doesn't flicker.
108
+ const ordered = Object.keys(errors)
109
+ .sort()
110
+ .map((k) => errors[k])
111
+ .filter(Boolean)
112
+ const message = ordered[0] || null
113
+
114
+ const container = this.errorContainer({ create: !!message })
115
+ if (container) {
116
+ container.textContent = message || ""
117
+ container.hidden = !message
118
+ }
119
+ const errorClass = this.errorClassForElement()
120
+ if (message) {
121
+ this.element.setAttribute("aria-invalid", "true")
122
+ this.element.classList.add(errorClass)
123
+ } else {
124
+ this.element.removeAttribute("aria-invalid")
125
+ this.element.classList.remove(errorClass)
126
+ }
127
+ }
128
+
129
+ // DaisyUI uses tag-specific error classes (`input-error` vs
130
+ // `textarea-error` vs `select-error`). Adding all three to every
131
+ // element is inert today but invites CSS bleed once the variants
132
+ // diverge — pick the one that matches.
133
+ errorClassForElement() {
134
+ switch (this.element.tagName) {
135
+ case "TEXTAREA":
136
+ return "textarea-error"
137
+ case "SELECT":
138
+ return "select-error"
139
+ default:
140
+ return "input-error"
141
+ }
142
+ }
143
+
144
+ errorContainer({ create = true } = {}) {
145
+ // If the caller pre-rendered a Stimulus error target on the same
146
+ // controller, use that — preferred path for server-rendered forms.
147
+ if (this.hasErrorTarget) return this.errorTarget
148
+
149
+ // Fallback: lazily create (and cache by element id/name) a slot
150
+ // adjacent to the input. Keeps the framework usable on plain
151
+ // forms that haven't opted into the static-target convention.
152
+ const id = this.element.id || this.element.name
153
+ const selector = `[data-forms--validations--error="${id}"]`
154
+ const existing = this.element.closest("form")?.querySelector(selector)
155
+ if (existing) return existing
156
+ if (!create) return null
157
+
158
+ const container = document.createElement("p")
159
+ container.className = "text-error text-sm mt-1"
160
+ // `dataset` rejects keys with `--`, so we set the attribute
161
+ // directly. The CSS selector still matches.
162
+ container.setAttribute("data-forms--validations--error", id)
163
+ this.element.insertAdjacentElement("afterend", container)
164
+ return container
165
+ }
166
+ }
@@ -0,0 +1,27 @@
1
+ import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
+ import { t } from "phlex_forms/i18n"
3
+
4
+ // Mirrors ActiveModel::Validations::ConfirmationValidator. Looks
5
+ // for the `<name>_confirmation` field by name within the same form
6
+ // and asserts equality.
7
+ export default class extends FieldValidatorController {
8
+ static values = {
9
+ match: String,
10
+ }
11
+
12
+ check(value) {
13
+ if (!this.hasMatchValue) return null
14
+ const form = this.element.closest("form")
15
+ if (!form) return null
16
+ const partner = form.elements[this.matchValue] || this.partnerByAttribute(form)
17
+ if (!partner) return null
18
+ return String(partner.value) === String(value ?? "") ? null : t("js.forms.validations.confirmation.confirmation")
19
+ }
20
+
21
+ partnerByAttribute(form) {
22
+ // Rails scopes form fields as `model[name_confirmation]`, so
23
+ // `form.elements[matchValue]` lookup fails for scoped names.
24
+ // Fall back to a `[name$="[<matchValue>]"]` selector.
25
+ return form.querySelector(`[name$="[${this.matchValue}]"]`)
26
+ }
27
+ }
@@ -0,0 +1,19 @@
1
+ import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
+ import { t } from "phlex_forms/i18n"
3
+
4
+ // Mirrors ActiveModel::Validations::ExclusionValidator.
5
+ export default class extends FieldValidatorController {
6
+ static values = {
7
+ in: String,
8
+ }
9
+
10
+ check(value) {
11
+ if (!this.hasInValue) return null
12
+ try {
13
+ const list = JSON.parse(this.inValue)
14
+ return list.map(String).includes(String(value ?? "")) ? t("js.forms.validations.exclusion.exclusion") : null
15
+ } catch (_e) {
16
+ return null
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,43 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Form-level coordinator for the validation framework. Sits on the
4
+ // <form> element and intercepts `submit` to broadcast a synchronous
5
+ // validation event to every field. Each field controller listens
6
+ // for `invalidate:forms--validations`, runs its check, and (on
7
+ // failure) appends its error to the event's `detail.errors` array.
8
+ // If anything ended up in that array, we cancel the submit and
9
+ // focus the first invalid field.
10
+ //
11
+ // The mechanism is deliberately decoupled — the form controller
12
+ // doesn't know which validators are attached to which fields. It
13
+ // just fires the event and looks at what came back. Adding a new
14
+ // validator type means adding a new field controller, nothing here.
15
+ export default class extends Controller {
16
+ onSubmit = (event) => {
17
+ const errors = []
18
+ // Skip disabled controls — the browser won't submit them, so
19
+ // validating them can incorrectly block a submit that the server
20
+ // would happily accept.
21
+ const fields = this.element.querySelectorAll("input:not(:disabled), textarea:not(:disabled), select:not(:disabled)")
22
+
23
+ fields.forEach((field) => {
24
+ const validators = (field.dataset.controller || "")
25
+ .split(/\s+/)
26
+ .filter((c) => c.startsWith("forms--validations--") && c !== "forms--validations--form")
27
+ if (validators.length === 0) return
28
+
29
+ field.dispatchEvent(
30
+ new CustomEvent("invalidate:forms--validations", {
31
+ detail: { errors },
32
+ }),
33
+ )
34
+ })
35
+
36
+ if (errors.length === 0) return
37
+
38
+ event.preventDefault()
39
+ event.stopPropagation()
40
+ errors[0].element.focus({ preventScroll: false })
41
+ errors[0].element.scrollIntoView({ behavior: "smooth", block: "center" })
42
+ }
43
+ }
@@ -0,0 +1,23 @@
1
+ import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
+ import { t } from "phlex_forms/i18n"
3
+
4
+ // Mirrors ActiveModel::Validations::FormatValidator.
5
+ export default class extends FieldValidatorController {
6
+ static values = {
7
+ pattern: String,
8
+ flags: { type: String, default: "" },
9
+ }
10
+
11
+ check(value) {
12
+ if (!this.hasPatternValue) return null
13
+ try {
14
+ const regex = new RegExp(this.patternValue, this.flagsValue)
15
+ return regex.test(String(value ?? "")) ? null : t("js.forms.validations.format.invalid")
16
+ } catch (_e) {
17
+ // If Rails' regex doesn't translate cleanly to JS, fall back to
18
+ // letting the server be the authority — don't block submit on a
19
+ // pattern we can't evaluate.
20
+ return null
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,19 @@
1
+ import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
+ import { t } from "phlex_forms/i18n"
3
+
4
+ // Mirrors ActiveModel::Validations::InclusionValidator.
5
+ export default class extends FieldValidatorController {
6
+ static values = {
7
+ in: String,
8
+ }
9
+
10
+ check(value) {
11
+ if (!this.hasInValue) return null
12
+ try {
13
+ const list = JSON.parse(this.inValue)
14
+ return list.map(String).includes(String(value ?? "")) ? null : t("js.forms.validations.inclusion.inclusion")
15
+ } catch (_e) {
16
+ return null
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,95 @@
1
+ import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
+ import { t } from "phlex_forms/i18n"
3
+
4
+ // Mirrors ActiveModel::Validations::LengthValidator. Reads:
5
+ // - maximum / minimum / is
6
+ // - allow-blank / allow-nil
7
+ //
8
+ // Counter is rendered next to the input when `maximum` is set, so
9
+ // the user gets a live "12 / 60" indicator without having to wait
10
+ // for blur.
11
+ export default class extends FieldValidatorController {
12
+ // Stimulus walks the prototype chain to accumulate `static values`
13
+ // and `static targets`, so we only declare the validator-specific
14
+ // ones here. `counter` is opt-in: callers that pre-render
15
+ // `<span data-forms--validations--length-target="counter">` get a
16
+ // stable slot the controller updates. Inputs without an explicit
17
+ // target still get a lazily-injected one (see counterElement).
18
+ static targets = ["counter"]
19
+ static values = {
20
+ maximum: Number,
21
+ minimum: Number,
22
+ is: Number,
23
+ }
24
+
25
+ connect() {
26
+ super.connect()
27
+ if (this.hasMaximumValue) {
28
+ this.renderCounter()
29
+ this.element.addEventListener("input", this.updateCounter)
30
+ }
31
+ }
32
+
33
+ disconnect() {
34
+ super.disconnect()
35
+ if (this.hasMaximumValue) {
36
+ this.element.removeEventListener("input", this.updateCounter)
37
+ }
38
+ }
39
+
40
+ check(value) {
41
+ const length = this.codepointLength(value)
42
+
43
+ if (this.hasIsValue && length !== this.isValue) {
44
+ return t("js.forms.validations.length.wrong_length", { count: this.isValue })
45
+ }
46
+ if (this.hasMaximumValue && length > this.maximumValue) {
47
+ return t("js.forms.validations.length.too_long", { count: this.maximumValue })
48
+ }
49
+ if (this.hasMinimumValue && length < this.minimumValue) {
50
+ return t("js.forms.validations.length.too_short", { count: this.minimumValue })
51
+ }
52
+ return null
53
+ }
54
+
55
+ updateCounter = () => {
56
+ const counter = this.counterElement()
57
+ if (!counter) return
58
+ const length = this.codepointLength(this.element.value)
59
+ counter.textContent = `${length} / ${this.maximumValue}`
60
+ counter.classList.toggle("text-error", length > this.maximumValue)
61
+ }
62
+
63
+ // Ruby's String#length counts Unicode codepoints; JS's String#length
64
+ // counts UTF-16 code units, so an emoji like 👍 reads as 2 in JS but
65
+ // 1 in Ruby. Matching Rails here avoids the client falsely rejecting
66
+ // a string the server would accept.
67
+ codepointLength(value) {
68
+ return Array.from(String(value ?? "")).length
69
+ }
70
+
71
+ renderCounter() {
72
+ const counter = this.counterElement({ create: true })
73
+ this.updateCounter()
74
+ return counter
75
+ }
76
+
77
+ counterElement({ create = false } = {}) {
78
+ // Caller pre-rendered a Stimulus target — preferred path.
79
+ if (this.hasCounterTarget) return this.counterTarget
80
+
81
+ // Fallback: cache-by-id lookup or lazy injection for plain forms.
82
+ const id = this.element.id || this.element.name
83
+ const selector = `[data-forms--validations--counter="${id}"]`
84
+ const existing = this.element.closest("form")?.querySelector(selector)
85
+ if (existing) return existing
86
+ if (!create) return null
87
+
88
+ const counter = document.createElement("span")
89
+ counter.className = "text-xs text-base-content/60 ml-auto"
90
+ // `dataset` rejects keys containing `--`; use the raw attribute API.
91
+ counter.setAttribute("data-forms--validations--counter", id)
92
+ this.element.insertAdjacentElement("afterend", counter)
93
+ return counter
94
+ }
95
+ }