/** * Progressive enhancement for the student registration form. * * Two independent behaviours, both optional — without JS every panel stays * visible and the single submit still works: * * 1. **Two steps.** When account-signup questions are configured the form * renders two panels (`[data-step="1"]` account details, `[data-step="2"]` * the questions) inside a form marked `data-steps="1"`. Step two is hidden * behind a "Next" button that only advances once step one passes native * validation. * 2. **Parent/guardian.** The children section is hidden until the * parent/guardian box is ticked, and "Add another child" clones the child * block. Ticking the box also takes the guardian's *own* question panel out * of play — in guardian mode the questions are asked per child, so the * server ignores those answers and the browser must not demand them. * 3. **Password strength.** The password is scored with zxcvbn (via WordPress's * own `wp.passwordStrength`) and a weak one is refused. The server applies * its own, coarser rule regardless — see `Auth\PasswordPolicy`. */ (function () { 'use strict'; var PASSWORD = window.usSchedulerPassword || {}; /** * Gate the form on password strength. * * The verdict is attached to the field with `setCustomValidity()` rather than * by disabling the submit button: the form has up to three submits (the plain * one, the guardian-mode early one, and step two's) plus a "Next" that * already gates on `checkValidity()`, and an invalid field blocks all of them * at once without any of them having to know why. */ function enhancePassword(form) { var field = form.querySelector('#us-reg-pass'); var output = form.querySelector('#us-reg-pass-strength'); var strings = PASSWORD.strings || {}; if (!field || !PASSWORD.minScore) { return; } // What the password must not simply repeat back. Mirrors the identity // check PasswordPolicy makes server-side. function identity() { var out = []; var sources = form.querySelectorAll('#us-reg-email, #us-reg-name'); for (var i = 0; i < sources.length; i++) { var value = (sources[i].value || '').trim(); if (value) { out.push(value); if (value.indexOf('@') > 0) { out.push(value.split('@')[0]); } } } return out; } function assess() { var value = field.value || ''; if (!value) { report('', ''); return; } if (value.length < (PASSWORD.minLength || 8)) { report(strings.short, 'short'); return; } // zxcvbn's dictionary is fetched after load, and wp.passwordStrength // reports -1 until it arrives. Say nothing and allow the submit in that // window — the server still checks, and the next keystroke re-runs this // once the dictionary is in. if (!window.wp || !window.wp.passwordStrength || typeof window.zxcvbn === 'undefined') { report('', ''); return; } var score = window.wp.passwordStrength.meter(value, identity(), ''); if (score < 0) { report('', ''); return; } if (score >= 3) { report(strings.strong, 'strong'); } else if (score >= PASSWORD.minScore) { report(strings.medium, 'medium'); } else { report(score <= 0 ? strings.veryWeak : strings.weak, 'weak'); } } /** Show the verdict, and make it the field's validity at the same time. */ function report(message, level) { var acceptable = '' === level || 'medium' === level || 'strong' === level; if (output) { output.textContent = message || ''; output.className = 'us-password-strength' + (level ? ' is-' + level : ''); } field.setCustomValidity(acceptable ? '' : message || ''); } field.addEventListener('input', assess); field.addEventListener('blur', assess); // The identity check depends on these, so a password typed first and an // email typed second is still caught. var sources = form.querySelectorAll('#us-reg-email, #us-reg-name'); for (var i = 0; i < sources.length; i++) { sources[i].addEventListener('change', assess); } } function enhanceSteps(form) { var step1 = form.querySelector('[data-step="1"]'); var step2 = form.querySelector('[data-step="2"]'); var next = form.querySelector('.us-reg-next'); var back = form.querySelector('.us-reg-back'); if (!step1 || !step2 || !next) { return null; } function show(step) { step1.hidden = step !== 1; step2.hidden = step !== 2; } show(1); next.addEventListener('click', function () { var fields = step1.querySelectorAll('input, select, textarea'); for (var i = 0; i < fields.length; i++) { if (!fields[i].checkValidity()) { fields[i].reportValidity(); return; } } show(2); }); if (back) { back.addEventListener('click', function () { show(1); }); } return { step2: step2, next: next, earlySubmit: form.querySelector('.us-reg-submit-early'), }; } /** * Rewrite a cloned child block's `children[0][…]` names and ids to the new * index, and clear the values carried over from the block it was cloned from. */ function reindex(block, index) { block.setAttribute('data-child-index', String(index)); var fields = block.querySelectorAll('input, select, textarea'); for (var i = 0; i < fields.length; i++) { var field = fields[i]; if (field.name) { field.name = field.name.replace(/^children\[\d+\]/, 'children[' + index + ']'); } var oldId = field.id; if (oldId) { field.id = oldId.replace(/^us-child-\d+-/, 'us-child-' + index + '-'); var label = block.querySelector('label[for="' + oldId + '"]'); if (label) { label.setAttribute('for', field.id); } } if (field.type === 'checkbox' || field.type === 'radio') { field.checked = false; } else { field.value = ''; } } } function enhanceGuardian(form, steps) { var toggle = form.querySelector('#us-is-guardian'); var children = form.querySelector('#us-children'); if (!toggle || !children) { return; } var addButton = children.querySelector('.us-add-child'); var nextIndex = 1; // The guardian's own question panel is only meaningful when they are // registering for themselves. Disabling it (rather than hiding it) is what // stops a `required` question the server will ignore from blocking submit. function sync() { children.hidden = !toggle.checked; // Each student's name and birth year are required, but only once the // block is in play: a `required` field inside a hidden container makes // the form unsubmittable with no way to reach the offending control, so // the attribute goes on and comes off with the block itself. The server // enforces the same rule either way. var required = children.querySelectorAll('[data-us-child-required]'); for (var r = 0; r < required.length; r++) { required[r].required = toggle.checked; } if (!steps) { return; } var fields = steps.step2.querySelectorAll('input, select, textarea'); for (var i = 0; i < fields.length; i++) { fields[i].disabled = toggle.checked; } // With the questions out of play there is no second step to advance to, // so "Next" would be a dead end — swap it for the submit. steps.next.hidden = toggle.checked; if (steps.earlySubmit) { steps.earlySubmit.hidden = !toggle.checked; } } toggle.addEventListener('change', sync); sync(); if (addButton) { addButton.addEventListener('click', function () { var blocks = children.querySelectorAll('.us-child'); var clone = blocks[blocks.length - 1].cloneNode(true); reindex(clone, nextIndex); nextIndex += 1; children.insertBefore(clone, addButton.parentNode); // The clone carries the data attribute but not necessarily the // current required state, so settle it the same way as the rest. sync(); }); } } document.addEventListener('DOMContentLoaded', function () { var forms = document.querySelectorAll('.us-register-form form'); for (var i = 0; i < forms.length; i++) { var steps = forms[i].getAttribute('data-steps') === '1' ? enhanceSteps(forms[i]) : null; enhanceGuardian(forms[i], steps); enhancePassword(forms[i]); } }); })();