diff --git a/js/index.esm.js b/js/index.esm.js index 062b25408f..1b3806c5ed 100644 --- a/js/index.esm.js +++ b/js/index.esm.js @@ -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' diff --git a/js/index.umd.js b/js/index.umd.js index c63d7c2079..8e054aabae 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 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, diff --git a/js/src/forms/form-field.js b/js/src/forms/form-field.js new file mode 100644 index 0000000000..e8fd5223c5 --- /dev/null +++ b/js/src/forms/form-field.js @@ -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 diff --git a/js/src/forms/form.js b/js/src/forms/form.js new file mode 100644 index 0000000000..da47037890 --- /dev/null +++ b/js/src/forms/form.js @@ -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 + diff --git a/site/content/docs/5.2/examples/checkout-rtl/index.html b/site/content/docs/5.2/examples/checkout-rtl/index.html index e2a7971c1a..44120b272b 100644 --- a/site/content/docs/5.2/examples/checkout-rtl/index.html +++ b/site/content/docs/5.2/examples/checkout-rtl/index.html @@ -4,8 +4,6 @@ title: مثال إتمام الشراء direction: rtl extra_css: - "../checkout/checkout.css" -extra_js: - - src: "../checkout/checkout.js" body_class: "bg-light" --- @@ -67,7 +65,7 @@ body_class: "bg-light"