/** * @file * JavaScript behaviors for custom webform #states. */ (function ($, Drupal) { 'use strict'; Drupal.webform = Drupal.webform || {}; Drupal.webform.states = Drupal.webform.states || {}; Drupal.webform.states.slideDown = Drupal.webform.states.slideDown || {}; Drupal.webform.states.slideDown.duration = 'slow'; Drupal.webform.states.slideUp = Drupal.webform.states.slideUp || {}; Drupal.webform.states.slideUp.duration = 'fast'; /* ************************************************************************ */ // jQuery functions. /* ************************************************************************ */ /** * Check if an element has a specified data attribute. * * @param {string} data * The data attribute name. * * @return {boolean} * TRUE if an element has a specified data attribute. */ $.fn.hasData = function (data) { return (typeof this.data(data) !== 'undefined'); }; /** * Check if element is within the webform or not. * * @return {boolean} * TRUE if element is within the webform. */ $.fn.isWebform = function () { return $(this).closest('form.webform-submission-form, form[id^="webform"], form[data-is-webform]').length ? true : false; }; /** * Check if element is to be treated as a webform element. * * @return {boolean} * TRUE if element is to be treated as a webform element. */ $.fn.isWebformElement = function () { return ($(this).isWebform() || $(this).closest('[data-is-webform-element]').length) ? true : false; }; /* ************************************************************************ */ // Trigger. /* ************************************************************************ */ // The change event is triggered by cut-n-paste and select menus. // Issue #2445271: #states element empty check not triggered on mouse // based paste. // @see https://www.drupal.org/node/2445271 Drupal.states.Trigger.states.empty.change = function change() { return this.val() === ''; }; /* ************************************************************************ */ // Dependents. /* ************************************************************************ */ // Apply solution included in #1962800 patch. // Issue #1962800: Form #states not working with literal integers as // values in IE11. // @see https://www.drupal.org/project/drupal/issues/1962800 // @see https://www.drupal.org/files/issues/core-states-not-working-with-integers-ie11_1962800_46.patch // // This issue causes pattern, less than, and greater than support to break. // @see https://www.drupal.org/project/webform/issues/2981724 var states = Drupal.states; Drupal.states.Dependent.prototype.compare = function compare(reference, selector, state) { var value = this.values[selector][state.name]; var name = reference.constructor.name; if (!name) { name = $.type(reference); name = name.charAt(0).toUpperCase() + name.slice(1); } if (name in states.Dependent.comparisons) { return states.Dependent.comparisons[name](reference, value); } if (reference.constructor.name in states.Dependent.comparisons) { return states.Dependent.comparisons[reference.constructor.name](reference, value); } return _compare2(reference, value); }; function _compare2(a, b) { if (a === b) { return typeof a === 'undefined' ? a : true; } return typeof a === 'undefined' || typeof b === 'undefined'; } // Adds pattern, less than, and greater than support to #state API. // @see http://drupalsun.com/julia-evans/2012/03/09/extending-form-api-states-regular-expressions Drupal.states.Dependent.comparisons.Object = function (reference, value) { if ('pattern' in reference) { return (new RegExp(reference['pattern'])).test(value); } else if ('!pattern' in reference) { return !((new RegExp(reference['!pattern'])).test(value)); } else if ('less' in reference) { return (value !== '' && parseFloat(reference['less']) > parseFloat(value)); } else if ('less_equal' in reference) { return (value !== '' && parseFloat(reference['less_equal']) >= parseFloat(value)); } else if ('greater' in reference) { return (value !== '' && parseFloat(reference['greater']) < parseFloat(value)); } else if ('greater_equal' in reference) { return (value !== '' && parseFloat(reference['greater_equal']) <= parseFloat(value)); } else if ('between' in reference || '!between' in reference) { if (value === '') { return false; } var between = reference['between'] || reference['!between']; var betweenParts = between.split(':'); var greater = betweenParts[0]; var less = (typeof betweenParts[1] !== 'undefined') ? betweenParts[1] : null; var isGreaterThan = (greater === null || greater === '' || parseFloat(value) >= parseFloat(greater)); var isLessThan = (less === null || less === '' || parseFloat(value) <= parseFloat(less)); var result = (isGreaterThan && isLessThan); return (reference['!between']) ? !result : result; } else { return reference.indexOf(value) !== false; } }; /* ************************************************************************ */ // States events. /* ************************************************************************ */ var $document = $(document); $document.on('state:required', function (e) { if (e.trigger && $(e.target).isWebformElement()) { var $target = $(e.target); // Fix #required file upload. // @see Issue #2860529: Conditional required File upload field don't work. toggleRequired($target.find('input[type="file"]'), e.value); // Fix #required for radios and likert. // @see Issue #2856795: If radio buttons are required but not filled form is nevertheless submitted. if ($target.is('.js-form-type-radios, .js-form-type-webform-radios-other, .js-webform-type-radios, .js-webform-type-webform-radios-other, .js-webform-type-webform-entity-radios, .webform-likert-table')) { $target.toggleClass('required', e.value); toggleRequired($target.find('input[type="radio"]'), e.value); } // Fix #required for checkboxes. // @see Issue #2938414: Checkboxes don't support #states required. // @see checkboxRequiredhandler if ($target.is('.js-form-type-checkboxes, .js-form-type-webform-checkboxes-other, .js-webform-type-checkboxes, .js-webform-type-webform-checkboxes-other')) { $target.toggleClass('required', e.value); var $checkboxes = $target.find('input[type="checkbox"]'); if (e.value) { // Add event handler. $checkboxes.on('click', statesCheckboxesRequiredEventHandler); // Initialize and add required attribute. checkboxesRequired($target); } else { // Remove event handler. $checkboxes.off('click', statesCheckboxesRequiredEventHandler); // Remove required attribute. toggleRequired($checkboxes, false); } } // Fix #required for tableselect. // @see Issue #3212581: Table select does not trigger client side validation if ($target.is('.js-webform-tableselect')) { $target.toggleClass('required', e.value); var isMultiple = $target.is('[multiple]'); if (isMultiple) { // Checkboxes. var $tbody = $target.find('tbody'); var $checkboxes = $tbody.find('input[type="checkbox"]'); copyRequireMessage($target, $checkboxes); if (e.value) { $checkboxes.on('click change', statesCheckboxesRequiredEventHandler); checkboxesRequired($tbody); } else { $checkboxes.off('click change ', statesCheckboxesRequiredEventHandler); toggleRequired($tbody, false); } } else { // Radios. var $radios = $target.find('input[type="radio"]'); copyRequireMessage($target, $radios); toggleRequired($radios, e.value); } } // Fix required label for elements without the for attribute. // @see Issue #3145300: Conditional Visible Select Other not working. if ($target.is('.js-form-type-webform-select-other, .js-webform-type-webform-select-other')) { var $select = $target.find('select'); toggleRequired($select, e.value); copyRequireMessage($target, $select); } if ($target.find('> label:not([for])').length) { $target.find('> label').toggleClass('js-form-required form-required', e.value); } // Fix required label for checkboxes and radios. // @see Issue #2938414: Checkboxes don't support #states required // @see Issue #2731991: Setting required on radios marks all options required. // @see Issue #2856315: Conditional Logic - Requiring Radios in a Fieldset. // Fix #required for fieldsets. // @see Issue #2977569: Hidden fieldsets that become visible with conditional logic cannot be made required. if ($target.is('.js-webform-type-radios, .js-webform-type-checkboxes, fieldset')) { $target.find('legend span.fieldset-legend:not(.visually-hidden)').toggleClass('js-form-required form-required', e.value); } // Issue #2986017: Fieldsets shouldn't have required attribute. if ($target.is('fieldset')) { $target.removeAttr('required aria-required'); } } }); $document.on('state:checked', function (e) { if (e.trigger) { $(e.target).trigger('change'); } }); $document.on('state:readonly', function (e) { if (e.trigger && $(e.target).isWebformElement()) { $(e.target).prop('readonly', e.value).closest('.js-form-item, .js-form-wrapper').toggleClass('webform-readonly', e.value).find('input, textarea').prop('readonly', e.value); // Trigger webform:readonly. $(e.target).trigger('webform:readonly') .find('select, input, textarea, button').trigger('webform:readonly'); } }); $document.on('state:visible state:visible-slide', function (e) { if (e.trigger && $(e.target).isWebformElement()) { if (e.value) { $(':input', e.target).addBack().each(function () { restoreValueAndRequired(this); triggerEventHandlers(this); }); } else { // @see https://www.sitepoint.com/jquery-function-clear-form-data/ $(':input', e.target).addBack().each(function () { backupValueAndRequired(this); clearValueAndRequired(this); triggerEventHandlers(this); }); } } }); $document.on('state:visible-slide', function (e) { if (e.trigger && $(e.target).isWebformElement()) { var effect = e.value ? 'slideDown' : 'slideUp'; var duration = Drupal.webform.states[effect].duration; $(e.target).closest('.js-form-item, .js-form-submit, .js-form-wrapper')[effect](duration); } }); Drupal.states.State.aliases['invisible-slide'] = '!visible-slide'; $document.on('state:disabled', function (e) { if (e.trigger && $(e.target).isWebformElement()) { // Make sure disabled property is set before triggering webform:disabled. // Copied from: core/misc/states.js $(e.target) .prop('disabled', e.value) .closest('.js-form-item, .js-form-submit, .js-form-wrapper').toggleClass('form-disabled', e.value) .find('select, input, textarea, button').prop('disabled', e.value); // Never disable hidden file[fids] because the existing values will // be completely lost when the webform is submitted. var fileElements = $(e.target) .find(':input[type="hidden"][name$="[fids]"]'); if (fileElements.length) { // Remove 'disabled' attribute from fieldset which will block // all disabled elements from being submitted. if ($(e.target).is('fieldset')) { $(e.target).prop('disabled', false); } fileElements.removeAttr('disabled'); } // Trigger webform:disabled. $(e.target).trigger('webform:disabled') .find('select, input, textarea, button').trigger('webform:disabled'); } }); /* ************************************************************************ */ // Behaviors. /* ************************************************************************ */ /** * Adds HTML5 validation to required checkboxes. * * @type {Drupal~behavior} * * @see https://www.drupal.org/project/webform/issues/3068998 */ Drupal.behaviors.webformCheckboxesRequired = { attach: function (context) { $('.js-form-type-checkboxes.required, .js-form-type-webform-checkboxes-other.required, .js-webform-type-checkboxes.required, .js-webform-type-webform-checkboxes-other.required, .js-webform-type-webform-radios-other.checkboxes', context) .once('webform-checkboxes-required') .each(function () { var $element = $(this); $element.find('input[type="checkbox"]').on('click', statesCheckboxesRequiredEventHandler); setTimeout(function () {checkboxesRequired($element);}); }); } }; /** * Adds HTML5 validation to required radios. * * @type {Drupal~behavior} * * @see https://www.drupal.org/project/webform/issues/2856795 */ Drupal.behaviors.webformRadiosRequired = { attach: function (context) { $('.js-form-type-radios, .js-form-type-webform-radios-other, .js-webform-type-radios, .js-webform-type-webform-radios-other, .js-webform-type-webform-entity-radios, .js-webform-type-webform-scale', context) .once('webform-radios-required') .each(function () { var $element = $(this); setTimeout(function () {radiosRequired($element);}); }); } }; /** * Adds HTML5 validation to required table select. * * @type {Drupal~behavior} * * @see https://www.drupal.org/project/webform/issues/2856795 */ Drupal.behaviors.webformTableSelectRequired = { attach: function (context) { $('.js-webform-tableselect.required', context) .once('webform-tableselect-required') .each(function () { var $element = $(this); var $tbody = $element.find('tbody'); var isMultiple = $element.is('[multiple]'); if (isMultiple) { // Check all checkbox triggers checkbox 'change' event on // select and deselect all. // @see Drupal.tableSelect $tbody.find('input[type="checkbox"]').on('click change', function () { checkboxesRequired($tbody); }); } setTimeout(function () { isMultiple ? checkboxesRequired($tbody) : radiosRequired($element); }); }); } }; /** * Add HTML5 multiple checkboxes required validation. * * @param {jQuery} $element * An jQuery object containing HTML5 radios. * * @see https://stackoverflow.com/a/37825072/145846 */ function checkboxesRequired($element) { var $firstCheckbox = $element.find('input[type="checkbox"]').first(); var isChecked = $element.find('input[type="checkbox"]').is(':checked'); toggleRequired($firstCheckbox, !isChecked); copyRequireMessage($element, $firstCheckbox); } /** * Add HTML5 radios required validation. * * @param {jQuery} $element * An jQuery object containing HTML5 radios. * * @see https://www.drupal.org/project/webform/issues/2856795 */ function radiosRequired($element) { var $radios = $element.find('input[type="radio"]'); var isRequired = $element.hasClass('required'); toggleRequired($radios, isRequired); copyRequireMessage($element, $radios); } /* ************************************************************************ */ // Event handlers. /* ************************************************************************ */ /** * Trigger #states API HTML5 multiple checkboxes required validation. * * @see https://stackoverflow.com/a/37825072/145846 */ function statesCheckboxesRequiredEventHandler() { var $element = $(this).closest('.js-webform-type-checkboxes, .js-webform-type-webform-checkboxes-other'); checkboxesRequired($element); } /** * Trigger an input's event handlers. * * @param {element} input * An input. */ function triggerEventHandlers(input) { var $input = $(input); var type = input.type; var tag = input.tagName.toLowerCase(); // Add 'webform.states' as extra parameter to event handlers. // @see Drupal.behaviors.webformUnsaved var extraParameters = ['webform.states']; if (type === 'checkbox' || type === 'radio') { $input .trigger('change', extraParameters) .trigger('blur', extraParameters); } else if (tag === 'select') { // Do not trigger the onchange event for Address element's country code // when it is initialized. // @see \Drupal\address\Element\Country if ($input.closest('.webform-type-address').length) { if (!$input.data('webform-states-address-initialized') && $input.attr('autocomplete') === 'country' && $input.val() === $input.find("option[selected]").attr('value')) { return; } $input.data('webform-states-address-initialized', true); } $input .trigger('change', extraParameters) .trigger('blur', extraParameters); } else if (type !== 'submit' && type !== 'button' && type !== 'file') { // Make sure input mask is removed and then reset when value is restored. // @see https://www.drupal.org/project/webform/issues/3124155 // @see https://www.drupal.org/project/webform/issues/3202795 var hasInputMask = ($.fn.inputmask && $input.hasClass('js-webform-input-mask')); hasInputMask && $input.inputmask('remove'); $input .trigger('input', extraParameters) .trigger('change', extraParameters) .trigger('keydown', extraParameters) .trigger('keyup', extraParameters) .trigger('blur', extraParameters); hasInputMask && $input.inputmask(); } } /* ************************************************************************ */ // Backup and restore value functions. /* ************************************************************************ */ /** * Backup an input's current value and required attribute * * @param {element} input * An input. */ function backupValueAndRequired(input) { var $input = $(input); var type = input.type; var tag = input.tagName.toLowerCase(); // Normalize case. // Backup required. if ($input.prop('required') && !$input.hasData('webform-required')) { $input.data('webform-required', true); } // Backup value. if (!$input.hasData('webform-value')) { if (type === 'checkbox' || type === 'radio') { $input.data('webform-value', $input.prop('checked')); } else if (tag === 'select') { var values = []; $input.find('option:selected').each(function (i, option) { values[i] = option.value; }); $input.data('webform-value', values); } else if (type !== 'submit' && type !== 'button') { $input.data('webform-value', input.value); } } } /** * Restore an input's value and required attribute. * * @param {element} input * An input. */ function restoreValueAndRequired(input) { var $input = $(input); // Restore value. var value = $input.data('webform-value'); if (typeof value !== 'undefined') { var type = input.type; var tag = input.tagName.toLowerCase(); // Normalize case. if (type === 'checkbox' || type === 'radio') { $input.prop('checked', value); } else if (tag === 'select') { $.each(value, function (i, option_value) { // Prevent "Syntax error, unrecognized expression" error by // escaping single quotes. // @see https://forum.jquery.com/topic/escape-characters-prior-to-using-selector option_value = option_value.replace(/'/g, "\\\'"); $input.find("option[value='" + option_value + "']").prop('selected', true); }); } else if (type !== 'submit' && type !== 'button') { input.value = value; } $input.removeData('webform-value'); } // Restore required. var required = $input.data('webform-required'); if (typeof required !== 'undefined') { if (required) { $input.prop('required', true); } $input.removeData('webform-required'); } } /** * Clear an input's value and required attributes. * * @param {element} input * An input. */ function clearValueAndRequired(input) { var $input = $(input); // Check for #states no clear attribute. // @see https://css-tricks.com/snippets/jquery/make-an-jquery-hasattr/ if ($input.closest('[data-webform-states-no-clear]').length) { return; } // Clear value. var type = input.type; var tag = input.tagName.toLowerCase(); // Normalize case. if (type === 'checkbox' || type === 'radio') { $input.prop('checked', false); } else if (tag === 'select') { if ($input.find('option[value=""]').length) { $input.val(''); } else { input.selectedIndex = -1; } } else if (type !== 'submit' && type !== 'button') { input.value = (type === 'color') ? '#000000' : ''; } // Clear required. $input.prop('required', false); } /* ************************************************************************ */ // Helper functions. /* ************************************************************************ */ /** * Toggle an input's required attributes. * * @param {element} $input * An input. * @param {boolean} required * Is input required. */ function toggleRequired($input, required) { var isCheckboxOrRadio = ($input.attr('type') === 'radio' || $input.attr('type') === 'checkbox'); if (required) { if (isCheckboxOrRadio) { $input.attr({'required': 'required'}); } else { $input.attr({'required': 'required', 'aria-required': 'true'}); } } else { if (isCheckboxOrRadio) { $input.removeAttr('required'); } else { $input.removeAttr('required aria-required'); } } } /** * Copy the clientside_validation.module's message. * * @param {jQuery} $source * The source element. * @param {jQuery} $destination * The destination element. */ function copyRequireMessage($source, $destination) { if ($source.attr('data-msg-required')) { $destination.attr('data-msg-required', $source.attr('data-msg-required')); } } })(jQuery, Drupal);