├── .gitignore ├── scripts └── build.js ├── eslint.config.js ├── LICENSE ├── package.json ├── src ├── index.js └── checkInput.js ├── dist ├── validation.esm.js └── validation.min.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # misc 9 | .DS_Store 10 | *.pem 11 | 12 | # debug 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # local env files 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | # test 24 | index.html 25 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | import { buildSync } from 'esbuild' 2 | 3 | buildPlugin({ 4 | entryPoints: ['builds/cdn.js'], 5 | outfile: 'dist/validation.min.js', 6 | }) 7 | 8 | buildPlugin({ 9 | entryPoints: ['builds/module.js'], 10 | outfile: 'dist/validation.esm.js', 11 | platform: 'neutral', 12 | mainFields: ['main', 'module'], 13 | }) 14 | 15 | function buildPlugin(buildOptions) { 16 | return buildSync({ 17 | ...buildOptions, 18 | minify: true, 19 | bundle: true, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | 3 | export default [ 4 | js.configs.recommended, 5 | { 6 | languageOptions: { 7 | ecmaVersion: 2022, 8 | sourceType: 'module', 9 | globals: { 10 | Alpine: 'readonly', 11 | console: 'readonly', 12 | document: 'readonly', 13 | process: 'readonly', 14 | window: 'readonly', 15 | }, 16 | }, 17 | files: ['src/**/*.js', 'builds/**/*.js', 'scripts/**/*.js'], 18 | }, 19 | { 20 | ignores: ['dist/**', 'node_modules/**'], 21 | }, 22 | ] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mark Mead 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alpinejs-form-validation", 3 | "version": "1.2.2", 4 | "type": "module", 5 | "description": "Lightweight, CSS-driven form validation plugin for Alpine.js with real-time validation and data attributes", 6 | "keywords": [ 7 | "alpinejs", 8 | "form-validation", 9 | "css-validation", 10 | "real-time", 11 | "lightweight" 12 | ], 13 | "author": "Mark Mead", 14 | "license": "MIT", 15 | "homepage": "https://github.com/markmead/alpinejs-form-validation#readme", 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/markmead/alpinejs-form-validation.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/markmead/alpinejs-form-validation/issues" 22 | }, 23 | "main": "dist/validation.esm.js", 24 | "module": "dist/validation.esm.js", 25 | "unpkg": "dist/validation.min.js", 26 | "files": [ 27 | "dist/", 28 | "src/", 29 | "builds/" 30 | ], 31 | "scripts": { 32 | "build": "node scripts/build.js", 33 | "lint": "eslint . --fix", 34 | "prebuild": "npm run lint" 35 | }, 36 | "devDependencies": { 37 | "@eslint/js": "^9.39.2", 38 | "esbuild": "^0.27.1", 39 | "eslint": "^9.39.2" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { checkInput } from './checkInput' 2 | 3 | export default function (Alpine) { 4 | Alpine.directive( 5 | 'validation', 6 | (el, { modifiers, expression }, { evaluateLater, effect }) => { 7 | effect(() => checkInput(el, modifiers, expression, evaluateLater)) 8 | 9 | const handleValidate = (validateEvent) => { 10 | const { target } = validateEvent 11 | 12 | if (!target.contains(el)) { 13 | return 14 | } 15 | 16 | checkInput(el, modifiers, expression, evaluateLater, true) 17 | } 18 | 19 | // Clear validation data attributes on form reset 20 | const handleReset = (resetEvent) => { 21 | const { target } = resetEvent 22 | 23 | if (!target.contains(el)) { 24 | return 25 | } 26 | 27 | const validationAttributes = [ 28 | 'data-validation-dirty', 29 | 'data-validation-valid', 30 | 'data-validation-reason', 31 | 'data-validation-status', 32 | 'data-validation-options', 33 | ] 34 | 35 | validationAttributes.forEach((htmlAttribute) => 36 | el.removeAttribute(htmlAttribute) 37 | ) 38 | } 39 | 40 | document.addEventListener('validate', handleValidate) 41 | document.addEventListener('reset', handleReset) 42 | 43 | return () => { 44 | document.removeEventListener('validate', handleValidate) 45 | document.removeEventListener('reset', handleReset) 46 | } 47 | } 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /dist/validation.esm.js: -------------------------------------------------------------------------------- 1 | function g(c,i,l,u,m=!1){let d=t=>i.includes(t),v=t=>{let r=i.indexOf(t);if(r===-1||r+1>=i.length)return null;let e=Number(i[r+1]);return isNaN(e)?null:e},o=(t,r)=>{if(!d(t))return null;let e=v(t);return e!==null?{[r]:e}:null},s=u(l),a=Object.fromEntries(Object.entries({...d("required")&&{required:!0},...o("min","min"),...o("max","max"),...o("min:length","minLength"),...o("max:length","maxLength"),...d("checked")&&{checked:!0}}).filter(([,t])=>t!=null));s(t=>{if(!m&&(t==null||t==="")&&!c.getAttribute("data-validation-dirty"))return;let r={required:()=>!!t,min:()=>{let n=Number(t);return!isNaN(n)&&n>=a.min},max:()=>{let n=Number(t);return!isNaN(n)&&n<=a.max},minLength:()=>String(t||"").length>=a.minLength,maxLength:()=>String(t||"").length<=a.maxLength,checked:()=>!!t},e={};for(let[n]of Object.entries(a))r[n]&&(e[n]=r[n]());let h=Object.values(e).every(Boolean),b=Object.keys(e).find(n=>!e[n]),x={"data-validation-dirty":!0,"data-validation-valid":h,"data-validation-reason":b||"","data-validation-status":JSON.stringify(e),"data-validation-options":JSON.stringify(a)};Object.entries(x).forEach(([n,N])=>{c.setAttribute(n,N)})})}function f(c){c.directive("validation",(i,{modifiers:l,expression:u},{evaluateLater:m,effect:d})=>{d(()=>g(i,l,u,m));let v=s=>{let{target:a}=s;a.contains(i)&&g(i,l,u,m,!0)},o=s=>{let{target:a}=s;if(!a.contains(i))return;["data-validation-dirty","data-validation-valid","data-validation-reason","data-validation-status","data-validation-options"].forEach(r=>i.removeAttribute(r))};return document.addEventListener("validate",v),document.addEventListener("reset",o),()=>{document.removeEventListener("validate",v),document.removeEventListener("reset",o)}})}var k=f;export{k as default}; 2 | -------------------------------------------------------------------------------- /dist/validation.min.js: -------------------------------------------------------------------------------- 1 | (()=>{function g(c,i,l,u,m=!1){let d=t=>i.includes(t),v=t=>{let r=i.indexOf(t);if(r===-1||r+1>=i.length)return null;let e=Number(i[r+1]);return isNaN(e)?null:e},o=(t,r)=>{if(!d(t))return null;let e=v(t);return e!==null?{[r]:e}:null},s=u(l),a=Object.fromEntries(Object.entries({...d("required")&&{required:!0},...o("min","min"),...o("max","max"),...o("min:length","minLength"),...o("max:length","maxLength"),...d("checked")&&{checked:!0}}).filter(([,t])=>t!=null));s(t=>{if(!m&&(t==null||t==="")&&!c.getAttribute("data-validation-dirty"))return;let r={required:()=>!!t,min:()=>{let n=Number(t);return!isNaN(n)&&n>=a.min},max:()=>{let n=Number(t);return!isNaN(n)&&n<=a.max},minLength:()=>String(t||"").length>=a.minLength,maxLength:()=>String(t||"").length<=a.maxLength,checked:()=>!!t},e={};for(let[n]of Object.entries(a))r[n]&&(e[n]=r[n]());let f=Object.values(e).every(Boolean),b=Object.keys(e).find(n=>!e[n]),x={"data-validation-dirty":!0,"data-validation-valid":f,"data-validation-reason":b||"","data-validation-status":JSON.stringify(e),"data-validation-options":JSON.stringify(a)};Object.entries(x).forEach(([n,N])=>{c.setAttribute(n,N)})})}function h(c){c.directive("validation",(i,{modifiers:l,expression:u},{evaluateLater:m,effect:d})=>{d(()=>g(i,l,u,m));let v=s=>{let{target:a}=s;a.contains(i)&&g(i,l,u,m,!0)},o=s=>{let{target:a}=s;if(!a.contains(i))return;["data-validation-dirty","data-validation-valid","data-validation-reason","data-validation-status","data-validation-options"].forEach(r=>i.removeAttribute(r))};return document.addEventListener("validate",v),document.addEventListener("reset",o),()=>{document.removeEventListener("validate",v),document.removeEventListener("reset",o)}})}document.addEventListener("alpine:init",()=>window.Alpine.plugin(h));})(); 2 | -------------------------------------------------------------------------------- /src/checkInput.js: -------------------------------------------------------------------------------- 1 | export function checkInput( 2 | el, 3 | modifiers, 4 | expression, 5 | evaluateLater, 6 | ignoreDirty = false 7 | ) { 8 | const hasModifier = (modKey) => modifiers.includes(modKey) 9 | 10 | const getModifierValue = (modKey) => { 11 | const modIndex = modifiers.indexOf(modKey) 12 | 13 | if (modIndex === -1 || modIndex + 1 >= modifiers.length) { 14 | return null 15 | } 16 | 17 | const modValue = Number(modifiers[modIndex + 1]) 18 | 19 | return isNaN(modValue) ? null : modValue 20 | } 21 | 22 | const getValidationOptionWithValue = (modKey, optionKey) => { 23 | if (!hasModifier(modKey)) { 24 | return null 25 | } 26 | 27 | const modValue = getModifierValue(modKey) 28 | 29 | return modValue !== null ? { [optionKey]: modValue } : null 30 | } 31 | 32 | const getErrors = evaluateLater(expression) 33 | 34 | // Build validation options object - only include options with valid values 35 | const validationOptions = Object.fromEntries( 36 | Object.entries({ 37 | ...(hasModifier('required') && { required: true }), 38 | ...getValidationOptionWithValue('min', 'min'), 39 | ...getValidationOptionWithValue('max', 'max'), 40 | ...getValidationOptionWithValue('min:length', 'minLength'), 41 | ...getValidationOptionWithValue('max:length', 'maxLength'), 42 | ...(hasModifier('checked') && { checked: true }), 43 | }).filter( 44 | ([, optionValue]) => optionValue !== null && optionValue !== undefined 45 | ) 46 | ) 47 | 48 | getErrors((inputValue) => { 49 | // Skip validation if no input value and element hasn't been interacted with 50 | if ( 51 | !ignoreDirty && 52 | (inputValue === null || inputValue === undefined || inputValue === '') && 53 | !el.getAttribute('data-validation-dirty') 54 | ) { 55 | return 56 | } 57 | 58 | // Validation logic mapping 59 | const validationChecks = { 60 | required: () => !!inputValue, 61 | min: () => { 62 | const numericValue = Number(inputValue) 63 | 64 | return !isNaN(numericValue) && numericValue >= validationOptions.min 65 | }, 66 | max: () => { 67 | const numericValue = Number(inputValue) 68 | 69 | return !isNaN(numericValue) && numericValue <= validationOptions.max 70 | }, 71 | minLength: () => { 72 | const stringValue = String(inputValue || '') 73 | 74 | return stringValue.length >= validationOptions.minLength 75 | }, 76 | maxLength: () => { 77 | const stringValue = String(inputValue || '') 78 | 79 | return stringValue.length <= validationOptions.maxLength 80 | }, 81 | checked: () => !!inputValue, 82 | } 83 | 84 | // Run only applicable validations 85 | const validationStatus = {} 86 | 87 | for (const [optionKey] of Object.entries(validationOptions)) { 88 | if (validationChecks[optionKey]) { 89 | validationStatus[optionKey] = validationChecks[optionKey]() 90 | } 91 | } 92 | 93 | const isValid = Object.values(validationStatus).every(Boolean) 94 | const errorKey = Object.keys(validationStatus).find( 95 | (statusKey) => !validationStatus[statusKey] 96 | ) 97 | 98 | // Set data attributes 99 | const inputAttributes = { 100 | 'data-validation-dirty': true, 101 | 'data-validation-valid': isValid, 102 | 'data-validation-reason': errorKey || '', 103 | 'data-validation-status': JSON.stringify(validationStatus), 104 | 'data-validation-options': JSON.stringify(validationOptions), 105 | } 106 | 107 | Object.entries(inputAttributes).forEach( 108 | ([attributeName, attributeValue]) => { 109 | el.setAttribute(attributeName, attributeValue) 110 | } 111 | ) 112 | }) 113 | } 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alpine JS Form Validation 2 | 3 | [](https://www.npmjs.com/package/alpinejs-form-validation) 4 | [](https://bundlephobia.com/package/alpinejs-form-validation) 5 | [](https://www.npmjs.com/package/alpinejs-form-validation) 6 | [](https://github.com/markmead/alpinejs-form-validation/blob/main/LICENSE) 7 | 8 | A lightweight, CSS-driven form validation plugin for Alpine.js that provides 9 | real-time client-side validation with data attributes. 10 | 11 | ## ✨ Features 12 | 13 | - 🪶 **Lightweight** - Minimal overhead, maximum performance 14 | - 🎨 **CSS-driven** - Style validation states with data attributes 15 | - ⚡ **Real-time** - Immediate feedback as users type 16 | - 🔧 **Flexible** - Multiple validation rules per input 17 | - 📱 **Accessible** - Works with screen readers and keyboard navigation 18 | - 🚫 **Zero dependencies** - Only requires Alpine.js 19 | 20 | ## 📦 Installation 21 | 22 | ### With a CDN 23 | 24 | ```html 25 | 29 | 30 | 31 | ``` 32 | 33 | ### With a Package Manager 34 | 35 | ```shell 36 | yarn add -D alpinejs-form-validation 37 | 38 | npm install -D alpinejs-form-validation 39 | ``` 40 | 41 | ```js 42 | import Alpine from 'alpinejs' 43 | import validation from 'alpinejs-form-validation' 44 | 45 | Alpine.plugin(validation) 46 | 47 | Alpine.start() 48 | ``` 49 | 50 | ## 🚀 Quick Start 51 | 52 | 1. Add the validation directive to your inputs with `x-validation` 53 | 2. Use modifiers to specify validation rules (e.g., `x-validation.required`) 54 | 3. Trigger validation with `$dispatch('validate')` 55 | 4. Style validation states using data attributes 56 | 57 | ## 📋 Validation Rules 58 | 59 | | Rule | Example | Description | 60 | | -------------- | ----------------------------- | ------------------------ | 61 | | `required` | `x-validation.required` | Field must have a value | 62 | | `min.X` | `x-validation.min.18` | Numeric minimum value | 63 | | `max.X` | `x-validation.max.65` | Numeric maximum value | 64 | | `min:length.X` | `x-validation.min:length.5` | Minimum string length | 65 | | `max:length.X` | `x-validation.max:length.100` | Maximum string length | 66 | | `checked` | `x-validation.checked` | Checkbox must be checked | 67 | 68 | ### Combining Rules 69 | 70 | You can combine multiple validation rules on a single input: 71 | 72 | ```html 73 | 74 | 75 | 79 | ``` 80 | 81 | ## 🎨 Data Attributes 82 | 83 | The plugin automatically sets these data attributes on validated elements: 84 | 85 | - `data-validation-valid` - `"true"` or `"false"` 86 | - `data-validation-reason` - The specific validation rule that failed 87 | - `data-validation-dirty` - `"true"` once the user has interacted with the field 88 | - `data-validation-status` - JSON object with all validation results 89 | - `data-validation-options` - JSON object with all validation rules 90 | 91 | ## 💡 Complete Example 92 | 93 | _This example uses Tailwind CSS for styling but that is not required._ 94 | 95 | ```html 96 |
197 | ``` 198 | 199 | ## 🎯 How It Works 200 | 201 | ### Triggering Validation 202 | 203 | #### `$dispatch('validate')` 204 | 205 | Dispatch the `validate` event to trigger validation for all inputs within the 206 | target element: 207 | 208 | ```html 209 | 210 | 211 | ``` 212 | 213 | ### Real-time Validation 214 | 215 | Validation automatically runs as users interact with inputs. The plugin tracks 216 | whether an input is "dirty" (has been interacted with) to avoid showing errors 217 | prematurely. 218 | 219 | ## 🎨 Styling with CSS 220 | 221 | ### Basic Styling 222 | 223 | ```css 224 | /* Valid inputs */ 225 | [data-validation-valid='true'] { 226 | border-color: #10b981; 227 | } 228 | 229 | /* Invalid inputs */ 230 | [data-validation-valid='false'] { 231 | border-color: #ef4444; 232 | } 233 | 234 | /* Error messages (hidden by default) */ 235 | .error-message { 236 | display: none; 237 | color: #ef4444; 238 | } 239 | 240 | /* Show error when parent contains invalid input */ 241 | .form-group:has([data-validation-valid='false']) .error-message { 242 | display: block; 243 | } 244 | ``` 245 | 246 | ### Specific Error Messages 247 | 248 | Target specific validation failures for tailored messaging: 249 | 250 | ```css 251 | /* Show specific error for minimum value */ 252 | .form-group:has([data-validation-reason='min']) .min-error { 253 | display: block; 254 | } 255 | 256 | /* Show specific error for required field */ 257 | .form-group:has([data-validation-reason='required']) .required-error { 258 | display: block; 259 | } 260 | ``` 261 | 262 | ### Advanced Example with Tailwind CSS 263 | 264 | ```html 265 |