Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
19c3a13
feat(dom): resolve label position from labelPosition, then legacy lab…
kevinchappell Oct 1, 2026
9dbcfc2
feat(renderer): give labelled fields an f-field f-label-<position> wr…
kevinchappell Oct 1, 2026
3682d3c
fix(renderer): name checkbox and radio groups by their label with rol…
kevinchappell Oct 1, 2026
e9af548
feat(editor): config panel dropdowns from an options list on a config…
kevinchappell Oct 1, 2026
5f75b11
feat(editor): offer Label Position in the Config panel and convert la…
kevinchappell Oct 1, 2026
ae517ae
fix(editor): keep each field method's docblock and test an empty config
kevinchappell Oct 1, 2026
7a130f2
feat(editor): wrap a field's label and preview in f-field, in label-p…
kevinchappell Oct 1, 2026
eeded7e
feat(styles): lay out before/after labels beside their control and st…
kevinchappell Oct 1, 2026
8f5e7fe
feat(types): labelPosition in the formData schema and type definitions
kevinchappell Oct 1, 2026
7acea3a
fix(types): export the label position fixtures and regroup LabelPosition
kevinchappell Oct 1, 2026
0788ec6
docs: label position, the f-field wrapper and --formeo-label-width
kevinchappell Oct 1, 2026
3cc941e
test(e2e): label positions in the editor and the rendered form
kevinchappell Oct 1, 2026
51f05f6
test(styles): move the label position CSS test into the custom proper…
kevinchappell Oct 1, 2026
d24c2bf
fix(styles): keep condition-hidden before/after fields hidden
kevinchappell Oct 1, 2026
796cf68
fix(dom): keep label wrapper classes off the group element and button…
kevinchappell Oct 1, 2026
653606f
fix(editor): start an added Label Position at the field's current pos…
kevinchappell Oct 1, 2026
83672d4
docs: how Label position is added in the editor, and that a lone chec…
kevinchappell Oct 1, 2026
72084da
docs(types): a ConfigOptionDeclaration with options is a dropdown
kevinchappell Oct 1, 2026
d908c13
fix(editor): start only a field's added Label Position at its current…
kevinchappell Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ Formeo can be integrated with popular frontend frameworks:

## Theming

Every color formeo uses is a `--formeo-*` CSS custom property, declared on `:root` with zero specificity. Override them on `:root` or `<body>`: formeo appends its dialogs to `document.body`, so a rule scoped to the editor's container misses them. A narrower selector works too if it also covers `.formeo-dialog` and, when you move the controls panel with `controls.container`, `.formeo-controls`. For example, to map your own dark theme:
Every color formeo uses is a `--formeo-*` CSS custom property, and so is the width of a label that sits beside its control, all declared on `:root` with zero specificity. Override them on `:root` or `<body>`: formeo appends its dialogs to `document.body`, so a rule scoped to the editor's container misses them. A narrower selector works too if it also covers `.formeo-dialog` and, when you move the controls panel with `controls.container`, `.formeo-controls`. For example, to map your own dark theme:

```css
:root {
Expand All @@ -203,6 +203,7 @@ The full list, with defaults, is in [`_properties.scss`](https://github.com/Drag
- **Borders and focus:** `border`, `border-strong`, `focus`
- **Accents:** `primary`, `success`, `warning` and `danger` (each with a `-dark` variant), `danger-subtle`, `info`, `remove-bg`
- **Component outlines:** `{stage,row,column,field,option}-outline`, `-outline-text`, `-highlight` and `-highlight-text`, plus `column-outline-soft`
- **Layout:** `label-width` (the label's width when `labelPosition` is `before` or `after`, `10rem`)

**Visual changes from 5.1.3:** icons that hard-coded `#444` (header, paragraph and the triangles) now use `--formeo-icon`, which defaults to `#000`. The column resize-handle triangles now use the column outline color (`--formeo-column-outline-soft`, or the darker `--formeo-column-outline` on hover) instead of `#444`.

