From 84345af2dccf966ec8c329757436709b3960ab74 Mon Sep 17 00:00:00 2001 From: Todd Kennedy Date: Fri, 16 Jun 2017 17:55:34 -0700 Subject: [PATCH] Provide hooks for form validation Input elements can now be provided with validation information, either utilizing the built-in HTML 5 validation routines and error messages, or by providing a custom validation method which sets a custom validation error. --- README.md | 101 +++++++++++++++++++++++++++++++++--- create-validator.js | 18 +++++++ element-helper.js | 4 +- error-el.js | 6 ++- input-el.js | 15 +++++- merge-function.js | 7 +++ standalone.js | 9 +++- test/merge-function.spec.js | 23 ++++++++ test/validation.spec.js | 101 ++++++++++++++++++++++++++++++++++++ 9 files changed, 271 insertions(+), 13 deletions(-) create mode 100644 create-validator.js create mode 100644 merge-function.js create mode 100644 test/merge-function.spec.js create mode 100644 test/validation.spec.js diff --git a/README.md b/README.md index a06f9f6..6065824 100644 --- a/README.md +++ b/README.md @@ -3,23 +3,110 @@ A collection of form elements. +## Usage +```js +const els = require('@scriptoll/form-elements') +const html = require('bel') + +function form (state, emit) { + const err = els.error() + + return html`
+ ${err.render()} + ${els.labeledInput('Email', 'email', update, state.email, {validate: true, errorDisplay: err})} + ${els.button('submit!', submit)} +
` + + function input (evt) { + state.email = evt.currentTarget.value + } + + function submit (evt) { + evt.preventDefault() // so the browser doesn't submit this thing + emit('form:submit') + } +} +``` + +Results in: + +```html +
+
+ + +
+``` + +In this example, the `` field will be validated using the +built-in browser's validation framework for email addresses. When the user +`blur`s from the field, it'll attempt to validate the data inside, and if +it isn't valid, it'll display the error in the `
` located at the top +of the form. + + +## Options + +#### `elementOptions:object(key:string,value:any)` +All the elements accept an `elementOptions` hash as the final argument which controls any additional element attributes you may wish to set, in addition to the following: + +* `classes:array(string)` - an array of class names to apply, in addition to the default ones. +* `style:string` - a string containing inline style information + +If you wish to override all the classes attached to the input, you can do so by providing the `class` key on this options list. + +Event handlers should be provided using the `on[event]` key, e.g. `oninput` or `onblur`. + +Each element type will have specific attributes that can be set in addition to the [default attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes) provided by the DOM spec. + +## API + +#### `error(opts:elementOptions):ErrorDisplay` +Create a new error display object + +`ErrorDisplay`: +* `#render():HTMLDivElement` - return a `
` element, using the `opts` provided by the constructor +* `#displayError(key:string, text:string):undefined` - Add a new error to the display +* `#removeError(key:string):undefined` - Remove an error from the display +* `#clear():undefined` - Remove all errors + ## Elements -#### `input(label:string, type:string, inputHandler:function(evt:HTMLInputEvent, value:(string|number):HTMLLabelElement` -Create a form input wrapped in a label tag: +#### `input(label:string, type:string, inputHandler:function(evt:HTMLInputEvent, value:(string|number), opts:elementOptions):HTMLInputElement` +Create a form input with various options. `opts` accepts any of the [HTMLInputElement](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#Attributes) attributes, the usual default attributes, in addition to several custom fields: + +* `validate:boolean` - should validation be attempted when the `blur` event has been fired +* `validator:function($el:HTMLInputElement):boolean` - a custom validation method that returns true/false. If you need to set a custom error message, you should use `$el.setCustomValidity`, but don't forget to set to an empty string when the error has been resolved! +* `errorDisplay:ErrorElement` - the error element that validation errors should be placed into. Must match the API provided by the included `error` object + ```html - + ``` +--- -#### `button(text:string, clickHandler:function(evt:HTMLClickEvent):HTMLButtonElement` -Create a button with a click handler: +#### `button(text:string, clickHandler:function(evt:HTMLClickEvent), opts:elementOptions):HTMLButtonElement` +Create a button with a click handler. ```html ``` +--- + +#### `label(text:string, opts:elementOptions):HTMLLabellement` +Create a label + +```html + +``` +--- + +#### `labeledInput(label:string, type:string, inputHandler:function(evt:HTMLInputEvent, value:(string|number), labelOpts:elementOptions, inputOpts:elementOptions)` +Create a form input element wrapped by a label + +```html + +``` ## License Copyright © 2017 Scripto, LLC. All rights reserved diff --git a/create-validator.js b/create-validator.js new file mode 100644 index 0000000..b8606fd --- /dev/null +++ b/create-validator.js @@ -0,0 +1,18 @@ +module.exports = function createValidator (errDisplay, validator) { + const key = (new Date() % 9e6).toString(36) + + return function checkValidity (evt) { + const $el = evt.currentTarget + if (typeof validator !== 'function') { + validator = $el.checkValidity.bind($el) + } + + if (!validator($el)) { + $el.dataset.valid = false + return errDisplay.displayError(key, $el.validationMessage) + } + + $el.dataset.valid = true + errDisplay.removeError(key) + } +} diff --git a/element-helper.js b/element-helper.js index ae4151d..f24b415 100644 --- a/element-helper.js +++ b/element-helper.js @@ -20,9 +20,11 @@ function applyClasses (classes, opts) { return classes } +// We don't want to set any of these keys directly on the DOM object that we create +const optsIgnore = ['classes', 'style', 'validator', 'errDisplay', 'validate'] function applyOpts (el, opts) { Object.keys(opts) - .filter((key) => key !== 'classes' || key !== 'style') + .filter((key) => !optsIgnore.includes(key)) .forEach((key) => { el[key] = opts[key] }) diff --git a/error-el.js b/error-el.js index 3d7a005..10dc634 100644 --- a/error-el.js +++ b/error-el.js @@ -35,8 +35,10 @@ function error (opts) { } function removeError (key) { - messages.delete(key) - container.removeChild(getMsgDiv(key)) + if (messages.has(key)) { + messages.delete(key) + container.removeChild(getMsgDiv(key)) + } } function clear () { diff --git a/input-el.js b/input-el.js index 2451101..a1c4c6a 100644 --- a/input-el.js +++ b/input-el.js @@ -1,5 +1,9 @@ -const applyOpts = require('./element-helper') const html = require('bel') + +const applyOpts = require('./element-helper') +const mergeFuncs = require('./merge-function') +const createValidator = require('./create-validator') + const noop = () => {} module.exports = input @@ -19,5 +23,14 @@ function input (type, handler, value, inputOpts) { oninput=${handler} value="${value}">` + if (inputOpts.validate || typeof inputOpts.validator === 'function') { + const validator = createValidator(inputOpts.errorDisplay, inputOpts.validator) + const blur = inputOpts.onblur + + inputOpts.onblur = typeof blur === 'function' + ? mergeFuncs(validator, blur) : validator + } + return applyOpts.opts($inputEl, inputOpts) } + diff --git a/merge-function.js b/merge-function.js new file mode 100644 index 0000000..85f6942 --- /dev/null +++ b/merge-function.js @@ -0,0 +1,7 @@ +module.exports = function merge (...funcs) { + return function (...args) { + for (let i = 0, len = funcs.length; i < len; i++) { + funcs[i].call(funcs[i], ...args) + } + } +} diff --git a/standalone.js b/standalone.js index 321fbb8..cb5931a 100644 --- a/standalone.js +++ b/standalone.js @@ -8,17 +8,22 @@ const scratch = { } function main (state, emit) { + const formErrors = els.error() + const inputOpts = { classes: ['w-50'], + validate: true, onblur: function (evt) { console.log('you blurred me') - } + }, + errorDisplay: formErrors } - const $input = els.labeledInput('test input', 'text', oninput, scratch.test, inputOpts) + const $input = els.labeledInput('test input', 'email', oninput, scratch.test, inputOpts) return html`
+ ${formErrors.render()}
Button pushed: ${scratch.pushed}
Input value: ${scratch.test}
${$input}
diff --git a/test/merge-function.spec.js b/test/merge-function.spec.js new file mode 100644 index 0000000..bbaaf15 --- /dev/null +++ b/test/merge-function.spec.js @@ -0,0 +1,23 @@ +const test = require('tape') +const mergeFuncs = require('../merge-function') + +test('funky merge', (t) => { + t.plan(8) + + function foo (...args) { + t.equal(args.length, 2, '2 args') + t.equal(args[0], 'beep', 'beepin') + t.equal(args[1], 'boop', 'boopin') + t.pass('foo called') + } + + function bar (...args) { + t.equal(args.length, 2, '2 args') + t.equal(args[0], 'beep', 'beepin') + t.equal(args[1], 'boop', 'boopin') + t.pass('bar called') + } + + const merged = mergeFuncs(foo, bar) + merged('beep', 'boop') +}) diff --git a/test/validation.spec.js b/test/validation.spec.js new file mode 100644 index 0000000..6936dd5 --- /dev/null +++ b/test/validation.spec.js @@ -0,0 +1,101 @@ +const test = require('tape') + +const error = require('../error-el') +const input = require('../input-el') + +const emailErrors = { + chrome: "Please include an '@' in the email address. 't' is missing an '@'.", + firefox: 'Please enter an email address.', + safari: 'Please enter an email address.' +} + +let browser = 'chrome' +let ua = navigator.userAgent.toLowerCase() +if (ua.includes('firefox')) { + browser = 'firefox' +} else if (ua.includes('chrome')) { + browser = 'chrome' +} else if (ua.includes('safari')) { + browser = 'safari' +} + +test('no validation, no problems', (t) => { + const err = error() + const inputOpts = { + validate: false, + errorDisplay: err + } + const $err = err.render() + const $input = input('email', null, '', inputOpts) + $input.value = 't' + $input.dispatchEvent(new window.Event('blur')) + t.equal($err.children.length, 0, 'no errors') + t.end() +}) + +test('no explicit validation, no problems', (t) => { + const err = error() + const inputOpts = { + errorDisplay: err + } + const $err = err.render() + const $input = input('email', null, '', inputOpts) + $input.value = 't' + $input.dispatchEvent(new window.Event('blur')) + t.equal($err.children.length, 0, 'no errors') + t.end() +}) + +test('built-in validation', (t) => { + t.plan(9) // every time you call blur, the custom onblur runs... + const err = error() + const inputOpts = { + errorDisplay: err, + validate: true, + onblur: () => t.pass('ran custom on blur') + } + const $err = err.render() + const $input = input('email', null, '', inputOpts) + + $input.value = 't' + $input.dispatchEvent(new window.Event('blur')) + t.equal($err.children.length, 1, 'show built-in error') + + $input.dispatchEvent(new window.Event('blur')) + t.equal($err.children.length, 1, 'still only one error') + t.equal($err.children[0].innerText, emailErrors[browser], 'chrome error message') + + $input.value = 't@t' + $input.dispatchEvent(new window.Event('blur')) + t.equal($err.children.length, 0, 'no errors') + + $input.dispatchEvent(new window.Event('blur')) + t.equal($err.children.length, 0, 'still no errors') +}) + +test('custom validation', (t) => { + const err = error() + const inputOpts = { + errorDisplay: err, + validator: ($el) => { + if ($el.value !== 'boop') { + $el.setCustomValidity('You must boop!') + return false + } + $el.setCustomValidity('') + return true + } + } + const $err = err.render() + const $input = input('email', null, '', inputOpts) + + $input.value = 't' + $input.dispatchEvent(new window.Event('blur')) + t.equal($err.children.length, 1, 'only one error') + t.equal($err.children[0].innerText, 'You must boop!', 'custom error message') + + $input.value = 'boop' + $input.dispatchEvent(new window.Event('blur')) + t.equal($err.children.length, 0, 'no errors') + t.end() +})