, so the group gets role="group" and aria-labelledby this id instead (#243).
+ * @param {Object} elem group config
+ * @param {Boolean} isPreview editor preview
+ * @return {String|null}
+ */
+ groupLabelId(elem, isPreview) {
+ const { id, attrs = {}, config = {} } = elem
+ const isOptionGroup = attrs.type === 'checkbox' || attrs.type === 'radio'
+ return !isPreview && isOptionGroup && id && config.label && !config.hideLabel ? `${id}-label` : null
+ }
+
/**
* Generate a label
* @param {Object} elem config object
@@ -835,14 +861,14 @@ class DOM {
config: { label: labelText = '', helpText = '', tooltip = null },
} = elem
const { id: elemId, attrs } = elem
+ const { labelId } = elem.config
if (typeof labelText === 'function') {
labelText = labelText()
}
const fieldLabel = {
tag: 'label',
- attrs: {
- for: elemId || attrs?.id,
- },
+ // a group's label names it through aria-labelledby; a control's label points at it with for
+ attrs: labelId ? { id: labelId } : { for: elemId || attrs?.id },
className: [],
children: [
labelText,
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()
+ }
+ })
+})
diff --git a/src/lib/js/components/controls/control-config-options.test.js b/src/lib/js/components/controls/control-config-options.test.js
index 1fafb15f..e873d8c3 100644
--- a/src/lib/js/components/controls/control-config-options.test.js
+++ b/src/lib/js/components/controls/control-config-options.test.js
@@ -55,7 +55,7 @@ describe('control configOptions', () => {
assert.deepEqual(panelKeys(other), ['config.label'])
assert.deepEqual(
[...configOptionsOf(other.config).keys()],
- ['label', 'hideLabel', 'helpText', 'labelAfter', 'disableHtmlLabel', 'tooltip']
+ ['label', 'hideLabel', 'helpText', 'labelPosition', 'disableHtmlLabel', 'tooltip']
)
})
diff --git a/src/lib/js/components/edit-panel/config-options.mjs b/src/lib/js/components/edit-panel/config-options.mjs
index ce1a14cd..fbf2c97c 100644
--- a/src/lib/js/components/edit-panel/config-options.mjs
+++ b/src/lib/js/components/edit-panel/config-options.mjs
@@ -1,14 +1,45 @@
+import i18n from '@draggable/i18n'
+import { toTitleCase } from '../../common/utils/string.mjs'
import { labelHelper } from './helpers.mjs'
-// a Config panel item edits its value with a checkbox or a text input, so only these defaults can be declared
+// a Config panel item edits its value with a checkbox, a text input or (with options) a select
const EDITABLE_DEFAULT_TYPES = new Set(['boolean', 'string', 'number'])
const warnedKeys = new Set()
+const warnOnce = (key, problem) => {
+ if (!warnedKeys.has(key)) {
+ warnedKeys.add(key)
+ console.warn(`formeo: config option "${key}" ${problem}; it is ignored`)
+ }
+}
+
+/**
+ * A declaration's dropdown choices, labelled, or null when they can't be offered: they must be a non-empty list of
+ * string values that includes the default
+ * @param {String} key config key
+ * @param {Object} declaration
+ * @return {Array<{value: String, label: String}>|null}
+ */
+const declaredChoices = (key, { options, default: defaultValue }) => {
+ const usable =
+ Array.isArray(options) &&
+ options.length > 0 &&
+ options.every(option => typeof option?.value === 'string') &&
+ options.some(option => option.value === defaultValue)
+ if (!usable) {
+ return null
+ }
+ return options.map(({ value, label }) => ({
+ value,
+ label: label || i18n.get(`${key}.${value}`) || toTitleCase(value),
+ }))
+}
+
/**
* The config keys a component's Config panel offers: its resolved `panels.config.options` without the
- * `panels.config.disabled` ones. A declaration whose default the panel can't edit is dropped with a warning.
+ * `panels.config.disabled` ones. A declaration the panel can't edit is dropped with a warning.
* @param {Object} [componentConfig] a component's resolved config (Component#config)
- * @return {Map} the keys, in declaration order
+ * @return {Map} the keys, in declaration order
*/
export const configOptionsOf = componentConfig => {
const { options = {}, disabled = [] } = componentConfig?.panels?.config || {}
@@ -18,13 +49,19 @@ export const configOptionsOf = componentConfig => {
continue
}
if (!EDITABLE_DEFAULT_TYPES.has(typeof declaration?.default)) {
- if (!warnedKeys.has(key)) {
- warnedKeys.add(key)
- console.warn(`formeo: config option "${key}" needs a boolean, string or number default; it is ignored`)
- }
+ warnOnce(key, 'needs a boolean, string or number default')
continue
}
- declared.set(key, { label: declaration.label || labelHelper(`config.${key}`), default: declaration.default })
+ const entry = { label: declaration.label || labelHelper(`config.${key}`), default: declaration.default }
+ if (declaration.options !== undefined) {
+ const choices = declaredChoices(key, declaration)
+ if (!choices) {
+ warnOnce(key, 'needs options with string values that include its default')
+ continue
+ }
+ entry.options = choices
+ }
+ declared.set(key, entry)
}
return declared
}
diff --git a/src/lib/js/components/edit-panel/config-options.test.mjs b/src/lib/js/components/edit-panel/config-options.test.mjs
index 70d197a6..4dc9fb69 100644
--- a/src/lib/js/components/edit-panel/config-options.test.mjs
+++ b/src/lib/js/components/edit-panel/config-options.test.mjs
@@ -47,4 +47,41 @@ describe('configOptionsOf', () => {
warn.mock.restore()
}
})
+
+ it('keeps declared options, labelling each one', () => {
+ const declared = configOptionsOf(
+ withOptions({
+ density: {
+ default: 'cosy',
+ options: [{ value: 'cosy' }, { value: 'compact', label: 'Tight' }],
+ },
+ })
+ )
+ assert.deepEqual(declared.get('density'), {
+ label: labelHelper('config.density'),
+ default: 'cosy',
+ options: [
+ { value: 'cosy', label: 'Cosy' },
+ { value: 'compact', label: 'Tight' },
+ ],
+ })
+ })
+
+ it('drops, and warns once about, options it cannot offer', () => {
+ const warn = mock.method(console, 'warn', () => {})
+ try {
+ const config = withOptions({
+ emptyChoice: { default: 'a', options: [] },
+ numberChoice: { default: 'a', options: [{ value: 1 }] },
+ missingDefault: { default: 'z', options: [{ value: 'a' }] },
+ notAList: { default: 'a', options: 'a,b' },
+ })
+ assert.equal(configOptionsOf(config).size, 0)
+ configOptionsOf(config)
+ assert.equal(warn.mock.callCount(), 4, 'one warning per bad key, not per call')
+ assert.match(warn.mock.calls[0].arguments[0], /^formeo: config option "emptyChoice" needs options/)
+ } finally {
+ warn.mock.restore()
+ }
+ })
})
diff --git a/src/lib/js/components/edit-panel/edit-panel-item.mjs b/src/lib/js/components/edit-panel/edit-panel-item.mjs
index 4d3cb2c2..576bfe96 100644
--- a/src/lib/js/components/edit-panel/edit-panel-item.mjs
+++ b/src/lib/js/components/edit-panel/edit-panel-item.mjs
@@ -324,6 +324,15 @@ export default class EditPanelItem {
}
}
+ // a Config panel key declared with options is a dropdown (#243)
+ if (this.panelName === 'config') {
+ const choices = configOptionsOf(this.field.config).get(key.replace(/^config\./, ''))?.options
+ if (choices) {
+ effectiveValue = choices.map(choice => ({ ...choice, selected: choice.value === value }))
+ valType = 'array'
+ }
+ }
+
const dataKey = panelDataKeyMap.get(this.panelName)?.({ itemKey: this.itemKey, key }) || this.itemKey
const labelKey = dataKey.split('.').filter(Number.isNaN).join('.') || key
const baseConfig = ITEM_INPUT_TYPE_MAP[valType]({ key, value: effectiveValue })
diff --git a/src/lib/js/components/edit-panel/edit-panel.js b/src/lib/js/components/edit-panel/edit-panel.js
index 5f3391af..c36fe8d2 100644
--- a/src/lib/js/components/edit-panel/edit-panel.js
+++ b/src/lib/js/components/edit-panel/edit-panel.js
@@ -2,6 +2,7 @@ import i18n from '@draggable/i18n'
import Sortable from 'sortablejs'
import dom from '../../common/dom.js'
import { capitalize, safeAttrName } from '../../common/helpers.mjs'
+import { resolveLabelPosition } from '../../common/label-position.mjs'
import { slugify, slugifyAddress, toTitleCase } from '../../common/utils/string.mjs'
import { PANEL_CLASSNAME } from '../../constants.js'
import Dialog from '../dialog.js'
@@ -348,7 +349,9 @@ export default class EditPanel {
}
/**
- * Adds a declared config key, set to its declared default, when it can still be added
+ * Adds a declared config key, set to its declared default, when it can still be added. A field's labelPosition
+ * starts at the position its label already renders in (after, for a lone checkbox or radio), so adding it doesn't
+ * move it. Other components have no field label, so their labelPosition keeps its declared default.
* @param {String} configKey
*/
addConfigItem = configKey => {
@@ -356,9 +359,10 @@ export default class EditPanel {
if (!declaration) {
return
}
+ const isFieldLabelPosition = configKey === 'labelPosition' && this.component.name === 'field'
const newConfig = new EditPanelItem({
key: `config.${configKey}`,
- data: declaration.default,
+ data: isFieldLabelPosition ? resolveLabelPosition(this.component.data) : declaration.default,
field: this.component,
panel: this,
})
diff --git a/src/lib/js/components/edit-panel/edit-panel.test.js b/src/lib/js/components/edit-panel/edit-panel.test.js
index efc79e9c..457ffe9a 100644
--- a/src/lib/js/components/edit-panel/edit-panel.test.js
+++ b/src/lib/js/components/edit-panel/edit-panel.test.js
@@ -260,6 +260,43 @@ describe('Config panel declarations', () => {
assert.deepEqual(panelKeys(field), ['config.label', 'config.hint'])
})
+ const densityConfig = {
+ fields: {
+ all: {
+ panels: {
+ config: {
+ options: {
+ density: { default: 'cosy', options: [{ value: 'cosy' }, { value: 'compact' }] },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ it('renders a key declared with options as a select with its value selected', () => {
+ const field = textField(editorWith(densityConfig), { density: 'compact' })
+ const select = field.editPanels.get('config').props.querySelector('select.config-density')
+ assert.ok(select, 'a select, not a text input')
+ assert.deepEqual(
+ [...select.options].map(option => [option.value, option.textContent]),
+ [
+ ['cosy', 'Cosy'],
+ ['compact', 'Compact'],
+ ]
+ )
+ assert.equal(select.value, 'compact')
+ })
+
+ it('writes the chosen option to the field', () => {
+ const field = textField(editorWith(densityConfig), { density: 'cosy' })
+ mock.method(field, 'debouncedUpdatePreview', () => {})
+ const select = field.editPanels.get('config').props.querySelector('select.config-density')
+ select.value = 'compact'
+ select.dispatchEvent(new window.Event('change', { bubbles: true }))
+ assert.equal(field.get('config.density'), 'compact')
+ })
+
it('a stage has no Config panel without pages', () => {
const stage = editorWith().stages.get('s-1')
assert.equal(stage.editPanels.has('config'), false)
@@ -289,7 +326,7 @@ describe('Config panel "Add config"', () => {
it('offers the declared keys not set yet, with their labels', () => {
const panel = textField(editorWith()).editPanels.get('config')
const addable = panel.addableConfigOptions()
- assert.deepEqual([...addable.keys()], ['hideLabel', 'helpText', 'labelAfter', 'disableHtmlLabel', 'tooltip'])
+ assert.deepEqual([...addable.keys()], ['hideLabel', 'helpText', 'labelPosition', 'disableHtmlLabel', 'tooltip'])
assert.equal(addable.get('tooltip').label, labelHelper('config.tooltip'))
})
@@ -301,14 +338,37 @@ describe('Config panel "Add config"', () => {
it('adds a key with its declared default, once', () => {
const field = textField(editorWith())
const panel = field.editPanels.get('config')
- panel.addConfigItem('labelAfter')
- panel.addConfigItem('labelAfter')
- assert.equal(field.get('config.labelAfter'), false)
- const items = panel.editPanelItems.filter(({ itemKey }) => itemKey === 'config.labelAfter')
+ panel.addConfigItem('labelPosition')
+ panel.addConfigItem('labelPosition')
+ assert.equal(field.get('config.labelPosition'), 'top')
+ const items = panel.editPanelItems.filter(({ itemKey }) => itemKey === 'config.labelPosition')
assert.equal(items.length, 1)
assert.ok(panel.props.contains(items[0].dom))
})
+ it("adds Label Position at the field's current position, so the label doesn't move", () => {
+ const editorComponents = editorWith()
+ const checkbox = new Field(
+ {
+ id: 'f-lone',
+ tag: 'input',
+ attrs: { type: 'checkbox' },
+ config: { label: 'Agree', controlId: 'checkbox' },
+ },
+ editorComponents
+ )
+ const text = textField(editorComponents)
+ for (const [field, expected] of [
+ [checkbox, 'after'],
+ [text, 'top'],
+ ]) {
+ const panel = field.editPanels.get('config')
+ panel.addConfigItem('labelPosition')
+ assert.equal(field.get('config.labelPosition'), expected)
+ assert.equal(panel.props.querySelector('select.config-labelPosition').value, expected)
+ }
+ })
+
it('ignores a key that is not declared', () => {
const field = textField(editorWith())
field.editPanels.get('config').addConfigItem('bogus')
@@ -324,11 +384,20 @@ describe('Config panel "Add config"', () => {
assert.deepEqual(panelKeys(stage), ['config.note'])
})
+ it('adds a labelPosition key declared on a non-field component at its declared default', () => {
+ const options = [{ value: 'top' }, { value: 'side' }]
+ const stage = editorWith({
+ stages: { all: { panels: { config: { options: { labelPosition: { default: 'side', options } } } } } },
+ }).stages.get('s-1')
+ stage.editPanels.get('config').addConfigItem('labelPosition')
+ assert.equal(stage.get('config.labelPosition'), 'side')
+ })
+
it('hides the Add button while nothing is left to add, and shows it again after a remove', () => {
const field = textField(editorWith(), {
hideLabel: false,
helpText: '',
- labelAfter: false,
+ labelPosition: 'top',
disableHtmlLabel: false,
tooltip: '',
})
diff --git a/src/lib/js/components/fields/field-label-position.test.js b/src/lib/js/components/fields/field-label-position.test.js
new file mode 100644
index 00000000..04800924
--- /dev/null
+++ b/src/lib/js/components/fields/field-label-position.test.js
@@ -0,0 +1,135 @@
+import assert from 'node:assert/strict'
+import { describe, it, mock } from 'node:test'
+import { Actions } from '../../common/actions.js'
+import { Events } from '../../common/events.js'
+import { Components } from '../index.js'
+import Field from './field.js'
+
+// an editor's Components loaded with one empty stage
+const editor = () => {
+ const events = new Events().init({})
+ const editorComponents = new Components({ events, actions: new Actions(events).init({}) })
+ editorComponents.load({ id: 'form-lp', stages: { 's-1': { id: 's-1', config: {}, children: [] } } })
+ return editorComponents
+}
+
+const textField = (editorComponents, config = {}) =>
+ new Field(
+ {
+ id: 'f-lp',
+ tag: 'input',
+ attrs: { type: 'text' },
+ config: { label: 'Name', controlId: 'text-input', ...config },
+ },
+ editorComponents
+ )
+
+describe('Field label position data (#243)', () => {
+ it('converts legacy labelAfter to labelPosition on load', () => {
+ const field = textField(editor(), { labelAfter: true })
+ assert.equal(field.get('config.labelPosition'), 'bottom')
+ assert.equal('labelAfter' in field.get('config'), false)
+ })
+
+ it('maps labelAfter: false on a lone checkbox to before', () => {
+ const field = new Field(
+ { id: 'f-cb', tag: 'input', attrs: { type: 'checkbox' }, config: { label: 'Agree', labelAfter: false } },
+ editor()
+ )
+ assert.equal(field.get('config.labelPosition'), 'before')
+ })
+
+ it('keeps labelPosition when both are set, and drops labelAfter', () => {
+ const field = textField(editor(), { labelPosition: 'before', labelAfter: true })
+ assert.equal(field.get('config.labelPosition'), 'before')
+ assert.equal('labelAfter' in field.get('config'), false)
+ })
+
+ it('rewrites an unknown labelPosition to the resolved one', () => {
+ const warn = mock.method(console, 'warn', () => {})
+ try {
+ const field = textField(editor(), { labelPosition: 'left' })
+ assert.equal(field.get('config.labelPosition'), 'top')
+ } finally {
+ warn.mock.restore()
+ }
+ })
+
+ it('leaves the data it was given, such as a control definition, untouched', () => {
+ const definitionConfig = { label: 'Name', controlId: 'text-input', labelAfter: true }
+ new Field({ id: 'f-def', tag: 'input', attrs: { type: 'text' }, config: definitionConfig }, editor())
+ assert.deepEqual(definitionConfig, { label: 'Name', controlId: 'text-input', labelAfter: true })
+ })
+
+ it('fires no update events for the conversion', () => {
+ const editorComponents = editor()
+ const formeoUpdated = mock.method(editorComponents.events, 'formeoUpdated')
+ textField(editorComponents, { labelAfter: true })
+ const labelChanges = formeoUpdated.mock.calls.filter(({ arguments: [evt] }) =>
+ /label(Position|After)/.test(evt?.changePath || '')
+ )
+ assert.equal(labelChanges.length, 0)
+ })
+
+ it('constructs a field with an empty config, without writing labelPosition', () => {
+ const field = new Field({ id: 'f-hr', tag: 'hr', config: {} }, editor())
+ assert.equal(field.get('config.labelPosition'), undefined)
+ })
+
+ it('shows the converted value in a Label Position dropdown', () => {
+ const field = textField(editor(), { labelAfter: true })
+ const select = field.editPanels.get('config').props.querySelector('select.config-labelPosition')
+ assert.ok(select)
+ assert.deepEqual(
+ [...select.options].map(option => option.value),
+ ['top', 'bottom', 'before', 'after']
+ )
+ assert.equal(select.value, 'bottom')
+ })
+})
+
+describe('Field preview wrapper (#243)', () => {
+ const wrapOf = field => field.dom.querySelector(':scope > .f-field')
+ const partsOf = field => [...wrapOf(field).children].map(child => child.className)
+
+ it('wraps the label and preview in f-field f-label-, in position order', () => {
+ const top = textField(editor())
+ assert.equal(wrapOf(top).className, 'f-field f-label-top')
+ assert.deepEqual(partsOf(top), ['prev-label', 'field-preview'])
+
+ const after = textField(editor(), { labelPosition: 'after' })
+ assert.equal(wrapOf(after).className, 'f-field f-label-after')
+ assert.deepEqual(partsOf(after), ['field-preview', 'prev-label'])
+ })
+
+ it('renders legacy labelAfter after the preview on first render', () => {
+ const field = textField(editor(), { labelAfter: true })
+ assert.deepEqual(partsOf(field), ['field-preview', 'prev-label'])
+ })
+
+ it('puts the edit window after the wrapper', () => {
+ const field = textField(editor())
+ const children = [...field.dom.children]
+ assert.ok(children.indexOf(wrapOf(field)) < children.indexOf(field.dom.querySelector('.field-edit')))
+ })
+
+ it('moves the label and swaps the class when labelPosition changes', () => {
+ const field = textField(editor())
+ field.set('config.labelPosition', 'before')
+ field.updatePreview()
+ assert.equal(wrapOf(field).className, 'f-field f-label-before')
+ field.set('config.labelPosition', 'bottom')
+ field.updatePreview()
+ assert.deepEqual(partsOf(field), ['field-preview', 'prev-label'])
+ })
+
+ it('keeps the wrapper with only the preview while the label is hidden, and brings the label back', () => {
+ const field = textField(editor(), { labelPosition: 'after' })
+ field.set('config.hideLabel', true)
+ assert.doesNotThrow(() => field.updatePreview())
+ assert.deepEqual(partsOf(field), ['field-preview'])
+ field.set('config.hideLabel', false)
+ field.updatePreview()
+ assert.deepEqual(partsOf(field), ['field-preview', 'prev-label'])
+ })
+})
diff --git a/src/lib/js/components/fields/field.js b/src/lib/js/components/fields/field.js
index 8fe11e21..9ab250f7 100644
--- a/src/lib/js/components/fields/field.js
+++ b/src/lib/js/components/fields/field.js
@@ -1,6 +1,13 @@
import i18n from '@draggable/i18n'
import dom from '../../common/dom.js'
import { indexOfNode } from '../../common/helpers.mjs'
+import {
+ FIELD_WRAP_CLASSNAME,
+ isLabelAfter,
+ labelWrapClassNames,
+ normalizeLabelConfig,
+ resolveLabelPosition,
+} from '../../common/label-position.mjs'
import { clone, debounce } from '../../common/utils/index.mjs'
import { FIELD_CLASSNAME } from '../../constants.js'
import Component from '../component.js'
@@ -21,6 +28,7 @@ export default class Field extends Component {
constructor(fieldData = Object.create(null), components) {
super('field', fieldData, components)
+ this.normalizeLabelConfig()
this.controlId = this.get('config.controlId') || this.get('meta.id')
this.applyControlAttrConfig()
@@ -31,6 +39,9 @@ export default class Field extends Component {
this.preview = this.fieldPreview()
+ this.labelWrap = dom.create({ className: FIELD_WRAP_CLASSNAME })
+ this.syncLabelWrap()
+
const actionButtons = this.getActionButtons()
const hasEditButton = this.actionButtons.some(child => child.meta?.id === 'edit')
@@ -43,11 +54,10 @@ export default class Field extends Component {
},
id: this.id,
children: [
- this.label,
this.getComponentTag(),
actionButtons,
+ this.labelWrap, // label and preview, in label-position order (#243)
hasEditButton && this.editWindow, // fieldEdit window,
- this.preview,
].filter(Boolean),
panelNav: this.panelNav,
dataset: {
@@ -74,6 +84,18 @@ export default class Field extends Component {
}
}
+ /**
+ * Converts legacy config.labelAfter, and an unknown config.labelPosition, to the labelPosition it resolves to (#243).
+ * Replaces the config object instead of calling set(), so loading a form fires no update events and the data it was
+ * given (a saved form, a control definition) is never mutated.
+ */
+ normalizeLabelConfig() {
+ const config = normalizeLabelConfig(this.data)
+ if (config !== this.data.config) {
+ this.data.config = config
+ }
+ }
+
get labelConfig() {
const hideLabel = !!this.get('config.hideLabel')
@@ -140,35 +162,32 @@ export default class Field extends Component {
}
/**
- * Update the label dom when label data changes
+ * Puts the label and preview into the field wrapper in label-position order, with the matching classes (#243)
*/
- updateLabel() {
- const newLabel = dom.create(this.labelConfig)
-
- if (this.label || !newLabel) {
- this.label.remove()
- }
-
- if (newLabel) {
- if (this.data.config?.labelAfter) {
- this.dom.append(newLabel)
- } else {
- this.dom.prepend(newLabel)
- }
+ syncLabelWrap() {
+ const position = resolveLabelPosition(this.data)
+ const children = [this.label, this.preview].filter(Boolean)
+ if (isLabelAfter(position)) {
+ children.reverse()
}
+ this.labelWrap.className = labelWrapClassNames(position).join(' ')
+ this.labelWrap.replaceChildren(...children)
+ }
- this.label = newLabel
+ /**
+ * Rebuilds the label from the field's data
+ */
+ updateLabel() {
+ this.label = dom.create(this.labelConfig)
+ this.syncLabelWrap()
}
/**
* Updates a field's preview
- * @return {Object} fresh preview
*/
updatePreview = () => {
+ this.preview = this.fieldPreview()
this.updateLabel()
- const newPreview = this.fieldPreview()
- this.preview.replaceWith(newPreview)
- this.preview = newPreview
}
get defaultPreviewActions() {
diff --git a/src/lib/js/components/fields/index.js b/src/lib/js/components/fields/index.js
index aaa9d1eb..85c2e67b 100644
--- a/src/lib/js/components/fields/index.js
+++ b/src/lib/js/components/fields/index.js
@@ -1,3 +1,4 @@
+import { LABEL_POSITIONS } from '../../common/label-position.mjs'
import { parseData } from '../../common/utils/index.mjs'
import { get, set } from '../../common/utils/object.mjs'
import ComponentData from '../component-data.js'
@@ -22,7 +23,8 @@ const DEFAULT_CONFIG = () => ({
label: { default: 'New Field' },
hideLabel: { default: false },
helpText: { default: '' },
- labelAfter: { default: false },
+ // where the label sits; replaces labelAfter, which the editor converts on load (#243)
+ labelPosition: { default: 'top', options: LABEL_POSITIONS.map(value => ({ value })) },
disableHtmlLabel: { default: false },
tooltip: { default: '' },
},
diff --git a/src/lib/js/formdata-schema.test.mjs b/src/lib/js/formdata-schema.test.mjs
index a0d96d17..1b8d6b56 100644
--- a/src/lib/js/formdata-schema.test.mjs
+++ b/src/lib/js/formdata-schema.test.mjs
@@ -24,6 +24,26 @@ suite('formData schema', () => {
t.assert.ok(result.success, JSON.stringify(result.error?.issues))
})
+ test('accepts a field labelPosition and legacy labelAfter, and rejects other positions (#243)', t => {
+ const data = structuredClone(conditionalFields)
+ const [fieldId] = Object.keys(data.fields)
+ const withConfig = config => {
+ data.fields[fieldId].config = { label: 'Name', ...config }
+ return formDataSchema.safeParse(data).success
+ }
+ for (const labelPosition of ['top', 'bottom', 'before', 'after']) {
+ t.assert.ok(withConfig({ labelPosition }), labelPosition)
+ }
+ t.assert.strictEqual(withConfig({ labelPosition: 'left' }), false)
+ t.assert.ok(withConfig({ labelAfter: true }))
+ t.assert.strictEqual(withConfig({ labelAfter: 'yes' }), false)
+ })
+
+ test('the generated JSON schema lists the label positions (#243)', t => {
+ const config = buildFormDataJsonSchema().properties.fields.additionalProperties.properties.config
+ t.assert.deepStrictEqual(config.properties.labelPosition.enum, ['top', 'bottom', 'before', 'after'])
+ })
+
test('accepts the logical operator between if clauses and rejects unknown ones', t => {
t.assert.ok(formDataSchema.safeParse(withSecondClause('&&')).success)
t.assert.ok(formDataSchema.safeParse(withSecondClause('||')).success)
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..857ec693
--- /dev/null
+++ b/src/lib/js/renderer/label-position.test.js
@@ -0,0 +1,262 @@
+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'])
+ })
+
+ test('a group without a className keeps f-field off the group element', () => {
+ render({ colour: groupField('colour', 'checkbox', { labelPosition: 'before' }) })
+ const group = container.querySelector('#f-colour')
+ assert.equal(group.classList.contains('f-field'), false)
+ assert.equal(group.className.includes('f-label-'), false)
+ assert.deepEqual([...group.parentElement.classList], ['f-field', 'f-label-before'])
+ assert.equal(container.querySelector('#f-column-colour').querySelectorAll('.f-field').length, 1)
+ })
+
+ test('a group with a hidden label has no f-field at all', () => {
+ render({ colour: groupField('colour', 'radio', { hideLabel: true, labelPosition: 'before' }) })
+ assert.equal(container.querySelector('#f-column-colour').querySelectorAll('.f-field').length, 0)
+ })
+
+ const buttonField = (id, attrs) => ({
+ id,
+ tag: 'button',
+ attrs,
+ config: { label: 'Button', hideLabel: true },
+ options: [{ label: 'Go', type: 'button', className: '' }],
+ })
+
+ test('a button with an empty or missing attrs.className has no f-field', () => {
+ render({ empty: buttonField('empty', { className: '' }), none: buttonField('none', {}) })
+ for (const id of ['empty', 'none']) {
+ const column = container.querySelector(`#f-column-${id}`)
+ assert.ok(column.querySelector('button'), `${id} renders its button`)
+ assert.equal(column.querySelectorAll('.f-field').length, 0, `${id} has no f-field`)
+ }
+ })
+ })
+
+ describe('groups', () => {
+ test("a group's label names it: role=group and aria-labelledby, label with an id and no for", () => {
+ render({ colour: groupField('colour', 'radio') })
+ const group = container.querySelector('#f-colour')
+ const label = container.querySelector('#f-colour-label')
+ assert.equal(group.getAttribute('role'), 'group')
+ assert.equal(group.getAttribute('aria-labelledby'), 'f-colour-label')
+ assert.equal(label.tagName, 'LABEL')
+ assert.equal(label.hasAttribute('for'), false)
+ assert.equal(label.textContent, 'Colour')
+ })
+
+ test('a role or aria-labelledby set in attrs wins', () => {
+ render({ colour: groupField('colour', 'checkbox', {}, { role: 'radiogroup', 'aria-labelledby': 'mine' }) })
+ const group = container.querySelector('#f-colour')
+ assert.equal(group.getAttribute('role'), 'radiogroup')
+ assert.equal(group.getAttribute('aria-labelledby'), 'mine')
+ })
+
+ test('a group with a hidden label gets no role or label id', () => {
+ render({ colour: groupField('colour', 'checkbox', { hideLabel: true }) })
+ const group = container.querySelector('#f-colour')
+ assert.equal(group.hasAttribute('role'), false)
+ assert.equal(container.querySelector('#f-colour-label'), null)
+ })
+
+ test('labelPosition moves the group label; option labels stay after their inputs', () => {
+ render({ colour: groupField('colour', 'checkbox', { labelPosition: 'after' }) })
+ const wrapper = wrapperOf('colour')
+ assert.deepEqual(tagsIn(wrapper), ['div', 'label'])
+ assert.ok(wrapper.classList.contains('f-label-after'))
+ for (const option of container.querySelectorAll('#f-colour .f-checkbox')) {
+ assert.deepEqual(tagsIn(option), ['input', 'label'])
+ }
+ })
+
+ test('the Other choice keeps its own label and text box name', () => {
+ render({ colour: groupField('colour', 'radio', { other: true, labelPosition: 'before' }) })
+ const text = container.querySelector('#f-colour-other-value')
+ assert.equal(text.getAttribute('aria-labelledby'), 'f-colour-other-label')
+ assert.equal(container.querySelector('#f-colour-other-label').getAttribute('for'), 'f-colour-other')
+ })
+
+ test('an input group clone gets its own group label id', () => {
+ render({ colour: groupField('colour', 'radio') }, { inputGroup: true })
+ container.querySelector('.add-input-group').click()
+ const groups = [...container.querySelectorAll('[role="group"]')]
+ assert.equal(groups.length, 2)
+ const [original, clone] = groups.map(group => group.getAttribute('aria-labelledby'))
+ assert.notEqual(original, clone)
+ assert.ok(container.querySelector(`#${clone}`), "the clone's label id exists")
+ })
+ })
+})
diff --git a/src/lib/js/renderer/option-groups.test.js b/src/lib/js/renderer/option-groups.test.js
index 389fe2a1..4f570579 100644
--- a/src/lib/js/renderer/option-groups.test.js
+++ b/src/lib/js/renderer/option-groups.test.js
@@ -125,7 +125,7 @@ describe('checkbox and radio groups', () => {
inputsOf('radio-1').map(input => input.required),
[true, true]
)
- assert.ok(container.querySelector('label[for="f-radio-1"] .text-error'), 'group label has the * mark')
+ assert.ok(container.querySelector('#f-radio-1-label .text-error'), 'group label has the * mark')
})
test('a required radio group blocks submission until an option is chosen', () => {
diff --git a/src/lib/sass/__fixtures__/formeo-baseline.css b/src/lib/sass/__fixtures__/formeo-baseline.css
index 8b6950bc..72ec34d3 100644
--- a/src/lib/sass/__fixtures__/formeo-baseline.css
+++ b/src/lib/sass/__fixtures__/formeo-baseline.css
@@ -428,6 +428,35 @@ button[class*=-clone]:hover .svg-icon {
.formeo .f-field-group button {
margin-right: 4px;
}
+.formeo .f-field.f-label-before, .formeo .f-field.f-label-after {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 4px 8px;
+}
+.formeo .f-field.f-label-before > label,
+.formeo .f-field.f-label-before > .prev-label, .formeo .f-field.f-label-after > label,
+.formeo .f-field.f-label-after > .prev-label {
+ flex: 1 1 10rem;
+}
+.formeo .f-field.f-label-before > :not(label, .prev-label), .formeo .f-field.f-label-after > :not(label, .prev-label) {
+ flex: 999 1 0;
+ min-inline-size: 50%;
+}
+.formeo .f-field.f-label-before > input:is([type=checkbox], [type=radio]),
+.formeo .f-field.f-label-before > .field-preview:has(> input:is([type=checkbox], [type=radio])), .formeo .f-field.f-label-after > input:is([type=checkbox], [type=radio]),
+.formeo .f-field.f-label-after > .field-preview:has(> input:is([type=checkbox], [type=radio])) {
+ flex: 0 0 auto;
+ min-inline-size: auto;
+}
+.formeo .f-field.f-label-top > label,
+.formeo .f-field.f-label-top > .prev-label, .formeo .f-field.f-label-bottom > label,
+.formeo .f-field.f-label-bottom > .prev-label {
+ display: block;
+}
+.formeo .f-field[hidden] {
+ display: none;
+}
.formeo select {
font-size: 100%;
font-family: inherit;
@@ -812,8 +841,7 @@ button[class*=-clone]:hover .svg-icon {
display: none;
}
.formeo.formeo-editor .hovering > .children,
-.formeo.formeo-editor .hovering > .field-preview,
-.formeo.formeo-editor .hovering > .prev-label {
+.formeo.formeo-editor .hovering > .f-field {
opacity: 0.65;
}
.formeo.formeo-editor .hovering .group-actions .f-i-handle {
diff --git a/src/lib/sass/base/_bs.scss b/src/lib/sass/base/_bs.scss
index a4b61602..a5aa294b 100644
--- a/src/lib/sass/base/_bs.scss
+++ b/src/lib/sass/base/_bs.scss
@@ -36,6 +36,50 @@ hr {
}
}
+// label position (#243). .f-field wraps a rendered field's label and control, and the editor's label and preview.
+// DOM order is the visual order, so nothing here reorders.
+.f-field {
+ &.f-label-before,
+ &.f-label-after {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var.$half-space mixins.space(1);
+
+ // the label keeps --formeo-label-width and the control takes the rest, until the control would drop below half
+ // the row: then both wrap to full width, so a narrow column or viewport stacks them
+ > label,
+ > .prev-label {
+ flex: 1 1 var(--formeo-label-width);
+ }
+
+ > :not(label, .prev-label) {
+ flex: 999 1 0;
+ min-inline-size: 50%;
+ }
+
+ // a lone checkbox or radio keeps its own size next to its label
+ > input:is([type='checkbox'], [type='radio']),
+ > .field-preview:has(> input:is([type='checkbox'], [type='radio'])) {
+ flex: 0 0 auto;
+ min-inline-size: auto;
+ }
+ }
+
+ &.f-label-top,
+ &.f-label-bottom {
+ > label,
+ > .prev-label {
+ display: block;
+ }
+ }
+
+ // a condition hides a field by setting `hidden` on this wrapper, which the flex display above would override
+ &[hidden] {
+ display: none;
+ }
+}
+
select {
@include mixins.input-style;
diff --git a/src/lib/sass/base/_properties.scss b/src/lib/sass/base/_properties.scss
index dce7b69a..45665fc1 100644
--- a/src/lib/sass/base/_properties.scss
+++ b/src/lib/sass/base/_properties.scss
@@ -43,6 +43,9 @@ $_structural: (
--formeo-remove-bg: #{c.$remove-bg};
--formeo-column-outline-soft: #{sass-color.adjust(c.$column-outline-color, $lightness: 15%)};
+ // layout: the label's width when it sits beside its control (labelPosition before / after)
+ --formeo-label-width: 10rem;
+
@each $name, $values in $_structural {
--formeo-#{$name}-outline: #{list.nth($values, 1)};
--formeo-#{$name}-outline-text: #{list.nth($values, 2)};
diff --git a/src/lib/sass/components/_group-actions.scss b/src/lib/sass/components/_group-actions.scss
index 693c4333..19e2969e 100644
--- a/src/lib/sass/components/_group-actions.scss
+++ b/src/lib/sass/components/_group-actions.scss
@@ -83,8 +83,7 @@
.hovering {
> .children,
- > .field-preview,
- > .prev-label {
+ > .f-field {
opacity: 0.65;
}
diff --git a/src/lib/sass/css-properties.test.mjs b/src/lib/sass/css-properties.test.mjs
index 978343b1..aade94c1 100644
--- a/src/lib/sass/css-properties.test.mjs
+++ b/src/lib/sass/css-properties.test.mjs
@@ -34,6 +34,7 @@ const EXPECTED = [
'info',
'remove-bg',
'column-outline-soft',
+ 'label-width',
...['stage', 'row', 'column', 'field', 'option'].flatMap(s => [
`${s}-outline`,
`${s}-outline-text`,
@@ -139,6 +140,19 @@ suite('formeo CSS custom properties', () => {
t.assert.ok(!css.includes(fragment), `${fragment} must be stripped before baseline comparison`)
}
})
+
+ test('side-by-side label positions wrap instead of reordering, and use --formeo-label-width (#243)', t => {
+ const compiled = compileFormeoCss()
+ const sideBySide = compiledRule('.formeo .f-field.f-label-before,')
+ t.assert.match(sideBySide, /display: flex;/)
+ t.assert.match(sideBySide, /flex-wrap: wrap;/)
+ t.assert.match(compiled, /flex: 1 1 var\(--formeo-label-width\);/)
+ t.assert.doesNotMatch(compiled, /\.f-field[^{]*\{[^}]*(?:\border:|flex-direction: \w+-reverse)/)
+ })
+
+ test('a hidden label wrapper stays hidden in every position, so conditions can hide it (#243)', t => {
+ t.assert.strictEqual(compiledRule('.formeo .f-field[hidden] {'), '.formeo .f-field[hidden] {\n display: none;\n}')
+ })
})
suite('resolveFormeoProperties purity', () => {
diff --git a/src/types/formeo.d.ts b/src/types/formeo.d.ts
index 14e2381c..c1da085d 100644
--- a/src/types/formeo.d.ts
+++ b/src/types/formeo.d.ts
@@ -30,6 +30,9 @@ export type AssignmentOperator = '=' | 'equals'
export type ConditionSourceProperty = 'value' | 'isChecked' | 'isNotChecked' | 'isVisible' | 'isNotVisible' | 'checked'
export type ConditionTargetProperty = 'value' | 'isChecked' | 'isNotChecked' | 'isVisible' | 'isNotVisible'
+/** Where a field's label sits: `top`/`bottom` stack, `before`/`after` sit beside the control (mirrored in RTL). */
+export type LabelPosition = 'top' | 'bottom' | 'before' | 'after'
+
/** Empty strings are what an unfinished condition row in the editor saves. */
type Blank = ''
@@ -122,6 +125,12 @@ export interface FieldConfigData {
label?: string
editorLabel?: string
hideLabel?: boolean
+ /** Where the label sits relative to the control. Defaults to `after` for a lone checkbox or radio, `top` otherwise. */
+ labelPosition?: LabelPosition
+ /**
+ * @deprecated Use `labelPosition`. Still read by the renderer (`true` is `bottom`, or `after` for a lone checkbox or
+ * radio); the editor converts it to `labelPosition` on load.
+ */
labelAfter?: boolean
disableHtmlLabel?: boolean
editableContent?: boolean
@@ -351,8 +360,16 @@ export interface ComponentEvents {
export interface ConfigOptionDeclaration {
/** Shown in the panel and its "Add config" dialog. Defaults to the `config.` translation, then the key in title case. */
label?: string
- /** The value a key added from the dialog starts with. Its type picks the input: a checkbox for boolean, text otherwise. */
+ /**
+ * The value a key added from the dialog starts with (`labelPosition` starts at the field's current position instead).
+ * Its type picks the input: a checkbox for boolean, text otherwise. With `options`, the input is a dropdown.
+ */
default: boolean | string | number
+ /**
+ * Makes the item a dropdown of these choices. Each `value` is a string, and `default` must be one of them. A missing
+ * `label` is the `.` translation, then the value in title case.
+ */
+ options?: Array<{ value: string; label?: string }>
}
export interface ComponentConfig {
diff --git a/src/types/formeo.test-d.ts b/src/types/formeo.test-d.ts
index fa795fdb..d37d562d 100644
--- a/src/types/formeo.test-d.ts
+++ b/src/types/formeo.test-d.ts
@@ -17,6 +17,7 @@ import {
type FormeoOptions,
FormeoRenderer,
type FormeoRendererOptions,
+ type LabelPosition,
type LogicalOperator,
type RemoveItemsActionEvent,
type UserData,
@@ -431,17 +432,23 @@ export {
badPanelDefault,
badStage,
bare,
+ besideLabel,
clearAll,
componentEvents,
describeReorder,
+ everyPosition,
fromGlobal,
headless,
html,
jquery,
json,
+ leftLabel,
+ legacyLabel,
lockedDown,
newPage,
+ numericChoice,
otherChoiceForm,
+ positionDeclaration,
queried,
ratingControl,
stars,
@@ -452,3 +459,16 @@ export {
values,
withLayoutAttrs,
}
+
+// #243: label position
+const besideLabel: FieldData = { id: 'x', tag: 'input', config: { label: 'Name', labelPosition: 'before' } }
+const everyPosition: LabelPosition[] = ['top', 'bottom', 'before', 'after']
+// @ts-expect-error labelPosition is 'top', 'bottom', 'before' or 'after'; 'before' puts the label on the left
+const leftLabel: FieldData = { id: 'x', tag: 'input', config: { labelPosition: 'left' } }
+const legacyLabel: FieldData = { id: 'x', tag: 'input', config: { labelAfter: true } }
+const positionDeclaration: ConfigOptionDeclaration = {
+ default: 'top',
+ options: [{ value: 'top' }, { value: 'before', label: 'Left' }],
+}
+// @ts-expect-error an option's value is a string
+const numericChoice: ConfigOptionDeclaration = { default: 1, options: [{ value: 1 }] }
diff --git a/tests/add-configuration.spec.js b/tests/add-configuration.spec.js
index 93b92764..d01020cf 100644
--- a/tests/add-configuration.spec.js
+++ b/tests/add-configuration.spec.js
@@ -82,7 +82,7 @@ test.describe('Add Configuration to Field', () => {
expect(configItemsAfter).toBeGreaterThan(configItemsBefore)
})
- test('should add labelAfter configuration to field', async ({ page }) => {
+ test('should add labelPosition configuration to field', async ({ page }) => {
// Add an email field (different field type to verify it works across controls)
await page.getByRole('button', { name: 'Email' }).click()
await page.waitForTimeout(300)
@@ -105,8 +105,8 @@ test.describe('Add Configuration to Field', () => {
const dialog = page.locator('.formeo-dialog.config-item-dialog')
await expect(dialog).toBeVisible()
- // Select labelAfter option
- await dialog.locator('select.config-key-select').selectOption('labelAfter')
+ // Select labelPosition option
+ await dialog.locator('select.config-key-select').selectOption('labelPosition')
// Click submit
await dialog.locator('button[type="submit"]').click()
@@ -114,10 +114,10 @@ test.describe('Add Configuration to Field', () => {
// Wait for dialog to close
await expect(dialog).not.toBeVisible()
- // Verify labelAfter config item exists in the panel
+ // Verify the labelPosition config item exists in the panel, with its default
await page.waitForTimeout(200)
- const labelAfterItem = page.locator('.config-panel').getByText('Label After')
- await expect(labelAfterItem).toBeVisible()
+ const labelPositionItem = page.locator('.config-panel select.config-labelPosition')
+ await expect(labelPositionItem).toHaveValue('top')
})
test('should filter out already existing config options', async ({ page }) => {
@@ -153,8 +153,8 @@ test.describe('Add Configuration to Field', () => {
expect(optionValues).not.toContain('label')
expect(optionValues).not.toContain('hideLabel')
- // But labelAfter, tooltip, disableHtmlLabel should still be available
- expect(optionValues).toContain('labelAfter')
+ // But labelPosition, tooltip, disableHtmlLabel should still be available
+ expect(optionValues).toContain('labelPosition')
expect(optionValues).toContain('tooltip')
expect(optionValues).toContain('disableHtmlLabel')
@@ -233,11 +233,11 @@ test.describe('Add Configuration to Field', () => {
await expect(dialog).not.toBeVisible()
await page.waitForTimeout(200)
- // Add second config item (labelAfter)
+ // Add second config item (labelPosition)
await editPanel.locator('.add-config').click()
dialog = page.locator('.formeo-dialog.config-item-dialog')
await expect(dialog).toBeVisible()
- await dialog.locator('select.config-key-select').selectOption('labelAfter')
+ await dialog.locator('select.config-key-select').selectOption('labelPosition')
await dialog.locator('button[type="submit"]').click()
await expect(dialog).not.toBeVisible()
await page.waitForTimeout(200)
diff --git a/tests/label-position.spec.js b/tests/label-position.spec.js
new file mode 100644
index 00000000..0cb87ea2
--- /dev/null
+++ b/tests/label-position.spec.js
@@ -0,0 +1,257 @@
+// @ts-check
+import { expect, test } from '@playwright/test'
+
+const POSITIONS = ['top', 'bottom', 'before', 'after']
+
+/** One text field `field-lp` (and optionally more fields) in a single-column form */
+const formWith = (fields = {}) => {
+ const ids = Object.keys(fields)
+ return {
+ id: 'form-lp',
+ stages: { 'stage-lp': { id: 'stage-lp', children: ['row-lp'] } },
+ rows: { 'row-lp': { id: 'row-lp', config: {}, children: ['col-lp'] } },
+ columns: { 'col-lp': { id: 'col-lp', config: { width: '100%' }, children: ids } },
+ fields,
+ }
+}
+
+const textField = (config = {}) => ({
+ 'field-lp': {
+ id: 'field-lp',
+ tag: 'input',
+ attrs: { type: 'text' },
+ config: { label: 'Name', controlId: 'text-input', ...config },
+ },
+})
+
+/**
+ * Mounts an editor in #e2e-lp (above the demo editor). The editor is window.__editor.
+ * @param {import('@playwright/test').Page} page
+ * @param {Object} formData
+ */
+const mountEditor = async (page, formData) => {
+ await page.goto('/')
+ await expect(page.locator('.formeo-editor').first()).toBeVisible()
+ await page.evaluate(async formData => {
+ const container = document.createElement('div')
+ container.id = 'e2e-lp'
+ document.body.prepend(container)
+ window.__editor = new window.FormeoEditor(
+ { editorContainer: container, sessionStorage: false, style: null },
+ formData
+ )
+ await window.__editor.whenReady()
+ }, formData)
+ return page.locator('#e2e-lp')
+}
+
+/**
+ * Renders the editor's formData into #e2e-lp-render
+ * @param {import('@playwright/test').Page} page
+ * @param {{ width?: string, dir?: string }} [opts]
+ */
+const renderEditorForm = (page, { width = '', dir = '' } = {}) =>
+ page.evaluate(
+ ({ width, dir }) => {
+ const container = Object.assign(document.createElement('div'), { id: 'e2e-lp-render' })
+ container.style.width = width
+ if (dir) {
+ container.dir = dir
+ }
+ document.body.appendChild(container)
+ new window.FormeoRenderer({ renderContainer: container }).render(window.__editor.formData)
+ },
+ { width, dir }
+ )
+
+const formDataOf = page => page.evaluate(() => window.__editor.formData)
+
+/** Bounding boxes of a label and its control */
+const boxesOf = async (label, control) => {
+ const [l, c] = [await label.boundingBox(), await control.boundingBox()]
+ if (!l || !c) throw new Error('label or control is not visible')
+ return { l, c }
+}
+
+const overlapsVertically = ({ l, c }) => l.y < c.y + c.height && c.y < l.y + l.height
+
+/** Asserts the label sits where `position` puts it, for left-to-right text */
+const expectPlaced = (position, boxes) => {
+ const { l, c } = boxes
+ if (position === 'top') expect(l.y + l.height).toBeLessThanOrEqual(c.y + 1)
+ if (position === 'bottom') expect(c.y + c.height).toBeLessThanOrEqual(l.y + 1)
+ if (position === 'before') {
+ expect(overlapsVertically(boxes)).toBe(true)
+ expect(l.x + l.width).toBeLessThanOrEqual(c.x + 1)
+ }
+ if (position === 'after') {
+ expect(overlapsVertically(boxes)).toBe(true)
+ expect(c.x + c.width).toBeLessThanOrEqual(l.x + 1)
+ }
+}
+
+test.describe('label position (#243)', () => {
+ for (const position of POSITIONS) {
+ test(`${position}: the editor preview and the rendered form agree`, async ({ page }) => {
+ const editor = await mountEditor(page, formWith(textField({ labelPosition: position })))
+ const field = editor.locator('.formeo-field').first()
+ await expect(field.locator(':scope > .f-field')).toHaveClass(`f-field f-label-${position}`)
+ expectPlaced(position, await boxesOf(field.locator('.prev-label'), field.locator('.field-preview input')))
+
+ await renderEditorForm(page)
+ const wrapper = page.locator('#e2e-lp-render .f-field')
+ await expect(wrapper).toHaveClass(`f-field f-label-${position}`)
+ expectPlaced(position, await boxesOf(wrapper.locator('label'), wrapper.locator('input')))
+ })
+ }
+
+ test('the Configuration panel sets labelPosition from a dropdown', async ({ page }) => {
+ const editor = await mountEditor(page, formWith(textField()))
+ const field = editor.locator('.formeo-field').first()
+ await field.hover()
+ await field.locator('.field-actions').hover()
+ await field.locator('.field-actions .edit-toggle').click()
+ const editWindow = field.locator('.field-edit')
+ await expect(editWindow).toBeVisible()
+ await editWindow.getByRole('heading', { name: 'Configuration' }).click()
+ await editWindow.locator('.add-config').click()
+ const dialog = page.locator('.formeo-dialog.config-item-dialog')
+ await dialog.locator('select.config-key-select').selectOption('labelPosition')
+ await dialog.locator('button[type="submit"]').click()
+
+ await editWindow.locator('select.config-labelPosition').selectOption('before')
+ await expect.poll(async () => (await formDataOf(page)).fields['field-lp'].config.labelPosition).toBe('before')
+
+ await field.locator('.field-actions .edit-toggle').click()
+ await expect(field.locator(':scope > .f-field')).toHaveClass('f-field f-label-before')
+ })
+
+ test('a form saved with labelAfter loads as bottom and keeps its look', async ({ page }) => {
+ const editor = await mountEditor(page, formWith(textField({ labelAfter: true })))
+ const { config } = (await formDataOf(page)).fields['field-lp']
+ expect(config.labelPosition).toBe('bottom')
+ expect('labelAfter' in config).toBe(false)
+ const field = editor.locator('.formeo-field').first()
+ expectPlaced('bottom', await boxesOf(field.locator('.prev-label'), field.locator('.field-preview input')))
+ })
+
+ test('before stacks in a narrow container, with the input at full width', async ({ page }) => {
+ await mountEditor(page, formWith(textField({ labelPosition: 'before' })))
+ await renderEditorForm(page, { width: '240px' })
+ const wrapper = page.locator('#e2e-lp-render .f-field')
+ const { l, c } = await boxesOf(wrapper.locator('label'), wrapper.locator('input'))
+ const w = await wrapper.boundingBox()
+ expect(l.y + l.height).toBeLessThanOrEqual(c.y + 1)
+ expect(Math.abs(c.width - (w?.width ?? 0))).toBeLessThanOrEqual(1)
+ })
+
+ test('before is on the right in a right-to-left form', async ({ page }) => {
+ await mountEditor(page, formWith(textField({ labelPosition: 'before' })))
+ await renderEditorForm(page, { dir: 'rtl' })
+ const wrapper = page.locator('#e2e-lp-render .f-field')
+ const boxes = await boxesOf(wrapper.locator('label'), wrapper.locator('input'))
+ expect(overlapsVertically(boxes)).toBe(true)
+ expect(boxes.l.x).toBeGreaterThanOrEqual(boxes.c.x + boxes.c.width - 1)
+ })
+
+ test('a long before label wraps beside the input instead of pushing it down', async ({ page }) => {
+ const label = 'A label much longer than the default label width, so its text has to wrap'
+ await mountEditor(page, formWith(textField({ label, labelPosition: 'before' })))
+ await renderEditorForm(page, { width: '900px' })
+ const wrapper = page.locator('#e2e-lp-render .f-field')
+ expectPlaced('before', await boxesOf(wrapper.locator('label'), wrapper.locator('input')))
+ })
+
+ test('clicking a label focuses its input in every position', async ({ page }) => {
+ const fields = Object.fromEntries(
+ POSITIONS.map(position => [
+ `field-${position}`,
+ {
+ id: `field-${position}`,
+ tag: 'input',
+ attrs: { type: 'text' },
+ config: { label: `Label ${position}`, controlId: 'text-input', labelPosition: position },
+ },
+ ])
+ )
+ await mountEditor(page, formWith(fields))
+ await renderEditorForm(page)
+ for (const position of POSITIONS) {
+ await page.locator(`#e2e-lp-render label[for="f-field-${position}"]`).click()
+ await expect(page.locator(`#e2e-lp-render #f-field-${position}`)).toBeFocused()
+ }
+ })
+
+ test('a condition hides a before field and a lone checkbox', async ({ page }) => {
+ const hide = target => ({
+ if: [
+ { source: 'fields.trigger', sourceProperty: 'value', comparison: 'equals', target: 'hide', targetProperty: '' },
+ ],
+ then: [{ target: `fields.${target}`, targetProperty: 'isNotVisible', assignment: '', value: '' }],
+ })
+ const formData = formWith({
+ trigger: { id: 'trigger', tag: 'input', attrs: { type: 'text' }, config: { label: 'Trigger' } },
+ ...textField({ labelPosition: 'before' }),
+ agree: { id: 'agree', tag: 'input', attrs: { type: 'checkbox' }, config: { label: 'I agree' } },
+ })
+ formData.stages['stage-lp'].conditions = [hide('field-lp'), hide('agree')]
+ await page.goto('/')
+ await page.evaluate(formData => {
+ const container = Object.assign(document.createElement('div'), { id: 'e2e-lp-render' })
+ document.body.appendChild(container)
+ new window.FormeoRenderer({ renderContainer: container }).render(formData)
+ }, formData)
+ const form = page.locator('#e2e-lp-render')
+ const before = form.locator('.f-field:has(> #f-field-lp)')
+ const checkbox = form.locator('.f-field:has(> #f-agree)')
+ await expect(before).toHaveClass('f-field f-label-before')
+ await expect(checkbox).toHaveClass('f-field f-label-after')
+ await expect(before).toBeVisible()
+ await expect(checkbox).toBeVisible()
+
+ await form.locator('#f-trigger').fill('hide')
+ await expect(before).toBeHidden()
+ await expect(checkbox).toBeHidden()
+ })
+
+ test('a radio group with a before label is not itself a label-position flex wrapper', async ({ page }) => {
+ const size = {
+ id: 'size',
+ tag: 'input',
+ attrs: { type: 'radio' },
+ config: { label: 'Size', controlId: 'radio', labelPosition: 'before' },
+ options: ['S', 'M', 'L'].map(label => ({ label, value: label.toLowerCase() })),
+ }
+ await mountEditor(page, formWith({ size }))
+ await renderEditorForm(page)
+ const form = page.locator('#e2e-lp-render')
+ const group = form.locator('#f-size')
+ await expect(form.locator('.f-field')).toHaveCount(1)
+ await expect(group).not.toHaveClass(/f-field/)
+ expect(await group.evaluate(el => getComputedStyle(el).display)).toBe('block')
+ expectPlaced('before', await boxesOf(form.locator('#f-size-label'), group))
+ })
+
+ test('checkbox and radio groups are named by their label', async ({ page }) => {
+ const group = (id, type, label) => ({
+ id,
+ tag: 'input',
+ attrs: { type },
+ config: { label, controlId: type, labelPosition: 'before' },
+ options: [
+ { label: 'One', value: 'one' },
+ { label: 'Two', value: 'two' },
+ ],
+ })
+ await mountEditor(
+ page,
+ formWith({ colours: group('colours', 'checkbox', 'Colours'), size: group('size', 'radio', 'Size') })
+ )
+ await renderEditorForm(page)
+ const form = page.locator('#e2e-lp-render')
+ await expect(form.getByRole('group', { name: 'Colours' })).toBeVisible()
+ await expect(form.getByRole('group', { name: 'Size' })).toBeVisible()
+ await form.getByRole('group', { name: 'Size' }).getByLabel('Two').check()
+ await expect(form.locator('#f-size-1')).toBeChecked()
+ })
+})
diff --git a/tools/formdata-schema.mjs b/tools/formdata-schema.mjs
index a59b1334..b52c4db8 100644
--- a/tools/formdata-schema.mjs
+++ b/tools/formdata-schema.mjs
@@ -112,6 +112,14 @@ export const formDataSchema = z
.object({
label: z.string().optional(),
hideLabel: z.boolean().optional(),
+ labelPosition: z
+ .enum(['top', 'bottom', 'before', 'after'])
+ .optional()
+ .describe('Where the label sits: top/bottom stack, before/after sit beside the control'),
+ labelAfter: z
+ .boolean()
+ .optional()
+ .describe('Legacy: replaced by labelPosition, which wins when both are set'),
editableContent: z.boolean().optional(),
controlId: z.string().optional(),
disabledAttrs: z.array(z.string()).optional(),