Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions modules/custom/wxt_ext/wxt_ext_webform/js/webform_checkboxes_group.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* Collapse WET-BOEW / jQuery Validate errors for Webform checkbox *groups* so:
* - "at least one checked" is enforced via require_from_group
* - only ONE inline error is rendered for the whole set
* - only ONE item appears in the WET error summary
* - checking ANY checkbox in the set clears the group error
*
* How it works (high level):
* 1) We wait until WET's validator is initialized on the form.
* 2) We discover checkbox groups by looking for inputs named like base[option]
* that also have data-rule-require_from_group (added in PHP preprocess).
* 3) We register jQuery Validate "groups" for each base so the set acts as one field.
* 4) We only *place* an inline error label for the FIRST checkbox of each base.
* (The others are skipped to avoid duplicate labels.)
* 5) We de-duplicate the validator's error list in invalidHandler so WET's top
* summary only contains one entry per base.
* 6) We prevent submission when the validator reports invalid, covering
* normal submit + Enter key + direct submit-button clicks.
*/

(function ($, Drupal, once) {
Drupal.behaviors.wetGroupOneMessage = {
attach(context) {
// Bind this behavior once per element with .wb-frmvld.
const hosts = once('wetGroupOneMessage', '.wb-frmvld', context);

hosts.forEach((host) => {
const $host = $(host);

// Find the <form> that is being validated.
// In WxT it’s usually a child of .wb-frmvld.
const $form = $host.is('form') ? $host : $host.find('form').first();
if (!$form.length) return;

/**
* Given a name like "select_from_the_following[2]" return the base "select_from_the_following".
* Returns null for non-array names.
*/
const baseOf = (name) => (name || '').match(/^([^\[]+)\[.+\]$/)?.[1] || null;

/**
* Discover checkbox groups on the form.
* We only consider checkboxes that:
* - have a name like base[option]
* - and have data-rule-require_from_group (added server-side)
*
* Returns a map: { baseName: [ "base[1]", "base[2]", ... ] }
*/
function discoverGroups() {
const map = {};
$form.find('input[type=checkbox][name*="["][data-rule-require_from_group]').each(function () {
const base = baseOf(this.name);
if (!base) return;
(map[base] ||= []).push(this.name);
});
return map;
}

/**
* Wire our grouping + dedup logic into the existing validator instance.
* @param {Object} v - jQuery Validate instance stored on the form by WET.
*/
function wire(v) {
const groups = discoverGroups();

// Make jQuery Validate prefer element.id over name (fixes accented name issues)
v.idOrName = function (el) {
return el.id || (el.name ? el.name.replace(/[^\w\-]+/g, '_') : '');
};

// 1) Register one jQuery Validate "group" per checkbox base.
// This makes jQuery Validate treat multiple field names as one logical group
// when deciding which single label/entry to render.
v.settings.groups = v.settings.groups || {};
Object.keys(groups).forEach((base) => {
// Names must be space-separated for JQV "groups".
v.settings.groups[base] = groups[base].join(' ');
});

// Precompute the FIRST field *id* of each base so we can place a single inline label.
const firstIdOf = {};
Object.keys(groups).forEach((base) => {
const firstName = groups[base][0];
const $el = $form.find('[name="' + CSS.escape(firstName) + '"]').first();
firstIdOf[base] = $el.attr('id') || '';
});

// Shadow a local baseOf so inner functions have it in scope (performance/readability).
const baseOf = (name) => (name || '').match(/^([^\[]+)\[.+\]$/)?.[1] || null;

// 2) Override errorPlacement to only place the inline label for the FIRST checkbox.
// This avoids multiple inline labels stacked under the group.
const origPlace = v.settings.errorPlacement || function (error, element) { error.insertAfter(element); };
v.settings.errorPlacement = function (error, element) {
const name = element.attr('name');
const base = baseOf(name);

// If this is part of a checkbox base and it's NOT the first item,
// skip placing the label entirely (the "first" item will get it).
if (base) {
const id = element.attr('id') || '';
// Only place for the FIRST id in the group, skip others to avoid duplicates.
if (firstIdOf[base] && id !== firstIdOf[base]) return;
}
return origPlace(error, element);
};

// 3) Override invalidHandler to de-duplicate BEFORE WET composes its summary.
// We reduce validator.errorList/errorMap to contain only the first failing
// element of each base, so the top summary has one entry per group.
const origInvalid = v.settings.invalidHandler || $.noop;
v.settings.invalidHandler = function (formEl, validator) {
const seen = new Set();
const list = [];
const map = {};

validator.errorList.forEach((item) => {
const el = item.element;
const nm = el && el.name;
const id = el && el.id;
const base = baseOf(nm) || nm;
const dedupeKey = base + '::' + (id || '');

if (!seen.has(dedupeKey)) {
seen.add(dedupeKey);
list.push(item);
if (nm) map[nm] = item.message;
}
});

// Replace the validator's error structures with our reduced versions.
validator.errorList = list;
validator.errorMap = map;

// Clean up any duplicate labels that may exist (caused earlier by accented names)
const seenLabelFor = {};
$(formEl).find('label.error[for]').each(function () {
const f = $(this).attr('for');
if (seenLabelFor[f]) $(this).remove();
else seenLabelFor[f] = true;
});

// Allow any original invalidHandler (including WET's) to run with the reduced list.
return origInvalid.call(this, formEl, validator);
};
}

/**
* Try to find the jQuery Validate instance created by WET for this form.
* If found, wire our grouping/dedup logic into it.
* Returns true if the validator exists and was wired.
*/
function tryWire() {
const v = $form.data('validator') || $host.data('validator');
if (v) wire(v);
return !!v;
}

// If WET's validator is already present, wire immediately.
if (tryWire()) return;

// Otherwise, wait for WET to finish attaching the validator, then wire.
// 'wb-ready.wb-frmvld' fires when the plugin is ready on this wrapper.
$host.on('wb-ready.wb-frmvld', tryWire);

// ---- Submission guards --------------------------------------------------
// Prevent the form from submitting when invalid (covers:
// - clicking the submit button
// - pressing Enter in a field
// - any custom handler that falls back to form.submit())
//
// We *evaluate* validity using $form.valid()/v.checkForm() and block the
// event if invalid. We also focus the first invalid field for accessibility.

// Guard the native form submit event.
$form.on('submit.wetGroupOneMessage', function (e) {
const v = $form.data('validator') || $host.data('validator');
// If no validator, let normal processing happen.
if (!v) return;

// valid() calls checkForm() and sets up error display without submitting.
const ok = $form.valid ? $form.valid() : v.checkForm();
if (!ok) {
v.focusInvalid && v.focusInvalid();
e.preventDefault();
e.stopImmediatePropagation();
e.stopPropagation();
return false;
}
});

// Guard submit button clicks as well to catch flows
// where other handlers might try to submit programmatically.
$form.find('button[type=submit], input[type=submit]').on('click.wetGroupOneMessage', function (e) {
const v = $form.data('validator') || $host.data('validator');
if (!v) return;
const ok = $form.valid ? $form.valid() : v.checkForm();
if (!ok) {
v.focusInvalid && v.focusInvalid();
e.preventDefault();
e.stopImmediatePropagation();
e.stopPropagation();
return false;
}
});
});
}
};
})(jQuery, Drupal, once);
137 changes: 137 additions & 0 deletions modules/custom/wxt_ext/wxt_ext_webform/js/webform_required_marker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* @file
* Webform/WET-BOEW required marker enhancer for conditional fields.
*
* Purpose:
* - Mirror runtime toggles of "required" on Webform elements without
* duplicating server-rendered markers.
* - When a field becomes required client-side, append
* <strong class="required" aria-hidden="true">(required)</strong> as the
* last child of its <label> or <legend>. Remove it when no longer required.
*/
(function (Drupal, once) {
'use strict';

const OUR_ATTR = 'data-webform-required-marker';
const OUR_SEL = `strong.required[${OUR_ATTR}="1"]`;

// Track prior required state per element (label/legend).
const baseline = new WeakMap();

// Helpers
function isLabelRequired(label) {
return label.classList.contains('js-form-required');
}
function isLegendRequired(legend) {
return !!legend.querySelector('span.js-form-required');
}
// Place marker before any inline error badge (e.g., .label.label-danger).
function ensureBeforeError(el, node) {
const err = el.querySelector('strong.error');
const errIsChild = err && err.parentNode === el;
if (errIsChild) {
if (node.parentNode !== el || node.nextElementSibling !== err) {
el.insertBefore(node, err);
}
} else {
if (node.parentNode !== el || el.lastElementChild !== node) {
el.appendChild(node);
}
}
}
function addMarker(el) {
el.classList.add('required');
let strong = el.querySelector(OUR_SEL);
if (!strong) {
strong = document.createElement('strong');
strong.className = 'required';
strong.setAttribute(OUR_ATTR, '1');
strong.setAttribute('aria-hidden', 'true');
strong.textContent = `(${Drupal.t('required')})`;
}
ensureBeforeError(el, strong);
}
function removeMarker(el) {
el.classList.remove('required');
const ours = el.querySelector(OUR_SEL);
if (ours) ours.remove();
}

// React to a possible state change on a label/legend,
// but only if baseline exists.
function syncWithTransition(el, nowRequired) {
if (!baseline.has(el)) {
// First time we see this element post-attach:
// set baseline, do nothing.
baseline.set(el, nowRequired);
return;
}
const wasRequired = baseline.get(el);
if (wasRequired === nowRequired) {
// No change.
return;
}
// Update baseline then act.
baseline.set(el, nowRequired);
if (nowRequired) addMarker(el);
else removeMarker(el);
}

Drupal.behaviors.webformRequiredMarkerObserver = {
attach(context) {
// Scope to .wb-frmvld (your library is only attached
// when inline validation is enabled).
once('webform-required-marker-observer', '.wb-frmvld', context).forEach((root) => {
// Defer baseline capture to the next frame so we
// don't react to initial build churn.
requestAnimationFrame(() => {
// Capture baseline for all labels and legends without touching DOM.
root.querySelectorAll('label').forEach((label) => {
baseline.set(label, isLabelRequired(label));
});
root.querySelectorAll('legend').forEach((legend) => {
baseline.set(legend, isLegendRequired(legend));
});
});

// Observe only class/child mutations;
// act *after* baseline exists.
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
// Class toggles on <label>.
if (m.type === 'attributes' && m.attributeName === 'class' && m.target.tagName === 'LABEL') {
const label = /** @type {HTMLElement} */ (m.target);
syncWithTransition(label, isLabelRequired(label));
continue;
}

// Class toggles on <span> inside <legend>.
if (m.type === 'attributes' && m.attributeName === 'class' && m.target.tagName === 'SPAN') {
const legend = m.target.closest('legend');
if (legend) {
syncWithTransition(legend, isLegendRequired(legend));
}
continue;
}

// Child list changes within/under a <legend> (some UIs replace nodes).
if (m.type === 'childList') {
const legend = (m.target.closest && m.target.closest('legend')) || (m.target.tagName === 'LEGEND' ? m.target : null);
if (legend) {
syncWithTransition(legend, isLegendRequired(legend));
}
}
}
});

observer.observe(root, {
subtree: true,
attributes: true,
attributeFilter: ['class'],
attributeOldValue: true,
childList: true,
});
});
}
};
})(Drupal, once);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
wet_webform_enhancements:
js:
js/webform_required_marker.js: {}
js/webform_checkboxes_group.js: {}
dependencies:
- core/drupal
- core/once
- core/drupalSettings
Loading
Loading