From dde7e1cb57d1fbebe7fcd9e507c390af6cca88af Mon Sep 17 00:00:00 2001 From: GeoSot Date: Wed, 19 May 2021 18:37:26 +0300 Subject: Make a form validation handler | handle form messages --- js/index.esm.js | 2 + js/index.umd.js | 2 + js/src/forms/field.js | 160 ++++++++++++++++++++++++++++++++++++++++ js/src/forms/form-validation.js | 157 +++++++++++++++++++++++++++++++++++++++ js/src/forms/messages.js | 34 +++++++++ js/src/util/template-factory.js | 149 +++++++++++++++++++++++++++++++++++++ 6 files changed, 504 insertions(+) create mode 100644 js/src/forms/field.js create mode 100644 js/src/forms/form-validation.js create mode 100644 js/src/forms/messages.js create mode 100644 js/src/util/template-factory.js (limited to 'js') diff --git a/js/index.esm.js b/js/index.esm.js index 56f1db755..bb9966598 100644 --- a/js/index.esm.js +++ b/js/index.esm.js @@ -9,6 +9,7 @@ import Alert from './src/alert' import Button from './src/button' import Carousel from './src/carousel' import Collapse from './src/collapse' +import FormValidation from './src/forms/form-validation' import Dropdown from './src/dropdown' import Modal from './src/modal' import Offcanvas from './src/offcanvas' @@ -24,6 +25,7 @@ export { Carousel, Collapse, Dropdown, + FormValidation, Modal, Offcanvas, Popover, diff --git a/js/index.umd.js b/js/index.umd.js index 59863aa1b..ba8e3df3c 100644 --- a/js/index.umd.js +++ b/js/index.umd.js @@ -9,6 +9,7 @@ import Alert from './src/alert' import Button from './src/button' import Carousel from './src/carousel' import Collapse from './src/collapse' +import FormValidation from './src/forms/form-validation' import Dropdown from './src/dropdown' import Modal from './src/modal' import Offcanvas from './src/offcanvas' @@ -23,6 +24,7 @@ export default { Button, Carousel, Collapse, + FormValidation, Dropdown, Modal, Offcanvas, diff --git a/js/src/forms/field.js b/js/src/forms/field.js new file mode 100644 index 000000000..287d69126 --- /dev/null +++ b/js/src/forms/field.js @@ -0,0 +1,160 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.1.0): forms/field.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { isElement, typeCheckConfig } from '../util/index' +import Messages from './messages' +import Manipulator from '../dom/manipulator' +import EventHandler from '../dom/event-handler' +import BaseComponent from '../base-component' +import SelectorEngine from '../dom/selector-engine' + +const NAME = 'field' +const DATA_KEY = 'bs.field' +const EVENT_KEY = `.${DATA_KEY}` +const EVENT_INPUT = `input${EVENT_KEY}` +const CLASS_PREFIX_ERROR = 'invalid' +const CLASS_PREFIX_INFO = 'info' +const CLASS_PREFIX_SUCCESS = 'valid' +const CLASS_FIELD_ERROR = 'is-invalid' +const CLASS_FIELD_SUCCESS = 'is-valid' + +const ARIA_DESCRIBED_BY = 'aria-describedby' +const Default = { + name: null, + type: 'feedback', // or tooltip + valid: '', // valid message to add + invalid: '' // invalid message to add +} + +const DefaultType = { + name: 'string', + type: 'string', + valid: 'string', + invalid: 'string' +} + +class Field extends BaseComponent { + constructor(element, config) { + super(element) + if (!isElement(this._element)) { + throw new TypeError(`field "${this._config.name}" not found`) + } + + this._config = this._getConfig(config) + + this._errorMessages = this._getNewMessagesCollection(CLASS_PREFIX_ERROR, CLASS_FIELD_ERROR) + this._helpMessages = this._getNewMessagesCollection(CLASS_PREFIX_INFO, '') + this._successMessages = this._getNewMessagesCollection(CLASS_PREFIX_SUCCESS, CLASS_FIELD_SUCCESS) + + this._initializeMessageCollections() + EventHandler.on(this._element, EVENT_INPUT, () => { + this.clearAppended() + }) + } + + static get NAME() { + return NAME + } + + getElement() { + return this._element + } + + clearAppended() { + const appendedFeedback = SelectorEngine.findOne(`[class*=-${this._config.type}], ${this._getId()}`, this._element.parentNode) + if (!appendedFeedback) { + return + } + + appendedFeedback.remove() + + this._element.classList.remove(CLASS_FIELD_ERROR, CLASS_FIELD_SUCCESS) + + const initialDescribedBy = this._initialDescribedBy() + if (initialDescribedBy) { + this._element.setAttribute(ARIA_DESCRIBED_BY, initialDescribedBy) + } else { + this._element.removeAttribute(ARIA_DESCRIBED_BY) + } + } + + dispose() { + EventHandler.off(this._element, EVENT_KEY) + Object.getOwnPropertyNames(this).forEach(propertyName => { + this[propertyName] = null + }) + } + + errorMessages() { + return this._errorMessages + } + + helpMessages() { + return this._helpMessages + } + + successMessages() { + return this._successMessages + } + + _getConfig(config) { + config = { + ...Default, + ...Manipulator.getDataAttributes(this._element), + ...(typeof config === 'object' ? config : {}) + } + + typeCheckConfig(NAME, config, DefaultType) + return config + } + + _appendFeedback(htmlElement, elementClass) { + if (!isElement(htmlElement)) { + return + } + + this.clearAppended() + + const feedbackElement = htmlElement + + this._element.parentNode.append(feedbackElement) + feedbackElement.id = this._getId() + + this._element.classList.add(elementClass) + const initialDescribedBy = this._initialDescribedBy() + const describedBy = initialDescribedBy ? `${initialDescribedBy} ` : '' + this._element.setAttribute(ARIA_DESCRIBED_BY, `${describedBy}${feedbackElement.id}`) + } + + _getId() { + return `${this._config.name}-formTip` + } + + _getNewMessagesCollection(classPrefix, elementClass) { + const config = { + appendFunction: html => this._appendFeedback(html, elementClass), + extraClass: `${classPrefix}-${this._config.type}` + } + return new Messages(config) + } + + _initialDescribedBy() { + return (this._element.getAttribute(ARIA_DESCRIBED_BY) || '').replaceAll(this._getId(), '').trim() + } + + _initializeMessageCollections() { + if (this._config.invalid) { + this.errorMessages().set('default', this._config.invalid) + } + + if (this._config.valid) { + this.successMessages().set('default', this._config.valid) + } + } +} + +export default Field diff --git a/js/src/forms/form-validation.js b/js/src/forms/form-validation.js new file mode 100644 index 000000000..b445ed528 --- /dev/null +++ b/js/src/forms/form-validation.js @@ -0,0 +1,157 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.1.0): forms/form-validation.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from '../base-component' +import EventHandler from '../dom/event-handler' +import { typeCheckConfig } from '../util/index' +import Field from './field' +import Manipulator from '../dom/manipulator' +import SelectorEngine from '../dom/selector-engine' + +const NAME = 'formValidation' +const DATA_KEY = 'bs.formValidation' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' +const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}` +const EVENT_SUBMIT = `submit${EVENT_KEY}${DATA_API_KEY}` +const EVENT_RESET = `reset${EVENT_KEY}` + +const CLASS_VALIDATED = 'was-validated' +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="form-validation"]' + +const Default = { + type: 'feedback' // or 'tooltip' +} + +const DefaultType = { + type: 'string' +} + +class FormValidation extends BaseComponent { + constructor(element, config) { + super(element) + if (this._element.tagName !== 'FORM') { + throw new TypeError(`Need to be initialized in form elements. "${this._element.tagName}" given`) + } + + this._config = this._getConfig(config) + + this._addEventListeners() + this._formFields = new Map() // Our fields + } + + static get NAME() { + return NAME + } + + getFields() { + if (!this._formFields.size) { + this._formFields = this._initializeFields() + } + + return this._formFields + } + + getField(name) { + return this.getFields().get(name) + } + + clear() { + this.toggleValidateClass(false) + this.getFields().forEach(field => { + field.clearAppended() + }) + } + + toggleValidateClass(add) { + if (add) { + this._element.classList.add(CLASS_VALIDATED) + return + } + + this._element.classList.remove(CLASS_VALIDATED) + } + + autoValidate() { + this.clear() + if (this._element.checkValidity()) { + return + } + + this.getFields().forEach(field => { + const element = field.getElement() + if (element.checkValidity()) { + field.successMessages().getFirst()?.append() + return + } + + if (field.errorMessages().has('default')) { + field.errorMessages().get('default').append() + return + } + + for (const property in element.validity) { + if (element.validity[property]) { + field.errorMessages().set(property, element.validationMessage) + field.errorMessages().get(property).append() + } + } + }) + + this.toggleValidateClass(true) + } + + _getConfig(config) { + config = { + ...Default, + ...Manipulator.getDataAttributes(this._element), + ...(typeof config === 'object' ? config : {}) + } + + typeCheckConfig(NAME, config, DefaultType) + return config + } + + _addEventListeners() { + EventHandler.on(this._element, EVENT_RESET, () => { + this.clear() + }) + } + + _initializeFields() { + const arrayFields = new Map() + Array.from(this._element.elements).forEach(element => { + const { id, name } = element + + const field = Field.getOrCreateInstance(element, { + name: id || name, + type: this._config.type + }) + arrayFields.set(id, field) + }) + return arrayFields + } +} + +EventHandler.on(document, EVENT_SUBMIT, SELECTOR_DATA_TOGGLE, event => { + const { target } = event + const data = FormValidation.getOrCreateInstance(target) + if (!target.checkValidity()) { + event.preventDefault() + event.stopPropagation() + } + + data.autoValidate() +}) + +EventHandler.on(window, EVENT_LOAD_DATA_API, () => { + SelectorEngine.find(SELECTOR_DATA_TOGGLE).forEach(el => { + el.setAttribute('novalidate', true) + }) +}) +export default FormValidation + diff --git a/js/src/forms/messages.js b/js/src/forms/messages.js new file mode 100644 index 000000000..5c8c162c7 --- /dev/null +++ b/js/src/forms/messages.js @@ -0,0 +1,34 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.1.0): forms/messages.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ +import TemplateFactory from '../util/template-factory' + +class Messages extends Map { + constructor(templateConfig) { + super() + this._templateConfig = templateConfig + } + + set(key, message) { + const config = { ...this._templateConfig, content: { div: message } } + super.set(key, new TemplateFactory(config)) + } + + getAllAsTextArray() { + return Array.from(this.values()).map(message => message.getContent().join(', ')) + } + + getFirst() { + const first = this.values().next() + return first ? first.value : null + } + + count() { + return this.size + } +} + +export default Messages diff --git a/js/src/util/template-factory.js b/js/src/util/template-factory.js new file mode 100644 index 000000000..70d76cb00 --- /dev/null +++ b/js/src/util/template-factory.js @@ -0,0 +1,149 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.1.0): util/template-factory.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { DefaultAllowlist, sanitizeHtml } from './sanitizer' +import { getElement, isElement, typeCheckConfig } from '../util/index' +import SelectorEngine from '../dom/selector-engine' + +const NAME = 'TemplateFactory' +const Default = { + appendFunction: null, + extraClass: '', + template: '
', + content: {}, // { selector : text , selector2 : text2 , } + html: false, + sanitize: true, + sanitizeFn: null, + allowList: DefaultAllowlist +} + +const DefaultType = { + appendFunction: '(function|null)', + extraClass: '(string|function)', + template: 'string', + content: 'object', + html: 'boolean', + sanitize: 'boolean', + sanitizeFn: '(null|function)', + allowList: 'object' +} +const DefaultContentType = { + selector: '(string|element|function)', + entry: '(string|element|function)' +} + +class TemplateFactory { + constructor(config) { + this._config = this._getConfig(config) + } + + // Getters + + static get NAME() { + return NAME + } + + static get Default() { + return Default + } + + // Public + + getContent() { + return Object.values(this._config.content) + } + + hasContent() { + return this.getContent().length > 0 + } + + getHtml() { + const templateWrapper = document.createElement('div') + templateWrapper.innerHTML = this._maybeSanitize(this._config.template) + + for (const [selector, text] of Object.entries(this._config.content)) { + this._setContent(templateWrapper, text, selector) + } + + const template = templateWrapper.children[0] + template.classList.add(...this._parseMaybeFunction(this._config.extraClass).split(' ')) + return template + } + + append() { + if (typeof this._config.appendFunction === 'function') { + this._config.appendFunction(this.getHtml()) + } + } + + // Private + _getConfig(config) { + config = { + ...Default, + ...(typeof config === 'object' ? config : {}) + } + + typeCheckConfig(NAME, config, DefaultType) + + for (const [selector, content] of Object.entries(config.content)) { + typeCheckConfig(NAME, { selector, entry: content }, DefaultContentType) + } + + return config + } + + _setContent(template, content, selector) { + const templateElement = SelectorEngine.findOne(selector, template) + + if (templateElement === null) { + return + } + + if (!content) { + templateElement.remove() + return + } + + content = this._parseMaybeFunction(content) + + if (isElement(content)) { + this._putElementInTemplate(content, templateElement) + return + } + + content = this._maybeSanitize(content) + + if (this._config.html) { + templateElement.innerHTML = content + } else { + templateElement.textContent = content + } + } + + _maybeSanitize(content) { + return this._config.sanitize ? sanitizeHtml(content, this._config.allowList, this._config.sanitizeFn) : content + } + + _parseMaybeFunction(content) { + return typeof content === 'function' ? content(this) : content + } + + _putElementInTemplate(content, templateElement) { + content = getElement(content) + + if (this._config.html) { + if (content.parentNode !== templateElement) { // guess we are avoiding infinite loop + templateElement.innerHTML = '' + templateElement.append(content) + } + } else { + templateElement.textContent = content.textContent + } + } +} + +export default TemplateFactory -- cgit v1.2.3