From 36afd07a4aa9c4a001906e290b06e8da1eb59c06 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 10 Aug 2026 15:20:32 -0700 Subject: [PATCH 1/2] Forms: apply conditional logic in the browser Wire the resolver into the front-end form so fields appear and disappear as the visitor answers, and skip hidden fields when validating. Visibility is memoised per form rather than globally: two forms on one page have their own rules and answers, and a single shared cache let one form's result satisfy the other's lookup. Hidden fields use `display: none` as the base state, so a visitor with JavaScript or animations off still gets the right form. The reveal animation is additive, scoped to conditional fields, and inside `@media not (prefers-reduced-motion)`. Checkbox normalisation keys off the control's actual type rather than the field's declared one, so a consent field -- which renders as a checkbox but declares 'consent' -- no longer reads as checked after the visitor unchecks it. The context is also reconciled from the DOM on a bfcache restore, since neither input nor change fires when the browser puts the visitor's values back, and focus moves to the nearest visible control when the field being filled in is hidden by a cascade. --- .../add-forms-conditional-logic-frontend | 4 + .../forms/src/contact-form/css/grunion.scss | 42 ++++ .../modules/form/conditional-visibility.js | 96 ++++++++ .../packages/forms/src/modules/form/view.js | 101 +++++++- .../form/conditional-visibility.test.js | 226 ++++++++++++++++++ 5 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 projects/packages/forms/changelog/add-forms-conditional-logic-frontend create mode 100644 projects/packages/forms/src/modules/form/conditional-visibility.js create mode 100644 projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js diff --git a/projects/packages/forms/changelog/add-forms-conditional-logic-frontend b/projects/packages/forms/changelog/add-forms-conditional-logic-frontend new file mode 100644 index 000000000000..120e1b653bf5 --- /dev/null +++ b/projects/packages/forms/changelog/add-forms-conditional-logic-frontend @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Show and hide form fields in the browser as conditional logic rules are satisfied. diff --git a/projects/packages/forms/src/contact-form/css/grunion.scss b/projects/packages/forms/src/contact-form/css/grunion.scss index f60389b93a02..710065b839e0 100644 --- a/projects/packages/forms/src/contact-form/css/grunion.scss +++ b/projects/packages/forms/src/contact-form/css/grunion.scss @@ -1664,3 +1664,45 @@ on production builds, the attributes are being reordered, causing side-effects display: block; } } + +/* Conditional logic hides the entire field wrapper and its inputs. */ +.jetpack-field--conditionally-hidden { + display: none !important; +} + +/* + * Ease a conditional field in and out rather than + * snapping it. + * + * Scoped to [data-jp-conditional] so only fields that + * carry a condition transition; every other field + * renders with no animation cost. `display` is in the + * transition with allow-discrete so the element still + * leaves the layout when the exit finishes, and + * @starting-style supplies the entry state for an + * element arriving from display: none. Browsers without + * those features show and hide instantly. + * + * None of this runs when the visitor has asked for + * reduced motion: the rule above is the whole + * behaviour in that case. + */ +@media not (prefers-reduced-motion) { + + [data-jp-conditional] { + transition: + opacity 120ms ease-out, + transform 120ms ease-out, + display 120ms allow-discrete; + + @starting-style { + opacity: 0; + transform: translateY(-4px); + } + } + + .jetpack-field--conditionally-hidden[data-jp-conditional] { + opacity: 0; + transform: translateY(-4px); + } +} diff --git a/projects/packages/forms/src/modules/form/conditional-visibility.js b/projects/packages/forms/src/modules/form/conditional-visibility.js new file mode 100644 index 000000000000..759c48af4c4f --- /dev/null +++ b/projects/packages/forms/src/modules/form/conditional-visibility.js @@ -0,0 +1,96 @@ +import { resolveVisibility } from '../../blocks/shared/conditional-logic/util/evaluate.ts'; + +/** + * Resolved visibility, memoized per form. + * + * Keyed by form hash rather than held in a single slot: a page can carry more than one form, + * and two forms often produce the same value signature — field ids are derived from labels, + * so a "Name"/"Other" pair repeats readily. A shared slot would hand one form's visibility + * map to another and show or hide the wrong fields. + * + * @type {Map} + */ +const memoByForm = new Map(); + +/** + * Discard memoized visibility. Exposed for tests. + * + * @return {void} + */ +export const clearVisibilityMemo = () => { + memoByForm.clear(); +}; + +/** + * Resolve visibility for every field in the form the current field belongs to. + * + * The form block emits `conditionalLogic` as `{ types, logic }`: a type for every field, so + * rules can reference any of them, plus logic for only the fields that have some. When no + * field uses conditional logic the key is absent and this returns null, so callers can skip + * the work entirely. + * + * `isFieldHidden` is read once per field but conditional logic cascades across the whole + * form, so the resolver considers every field on each call; the memo keeps a keystroke at one + * resolution rather than one per field. + * + * @param {object} context - The interactivity context, merged from form and field level. + * @return {object|null} Map of field id to visibility, or null when nothing is conditional. + */ +export const resolveFormVisibility = context => { + const conditionalLogic = context?.conditionalLogic; + const logicByField = conditionalLogic?.logic; + + if ( ! logicByField || ! Object.keys( logicByField ).length ) { + return null; + } + + const typesByField = conditionalLogic.types || {}; + // Only date fields carry one; the comparison needs it to read the value the way the + // datepicker wrote it. + const formatsByField = conditionalLogic.formats || {}; + const fields = context.fields || {}; + const values = {}; + for ( const id in fields ) { + values[ id ] = fields[ id ]?.value; + } + + const formKey = context.formHash || 'default'; + const signature = JSON.stringify( values ); + const cached = memoByForm.get( formKey ); + + if ( cached && cached.signature === signature ) { + return cached.map; + } + + const descriptors = {}; + for ( const id in typesByField ) { + descriptors[ id ] = { + logic: logicByField[ id ] || null, + type: typesByField[ id ], + format: formatsByField[ id ], + }; + } + + const map = resolveVisibility( descriptors, values ); + memoByForm.set( formKey, { signature, map } ); + + return map; +}; + +/** + * Whether a field is currently hidden by conditional logic. + * + * Validation has to agree with what is on screen. A required field the visitor cannot see is + * one they cannot fill, so counting its error would block the form with nothing to explain + * why — the submit button simply stops working. The server drops the same fields before + * validating, so skipping them here keeps the two sides in step. + * + * @param {object} context - The interactivity context. + * @param {string} fieldId - The field's id. + * @return {boolean} True when conditional logic hides the field. + */ +export const isFieldHiddenByLogic = ( context, fieldId ) => { + const visibility = resolveFormVisibility( context ); + + return !! visibility && false === visibility[ fieldId ]; +}; diff --git a/projects/packages/forms/src/modules/form/view.js b/projects/packages/forms/src/modules/form/view.js index c1594c09015a..1e21af6df83c 100644 --- a/projects/packages/forms/src/modules/form/view.js +++ b/projects/packages/forms/src/modules/form/view.js @@ -13,6 +13,7 @@ import { */ import { validateField, isEmptyValue } from '../../contact-form/js/validate-helper.js'; import { getRating } from '../field-rating/view.js'; +import { isFieldHiddenByLogic } from './conditional-visibility.js'; import { maybeAddColonToLabel, maybeTransformValue, getImages, getUrl } from './helpers.js'; import { focusNextInput, getForm, submitForm } from './shared.ts'; // Import field type icons view to register its callbacks. @@ -24,6 +25,33 @@ const withSyncEvent = ( ...args ) => cb( ...args ) ); +/** + * Re-read field values from the DOM after a back/forward restore. + * + * The interactivity context is only written by the input and change handlers, and neither + * fires when the browser restores a page from the bfcache -- it puts the visitor's values + * straight back into the DOM. Conditional logic then keeps resolving against the values the + * context still holds, so a dependent field can stay hidden even though the restored trigger + * should reveal it, with no way out short of re-touching the trigger. Autofill has the same + * shape across some engines. + * + * Dispatching `input` routes through the same handler the visitor's typing does, so there is + * no second code path to keep in step. + */ +if ( typeof window !== 'undefined' ) { + window.addEventListener( 'pageshow', event => { + if ( ! event.persisted ) { + return; + } + + document + .querySelectorAll( + '[data-jp-field-id] input, [data-jp-field-id] select, [data-jp-field-id] textarea' + ) + .forEach( element => element.dispatchEvent( new Event( 'input', { bubbles: true } ) ) ); + } ); +} + const NAMESPACE = 'jetpack/form'; const config = getConfig( NAMESPACE ); let errorTimeout = null; @@ -312,7 +340,9 @@ const { state, actions } = store( NAMESPACE, { return false; } - return ! Object.values( context.fields ).some( field => ! isEmptyValue( field.value ) ); + return ! Object.values( context.fields ).some( + field => ! isEmptyValue( field.value ) && ! isFieldHiddenByLogic( context, field.id ) + ); }, get isStepActive() { @@ -382,13 +412,21 @@ const { state, actions } = store( NAMESPACE, { return false; } const context = getContext(); + // A field hidden by conditional logic is skipped: the visitor cannot see it, so an + // error against it would block submission with nothing on screen to explain why. + // The server re-checks visibility and drops the same fields before validating. if ( context.isMultiStep ) { // For multistep forms, we only validate fields that are part of the current step. return ! Object.values( context.fields ).some( - field => field.error !== 'yes' && field.step === context.currentStep + field => + field.error !== 'yes' && + field.step === context.currentStep && + ! isFieldHiddenByLogic( context, field.id ) ); } - return ! Object.values( context.fields ).some( field => field.error !== 'yes' ); + return ! Object.values( context.fields ).some( + field => field.error !== 'yes' && ! isFieldHiddenByLogic( context, field.id ) + ); }, get showFormErrors() { @@ -433,6 +471,9 @@ const { state, actions } = store( NAMESPACE, { if ( context.isMultiStep && field.step !== context.currentStep ) { return; } + if ( isFieldHiddenByLogic( context, field.id ) ) { + return; + } if ( field.error && field.error !== 'yes' ) { errors.push( { anchor: '#' + field.id, @@ -451,6 +492,14 @@ const { state, actions } = store( NAMESPACE, { const field = context.fields[ fieldId ]; return field?.value || ''; }, + + get isFieldHidden() { + // The shared helper rather than a second copy of the same three lines: a fix to + // how hiding is decided has to reach the class binding too. + const context = getContext(); + + return isFieldHiddenByLogic( context, context.fieldId ); + }, }, actions: { @@ -486,7 +535,14 @@ const { state, actions } = store( NAMESPACE, { const fieldId = context.fieldId; const field = context.fields[ fieldId ]; - if ( context.fieldType === 'checkbox' ) { + // Keyed off what the control actually is, not what the field is called. A consent + // field renders as a checkbox but carries fieldType 'consent', so it skipped this + // and stored its literal `Yes` value whether checked or not. Conditional logic + // reads consent as a boolean, so unchecking it still read as checked: the fields + // it was supposed to gate stayed on screen and got filled in, and the server then + // resolved them hidden and dropped the answers. Anything checkbox-rendered later + // is covered by the same test. + if ( event.target.type === 'checkbox' ) { value = event.target.checked ? '1' : ''; } @@ -805,6 +861,43 @@ const { state, actions } = store( NAMESPACE, { }, callbacks: { + /** + * Keep focus somewhere usable when conditional logic hides the focused field. + * + * `display: none` is right for tab order and the accessibility tree, but a keyboard or + * screen-reader user filling this field can have it disappear because their answer to + * another field cascaded. Focus then falls to with nothing to explain it. + * scrollToWrapper() already moves focus for a store-driven DOM change; this matches. + */ + manageConditionalFocus() { + const context = getContext(); + const { ref } = getElement(); + + if ( ! ref || ! isFieldHiddenByLogic( context, context.fieldId ) ) { + return; + } + + // ownerDocument rather than the global: the form may be inside an iframe, as it is + // in the editor preview. + const activeElement = ref.ownerDocument?.activeElement; + + if ( ! activeElement || ! ref.contains( activeElement ) ) { + return; + } + + // The nearest still-visible control, so the visitor carries on where they were + // rather than being sent to the top of the form. + const form = ref.closest( 'form' ); + const candidates = form + ? Array.from( form.querySelectorAll( 'input, select, textarea, button' ) ) + : []; + const next = candidates.find( + element => ! ref.contains( element ) && null !== element.offsetParent + ); + + ( next || form )?.focus?.(); + }, + initializeField() { const context = getContext(); const { fieldId, fieldType, fieldLabel, fieldValue, fieldIsRequired, fieldExtra } = context; diff --git a/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js b/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js new file mode 100644 index 000000000000..df89ecd3d1c6 --- /dev/null +++ b/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js @@ -0,0 +1,226 @@ +import { + clearVisibilityMemo, + isFieldHiddenByLogic, + resolveFormVisibility, +} from '../../../../src/modules/form/conditional-visibility.js'; + +const showWhen = ( field, value ) => ( { + enabled: true, + action: 'show', + logicalOperator: 'all', + controls: { + fieldValue: { rules: [ { field, operator: 'is', value } ] }, + }, +} ); + +/** + * Build the interactivity context the form block emits. + * + * @param {object} options - Context options. + * @param {string} options.formHash - The form's hash. + * @param {object} options.types - Map of field id to shortcode type. + * @param {object} options.logic - Map of field id to conditional-logic config. + * @param {object} options.values - Map of field id to current value. + * @return {object} An interactivity context. + */ +const context = ( { formHash, types, logic, values } ) => ( { + formHash, + conditionalLogic: { types, logic }, + fields: Object.fromEntries( + Object.entries( values ).map( ( [ id, value ] ) => [ id, { value } ] ) + ), +} ); + +describe( 'resolveFormVisibility', () => { + beforeEach( clearVisibilityMemo ); + + it( 'returns null when no field has conditional logic', () => { + expect( + resolveFormVisibility( { + formHash: 'a', + fields: { one: { value: '' } }, + } ) + ).toBeNull(); + expect( + resolveFormVisibility( + context( { + formHash: 'a', + types: { one: 'text' }, + logic: {}, + values: { one: '' }, + } ) + ) + ).toBeNull(); + } ); + + it( 'hides a field whose condition is not met', () => { + const visible = resolveFormVisibility( + context( { + formHash: 'a', + types: { trigger: 'text', dependent: 'text' }, + logic: { dependent: showWhen( 'trigger', 'Other' ) }, + values: { trigger: 'Something else', dependent: '' }, + } ) + ); + + expect( visible.dependent ).toBe( false ); + expect( visible.trigger ).toBe( true ); + } ); + + it( 'shows it once the condition is met', () => { + const visible = resolveFormVisibility( + context( { + formHash: 'a', + types: { trigger: 'text', dependent: 'text' }, + logic: { dependent: showWhen( 'trigger', 'Other' ) }, + values: { trigger: 'Other', dependent: '' }, + } ) + ); + + expect( visible.dependent ).toBe( true ); + } ); + + // Regression: the memo used to be a single module-level slot keyed only on the value + // signature, so a second form on the page with a matching signature was handed the first + // form's map and showed or hid the wrong fields. Field ids derive from labels, so two + // forms sharing a "trigger"/"dependent" pair is entirely ordinary. + it( "does not leak one form's visibility to another with the same value signature", () => { + const shared = { + types: { trigger: 'text', dependent: 'text' }, + values: { trigger: 'Other', dependent: '' }, + }; + + const first = resolveFormVisibility( + context( { + ...shared, + formHash: 'form-one', + logic: { dependent: showWhen( 'trigger', 'Other' ) }, + } ) + ); + + // Same field ids and identical values, but the opposite rule. + const second = resolveFormVisibility( + context( { + ...shared, + formHash: 'form-two', + logic: { dependent: showWhen( 'trigger', 'Something else' ) }, + } ) + ); + + expect( first.dependent ).toBe( true ); + expect( second.dependent ).toBe( false ); + } ); + + it( 'memoizes per form rather than globally', () => { + const build = ( formHash, triggerValue ) => + context( { + formHash, + types: { trigger: 'text', dependent: 'text' }, + logic: { dependent: showWhen( 'trigger', 'Other' ) }, + values: { trigger: triggerValue, dependent: '' }, + } ); + + const a1 = resolveFormVisibility( build( 'form-one', 'Other' ) ); + const b1 = resolveFormVisibility( build( 'form-two', 'Other' ) ); + const a2 = resolveFormVisibility( build( 'form-one', 'Other' ) ); + + // Each form keeps its own cached map, and a repeat call reuses it. + expect( a2 ).toBe( a1 ); + expect( b1 ).not.toBe( a1 ); + } ); + + it( 're-resolves when a value changes', () => { + const build = triggerValue => + context( { + formHash: 'form-one', + types: { trigger: 'text', dependent: 'text' }, + logic: { dependent: showWhen( 'trigger', 'Other' ) }, + values: { trigger: triggerValue, dependent: '' }, + } ); + + expect( resolveFormVisibility( build( 'Other' ) ).dependent ).toBe( true ); + expect( resolveFormVisibility( build( 'Nope' ) ).dependent ).toBe( false ); + } ); + + // The context carries shortcode types, which is what both evaluators take. + it( 'compares a multiple-choice field by membership, not substring', () => { + const build = values => + context( { + formHash: 'form-choice', + types: { colours: 'checkbox-multiple', dependent: 'text' }, + logic: { + dependent: { + enabled: true, + action: 'show', + logicalOperator: 'all', + controls: { + fieldValue: { + rules: [ { field: 'colours', operator: 'contains', value: 'Blue' } ], + }, + }, + }, + }, + values, + } ); + + expect( + resolveFormVisibility( build( { colours: [ 'Blueberry' ], dependent: '' } ) ).dependent + ).toBe( false ); + + clearVisibilityMemo(); + + expect( + resolveFormVisibility( build( { colours: [ 'Blue' ], dependent: '' } ) ).dependent + ).toBe( true ); + } ); + + it( 'falls back to a shared key when the form has no hash', () => { + const visible = resolveFormVisibility( + context( { + formHash: undefined, + types: { trigger: 'text', dependent: 'text' }, + logic: { dependent: showWhen( 'trigger', 'Other' ) }, + values: { trigger: 'Other', dependent: '' }, + } ) + ); + + expect( visible.dependent ).toBe( true ); + } ); + + describe( 'isFieldHiddenByLogic', () => { + const build = triggerValue => + context( { + formHash: 'form-one', + types: { trigger: 'text', dependent: 'text' }, + logic: { dependent: showWhen( 'trigger', 'Other' ) }, + values: { trigger: triggerValue, dependent: '' }, + } ); + + // The visitor cannot see or fill a hidden field, so client-side validation must not + // count an error against it — otherwise Submit silently stops working. + it( 'reports a field hidden by its condition', () => { + expect( isFieldHiddenByLogic( build( 'Nope' ), 'dependent' ) ).toBe( true ); + } ); + + it( 'reports a shown field as not hidden', () => { + expect( isFieldHiddenByLogic( build( 'Other' ), 'dependent' ) ).toBe( false ); + } ); + + it( 'reports an unconditional field as not hidden', () => { + expect( isFieldHiddenByLogic( build( 'Nope' ), 'trigger' ) ).toBe( false ); + } ); + + it( 'reports nothing hidden when the form has no conditional logic', () => { + const plain = { + formHash: 'plain', + fields: { one: { value: '' } }, + }; + + expect( isFieldHiddenByLogic( plain, 'one' ) ).toBe( false ); + } ); + + it( 'reports an unknown field as not hidden', () => { + expect( isFieldHiddenByLogic( build( 'Nope' ), 'no-such-field' ) ).toBe( false ); + } ); + } ); +} ); From 31baed00387bbe11292c0bebdec9a7aa81115d46 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 13 Aug 2026 08:01:48 -0700 Subject: [PATCH 2/2] Forms: store conditional logic as groups of rules The attribute kept its rules in a map keyed by condition kind, which cannot express "any of these AND all of those" -- so supporting more than one grouping later meant reshaping what is already stored. It is an array of groups now, each combining its own rules with its own operator, combined with each other by the top-level one. Both evaluators handle several groups already, even though the V1 panel writes exactly one: if only the storage changed, the second group would still arrive needing an evaluator change. With a single group the outer reduction is a no-op, so behaviour is unchanged. Rules now carry their own type, so further condition kinds become new rule types inside a group rather than another reshape. A rule of an unknown kind is ignored, so a form saved by a newer editor degrades to its remaining conditions. --- .../js/modules/form/conditional-visibility.test.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js b/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js index df89ecd3d1c6..6c16eaf141bd 100644 --- a/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js +++ b/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js @@ -8,9 +8,7 @@ const showWhen = ( field, value ) => ( { enabled: true, action: 'show', logicalOperator: 'all', - controls: { - fieldValue: { rules: [ { field, operator: 'is', value } ] }, - }, + groups: [ { logicalOperator: 'all', rules: [ { field, operator: 'is', value } ] } ], } ); /** @@ -153,11 +151,12 @@ describe( 'resolveFormVisibility', () => { enabled: true, action: 'show', logicalOperator: 'all', - controls: { - fieldValue: { + groups: [ + { + logicalOperator: 'all', rules: [ { field: 'colours', operator: 'contains', value: 'Blue' } ], }, - }, + ], }, }, values,