Skip to content

Commit ef2c8af

Browse files
committed
Merge branch 'feat-wet-boew-form-validation-v2' of https://github.com/drupalwxt/wxt into 6.1.x
2 parents 5b8c4ae + 306010f commit ef2c8af

File tree

4 files changed

+358
-0
lines changed

4 files changed

+358
-0
lines changed
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/**
2+
* Collapse WET-BOEW / jQuery Validate errors for Webform checkbox *groups* so:
3+
* - "at least one checked" is enforced via require_from_group
4+
* - only ONE inline error is rendered for the whole set
5+
* - only ONE item appears in the WET error summary
6+
* - checking ANY checkbox in the set clears the group error
7+
*
8+
* How it works (high level):
9+
* 1) We wait until WET's validator is initialized on the form.
10+
* 2) We discover checkbox groups by looking for inputs named like base[option]
11+
* that also have data-rule-require_from_group (added in PHP preprocess).
12+
* 3) We register jQuery Validate "groups" for each base so the set acts as one field.
13+
* 4) We only *place* an inline error label for the FIRST checkbox of each base.
14+
* (The others are skipped to avoid duplicate labels.)
15+
* 5) We de-duplicate the validator's error list in invalidHandler so WET's top
16+
* summary only contains one entry per base.
17+
* 6) We prevent submission when the validator reports invalid, covering
18+
* normal submit + Enter key + direct submit-button clicks.
19+
*/
20+
21+
(function ($, Drupal, once) {
22+
Drupal.behaviors.wetGroupOneMessage = {
23+
attach(context) {
24+
// Bind this behavior once per element with .wb-frmvld.
25+
const hosts = once('wetGroupOneMessage', '.wb-frmvld', context);
26+
27+
hosts.forEach((host) => {
28+
const $host = $(host);
29+
30+
// Find the <form> that is being validated.
31+
// In WxT it’s usually a child of .wb-frmvld.
32+
const $form = $host.is('form') ? $host : $host.find('form').first();
33+
if (!$form.length) return;
34+
35+
/**
36+
* Given a name like "select_from_the_following[2]" return the base "select_from_the_following".
37+
* Returns null for non-array names.
38+
*/
39+
const baseOf = (name) => (name || '').match(/^([^\[]+)\[.+\]$/)?.[1] || null;
40+
41+
/**
42+
* Discover checkbox groups on the form.
43+
* We only consider checkboxes that:
44+
* - have a name like base[option]
45+
* - and have data-rule-require_from_group (added server-side)
46+
*
47+
* Returns a map: { baseName: [ "base[1]", "base[2]", ... ] }
48+
*/
49+
function discoverGroups() {
50+
const map = {};
51+
$form.find('input[type=checkbox][name*="["][data-rule-require_from_group]').each(function () {
52+
const base = baseOf(this.name);
53+
if (!base) return;
54+
(map[base] ||= []).push(this.name);
55+
});
56+
return map;
57+
}
58+
59+
/**
60+
* Wire our grouping + dedup logic into the existing validator instance.
61+
* @param {Object} v - jQuery Validate instance stored on the form by WET.
62+
*/
63+
function wire(v) {
64+
const groups = discoverGroups();
65+
66+
// Make jQuery Validate prefer element.id over name (fixes accented name issues)
67+
v.idOrName = function (el) {
68+
return el.id || (el.name ? el.name.replace(/[^\w\-]+/g, '_') : '');
69+
};
70+
71+
// 1) Register one jQuery Validate "group" per checkbox base.
72+
// This makes jQuery Validate treat multiple field names as one logical group
73+
// when deciding which single label/entry to render.
74+
v.settings.groups = v.settings.groups || {};
75+
Object.keys(groups).forEach((base) => {
76+
// Names must be space-separated for JQV "groups".
77+
v.settings.groups[base] = groups[base].join(' ');
78+
});
79+
80+
// Precompute the FIRST field *id* of each base so we can place a single inline label.
81+
const firstIdOf = {};
82+
Object.keys(groups).forEach((base) => {
83+
const firstName = groups[base][0];
84+
const $el = $form.find('[name="' + CSS.escape(firstName) + '"]').first();
85+
firstIdOf[base] = $el.attr('id') || '';
86+
});
87+
88+
// Shadow a local baseOf so inner functions have it in scope (performance/readability).
89+
const baseOf = (name) => (name || '').match(/^([^\[]+)\[.+\]$/)?.[1] || null;
90+
91+
// 2) Override errorPlacement to only place the inline label for the FIRST checkbox.
92+
// This avoids multiple inline labels stacked under the group.
93+
const origPlace = v.settings.errorPlacement || function (error, element) { error.insertAfter(element); };
94+
v.settings.errorPlacement = function (error, element) {
95+
const name = element.attr('name');
96+
const base = baseOf(name);
97+
98+
// If this is part of a checkbox base and it's NOT the first item,
99+
// skip placing the label entirely (the "first" item will get it).
100+
if (base) {
101+
const id = element.attr('id') || '';
102+
// Only place for the FIRST id in the group, skip others to avoid duplicates.
103+
if (firstIdOf[base] && id !== firstIdOf[base]) return;
104+
}
105+
return origPlace(error, element);
106+
};
107+
108+
// 3) Override invalidHandler to de-duplicate BEFORE WET composes its summary.
109+
// We reduce validator.errorList/errorMap to contain only the first failing
110+
// element of each base, so the top summary has one entry per group.
111+
const origInvalid = v.settings.invalidHandler || $.noop;
112+
v.settings.invalidHandler = function (formEl, validator) {
113+
const seen = new Set();
114+
const list = [];
115+
const map = {};
116+
117+
validator.errorList.forEach((item) => {
118+
const el = item.element;
119+
const nm = el && el.name;
120+
const id = el && el.id;
121+
const base = baseOf(nm) || nm;
122+
const dedupeKey = base + '::' + (id || '');
123+
124+
if (!seen.has(dedupeKey)) {
125+
seen.add(dedupeKey);
126+
list.push(item);
127+
if (nm) map[nm] = item.message;
128+
}
129+
});
130+
131+
// Replace the validator's error structures with our reduced versions.
132+
validator.errorList = list;
133+
validator.errorMap = map;
134+
135+
// Clean up any duplicate labels that may exist (caused earlier by accented names)
136+
const seenLabelFor = {};
137+
$(formEl).find('label.error[for]').each(function () {
138+
const f = $(this).attr('for');
139+
if (seenLabelFor[f]) $(this).remove();
140+
else seenLabelFor[f] = true;
141+
});
142+
143+
// Allow any original invalidHandler (including WET's) to run with the reduced list.
144+
return origInvalid.call(this, formEl, validator);
145+
};
146+
}
147+
148+
/**
149+
* Try to find the jQuery Validate instance created by WET for this form.
150+
* If found, wire our grouping/dedup logic into it.
151+
* Returns true if the validator exists and was wired.
152+
*/
153+
function tryWire() {
154+
const v = $form.data('validator') || $host.data('validator');
155+
if (v) wire(v);
156+
return !!v;
157+
}
158+
159+
// If WET's validator is already present, wire immediately.
160+
if (tryWire()) return;
161+
162+
// Otherwise, wait for WET to finish attaching the validator, then wire.
163+
// 'wb-ready.wb-frmvld' fires when the plugin is ready on this wrapper.
164+
$host.on('wb-ready.wb-frmvld', tryWire);
165+
166+
// ---- Submission guards --------------------------------------------------
167+
// Prevent the form from submitting when invalid (covers:
168+
// - clicking the submit button
169+
// - pressing Enter in a field
170+
// - any custom handler that falls back to form.submit())
171+
//
172+
// We *evaluate* validity using $form.valid()/v.checkForm() and block the
173+
// event if invalid. We also focus the first invalid field for accessibility.
174+
175+
// Guard the native form submit event.
176+
$form.on('submit.wetGroupOneMessage', function (e) {
177+
const v = $form.data('validator') || $host.data('validator');
178+
// If no validator, let normal processing happen.
179+
if (!v) return;
180+
181+
// valid() calls checkForm() and sets up error display without submitting.
182+
const ok = $form.valid ? $form.valid() : v.checkForm();
183+
if (!ok) {
184+
v.focusInvalid && v.focusInvalid();
185+
e.preventDefault();
186+
e.stopImmediatePropagation();
187+
e.stopPropagation();
188+
return false;
189+
}
190+
});
191+
192+
// Guard submit button clicks as well to catch flows
193+
// where other handlers might try to submit programmatically.
194+
$form.find('button[type=submit], input[type=submit]').on('click.wetGroupOneMessage', function (e) {
195+
const v = $form.data('validator') || $host.data('validator');
196+
if (!v) return;
197+
const ok = $form.valid ? $form.valid() : v.checkForm();
198+
if (!ok) {
199+
v.focusInvalid && v.focusInvalid();
200+
e.preventDefault();
201+
e.stopImmediatePropagation();
202+
e.stopPropagation();
203+
return false;
204+
}
205+
});
206+
});
207+
}
208+
};
209+
})(jQuery, Drupal, once);
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* @file
3+
* Webform/WET-BOEW required marker enhancer for conditional fields.
4+
*
5+
* Purpose:
6+
* - Mirror runtime toggles of "required" on Webform elements without
7+
* duplicating server-rendered markers.
8+
* - When a field becomes required client-side, append
9+
* <strong class="required" aria-hidden="true">(required)</strong> as the
10+
* last child of its <label> or <legend>. Remove it when no longer required.
11+
*/
12+
(function (Drupal, once) {
13+
'use strict';
14+
15+
const OUR_ATTR = 'data-webform-required-marker';
16+
const OUR_SEL = `strong.required[${OUR_ATTR}="1"]`;
17+
18+
// Track prior required state per element (label/legend).
19+
const baseline = new WeakMap();
20+
21+
// Helpers
22+
function isLabelRequired(label) {
23+
return label.classList.contains('js-form-required');
24+
}
25+
function isLegendRequired(legend) {
26+
return !!legend.querySelector('span.js-form-required');
27+
}
28+
// Place marker before any inline error badge (e.g., .label.label-danger).
29+
function ensureBeforeError(el, node) {
30+
const err = el.querySelector('strong.error');
31+
const errIsChild = err && err.parentNode === el;
32+
if (errIsChild) {
33+
if (node.parentNode !== el || node.nextElementSibling !== err) {
34+
el.insertBefore(node, err);
35+
}
36+
} else {
37+
if (node.parentNode !== el || el.lastElementChild !== node) {
38+
el.appendChild(node);
39+
}
40+
}
41+
}
42+
function addMarker(el) {
43+
el.classList.add('required');
44+
let strong = el.querySelector(OUR_SEL);
45+
if (!strong) {
46+
strong = document.createElement('strong');
47+
strong.className = 'required';
48+
strong.setAttribute(OUR_ATTR, '1');
49+
strong.setAttribute('aria-hidden', 'true');
50+
strong.textContent = `(${Drupal.t('required')})`;
51+
}
52+
ensureBeforeError(el, strong);
53+
}
54+
function removeMarker(el) {
55+
el.classList.remove('required');
56+
const ours = el.querySelector(OUR_SEL);
57+
if (ours) ours.remove();
58+
}
59+
60+
// React to a possible state change on a label/legend,
61+
// but only if baseline exists.
62+
function syncWithTransition(el, nowRequired) {
63+
if (!baseline.has(el)) {
64+
// First time we see this element post-attach:
65+
// set baseline, do nothing.
66+
baseline.set(el, nowRequired);
67+
return;
68+
}
69+
const wasRequired = baseline.get(el);
70+
if (wasRequired === nowRequired) {
71+
// No change.
72+
return;
73+
}
74+
// Update baseline then act.
75+
baseline.set(el, nowRequired);
76+
if (nowRequired) addMarker(el);
77+
else removeMarker(el);
78+
}
79+
80+
Drupal.behaviors.webformRequiredMarkerObserver = {
81+
attach(context) {
82+
// Scope to .wb-frmvld (your library is only attached
83+
// when inline validation is enabled).
84+
once('webform-required-marker-observer', '.wb-frmvld', context).forEach((root) => {
85+
// Defer baseline capture to the next frame so we
86+
// don't react to initial build churn.
87+
requestAnimationFrame(() => {
88+
// Capture baseline for all labels and legends without touching DOM.
89+
root.querySelectorAll('label').forEach((label) => {
90+
baseline.set(label, isLabelRequired(label));
91+
});
92+
root.querySelectorAll('legend').forEach((legend) => {
93+
baseline.set(legend, isLegendRequired(legend));
94+
});
95+
});
96+
97+
// Observe only class/child mutations;
98+
// act *after* baseline exists.
99+
const observer = new MutationObserver((mutations) => {
100+
for (const m of mutations) {
101+
// Class toggles on <label>.
102+
if (m.type === 'attributes' && m.attributeName === 'class' && m.target.tagName === 'LABEL') {
103+
const label = /** @type {HTMLElement} */ (m.target);
104+
syncWithTransition(label, isLabelRequired(label));
105+
continue;
106+
}
107+
108+
// Class toggles on <span> inside <legend>.
109+
if (m.type === 'attributes' && m.attributeName === 'class' && m.target.tagName === 'SPAN') {
110+
const legend = m.target.closest('legend');
111+
if (legend) {
112+
syncWithTransition(legend, isLegendRequired(legend));
113+
}
114+
continue;
115+
}
116+
117+
// Child list changes within/under a <legend> (some UIs replace nodes).
118+
if (m.type === 'childList') {
119+
const legend = (m.target.closest && m.target.closest('legend')) || (m.target.tagName === 'LEGEND' ? m.target : null);
120+
if (legend) {
121+
syncWithTransition(legend, isLegendRequired(legend));
122+
}
123+
}
124+
}
125+
});
126+
127+
observer.observe(root, {
128+
subtree: true,
129+
attributes: true,
130+
attributeFilter: ['class'],
131+
attributeOldValue: true,
132+
childList: true,
133+
});
134+
});
135+
}
136+
};
137+
})(Drupal, once);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
wet_webform_enhancements:
2+
js:
3+
js/webform_required_marker.js: {}
4+
js/webform_checkboxes_group.js: {}
5+
dependencies:
6+
- core/drupal
7+
- core/once
8+
- core/drupalSettings
9+

modules/custom/wxt_ext/wxt_ext_webform/wxt_ext_webform.module

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ function wxt_ext_webform_webform_submission_form_alter(array &$form, FormStateIn
166166
if ($webform->getThirdPartySetting('wxt_ext_webform', 'wet_inline_validation', FALSE)) {
167167
$form['#prefix'] = ($form['#prefix'] ?? '') . '<div class="wb-frmvld">';
168168
$form['#suffix'] = '</div>' . ($form['#suffix'] ?? '');
169+
170+
// Attach label/required enhancer only when inline validation is enabled.
171+
$form['#attached']['library'][] = 'wxt_ext_webform/wet_webform_enhancements';
169172
}
170173
}
171174

0 commit comments

Comments
 (0)