ultimate-jekyll-manager
Version:
Ultimate Jekyll dependency manager
1,063 lines (902 loc) • 28 kB
JavaScript
/**
* FormManager - Lightweight form state management
*
* States: initializing → ready ⇄ submitting → ready (or submitted)
*
* Usage:
* const formManager = new FormManager('#my-form', { options });
* formManager.on('submit', async (data) => {
* const response = await fetch('/api', { body: JSON.stringify(data) });
* if (!response.ok) throw new Error('Failed');
* });
*/
// Libraries
import { ready as domReady } from 'web-manager/modules/dom.js';
// Constants
const HONEYPOT_SELECTOR = '[data-honey], [name="honey"]';
export class FormManager {
constructor(selector, options = {}) {
// Get form element
this.$form = typeof selector === 'string'
? document.querySelector(selector)
: selector;
if (!this.$form) {
throw new Error(`FormManager: Form not found: ${selector}`);
}
// Configuration
this.config = {
autoReady: true, // Auto-transition to initialState when DOM is ready
initialState: 'ready', // State to transition to when autoReady fires
allowResubmit: true, // Allow resubmission after success (false = go to 'submitted' state)
resetOnSuccess: false, // Clear form fields after successful submission
warnOnUnsavedChanges: false, // Warn user before leaving page with unsaved changes
submittingText: 'Processing...', // Text shown on submit button during submission
submittedText: 'Processed!', // Text shown on submit button after submission (when allowResubmit: false)
inputGroup: null, // Filter getData() to only include fields with matching data-input-group (null = all fields)
...options,
};
// State
this.state = 'initializing';
this._isDirty = false;
// Event listeners
this._listeners = {
change: [],
validation: [],
submit: [],
statechange: [],
honeypot: [],
};
// Field errors (populated during validation)
this._fieldErrors = {};
// Bind beforeunload handler so we can remove it later
this._beforeUnloadHandler = (e) => this._handleBeforeUnload(e);
/* @dev-only:start */
{
console.log('[Form-manager] Initialized', {
selector: typeof selector === 'string' ? selector : this.$form.id || this.$form,
config: this.config,
});
}
/* @dev-only:end */
// Initialize
this._init();
}
/**
* Initialize the form manager
*/
_init() {
// Disable form during initialization
this._setDisabled(true);
// Attach submit handler
this.$form.addEventListener('submit', (e) => this._handleSubmit(e));
// Attach change handlers
this.$form.addEventListener('input', (e) => this._handleChange(e));
this.$form.addEventListener('change', (e) => this._handleChange(e));
// Attach beforeunload handler if configured
if (this.config.warnOnUnsavedChanges) {
window.addEventListener('beforeunload', this._beforeUnloadHandler);
}
// Handle page restored from bfcache (e.g., back button after OAuth redirect)
window.addEventListener('pageshow', (e) => this._handlePageShow(e));
// Auto-transition to initialState when DOM is ready
if (this.config.autoReady) {
domReady().then(() => this._setInitialState());
}
}
/**
* Register event listener
*/
on(event, callback) {
if (!this._listeners[event]) {
this._listeners[event] = [];
}
this._listeners[event].push(callback);
return this; // Allow chaining
}
/**
* Emit event to all listeners
*/
async _emit(event, data) {
const listeners = this._listeners[event] || [];
for (const callback of listeners) {
await callback(data);
}
}
/**
* Set initial state based on config
*/
_setInitialState() {
const state = this.config.initialState;
/* @dev-only:start */
{
console.log('[Form-manager] DOM ready, setting initial state:', state);
}
/* @dev-only:end */
if (state === 'ready') {
this.ready();
} else {
this._setState(state);
}
}
/**
* Transition to ready state
*/
ready() {
/* @dev-only:start */
{
console.log('[Form-manager] ready() called');
}
/* @dev-only:end */
this._setState('ready');
this._setDisabled(false);
// Focus the field with autofocus attribute if it exists (desktop only)
const $autofocusField = this.$form.querySelector('[autofocus]');
if ($autofocusField && !$autofocusField.disabled && window.Manager?.webManager?.utilities()?.getDeviceType() === 'desktop') {
this._focusField($autofocusField);
}
}
/**
* Handle form submission
*/
async _handleSubmit(e) {
// Always prevent default - this is the whole point
e.preventDefault();
// Ignore if not ready
if (this.state !== 'ready') {
/* @dev-only:start */
{
console.log('[Form-manager] Submit ignored, not ready. Current state:', this.state);
}
/* @dev-only:end */
return;
}
// Get the submit button that was clicked (native browser API)
const $submitButton = e.submitter;
// Collect form data BEFORE disabling (disabled elements aren't in FormData)
const data = this.getData();
// Clear previous field errors
this.clearFieldErrors();
// Run validation BEFORE transitioning to submitting state
const validationPassed = await this._runValidation(data, $submitButton);
if (!validationPassed) {
return;
}
// Transition to submitting
this._setState('submitting');
this._setDisabled(true);
this._showSpinner(true);
/* @dev-only:start */
{
console.log('[Form-manager] Submitting', {
data,
submitButton: $submitButton?.name ? `${$submitButton.name}=${$submitButton.value}` : null,
});
}
/* @dev-only:end */
try {
// Let consumers handle the submission
await this._emit('submit', { data, $submitButton });
/* @dev-only:start */
{
console.log('[Form-manager] Submit success', {
resetOnSuccess: this.config.resetOnSuccess,
allowResubmit: this.config.allowResubmit,
});
}
/* @dev-only:end */
// Success - clear dirty state
this.setDirty(false);
this._showSpinner(false);
if (this.config.resetOnSuccess) {
this.$form.reset();
}
if (this.config.allowResubmit) {
this._setState('ready');
this._setDisabled(false);
} else {
this._setState('submitted');
this._showSubmittedText();
// Stay disabled - no more submissions allowed
}
} catch (error) {
/* @dev-only:start */
{
console.log('[Form-manager] Submit error:', error.message);
}
/* @dev-only:end */
// Error - go back to ready and show error
this._setState('ready');
this._setDisabled(false);
this._showSpinner(false);
this.showError(error.message || 'An error occurred');
}
}
/**
* Handle input changes
*/
_handleChange(e) {
// Mark form as dirty
this.setDirty(true);
const data = this.getData();
/* @dev-only:start */
{
console.log('[Form-manager] Change', {
name: e.target.name,
value: e.target.value,
data,
});
}
/* @dev-only:end */
this._emit('change', {
field: e.target,
name: e.target.name,
value: e.target.value,
data,
});
// Clear field error when user types in that field
if (this._fieldErrors[e.target.name]) {
this._clearFieldError(e.target.name);
}
}
/**
* Run validation (HTML5 + custom validation event)
* Returns true if validation passed, false if there are errors
*/
async _runValidation(data, $submitButton) {
/* @dev-only:start */
{
console.log('[Form-manager] Running validation');
}
/* @dev-only:end */
// 0. Check honeypot fields first (bot detection)
if (this._isHoneypotFilled()) {
/* @dev-only:start */
{
console.log('[Form-manager] Honeypot triggered - rejecting submission');
}
/* @dev-only:end */
// Emit honeypot event for tracking
this._emit('honeypot', { data });
this.showError('Something went wrong. Please try again.');
return false;
}
// Create setError helper for custom validation
const setError = (fieldName, message) => {
this._fieldErrors[fieldName] = message;
};
// 1. Run automatic HTML5 validation
this._runHTML5Validation(setError);
// 2. Run custom validation listeners
await this._emit('validation', { data, setError, $submitButton });
// 3. Check if there are any errors
const errorCount = Object.keys(this._fieldErrors).length;
if (errorCount > 0) {
/* @dev-only:start */
{
console.log('[Form-manager] Validation failed:', this._fieldErrors);
}
/* @dev-only:end */
// Display all field errors
this._displayFieldErrors();
// Focus first error field
this._focusFirstError();
return false;
}
/* @dev-only:start */
{
console.log('[Form-manager] Validation passed');
}
/* @dev-only:end */
return true;
}
/**
* Run HTML5 constraint validation on all form fields
*/
_runHTML5Validation(setError) {
const $fields = this.$form.querySelectorAll('input, select, textarea');
$fields.forEach(($field) => {
const name = $field.name;
if (!name) {
return;
}
// Skip if field is not in current input group (respects setInputGroup filter)
if (!this._isFieldInGroup($field)) {
return;
}
// Skip if already has an error (from previous validation)
if (this._fieldErrors[name]) {
return;
}
const value = $field.value;
const type = $field.type;
// Required validation
if ($field.hasAttribute('required')) {
if (type === 'checkbox' && !$field.checked) {
setError(name, 'This field is required');
return;
}
if (!value || !value.trim()) {
setError(name, 'This field is required');
return;
}
}
// Skip further validation if empty and not required
if (!value) {
return;
}
// Email validation
if (type === 'email') {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(value)) {
setError(name, 'Please enter a valid email address');
return;
}
}
// URL validation
if (type === 'url') {
try {
new URL(value);
} catch {
setError(name, 'Please enter a valid URL');
return;
}
}
// Min length validation
if ($field.hasAttribute('minlength')) {
const minLength = parseInt($field.getAttribute('minlength'), 10);
if (value.length < minLength) {
setError(name, `Must be at least ${minLength} characters`);
return;
}
}
// Max length validation
if ($field.hasAttribute('maxlength')) {
const maxLength = parseInt($field.getAttribute('maxlength'), 10);
if (value.length > maxLength) {
setError(name, `Must be no more than ${maxLength} characters`);
return;
}
}
// Min value validation (for number, range, date, etc.)
if ($field.hasAttribute('min')) {
const min = $field.getAttribute('min');
if (type === 'number' || type === 'range') {
if (parseFloat(value) < parseFloat(min)) {
setError(name, `Must be at least ${min}`);
return;
}
} else if (type === 'date' || type === 'datetime-local') {
if (new Date(value) < new Date(min)) {
setError(name, `Must be on or after ${min}`);
return;
}
}
}
// Max value validation
if ($field.hasAttribute('max')) {
const max = $field.getAttribute('max');
if (type === 'number' || type === 'range') {
if (parseFloat(value) > parseFloat(max)) {
setError(name, `Must be no more than ${max}`);
return;
}
} else if (type === 'date' || type === 'datetime-local') {
if (new Date(value) > new Date(max)) {
setError(name, `Must be on or before ${max}`);
return;
}
}
}
// Pattern validation
if ($field.hasAttribute('pattern')) {
const pattern = new RegExp(`^${$field.getAttribute('pattern')}$`);
if (!pattern.test(value)) {
const title = $field.getAttribute('title') || 'Please match the requested format';
setError(name, title);
return;
}
}
});
}
/**
* Display all field errors in the DOM
*/
_displayFieldErrors() {
for (const [fieldName, message] of Object.entries(this._fieldErrors)) {
this._showFieldError(fieldName, message);
}
}
/**
* Show error on a specific field
*/
_showFieldError(fieldName, message) {
const $field = this.$form.querySelector(`[name="${fieldName}"]`);
if (!$field) {
return;
}
// Add invalid class to field
$field.classList.add('is-invalid');
// Find or create feedback element
let $feedback = $field.parentElement.querySelector('.invalid-feedback');
if (!$feedback) {
$feedback = document.createElement('div');
$feedback.className = 'invalid-feedback';
// Insert after the field (or after the label for checkboxes)
if ($field.type === 'checkbox' || $field.type === 'radio') {
const $parent = $field.closest('.form-check') || $field.parentElement;
$parent.appendChild($feedback);
} else {
$field.parentElement.appendChild($feedback);
}
}
$feedback.textContent = message;
$feedback.style.display = 'block';
}
/**
* Clear error on a specific field
*/
_clearFieldError(fieldName) {
delete this._fieldErrors[fieldName];
const $field = this.$form.querySelector(`[name="${fieldName}"]`);
if (!$field) {
return;
}
$field.classList.remove('is-invalid');
const $feedback = $field.parentElement.querySelector('.invalid-feedback');
if ($feedback) {
$feedback.style.display = 'none';
}
}
/**
* Clear all field errors
*/
clearFieldErrors() {
for (const fieldName of Object.keys(this._fieldErrors)) {
this._clearFieldError(fieldName);
}
this._fieldErrors = {};
}
/**
* Focus the first field with an error
*/
_focusFirstError() {
const firstFieldName = Object.keys(this._fieldErrors)[0];
if (!firstFieldName) {
return;
}
this._focusField(firstFieldName);
}
/**
* Scroll to and focus a field if it exists
* @param {HTMLElement|string} field - Field element or field name
*/
_focusField(field) {
// Resolve field element from name if string provided
const $field = typeof field === 'string'
? this.$form.querySelector(`[name="${field}"]`)
: field;
if (!$field) {
return;
}
$field.scrollIntoView({ behavior: 'smooth', block: 'center' });
$field.focus();
// Move cursor to end of input if it has existing text
// Disabled because throws errors on some inputs (eg email)
// if (typeof $autofocusField.setSelectionRange === 'function') {
// const len = $autofocusField.value.length;
// $autofocusField.setSelectionRange(len, len);
// }
}
/**
* Programmatically set field errors and display them (for use in submit handler)
*/
throwFieldErrors(errors) {
for (const [fieldName, message] of Object.entries(errors)) {
this._fieldErrors[fieldName] = message;
}
this._displayFieldErrors();
this._focusFirstError();
throw new Error('Validation failed');
}
/**
* Handle beforeunload event
*/
_handleBeforeUnload(e) {
if (!this._isDirty) {
return;
}
// Standard way to trigger browser's "unsaved changes" dialog
e.preventDefault();
e.returnValue = '';
}
/**
* Handle pageshow event (bfcache restoration)
*/
_handlePageShow(e) {
// Only handle if page was restored from bfcache
if (!e.persisted) {
return;
}
/* @dev-only:start */
{
console.log('[Form-manager] Page restored from bfcache, current state:', this.state);
}
/* @dev-only:end */
// Reset form to ready if it was stuck in submitting state
if (this.state === 'submitting') {
this._showSpinner(false);
this.ready();
}
}
/**
* Set dirty state
*/
setDirty(dirty) {
if (this._isDirty === dirty) {
return;
}
this._isDirty = dirty;
/* @dev-only:start */
{
console.log('[Form-manager] Dirty state:', dirty);
}
/* @dev-only:end */
}
/**
* Set form state
*/
_setState(newState) {
const previousState = this.state;
this.state = newState;
this.$form.setAttribute('data-form-state', newState);
/* @dev-only:start */
{
console.log('[Form-manager] State change', {
from: previousState,
to: newState,
});
}
/* @dev-only:end */
this._emit('statechange', { state: newState, previousState });
}
/**
* Enable/disable form controls
*/
_setDisabled(disabled) {
/* @dev-only:start */
{
console.log('[Form-manager] Set disabled:', disabled);
}
/* @dev-only:end */
this.$form.querySelectorAll('button, input, select, textarea').forEach(($el) => {
$el.disabled = disabled;
});
}
/**
* Get all submit buttons in the form
* Note: Uses button.type property instead of [type="submit"] selector
* because HTML minifiers may strip the attribute (it's the default)
*/
_getSubmitButtons() {
return Array.from(this.$form.querySelectorAll('button')).filter($btn => $btn.type === 'submit');
}
/**
* Show/hide spinner on submit buttons
*/
_showSpinner(show) {
this._getSubmitButtons().forEach(($btn) => {
if (show) {
// Store original content
$btn._originalHTML = $btn.innerHTML;
$btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${this.config.submittingText}`;
} else if ($btn._originalHTML) {
$btn.innerHTML = $btn._originalHTML;
}
});
}
/**
* Show submitted text on submit buttons (when allowResubmit: false)
*/
_showSubmittedText() {
this._getSubmitButtons().forEach(($btn) => {
const $buttonText = $btn.querySelector('.button-text');
if ($buttonText) {
$buttonText.textContent = this.config.submittedText;
} else {
$btn.textContent = this.config.submittedText;
}
});
}
/**
* Set nested value using dot notation (e.g., "user.address.city")
*/
_setNested(obj, path, value) {
const keys = path.split('.');
const lastKey = keys.pop();
let current = obj;
for (const key of keys) {
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key];
}
// Handle multiple values (e.g., checkboxes with same name)
if (current[lastKey] !== undefined) {
if (!Array.isArray(current[lastKey])) {
current[lastKey] = [current[lastKey]];
}
current[lastKey].push(value);
} else {
current[lastKey] = value;
}
}
/**
* Get nested value using dot notation
*/
_getNested(obj, path) {
return path.split('.').reduce((current, key) => {
return current && current[key] !== undefined ? current[key] : undefined;
}, obj);
}
/**
* Collect form data as plain object (supports dot notation for nested fields)
* Respects inputGroup filter when set - only includes fields matching the group
*/
getData() {
const data = {};
// Get all form fields
const $fields = this.$form.querySelectorAll('input, select, textarea');
// Count checkboxes per name to detect groups vs single (only for fields in group)
const checkboxCounts = {};
$fields.forEach(($field) => {
if ($field.type === 'checkbox' && this._isFieldInGroup($field)) {
checkboxCounts[$field.name] = (checkboxCounts[$field.name] || 0) + 1;
}
});
// Process non-checkbox fields
$fields.forEach(($field) => {
const name = $field.name;
// Skip fields without name
if (!name) {
return;
}
// Skip if field is not in current input group
if (!this._isFieldInGroup($field)) {
return;
}
// Skip honeypot fields (should never be in form data)
if ($field.matches(HONEYPOT_SELECTOR)) {
return;
}
// Skip checkboxes - we handle them separately
if ($field.type === 'checkbox') {
return;
}
// Skip radio buttons that aren't checked
if ($field.type === 'radio' && !$field.checked) {
return;
}
this._setNested(data, name, $field.value);
});
// Handle checkboxes
const processedGroups = new Set();
$fields.forEach(($cb) => {
if ($cb.type !== 'checkbox') {
return;
}
const name = $cb.name;
// Skip if field is not in current input group
if (!this._isFieldInGroup($cb)) {
return;
}
// Single checkbox: true/false
if (checkboxCounts[name] === 1) {
this._setNested(data, name, $cb.checked);
return;
}
// Checkbox group: object with value: true/false (only process once per group)
if (processedGroups.has(name)) {
return;
}
processedGroups.add(name);
const values = {};
this.$form.querySelectorAll(`input[type="checkbox"][name="${name}"]`).forEach(($groupCb) => {
// Only include checkboxes that are in the group
if (this._isFieldInGroup($groupCb)) {
values[$groupCb.value] = $groupCb.checked;
}
});
this._setNested(data, name, values);
});
return data;
}
/**
* Show success message
*/
showSuccess(message) {
/* @dev-only:start */
{
console.log('[Form-manager] Show success:', message);
}
/* @dev-only:end */
window.Manager?.webManager?.utilities()?.showNotification(message, { type: 'success' });
}
/**
* Show error message
*/
showError(message) {
/* @dev-only:start */
{
console.log('[Form-manager] Show error:', message);
}
/* @dev-only:end */
window.Manager?.webManager?.utilities()?.showNotification(message, { type: 'danger' });
}
/**
* Reset the form
*/
reset() {
/* @dev-only:start */
{
console.log('[Form-manager] reset() called');
}
/* @dev-only:end */
this.setDirty(false);
this.$form.reset();
this._setState('ready');
}
/**
* Check if form has unsaved changes
*/
isDirty() {
return this._isDirty;
}
/**
* Set the input group filter for getData()
* When set, getData() only returns fields matching the group (via data-input-group attribute)
* Fields without data-input-group or with empty value are considered "global" and always included
*
* @param {string|string[]|null} group - Group name(s) to filter by (e.g., 'url', ['url', 'wifi']), or null to disable filtering
* @returns {FormManager} - Returns this for chaining
*/
setInputGroup(group) {
// Normalize to array or null
if (group === null || group === undefined || group === '') {
this.config.inputGroup = null;
} else if (Array.isArray(group)) {
this.config.inputGroup = group.map((g) => g.toLowerCase());
} else {
this.config.inputGroup = [group.toLowerCase()];
}
/* @dev-only:start */
{
console.log('[Form-manager] setInputGroup:', this.config.inputGroup);
}
/* @dev-only:end */
return this;
}
/**
* Get the current input group filter
* @returns {string[]|null}
*/
getInputGroup() {
return this.config.inputGroup;
}
/**
* Check if any honeypot field has been filled (bot detection)
* Honeypot fields are hidden from users but bots fill them automatically
* @returns {boolean} - true if a honeypot field has a value (bot detected)
*/
_isHoneypotFilled() {
const $honeypots = this.$form.querySelectorAll(HONEYPOT_SELECTOR);
for (const $field of $honeypots) {
if ($field.value && $field.value.trim() !== '') {
return true;
}
}
return false;
}
/**
* Check if a field should be included based on input group filter
* @param {HTMLElement} $field - The field element to check
* @returns {boolean}
*/
_isFieldInGroup($field) {
const allowedGroups = this.config.inputGroup;
// No filter set - include all fields
if (!allowedGroups) {
return true;
}
// Get field's group attribute
const fieldGroup = $field.getAttribute('data-input-group');
// No group attribute or empty = global field, always include
if (!fieldGroup || fieldGroup.trim() === '') {
return true;
}
// Check if field's group is in allowed groups
return allowedGroups.includes(fieldGroup.toLowerCase());
}
/**
* Set form data from a nested object (supports dot notation field names)
*/
setData(data) {
/* @dev-only:start */
{
console.log('[Form-manager] setData() called', data);
}
/* @dev-only:end */
// Flatten nested object to dot notation paths
const flatData = this._flattenObject(data);
// Set each field value
for (const [path, value] of Object.entries(flatData)) {
this._setFieldValue(path, value);
}
}
/**
* Flatten a nested object to dot notation paths
*/
_flattenObject(obj, prefix = '') {
const result = {};
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
// Check if this is a checkbox group (object with boolean values)
const isCheckboxGroup = Object.values(value).every((v) => typeof v === 'boolean');
if (isCheckboxGroup) {
// Keep as object for checkbox group handling
result[path] = value;
} else {
// Recurse into nested object
Object.assign(result, this._flattenObject(value, path));
}
} else {
result[path] = value;
}
}
return result;
}
/**
* Set a single field value by name (supports dot notation)
*/
_setFieldValue(name, value) {
const $fields = this.$form.querySelectorAll(`[name="${name}"]`);
if ($fields.length === 0) {
/* @dev-only:start */
{
console.log('[Form-manager] setData: field not found:', name);
}
/* @dev-only:end */
return;
}
const $field = $fields[0];
const type = $field.type;
// Handle different input types
if (type === 'checkbox') {
if ($fields.length === 1) {
// Single checkbox: boolean value
$field.checked = !!value;
} else if (typeof value === 'object') {
// Checkbox group: object with value: boolean
$fields.forEach(($cb) => {
$cb.checked = !!value[$cb.value];
});
}
} else if (type === 'radio') {
// Radio group: set the one with matching value
$fields.forEach(($radio) => {
$radio.checked = $radio.value === value;
});
} else if ($field.tagName === 'SELECT') {
// Select: set value
$field.value = value;
} else {
// Text, email, textarea, etc.
$field.value = value;
}
/* @dev-only:start */
{
console.log('[Form-manager] setData: set field', { name, value, type });
}
/* @dev-only:end */
}
}