@jdlien/validator 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierrc +8 -0
- package/README.md +1 -1
- package/demo-src.css +106 -0
- package/demo.html +779 -0
- package/index.ts +5 -0
- package/package.json +1 -10
- package/src/Validator.ts +628 -0
- package/src/types.d.ts +23 -0
- package/src/validator-utils.ts +656 -0
- package/tailwind.config.cjs +12 -0
- package/tests/Validator.test.ts +1922 -0
- package/tests/utils.test.ts +1048 -0
- package/tsconfig.json +19 -0
- package/vite.config.js +22 -0
- package/dist/validator.js +0 -1
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jdlien/validator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"files": [
|
|
6
|
-
"dist"
|
|
7
|
-
],
|
|
8
5
|
"module": "dist/validator.js",
|
|
9
|
-
"exports": {
|
|
10
|
-
".": {
|
|
11
|
-
"import": "./dist/validator.js"
|
|
12
|
-
},
|
|
13
|
-
"./package.json": "./package.json"
|
|
14
|
-
},
|
|
15
6
|
"description": "Validates and sanitizes the inputs in a form using native html attributes.",
|
|
16
7
|
"scripts": {
|
|
17
8
|
"dev": "vite",
|
package/src/Validator.ts
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form Validator used by EPL apps and www2.
|
|
3
|
+
* © 2023 JD Lien
|
|
4
|
+
*
|
|
5
|
+
* @format
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Import the validator utility functions
|
|
9
|
+
import * as utils from './validator-utils'
|
|
10
|
+
import { FormControl, ValidatorOptions, InputHandlers } from './types'
|
|
11
|
+
|
|
12
|
+
export class ValidationSuccessEvent extends Event {
|
|
13
|
+
submitEvent: Event
|
|
14
|
+
constructor(submitEvent: Event) {
|
|
15
|
+
super('validationSuccess', { cancelable: true })
|
|
16
|
+
this.submitEvent = submitEvent
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ValidationErrorEvent extends Event {
|
|
21
|
+
submitEvent: Event
|
|
22
|
+
constructor(submitEvent: Event) {
|
|
23
|
+
super('validationError', { cancelable: true })
|
|
24
|
+
this.submitEvent = submitEvent
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default class Validator {
|
|
29
|
+
form: HTMLFormElement
|
|
30
|
+
inputs: FormControl[] = []
|
|
31
|
+
// Keeps track of error messages accumulated for each input
|
|
32
|
+
inputErrors: { [key: string]: string[] } = {}
|
|
33
|
+
|
|
34
|
+
// Default error messages.
|
|
35
|
+
messages = {
|
|
36
|
+
ERROR_MAIN: 'There is a problem with your submission.',
|
|
37
|
+
ERROR_GENERIC: 'Enter a valid value.',
|
|
38
|
+
ERROR_REQUIRED: 'This field is required.',
|
|
39
|
+
OPTION_REQUIRED: 'An option must be selected.',
|
|
40
|
+
CHECKED_REQUIRED: 'This must be checked.',
|
|
41
|
+
ERROR_MAXLENGTH: 'This must be ${val} characters or fewer.',
|
|
42
|
+
ERROR_MINLENGTH: 'This must be at least ${val} characters.',
|
|
43
|
+
ERROR_NUMBER: 'This must be a number.',
|
|
44
|
+
ERROR_INTEGER: 'This must be a whole number.',
|
|
45
|
+
ERROR_TEL: 'This is not a valid telephone number.',
|
|
46
|
+
ERROR_EMAIL: 'This is not a valid email address.',
|
|
47
|
+
ERROR_ZIP: 'This is not a valid zip code.',
|
|
48
|
+
ERROR_POSTAL: 'This is not a valid postal code.',
|
|
49
|
+
ERROR_DATE: 'This is not a valid date.',
|
|
50
|
+
ERROR_DATE_PAST: 'The date must be in the past.',
|
|
51
|
+
ERROR_DATE_FUTURE: 'The date must be in the future.',
|
|
52
|
+
ERROR_DATE_RANGE: 'The date is outside the allowed range.',
|
|
53
|
+
ERROR_TIME: 'This is not a valid time.',
|
|
54
|
+
ERROR_TIME_RANGE: 'The time is outside the allowed range.',
|
|
55
|
+
ERROR_URL: 'This is not a valid URL.',
|
|
56
|
+
ERROR_COLOR: 'This is not a valid CSS colour.',
|
|
57
|
+
ERROR_CUSTOM_VALIDATION: 'There was a problem validating this field.',
|
|
58
|
+
}
|
|
59
|
+
// Show debug messages in the console
|
|
60
|
+
debug: boolean
|
|
61
|
+
// Whether validation should be performed immediately on instantiation
|
|
62
|
+
autoInit: boolean
|
|
63
|
+
// Whether to prevent the form from submitting if validation is successful
|
|
64
|
+
preventSubmit: boolean = false
|
|
65
|
+
// Class toggled hide an element (eg display:none)
|
|
66
|
+
hiddenClasses: string
|
|
67
|
+
|
|
68
|
+
// Classes to apply to the main error message (space-separated)
|
|
69
|
+
errorMainClasses: string
|
|
70
|
+
// Classes added to an invalid input (space-separated)
|
|
71
|
+
errorInputClasses: string
|
|
72
|
+
// Timeout for dispatching events on input (used by syncColorInput)
|
|
73
|
+
private dispatchTimeout: number = 0
|
|
74
|
+
|
|
75
|
+
// Whether the original form has a novalidate attribute
|
|
76
|
+
private originalNoValidate: boolean = false
|
|
77
|
+
|
|
78
|
+
private validationSuccessCallback: (event: Event) => void
|
|
79
|
+
private validationErrorCallback: (event: Event) => void
|
|
80
|
+
|
|
81
|
+
// Sets defaults and adds event listeners
|
|
82
|
+
constructor(form: HTMLFormElement, options: ValidatorOptions = {}) {
|
|
83
|
+
if (!form) throw new Error('Validator requires a form to be passed as the first argument.')
|
|
84
|
+
if (!(form instanceof HTMLFormElement)) {
|
|
85
|
+
throw new Error('form argument must be an instance of HTMLFormElement')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.form = form
|
|
89
|
+
|
|
90
|
+
// If the form has a data-prevent-submit attribute, set preventSubmit to true
|
|
91
|
+
// Can be overridden with the preventSubmit option
|
|
92
|
+
if (form.dataset.preventSubmit === '' || form.dataset.preventSubmit) this.preventSubmit = true
|
|
93
|
+
|
|
94
|
+
// Merge options with defaults
|
|
95
|
+
Object.assign(this.messages, options.messages || {})
|
|
96
|
+
this.debug = options.debug || false
|
|
97
|
+
this.autoInit = options.autoInit === false ? false : true
|
|
98
|
+
this.preventSubmit = options.preventSubmit === false ? false : this.preventSubmit
|
|
99
|
+
this.hiddenClasses = options.hiddenClasses || 'hidden opacity-0'
|
|
100
|
+
this.errorMainClasses =
|
|
101
|
+
options.errorMainClasses ||
|
|
102
|
+
'm-2 border border-red-500 bg-red-100 p-3 dark:bg-red-900/80 text-center'
|
|
103
|
+
|
|
104
|
+
this.errorInputClasses = options.errorInputClasses || 'border-red-600 dark:border-red-500'
|
|
105
|
+
this.validationSuccessCallback = options.validationSuccessCallback || (() => {})
|
|
106
|
+
this.validationErrorCallback = options.validationErrorCallback || (() => {})
|
|
107
|
+
|
|
108
|
+
if (this.autoInit) this.init()
|
|
109
|
+
|
|
110
|
+
// Re-initialize the form if it altered in the DOM
|
|
111
|
+
new MutationObserver(() => this.autoInit && this.init()).observe(form, { childList: true })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Event handler references
|
|
115
|
+
private submitHandlerRef = this.submitHandler.bind(this)
|
|
116
|
+
private inputInputHandlerRef = this.inputInputHandler.bind(this)
|
|
117
|
+
private inputChangeHandlerRef = this.inputChangeHandler.bind(this)
|
|
118
|
+
private inputKeydownHandlerRef = this.inputKeydownHandler.bind(this)
|
|
119
|
+
|
|
120
|
+
public addEventListeners(): void {
|
|
121
|
+
this.form.addEventListener('submit', this.submitHandlerRef)
|
|
122
|
+
this.form.addEventListener('input', this.inputInputHandlerRef)
|
|
123
|
+
this.form.addEventListener('change', this.inputChangeHandlerRef)
|
|
124
|
+
this.form.addEventListener('keydown', this.inputKeydownHandlerRef)
|
|
125
|
+
// This doesn't seem to be very useful
|
|
126
|
+
this.form.addEventListener('remove', this.destroy, { once: true })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public removeEventListeners(): void {
|
|
130
|
+
this.form.removeEventListener('submit', this.submitHandlerRef)
|
|
131
|
+
this.form.removeEventListener('input', this.inputInputHandlerRef)
|
|
132
|
+
this.form.removeEventListener('change', this.inputChangeHandlerRef)
|
|
133
|
+
this.form.removeEventListener('keydown', this.inputKeydownHandlerRef)
|
|
134
|
+
this.form.removeEventListener('remove', this.destroy)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Adds event listeners to all formFields in a specified form
|
|
138
|
+
init(): void {
|
|
139
|
+
this.inputs = Array.from(this.form.elements) as FormControl[]
|
|
140
|
+
// Ensure each input has a unique ID and an empty array in inputErrors
|
|
141
|
+
this.inputs.forEach((input) => {
|
|
142
|
+
if (!input.name && !input.id) input.id = `vl-input-${Math.random().toString(36).slice(2)}`
|
|
143
|
+
this.inputErrors[input.name || input.id] = []
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Check that the original form has a novalidate attribute
|
|
147
|
+
this.originalNoValidate = this.form.hasAttribute('novalidate')
|
|
148
|
+
|
|
149
|
+
// Disable the browser's built-in validation
|
|
150
|
+
this.form.setAttribute('novalidate', 'novalidate')
|
|
151
|
+
|
|
152
|
+
this.removeEventListeners()
|
|
153
|
+
this.addEventListeners()
|
|
154
|
+
} // end init()
|
|
155
|
+
|
|
156
|
+
private getErrorEl(input: FormControl): HTMLElement | null {
|
|
157
|
+
const errorEl = document.getElementById(input.name + '-error')
|
|
158
|
+
if (errorEl) return errorEl
|
|
159
|
+
|
|
160
|
+
return document.getElementById(input.id + '-error') || null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private addErrorMain(message?: string): void {
|
|
164
|
+
const errorEl = document.createElement('div')
|
|
165
|
+
errorEl.id = 'form-error-main'
|
|
166
|
+
this.errorMainClasses.split(' ').forEach((className) => {
|
|
167
|
+
errorEl.classList.add(className)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
if (message) errorEl.innerHTML = message
|
|
171
|
+
else errorEl.innerHTML = this.messages.ERROR_MAIN
|
|
172
|
+
// Add the error message to the bottom of the form
|
|
173
|
+
this.form.appendChild(errorEl)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Adds an error to the array of strings to be displayed by an input that failed
|
|
177
|
+
private addInputError(
|
|
178
|
+
el: FormControl,
|
|
179
|
+
message = el.dataset.errorDefault || this.messages.ERROR_GENERIC
|
|
180
|
+
): void {
|
|
181
|
+
const name = el.name || el.id
|
|
182
|
+
|
|
183
|
+
if (this.debug) console.log('Invalid value for ' + name + ': ' + message)
|
|
184
|
+
|
|
185
|
+
// init already does this, but ensure the input has an array
|
|
186
|
+
if (!(name in this.inputErrors)) this.inputErrors[name] = []
|
|
187
|
+
// Add the message if it isn't already in the array
|
|
188
|
+
if (!this.inputErrors[name].includes(message)) this.inputErrors[name].push(message)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Shows an error message in a container with the input's id suffixed with -error.
|
|
192
|
+
// A future version of this could inject an error element into the DOM if it doesn't exist
|
|
193
|
+
private showInputErrors(el: FormControl): void {
|
|
194
|
+
if (!el || (!el.name && !el.id)) return
|
|
195
|
+
|
|
196
|
+
// Check if the input has any error messages
|
|
197
|
+
const name = el.name || el.id
|
|
198
|
+
const errors = name in this.inputErrors ? this.inputErrors[name] : []
|
|
199
|
+
// If there are no errors, don't do anything
|
|
200
|
+
if (!errors.length) return
|
|
201
|
+
|
|
202
|
+
el.setAttribute('aria-invalid', 'true')
|
|
203
|
+
|
|
204
|
+
// Apply input classes to indicate an error on the input itself
|
|
205
|
+
this.errorInputClasses.split(' ').forEach((className) => {
|
|
206
|
+
el.classList.add(className)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Add the error messages to the error element and show it
|
|
210
|
+
let errorEl = this.getErrorEl(el)
|
|
211
|
+
if (!errorEl) return
|
|
212
|
+
|
|
213
|
+
errorEl.innerHTML = errors.join('<br>')
|
|
214
|
+
|
|
215
|
+
this.hiddenClasses.split(' ').forEach((className) => {
|
|
216
|
+
if (errorEl) errorEl.classList.remove(className)
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Shows all the error messages for all the inputs of the form, and a main error message
|
|
221
|
+
// TODO: Consider (optionally) scrolling to the first error message
|
|
222
|
+
private showFormErrors(): void {
|
|
223
|
+
// Show any errors from validation
|
|
224
|
+
this.inputs.forEach((el) => this.showInputErrors(el))
|
|
225
|
+
|
|
226
|
+
// If if any of the inputs have error messages, show the main error message
|
|
227
|
+
// One could show all the errors in the main message, but it might get long
|
|
228
|
+
// Should this be in the same branch of code that does the dispatch and callback?
|
|
229
|
+
if (Object.values(this.inputErrors).some((el) => Array.isArray(el) && el.length)) {
|
|
230
|
+
const mainErrorEl = this.form.querySelectorAll('#form-error-main')
|
|
231
|
+
if (mainErrorEl.length) {
|
|
232
|
+
mainErrorEl.forEach((el) => {
|
|
233
|
+
// If there are no contents, add the default message
|
|
234
|
+
if (!el.innerHTML) el.innerHTML = this.messages.ERROR_MAIN
|
|
235
|
+
this.hiddenClasses.split(' ').forEach((className) => {
|
|
236
|
+
el.classList.remove(className)
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
} else this.addErrorMain()
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Clears error messages from an input and removes its errors from the inputErrors array
|
|
244
|
+
private clearInputErrors(el: FormControl): void {
|
|
245
|
+
this.inputErrors[el.name || el.id] = []
|
|
246
|
+
|
|
247
|
+
// Remove the aria-invalid attribute from the input
|
|
248
|
+
el.removeAttribute('aria-invalid')
|
|
249
|
+
|
|
250
|
+
let errorEl = this.getErrorEl(el)
|
|
251
|
+
if (!errorEl) return
|
|
252
|
+
|
|
253
|
+
// Remove the error style
|
|
254
|
+
this.errorInputClasses.split(' ').forEach((className) => {
|
|
255
|
+
el.classList.remove(className)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
// Hide the error element
|
|
259
|
+
this.hiddenClasses.split(' ').forEach((className) => {
|
|
260
|
+
if (errorEl) errorEl.classList.add(className)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// Clear the error message
|
|
264
|
+
// TODO: This needs to happen on transitionend if we want to animate the error message out
|
|
265
|
+
errorEl.textContent = ''
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private clearFormErrors(): void {
|
|
269
|
+
// If there's a big error message, hide it
|
|
270
|
+
this.form.querySelectorAll('#form-error-main').forEach((el) => {
|
|
271
|
+
this.hiddenClasses.split(' ').forEach((className) => {
|
|
272
|
+
el.classList.add(className)
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
// Clear any previous errors
|
|
277
|
+
this.inputs.forEach((el) => this.clearInputErrors(el))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Validates a required input and returns true if it's valid.
|
|
281
|
+
// Shows an error if the input is required and empty.
|
|
282
|
+
private validateRequired(el: FormControl): boolean {
|
|
283
|
+
let valid = true
|
|
284
|
+
if (
|
|
285
|
+
el.required &&
|
|
286
|
+
(el.value === '' ||
|
|
287
|
+
(el instanceof HTMLInputElement && ['checkbox', 'radio'].includes(el.type) && !el.checked))
|
|
288
|
+
) {
|
|
289
|
+
// Handle checkboxes and radio buttons. Check that at least one of any name group is checked
|
|
290
|
+
// Check that any checkbox of a group of checkboxes is checked
|
|
291
|
+
// This assumes the checkbox or radio button is in a group... if it's not,
|
|
292
|
+
// we can specify a default error message with error=
|
|
293
|
+
if (el instanceof HTMLInputElement && ['checkbox', 'radio'].includes(el.type)) {
|
|
294
|
+
let groupChecked = false
|
|
295
|
+
let groupName = el.name
|
|
296
|
+
const groupInputs = this.form.querySelectorAll(`input[name="${groupName}"]`)
|
|
297
|
+
groupInputs.forEach((input) => {
|
|
298
|
+
if (input instanceof HTMLInputElement && input.checked === true) {
|
|
299
|
+
groupChecked = true
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
if (groupChecked === false) {
|
|
305
|
+
valid = false
|
|
306
|
+
|
|
307
|
+
let message =
|
|
308
|
+
groupInputs.length > 1 ? this.messages.OPTION_REQUIRED : this.messages.CHECKED_REQUIRED
|
|
309
|
+
|
|
310
|
+
// If there's a data-error-default attribute, use that as the error message
|
|
311
|
+
if (el.dataset.errorDefault) message = el.dataset.errorDefault
|
|
312
|
+
this.addInputError(el, message)
|
|
313
|
+
}
|
|
314
|
+
} else if (utils.isFormControl(el)) {
|
|
315
|
+
valid = false
|
|
316
|
+
this.addInputError(el, el.dataset.errorDefault || this.messages.ERROR_REQUIRED)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return valid
|
|
320
|
+
} // end validateRequired
|
|
321
|
+
|
|
322
|
+
// Validates a min and max length
|
|
323
|
+
private validateLength(el: FormControl): boolean {
|
|
324
|
+
let valid = true
|
|
325
|
+
|
|
326
|
+
if ((el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) && el.value.length) {
|
|
327
|
+
// prettier-ignore
|
|
328
|
+
let minLength = el.minLength > 0 ? el.minLength
|
|
329
|
+
: el.dataset.minLength ? parseInt(el.dataset.minLength as string) : 0
|
|
330
|
+
|
|
331
|
+
// prettier-ignore
|
|
332
|
+
let maxLength = el.maxLength > 0 && el.maxLength < 500_000 ? el.maxLength
|
|
333
|
+
: el.dataset.maxLength ? parseInt(el.dataset.maxLength as string) : Infinity
|
|
334
|
+
|
|
335
|
+
if (minLength > 0 && el.value.length < minLength) {
|
|
336
|
+
valid = false
|
|
337
|
+
this.addInputError(
|
|
338
|
+
el,
|
|
339
|
+
this.messages.ERROR_MINLENGTH.replace('${val}', minLength.toString())
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (el.value.length > maxLength) {
|
|
344
|
+
valid = false
|
|
345
|
+
this.addInputError(
|
|
346
|
+
el,
|
|
347
|
+
this.messages.ERROR_MAXLENGTH.replace('${val}', maxLength.toString())
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return valid
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// A map of input handlers that can be used for each type of input.
|
|
356
|
+
private inputHandlers: InputHandlers = {
|
|
357
|
+
number: {
|
|
358
|
+
parse: utils.parseNumber,
|
|
359
|
+
isValid: utils.isNumber,
|
|
360
|
+
error: this.messages.ERROR_NUMBER,
|
|
361
|
+
},
|
|
362
|
+
integer: {
|
|
363
|
+
parse: utils.parseInteger,
|
|
364
|
+
isValid: utils.isInteger,
|
|
365
|
+
error: this.messages.ERROR_INTEGER,
|
|
366
|
+
},
|
|
367
|
+
tel: {
|
|
368
|
+
parse: utils.parseNANPTel,
|
|
369
|
+
isValid: utils.isNANPTel,
|
|
370
|
+
error: this.messages.ERROR_TEL,
|
|
371
|
+
},
|
|
372
|
+
email: {
|
|
373
|
+
parse: (value: string) => value.trim(),
|
|
374
|
+
isValid: utils.isEmail,
|
|
375
|
+
error: this.messages.ERROR_EMAIL,
|
|
376
|
+
},
|
|
377
|
+
zip: {
|
|
378
|
+
parse: utils.parseZip,
|
|
379
|
+
isValid: utils.isZip,
|
|
380
|
+
error: this.messages.ERROR_ZIP,
|
|
381
|
+
},
|
|
382
|
+
postal: {
|
|
383
|
+
parse: utils.parsePostalCA,
|
|
384
|
+
isValid: utils.isPostalCA,
|
|
385
|
+
error: this.messages.ERROR_POSTAL,
|
|
386
|
+
},
|
|
387
|
+
url: {
|
|
388
|
+
parse: utils.parseUrl,
|
|
389
|
+
isValid: utils.isUrl,
|
|
390
|
+
error: this.messages.ERROR_URL,
|
|
391
|
+
},
|
|
392
|
+
date: {
|
|
393
|
+
parse: utils.parseDateToString,
|
|
394
|
+
isValid: utils.isDate,
|
|
395
|
+
error: this.messages.ERROR_DATE,
|
|
396
|
+
},
|
|
397
|
+
time: {
|
|
398
|
+
parse: utils.parseTimeToString,
|
|
399
|
+
isValid: utils.isTime,
|
|
400
|
+
error: this.messages.ERROR_TIME,
|
|
401
|
+
},
|
|
402
|
+
color: {
|
|
403
|
+
parse: (value: string) => value.trim().toLowerCase(),
|
|
404
|
+
isValid: utils.isColor,
|
|
405
|
+
error: this.messages.ERROR_COLOR,
|
|
406
|
+
},
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private validateInputType(el: FormControl): boolean {
|
|
410
|
+
const dataType = el.dataset.type || el.type
|
|
411
|
+
const inputHandler = this.inputHandlers[el.type] || this.inputHandlers[dataType]
|
|
412
|
+
|
|
413
|
+
if (inputHandler) {
|
|
414
|
+
const dateFormat = el.dataset.dateFormat || el.dataset.timeFormat
|
|
415
|
+
const parsedValue = inputHandler.parse(el.value, dateFormat)
|
|
416
|
+
|
|
417
|
+
// Do not update the value if the input is one of these input types
|
|
418
|
+
const nonUpdateableTypes = ['date', 'time', 'datetime-local', 'month', 'week']
|
|
419
|
+
if (parsedValue.length && !nonUpdateableTypes.includes(el.type)) el.value = parsedValue
|
|
420
|
+
|
|
421
|
+
if (!inputHandler.isValid(el.value)) {
|
|
422
|
+
this.addInputError(el, inputHandler.error)
|
|
423
|
+
return false
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return true
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private validateDateRange(el: FormControl): boolean {
|
|
431
|
+
if (el.dataset.dateRange) {
|
|
432
|
+
const range = el.dataset.dateRange
|
|
433
|
+
const date = utils.parseDate(el.value)
|
|
434
|
+
// only validate the date range if it's a valid date
|
|
435
|
+
if (!isNaN(date.getTime()) && !utils.isDateInRange(date, range)) {
|
|
436
|
+
let msg = el.dataset.errorDefault || this.messages.ERROR_DATE_RANGE
|
|
437
|
+
if (range === 'past') msg = this.messages.ERROR_DATE_PAST
|
|
438
|
+
else if (range === 'future') msg = this.messages.ERROR_DATE_FUTURE
|
|
439
|
+
this.addInputError(el, msg)
|
|
440
|
+
return false
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return true
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Validates a pattern from data-pattern or pattern; data-pattern takes precedence
|
|
448
|
+
private validatePattern(el: FormControl): boolean {
|
|
449
|
+
const pattern = el.dataset.pattern || (el instanceof HTMLInputElement && el.pattern) || null
|
|
450
|
+
if (pattern && !new RegExp(pattern).test(el.value)) {
|
|
451
|
+
this.addInputError(el) // Use the default error message
|
|
452
|
+
return false
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return true
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Specify a custom function in data-validation and it gets called to validate the input
|
|
460
|
+
* The custom function can return
|
|
461
|
+
* - a boolean
|
|
462
|
+
* - a Promise that resolves to a boolean
|
|
463
|
+
* - an object with a valid property that is a boolean
|
|
464
|
+
* - a Promise that resolves to an object with a valid property that is a boolean
|
|
465
|
+
* - and optionally a messages property that is a string or array of strings
|
|
466
|
+
* - OR optionally, a message property that is a string
|
|
467
|
+
* - optionaly, a boolean error property that is true if something went wrong
|
|
468
|
+
*/
|
|
469
|
+
private async validateCustom(el: FormControl): Promise<boolean> {
|
|
470
|
+
const validation = el.dataset.validation
|
|
471
|
+
if (!validation || typeof validation !== 'string') return true
|
|
472
|
+
const validationFn: Function = window[validation as keyof Window] as Function
|
|
473
|
+
if (!validationFn || typeof validationFn !== 'function') return true
|
|
474
|
+
|
|
475
|
+
let result: any
|
|
476
|
+
try {
|
|
477
|
+
result = await Promise.resolve(validationFn(el.value))
|
|
478
|
+
result = utils.normalizeValidationResult(result)
|
|
479
|
+
} catch (err) {
|
|
480
|
+
this.addInputError(el, this.messages.ERROR_CUSTOM_VALIDATION)
|
|
481
|
+
return false
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const message = result.messages.join('<br>') || this.messages.ERROR_CUSTOM_VALIDATION
|
|
485
|
+
if (!result.valid) this.addInputError(el, message)
|
|
486
|
+
|
|
487
|
+
return result.valid
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Validates an input with a value and returns true if it's valid
|
|
491
|
+
// Checks inputs defined in the inputHandlers map, pattern, and date range,
|
|
492
|
+
private async validateInput(el: FormControl): Promise<boolean> {
|
|
493
|
+
if (!(el instanceof HTMLInputElement) || !el.value.length) return true
|
|
494
|
+
|
|
495
|
+
let valid = true
|
|
496
|
+
valid = this.validateInputType(el) && valid
|
|
497
|
+
valid = this.validateDateRange(el) && valid
|
|
498
|
+
valid = this.validatePattern(el) && valid
|
|
499
|
+
valid = (await this.validateCustom(el)) && valid
|
|
500
|
+
|
|
501
|
+
return valid
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Validates all the fields in the form. It will show an error message
|
|
505
|
+
// in all invalid fields and return false if any are invalid.
|
|
506
|
+
async validate(_e?: Event): Promise<boolean> {
|
|
507
|
+
let valid = true
|
|
508
|
+
|
|
509
|
+
for (const el of this.inputs) {
|
|
510
|
+
valid = this.validateRequired(el) && valid
|
|
511
|
+
valid = this.validateLength(el) && valid
|
|
512
|
+
valid = (await this.validateInput(el)) && valid
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return valid
|
|
516
|
+
} //end validate()
|
|
517
|
+
|
|
518
|
+
private isSubmitting = false
|
|
519
|
+
private async submitHandler(e: Event): Promise<void> {
|
|
520
|
+
if (this.isSubmitting) return
|
|
521
|
+
e.preventDefault()
|
|
522
|
+
|
|
523
|
+
// Clear any error messages
|
|
524
|
+
this.clearFormErrors()
|
|
525
|
+
let valid = await this.validate(e)
|
|
526
|
+
// Show messages for any invalid inputs and show a large error message
|
|
527
|
+
this.showFormErrors()
|
|
528
|
+
|
|
529
|
+
// External functions can prevent the form from submitting
|
|
530
|
+
// by calling e.preventDefault() in the validationSuccess event
|
|
531
|
+
const validationSuccessEvent = new ValidationSuccessEvent(e)
|
|
532
|
+
const validationErrorEvent = new ValidationErrorEvent(e)
|
|
533
|
+
|
|
534
|
+
if (valid) {
|
|
535
|
+
this.form.dispatchEvent(validationSuccessEvent)
|
|
536
|
+
if (this.validationSuccessCallback) this.validationSuccessCallback(e)
|
|
537
|
+
} else {
|
|
538
|
+
this.form.dispatchEvent(validationErrorEvent)
|
|
539
|
+
if (this.validationErrorCallback) this.validationErrorCallback(e)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (valid && !this.preventSubmit) {
|
|
543
|
+
this.isSubmitting = true
|
|
544
|
+
if (!validationSuccessEvent.defaultPrevented) this.form.submit()
|
|
545
|
+
this.isSubmitting = false
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private async inputChangeHandler(e: Event): Promise<void> {
|
|
550
|
+
if (!(e.target instanceof HTMLInputElement)) return
|
|
551
|
+
|
|
552
|
+
// Clear and reset error messages for the input
|
|
553
|
+
this.clearInputErrors(e.target)
|
|
554
|
+
await this.validateInput(e.target)
|
|
555
|
+
// Show any error messages for the input after validation
|
|
556
|
+
this.showInputErrors(e.target)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private inputInputHandler(e: Event) {
|
|
560
|
+
const input = e.target as HTMLInputElement
|
|
561
|
+
|
|
562
|
+
// Ensure that a user cannot type non-numerics into an integer input
|
|
563
|
+
if (utils.isType(input, 'integer')) input.value = utils.parseInteger(input.value)
|
|
564
|
+
|
|
565
|
+
// We don't filter native number inputs because it causes issues in Chrome
|
|
566
|
+
if (input.type !== 'number' && utils.isType(input, ['number', 'float', 'decimal'])) {
|
|
567
|
+
input.value = utils.parseNumber(input.value)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (utils.isType(input, 'color')) this.syncColorInput(e)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Sync color inputs (data-type="color") with an associated native color input type
|
|
574
|
+
private syncColorInput(e: Event): void {
|
|
575
|
+
let input = e.target as HTMLInputElement
|
|
576
|
+
let colorInput = input
|
|
577
|
+
|
|
578
|
+
if (input.type === 'color')
|
|
579
|
+
colorInput = this.form.querySelector(`#${input.id.replace(/-color/, '')}`) as HTMLInputElement
|
|
580
|
+
|
|
581
|
+
let colorLabel = this.form.querySelector(`#${colorInput.id}-color-label`) as HTMLInputElement
|
|
582
|
+
|
|
583
|
+
// Update the HTML color picker input and its label background when color input changes
|
|
584
|
+
if ((input.dataset.type || '') === 'color') {
|
|
585
|
+
let colorPicker = this.form.querySelector(`input#${input.id}-color`) as HTMLInputElement
|
|
586
|
+
|
|
587
|
+
if (!colorPicker || !utils.isColor(input.value)) return
|
|
588
|
+
colorPicker.value = utils.parseColor(input.value)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Update the color input and label background when the HTML color picker is changed
|
|
592
|
+
if (input.type === 'color') colorInput.value = input.value
|
|
593
|
+
|
|
594
|
+
if (colorLabel) colorLabel.style.backgroundColor = input.value
|
|
595
|
+
|
|
596
|
+
// Dispatch a change event so the color picker's error message updates
|
|
597
|
+
// Debounce so it doesn't get called rapidly when selecting a color
|
|
598
|
+
clearTimeout(this.dispatchTimeout)
|
|
599
|
+
this.dispatchTimeout = window.setTimeout(() => {
|
|
600
|
+
colorInput.dispatchEvent(new Event('change', { bubbles: true }))
|
|
601
|
+
}, 200)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Support using arrow keys to cycle through numbers.
|
|
605
|
+
// Other handling for keyboard events can be done here
|
|
606
|
+
private inputKeydownHandler(e: KeyboardEvent) {
|
|
607
|
+
if (!(e.target instanceof HTMLInputElement)) return
|
|
608
|
+
|
|
609
|
+
if (utils.isType(e.target, 'integer')) {
|
|
610
|
+
if (e.key === 'ArrowUp') {
|
|
611
|
+
// Prevent the cursor from moving to the beginning of the input
|
|
612
|
+
e.preventDefault()
|
|
613
|
+
if (e.target.value === '') e.target.value = '0'
|
|
614
|
+
e.target.value = (parseInt(e.target.value) + 1).toString()
|
|
615
|
+
} else if (e.key === 'ArrowDown') {
|
|
616
|
+
if (parseInt(e.target.value) > 0) e.target.value = (parseInt(e.target.value) - 1).toString()
|
|
617
|
+
else e.target.value = '0'
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
public destroy() {
|
|
623
|
+
this.removeEventListeners()
|
|
624
|
+
|
|
625
|
+
// Perform other cleanup actions here
|
|
626
|
+
if (!this.originalNoValidate) this.form.removeAttribute('novalidate')
|
|
627
|
+
}
|
|
628
|
+
}
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** @format */
|
|
2
|
+
|
|
3
|
+
export type FormControl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
|
4
|
+
|
|
5
|
+
export interface ValidatorOptions {
|
|
6
|
+
messages?: object
|
|
7
|
+
debug?: boolean
|
|
8
|
+
autoInit?: boolean
|
|
9
|
+
preventSubmit?: boolean
|
|
10
|
+
hiddenClasses?: string
|
|
11
|
+
errorMainClasses?: string
|
|
12
|
+
errorInputClasses?: string
|
|
13
|
+
validationSuccessCallback?: (event: Event) => void
|
|
14
|
+
validationErrorCallback?: (event: Event) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface InputHandlers {
|
|
18
|
+
[key: string]: {
|
|
19
|
+
parse: (value: string, dateFormat?: string) => string
|
|
20
|
+
isValid: (value: string) => boolean
|
|
21
|
+
error: string
|
|
22
|
+
}
|
|
23
|
+
}
|