diff --git a/.github/media/inline-editing.png b/.github/media/inline-editing.png new file mode 100644 index 0000000..7cdded8 Binary files /dev/null and b/.github/media/inline-editing.png differ diff --git a/assets/css/builder.css b/assets/css/builder.css index 0875d03..4a4c141 100644 --- a/assets/css/builder.css +++ b/assets/css/builder.css @@ -2105,3 +2105,220 @@ os-checkbox-label.atfb-check { .atfb-row > .atfb-field { margin: 0; } + +/* --- The field, drawn as it will look -------------------------------------- + * + * The canvas renders each field with the real front-end classes inside + * `.atfb-preview`, and `builder.css` is registered with `form.css` as a + * dependency — so the theme's radius, borders, spacing and colours apply here + * because it is the same stylesheet, not a copy of it. + * + * What these rules add is only the *editing* layer: where the caret goes, what + * an empty value looks like, and how a hover says "you can change this". None of + * them may restate anything the theme decides, or the canvas stops being a + * preview and starts being an impression of one. */ + +.atfb-preview { + /* The theme paints the form's own card; on the canvas the builder's card is + already that surface, so the preview contributes only its contents. */ + background: none; + border: 0; + box-shadow: none; + padding: 0; + margin-block-start: 4px; +} + +/* Anything editable in place. Invisible until pointed at, because a canvas of + twelve outlined boxes reads as a form full of errors. */ +.atfb-editable { + display: inline-block; + min-inline-size: 2ch; + border-radius: 3px; + outline: 1px dashed transparent; + outline-offset: 2px; + cursor: text; + transition: outline-color 120ms ease, background-color 120ms ease; +} + +.atfb-editable:hover { + outline-color: var( --atfb-border-strong ); +} + +.atfb-editable:focus { + outline: 1px solid var( --atfb-accent ); + background: var( --atfb-accent-soft ); +} + +/* An empty value has to occupy space and say what belongs there, or the row + collapses to nothing and there is no target to click. */ +.atfb-editable:empty::before { + content: attr( data-placeholder ); + opacity: 0.55; + font-style: italic; +} + +/* The faux control. It is a `contenteditable` wearing `.atf-input`, so the + theme's border, radius and padding are the real ones — only the text inside + is ours. */ +.atfb-preview__box { + display: block; + inline-size: 100%; + min-block-size: 1lh; + cursor: text; +} + +.atfb-preview__box--tall { + min-block-size: 3lh; +} + +/* The dropdown's chevron, drawn on the wrapper so the box itself stays a plain + editable area. */ +.atfb-preview__select { + position: relative; +} + +.atfb-preview__select::after { + content: ""; + position: absolute; + inset-inline-end: 12px; + inset-block-start: 50%; + inline-size: 7px; + block-size: 7px; + border-inline-end: 2px solid currentColor; + border-block-end: 2px solid currentColor; + transform: translateY( -70% ) rotate( 45deg ); + opacity: 0.5; + pointer-events: none; +} + +.atfb-preview__ghost { + display: inline-block; + min-block-size: 1lh; + flex: 1 1 0; +} + +.atfb-preview__option { + align-items: center; + gap: 8px; +} + +/* Reveals on hover rather than sitting there: a column of × buttons beside every + option is a canvas that looks like it is asking to be dismantled. */ +.atfb-preview__remove { + margin-inline-start: auto; + padding: 0 6px; + border: 0; + border-radius: 4px; + background: none; + color: var( --atfb-muted ); + font-size: 15px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 120ms ease, color 120ms ease; +} + +.atfb-preview__option:hover .atfb-preview__remove, +.atfb-preview__remove:focus-visible { + opacity: 1; +} + +.atfb-preview__remove:hover, +.atfb-preview__remove:focus-visible { + color: var( --atfb-danger ); + background: var( --atfb-danger-soft ); +} + +.atfb-preview__add { + margin-block-start: 2px; + padding: 2px 6px; + border: 0; + border-radius: 4px; + background: none; + color: var( --atfb-accent ); + font: inherit; + font-size: 11px; + cursor: pointer; +} + +.atfb-preview__add:hover, +.atfb-preview__add:focus-visible { + background: var( --atfb-accent-soft ); +} + +/* What an elaborate control says instead of pretending to be itself. */ +.atfb-preview__summary { + margin: 0; + padding: 8px 10px; + border: 1px dashed var( --atfb-border ); + border-radius: 6px; + color: var( --atfb-muted ); + font-size: 11px; +} + +/* Required, as a switch rather than a badge. */ +.atfb-req { + padding: 1px 7px; + border: 1px solid var( --atfb-border ); + border-radius: 999px; + background: none; + color: var( --atfb-muted ); + font: inherit; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.atfb-req:hover { + border-color: var( --atfb-accent ); + color: var( --atfb-accent ); +} + +.atfb-req.is-on { + background: var( --atfb-accent-soft ); + border-color: transparent; + color: var( --atfb-accent ); + font-weight: 700; +} + +.atfb-req:focus-visible { + outline: 2px solid var( --atfb-accent ); + outline-offset: 1px; +} + +/* The card's own head is now a caption — the type and its switches — because + the field's name moved into the preview where it belongs. */ +.atfb-card__head { + gap: 8px; + opacity: 0.85; +} + +.atfb-card__head .atfb-card__type { + font-size: 11px; +} + +@media ( prefers-reduced-motion: reduce ) { + .atfb-editable, + .atfb-preview__remove, + .atfb-req { + transition: none; + } +} + +/* The card is now as tall as the field it draws, so its furniture aligns to the + top rather than floating at the middle of a five-option list. */ +.atfb-card { + align-items: flex-start; +} + +.atfb-card__grip, +.atfb-card__actions { + margin-block-start: 2px; +} + +.atfb-card__body { + min-inline-size: 0; + flex: 1 1 auto; +} diff --git a/assets/js/builder.js b/assets/js/builder.js index c7f57ea..f5b8486 100644 --- a/assets/js/builder.js +++ b/assets/js/builder.js @@ -416,14 +416,14 @@ var allTerrainFormsBuilder = function(exports) { attrs: { "aria-hidden": "true" } }); } - function row(label, control, hint) { - const id = control.id || `atf-c-${Math.random().toString(36).slice(2, 9)}`; - control.id = id; + function row(label, control2, hint) { + const id = control2.id || `atf-c-${Math.random().toString(36).slice(2, 9)}`; + control2.id = id; return el("div", { class: "atfb-row", children: [ el("label", { class: "atfb-row__label", text: label, attrs: { for: id } }), - control, + control2, hint ? el("p", { class: "atfb-row__hint", text: hint }) : null ] }); @@ -944,6 +944,272 @@ var allTerrainFormsBuilder = function(exports) { this.svg.append(text); } } + const SHAPES = { + text: "text", + email: "text", + url: "text", + tel: "text", + number: "text", + password: "text", + date: "text", + time: "text", + datetime: "text", + date_range: "composite", + textarea: "textarea", + select: "select", + country: "select", + multiselect: "options", + radio: "options", + checkboxes: "options", + image_choice: "options", + quiz: "options", + switch: "toggle", + consent: "toggle", + file: "file", + range: "range", + rating: "summary", + scale: "summary", + likert: "summary", + signature: "summary", + repeater: "summary", + color: "text", + name: "composite", + address: "composite", + total: "text", + hidden: "static", + heading: "static", + html: "static", + divider: "static", + spacer: "static", + page_break: "static" + }; + function shapeFor(type) { + return SHAPES[type] ?? "text"; + } + function editableText(value, placeholder, className, onInput, onCommit) { + const node = el("span", { + class: `${className} atfb-editable`, + text: value, + attrs: { + contenteditable: "plaintext-only", + role: "textbox", + spellcheck: "false", + "data-placeholder": placeholder + } + }); + node.addEventListener("pointerdown", (event) => event.stopPropagation()); + node.addEventListener("input", () => onInput(node.textContent ?? "")); + node.addEventListener("keydown", (event) => { + if ("Enter" === event.key) { + event.preventDefault(); + node.blur(); + } + if ("Escape" === event.key) { + node.textContent = value; + node.blur(); + } + event.stopPropagation(); + }); + node.addEventListener("blur", () => onCommit?.()); + return node; + } + function optionInputFor(type) { + const input = el("input", { + class: "atf-choice__input", + type: "checkboxes" === type || "multiselect" === type ? "checkbox" : "radio" + }); + input.disabled = true; + return input; + } + function renderFieldPreview(field, type, handlers) { + const shape = shapeFor(field.type); + const label = editableText( + field.label, + "Write the question…", + "atf-label", + (value) => handlers.edit((live) => { + live.label = value; + }), + // Committed on blur rather than per keystroke: the label appears in other + // cards' condition chips and in the merge-tag picker, and repainting the + // canvas on every character would take the caret with it. + () => handlers.restructure(() => { + }) + ); + const parts = [ + "static" === shape ? null : label, + field.hint ? el("p", { class: "atf-hint", text: field.hint }) : null, + control(field, type, shape, handlers) + ]; + return el("div", { + // `.atf-form` is what the theme's custom properties are scoped to, and + // `.atf-field` is what gives the control its spacing. Both are the real + // front-end classes: the look here is the stylesheet, not a copy of it. + class: "atfb-preview atf-form", + children: [ + el("div", { + class: `atf-field atf-field--${field.type}`, + children: parts + }) + ] + }); + } + function control(field, type, shape, handlers) { + switch (shape) { + case "text": + return placeholderBox(field, "atf-input", handlers); + case "textarea": + return placeholderBox(field, "atf-input atf-textarea", handlers, true); + case "select": + return el("div", { + class: "atfb-preview__select", + children: [placeholderBox(field, "atf-input atf-select", handlers)] + }); + case "options": + return optionList(field, handlers); + case "toggle": + return el("div", { + class: "atf-toggle", + children: [ + (() => { + const box = el("input", { class: "atf-toggle__input", type: "checkbox" }); + box.disabled = true; + return box; + })(), + editableText( + field.label || "", + "What are they agreeing to?…", + "atf-toggle__label", + (value) => handlers.edit((live) => { + live.label = value; + }), + () => handlers.restructure(() => { + }) + ) + ] + }); + case "file": + return el("div", { class: "atf-file", children: [el("input", { class: "atf-file__input", type: "file" })] }); + case "range": + return el("input", { class: "atf-range__input", type: "range" }); + case "composite": + return el("div", { + class: "atf-composite__parts", + children: [ + el("span", { class: "atf-input atfb-preview__ghost", text: "" }), + el("span", { class: "atf-input atfb-preview__ghost", text: "" }) + ] + }); + case "static": + return staticBlock(field, type, handlers); + default: + return el("p", { + class: "atfb-preview__summary", + text: `${type?.label ?? field.type} — set this up in the panel on the right.` + }); + } + } + function placeholderBox(field, className, handlers, tall = false) { + const box = editableText( + field.placeholder ?? "", + "select" === field.type || "country" === field.type ? "Choose…" : "Placeholder…", + `${className} atfb-preview__box${tall ? " atfb-preview__box--tall" : ""}`, + (value) => handlers.edit((live) => { + live.placeholder = value; + }) + ); + box.setAttribute("aria-label", "Placeholder"); + return box; + } + function optionList(field, handlers) { + const list = el("div", { class: "atf-choices__list" }); + (field.choices ?? []).forEach((choice, index) => { + list.append( + el("div", { + class: "atf-choice atfb-preview__option", + children: [ + optionInputFor(field.type), + editableText( + choice.label, + "Option…", + "atf-choice__label", + (value) => { + handlers.edit((live) => { + const target = live.choices?.[index]; + if (!target) { + return; + } + const mirroring = !target.value || target.value === target.label; + target.label = value; + if (mirroring) { + target.value = value; + } + }); + } + ), + el("button", { + class: "atfb-preview__remove", + type: "button", + title: "Remove this option", + attrs: { "aria-label": `Remove ${choice.label || "this option"}` }, + on: { + pointerdown: (event) => event.stopPropagation(), + click: (event) => { + event.stopPropagation(); + handlers.restructure((live) => { + live.choices.splice(index, 1); + }); + } + }, + children: [el("span", { text: "×" })] + }) + ] + }) + ); + }); + list.append( + el("button", { + class: "atfb-preview__add", + type: "button", + on: { + pointerdown: (event) => event.stopPropagation(), + click: (event) => { + event.stopPropagation(); + handlers.restructure((live) => { + const next = live.choices.length + 1; + live.choices.push({ label: `Option ${next}`, value: `Option ${next}` }); + }); + } + }, + children: [el("span", { text: "+ Add option" })] + }) + ); + return el("fieldset", { class: "atf-choices", children: [list] }); + } + function staticBlock(field, type, handlers) { + if ("heading" === field.type) { + return editableText( + field.label, + "Section heading…", + "atf-heading", + (value) => handlers.edit((live) => { + live.label = value; + }), + () => handlers.restructure(() => { + }) + ); + } + if ("divider" === field.type) { + return el("hr", { class: "atf-divider" }); + } + if ("page_break" === field.type) { + return el("p", { class: "atfb-preview__summary", text: "Everything after this is a new page." }); + } + return el("p", { + class: "atfb-preview__summary", + text: `${type?.label ?? field.type} — nothing is shown to the visitor here.` + }); + } const cache = /* @__PURE__ */ new Map(); function mergeTags(formId) { let pending2 = cache.get(formId); @@ -1423,7 +1689,7 @@ var allTerrainFormsBuilder = function(exports) { const tokens = currentTokens(); for (const dial of quickDials()) { const at = dial.read(tokens); - let control; + let control2; if (dial.kind === "colour") { const picker = el("input", { class: "atfs-color", @@ -1434,7 +1700,7 @@ var allTerrainFormsBuilder = function(exports) { input: (event) => applyDial(dial, event.target.value) } }); - control = el("div", { + control2 = el("div", { class: "atfs-color-row", children: [ picker, @@ -1442,7 +1708,7 @@ var allTerrainFormsBuilder = function(exports) { ] }); } else { - control = el("div", { + control2 = el("div", { class: `atfs-segmented atfs-segmented--${dial.kind}`, attrs: { role: "group", "aria-label": dial.label }, children: (dial.steps ?? []).map( @@ -1467,7 +1733,7 @@ var allTerrainFormsBuilder = function(exports) { el("span", { class: "atfs-dial__hint", text: dial.hint }) ] }), - control + control2 ] }) ); @@ -1517,7 +1783,7 @@ var allTerrainFormsBuilder = function(exports) { options.onOverride(token.token, next); repaint(); }; - let control; + let control2; if (token.control === "color") { const picker = el("input", { class: "atfs-color", @@ -1536,9 +1802,9 @@ var allTerrainFormsBuilder = function(exports) { picker.value = normaliseHex(next); change(next); }); - control = el("div", { class: "atfs-color-row", children: [picker, text] }); + control2 = el("div", { class: "atfs-color-row", children: [picker, text] }); } else if (token.control === "select") { - control = select( + control2 = select( value, (token.options ?? []).map((option) => ({ value: option, label: option })), change @@ -1564,11 +1830,11 @@ var allTerrainFormsBuilder = function(exports) { range.value = String(parseFloat(next) || 0); change(next); }); - control = el("div", { class: "atfs-length-row", children: [range, text] }); + control2 = el("div", { class: "atfs-length-row", children: [range, text] }); } else { - control = textInput(value, change); + control2 = textInput(value, change); } - const wrapper = row(token.label, control); + const wrapper = row(token.label, control2); const dial = dialOwning(token.token); if (dial) { wrapper.classList.add("is-dialled"); @@ -2027,6 +2293,10 @@ var allTerrainFormsBuilder = function(exports) { const FIELD_PAYLOAD_TYPE = "allterrain-forms/field"; const MEDIA_PAYLOAD_TYPES = ["openstation/file", "desktop-mode/file", "openstation/attachment"]; const LOGIC_MAP_SETTING = "allterrain-forms/logic-map-v2"; + function bind(control2, key) { + control2.dataset.atfbBind = key; + return control2; + } const PREFILL_SOURCES = [ { value: "user:email", label: "Their email address", group: "About the person filling it in", tag: "{user:email}" }, { value: "user:display_name", label: "Their name", group: "About the person filling it in", tag: "{user:display_name}" }, @@ -2051,6 +2321,9 @@ var allTerrainFormsBuilder = function(exports) { this.selected = null; this.tab = "build"; this.logicMap = null; + this.canvasTheme = el("style"); + this.canvasThemeSignature = ""; + this.openSections = /* @__PURE__ */ new Map(); this.logicMapOn = "off" !== readSetting(LOGIC_MAP_SETTING); this.dirty = false; this.teardowns = []; @@ -2099,7 +2372,7 @@ var allTerrainFormsBuilder = function(exports) { this.inspector.append( row( "Label", - textInput(field.label, (value) => update("label", value)) + bind(textInput(field.label, (value) => update("label", value)), "label") ) ); } @@ -2107,7 +2380,7 @@ var allTerrainFormsBuilder = function(exports) { this.inspector.append( row( "Placeholder", - textInput(field.placeholder, (value) => update("placeholder", value)) + bind(textInput(field.placeholder, (value) => update("placeholder", value)), "placeholder") ) ); } @@ -2324,6 +2597,106 @@ var allTerrainFormsBuilder = function(exports) { }) ); } + /** + * The field with this id in the *current* schema. + * + * Returns undefined when it has gone — deleted in another window, or dropped + * by the server's normalisation — which is a write that should simply not + * happen rather than one that should throw. + * + * @param fieldId The field's id. + * @return The live field, if it is still there. + */ + liveField(fieldId) { + return this.schema?.fields.find((candidate) => candidate.id === fieldId); + } + /** + * Writes a field's current values into the inspector's matching controls. + * + * Only when the inspector is actually showing that field — editing a card that + * is not selected must not rewrite the pane describing a different one. + * + * Deliberately one-directional and value-only: the inspector's own handlers + * already write to the schema, and firing them from here would put the two + * panes in a loop, each telling the other about a change it had just made. + * + * @param field The field that was edited on the canvas. + */ + /** + * Writes a field's current values into its card on the canvas. + * + * The mirror image of `syncInspector()`, for the same reason: the two panes + * edit one value and have to agree while it is being typed. The inspector's + * `update()` already repaints the canvas wholesale, but the choices editor + * mutates in place and only marks the form dirty — which was invisible until + * the canvas started drawing the options. + * + * Writes `textContent` rather than re-rendering, so the caret in the + * inspector is untouched. + * + * @param field The field that was edited in the inspector. + */ + syncCanvas(field) { + const card = this.canvas.querySelector( + `[data-atfb-card="${CSS.escape(field.id)}"]` + ); + if (!card) { + return; + } + const label = card.querySelector(".atf-label.atfb-editable"); + if (label && label.textContent !== field.label) { + label.textContent = field.label; + } + const options = card.querySelectorAll(".atf-choice__label.atfb-editable"); + (field.choices ?? []).forEach((choice, index) => { + const option = options[index]; + if (option && option.textContent !== choice.label) { + option.textContent = choice.label; + } + }); + } + syncInspector(field) { + if (this.selected !== field.id) { + return; + } + const write = (key, value) => { + const control2 = this.inspector.querySelector( + `[data-atfb-bind="${CSS.escape(key)}"]` + ); + if (!control2) { + return; + } + if ("value" in control2) { + control2.value = value; + } else { + control2.setAttribute("value", value); + } + }; + write("label", field.label ?? ""); + write("placeholder", field.placeholder ?? ""); + (field.choices ?? []).forEach((choice, index) => { + write(`choice:${index}:label`, choice.label ?? ""); + write(`choice:${index}:value`, choice.value ?? ""); + }); + } + /** + * Rebuilds the canvas so its cards point at the current schema objects. + * + * Deferred while the canvas holds focus. An autosave fires 2.5 seconds after + * the last keystroke, which is exactly when somebody has paused mid-sentence + * with the caret still in a label — and rebuilding then would take the caret + * away for no reason they could see. Waiting for the blur costs nothing: the + * card on screen already shows what they typed, and the rebind only has to + * happen before the *next* edit. + */ + rebindCanvas() { + const focused = document.activeElement; + if (focused instanceof HTMLElement && this.canvas.contains(focused)) { + focused.addEventListener("blur", () => this.rebindCanvas(), { once: true }); + return; + } + this.renderCanvas(); + } /** * Takes a history snapshot. * @@ -2490,6 +2863,7 @@ var allTerrainFormsBuilder = function(exports) { this.form = saved; this.schema = saved.schema; this.dirty = false; + this.rebindCanvas(); forgetMergeTags(saved.id); const summary = this.forms.find((candidate) => candidate.id === saved.id); if (summary) { @@ -2777,6 +3151,7 @@ var allTerrainFormsBuilder = function(exports) { this.canvas.append(inner); this.registerCanvasTarget(list); this.paintLogicMap(inner); + void this.paintCanvasTheme(); } /** * Where a field's opening value comes from, asked in plain language. @@ -2972,6 +3347,36 @@ var allTerrainFormsBuilder = function(exports) { }; return el("span", { class: classes[token.kind], text: token.text }); } + /** + * A disclosure panel that remembers whether it was open. + * + * `openByDefault` decides only what happens the *first* time a key is seen — + * a field that already has a condition opens showing it, because arriving at + * a field and being told nothing about a rule that governs it is worse than a + * little extra height. After that the person's own choice wins. + * + * What this deliberately does not do is derive `open` from the data inside + * it. Conditional logic used to: `open: logic.enabled`, so unticking "Only + * show this field sometimes" collapsed the panel around the checkbox that had + * just been clicked. Whether a panel is open is a question about the + * *person's attention*; whether a feature is on is a question about the + * *form*. Binding one to the other means neither can be set independently. + * + * @param key Stable identity for this panel. + * @param summary The panel's heading. + * @param children What it contains. + * @param openByDefault Whether to open it the first time it is rendered. + * @return The panel. + */ + section(key, summary, children, openByDefault = false) { + const details = el("details", { + class: "atfb-section", + attrs: { open: this.openSections.get(key) ?? openByDefault }, + children: [el("summary", { text: summary }), ...children] + }); + details.addEventListener("toggle", () => this.openSections.set(key, details.open)); + return details; + } /** * The toolbar's toggle for the logic overlay. * @@ -3055,8 +3460,8 @@ var allTerrainFormsBuilder = function(exports) { class: "atfb-card__head", children: [ icon(type?.icon ?? "dashicons-forms"), - el("strong", { text: field.label || i18n("untitledField", "Untitled field") }), - field.required ? el("span", { class: "atfb-badge", text: "required" }) : null, + el("span", { class: "atfb-card__type", text: type?.label ?? field.type }), + this.requiredToggle(field), controls ? el("span", { class: "atfb-badge atfb-badge--controls", text: 1 === controls ? "controls 1 field" : `controls ${controls} fields`, @@ -3064,7 +3469,34 @@ var allTerrainFormsBuilder = function(exports) { }) : null ] }), - el("span", { class: "atfb-card__type", text: type?.label ?? field.type }), + // The field itself, drawn with the real front-end classes and the + // form's own theme, with its text editable where it sits. + renderFieldPreview(field, type, { + // The live field is looked up by id on every write. A save + // replaces `this.schema` with the server's normalised copy, + // so the object this card was rendered from stops being the + // one that gets serialised — see `PreviewHandlers`. + edit: (apply) => { + const live = this.liveField(field.id); + if (!live) { + return; + } + apply(live); + this.markDirty(); + this.syncInspector(live); + }, + restructure: (apply) => { + const live = this.liveField(field.id); + if (!live) { + return; + } + this.snapshot(); + apply(live); + this.markDirty(); + this.renderCanvas(); + this.renderInspector(); + } + }), condition.length ? this.renderCondition(condition) : null ] }), @@ -3115,6 +3547,36 @@ var allTerrainFormsBuilder = function(exports) { }); return card; } + /** + * The required flag, as a toggle on the card rather than a badge. + * + * It was already displayed here as a read-only badge, and the switch that set + * it was in the inspector — so the canvas told you a field was required and + * made you go somewhere else to change it. Marking a question required is a + * decision you make while writing it, not afterwards. + */ + requiredToggle(field) { + const toggle = el("button", { + class: `atfb-req${field.required ? " is-on" : ""}`, + type: "button", + text: field.required ? "Required" : "Optional", + title: field.required ? "This must be answered. Click to make it optional." : "Click to make this required.", + attrs: { "aria-pressed": field.required ? "true" : "false" }, + on: { + // The card is draggable and clicking it selects the field; neither + // should happen when the target was this switch. + pointerdown: (event) => event.stopPropagation(), + click: (event) => { + event.stopPropagation(); + field.required = !field.required; + this.markDirty(); + this.renderCanvas(); + this.renderInspector(); + } + } + }); + return toggle; + } /** A small icon button on a field card. */ cardAction(iconSlug, label, onClick) { return el("button", { @@ -3289,7 +3751,12 @@ var allTerrainFormsBuilder = function(exports) { /** Selects a field and shows it in the inspector. */ selectField(fieldId) { this.selected = fieldId; - this.renderCanvas(); + for (const card of this.canvas.querySelectorAll("[data-atfb-card]")) { + const isSelected = card.dataset.atfbCard === fieldId; + card.classList.toggle("is-selected", isSelected); + card.setAttribute("aria-pressed", isSelected ? "true" : "false"); + } + this.logicMap?.highlight(fieldId); this.renderInspector(); } /** A field id not already in use. */ @@ -3313,20 +3780,25 @@ var allTerrainFormsBuilder = function(exports) { // pictures. Everywhere else it would be a column of // empty boxes for a setting that does nothing. field.type === "image_choice" ? this.choiceImageWell(choice, index, field) : null, - textInput(choice.label, (value) => { + bind(textInput(choice.label, (value) => { + const mirroring = !choice.value || choice.value === choice.label; choice.label = value; - if (!choice.value || choice.value === choices[index].value) { + if (mirroring) { choice.value = value; } this.markDirty(); - }), - textInput( - choice.value, - (value) => { - choice.value = value; - this.markDirty(); - }, - "value" + this.syncCanvas(field); + }), `choice:${index}:label`), + bind( + textInput( + choice.value, + (value) => { + choice.value = value; + this.markDirty(); + }, + "value" + ), + `choice:${index}:value` ), field.type === "quiz" || choice.points !== void 0 ? numberInput(String(choice.points ?? ""), (value) => { choice.points = value === "" ? void 0 : Number(value); @@ -3491,10 +3963,7 @@ var allTerrainFormsBuilder = function(exports) { "Leave empty for the default wording." ) ); - return el("details", { - class: "atfb-section", - children: [el("summary", { text: "Validation" }), ...rows] - }); + return this.section(`validation:${field.id}`, "Validation", rows); } /** The conditional-logic editor. */ renderLogicSection(field, update) { @@ -3548,11 +4017,10 @@ var allTerrainFormsBuilder = function(exports) { }) ); }); - return el("details", { - class: "atfb-section", - attrs: { open: logic.enabled }, - children: [ - el("summary", { text: "Conditional logic" }), + return this.section( + `logic:${field.id}`, + "Conditional logic", + [ checkbox("Only show this field sometimes", logic.enabled, (value) => { logic.enabled = value; update("logic", logic); @@ -3606,8 +4074,12 @@ var allTerrainFormsBuilder = function(exports) { ) ] }) : null - ] - }); + ], + // A field that already has a condition opens showing it: being told a + // rule governs this field and not what it says is the problem the + // whole logic display exists to solve. + logic.enabled + ); } /* ------------------------------------------------------------ Tab panes */ /** The canvas contents for the non-Build tabs. */ @@ -3647,6 +4119,50 @@ var allTerrainFormsBuilder = function(exports) { } return this.renderConfirmationsPane(); } + /** + * Puts the form's own theme tokens onto the canvas. + * + * The previews on the canvas use the real front-end classes, so they are + * already styled by `form.css` — but `form.css` reads everything from custom + * properties, and without them it falls back to the built-in defaults. The + * result would be a canvas that looks like Clean whatever theme the form is + * set to, which is the one thing a WYSIWYG canvas must not do. + * + * The values come from the server's own renderer rather than being resolved + * again here. A form's theme is a base theme plus per-form overrides plus + * whatever `atf_theme_tokens` filters did to it, and a second resolver in + * TypeScript would be a second answer to "what colour is this" — the same + * twin-engine problem the logic and calculation code goes to some length to + * avoid. One render is asked for, its `