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
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
|
+
}
|