Expand Down
3 changes: 3 additions & 0 deletions docs/css-frameworks.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ group and a select, trimmed to the structural elements and their classes:
- **Column:** same as rows — editor-built columns carry `className: ['formeo-column']`; hand-written data may not.
Either way its width comes from an inline `style="width: ...%"` (from the column's `config.width`, `100%` if
unset).
- **Field:** a field with a visible label is wrapped in a div with `f-field` and its label position,
`f-label-top|bottom|before|after` (see [Label position](renderer/renderer.md#label-position)). A field without a
visible label has no wrapper.
- **Checkbox/radio options:** each option is wrapped in a div with a fixed `f-checkbox` or `f-radio` class.
- **Required mark:** the `*` next to a label is a `<span class="text-error">`.
- **Tooltip:** `<span class="f-tooltip" data-tooltip="...">`.
Expand Down
10 changes: 8 additions & 2 deletions docs/options/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,24 @@ A component's Configuration panel shows the `config` keys declared for it in `pa
"Add config" dialog offers the declared keys the component doesn't have yet, and its Add button hides when there are
none left. Keys that aren't declared, such as `controlId`, never show.

- `fields.all` declares `label`, `hideLabel`, `helpText`, `labelAfter`, `disableHtmlLabel` and `tooltip`.
- `fields.all` declares `label`, `hideLabel`, `helpText`, `labelPosition`, `disableHtmlLabel` and `tooltip`.
`labelPosition` is a dropdown (see [Label position](../../renderer/renderer.md#label-position)). Added from the
dialog, it starts at the position the label already has rather than its declared default, so the label doesn't
move. It replaces `labelAfter`, which the editor converts on load.
- A control can declare more for its own fields with `configOptions` (see [elements](../controls/README.md#configoptions)).
- The checkbox and radio group controls declare `other` and `otherLabel`, which add an
[Other choice](../../renderer/renderer.md#other-choice) to the group.
- Stages declare `title` when the editor's [`pages`](../../editor/pages.md) option is on and nothing otherwise, so a
stage without pages has no Configuration panel.

A declaration is `{ default, label }`:
A declaration is `{ default, label, options }`:

- `default` is the value a key added from the dialog starts with. It must be a boolean (edited with a checkbox), a
string or a number (edited with a text input). Other defaults are ignored with a console warning.
- `label` is optional. It defaults to the `config.<key>` translation, then to the key in title case.
- `options` is optional and makes the item a dropdown: `[{ value, label }]`, where each `value` is a string and
`default` is one of them. A missing `label` is the `<key>.<value>` translation, then the value in title case.
Options that don't meet these rules make the declaration ignored, with a console warning.

Declarations merge in this order, later ones winning: `all`, the control's own `configOptions`, the field type, then the
component id. A key listed in `disabled` at any of those levels is hidden from the panel and the dialog. It stays in
Expand Down
13 changes: 12 additions & 1 deletion docs/options/controls/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ An element with a `controlSet` key adds a group of fields at once; see
### configOptions

`configOptions` declares `config` keys the element's fields offer in their Configuration panel, on top of the keys every
field has. Each one is `{ default, label }`, as described in
field has. Each one is `{ default, label, options }`, as described in
[Configuration panel keys](../config/README.md#configuration-panel-keys). `configOptions` itself is never saved in
form data. A field only gets one of these keys when the element's `config` sets it or a user adds it from the dialog.
The editor's `config` option can still relabel or disable them.
Expand All @@ -116,6 +116,17 @@ The editor's `config` option can still relabel or disable them.
}
```

A lone checkbox or radio control can start its label after the box in the panel's dropdown:

```javascript
configOptions: {
labelPosition: {
default: 'after',
options: [{ value: 'top' }, { value: 'bottom' }, { value: 'before' }, { value: 'after' }],
},
},
```

## elementOrder

Set the element order within a control group. May be overridden if [sortable](#sortable) is set to true
Expand Down
55 changes: 55 additions & 0 deletions docs/renderer/renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,61 @@ rows: {
`config.width` is added after it, so `config.width` always wins.
- `id` and `tag` are ignored: Formeo needs the element's id and always renders a `div`.

### Label position

`config.labelPosition` sets where a field's label sits:

| Value | Layout | Order |
| -------- | ---------------------- | -------------- |
| `top` | label above | label, control |
| `bottom` | label below | control, label |
| `before` | label beside, leading | label, control |
| `after` | label beside, trailing | control, label |

`before` and `after` follow the text direction, so in a right-to-left form `before` is on the right. The label and
control are always in the DOM in the order you see them, so screen readers read them in that order. In the editor, add
Label position to a field from its Configuration panel's "Add config" dialog. It starts at the position the label
already has, and is then a dropdown in that panel. A field whose data already has `labelPosition` shows the dropdown
straight away.

Without `labelPosition`, a lone checkbox or radio is `after` and everything else is `top`. A checkbox or radio group's
`labelPosition` moves the group's label. Each option's label always follows its own input.

Every rendered field with a visible label is wrapped like this:

```html
<div class="f-field f-label-before">
<label for="f-name">Name</label>
<input id="f-name" name="name" type="text">
</div>
```

`before` and `after` sit side by side while the control has at least half the row. In a narrower column or viewport
they stack, with the label above (`before`) or below (`after`). (A lone checkbox or radio keeps its own size, so it
never stacks.) The label's width beside the control is `--formeo-label-width`, `10rem` by default:

```css
.my-form {
--formeo-label-width: 14rem;
}
```

A checkbox or radio group is named by its label: the group is `role="group"` with `aria-labelledby` pointing at the
label, whose id is `f-<id>-label`.

Fields without a visible label (`hideLabel`, hidden inputs, headers, paragraphs, dividers and buttons) have no wrapper,
and `labelPosition` doesn't apply to them.

**`labelAfter`:** older forms and control definitions may use `config.labelAfter`. The renderer still reads it: `true`
is `bottom` (`after` for a lone checkbox or radio) and `false` is `top` (`before`). `labelPosition` wins when both are
set. The editor converts `labelAfter` to `labelPosition` when it loads a field, so the form saves with
`labelPosition`.

**Replacing a `:has()` workaround:** if you used a rule like
`.formeo-render div:has(> label + input) { display: flex; }` to put labels beside inputs, set `labelPosition: 'before'`
on those fields (or target `.f-field.f-label-before`) and delete the rule. The built-in layout also stacks on narrow
screens.

### Accessing Components

The renderer caches all rendered components internally:
Expand Down
66 changes: 46 additions & 20 deletions src/lib/js/common/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -60,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 => {
Expand Down Expand Up @@ -228,13 +240,19 @@ class DOM {
wrap.children.push(_this.create(option, isPreview))
})
const groupAttrs = elem.attrs || {}
if (groupAttrs.className) {
wrap.className = groupAttrs.className
}
// the group (or button container) gets only its own class; config.inputWrap (f-field f-label-*) stays in
// wrap.config for the label wrapper, which is only built when the label renders
wrap.className = groupAttrs.className || []
wrap.id = elem.id
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) {
Expand Down Expand Up @@ -278,9 +296,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)
}
}
Expand Down Expand Up @@ -602,10 +617,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`)
}
Expand Down Expand Up @@ -653,6 +664,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) {
Expand Down Expand Up @@ -763,15 +779,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))
}

/**
Expand Down Expand Up @@ -822,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 <label for> can't
* name a <div>, 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
Expand All @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions src/lib/js/common/dom.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
75 changes: 75 additions & 0 deletions src/lib/js/common/label-position.mjs
Original file line number Diff line number Diff line change
@@ -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-<position>']
*/
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) }
}
Loading
Loading