alpine-validation-plugin 1.0.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.
- package/README.md +94 -0
- package/docs/validation-alpine.md +490 -0
- package/package.json +29 -0
- package/src/validation-alpine.js +376 -0
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# alpine-validation-plugin
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
A lightweight, class-based form validation plugin for [Alpine.js](https://alpinejs.dev) v3.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
### NPM
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install alpine-validation-plugin
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import Alpine from 'alpinejs';
|
|
20
|
+
import AlpineValidation from 'alpine-validation-plugin';
|
|
21
|
+
|
|
22
|
+
Alpine.plugin(AlpineValidation);
|
|
23
|
+
Alpine.start();
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### CDN
|
|
27
|
+
|
|
28
|
+
```html
|
|
29
|
+
<script src="https://cdn.jsdelivr.net/gh/tmjaga/alpine-validation-plugin/src/validation-alpine.js"></script>
|
|
30
|
+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
Add CSS class names matching the built-in rules to your inputs.
|
|
38
|
+
Errors are stored reactively and keyed by the input's `name` / `id` / `data-field`.
|
|
39
|
+
|
|
40
|
+
```html
|
|
41
|
+
<form x-data x-validate="{ live: true }" @submit.prevent="$validate() && save()">
|
|
42
|
+
|
|
43
|
+
<input class="req email" name="email" title="Email" />
|
|
44
|
+
<span x-text="$validation.errors.email" class="text-red-500 text-sm"></span>
|
|
45
|
+
|
|
46
|
+
<input class="req int unsigned" name="age" title="Age" />
|
|
47
|
+
<span x-text="$validation.errors.age" class="text-red-500 text-sm"></span>
|
|
48
|
+
|
|
49
|
+
<p x-show="$validation.hasErrors" class="text-red-600">
|
|
50
|
+
Please fix the errors above.
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
<button type="submit" :disabled="$validation.hasErrors">Save</button>
|
|
54
|
+
|
|
55
|
+
</form>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Built-in rules
|
|
61
|
+
|
|
62
|
+
| Class | Description |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `req` | Required (whitespace-only counts as empty) |
|
|
65
|
+
| `email` | Valid email address |
|
|
66
|
+
| `int` | Integer (positive or negative) |
|
|
67
|
+
| `float` | Floating point number |
|
|
68
|
+
| `decimal` | Decimal number |
|
|
69
|
+
| `unsigned` | No negative sign allowed |
|
|
70
|
+
| `nonzero` | Value must not be zero |
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Live validation modes
|
|
75
|
+
|
|
76
|
+
| Directive | When errors appear |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `x-validate` | On submit only |
|
|
79
|
+
| `x-validate="{ live: 'input' }"` | From the first keystroke |
|
|
80
|
+
| `x-validate="{ live: true }"` | On blur, then update while typing |
|
|
81
|
+
| `x-validate="{ live: 'blur' }"` | On blur only |
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Documentation
|
|
86
|
+
|
|
87
|
+
Full API reference, all options, custom rules, and examples:
|
|
88
|
+
**[docs/validation-alpine.md](docs/validation-alpine.md)**
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
# Alpine.js Validation Plugin
|
|
2
|
+
|
|
3
|
+
A lightweight, class-based form validation plugin for Alpine.js v3.
|
|
4
|
+
Ported from `com.opencode.Validation` (jQuery).
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
### NPM / bundler
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
// app.js
|
|
14
|
+
import Alpine from 'alpinejs';
|
|
15
|
+
import AlpineValidation from './validation-alpine.js';
|
|
16
|
+
|
|
17
|
+
Alpine.plugin(AlpineValidation); // must come before Alpine.start()
|
|
18
|
+
Alpine.start();
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### CDN
|
|
22
|
+
|
|
23
|
+
```html
|
|
24
|
+
<script src="validation-alpine.js"></script>
|
|
25
|
+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Core concept
|
|
31
|
+
|
|
32
|
+
Validation rules are identified by **CSS class names** on input elements.
|
|
33
|
+
Add the class to an input and the plugin handles the rest.
|
|
34
|
+
|
|
35
|
+
```html
|
|
36
|
+
<input class="req email" name="email" />
|
|
37
|
+
<!-- ^^^ ^^^^^
|
|
38
|
+
| └─ email format check
|
|
39
|
+
└─ required field -->
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The **field key** used to store errors is resolved in this order:
|
|
43
|
+
|
|
44
|
+
1. `data-field="..."` attribute
|
|
45
|
+
2. `name="..."` attribute
|
|
46
|
+
3. `id="..."` attribute
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Built-in rules
|
|
51
|
+
|
|
52
|
+
| Class | Description | Error message |
|
|
53
|
+
|------------|-------------------------------------------|---------------------------------------|
|
|
54
|
+
| `req` | Field is required (trims whitespace) | `"{title}" is required` |
|
|
55
|
+
| `email` | Valid email address | `Invalid email address` |
|
|
56
|
+
| `int` | Integer (positive or negative) | `Invalid integer value` |
|
|
57
|
+
| `float` | Floating point number | `Invalid floating point value` |
|
|
58
|
+
| `decimal` | Decimal number | `Invalid decimal value` |
|
|
59
|
+
| `unsigned` | No negative sign allowed | `The value cannot be negative` |
|
|
60
|
+
| `nonzero` | Value must not be zero | `The value cannot be zero` |
|
|
61
|
+
|
|
62
|
+
> **`req` + whitespace** — a value of `" "` (spaces only) is treated as empty.
|
|
63
|
+
|
|
64
|
+
Multiple rules can be combined on a single input:
|
|
65
|
+
|
|
66
|
+
```html
|
|
67
|
+
<input class="req int unsigned" name="quantity" title="Quantity" />
|
|
68
|
+
<!-- required + must be integer + cannot be negative -->
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Directive: `x-validate`
|
|
74
|
+
|
|
75
|
+
Place on the `<form>` or any wrapper element that contains the validated inputs.
|
|
76
|
+
Event listeners are **delegated** to this root element — no per-input attributes needed.
|
|
77
|
+
|
|
78
|
+
### Modes
|
|
79
|
+
|
|
80
|
+
#### Submit-only (default)
|
|
81
|
+
|
|
82
|
+
Validation runs only when `$validate()` is called. No live feedback.
|
|
83
|
+
|
|
84
|
+
```html
|
|
85
|
+
<form x-data x-validate @submit.prevent="$validate() && save()">
|
|
86
|
+
<input class="req" name="title" title="Title" />
|
|
87
|
+
<span x-text="$store.validation.errors.title" class="text-red-500 text-sm"></span>
|
|
88
|
+
<button type="submit">Save</button>
|
|
89
|
+
</form>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### `live: 'input'` — validate on every keystroke
|
|
93
|
+
|
|
94
|
+
Error appears immediately as the user types, from the very first character.
|
|
95
|
+
Best for strict forms where instant feedback is expected.
|
|
96
|
+
|
|
97
|
+
```html
|
|
98
|
+
<form x-data x-validate="{ live: 'input' }" @submit.prevent="$validate() && save()">
|
|
99
|
+
<input class="req int" name="age" title="Age" />
|
|
100
|
+
<span x-text="$store.validation.errors.age" class="text-red-500 text-sm"></span>
|
|
101
|
+
</form>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
#### `live: true` — blur first, then live
|
|
105
|
+
|
|
106
|
+
Error appears only after the user leaves the field for the first time (`blur`).
|
|
107
|
+
After that, it updates in real time as the user corrects the value.
|
|
108
|
+
Recommended default — less aggressive, better UX.
|
|
109
|
+
|
|
110
|
+
```html
|
|
111
|
+
<form x-data x-validate="{ live: true }" @submit.prevent="$validate() && save()">
|
|
112
|
+
<input class="req email" name="email" title="Email" />
|
|
113
|
+
<span x-text="$store.validation.errors.email" class="text-red-500 text-sm"></span>
|
|
114
|
+
</form>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
#### `live: 'blur'` — blur only, no keystroke reaction
|
|
118
|
+
|
|
119
|
+
Error appears when the user leaves the field. Does not update while typing.
|
|
120
|
+
|
|
121
|
+
```html
|
|
122
|
+
<form x-data x-validate="{ live: 'blur' }" @submit.prevent="$validate() && save()">
|
|
123
|
+
<input class="req" name="username" title="Username" />
|
|
124
|
+
<span x-text="$store.validation.errors.username" class="text-red-500 text-sm"></span>
|
|
125
|
+
</form>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Mode comparison
|
|
129
|
+
|
|
130
|
+
| | `(none)` | `live: 'input'` | `live: true` | `live: 'blur'` |
|
|
131
|
+
|---|:---:|:---:|:---:|:---:|
|
|
132
|
+
| Error on first keystroke | ✗ | ✓ | ✗ | ✗ |
|
|
133
|
+
| Error on blur | ✗ | ✓ | ✓ | ✓ |
|
|
134
|
+
| Updates while typing after blur | ✗ | ✓ | ✓ | ✗ |
|
|
135
|
+
| Error on submit | ✓ | ✓ | ✓ | ✓ |
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Magic: `$validate()`
|
|
140
|
+
|
|
141
|
+
Runs full validation on the nearest `x-validate` root.
|
|
142
|
+
Clears previous errors, populates `$validation.errors`, focuses the first invalid field, and returns `true` or `false`.
|
|
143
|
+
|
|
144
|
+
```html
|
|
145
|
+
<!-- Inline in template -->
|
|
146
|
+
<button @click.prevent="$validate() && save()">Submit</button>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
// Inside Alpine.data()
|
|
151
|
+
Alpine.data('myForm', () => ({
|
|
152
|
+
submit() {
|
|
153
|
+
if (!this.$validate()) return; // errors are already reactive
|
|
154
|
+
fetch('/api/save', { method: 'POST', body: JSON.stringify(this.fields) });
|
|
155
|
+
},
|
|
156
|
+
}));
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Magic: `$validation`
|
|
162
|
+
|
|
163
|
+
Shorthand for `$store.validation`. Available inside any Alpine component.
|
|
164
|
+
|
|
165
|
+
### Display errors
|
|
166
|
+
|
|
167
|
+
```html
|
|
168
|
+
<!-- Error message for a specific field -->
|
|
169
|
+
<span x-text="$validation.errors.email" class="text-red-500 text-sm"></span>
|
|
170
|
+
|
|
171
|
+
<!-- Show/hide a block when a field has an error -->
|
|
172
|
+
<p x-show="$validation.errors.price" class="text-red-500 text-sm">
|
|
173
|
+
Please enter a valid price.
|
|
174
|
+
</p>
|
|
175
|
+
|
|
176
|
+
<!-- Global error banner -->
|
|
177
|
+
<div x-show="$validation.hasErrors" class="rounded bg-red-50 px-4 py-3 text-red-700">
|
|
178
|
+
Please fix the errors below before saving.
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<!-- Disable submit button while errors exist -->
|
|
182
|
+
<button :disabled="$validation.hasErrors" type="submit">Save</button>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Touched state
|
|
186
|
+
|
|
187
|
+
`touched[fieldKey]` becomes `true` after the user leaves the field at least once.
|
|
188
|
+
Useful to show errors only after interaction:
|
|
189
|
+
|
|
190
|
+
```html
|
|
191
|
+
<span x-show="$validation.touched.email && $validation.errors.email"
|
|
192
|
+
x-text="$validation.errors.email"
|
|
193
|
+
class="text-red-500 text-sm">
|
|
194
|
+
</span>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Clear errors
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
this.$validation.clearErrors(); // reset all errors and touched state
|
|
201
|
+
this.$validation.clearErrors('email'); // reset only the email field
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Store API: `$store.validation`
|
|
207
|
+
|
|
208
|
+
The same object as `$validation`, accessible from anywhere including outside Alpine components.
|
|
209
|
+
|
|
210
|
+
| Property / Method | Type | Description |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| `errors` | `Object` | Reactive map `{ fieldKey: 'error message' }` |
|
|
213
|
+
| `touched` | `Object` | Reactive map `{ fieldKey: true }` |
|
|
214
|
+
| `hasErrors` | `boolean` (getter) | `true` if `errors` has at least one key |
|
|
215
|
+
| `touch(fieldKey, el)` | `void` | Mark field as touched and validate it |
|
|
216
|
+
| `validateField(fieldKey, el)` | `void` | Validate one field, update `errors[fieldKey]` |
|
|
217
|
+
| `validate(root?)` | `boolean` | Validate all fields inside `root`, return pass/fail |
|
|
218
|
+
| `clearErrors(field?)` | `void` | Clear errors (and touched) for one field or all |
|
|
219
|
+
| `addRules(...ruleSets)` | `void` | Add or overwrite rules |
|
|
220
|
+
| `delRules(...classNames)` | `void` | Remove rules by class name |
|
|
221
|
+
| `flushRules()` | `void` | Remove all rules |
|
|
222
|
+
| `addCheck(fn)` | `void` | Register a global error suppressor |
|
|
223
|
+
| `delCheck()` | `void` | Remove the suppressor |
|
|
224
|
+
| `toString()` | `string` | Debug summary of all registered rules |
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Adding custom rules
|
|
229
|
+
|
|
230
|
+
Register custom rules in `app.js` before `Alpine.start()`:
|
|
231
|
+
|
|
232
|
+
```js
|
|
233
|
+
Alpine.plugin(AlpineValidation);
|
|
234
|
+
|
|
235
|
+
Alpine.store('validation').addRules({
|
|
236
|
+
// Regexp rule
|
|
237
|
+
phone: {
|
|
238
|
+
regexp: /^\+?\d{10,}$/,
|
|
239
|
+
msg: 'Please enter a valid phone number',
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
// Function rule — return truthy to trigger an error
|
|
243
|
+
future: {
|
|
244
|
+
func: (el) => new Date(el.value) <= new Date(),
|
|
245
|
+
msg: 'Date must be in the future',
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
// URL
|
|
249
|
+
url: {
|
|
250
|
+
regexp: /^https?:\/\/.+\..+/,
|
|
251
|
+
msg: 'Please enter a valid URL (must start with http:// or https://)',
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
// Slug
|
|
255
|
+
slug: {
|
|
256
|
+
regexp: /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
257
|
+
msg: 'Only lowercase letters, numbers and hyphens allowed',
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
Alpine.start();
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```html
|
|
265
|
+
<input class="req phone" name="phone" title="Phone number" />
|
|
266
|
+
<span x-text="$validation.errors.phone" class="text-red-500 text-sm"></span>
|
|
267
|
+
|
|
268
|
+
<input class="req future" name="event_date" title="Event date" type="date" />
|
|
269
|
+
<span x-text="$validation.errors.event_date" class="text-red-500 text-sm"></span>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Removing rules
|
|
275
|
+
|
|
276
|
+
### `delRules(...classNames)` — remove specific rules
|
|
277
|
+
|
|
278
|
+
Use when you want to disable one or more built-in rules for your project,
|
|
279
|
+
or remove a custom rule that is no longer needed.
|
|
280
|
+
|
|
281
|
+
```js
|
|
282
|
+
// Remove a single built-in rule
|
|
283
|
+
Alpine.store('validation').delRules('nonzero');
|
|
284
|
+
|
|
285
|
+
// Remove multiple rules at once
|
|
286
|
+
Alpine.store('validation').delRules('unsigned', 'nonzero', 'float');
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
A practical example — your app only works with text fields, so numeric rules are unnecessary:
|
|
290
|
+
|
|
291
|
+
```js
|
|
292
|
+
Alpine.plugin(AlpineValidation);
|
|
293
|
+
|
|
294
|
+
// Strip out all numeric rules, keep only req and email
|
|
295
|
+
Alpine.store('validation').delRules('int', 'float', 'decimal', 'unsigned', 'nonzero');
|
|
296
|
+
|
|
297
|
+
Alpine.start();
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Another example — remove a custom rule dynamically based on user role:
|
|
301
|
+
|
|
302
|
+
```js
|
|
303
|
+
if (window.currentUser?.isAdmin) {
|
|
304
|
+
// Admins are not required to fill in the phone field
|
|
305
|
+
Alpine.store('validation').delRules('phone');
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### `flushRules()` — remove all rules
|
|
310
|
+
|
|
311
|
+
Wipes the entire rule set. Useful when you want to start from scratch
|
|
312
|
+
and register only your own custom rules:
|
|
313
|
+
|
|
314
|
+
```js
|
|
315
|
+
Alpine.plugin(AlpineValidation);
|
|
316
|
+
|
|
317
|
+
// Remove every built-in rule
|
|
318
|
+
Alpine.store('validation').flushRules();
|
|
319
|
+
|
|
320
|
+
// Register only what your app actually needs
|
|
321
|
+
Alpine.store('validation').addRules({
|
|
322
|
+
req: { req: true },
|
|
323
|
+
email: { regexp: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, msg: 'Invalid email address' },
|
|
324
|
+
phone: { regexp: /^\+?\d{10,}$/, msg: 'Invalid phone number' },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
Alpine.start();
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## Global error suppressor
|
|
333
|
+
|
|
334
|
+
`addCheck` registers a function called after every rule failure.
|
|
335
|
+
If it returns `true`, the error is suppressed and the field is treated as valid.
|
|
336
|
+
|
|
337
|
+
```js
|
|
338
|
+
// Skip validation for fields that are currently hidden
|
|
339
|
+
Alpine.store('validation').addCheck((el, rule) => {
|
|
340
|
+
return el.closest('[x-show]') !== null && el.offsetParent === null;
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
```js
|
|
345
|
+
// Allow admins to bypass required fields
|
|
346
|
+
Alpine.store('validation').addCheck((el, rule) => {
|
|
347
|
+
return rule.req && window.currentUser?.isAdmin === true;
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Remove it when no longer needed:
|
|
352
|
+
|
|
353
|
+
```js
|
|
354
|
+
Alpine.store('validation').delCheck();
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## Error label resolution for `req`
|
|
360
|
+
|
|
361
|
+
The error message includes the field label when it can be found.
|
|
362
|
+
Resolution order:
|
|
363
|
+
|
|
364
|
+
1. `title="..."` attribute on the input
|
|
365
|
+
2. Text of `<label for="fieldId">` found in the DOM
|
|
366
|
+
3. Fallback generic message
|
|
367
|
+
|
|
368
|
+
```html
|
|
369
|
+
<!-- Uses title attribute -->
|
|
370
|
+
<input class="req" name="city" title="City" />
|
|
371
|
+
<!-- Error: "City is required" -->
|
|
372
|
+
|
|
373
|
+
<!-- Uses associated <label> -->
|
|
374
|
+
<label for="country">Country</label>
|
|
375
|
+
<input class="req" id="country" name="country" />
|
|
376
|
+
<!-- Error: "Country is required" -->
|
|
377
|
+
|
|
378
|
+
<!-- Fallback -->
|
|
379
|
+
<input class="req" name="x" />
|
|
380
|
+
<!-- Error: "Please fill in the required fields." -->
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## Manual touch (without x-validate live mode)
|
|
386
|
+
|
|
387
|
+
If you cannot use the directive's live mode, you can attach events manually:
|
|
388
|
+
|
|
389
|
+
```html
|
|
390
|
+
<input
|
|
391
|
+
name="title"
|
|
392
|
+
class="req"
|
|
393
|
+
@blur="$validation.touch('title', $el)"
|
|
394
|
+
@input="$validation.touched.title && $validation.touch('title', $el)"
|
|
395
|
+
/>
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Or trigger validation programmatically from JS:
|
|
399
|
+
|
|
400
|
+
```js
|
|
401
|
+
const el = document.getElementById('price');
|
|
402
|
+
this.$validation.validateField('price', el);
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## Full example (Laravel Blade)
|
|
408
|
+
|
|
409
|
+
```html
|
|
410
|
+
<div x-data="albumForm()" class="space-y-6 p-6">
|
|
411
|
+
<form x-validate="{ live: true }" @submit.prevent="submit">
|
|
412
|
+
@csrf
|
|
413
|
+
|
|
414
|
+
{{-- Title --}}
|
|
415
|
+
<div class="mb-4">
|
|
416
|
+
<label for="title" class="block text-sm font-bold text-gray-700">
|
|
417
|
+
{{ __('Album Title') }} <span class="text-red-500">*</span>
|
|
418
|
+
</label>
|
|
419
|
+
<input id="title" name="title" class="req"
|
|
420
|
+
x-model="title" type="text" title="{{ __('Album Title') }}"
|
|
421
|
+
class="w-full rounded border border-gray-300 px-3 py-2 text-sm" />
|
|
422
|
+
<p x-show="$validation.errors.title"
|
|
423
|
+
x-text="$validation.errors.title"
|
|
424
|
+
class="mt-1 text-sm text-red-500"></p>
|
|
425
|
+
@error('title')
|
|
426
|
+
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
|
427
|
+
@enderror
|
|
428
|
+
</div>
|
|
429
|
+
|
|
430
|
+
{{-- Release year --}}
|
|
431
|
+
<div class="mb-4">
|
|
432
|
+
<label for="year" class="block text-sm font-bold text-gray-700">
|
|
433
|
+
{{ __('Release Year') }}
|
|
434
|
+
</label>
|
|
435
|
+
<input id="year" name="year" class="int unsigned"
|
|
436
|
+
x-model="year" type="text" title="{{ __('Release Year') }}"
|
|
437
|
+
class="w-full rounded border border-gray-300 px-3 py-2 text-sm" />
|
|
438
|
+
<p x-show="$validation.errors.year"
|
|
439
|
+
x-text="$validation.errors.year"
|
|
440
|
+
class="mt-1 text-sm text-red-500"></p>
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
{{-- Global error banner --}}
|
|
444
|
+
<div x-show="$validation.hasErrors"
|
|
445
|
+
class="mb-4 rounded bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
446
|
+
{{ __('Please fix the errors above before saving.') }}
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
<button type="submit"
|
|
450
|
+
:disabled="$validation.hasErrors"
|
|
451
|
+
class="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50">
|
|
452
|
+
{{ __('Save') }}
|
|
453
|
+
</button>
|
|
454
|
+
</form>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<script>
|
|
458
|
+
Alpine.data('albumForm', () => ({
|
|
459
|
+
title: @json(old('title', $album->title ?? '')),
|
|
460
|
+
year: @json(old('year', $album->year ?? '')),
|
|
461
|
+
|
|
462
|
+
submit() {
|
|
463
|
+
this.title = this.title.trim();
|
|
464
|
+
if (!this.$validate()) return;
|
|
465
|
+
this.$el.closest('form').submit();
|
|
466
|
+
},
|
|
467
|
+
}));
|
|
468
|
+
</script>
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
## Debugging
|
|
474
|
+
|
|
475
|
+
```js
|
|
476
|
+
// Print all registered rules
|
|
477
|
+
console.log(Alpine.store('validation').toString());
|
|
478
|
+
|
|
479
|
+
// Inspect current errors
|
|
480
|
+
console.log(Alpine.store('validation').errors);
|
|
481
|
+
|
|
482
|
+
// Inspect which fields have been touched
|
|
483
|
+
console.log(Alpine.store('validation').touched);
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
```html
|
|
487
|
+
<!-- Render errors inline for debugging -->
|
|
488
|
+
<pre x-text="JSON.stringify($store.validation.errors, null, 2)"></pre>
|
|
489
|
+
<pre x-text="JSON.stringify($store.validation.touched, null, 2)"></pre>
|
|
490
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "alpine-validation-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight class-based form validation plugin for Alpine.js v3",
|
|
5
|
+
"main": "src/validation-alpine.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"alpinejs",
|
|
9
|
+
"alpine",
|
|
10
|
+
"alpine-plugin",
|
|
11
|
+
"validation",
|
|
12
|
+
"form-validation",
|
|
13
|
+
"form",
|
|
14
|
+
"plugin"
|
|
15
|
+
],
|
|
16
|
+
"author": "",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"alpinejs": ">=3.0.0"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/tmjaga/alpine-validation-plugin.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/tmjaga/alpine-validation-plugin/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/tmjaga/alpine-validation-plugin#readme"
|
|
29
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alpine.js Validation Plugin
|
|
3
|
+
* Converted from com.opencode.Validation (jQuery)
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* 1. Register the plugin before Alpine.start():
|
|
7
|
+
* Alpine.plugin(AlpineValidation)
|
|
8
|
+
*
|
|
9
|
+
* 2. Add x-validate to your form (and x-data on the same or a parent element).
|
|
10
|
+
*
|
|
11
|
+
* 3. Add CSS class names matching the built-in rules to your inputs.
|
|
12
|
+
* Field errors are keyed by the input's name / id / data-field attribute.
|
|
13
|
+
*
|
|
14
|
+
* Quick example:
|
|
15
|
+
* <form x-data x-validate="{ live: true }" @submit.prevent="$validate() && save()">
|
|
16
|
+
* <input class="req email" name="email" title="Email" />
|
|
17
|
+
* <span x-text="$store.validation.errors.email"></span>
|
|
18
|
+
* <button type="submit">Save</button>
|
|
19
|
+
* </form>
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const AlpineValidation = (Alpine) => {
|
|
23
|
+
|
|
24
|
+
// ── Built-in rules ───────────────────────────────────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
// Each rule is identified by a CSS class name applied to the input element.
|
|
27
|
+
// A rule object may contain:
|
|
28
|
+
// req {boolean} — field is required (whitespace-only counts as empty)
|
|
29
|
+
// regexp {RegExp} — value must match this pattern
|
|
30
|
+
// func {Function} — custom check: receives the element, returns truthy on error
|
|
31
|
+
// msg {string} — error message shown when regexp or func fails
|
|
32
|
+
//
|
|
33
|
+
let rules = {
|
|
34
|
+
req: { req: true },
|
|
35
|
+
float: { regexp: /^[-+]?\d*\.?\d+$/, msg: 'Invalid floating point value' },
|
|
36
|
+
int: { regexp: /^[-+]?\d+$/, msg: 'Invalid integer value' },
|
|
37
|
+
unsigned: { regexp: /^[^-]*$/, msg: 'The value cannot be negative' },
|
|
38
|
+
nonzero: { func: (el) => parseFloat(el.value) === 0, msg: 'The value cannot be zero' },
|
|
39
|
+
decimal: { regexp: /^[-+]?((\d+(\.\d+)?)|(\.\d+))$/, msg: 'Invalid decimal value' },
|
|
40
|
+
email: { regexp: /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/, msg: 'Invalid email address' },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
let additionalCheck = null;
|
|
44
|
+
|
|
45
|
+
// ── Internal helper ──────────────────────────────────────────────────────────
|
|
46
|
+
// Runs all matching rules against a single element.
|
|
47
|
+
// Returns the first error message string, or null if the field is valid.
|
|
48
|
+
function checkElement(el) {
|
|
49
|
+
const value = el.value;
|
|
50
|
+
|
|
51
|
+
for (const [className, rule] of Object.entries(rules)) {
|
|
52
|
+
if (!el.classList.contains(className)) continue;
|
|
53
|
+
|
|
54
|
+
let error = null;
|
|
55
|
+
|
|
56
|
+
if (!value || (rule.req && !value.trim())) {
|
|
57
|
+
// req rule: whitespace-only value is treated as empty
|
|
58
|
+
if (rule.req) {
|
|
59
|
+
const labelText =
|
|
60
|
+
el.title ||
|
|
61
|
+
(el.id && document.querySelector(`label[for="${el.id}"]`)?.textContent?.trim());
|
|
62
|
+
error = labelText ? `${labelText} is required` : 'Please fill in the required fields.';
|
|
63
|
+
}
|
|
64
|
+
} else if (rule.regexp && !rule.regexp.test(value)) {
|
|
65
|
+
error = rule.msg || 'Invalid value.';
|
|
66
|
+
} else if (rule.func) {
|
|
67
|
+
const result = rule.func(el);
|
|
68
|
+
if (result) error = rule.msg || result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (error) {
|
|
72
|
+
// additionalCheck can suppress the error by returning true
|
|
73
|
+
if (additionalCheck && additionalCheck(el, rule)) continue;
|
|
74
|
+
return error; // stop at first error per field
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Alpine store: $store.validation ─────────────────────────────────────────
|
|
82
|
+
Alpine.store('validation', {
|
|
83
|
+
|
|
84
|
+
/** Reactive map of field errors: { fieldKey: 'Error message', ... } */
|
|
85
|
+
errors: {},
|
|
86
|
+
|
|
87
|
+
/** Tracks which fields the user has already interacted with (touched). */
|
|
88
|
+
touched: {},
|
|
89
|
+
|
|
90
|
+
/** True when at least one error is present. */
|
|
91
|
+
get hasErrors() {
|
|
92
|
+
return Object.keys(this.errors).length > 0;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// ── Live validation ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Mark a field as touched and validate it immediately.
|
|
99
|
+
*
|
|
100
|
+
* Called automatically by the x-validate directive when live mode is active.
|
|
101
|
+
* Can also be called manually when you need fine-grained control:
|
|
102
|
+
*
|
|
103
|
+
* <input name="title"
|
|
104
|
+
* @blur="$validation.touch('title', $el)"
|
|
105
|
+
* @input="$validation.touched.title && $validation.touch('title', $el)" />
|
|
106
|
+
*
|
|
107
|
+
* @param {string} fieldKey — name / id / data-field value of the input
|
|
108
|
+
* @param {Element} el — the input element
|
|
109
|
+
*/
|
|
110
|
+
touch(fieldKey, el) {
|
|
111
|
+
this.touched[fieldKey] = true;
|
|
112
|
+
this.validateField(fieldKey, el);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validate a single field and update errors[fieldKey].
|
|
117
|
+
* Useful when you want to re-check one field programmatically:
|
|
118
|
+
*
|
|
119
|
+
* this.$validation.validateField('price', document.getElementById('price'));
|
|
120
|
+
*
|
|
121
|
+
* @param {string} fieldKey
|
|
122
|
+
* @param {Element} el
|
|
123
|
+
*/
|
|
124
|
+
validateField(fieldKey, el) {
|
|
125
|
+
delete this.errors[fieldKey];
|
|
126
|
+
const error = checkElement(el);
|
|
127
|
+
if (error) this.errors[fieldKey] = error;
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// ── Full validation (submit) ───────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate all rule-bearing fields inside root.
|
|
134
|
+
* Clears previous errors, populates errors{} with all failures,
|
|
135
|
+
* focuses the first invalid field, and returns true/false.
|
|
136
|
+
*
|
|
137
|
+
* Called automatically by $validate(). Rarely needed directly.
|
|
138
|
+
*
|
|
139
|
+
* @param {Element} root — defaults to document
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
validate(root = document) {
|
|
143
|
+
// Clear all previous errors
|
|
144
|
+
Object.keys(this.errors).forEach((k) => delete this.errors[k]);
|
|
145
|
+
|
|
146
|
+
let valid = true;
|
|
147
|
+
let firstInvalid = null;
|
|
148
|
+
|
|
149
|
+
for (const [className] of Object.entries(rules)) {
|
|
150
|
+
root.querySelectorAll(`.${className}`).forEach((el) => {
|
|
151
|
+
const fieldKey = el.dataset.field || el.name || el.id || null;
|
|
152
|
+
const error = checkElement(el);
|
|
153
|
+
|
|
154
|
+
if (error) {
|
|
155
|
+
// Keep the first error per field key
|
|
156
|
+
if (fieldKey && !this.errors[fieldKey]) this.errors[fieldKey] = error;
|
|
157
|
+
if (!firstInvalid) firstInvalid = el;
|
|
158
|
+
valid = false;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Focus the first invalid field; switch tab if a tabber is present
|
|
164
|
+
if (firstInvalid) {
|
|
165
|
+
const tabber = root.querySelector('#element-tabber');
|
|
166
|
+
if (tabber) {
|
|
167
|
+
const tab = firstInvalid.closest('.tabbertab');
|
|
168
|
+
if (tab) {
|
|
169
|
+
const tabs = [...tabber.querySelectorAll(':scope > .tabbertab')];
|
|
170
|
+
const index = tabs.indexOf(tab);
|
|
171
|
+
if (index !== -1 && tabber._tabber) tabber._tabber.tabShow(index);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
firstInvalid.focus();
|
|
175
|
+
if (typeof firstInvalid.select === 'function') firstInvalid.select();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return valid;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
// ── Error management ──────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Clear errors (and touched state) for one field or all fields.
|
|
185
|
+
*
|
|
186
|
+
* $validation.clearErrors() // clear everything
|
|
187
|
+
* $validation.clearErrors('email') // clear only email
|
|
188
|
+
*
|
|
189
|
+
* @param {string|null} field
|
|
190
|
+
*/
|
|
191
|
+
clearErrors(field = null) {
|
|
192
|
+
if (field) {
|
|
193
|
+
delete this.errors[field];
|
|
194
|
+
delete this.touched[field];
|
|
195
|
+
} else {
|
|
196
|
+
Object.keys(this.errors).forEach((k) => delete this.errors[k]);
|
|
197
|
+
Object.keys(this.touched).forEach((k) => delete this.touched[k]);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
// ── Rule management ───────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Add or overwrite one or more rules.
|
|
205
|
+
*
|
|
206
|
+
* $store.validation.addRules({
|
|
207
|
+
* phone: { regexp: /^\+?\d{10,}$/, msg: 'Invalid phone number' },
|
|
208
|
+
* slug: { regexp: /^[a-z0-9-]+$/, msg: 'Only lowercase letters, numbers and hyphens' },
|
|
209
|
+
* });
|
|
210
|
+
*
|
|
211
|
+
* @param {...Object} ruleSets
|
|
212
|
+
*/
|
|
213
|
+
addRules(...ruleSets) {
|
|
214
|
+
ruleSets.forEach((set) => Object.assign(rules, set));
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Remove rules by class name.
|
|
219
|
+
*
|
|
220
|
+
* $store.validation.delRules('nonzero', 'unsigned');
|
|
221
|
+
*
|
|
222
|
+
* @param {...string} classNames
|
|
223
|
+
*/
|
|
224
|
+
delRules(...classNames) {
|
|
225
|
+
classNames.forEach((name) => delete rules[name]);
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
/** Remove all rules. */
|
|
229
|
+
flushRules() {
|
|
230
|
+
rules = {};
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Register a global additional check.
|
|
235
|
+
* Called after a rule fails; if it returns true the error is suppressed.
|
|
236
|
+
*
|
|
237
|
+
* $store.validation.addCheck((el, rule) => {
|
|
238
|
+
* // e.g. skip validation for hidden fields
|
|
239
|
+
* return el.closest('[hidden]') !== null;
|
|
240
|
+
* });
|
|
241
|
+
*
|
|
242
|
+
* @param {Function} check — (element, rule) => boolean
|
|
243
|
+
*/
|
|
244
|
+
addCheck(check) {
|
|
245
|
+
if (typeof check !== 'function') throw new Error('The check is not a function');
|
|
246
|
+
if (check.length !== 2) throw new Error('The check function must have two parameters');
|
|
247
|
+
additionalCheck = check;
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
/** Remove the global additional check. */
|
|
251
|
+
delCheck() {
|
|
252
|
+
additionalCheck = null;
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
/** Return a human-readable summary of all registered rules (useful for debugging). */
|
|
256
|
+
toString() {
|
|
257
|
+
return Object.entries(rules)
|
|
258
|
+
.map(([className, rule]) => {
|
|
259
|
+
const parts = [];
|
|
260
|
+
if (rule.req) parts.push('Required');
|
|
261
|
+
if (rule.regexp) parts.push(`RegExp: ${rule.regexp}`);
|
|
262
|
+
if (rule.func) parts.push('Has check function');
|
|
263
|
+
if (rule.msg) parts.push(`Error Message: ${rule.msg}`);
|
|
264
|
+
return `.${className}: ${parts.join('; ')}`;
|
|
265
|
+
})
|
|
266
|
+
.join('\n');
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ── x-validate directive ──────────────────────────────────────────────────────
|
|
271
|
+
//
|
|
272
|
+
// Place on the form (or any wrapper element) that contains the validated inputs.
|
|
273
|
+
//
|
|
274
|
+
// Modes:
|
|
275
|
+
// x-validate — submit-only validation
|
|
276
|
+
// x-validate="{ live: true }" — validate on blur + re-validate on every keystroke
|
|
277
|
+
// x-validate="{ live: 'blur' }" — validate on blur only (no keystroke re-validation)
|
|
278
|
+
//
|
|
279
|
+
// Event listeners are delegated to the root element, so no per-input attributes needed.
|
|
280
|
+
//
|
|
281
|
+
Alpine.directive('validate', (el, { expression }, { evaluateLater, effect, cleanup }) => {
|
|
282
|
+
el._alpineValidationRoot = el;
|
|
283
|
+
|
|
284
|
+
if (!expression) return;
|
|
285
|
+
|
|
286
|
+
const store = Alpine.store('validation');
|
|
287
|
+
const getOptions = evaluateLater(expression);
|
|
288
|
+
let live = false;
|
|
289
|
+
|
|
290
|
+
const onBlur = (e) => {
|
|
291
|
+
const target = e.target;
|
|
292
|
+
// Ignore elements that don't carry any validation rule class
|
|
293
|
+
if (!Object.keys(rules).some((c) => target.classList.contains(c))) return;
|
|
294
|
+
const field = target.dataset.field || target.name || target.id;
|
|
295
|
+
if (!field) return;
|
|
296
|
+
store.touch(field, target);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const onInput = (e) => {
|
|
300
|
+
if (live === 'blur') return; // blur-only mode — ignore keystrokes
|
|
301
|
+
const target = e.target;
|
|
302
|
+
if (!Object.keys(rules).some((c) => target.classList.contains(c))) return;
|
|
303
|
+
const field = target.dataset.field || target.name || target.id;
|
|
304
|
+
if (!field) return;
|
|
305
|
+
// live: true — validate only after the field was blurred at least once
|
|
306
|
+
// live: 'input' — validate immediately on every keystroke
|
|
307
|
+
if (live !== 'input' && !store.touched[field]) return;
|
|
308
|
+
store.validateField(field, target);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
effect(() => {
|
|
312
|
+
getOptions((options) => {
|
|
313
|
+
live = options?.live ?? false;
|
|
314
|
+
if (live) {
|
|
315
|
+
// Delegate to root — no need to attach listeners to individual inputs
|
|
316
|
+
el.addEventListener('blur', onBlur, { capture: true });
|
|
317
|
+
el.addEventListener('input', onInput, { capture: true });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Remove listeners when the component is destroyed
|
|
323
|
+
cleanup(() => {
|
|
324
|
+
el.removeEventListener('blur', onBlur, { capture: true });
|
|
325
|
+
el.removeEventListener('input', onInput, { capture: true });
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ── $validate() magic ─────────────────────────────────────────────────────────
|
|
330
|
+
//
|
|
331
|
+
// Walks up the DOM to find the nearest x-validate root, then runs full validation.
|
|
332
|
+
// Returns true if all fields pass, false otherwise.
|
|
333
|
+
//
|
|
334
|
+
// Usage in templates:
|
|
335
|
+
// <button @click.prevent="$validate() && save()">Submit</button>
|
|
336
|
+
//
|
|
337
|
+
// Usage in Alpine.data components:
|
|
338
|
+
// submit() {
|
|
339
|
+
// if (!this.$validate()) return;
|
|
340
|
+
// // ... send data
|
|
341
|
+
// }
|
|
342
|
+
//
|
|
343
|
+
Alpine.magic('validate', (el) => {
|
|
344
|
+
return () => {
|
|
345
|
+
let root = el;
|
|
346
|
+
while (root && !root.hasAttribute('x-validate')) {
|
|
347
|
+
root = root.parentElement;
|
|
348
|
+
}
|
|
349
|
+
return Alpine.store('validation').validate(root || document);
|
|
350
|
+
};
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// ── $validation magic ─────────────────────────────────────────────────────────
|
|
354
|
+
//
|
|
355
|
+
// Shorthand for $store.validation. Available inside any Alpine component.
|
|
356
|
+
//
|
|
357
|
+
// Usage:
|
|
358
|
+
// $validation.errors.email — error message for the email field
|
|
359
|
+
// $validation.hasErrors — true if any error is present
|
|
360
|
+
// $validation.touched.email — true if email field was blurred at least once
|
|
361
|
+
// $validation.clearErrors() — reset all errors and touched state
|
|
362
|
+
// $validation.clearErrors('email')— reset only the email field
|
|
363
|
+
//
|
|
364
|
+
Alpine.magic('validation', () => Alpine.store('validation'));
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// ── Export ────────────────────────────────────────────────────────────────────
|
|
368
|
+
export default AlpineValidation;
|
|
369
|
+
|
|
370
|
+
// CDN / global <script> fallback — auto-registers when Alpine is present
|
|
371
|
+
if (typeof window !== 'undefined') {
|
|
372
|
+
window.AlpineValidation = AlpineValidation;
|
|
373
|
+
document.addEventListener('alpine:init', () => {
|
|
374
|
+
if (window.Alpine) window.Alpine.plugin(AlpineValidation);
|
|
375
|
+
});
|
|
376
|
+
}
|