Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: added

Show and hide form fields in the browser as conditional logic rules are satisfied.
42 changes: 42 additions & 0 deletions projects/packages/forms/src/contact-form/css/grunion.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
96 changes: 96 additions & 0 deletions projects/packages/forms/src/modules/form/conditional-visibility.js
Original file line number Diff line number Diff line change
@@ -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<string, {signature: string, map: object}>}
*/
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 ];
};
101 changes: 97 additions & 4 deletions projects/packages/forms/src/modules/form/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard is inverted with respect to the problem the docblock describes, and I think it means the handler can never fire usefully.

persisted === true means the browser reused the whole document including the JS heap. The interactivity context is the same object graph it was at freeze time, and every DOM value is the one the last handler already wrote into it — there's nothing to repair. The case the comment describes, where the browser "puts the visitor's values straight back into the DOM" while the context holds stale values, is session-history form restoration on a fresh document: Back without bfcache, or a reload. That fires pageshow with persisted === false and returns here on line 44.

The skipped case, concretely: visitor answers a select "Are you a member? → Yes", field B appears, they navigate away and press Back without bfcache. The document is re-parsed, fields comes back empty and re-registers from the server-rendered fieldValue, then the browser restores the select to "Yes" in the DOM. Text fields self-correct because they carry data-wp-bind--value='state.getFieldValue', but select, radio and checkbox have no value binding — so the DOM says "Yes" and the context says "". The client resolves B hidden, new FormData( form ) posts member=Yes anyway, and the server resolves B visible and required and rejects the submission for a field the visitor never saw.

Second half, on line 51: even when it does run, input reaches almost nothing. Counting the wiring in class-contact-form-field.php on this branch — radio (1516, 1555), checkbox (1634), explicit consent (1662), checkbox-multiple (2137, 2168), select (2209), image-select (2581) and rating (3199) are all data-wp-on--change exclusively. data-wp-on--input is only on the free-text controls. So a synthetic input is a no-op for every choice-style field, which is the set most likely to be a conditional trigger.

Dropping the persisted guard and dispatching both event types covers it: [ 'input', 'change' ].forEach( type => element.dispatchEvent( new Event( type, { bubbles: true } ) ) ). Worth scoping the selector to forms that actually carry conditional logic so flag-off sites pay nothing.

Neither half is currently covered by a test, which is probably why both survived — a jsdom case dispatching pageshow with persisted: false against a restored select would catch it.

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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand All @@ -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: {
Expand Down Expand Up @@ -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' : '';
}

Expand Down Expand Up @@ -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 <body> 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does the opposite of what the comment two lines up says. querySelectorAll returns document order and .find() takes the first match, with nothing referencing ref's position — so if the hidden field is the fifth of eight, focus goes to field one. A visitor keyboard-navigating a long form gets sent back to a field they filled in eight steps ago, and tabbing forward walks them through everything they already completed.

Three smaller things in the same block:

  • The fallback on line 898 is inert. <form> has no tabindex, so form.focus() is a successful call that does nothing and focus drops to <body> — the exact outcome this callback exists to prevent. ?.focus?.() hides that rather than surfacing it.
  • offsetParent !== null is still true for a sibling field mid-exit, because the display transition added in this PR runs 120ms with allow-discrete. A second field hiding in the same cascade can be picked as the target and then vanish under the visitor.
  • Disabled controls aren't excluded, and .focus() on one is a no-op with the same end state.

Walking forward from ref rather than over the whole form would settle the main issue — something like slicing candidates from findIndex( el => ref.contains( el ) ) + 1 before the .find(), falling back to the last preceding candidate. Plus :not([disabled]), excluding anything already carrying the hidden class, and giving the fallback something real like the submit button.

Checked the mount behavior while I was here and it's fine: data-wp-watch is a useSignalEffect so it does fire on mount, but activeElement is <body> on load and the ref.contains guard returns early, so it won't move focus on page load. The one hole is loading with a #field-id fragment pointing inside a field that starts hidden.

element => ! ref.contains( element ) && null !== element.offsetParent
);

( next || form )?.focus?.();
},

initializeField() {
const context = getContext();
const { fieldId, fieldType, fieldLabel, fieldValue, fieldIsRequired, fieldExtra } = context;
Expand Down
Loading
Loading