Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
114 lines
4.0 KiB
JavaScript
114 lines
4.0 KiB
JavaScript
/**
|
|
* Real-time password validation hints — checks length, uppercase, digit, special char.
|
|
*/
|
|
import { warnOnce, resolveHost } from '../core/app-dom.js';
|
|
|
|
const rules = {
|
|
min: (value, min) => value.length >= min,
|
|
upper: (value) => /[A-Z]/.test(value),
|
|
lower: (value) => /[a-z]/.test(value),
|
|
number: (value) => /\d/.test(value),
|
|
symbol: (value) => /[^a-zA-Z0-9]/.test(value),
|
|
email: (value, _min, email) => {
|
|
if (!email) {return true;}
|
|
return !value.toLowerCase().includes(email.toLowerCase());
|
|
},
|
|
match: (_value, _min, _email, confirm) => {
|
|
if (!confirm) {return false;}
|
|
return true;
|
|
}
|
|
};
|
|
|
|
export function initPasswordHints(root = document, options = {}) {
|
|
const selector = String(options.selector || '[data-password-hints]').trim() || '[data-password-hints]';
|
|
const host = resolveHost(root);
|
|
const containers = Array.from(host.querySelectorAll(selector));
|
|
if (!containers.length) {
|
|
return { destroy: () => {} };
|
|
}
|
|
|
|
const cleanupFns = [];
|
|
|
|
containers.forEach((container) => {
|
|
if (!(container instanceof HTMLElement)) {return;}
|
|
if (container.dataset.passwordHintsBound === '1') {return;}
|
|
|
|
const passwordSelector = container.dataset.passwordInput;
|
|
const confirmSelector = container.dataset.confirmInput;
|
|
const emailSelector = container.dataset.emailInput;
|
|
const scope = document;
|
|
const passwordInput = passwordSelector ? scope.querySelector(passwordSelector) : null;
|
|
const confirmInput = confirmSelector ? scope.querySelector(confirmSelector) : null;
|
|
const emailInput = emailSelector ? scope.querySelector(emailSelector) : null;
|
|
if (passwordSelector && !passwordInput) {
|
|
warnOnce('UI_EL_MISSING', `Missing password input: ${passwordSelector}`, { module: 'password-hints' });
|
|
}
|
|
if (confirmSelector && !confirmInput) {
|
|
warnOnce('UI_EL_MISSING', `Missing confirm input: ${confirmSelector}`, { module: 'password-hints' });
|
|
}
|
|
if (emailSelector && !emailInput) {
|
|
warnOnce('UI_EL_MISSING', `Missing email input: ${emailSelector}`, { module: 'password-hints' });
|
|
}
|
|
const minLength = Number.parseInt(container.dataset.minLength || '12', 10);
|
|
const items = container.querySelectorAll('[data-rule]');
|
|
|
|
const setState = (item, state) => {
|
|
item.classList.remove('is-valid', 'is-invalid');
|
|
if (state === 'valid') {item.classList.add('is-valid');}
|
|
if (state === 'invalid') {item.classList.add('is-invalid');}
|
|
};
|
|
|
|
const evaluate = () => {
|
|
const password = passwordInput?.value ?? '';
|
|
const confirm = confirmInput?.value ?? '';
|
|
const email = emailInput?.value ?? '';
|
|
const hasValue = password.length > 0 || confirm.length > 0;
|
|
|
|
items.forEach((item) => {
|
|
const rule = item.dataset.rule;
|
|
if (!rule) {return;}
|
|
if (!hasValue) {
|
|
setState(item, 'neutral');
|
|
return;
|
|
}
|
|
if (rule === 'match') {
|
|
if (password === '' || confirm === '') {
|
|
setState(item, 'neutral');
|
|
return;
|
|
}
|
|
setState(item, password === confirm ? 'valid' : 'invalid');
|
|
return;
|
|
}
|
|
const check = rules[rule];
|
|
if (!check) {return;}
|
|
const ok = check(password, minLength, email, confirm);
|
|
setState(item, ok ? 'valid' : 'invalid');
|
|
});
|
|
};
|
|
|
|
if (passwordInput) {
|
|
passwordInput.addEventListener('input', evaluate);
|
|
cleanupFns.push(() => passwordInput.removeEventListener('input', evaluate));
|
|
}
|
|
if (confirmInput) {
|
|
confirmInput.addEventListener('input', evaluate);
|
|
cleanupFns.push(() => confirmInput.removeEventListener('input', evaluate));
|
|
}
|
|
if (emailInput) {
|
|
emailInput.addEventListener('input', evaluate);
|
|
cleanupFns.push(() => emailInput.removeEventListener('input', evaluate));
|
|
}
|
|
evaluate();
|
|
container.dataset.passwordHintsBound = '1';
|
|
cleanupFns.push(() => {
|
|
delete container.dataset.passwordHintsBound;
|
|
});
|
|
});
|
|
|
|
const destroy = () => {
|
|
cleanupFns.forEach((cleanup) => cleanup());
|
|
};
|
|
|
|
return { destroy };
|
|
}
|