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 ADDED
@@ -0,0 +1,94 @@
1
+ # alpine-validation-plugin
2
+
3
+ ![license](https://img.shields.io/github/license/tmjaga/alpine-validation-plugin)
4
+ ![alpine](https://img.shields.io/badge/alpine.js-v3-blue)
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
+ }