diff --git a/README.md b/README.md index 692d2a75..afddcd09 100644 --- a/README.md +++ b/README.md @@ -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 ``: 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 ``: 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 { @@ -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`. diff --git a/docs/css-frameworks.md b/docs/css-frameworks.md index a3da23b0..d2181256 100644 --- a/docs/css-frameworks.md +++ b/docs/css-frameworks.md @@ -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 ``. - **Tooltip:** ``. diff --git a/docs/options/config/README.md b/docs/options/config/README.md index de344fbf..6871cbe0 100644 --- a/docs/options/config/README.md +++ b/docs/options/config/README.md @@ -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.` 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 `.` 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 diff --git a/docs/options/controls/README.md b/docs/options/controls/README.md index b7a7d335..0ddc0513 100644 --- a/docs/options/controls/README.md +++ b/docs/options/controls/README.md @@ -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. @@ -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 diff --git a/docs/renderer/renderer.md b/docs/renderer/renderer.md index 1ea7e61e..13978e56 100644 --- a/docs/renderer/renderer.md +++ b/docs/renderer/renderer.md @@ -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 +
+ + +
+``` + +`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--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: diff --git a/src/lib/js/common/dom.js b/src/lib/js/common/dom.js index 94fe5553..b2550433 100644 --- a/src/lib/js/common/dom.js +++ b/src/lib/js/common/dom.js @@ -13,6 +13,7 @@ import { } from '../constants.js' import animate from './animation.js' import h, { forEach } from './helpers.mjs' +import { isLabelAfter, resolveLabelPosition } from './label-position.mjs' import { loaded } from './loaders.js' import { componentType, merge, uuid } from './utils/index.mjs' import { extractTextFromHtml, groupInputName, slugify, truncateByWord } from './utils/string.mjs' @@ -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 => { @@ -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) { @@ -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) } } @@ -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`) } @@ -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) { @@ -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)) } /** @@ -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