├── .npmignore ├── test ├── .eslintrc.yml └── unit │ └── setup.js ├── .gitignore ├── .eslintrc.yml ├── .testiumrc ├── testem.yml ├── .travis.yml ├── src ├── index.js ├── injectProps.js ├── Error.js ├── FieldSet.js ├── Field.js └── Form.js ├── .babelrc ├── LICENSE ├── scripts └── rollup.config.js ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | mocha: true 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log* 3 | 4 | /dist/ 5 | /.tmp/ 6 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | parser: babel-eslint 3 | plugins: 4 | - html 5 | -------------------------------------------------------------------------------- /.testiumrc: -------------------------------------------------------------------------------- 1 | launch = true 2 | app.command = npm start 3 | app.port = 8080 4 | -------------------------------------------------------------------------------- /testem.yml: -------------------------------------------------------------------------------- 1 | --- 2 | framework: mocha 3 | src_files: 4 | - .tmp/test.js 5 | -------------------------------------------------------------------------------- /test/unit/setup.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | Vue.config.productionTip = false 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | before_script: 5 | - "export DISPLAY=:99.0" 6 | - "sh -e /etc/init.d/xvfb start" 7 | - sleep 3 # give xvfb some time to start 8 | script: 9 | - echo 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Form from './Form' 2 | import SimpleFormFieldSet from './FieldSet' 3 | import Field from './Field' 4 | import Error from './Error' 5 | 6 | export { SimpleFormFieldSet, Field, Error } 7 | export default Form 8 | -------------------------------------------------------------------------------- /src/injectProps.js: -------------------------------------------------------------------------------- 1 | export default function injectProps (vnode, props) { 2 | const options = vnode.componentOptions = vnode.componentOptions || {} 3 | const propsData = options.propsData = options.propsData || {} 4 | 5 | Object.assign(propsData, props) 6 | 7 | return vnode 8 | } 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "modules": false 7 | } 8 | ] 9 | ], 10 | "plugins": [ 11 | "external-helpers", 12 | "transform-runtime", 13 | "transform-object-rest-spread" 14 | ], 15 | "env": { 16 | "test": { 17 | "presets": [ 18 | "power-assert" 19 | ] 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/Error.js: -------------------------------------------------------------------------------- 1 | export default { 2 | inject: ['simpleForm'], 3 | props: { 4 | tag: { 5 | type: String, 6 | default: 'span' 7 | }, 8 | name: { 9 | type: String, 10 | required: true 11 | } 12 | }, 13 | mounted () { 14 | this.$watch(() => [this.simpleForm.errors(this.name), this.simpleForm.touched(this.name)], async ([error, touched]) => { 15 | if (!touched || !error) return 16 | 17 | if (!this.simpleForm.scrolledToFirstError.value) { 18 | this.simpleForm.scrolledToFirstError.value = true 19 | 20 | await this.$nextTick() 21 | 22 | this.$refs.focuser.focus() 23 | } 24 | }) 25 | }, 26 | render (h) { 27 | const { errors, touched } = this.simpleForm 28 | 29 | if (errors(this.name) && touched(this.name)) { 30 | return h(this.tag, [errors(this.name),h('input', { 31 | style: { 32 | height: 0, 33 | opacity: 0 34 | }, 35 | ref: 'focuser' 36 | })]) 37 | } 38 | 39 | return null 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Avi Block 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/FieldSet.js: -------------------------------------------------------------------------------- 1 | import injectProps from './injectProps' 2 | import get from 'lodash/get' 3 | 4 | export default { 5 | name: 'SimpleFormFieldSet', 6 | props: { 7 | name: { 8 | required: true, 9 | type: String 10 | } 11 | }, 12 | inject: ['simpleForm'], 13 | render (h) { 14 | const { values, errors, setValue, touched, setTouched } = this.simpleForm 15 | 16 | if (this.$slots.default.length !== 1 && !this.$scopedSlots.default) { 17 | // eslint-disable-next-line 18 | console.warn('FieldSet requires one element as a child, or a scoped slot') 19 | 20 | return null 21 | } 22 | 23 | const $props = { 24 | values: get(values, this.name), 25 | setValue: (field, value) => setValue(`${this.name}.${field}`, value), 26 | input: e => setValue(`${this.name}.${e.target.name}`, e.target.value), 27 | blur: e => setTouched(`${this.name}.${e.target.name}`), 28 | touched: field => touched(`${this.name}.${field}`), 29 | setTouched: field => setTouched(`${this.name}.${field}`), 30 | errors: field => errors(`${this.name}.${field}`) 31 | } 32 | 33 | if (this.$slots.default.length === 1) { 34 | const vnode = injectProps(this.$slots.default[0], $props) 35 | 36 | return vnode 37 | } else { 38 | const vnodes = this.$scopedSlots.default($props) 39 | 40 | return vnodes.length > 1 ? h('div', vnodes) : vnodes[0] 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /scripts/rollup.config.js: -------------------------------------------------------------------------------- 1 | const babel = require('rollup-plugin-babel') 2 | const vue = require('rollup-plugin-vue') 3 | const replace = require('rollup-plugin-replace') 4 | const meta = require('../package.json') 5 | const resolve = require('rollup-plugin-node-resolve') 6 | const commonjs = require('rollup-plugin-commonjs') 7 | 8 | const config = { 9 | input: 'src/index.js', 10 | output: {}, 11 | plugins: [ 12 | vue(), 13 | babel({ 14 | exclude: 'node_modules/**', 15 | runtimeHelpers: true 16 | }), 17 | resolve(), 18 | commonjs({ 19 | namedExports: { 20 | 'flatulence': ['flatten'] 21 | } 22 | }) 23 | ], 24 | name: 'Lib', 25 | banner: `/*! 26 | * ${meta.name} v${meta.version} 27 | * ${meta.homepage} 28 | * 29 | * @license 30 | * Copyright (c) 2017 ${meta.author} 31 | * Released under the MIT license 32 | * ${meta.homepage}/blob/master/LICENSE 33 | */` 34 | } 35 | 36 | switch (process.env.BUILD) { 37 | case 'cjs': 38 | config.output.format = 'cjs' 39 | config.output.file = `dist/${meta.name}.cjs.js` 40 | break 41 | case 'prod': 42 | config.output.format = 'umd' 43 | config.output.file = `dist/${meta.name}.js` 44 | config.plugins.push( 45 | replace({ 46 | 'process.env.NODE_ENV': JSON.stringify('production') 47 | }) 48 | ) 49 | break 50 | case 'dev': 51 | default: 52 | config.output.format = 'umd' 53 | config.output.file = `dist/${meta.name}.js` 54 | config.plugins.push( 55 | replace({ 56 | 'process.env.NODE_ENV': JSON.stringify('development') 57 | }) 58 | ) 59 | } 60 | 61 | module.exports = config 62 | -------------------------------------------------------------------------------- /src/Field.js: -------------------------------------------------------------------------------- 1 | import merge from 'lodash/merge' 2 | import get from 'lodash/get' 3 | 4 | export default { 5 | name: "SimpleFormField", 6 | inject: ["simpleForm"], 7 | props: { 8 | name: { 9 | type: String, 10 | required: true 11 | }, 12 | errorClass: { 13 | type: String, 14 | default: 'error' 15 | } 16 | }, 17 | render (h) { 18 | const { values, errors, touched, input, blur, setValue, setTouched } = this.simpleForm 19 | 20 | const value = get(values, this.name) 21 | 22 | if (this.$slots.default) { 23 | if (this.$slots.default.length > 1) { 24 | // eslint-disable-next-line 25 | console.warn('Only one root element is supported') 26 | } 27 | 28 | const $vnode = this.$slots.default[0] 29 | 30 | if ($vnode.componentOptions) { 31 | return h($vnode.componentOptions.Ctor, { 32 | ...$vnode.data, 33 | on: { 34 | ...($vnode.componentOptions.listeners || {}), 35 | input: val => setValue(this.name, val), 36 | blur: () => setTouched(this.name) 37 | }, 38 | class: { 39 | ...($vnode.data.class || {}), 40 | [this.errorClass]: errors(this.name) && touched(this.name) 41 | }, 42 | props: { 43 | ...($vnode.data.props || {}), 44 | ...($vnode.componentOptions.propsData || {}), 45 | value 46 | } 47 | } 48 | , $vnode.children || $vnode.componentOptions.children) 49 | } else { 50 | const data = merge($vnode.data, { 51 | domProps: { 52 | name: this.name, 53 | value 54 | }, 55 | on: { 56 | input: e => setValue(this.name, e.target.value), 57 | change: e => setValue(this.name, e.target.value), 58 | blur: () => setTouched(this.name) 59 | }, 60 | class: { 61 | [this.errorClass]: errors(this.name) && touched(this.name) 62 | } 63 | }) 64 | 65 | return h($vnode.tag, data, $vnode.children) 66 | 67 | } 68 | } 69 | 70 | return h('input', { 71 | domProps: { 72 | name: this.name, 73 | value, 74 | checked: !!value 75 | }, 76 | class: { 77 | [this.errorClass]: touched(this.name) && errors(this.name) 78 | }, 79 | on: { 80 | input, 81 | change: input, 82 | blur 83 | } 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-simpleform", 3 | "version": "1.0.5", 4 | "author": "Avi Block ", 5 | "private": false, 6 | "description": "Form library for vue. Inspired by Formik", 7 | "keywords": [ 8 | "component", 9 | "Vue.js", 10 | "forms" 11 | ], 12 | "license": "MIT", 13 | "main": "dist/vue-simpleform.cjs.js", 14 | "files": [ 15 | "dist" 16 | ], 17 | "homepage": "https://github.com/blocka/vue-simpleform", 18 | "bugs": "https://github.com/blocka/vue-simpleform/issues", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/blocka/vue-simpleform.git" 22 | }, 23 | "scripts": { 24 | "prepublishOnly": "npm run release", 25 | "clean": "rm -rf dist .tmp", 26 | "build": "run-p build:cjs build:dev build:prod", 27 | "build:cjs": "rollup -c scripts/rollup.config.js --environment BUILD:cjs", 28 | "build:dev": "rollup -c scripts/rollup.config.js --environment BUILD:dev", 29 | "build:prod": "rollup -c scripts/rollup.config.js --environment BUILD:prod | uglifyjs -mc warnings=false --comments -o dist/vue-simpleform.min.js", 30 | "build:test": "cross-env NODE_ENV=test webpack --config scripts/webpack.config.unit.js", 31 | "watch:test": "cross-env NODE_ENV=test webpack -w --config scripts/webpack.config.unit.js", 32 | "lint": "eslint --fix \"@(src|test|scripts)/**/*.js\"", 33 | "testem": "testem", 34 | "testem:ci": "testem ci --launch PhantomJS", 35 | "test": "npm run test:unit", 36 | "test:unit": "run-s build:test testem:ci", 37 | "test:dev": "run-p watch:test testem", 38 | "release": "run-s lint clean build" 39 | }, 40 | "devDependencies": { 41 | "babel-core": "^6.25.0", 42 | "babel-eslint": "^7.2.3", 43 | "babel-loader": "^7.0.0", 44 | "babel-plugin-external-helpers": "^6.22.0", 45 | "babel-preset-env": "^1.6.0", 46 | "babel-preset-power-assert": "^1.0.0", 47 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 48 | "babel-plugin-transform-runtime": "^6.23.0", 49 | "babel-register": "^6.24.1", 50 | "cross-env": "^5.0.1", 51 | "css-loader": "^0.28.4", 52 | "eslint": "^4.0.0", 53 | "eslint-config-ktsn": "^1.0.2", 54 | "eslint-plugin-html": "^3.0.0", 55 | "glob": "^7.1.2", 56 | "npm-run-all": "^4.0.2", 57 | "power-assert": "^1.4.4", 58 | "rollup": "^0.50.0", 59 | "rollup-plugin-babel": "^2.7.1", 60 | "rollup-plugin-commonjs": "^8.2.1", 61 | "rollup-plugin-node-resolve": "^3.0.0", 62 | "rollup-plugin-replace": "^1.1.1", 63 | "rollup-plugin-vue": "^2.4.0", 64 | "testem": "^1.16.2", 65 | "uglify-js": "^3.0.15", 66 | "vue": "^2.3.4", 67 | "vue-loader": "^12.2.1", 68 | "webpack": "^2.6.1" 69 | }, 70 | "dependencies": { 71 | "flatulence": "^0.2.13", 72 | "lodash": "^4.17.11", 73 | "vue-deepset": "^0.6.3" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Form.js: -------------------------------------------------------------------------------- 1 | /* globals Event */ 2 | 3 | import omit from 'lodash/omit' 4 | import { vueSet as set } from 'vue-deepset' 5 | import get from 'lodash/get'; 6 | import { flatten } from 'flatulence' 7 | 8 | function eventOrValue (e) { 9 | if (e instanceof Event) { 10 | if (e.target.options) { 11 | const selectedOption = e.target.options[e.target.selectedIndex] 12 | 13 | return selectedOption._value !== undefined ? selectedOption._value : selectedOption.value 14 | } 15 | 16 | if (e.target.type === 'checkbox') { 17 | return e.target.checked 18 | } 19 | 20 | return e.target.value 21 | } 22 | 23 | return e 24 | } 25 | 26 | export default { 27 | name: 'SimpleForm', 28 | props: ['validate', 'submitOnBlur', 'value'], 29 | data () { 30 | return { 31 | values: JSON.parse(JSON.stringify(this.value)), 32 | touched: {}, 33 | submitted: false, 34 | submitting: false, 35 | scrolledToFirstError: { value: false } 36 | } 37 | }, 38 | watch: { 39 | values: { 40 | deep: true, 41 | immediate: true, 42 | handler () { 43 | this.$emit('values', {values: this.values, setValue: this.setValues}) 44 | } 45 | } 46 | }, 47 | provide () { 48 | return { 49 | simpleForm: { 50 | values: this.values, 51 | setValue: (field, value) => this.setValues({ [field]: value }), 52 | errors: (field) => this.errors && get(this.errors, field), 53 | touched: (field) => this.touched[field], 54 | setTouched: this.setTouched, 55 | input: this.handleInput, 56 | blur: this.handleBlur, 57 | scrolledToFirstError: this.scrolledToFirstError 58 | } 59 | } 60 | }, 61 | computed: { 62 | errors () { 63 | return this.validate(this.values) 64 | } 65 | }, 66 | methods: { 67 | setValues (values) { 68 | Object.entries(values).forEach(([key, val]) => { 69 | set(this.values, key, val) 70 | }) 71 | }, 72 | handleInput (e) { 73 | this.setValues({ [e.target.name]: eventOrValue(e) }) 74 | }, 75 | handleBlur (e) { 76 | this.setTouched(e.target.name) 77 | }, 78 | handleFocus (e) { 79 | this.untouch(e.target.name) 80 | }, 81 | setTouched (field) { 82 | this.touched = { ...this.touched, [field]: true } 83 | 84 | if (this.submitOnBlur) { 85 | this.handleSubmit() 86 | } 87 | }, 88 | untouch (field) { 89 | this.touched = omit(this.touched, field) 90 | }, 91 | handleSubmit () { 92 | this.scrolledToFirstError.value = false 93 | 94 | if (this.errors && Object.keys(this.errors).length > 0) { 95 | this.$emit('submit', { values: null, errors: this.errors }) 96 | 97 | return 98 | } 99 | 100 | this.$emit('submit', { 101 | values: this.values, 102 | setSubmitting: () => { 103 | this.submitting = true 104 | this.submitted = false 105 | }, 106 | setSubmitted: () => { 107 | this.submitting = false 108 | this.submitted = true 109 | } 110 | }) 111 | } 112 | }, 113 | render (h) { 114 | const $vnodes = this.$scopedSlots.default({ 115 | input: this.handleInput, 116 | blur: this.handleBlur, 117 | focus: this.handleFocus, 118 | setValue: (field, value) => this.setValues({ [field]: value }), 119 | setTouched: this.setTouched, 120 | untouch: this.untouch, 121 | values: this.values, 122 | errors: (field) => this.errors && this.errors[field], 123 | touched: (field) => this.touched[field], 124 | handleSubmit: () => { 125 | this.touched = [...Object.entries(flatten.keepEmpty(this.values)),...Object.entries(this.values)] 126 | .reduce((touched, [key]) => ({ ...touched, [key]: true }), {}) 127 | 128 | this.handleSubmit() 129 | }, 130 | submitted: this.submitted, 131 | submitting: this.submitting 132 | }) 133 | 134 | if ($vnodes.length === 0) return $vnodes[0] 135 | 136 | return h('div', $vnodes) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-simpleform 2 | 3 | A form library for vue, inspired by Formik for react 4 | ## Is it really simple? 5 | 6 | I think it is, but really I couldn't think of a better name 7 | 8 | ## Basic Usage 9 | 10 | ```html 11 | 23 | 47 | ``` 48 | 49 | The main component takes two props: 50 | 51 | 1. `value`. This is used to set the initial form state, which will be a deep copy of what is passed in. 52 | 2. `validate`. This is a function which is called to validate the form. This happens when any of the fields are updated, or the form is submitted. ~It can return a promise to do asynchronous validation~ as of 1.0.0 it only works synchronously 53 | 54 | And `$emits` a `submit` event when the form is submitted. The callback for the submit event takes an object with following keys: 55 | 56 | 1. values 57 | 2. errors 58 | 3. setSubmitting 59 | 4. setSubmitted 60 | 61 | If the form is valid, `errors` will be undefined 62 | 63 | The scoped slot is passed the following props: 64 | 65 | 1. values. All the form values, but "flattened". 66 | 2. errors. A function taking a name of a field, and returning it's error message (if invalid. 67 | 3. touched. A function taking a name of a field, and returning if the field was touched 68 | 4. input. Input and blur are functions ready to be passed in as event handlers. They are only useful on a real form field (eg., and element. The element needs a `name` attribute as well 69 | 5. blur 70 | 6. setValue. Manually set a field value. Useful for integrating a custom component 71 | 7. setTouched. Ditto, but for setting touched 72 | 8. handleSubmit. A callback that will initiate the submittion process 73 | 9. submitted 74 | 10. submitting 75 | ## Other components 76 | 77 | There are two other components which are useful for encapsulating common patterns, or removing boilerplate. They are available as named exports. 78 | 79 | 1. ``. This is used to make a set of fields which are prefixed. It can be used also to set up an array of fields 80 | ```html 81 |