diff --git a/blocks/form/form-fields.js b/blocks/form/form-fields.js new file mode 100644 index 00000000..ea720695 --- /dev/null +++ b/blocks/form/form-fields.js @@ -0,0 +1,229 @@ +import { toClassName } from '../../scripts/aem.js'; + +function createFieldWrapper(fd) { + const fieldWrapper = document.createElement('div'); + if (fd.Style) fieldWrapper.className = fd.Style; + fieldWrapper.classList.add('field-wrapper', `${fd.Type}-wrapper`); + + fieldWrapper.dataset.fieldset = fd.Fieldset; + + return fieldWrapper; +} + +const ids = []; +function generateFieldId(fd, suffix = '') { + const slug = toClassName(`form-${fd.Name}${suffix}`); + ids[slug] = ids[slug] || 0; + const idSuffix = ids[slug] ? `-${ids[slug]}` : ''; + ids[slug] += 1; + return `${slug}${idSuffix}`; +} + +function createLabel(fd) { + const label = document.createElement('label'); + label.id = generateFieldId(fd, '-label'); + label.textContent = fd.Label || fd.Name; + label.setAttribute('for', fd.Id); + return label; +} + +function setCommonAttributes(field, fd) { + field.id = fd.Id; + field.name = fd.Name; + field.required = fd.Mandatory && (fd.Mandatory.toLowerCase() === 'true' || fd.Mandatory.toLowerCase() === 'x'); + field.placeholder = fd.Placeholder; + field.value = fd.Value; +} + +const createHeading = (fd) => { + const fieldWrapper = createFieldWrapper(fd); + + const level = fd.Style && fd.Style.includes('sub-heading') ? 3 : 2; + const heading = document.createElement(`h${level}`); + heading.textContent = fd.Value || fd.Label; + heading.id = fd.Id; + + fieldWrapper.append(heading); + + return { field: heading, fieldWrapper }; +}; + +const createPlaintext = (fd) => { + const fieldWrapper = createFieldWrapper(fd); + + const text = document.createElement('p'); + text.textContent = fd.Value || fd.Label; + text.id = fd.Id; + + fieldWrapper.append(text); + + return { field: text, fieldWrapper }; +}; + +const createSelect = async (fd) => { + const select = document.createElement('select'); + setCommonAttributes(select, fd); + const addOption = ({ text, value }) => { + const option = document.createElement('option'); + option.text = text.trim(); + option.value = value.trim(); + if (option.value === select.value) { + option.setAttribute('selected', ''); + } + select.add(option); + return option; + }; + + if (fd.Placeholder) { + const ph = addOption({ text: fd.Placeholder, value: '' }); + ph.setAttribute('disabled', ''); + } + + if (fd.Options) { + let options = []; + if (fd.Options.startsWith('https://')) { + const optionsUrl = new URL(fd.Options); + const resp = await fetch(`${optionsUrl.pathname}${optionsUrl.search}`); + const json = await resp.json(); + json.data.forEach((opt) => { + options.push({ + text: opt.Option, + value: opt.Value || opt.Option, + }); + }); + } else { + options = fd.Options.split(',').map((opt) => ({ + text: opt.trim(), + value: opt.trim().toLowerCase(), + })); + } + + options.forEach((opt) => addOption(opt)); + } + + const fieldWrapper = createFieldWrapper(fd); + fieldWrapper.append(select); + fieldWrapper.append(createLabel(fd)); + + return { field: select, fieldWrapper }; +}; + +const createConfirmation = (fd, form) => { + form.dataset.confirmation = new URL(fd.Value).pathname; + + return {}; +}; + +const createSubmit = (fd) => { + const button = document.createElement('button'); + button.textContent = fd.Label || fd.Name; + button.classList.add('button'); + button.type = 'submit'; + + const fieldWrapper = createFieldWrapper(fd); + fieldWrapper.append(button); + return { field: button, fieldWrapper }; +}; + +const createTextArea = (fd) => { + const field = document.createElement('textarea'); + setCommonAttributes(field, fd); + + const fieldWrapper = createFieldWrapper(fd); + const label = createLabel(fd); + field.setAttribute('aria-labelledby', label.id); + fieldWrapper.append(field); + fieldWrapper.append(label); + + return { field, fieldWrapper }; +}; + +const createInput = (fd) => { + const field = document.createElement('input'); + field.type = fd.Type; + setCommonAttributes(field, fd); + + const fieldWrapper = createFieldWrapper(fd); + const label = createLabel(fd); + field.setAttribute('aria-labelledby', label.id); + fieldWrapper.append(field); + fieldWrapper.append(label); + + return { field, fieldWrapper }; +}; + +const createFieldset = (fd) => { + const field = document.createElement('fieldset'); + setCommonAttributes(field, fd); + + if (fd.Label) { + const legend = document.createElement('legend'); + legend.textContent = fd.Label; + field.append(legend); + } + + const fieldWrapper = createFieldWrapper(fd); + fieldWrapper.append(field); + + return { field, fieldWrapper }; +}; + +const createToggle = (fd) => { + const { field, fieldWrapper } = createInput(fd); + field.type = 'checkbox'; + if (!field.value) field.value = 'on'; + field.classList.add('toggle'); + fieldWrapper.classList.add('selection-wrapper'); + + const toggleSwitch = document.createElement('div'); + toggleSwitch.classList.add('switch'); + toggleSwitch.append(field); + fieldWrapper.append(toggleSwitch); + + const slider = document.createElement('span'); + slider.classList.add('slider'); + toggleSwitch.append(slider); + slider.addEventListener('click', () => { + field.checked = !field.checked; + }); + + return { field, fieldWrapper }; +}; + +const createCheckbox = (fd) => { + const { field, fieldWrapper } = createInput(fd); + if (!field.value) field.value = 'checked'; + fieldWrapper.classList.add('selection-wrapper'); + + return { field, fieldWrapper }; +}; + +const createRadio = (fd) => { + const { field, fieldWrapper } = createInput(fd); + if (!field.value) field.value = fd.Label || 'on'; + fieldWrapper.classList.add('selection-wrapper'); + + return { field, fieldWrapper }; +}; + +const FIELD_CREATOR_FUNCTIONS = { + select: createSelect, + heading: createHeading, + plaintext: createPlaintext, + 'text-area': createTextArea, + toggle: createToggle, + submit: createSubmit, + confirmation: createConfirmation, + fieldset: createFieldset, + checkbox: createCheckbox, + radio: createRadio, +}; + +export default async function createField(fd, form) { + fd.Id = fd.Id || generateFieldId(fd); + const type = fd.Type.toLowerCase(); + const createFieldFunc = FIELD_CREATOR_FUNCTIONS[type] || createInput; + const fieldElements = await createFieldFunc(fd, form); + + return fieldElements.fieldWrapper; +} diff --git a/blocks/form/form.css b/blocks/form/form.css new file mode 100644 index 00000000..b13495ea --- /dev/null +++ b/blocks/form/form.css @@ -0,0 +1,139 @@ +.form .field-wrapper { + display: grid; + grid-auto-flow: row; + align-items: center; + padding: 8px; +} + +.form fieldset { + display: grid; + grid-auto-flow: row; + gap: 8px; + border: none; + padding: 0; +} + +@media (min-width: 900px) { + .form fieldset { + grid-template-columns: repeat(3, 1fr); + gap: 16px; + } +} + +.form fieldset > legend { + font-weight: 700; + font-size: var(--heading-font-size-xs); +} + +.form label { + font-weight: 700; + font-size: var(--body-font-size-xs); + margin-bottom: 0.25em; +} + +.form input, +.form select, +.form textarea { + font-size: var(--body-font-size-s); + width: 100%; + max-width: 50rem; + display: block; + padding: 12px 8px; + border-radius: 4px; + box-sizing: border-box; + border: 1px solid var(--text-color); + color: var(--text-color); + background-color: var(--background-color); +} + +.form textarea { + resize: vertical; +} + +.form .selection-wrapper input { + width: 16px; +} + +.form .selection-wrapper label { + margin-bottom: 0; +} + +.form input:hover { + border: 1px solid var(--text-color); +} + +.form .button { + max-width: 225px; + font-size: var(--body-font-size-m); + padding: 0.2em 0.4em; +} + +.form .field-wrapper.selection-wrapper { + grid-auto-flow: column; + justify-content: start; + gap: 16px; +} + +.form .field-wrapper > label { + order: -1; +} + +.form .field-wrapper.selection-wrapper > label { + order: 1; +} + +.form input[required] + label::after { + content: "*"; + color: firebrick; + margin-inline-start: 1ch; +} + +.form .toggle-wrapper .switch { + position: relative; + display: inline-block; + width: 48px; + height: 24px; +} + +.form .toggle-wrapper input { + opacity: 0; + width: 0; + height: 0; +} + +.form .toggle-wrapper .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--dark-color); + transition: 0.4s; + border-radius: 30px; +} + +.form .toggle-wrapper .slider::before { + position: absolute; + content: ""; + height: 20px; + width: 20px; + left: 2px; + bottom: 2px; + background-color: var(--background-color); + transition: 0.4s; + border-radius: 50%; +} + +.form .toggle-wrapper input:checked + .slider { + background-color: var(--link-color); +} + +.form .toggle-wrapper input:focus + .slider { + outline: 2px solid var(--link-color); + outline-offset: 2px; +} + +.form .toggle-wrapper input:checked + .slider::before { + transform: translateX(24px); +} diff --git a/blocks/form/form.js b/blocks/form/form.js new file mode 100644 index 00000000..47a87ed0 --- /dev/null +++ b/blocks/form/form.js @@ -0,0 +1,108 @@ +import createField from './form-fields.js'; +import { sampleRUM } from '../../scripts/aem.js'; + +async function createForm(formHref) { + const { pathname } = new URL(formHref); + const resp = await fetch(pathname); + const json = await resp.json(); + + const form = document.createElement('form'); + // eslint-disable-next-line prefer-destructuring + form.dataset.action = pathname.split('.json')[0]; + + const fields = await Promise.all(json.data.map((fd) => createField(fd, form))); + fields.forEach((field) => { + if (field) { + form.append(field); + } + }); + + // group fields into fieldsets + const fieldsets = form.querySelectorAll('fieldset'); + fieldsets.forEach((fieldset) => { + form.querySelectorAll(`[data-fieldset="${fieldset.name}"`).forEach((field) => { + fieldset.append(field); + }); + }); + + return form; +} + +function generatePayload(form) { + const payload = {}; + + [...form.elements].forEach((field) => { + if (field.name && field.type !== 'submit' && !field.disabled) { + if (field.type === 'radio') { + if (field.checked) payload[field.name] = field.value; + } else if (field.type === 'checkbox') { + if (field.checked) payload[field.name] = payload[field.name] ? `${payload[field.name]},${field.value}` : field.value; + } else { + payload[field.name] = field.value; + } + } + }); + return payload; +} + +function handleSubmitError(form, error) { + // eslint-disable-next-line no-console + console.error(error); + form.querySelector('button[type="submit"]').disabled = false; + sampleRUM('form:error', { source: '.form', target: error.stack || error.message || 'unknown error' }); +} + +async function handleSubmit(form) { + if (form.getAttribute('data-submitting') === 'true') return; + + const submit = form.querySelector('button[type="submit"]'); + try { + form.setAttribute('data-submitting', 'true'); + submit.disabled = true; + + // create payload + const payload = generatePayload(form); + const response = await fetch(form.dataset.action, { + method: 'POST', + body: JSON.stringify({ data: payload }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (response.ok) { + sampleRUM('form:submit', { source: '.form', target: form.dataset.action }); + if (form.dataset.confirmation) { + window.location.href = form.dataset.confirmation; + } + } else { + const error = await response.text(); + throw new Error(error); + } + } catch (e) { + handleSubmitError(form, e); + } finally { + form.setAttribute('data-submitting', 'false'); + } +} + +export default async function decorate(block) { + const formLink = block.querySelector('a[href$=".json"]'); + if (!formLink) return; + + const form = await createForm(formLink.href); + block.replaceChildren(form); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + const valid = form.checkValidity(); + if (valid) { + handleSubmit(form); + } else { + const firstInvalidEl = form.querySelector(':invalid:not(fieldset)'); + if (firstInvalidEl) { + firstInvalidEl.focus(); + firstInvalidEl.scrollIntoView({ behavior: 'smooth' }); + } + } + }); +}