1
0
mirror of https://github.com/twbs/bootstrap.git synced 2025-08-27 15:19:52 +02:00

Make a form validation handler | handle form messages

add "aria-describedby" attribute on "supported elements" section

add "aria-describedby" attribute on server side succeed validation messages
This commit is contained in:
GeoSot
2021-05-19 18:37:26 +03:00
parent 4cb046a6b8
commit aa6a1ece56
9 changed files with 326 additions and 118 deletions

View File

@@ -10,6 +10,7 @@ export { default as Button } from './src/button'
export { default as Carousel } from './src/carousel'
export { default as Collapse } from './src/collapse'
export { default as Dropdown } from './src/dropdown'
export { default as Form } from './src/forms/form'
export { default as Modal } from './src/modal'
export { default as Offcanvas } from './src/offcanvas'
export { default as Popover } from './src/popover'

View File

@@ -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 Form from './src/forms/form'
import Dropdown from './src/dropdown'
import Modal from './src/modal'
import Offcanvas from './src/offcanvas'
@@ -23,6 +24,7 @@ export default {
Button,
Carousel,
Collapse,
Form,
Dropdown,
Modal,
Offcanvas,

127
js/src/forms/form-field.js Normal file
View File

@@ -0,0 +1,127 @@
/**
* --------------------------------------------------------------------------
* Bootstrap (v5.3.0): forms/field.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import { getUID, isElement } from '../util/index'
import EventHandler from '../dom/event-handler'
import BaseComponent from '../base-component'
import SelectorEngine from '../dom/selector-engine'
import TemplateFactory from '../util/template-factory'
const NAME = 'formField'
const DATA_KEY = 'bs.field'
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_INPUT = `input${EVENT_KEY}`
const CLASS_FIELD_ERROR = 'is-invalid'
const CLASS_FIELD_SUCCESS = 'is-valid'
const ARIA_DESCRIBED_BY = 'aria-describedby'
const Default = {
invalid: '', // invalid message to add
name: null,
valid: '', // valid message to add
type: 'feedback' // or tooltip
}
const DefaultType = {
invalid: 'string',
name: 'string',
valid: 'string',
type: 'string'
}
const MessageTypes = {
ERROR: { prefix: 'invalid', class: CLASS_FIELD_ERROR },
INFO: { prefix: 'info', class: '' },
SUCCESS: { prefix: 'valid', class: CLASS_FIELD_SUCCESS }
}
class FormField extends BaseComponent {
constructor(element, config) {
super(element, config)
if (!isElement(this._element)) {
throw new TypeError(`field "${this._config.name}" not found`)
}
this._tipId = getUID(`${this._config.name}-formTip-`)
this._initialDescribedBy = this._element.getAttribute(ARIA_DESCRIBED_BY) || ''
EventHandler.on(this._element, EVENT_INPUT, () => {
this.clearAppended()
})
}
static get NAME() {
return NAME
}
static get Default() {
return Default
}
static get DefaultType() {
return DefaultType
}
static get MessageTypes() {
return MessageTypes
}
getElement() {
return this._element
}
clearAppended() {
const appendedFeedback = SelectorEngine.findOne(`#${this._tipId}`, this._element.parentNode)
if (!appendedFeedback) {
return
}
appendedFeedback.remove()
this._element.classList.remove(CLASS_FIELD_ERROR, CLASS_FIELD_SUCCESS)
if (this._initialDescribedBy) {
this._element.setAttribute(ARIA_DESCRIBED_BY, this._initialDescribedBy)
return
}
this._element.removeAttribute(ARIA_DESCRIBED_BY)
}
appendError(message = this._config.invalid) {
return this.appendFeedback(message, this.constructor.MessageTypes.ERROR)
}
appendSuccess(message = this._config.valid) {
return this.appendFeedback(message, this.constructor.MessageTypes.SUCCESS)
}
appendFeedback(feedback, classes = this.constructor.MessageTypes.INFO) {
if (!feedback) {
return false
}
this.clearAppended()
const config = {
extraClass: `${classes.prefix}-${this._config.type} ${classes.class}`,
content: { div: feedback }
}
feedback = new TemplateFactory(config)
const feedbackElement = feedback.toHtml()
feedbackElement.id = this._tipId
this._element.parentNode.append(feedbackElement)
const describedBy = `${this._initialDescribedBy} ${feedbackElement.id}`.trim()
this._element.setAttribute(ARIA_DESCRIBED_BY, describedBy)
return true
}
}
export default FormField

161
js/src/forms/form.js Normal file
View File

@@ -0,0 +1,161 @@
/**
* --------------------------------------------------------------------------
* Bootstrap (v5.3.0): util/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 FormField from './form-field'
import SelectorEngine from '../dom/selector-engine'
const NAME = 'formValidation'
const DATA_KEY = 'bs.formValidation'
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}`
const EVENT_SUBMIT = `submit${EVENT_KEY}`
const EVENT_RESET = `reset${EVENT_KEY}`
const CLASS_VALIDATED = 'was-validated'
const SELECTOR_DATA_TOGGLE = 'form[data-bs-toggle="form-validation"]'
const Default = {
type: 'feedback', // or 'tooltip'
validateCallback: null
}
const DefaultType = {
type: 'string', validateCallback: '(function|null)'
}
class Form extends BaseComponent {
constructor(element, config) {
if (element.tagName !== 'FORM') {
throw new TypeError(`Need to be initialized in form elements. "${element.tagName}" given`)
}
super(element, config)
this._formFields = null // form field instances
}
static get NAME() {
return NAME
}
static get Default() {
return Default
}
static get DefaultType() {
return DefaultType
}
getFields() {
if (!this._formFields) {
this._formFields = this._initializeFields()
}
return this._formFields
}
getField(name) {
return this.getFields().get(name)
}
clear() {
this._element.classList.remove(CLASS_VALIDATED)
// eslint-disable-next-line no-unused-vars
for (const [name, field] of this.getFields()) {
field.clearAppended()
}
}
validate() {
this.clear()
const fetchedErrors = this._fetchErrors()
if (this._element.checkValidity() && !Object.keys(fetchedErrors).length) {
return true
}
for (const [name, field] of this.getFields()) {
this._appendErrorToField(field, fetchedErrors[name] || null)
}
this._element.classList.add(CLASS_VALIDATED)
return false
}
getDataForSubmission() {
return new FormData(this._element)
}
_appendErrorToField(field, givenMessage) {
const element = field.getElement()
if (givenMessage) { // if field is invalid check and return for default message
field.appendError(givenMessage)
return
}
if (element.checkValidity()) { // if field is valid, return first success message
field.appendSuccess()
return
}
if (field.appendError()) { // if field is invalid check and return for default message
return
}
field.appendError(element.validationMessage)
}
_initializeFields() {
const fields = new Map()
const formElements = Array.from(this._element.elements) // the DOM elements
for (const element of formElements) {
const name = element.name || element.id
const field = FormField.getOrCreateInstance(element, {
name, type: this._config.type
})
fields.set(name, field)
}
return fields
}
_fetchErrors() {
return typeof this._config.validateCallback === 'function' ? this._config.validateCallback(this.getDataForSubmission()) : {}
}
}
// On submit we want to auto-validate form
EventHandler.on(document, EVENT_SUBMIT, SELECTOR_DATA_TOGGLE, event => {
const { target } = event
const instance = Form.getOrCreateInstance(target)
if (!target.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
if (instance.validate()) {
target.submit()
}
})
EventHandler.on(document, EVENT_RESET, SELECTOR_DATA_TOGGLE, event => {
const { target } = event
const instance = Form.getOrCreateInstance(target)
instance.clear()
})
// On load, add `novalidate` attribute to avoid browser validation
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
for (const el of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) {
el.setAttribute('novalidate', true)
}
})
export default Form