/* * Dynamic form field creation. Each function returns a DOM node tree for a * single template field. The approach keeps template-driven rendering in one * module while the orchestrator (app.js) provides callbacks for state mutations. * * Callback contract: * onFieldChange(field, nextValue) — called when the user edits a field * onAttachFiles(field, report, files) — called when files are selected * onRemoveAttachment(attachmentId) — called to remove an attachment */ import { evaluateRequiredWhen } from './validation.js'; import { formatFileSize } from './utils.js'; export function createFieldNode(field, report, { state, onFieldChange, onAttachFiles, onRemoveAttachment }) { const wrapper = document.createElement('div'); wrapper.className = `field ${field.type === 'comment' || field.type === 'attachment' ? 'field-full' : ''}`; const isRequired = Boolean(field.required || evaluateRequiredWhen(field.requiredWhen, report.answers)); const currentValue = report.answers[field.id]; wrapper.innerHTML = `
${isRequired ? 'Required' : ''}
`; let inputNode; switch (field.type) { case 'text': case 'date': case 'number': inputNode = document.createElement('input'); inputNode.className = 'text-input'; inputNode.id = `field-${field.id}`; inputNode.name = field.id; inputNode.type = field.type; inputNode.value = currentValue ?? ''; inputNode.readOnly = Boolean(field.readOnly); if (field.validation?.min !== undefined) { inputNode.min = String(field.validation.min); } inputNode.addEventListener('input', (event) => { onFieldChange(field, event.target.value); }); break; case 'lookup': { inputNode = document.createElement('select'); inputNode.className = 'select-input'; inputNode.id = `field-${field.id}`; inputNode.name = field.id; const emptyOption = document.createElement('option'); emptyOption.value = ''; emptyOption.textContent = 'Select an option'; inputNode.append(emptyOption); const lookup = state.lookups.get(field.lookupCode); for (const optionData of lookup?.values || []) { const option = document.createElement('option'); option.value = optionData.value; option.textContent = optionData.label; option.selected = optionData.value === currentValue; inputNode.append(option); } inputNode.addEventListener('change', (event) => { onFieldChange(field, event.target.value); }); break; } case 'checkbox': inputNode = document.createElement('label'); inputNode.className = 'checkbox-row'; inputNode.innerHTML = ` ${escapeHtml(field.label)} `; inputNode.querySelector('input').addEventListener('change', (event) => { onFieldChange(field, event.target.checked); }); break; case 'comment': inputNode = document.createElement('textarea'); inputNode.className = 'text-area'; inputNode.id = `field-${field.id}`; inputNode.name = field.id; inputNode.maxLength = field.maxLength || 5000; inputNode.value = currentValue ?? ''; inputNode.addEventListener('input', (event) => { onFieldChange(field, event.target.value); }); break; case 'attachment': inputNode = createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment }); break; default: inputNode = document.createElement('input'); inputNode.className = 'text-input'; inputNode.id = `field-${field.id}`; inputNode.name = field.id; inputNode.type = 'text'; inputNode.value = currentValue ?? ''; inputNode.addEventListener('input', (event) => { onFieldChange(field, event.target.value); }); } wrapper.append(inputNode); if (field.requiredWhen?.field) { const help = document.createElement('p'); help.className = 'field-help'; help.textContent = `Required when ${field.requiredWhen.field} is ${String(field.requiredWhen.equals)}.`; wrapper.append(help); } return wrapper; } /* ── Attachment field ───────────────────────────────────────────────────── */ function createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment }) { const container = document.createElement('div'); container.className = 'attachment-list'; const toolbar = document.createElement('div'); toolbar.className = 'attachment-toolbar'; const input = document.createElement('input'); input.className = 'file-input'; input.id = `field-${field.id}`; input.type = 'file'; input.accept = state.imageRules?.allowedMimeTypes?.join(',') || 'image/*'; input.multiple = true; input.addEventListener('change', async (event) => { const files = Array.from(event.target.files || []); await onAttachFiles(field, report, files); event.target.value = ''; }); toolbar.append(input); container.append(toolbar); /* * P3 — Lazy-load attachment previews. Thumbnails are created from object URLs * on demand using IntersectionObserver. Only attachments scrolled into view * allocate a Blob URL, keeping memory use proportional to the visible area. */ const attachments = state.currentAttachments.filter((item) => item.fieldId === field.id); if (!attachments.length) { const hint = document.createElement('p'); hint.className = 'field-help'; hint.textContent = 'No images attached yet.'; container.append(hint); return container; } const observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (!entry.isIntersecting) { continue; } const img = entry.target; const attachment = attachments.find((a) => a.id === img.dataset.attachmentId); if (attachment?.blob) { const objectUrl = URL.createObjectURL(attachment.blob); img.src = objectUrl; img.addEventListener('load', () => URL.revokeObjectURL(objectUrl), { once: true }); } observer.unobserve(img); } }, { rootMargin: '200px' }); for (const attachment of attachments) { const card = document.createElement('article'); card.className = 'attachment-card'; const preview = document.createElement('img'); preview.className = 'attachment-preview'; preview.alt = attachment.generatedFilename; preview.dataset.attachmentId = attachment.id; /* Actual src loaded lazily by the IntersectionObserver above. */ observer.observe(preview); const copy = document.createElement('div'); copy.className = 'attachment-card__copy'; copy.innerHTML = ` ${escapeHtml(attachment.generatedFilename)} ${escapeHtml(attachment.originalFilename)} ${attachment.width}x${attachment.height}px • ${formatFileSize(attachment.sizeBytes)} `; const removeButton = document.createElement('button'); removeButton.type = 'button'; removeButton.className = 'button button-small button-ghost'; removeButton.textContent = 'Remove'; removeButton.addEventListener('click', () => { void onRemoveAttachment(attachment.id); }); card.append(preview, copy, removeButton); container.append(card); } return container; } /* ── Helpers ────────────────────────────────────────────────────────────── */ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }