theme/boost/amd/src/form-display-errors.js

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Custom form error event handler to manipulate the bootstrap markup and show
 * nicely styled errors in an mform.
 *
 * @module     theme_boost/form-display-errors
 * @copyright  2016 Damyon Wiese <damyon@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
define(['jquery', 'core_form/events'], function($, FormEvent) {
    let focusedAlready = false;
    return {
        /**
         * Enhance the supplied element to handle form field errors.
         *
         * @method
         * @param {String} elementid
         * @listens event:formFieldValidationFailed
         */
        enhance: function(elementid) {
            var element = document.getElementById(elementid);
            if (!element) {
                // Some elements (e.g. static) don't have a form field.
                // Hence there is no validation. So, no setup required here.
                return;
            }

            element.addEventListener(FormEvent.eventTypes.formFieldValidationFailed, e => {
                const msg = e.detail.message;
                e.preventDefault();

                var parent = $(element).closest('.fitem');
                var feedback = parent.find('.form-control-feedback');
                const feedbackId = feedback.attr('id');

                // Get current aria-describedby value.
                let describedBy = $(element).attr('aria-describedby');
                if (typeof describedBy === "undefined") {
                    describedBy = '';
                }
                // Split aria-describedby attribute into an array of IDs if necessary.
                let describedByIds = [];
                if (describedBy.length) {
                    describedByIds = describedBy.split(" ");
                }
                // Find the the feedback container in the aria-describedby attribute.
                const feedbackIndex = describedByIds.indexOf(feedbackId);

                // Sometimes (atto) we have a hidden textarea backed by a real contenteditable div.
                if (($(element).prop("tagName") == 'TEXTAREA') && parent.find('[contenteditable]').length > 0) {
                    element = parent.find('[contenteditable]');
                }
                if (msg !== '') {
                    parent.addClass('has-danger');
                    parent.data('client-validation-error', true);
                    $(element).addClass('is-invalid');
                    // Append the feedback ID to the aria-describedby attribute if it doesn't exist yet.
                    if (feedbackIndex === -1) {
                        describedByIds.push(feedbackId);
                        $(element).attr('aria-describedby', describedByIds.join(" "));
                    }
                    $(element).attr('aria-invalid', true);
                    feedback.html(msg);
                    feedback.show();

                    // If we haven't focused anything yet, focus this one.
                    if (!focusedAlready) {
                        element.scrollIntoView({behavior: "smooth", block: "center"});
                        focusedAlready = true;
                        setTimeout(()=> {
                            // Actual focus happens later in case we need to do this in response to
                            // a change event which happens in the middle of changing focus.
                            element.focus({preventScroll: true});
                            // Let it focus again next time they submit the form.
                            focusedAlready = false;
                        }, 0);
                    }

                } else {
                    if (parent.data('client-validation-error') === true) {
                        parent.removeClass('has-danger');
                        parent.data('client-validation-error', false);
                        $(element).removeClass('is-invalid');
                        // If the aria-describedby attribute contains the error container's ID, remove it.
                        if (feedbackIndex > -1) {
                            describedByIds.splice(feedbackIndex, 1);
                        }
                        // Check the remaining element IDs in the aria-describedby attribute.
                        if (describedByIds.length) {
                            // If there's at least one, combine them with a blank space and update the aria-describedby attribute.
                            describedBy = describedByIds.join(" ");
                            // Put back the new describedby attribute.
                            $(element).attr('aria-describedby', describedBy);
                        } else {
                            // If there's none, remove the aria-describedby attribute.
                            $(element).removeAttr('aria-describedby');
                        }
                        $(element).attr('aria-invalid', false);
                        feedback.hide();
                    }
                }
            });

            var form = element.closest('form');
            if (form && !('boostFormErrorsEnhanced' in form.dataset)) {
                form.addEventListener('submit', function() {
                    var visibleError = $('.form-control-feedback:visible');
                    if (visibleError.length) {
                        visibleError[0].focus();
                    }
                });
                form.dataset.boostFormErrorsEnhanced = 1;
            }
        }
    };
});