From 19c3a1376eebad8a18b3683e459239da3b82b36c Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 1 Oct 2026 12:22:02 +0100 Subject: [PATCH 01/19] feat(dom): resolve label position from labelPosition, then legacy labelAfter Refs #243 --- src/lib/js/common/dom.js | 15 ++-- src/lib/js/common/dom.test.js | 16 ++++ src/lib/js/common/label-position.mjs | 75 ++++++++++++++++ src/lib/js/common/label-position.test.mjs | 103 ++++++++++++++++++++++ 4 files changed, 199 insertions(+), 10 deletions(-) create mode 100644 src/lib/js/common/label-position.mjs create mode 100644 src/lib/js/common/label-position.test.mjs diff --git a/src/lib/js/common/dom.js b/src/lib/js/common/dom.js index 94fe5553..973b1516 100644 --- a/src/lib/js/common/dom.js +++ b/src/lib/js/common/dom.js @@ -13,6 +13,7 @@ import { } from '../constants.js' import animate from './animation.js' import h, { forEach } from './helpers.mjs' +import { isLabelAfter, resolveLabelPosition } from './label-position.mjs' import { loaded } from './loaders.js' import { componentType, merge, uuid } from './utils/index.mjs' import { extractTextFromHtml, groupInputName, slugify, truncateByWord } from './utils/string.mjs' @@ -278,9 +279,6 @@ class DOM { if (_this.labelAfter(elem)) { wrapContent.reverse() } - // if has label config, must be a field. - // @todo change this logic so dom.create is project agnostic - // wrap.className.push('formeo-field') wrap.children.push(wrapContent) } } @@ -763,15 +761,12 @@ class DOM { } /** - * Test if label should be display before or after an element - * @param {Object} elem config - * @return {Boolean} labelAfter + * Whether a field's label comes after its control: bottom and after (#243). See label-position.mjs + * @param {Object} elem field config + * @return {Boolean} */ labelAfter(elem) { - const type = h.get(elem, 'attrs.type') - const labelAfter = h.get(elem, 'config.labelAfter') - const isCB = type === 'checkbox' || type === 'radio' - return labelAfter === undefined ? isCB : labelAfter + return isLabelAfter(resolveLabelPosition(elem)) } /** diff --git a/src/lib/js/common/dom.test.js b/src/lib/js/common/dom.test.js index e51658e9..77eba822 100644 --- a/src/lib/js/common/dom.test.js +++ b/src/lib/js/common/dom.test.js @@ -115,6 +115,22 @@ describe('DOM Class', async _t => { assert.equal(dom.labelAfter(explicitLabelAfter), true) }) + await test('create puts the label where config.labelPosition says (#243)', () => { + const order = labelPosition => { + const wrap = dom.create({ + tag: 'input', + id: 'lp', + attrs: { type: 'text' }, + config: { label: 'Name', labelPosition }, + }) + return [...wrap.children].map(child => child.tagName.toLowerCase()) + } + assert.deepEqual(order('top'), ['label', 'input']) + assert.deepEqual(order('before'), ['label', 'input']) + assert.deepEqual(order('bottom'), ['input', 'label']) + assert.deepEqual(order('after'), ['input', 'label']) + }) + await test('isDOMElement', () => { const elem = document.createElement('div') assert.equal(dom.isDOMElement(elem), true) diff --git a/src/lib/js/common/label-position.mjs b/src/lib/js/common/label-position.mjs new file mode 100644 index 00000000..4ce2514a --- /dev/null +++ b/src/lib/js/common/label-position.mjs @@ -0,0 +1,75 @@ +// Where a field's label sits relative to its control (#243). Pure: no DOM and no editor state. + +/** top and bottom stack; before and after sit side by side (mirrored in RTL). DOM order always matches visual order. */ +export const LABEL_POSITIONS = ['top', 'bottom', 'before', 'after'] + +/** The class on a rendered field's label wrapper, and on the editor's label and preview wrapper */ +export const FIELD_WRAP_CLASSNAME = 'f-field' + +const warnedValues = new Set() + +/** + * A checkbox or radio input on its own, not a group of options: its label defaults to after it + * @param {Object} field + * @return {Boolean} + */ +const isLoneCheckable = ({ attrs, options } = {}) => ['checkbox', 'radio'].includes(attrs?.type) && !options + +/** + * The position a field's label renders in: a valid config.labelPosition, then legacy config.labelAfter, then the + * default (after for a lone checkbox or radio, top for everything else) + * @param {Object} [field] field data: { attrs, config, options } + * @return {'top'|'bottom'|'before'|'after'} + */ +export const resolveLabelPosition = (field = {}) => { + const { labelPosition, labelAfter } = field.config || {} + if (LABEL_POSITIONS.includes(labelPosition)) { + return labelPosition + } + if (labelPosition !== undefined && !warnedValues.has(String(labelPosition))) { + warnedValues.add(String(labelPosition)) + console.warn(`formeo: unknown labelPosition "${labelPosition}"; use one of ${LABEL_POSITIONS.join(', ')}`) + } + const lone = isLoneCheckable(field) + if (typeof labelAfter === 'boolean') { + if (lone) { + return labelAfter ? 'after' : 'before' + } + return labelAfter ? 'bottom' : 'top' + } + return lone ? 'after' : 'top' +} + +/** + * Whether the label comes after the control in the DOM (and on screen) + * @param {String} position a label position + * @return {Boolean} + */ +export const isLabelAfter = position => position === 'bottom' || position === 'after' + +/** + * The label wrapper's classes for a position + * @param {String} position a label position + * @return {String[]} ['f-field', 'f-label-'] + */ +export const labelWrapClassNames = position => [FIELD_WRAP_CLASSNAME, `f-label-${position}`] + +/** + * A field's config with legacy labelAfter, or an unknown labelPosition, replaced by the labelPosition it resolves to. + * Returns the same object when there is nothing to convert. Never mutates the field. + * @param {Object} field field data + * @return {Object|undefined} config + */ +export const normalizeLabelConfig = (field = {}) => { + const { config } = field + if (!config) { + return config + } + const hasLegacy = 'labelAfter' in config + const hasUnknown = config.labelPosition !== undefined && !LABEL_POSITIONS.includes(config.labelPosition) + if (!hasLegacy && !hasUnknown) { + return config + } + const { labelAfter: _labelAfter, ...rest } = config + return { ...rest, labelPosition: resolveLabelPosition(field) } +} diff --git a/src/lib/js/common/label-position.test.mjs b/src/lib/js/common/label-position.test.mjs new file mode 100644 index 00000000..dd31f2ae --- /dev/null +++ b/src/lib/js/common/label-position.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict' +import { describe, it, mock } from 'node:test' +import { + FIELD_WRAP_CLASSNAME, + isLabelAfter, + LABEL_POSITIONS, + labelWrapClassNames, + normalizeLabelConfig, + resolveLabelPosition, +} from './label-position.mjs' + +const text = config => ({ tag: 'input', attrs: { type: 'text' }, config }) +const checkbox = config => ({ tag: 'input', attrs: { type: 'checkbox' }, config }) +const radio = config => ({ tag: 'input', attrs: { type: 'radio' }, config }) +const group = config => ({ tag: 'input', attrs: { type: 'checkbox' }, config, options: [{ label: 'A', value: 'a' }] }) + +describe('resolveLabelPosition (#243)', () => { + it('uses a valid config.labelPosition as is', () => { + for (const position of LABEL_POSITIONS) { + assert.equal(resolveLabelPosition(text({ labelPosition: position })), position) + assert.equal(resolveLabelPosition(checkbox({ labelPosition: position })), position) + } + }) + + it('defaults to after for a lone checkbox or radio and top for everything else', () => { + assert.equal(resolveLabelPosition(text({})), 'top') + assert.equal(resolveLabelPosition(checkbox({})), 'after') + assert.equal(resolveLabelPosition(radio({})), 'after') + assert.equal(resolveLabelPosition(group({})), 'top') + assert.equal(resolveLabelPosition({ tag: 'select', config: {} }), 'top') + assert.equal(resolveLabelPosition({ config: {} }), 'top') + assert.equal(resolveLabelPosition(), 'top') + }) + + it('maps legacy labelAfter: stacked for most controls, beside for a lone checkbox or radio', () => { + assert.equal(resolveLabelPosition(text({ labelAfter: true })), 'bottom') + assert.equal(resolveLabelPosition(text({ labelAfter: false })), 'top') + assert.equal(resolveLabelPosition(checkbox({ labelAfter: true })), 'after') + assert.equal(resolveLabelPosition(checkbox({ labelAfter: false })), 'before') + assert.equal(resolveLabelPosition(radio({ labelAfter: false })), 'before') + assert.equal(resolveLabelPosition(group({ labelAfter: true })), 'bottom') + assert.equal(resolveLabelPosition({ config: { labelAfter: true } }), 'bottom') + }) + + it('lets labelPosition win over labelAfter', () => { + assert.equal(resolveLabelPosition(text({ labelPosition: 'before', labelAfter: true })), 'before') + }) + + it('warns once about an unknown labelPosition and falls back', () => { + const warn = mock.method(console, 'warn', () => {}) + try { + assert.equal(resolveLabelPosition(text({ labelPosition: 'left' })), 'top') + assert.equal(resolveLabelPosition(checkbox({ labelPosition: 'left', labelAfter: false })), 'before') + assert.equal(warn.mock.callCount(), 1, 'one warning per unknown value') + assert.match(warn.mock.calls[0].arguments[0], /^formeo: unknown labelPosition "left"/) + } finally { + warn.mock.restore() + } + }) +}) + +describe('isLabelAfter and labelWrapClassNames', () => { + it('puts the label after the control for bottom and after only', () => { + assert.deepEqual(LABEL_POSITIONS.map(isLabelAfter), [false, true, false, true]) + }) + + it('names the wrapper f-field plus a position modifier', () => { + assert.equal(FIELD_WRAP_CLASSNAME, 'f-field') + assert.deepEqual(labelWrapClassNames('before'), ['f-field', 'f-label-before']) + }) +}) + +describe('normalizeLabelConfig', () => { + it('returns the same config when there is nothing to convert', () => { + const plain = { label: 'Name' } + const positioned = { label: 'Name', labelPosition: 'after' } + assert.equal(normalizeLabelConfig(text(plain)), plain) + assert.equal(normalizeLabelConfig(text(positioned)), positioned) + assert.equal(normalizeLabelConfig({ tag: 'hr' }), undefined) + }) + + it('replaces labelAfter with the position it resolves to, without mutating the input', () => { + const legacy = { label: 'Name', labelAfter: true } + assert.deepEqual(normalizeLabelConfig(text(legacy)), { label: 'Name', labelPosition: 'bottom' }) + assert.deepEqual(legacy, { label: 'Name', labelAfter: true }) + assert.deepEqual(normalizeLabelConfig(checkbox({ labelAfter: false })), { labelPosition: 'before' }) + }) + + it('drops labelAfter when labelPosition is already set', () => { + assert.deepEqual(normalizeLabelConfig(text({ labelPosition: 'after', labelAfter: false })), { + labelPosition: 'after', + }) + }) + + it('replaces an unknown labelPosition with the resolved one', () => { + const warn = mock.method(console, 'warn', () => {}) + try { + assert.deepEqual(normalizeLabelConfig(text({ labelPosition: 'sideways' })), { labelPosition: 'top' }) + } finally { + warn.mock.restore() + } + }) +}) From 9dbcfc2461f9e235827ee1422d64b56df685b7ed Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 1 Oct 2026 12:24:15 +0100 Subject: [PATCH 02/19] feat(renderer): give labelled fields an f-field f-label- wrapper Refs #243 --- src/lib/js/common/dom.js | 20 ++- src/lib/js/renderer/index.js | 21 ++- src/lib/js/renderer/label-position.test.js | 177 +++++++++++++++++++++ 3 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 src/lib/js/renderer/label-position.test.js diff --git a/src/lib/js/common/dom.js b/src/lib/js/common/dom.js index 973b1516..38419819 100644 --- a/src/lib/js/common/dom.js +++ b/src/lib/js/common/dom.js @@ -61,6 +61,17 @@ const GROUP_CONSUMED_ATTRS = new Set(['type', 'id', 'name', 'className', 'value' export const groupWrapperAttrs = (attrs = {}) => Object.fromEntries(Object.entries(attrs).filter(([key]) => !GROUP_CONSUMED_ATTRS.has(key))) +/** + * Class values (strings, arrays, nested arrays) as one space-separated string + * @param {...(String|Array)} values + * @return {String} + */ +const joinClassNames = (...values) => + values + .flat(Infinity) + .filter(value => typeof value === 'string' && value.trim()) + .join(' ') + const stripOn = str => str.replace(/^on([A-Z])/, (_, l) => l.toLowerCase()) const useCaptureEvts = new Set(['focus', 'blur']) const defaultActionHandler = event => { @@ -600,10 +611,6 @@ class DOM { className: [`f-${fieldType}`], } - if (attrs.className) { - elem.config = { ...elem.config, inputWrap: attrs.className } - } - if (elem.config?.inline) { inputWrap.className.push(`f-${fieldType}-inline`) } @@ -651,6 +658,11 @@ class DOM { return optionMarkup[fieldType]?.(option) } + // a checkbox or radio group's class also lands on its label wrapper, after the wrapper's own classes (f-field) + if (attrs.className && ['checkbox', 'radio'].includes(fieldType)) { + elem.config = { ...elem.config, inputWrap: joinClassNames(elem.config?.inputWrap, attrs.className) } + } + const mappedOptions = options.map(optionMap) if (withOther) { diff --git a/src/lib/js/renderer/index.js b/src/lib/js/renderer/index.js index c3d1dbea..38ac04d6 100644 --- a/src/lib/js/renderer/index.js +++ b/src/lib/js/renderer/index.js @@ -5,6 +5,7 @@ import dom, { OTHER_NAME_SUFFIX, REQUIRED_GROUP_ATTR, } from '../common/dom.js' +import { labelWrapClassNames, resolveLabelPosition } from '../common/label-position.mjs' import { fetchDependencies } from '../common/loaders.js' import { cleanFormData, isAddress, merge, uuid } from '../common/utils/index.mjs' import { splitAddress } from '../common/utils/string.mjs' @@ -48,6 +49,20 @@ const classNames = value => { return [resolved].flat(Infinity).filter(name => typeof name === 'string' && name.trim()) } +/** + * A field's config with its label wrapper's classes (#243): any `inputWrap`, then `f-field f-label-`. + * dom.create only builds that wrapper when the label renders, so fields without one are unaffected. + * @param {Object} field processed field data + * @return {Object|undefined} config + */ +const fieldWrapConfig = field => { + if (!field.config) { + return field.config + } + const inputWrap = [...classNames(field.config.inputWrap), ...labelWrapClassNames(resolveLabelPosition(field))] + return { ...field.config, inputWrap: inputWrap.join(' ') } +} + /** * A row's or column's own attributes, ready to render (#112). `id` and `tag` are Formeo's: the element is found by * `#f-` (conditions use it) and is always a div. Formeo's own class list (the component's top-level `className`), @@ -599,7 +614,11 @@ export default class FormeoRenderer { const mergedFieldData = merge({ action }, field) - return this.cacheComponent({ ...mergedFieldData, id: this.prefixId(id) }) + return this.cacheComponent({ + ...mergedFieldData, + config: fieldWrapConfig(mergedFieldData), + id: this.prefixId(id), + }) }) get processedData() { diff --git a/src/lib/js/renderer/label-position.test.js b/src/lib/js/renderer/label-position.test.js new file mode 100644 index 00000000..88c116bc --- /dev/null +++ b/src/lib/js/renderer/label-position.test.js @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, mock, test } from 'node:test' +import { JSDOM } from 'jsdom' +import FormeoRenderer from './index.js' + +const formWith = (fields, rowConfig = {}) => { + const ids = Object.keys(fields) + return { + id: 'label-position-form', + stages: { 'stage-1': { id: 'stage-1', children: ['row-1'] } }, + rows: { 'row-1': { id: 'row-1', config: rowConfig, children: ids.map(id => `column-${id}`) } }, + columns: Object.fromEntries( + ids.map(id => [`column-${id}`, { id: `column-${id}`, config: { width: '100%' }, children: [id] }]) + ), + fields, + } +} + +const textField = (id, config = {}, attrs = {}) => ({ + id, + tag: 'input', + attrs: { type: 'text', ...attrs }, + config: { label: 'Name', ...config }, +}) + +const groupField = (id, type, config = {}, attrs = {}) => ({ + id, + tag: 'input', + attrs: { type, ...attrs }, + config: { label: 'Colour', ...config }, + options: [ + { label: 'Red', value: 'red' }, + { label: 'Blue', value: 'blue' }, + ], +}) + +const GLOBALS = ['document', 'window', 'Element', 'HTMLElement', 'HTMLFormElement', 'Node', 'FormData'] + +describe('label position in the renderer (#243)', () => { + let window + let container + const nativeEvent = global.Event + + beforeEach(() => { + const jsdom = new JSDOM('
', { + url: 'http://localhost', + pretendToBeVisual: true, + }) + window = jsdom.window + global.document = window.document + global.window = window + global.Element = window.Element + global.HTMLElement = window.HTMLElement + global.HTMLFormElement = window.HTMLFormElement + global.Node = window.Node + global.FormData = window.FormData + global.Event = window.Event + container = window.document.getElementById('container') + }) + + afterEach(() => { + for (const key of GLOBALS) { + delete global[key] + } + global.Event = nativeEvent + }) + + const render = (fields, rowConfig) => { + const renderer = new FormeoRenderer({ renderContainer: container, formData: formWith(fields, rowConfig) }) + renderer.render() + return renderer + } + + /** The element that holds a field's label and control: the parent of its `#f-` control */ + const wrapperOf = id => container.querySelector(`#f-${id}`).parentElement + const tagsIn = elem => [...elem.children].map(child => child.tagName.toLowerCase()) + + describe('wrapper classes', () => { + for (const labelPosition of ['top', 'bottom', 'before', 'after']) { + test(`a ${labelPosition} label's wrapper is f-field f-label-${labelPosition}`, () => { + render({ name: textField('name', { labelPosition }) }) + assert.deepEqual([...wrapperOf('name').classList], ['f-field', `f-label-${labelPosition}`]) + }) + } + + test('label and control are in position order', () => { + render({ top: textField('top', { labelPosition: 'top' }), after: textField('after', { labelPosition: 'after' }) }) + assert.deepEqual(tagsIn(wrapperOf('top')), ['label', 'input']) + assert.deepEqual(tagsIn(wrapperOf('after')), ['input', 'label']) + }) + + test('without labelPosition a text field is top and a lone checkbox is after', () => { + render({ + name: textField('name'), + agree: { id: 'agree', tag: 'input', attrs: { type: 'checkbox' }, config: { label: 'I agree' } }, + }) + assert.ok(wrapperOf('name').classList.contains('f-label-top')) + assert.ok(wrapperOf('agree').classList.contains('f-label-after')) + }) + + test('legacy labelAfter on a text field renders as bottom', () => { + render({ name: textField('name', { labelAfter: true }) }) + assert.ok(wrapperOf('name').classList.contains('f-label-bottom')) + assert.deepEqual(tagsIn(wrapperOf('name')), ['input', 'label']) + }) + + test('a configured inputWrap class stays on the wrapper', () => { + render({ name: textField('name', { inputWrap: 'my-wrap', labelPosition: 'before' }) }) + assert.deepEqual([...wrapperOf('name').classList], ['my-wrap', 'f-field', 'f-label-before']) + }) + + test('a field whose label is hidden gets no wrapper, even with a labelPosition', () => { + render({ + quiet: textField('quiet', { hideLabel: true, labelPosition: 'before' }), + secret: { id: 'secret', tag: 'input', attrs: { type: 'hidden' }, config: { label: 'S', hideLabel: true } }, + heading: { id: 'heading', tag: 'h1', config: { label: 'H', hideLabel: true }, content: 'Heading' }, + }) + assert.equal(container.querySelectorAll('.f-field').length, 0) + assert.equal(wrapperOf('quiet').id, 'f-column-quiet', 'the bare input sits in its column') + }) + + test('an unknown labelPosition warns and renders as the default', () => { + const warn = mock.method(console, 'warn', () => {}) + try { + render({ name: textField('name', { labelPosition: 'upside-down' }) }) + assert.ok(wrapperOf('name').classList.contains('f-label-top')) + assert.ok(warn.mock.calls.some(({ arguments: [message] }) => message.includes('"upside-down"'))) + } finally { + warn.mock.restore() + } + }) + + test('an input group clone of a positioned field keeps its wrapper classes', () => { + render({ name: textField('name', { labelPosition: 'before' }) }, { inputGroup: true }) + container.querySelector('.add-input-group').click() + const wrappers = [...container.querySelectorAll('.f-field.f-label-before')] + assert.equal(wrappers.length, 2, 'original and clone') + }) + + test('a condition that hides a positioned field hides only its wrapper', () => { + const source = { + id: 'source', + tag: 'select', + attrs: {}, + config: { label: 'Source' }, + options: [ + { label: 'A', value: 'a' }, + { label: 'B', value: 'b' }, + ], + } + const target = textField('target', { labelPosition: 'before' }) + target.conditions = [ + { + if: [ + { source: 'fields.source', sourceProperty: 'value', comparison: 'equals', target: 'b', targetProperty: '' }, + ], + then: [{ target: 'fields.target', targetProperty: 'isNotVisible', assignment: '', value: '' }], + }, + ] + render({ source, target }) + const select = container.querySelector('#f-source') + select.value = 'b' + select.dispatchEvent(new window.Event('change', { bubbles: true })) + assert.equal(wrapperOf('target').hasAttribute('hidden'), true) + assert.equal(container.querySelector('#f-column-target').hasAttribute('hidden'), false) + }) + }) + + describe('group class names', () => { + test("a group's attrs.className lands on the group and on its wrapper, next to f-field", () => { + render({ colour: groupField('colour', 'radio', { labelPosition: 'before' }, { className: 'my-group' }) }) + const group = container.querySelector('#f-colour') + assert.ok(group.classList.contains('my-group')) + assert.deepEqual([...group.parentElement.classList], ['f-field', 'f-label-before', 'my-group']) + }) + }) +}) From 3682d3ce98f87b7880b938ae0c3d960ef514fe43 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 1 Oct 2026 12:27:37 +0100 Subject: [PATCH 03/19] fix(renderer): name checkbox and radio groups by their label with role=group Refs #243 --- src/lib/js/common/dom.js | 25 ++++++++-- src/lib/js/renderer/label-position.test.js | 54 ++++++++++++++++++++++ src/lib/js/renderer/option-groups.test.js | 2 +- 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/lib/js/common/dom.js b/src/lib/js/common/dom.js index 38419819..a5a0658f 100644 --- a/src/lib/js/common/dom.js +++ b/src/lib/js/common/dom.js @@ -247,6 +247,12 @@ class DOM { wrap.attrs = groupWrapperAttrs(groupAttrs) // config.required only drives the label's required mark; `required` itself lives on the option inputs wrap.config = { ...elem.config, required: Boolean(groupAttrs.required) } + const groupLabelId = this.groupLabelId(elem, isPreview) + if (groupLabelId) { + // the user's own role or aria-labelledby wins + wrap.attrs = { role: 'group', 'aria-labelledby': groupLabelId, ...wrap.attrs } + wrap.config.labelId = groupLabelId + } // which of the group's inputs are required or enabled depends on what is checked, so re-sync on change const groupSyncs = [] if (!isPreview && groupAttrs.type === 'checkbox' && groupAttrs.required) { @@ -829,6 +835,19 @@ class DOM { children: helpText, }) + /** + * The id of a rendered checkbox or radio group's label, or null when no group label renders. A