diff --git a/assets/css/builder.css b/assets/css/builder.css index b0909f0..05da9ab 100644 --- a/assets/css/builder.css +++ b/assets/css/builder.css @@ -1904,22 +1904,29 @@ button.atfb-cond__join:focus-visible { button.atfb-cond__verb:focus-visible, button.atfb-cond__join:focus-visible, -select.atfb-cond__op:focus-visible, +.atfb-cond__control:focus-visible, .atfb-cond__value:focus-visible { outline: 2px solid var( --atfb-accent ); outline-offset: 1px; border-radius: 3px; } -.atfb-cond__op { - font-style: italic; - opacity: 0.9; +/* The dropdowns in the row — the comparison and, for a choice question, the + answer. Under the shell this is the shell's own , the same + control the inspector uses, so nothing on the card reads as a foreign + widget. The host shrink-wraps its trigger; the cap stops a long operator + from eating the row. */ +.atfb-cond__control { + flex: 0 1 auto; + max-inline-size: 22ch; + min-inline-size: 0; } -/* The operator as a borderless select: reads as the words it shows until - pointed at, then admits it is a control. The browser's own chrome is turned - off so the row keeps reading as a sentence. */ -select.atfb-cond__op { +/* The fallback for the plain admin page, where the shell's components do not + exist: a compact native select, sized to the sentence it sits in. + `min-block-size: 0` matters — wp-admin gives every select a 30px floor, + which is what left the old control towering over its own row. */ +select.atfb-cond__control--native { appearance: none; -webkit-appearance: none; border: 1px solid transparent; @@ -1933,10 +1940,11 @@ select.atfb-cond__op { line-height: 1.5; color: inherit; cursor: pointer; - max-inline-size: 16ch; + min-block-size: 0; + block-size: auto; } -select.atfb-cond__op:hover { +select.atfb-cond__control--native:hover { border-color: var( --atfb-border ); background: var( --atfb-surface ); } @@ -1944,12 +1952,6 @@ select.atfb-cond__op:hover { /* The answer as an editable chip. Same outline as the read-only chip it replaced, so nothing about the card's look changes until it is touched. */ input.atfb-cond__value { - min-inline-size: 3ch; - field-sizing: content; -} - -input.atfb-cond__value, -select.atfb-cond__value { appearance: none; -webkit-appearance: none; margin: 0; @@ -1963,21 +1965,39 @@ select.atfb-cond__value { line-height: 1.5; color: var( --atfb-fg ); max-inline-size: 24ch; + min-inline-size: 3ch; + field-sizing: content; cursor: text; min-block-size: 0; block-size: auto; } -select.atfb-cond__value { - cursor: pointer; -} - input.atfb-cond__value:hover, -input.atfb-cond__value:focus, -select.atfb-cond__value:hover { +input.atfb-cond__value:focus { border-color: var( --atfb-accent ); } +/* When the shell's selects are in the row, every neighbour rises to their + scale — the input matches the trigger's height and radius, the chips grow a + step — and `align-items: center` on the row keeps the sentence reading as + one centered line rather than as parts stacked on different baselines. */ +.atfb-cond:has( os-select ) { + gap: 6px; + row-gap: 4px; +} + +.atfb-cond:has( os-select ) input.atfb-cond__value { + padding: 7px 10px; + border-radius: 8px; + font-size: 12px; +} + +.atfb-cond:has( os-select ) .atfb-cond__chip { + padding: 3px 8px; + font-size: 12px; + border-radius: 6px; +} + .atfb-cond__join { font-size: 9px; font-weight: 700; @@ -2663,6 +2683,43 @@ os-checkbox-label.atfb-check { overflow-y: auto; } +/* The editor as a real shell child window. The window's own chrome carries the + title and the close button, and the body is ours to fill — so the modal's + self-imposed footprint goes, and the content takes the window at whatever + size the person drags it to. `.atfa` on the same element brings the token + palette a child window's body does not inherit from the builder's root. */ +.atfb-valwin--window { + /* Pinned to the body's box rather than sized 100%: the body is a flex + item with `overflow: hidden` and no explicit height, so a percentage + here can resolve against nothing — the content then grows past the + window and the body clips it with no scrollbar, which put the Save + button somewhere nobody could reach. Absolute against the body's + `position: relative` is exact at every window size. */ + position: absolute; + inset: 0; + inline-size: auto; + max-block-size: none; + block-size: auto; + padding: 14px 16px; + overflow-y: auto; + display: block; +} + +/* Save and Cancel ride the bottom of the scrollport rather than the bottom of + the content: in a window the person sizes themselves, "scroll to find Save" + is a chore the chrome can simply remove. Stretched across the host's padding + and given a top border + opaque background so content sliding under it reads + as under it. */ +.atfb-valwin--window .atfb-modal__actions { + position: sticky; + inset-block-end: -14px; + margin-inline: -16px; + margin-block-end: -14px; + padding: 10px 16px; + background: var( --atfb-bg ); + border-block-start: 1px solid var( --atfb-border ); +} + .atfb-valwin__tabs { display: flex; gap: 4px; diff --git a/assets/js/builder.js b/assets/js/builder.js index f03b7f4..e360378 100644 --- a/assets/js/builder.js +++ b/assets/js/builder.js @@ -2838,6 +2838,97 @@ var allTerrainFormsBuilder = function(exports) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } + const FORM_TYPE = "allterrain-forms/form"; + function relations() { + const os = window.wp?.os; + return os?.relations ?? null; + } + function windowIdOf(element) { + const host = element.closest("[data-window-id], .os-window"); + if (!host) { + return null; + } + const attribute = host.getAttribute("data-window-id"); + if (attribute) { + return attribute; + } + const id = host.id ?? ""; + return id ? id.replace(/^wp-window-/, "") : null; + } + const ATTACH_TIMEOUT_MS = 6e3; + const ATTACH_POLL_MS = 120; + function setIdentity(element, ref) { + const api2 = relations(); + wanted.set(element, ref); + if (!api2?.set) { + return; + } + const attempt = (deadline) => { + if (pending.get(element) !== token) { + return; + } + const windowId = windowIdOf(element); + if (!windowId) { + if (Date.now() < deadline) { + window.setTimeout(() => attempt(deadline), ATTACH_POLL_MS); + } + return; + } + try { + api2.set(windowId, ref); + } catch (error) { + if (!warned) { + warned = true; + console.error("[AllTerrain Forms] The shell refused a window identity.", error, ref); + } + pending.delete(element); + return; + } + const stuck = !ref || api2.get?.(windowId)?.id === ref.id; + if (stuck || Date.now() >= deadline) { + pending.delete(element); + return; + } + window.setTimeout(() => attempt(deadline), ATTACH_POLL_MS); + }; + const token = Symbol("atf-identity"); + pending.set(element, token); + attempt(Date.now() + ATTACH_TIMEOUT_MS); + } + let warned = false; + const pending = /* @__PURE__ */ new WeakMap(); + const wanted = /* @__PURE__ */ new Map(); + function reapply() { + for (const [element, ref] of wanted) { + if (!element.isConnected) { + wanted.delete(element); + continue; + } + setIdentity(element, ref); + } + } + if (typeof document !== "undefined") { + for (const event of ["os-window-content-loaded", "os-window-opened"]) { + document.addEventListener(event, () => reapply()); + } + } + function formIdentity(form, adminUrl) { + return { + type: FORM_TYPE, + id: form.id, + label: form.title || "Untitled form", + related: [ + { + id: `allterrain-forms/entries-${form.id}`, + label: "Entries for this form", + url: `${adminUrl}admin.php?page=allterrain-forms-entries&form=${form.id}`, + group: "allterrain-forms", + groupLabel: "Forms", + icon: "dashicons-list-view" + } + ] + }; + } const CHAR_GROUPS = { letters: { label: "Letters", chars: "A-Za-zÀ-ÖØ-öø-ÿ" }, numbers: { label: "Numbers", chars: "0-9" }, @@ -2967,21 +3058,69 @@ var allTerrainFormsBuilder = function(exports) { return null; } } + function shellWindows() { + return window.wp?.os?.windowManager ?? null; + } + const EDITOR_WINDOW_BASE = "allterrain-forms-validation"; function openValidationEditor(options) { - const recipe = parseRecipe(String(options.field.validationRecipe ?? "")); - if ("blocks" === recipe.mode && "" === compileRecipe(recipe) && options.field.pattern) { - recipe.mode = "regex"; - recipe.regex = String(options.field.pattern); + const manager = shellWindows(); + const parentId = windowIdOf(options.root); + if (manager?.openChild && parentId) { + openAsChildWindow(manager, parentId, options); + return; } - if (!recipe.message) { - recipe.message = String(options.field.messages?.invalid ?? ""); + openAsOverlay(options); + } + function openAsChildWindow(manager, parentId, options) { + const id = `${EDITOR_WINDOW_BASE}-${options.field.id}`; + if (manager.getById?.(id)) { + manager.remove?.(id); } + const state = { saved: false }; + const close = () => { + const win = manager.getById?.(id); + if (win?.close) { + win.close(); + } else { + manager.remove?.(id); + } + }; + void manager.openChild?.(parentId, { + id, + baseId: EDITOR_WINDOW_BASE, + url: `#${id}`, + title: `Custom rule — ${options.field.label || "this question"}`, + icon: "dashicons-yes-alt", + native: true, + width: 560, + height: 700, + minWidth: 440, + minHeight: 480, + // A rule mid-edit is not worth resurrecting against a field that may + // be gone; children are excluded from snapshots anyway, this says so. + ephemeral: true, + autofocus: ".atfb-valwin__pane:not([hidden]) input, .atfb-valwin__pane:not([hidden]) textarea", + render: (body) => { + const host = el("div", { class: "atfa atfb-valwin atfb-valwin--window" }); + buildEditor(host, options, state, close, "window"); + body.append(host); + }, + // Fires however the window closes — Save, Cancel, the title-bar X, + // or its owner closing. One place decides whether that was a cancel. + onClose: () => { + if (!state.saved) { + options.onCancel?.(); + } + } + }); + } + function openAsOverlay(options) { const overlay = el("div", { class: "atfb-overlay" }); - let saved = false; + const state = { saved: false }; const close = () => { overlay.remove(); document.removeEventListener("keydown", onKeydown); - if (!saved) { + if (!state.saved) { options.onCancel?.(); } }; @@ -2990,6 +3129,33 @@ var allTerrainFormsBuilder = function(exports) { close(); } }; + const modal = el("div", { + class: "atfb-modal atfb-valwin", + attrs: { role: "dialog", "aria-label": "Custom validation rule" }, + children: [el("h2", { text: "Custom rule" })] + }); + buildEditor(modal, options, state, close, "overlay"); + overlay.append(modal); + overlay.addEventListener("click", (event) => { + if (event.target === overlay) { + close(); + } + }); + document.addEventListener("keydown", onKeydown); + options.root.append(overlay); + overlay.querySelector( + ".atfb-valwin__pane:not([hidden]) input, .atfb-valwin__pane:not([hidden]) textarea" + )?.focus(); + } + function buildEditor(host, options, state, requestClose, chrome) { + const recipe = parseRecipe(String(options.field.validationRecipe ?? "")); + if ("blocks" === recipe.mode && "" === compileRecipe(recipe) && options.field.pattern) { + recipe.mode = "regex"; + recipe.regex = String(options.field.pattern); + } + if (!recipe.message) { + recipe.message = String(options.field.messages?.invalid ?? ""); + } const blockInput = (key, placeholder) => { const input = el("input", { class: "atfb-input", @@ -3141,70 +3307,55 @@ var allTerrainFormsBuilder = function(exports) { verdict.classList.toggle("is-fail", false === result); } }; - overlay.append( + host.append( + el("p", { + class: "atfb-hint", + text: `Describe what a good answer to “${options.field.label || "this question"}” looks like — no code needed.` + }), + tabs, + blocksPane, + regexPane, el("div", { - class: "atfb-modal atfb-valwin", - attrs: { role: "dialog", "aria-label": "Custom validation rule" }, + class: "atfb-valwin__try", children: [ - el("h2", { text: "Custom rule" }), - el("p", { - class: "atfb-hint", - text: `Describe what a good answer to “${options.field.label || "this question"}” looks like — no code needed.` - }), - tabs, - blocksPane, - regexPane, - el("div", { - class: "atfb-valwin__try", - children: [ - el("h3", { text: "Try it out" }), - summary, - samples, - button( - "Add another sample", - () => { - sampleRow(""); - refresh(); - }, - "ghost", - "plus-alt2" - ) - ] - }), - row( - "When it fails, say", - messageInput, - "Shown to the visitor when their answer breaks the rule. Leave empty for the default wording." - ), - el("div", { - class: "atfb-modal__actions", - children: [ - button("Cancel", close), - button( - "Save rule", - () => { - const pattern = compileRecipe(recipe); - recipe.tests = readSamples().filter((value) => "" !== value).slice(0, 10); - saved = true; - options.onSave({ pattern, recipe, message: recipe.message.trim() }); - close(); - }, - "primary" - ) - ] - }) + el("h3", { text: "Try it out" }), + summary, + samples, + button( + "Add another sample", + () => { + sampleRow(""); + refresh(); + }, + "ghost", + "plus-alt2" + ) + ] + }), + row( + "When it fails, say", + messageInput, + "Shown to the visitor when their answer breaks the rule. Leave empty for the default wording." + ), + el("div", { + class: "atfb-modal__actions", + children: [ + button("window" === chrome ? "Close without saving" : "Cancel", requestClose), + button( + "Save rule", + () => { + const pattern = compileRecipe(recipe); + recipe.tests = readSamples().filter((value) => "" !== value).slice(0, 10); + state.saved = true; + options.onSave({ pattern, recipe, message: recipe.message.trim() }); + requestClose(); + }, + "primary" + ) ] }) ); - overlay.addEventListener("click", (event) => { - if (event.target === overlay) { - close(); - } - }); - document.addEventListener("keydown", onKeydown); - options.root.append(overlay); refresh(); - overlay.querySelector(".atfb-valwin__pane:not([hidden]) input, .atfb-valwin__pane:not([hidden]) textarea")?.focus(); } const VALIDATION_GROUPS = ["Contact", "Numbers & codes", "Text shape", "Web"]; const VALIDATION_PRESETS = [ @@ -3432,97 +3583,6 @@ var allTerrainFormsBuilder = function(exports) { const separator = url.includes("?") ? "&" : "?"; openPreviewWindow(formId, title, `${url}${separator}atf_r=${Date.now()}`); } - const FORM_TYPE = "allterrain-forms/form"; - function relations() { - const os = window.wp?.os; - return os?.relations ?? null; - } - function windowIdOf(element) { - const host = element.closest("[data-window-id], .os-window"); - if (!host) { - return null; - } - const attribute = host.getAttribute("data-window-id"); - if (attribute) { - return attribute; - } - const id = host.id ?? ""; - return id ? id.replace(/^wp-window-/, "") : null; - } - const ATTACH_TIMEOUT_MS = 6e3; - const ATTACH_POLL_MS = 120; - function setIdentity(element, ref) { - const api2 = relations(); - wanted.set(element, ref); - if (!api2?.set) { - return; - } - const attempt = (deadline) => { - if (pending.get(element) !== token) { - return; - } - const windowId = windowIdOf(element); - if (!windowId) { - if (Date.now() < deadline) { - window.setTimeout(() => attempt(deadline), ATTACH_POLL_MS); - } - return; - } - try { - api2.set(windowId, ref); - } catch (error) { - if (!warned) { - warned = true; - console.error("[AllTerrain Forms] The shell refused a window identity.", error, ref); - } - pending.delete(element); - return; - } - const stuck = !ref || api2.get?.(windowId)?.id === ref.id; - if (stuck || Date.now() >= deadline) { - pending.delete(element); - return; - } - window.setTimeout(() => attempt(deadline), ATTACH_POLL_MS); - }; - const token = Symbol("atf-identity"); - pending.set(element, token); - attempt(Date.now() + ATTACH_TIMEOUT_MS); - } - let warned = false; - const pending = /* @__PURE__ */ new WeakMap(); - const wanted = /* @__PURE__ */ new Map(); - function reapply() { - for (const [element, ref] of wanted) { - if (!element.isConnected) { - wanted.delete(element); - continue; - } - setIdentity(element, ref); - } - } - if (typeof document !== "undefined") { - for (const event of ["os-window-content-loaded", "os-window-opened"]) { - document.addEventListener(event, () => reapply()); - } - } - function formIdentity(form, adminUrl) { - return { - type: FORM_TYPE, - id: form.id, - label: form.title || "Untitled form", - related: [ - { - id: `allterrain-forms/entries-${form.id}`, - label: "Entries for this form", - url: `${adminUrl}admin.php?page=allterrain-forms-entries&form=${form.id}`, - group: "allterrain-forms", - groupLabel: "Forms", - icon: "dashicons-list-view" - } - ] - }; - } 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"; @@ -4891,6 +4951,53 @@ var allTerrainFormsBuilder = function(exports) { }); } } + /** + * A small dropdown for the condition row. + * + * The shell's own `` when its components are loaded, so the + * control on the card is the same control everywhere else on the desktop — + * a bare browser `` next to os-styled chrome read as a seam. On + * the plain admin page, where the components do not exist, a native select + * is the seamless choice for exactly the same reason. + * + * @param value The selected value. + * @param options What can be picked. + * @param key The `data-cond` refocus key. + * @param label The accessible name. + * @param onChange Called with the newly picked value. + * @return The control. + */ + private condSelect( + value: string, + options: Array< { value: string; label: string } >, + key: string, + label: string, + onChange: ( picked: string ) => void + ): HTMLElement { + if ( hasComponent( 'os-select' ) && hasComponent( 'os-option' ) ) { + const host = document.createElement( 'os-select' ); + + host.setAttribute( 'value', value ); + host.setAttribute( 'aria-label', label ); + host.setAttribute( 'data-cond', key ); + host.className = 'atfb-cond__control'; + host.title = label; + + for ( const option of options ) { + const item = document.createElement( 'os-option' ); + + item.setAttribute( 'value', option.value ); + item.textContent = option.label; + host.append( item ); + } + + host.addEventListener( 'os-pick', ( event: Event ) => { + onChange( String( ( event as CustomEvent< { value?: string } > ).detail?.value ?? '' ) ); + } ); + + return host; + } + + return el( 'select', { + class: 'atfb-cond__control atfb-cond__control--native', + title: label, + attrs: { 'aria-label': label, 'data-cond': key }, + on: { + change: ( event: Event ) => onChange( ( event.target as HTMLSelectElement ).value ), + }, + children: options.map( ( option ) => + el( 'option', { value: option.value, text: option.label, attrs: { selected: option.value === value } } ) + ), + } ); + } + /** * One tagged part of a condition — as the control that edits it. * @@ -1959,32 +2020,25 @@ export class Builder { if ( 'operator' === token.kind ) { const key = `${ owner.id }:op:${ token.ruleIndex }`; - return el( 'select', { - class: 'atfb-cond__op', - title: 'How the answer is compared.', - attrs: { 'aria-label': 'How the answer is compared', 'data-cond': key }, - on: { - change: ( event: Event ) => { - const operator = ( event.target as HTMLSelectElement ).value as keyof typeof OPERATOR_LABELS; - - this.editCondition( - owner.id, - ( logic ) => { - const rule = logic.rules[ token.ruleIndex ]; - - if ( rule ) { - rule.operator = operator; - } - }, - true, - key - ); - }, - }, - children: Object.entries( OPERATOR_LABELS ).map( ( [ value, label ] ) => - el( 'option', { value, text: label, attrs: { selected: value === token.operator } } ) - ), - } ); + return this.condSelect( + token.operator, + Object.entries( OPERATOR_LABELS ).map( ( [ value, label ] ) => ( { value, label } ) ), + key, + 'How the answer is compared', + ( picked ) => + this.editCondition( + owner.id, + ( logic ) => { + const rule = logic.rules[ token.ruleIndex ]; + + if ( rule ) { + rule.operator = picked as keyof typeof OPERATOR_LABELS; + } + }, + true, + key + ) + ); } if ( 'value' === token.kind ) { @@ -2026,26 +2080,21 @@ export class Builder { ); if ( source?.choices?.length ) { - const options = source.choices.map( ( choice ) => - el( 'option', { value: choice.value, text: choice.label || choice.value, attrs: { selected: choice.value === token.raw } } ) - ); + const options = source.choices.map( ( choice ) => ( { + value: choice.value, + label: choice.label || choice.value, + } ) ); // A stored value no choice carries any more — the option was renamed // or deleted — is kept visible rather than silently swapped for the // first choice, so what the select shows is always what the rule says. if ( token.raw !== '' && ! source.choices.some( ( choice ) => choice.value === token.raw ) ) { - options.unshift( el( 'option', { value: token.raw, text: token.text, attrs: { selected: true } } ) ); + options.unshift( { value: token.raw, label: token.text } ); } - return el( 'select', { - class: 'atfb-cond__chip atfb-cond__chip--value atfb-cond__value', - title: 'The answer that triggers this.', - attrs: { 'aria-label': 'The answer that triggers this', 'data-cond': key }, - on: { - change: ( event: Event ) => write( ( event.target as HTMLSelectElement ).value, true ), - }, - children: options, - } ); + return this.condSelect( token.raw, options, key, 'The answer that triggers this', ( picked ) => + write( picked, true ) + ); } const numeric = [ 'number', 'range', 'scale', 'rating', 'total' ].includes( source?.type ?? '' ); diff --git a/src/validation-editor.ts b/src/validation-editor.ts index e0efbd5..1c7539d 100644 --- a/src/validation-editor.ts +++ b/src/validation-editor.ts @@ -22,6 +22,7 @@ */ import { button, el, row } from './ui'; +import { windowIdOf } from './relations'; import type { Field } from './types'; /** The blocks, as filled in. Stored as JSON so the editor can be reopened. */ @@ -274,7 +275,7 @@ export function recipePasses( recipe: ValidationRecipe, value: string ): boolean /** What the editor needs from its host. */ export interface ValidationEditorOptions { - /** Where the overlay mounts — the builder root, so it stays inside the window. */ + /** The builder's root — resolves which shell window owns the editor, and hosts the fallback overlay. */ root: HTMLElement; /** The field whose rule this is — read for its stored recipe and label. */ field: Field; @@ -285,34 +286,141 @@ export interface ValidationEditorOptions { } /** - * Opens the custom-rule builder as a child window over the builder. + * The slice of the shell's window manager this module uses. + * + * Everything optional — the builder also runs on the plain admin page, where + * there is no shell and the editor falls back to an overlay. + */ +interface ShellWindowManager { + openChild?: ( + parentWindowId: string, + config: { + id: string; + baseId?: string; + url: string; + title: string; + icon?: string; + native?: boolean; + width?: number; + height?: number; + minWidth?: number; + minHeight?: number; + ephemeral?: boolean; + autofocus?: boolean | string; + render?: ( body: HTMLElement ) => void; + onClose?: () => void; + } + ) => Promise< unknown >; + getById?: ( id: string ) => { close?: () => void } | null | undefined; + remove?: ( id: string ) => void; +} + +/** The shell's window manager, when a shell is hosting us. */ +function shellWindows(): ShellWindowManager | null { + return ( + ( window as unknown as { wp?: { os?: { windowManager?: ShellWindowManager } } } ).wp?.os?.windowManager ?? + null + ); +} + +/** Geometry memory is shared across fields; the id itself is per field. */ +const EDITOR_WINDOW_BASE = 'allterrain-forms-validation'; + +/** + * Opens the custom-rule builder. + * + * Under the shell this is a real **child window** — its own chrome, drag, + * resize, minimize, a taskbar entry — owned by the builder window via + * `windowManager.openChild()`, so the builder can never sit above it but stays + * scrollable and readable beside it the whole time. That is the difference + * between a dialog and a child window, and it matters here: the rule being + * written is *about* a field the person can still see, select and reread while + * they write it. + * + * On the plain admin page, where there is no shell to ask, the same editor + * mounts as an overlay over the builder — the one place a dialog is the honest + * fallback rather than a missed opportunity. * * @param options What to edit and where to say so. * @return void */ export function openValidationEditor( options: ValidationEditorOptions ): void { - const recipe = parseRecipe( String( options.field.validationRecipe ?? '' ) ); + const manager = shellWindows(); + const parentId = windowIdOf( options.root ); - // A field that has a hand-written pattern but no recipe — authored before - // this editor existed, or pasted by an expert — opens in the expert tab - // with that pattern, so editing it does not silently start from zero. - if ( 'blocks' === recipe.mode && '' === compileRecipe( recipe ) && options.field.pattern ) { - recipe.mode = 'regex'; - recipe.regex = String( options.field.pattern ); + if ( manager?.openChild && parentId ) { + openAsChildWindow( manager, parentId, options ); + + return; } - if ( ! recipe.message ) { - recipe.message = String( ( options.field.messages as Record< string, string > | undefined )?.invalid ?? '' ); + openAsOverlay( options ); +} + +/** The editor as a shell child window, owned by the builder. */ +function openAsChildWindow( manager: ShellWindowManager, parentId: string, options: ValidationEditorOptions ): void { + const id = `${ EDITOR_WINDOW_BASE }-${ options.field.id }`; + + // An editor for this same field may already be open — from a double click, + // or left behind after the rule changed under it. Its content is stale by + // definition, so it is replaced rather than focused. + if ( manager.getById?.( id ) ) { + manager.remove?.( id ); } + const state = { saved: false }; + + const close = () => { + const win = manager.getById?.( id ); + + if ( win?.close ) { + win.close(); + } else { + manager.remove?.( id ); + } + }; + + void manager.openChild?.( parentId, { + id, + baseId: EDITOR_WINDOW_BASE, + url: `#${ id }`, + title: `Custom rule — ${ options.field.label || 'this question' }`, + icon: 'dashicons-yes-alt', + native: true, + width: 560, + height: 700, + minWidth: 440, + minHeight: 480, + // A rule mid-edit is not worth resurrecting against a field that may + // be gone; children are excluded from snapshots anyway, this says so. + ephemeral: true, + autofocus: '.atfb-valwin__pane:not([hidden]) input, .atfb-valwin__pane:not([hidden]) textarea', + render: ( body ) => { + const host = el( 'div', { class: 'atfa atfb-valwin atfb-valwin--window' } ); + + buildEditor( host, options, state, close, 'window' ); + body.append( host ); + }, + // Fires however the window closes — Save, Cancel, the title-bar X, + // or its owner closing. One place decides whether that was a cancel. + onClose: () => { + if ( ! state.saved ) { + options.onCancel?.(); + } + }, + } ); +} + +/** The editor as an overlay — the no-shell fallback. */ +function openAsOverlay( options: ValidationEditorOptions ): void { const overlay = el( 'div', { class: 'atfb-overlay' } ); - let saved = false; + const state = { saved: false }; const close = () => { overlay.remove(); document.removeEventListener( 'keydown', onKeydown ); - if ( ! saved ) { + if ( ! state.saved ) { options.onCancel?.(); } }; @@ -323,6 +431,68 @@ export function openValidationEditor( options: ValidationEditorOptions ): void { } }; + const modal = el( 'div', { + class: 'atfb-modal atfb-valwin', + attrs: { role: 'dialog', 'aria-label': 'Custom validation rule' }, + children: [ el( 'h2', { text: 'Custom rule' } ) ], + } ); + + buildEditor( modal, options, state, close, 'overlay' ); + overlay.append( modal ); + + overlay.addEventListener( 'click', ( event ) => { + if ( event.target === overlay ) { + close(); + } + } ); + + document.addEventListener( 'keydown', onKeydown ); + options.root.append( overlay ); + + overlay + .querySelector< HTMLInputElement >( + '.atfb-valwin__pane:not([hidden]) input, .atfb-valwin__pane:not([hidden]) textarea' + ) + ?.focus(); +} + +/** + * The editor itself, appended into whichever host is showing it. + * + * Everything both hosts share: the tabs, the blocks, the expression pane, the + * plain-words summary, the playground and the actions. The hosts differ only + * in chrome — a shell window carries its own title bar, so the `window` mode + * skips the heading the overlay draws itself. + * + * @param host Where the editor mounts. + * @param options What to edit and where to say so. + * @param state Shared save flag — the host's close path reads it to + * tell Save from Cancel. + * @param requestClose Asks the host to close (window close or overlay removal). + * @param chrome Which host is showing the editor. + * @return void + */ +function buildEditor( + host: HTMLElement, + options: ValidationEditorOptions, + state: { saved: boolean }, + requestClose: () => void, + chrome: 'window' | 'overlay' +): void { + const recipe = parseRecipe( String( options.field.validationRecipe ?? '' ) ); + + // A field that has a hand-written pattern but no recipe — authored before + // this editor existed, or pasted by an expert — opens in the expert tab + // with that pattern, so editing it does not silently start from zero. + if ( 'blocks' === recipe.mode && '' === compileRecipe( recipe ) && options.field.pattern ) { + recipe.mode = 'regex'; + recipe.regex = String( options.field.pattern ); + } + + if ( ! recipe.message ) { + recipe.message = String( ( options.field.messages as Record< string, string > | undefined )?.invalid ?? '' ); + } + /* ------------------------------------------------------------- Blocks */ const blockInput = ( key: 'starts' | 'ends' | 'contains' | 'notContains', placeholder: string ) => { @@ -503,7 +673,8 @@ export function openValidationEditor( options: ValidationEditorOptions ): void { const refresh = () => { const description = describeRecipe( recipe ); - summary.textContent = description || 'Nothing yet — fill in a block above and the rule appears here in plain words.'; + summary.textContent = + description || 'Nothing yet — fill in a block above and the rule appears here in plain words.'; for ( const sample of Array.from( samples.querySelectorAll< HTMLElement >( '.atfb-valwin__sample' ) ) ) { const input = sample.querySelector< HTMLInputElement >( 'input' ); @@ -523,76 +694,59 @@ export function openValidationEditor( options: ValidationEditorOptions ): void { /* -------------------------------------------------------------- Shell */ - overlay.append( + host.append( + el( 'p', { + class: 'atfb-hint', + text: `Describe what a good answer to “${ + options.field.label || 'this question' + }” looks like — no code needed.`, + } ), + tabs, + blocksPane, + regexPane, el( 'div', { - class: 'atfb-modal atfb-valwin', - attrs: { role: 'dialog', 'aria-label': 'Custom validation rule' }, + class: 'atfb-valwin__try', children: [ - el( 'h2', { text: 'Custom rule' } ), - el( 'p', { - class: 'atfb-hint', - text: `Describe what a good answer to “${ - options.field.label || 'this question' - }” looks like — no code needed.`, - } ), - tabs, - blocksPane, - regexPane, - el( 'div', { - class: 'atfb-valwin__try', - children: [ - el( 'h3', { text: 'Try it out' } ), - summary, - samples, - button( - 'Add another sample', - () => { - sampleRow( '' ); - refresh(); - }, - 'ghost', - 'plus-alt2' - ), - ], - } ), - row( - 'When it fails, say', - messageInput, - 'Shown to the visitor when their answer breaks the rule. Leave empty for the default wording.' + el( 'h3', { text: 'Try it out' } ), + summary, + samples, + button( + 'Add another sample', + () => { + sampleRow( '' ); + refresh(); + }, + 'ghost', + 'plus-alt2' + ), + ], + } ), + row( + 'When it fails, say', + messageInput, + 'Shown to the visitor when their answer breaks the rule. Leave empty for the default wording.' + ), + el( 'div', { + class: 'atfb-modal__actions', + children: [ + button( 'window' === chrome ? 'Close without saving' : 'Cancel', requestClose ), + button( + 'Save rule', + () => { + const pattern = compileRecipe( recipe ); + recipe.tests = readSamples() + .filter( ( value ) => '' !== value ) + .slice( 0, 10 ); + + state.saved = true; + options.onSave( { pattern, recipe, message: recipe.message.trim() } ); + requestClose(); + }, + 'primary' ), - el( 'div', { - class: 'atfb-modal__actions', - children: [ - button( 'Cancel', close ), - button( - 'Save rule', - () => { - const pattern = compileRecipe( recipe ); - recipe.tests = readSamples() - .filter( ( value ) => '' !== value ) - .slice( 0, 10 ); - - saved = true; - options.onSave( { pattern, recipe, message: recipe.message.trim() } ); - close(); - }, - 'primary' - ), - ], - } ), ], } ) ); - overlay.addEventListener( 'click', ( event ) => { - if ( event.target === overlay ) { - close(); - } - } ); - - document.addEventListener( 'keydown', onKeydown ); - options.root.append( overlay ); refresh(); - - overlay.querySelector< HTMLInputElement >( '.atfb-valwin__pane:not([hidden]) input, .atfb-valwin__pane:not([hidden]) textarea' )?.focus(); }