├── .gitignore ├── .travis.yml ├── .jsbeautifyrc ├── .babelrc ├── .editorconfig ├── src ├── providers.js ├── main.js ├── components │ ├── field.js │ ├── messages.js │ ├── vue-form.js │ └── validate.js ├── config.js ├── validators.js ├── util.js └── directives │ └── vue-form-validator.js ├── rollup.config.js ├── LICENSE ├── package.json ├── karma.conf.js ├── example ├── bootstrap.html └── component.html ├── README.md ├── dist ├── vue-form.min.js └── vue-form.js └── test └── specs └── vue-form.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/vue-form.js.map -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | before_script: npm run dist 4 | node_js: 5 | - "12" 6 | -------------------------------------------------------------------------------- /.jsbeautifyrc: -------------------------------------------------------------------------------- 1 | { 2 | "indent_size": 2, 3 | "indent_char": " ", 4 | "brace_style": "collapse-preserve-inline", 5 | "max_preserve_newlines": 4, 6 | "preserve_newlines": true 7 | } 8 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "es2015", 5 | { 6 | "modules": false 7 | } 8 | ] 9 | ], 10 | "plugins": [ 11 | "external-helpers" 12 | ] 13 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = crlf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /src/providers.js: -------------------------------------------------------------------------------- 1 | import { randomId } from './util'; 2 | 3 | export const vueFormConfig = `VueFormProviderConfig${randomId()}`; 4 | export const vueFormState = `VueFormProviderState${randomId()}`; 5 | export const vueFormParentForm = `VueFormProviderParentForm${randomId()}`; 6 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import { argv } from 'yargs'; 3 | import resolve from 'rollup-plugin-node-resolve'; 4 | import commonjs from 'rollup-plugin-commonjs'; 5 | 6 | export default { 7 | entry: 'src/main.js', 8 | dest: 'dist/vue-form.js', 9 | format: 'umd', 10 | moduleName: 'VueForm', 11 | sourceMap: argv.w, 12 | plugins: [ 13 | resolve({ jsnext: true, main: true }), 14 | commonjs(), 15 | babel({ 16 | exclude: 'node_modules/**' 17 | }) 18 | ] 19 | }; 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 fergaldoyle 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 | 23 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import vueForm from './components/vue-form'; 2 | import messages from './components/messages'; 3 | import validate from './components/validate'; 4 | import field from './components/field'; 5 | import vueFormValidator from './directives/vue-form-validator'; 6 | import extend from 'extend'; 7 | import { config } from './config'; 8 | import { vueFormConfig } from './providers'; 9 | 10 | function VueFormBase (options) { 11 | const c = extend(true, {}, config, options); 12 | this.provide = () => ({ 13 | [vueFormConfig]: c 14 | }) 15 | this.components = { 16 | [c.formComponent]: vueForm, 17 | [c.messagesComponent]: messages, 18 | [c.validateComponent]: validate, 19 | [c.fieldComponent]: field, 20 | }; 21 | this.directives = { vueFormValidator }; 22 | } 23 | 24 | export default class VueForm extends VueFormBase { 25 | static install(Vue, options) { 26 | Vue.mixin(new this(options)); 27 | } 28 | static get installed() { 29 | return !!this.install.done; 30 | } 31 | static set installed(val) { 32 | this.install.done = val; 33 | } 34 | } 35 | 36 | VueFormBase.call(VueForm); 37 | // temp fix for vue 2.3.0 38 | VueForm.options = new VueForm(); 39 | -------------------------------------------------------------------------------- /src/components/field.js: -------------------------------------------------------------------------------- 1 | import { getVModelAndLabel, randomId } from '../util'; 2 | import { vueFormConfig } from '../providers'; 3 | 4 | export default { 5 | inject: {vueFormConfig}, 6 | render(h) { 7 | let foundVnodes = getVModelAndLabel(this.$slots.default, this.vueFormConfig); 8 | const vModelnodes = foundVnodes.vModel; 9 | const attrs = { 10 | for: null 11 | }; 12 | if (vModelnodes.length) { 13 | if(this.autoLabel) { 14 | const id = (vModelnodes[0].data.attrs && vModelnodes[0].data.attrs.id) || 'vf' + randomId(); 15 | vModelnodes[0].data.attrs.id = id; 16 | if(foundVnodes.label) { 17 | foundVnodes.label.data = foundVnodes.label.data || {}; 18 | foundVnodes.label.data.attrs = foundVnodes.label.data.attrs = {}; 19 | foundVnodes.label.data.attrs.for = id; 20 | } else if (this.tag === 'label') { 21 | attrs.for = id; 22 | } 23 | } 24 | } 25 | return h(this.tag || this.vueFormConfig.fieldTag , { attrs }, this.$slots.default); 26 | }, 27 | props: { 28 | tag: { 29 | type: String 30 | }, 31 | autoLabel: { 32 | type: Boolean, 33 | default: true 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-form", 3 | "version": "4.10.3", 4 | "description": "Form validation for Vue.js", 5 | "repository": "github:fergaldoyle/vue-form", 6 | "main": "dist/vue-form.js", 7 | "scripts": { 8 | "dev": "rollup -w -c", 9 | "test": "karma start", 10 | "dist": "rollup -c && uglifyjs -c -m -- dist/vue-form.js > dist/vue-form.min.js" 11 | }, 12 | "author": "Fergal Doyle", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "babel-core": "^6.14.0", 16 | "babel-plugin-external-helpers": "^6.8.0", 17 | "babel-preset-es2015": "^6.14.0", 18 | "jasmine-core": "^2.5.2", 19 | "karma": "^1.5.0", 20 | "karma-babel-preprocessor": "^6.0.1", 21 | "karma-chrome-launcher": "^2.0.0", 22 | "karma-firefox-launcher": "^0.1.7", 23 | "karma-jasmine": "^1.0.2", 24 | "karma-phantomjs-launcher": "^1.0.2", 25 | "phantomjs": "^2.1.7", 26 | "rollup": "^0.41.4", 27 | "rollup-plugin-babel": "^2.7.1", 28 | "rollup-plugin-commonjs": "^8.0.2", 29 | "rollup-plugin-node-resolve": "^3.0.0", 30 | "rollup-watch": "^3.2.2", 31 | "uglify-js": "^2.7.3", 32 | "vue": "2.4.1", 33 | "yargs": "^7.0.2" 34 | }, 35 | "dependencies": { 36 | "extend": "3.0.2", 37 | "scope-eval": "1.0.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | import { validators } from './validators'; 2 | 3 | export const config = { 4 | validators, 5 | formComponent: 'VueForm', 6 | formTag: 'form', 7 | messagesComponent: 'FieldMessages', 8 | messagesTag: 'div', 9 | showMessages: '', 10 | validateComponent: 'Validate', 11 | validateTag: 'div', 12 | fieldComponent: 'Field', 13 | fieldTag: 'div', 14 | formClasses: { 15 | dirty: 'vf-form-dirty', 16 | pristine: 'vf-form-pristine', 17 | valid: 'vf-form-valid', 18 | invalid: 'vf-form-invalid', 19 | touched: 'vf-form-touched', 20 | untouched: 'vf-form-untouched', 21 | focused: 'vf-form-focused', 22 | submitted: 'vf-form-submitted', 23 | pending: 'vf-form-pending' 24 | }, 25 | validateClasses: { 26 | dirty: 'vf-field-dirty', 27 | pristine: 'vf-field-pristine', 28 | valid: 'vf-field-valid', 29 | invalid: 'vf-field-invalid', 30 | touched: 'vf-field-touched', 31 | untouched: 'vf-field-untouched', 32 | focused: 'vf-field-focused', 33 | submitted: 'vf-field-submitted', 34 | pending: 'vf-field-pending' 35 | }, 36 | inputClasses: { 37 | dirty: 'vf-dirty', 38 | pristine: 'vf-pristine', 39 | valid: 'vf-valid', 40 | invalid: 'vf-invalid', 41 | touched: 'vf-touched', 42 | untouched: 'vf-untouched', 43 | focused: 'vf-focused', 44 | submitted: 'vf-submitted', 45 | pending: 'vf-pending' 46 | }, 47 | Promise: typeof Promise === 'function' ? Promise : null 48 | }; 49 | -------------------------------------------------------------------------------- /src/validators.js: -------------------------------------------------------------------------------- 1 | const emailRegExp = /^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; // from angular 2 | const urlRegExp = /^(http\:\/\/|https\:\/\/)(.{4,})$/; 3 | 4 | const email = function (value, attrValue, vnode) { 5 | return emailRegExp.test(value); 6 | } 7 | email._allowNulls = true; 8 | 9 | const number = function (value, attrValue, vnode) { 10 | return !isNaN(value); 11 | } 12 | number._allowNulls = true; 13 | 14 | const url = function (value, attrValue, vnode) { 15 | return urlRegExp.test(value); 16 | } 17 | url._allowNulls = true; 18 | 19 | export const validators = { 20 | email, 21 | number, 22 | url, 23 | required(value, attrValue, vnode) { 24 | if (attrValue === false) { 25 | return true; 26 | } 27 | 28 | if (value === 0) { 29 | return true; 30 | } 31 | 32 | if ((vnode.data.attrs && typeof vnode.data.attrs.bool !== 'undefined') || (vnode.componentOptions && vnode.componentOptions.propsData && typeof vnode.componentOptions.propsData.bool !== 'undefined')) { 33 | // bool attribute is present, allow false pass validation 34 | if (value === false) { 35 | return true; 36 | } 37 | } 38 | 39 | if (Array.isArray(value)) { 40 | return !!value.length; 41 | } 42 | return !!value; 43 | }, 44 | minlength(value, length) { 45 | return value.length >= length; 46 | }, 47 | maxlength(value, length) { 48 | return length >= value.length; 49 | }, 50 | pattern(value, pattern) { 51 | const patternRegExp = new RegExp('^' + pattern + '$'); 52 | return patternRegExp.test(value); 53 | }, 54 | min(value, min, vnode) { 55 | if ((vnode.data.attrs.type || '').toLowerCase() == 'number') { 56 | return +value >= +min; 57 | } 58 | return value >= min; 59 | }, 60 | max(value, max, vnode) { 61 | if ((vnode.data.attrs.type || '').toLowerCase() == 'number') { 62 | return +max >= +value; 63 | } 64 | return max >= value; 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Mon Oct 19 2015 20:15:39 GMT+0100 (GMT Daylight Time) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '', 9 | 10 | 11 | // frameworks to use 12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 13 | frameworks: ['jasmine'], 14 | 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | 'https://www.promisejs.org/polyfills/promise-6.1.0.js', 19 | 20 | //'https://cdnjs.cloudflare.com/ajax/libs/vue/2.1.10/vue.js', 21 | //'https://cdnjs.cloudflare.com/ajax/libs/vue/2.2.1/vue.js', 22 | 'node_modules/vue/dist/vue.js', // 2.3.4 23 | 'dist/vue-form.js', 24 | 'test/specs/*.js' 25 | ], 26 | 27 | 28 | // list of files to exclude 29 | exclude: [], 30 | 31 | 32 | // preprocess matching files before serving them to the browser 33 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 34 | preprocessors: { 35 | 'test/specs/*.js': ['babel'] 36 | }, 37 | 38 | babel: { 39 | options: { 40 | sourceMap: 'inline' 41 | }, 42 | filename: function (file) { 43 | return file.originalPath.replace(/\.js$/, '.es5.js'); 44 | }, 45 | sourceFileName: function (file) { 46 | return file.originalPath; 47 | } 48 | }, 49 | 50 | // test results reporter to use 51 | // possible values: 'dots', 'progress' 52 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 53 | reporters: ['progress'], 54 | 55 | 56 | // web server port 57 | port: 9876, 58 | 59 | 60 | // enable / disable colors in the output (reporters and logs) 61 | colors: true, 62 | 63 | 64 | // level of logging 65 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 66 | logLevel: config.LOG_DEBUG, 67 | 68 | client: { 69 | captureConsole: true 70 | }, 71 | 72 | 73 | // enable / disable watching file and executing tests whenever any file changes 74 | autoWatch: true, 75 | 76 | 77 | // start these browsers 78 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 79 | browsers: ['PhantomJS'], 80 | 81 | 82 | // Continuous Integration mode 83 | // if true, Karma captures browsers, runs the tests and exits 84 | singleRun: true 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /src/components/messages.js: -------------------------------------------------------------------------------- 1 | import { vueFormConfig, vueFormState, vueFormParentForm } from '../providers'; 2 | import scopeEval from 'scope-eval'; 3 | 4 | function findLabel (nodes) { 5 | if(!nodes) { 6 | return; 7 | } 8 | for (let i = 0; i < nodes.length; i++) { 9 | let vnode = nodes[i]; 10 | if(vnode.tag === 'label') { 11 | return nodes[i]; 12 | } else if (nodes[i].children) { 13 | return findLabel(nodes[i].children); 14 | } 15 | } 16 | } 17 | 18 | export default { 19 | inject: {vueFormConfig, vueFormState, vueFormParentForm}, 20 | render(h) { 21 | const children = []; 22 | const field = this.formstate[this.fieldname]; 23 | if (field && field.$error && this.isShown) { 24 | Object.keys(field.$error).forEach((key) => { 25 | if(this.$slots[key] || this.$scopedSlots[key]) { 26 | const out = this.$slots[key] || this.$scopedSlots[key](field); 27 | if(this.autoLabel) { 28 | const label = findLabel(out); 29 | if(label) { 30 | label.data = label.data || {}; 31 | label.data.attrs = label.data.attrs || {}; 32 | label.data.attrs.for = field._id; 33 | } 34 | } 35 | children.push(out); 36 | } 37 | }); 38 | if(!children.length && field.$valid) { 39 | if(this.$slots.default || this.$scopedSlots.default) { 40 | const out = this.$slots.default || this.$scopedSlots.default(field); 41 | if(this.autoLabel) { 42 | const label = findLabel(out); 43 | if(label) { 44 | label.data = label.data || {}; 45 | label.data.attrs = label.data.attrs || {}; 46 | label.data.attrs.for = field._id; 47 | } 48 | } 49 | children.push(out); 50 | } 51 | } 52 | } 53 | return h(this.tag || this.vueFormConfig.messagesTag, children); 54 | }, 55 | props: { 56 | state: Object, 57 | name: String, 58 | show: { 59 | type: String, 60 | default: '' 61 | }, 62 | tag: { 63 | type: String 64 | }, 65 | autoLabel: Boolean, 66 | }, 67 | data () { 68 | return { 69 | formstate: null, 70 | fieldname: '' 71 | }; 72 | }, 73 | created () { 74 | this.fieldname = this.name; 75 | this.formstate = this.state || this.vueFormState; 76 | }, 77 | computed: { 78 | isShown() { 79 | const field = this.formstate[this.fieldname]; 80 | const show = this.show || this.vueFormParentForm.showMessages || this.vueFormConfig.showMessages; 81 | 82 | if (!show || !field) { 83 | return true; 84 | } 85 | 86 | return scopeEval(show,field); 87 | } 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | export function getClasses(classConfig, state) { 2 | return { 3 | [classConfig.dirty]: state.$dirty, 4 | [classConfig.pristine]: state.$pristine, 5 | [classConfig.valid]: state.$valid, 6 | [classConfig.invalid]: state.$invalid, 7 | [classConfig.touched]: state.$touched, 8 | [classConfig.untouched]: state.$untouched, 9 | [classConfig.focused]: state.$focused, 10 | [classConfig.pending]: state.$pending, 11 | [classConfig.submitted]: state.$submitted, 12 | }; 13 | } 14 | 15 | export function addClass(el, className) { 16 | if (el.classList) { 17 | el.classList.add(className); 18 | } else { 19 | el.className += ' ' + className; 20 | } 21 | } 22 | 23 | export function removeClass(el, className) { 24 | if (el.classList) { 25 | el.classList.remove(className); 26 | } else { 27 | el.className = el.className.replace(new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'), ' '); 28 | } 29 | } 30 | 31 | export function vModelValue(data) { 32 | if (data.model) { 33 | return data.model.value; 34 | } 35 | return data.directives.filter(v => v.name === 'model')[0].value; 36 | } 37 | 38 | export function getVModelAndLabel(nodes, config) { 39 | const foundVnodes = { 40 | vModel: [], 41 | label: null, 42 | messages: null 43 | }; 44 | 45 | if(!nodes) { 46 | return foundVnodes; 47 | } 48 | 49 | function traverse(nodes) { 50 | for (let i = 0; i < nodes.length; i++) { 51 | let node = nodes[i]; 52 | 53 | if(node.componentOptions) { 54 | if(node.componentOptions.tag === hyphenate(config.messagesComponent)) { 55 | foundVnodes.messages = node; 56 | } 57 | } 58 | 59 | if(node.tag === 'label' && !foundVnodes.label) { 60 | foundVnodes.label = node; 61 | } 62 | 63 | if (node.data) { 64 | if (node.data.model) { 65 | // model check has to come first. If a component has 66 | // a directive and v-model, the directive will be in .directives 67 | // and v-modelstored in .model 68 | foundVnodes.vModel.push(node); 69 | } else if (node.data.directives) { 70 | const match = node.data.directives.filter(v => v.name === 'model'); 71 | if (match.length) { 72 | foundVnodes.vModel.push(node); 73 | } 74 | } 75 | } 76 | if (node.children) { 77 | traverse(node.children); 78 | } else if (node.componentOptions && node.componentOptions.children) { 79 | traverse(node.componentOptions.children); 80 | } 81 | } 82 | } 83 | 84 | traverse(nodes); 85 | 86 | return foundVnodes; 87 | } 88 | 89 | export function getName(vnode) { 90 | if(vnode.data && vnode.data.attrs && vnode.data.attrs.name) { 91 | return vnode.data.attrs.name; 92 | } else if (vnode.componentOptions && vnode.componentOptions.propsData && vnode.componentOptions.propsData.name) { 93 | return vnode.componentOptions.propsData.name; 94 | } 95 | } 96 | 97 | const hyphenateRE = /([^-])([A-Z])/g; 98 | export function hyphenate (str) { 99 | return str 100 | .replace(hyphenateRE, '$1-$2') 101 | .replace(hyphenateRE, '$1-$2') 102 | .toLowerCase() 103 | } 104 | 105 | export function randomId() { 106 | return Math.random().toString(36).substr(2, 10); 107 | } 108 | 109 | // https://davidwalsh.name/javascript-debounce-function 110 | export function debounce(func, wait, immediate) { 111 | var timeout; 112 | return function() { 113 | var context = this, args = arguments; 114 | var later = function() { 115 | timeout = null; 116 | if (!immediate) func.apply(context, args); 117 | }; 118 | var callNow = immediate && !timeout; 119 | clearTimeout(timeout); 120 | timeout = setTimeout(later, wait); 121 | if (callNow) func.apply(context, args); 122 | }; 123 | }; 124 | 125 | export function isShallowObjectDifferent(a, b) { 126 | let aValue = ''; 127 | let bValue = ''; 128 | Object.keys(a).sort().filter(v => typeof a[v] !== 'function').forEach(v => aValue += a[v]); 129 | Object.keys(b).sort().filter(v => typeof a[v] !== 'function').forEach(v => bValue += b[v]); 130 | return aValue !== bValue; 131 | } 132 | -------------------------------------------------------------------------------- /src/directives/vue-form-validator.js: -------------------------------------------------------------------------------- 1 | import { config } from '../config'; 2 | import { vModelValue, getName, debounce } from '../util'; 3 | import extend from 'extend'; 4 | 5 | const debouncedValidators = {}; 6 | 7 | function addValidators(attrs, validators, fieldValidators) { 8 | Object.keys(attrs).forEach(attr => { 9 | const prop = (attr === 'type') ? attrs[attr].toLowerCase() : attr.toLowerCase(); 10 | 11 | if (validators[prop] && !fieldValidators[prop]) { 12 | fieldValidators[prop] = validators[prop]; 13 | } 14 | }); 15 | } 16 | 17 | export function compareChanges(vnode, oldvnode, validators) { 18 | 19 | let hasChanged = false; 20 | const attrs = vnode.data.attrs || {}; 21 | const oldAttrs = oldvnode.data.attrs || {}; 22 | const out = {}; 23 | 24 | if (vModelValue(vnode.data) !== vModelValue(oldvnode.data)) { 25 | out.vModel = true; 26 | hasChanged = true; 27 | } 28 | 29 | Object.keys(validators).forEach((validator) => { 30 | if (attrs[validator] !== oldAttrs[validator]) { 31 | out[validator] = true; 32 | hasChanged = true; 33 | } 34 | }); 35 | 36 | // if is a component 37 | if (vnode.componentOptions && vnode.componentOptions.propsData) { 38 | const attrs = vnode.componentOptions.propsData; 39 | const oldAttrs = oldvnode.componentOptions.propsData; 40 | Object.keys(validators).forEach((validator) => { 41 | if (attrs[validator] !== oldAttrs[validator]) { 42 | out[validator] = true; 43 | hasChanged = true; 44 | } 45 | }); 46 | } 47 | 48 | if (hasChanged) { 49 | return out; 50 | } 51 | } 52 | 53 | export default { 54 | name: 'vue-form-validator', 55 | bind(el, binding, vnode) { 56 | const { fieldstate } = binding.value; 57 | const { validators } = binding.value.config; 58 | const attrs = (vnode.data.attrs || {}); 59 | const inputName = getName(vnode); 60 | 61 | if (!inputName) { 62 | console.warn('vue-form: name attribute missing'); 63 | return; 64 | } 65 | 66 | if(attrs.debounce) { 67 | debouncedValidators[fieldstate._id] = debounce(function(fieldstate, vnode) { 68 | if (fieldstate._hasFocused) { 69 | fieldstate._setDirty(); 70 | } 71 | fieldstate._validate(vnode); 72 | }, attrs.debounce); 73 | } 74 | 75 | // add validators 76 | addValidators(attrs, validators, fieldstate._validators); 77 | 78 | // if is a component, a validator attribute could be a prop this component uses 79 | if (vnode.componentOptions && vnode.componentOptions.propsData) { 80 | addValidators(vnode.componentOptions.propsData, validators, fieldstate._validators); 81 | } 82 | 83 | fieldstate._validate(vnode); 84 | 85 | // native listeners 86 | el.addEventListener('blur', () => { 87 | fieldstate._setFocused(false); 88 | }, false); 89 | el.addEventListener('focus', () => { 90 | fieldstate._setFocused(true); 91 | }, false); 92 | 93 | // component listeners 94 | const vm = vnode.componentInstance; 95 | if (vm) { 96 | vm.$on('blur', () => { 97 | fieldstate._setFocused(false); 98 | }); 99 | vm.$on('focus', () => { 100 | fieldstate._setFocused(true); 101 | }); 102 | 103 | vm.$once('vf:addFocusListeners', () => { 104 | el.addEventListener('focusout', () => { 105 | fieldstate._setFocused(false); 106 | }, false); 107 | el.addEventListener('focusin', () => { 108 | fieldstate._setFocused(true); 109 | }, false); 110 | }); 111 | 112 | vm.$on('vf:validate', data => { 113 | if(!vm._vfValidationData_) { 114 | addValidators(data, validators, fieldstate._validators); 115 | } 116 | vm._vfValidationData_ = data; 117 | fieldstate._validate(vm.$vnode); 118 | }); 119 | } 120 | }, 121 | 122 | update(el, binding, vnode, oldVNode) { 123 | const { validators } = binding.value.config; 124 | const changes = compareChanges(vnode, oldVNode, validators); 125 | const { fieldstate } = binding.value; 126 | 127 | let attrs = vnode.data.attrs || {}; 128 | const vm = vnode.componentInstance; 129 | if(vm && vm._vfValidationData_) { 130 | attrs = extend({}, attrs, vm[vm._vfValidationData_]); 131 | } 132 | 133 | if(vnode.elm.className.indexOf(fieldstate._className[0]) === -1) { 134 | vnode.elm.className = vnode.elm.className + ' ' + fieldstate._className.join(' '); 135 | } 136 | 137 | if (!changes) { 138 | return; 139 | } 140 | 141 | if (changes.vModel) { 142 | // re-validate all 143 | if(attrs.debounce) { 144 | fieldstate.$pending = true; 145 | debouncedValidators[fieldstate._id](fieldstate, vnode); 146 | } else { 147 | if (fieldstate._hasFocused) { 148 | fieldstate._setDirty(); 149 | } 150 | fieldstate._validate(vnode); 151 | } 152 | } else { 153 | // attributes have changed 154 | // to do: loop through them and re-validate changed ones 155 | //for(let prop in changes) { 156 | // fieldstate._validate(vnode, validator); 157 | //} 158 | // for now 159 | fieldstate._validate(vnode); 160 | } 161 | 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/components/vue-form.js: -------------------------------------------------------------------------------- 1 | import { config } from '../config'; 2 | import { getClasses } from '../util'; 3 | import { vueFormConfig, vueFormState, vueFormParentForm } from '../providers'; 4 | import extend from 'extend'; 5 | 6 | export default { 7 | render(h) { 8 | const attrs = {}; 9 | 10 | if (typeof window !== 'undefined') { 11 | attrs.novalidate = ''; 12 | } 13 | 14 | return h( 15 | this.tag || this.vueFormConfig.formTag, { 16 | on: { 17 | submit: (event) => { 18 | if(this.state.$pending) { 19 | event.preventDefault(); 20 | this.vueFormConfig.Promise.all(this.promises).then(() => { 21 | this.state._submit(); 22 | this.$emit('submit', event); 23 | this.promises = []; 24 | }); 25 | } else { 26 | this.state._submit(); 27 | this.$emit('submit', event); 28 | } 29 | }, 30 | reset: (event) => { 31 | this.state._reset(); 32 | this.$emit('reset', event); 33 | } 34 | }, 35 | class: this.className, 36 | attrs 37 | }, [this.$slots.default] 38 | ); 39 | }, 40 | props: { 41 | state: { 42 | type: Object, 43 | required: true 44 | }, 45 | tag: String, 46 | showMessages: String 47 | }, 48 | inject: { vueFormConfig }, 49 | provide() { 50 | return { 51 | [vueFormState]: this.state, 52 | [vueFormParentForm]: this 53 | }; 54 | }, 55 | data:() => ({ 56 | promises: [] 57 | }), 58 | created() { 59 | if(!this.state) { return } 60 | const controls = {}; 61 | const state = this.state; 62 | const formstate = { 63 | $dirty: false, 64 | $pristine: true, 65 | $valid: true, 66 | $invalid: false, 67 | $submitted: false, 68 | $touched: false, 69 | $untouched: true, 70 | $focused: false, 71 | $pending: false, 72 | $error: {}, 73 | $submittedState: {}, 74 | _id: '', 75 | _submit: () => { 76 | this.state.$submitted = true; 77 | this.state._cloneState(); 78 | }, 79 | _cloneState: () => { 80 | const cloned = JSON.parse(JSON.stringify(state)); 81 | delete cloned.$submittedState; 82 | Object.keys(cloned).forEach((key) => { 83 | this.$set(this.state.$submittedState, key, cloned[key]); 84 | }); 85 | }, 86 | _addControl: (ctrl) => { 87 | controls[ctrl.$name] = ctrl; 88 | this.$set(state, ctrl.$name, ctrl); 89 | }, 90 | _removeControl: (ctrl) => { 91 | delete controls[ctrl.$name]; 92 | this.$delete(this.state, ctrl.$name); 93 | this.$delete(this.state.$error, ctrl.$name); 94 | }, 95 | _validate: () => { 96 | Object.keys(controls).forEach((key) => { 97 | controls[key]._validate(); 98 | }); 99 | }, 100 | _reset: () => { 101 | state.$submitted = false; 102 | state.$pending = false; 103 | state.$submittedState = {}; 104 | Object.keys(controls).forEach((key) => { 105 | const control = controls[key]; 106 | control._hasFocused = false; 107 | control._setUntouched(); 108 | control._setPristine(); 109 | control.$submitted = false; 110 | control.$pending = false; 111 | }); 112 | } 113 | } 114 | 115 | Object.keys(formstate).forEach((key) => { 116 | this.$set(this.state, key, formstate[key]); 117 | }); 118 | 119 | this.$watch('state', () => { 120 | let isDirty = false; 121 | let isValid = true; 122 | let isTouched = false; 123 | let isFocused = false; 124 | let isPending = false; 125 | Object.keys(controls).forEach((key) => { 126 | const control = controls[key]; 127 | 128 | control.$submitted = state.$submitted; 129 | 130 | if (control.$dirty) { 131 | isDirty = true; 132 | } 133 | if (control.$touched) { 134 | isTouched = true; 135 | } 136 | if (control.$focused) { 137 | isFocused = true; 138 | } 139 | if (control.$pending) { 140 | isPending = true; 141 | } 142 | if (!control.$valid) { 143 | isValid = false; 144 | // add control to errors 145 | this.$set(state.$error, control.$name, control); 146 | } else { 147 | this.$delete(state.$error, control.$name); 148 | } 149 | }); 150 | 151 | state.$dirty = isDirty; 152 | state.$pristine = !isDirty; 153 | state.$touched = isTouched; 154 | state.$untouched = !isTouched; 155 | state.$focused = isFocused; 156 | state.$valid = isValid; 157 | state.$invalid = !isValid; 158 | state.$pending = isPending; 159 | 160 | }, { 161 | deep: true, 162 | immediate: true 163 | }); 164 | 165 | /* watch pristine? if set to true, set all children to pristine 166 | Object.keys(controls).forEach((ctrl) => { 167 | controls[ctrl].setPristine(); 168 | });*/ 169 | 170 | }, 171 | computed: { 172 | className() { 173 | const classes = getClasses(this.vueFormConfig.formClasses, this.state); 174 | return classes; 175 | } 176 | }, 177 | methods: { 178 | reset() { 179 | this.state._reset(); 180 | }, 181 | validate() { 182 | this.state._validate(); 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /example/bootstrap.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | vue-form example 6 | 7 | 8 | 17 | 18 | 19 | 20 |
21 | 22 |

