aboutsummaryrefslogtreecommitdiff
path: root/js
diff options
context:
space:
mode:
authorGeoSot <[email protected]>2021-05-19 18:37:26 +0300
committerXhmikosR <[email protected]>2021-08-30 16:57:39 +0300
commitdde7e1cb57d1fbebe7fcd9e507c390af6cca88af (patch)
treeb461b96680355d75087db17ce6c4768d355862bb /js
parenta7e64b5a4f1b78c2a843c22ddb9bd427b45557cf (diff)
downloadbootstrap-dde7e1cb57d1fbebe7fcd9e507c390af6cca88af.tar.xz
bootstrap-dde7e1cb57d1fbebe7fcd9e507c390af6cca88af.zip
Make a form validation handler | handle form messages
Diffstat (limited to 'js')
-rw-r--r--js/index.esm.js2
-rw-r--r--js/index.umd.js2
-rw-r--r--js/src/forms/field.js160
-rw-r--r--js/src/forms/form-validation.js157
-rw-r--r--js/src/forms/messages.js34
-rw-r--r--js/src/util/template-factory.js149
6 files changed, 504 insertions, 0 deletions
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: '<div></div>',
+ 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