Example showing vue-form usage with Bootstrap styles, validation messages are shown on field touch or form submission

23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 70 | 71 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | Enter no more than 50 characters. 82 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 |
94 | 95 |
96 |
97 | 98 |
{{formstate}}
99 | 100 |
101 | 102 | 103 | 104 | 145 | 146 | 147 | 148 | 149 | -------------------------------------------------------------------------------- /example/component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | vue-form component examples 6 | 7 | 9 | 10 | 11 | 12 |
13 | 14 |

Example showing vue-form usage with custom components

15 | 16 | 17 | 18 | 19 | 20 | 21 | Component with validation attributes set on the component element 22 | 27 | 28 | 29 | 30 | 31 | 32 | Component where validation attributes are props, along with v-bind 33 | 39 | 40 | 41 | 42 | 43 | 44 | Component where validation attributes are emitted within the component using the vf:validate event 45 | 51 | 52 | 53 |
54 | 55 |
56 |
57 | 58 |
{{formstate}}
59 | 60 |
61 | 62 | 63 | 64 | 205 | 206 | 207 | 208 | 209 | -------------------------------------------------------------------------------- /src/components/validate.js: -------------------------------------------------------------------------------- 1 | import { getVModelAndLabel, vModelValue, addClass, removeClass, getName, hyphenate, randomId, getClasses, isShallowObjectDifferent } from '../util'; 2 | import { vueFormConfig, vueFormState, vueFormParentForm } from '../providers'; 3 | import { validators } from '../validators'; 4 | import extend from 'extend'; 5 | 6 | export default { 7 | render(h) { 8 | let foundVnodes = getVModelAndLabel(this.$slots.default, this.vueFormConfig); 9 | const vModelnodes = foundVnodes.vModel; 10 | const attrs = { 11 | for: null 12 | }; 13 | if (vModelnodes.length) { 14 | this.name = getName(vModelnodes[0]); 15 | 16 | if (foundVnodes.messages) { 17 | this.$nextTick(() => { 18 | const messagesVm = foundVnodes.messages.componentInstance 19 | if (messagesVm) { 20 | messagesVm.fieldname = messagesVm.fieldname || this.name; 21 | } 22 | }); 23 | } 24 | 25 | if (this.autoLabel) { 26 | const id = vModelnodes[0].data.attrs.id || this.fieldstate._id; 27 | this.fieldstate._id = id; 28 | vModelnodes[0].data.attrs.id = id; 29 | if (foundVnodes.label) { 30 | foundVnodes.label.data = foundVnodes.label.data || {}; 31 | foundVnodes.label.data.attrs = foundVnodes.label.data.attrs || {}; 32 | foundVnodes.label.data.attrs.for = id; 33 | } else if (this.tag === 'label') { 34 | attrs.for = id; 35 | } 36 | } 37 | vModelnodes.forEach((vnode) => { 38 | if (!vnode.data.directives) { 39 | vnode.data.directives = []; 40 | } 41 | vnode.data.directives.push({ name: 'vue-form-validator', value: { fieldstate: this.fieldstate, config: this.vueFormConfig } }); 42 | vnode.data.attrs['vue-form-validator'] = ''; 43 | vnode.data.attrs['debounce'] = this.debounce; 44 | }); 45 | } else { 46 | //console.warn('Element with v-model not found'); 47 | } 48 | return h(this.tag || this.vueFormConfig.validateTag, { 'class': this.className, attrs }, this.$slots.default); 49 | }, 50 | props: { 51 | state: Object, 52 | custom: null, 53 | autoLabel: Boolean, 54 | tag: { 55 | type: String 56 | }, 57 | debounce: Number 58 | }, 59 | inject: { vueFormConfig, vueFormState, vueFormParentForm }, 60 | data() { 61 | return { 62 | name: '', 63 | formstate: null, 64 | fieldstate: {} 65 | }; 66 | }, 67 | methods: { 68 | getClasses(classConfig) { 69 | var s = this.fieldstate; 70 | return Object.keys(s.$error).reduce((classes, error) => { 71 | classes[classConfig.invalid + '-' + hyphenate(error)] = true; 72 | return classes; 73 | }, getClasses(classConfig, s)); 74 | } 75 | }, 76 | computed: { 77 | className() { 78 | return this.getClasses(this.vueFormConfig.validateClasses); 79 | }, 80 | inputClassName() { 81 | return this.getClasses(this.vueFormConfig.inputClasses); 82 | } 83 | }, 84 | mounted() { 85 | this.fieldstate.$name = this.name; 86 | this.formstate._addControl(this.fieldstate); 87 | 88 | const vModelEls = this.$el.querySelectorAll('[vue-form-validator]'); 89 | 90 | // add classes to the input element 91 | this.$watch('inputClassName', (value, oldValue) => { 92 | let out; 93 | for (let i = 0, el; el = vModelEls[i++];) { 94 | if (oldValue) { 95 | Object.keys(oldValue).filter(k => oldValue[k]).forEach(k => removeClass(el, k)); 96 | } 97 | out = []; 98 | Object.keys(value).filter(k => value[k]).forEach((k) => { 99 | out.push(k); 100 | addClass(el, k) 101 | }); 102 | } 103 | this.fieldstate._className = out; 104 | }, { 105 | deep: true, 106 | immediate: true 107 | }); 108 | 109 | this.$watch('name', (value, oldValue) => { 110 | this.formstate._removeControl(this.fieldstate); 111 | this.fieldstate.$name = value; 112 | this.formstate._addControl(this.fieldstate); 113 | }); 114 | 115 | }, 116 | created() { 117 | this.formstate = this.state || this.vueFormState; 118 | const vm = this; 119 | let pendingValidators = []; 120 | let _val; 121 | let prevVnode; 122 | this.fieldstate = { 123 | $name: '', 124 | $dirty: false, 125 | $pristine: true, 126 | $valid: true, 127 | $invalid: false, 128 | $touched: false, 129 | $untouched: true, 130 | $focused: false, 131 | $pending: false, 132 | $submitted: false, 133 | $error: {}, 134 | $attrs: {}, 135 | _className: null, 136 | _id: 'vf' + randomId(), 137 | _setValidatorValidity(validator, isValid) { 138 | if (isValid) { 139 | vm.$delete(this.$error, validator); 140 | } else { 141 | vm.$set(this.$error, validator, true); 142 | } 143 | }, 144 | _setValidity(isValid) { 145 | this.$valid = isValid; 146 | this.$invalid = !isValid; 147 | }, 148 | _setDirty() { 149 | this.$dirty = true; 150 | this.$pristine = false; 151 | }, 152 | _setPristine() { 153 | this.$dirty = false; 154 | this.$pristine = true; 155 | }, 156 | _setTouched() { 157 | this.$touched = true; 158 | this.$untouched = false; 159 | }, 160 | _setUntouched() { 161 | this.$touched = false; 162 | this.$untouched = true; 163 | }, 164 | _setFocused(value) { 165 | this.$focused = typeof value === 'boolean' ? value : false; 166 | if (this.$focused) { 167 | this._setHasFocused(); 168 | } else { 169 | this._setTouched(); 170 | } 171 | }, 172 | _setHasFocused() { 173 | this._hasFocused = true; 174 | }, 175 | _hasFocused: false, 176 | _validators: {}, 177 | _validate(vnode) { 178 | if(!vnode) { 179 | vnode = prevVnode; 180 | } else { 181 | prevVnode = vnode; 182 | } 183 | this.$pending = true; 184 | let isValid = true; 185 | let emptyAndRequired = false; 186 | const value = vModelValue(vnode.data); 187 | _val = value; 188 | 189 | const pending = { 190 | promises: [], 191 | names: [] 192 | }; 193 | 194 | pendingValidators.push(pending); 195 | 196 | let attrs = vnode.data.attrs || {}; 197 | const childvm = vnode.componentInstance; 198 | if(childvm && childvm._vfValidationData_) { 199 | attrs = extend({}, attrs, childvm._vfValidationData_); 200 | } 201 | 202 | const propsData = (vnode.componentOptions && vnode.componentOptions.propsData ? vnode.componentOptions.propsData : {}); 203 | 204 | Object.keys(this._validators).forEach((validator) => { 205 | // when value is empty and current validator is not the required validator, the field is valid 206 | if ((value === '' || value === undefined || value === null) && validator !== 'required') { 207 | this._setValidatorValidity(validator, true); 208 | emptyAndRequired = true; 209 | // return early, required validator will 210 | // fall through if it is present 211 | return; 212 | } 213 | 214 | const attrValue = typeof attrs[validator] !== 'undefined' ? attrs[validator] : propsData[validator]; 215 | const isFunction = typeof this._validators[validator] === 'function'; 216 | 217 | // match vue behaviour, ignore if attribute is null or undefined. But for type=email|url|number and custom validators, the value will be null, so allow with _allowNulls 218 | if (isFunction && (attrValue === null || typeof attrValue === 'undefined') && !this._validators[validator]._allowNulls) { 219 | return; 220 | } 221 | 222 | if(attrValue) { 223 | this.$attrs[validator] = attrValue; 224 | } 225 | 226 | const result = isFunction ? this._validators[validator](value, attrValue, vnode) : vm.custom[validator]; 227 | 228 | if (typeof result === 'boolean') { 229 | if (result) { 230 | this._setValidatorValidity(validator, true); 231 | } else { 232 | isValid = false; 233 | this._setValidatorValidity(validator, false); 234 | } 235 | } else { 236 | pending.promises.push(result); 237 | pending.names.push(validator); 238 | vm.vueFormParentForm.promises.push(result); 239 | } 240 | }); 241 | 242 | if (pending.promises.length) { 243 | vm.vueFormConfig.Promise.all(pending.promises).then((results) => { 244 | 245 | // only concerned with the last promise results, in case 246 | // async responses return out of order 247 | if (pending !== pendingValidators[pendingValidators.length - 1]) { 248 | //console.log('ignoring old promise', pending.promises); 249 | return; 250 | } 251 | 252 | pendingValidators = []; 253 | 254 | results.forEach((result, i) => { 255 | const name = pending.names[i]; 256 | if (result) { 257 | this._setValidatorValidity(name, true); 258 | } else { 259 | isValid = false; 260 | this._setValidatorValidity(name, false); 261 | } 262 | }); 263 | this._setValidity(isValid); 264 | this.$pending = false; 265 | }); 266 | } else { 267 | this._setValidity(isValid); 268 | this.$pending = false; 269 | } 270 | } 271 | } 272 | 273 | // add custom validators 274 | if (this.custom) { 275 | Object.keys(this.custom).forEach((prop) => { 276 | if (typeof this.custom[prop] === 'function') { 277 | this.custom[prop]._allowNulls = true; 278 | this.fieldstate._validators[prop] = this.custom[prop]; 279 | } else { 280 | this.fieldstate._validators[prop] = this.custom[prop]; 281 | } 282 | }); 283 | } 284 | 285 | this.$watch('custom', (v, oldV) => { 286 | if(!oldV || !v) { return } 287 | if(isShallowObjectDifferent(v, oldV)){ 288 | this.fieldstate._validate(); 289 | } 290 | }, { 291 | deep: true 292 | }); 293 | 294 | }, 295 | destroyed() { 296 | this.formstate._removeControl(this.fieldstate); 297 | } 298 | }; 299 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-form 2 | 3 | [![Build Status](https://travis-ci.org/fergaldoyle/vue-form.svg?branch=master)](https://travis-ci.org/fergaldoyle/vue-form) 4 | 5 | Form validation for Vue.js 2.2+ 6 | 7 | ### Install 8 | 9 | Available through npm as `vue-form`. 10 | 11 | ``` js 12 | import VueForm from 'vue-form'; 13 | // or var VueForm = require('vue-form') or window.VueForm if you are linking directly to the dist file 14 | 15 | // install globally 16 | Vue.use(VueForm); 17 | Vue.use(VueForm, options); 18 | 19 | // or use the mixin 20 | ... 21 | mixins: [VueForm] 22 | ... 23 | mixins: [new VueForm(options)] 24 | ... 25 | ``` 26 | 27 | ### Usage 28 | 29 | Once installed you have access to four components (`vue-form`, `validate`, `field`, `field-messages`) for managing form state, validating form fields and displaying validation messages. 30 | 31 | Live examples 32 | * Configured to work with Bootstrap styles: https://jsfiddle.net/fergal_doyle/zfqt4yhq/ 33 | * Matching passwords and password strength: https://jsfiddle.net/fergal_doyle/9rn5kLkw/ 34 | * Field error messages based on submitted state (labels tied to inputs with `auto-label`): https://jsfiddle.net/fergal_doyle/bqys2p5y/ 35 | 36 | Example 37 | 38 | ```html 39 |
40 | 41 | 42 | 43 | Name * 44 | 45 | 46 | 47 |
Success!
48 |
Name is a required field
49 |
50 |
51 | 52 | 53 | Email 54 | 55 | 56 | 57 |
Email is a required field
58 |
Email is not valid
59 |
60 |
61 | 62 | 63 |
64 |
{{ formstate }}
65 |
66 | ``` 67 | 68 | ```js 69 | Vue.use(VueForm); 70 | 71 | new Vue({ 72 | el: '#app', 73 | data: { 74 | formstate: {}, 75 | model: { 76 | name: '', 77 | email: 'invalid-email' 78 | } 79 | }, 80 | methods: { 81 | onSubmit: function () { 82 | if(this.formstate.$invalid) { 83 | // alert user and exit early 84 | return; 85 | } 86 | // otherwise submit form 87 | } 88 | } 89 | }); 90 | ``` 91 | 92 | The output of `formstate` will be: 93 | ```js 94 | { 95 | "$dirty": false, 96 | "$pristine": true, 97 | "$valid": false, 98 | "$invalid": true, 99 | "$submitted": false, 100 | "$touched": false, 101 | "$untouched": true, 102 | "$focused": false, 103 | "$pending": false, 104 | "$error": { 105 | // fields with errors are copied into this object 106 | }, 107 | "$submittedState": { 108 | // each form sumbit, state is cloned into this object 109 | }, 110 | "name": { 111 | "$name": "name", 112 | "$dirty": false, 113 | "$pristine": true, 114 | "$valid": false, 115 | "$invalid": true, 116 | "$touched": false, 117 | "$untouched": true, 118 | "$focused": false, 119 | "$pending": false, 120 | "$error": { 121 | "required": true 122 | } 123 | }, 124 | "email": { 125 | "$name": "email", 126 | "$dirty": false, 127 | "$pristine": true, 128 | "$valid": false, 129 | "$invalid": true, 130 | "$touched": false, 131 | "$untouched": true, 132 | "$focused": false, 133 | "$pending": false, 134 | "$error": { 135 | "email": true 136 | } 137 | } 138 | } 139 | ``` 140 | 141 | ### Displaying messages 142 | Display validation errors or success messages with `field-messages`. 143 | 144 | The `show` prop supports simple expressions which specifiy when messages should be displayed based on the current state of the field, e.g: `$dirty`, `$dirty && $touched`, `$dirty || $touched`, `$touched || $submitted`, `$focused && ($dirty || $submitted)` 145 | 146 | ```html 147 | 148 |
Error message A
149 |
Error message B
150 |
151 | ``` 152 | 153 | Or use scoped templates: 154 | ```html 155 | 156 | Success 157 | 160 | 163 | 164 | ``` 165 | 166 | ### Validators 167 | 168 | ``` 169 | type="email" 170 | type="url" 171 | type="number" 172 | required 173 | minlength 174 | maxlength 175 | pattern 176 | min (for type="number") 177 | max (for type="number") 178 | 179 | ``` 180 | 181 | You can use static validation attributes or bindings. If it is a binding, the input will be re-validated every binding update meaning you can have inputs which are conditionally required etc. 182 | ```html 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | ``` 199 | 200 | #### Custom validators 201 | You can register global and local custom validators. 202 | 203 | Global custom validator 204 | ```js 205 | 206 | var options = { 207 | validators: { 208 | 'my-custom-validator': function (value, attrValue, vnode) { 209 | // return true to set input as $valid, false to set as $invalid 210 | return value === 'custom'; 211 | } 212 | } 213 | } 214 | 215 | Vue.use(VueForm, options); 216 | // or 217 | // mixins: [new VueForm(options)] 218 | ``` 219 | 220 | ```html 221 | 222 | 223 | 226 | 227 | ``` 228 | 229 | Local custom validator 230 | ```js 231 | // ... 232 | methods: { 233 | customValidator: function (value) { 234 | // return true to set input as $valid, false to set as $invalid 235 | return value === 'custom'; 236 | } 237 | }, 238 | // local custom validator can also be a data or computed property 239 | computed: { 240 | isEmailAvailable: function () { 241 | // return true to set input as $valid, false to set as $invalid 242 | } 243 | } 244 | // ... 245 | ``` 246 | 247 | ```html 248 | 249 | 250 | 253 | 254 | ``` 255 | 256 | #### Async validators: 257 | 258 | Async validators are custom validators which return a Promise. `resolve()` `true` or `false` to set field validity. 259 | ```js 260 | // ... 261 | methods: { 262 | customValidator (value) { 263 | return new Promise((resolve, reject) => { 264 | setTimeout(() => { 265 | resolve(value === 'ajax'); 266 | }, 100); 267 | }); 268 | } 269 | } 270 | // ... 271 | ``` 272 | 273 | Async validator with debounce (example uses lodash debounce) 274 | ```js 275 | methods: { 276 | debounced: _.debounce(function (value, resolve, reject) { 277 | fetch('https://httpbin.org/get').then(function(response){ 278 | resolve(response.isValid); 279 | }); 280 | }, 500), 281 | customValidator (value) { 282 | return new Promise((resolve, reject) => { 283 | this.debounced(value, resolve, reject); 284 | }); 285 | } 286 | } 287 | ``` 288 | 289 | ### Reset state 290 | ``` 291 | 292 | 293 | resetState: function () { 294 | this.formstate._reset(); 295 | // or 296 | this.$refs.form.reset(); 297 | } 298 | ``` 299 | 300 | ### State classes 301 | As form and input validation states change, state classes are added and removed 302 | 303 | Possible form classes: 304 | ``` 305 | vf-form-dirty, vf-form-pristine, vf-form-valid, vf-form-invalid, vf-form-submitted, vf-form-focused- vf-form-pending 306 | ``` 307 | 308 | Possible input classes: 309 | ``` 310 | vf-dirty, vf-pristine, vf-valid, vf-invalid, vf-focused, vf-pending 311 | 312 | // also for every validation error, a class will be added, e.g. 313 | vf-invalid-required, vf-invalid-minlength, vf-invalid-max, etc 314 | ``` 315 | 316 | Input wrappers (e.g. the tag the `validate` component renders) will also get state classes, but with the `field` prefix, e.g. 317 | ``` 318 | vf-field-dirty, vf-field-pristine, vf-field-valid, vf-field-invalid, vf-field-focused, vf-field-pending 319 | ``` 320 | 321 | ### Custom components 322 | 323 | When writing custom form field components, e.g. `` you should trigger the `focus` and `blur` events after user interaction either by triggering native dom events on the root node of your component, or emitting Vue events (`this.$emit('focus)`) so the `validate` component can detect and set the `$dirty` and `$touched` states on the field. 324 | 325 | ### Component props 326 | 327 | #### vue-form 328 | * `state` Object on which form state is set 329 | * `tag` String, defaults to `form` 330 | * `show-messages` String, applies to all child `field-messages`, show errors dependant on form field state e.g. `$touched`, `$dirty || $touched`, '$touched || $submitted' 331 | 332 | #### validate 333 | * `state` Optional way of passing in the form state. If omitted form state will be found in the $parent 334 | * `custom` Object containing one or many custom validators. `{validatorName: validatorFunction}` 335 | * `tag` String which specifies what element tag should be rendered by the `validate` component, defaults to `span` 336 | * `auto-label`: Boolean, defaults to false. Automatically set `for` and `id` attributes of label and input elements found inside the `validate` component 337 | * `debounce` Number, defaults to none, which specifies the delay (milliseconds) before validation takes place. 338 | 339 | #### field-messages 340 | * `state` Optional way of passing in the form state. If omitted form state will be found in the $parent 341 | * `name` String which specifies the related field name 342 | * `tag` String, defaults to `div` 343 | * `show` String, show error dependant on form field state e.g. `$touched`, `$dirty || $touched`, '$touched || $submitted' 344 | * `auto-label` Boolean, defaults to false. Automatically set the `for` attribute of labels found inside the `field-messages` component 345 | 346 | #### field 347 | * `tag` String, defaults to `div` 348 | * `auto-label` Boolean, defaults to true. Automatically set `for` and `id` attributes of label and input elements found inside the `validate` component 349 | 350 | ### Config 351 | Set config options when using `Vue.use(VueForm, options)`, or when using a mixin `mixins: [new VueForm(options)]` defaults: 352 | 353 | ```js 354 | { 355 | validators: {}, 356 | formComponent: 'vueForm', 357 | formTag: 'form', 358 | messagesComponent: 'fieldMessages', 359 | messagesTag: 'div', 360 | showMessages: '', 361 | validateComponent: 'validate', 362 | validateTag: 'div', 363 | fieldComponent: 'field', 364 | fieldTag: 'div', 365 | formClasses: { 366 | dirty: 'vf-form-dirty', 367 | pristine: 'vf-form-pristine', 368 | valid: 'vf-form-valid', 369 | invalid: 'vf-form-invalid', 370 | touched: 'vf-form-touched', 371 | untouched: 'vf-form-untouched', 372 | focused: 'vf-form-focused', 373 | submitted: 'vf-form-submitted', 374 | pending: 'vf-form-pending' 375 | }, 376 | validateClasses: { 377 | dirty: 'vf-field-dirty', 378 | pristine: 'vf-field-pristine', 379 | valid: 'vf-field-valid', 380 | invalid: 'vf-field-invalid', 381 | touched: 'vf-field-touched', 382 | untouched: 'vf-field-untouched', 383 | focused: 'vf-field-focused', 384 | submitted: 'vf-field-submitted', 385 | pending: 'vf-field-pending' 386 | }, 387 | inputClasses: { 388 | dirty: 'vf-dirty', 389 | pristine: 'vf-pristine', 390 | valid: 'vf-valid', 391 | invalid: 'vf-invalid', 392 | touched: 'vf-touched', 393 | untouched: 'vf-untouched', 394 | focused: 'vf-focused', 395 | submitted: 'vf-submitted', 396 | pending: 'vf-pending' 397 | }, 398 | Promise: typeof Promise === 'function' ? Promise : null 399 | } 400 | ``` 401 | -------------------------------------------------------------------------------- /dist/vue-form.min.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):t.VueForm=e()}(this,function(){"use strict";function t(t,e){var n;return n={},F(n,t.dirty,e.$dirty),F(n,t.pristine,e.$pristine),F(n,t.valid,e.$valid),F(n,t.invalid,e.$invalid),F(n,t.touched,e.$touched),F(n,t.untouched,e.$untouched),F(n,t.focused,e.$focused),F(n,t.pending,e.$pending),F(n,t.submitted,e.$submitted),n}function e(t,e){t.classList?t.classList.add(e):t.className+=" "+e}function n(t,e){t.classList?t.classList.remove(e):t.className=t.className.replace(new RegExp("(^|\\b)"+e.split(" ").join("|")+"(\\b|$)","gi")," ")}function a(t){return t.model?t.model.value:t.directives.filter(function(t){return"model"===t.name})[0].value}function i(t,e){function n(t){for(var i=0;i=e},maxlength:function(t,e){return e>=t.length},pattern:function(t,e){return new RegExp("^"+e+"$").test(t)},min:function(t,e,n){return"number"==(n.data.attrs.type||"").toLowerCase()?+t>=+e:t>=e},max:function(t,e,n){return"number"==(n.data.attrs.type||"").toLowerCase()?+e>=+t:e>=t}},_={validators:g,formComponent:"VueForm",formTag:"form",messagesComponent:"FieldMessages",messagesTag:"div",showMessages:"",validateComponent:"Validate",validateTag:"div",fieldComponent:"Field",fieldTag:"div",formClasses:{dirty:"vf-form-dirty",pristine:"vf-form-pristine",valid:"vf-form-valid",invalid:"vf-form-invalid",touched:"vf-form-touched",untouched:"vf-form-untouched",focused:"vf-form-focused",submitted:"vf-form-submitted",pending:"vf-form-pending"},validateClasses:{dirty:"vf-field-dirty",pristine:"vf-field-pristine",valid:"vf-field-valid",invalid:"vf-field-invalid",touched:"vf-field-touched",untouched:"vf-field-untouched",focused:"vf-field-focused",submitted:"vf-field-submitted",pending:"vf-field-pending"},inputClasses:{dirty:"vf-dirty",pristine:"vf-pristine",valid:"vf-valid",invalid:"vf-invalid",touched:"vf-touched",untouched:"vf-untouched",focused:"vf-focused",submitted:"vf-submitted",pending:"vf-pending"},Promise:"function"==typeof Promise?Promise:null},y=function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")},O=function(){function t(t,e){for(var n=0;n 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | Field is OK 33 | required error 34 | 35 | 36 | 37 |
38 | 39 | 40 | 41 | 42 | required error 43 | minlength error 44 | 45 |
46 | 47 | 48 | 49 | 50 | Field is OK 51 | required error 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 |
  • 99 |
    100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | Sample 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | Sample 136 | 137 | 138 | 139 | 140 | 141 |
    142 | `, 143 | data: { 144 | hasSubmitted: false, 145 | formstate: {}, 146 | isRequired: true, 147 | minlength: 5, 148 | isCEnabled: true, 149 | asyncEnabled: false, 150 | model: { 151 | a: 'aaa', 152 | b: '', 153 | c: null, 154 | d: '', 155 | email: 'joe.doe@foo.com', 156 | number: 1, 157 | url: 'https://foo.bar.com', 158 | length: '12345', 159 | minmax: 5, 160 | pattern: '1234', 161 | custom: 'custom', 162 | custom2: 'custom2', 163 | multicheck: [], 164 | sample: '' 165 | } 166 | }, 167 | methods: { 168 | onSubmit() { 169 | this.hasSubmitted = true; 170 | }, 171 | customValidator(value) { 172 | return value === 'custom'; 173 | }, 174 | customValidatorAsync(value) { 175 | return new Promise((resolve, reject) => { 176 | setTimeout(() => { 177 | resolve(value === 'custom2'); 178 | }, 100); 179 | }); 180 | } 181 | } 182 | }); 183 | Vue.nextTick(done); 184 | }); 185 | 186 | afterEach(function(done) { 187 | vm.$destroy(); 188 | Vue.nextTick(done); 189 | }); 190 | 191 | it('should create a form tag and listen to submit event', () => { 192 | expect(vm.$el.tagName).toBe('FORM'); 193 | vm.$el.querySelector('button[type=submit]').click(); 194 | expect(vm.hasSubmitted).toBe(true); 195 | }); 196 | 197 | it('should populate formstate', () => { 198 | expect(vm.formstate.$valid).toBeDefined(); 199 | }); 200 | 201 | it('should automatically find parent formstate and also work by passing state as a prop', () => { 202 | expect(vm.formstate.a).toBeDefined(true); 203 | expect(vm.formstate.b).toBeDefined(true); 204 | }); 205 | 206 | it('should validate required fields', (done) => { 207 | expect(vm.formstate.a.$valid).toBe(true); 208 | expect(vm.formstate.a.$error.required).toBeUndefined(); 209 | vm.model.a = ''; 210 | vm.$nextTick(() => { 211 | expect(vm.formstate.a.$valid).toBe(false); 212 | expect(vm.formstate.a.$error.required).toBe(true); 213 | done(); 214 | }); 215 | }); 216 | 217 | it('should not show other validators if required validator fails', (done) => { 218 | expect(vm.formstate.b.$error.required).toBe(true); 219 | expect(vm.formstate.b.$error.minlength).toBeUndefined(); 220 | vm.model.b = 'acb'; 221 | vm.$nextTick(() => { 222 | expect(vm.formstate.b.$error.required).toBeUndefined(); 223 | expect(vm.formstate.b.$error.minlength).toBe(true); 224 | done(); 225 | }); 226 | }); 227 | 228 | it('should react to bound validators', (done) => { 229 | expect(vm.formstate.c.$error.required).toBe(true); 230 | vm.isRequired = false; 231 | vm.$nextTick(() => { 232 | expect(vm.formstate.c.$error.required).toBeUndefined(); 233 | vm.model.c = '1234'; 234 | vm.$nextTick(() => { 235 | expect(vm.formstate.c.$error.minlength).toBe(true); 236 | vm.minlength = 2; 237 | vm.$nextTick(() => { 238 | expect(vm.formstate.c.$error.minlength).toBeUndefined(); 239 | done(); 240 | }); 241 | }); 242 | }); 243 | }); 244 | 245 | it('should validate [type=email]', (done) => { 246 | expect(vm.formstate.email.$valid).toBe(true); 247 | expect(vm.formstate.email.$error.email).toBeUndefined(); 248 | vm.model.email = 'not a real email'; 249 | vm.$nextTick(() => { 250 | expect(vm.formstate.email.$valid).toBe(false); 251 | expect(vm.formstate.email.$error.email).toBe(true); 252 | done(); 253 | }); 254 | }); 255 | 256 | it('should validate [type=number]', (done) => { 257 | expect(vm.formstate.number.$valid).toBe(true); 258 | expect(vm.formstate.number.$error.number).toBeUndefined(); 259 | vm.model.number = 'a string'; 260 | vm.$nextTick(() => { 261 | expect(vm.formstate.number.$valid).toBe(false); 262 | expect(vm.formstate.number.$error.number).toBe(true); 263 | done(); 264 | }); 265 | }); 266 | 267 | it('should validate required [type=number] === 0', (done) => { 268 | vm.model.number = 0; 269 | vm.$nextTick(() => { 270 | expect(vm.formstate.number.$valid).toBe(true); 271 | expect(vm.formstate.number.$error.number).toBeUndefined(); 272 | expect(vm.formstate.number.$error.required).toBeUndefined(); 273 | done(); 274 | }); 275 | }); 276 | 277 | it('should validate [type=url]', (done) => { 278 | expect(vm.formstate.url.$valid).toBe(true); 279 | expect(vm.formstate.url.$error.url).toBeUndefined(); 280 | vm.model.url = 'not a real url'; 281 | vm.$nextTick(() => { 282 | expect(vm.formstate.url.$valid).toBe(false); 283 | expect(vm.formstate.url.$error.url).toBe(true); 284 | done(); 285 | }); 286 | }); 287 | 288 | it('should validate [minlength]', (done) => { 289 | expect(vm.formstate.length.$valid).toBe(true); 290 | expect(vm.formstate.length.$error.minlength).toBeUndefined(); 291 | vm.model.length = '1'; 292 | vm.$nextTick(() => { 293 | expect(vm.formstate.length.$valid).toBe(false); 294 | expect(vm.formstate.length.$error.minlength).toBe(true); 295 | done(); 296 | }); 297 | }); 298 | 299 | it('should validate [maxlength]', (done) => { 300 | expect(vm.formstate.length.$valid).toBe(true); 301 | expect(vm.formstate.length.$error.maxlength).toBeUndefined(); 302 | vm.model.length = '1234567890'; 303 | vm.$nextTick(() => { 304 | expect(vm.formstate.length.$valid).toBe(false); 305 | expect(vm.formstate.length.$error.maxlength).toBe(true); 306 | done(); 307 | }); 308 | }); 309 | 310 | it('should validate [number][min]', (done) => { 311 | expect(vm.formstate.minmax.$valid).toBe(true); 312 | expect(vm.formstate.minmax.$error.min).toBeUndefined(); 313 | vm.model.minmax = 1; 314 | vm.$nextTick(() => { 315 | expect(vm.formstate.minmax.$valid).toBe(false); 316 | expect(vm.formstate.minmax.$error.min).toBe(true); 317 | done(); 318 | }); 319 | }); 320 | 321 | it('should validate [number][max]', (done) => { 322 | expect(vm.formstate.minmax.$valid).toBe(true); 323 | expect(vm.formstate.minmax.$error.max).toBeUndefined(); 324 | vm.model.minmax = 100; 325 | vm.$nextTick(() => { 326 | expect(vm.formstate.minmax.$valid).toBe(false); 327 | expect(vm.formstate.minmax.$error.max).toBe(true); 328 | done(); 329 | }); 330 | }); 331 | 332 | it('should validate [pattern]', (done) => { 333 | expect(vm.formstate.pattern.$valid).toBe(true); 334 | expect(vm.formstate.pattern.$error.pattern).toBeUndefined(); 335 | vm.model.pattern = 'not four numbers'; 336 | vm.$nextTick(() => { 337 | expect(vm.formstate.pattern.$valid).toBe(false); 338 | expect(vm.formstate.pattern.$error.pattern).toBe(true); 339 | done(); 340 | }); 341 | }); 342 | 343 | it('should validate custom validators', function(done) { 344 | expect(vm.formstate.custom.$valid).toBe(true); 345 | expect(vm.formstate.custom.$error.customKey).toBeUndefined(); 346 | vm.model.custom = 'custom invalid value'; 347 | vm.$nextTick(function() { 348 | expect(vm.formstate.custom.$valid).toBe(false); 349 | expect(vm.formstate.custom.$error.customKey).toBe(true); 350 | done(); 351 | }); 352 | }); 353 | 354 | it('should validate async validators', function(done) { 355 | vm.asyncEnabled = true; 356 | vm.$nextTick(() => { 357 | expect(vm.formstate.custom2.$pending).toBe(true); 358 | expect(vm.formstate.custom2.$valid).toBe(true); 359 | setTimeout(() => { 360 | expect(vm.formstate.custom2.$pending).toBe(false); 361 | expect(vm.formstate.custom2.$valid).toBe(true); 362 | vm.model.custom2 = 'foo'; 363 | setTimeout(() => { 364 | expect(vm.formstate.custom2.$pending).toBe(false); 365 | expect(vm.formstate.custom2.$valid).toBe(false); 366 | done(); 367 | }, 150); 368 | }, 150); 369 | }); 370 | }); 371 | 372 | it('should hyphenate camelcase validator names', function(done) { 373 | vm.model.custom = 'custom invalid value'; 374 | vm.$nextTick(function() { 375 | expect(vm.$el.querySelector('[name="custom"]').classList.contains('vf-invalid-custom-key')).toBe(true); 376 | done(); 377 | }); 378 | }); 379 | 380 | it('should validate checkbox array', (done) => { 381 | expect(vm.formstate.multicheck.$valid).toBe(false); 382 | expect(vm.formstate.multicheck.$error.required).toBe(true); 383 | vm.$el.querySelector('[name=multicheck]').click(); 384 | vm.$nextTick(() => { 385 | expect(vm.formstate.multicheck.$valid).toBe(true); 386 | expect(vm.formstate.multicheck.$error.required).toBeUndefined(); 387 | done(); 388 | }); 389 | }); 390 | 391 | it('should set $dirty when model changed by user', (done) => { 392 | expect(vm.formstate.a.$dirty).toBe(false); 393 | // non user change 394 | vm.model.a = 'abc'; 395 | 396 | vm.$nextTick(() => { 397 | expect(vm.formstate.a.$dirty).toBe(false); 398 | 399 | // user interacted with field then changed text 400 | vm.$el.querySelector('[name=a]').focus(); 401 | vm.model.a = 'abcc'; 402 | 403 | vm.$nextTick(() => { 404 | expect(vm.formstate.a._hasFocused).toBe(true); 405 | expect(vm.formstate.a.$focused).toBe(true); 406 | expect(vm.formstate.a.$dirty).toBe(true); 407 | done(); 408 | }); 409 | 410 | }); 411 | 412 | }); 413 | 414 | it('should set $touched on blur', (done) => { 415 | expect(vm.formstate.a.$touched).toBe(false); 416 | vm.$el.querySelector('[name=a]').focus(); 417 | vm.$el.querySelector('[name=a]').blur(); 418 | vm.$nextTick(() => { 419 | expect(vm.formstate.a.$touched).toBe(true); 420 | done(); 421 | }); 422 | }); 423 | 424 | it('should set $focused to false on blur', (done) => { 425 | expect(vm.formstate.a.$focused).toBe(false); 426 | expect(vm.formstate.a._hasFocused).toBe(false); 427 | vm.$el.querySelector('[name=a]').focus(); 428 | expect(vm.formstate.a.$focused).toBe(true); 429 | expect(vm.formstate.a._hasFocused).toBe(true); 430 | vm.$el.querySelector('[name=a]').blur(); 431 | vm.$nextTick(() => { 432 | expect(vm.formstate.a.$focused).toBe(false); 433 | expect(vm.formstate.a._hasFocused).toBe(true); 434 | done(); 435 | }); 436 | }); 437 | 438 | it('should set form properties when child properties change', (done) => { 439 | // starts off invalid 440 | expect(vm.formstate.$valid).toBe(false); 441 | expect(vm.formstate.$invalid).toBe(true); 442 | expect(vm.formstate.$dirty).toBe(false); 443 | expect(vm.formstate.$pristine).toBe(true); 444 | expect(vm.formstate.$touched).toBe(false); 445 | expect(vm.formstate.$untouched).toBe(true); 446 | expect(vm.formstate.$focused).toBe(false) 447 | expect(Object.keys(vm.formstate.$error).length).toBe(5); 448 | 449 | // emulate user interaction 450 | vm.$el.querySelector('[name=b]').focus(); 451 | 452 | vm.$nextTick(() => { 453 | expect(vm.formstate.$focused).toBe(true) 454 | 455 | vm.$el.querySelector('[name=b]').blur(); 456 | 457 | setValid(); 458 | 459 | vm.$nextTick(() => { 460 | expect(vm.formstate.$valid).toBe(true); 461 | expect(vm.formstate.$invalid).toBe(false); 462 | expect(vm.formstate.$dirty).toBe(true); 463 | expect(vm.formstate.$pristine).toBe(false); 464 | expect(vm.formstate.$touched).toBe(true); 465 | expect(vm.formstate.$untouched).toBe(false); 466 | expect(vm.formstate.$focused).toBe(false); 467 | expect(Object.keys(vm.formstate.$error).length).toBe(0); 468 | done(); 469 | }); 470 | }); 471 | }); 472 | 473 | it('should add and remove state classes', (done) => { 474 | 475 | // starts off invalid, pristine and untouched 476 | expect(vm.$el.classList.contains('vf-form-pristine')).toBe(true); 477 | expect(vm.$el.classList.contains('vf-form-invalid')).toBe(true); 478 | expect(vm.$el.classList.contains('vf-form-untouched')).toBe(true); 479 | expect(vm.$el.classList.contains('vf-form-focused')).toBe(false); 480 | 481 | const input = vm.$el.querySelector('[name=b]'); 482 | 483 | expect(input.classList.contains('vf-pristine')).toBe(true); 484 | expect(input.classList.contains('vf-invalid')).toBe(true); 485 | expect(input.classList.contains('vf-untouched')).toBe(true); 486 | expect(input.classList.contains('vf-focused')).toBe(false); 487 | expect(input.classList.contains('vf-invalid-required')).toBe(true); 488 | 489 | // set valid and interacted 490 | input.focus(); 491 | 492 | vm.$nextTick(() => { 493 | expect(vm.$el.classList.contains('vf-form-focused')).toBe(true); 494 | expect(input.classList.contains('vf-focused')).toBe(true); 495 | 496 | input.blur(); 497 | setValid(); 498 | 499 | vm.$nextTick(() => { 500 | expect(vm.$el.classList.contains('vf-form-dirty')).toBe(true); 501 | expect(vm.$el.classList.contains('vf-form-valid')).toBe(true); 502 | expect(vm.$el.classList.contains('vf-form-touched')).toBe(true); 503 | expect(vm.$el.classList.contains('vf-form-focused')).toBe(false); 504 | expect(input.classList.contains('vf-dirty')).toBe(true); 505 | expect(input.classList.contains('vf-valid')).toBe(true); 506 | expect(input.classList.contains('vf-touched')).toBe(true); 507 | expect(input.classList.contains('vf-focused')).toBe(false); 508 | expect(input.classList.contains('vf-invalid-required')).toBe(false); 509 | done(); 510 | }); 511 | }); 512 | }); 513 | 514 | it('should add and remove a field from overall state when inside v-if', (done) => { 515 | setValid(); 516 | vm.model.c = ''; 517 | 518 | vm.$nextTick(() => { 519 | expect(vm.formstate.c).toBeDefined(); 520 | expect(vm.formstate.c.$invalid).toBe(true); 521 | expect(vm.formstate.$invalid).toBe(true); 522 | 523 | vm.isCEnabled = false; 524 | 525 | vm.$nextTick(() => { 526 | expect(vm.formstate.c).toBeUndefined(); 527 | expect(vm.formstate.$valid).toBe(true); 528 | done(); 529 | }); 530 | }); 531 | }); 532 | 533 | it('should set tie labels and inputs together with auto-label', (done) => { 534 | vm.$nextTick(() => { 535 | expect(vm.$el.querySelector('#auto-label-a > label').getAttribute('for')).toBeDefined(); 536 | expect(vm.$el.querySelector('#auto-label-b > label').getAttribute('for')).toBeDefined(); 537 | expect(vm.$el.querySelector('#auto-label-c > label').getAttribute('for')).toBeDefined(); 538 | expect(vm.$el.querySelector('#auto-label-c > label').getAttribute('for')).toBe('existing'); 539 | expect(vm.$el.querySelector('#auto-label-d').getAttribute('for')).toBeDefined(); 540 | 541 | expect(vm.$el.querySelector('#field-auto-label-a > label').getAttribute('for')).toBeDefined(); 542 | expect(vm.$el.querySelector('#field-auto-label-b > label').getAttribute('for')).toBeDefined(); 543 | expect(vm.$el.querySelector('#field-auto-label-c > label').getAttribute('for')).toBeDefined(); 544 | expect(vm.$el.querySelector('#field-auto-label-c > label').getAttribute('for')).toBe('existing'); 545 | expect(vm.$el.querySelector('#field-auto-label-d').getAttribute('for')).toBeDefined(); 546 | 547 | expect(vm.$el.querySelector('#field-messages-auto-label-a').getAttribute('for')).toBeDefined(); 548 | expect(vm.$el.querySelector('#field-messages-auto-label-b > label').getAttribute('for')).toBeDefined(); 549 | done(); 550 | 551 | }); 552 | }); 553 | 554 | it('should show the correct field messages', (done) => { 555 | vm.$nextTick(() => { 556 | 557 | // field b 558 | expect(vm.$el.querySelector('#message-b')).toBeNull(); 559 | expect(vm.$el.querySelector('#message-b-ok')).toBeNull(); 560 | vm.model.b = '123'; 561 | 562 | // field c 563 | expect(vm.$el.querySelector('#message')).not.toBeNull(); 564 | expect(vm.$el.querySelector('#minlength-message')).toBeNull(); 565 | vm.model.c = '123'; 566 | 567 | vm.$nextTick(() => { 568 | // field b sould still be null 569 | expect(vm.$el.querySelector('#message-b')).toBeNull(); 570 | vm.$el.querySelector('[name=b]').focus(); 571 | vm.$el.querySelector('[name=b]').blur(); 572 | vm.model.b = '123456'; 573 | 574 | // field c 575 | expect(vm.$el.querySelector('#message')).toBeNull(); 576 | expect(vm.$el.querySelector('#minlength-message')).not.toBeNull(); 577 | vm.model.c = '123456'; 578 | 579 | vm.$nextTick(() => { 580 | 581 | // field b 582 | expect(vm.$el.querySelector('#message-b-ok')).not.toBeNull(); 583 | expect(vm.$el.querySelector('#message-b')).toBeNull(); 584 | vm.model.b = ''; 585 | 586 | // field c 587 | expect(vm.$el.querySelector('#message')).toBeNull(); 588 | expect(vm.$el.querySelector('#minlength-message')).toBeNull(); 589 | 590 | vm.$nextTick(()=>{ 591 | // field b 592 | expect(vm.$el.querySelector('#message-b-ok')).toBeNull(); 593 | expect(vm.$el.querySelector('#message-b')).not.toBeNull(); 594 | done(); 595 | }); 596 | 597 | }); 598 | }); 599 | }); 600 | }); 601 | 602 | it('should work with v-for', function(done) { 603 | 604 | vm.$destroy(); 605 | 606 | const div = document.createElement('div'); 607 | document.body.appendChild(div); 608 | 609 | new Vue({ 610 | mixins: [VueForm], 611 | el: div, 612 | template: ` 613 | 614 | 615 | {{input.label}}
    616 | 617 |
    618 |
    619 | `, 620 | data: { 621 | inputs: [{ 622 | label: 'Input A', 623 | name: 'a', 624 | model: '', 625 | required: true 626 | }, { 627 | label: 'Input B', 628 | name: 'b', 629 | model: '', 630 | required: false 631 | }, { 632 | label: 'Input C', 633 | name: 'c', 634 | model: 'abc', 635 | required: true 636 | }], 637 | formstate: {} 638 | }, 639 | mounted: function() { 640 | this.$nextTick(() => { 641 | expect(this.formstate.a.$valid).toBe(false); 642 | expect(this.formstate.b.$valid).toBe(true); 643 | expect(this.formstate.c.$valid).toBe(true); 644 | done(); 645 | }); 646 | } 647 | }); 648 | 649 | }); 650 | 651 | it('should work with components, even if some validation attributes are also component props', function(done) { 652 | 653 | vm.$destroy(); 654 | 655 | const div = document.createElement('div'); 656 | document.body.appendChild(div); 657 | 658 | new Vue({ 659 | mixins: [VueForm], 660 | el: div, 661 | components: { 662 | test: { 663 | props: ['value'], 664 | template: '' 665 | }, 666 | test2: { 667 | props: ['value', 'required', 'name'], 668 | template: '', 669 | mounted () { 670 | this.$el.querySelector('input').addEventListener('focus', this.$emit('focus')); 671 | this.$el.querySelector('input').addEventListener('blur', this.$emit('blur')); 672 | } 673 | } 674 | }, 675 | template: ` 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | `, 685 | data: { 686 | model: { 687 | test: '', 688 | test2: '' 689 | }, 690 | formstate: {} 691 | }, 692 | mounted: function() { 693 | expect(this.formstate.test).toBeDefined(); 694 | expect(this.formstate.test.$error.required).toBe(true); 695 | expect(this.formstate.test2).toBeDefined(); 696 | expect(this.formstate.test2.$error.required).toBe(true); 697 | expect(this.formstate.test2.$dirty).toBe(false); 698 | this.$el.querySelector('#input').focus(); 699 | this.$el.querySelector('#input').blur(); 700 | 701 | this.model.test = 'xxx'; 702 | this.model.test2 = 'xxx'; 703 | 704 | this.$nextTick(() => { 705 | expect(this.formstate.test.$error.required).toBeUndefined(); 706 | expect(this.formstate.test.$dirty).toBe(false); 707 | expect(this.formstate.test2.$dirty).toBe(true); 708 | expect(this.formstate.test2.$touched).toBe(true); 709 | expect(this.formstate.test2.$focused).toBe(false); 710 | expect(this.formstate.test2._hasFocused).toBe(true); 711 | done(); 712 | }); 713 | } 714 | }); 715 | 716 | }); 717 | 718 | it('should be configurable', function(done) { 719 | 720 | vm.$destroy(); 721 | 722 | const div = document.createElement('div'); 723 | document.body.appendChild(div); 724 | 725 | var optionsA = { 726 | formTag: 'article', 727 | validateTag: 'section', 728 | messagesTag: 'ul', 729 | inputClasses: { 730 | invalid: 'form-control-danger', 731 | valid: 'form-control-success', 732 | focused: 'input-focused-class' 733 | }, 734 | formClasses: { 735 | invalid: 'foo', 736 | valid: 'bar', 737 | focused: 'form-focused-class' 738 | }, 739 | validateClasses: { 740 | invalid: 'baz', 741 | valid: 'jaz', 742 | focused: 'validate-focused-class' 743 | }, 744 | validators: { 745 | 'foo-validator' () { return false }, 746 | 'bar-validator' () { return false } 747 | } 748 | } 749 | 750 | new Vue({ 751 | mixins: [new VueForm(optionsA)], 752 | el: div, 753 | template: ` 754 | 755 | 756 | 757 | 758 | 759 | 760 |
  • 761 |
  • 762 |
    763 | 764 |
    765 | `, 766 | data: { 767 | name: '', 768 | formstate: {} 769 | }, 770 | mounted: function() { 771 | this.name = 'abc'; 772 | const form = this.$el; 773 | 774 | this.$nextTick(() => { 775 | expect(form.querySelector('#foo-message')).not.toBeNull(); 776 | expect(form.querySelector('#bar-message')).not.toBeNull(); 777 | expect(form.tagName).toBe('ARTICLE'); 778 | expect(form.querySelector('section')).not.toBeNull(); 779 | expect(form.querySelector('.messages').tagName).toBe('UL'); 780 | expect(form.querySelector('.form-control-danger')).not.toBeNull(); 781 | expect(form.className.indexOf('foo')).not.toBe(-1); 782 | expect(form.querySelector('section.baz')).not.toBeNull(); 783 | expect(form.classList.contains('form-focused-class')).toBe(false); 784 | expect(form.querySelector('section.validate-focused-class')).toBeNull(); 785 | expect(form.querySelector('.input-focused-class')).toBeNull(); 786 | 787 | form.querySelector('input').focus(); 788 | 789 | this.$nextTick(() => { 790 | expect(form.classList.contains('form-focused-class')).toBe(true); 791 | expect(form.querySelector('section.validate-focused-class')).not.toBeNull(); 792 | expect(form.querySelector('.input-focused-class')).not.toBeNull(); 793 | 794 | done(); 795 | }); 796 | }); 797 | } 798 | }); 799 | 800 | }); 801 | 802 | }); 803 | -------------------------------------------------------------------------------- /dist/vue-form.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : 3 | typeof define === 'function' && define.amd ? define(factory) : 4 | (global.VueForm = factory()); 5 | }(this, (function () { 'use strict'; 6 | 7 | var emailRegExp = /^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; // from angular 8 | var urlRegExp = /^(http\:\/\/|https\:\/\/)(.{4,})$/; 9 | 10 | var email = function email(value, attrValue, vnode) { 11 | return emailRegExp.test(value); 12 | }; 13 | email._allowNulls = true; 14 | 15 | var number = function number(value, attrValue, vnode) { 16 | return !isNaN(value); 17 | }; 18 | number._allowNulls = true; 19 | 20 | var url = function url(value, attrValue, vnode) { 21 | return urlRegExp.test(value); 22 | }; 23 | url._allowNulls = true; 24 | 25 | var validators = { 26 | email: email, 27 | number: number, 28 | url: url, 29 | required: function required(value, attrValue, vnode) { 30 | if (attrValue === false) { 31 | return true; 32 | } 33 | 34 | if (value === 0) { 35 | return true; 36 | } 37 | 38 | if (vnode.data.attrs && typeof vnode.data.attrs.bool !== 'undefined' || vnode.componentOptions && vnode.componentOptions.propsData && typeof vnode.componentOptions.propsData.bool !== 'undefined') { 39 | // bool attribute is present, allow false pass validation 40 | if (value === false) { 41 | return true; 42 | } 43 | } 44 | 45 | if (Array.isArray(value)) { 46 | return !!value.length; 47 | } 48 | return !!value; 49 | }, 50 | minlength: function minlength(value, length) { 51 | return value.length >= length; 52 | }, 53 | maxlength: function maxlength(value, length) { 54 | return length >= value.length; 55 | }, 56 | pattern: function pattern(value, _pattern) { 57 | var patternRegExp = new RegExp('^' + _pattern + '$'); 58 | return patternRegExp.test(value); 59 | }, 60 | min: function min(value, _min, vnode) { 61 | if ((vnode.data.attrs.type || '').toLowerCase() == 'number') { 62 | return +value >= +_min; 63 | } 64 | return value >= _min; 65 | }, 66 | max: function max(value, _max, vnode) { 67 | if ((vnode.data.attrs.type || '').toLowerCase() == 'number') { 68 | return +_max >= +value; 69 | } 70 | return _max >= value; 71 | } 72 | }; 73 | 74 | var config = { 75 | validators: validators, 76 | formComponent: 'VueForm', 77 | formTag: 'form', 78 | messagesComponent: 'FieldMessages', 79 | messagesTag: 'div', 80 | showMessages: '', 81 | validateComponent: 'Validate', 82 | validateTag: 'div', 83 | fieldComponent: 'Field', 84 | fieldTag: 'div', 85 | formClasses: { 86 | dirty: 'vf-form-dirty', 87 | pristine: 'vf-form-pristine', 88 | valid: 'vf-form-valid', 89 | invalid: 'vf-form-invalid', 90 | touched: 'vf-form-touched', 91 | untouched: 'vf-form-untouched', 92 | focused: 'vf-form-focused', 93 | submitted: 'vf-form-submitted', 94 | pending: 'vf-form-pending' 95 | }, 96 | validateClasses: { 97 | dirty: 'vf-field-dirty', 98 | pristine: 'vf-field-pristine', 99 | valid: 'vf-field-valid', 100 | invalid: 'vf-field-invalid', 101 | touched: 'vf-field-touched', 102 | untouched: 'vf-field-untouched', 103 | focused: 'vf-field-focused', 104 | submitted: 'vf-field-submitted', 105 | pending: 'vf-field-pending' 106 | }, 107 | inputClasses: { 108 | dirty: 'vf-dirty', 109 | pristine: 'vf-pristine', 110 | valid: 'vf-valid', 111 | invalid: 'vf-invalid', 112 | touched: 'vf-touched', 113 | untouched: 'vf-untouched', 114 | focused: 'vf-focused', 115 | submitted: 'vf-submitted', 116 | pending: 'vf-pending' 117 | }, 118 | Promise: typeof Promise === 'function' ? Promise : null 119 | }; 120 | 121 | var classCallCheck = function (instance, Constructor) { 122 | if (!(instance instanceof Constructor)) { 123 | throw new TypeError("Cannot call a class as a function"); 124 | } 125 | }; 126 | 127 | var createClass = function () { 128 | function defineProperties(target, props) { 129 | for (var i = 0; i < props.length; i++) { 130 | var descriptor = props[i]; 131 | descriptor.enumerable = descriptor.enumerable || false; 132 | descriptor.configurable = true; 133 | if ("value" in descriptor) descriptor.writable = true; 134 | Object.defineProperty(target, descriptor.key, descriptor); 135 | } 136 | } 137 | 138 | return function (Constructor, protoProps, staticProps) { 139 | if (protoProps) defineProperties(Constructor.prototype, protoProps); 140 | if (staticProps) defineProperties(Constructor, staticProps); 141 | return Constructor; 142 | }; 143 | }(); 144 | 145 | 146 | 147 | 148 | 149 | var defineProperty = function (obj, key, value) { 150 | if (key in obj) { 151 | Object.defineProperty(obj, key, { 152 | value: value, 153 | enumerable: true, 154 | configurable: true, 155 | writable: true 156 | }); 157 | } else { 158 | obj[key] = value; 159 | } 160 | 161 | return obj; 162 | }; 163 | 164 | 165 | 166 | var inherits = function (subClass, superClass) { 167 | if (typeof superClass !== "function" && superClass !== null) { 168 | throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); 169 | } 170 | 171 | subClass.prototype = Object.create(superClass && superClass.prototype, { 172 | constructor: { 173 | value: subClass, 174 | enumerable: false, 175 | writable: true, 176 | configurable: true 177 | } 178 | }); 179 | if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; 180 | }; 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | var possibleConstructorReturn = function (self, call) { 193 | if (!self) { 194 | throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); 195 | } 196 | 197 | return call && (typeof call === "object" || typeof call === "function") ? call : self; 198 | }; 199 | 200 | function getClasses(classConfig, state) { 201 | var _ref; 202 | 203 | return _ref = {}, defineProperty(_ref, classConfig.dirty, state.$dirty), defineProperty(_ref, classConfig.pristine, state.$pristine), defineProperty(_ref, classConfig.valid, state.$valid), defineProperty(_ref, classConfig.invalid, state.$invalid), defineProperty(_ref, classConfig.touched, state.$touched), defineProperty(_ref, classConfig.untouched, state.$untouched), defineProperty(_ref, classConfig.focused, state.$focused), defineProperty(_ref, classConfig.pending, state.$pending), defineProperty(_ref, classConfig.submitted, state.$submitted), _ref; 204 | } 205 | 206 | function addClass(el, className) { 207 | if (el.classList) { 208 | el.classList.add(className); 209 | } else { 210 | el.className += ' ' + className; 211 | } 212 | } 213 | 214 | function removeClass(el, className) { 215 | if (el.classList) { 216 | el.classList.remove(className); 217 | } else { 218 | el.className = el.className.replace(new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'), ' '); 219 | } 220 | } 221 | 222 | function vModelValue(data) { 223 | if (data.model) { 224 | return data.model.value; 225 | } 226 | return data.directives.filter(function (v) { 227 | return v.name === 'model'; 228 | })[0].value; 229 | } 230 | 231 | function getVModelAndLabel(nodes, config) { 232 | var foundVnodes = { 233 | vModel: [], 234 | label: null, 235 | messages: null 236 | }; 237 | 238 | if (!nodes) { 239 | return foundVnodes; 240 | } 241 | 242 | function traverse(nodes) { 243 | for (var i = 0; i < nodes.length; i++) { 244 | var node = nodes[i]; 245 | 246 | if (node.componentOptions) { 247 | if (node.componentOptions.tag === hyphenate(config.messagesComponent)) { 248 | foundVnodes.messages = node; 249 | } 250 | } 251 | 252 | if (node.tag === 'label' && !foundVnodes.label) { 253 | foundVnodes.label = node; 254 | } 255 | 256 | if (node.data) { 257 | if (node.data.model) { 258 | // model check has to come first. If a component has 259 | // a directive and v-model, the directive will be in .directives 260 | // and v-modelstored in .model 261 | foundVnodes.vModel.push(node); 262 | } else if (node.data.directives) { 263 | var match = node.data.directives.filter(function (v) { 264 | return v.name === 'model'; 265 | }); 266 | if (match.length) { 267 | foundVnodes.vModel.push(node); 268 | } 269 | } 270 | } 271 | if (node.children) { 272 | traverse(node.children); 273 | } else if (node.componentOptions && node.componentOptions.children) { 274 | traverse(node.componentOptions.children); 275 | } 276 | } 277 | } 278 | 279 | traverse(nodes); 280 | 281 | return foundVnodes; 282 | } 283 | 284 | function getName(vnode) { 285 | if (vnode.data && vnode.data.attrs && vnode.data.attrs.name) { 286 | return vnode.data.attrs.name; 287 | } else if (vnode.componentOptions && vnode.componentOptions.propsData && vnode.componentOptions.propsData.name) { 288 | return vnode.componentOptions.propsData.name; 289 | } 290 | } 291 | 292 | var hyphenateRE = /([^-])([A-Z])/g; 293 | function hyphenate(str) { 294 | return str.replace(hyphenateRE, '$1-$2').replace(hyphenateRE, '$1-$2').toLowerCase(); 295 | } 296 | 297 | function randomId() { 298 | return Math.random().toString(36).substr(2, 10); 299 | } 300 | 301 | // https://davidwalsh.name/javascript-debounce-function 302 | function debounce(func, wait, immediate) { 303 | var timeout; 304 | return function () { 305 | var context = this, 306 | args = arguments; 307 | var later = function later() { 308 | timeout = null; 309 | if (!immediate) func.apply(context, args); 310 | }; 311 | var callNow = immediate && !timeout; 312 | clearTimeout(timeout); 313 | timeout = setTimeout(later, wait); 314 | if (callNow) func.apply(context, args); 315 | }; 316 | } 317 | 318 | function isShallowObjectDifferent(a, b) { 319 | var aValue = ''; 320 | var bValue = ''; 321 | Object.keys(a).sort().filter(function (v) { 322 | return typeof a[v] !== 'function'; 323 | }).forEach(function (v) { 324 | return aValue += a[v]; 325 | }); 326 | Object.keys(b).sort().filter(function (v) { 327 | return typeof a[v] !== 'function'; 328 | }).forEach(function (v) { 329 | return bValue += b[v]; 330 | }); 331 | return aValue !== bValue; 332 | } 333 | 334 | var vueFormConfig = 'VueFormProviderConfig' + randomId(); 335 | var vueFormState = 'VueFormProviderState' + randomId(); 336 | var vueFormParentForm = 'VueFormProviderParentForm' + randomId(); 337 | 338 | var hasOwn = Object.prototype.hasOwnProperty; 339 | var toStr = Object.prototype.toString; 340 | var defineProperty$1 = Object.defineProperty; 341 | var gOPD = Object.getOwnPropertyDescriptor; 342 | 343 | var isArray = function isArray(arr) { 344 | if (typeof Array.isArray === 'function') { 345 | return Array.isArray(arr); 346 | } 347 | 348 | return toStr.call(arr) === '[object Array]'; 349 | }; 350 | 351 | var isPlainObject = function isPlainObject(obj) { 352 | if (!obj || toStr.call(obj) !== '[object Object]') { 353 | return false; 354 | } 355 | 356 | var hasOwnConstructor = hasOwn.call(obj, 'constructor'); 357 | var hasIsPrototypeOf = obj.constructor && obj.constructor.prototype && hasOwn.call(obj.constructor.prototype, 'isPrototypeOf'); 358 | // Not own constructor property must be Object 359 | if (obj.constructor && !hasOwnConstructor && !hasIsPrototypeOf) { 360 | return false; 361 | } 362 | 363 | // Own properties are enumerated firstly, so to speed up, 364 | // if last one is own, then all properties are own. 365 | var key; 366 | for (key in obj) { /**/ } 367 | 368 | return typeof key === 'undefined' || hasOwn.call(obj, key); 369 | }; 370 | 371 | // If name is '__proto__', and Object.defineProperty is available, define __proto__ as an own property on target 372 | var setProperty = function setProperty(target, options) { 373 | if (defineProperty$1 && options.name === '__proto__') { 374 | defineProperty$1(target, options.name, { 375 | enumerable: true, 376 | configurable: true, 377 | value: options.newValue, 378 | writable: true 379 | }); 380 | } else { 381 | target[options.name] = options.newValue; 382 | } 383 | }; 384 | 385 | // Return undefined instead of __proto__ if '__proto__' is not an own property 386 | var getProperty = function getProperty(obj, name) { 387 | if (name === '__proto__') { 388 | if (!hasOwn.call(obj, name)) { 389 | return void 0; 390 | } else if (gOPD) { 391 | // In early versions of node, obj['__proto__'] is buggy when obj has 392 | // __proto__ as an own property. Object.getOwnPropertyDescriptor() works. 393 | return gOPD(obj, name).value; 394 | } 395 | } 396 | 397 | return obj[name]; 398 | }; 399 | 400 | var extend = function extend() { 401 | var options, name, src, copy, copyIsArray, clone; 402 | var target = arguments[0]; 403 | var i = 1; 404 | var length = arguments.length; 405 | var deep = false; 406 | 407 | // Handle a deep copy situation 408 | if (typeof target === 'boolean') { 409 | deep = target; 410 | target = arguments[1] || {}; 411 | // skip the boolean and the target 412 | i = 2; 413 | } 414 | if (target == null || (typeof target !== 'object' && typeof target !== 'function')) { 415 | target = {}; 416 | } 417 | 418 | for (; i < length; ++i) { 419 | options = arguments[i]; 420 | // Only deal with non-null/undefined values 421 | if (options != null) { 422 | // Extend the base object 423 | for (name in options) { 424 | src = getProperty(target, name); 425 | copy = getProperty(options, name); 426 | 427 | // Prevent never-ending loop 428 | if (target !== copy) { 429 | // Recurse if we're merging plain objects or arrays 430 | if (deep && copy && (isPlainObject(copy) || (copyIsArray = isArray(copy)))) { 431 | if (copyIsArray) { 432 | copyIsArray = false; 433 | clone = src && isArray(src) ? src : []; 434 | } else { 435 | clone = src && isPlainObject(src) ? src : {}; 436 | } 437 | 438 | // Never move original objects, clone them 439 | setProperty(target, { name: name, newValue: extend(deep, clone, copy) }); 440 | 441 | // Don't bring in undefined values 442 | } else if (typeof copy !== 'undefined') { 443 | setProperty(target, { name: name, newValue: copy }); 444 | } 445 | } 446 | } 447 | } 448 | } 449 | 450 | // Return the modified object 451 | return target; 452 | }; 453 | 454 | var vueForm = { 455 | render: function render(h) { 456 | var _this = this; 457 | 458 | var attrs = {}; 459 | 460 | if (typeof window !== 'undefined') { 461 | attrs.novalidate = ''; 462 | } 463 | 464 | return h(this.tag || this.vueFormConfig.formTag, { 465 | on: { 466 | submit: function submit(event) { 467 | if (_this.state.$pending) { 468 | event.preventDefault(); 469 | _this.vueFormConfig.Promise.all(_this.promises).then(function () { 470 | _this.state._submit(); 471 | _this.$emit('submit', event); 472 | _this.promises = []; 473 | }); 474 | } else { 475 | _this.state._submit(); 476 | _this.$emit('submit', event); 477 | } 478 | }, 479 | reset: function reset(event) { 480 | _this.state._reset(); 481 | _this.$emit('reset', event); 482 | } 483 | }, 484 | class: this.className, 485 | attrs: attrs 486 | }, [this.$slots.default]); 487 | }, 488 | 489 | props: { 490 | state: { 491 | type: Object, 492 | required: true 493 | }, 494 | tag: String, 495 | showMessages: String 496 | }, 497 | inject: { vueFormConfig: vueFormConfig }, 498 | provide: function provide() { 499 | var _ref; 500 | 501 | return _ref = {}, defineProperty(_ref, vueFormState, this.state), defineProperty(_ref, vueFormParentForm, this), _ref; 502 | }, 503 | 504 | data: function data() { 505 | return { 506 | promises: [] 507 | }; 508 | }, 509 | created: function created() { 510 | var _this2 = this; 511 | 512 | if (!this.state) { 513 | return; 514 | } 515 | var controls = {}; 516 | var state = this.state; 517 | var formstate = { 518 | $dirty: false, 519 | $pristine: true, 520 | $valid: true, 521 | $invalid: false, 522 | $submitted: false, 523 | $touched: false, 524 | $untouched: true, 525 | $focused: false, 526 | $pending: false, 527 | $error: {}, 528 | $submittedState: {}, 529 | _id: '', 530 | _submit: function _submit() { 531 | _this2.state.$submitted = true; 532 | _this2.state._cloneState(); 533 | }, 534 | _cloneState: function _cloneState() { 535 | var cloned = JSON.parse(JSON.stringify(state)); 536 | delete cloned.$submittedState; 537 | Object.keys(cloned).forEach(function (key) { 538 | _this2.$set(_this2.state.$submittedState, key, cloned[key]); 539 | }); 540 | }, 541 | _addControl: function _addControl(ctrl) { 542 | controls[ctrl.$name] = ctrl; 543 | _this2.$set(state, ctrl.$name, ctrl); 544 | }, 545 | _removeControl: function _removeControl(ctrl) { 546 | delete controls[ctrl.$name]; 547 | _this2.$delete(_this2.state, ctrl.$name); 548 | _this2.$delete(_this2.state.$error, ctrl.$name); 549 | }, 550 | _validate: function _validate() { 551 | Object.keys(controls).forEach(function (key) { 552 | controls[key]._validate(); 553 | }); 554 | }, 555 | _reset: function _reset() { 556 | state.$submitted = false; 557 | state.$pending = false; 558 | state.$submittedState = {}; 559 | Object.keys(controls).forEach(function (key) { 560 | var control = controls[key]; 561 | control._hasFocused = false; 562 | control._setUntouched(); 563 | control._setPristine(); 564 | control.$submitted = false; 565 | control.$pending = false; 566 | }); 567 | } 568 | }; 569 | 570 | Object.keys(formstate).forEach(function (key) { 571 | _this2.$set(_this2.state, key, formstate[key]); 572 | }); 573 | 574 | this.$watch('state', function () { 575 | var isDirty = false; 576 | var isValid = true; 577 | var isTouched = false; 578 | var isFocused = false; 579 | var isPending = false; 580 | Object.keys(controls).forEach(function (key) { 581 | var control = controls[key]; 582 | 583 | control.$submitted = state.$submitted; 584 | 585 | if (control.$dirty) { 586 | isDirty = true; 587 | } 588 | if (control.$touched) { 589 | isTouched = true; 590 | } 591 | if (control.$focused) { 592 | isFocused = true; 593 | } 594 | if (control.$pending) { 595 | isPending = true; 596 | } 597 | if (!control.$valid) { 598 | isValid = false; 599 | // add control to errors 600 | _this2.$set(state.$error, control.$name, control); 601 | } else { 602 | _this2.$delete(state.$error, control.$name); 603 | } 604 | }); 605 | 606 | state.$dirty = isDirty; 607 | state.$pristine = !isDirty; 608 | state.$touched = isTouched; 609 | state.$untouched = !isTouched; 610 | state.$focused = isFocused; 611 | state.$valid = isValid; 612 | state.$invalid = !isValid; 613 | state.$pending = isPending; 614 | }, { 615 | deep: true, 616 | immediate: true 617 | }); 618 | 619 | /* watch pristine? if set to true, set all children to pristine 620 | Object.keys(controls).forEach((ctrl) => { 621 | controls[ctrl].setPristine(); 622 | });*/ 623 | }, 624 | 625 | computed: { 626 | className: function className() { 627 | var classes = getClasses(this.vueFormConfig.formClasses, this.state); 628 | return classes; 629 | } 630 | }, 631 | methods: { 632 | reset: function reset() { 633 | this.state._reset(); 634 | }, 635 | validate: function validate() { 636 | this.state._validate(); 637 | } 638 | } 639 | }; 640 | 641 | var commonjsGlobal = typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; 642 | 643 | 644 | 645 | 646 | 647 | function createCommonjsModule(fn, module) { 648 | return module = { exports: {} }, fn(module, module.exports), module.exports; 649 | } 650 | 651 | var scope_eval = createCommonjsModule(function (module) { 652 | // Generated by CoffeeScript 1.10.0 653 | (function() { 654 | var hasProp = {}.hasOwnProperty, 655 | slice = [].slice; 656 | 657 | module.exports = function(source, scope) { 658 | var key, keys, value, values; 659 | keys = []; 660 | values = []; 661 | for (key in scope) { 662 | if (!hasProp.call(scope, key)) continue; 663 | value = scope[key]; 664 | if (key === 'this') { 665 | continue; 666 | } 667 | keys.push(key); 668 | values.push(value); 669 | } 670 | return Function.apply(null, slice.call(keys).concat(["return eval(" + (JSON.stringify(source)) + ")"])).apply(scope["this"], values); 671 | }; 672 | 673 | }).call(commonjsGlobal); 674 | }); 675 | 676 | function findLabel(nodes) { 677 | if (!nodes) { 678 | return; 679 | } 680 | for (var i = 0; i < nodes.length; i++) { 681 | var vnode = nodes[i]; 682 | if (vnode.tag === 'label') { 683 | return nodes[i]; 684 | } else if (nodes[i].children) { 685 | return findLabel(nodes[i].children); 686 | } 687 | } 688 | } 689 | 690 | var messages = { 691 | inject: { vueFormConfig: vueFormConfig, vueFormState: vueFormState, vueFormParentForm: vueFormParentForm }, 692 | render: function render(h) { 693 | var _this = this; 694 | 695 | var children = []; 696 | var field = this.formstate[this.fieldname]; 697 | if (field && field.$error && this.isShown) { 698 | Object.keys(field.$error).forEach(function (key) { 699 | if (_this.$slots[key] || _this.$scopedSlots[key]) { 700 | var out = _this.$slots[key] || _this.$scopedSlots[key](field); 701 | if (_this.autoLabel) { 702 | var label = findLabel(out); 703 | if (label) { 704 | label.data = label.data || {}; 705 | label.data.attrs = label.data.attrs || {}; 706 | label.data.attrs.for = field._id; 707 | } 708 | } 709 | children.push(out); 710 | } 711 | }); 712 | if (!children.length && field.$valid) { 713 | if (this.$slots.default || this.$scopedSlots.default) { 714 | var out = this.$slots.default || this.$scopedSlots.default(field); 715 | if (this.autoLabel) { 716 | var label = findLabel(out); 717 | if (label) { 718 | label.data = label.data || {}; 719 | label.data.attrs = label.data.attrs || {}; 720 | label.data.attrs.for = field._id; 721 | } 722 | } 723 | children.push(out); 724 | } 725 | } 726 | } 727 | return h(this.tag || this.vueFormConfig.messagesTag, children); 728 | }, 729 | 730 | props: { 731 | state: Object, 732 | name: String, 733 | show: { 734 | type: String, 735 | default: '' 736 | }, 737 | tag: { 738 | type: String 739 | }, 740 | autoLabel: Boolean 741 | }, 742 | data: function data() { 743 | return { 744 | formstate: null, 745 | fieldname: '' 746 | }; 747 | }, 748 | created: function created() { 749 | this.fieldname = this.name; 750 | this.formstate = this.state || this.vueFormState; 751 | }, 752 | 753 | computed: { 754 | isShown: function isShown() { 755 | var field = this.formstate[this.fieldname]; 756 | var show = this.show || this.vueFormParentForm.showMessages || this.vueFormConfig.showMessages; 757 | 758 | if (!show || !field) { 759 | return true; 760 | } 761 | 762 | return scope_eval(show, field); 763 | } 764 | } 765 | }; 766 | 767 | var validate = { 768 | render: function render(h) { 769 | var _this = this; 770 | 771 | var foundVnodes = getVModelAndLabel(this.$slots.default, this.vueFormConfig); 772 | var vModelnodes = foundVnodes.vModel; 773 | var attrs = { 774 | for: null 775 | }; 776 | if (vModelnodes.length) { 777 | this.name = getName(vModelnodes[0]); 778 | 779 | if (foundVnodes.messages) { 780 | this.$nextTick(function () { 781 | var messagesVm = foundVnodes.messages.componentInstance; 782 | if (messagesVm) { 783 | messagesVm.fieldname = messagesVm.fieldname || _this.name; 784 | } 785 | }); 786 | } 787 | 788 | if (this.autoLabel) { 789 | var id = vModelnodes[0].data.attrs.id || this.fieldstate._id; 790 | this.fieldstate._id = id; 791 | vModelnodes[0].data.attrs.id = id; 792 | if (foundVnodes.label) { 793 | foundVnodes.label.data = foundVnodes.label.data || {}; 794 | foundVnodes.label.data.attrs = foundVnodes.label.data.attrs || {}; 795 | foundVnodes.label.data.attrs.for = id; 796 | } else if (this.tag === 'label') { 797 | attrs.for = id; 798 | } 799 | } 800 | vModelnodes.forEach(function (vnode) { 801 | if (!vnode.data.directives) { 802 | vnode.data.directives = []; 803 | } 804 | vnode.data.directives.push({ name: 'vue-form-validator', value: { fieldstate: _this.fieldstate, config: _this.vueFormConfig } }); 805 | vnode.data.attrs['vue-form-validator'] = ''; 806 | vnode.data.attrs['debounce'] = _this.debounce; 807 | }); 808 | } else { 809 | //console.warn('Element with v-model not found'); 810 | } 811 | return h(this.tag || this.vueFormConfig.validateTag, { 'class': this.className, attrs: attrs }, this.$slots.default); 812 | }, 813 | 814 | props: { 815 | state: Object, 816 | custom: null, 817 | autoLabel: Boolean, 818 | tag: { 819 | type: String 820 | }, 821 | debounce: Number 822 | }, 823 | inject: { vueFormConfig: vueFormConfig, vueFormState: vueFormState, vueFormParentForm: vueFormParentForm }, 824 | data: function data() { 825 | return { 826 | name: '', 827 | formstate: null, 828 | fieldstate: {} 829 | }; 830 | }, 831 | 832 | methods: { 833 | getClasses: function getClasses$$1(classConfig) { 834 | var s = this.fieldstate; 835 | return Object.keys(s.$error).reduce(function (classes, error) { 836 | classes[classConfig.invalid + '-' + hyphenate(error)] = true; 837 | return classes; 838 | }, getClasses(classConfig, s)); 839 | } 840 | }, 841 | computed: { 842 | className: function className() { 843 | return this.getClasses(this.vueFormConfig.validateClasses); 844 | }, 845 | inputClassName: function inputClassName() { 846 | return this.getClasses(this.vueFormConfig.inputClasses); 847 | } 848 | }, 849 | mounted: function mounted() { 850 | var _this2 = this; 851 | 852 | this.fieldstate.$name = this.name; 853 | this.formstate._addControl(this.fieldstate); 854 | 855 | var vModelEls = this.$el.querySelectorAll('[vue-form-validator]'); 856 | 857 | // add classes to the input element 858 | this.$watch('inputClassName', function (value, oldValue) { 859 | var out = void 0; 860 | 861 | var _loop = function _loop(i, el) { 862 | if (oldValue) { 863 | Object.keys(oldValue).filter(function (k) { 864 | return oldValue[k]; 865 | }).forEach(function (k) { 866 | return removeClass(el, k); 867 | }); 868 | } 869 | out = []; 870 | Object.keys(value).filter(function (k) { 871 | return value[k]; 872 | }).forEach(function (k) { 873 | out.push(k); 874 | addClass(el, k); 875 | }); 876 | }; 877 | 878 | for (var i = 0, el; el = vModelEls[i++];) { 879 | _loop(i, el); 880 | } 881 | _this2.fieldstate._className = out; 882 | }, { 883 | deep: true, 884 | immediate: true 885 | }); 886 | 887 | this.$watch('name', function (value, oldValue) { 888 | _this2.formstate._removeControl(_this2.fieldstate); 889 | _this2.fieldstate.$name = value; 890 | _this2.formstate._addControl(_this2.fieldstate); 891 | }); 892 | }, 893 | created: function created() { 894 | var _this4 = this; 895 | 896 | this.formstate = this.state || this.vueFormState; 897 | var vm = this; 898 | var pendingValidators = []; 899 | var _val = void 0; 900 | var prevVnode = void 0; 901 | this.fieldstate = { 902 | $name: '', 903 | $dirty: false, 904 | $pristine: true, 905 | $valid: true, 906 | $invalid: false, 907 | $touched: false, 908 | $untouched: true, 909 | $focused: false, 910 | $pending: false, 911 | $submitted: false, 912 | $error: {}, 913 | $attrs: {}, 914 | _className: null, 915 | _id: 'vf' + randomId(), 916 | _setValidatorValidity: function _setValidatorValidity(validator, isValid) { 917 | if (isValid) { 918 | vm.$delete(this.$error, validator); 919 | } else { 920 | vm.$set(this.$error, validator, true); 921 | } 922 | }, 923 | _setValidity: function _setValidity(isValid) { 924 | this.$valid = isValid; 925 | this.$invalid = !isValid; 926 | }, 927 | _setDirty: function _setDirty() { 928 | this.$dirty = true; 929 | this.$pristine = false; 930 | }, 931 | _setPristine: function _setPristine() { 932 | this.$dirty = false; 933 | this.$pristine = true; 934 | }, 935 | _setTouched: function _setTouched() { 936 | this.$touched = true; 937 | this.$untouched = false; 938 | }, 939 | _setUntouched: function _setUntouched() { 940 | this.$touched = false; 941 | this.$untouched = true; 942 | }, 943 | _setFocused: function _setFocused(value) { 944 | this.$focused = typeof value === 'boolean' ? value : false; 945 | if (this.$focused) { 946 | this._setHasFocused(); 947 | } else { 948 | this._setTouched(); 949 | } 950 | }, 951 | _setHasFocused: function _setHasFocused() { 952 | this._hasFocused = true; 953 | }, 954 | 955 | _hasFocused: false, 956 | _validators: {}, 957 | _validate: function _validate(vnode) { 958 | var _this3 = this; 959 | 960 | if (!vnode) { 961 | vnode = prevVnode; 962 | } else { 963 | prevVnode = vnode; 964 | } 965 | this.$pending = true; 966 | var isValid = true; 967 | var emptyAndRequired = false; 968 | var value = vModelValue(vnode.data); 969 | _val = value; 970 | 971 | var pending = { 972 | promises: [], 973 | names: [] 974 | }; 975 | 976 | pendingValidators.push(pending); 977 | 978 | var attrs = vnode.data.attrs || {}; 979 | var childvm = vnode.componentInstance; 980 | if (childvm && childvm._vfValidationData_) { 981 | attrs = extend({}, attrs, childvm._vfValidationData_); 982 | } 983 | 984 | var propsData = vnode.componentOptions && vnode.componentOptions.propsData ? vnode.componentOptions.propsData : {}; 985 | 986 | Object.keys(this._validators).forEach(function (validator) { 987 | // when value is empty and current validator is not the required validator, the field is valid 988 | if ((value === '' || value === undefined || value === null) && validator !== 'required') { 989 | _this3._setValidatorValidity(validator, true); 990 | emptyAndRequired = true; 991 | // return early, required validator will 992 | // fall through if it is present 993 | return; 994 | } 995 | 996 | var attrValue = typeof attrs[validator] !== 'undefined' ? attrs[validator] : propsData[validator]; 997 | var isFunction = typeof _this3._validators[validator] === 'function'; 998 | 999 | // match vue behaviour, ignore if attribute is null or undefined. But for type=email|url|number and custom validators, the value will be null, so allow with _allowNulls 1000 | if (isFunction && (attrValue === null || typeof attrValue === 'undefined') && !_this3._validators[validator]._allowNulls) { 1001 | return; 1002 | } 1003 | 1004 | if (attrValue) { 1005 | _this3.$attrs[validator] = attrValue; 1006 | } 1007 | 1008 | var result = isFunction ? _this3._validators[validator](value, attrValue, vnode) : vm.custom[validator]; 1009 | 1010 | if (typeof result === 'boolean') { 1011 | if (result) { 1012 | _this3._setValidatorValidity(validator, true); 1013 | } else { 1014 | isValid = false; 1015 | _this3._setValidatorValidity(validator, false); 1016 | } 1017 | } else { 1018 | pending.promises.push(result); 1019 | pending.names.push(validator); 1020 | vm.vueFormParentForm.promises.push(result); 1021 | } 1022 | }); 1023 | 1024 | if (pending.promises.length) { 1025 | vm.vueFormConfig.Promise.all(pending.promises).then(function (results) { 1026 | 1027 | // only concerned with the last promise results, in case 1028 | // async responses return out of order 1029 | if (pending !== pendingValidators[pendingValidators.length - 1]) { 1030 | //console.log('ignoring old promise', pending.promises); 1031 | return; 1032 | } 1033 | 1034 | pendingValidators = []; 1035 | 1036 | results.forEach(function (result, i) { 1037 | var name = pending.names[i]; 1038 | if (result) { 1039 | _this3._setValidatorValidity(name, true); 1040 | } else { 1041 | isValid = false; 1042 | _this3._setValidatorValidity(name, false); 1043 | } 1044 | }); 1045 | _this3._setValidity(isValid); 1046 | _this3.$pending = false; 1047 | }); 1048 | } else { 1049 | this._setValidity(isValid); 1050 | this.$pending = false; 1051 | } 1052 | } 1053 | }; 1054 | 1055 | // add custom validators 1056 | if (this.custom) { 1057 | Object.keys(this.custom).forEach(function (prop) { 1058 | if (typeof _this4.custom[prop] === 'function') { 1059 | _this4.custom[prop]._allowNulls = true; 1060 | _this4.fieldstate._validators[prop] = _this4.custom[prop]; 1061 | } else { 1062 | _this4.fieldstate._validators[prop] = _this4.custom[prop]; 1063 | } 1064 | }); 1065 | } 1066 | 1067 | this.$watch('custom', function (v, oldV) { 1068 | if (!oldV || !v) { 1069 | return; 1070 | } 1071 | if (isShallowObjectDifferent(v, oldV)) { 1072 | _this4.fieldstate._validate(); 1073 | } 1074 | }, { 1075 | deep: true 1076 | }); 1077 | }, 1078 | destroyed: function destroyed() { 1079 | this.formstate._removeControl(this.fieldstate); 1080 | } 1081 | }; 1082 | 1083 | var field = { 1084 | inject: { vueFormConfig: vueFormConfig }, 1085 | render: function render(h) { 1086 | var foundVnodes = getVModelAndLabel(this.$slots.default, this.vueFormConfig); 1087 | var vModelnodes = foundVnodes.vModel; 1088 | var attrs = { 1089 | for: null 1090 | }; 1091 | if (vModelnodes.length) { 1092 | if (this.autoLabel) { 1093 | var id = vModelnodes[0].data.attrs && vModelnodes[0].data.attrs.id || 'vf' + randomId(); 1094 | vModelnodes[0].data.attrs.id = id; 1095 | if (foundVnodes.label) { 1096 | foundVnodes.label.data = foundVnodes.label.data || {}; 1097 | foundVnodes.label.data.attrs = foundVnodes.label.data.attrs = {}; 1098 | foundVnodes.label.data.attrs.for = id; 1099 | } else if (this.tag === 'label') { 1100 | attrs.for = id; 1101 | } 1102 | } 1103 | } 1104 | return h(this.tag || this.vueFormConfig.fieldTag, { attrs: attrs }, this.$slots.default); 1105 | }, 1106 | 1107 | props: { 1108 | tag: { 1109 | type: String 1110 | }, 1111 | autoLabel: { 1112 | type: Boolean, 1113 | default: true 1114 | } 1115 | } 1116 | }; 1117 | 1118 | var debouncedValidators = {}; 1119 | 1120 | function addValidators(attrs, validators, fieldValidators) { 1121 | Object.keys(attrs).forEach(function (attr) { 1122 | var prop = attr === 'type' ? attrs[attr].toLowerCase() : attr.toLowerCase(); 1123 | 1124 | if (validators[prop] && !fieldValidators[prop]) { 1125 | fieldValidators[prop] = validators[prop]; 1126 | } 1127 | }); 1128 | } 1129 | 1130 | function compareChanges(vnode, oldvnode, validators) { 1131 | 1132 | var hasChanged = false; 1133 | var attrs = vnode.data.attrs || {}; 1134 | var oldAttrs = oldvnode.data.attrs || {}; 1135 | var out = {}; 1136 | 1137 | if (vModelValue(vnode.data) !== vModelValue(oldvnode.data)) { 1138 | out.vModel = true; 1139 | hasChanged = true; 1140 | } 1141 | 1142 | Object.keys(validators).forEach(function (validator) { 1143 | if (attrs[validator] !== oldAttrs[validator]) { 1144 | out[validator] = true; 1145 | hasChanged = true; 1146 | } 1147 | }); 1148 | 1149 | // if is a component 1150 | if (vnode.componentOptions && vnode.componentOptions.propsData) { 1151 | var _attrs = vnode.componentOptions.propsData; 1152 | var _oldAttrs = oldvnode.componentOptions.propsData; 1153 | Object.keys(validators).forEach(function (validator) { 1154 | if (_attrs[validator] !== _oldAttrs[validator]) { 1155 | out[validator] = true; 1156 | hasChanged = true; 1157 | } 1158 | }); 1159 | } 1160 | 1161 | if (hasChanged) { 1162 | return out; 1163 | } 1164 | } 1165 | 1166 | var vueFormValidator = { 1167 | name: 'vue-form-validator', 1168 | bind: function bind(el, binding, vnode) { 1169 | var fieldstate = binding.value.fieldstate; 1170 | var validators = binding.value.config.validators; 1171 | 1172 | var attrs = vnode.data.attrs || {}; 1173 | var inputName = getName(vnode); 1174 | 1175 | if (!inputName) { 1176 | console.warn('vue-form: name attribute missing'); 1177 | return; 1178 | } 1179 | 1180 | if (attrs.debounce) { 1181 | debouncedValidators[fieldstate._id] = debounce(function (fieldstate, vnode) { 1182 | if (fieldstate._hasFocused) { 1183 | fieldstate._setDirty(); 1184 | } 1185 | fieldstate._validate(vnode); 1186 | }, attrs.debounce); 1187 | } 1188 | 1189 | // add validators 1190 | addValidators(attrs, validators, fieldstate._validators); 1191 | 1192 | // if is a component, a validator attribute could be a prop this component uses 1193 | if (vnode.componentOptions && vnode.componentOptions.propsData) { 1194 | addValidators(vnode.componentOptions.propsData, validators, fieldstate._validators); 1195 | } 1196 | 1197 | fieldstate._validate(vnode); 1198 | 1199 | // native listeners 1200 | el.addEventListener('blur', function () { 1201 | fieldstate._setFocused(false); 1202 | }, false); 1203 | el.addEventListener('focus', function () { 1204 | fieldstate._setFocused(true); 1205 | }, false); 1206 | 1207 | // component listeners 1208 | var vm = vnode.componentInstance; 1209 | if (vm) { 1210 | vm.$on('blur', function () { 1211 | fieldstate._setFocused(false); 1212 | }); 1213 | vm.$on('focus', function () { 1214 | fieldstate._setFocused(true); 1215 | }); 1216 | 1217 | vm.$once('vf:addFocusListeners', function () { 1218 | el.addEventListener('focusout', function () { 1219 | fieldstate._setFocused(false); 1220 | }, false); 1221 | el.addEventListener('focusin', function () { 1222 | fieldstate._setFocused(true); 1223 | }, false); 1224 | }); 1225 | 1226 | vm.$on('vf:validate', function (data) { 1227 | if (!vm._vfValidationData_) { 1228 | addValidators(data, validators, fieldstate._validators); 1229 | } 1230 | vm._vfValidationData_ = data; 1231 | fieldstate._validate(vm.$vnode); 1232 | }); 1233 | } 1234 | }, 1235 | update: function update(el, binding, vnode, oldVNode) { 1236 | var validators = binding.value.config.validators; 1237 | 1238 | var changes = compareChanges(vnode, oldVNode, validators); 1239 | var fieldstate = binding.value.fieldstate; 1240 | 1241 | 1242 | var attrs = vnode.data.attrs || {}; 1243 | var vm = vnode.componentInstance; 1244 | if (vm && vm._vfValidationData_) { 1245 | attrs = extend({}, attrs, vm[vm._vfValidationData_]); 1246 | } 1247 | 1248 | if (vnode.elm.className.indexOf(fieldstate._className[0]) === -1) { 1249 | vnode.elm.className = vnode.elm.className + ' ' + fieldstate._className.join(' '); 1250 | } 1251 | 1252 | if (!changes) { 1253 | return; 1254 | } 1255 | 1256 | if (changes.vModel) { 1257 | // re-validate all 1258 | if (attrs.debounce) { 1259 | fieldstate.$pending = true; 1260 | debouncedValidators[fieldstate._id](fieldstate, vnode); 1261 | } else { 1262 | if (fieldstate._hasFocused) { 1263 | fieldstate._setDirty(); 1264 | } 1265 | fieldstate._validate(vnode); 1266 | } 1267 | } else { 1268 | // attributes have changed 1269 | // to do: loop through them and re-validate changed ones 1270 | //for(let prop in changes) { 1271 | // fieldstate._validate(vnode, validator); 1272 | //} 1273 | // for now 1274 | fieldstate._validate(vnode); 1275 | } 1276 | } 1277 | }; 1278 | 1279 | function VueFormBase(options) { 1280 | var _components; 1281 | 1282 | var c = extend(true, {}, config, options); 1283 | this.provide = function () { 1284 | return defineProperty({}, vueFormConfig, c); 1285 | }; 1286 | this.components = (_components = {}, defineProperty(_components, c.formComponent, vueForm), defineProperty(_components, c.messagesComponent, messages), defineProperty(_components, c.validateComponent, validate), defineProperty(_components, c.fieldComponent, field), _components); 1287 | this.directives = { vueFormValidator: vueFormValidator }; 1288 | } 1289 | 1290 | var VueForm = function (_VueFormBase) { 1291 | inherits(VueForm, _VueFormBase); 1292 | 1293 | function VueForm() { 1294 | classCallCheck(this, VueForm); 1295 | return possibleConstructorReturn(this, (VueForm.__proto__ || Object.getPrototypeOf(VueForm)).apply(this, arguments)); 1296 | } 1297 | 1298 | createClass(VueForm, null, [{ 1299 | key: 'install', 1300 | value: function install(Vue, options) { 1301 | Vue.mixin(new this(options)); 1302 | } 1303 | }, { 1304 | key: 'installed', 1305 | get: function get$$1() { 1306 | return !!this.install.done; 1307 | }, 1308 | set: function set$$1(val) { 1309 | this.install.done = val; 1310 | } 1311 | }]); 1312 | return VueForm; 1313 | }(VueFormBase); 1314 | 1315 | VueFormBase.call(VueForm); 1316 | // temp fix for vue 2.3.0 1317 | VueForm.options = new VueForm(); 1318 | 1319 | return VueForm; 1320 | 1321 | }))); 1322 | --------------------------------------------------------------------------------