diff --git a/assets/src/js/_acf-condition-types.js b/assets/src/js/_acf-condition-types.js index a57613d1..9761bc52 100644 --- a/assets/src/js/_acf-condition-types.js +++ b/assets/src/js/_acf-condition-types.js @@ -1,11 +1,11 @@ ( function ( $, undefined ) { - var __ = acf.__; + const __ = acf.__; - var parseString = function ( val ) { + const parseString = function ( val ) { return val ? '' + val : ''; }; - var isEqualTo = function ( v1, v2 ) { + const isEqualTo = function ( v1, v2 ) { return ( parseString( v1 ).toLowerCase() === parseString( v2 ).toLowerCase() ); @@ -18,22 +18,22 @@ * @param {number|string|Array} v2 - The selected value to compare. * @returns {boolean} Returns true if the values are equal numbers, otherwise returns false. */ - var isEqualToNumber = function ( v1, v2 ) { + const isEqualToNumber = function ( v1, v2 ) { if ( v2 instanceof Array ) { return v2.length === 1 && isEqualToNumber( v1, v2[ 0 ] ); } return parseFloat( v1 ) === parseFloat( v2 ); }; - var isGreaterThan = function ( v1, v2 ) { + const isGreaterThan = function ( v1, v2 ) { return parseFloat( v1 ) > parseFloat( v2 ); }; - var isLessThan = function ( v1, v2 ) { + const isLessThan = function ( v1, v2 ) { return parseFloat( v1 ) < parseFloat( v2 ); }; - var inArray = function ( v1, array ) { + const inArray = function ( v1, array ) { // cast all values as string array = array.map( function ( v2 ) { return parseString( v2 ); @@ -42,12 +42,12 @@ return array.indexOf( v1 ) > -1; }; - var containsString = function ( haystack, needle ) { + const containsString = function ( haystack, needle ) { return parseString( haystack ).indexOf( parseString( needle ) ) > -1; }; - var matchesPattern = function ( v1, pattern ) { - var regexp = new RegExp( parseString( pattern ), 'gi' ); + const matchesPattern = function ( v1, pattern ) { + const regexp = new RegExp( parseString( pattern ), 'gi' ); return parseString( v1 ).match( regexp ); }; @@ -120,7 +120,7 @@ * * @since ACF 6.3 */ - var HasPageLink = acf.Condition.extend( { + const HasPageLink = acf.Condition.extend( { type: 'hasPageLink', operator: '==', label: __( 'Page is equal to' ), @@ -140,7 +140,7 @@ * * @since ACF 6.3 */ - var HasPageLinkNotEqual = acf.Condition.extend( { + const HasPageLinkNotEqual = acf.Condition.extend( { type: 'hasPageLinkNotEqual', operator: '!==', label: __( 'Page is not equal to' ), @@ -160,7 +160,7 @@ * * @since ACF 6.3 */ - var containsPageLink = acf.Condition.extend( { + const containsPageLink = acf.Condition.extend( { type: 'containsPageLink', operator: '==contains', label: __( 'Pages contain' ), @@ -189,7 +189,7 @@ * * @since ACF 6.3 */ - var containsNotPageLink = acf.Condition.extend( { + const containsNotPageLink = acf.Condition.extend( { type: 'containsNotPageLink', operator: '!=contains', label: __( 'Pages do not contain' ), @@ -218,7 +218,7 @@ * * @since ACF 6.3 */ - var HasAnyPageLink = acf.Condition.extend( { + const HasAnyPageLink = acf.Condition.extend( { type: 'hasAnyPageLink', operator: '!=empty', label: __( 'Has any page selected' ), @@ -242,7 +242,7 @@ * * @since ACF 6.3 */ - var HasNoPageLink = acf.Condition.extend( { + const HasNoPageLink = acf.Condition.extend( { type: 'hasNoPageLink', operator: '==empty', label: __( 'Has no page selected' ), @@ -266,7 +266,7 @@ * * @since ACF 6.3 */ - var HasUser = acf.Condition.extend( { + const HasUser = acf.Condition.extend( { type: 'hasUser', operator: '==', label: __( 'User is equal to' ), @@ -286,7 +286,7 @@ * * @since ACF 6.3 */ - var HasUserNotEqual = acf.Condition.extend( { + const HasUserNotEqual = acf.Condition.extend( { type: 'hasUserNotEqual', operator: '!==', label: __( 'User is not equal to' ), @@ -306,7 +306,7 @@ * * @since ACF 6.3 */ - var containsUser = acf.Condition.extend( { + const containsUser = acf.Condition.extend( { type: 'containsUser', operator: '==contains', label: __( 'Users contain' ), @@ -335,7 +335,7 @@ * * @since ACF 6.3 */ - var containsNotUser = acf.Condition.extend( { + const containsNotUser = acf.Condition.extend( { type: 'containsNotUser', operator: '!=contains', label: __( 'Users do not contain' ), @@ -363,7 +363,7 @@ * * @since ACF 6.3 */ - var HasAnyUser = acf.Condition.extend( { + const HasAnyUser = acf.Condition.extend( { type: 'hasAnyUser', operator: '!=empty', label: __( 'Has any user selected' ), @@ -387,7 +387,7 @@ * * @since ACF 6.3 */ - var HasNoUser = acf.Condition.extend( { + const HasNoUser = acf.Condition.extend( { type: 'hasNoUser', operator: '==empty', label: __( 'Has no user selected' ), @@ -411,7 +411,7 @@ * * @since ACF 6.3 */ - var HasRelationship = acf.Condition.extend( { + const HasRelationship = acf.Condition.extend( { type: 'hasRelationship', operator: '==', label: __( 'Relationship is equal to' ), @@ -431,7 +431,7 @@ * * @since ACF 6.3 */ - var HasRelationshipNotEqual = acf.Condition.extend( { + const HasRelationshipNotEqual = acf.Condition.extend( { type: 'hasRelationshipNotEqual', operator: '!==', label: __( 'Relationship is not equal to' ), @@ -451,7 +451,7 @@ * * @since ACF 6.3 */ - var containsRelationship = acf.Condition.extend( { + const containsRelationship = acf.Condition.extend( { type: 'containsRelationship', operator: '==contains', label: __( 'Relationships contain' ), @@ -478,7 +478,7 @@ * * @since ACF 6.3 */ - var containsNotRelationship = acf.Condition.extend( { + const containsNotRelationship = acf.Condition.extend( { type: 'containsNotRelationship', operator: '!=contains', label: __( 'Relationships do not contain' ), @@ -506,7 +506,7 @@ * * @since ACF 6.3 */ - var HasAnyRelation = acf.Condition.extend( { + const HasAnyRelation = acf.Condition.extend( { type: 'hasAnyRelation', operator: '!=empty', label: __( 'Has any relationship selected' ), @@ -530,7 +530,7 @@ * * @since ACF 6.3 */ - var HasNoRelation = acf.Condition.extend( { + const HasNoRelation = acf.Condition.extend( { type: 'hasNoRelation', operator: '==empty', label: __( 'Has no relationship selected' ), @@ -554,7 +554,7 @@ * * @since ACF 6.3 */ - var HasPostObject = acf.Condition.extend( { + const HasPostObject = acf.Condition.extend( { type: 'hasPostObject', operator: '==', label: __( 'Post is equal to' ), @@ -574,7 +574,7 @@ * * @since ACF 6.3 */ - var HasPostObjectNotEqual = acf.Condition.extend( { + const HasPostObjectNotEqual = acf.Condition.extend( { type: 'hasPostObjectNotEqual', operator: '!==', label: __( 'Post is not equal to' ), @@ -594,7 +594,7 @@ * * @since ACF 6.3 */ - var containsPostObject = acf.Condition.extend( { + const containsPostObject = acf.Condition.extend( { type: 'containsPostObject', operator: '==contains', label: __( 'Posts contain' ), @@ -623,7 +623,7 @@ * * @since ACF 6.3 */ - var containsNotPostObject = acf.Condition.extend( { + const containsNotPostObject = acf.Condition.extend( { type: 'containsNotPostObject', operator: '!=contains', label: __( 'Posts do not contain' ), @@ -652,7 +652,7 @@ * * @since ACF 6.3 */ - var HasAnyPostObject = acf.Condition.extend( { + const HasAnyPostObject = acf.Condition.extend( { type: 'hasAnyPostObject', operator: '!=empty', label: __( 'Has any post selected' ), @@ -676,7 +676,7 @@ * * @since ACF 6.3 */ - var HasNoPostObject = acf.Condition.extend( { + const HasNoPostObject = acf.Condition.extend( { type: 'hasNoPostObject', operator: '==empty', label: __( 'Has no post selected' ), @@ -700,7 +700,7 @@ * * @since ACF 6.3 */ - var HasTerm = acf.Condition.extend( { + const HasTerm = acf.Condition.extend( { type: 'hasTerm', operator: '==', label: __( 'Term is equal to' ), @@ -720,7 +720,7 @@ * * @since ACF 6.3 */ - var hasTermNotEqual = acf.Condition.extend( { + const hasTermNotEqual = acf.Condition.extend( { type: 'hasTermNotEqual', operator: '!==', label: __( 'Term is not equal to' ), @@ -740,7 +740,7 @@ * * @since ACF 6.3 */ - var containsTerm = acf.Condition.extend( { + const containsTerm = acf.Condition.extend( { type: 'containsTerm', operator: '==contains', label: __( 'Terms contain' ), @@ -766,7 +766,7 @@ * * @since ACF 6.3 */ - var containsNotTerm = acf.Condition.extend( { + const containsNotTerm = acf.Condition.extend( { type: 'containsNotTerm', operator: '!=contains', label: __( 'Terms do not contain' ), @@ -793,7 +793,7 @@ * * @since ACF 6.3 */ - var HasAnyTerm = acf.Condition.extend( { + const HasAnyTerm = acf.Condition.extend( { type: 'hasAnyTerm', operator: '!=empty', label: __( 'Has any term selected' ), @@ -817,7 +817,7 @@ * * @since ACF 6.3 */ - var HasNoTerm = acf.Condition.extend( { + const HasNoTerm = acf.Condition.extend( { type: 'hasNoTerm', operator: '==empty', label: __( 'Has no term selected' ), @@ -845,7 +845,7 @@ * @param void * @return void */ - var HasValue = acf.Condition.extend( { + const HasValue = acf.Condition.extend( { type: 'hasValue', operator: '!=empty', label: __( 'Has any value' ), @@ -896,7 +896,7 @@ * @param void * @return void */ - var HasNoValue = HasValue.extend( { + const HasNoValue = HasValue.extend( { type: 'hasNoValue', operator: '==empty', label: __( 'Has no value' ), @@ -916,7 +916,7 @@ * @param void * @return void */ - var EqualTo = acf.Condition.extend( { + const EqualTo = acf.Condition.extend( { type: 'equalTo', operator: '==', label: __( 'Value is equal to' ), @@ -952,7 +952,7 @@ * @param void * @return void */ - var NotEqualTo = EqualTo.extend( { + const NotEqualTo = EqualTo.extend( { type: 'notEqualTo', operator: '!=', label: __( 'Value is not equal to' ), @@ -972,7 +972,7 @@ * @param void * @return void */ - var PatternMatch = acf.Condition.extend( { + const PatternMatch = acf.Condition.extend( { type: 'patternMatch', operator: '==pattern', label: __( 'Value matches pattern' ), @@ -1003,7 +1003,7 @@ * @param void * @return void */ - var Contains = acf.Condition.extend( { + const Contains = acf.Condition.extend( { type: 'contains', operator: '==contains', label: __( 'Value contains' ), @@ -1037,7 +1037,7 @@ * @param void * @return void */ - var TrueFalseEqualTo = EqualTo.extend( { + const TrueFalseEqualTo = EqualTo.extend( { type: 'trueFalseEqualTo', choiceType: 'select', fieldTypes: [ 'true_false' ], @@ -1062,7 +1062,7 @@ * @param void * @return void */ - var TrueFalseNotEqualTo = NotEqualTo.extend( { + const TrueFalseNotEqualTo = NotEqualTo.extend( { type: 'trueFalseNotEqualTo', choiceType: 'select', fieldTypes: [ 'true_false' ], @@ -1087,13 +1087,13 @@ * @param void * @return void */ - var SelectEqualTo = acf.Condition.extend( { + const SelectEqualTo = acf.Condition.extend( { type: 'selectEqualTo', operator: '==', label: __( 'Value is equal to' ), fieldTypes: [ 'select', 'checkbox', 'radio', 'button_group' ], match: function ( rule, field ) { - var val = field.val(); + const val = field.val(); if ( val instanceof Array ) { return inArray( rule.value, val ); } else { @@ -1102,8 +1102,8 @@ }, choices: function ( fieldObject ) { // vars - var choices = []; - var lines = fieldObject + const choices = []; + const lines = fieldObject .$setting( 'choices textarea' ) .val() .split( '\n' ); @@ -1147,7 +1147,7 @@ * @param void * @return void */ - var SelectNotEqualTo = SelectEqualTo.extend( { + const SelectNotEqualTo = SelectEqualTo.extend( { type: 'selectNotEqualTo', operator: '!=', label: __( 'Value is not equal to' ), @@ -1167,13 +1167,13 @@ * @param void * @return void */ - var GreaterThan = acf.Condition.extend( { + const GreaterThan = acf.Condition.extend( { type: 'greaterThan', operator: '>', label: __( 'Value is greater than' ), fieldTypes: [ 'number', 'range' ], match: function ( rule, field ) { - var val = field.val(); + let val = field.val(); if ( val instanceof Array ) { val = val.length; } @@ -1195,12 +1195,12 @@ * @param void * @return void */ - var LessThan = GreaterThan.extend( { + const LessThan = GreaterThan.extend( { type: 'lessThan', operator: '<', label: __( 'Value is less than' ), match: function ( rule, field ) { - var val = field.val(); + let val = field.val(); if ( val instanceof Array ) { val = val.length; } @@ -1225,7 +1225,7 @@ * @param void * @return void */ - var SelectionGreaterThan = GreaterThan.extend( { + const SelectionGreaterThan = GreaterThan.extend( { type: 'selectionGreaterThan', label: __( 'Selection is greater than' ), fieldTypes: [ @@ -1250,7 +1250,7 @@ * @param void * @return void */ - var SelectionLessThan = LessThan.extend( { + const SelectionLessThan = LessThan.extend( { type: 'selectionLessThan', label: __( 'Selection is less than' ), fieldTypes: [ diff --git a/assets/src/js/_acf-field-button-group.js b/assets/src/js/_acf-field-button-group.js index b3094740..5a6a07ef 100644 --- a/assets/src/js/_acf-field-button-group.js +++ b/assets/src/js/_acf-field-button-group.js @@ -1,9 +1,12 @@ +import { update } from '@wordpress/icons'; + ( function ( $, undefined ) { - var Field = acf.Field.extend( { + const Field = acf.Field.extend( { type: 'button_group', events: { 'click input[type="radio"]': 'onClick', + 'keydown label': 'onKeyDown', }, $control: function () { @@ -13,29 +16,80 @@ $input: function () { return this.$( 'input:checked' ); }, + initialize: function () { + this.updateButtonStates(); + }, setValue: function ( val ) { this.$( 'input[value="' + val + '"]' ) .prop( 'checked', true ) .trigger( 'change' ); + this.updateButtonStates(); }, + updateButtonStates: function () { + const labels = this.$control().find( 'label' ); + const input = this.$input(); + labels + .removeClass( 'selected' ) + .attr( 'aria-checked', 'false' ) + .attr( 'tabindex', '-1' ); + if ( input.length ) { + // If there's a checked input, mark its parent label as selected + input + .parent( 'label' ) + .addClass( 'selected' ) + .attr( 'aria-checked', 'true' ) + .attr( 'tabindex', '0' ); + } else { + labels.first().attr( 'tabindex', '0' ); + } + }, onClick: function ( e, $el ) { - // vars - var $label = $el.parent( 'label' ); - var selected = $label.hasClass( 'selected' ); + this.selectButton( $el.parent( 'label' ) ); + }, + onKeyDown: function ( event, label ) { + const key = event.which; - // remove previous selected - this.$( '.selected' ).removeClass( 'selected' ); + // Space or Enter: select the button + if ( key === 13 || key === 32 ) { + event.preventDefault(); + this.selectButton( label ); + return; + } + + // Arrow keys: move focus between buttons + if ( key === 37 || key === 39 || key === 38 || key === 40 ) { + event.preventDefault(); + const labels = this.$control().find( 'label' ); + const currentIndex = labels.index( label ); + let nextIndex; + + // Left/Up arrow: move to previous, wrap to last if at start + if ( key === 37 || key === 38 ) { + nextIndex = + currentIndex > 0 ? currentIndex - 1 : labels.length - 1; + } + // Right/Down arrow: move to next, wrap to first if at end + else { + nextIndex = + currentIndex < labels.length - 1 ? currentIndex + 1 : 0; + } - // add active class - $label.addClass( 'selected' ); + const nextLabel = labels.eq( nextIndex ); + labels.attr( 'tabindex', '-1' ); + nextLabel.attr( 'tabindex', '0' ).trigger( 'focus' ); + } + }, - // allow null - if ( this.get( 'allow_null' ) && selected ) { - $label.removeClass( 'selected' ); - $el.prop( 'checked', false ).trigger( 'change' ); + selectButton: function ( element ) { + const inputRadio = element.find( 'input[type="radio"]' ); + const isSelected = element.hasClass( 'selected' ); + inputRadio.prop( 'checked', true ).trigger( 'change' ); + if ( this.get( 'allow_null' ) && isSelected ) { + inputRadio.prop( 'checked', false ).trigger( 'change' ); } + this.updateButtonStates(); }, } ); diff --git a/assets/src/js/_acf-field-checkbox.js b/assets/src/js/_acf-field-checkbox.js index ed37dfb8..3ade175a 100644 --- a/assets/src/js/_acf-field-checkbox.js +++ b/assets/src/js/_acf-field-checkbox.js @@ -7,6 +7,7 @@ 'click .acf-add-checkbox': 'onClickAdd', 'click .acf-checkbox-toggle': 'onClickToggle', 'click .acf-checkbox-custom': 'onClickCustom', + 'keydown input[type="checkbox"]': 'onKeyDownInput', }, $control: function () { @@ -71,24 +72,21 @@ .parent() .find( 'input[type="text"]' ) .last() - .focus(); + .trigger( 'focus' ); }, onClickToggle: function ( e, $el ) { // Vars. - var checked = $el.prop( 'checked' ); - var $inputs = this.$( 'input[type="checkbox"]' ); - var $labels = this.$( 'label' ); - - // Update "checked" state. - $inputs.prop( 'checked', checked ); - - // Add or remove "selected" class. - if ( checked ) { - $labels.addClass( 'selected' ); - } else { - $labels.removeClass( 'selected' ); - } + const inputs = this.$inputs(); + const hasUnchecked = $inputs.not( ':checked' ).length > 0; + inputs.each( function () { + $inputs.each( function () { + jQuery( this ) + .prop( 'checked', hasUnchecked ) + .trigger( 'change' ); + } ); + } ); + $el.prop( 'checked', hasUnchecked ); }, onClickCustom: function ( e, $el ) { @@ -109,6 +107,23 @@ } } }, + onKeyDownInput: function ( e, $el ) { + // Check if Enter key (keyCode 13) was pressed + if ( e.which === 13 ) { + // Prevent default form submission + e.preventDefault(); + + // Toggle the checkbox state and trigger change event + $el.prop( 'checked', ! $el.prop( 'checked' ) ).trigger( + 'change' + ); + + // If this is the "Select All" toggle checkbox, run the toggle logic + if ( $el.is( '.acf-checkbox-toggle' ) ) { + this.onClickToggle( e, $el ); + } + } + }, } ); acf.registerFieldType( Field ); diff --git a/assets/src/js/_acf-field-color-picker.js b/assets/src/js/_acf-field-color-picker.js index 4d8a4eb5..3974c26e 100644 --- a/assets/src/js/_acf-field-color-picker.js +++ b/assets/src/js/_acf-field-color-picker.js @@ -49,10 +49,26 @@ change: onChange, clear: onChange, }; + if ( 'custom' === $inputText.data( 'acf-palette-type' ) ) { + const paletteColor = $inputText + .data( 'acf-palette-colors' ) + .match( + /#(?:[0-9a-fA-F]{3}){1,2}|rgba?\([\s*(\d|.)+\s*,]+\)/g + ); + if ( paletteColor ) { + let trimmed = paletteColor.map( ( color ) => color.trim() ); + args.palettes = trimmed; + } + } // filter var args = acf.applyFilters( 'color_picker_args', args, this ); - + if ( Array.isArray( args.palettes ) && args.palettes.length > 10 ) { + // Add class for large custom palette styling + this.$control().addClass( + 'acf-color-picker-large-custom-palette' + ); + } // initialize $inputText.wpColorPicker( args ); }, diff --git a/assets/src/js/_acf-field-file.js b/assets/src/js/_acf-field-file.js index 667a8882..345546b0 100644 --- a/assets/src/js/_acf-field-file.js +++ b/assets/src/js/_acf-field-file.js @@ -1,5 +1,5 @@ ( function ( $, undefined ) { - var Field = acf.models.ImageField.extend( { + const Field = acf.models.ImageField.extend( { type: 'file', $control: function () { @@ -9,7 +9,13 @@ $input: function () { return this.$( 'input[type="hidden"]:first' ); }, - + events: { + 'click a[data-name="add"]': 'onClickAdd', + 'click a[data-name="edit"]': 'onClickEdit', + 'click a[data-name="remove"]': 'onClickRemove', + 'change input[type="file"]': 'onChange', + 'keydown .file-wrap': 'onImageWrapKeydown', + }, validateAttachment: function ( attachment ) { // defaults attachment = attachment || {}; @@ -54,14 +60,17 @@ ); // vars - var val = attachment.id || ''; + const val = attachment.id || ''; // update val acf.val( this.$input(), val ); - - // update class if ( val ) { + // update class this.$control().addClass( 'has-value' ); + const fileWrap = this.$( '.file-wrap' ); + if ( fileWrap.length ) { + fileWrap.trigger( 'focus' ); + } } else { this.$control().removeClass( 'has-value' ); } @@ -69,11 +78,11 @@ selectAttachment: function () { // vars - var parent = this.parent(); - var multiple = parent && parent.get( 'type' ) === 'repeater'; + const parent = this.parent(); + const multiple = parent && parent.get( 'type' ) === 'repeater'; // new frame - var frame = acf.newMediaPopup( { + const frame = acf.newMediaPopup( { mode: 'select', title: acf.__( 'Select File' ), field: this.get( 'key' ), @@ -90,9 +99,9 @@ } ); }, - editAttachment: function () { + editAttachment: function ( button ) { // vars - var val = this.val(); + const val = this.val(); // bail early if no val if ( ! val ) { @@ -106,9 +115,22 @@ button: acf.__( 'Update File' ), attachment: val, field: this.get( 'key' ), - select: $.proxy( function ( attachment, i ) { + select: $.proxy( function ( attachment ) { this.render( attachment ); }, this ), + close: $.proxy( function () { + if ( 'edit-button' === button ) { + const edit = this.$el.find( 'a[data-name="edit"]' ); + if ( edit.length ) { + edit.trigger( 'focus' ); + } + } else { + const imageWrap = this.$el.find( '.image-wrap' ); + if ( imageWrap.length ) { + imageWrap.trigger( 'focus' ); + } + } + }, this ), } ); }, } ); diff --git a/assets/src/js/_acf-field-image.js b/assets/src/js/_acf-field-image.js index 20c235f5..224ee208 100644 --- a/assets/src/js/_acf-field-image.js +++ b/assets/src/js/_acf-field-image.js @@ -1,5 +1,5 @@ -( function ( $, undefined ) { - var Field = acf.Field.extend( { +( function ( $ ) { + const Field = acf.Field.extend( { type: 'image', $control: function () { @@ -15,6 +15,7 @@ 'click a[data-name="edit"]': 'onClickEdit', 'click a[data-name="remove"]': 'onClickRemove', 'change input[type="file"]': 'onChange', + 'keydown .image-wrap': 'onImageWrapKeydown', }, initialize: function () { @@ -45,7 +46,7 @@ } ); // Override with "preview size". - var size = acf.isget( + const size = acf.isget( attachment, 'sizes', this.get( 'preview_size' ) @@ -71,6 +72,10 @@ if ( attachment.id ) { this.val( attachment.id ); this.$control().addClass( 'has-value' ); + const imageWrap = this.$( '.image-wrap' ); + if ( imageWrap.length ) { + imageWrap.trigger( 'focus' ); + } } else { this.val( '' ); this.$control().removeClass( 'has-value' ); @@ -80,15 +85,15 @@ // create a new repeater row and render value append: function ( attachment, parent ) { // create function to find next available field within parent - var getNext = function ( field, parent ) { + const getNext = function ( field, parent ) { // find existing file fields within parent - var fields = acf.getFields( { + const fields = acf.getFields( { key: field.get( 'key' ), parent: parent.$el, } ); // find the first field with no value - for ( var i = 0; i < fields.length; i++ ) { + for ( let i = 0; i < fields.length; i++ ) { if ( ! fields[ i ].val() ) { return fields[ i ]; } @@ -99,7 +104,7 @@ }; // find existing file fields within parent - var field = getNext( this, parent ); + let field = getNext( this, parent ); // add new row if no available field if ( ! field ) { @@ -115,11 +120,11 @@ selectAttachment: function () { // vars - var parent = this.parent(); - var multiple = parent && parent.get( 'type' ) === 'repeater'; + const parent = this.parent(); + const multiple = parent && parent.get( 'type' ) === 'repeater'; // new frame - var frame = acf.newMediaPopup( { + const frame = acf.newMediaPopup( { mode: 'select', type: 'image', title: acf.__( 'Select Image' ), @@ -137,24 +142,36 @@ } ); }, - editAttachment: function () { + editAttachment: function ( attachment ) { // vars - var val = this.val(); - - // bail early if no val - if ( ! val ) return; - - // popup - var frame = acf.newMediaPopup( { - mode: 'edit', - title: acf.__( 'Edit Image' ), - button: acf.__( 'Update Image' ), - attachment: val, - field: this.get( 'key' ), - select: $.proxy( function ( attachment, i ) { - this.render( attachment ); - }, this ), - } ); + const val = this.val(); + + if ( val ) { + // popup + var frame = acf.newMediaPopup( { + mode: 'edit', + title: acf.__( 'Edit Image' ), + button: acf.__( 'Update Image' ), + attachment: val, + field: this.get( 'key' ), + select: $.proxy( function ( attachment ) { + this.render( attachment ); + }, this ), + close: $.proxy( function () { + if ( 'edit-button' === attachment ) { + const edit = this.$el.find( 'a[data-name="edit"]' ); + if ( edit.length ) { + edit.trigger( 'focus' ); + } + } else { + const imageWrap = this.$( '.image-wrap' ); + if ( imageWrap.length ) { + imageWrap.trigger( 'focus' ); + } + } + }, this ), + } ); + } }, removeAttachment: function () { @@ -166,7 +183,7 @@ }, onClickEdit: function ( e, $el ) { - this.editAttachment(); + this.editAttachment( 'edit-button' ); }, onClickRemove: function ( e, $el ) { @@ -174,7 +191,7 @@ }, onChange: function ( e, $el ) { - var $hiddenInput = this.$input(); + const $hiddenInput = this.$input(); if ( ! $el.val() ) { $hiddenInput.val( '' ); @@ -184,6 +201,25 @@ $hiddenInput.val( $.param( data ) ); } ); }, + onImageWrapKeydown: function ( event, imageWrapElement ) { + // Check if Enter key was pressed (keycode 13) + if ( event.which === 13 ) { + // Check if the event target is the imageWrapElement itself + if ( event.target === imageWrapElement[ 0 ] ) { + // Prevent the default Enter key behavior + event.preventDefault(); + + // Check if the field has a value + if ( this.val() ) { + // Check if the uploader is NOT the basic uploader + if ( this.get( 'uploader' ) !== 'basic' ) { + // Open the edit attachment dialog + this.editAttachment(); + } + } + } + } + }, } ); acf.registerFieldType( Field ); diff --git a/assets/src/js/_acf-field-radio.js b/assets/src/js/_acf-field-radio.js index 2c7ddb44..7d37078b 100644 --- a/assets/src/js/_acf-field-radio.js +++ b/assets/src/js/_acf-field-radio.js @@ -4,6 +4,7 @@ events: { 'click input[type="radio"]': 'onClick', + 'keydown input[type="radio"]': 'onKeyDownInput', }, $control: function () { @@ -57,6 +58,12 @@ } } }, + onKeyDownInput: function ( event, input ) { + if ( event.which === 13 ) { + event.preventDefault(); + input.prop( 'checked', true ).trigger( 'change' ); + } + }, } ); acf.registerFieldType( Field ); diff --git a/assets/src/js/_acf-field-taxonomy.js b/assets/src/js/_acf-field-taxonomy.js index f9dde190..2b0915c7 100644 --- a/assets/src/js/_acf-field-taxonomy.js +++ b/assets/src/js/_acf-field-taxonomy.js @@ -13,6 +13,7 @@ events: { 'click a[data-name="add"]': 'onClickAdd', 'click input[type="radio"]': 'onClickRadio', + 'keydown label': 'onKeyDownLabel', removeField: 'onRemove', }, @@ -319,6 +320,17 @@ $el.prop( 'checked', false ).trigger( 'change' ); } }, + onKeyDownLabel: function ( e, $el ) { + // bail early if not space or enter + if ( e.which !== 13 ) { + return; + } + e.preventDefault(); + const firstInput = $el.find( 'input' ).first(); + if ( firstInput.length ) { + firstInput.trigger( 'click' ).trigger( 'focus' ); + } + }, } ); acf.registerFieldType( Field ); diff --git a/assets/src/js/_acf-internal-post-type.js b/assets/src/js/_acf-internal-post-type.js index bc6ce8d3..22f03c9a 100644 --- a/assets/src/js/_acf-internal-post-type.js +++ b/assets/src/js/_acf-internal-post-type.js @@ -66,11 +66,18 @@ let isDefault = false; - if ( $parentSelect.filter( '.acf-taxonomy-manage_terms, .acf-taxonomy-edit_terms, .acf-taxonomy-delete_terms' ).length && + if ( + $parentSelect.filter( + '.acf-taxonomy-manage_terms, .acf-taxonomy-edit_terms, .acf-taxonomy-delete_terms' + ).length && selection.id === 'manage_categories' ) { isDefault = true; - } else if ( $parentSelect.filter( '.acf-taxonomy-assign_terms' ).length && selection.id === 'edit_posts' ) { + } else if ( + $parentSelect.filter( '.acf-taxonomy-assign_terms' ) + .length && + selection.id === 'edit_posts' + ) { isDefault = true; } else if ( selection.id === 'taxonomy_key' || @@ -83,8 +90,8 @@ if ( isDefault ) { $selection.append( '' + - acf.__( 'Default' ) + - '' + acf.__( 'Default' ) + + '' ); } @@ -391,7 +398,7 @@ const val = $select.val(); if ( ! val.length ) { - $select.focus(); + $select.trigger( 'focus' ); return; } @@ -419,7 +426,7 @@ ); } - popup.$( 'button.acf-close-popup' ).focus(); + popup.$( 'button.acf-close-popup' ).trigger( 'focus' ); }; step1(); diff --git a/assets/src/js/_acf-notice.js b/assets/src/js/_acf-notice.js index f3227c5e..6bc2b6a6 100644 --- a/assets/src/js/_acf-notice.js +++ b/assets/src/js/_acf-notice.js @@ -133,7 +133,9 @@ priority: 1, initialize: function () { const $notices = $( '.acf-admin-notice' ); - + if ( ! $notices.length ) { + return; + } $notices.each( function () { if ( $( this ).data( 'persisted' ) ) { let dismissed = acf.getPreference( 'dismissed-notices' ); diff --git a/assets/src/js/_acf-tinymce.js b/assets/src/js/_acf-tinymce.js index 3b66f8f7..d9e1637e 100644 --- a/assets/src/js/_acf-tinymce.js +++ b/assets/src/js/_acf-tinymce.js @@ -352,7 +352,9 @@ // Ensure textarea element is visible // - Fixes bug in block editor when switching between "Block" and "Document" tabs. - $( '#' + id ).show(); + if ( ! tinymce.get( id ) ) { + $( '#' + id ).show(); + } // toggle switchEditors.go( id, 'tmce' ); diff --git a/assets/src/js/_acf-validation.js b/assets/src/js/_acf-validation.js index 19960409..7723ab07 100644 --- a/assets/src/js/_acf-validation.js +++ b/assets/src/js/_acf-validation.js @@ -226,7 +226,11 @@ if ( errorCount == 1 ) { errorMessage += '. ' + acf.__( '1 field requires attention' ); } else if ( errorCount > 1 ) { - errorMessage += '. ' + acf.__( '%d fields require attention' ).replace( '%d', errorCount ); + errorMessage += + '. ' + + acf + .__( '%d fields require attention' ) + .replace( '%d', errorCount ); } // notice @@ -258,7 +262,8 @@ setTimeout( function () { $( 'html, body' ).animate( { - scrollTop: $scrollTo.offset().top - $( window ).height() / 2, + scrollTop: + $scrollTo.offset().top - $( window ).height() / 2, }, 500 ); @@ -361,7 +366,12 @@ } // filter - var data = acf.applyFilters( 'validation_complete', json.data, this.$el, this ); + var data = acf.applyFilters( + 'validation_complete', + json.data, + this.$el, + this + ); // add errors if ( ! data.valid ) { @@ -607,7 +617,9 @@ acf.lockForm = function ( $form ) { // vars var $wrap = findSubmitWrap( $form ); - var $submit = $wrap.find( '.button, [type="submit"]' ).not( '.acf-nav, .acf-repeater-add-row' ); + var $submit = $wrap + .find( '.button, [type="submit"]' ) + .not( '.acf-nav, .acf-repeater-add-row' ); var $spinner = $wrap.find( '.spinner, .acf-spinner' ); // hide all spinners (hides the preview spinner) @@ -633,7 +645,9 @@ acf.unlockForm = function ( $form ) { // vars var $wrap = findSubmitWrap( $form ); - var $submit = $wrap.find( '.button, [type="submit"]' ).not( '.acf-nav, .acf-repeater-add-row' ); + var $submit = $wrap + .find( '.button, [type="submit"]' ) + .not( '.acf-nav, .acf-repeater-add-row' ); var $spinner = $wrap.find( '.spinner, .acf-spinner' ); // unlock @@ -1052,9 +1066,12 @@ var useValidation = false; var lastPostStatus = ''; wp.data.subscribe( function () { - var postStatus = editorSelect.getEditedPostAttribute( 'status' ); - useValidation = postStatus === 'publish' || postStatus === 'future'; - lastPostStatus = postStatus !== 'publish' ? postStatus : lastPostStatus; + var postStatus = + editorSelect.getEditedPostAttribute( 'status' ); + useValidation = + postStatus === 'publish' || postStatus === 'future'; + lastPostStatus = + postStatus !== 'publish' ? postStatus : lastPostStatus; } ); // Create validation version. @@ -1064,7 +1081,6 @@ // Backup vars. var _this = this; var _args = arguments; - // Perform validation within a Promise. return new Promise( function ( resolve, reject ) { // Bail early if is autosave or preview. @@ -1079,10 +1095,16 @@ // Check if we've currently got an ACF block selected which is failing validation, but might not be presented yet. if ( 'undefined' !== typeof acf.blockInstances ) { - const selectedBlockId = wp.data.select( 'core/block-editor' ).getSelectedBlockClientId(); + const selectedBlockId = wp.data + .select( 'core/block-editor' ) + .getSelectedBlockClientId(); - if ( selectedBlockId && selectedBlockId in acf.blockInstances ) { - const acfBlockState = acf.blockInstances[ selectedBlockId ]; + if ( + selectedBlockId && + selectedBlockId in acf.blockInstances + ) { + const acfBlockState = + acf.blockInstances[ selectedBlockId ]; if ( acfBlockState.validation_errors ) { // Deselect the block to show the error and lock the save. @@ -1090,24 +1112,124 @@ 'Rejecting save because the block editor has a invalid ACF block selected.' ); notices.createErrorNotice( - acf.__( 'An ACF Block on this page requires attention before you can save.' ), + acf.__( + 'An ACF Block on this page requires attention before you can save.' + ), { id: 'acf-validation', isDismissible: true, } ); - wp.data.dispatch( 'core/editor' ).lockPostSaving( 'acf/block/' + selectedBlockId ); - wp.data.dispatch( 'core/block-editor' ).selectBlock( false ); - - return reject( 'ACF Validation failed for selected block.' ); + wp.data + .dispatch( 'core/editor' ) + .lockPostSaving( + 'acf/block/' + selectedBlockId + ); + wp.data + .dispatch( 'core/block-editor' ) + .selectBlock( false ); + + return reject( + 'ACF Validation failed for selected block.' + ); } } } + // Recursive function to check all blocks (including nested innerBlocks) for ACF validation errors + function checkBlocksForErrors( blocks ) { + const errors = []; + return new Promise( function ( resolve ) { + // Iterate through each block + blocks.forEach( ( block ) => { + // If this block has nested blocks, recursively check them + if ( block.innerBlocks.length > 0 ) { + checkBlocksForErrors( + block.innerBlocks + ).then( ( hasError ) => { + if ( hasError ) { + return resolve( true ); + } + } ); + } + + // Check if this block has an ACF error attribute + if ( block.attributes.hasAcfError ) { + // Check if the publish panel is open and close it if so + const publishPanel = + document.getElementsByClassName( + 'editor-post-publish-panel' + )[ 0 ]; + if ( publishPanel ) { + wp.data + .dispatch( 'core/editor' ) + .togglePublishSidebar(); + } + + // Add block to errors array + errors.push( block ); + + // Dispatch a custom event to notify about the block with validation error + document.dispatchEvent( + new CustomEvent( + 'acf/block/has-error', + { + acfBlocksWithValidationErrors: [ + block, + ], + } + ) + ); + + // Log debug message + acf.debug( + 'Rejecting save because the block editor has a invalid ACF block selected.' + ); + + // Resolve with true (error found) + return resolve( true ); + } + } ); + + // If errors were found, select the first one + if ( errors.length > 0 ) { + const blockClientId = errors[ 0 ].clientId; + wp.data + .dispatch( 'core/block-editor' ) + .selectBlock( blockClientId ); + } + + // No errors found, resolve with false + return resolve( false ); + } ); + } + + // Call the function with all blocks from the editor + checkBlocksForErrors( + wp.data.select( 'core/block-editor' ).getBlocks() + ).then( ( hasError ) => { + // If errors were found + if ( hasError ) { + // Display an error notice + noticesDispatch.createErrorNotice( + acf.__( + 'An ACF Block on this page requires attention before you can save.' + ), + { + id: 'acf-blocks-validation', + isDismissible: true, + } + ); + + // Reject the save operation + return reject( 'ACF Block Validation failed' ); + } + } ); + // Validate the editor form. var valid = acf.validateForm( { - form: $( '#editor' ), + form: $( '#wpbody-content > .block-editor' ), reset: true, complete: function ( $form, validator ) { // Always unlock the form after AJAX. @@ -1116,12 +1238,35 @@ failure: function ( $form, validator ) { // Get validation error and append to Gutenberg notices. var notice = validator.get( 'notice' ); - notices.createErrorNotice( notice.get( 'text' ), { - id: 'acf-validation', - isDismissible: true, - } ); + var action = validator.get( 'action' ); + if ( + action && + 'object' === typeof action && + action.label && + action.url + ) { + notices.createErrorNotice( + notice.get( 'text', { + id: 'acf-validation', + isDismissible: true, + actions: [ + { + label: action.label, + url: action.url, + }, + ], + } ) + ); + } else { + notices.createErrorNotice( + notice.get( 'text' ), + { + id: 'acf-validation', + isDismissible: true, + } + ); + } notice.remove(); - // Restore last non "publish" status. if ( lastPostStatus ) { editor.editPost( { diff --git a/assets/src/js/_browse-fields-modal.js b/assets/src/js/_browse-fields-modal.js index 3e71ac25..c7ac4df1 100644 --- a/assets/src/js/_browse-fields-modal.js +++ b/assets/src/js/_browse-fields-modal.js @@ -44,7 +44,7 @@ initialize: function () { this.open(); this.lockFocusToModal( true ); - this.$el.find( '.acf-modal-title' ).focus(); + this.$el.find( '.acf-modal-title' ).trigger( 'focus' ); acf.doAction( 'show', this.$el ); }, diff --git a/assets/src/js/_field-group-field.js b/assets/src/js/_field-group-field.js index 1719987c..a00417e3 100644 --- a/assets/src/js/_field-group-field.js +++ b/assets/src/js/_field-group-field.js @@ -680,12 +680,41 @@ }, onChangeName: function ( e, $el ) { - const sanitizedName = acf.strSanitize( $el.val(), false ); + const id = this.get( 'id' ); + let forceSanitize = false; + // If id is not a number or is zero, force sanitize + if ( typeof id !== 'number' || id === 0 ) { + forceSanitize = true; + } + + // Get the input's value attribute + const valueAttr = input.attr( 'value' ); + + // If value is a lowercase string, force sanitize + if ( + typeof valueAttr === 'string' && + valueAttr === valueAttr.toLowerCase() + ) { + forceSanitize = true; + } + + forceSanitize = acf.applyFilters( + 'convert_field_name_to_lowercase', + forceSanitize, + this + ); + + // Sanitize the input value (force if needed) + const sanitized = acf.strSanitize( $el.val(), forceSanitize ); + + // Set the sanitized value back to the input + $el.val( sanitized ); - $el.val( sanitizedName ); - this.set( 'name', sanitizedName ); + // Update the field's name property + this.set( 'name', sanitized ); - if ( sanitizedName.startsWith( 'field_' ) ) { + // Warn if the name starts with "field_" + if ( sanitized.startsWith( 'field_' ) ) { alert( acf.__( 'The string "field_" may not be used at the start of a field name' @@ -969,7 +998,7 @@ ); } - popup.$( '.acf-close-popup' ).focus(); + popup.$( '.acf-close-popup' ).trigger( 'focus' ); field.removeAnimate(); }; diff --git a/assets/src/js/bindings/block-editor.js b/assets/src/js/bindings/block-editor.js index 76315566..34bed689 100644 --- a/assets/src/js/bindings/block-editor.js +++ b/assets/src/js/bindings/block-editor.js @@ -303,8 +303,8 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => { }; }, 'withCustomControls' ); -addFilter( - 'editor.BlockEdit', - 'secure-custom-fields/with-custom-controls', - withCustomControls -); +// addFilter( +// 'editor.BlockEdit', +// 'secure-custom-fields/with-custom-controls', +// withCustomControls +// ); diff --git a/assets/src/js/pro/_acf-blocks-v3.js b/assets/src/js/pro/_acf-blocks-v3.js new file mode 100644 index 00000000..177af6fb --- /dev/null +++ b/assets/src/js/pro/_acf-blocks-v3.js @@ -0,0 +1,13 @@ +/** + * ACF Blocks Version 3 - Entry Point + * Imports all modules and initializes the block system + */ + +// Import utilities +import './blocks-v3/utils/post-locking'; + +// Import JSX parser and expose on acf global +import './blocks-v3/components/jsx-parser'; + +// Import block registration (initializes automatically via acf.addAction) +import './blocks-v3/register-block-type-v3'; diff --git a/assets/src/js/pro/_acf-blocks.js b/assets/src/js/pro/_acf-blocks.js index 9b232471..91748001 100644 --- a/assets/src/js/pro/_acf-blocks.js +++ b/assets/src/js/pro/_acf-blocks.js @@ -19,15 +19,19 @@ const md5 = require( 'md5' ); // Potentially experimental dependencies. const BlockAlignmentMatrixToolbar = - wp.blockEditor.__experimentalBlockAlignmentMatrixToolbar || wp.blockEditor.BlockAlignmentMatrixToolbar; + wp.blockEditor.__experimentalBlockAlignmentMatrixToolbar || + wp.blockEditor.BlockAlignmentMatrixToolbar; // Gutenberg v10.x begins transition from Toolbar components to Control components. const BlockAlignmentMatrixControl = - wp.blockEditor.__experimentalBlockAlignmentMatrixControl || wp.blockEditor.BlockAlignmentMatrixControl; + wp.blockEditor.__experimentalBlockAlignmentMatrixControl || + wp.blockEditor.BlockAlignmentMatrixControl; const BlockFullHeightAlignmentControl = wp.blockEditor.__experimentalBlockFullHeightAligmentControl || wp.blockEditor.__experimentalBlockFullHeightAlignmentControl || wp.blockEditor.BlockFullHeightAlignmentControl; - const useInnerBlocksProps = wp.blockEditor.__experimentalUseInnerBlocksProps || wp.blockEditor.useInnerBlocksProps; + const useInnerBlocksProps = + wp.blockEditor.__experimentalUseInnerBlocksProps || + wp.blockEditor.useInnerBlocksProps; /** * Storage for registered block types. @@ -96,9 +100,14 @@ const md5 = require( 'md5' ); * @return boolean */ function isBlockInQueryLoop( clientId ) { - const parents = wp.data.select( 'core/block-editor' ).getBlockParents( clientId ); - const parentsData = wp.data.select( 'core/block-editor' ).getBlocksByClientId( parents ); - return parentsData.filter( ( block ) => block.name === 'core/query' ).length; + const parents = wp.data + .select( 'core/block-editor' ) + .getBlockParents( clientId ); + const parentsData = wp.data + .select( 'core/block-editor' ) + .getBlocksByClientId( parents ); + return parentsData.filter( ( block ) => block.name === 'core/query' ) + .length; } /** @@ -110,7 +119,10 @@ const md5 = require( 'md5' ); * @return boolean */ function isSiteEditor() { - return document.querySelectorAll( 'iframe[name="editor-canvas"]' ).length > 0; + return ( + document.querySelectorAll( 'iframe[name="editor-canvas"]' ).length > + 0 + ); } /** @@ -132,7 +144,9 @@ const md5 = require( 'md5' ); // Check if function exists (experimental or not) and return true if it's Desktop, or doesn't exist. if ( editPostStore.__experimentalGetPreviewDeviceType ) { - return 'Desktop' === editPostStore.__experimentalGetPreviewDeviceType(); + return ( + 'Desktop' === editPostStore.__experimentalGetPreviewDeviceType() + ); } else if ( editPostStore.getPreviewDeviceType ) { return 'Desktop' === editPostStore.getPreviewDeviceType(); } else { @@ -169,7 +183,10 @@ const md5 = require( 'md5' ); * @return boolean */ function isiFramedMobileDevicePreview() { - return $( 'iframe[name=editor-canvas]' ).length && ! isDesktopPreviewDeviceType(); + return ( + $( 'iframe[name=editor-canvas]' ).length && + ! isDesktopPreviewDeviceType() + ); } /** @@ -196,7 +213,10 @@ const md5 = require( 'md5' ); } // Handle svg HTML. - if ( typeof blockType.icon === 'string' && blockType.icon.substr( 0, 4 ) === '{ iconHTML }; } @@ -229,7 +249,10 @@ const md5 = require( 'md5' ); // Remove all empty attribute defaults from PHP values to allow serialisation. // https://github.com/WordPress/gutenberg/issues/7342 for ( const key in blockType.attributes ) { - if ( 'default' in blockType.attributes[ key ] && blockType.attributes[ key ].default.length === 0 ) { + if ( + 'default' in blockType.attributes[ key ] && + blockType.attributes[ key ].default.length === 0 + ) { delete blockType.attributes[ key ].default; } } @@ -247,20 +270,41 @@ const md5 = require( 'md5' ); // Apply alignText functionality. if ( blockType.supports.alignText || blockType.supports.align_text ) { - blockType.attributes = addBackCompatAttribute( blockType.attributes, 'align_text', 'string' ); + blockType.attributes = addBackCompatAttribute( + blockType.attributes, + 'align_text', + 'string' + ); ThisBlockEdit = withAlignTextComponent( ThisBlockEdit, blockType ); } // Apply alignContent functionality. - if ( blockType.supports.alignContent || blockType.supports.align_content ) { - blockType.attributes = addBackCompatAttribute( blockType.attributes, 'align_content', 'string' ); - ThisBlockEdit = withAlignContentComponent( ThisBlockEdit, blockType ); + if ( + blockType.supports.alignContent || + blockType.supports.align_content + ) { + blockType.attributes = addBackCompatAttribute( + blockType.attributes, + 'align_content', + 'string' + ); + ThisBlockEdit = withAlignContentComponent( + ThisBlockEdit, + blockType + ); } // Apply fullHeight functionality. if ( blockType.supports.fullHeight || blockType.supports.full_height ) { - blockType.attributes = addBackCompatAttribute( blockType.attributes, 'full_height', 'boolean' ); - ThisBlockEdit = withFullHeightComponent( ThisBlockEdit, blockType.blockType ); + blockType.attributes = addBackCompatAttribute( + blockType.attributes, + 'full_height', + 'boolean' + ); + ThisBlockEdit = withFullHeightComponent( + ThisBlockEdit, + blockType.blockType + ); } // Set edit and save functions. @@ -269,7 +313,9 @@ const md5 = require( 'md5' ); wp.element.useEffect( () => { return () => { if ( ! wp.data.dispatch( 'core/editor' ) ) return; - wp.data.dispatch( 'core/editor' ).unlockPostSaving( 'acf/block/' + props.clientId ); + wp.data + .dispatch( 'core/editor' ) + .unlockPostSaving( 'acf/block/' + props.clientId ); }; }, [] ); @@ -307,7 +353,10 @@ const md5 = require( 'md5' ); */ function select( selector ) { if ( selector === 'core/block-editor' ) { - return wp.data.select( 'core/block-editor' ) || wp.data.select( 'core/editor' ); + return ( + wp.data.select( 'core/block-editor' ) || + wp.data.select( 'core/editor' ) + ); } return wp.data.select( selector ); } @@ -340,7 +389,9 @@ const md5 = require( 'md5' ); // Local function to recurse through all child blocks and add to the blocks array. const recurseBlocks = ( block ) => { blocks.push( block ); - select( 'core/block-editor' ).getBlocks( block.clientId ).forEach( recurseBlocks ); + select( 'core/block-editor' ) + .getBlocks( block.clientId ) + .forEach( recurseBlocks ); }; // Trigger initial recursion for parent level blocks. @@ -348,7 +399,9 @@ const md5 = require( 'md5' ); // Loop over args and filter. for ( const k in args ) { - blocks = blocks.filter( ( { attributes } ) => attributes[ k ] === args[ k ] ); + blocks = blocks.filter( + ( { attributes } ) => attributes[ k ] === args[ k ] + ); } // Return results. @@ -381,10 +434,18 @@ const md5 = require( 'md5' ); * @return object The AJAX promise. */ function fetchBlock( args ) { - const { attributes = {}, context = {}, query = {}, clientId = null, delay = 0 } = args; + const { + attributes = {}, + context = {}, + query = {}, + clientId = null, + delay = 0, + } = args; // Build a unique queue ID from block data, including the clientId for edit forms. - const queueId = md5( JSON.stringify( { ...attributes, ...context, ...query } ) ); + const queueId = md5( + JSON.stringify( { ...attributes, ...context, ...query } ) + ); const data = ajaxQueue[ queueId ] || { query: {}, @@ -404,7 +465,10 @@ const md5 = require( 'md5' ); data.started = true; if ( fetchCache[ queueId ] ) { ajaxQueue[ queueId ] = null; - data.promise.resolve.apply( fetchCache[ queueId ][ 0 ], fetchCache[ queueId ][ 1 ] ); + data.promise.resolve.apply( + fetchCache[ queueId ][ 0 ], + fetchCache[ queueId ][ 1 ] + ); } else { $.ajax( { url: acf.get( 'ajaxurl' ), @@ -468,7 +532,10 @@ const md5 = require( 'md5' ); // Apply a temporary wrapper for the jQuery parse to prevent text nodes triggering errors. html = '
' + html + '
'; // Correctly balance InnerBlocks tags for jQuery's initial parse. - html = html.replace( /]+)?\/>/, '' ); + html = html.replace( + /]+)?\/>/, + '' + ); return parseNode( $( html )[ 0 ], acfBlockVersion, 0 ).props.children; }; @@ -485,7 +552,10 @@ const md5 = require( 'md5' ); */ function parseNode( node, acfBlockVersion, level = 0 ) { // Get node name. - const nodeName = parseNodeName( node.nodeName.toLowerCase(), acfBlockVersion ); + const nodeName = parseNodeName( + node.nodeName.toLowerCase(), + acfBlockVersion + ); if ( ! nodeName ) { return null; } @@ -578,7 +648,10 @@ const md5 = require( 'md5' ); */ function ACFInnerBlocks( props ) { const { className = 'acf-innerblocks-container' } = props; - const innerBlockProps = useInnerBlocksProps( { className: className }, props ); + const innerBlockProps = useInnerBlocksProps( + { className: className }, + props + ); return
{ innerBlockProps.children }
; } @@ -597,7 +670,11 @@ const md5 = require( 'md5' ); let value = nodeAttr.value; // Allow overrides for third party libraries who might use specific attributes. - let shortcut = acf.applyFilters( 'acf_blocks_parse_node_attr', false, nodeAttr ); + let shortcut = acf.applyFilters( + 'acf_blocks_parse_node_attr', + false, + nodeAttr + ); if ( shortcut ) return shortcut; @@ -698,10 +775,13 @@ const md5 = require( 'md5' ); Object.keys( upgrades ).forEach( ( key ) => { if ( attributes[ key ] !== undefined ) { attributes[ upgrades[ key ] ] = attributes[ key ]; - } else if ( attributes[ upgrades[ key ] ] === undefined ) { + } else if ( + attributes[ upgrades[ key ] ] === undefined + ) { //Check for a default if ( blockType[ key ] !== undefined ) { - attributes[ upgrades[ key ] ] = blockType[ key ]; + attributes[ upgrades[ key ] ] = + blockType[ key ]; } } delete blockType[ key ]; @@ -710,7 +790,10 @@ const md5 = require( 'md5' ); // Set default attributes for those undefined. for ( let attribute in blockType.attributes ) { - if ( attributes[ attribute ] === undefined && blockType[ attribute ] !== undefined ) { + if ( + attributes[ attribute ] === undefined && + blockType[ attribute ] !== undefined + ) { attributes[ attribute ] = blockType[ attribute ]; } } @@ -721,7 +804,11 @@ const md5 = require( 'md5' ); }, 'withDefaultAttributes' ); - wp.hooks.addFilter( 'editor.BlockListBlock', 'acf/with-default-attributes', withDefaultAttributes ); + wp.hooks.addFilter( + 'editor.BlockListBlock', + 'acf/with-default-attributes', + withDefaultAttributes + ); /** * The BlockSave functional component. @@ -756,10 +843,7 @@ const md5 = require( 'md5' ); } } - if ( - isBlockInQueryLoop( clientId ) || - isSiteEditor() - ) { + if ( isBlockInQueryLoop( clientId ) || isSiteEditor() ) { restrictMode( [ 'preview' ] ); } else { switch ( blockType.mode ) { @@ -780,8 +864,7 @@ const md5 = require( 'md5' ); const { name, attributes, setAttributes, clientId } = this.props; const blockType = getBlockType( name ); const forcePreview = - isBlockInQueryLoop( clientId ) || - isSiteEditor(); + isBlockInQueryLoop( clientId ) || isSiteEditor(); let { mode } = attributes; if ( forcePreview ) { @@ -795,8 +878,12 @@ const md5 = require( 'md5' ); } // Configure toggle variables. - const toggleText = mode === 'preview' ? acf.__( 'Switch to Edit' ) : acf.__( 'Switch to Preview' ); - const toggleIcon = mode === 'preview' ? 'edit' : 'welcome-view-site'; + const toggleText = + mode === 'preview' + ? acf.__( 'Switch to Edit' ) + : acf.__( 'Switch to Preview' ); + const toggleIcon = + mode === 'preview' ? 'edit' : 'welcome-view-site'; function toggleMode() { setAttributes( { mode: mode === 'preview' ? 'edit' : 'preview', @@ -843,8 +930,12 @@ const md5 = require( 'md5' ); const { mode } = attributes; const index = useSelect( ( select ) => { - const rootClientId = select( 'core/block-editor' ).getBlockRootClientId( clientId ); - return select( 'core/block-editor' ).getBlockIndex( clientId, rootClientId ); + const rootClientId = + select( 'core/block-editor' ).getBlockRootClientId( clientId ); + return select( 'core/block-editor' ).getBlockIndex( + clientId, + rootClientId + ); } ); let showForm = true; @@ -865,7 +956,10 @@ const md5 = require( 'md5' ); acf.blockInstances[ clientId ].mode = mode; if ( ! isSelected ) { - if ( blockSupportsValidation( name ) && acf.blockInstances[ clientId ].validation_errors ) { + if ( + blockSupportsValidation( name ) && + acf.blockInstances[ clientId ].validation_errors + ) { additionalClasses += ' acf-block-has-validation-error'; } acf.blockInstances[ clientId ].has_been_deselected = true; @@ -907,7 +1001,11 @@ const md5 = require( 'md5' ); */ class Div extends Component { render() { - return
; + return ( +
+ ); } } @@ -1003,20 +1101,32 @@ const md5 = require( 'md5' ); } // Set HTML to the preloaded version. - preloadedBlocks[ blockId ].html = preloadedBlocks[ blockId ].html.replaceAll( blockId, clientId ); + preloadedBlocks[ blockId ].html = preloadedBlocks[ + blockId + ].html.replaceAll( blockId, clientId ); // Replace blockId in errors. - if ( preloadedBlocks[ blockId ].validation && preloadedBlocks[ blockId ].validation.errors ) { - preloadedBlocks[ blockId ].validation.errors = preloadedBlocks[ blockId ].validation.errors.map( - ( error ) => { - error.input = error.input.replaceAll( blockId, clientId ); - return error; - } - ); + if ( + preloadedBlocks[ blockId ].validation && + preloadedBlocks[ blockId ].validation.errors + ) { + preloadedBlocks[ blockId ].validation.errors = + preloadedBlocks[ blockId ].validation.errors.map( + ( error ) => { + error.input = error.input.replaceAll( + blockId, + clientId + ); + return error; + } + ); } // Return preloaded object. - acf.debug( 'Preload successful', preloadedBlocks[ blockId ] ); + acf.debug( + 'Preload successful', + preloadedBlocks[ blockId ] + ); return preloadedBlocks[ blockId ]; } } @@ -1028,16 +1138,16 @@ const md5 = require( 'md5' ); const client = acf.blockInstances[ this.props.clientId ] || {}; this.state = client[ this.constructor.name ] || {}; } - setState( state ) { - acf.blockInstances[ this.props.clientId ][ this.constructor.name ] = { - ...this.state, - ...state, - }; + acf.blockInstances[ this.props.clientId ][ this.constructor.name ] = + { + ...this.state, + ...state, + }; // Update component state if subscribed. // - Allows AJAX callback to update store without modifying state of an unmounted component. - if ( this.subscribed ) { + if ( this.subscribed || acf.get( 'StrictMode' ) ) { super.setState( state ); } @@ -1046,7 +1156,12 @@ const md5 = require( 'md5' ); Object.assign( {}, this ), this.props.clientId, this.constructor.name, - Object.assign( {}, acf.blockInstances[ this.props.clientId ][ this.constructor.name ] ) + Object.assign( + {}, + acf.blockInstances[ this.props.clientId ][ + this.constructor.name + ] + ) ); } @@ -1064,7 +1179,10 @@ const md5 = require( 'md5' ); }; if ( this.renderMethod === 'jsx' ) { - state.jsx = acf.parseJSX( html, getBlockVersion( this.props.name ) ); + state.jsx = acf.parseJSX( + html, + getBlockVersion( this.props.name ) + ); // Handle templates which don't contain any valid JSX parsable elements. if ( ! state.jsx ) { @@ -1072,12 +1190,17 @@ const md5 = require( 'md5' ); 'Your ACF block template contains no valid HTML elements. Appending a empty div to prevent React JS errors.' ); state.html += '
'; - state.jsx = acf.parseJSX( state.html, getBlockVersion( this.props.name ) ); + state.jsx = acf.parseJSX( + state.html, + getBlockVersion( this.props.name ) + ); } // If we've got an object (as an array) find the first valid React ref. if ( Array.isArray( state.jsx ) ) { - let refElement = state.jsx.find( ( element ) => React.isValidElement( element ) ); + let refElement = state.jsx.find( ( element ) => + React.isValidElement( element ) + ); state.ref = refElement.ref; } else { state.ref = state.jsx.ref; @@ -1138,7 +1261,10 @@ const md5 = require( 'md5' ); // This causes all instances to share the same state (cool), which unfortunately // pulls $el back and forth between the last rendered reusable block. // This simple fix leaves a "clone" behind :) - if ( $prevParent.length && $prevParent[ 0 ] !== $thisParent[ 0 ] ) { + if ( + $prevParent.length && + $prevParent[ 0 ] !== $thisParent[ 0 ] + ) { $prevParent.html( $el.clone() ); } } @@ -1186,9 +1312,12 @@ const md5 = require( 'md5' ); } componentWillUnmount() { - acf.doAction( 'unmount', this.state.$el ); + // Only skip unmount action if in StrictMode AND component is not subscribed + if ( ! acf.get( 'StrictMode' ) || this.subscribed ) { + acf.doAction( 'unmount', this.state.$el ); + } - // Unsubscribe this component from state. + // Unsubscribe this component from state this.subscribed = false; } @@ -1213,11 +1342,17 @@ const md5 = require( 'md5' ); } isNotNewlyAdded() { - return acf.blockInstances[ this.props.clientId ].has_been_deselected || false; + return ( + acf.blockInstances[ this.props.clientId ].has_been_deselected || + false + ); } hasShownValidation() { - return acf.blockInstances[ this.props.clientId ].shown_validation || false; + return ( + acf.blockInstances[ this.props.clientId ].shown_validation || + false + ); } setShownValidation() { @@ -1225,7 +1360,8 @@ const md5 = require( 'md5' ); } setValidationErrors( errors ) { - acf.blockInstances[ this.props.clientId ].validation_errors = errors; + acf.blockInstances[ this.props.clientId ].validation_errors = + errors; } getValidationErrors() { @@ -1238,12 +1374,16 @@ const md5 = require( 'md5' ); lockBlockForSaving() { if ( ! wp.data.dispatch( 'core/editor' ) ) return; - wp.data.dispatch( 'core/editor' ).lockPostSaving( 'acf/block/' + this.props.clientId ); + wp.data + .dispatch( 'core/editor' ) + .lockPostSaving( 'acf/block/' + this.props.clientId ); } unlockBlockForSaving() { if ( ! wp.data.dispatch( 'core/editor' ) ) return; - wp.data.dispatch( 'core/editor' ).unlockPostSaving( 'acf/block/' + this.props.clientId ); + wp.data + .dispatch( 'core/editor' ) + .unlockPostSaving( 'acf/block/' + this.props.clientId ); } displayValidation( $formEl ) { @@ -1257,7 +1397,12 @@ const md5 = require( 'md5' ); } const errors = this.getValidationErrors(); - acf.debug( 'Starting handle validation', Object.assign( {}, this ), Object.assign( {}, $formEl ), errors ); + acf.debug( + 'Starting handle validation', + Object.assign( {}, this ), + Object.assign( {}, $formEl ), + errors + ); this.setShownValidation(); @@ -1323,8 +1468,15 @@ const md5 = require( 'md5' ); const preloaded = this.maybePreload( hash, clientId, true ); if ( preloaded ) { - this.setHtml( acf.applyFilters( 'blocks/form/render', preloaded.html, true ) ); - if ( preloaded.validation ) this.setValidationErrors( preloaded.validation.errors ); + this.setHtml( + acf.applyFilters( + 'blocks/form/render', + preloaded.html, + true + ) + ); + if ( preloaded.validation ) + this.setValidationErrors( preloaded.validation.errors ); return; } @@ -1342,20 +1494,31 @@ const md5 = require( 'md5' ); acf.debug( 'fetch block form promise' ); if ( ! data ) { - this.setHtml( `
${acf.__( 'Error loading block form' )}
` ); + this.setHtml( + `
${ acf.__( + 'Error loading block form' + ) }
` + ); return; } if ( data.form ) { this.setHtml( - acf.applyFilters( 'blocks/form/render', data.form.replaceAll( data.clientId, clientId ), false ) + acf.applyFilters( + 'blocks/form/render', + data.form.replaceAll( data.clientId, clientId ), + false + ) ); } - if ( data.validation ) this.setValidationErrors( data.validation.errors ); + if ( data.validation ) + this.setValidationErrors( data.validation.errors ); if ( this.isNotNewlyAdded() ) { - acf.debug( "Block has already shown it's invalid. The form needs to show validation errors" ); + acf.debug( + "Block has already shown it's invalid. The form needs to show validation errors" + ); this.validate(); } } ); @@ -1366,7 +1529,10 @@ const md5 = require( 'md5' ); this.loadState(); } - acf.debug( 'BlockForm calling validate with state', Object.assign( {}, this ) ); + acf.debug( + 'BlockForm calling validate with state', + Object.assign( {}, this ) + ); super.displayValidation( this.state.$el ); } @@ -1398,8 +1564,13 @@ const md5 = require( 'md5' ); const { $el } = this.state; - if ( blockSupportsValidation( this.props.name ) && this.isNotNewlyAdded() ) { - acf.debug( "Block has already shown it's invalid. The form needs to show validation errors" ); + if ( + blockSupportsValidation( this.props.name ) && + this.isNotNewlyAdded() + ) { + acf.debug( + "Block has already shown it's invalid. The form needs to show validation errors" + ); this.validate(); } @@ -1431,8 +1602,14 @@ const md5 = require( 'md5' ); } ); } - if ( blockSupportsValidation( name ) && ! silent && thisBlockForm.getMode() === 'edit' ) { - acf.debug( 'No block preview currently available. Need to trigger a validation only fetch.' ); + if ( + blockSupportsValidation( name ) && + ! silent && + thisBlockForm.getMode() === 'edit' + ) { + acf.debug( + 'No block preview currently available. Need to trigger a validation only fetch.' + ); thisBlockForm.fetch( true, data ); } } @@ -1508,10 +1685,20 @@ const md5 = require( 'md5' ); if ( preloaded ) { if ( getBlockVersion( name ) == 1 ) { - preloaded.html = '
' + preloaded.html + '
'; + preloaded.html = + '
' + + preloaded.html + + '
'; } - this.setHtml( acf.applyFilters( 'blocks/preview/render', preloaded.html, true ) ); - if ( preloaded.validation ) this.setValidationErrors( preloaded.validation.errors ); + this.setHtml( + acf.applyFilters( + 'blocks/preview/render', + preloaded.html, + true + ) + ); + if ( preloaded.validation ) + this.setValidationErrors( preloaded.validation.errors ); return; } @@ -1530,16 +1717,32 @@ const md5 = require( 'md5' ); delay, } ).done( ( { data } ) => { if ( ! data ) { - this.setHtml( `
${acf.__( 'Error previewing block' )}
` ); + this.setHtml( + `
${ acf.__( + 'Error previewing block' + ) }
` + ); return; } - let replaceHtml = data.preview.replaceAll( data.clientId, clientId ); + let replaceHtml = data.preview.replaceAll( + data.clientId, + clientId + ); if ( getBlockVersion( name ) == 1 ) { - replaceHtml = '
' + replaceHtml + '
'; + replaceHtml = + '
' + + replaceHtml + + '
'; } acf.debug( 'fetch block render promise' ); - this.setHtml( acf.applyFilters( 'blocks/preview/render', replaceHtml, false ) ); + this.setHtml( + acf.applyFilters( + 'blocks/preview/render', + replaceHtml, + false + ) + ); if ( data.validation ) { this.setValidationErrors( data.validation.errors ); } @@ -1582,7 +1785,9 @@ const md5 = require( 'md5' ); delay = 300; } - acf.debug( 'Triggering fetch from block preview shouldComponentUpdate' ); + acf.debug( + 'Triggering fetch from block preview shouldComponentUpdate' + ); this.fetch( { attributes: nextAttributes, @@ -1613,7 +1818,11 @@ const md5 = require( 'md5' ); // Do action. acf.doAction( 'render_block_preview', blockElement, attributes ); - acf.doAction( `render_block_preview/type=${ type }`, blockElement, attributes ); + acf.doAction( + `render_block_preview/type=${ type }`, + blockElement, + attributes + ); } componentDidRemount() { @@ -1629,10 +1838,15 @@ const md5 = require( 'md5' ); // Update preview if data has changed since last render (changing from "edit" to "preview"). if ( - ! compareObjects( this.state.prevAttributes, this.props.attributes ) || + ! compareObjects( + this.state.prevAttributes, + this.props.attributes + ) || ! compareObjects( this.state.prevContext, this.props.context ) ) { - acf.debug( 'Triggering block preview fetch from componentDidRemount' ); + acf.debug( + 'Triggering block preview fetch from componentDidRemount' + ); this.fetch(); } @@ -1656,7 +1870,12 @@ const md5 = require( 'md5' ); // Register block types. const blockTypes = acf.get( 'blockTypes' ); if ( blockTypes ) { - blockTypes.map( registerBlockType ); + // Only register blocks with version < 3 (v3 blocks are registered separately). + blockTypes + .filter( + ( blockType ) => parseInt( blockType.acf_block_version ) < 3 + ) + .map( registerBlockType ); } } @@ -1709,7 +1928,9 @@ const md5 = require( 'md5' ); const DEFAULT = 'center center'; if ( align ) { const [ y, x ] = align.split( ' ' ); - return `${ validateVerticalAlignment( y ) } ${ validateHorizontalAlignment( x ) }`; + return `${ validateVerticalAlignment( + y + ) } ${ validateHorizontalAlignment( x ) }`; } return DEFAULT; } @@ -1726,12 +1947,14 @@ const md5 = require( 'md5' ); */ function withAlignContentComponent( OriginalBlockEdit, blockType ) { // Determine alignment vars - let type = blockType.supports.align_content || blockType.supports.alignContent; + let type = + blockType.supports.align_content || blockType.supports.alignContent; let AlignmentComponent; let validateAlignment; switch ( type ) { case 'matrix': - AlignmentComponent = BlockAlignmentMatrixControl || BlockAlignmentMatrixToolbar; + AlignmentComponent = + BlockAlignmentMatrixControl || BlockAlignmentMatrixToolbar; validateAlignment = validateMatrixAlignment; break; default: @@ -1742,7 +1965,9 @@ const md5 = require( 'md5' ); // Ensure alignment component exists. if ( AlignmentComponent === undefined ) { - console.warn( `The "${ type }" alignment component was not found.` ); + console.warn( + `The "${ type }" alignment component was not found.` + ); return OriginalBlockEdit; } @@ -1806,7 +2031,10 @@ const md5 = require( 'md5' ); return ( - + @@ -1843,7 +2071,10 @@ const md5 = require( 'md5' ); return ( - + diff --git a/assets/src/js/pro/_acf-ui-options-page.js b/assets/src/js/pro/_acf-ui-options-page.js index 541a2fe0..db7baff2 100644 --- a/assets/src/js/pro/_acf-ui-options-page.js +++ b/assets/src/js/pro/_acf-ui-options-page.js @@ -1,5 +1,4 @@ ( function ( $, undefined ) { - const parentPageSelectTemplate = function ( selection ) { if ( 'undefined' === typeof selection.element ) { return selection; @@ -27,27 +26,24 @@ const $selection = $( '' ); $selection.html( acf.strEscape( selection.element.innerHTML ) ); - if ( - selection.id === 'options' || - selection.id === 'edit_posts' - ) { + if ( selection.id === 'options' || selection.id === 'edit_posts' ) { $selection.append( '' + - acf.__( 'Default' ) + - '' + acf.__( 'Default' ) + + '' ); } $selection.data( 'element', selection.element ); return $selection; }; - const UIOptionsPageManager = new acf.Model({ + const UIOptionsPageManager = new acf.Model( { id: 'UIOptionsPageManager', wait: 'ready', events: { - 'change .acf-options-page-parent_slug' : 'toggleMenuPositionDesc', + 'change .acf-options-page-parent_slug': 'toggleMenuPositionDesc', }, - initialize: function() { + initialize: function () { if ( 'ui_options_page' !== acf.get( 'screen' ) ) { return; } @@ -55,7 +51,7 @@ field: false, templateSelection: parentPageSelectTemplate, templateResult: parentPageSelectTemplate, - dropdownCssClass: 'field-type-select-results' + dropdownCssClass: 'field-type-select-results', } ); acf.newSelect2( $( 'select.acf-options-page-capability' ), { @@ -73,7 +69,7 @@ this.toggleMenuPositionDesc(); }, - toggleMenuPositionDesc: function( e, $el ) { + toggleMenuPositionDesc: function ( e, $el ) { const parentPage = $( 'select.acf-options-page-parent_slug' ).val(); if ( 'none' === parentPage ) { @@ -84,14 +80,14 @@ $( '.acf-menu-position-desc-child' ).show(); } }, - }); + } ); const optionsPageModalManager = new acf.Model( { id: 'optionsPageModalManager', events: { 'change .location-rule-value': 'createOptionsPage', }, - createOptionsPage: function( e ) { + createOptionsPage: function ( e ) { const $locationSelect = $( e.target ); if ( 'add_new_options_page' !== $locationSelect.val() ) { @@ -100,11 +96,14 @@ let popup = false; - const getForm = function() { + const getForm = function () { const fieldGroupTitle = $( '.acf-headerbar-title-field' ).val(); const ajaxData = { action: 'acf/create_options_page', - acf_parent_page_choices: this.acf.data.optionPageParentOptions ? this.acf.data.optionPageParentOptions : [] + acf_parent_page_choices: this.acf.data + .optionPageParentOptions + ? this.acf.data.optionPageParentOptions + : [], }; if ( fieldGroupTitle.length ) { @@ -120,7 +119,7 @@ } ); }; - const populateForm = function( response ) { + const populateForm = function ( response ) { popup = acf.newPopup( { title: response.data.title, content: response.data.content, @@ -130,37 +129,41 @@ popup.$el.addClass( 'acf-create-options-page-popup' ); // Hack to focus with the cursor at the end of the input. - const $pageTitle = popup.$el.find( '#acf_ui_options_page-page_title' ); + const $pageTitle = popup.$el.find( + '#acf_ui_options_page-page_title' + ); const pageTitleVal = $pageTitle.val(); - $pageTitle.focus().val( '' ).val( pageTitleVal ); + $pageTitle.trigger( 'focus' ).val( '' ).val( pageTitleVal ); acf.newSelect2( $( '#acf_ui_options_page-parent_slug' ), { field: false, templateSelection: parentPageSelectTemplate, templateResult: parentPageSelectTemplate, - dropdownCssClass: 'field-type-select-results' + dropdownCssClass: 'field-type-select-results', } ); popup.on( 'submit', 'form', validateForm ); }; - const validateForm = function( e ) { + const validateForm = function ( e ) { e.preventDefault(); acf.validateForm( { form: $( '#acf-create-options-page-form' ), success: submitForm, - failure: onFail + failure: onFail, } ); - } + }; - const submitForm = function() { - const formValues = $( '#acf-create-options-page-form' ).serializeArray(); + const submitForm = function () { + const formValues = $( + '#acf-create-options-page-form' + ).serializeArray(); const ajaxData = { - action: 'acf/create_options_page' + action: 'acf/create_options_page', }; - formValues.forEach( setting => { + formValues.forEach( ( setting ) => { ajaxData[ setting.name ] = setting.value; } ); @@ -173,34 +176,43 @@ } ); }; - const onFail = function( e ) { + const onFail = function ( e ) { const $form = $( '#acf-create-options-page-form' ); - const $fieldNotices = $form.find( '.acf-field .acf-error-message' ); + const $fieldNotices = $form.find( + '.acf-field .acf-error-message' + ); // Hide the general validation failed notice. $form.find( '.acf-notice' ).first().remove(); // Update class for inline notices and move into field label. - $fieldNotices.each( function() { - const $label = $( this ).closest( '.acf-field' ).find( '.acf-label:first' ); - $( this ).attr( 'class', 'acf-options-page-modal-error' ).appendTo( $label ); + $fieldNotices.each( function () { + const $label = $( this ) + .closest( '.acf-field' ) + .find( '.acf-label:first' ); + $( this ) + .attr( 'class', 'acf-options-page-modal-error' ) + .appendTo( $label ); } ); }; - const populateLocationSelect = function( response ) { + const populateLocationSelect = function ( response ) { if ( response.success && response.data.menu_slug ) { $locationSelect.prepend( - '' + '' ); $locationSelect.val( response.data.menu_slug ); popup.close(); } else if ( ! response.success && response.data.error ) { - alert(response.data.error); + alert( response.data.error ); } }; getForm(); }, } ); - -} )( jQuery ); \ No newline at end of file +} )( jQuery ); diff --git a/assets/src/js/pro/acf-pro-blocks.js b/assets/src/js/pro/acf-pro-blocks.js index 8e3e2f28..4661a36d 100644 --- a/assets/src/js/pro/acf-pro-blocks.js +++ b/assets/src/js/pro/acf-pro-blocks.js @@ -1,2 +1,3 @@ import './_acf-jsx-names.js'; import './_acf-blocks.js'; +import './_acf-blocks-v3.js'; diff --git a/assets/src/js/pro/blocks-v3/components/block-edit.js b/assets/src/js/pro/blocks-v3/components/block-edit.js new file mode 100644 index 00000000..d1dc15f9 --- /dev/null +++ b/assets/src/js/pro/blocks-v3/components/block-edit.js @@ -0,0 +1,709 @@ +/** + * BlockEdit Component + * Main component for editing ACF blocks in the Gutenberg editor + * Handles form fetching, validation, preview rendering, and user interactions + */ +import md5 from 'md5'; + +import { + useState, + useEffect, + useRef, + createPortal, + useMemo, +} from '@wordpress/element'; + +import { + BlockControls, + InspectorControls, + useBlockProps, + useBlockEditContext, +} from '@wordpress/block-editor'; +import { + Button, + ToolbarGroup, + ToolbarButton, + Placeholder, + Spinner, + Modal, +} from '@wordpress/components'; +import { BlockPlaceholder } from './block-placeholder'; +import { BlockForm } from './block-form'; +import { BlockPreview } from './block-preview'; +import { ErrorBoundary, BlockPreviewErrorFallback } from './error-boundary'; +import { + lockPostSaving, + unlockPostSaving, + sortObjectKeys, + lockPostSavingByName, + unlockPostSavingByName, +} from '../utils/post-locking'; + +/** + * InspectorBlockFormContainer + * Small helper component that manages the inspector panel container ref + * Sets the current form container when the inspector panel is available + * + * @param {Object} props + * @param {React.RefObject} props.inspectorBlockFormRef - Ref to inspector container + * @param {Function} props.setCurrentBlockFormContainer - Setter for current container + */ +const InspectorBlockFormContainer = ( { + inspectorBlockFormRef, + setCurrentBlockFormContainer, +} ) => { + useEffect( () => { + setCurrentBlockFormContainer( inspectorBlockFormRef.current ); + }, [] ); + + return
; +}; + +/** + * Main BlockEdit component wrapper + * Manages block data fetching and initial setup + * + * @param {Object} props - Component props + * @param {Object} props.attributes - Block attributes + * @param {Function} props.setAttributes - Function to update block attributes + * @param {Object} props.context - Block context + * @param {boolean} props.isSelected - Whether block is currently selected + * @param {jQuery} props.$ - jQuery instance + * @param {Object} props.blockType - ACF block type configuration + * @returns {JSX.Element} - Rendered block editor + */ +export const BlockEdit = ( props ) => { + const { attributes, setAttributes, context, isSelected, $, blockType } = + props; + + const shouldValidate = blockType.validate; + const { clientId } = useBlockEditContext(); + + const preloadedData = useMemo( () => { + return checkPreloadedData( + generateAttributesHash( attributes, context ), + clientId, + isSelected + ); + }, [] ); + + const [ validationErrors, setValidationErrors ] = useState( () => { + return preloadedData?.validation?.errors ?? null; + } ); + + const [ showValidationErrors, setShowValidationErrors ] = useState( null ); + const [ theSerializedAcfData, setTheSerializedAcfData ] = useState( null ); + const [ blockFormHtml, setBlockFormHtml ] = useState( '' ); + const [ blockPreviewHtml, setBlockPreviewHtml ] = useState( () => { + if ( preloadedData?.html ) { + return acf.applyFilters( + 'blocks/preview/render', + preloadedData.html, + true + ); + } + return 'acf-block-preview-loading'; + } ); + const [ userHasInteractedWithForm, setUserHasInteractedWithForm ] = + useState( false ); + const [ hasFetchedOnce, setHasFetchedOnce ] = useState( false ); + const [ ajaxRequest, setAjaxRequest ] = useState(); + + const acfFormRef = useRef( null ); + const previewRef = useRef( null ); + const debounceRef = useRef( null ); + + const attributesWithoutError = useMemo( () => { + const { hasAcfError, ...rest } = attributes; + return rest; + }, [ attributes ] ); + + /** + * Fetches block data from server (form HTML, preview HTML, validation) + * + * @param {Object} params - Fetch parameters + * @param {Object} params.theAttributes - Block attributes to fetch for + * @param {string} params.theClientId - Block client ID + * @param {Object} params.theContext - Block context + * @param {boolean} params.isSelected - Whether block is selected + */ + function fetchBlockData( { + theAttributes, + theClientId, + theContext, + isSelected, + } ) { + if ( ! theAttributes ) return; + + // NEW: Abort any pending request + if ( ajaxRequest ) { + ajaxRequest.abort(); + } + + // Generate hash of attributes for preload cache lookup + const attributesHash = generateAttributesHash( theAttributes, context ); + + // Check for preloaded block data + const preloadedData = checkPreloadedData( + attributesHash, + theClientId, + isSelected + ); + + if ( preloadedData ) { + handlePreloadedData( preloadedData ); + return; + } + + // Prepare query options + const queryOptions = { preview: true, form: true, validate: true }; + if ( ! blockFormHtml ) { + queryOptions.validate = false; + } + if ( ! shouldValidate ) { + queryOptions.validate = false; + } + + const blockData = { ...theAttributes }; + + lockPostSavingByName( 'acf-fetching-block' ); + + // Fetch block data via AJAX + const request = $.ajax( { + url: acf.get( 'ajaxurl' ), + dataType: 'json', + type: 'post', + cache: false, + data: acf.prepareForAjax( { + action: 'acf/ajax/fetch-block', + block: JSON.stringify( blockData ), + clientId: theClientId, + context: JSON.stringify( theContext ), + query: queryOptions, + } ), + } ) + .done( ( response ) => { + unlockPostSavingByName( 'acf-fetching-block' ); + + setBlockFormHtml( response.data.form ); + + if ( response.data.preview ) { + setBlockPreviewHtml( + acf.applyFilters( + 'blocks/preview/render', + response.data.preview, + false + ) + ); + } else { + setBlockPreviewHtml( + acf.applyFilters( + 'blocks/preview/render', + 'acf-block-preview-no-html', + false + ) + ); + } + + if ( + response.data?.validation && + ! response.data.validation.valid && + response.data.validation.errors + ) { + setValidationErrors( response.data.validation.errors ); + } else { + setValidationErrors( null ); + } + + setHasFetchedOnce( true ); + } ) + .fail( function () { + setHasFetchedOnce( true ); + unlockPostSavingByName( 'acf-fetching-block' ); + } ); + setAjaxRequest( request ); + } + + /** + * Generates a hash of block attributes for caching + * + * @param {Object} attrs - Block attributes + * @param {Object} ctx - Block context + * @returns {string} - MD5 hash of serialized attributes + */ + function generateAttributesHash( attrs, ctx ) { + delete attrs.hasAcfError; + attrs._acf_context = sortObjectKeys( ctx ); + return md5( JSON.stringify( sortObjectKeys( attrs ) ) ); + } + + /** + * Checks if block data was preloaded and returns it + * + * @param {string} hash - Attributes hash + * @param {string} clientId - Block client ID + * @param {boolean} selected - Whether block is selected + * @returns {Object|boolean} - Preloaded data or false + */ + function checkPreloadedData( hash, clientId, selected ) { + if ( selected ) return false; + + acf.debug( 'Preload check', hash, clientId ); + + // Don't preload blocks inside Query Loop blocks + if ( isInQueryLoop( clientId ) ) { + return false; + } + + const preloadedBlocks = acf.get( 'preloadedBlocks' ); + if ( ! preloadedBlocks || ! preloadedBlocks[ hash ] ) { + acf.debug( 'Preload failed: not preloaded.' ); + return false; + } + + const data = preloadedBlocks[ hash ]; + + // Replace placeholder client ID with actual client ID + data.html = data.html.replaceAll( hash, clientId ); + + if ( data?.validation && data?.validation.errors ) { + data.validation.errors = data.validation.errors.map( ( error ) => { + error.input = error.input.replaceAll( hash, clientId ); + return error; + } ); + } + + acf.debug( 'Preload successful', data ); + return data; + } + + /** + * Checks if block is inside a Query Loop block + * + * @param {string} clientId - Block client ID + * @returns {boolean} - True if inside Query Loop + */ + function isInQueryLoop( clientId ) { + const parentIds = wp.data + .select( 'core/block-editor' ) + .getBlockParents( clientId ); + + return ( + wp.data + .select( 'core/block-editor' ) + .getBlocksByClientId( parentIds ) + .filter( ( block ) => block.name === 'core/query' ).length > 0 + ); + } + + /** + * Handles preloaded block data + * + * @param {Object} data - Preloaded data + */ + function handlePreloadedData( data ) { + if ( data.form ) { + setBlockFormHtml( data.html ); + } else if ( data.html ) { + setBlockPreviewHtml( + acf.applyFilters( 'blocks/preview/render', data.html, true ) + ); + } else { + setBlockPreviewHtml( + acf.applyFilters( + 'blocks/preview/render', + 'acf-block-preview-no-html', + true + ) + ); + } + + if ( + data?.validation && + ! data.validation.valid && + data.validation.errors + ) { + setValidationErrors( data.validation.errors ); + } else { + setValidationErrors( null ); + } + } + + // Initial fetch on mount and when selection changes + useEffect( () => { + function trackUserInteraction() { + setUserHasInteractedWithForm( true ); + window.removeEventListener( 'click', trackUserInteraction ); + window.removeEventListener( 'keydown', trackUserInteraction ); + } + + fetchBlockData( { + theAttributes: attributes, + theClientId: clientId, + theContext: context, + isSelected: isSelected, + } ); + + window.addEventListener( 'click', trackUserInteraction ); + window.addEventListener( 'keydown', trackUserInteraction ); + + return () => { + window.removeEventListener( 'click', trackUserInteraction ); + window.removeEventListener( 'keydown', trackUserInteraction ); + }; + }, [] ); + + // Update hasAcfError attribute based on validation errors + useEffect( () => { + setAttributes( + validationErrors ? { hasAcfError: true } : { hasAcfError: false } + ); + }, [ validationErrors, setAttributes ] ); + + // Listen for validation error events from other blocks + useEffect( () => { + const handleErrorEvent = () => { + lockPostSaving( clientId ); + setShowValidationErrors( true ); + }; + + document.addEventListener( 'acf/block/has-error', handleErrorEvent ); + + return () => { + document.removeEventListener( + 'acf/block/has-error', + handleErrorEvent + ); + unlockPostSaving( clientId ); + }; + }, [] ); + + // Cleanup: unlock post saving on unmount + useEffect( + () => () => { + unlockPostSaving( props.clientId ); + }, + [] + ); + + // Handle form data changes with debouncing + useEffect( () => { + clearTimeout( debounceRef.current ); + + debounceRef.current = setTimeout( () => { + const parsedData = JSON.parse( theSerializedAcfData ); + + if ( ! parsedData ) { + return void fetchBlockData( { + theAttributes: attributesWithoutError, + theClientId: clientId, + theContext: context, + isSelected: isSelected, + } ); + } + + if ( + theSerializedAcfData === + JSON.stringify( attributesWithoutError.data ) + ) { + return void fetchBlockData( { + theAttributes: attributesWithoutError, + theClientId: clientId, + theContext: context, + isSelected: isSelected, + } ); + } + + // Use original attributes (with hasAcfError) when updating + const updatedAttributes = { + ...attributes, + data: { ...parsedData }, + }; + setAttributes( updatedAttributes ); + }, 200 ); + }, [ theSerializedAcfData, attributesWithoutError ] ); + + // Trigger ACF actions when preview is rendered + useEffect( () => { + if ( previewRef.current && blockPreviewHtml ) { + const blockName = attributes.name.replace( 'acf/', '' ); + const $preview = $( previewRef.current ); + + acf.doAction( 'render_block_preview', $preview, attributes ); + acf.doAction( + `render_block_preview/type=${ blockName }`, + $preview, + attributes + ); + } + }, [ blockPreviewHtml ] ); + + return ( + + ); +}; + +/** + * Inner component that handles rendering and portals + * Separated to manage refs and portal targets properly + */ +function BlockEditInner( props ) { + const { + blockType, + $, + isSelected, + attributes, + context, + validationErrors, + showValidationErrors, + theSerializedAcfData, + setTheSerializedAcfData, + acfFormRef, + blockFormHtml, + blockPreviewHtml, + blockFetcher, + userHasInteractedWithForm, + previewRef, + hasFetchedOnce, + } = props; + + const { clientId } = useBlockEditContext(); + const inspectorControlsRef = useRef(); + const [ isModalOpen, setIsModalOpen ] = useState( false ); + const modalFormContainerRef = useRef(); + const [ currentFormContainer, setCurrentFormContainer ] = useState(); + + // Set current form container when modal opens + useEffect( () => { + if ( isModalOpen && modalFormContainerRef?.current ) { + setCurrentFormContainer( modalFormContainerRef.current ); + } + }, [ isModalOpen, modalFormContainerRef ] ); + + // Update form container when inspector panel is available + useEffect( () => { + if ( isSelected && inspectorControlsRef?.current ) { + setCurrentFormContainer( inspectorControlsRef.current ); + } else if ( isSelected && ! inspectorControlsRef?.current ) { + // Wait for inspector to be available + setTimeout( () => { + setCurrentFormContainer( inspectorControlsRef.current ); + }, 1 ); + } else if ( ! isSelected ) { + setCurrentFormContainer( null ); + } + }, [ isSelected, inspectorControlsRef, inspectorControlsRef.current ] ); + + useEffect( () => { + if ( + isSelected && + validationErrors && + showValidationErrors && + blockType?.hide_fields_in_sidebar + ) { + setIsModalOpen( true ); + } + }, [ isSelected, showValidationErrors, validationErrors, blockType ] ); + + // Build block CSS classes + let blockClasses = 'acf-block-component acf-block-body'; + blockClasses += ' acf-block-preview'; + + if ( validationErrors && showValidationErrors ) { + blockClasses += ' acf-block-has-validation-error'; + } + + const blockProps = { + ...useBlockProps( { className: blockClasses, ref: previewRef } ), + }; + + // Determine portal target + let portalTarget = null; + if ( currentFormContainer ) { + portalTarget = currentFormContainer; + } else if ( inspectorControlsRef?.current ) { + portalTarget = inspectorControlsRef.current; + } + + return ( + <> + { /* Block toolbar controls */ } + + + { + setIsModalOpen( true ); + } } + /> + + + + { /* Inspector panel container */ } + +
+
+ +
+ + { /* Render form via portal when container is available */ } + { portalTarget && + currentFormContainer && + createPortal( + <> + { + if ( ! hasFetchedOnce ) { + blockFetcher( { + theAttributes: attributes, + theClientId: clientId, + theContext: context, + isSelected: isSelected, + } ); + } + } } + onChange={ function ( $form ) { + const serializedData = acf.serialize( + $form, + `acf-block_${ clientId }` + ); + if ( serializedData ) { + setTheSerializedAcfData( + JSON.stringify( serializedData ) + ); + } + } } + validationErrors={ validationErrors } + showValidationErrors={ showValidationErrors } + acfFormRef={ acfFormRef } + theSerializedAcfData={ theSerializedAcfData } + userHasInteractedWithForm={ + userHasInteractedWithForm + } + setCurrentBlockFormContainer={ + setCurrentFormContainer + } + attributes={ attributes } + hideFieldsInSidebar={ + blockType?.hide_fields_in_sidebar && + ( ! currentFormContainer || + inspectorControlsRef.current === + currentFormContainer ) + } + /> + , + currentFormContainer || inspectorControlsRef.current + ) } + <> + { /* Modal for editing block fields */ } + { isModalOpen && ( + { + setCurrentFormContainer( null ); + setIsModalOpen( false ); + } } + > +
+ + ) } + + + { /* Block preview */ } + <> + + ( + + ) } + onError={ ( error, errorInfo ) => { + acf.debug( + 'Block preview error caught:', + error, + errorInfo + ); + } } + resetKeys={ [ blockPreviewHtml ] } + onReset={ ( { reason, next, prev } ) => { + acf.debug( 'Error boundary reset:', reason ); + if ( reason === 'keys' ) { + acf.debug( + 'Preview HTML changed from', + prev, + 'to', + next + ); + } + } } + > + { /* Show placeholder when no HTML */ } + { blockPreviewHtml === 'acf-block-preview-no-html' ? ( + + ) : null } + + { /* Show spinner while loading */ } + { blockPreviewHtml === 'acf-block-preview-loading' && ( + + + + ) } + + { /* Render actual preview HTML */ } + { blockPreviewHtml !== 'acf-block-preview-loading' && + blockPreviewHtml !== 'acf-block-preview-no-html' && + blockPreviewHtml && + acf.parseJSX( blockPreviewHtml ) } + + + + + ); +} diff --git a/assets/src/js/pro/blocks-v3/components/block-form.js b/assets/src/js/pro/blocks-v3/components/block-form.js new file mode 100644 index 00000000..35b04b4b --- /dev/null +++ b/assets/src/js/pro/blocks-v3/components/block-form.js @@ -0,0 +1,298 @@ +/** + * BlockForm Component + * Renders the ACF fields form inside a block and handles form changes, validation, and remounting + */ + +import { useState, useEffect, useRef } from '@wordpress/element'; +import { lockPostSaving, unlockPostSaving } from '../utils/post-locking'; + +/** + * BlockForm component + * Manages ACF field forms within Gutenberg blocks, including validation and change detection + * + * @param {Object} props - Component props + * @param {jQuery} props.$ - jQuery instance + * @param {string} props.clientId - Block client ID + * @param {string} props.blockFormHtml - HTML markup for the ACF form + * @param {Function} props.onMount - Callback when form is mounted + * @param {Function} props.onChange - Callback when form data changes + * @param {Array} props.validationErrors - Array of validation error objects + * @param {boolean} props.showValidationErrors - Whether to display validation errors + * @param {React.RefObject} props.acfFormRef - Ref to the form container element + * @param {boolean} props.userHasInteractedWithForm - Whether user has interacted with the form + * @param {Object} props.attributes - Block attributes + * @returns {JSX.Element} - Rendered form component + */ +export const BlockForm = ( { + $, + clientId, + blockFormHtml, + onMount, + onChange, + validationErrors, + showValidationErrors, + acfFormRef, + userHasInteractedWithForm, + attributes, + hideFieldsInSidebar, +} ) => { + const [ formHtml, setFormHtml ] = useState( blockFormHtml ); + const [ pendingChange, setPendingChange ] = useState( false ); + const debounceTimer = useRef( null ); + const [ userInteracted, setUserInteracted ] = useState( false ); + const [ initialValuesCaptured, setInitialValuesCaptured ] = + useState( false ); + + // Call onMount when component first mounts + useEffect( () => { + onMount(); + }, [] ); + + // Trigger onChange when there's a pending change + useEffect( () => { + if ( pendingChange ) { + // For the first change, capture default values even without interaction + if ( + ! initialValuesCaptured || + userHasInteractedWithForm || + userInteracted + ) { + onChange( pendingChange ); + setPendingChange( false ); + if ( ! initialValuesCaptured ) { + setInitialValuesCaptured( true ); + } + } + } + }, [ + pendingChange, + userHasInteractedWithForm, + userInteracted, + initialValuesCaptured, + setPendingChange, + onChange, + ] ); + + // Update form HTML when blockFormHtml prop changes + useEffect( () => { + if ( ! formHtml && blockFormHtml ) { + setFormHtml( blockFormHtml ); + } + }, [ blockFormHtml ] ); + + // Handle validation errors + useEffect( () => { + if ( ! acfFormRef?.current ) return; + + const validator = acf.getBlockFormValidator( + $( acfFormRef.current ).find( '.acf-block-fields' ) + ); + + validator.clearErrors(); + validator.set( 'notice', null ); + + acf.doAction( 'blocks/validation/pre_apply', validationErrors ); + + if ( validationErrors ) { + if ( showValidationErrors ) { + lockPostSaving( clientId ); + validator.$el.find( '.acf-notice' ).remove(); + validator.addErrors( validationErrors ); + validator.showErrors( 'after' ); + } + } else { + // Handle successful validation + if ( + validator.$el.find( '.acf-notice' ).length > 0 && + showValidationErrors + ) { + validator.$el.find( '.acf-notice' ).remove(); + validator.addErrors( [ + { message: acf.__( 'Validation successful' ) }, + ] ); + validator.showErrors( 'after' ); + validator.get( 'notice' ).update( { + type: 'success', + text: acf.__( 'Validation successful' ), + timeout: 1000, + } ); + validator.set( 'notice', null ); + + setTimeout( () => { + validator.$el.find( '.acf-notice' ).remove(); + }, 1001 ); + + const noticeDispatch = wp.data.dispatch( 'core/notices' ); + + /** + * Recursively checks for ACF errors in blocks + * @param {Array} blocks - Array of block objects + * @returns {Promise} - True if error found + */ + function checkForErrors( blocks ) { + return new Promise( function ( resolve ) { + blocks.forEach( ( block ) => { + if ( block.innerBlocks.length > 0 ) { + checkForErrors( block.innerBlocks ).then( + ( hasError ) => { + if ( hasError ) return resolve( true ); + } + ); + } + + if ( + block.attributes.hasAcfError && + block.clientId !== clientId + ) { + return resolve( true ); + } + } ); + return resolve( false ); + } ); + } + + checkForErrors( + wp.data.select( 'core/block-editor' ).getBlocks() + ).then( ( hasError ) => { + if ( hasError ) { + noticeDispatch.createErrorNotice( + acf.__( + 'An ACF Block on this page requires attention before you can save.' + ), + { id: 'acf-blocks-validation', isDismissible: true } + ); + } else { + noticeDispatch.removeNotice( 'acf-blocks-validation' ); + } + } ); + } + + unlockPostSaving( clientId ); + } + + acf.doAction( 'blocks/validation/post_apply', validationErrors ); + }, [ validationErrors, clientId, showValidationErrors ] ); + + // Handle form remounting and change detection + useEffect( () => { + if ( ! acfFormRef?.current || ! formHtml ) return; + + acf.debug( 'Remounting ACF Form' ); + + const formElement = acfFormRef.current; + const $form = $( formElement ); + let isActive = true; + + acf.doAction( 'remount', $form ); + if ( ! initialValuesCaptured ) { + onChange( $form ); + setInitialValuesCaptured( true ); + } + + const handleChange = () => { + onChange( $form ); + }; + + const scheduleChange = () => { + if ( ! isActive ) return; + + const inputs = formElement.querySelectorAll( 'input, textarea' ); + const selects = formElement.querySelectorAll( 'select' ); + + inputs.forEach( ( input ) => { + input.removeEventListener( 'input', handleChange ); + input.addEventListener( 'input', handleChange ); + } ); + + selects.forEach( ( select ) => { + select.removeEventListener( 'change', handleChange ); + select.addEventListener( 'change', handleChange ); + } ); + + clearTimeout( debounceTimer.current ); + debounceTimer.current = setTimeout( () => { + if ( isActive ) { + setPendingChange( $form ); + } + }, 300 ); + }; + + // Observe DOM changes to detect field additions/removals + const domObserver = new MutationObserver( scheduleChange ); + + // Observe iframe content changes (for WYSIWYG editors) + const iframeObserver = new MutationObserver( () => { + if ( isActive ) { + setUserInteracted( true ); + scheduleChange(); + } + } ); + + const observerConfig = { + attributes: true, + childList: true, + subtree: true, + characterData: true, + }; + + domObserver.observe( formElement, observerConfig ); + + // Watch for changes in iframes (WYSIWYG fields) + [ ...formElement.querySelectorAll( 'iframe' ) ].forEach( ( iframe ) => { + if ( iframe && iframe.contentDocument ) { + const iframeBody = iframe.contentDocument.body; + if ( iframeBody ) { + iframeObserver.observe( iframeBody, observerConfig ); + } + } + } ); + + // Attach event listeners to form inputs + formElement + .querySelectorAll( 'input, textarea' ) + .forEach( ( input ) => { + input.addEventListener( 'input', handleChange ); + } ); + + formElement.querySelectorAll( 'select' ).forEach( ( select ) => { + select.addEventListener( 'change', handleChange ); + } ); + + // Cleanup function + return () => { + isActive = false; + domObserver.disconnect(); + iframeObserver.disconnect(); + clearTimeout( debounceTimer.current ); + + if ( formElement ) { + formElement + .querySelectorAll( 'input, textarea' ) + .forEach( ( input ) => { + input.removeEventListener( 'input', handleChange ); + } ); + + formElement + .querySelectorAll( 'select' ) + .forEach( ( select ) => { + select.removeEventListener( 'change', handleChange ); + } ); + } + }; + }, [ acfFormRef, attributes, formHtml ] ); + + return ( +
+ ); +}; diff --git a/assets/src/js/pro/blocks-v3/components/block-placeholder.js b/assets/src/js/pro/blocks-v3/components/block-placeholder.js new file mode 100644 index 00000000..fe264d07 --- /dev/null +++ b/assets/src/js/pro/blocks-v3/components/block-placeholder.js @@ -0,0 +1,56 @@ +/** + * BlockPlaceholder Component + * Displays a placeholder UI when block has no preview HTML + */ + +const { Placeholder, Button, Icon } = wp.components; + +/** + * SVG icon for the block placeholder + * Represents a generic block/form icon + */ +const blockIcon = ( + +); + +/** + * BlockPlaceholder component + * Shows when a block has no preview HTML available + * Prompts user to open the block form to edit fields + * + * @param {Object} props - Component props + * @param {Function} props.setBlockFormModalOpen - Function to open the block form modal + * @param {string} props.blockLabel - The block's title/label + * @returns {JSX.Element} - Rendered placeholder + */ +export const BlockPlaceholder = ( { + setBlockFormModalOpen, + blockLabel, + instructions, +} ) => { + return ( + } + label={ blockLabel } + instructions={ instructions } + > + + + ); +}; diff --git a/assets/src/js/pro/blocks-v3/components/block-preview.js b/assets/src/js/pro/blocks-v3/components/block-preview.js new file mode 100644 index 00000000..e8257953 --- /dev/null +++ b/assets/src/js/pro/blocks-v3/components/block-preview.js @@ -0,0 +1,20 @@ +/** + * BlockPreview Component + * Simple wrapper component that renders block preview HTML with block props + */ + +/** + * BlockPreview component + * Wraps block preview content with the appropriate block props from useBlockProps + * + * @param {Object} props - Component props + * @param {React.ReactNode} props.children - Child elements to render + * @param {string} props.blockPreviewHtml - HTML string of the block preview (used as key) + * @param {Object} props.blockProps - Block props from useBlockProps hook + * @returns {JSX.Element} - Rendered preview wrapper + */ +export const BlockPreview = ( { children, blockPreviewHtml, blockProps } ) => ( +
+ { children } +
+); diff --git a/assets/src/js/pro/blocks-v3/components/error-boundary.js b/assets/src/js/pro/blocks-v3/components/error-boundary.js new file mode 100644 index 00000000..ecc35cb3 --- /dev/null +++ b/assets/src/js/pro/blocks-v3/components/error-boundary.js @@ -0,0 +1,141 @@ +import { Component, createContext } from '@wordpress/element'; + +// Create context outside the class +export const ErrorBoundaryContext = createContext( null ); + +// Initial state constant +const initialState = { didCatch: false, error: null }; + +// Error Boundary Component +export class ErrorBoundary extends Component { + constructor( props ) { + super( props ); + this.resetErrorBoundary = this.resetErrorBoundary.bind( this ); + this.state = initialState; + } + + static getDerivedStateFromError( error ) { + return { didCatch: true, error: error }; + } + + resetErrorBoundary() { + const { error } = this.state; + if ( error !== null ) { + // Collect all arguments passed to reset + const args = Array.from( arguments ); + + // Call optional onReset callback with context + if ( this.props.onReset ) { + this.props.onReset( { + args: args, + reason: 'imperative-api', + } ); + } + + this.setState( initialState ); + } + } + + componentDidCatch( error, errorInfo ) { + // Call optional onError callback + if ( this.props.onError ) { + this.props.onError( error, errorInfo ); + } + } + + componentDidUpdate( prevProps, prevState ) { + const { didCatch } = this.state; + const { resetKeys } = this.props; + + // Auto-reset if resetKeys prop changed + if ( + didCatch && + prevState.error !== null && + hasResetKeysChanged( prevProps.resetKeys, resetKeys ) + ) { + if ( this.props.onReset ) { + this.props.onReset( { + next: resetKeys, + prev: prevProps.resetKeys, + reason: 'keys', + } ); + } + this.setState( initialState ); + } + } + + render() { + const { children, fallbackRender, FallbackComponent, fallback } = + this.props; + const { didCatch, error } = this.state; + + let content = children; + + if ( didCatch ) { + const errorProps = { + error: error, + resetErrorBoundary: this.resetErrorBoundary, + }; + + if ( typeof fallbackRender === 'function' ) { + content = fallbackRender( errorProps ); + } else if ( FallbackComponent ) { + content = ; + } else if ( fallback !== undefined ) { + content = fallback; + } else { + throw error; + } + } + + return ( + + { content } + + ); + } +} + +// Helper function to check if reset keys changed +function hasResetKeysChanged( prevKeys = [], nextKeys = [] ) { + return ( + prevKeys.length !== nextKeys.length || + prevKeys.some( ( key, index ) => ! Object.is( key, nextKeys[ index ] ) ) + ); +} + +export const BlockPreviewErrorFallback = ( { + setBlockFormModalOpen, + blockLabel, + error, +} ) => { + let errorMessage = null; + + if ( error ) { + acf.debug( 'Block preview error:', error ); + errorMessage = acf.__( 'Error previewing block v3' ); + } + + return ( + } + label={ blockLabel } + instructions={ errorMessage } + > + + + ); +}; diff --git a/assets/src/js/pro/blocks-v3/components/jsx-parser.js b/assets/src/js/pro/blocks-v3/components/jsx-parser.js new file mode 100644 index 00000000..a0e60e70 --- /dev/null +++ b/assets/src/js/pro/blocks-v3/components/jsx-parser.js @@ -0,0 +1,226 @@ +/** + * JSX Parser for ACF Blocks + * Converts HTML strings to React/JSX elements for rendering in the block editor + */ + +import jQuery from 'jquery'; + +const { createElement, createRef, Component } = wp.element; +const useInnerBlocksProps = + wp.blockEditor.__experimentalUseInnerBlocksProps || + wp.blockEditor.useInnerBlocksProps; + +/** + * Gets the JSX-compatible name for an HTML attribute + * Maps HTML attribute names to React prop names + * + * @param {string} attrName - HTML attribute name + * @returns {string} - JSX/React prop name + */ +function getJSXNameReplacement( attrName ) { + return acf.isget( acf, 'jsxNameReplacements', attrName ) || attrName; +} + +/** + * Script component for handling ` ); + } + + componentDidUpdate() { + this.setHTML( this.props.children ); + } + + componentDidMount() { + this.setHTML( this.props.children ); + } +} + +/** + * Gets the component type for a given node name + * Handles special cases like InnerBlocks, script tags, and comments + * + * @param {string} nodeName - Lowercase node name + * @returns {string|Function|null} - Component type or null + */ +function getComponentType( nodeName ) { + switch ( nodeName ) { + case 'innerblocks': + return 'ACFInnerBlocks'; + case 'script': + return ScriptComponent; + case '#comment': + return null; + default: + return getJSXNameReplacement( nodeName ); + } +} + +/** + * ACF InnerBlocks wrapper component + * Provides a container for WordPress InnerBlocks with proper props + * + * @param {Object} props - Component props + * @returns {JSX.Element} - Wrapped InnerBlocks component + */ +function ACFInnerBlocksComponent( props ) { + const { className = 'acf-innerblocks-container' } = props; + const innerBlocksProps = useInnerBlocksProps( { className }, props ); + + return createElement( 'div', { + ...innerBlocksProps, + children: innerBlocksProps.children, + } ); +} + +/** + * Parses and transforms a DOM attribute to React props format + * Handles special cases: class -> className, style string -> style object, JSON values, booleans + * + * @param {Attr} attribute - DOM attribute object with name and value + * @returns {Object} - Transformed attribute {name, value} + */ +function parseAttribute( attribute ) { + let attrName = attribute.name; + let attrValue = attribute.value; + + // Allow custom filtering via ACF hooks + const customParsed = acf.applyFilters( + 'acf_blocks_parse_node_attr', + false, + attribute + ); + if ( customParsed ) return customParsed; + + switch ( attrName ) { + case 'class': + // Convert HTML class to React className + attrName = 'className'; + break; + + case 'style': + // Parse inline CSS string to JavaScript style object + const styleObject = {}; + attrValue.split( ';' ).forEach( ( declaration ) => { + const colonIndex = declaration.indexOf( ':' ); + if ( colonIndex > 0 ) { + let property = declaration.substr( 0, colonIndex ).trim(); + const value = declaration.substr( colonIndex + 1 ).trim(); + + // Convert kebab-case to camelCase (except CSS variables starting with -) + if ( property.charAt( 0 ) !== '-' ) { + property = acf.strCamelCase( property ); + } + + styleObject[ property ] = value; + } + } ); + attrValue = styleObject; + break; + + default: + // Preserve data- attributes as-is + if ( attrName.indexOf( 'data-' ) === 0 ) break; + + // Apply JSX name transformations (e.g., onclick -> onClick) + attrName = getJSXNameReplacement( attrName ); + + // Parse JSON array/object values + const firstChar = attrValue.charAt( 0 ); + if ( firstChar === '[' || firstChar === '{' ) { + attrValue = JSON.parse( attrValue ); + } + + // Convert string booleans to actual booleans + if ( attrValue === 'true' || attrValue === 'false' ) { + attrValue = attrValue === 'true'; + } + } + + return { name: attrName, value: attrValue }; +} + +/** + * Recursively parses a DOM node and converts it to React/JSX elements + * + * @param {Node} node - The DOM node to parse + * @param {number} depth - Current recursion depth (0-based) + * @returns {JSX.Element|null} - React element or null if node should be skipped + */ +function parseNodeToJSX( node, depth = 0 ) { + // Determine the component type for this node + const componentType = getComponentType( node.nodeName.toLowerCase() ); + + if ( ! componentType ) return null; + + const props = {}; + + // Add ref to first-level elements (except ACFInnerBlocks) + if ( depth === 1 && componentType !== 'ACFInnerBlocks' ) { + props.ref = createRef(); + } + + // Parse all attributes and add to props + acf.arrayArgs( node.attributes ) + .map( parseAttribute ) + .forEach( ( { name, value } ) => { + props[ name ] = value; + } ); + + // Handle special ACFInnerBlocks component + if ( componentType === 'ACFInnerBlocks' ) { + return createElement( ACFInnerBlocksComponent, { ...props } ); + } + + // Build element array: [type, props, ...children] + const elementArray = [ componentType, props ]; + + // Recursively process child nodes + acf.arrayArgs( node.childNodes ).forEach( ( childNode ) => { + if ( childNode instanceof Text ) { + const textContent = childNode.textContent; + if ( textContent ) { + elementArray.push( textContent ); + } + } else { + elementArray.push( parseNodeToJSX( childNode, depth + 1 ) ); + } + } ); + + // Create and return React element + return createElement.apply( this, elementArray ); +} + +/** + * Main parseJSX function exposed on the acf global object + * Converts HTML string to React elements for use in ACF blocks + * + * @param {string} htmlString - HTML markup to parse + * @returns {Array|JSX.Element} - React children from parsed HTML + */ +export function parseJSX( htmlString ) { + // Wrap in div to ensure valid HTML structure + htmlString = '
' + htmlString + '
'; + + // Handle self-closing InnerBlocks tags (not valid HTML, but used in ACF) + htmlString = htmlString.replace( + /]+)?\/>/, + '' + ); + + // Parse with jQuery, convert to React, and extract children from wrapper div + const parsedElement = parseNodeToJSX( jQuery( htmlString )[ 0 ], 0 ); + return parsedElement.props.children; +} + +// Expose parseJSX function on acf global object for backward compatibility +acf.parseJSX = parseJSX; diff --git a/assets/src/js/pro/blocks-v3/high-order-components/with-align-content.js b/assets/src/js/pro/blocks-v3/high-order-components/with-align-content.js new file mode 100644 index 00000000..567123e1 --- /dev/null +++ b/assets/src/js/pro/blocks-v3/high-order-components/with-align-content.js @@ -0,0 +1,120 @@ +/** + * withAlignContent Higher-Order Component + * Adds content alignment toolbar controls to ACF blocks + * Supports both vertical alignment and matrix alignment (horizontal + vertical) + */ + +const { Fragment, Component } = wp.element; +const { BlockControls, BlockVerticalAlignmentToolbar } = wp.blockEditor; + +// Matrix alignment control (experimental) +const BlockAlignmentMatrixControl = + wp.blockEditor.__experimentalBlockAlignmentMatrixControl || + wp.blockEditor.BlockAlignmentMatrixControl; + +const BlockAlignmentMatrixToolbar = + wp.blockEditor.__experimentalBlockAlignmentMatrixToolbar || + wp.blockEditor.BlockAlignmentMatrixToolbar; + +/** + * Normalizes vertical alignment value + * + * @param {string} alignment - Alignment value + * @returns {string} - Normalized alignment (top, center, or bottom) + */ +const normalizeVerticalAlignment = ( alignment ) => { + return [ 'top', 'center', 'bottom' ].includes( alignment ) + ? alignment + : 'top'; +}; + +/** + * Gets the default horizontal alignment based on RTL setting + * + * @param {string} alignment - Current alignment value + * @returns {string} - Normalized alignment value (left, center, or right) + */ +const getDefaultHorizontalAlignment = ( alignment ) => { + const defaultAlign = acf.get( 'rtl' ) ? 'right' : 'left'; + return [ 'left', 'center', 'right' ].includes( alignment ) + ? alignment + : defaultAlign; +}; + +/** + * Normalizes matrix alignment value (vertical + horizontal) + * Format: "top left", "center center", etc. + * + * @param {string} alignment - Alignment value + * @returns {string} - Normalized matrix alignment + */ +const normalizeMatrixAlignment = ( alignment ) => { + if ( alignment ) { + const [ vertical, horizontal ] = alignment.split( ' ' ); + return `${ normalizeVerticalAlignment( + vertical + ) } ${ getDefaultHorizontalAlignment( horizontal ) }`; + } + return 'center center'; +}; + +/** + * Higher-order component that adds content alignment controls + * Supports either vertical-only or matrix (2D) alignment based on block config + * + * @param {React.Component} BlockComponent - The component to wrap + * @param {Object} blockConfig - ACF block configuration + * @returns {React.Component} - Enhanced component with content alignment controls + */ +export const withAlignContent = ( BlockComponent, blockConfig ) => { + let AlignmentControl; + let normalizeAlignment; + + // Determine which alignment control to use based on block supports + if ( + blockConfig.supports.align_content === 'matrix' || + blockConfig.supports.alignContent === 'matrix' + ) { + // Use matrix control (horizontal + vertical) + AlignmentControl = + BlockAlignmentMatrixControl || BlockAlignmentMatrixToolbar; + normalizeAlignment = normalizeMatrixAlignment; + } else { + // Use vertical-only control + AlignmentControl = BlockVerticalAlignmentToolbar; + normalizeAlignment = normalizeVerticalAlignment; + } + + // If alignment control is not available, return original component + if ( AlignmentControl === undefined ) { + return BlockComponent; + } + + // Set default alignment on block config + blockConfig.alignContent = normalizeAlignment( blockConfig.alignContent ); + + return class extends Component { + render() { + const { attributes, setAttributes } = this.props; + const { alignContent } = attributes; + + return ( + + + + + + + ); + } + }; +}; diff --git a/assets/src/js/pro/blocks-v3/high-order-components/with-align-text.js b/assets/src/js/pro/blocks-v3/high-order-components/with-align-text.js new file mode 100644 index 00000000..27d6e353 --- /dev/null +++ b/assets/src/js/pro/blocks-v3/high-order-components/with-align-text.js @@ -0,0 +1,59 @@ +/** + * withAlignText Higher-Order Component + * Adds text alignment toolbar controls to ACF blocks + */ + +const { Fragment, Component } = wp.element; +const { BlockControls, AlignmentToolbar } = wp.blockEditor; + +/** + * Gets the default text alignment based on RTL setting + * + * @param {string} alignment - Current alignment value + * @returns {string} - Normalized alignment value (left, center, or right) + */ +const getDefaultAlignment = ( alignment ) => { + const defaultAlign = acf.get( 'rtl' ) ? 'right' : 'left'; + return [ 'left', 'center', 'right' ].includes( alignment ) + ? alignment + : defaultAlign; +}; + +/** + * Higher-order component that adds text alignment controls + * Wraps a block component and adds AlignmentToolbar to BlockControls + * + * @param {React.Component} BlockComponent - The component to wrap + * @param {Object} blockConfig - ACF block configuration + * @returns {React.Component} - Enhanced component with text alignment controls + */ +export const withAlignText = ( BlockComponent, blockConfig ) => { + const normalizeAlignment = getDefaultAlignment; + + // Set default alignment on block config + blockConfig.alignText = normalizeAlignment( blockConfig.alignText ); + + return class extends Component { + render() { + const { attributes, setAttributes } = this.props; + const { alignText } = attributes; + + return ( + + + + + + + ); + } + }; +}; diff --git a/assets/src/js/pro/blocks-v3/high-order-components/with-full-height.js b/assets/src/js/pro/blocks-v3/high-order-components/with-full-height.js new file mode 100644 index 00000000..50782b20 --- /dev/null +++ b/assets/src/js/pro/blocks-v3/high-order-components/with-full-height.js @@ -0,0 +1,48 @@ +/** + * withFullHeight Higher-Order Component + * Adds full height toggle control to ACF blocks + */ + +const { Fragment, Component } = wp.element; +const { BlockControls } = wp.blockEditor; + +// Full height control (experimental) +const BlockFullHeightAlignmentControl = + wp.blockEditor.__experimentalBlockFullHeightAligmentControl || + wp.blockEditor.__experimentalBlockFullHeightAlignmentControl || + wp.blockEditor.BlockFullHeightAlignmentControl; + +/** + * Higher-order component that adds full height toggle controls + * Allows blocks to expand to full available height + * + * @param {React.Component} BlockComponent - The component to wrap + * @returns {React.Component} - Enhanced component with full height controls + */ +export const withFullHeight = ( BlockComponent ) => { + // If control is not available, return original component + if ( ! BlockFullHeightAlignmentControl ) { + return BlockComponent; + } + + return class extends Component { + render() { + const { attributes, setAttributes } = this.props; + const { fullHeight } = attributes; + + return ( + + + + + + + ); + } + }; +}; diff --git a/assets/src/js/pro/blocks-v3/register-block-type-v3.js b/assets/src/js/pro/blocks-v3/register-block-type-v3.js new file mode 100644 index 00000000..ba44bdb4 --- /dev/null +++ b/assets/src/js/pro/blocks-v3/register-block-type-v3.js @@ -0,0 +1,364 @@ +/** + * ACF Block Type Registration - Version 3 + * Handles registration of ACF blocks (version 3) with WordPress Gutenberg + * Includes attribute setup, higher-order component composition, and block filtering + */ + +import jQuery from 'jquery'; +import { BlockEdit } from './components/block-edit'; +import { withAlignText } from './high-order-components/with-align-text'; +import { withAlignContent } from './high-order-components/with-align-content'; +import { withFullHeight } from './high-order-components/with-full-height'; + +const { InnerBlocks } = wp.blockEditor; +const { Component } = wp.element; +const { createHigherOrderComponent } = wp.compose; + +// Registry to store registered block configurations +const registeredBlocks = {}; + +/** + * Adds an attribute to the block configuration + * + * @param {Object} attributes - Existing attributes object + * @param {string} attributeName - Name of the attribute to add + * @param {string} attributeType - Type of the attribute (string, boolean, etc.) + * @returns {Object} - Updated attributes object + */ +const addAttribute = ( attributes, attributeName, attributeType ) => { + attributes[ attributeName ] = { type: attributeType }; + return attributes; +}; + +/** + * Checks if block should be registered for current post type + * + * @param {Object} blockConfig - Block configuration + * @returns {boolean} - True if block should be registered + */ +function shouldRegisterBlock( blockConfig ) { + const allowedPostTypes = blockConfig.post_types || []; + + if ( allowedPostTypes.length ) { + // Always allow in reusable blocks + allowedPostTypes.push( 'wp_block' ); + + const currentPostType = acf.get( 'postType' ); + if ( ! allowedPostTypes.includes( currentPostType ) ) { + return false; + } + } + + return true; +} + +/** + * Processes and normalizes block icon + * + * @param {Object} blockConfig - Block configuration + */ +function processBlockIcon( blockConfig ) { + // Convert SVG string to JSX element + if ( + typeof blockConfig.icon === 'string' && + blockConfig.icon.substr( 0, 4 ) === ' + ); + } + + // Remove icon if empty/invalid + if ( ! blockConfig.icon ) { + delete blockConfig.icon; + } +} + +/** + * Validates and normalizes block category + * Falls back to 'common' if category doesn't exist + * + * @param {Object} blockConfig - Block configuration + */ +function validateBlockCategory( blockConfig ) { + const categoryExists = wp.blocks + .getCategories() + .filter( ( { slug } ) => slug === blockConfig.category ) + .pop(); + + if ( ! categoryExists ) { + blockConfig.category = 'common'; + } +} + +/** + * Sets default values for block configuration + * + * @param {Object} blockConfig - Block configuration + * @returns {Object} - Block configuration with defaults applied + */ +function applyBlockDefaults( blockConfig ) { + return acf.parseArgs( blockConfig, { + title: '', + name: '', + category: '', + api_version: 2, + acf_block_version: 3, + attributes: {}, + supports: {}, + } ); +} + +/** + * Cleans up block attributes + * Removes empty default values + * + * @param {Object} blockConfig - Block configuration + */ +function cleanBlockAttributes( blockConfig ) { + for ( const attributeName in blockConfig.attributes ) { + if ( + 'default' in blockConfig.attributes[ attributeName ] && + blockConfig.attributes[ attributeName ].default.length === 0 + ) { + delete blockConfig.attributes[ attributeName ].default; + } + } +} + +/** + * Configures anchor support if enabled + * + * @param {Object} blockConfig - Block configuration + */ +function configureAnchorSupport( blockConfig ) { + if ( blockConfig.supports && blockConfig.supports.anchor ) { + blockConfig.attributes.anchor = { type: 'string' }; + } +} + +/** + * Applies higher-order components based on block supports + * + * @param {React.Component} EditComponent - Base edit component + * @param {Object} blockConfig - Block configuration + * @returns {React.Component} - Enhanced edit component + */ +function applyHigherOrderComponents( EditComponent, blockConfig ) { + let enhancedComponent = EditComponent; + + // Add text alignment support + if ( blockConfig.supports.alignText || blockConfig.supports.align_text ) { + blockConfig.attributes = addAttribute( + blockConfig.attributes, + 'align_text', + 'string' + ); + enhancedComponent = withAlignText( enhancedComponent, blockConfig ); + } + + // Add content alignment support + if ( + blockConfig.supports.alignContent || + blockConfig.supports.align_content + ) { + blockConfig.attributes = addAttribute( + blockConfig.attributes, + 'align_content', + 'string' + ); + enhancedComponent = withAlignContent( enhancedComponent, blockConfig ); + } + + // Add full height support + if ( blockConfig.supports.fullHeight || blockConfig.supports.full_height ) { + blockConfig.attributes = addAttribute( + blockConfig.attributes, + 'full_height', + 'boolean' + ); + enhancedComponent = withFullHeight( enhancedComponent ); + } + + return enhancedComponent; +} + +/** + * Registers an ACF block type (version 3) with WordPress + * + * @param {Object} blockConfig - ACF block configuration object + * @returns {Object|boolean} - Registered block type or false if not registered + */ +function registerACFBlockType( blockConfig ) { + // Check if block should be registered for current post type + if ( ! shouldRegisterBlock( blockConfig ) ) { + return false; + } + + // Process icon + processBlockIcon( blockConfig ); + + // Validate category + validateBlockCategory( blockConfig ); + + // Apply default values + blockConfig = applyBlockDefaults( blockConfig ); + + // Clean up attributes + cleanBlockAttributes( blockConfig ); + + // Configure anchor support + configureAnchorSupport( blockConfig ); + + // Start with base BlockEdit component + let EditComponent = BlockEdit; + + // Apply higher-order components based on supports + EditComponent = applyHigherOrderComponents( EditComponent, blockConfig ); + + // Create edit function that passes blockConfig and jQuery + blockConfig.edit = function ( props ) { + return ( + + ); + }; + + // Create save function (ACF blocks save to post content as HTML comments) + blockConfig.save = () => ; + + // Store in registry + registeredBlocks[ blockConfig.name ] = blockConfig; + + // Register with WordPress + const registeredBlockType = wp.blocks.registerBlockType( + blockConfig.name, + blockConfig + ); + + // Ensure anchor attribute is properly configured + if ( + registeredBlockType && + registeredBlockType.attributes && + registeredBlockType.attributes.anchor + ) { + registeredBlockType.attributes.anchor = { type: 'string' }; + } + + return registeredBlockType; +} + +/** + * Retrieves a registered block configuration by name + * + * @param {string} blockName - Name of the block + * @returns {Object|boolean} - Block configuration or false + */ +function getRegisteredBlock( blockName ) { + return registeredBlocks[ blockName ] || false; +} + +/** + * Higher-order component to migrate legacy attribute names to new format + * Handles backward compatibility for align_text -> alignText, etc. + */ +const withDefaultAttributes = createHigherOrderComponent( + ( BlockListBlock ) => + class extends Component { + constructor( props ) { + super( props ); + + const { name, attributes } = this.props; + const blockConfig = getRegisteredBlock( name ); + + if ( ! blockConfig ) return; + + // Remove empty string attributes + Object.keys( attributes ).forEach( ( key ) => { + if ( attributes[ key ] === '' ) { + delete attributes[ key ]; + } + } ); + + // Map old attribute names to new camelCase names + const attributeMap = { + full_height: 'fullHeight', + align_content: 'alignContent', + align_text: 'alignText', + }; + + Object.keys( attributeMap ).forEach( ( oldKey ) => { + const newKey = attributeMap[ oldKey ]; + + if ( attributes[ oldKey ] !== undefined ) { + // Migrate old key to new key + attributes[ newKey ] = attributes[ oldKey ]; + } else if ( + attributes[ newKey ] === undefined && + blockConfig[ oldKey ] !== undefined + ) { + // Set default from block config if not present + attributes[ newKey ] = blockConfig[ oldKey ]; + } + + // Clean up old attribute names + delete blockConfig[ oldKey ]; + delete attributes[ oldKey ]; + } ); + + // Apply default values from block config for missing attributes + for ( let key in blockConfig.attributes ) { + if ( + attributes[ key ] === undefined && + blockConfig[ key ] !== undefined + ) { + attributes[ key ] = blockConfig[ key ]; + } + } + } + + render() { + return ; + } + }, + 'withDefaultAttributes' +); + +/** + * Initialize ACF blocks on the 'prepare' action + * Registers all ACF blocks with version 3 or higher + */ +acf.addAction( 'prepare', function () { + // Ensure wp.blockEditor exists (backward compatibility) + if ( ! wp.blockEditor ) { + wp.blockEditor = wp.editor; + } + + const blockTypes = acf.get( 'blockTypes' ); + + if ( blockTypes ) { + blockTypes.forEach( ( blockType ) => { + // Only register blocks with version 3 or higher + if ( parseInt( blockType.acf_block_version ) >= 3 ) { + registerACFBlockType( blockType ); + } + } ); + } +} ); + +/** + * Register WordPress filter for attribute migration + * Ensures backward compatibility with legacy attribute names + */ +wp.hooks.addFilter( + 'editor.BlockListBlock', + 'acf/with-default-attributes', + withDefaultAttributes +); + +// Export for testing/external use +export { registerACFBlockType, getRegisteredBlock }; diff --git a/assets/src/js/pro/blocks-v3/utils/post-locking.js b/assets/src/js/pro/blocks-v3/utils/post-locking.js new file mode 100644 index 00000000..c0797133 --- /dev/null +++ b/assets/src/js/pro/blocks-v3/utils/post-locking.js @@ -0,0 +1,73 @@ +/** + * WordPress post locking utilities for ACF blocks + * Handles locking/unlocking post saving during block operations + */ + +/** + * Locks post saving in the WordPress editor for a specific block + * Used when block operations are in progress for a specific block instance + * + * @param {string} clientId - The block's client ID + */ +export const lockPostSaving = ( clientId ) => { + const dispatch = wp.data.dispatch( 'core/editor' ); + if ( dispatch ) { + dispatch.lockPostSaving( 'acf/block/' + clientId ); + } +}; + +/** + * Unlocks post saving in the WordPress editor for a specific block + * Called when block operations are complete for a specific block instance + * + * @param {string} clientId - The block's client ID + */ +export const unlockPostSaving = ( clientId ) => { + const dispatch = wp.data.dispatch( 'core/editor' ); + if ( dispatch ) { + dispatch.unlockPostSaving( 'acf/block/' + clientId ); + } +}; + +/** + * Locks post saving with a custom lock name + * Used for global operations that aren't tied to a specific block + * + * @param {string} lockName - The name of the lock + */ +export const lockPostSavingByName = ( lockName ) => { + const dispatch = wp.data.dispatch( 'core/editor' ); + if ( dispatch ) { + dispatch.lockPostSaving( 'acf/block/' + lockName ); + } +}; + +/** + * Unlocks post saving with a custom lock name + * Used for global operations that aren't tied to a specific block + * + * @param {string} lockName - The name of the lock + */ +export const unlockPostSavingByName = ( lockName ) => { + const dispatch = wp.data.dispatch( 'core/editor' ); + if ( dispatch ) { + dispatch.unlockPostSaving( 'acf/block/' + lockName ); + } +}; + +/** + * Sorts an object's keys alphabetically and returns a new object + * Used for consistent object serialization and comparison + * Ensures that objects with same properties in different order produce same hash + * + * @param {Object} obj - Object to sort + * @returns {Object} - New object with sorted keys in alphabetical order + */ +export const sortObjectKeys = ( obj ) => { + return Object.keys( obj ) + .sort() + .reduce( ( sortedObj, key ) => { + sortedObj[ key ] = obj[ key ]; + return sortedObj; + }, {} ); +}; diff --git a/assets/src/sass/acf-field-group.scss b/assets/src/sass/acf-field-group.scss index c9b237b6..6a12da94 100644 --- a/assets/src/sass/acf-field-group.scss +++ b/assets/src/sass/acf-field-group.scss @@ -2260,7 +2260,7 @@ html[dir=rtl] .acf-field-object.open > .handle { border-left-style: solid; border-left-color: #EAECF0; } -#acf-field-group-options .acf-field[data-name=description] { +#acf-field-group-options .acf-field[data-name=description], #acf-field-group-options .acf-field[data-name=display_title] { max-width: 600px; } #acf-field-group-options .acf-button-group { diff --git a/assets/src/sass/acf-global.scss b/assets/src/sass/acf-global.scss index f8a73dd3..07c55be9 100644 --- a/assets/src/sass/acf-global.scss +++ b/assets/src/sass/acf-global.scss @@ -154,9 +154,8 @@ } .acf-admin-page .p5, .acf-admin-page .acf-modal.acf-browse-fields-modal .acf-field-picker .acf-modal-content .acf-field-types-tab .acf-field-type .field-type-label, .acf-modal.acf-browse-fields-modal .acf-field-picker .acf-modal-content .acf-field-types-tab .acf-field-type .acf-admin-page .field-type-label, .acf-admin-page .acf-modal.acf-browse-fields-modal .acf-field-picker .acf-modal-content .acf-field-type-search-results .acf-field-type .field-type-label, -.acf-modal.acf-browse-fields-modal .acf-field-picker .acf-modal-content .acf-field-type-search-results .acf-field-type .acf-admin-page .field-type-label, .acf-admin-page .acf-internal-post-type .row-actions, .acf-internal-post-type .acf-admin-page .row-actions, .acf-admin-page .acf-notice .button, -.acf-admin-page .notice .button, -.acf-admin-page #lost-connection-notice .button { +.acf-modal.acf-browse-fields-modal .acf-field-picker .acf-modal-content .acf-field-type-search-results .acf-field-type .acf-admin-page .field-type-label, .acf-admin-page .acf-internal-post-type .row-actions, +.acf-internal-post-type .acf-admin-page .row-actions { font-size: 12.5px; } .acf-admin-page .p6, .acf-admin-page #acf-update-information .acf-update-changelog p em, #acf-update-information .acf-update-changelog p .acf-admin-page em, .acf-admin-page .acf-no-field-groups-wrapper .acf-no-field-groups-inner p.acf-small, .acf-no-field-groups-wrapper .acf-no-field-groups-inner .acf-admin-page p.acf-small, @@ -210,9 +209,7 @@ .acf-options-preview-wrapper .acf-options-preview-inner .acf-admin-page p.acf-small, .acf-admin-page .acf-internal-post-type .row-actions, .acf-internal-post-type .acf-admin-page .row-actions, .acf-admin-page .acf-small { font-size: 12px; } -.acf-admin-page .p7, .acf-admin-page .acf-tooltip, .acf-admin-page .acf-notice p.help, -.acf-admin-page .notice p.help, -.acf-admin-page #lost-connection-notice p.help { +.acf-admin-page .p7, .acf-admin-page .acf-tooltip { font-size: 11.5px; } .acf-admin-page .p8 { @@ -972,6 +969,68 @@ a.acf-icon.dark.-minus:hover, a.acf-icon.dark.-cancel:hover { border-color: #fc7009; } + +.acf-admin-single-taxonomy .notice-success .acf-item-saved-links, +.acf-admin-single-post-type .notice-success .acf-item-saved-links, +.acf-admin-single-options-page .notice-success .acf-item-saved-links { + display: flex; + gap: 12px +} + +.acf-admin-single-taxonomy .notice-success .acf-item-saved-links a, +.acf-admin-single-post-type .notice-success .acf-item-saved-links a, +.acf-admin-single-options-page .notice-success .acf-item-saved-links a { + text-decoration: none; + opacity: 1 +} + +.acf-admin-single-taxonomy .notice-success .acf-item-saved-links a:after, +.acf-admin-single-post-type .notice-success .acf-item-saved-links a:after, +.acf-admin-single-options-page .notice-success .acf-item-saved-links a:after { + content: ""; + width: 1px; + height: 13px; + display: inline-flex; + position: relative; + top: 2px; + left: 6px; + background-color: #475467; + opacity: .3 +} + +.acf-admin-single-taxonomy .notice-success .acf-item-saved-links a:last-child:after, +.acf-admin-single-post-type .notice-success .acf-item-saved-links a:last-child:after, +.acf-admin-single-options-page .notice-success .acf-item-saved-links a:last-child:after { + content: none +} + +.rtl.acf-field-group .notice, +.rtl.acf-internal-post-type .notice { + padding-right: 50px !important +} + +.rtl.acf-field-group .notice .notice-dismiss, +.rtl.acf-internal-post-type .notice .notice-dismiss { + left: 8px; + right: unset +} + +.rtl.acf-field-group .notice:before, +.rtl.acf-internal-post-type .notice:before { + left: unset; + right: 10px +} + +.rtl.acf-field-group.acf-admin-single-taxonomy .notice-success .acf-item-saved-links a:after, +.rtl.acf-field-group.acf-admin-single-post-type .notice-success .acf-item-saved-links a:after, +.rtl.acf-field-group.acf-admin-single-options-page .notice-success .acf-item-saved-links a:after, +.rtl.acf-internal-post-type.acf-admin-single-taxonomy .notice-success .acf-item-saved-links a:after, +.rtl.acf-internal-post-type.acf-admin-single-post-type .notice-success .acf-item-saved-links a:after, +.rtl.acf-internal-post-type.acf-admin-single-options-page .notice-success .acf-item-saved-links a:after { + left: unset; + right: 6px +} + /*-------------------------------------------------------------------------------------------- * * acf-table @@ -1218,18 +1277,18 @@ html[dir=rtl] #acf-popup .acf-popup-box .title .acf-icon { border-bottom: none; display: flex; align-content: center; - justify-content: space-between + justify-content: space-between; } #acf-popup .acf-confirm-popup .title h3 { color: #101828; font-size: 20px; font-weight: 500; - line-height: 24px + line-height: 24px; } #acf-popup .acf-confirm-popup .title a:focus { - border-radius: 50% + border-radius: 50%; } #acf-popup .acf-confirm-popup .title .acf-icon.-close { @@ -1239,50 +1298,56 @@ html[dir=rtl] #acf-popup .acf-popup-box .title .acf-icon { background: #101828; position: relative; top: auto; - right: auto + right: auto; } #acf-popup .acf-confirm-popup .inner { margin: 0; - padding: 0 30px 30px 30px + padding: 0 30px 30px 30px; } #acf-popup .acf-confirm-popup .inner p { - margin-top: 0 + margin-top: 0; } #acf-popup .acf-confirm-popup .inner .acf-actions { display: flex; justify-content: flex-end; - gap: 5px + gap: 5px; } #acf-popup .acf-confirm-popup .inner .acf-actions .acf-btn-secondary { - border: none + border: none; } #acf-popup .acf-confirm-popup .inner .acf-actions .acf-btn-secondary:hover { background: none; - text-decoration: underline + text-decoration: underline; } #acf-popup .acf-confirm-popup .inner .acf-actions .acf-btn { border-radius: 2px; border: none; - min-height: 40px + min-height: 40px; } #acf-popup .acf-confirm-popup .inner .acf-actions .acf-btn.acf-confirm { - background-color: #dd1243 + background-color: #dd1243; } #acf-popup .acf-confirm-popup .inner .acf-actions .acf-btn.acf-confirm:hover { - background-color: rgb(173.8410041841, 14.1589958159, 52.7029288703) + background-color: rgb(173.8410041841, 14.1589958159, 52.7029288703); +} + +@media only screen and (max-width: 782px) { + #acf-popup .acf-confirm-popup { + width: auto !important; + } } body:not(.block-editor-page) #acf-popup .acf-confirm-popup .inner .acf-btn { min-height: 30px; - border-radius: 3px + border-radius: 3px; } /*-------------------------------------------------------------------------------------------- @@ -1290,68 +1355,45 @@ body:not(.block-editor-page) #acf-popup .acf-confirm-popup .inner .acf-btn { * upgrade notice * *--------------------------------------------------------------------------------------------*/ -#acf-upgrade-notice { - position: relative; - background: #fff; - padding: 20px; -} -#acf-upgrade-notice:after { - display: block; - clear: both; - content: ""; +#acf-upgrade-notice .notice-container { + display: flex; + justify-content: space-between; + align-items: center; + align-content: flex-start; + padding: 10px 0; } #acf-upgrade-notice .col-content { float: left; - width: 55%; - padding-left: 90px; -} -#acf-upgrade-notice .notice-container { - display: flex; - justify-content: space-between; - align-items: flex-start; - align-content: flex-start; + width: 58%; + padding-left: 66px; } #acf-upgrade-notice .col-actions { float: right; text-align: center; + margin-right: 5px; } #acf-upgrade-notice img { float: left; - width: 64px; - height: 64px; - margin: 0 0 0 -90px; + width: 48px; + height: 48px; + margin: 10px 4px 0 -62px; + vertical-align: top; } #acf-upgrade-notice h2 { - display: inline-block; - font-size: 16px; - margin: 2px 0 6.5px; -} -#acf-upgrade-notice p { - padding: 0; - margin: 0; -} -#acf-upgrade-notice .button:before { - margin-top: 11px; + font-size: 1em; + font-weight: 500; + margin: 0 0 .3em 0; } @media screen and (max-width: 640px) { #acf-upgrade-notice .col-content, #acf-upgrade-notice .col-actions { float: none; - padding-left: 90px; + padding-left: 66px; width: auto; text-align: left; } } -#acf-upgrade-notice:has(.notice-container)::before, -#acf-upgrade-notice:has(.notice-container)::after { - display: none; -} - -#acf-upgrade-notice:has(.notice-container) { - padding-left: 20px !important; -} - /*-------------------------------------------------------------------------------------------- * * Welcome @@ -2277,287 +2319,6 @@ html[dir=rtl] .acf-table > tbody > tr > td.order + td { margin-right: 8px; } -/*-------------------------------------------------------------------------------------------- -* -* Notices -* -*--------------------------------------------------------------------------------------------*/ -.acf-admin-page .acf-notice, -.acf-admin-page .notice, -.acf-admin-page #lost-connection-notice { - position: relative; - box-sizing: border-box; - min-height: 48px; - margin-top: 0 !important; - margin-right: 0 !important; - margin-bottom: 16px !important; - margin-left: 0 !important; - padding-top: 13px !important; - padding-right: 16px; - padding-bottom: 12px !important; - padding-left: 50px !important; - background-color: #e7eff9; - border-width: 1px; - border-style: solid; - border-color: #9dbaee; - border-radius: 8px; - box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.1); - color: #344054; -} -.acf-admin-page .acf-notice.update-nag, -.acf-admin-page .notice.update-nag, -.acf-admin-page #lost-connection-notice.update-nag { - display: block; - position: relative; - width: calc(100% - 44px); - margin-top: 48px !important; - margin-right: 44px !important; - margin-bottom: -32px !important; - margin-left: 12px !important; -} -.acf-admin-page .acf-notice .button, -.acf-admin-page .notice .button, -.acf-admin-page #lost-connection-notice .button { - height: auto; - margin-left: 8px; - padding: 0; - border: none; -} -.acf-admin-page .acf-notice > div, -.acf-admin-page .notice > div, -.acf-admin-page #lost-connection-notice > div { - margin-top: 0; - margin-bottom: 0; -} -.acf-admin-page .acf-notice p, -.acf-admin-page .notice p, -.acf-admin-page #lost-connection-notice p { - flex: 1 0 auto; - max-width: 100%; - line-height: 18px; - margin: 0; - padding: 0; -} -.acf-admin-page .acf-notice p.help, -.acf-admin-page .notice p.help, -.acf-admin-page #lost-connection-notice p.help { - margin-top: 0; - padding-top: 0; - color: rgba(52, 64, 84, 0.7); -} -.acf-admin-page .acf-notice .acf-notice-dismiss, -.acf-admin-page .acf-notice .notice-dismiss, -.acf-admin-page .notice .acf-notice-dismiss, -.acf-admin-page .notice .notice-dismiss, -.acf-admin-page #lost-connection-notice .acf-notice-dismiss, -.acf-admin-page #lost-connection-notice .notice-dismiss { - position: absolute; - top: 4px; - right: 8px; - padding: 9px; - border: none; -} -.acf-admin-page .acf-notice .acf-notice-dismiss:before, -.acf-admin-page .acf-notice .notice-dismiss:before, -.acf-admin-page .notice .acf-notice-dismiss:before, -.acf-admin-page .notice .notice-dismiss:before, -.acf-admin-page #lost-connection-notice .acf-notice-dismiss:before, -.acf-admin-page #lost-connection-notice .notice-dismiss:before { - content: ""; - display: block; - position: relative; - z-index: 600; - width: 20px; - height: 20px; - background-color: #667085; - border: none; - border-radius: 0; - -webkit-mask-size: contain; - mask-size: contain; - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; - -webkit-mask-position: center; - mask-position: center; - -webkit-mask-image: url("../../images/icons/icon-close.svg"); - mask-image: url("../../images/icons/icon-close.svg"); -} -.acf-admin-page .acf-notice .acf-notice-dismiss:hover::before, -.acf-admin-page .acf-notice .notice-dismiss:hover::before, -.acf-admin-page .notice .acf-notice-dismiss:hover::before, -.acf-admin-page .notice .notice-dismiss:hover::before, -.acf-admin-page #lost-connection-notice .acf-notice-dismiss:hover::before, -.acf-admin-page #lost-connection-notice .notice-dismiss:hover::before { - background-color: #344054; -} -.acf-admin-page .acf-notice a.acf-notice-dismiss, -.acf-admin-page .notice a.acf-notice-dismiss, -.acf-admin-page #lost-connection-notice a.acf-notice-dismiss { - position: absolute; - top: 5px; - right: 24px; -} -.acf-admin-page .acf-notice a.acf-notice-dismiss:before, -.acf-admin-page .notice a.acf-notice-dismiss:before, -.acf-admin-page #lost-connection-notice a.acf-notice-dismiss:before { - background-color: #475467; -} -.acf-admin-page .acf-notice:before, -.acf-admin-page .notice:before, -.acf-admin-page #lost-connection-notice:before { - content: ""; - display: block; - position: absolute; - top: 15px; - left: 18px; - z-index: 600; - width: 16px; - height: 16px; - margin-right: 8px; - background-color: #fff; - border: none; - border-radius: 0; - -webkit-mask-size: contain; - mask-size: contain; - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; - -webkit-mask-position: center; - mask-position: center; - -webkit-mask-image: url("../../images/icons/icon-info-solid.svg"); - mask-image: url("../../images/icons/icon-info-solid.svg"); -} -.acf-admin-page .acf-notice:after, -.acf-admin-page .notice:after, -.acf-admin-page #lost-connection-notice:after { - content: ""; - display: block; - position: absolute; - top: 9px; - left: 12px; - z-index: 500; - width: 28px; - height: 28px; - background-color: #2D69DA; - border-radius: 6px; - box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.1); -} -.acf-admin-page .acf-notice .local-restore, -.acf-admin-page .notice .local-restore, -.acf-admin-page #lost-connection-notice .local-restore { - align-items: center; - margin-top: -6px; - margin-bottom: 0; -} -.acf-admin-page .notice[data-persisted=true] { - display: none; -} -.acf-admin-page .notice.is-dismissible { - padding-right: 56px; -} -.acf-admin-page .notice.notice-success { - background-color: #edf7ef; - border-color: #b6deb9; -} -.acf-admin-page .notice.notice-success:before { - -webkit-mask-image: url("../../images/icons/icon-check-circle-solid.svg"); - mask-image: url("../../images/icons/icon-check-circle-solid.svg"); -} -.acf-admin-page .notice.notice-success:after { - background-color: #52AA59; -} -.acf-admin-page .acf-notice.acf-error-message, -.acf-admin-page .notice.notice-error, -.acf-admin-page #lost-connection-notice { - background-color: #f7eeeb; - border-color: #f1b6b3; -} -.acf-admin-page .acf-notice.acf-error-message:before, -.acf-admin-page .notice.notice-error:before, -.acf-admin-page #lost-connection-notice:before { - -webkit-mask-image: url("../../images/icons/icon-warning.svg"); - mask-image: url("../../images/icons/icon-warning.svg"); -} -.acf-admin-page .acf-notice.acf-error-message:after, -.acf-admin-page .notice.notice-error:after, -.acf-admin-page #lost-connection-notice:after { - background-color: #D13737; -} -.acf-admin-page .notice.notice-warning { - background: linear-gradient(0deg, rgba(247, 144, 9, 0.08), rgba(247, 144, 9, 0.08)), #FFFFFF; - border: 1px solid rgba(247, 144, 9, 0.32); - color: #344054; -} -.acf-admin-page .notice.notice-warning:before { - -webkit-mask-image: url("../../images/icons/icon-alert-triangle.svg"); - mask-image: url("../../images/icons/icon-alert-triangle.svg"); - background: #f56e28; -} -.acf-admin-page .notice.notice-warning:after { - content: none; -} - -.acf-admin-single-taxonomy .notice-success .acf-item-saved-text, -.acf-admin-single-post-type .notice-success .acf-item-saved-text, -.acf-admin-single-options-page .notice-success .acf-item-saved-text { - font-weight: 600; -} -.acf-admin-single-taxonomy .notice-success .acf-item-saved-links, -.acf-admin-single-post-type .notice-success .acf-item-saved-links, -.acf-admin-single-options-page .notice-success .acf-item-saved-links { - display: flex; - gap: 12px; -} -.acf-admin-single-taxonomy .notice-success .acf-item-saved-links a, -.acf-admin-single-post-type .notice-success .acf-item-saved-links a, -.acf-admin-single-options-page .notice-success .acf-item-saved-links a { - text-decoration: none; - opacity: 1; -} -.acf-admin-single-taxonomy .notice-success .acf-item-saved-links a:after, -.acf-admin-single-post-type .notice-success .acf-item-saved-links a:after, -.acf-admin-single-options-page .notice-success .acf-item-saved-links a:after { - content: ""; - width: 1px; - height: 13px; - display: inline-flex; - position: relative; - top: 2px; - left: 6px; - background-color: #475467; - opacity: 0.3; -} -.acf-admin-single-taxonomy .notice-success .acf-item-saved-links a:last-child:after, -.acf-admin-single-post-type .notice-success .acf-item-saved-links a:last-child:after, -.acf-admin-single-options-page .notice-success .acf-item-saved-links a:last-child:after { - content: none; -} - -.rtl.acf-field-group .notice, -.rtl.acf-internal-post-type .notice { - padding-right: 50px !important; -} -.rtl.acf-field-group .notice .notice-dismiss, -.rtl.acf-internal-post-type .notice .notice-dismiss { - left: 8px; - right: unset; -} -.rtl.acf-field-group .notice:before, -.rtl.acf-internal-post-type .notice:before { - left: unset; - right: 10px; -} -.rtl.acf-field-group .notice:after, -.rtl.acf-internal-post-type .notice:after { - left: unset; - right: 12px; -} -.rtl.acf-field-group.acf-admin-single-taxonomy .notice-success .acf-item-saved-links a:after, .rtl.acf-field-group.acf-admin-single-post-type .notice-success .acf-item-saved-links a:after, .rtl.acf-field-group.acf-admin-single-options-page .notice-success .acf-item-saved-links a:after, -.rtl.acf-internal-post-type.acf-admin-single-taxonomy .notice-success .acf-item-saved-links a:after, -.rtl.acf-internal-post-type.acf-admin-single-post-type .notice-success .acf-item-saved-links a:after, -.rtl.acf-internal-post-type.acf-admin-single-options-page .notice-success .acf-item-saved-links a:after { - left: unset; - right: 6px; -} - /*-------------------------------------------------------------------------------------------- * * ACF PRO label @@ -2579,6 +2340,10 @@ html[dir=rtl] .acf-table > tbody > tr > td.order + td { * Inline notice overrides * *--------------------------------------------------------------------------------------------*/ + +.acf-admin-page .notice[data-persisted=true] { + display: none; +} .acf-admin-page .acf-field .acf-notice { display: flex; align-items: center; @@ -5730,8 +5495,6 @@ h3.acf-sub-field-list-title:before, align-items: flex-start; } -.custom-fields_page_acf-settings-updates .acf-admin-notice, -.custom-fields_page_acf-settings-updates .acf-upgrade-notice, .custom-fields_page_acf-settings-updates .notice { flex: 1 1 100%; } @@ -5764,7 +5527,7 @@ h3.acf-sub-field-list-title:before, * Notices * *---------------------------------------------------------------------------------------------*/ -.acf-settings-wrap.acf-updates .acf-admin-notice { +.acf-settings-wrap.acf-updates .notice { flex: 1 1 100%; margin-top: 16px; margin-right: 0; diff --git a/assets/src/sass/acf-input.scss b/assets/src/sass/acf-input.scss index f9344ef1..2300b8b4 100644 --- a/assets/src/sass/acf-input.scss +++ b/assets/src/sass/acf-input.scss @@ -655,6 +655,52 @@ html[dir=rtl] input.acf-is-prepended.acf-is-appended { position: relative; z-index: 1; } +.acf-color-picker.acf-color-picker-large-custom-palette .iris-picker { + display: flex; + flex-direction: column; + height: inherit !important; +} + +.acf-color-picker.acf-color-picker-large-custom-palette .iris-picker .iris-picker-inner { + position: initial; + margin: 10px; +} + +.acf-color-picker.acf-color-picker-large-custom-palette .iris-picker .iris-palette-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 4px; + position: relative; + padding-top: 10px; +} + +.acf-color-picker.acf-color-picker-large-custom-palette .iris-picker .iris-palette-container .iris-palette { + width: 20px !important; + height: 20px !important; + margin: 0 !important; +} + +.acf-color-picker.acf-hide-color-picker-color-wheel:not(.acf-color-picker-large-custom-palette) .iris-picker { + width: inherit !important; + margin-right: 25px !important; +} + +.acf-color-picker.acf-hide-color-picker-color-wheel .iris-picker { + height: inherit !important; + padding: 10px 0 !important; +} + +.acf-color-picker.acf-hide-color-picker-color-wheel .iris-picker .iris-picker-inner { + display: none; +} + +.acf-color-picker.acf-hide-color-picker-color-wheel .iris-picker .iris-palette-container { + display: flex; + position: relative; + bottom: inherit; + padding-top: 0 !important; +} /*----------------------------------------------------------------------------- * @@ -938,8 +984,8 @@ ul.acf-checkbox-list { /* hl */ /* rtl */ } -ul.acf-radio-list:focus-within, -ul.acf-checkbox-list:focus-within { +ul.acf-radio-list:focus-visible, +ul.acf-checkbox-list:focus-visible { border: 1px solid #A5D2E7; border-radius: 6px; } @@ -1007,6 +1053,7 @@ html[dir=rtl] ul.acf-checkbox-list input[type=radio] { z-index: 1; padding: 5px 10px; background: #fff; + cursor: pointer; } .acf-button-group label:hover { color: #016087; @@ -1014,6 +1061,15 @@ html[dir=rtl] ul.acf-checkbox-list input[type=radio] { border-color: #0071a1; z-index: 2; } + +.acf-button-group label:focus-visible { + color: #016087; + background: #f3f5f6; + border-color: #0071a1; + box-shadow: 0 0 0 1px #fff, 0 0 0 3px #007cba; + z-index: 2; + outline: none; +} .acf-button-group label.selected { border-color: #007cba; background: #008dd4; @@ -1097,6 +1153,11 @@ html[dir=rtl] .acf-button-group label:last-child { .acf-admin-page .acf-button-group label:hover { color: #0783BE; } +.acf-admin-page .acf-button-group label:focus-visible { + color: #0783be; + box-shadow: 0 0 0 1px #fff, 0 0 0 3px #0783be; + outline: none; +} .acf-admin-page .acf-button-group label.selected { background: #F9FAFB; color: #0783BE; @@ -2048,6 +2109,29 @@ html[dir=rtl] .form-table > tbody > tr.acf-tab-wrap .acf-tab-group { float: left; /* hover */ } + +.acf-image-uploader .image-wrap.show-if-value .acf-actions { + display: block; + opacity: 0; + text-align: right; + z-index: 1; +} + +.acf-image-uploader .image-wrap.show-if-value .acf-actions.-hover a:focus { + outline: 2px solid #0073aa; + outline-offset: 2px; + border-radius: 2px; +} + +.acf-image-uploader .image-wrap.show-if-value:hover .acf-actions, +.acf-image-uploader .image-wrap.show-if-value:focus-within .acf-actions { + opacity: 1; +} + +.acf-image-uploader .image-wrap:focus { + outline: 2px solid #0073aa; + outline-offset: 2px; +} .acf-image-uploader .image-wrap img { max-width: 100%; max-height: 100%; @@ -2065,9 +2149,6 @@ html[dir=rtl] .form-table > tbody > tr.acf-tab-wrap .acf-tab-group { min-height: 100px; min-width: 100px; } -.acf-image-uploader .image-wrap:hover .acf-actions { - display: block; -} .acf-image-uploader input.button { width: auto; } @@ -2094,6 +2175,10 @@ html[dir=rtl] .acf-image-uploader .image-wrap { position: relative; background: #fff; } +.acf-file-uploader .file-wrap:focus { + outline: 2px solid #0073aa; + outline-offset: 2px; +} .acf-file-uploader .file-icon { position: absolute; top: 0; @@ -2122,6 +2207,24 @@ html[dir=rtl] .acf-image-uploader .image-wrap { .acf-file-uploader .file-info a { text-decoration: none; } + +.acf-file-uploader.has-value .acf-actions { + display: block; + opacity: 0; + text-align: right; + z-index: 1; +} + +.acf-file-uploader.has-value .acf-actions.-hover a:focus { + outline: 2px solid #0073aa; + outline-offset: 2px; + border-radius: 2px; +} + +.acf-file-uploader.has-value:hover .acf-actions, +.acf-file-uploader.has-value:focus-within .acf-actions { + opacity: 1; +} .acf-file-uploader:hover .acf-actions { display: block; } @@ -3268,4 +3371,8 @@ body.is-dragging-metaboxes #acf_after_title-sortables { display: flow-root; min-height: 60px; margin-bottom: 3px !important; +} + +.editor-sidebar__panel .is-side #poststuff .acf-postbox .postbox-header { + margin-top: -1px } \ No newline at end of file diff --git a/assets/src/sass/pro/_blocks.scss b/assets/src/sass/pro/_blocks.scss index e82f4448..280acaff 100644 --- a/assets/src/sass/pro/_blocks.scss +++ b/assets/src/sass/pro/_blocks.scss @@ -233,3 +233,93 @@ .components-panel__body .acf-block-panel { margin: 16px -16px -16px; } + + +@media(min-width: 600px)and (min-width: 782px) { + .acf-block-form-modal { + height: 100% !important; + width: 50% !important; + border-radius: 0; + position: absolute; + right: 0; + } + + html[dir=rtl] .acf-block-form-modal { + right: auto; + left: 0 + } + + html[dir=rtl] .acf-block-form-modal .components-modal__header .components-button { + left: 0; + right: auto + } + + @keyframes components-modal__appear-animation { + 0% { + right: -20px; + opacity: 0; + transform: scale(1); + } + + to { + right: 0; + opacity: 1; + transform: scale(1); + } + } + + @keyframes components-modal__disappear-animation { + 0% { + right: 0; + opacity: 1; + transform: scale(1); + } + + to { + right: -20px; + opacity: 0; + transform: scale(1); + } + } + + @keyframes components-modal__appear-animation-rtl { + 0% { + left: -20px; + opacity: 0; + transform: scale(1) + } + + to { + left: 0; + opacity: 1; + transform: scale(1) + } + } + + @keyframes components-modal__disappear-animation-rtl { + 0% { + left: 0; + opacity: 1; + transform: scale(1) + } + + to { + left: -20px; + opacity: 0; + transform: scale(1) + } + } + + html[dir=rtl] .acf-block-form-modal.components-modal__frame { + animation-name: components-modal__appear-animation-rtl !important + } + + html[dir=rtl] .acf-block-form-modal.is-closing { + animation-name: components-modal__disappear-animation-rtl !important + } +} + +.acf-blocks-open-expanded-editor-btn.has-text.has-icon { + width: 100%; + justify-content: center +} \ No newline at end of file diff --git a/assets/src/sass/pro/acf-pro-input.scss b/assets/src/sass/pro/acf-pro-input.scss index 025897c5..63df30ee 100644 --- a/assets/src/sass/pro/acf-pro-input.scss +++ b/assets/src/sass/pro/acf-pro-input.scss @@ -1,4 +1,5 @@ @charset "UTF-8"; +@use './blocks'; /*-------------------------------------------------------------------------------------------- * * Vars diff --git a/includes/acf-field-functions.php b/includes/acf-field-functions.php index a6d25e52..a4aa5f46 100644 --- a/includes/acf-field-functions.php +++ b/includes/acf-field-functions.php @@ -829,7 +829,12 @@ function acf_render_field_label( $field ) { // Output label. if ( $label ) { - echo '' . acf_esc_html( $label ) . ''; + // For multi-choice fields (radio, checkbox, taxonomy, button_group), don't use 'for' attribute but add ID for aria-labelledby + if ( in_array( $field['type'], array( 'radio', 'checkbox', 'taxonomy', 'button_group' ), true ) && $field['id'] ) { + echo ''; + } else { + echo '' . acf_esc_html( $label ) . ''; + } } } diff --git a/includes/acf-field-group-functions.php b/includes/acf-field-group-functions.php index d63013ac..c34d3a9c 100644 --- a/includes/acf-field-group-functions.php +++ b/includes/acf-field-group-functions.php @@ -540,3 +540,31 @@ function acf_field_group_has_location_type( int $post_id, string $location ) { return false; } + + +/** + * Retrieves the field group title, or display title if set. + * + * @since ACF 6.6 + * + * @param array|integer $field_group The field group array or ID. + * @return string The field group title. + */ +function acf_get_field_group_title( $field_group ): string { + if ( is_numeric( $field_group ) ) { + $field_group = acf_get_field_group( $field_group ); + } + + $title = ''; + if ( ! empty( $field_group['title'] ) && is_string( $field_group['title'] ) ) { + $title = $field_group['title']; + } + + // Override with the Display Title if set. + if ( ! empty( $field_group['display_title'] ) && is_string( $field_group['display_title'] ) ) { + $title = $field_group['display_title']; + } + + // Filter and return. + return apply_filters( 'acf/get_field_group_title', esc_html( $title ), $field_group ); +} diff --git a/includes/acf-input-functions.php b/includes/acf-input-functions.php index 4680a889..3e679a7b 100644 --- a/includes/acf-input-functions.php +++ b/includes/acf-input-functions.php @@ -333,7 +333,27 @@ function acf_get_checkbox_input( $attrs = array() ) { // Render. $checked = isset( $attrs['checked'] ); - return ' ' . acf_esc_html( $label ) . ''; + + // Build label attributes array for accessibility and consistency. + $label_attrs = array(); + if ( $checked ) { + $label_attrs['class'] = 'selected'; + } + + if ( ! empty( $attrs['button_group'] ) ) { + unset( $attrs['button_group'] ); + // If tabindex is provided, use it for the label; otherwise, use checked-based default. + if ( isset( $attrs['tabindex'] ) ) { + $label_attrs['tabindex'] = (string) $attrs['tabindex']; + unset( $attrs['tabindex'] ); + } else { + $label_attrs['tabindex'] = $checked ? '0' : '-1'; + } + $label_attrs['role'] = 'radio'; + $label_attrs['aria-checked'] = $checked ? 'true' : 'false'; + } + + return ' ' . acf_esc_html( $label ) . ''; } /** diff --git a/includes/acf-internal-post-type-functions.php b/includes/acf-internal-post-type-functions.php index affeb08d..e06aae9a 100644 --- a/includes/acf-internal-post-type-functions.php +++ b/includes/acf-internal-post-type-functions.php @@ -10,7 +10,7 @@ * Gets an instance of an ACF_Internal_Post_Type. * * @param string $post_type The ACF internal post type to get the instance for. - * @return ACF_Internal_Post_Type|bool The internal post type class instance, or false on failure. + * @return ACF_Internal_Post_Type|boolean The internal post type class instance, or false on failure. */ function acf_get_internal_post_type_instance( $post_type = 'acf-field-group' ) { $store = acf_get_store( 'internal-post-types' ); diff --git a/includes/admin/class-acf-admin-options-page.php b/includes/admin/class-acf-admin-options-page.php index f6cc0061..9a344e38 100644 --- a/includes/admin/class-acf-admin-options-page.php +++ b/includes/admin/class-acf-admin-options-page.php @@ -179,7 +179,6 @@ public function admin_head() { // vars $id = "acf-{$field_group['key']}"; - $title = $field_group['title']; $context = $field_group['position']; $priority = 'high'; $args = array( 'field_group' => $field_group ); @@ -195,7 +194,15 @@ public function admin_head() { $priority = apply_filters( 'acf/input/meta_box_priority', $priority, $field_group ); // add meta box - add_meta_box( $id, esc_html( $title ), array( $this, 'postbox_acf' ), 'acf_options_page', $context, $priority, $args ); + add_meta_box( + $id, + acf_esc_html( acf_get_field_group_title( $field_group ) ), + array( $this, 'postbox_acf' ), + 'acf_options_page', + $context, + $priority, + $args + ); } // foreach } diff --git a/includes/admin/views/acf-field-group/list-empty.php b/includes/admin/views/acf-field-group/list-empty.php index 02f154ba..2c6872f8 100644 --- a/includes/admin/views/acf-field-group/list-empty.php +++ b/includes/admin/views/acf-field-group/list-empty.php @@ -5,7 +5,8 @@ * @package wordpress/secure-custom-fields */ -?> +?> +
diff --git a/includes/admin/views/acf-field-group/location-rule.php b/includes/admin/views/acf-field-group/location-rule.php index a0caae22..44413909 100644 --- a/includes/admin/views/acf-field-group/location-rule.php +++ b/includes/admin/views/acf-field-group/location-rule.php @@ -2,7 +2,6 @@ // vars $prefix = 'acf_field_group[location][' . $rule['group'] . '][' . $rule['id'] . ']'; - ?> diff --git a/includes/admin/views/acf-field-group/locations.php b/includes/admin/views/acf-field-group/locations.php index fe00fb95..25df9992 100644 --- a/includes/admin/views/acf-field-group/locations.php +++ b/includes/admin/views/acf-field-group/locations.php @@ -2,7 +2,6 @@ // global global $field_group; - ?>
diff --git a/includes/admin/views/acf-field-group/options.php b/includes/admin/views/acf-field-group/options.php index 77c61360..a816c7ab 100644 --- a/includes/admin/views/acf-field-group/options.php +++ b/includes/admin/views/acf-field-group/options.php @@ -72,6 +72,8 @@ case 'location_rules': echo '
'; acf_get_view( 'acf-field-group/locations' ); + + do_action( 'acf/field_group/render_additional_location_settings', $field_group ); echo '
'; break; case 'presentation': @@ -164,6 +166,8 @@ 'field' ); + do_action( 'acf/field_group/render_additional_presentation_settings', $field_group ); + echo '
'; echo '
'; @@ -221,8 +225,6 @@ 'prefix' => 'acf_field_group', 'value' => $field_group['active'], 'ui' => 1, - // 'ui_on_text' => __('Active', 'secure-custom-fields'), - // 'ui_off_text' => __('Inactive', 'secure-custom-fields'), ) ); @@ -237,8 +239,6 @@ 'prefix' => 'acf_field_group', 'value' => $field_group['show_in_rest'], 'ui' => 1, - // 'ui_on_text' => __('Active', 'secure-custom-fields'), - // 'ui_off_text' => __('Inactive', 'secure-custom-fields'), ) ); } @@ -257,6 +257,21 @@ 'field' ); + acf_render_field_wrap( + array( + 'label' => __( 'Display Title', 'secure-custom-fields' ), + 'instructions' => __( 'Title shown on the edit screen for the field group meta box to use instead of the field group title', 'secure-custom-fields' ), + 'type' => 'text', + 'name' => 'display_title', + 'prefix' => 'acf_field_group', + 'value' => $field_group['display_title'], + ), + 'div', + 'field' + ); + + do_action( 'acf/field_group/render_additional_group_settings', $field_group ); + /* translators: 1: Post creation date 2: Post creation time */ $acf_created_on = sprintf( __( 'Created on %1$s at %2$s', 'secure-custom-fields' ), get_the_date(), get_the_time() ); ?> diff --git a/includes/admin/views/acf-post-type/list-empty.php b/includes/admin/views/acf-post-type/list-empty.php index f12219fd..0bfc2cca 100644 --- a/includes/admin/views/acf-post-type/list-empty.php +++ b/includes/admin/views/acf-post-type/list-empty.php @@ -5,7 +5,8 @@ * @package wordpress/secure-custom-fields */ -?> +?> +
diff --git a/includes/admin/views/upgrade/network.php b/includes/admin/views/upgrade/network.php index 49ab0457..643d4b1f 100644 --- a/includes/admin/views/upgrade/network.php +++ b/includes/admin/views/upgrade/network.php @@ -60,7 +60,7 @@ ?> class="alternate"> @@ -73,8 +73,16 @@ class="alternate"> - - + + + diff --git a/includes/admin/views/upgrade/notice.php b/includes/admin/views/upgrade/notice.php index bb14df96..603d8f2e 100644 --- a/includes/admin/views/upgrade/notice.php +++ b/includes/admin/views/upgrade/notice.php @@ -19,7 +19,7 @@ } ?> -
+
@@ -33,7 +33,7 @@
- +
diff --git a/includes/ajax/class-acf-ajax-check-screen.php b/includes/ajax/class-acf-ajax-check-screen.php index 6ee8cba8..0fe6ef7d 100644 --- a/includes/ajax/class-acf-ajax-check-screen.php +++ b/includes/ajax/class-acf-ajax-check-screen.php @@ -53,7 +53,7 @@ public function get_response( $request ) { $item = array( 'id' => esc_attr( 'acf-' . $field_group['key'] ), 'key' => esc_attr( $field_group['key'] ), - 'title' => esc_html( $field_group['title'] ), + 'title' => acf_esc_html( acf_get_field_group_title( $field_group ) ), 'position' => esc_attr( $field_group['position'] ), 'classes' => postbox_classes( 'acf-' . $field_group['key'], $args['screen'] ), 'style' => esc_attr( $field_group['style'] ), diff --git a/includes/blocks.php b/includes/blocks.php index c8765dd0..baea5c0d 100644 --- a/includes/blocks.php +++ b/includes/blocks.php @@ -51,6 +51,17 @@ function acf_handle_json_block_registration( $settings, $metadata ) { return $settings; } + /** + * Filters the default ACF block version for blocks registered via block.json. + * + * @since 6.6.0 + * + * @param integer $default_acf_block_version The default ACF block version. + * @param array $settings An array of block settings. + * @return integer + */ + $default_acf_block_version = apply_filters( 'acf/blocks/default_block_version', 2, $settings ); + // Setup SCF defaults. $settings = wp_parse_args( $settings, @@ -64,8 +75,7 @@ function acf_handle_json_block_registration( $settings, $metadata ) { 'uses_context' => array(), 'supports' => array(), 'attributes' => array(), - 'acf_block_version' => 2, - 'api_version' => 2, + 'acf_block_version' => $default_acf_block_version, 'validate' => true, 'validate_on_load' => true, 'use_post_meta' => false, @@ -105,14 +115,15 @@ function acf_handle_json_block_registration( $settings, $metadata ) { // Map custom SCF properties from the SCF key, with localization. $property_mappings = array( - 'renderCallback' => 'render_callback', - 'renderTemplate' => 'render_template', - 'mode' => 'mode', - 'blockVersion' => 'acf_block_version', - 'postTypes' => 'post_types', - 'validate' => 'validate', - 'validateOnLoad' => 'validate_on_load', - 'usePostMeta' => 'use_post_meta', + 'renderCallback' => 'render_callback', + 'renderTemplate' => 'render_template', + 'mode' => 'mode', + 'blockVersion' => 'acf_block_version', + 'postTypes' => 'post_types', + 'validate' => 'validate', + 'validateOnLoad' => 'validate_on_load', + 'usePostMeta' => 'use_post_meta', + 'hideFieldsInSidebar' => 'hide_fields_in_sidebar', ); $textdomain = ! empty( $metadata['textdomain'] ) ? $metadata['textdomain'] : 'secure-custom-fields'; $i18n_schema = get_block_metadata_i18n_schema(); @@ -127,6 +138,17 @@ function acf_handle_json_block_registration( $settings, $metadata ) { } } + if ( isset( $metadata['apiVersion'] ) ) { + // Use the apiVersion defined in block.json if it exists. + $settings['api_version'] = $metadata['apiVersion']; + } elseif ( $settings['acf_block_version'] >= 3 && version_compare( get_bloginfo( 'version' ), '6.3', '>=' ) ) { + // Otherwise, if we're on WP 6.3+ and the block is ACF block version 3 or greater, use apiVersion 3. + $settings['api_version'] = 3; + } else { + // Otherwise, default to apiVersion 2. + $settings['api_version'] = 2; + } + // Add the block name and registration path to settings. $settings['name'] = $metadata['name']; $settings['path'] = dirname( $metadata['file'] ); @@ -198,11 +220,28 @@ function acf_register_block_type( $block ) { // Set ACF required attributes. $block['attributes'] = acf_get_block_type_default_attributes( $block ); - if ( ! isset( $block['api_version'] ) ) { - $block['api_version'] = 2; - } + + /** + * Filters the default ACF block version for blocks registered via acf_register_block_type(). + * + * @since 6.6.0 + * + * @param integer $default_acf_block_version The default ACF block version. + * @param array $block An array of block settings. + * @return integer + */ + $default_acf_block_version = apply_filters( 'acf/blocks/default_block_version', 1, $block ); + if ( ! isset( $block['acf_block_version'] ) ) { - $block['acf_block_version'] = 1; + $block['acf_block_version'] = $default_acf_block_version; + } + + if ( ! isset( $block['api_version'] ) ) { + if ( $block['acf_block_version'] >= 3 && version_compare( get_bloginfo( 'version' ), '6.3', '>=' ) ) { + $block['api_version'] = 3; + } else { + $block['api_version'] = 2; + } } // Add to storage. @@ -559,8 +598,16 @@ function acf_render_block_callback( $attributes, $content = '', $wp_block = null * @return string The block HTML. */ function acf_rendered_block( $attributes, $content = '', $is_preview = false, $post_id = 0, $wp_block = null, $context = false, $is_ajax_render = false ) { - $mode = isset( $attributes['mode'] ) ? $attributes['mode'] : 'auto'; - $form = ( 'edit' === $mode && $is_preview ); + $registry = WP_Block_Type_Registry::get_instance(); + $wp_block_type = $registry->get_registered( $attributes['name'] ); + + if ( isset( $wp_block_type->acf_block_version ) && $wp_block_type->acf_block_version >= 3 ) { + $mode = 'preview'; + $form = false; + } else { + $mode = isset( $attributes['mode'] ) ? $attributes['mode'] : 'auto'; + $form = ( 'edit' === $mode && $is_preview ); + } // If context is available from the WP_Block class object and we have no context of our own, use that. if ( empty( $context ) && ! empty( $wp_block->context ) ) { @@ -754,12 +801,16 @@ function acf_block_render_template( $block, $content, $is_preview, $post_id, $wp $path = locate_template( $block['render_template'] ); } + do_action( 'acf/blocks/pre_block_template_render', $block, $content, $is_preview, $post_id, $wp_block, $context ); + // Include template. if ( file_exists( $path ) ) { include $path; } elseif ( $is_preview ) { echo acf_esc_html( apply_filters( 'acf/blocks/template_not_found_message', '

' . __( 'The render template for this ACF Block was not found', 'secure-custom-fields' ) . '

' ) ); } + + do_action( 'acf/blocks/post_block_template_render', $block, $content, $is_preview, $post_id, $wp_block, $context ); } /** @@ -808,14 +859,17 @@ function acf_enqueue_block_assets() { // Localize text. acf_localize_text( array( - 'Switch to Edit' => __( 'Switch to Edit', 'secure-custom-fields' ), - 'Switch to Preview' => __( 'Switch to Preview', 'secure-custom-fields' ), - 'Change content alignment' => __( 'Change content alignment', 'secure-custom-fields' ), - 'Error previewing block' => __( 'An error occurred when loading the preview for this block.', 'secure-custom-fields' ), - 'Error loading block form' => __( 'An error occurred when loading the block in edit mode.', 'secure-custom-fields' ), - + 'Switch to Edit' => __( 'Switch to Edit', 'secure-custom-fields' ), + 'Switch to Preview' => __( 'Switch to Preview', 'secure-custom-fields' ), + 'Change content alignment' => __( 'Change content alignment', 'secure-custom-fields' ), + 'Error previewing block' => __( 'An error occurred when loading the preview for this block.', 'secure-custom-fields' ), + 'Error loading block form' => __( 'An error occurred when loading the block in edit mode.', 'secure-custom-fields' ), + 'Edit Block' => __( 'Edit Block', 'secure-custom-fields' ), + 'Open Expanded Editor' => __( 'Open Expanded Editor', 'secure-custom-fields' ), + 'Error previewing block v3' => __( 'The preview for this block couldn’t be loaded. Review its content or settings for issues.', 'secure-custom-fields' ), + 'ACF Block' => __( 'ACF Block', 'secure-custom-fields' ), /* translators: %s: Block type title */ - '%s settings' => __( '%s settings', 'secure-custom-fields' ), + '%s settings' => __( '%s settings', 'secure-custom-fields' ), ) ); diff --git a/includes/class-acf-internal-post-type.php b/includes/class-acf-internal-post-type.php index 8808511d..67925f8c 100644 --- a/includes/class-acf-internal-post-type.php +++ b/includes/class-acf-internal-post-type.php @@ -166,7 +166,7 @@ public function get_raw_post( $id = 0 ) { * @since ACF 6.1 * * @param integer|string $id The post ID, key, or name. - * @return WP_Post|bool The post object, or false on failure. + * @return WP_Post|boolean The post object, or false on failure. */ public function get_post_object( $id = 0 ) { if ( is_numeric( $id ) ) { diff --git a/includes/fields/class-acf-field-button-group.php b/includes/fields/class-acf-field-button-group.php index d1000551..36e480fc 100644 --- a/includes/fields/class-acf-field-button-group.php +++ b/includes/fields/class-acf-field-button-group.php @@ -67,10 +67,11 @@ public function render_field( $field ) { // append $buttons[] = array( - 'name' => $field['name'], - 'value' => $_value, - 'label' => $_label, - 'checked' => $checked, + 'name' => $field['name'], + 'value' => $_value, + 'label' => $_label, + 'checked' => $checked, + 'button_group' => true, ); } @@ -79,16 +80,29 @@ public function render_field( $field ) { $buttons[0]['checked'] = true; } + // Ensure roving tabindex when allow_null is enabled and no selection yet. + if ( $field['allow_null'] && null === $selected && ! empty( $buttons ) ) { + $buttons[0]['tabindex'] = '0'; + } + // div - $div = array( 'class' => 'acf-button-group' ); + $div = array( + 'class' => 'acf-button-group', + 'role' => 'radiogroup', + ); + + // Add aria-labelledby if field has an ID for proper screen reader announcement + if ( ! empty( $field['id'] ) ) { + $div['aria-labelledby'] = $field['id'] . '-label'; + } - if ( 'vertical' === acf_maybe_get( $field, 'layout' ) ) { + if ( 'vertical' === $field['layout'] ) { $div['class'] .= ' -vertical'; } - if ( acf_maybe_get( $field, 'class' ) ) { - $div['class'] .= ' ' . acf_maybe_get( $field, 'class' ); + if ( $field['class'] ) { + $div['class'] .= ' ' . $field['class']; } - if ( acf_maybe_get( $field, 'allow_null' ) ) { + if ( $field['allow_null'] ) { $div['data-allow_null'] = 1; } diff --git a/includes/fields/class-acf-field-checkbox.php b/includes/fields/class-acf-field-checkbox.php index 582672be..5935a041 100644 --- a/includes/fields/class-acf-field-checkbox.php +++ b/includes/fields/class-acf-field-checkbox.php @@ -80,8 +80,14 @@ function render_field( $field ) { $li = ''; $ul = array( 'class' => 'acf-checkbox-list', + 'role' => 'group', ); + // Add aria-labelledby if field has an ID for proper screen reader announcement + if ( ! empty( $field['id'] ) ) { + $ul['aria-labelledby'] = $field['id'] . '-label'; + } + // append to class $ul['class'] .= ' ' . ( 'horizontal' === acf_maybe_get( $field, 'layout' ) ? 'acf-hl' : 'acf-bl' ); $ul['class'] .= ' ' . acf_maybe_get( $field, 'class', '' ); diff --git a/includes/fields/class-acf-field-color_picker.php b/includes/fields/class-acf-field-color_picker.php index 98fd1e0f..fd3d6ed9 100644 --- a/includes/fields/class-acf-field-color_picker.php +++ b/includes/fields/class-acf-field-color_picker.php @@ -26,9 +26,12 @@ function initialize() { $this->doc_url = 'https://developer.wordpress.org/secure-custom-fields/features/fields/color-picker/'; $this->tutorial_url = 'https://developer.wordpress.org/secure-custom-fields/features/fields/color-picker/color-picker-tutorial/'; $this->defaults = array( - 'default_value' => '', - 'enable_opacity' => false, - 'return_format' => 'string', // 'string'|'array' + 'default_value' => '', + 'enable_opacity' => false, + 'custom_palette_source' => '', + 'palette_colors' => '', + 'show_color_wheel' => true, + 'return_format' => 'string', // Possible values: 'string' or 'array'. ); } @@ -106,7 +109,7 @@ function input_admin_enqueue_scripts() { * @since ACF 3.6 * @date 23/01/13 */ - function render_field( $field ) { + public function render_field( $field ) { $text_input = acf_get_sub_array( $field, array( 'id', 'class', 'name', 'value' ) ); $hidden_input = acf_get_sub_array( $field, array( 'name', 'value' ) ); $text_input['data-alpha-skip-debounce'] = true; @@ -116,9 +119,55 @@ function render_field( $field ) { $text_input['data-alpha-enabled'] = true; } + // Handle color palette when the theme supports theme.json. + if ( wp_theme_has_theme_json() ) { + // If the field was set to use themejson. + if ( 'themejson' === $field['custom_palette_source'] ) { + $text_input['data-acf-palette-type'] = 'custom'; + + // Get the palette (theme + custom). + $global_settings = wp_get_global_settings(); + $palette = $global_settings['color']['palette']['theme'] ?? array(); + + // Extract only the color values. + $color_values = array_map( + fn( $c ) => $c['color'] ?? null, + $palette + ); + + // Remove nulls (in case any entries are missing 'color') + $color_values = array_filter( $color_values ); + + $hex_string = implode( ',', $color_values ); + + $text_input['data-acf-palette-colors'] = $hex_string; + } elseif ( 'custom' === $field['custom_palette_source'] && ! empty( $field['palette_colors'] ) ) { + // If the field was set to use a custom palette. + $text_input['data-acf-palette-type'] = 'custom'; + $text_input['data-acf-palette-colors'] = $field['palette_colors']; + } elseif ( '' === $field['custom_palette_source'] && ! empty( $field['palette_colors'] ) ) { + // This state can happen if they switched from a classic theme to a themejson theme without resaving the field. + $text_input['data-acf-palette-type'] = 'custom'; + $text_input['data-acf-palette-colors'] = $field['palette_colors']; + } else { + // Fallback to use the default color palette for the iris color picker. + $text_input['data-acf-palette-type'] = 'default'; + } + // phpcs:disable Universal.ControlStructures.DisallowLonelyIf.Found + } else { + // Handle color palette for themes that do not support themejson. + if ( ! empty( $field['palette_colors'] ) ) { + $text_input['data-acf-palette-type'] = 'custom'; + $text_input['data-acf-palette-colors'] = $field['palette_colors']; + } else { + // Fallback to use the default color palette for the iris color picker. + $text_input['data-acf-palette-type'] = 'default'; + } + } + // html ?> -
+
@@ -179,6 +228,86 @@ function render_field_settings( $field ) { ); } + + /** + * Renders the field settings used in the "Presentation" tab. + * + * @since 6.0 + * + * @param array $field The field settings array. + * @return void + */ + public function render_field_presentation_settings( $field ) { + acf_render_field_setting( + $field, + array( + 'label' => __( 'Show Custom Palette', 'secure-custom-fields' ), + 'instructions' => '', + 'type' => 'true_false', + 'name' => 'show_custom_palette', + 'ui' => 1, + ) + ); + + $custom_palette_conditions = array( + 'field' => 'show_custom_palette', + 'operator' => '==', + 'value' => 1, + ); + + if ( wp_theme_has_theme_json() ) { + acf_render_field_setting( + $field, + array( + 'label' => __( 'Custom Palette Source', 'secure-custom-fields' ), + 'instructions' => '', + 'type' => 'radio', + 'name' => 'custom_palette_source', + 'layout' => 'vertical', + 'choices' => array( + 'custom' => __( 'Specify custom colors', 'secure-custom-fields' ), + 'themejson' => __( 'Use colors from theme.json', 'secure-custom-fields' ), + ), + 'conditions' => array( + 'field' => 'show_custom_palette', + 'operator' => '==', + 'value' => 1, + ), + ) + ); + + $custom_palette_conditions = array( + 'field' => 'custom_palette_source', + 'operator' => '==', + 'value' => 'custom', + ); + } + + acf_render_field_setting( + $field, + array( + 'label' => __( 'Custom Palette', 'secure-custom-fields' ), + 'instructions' => __( 'Use a custom color palette by entering comma separated hex or rgba values', 'secure-custom-fields' ), + 'type' => 'text', + 'name' => 'palette_colors', + 'conditions' => $custom_palette_conditions, + ) + ); + + acf_render_field_setting( + $field, + array( + 'label' => __( 'Show Color Wheel', 'secure-custom-fields' ), + 'instructions' => '', + 'type' => 'true_false', + 'name' => 'show_color_wheel', + 'default_value' => 1, + 'ui' => 1, + ) + ); + } + + /** * Format the value for use in templates. At this stage, the value has been loaded from the * database and is being returned by an API function such as get_field(), the_field(), etc. diff --git a/includes/fields/class-acf-field-file.php b/includes/fields/class-acf-field-file.php index 37fe0dad..59c40187 100644 --- a/includes/fields/class-acf-field-file.php +++ b/includes/fields/class-acf-field-file.php @@ -130,7 +130,7 @@ function render_field( $field ) { ) ); ?> -
+
@@ -148,14 +148,14 @@ function render_field( $field ) {

- - + + - +
- +
diff --git a/includes/fields/class-acf-field-icon_picker.php b/includes/fields/class-acf-field-icon_picker.php index d7746ad2..31b4bb17 100644 --- a/includes/fields/class-acf-field-icon_picker.php +++ b/includes/fields/class-acf-field-icon_picker.php @@ -334,11 +334,16 @@ public function input_admin_enqueue_scripts() { * @return boolean true If the value is valid, false if not. */ public function validate_value( $valid, $value, $field, $input ) { - // If the value is empty, return true. You're allowed to save nothing. + // If the value is empty and it's not required, return true. You're allowed to save nothing. if ( empty( $value ) && empty( $field['required'] ) ) { return true; } + // Validate required. + if ( $field['required'] && ( empty( $value ) || empty( $value['value'] ) ) ) { + return false; + } + // If the value is not an array, return $valid status. if ( ! is_array( $value ) ) { return $valid; diff --git a/includes/fields/class-acf-field-image.php b/includes/fields/class-acf-field-image.php index d437965c..560fe737 100644 --- a/includes/fields/class-acf-field-image.php +++ b/includes/fields/class-acf-field-image.php @@ -127,17 +127,17 @@ function render_field( $field ) { ) ); ?> -
+
/>
- - + + - +
- +

diff --git a/includes/fields/class-acf-field-radio.php b/includes/fields/class-acf-field-radio.php index f0100200..28d33385 100644 --- a/includes/fields/class-acf-field-radio.php +++ b/includes/fields/class-acf-field-radio.php @@ -57,10 +57,16 @@ function render_field( $field ) { 'class' => 'acf-radio-list', 'data-allow_null' => $field['allow_null'], 'data-other_choice' => $field['other_choice'], + 'role' => 'radiogroup', ); + // Add aria-labelledby if field has an ID for proper screen reader announcement + if ( ! empty( $field['id'] ) ) { + $ul['aria-labelledby'] = $field['id'] . '-label'; + } + // append to class - $ul['class'] .= ' ' . ( $field['layout'] == 'horizontal' ? 'acf-hl' : 'acf-bl' ); + $ul['class'] .= ' ' . ( 'horizontal' === $field['layout'] ? 'acf-hl' : 'acf-bl' ); $ul['class'] .= ' ' . $field['class']; // Determine selected value. diff --git a/includes/fields/class-acf-field-repeater.php b/includes/fields/class-acf-field-repeater.php index 31b1743b..443dcfff 100644 --- a/includes/fields/class-acf-field-repeater.php +++ b/includes/fields/class-acf-field-repeater.php @@ -1016,24 +1016,55 @@ public function get_field_name_from_input_name( $input_name ) { $name_parts = array(); foreach ( $field_keys as $field_key ) { - if ( ! acf_is_field_key( $field_key ) ) { - if ( 'acfcloneindex' === $field_key ) { - $name_parts[] = 'acfcloneindex'; - continue; - } + // Preserve acfcloneindex + if ( 'acfcloneindex' === $field_key ) { + $name_parts[] = 'acfcloneindex'; + continue; + } - $row_num = str_replace( 'row-', '', $field_key ); + // Handle row numbers (row-0, row-1, etc.) + if ( strpos( $field_key, 'row-' ) === 0 ) { + $row_num = substr( $field_key, 4 ); if ( is_numeric( $row_num ) ) { $name_parts[] = (int) $row_num; continue; } } - $field = acf_get_field( $field_key ); + // Handle compound keys (field_..._field_...) + $compound_keys = preg_split( '/_field_/', $field_key ); + if ( count( $compound_keys ) > 1 ) { + foreach ( $compound_keys as $i => $sub_key ) { + if ( $i > 0 ) { + $sub_key = 'field_' . $sub_key; + } + + // Seamless clone fields use compound keys which can be skipped. + $field = acf_get_field( $sub_key ); + if ( $field && 'clone' === $field['type'] && 'seamless' === $field['display'] ) { + continue; + } + + $name_parts[] = $field && ! empty( $field['name'] ) ? $field['name'] : $sub_key; + } + continue; + } + + // Handle standard field keys + if ( strpos( $field_key, 'field_' ) === 0 ) { + + // Skip clone fields with prefix_name disabled. + $field = acf_get_field( $field_key ); + if ( $field && 'clone' === $field['type'] && empty( $field['prefix_name'] ) ) { + continue; + } - if ( $field ) { - $name_parts[] = $field['name']; + $name_parts[] = $field && ! empty( $field['name'] ) ? $field['name'] : $field_key; + continue; } + + // Fallback: just add as is + $name_parts[] = $field_key; } return implode( '_', $name_parts ); @@ -1093,17 +1124,19 @@ public function ajax_get_rows() { * We have to swap out the field name with the one sent via JS, * as the repeater could be inside a subfield. */ - $field['name'] = $args['field_name']; + $field['name'] = $args['field_name']; + $field['prefix'] = $args['field_prefix']; + $field['value'] = acf_get_value( $post_id, $field ); - $field['value'] = acf_get_value( $post_id, $field ); + if ( $args['refresh'] ) { + $response['total_rows'] = (int) acf_get_metadata_by_field( $post_id, $field ); + } + + // Render the rows to be sent back via AJAX. $field = acf_prepare_field( $field ); $repeater_table = new ACF_Repeater_Table( $field ); $response['rows'] = $repeater_table->rows( true ); - if ( $args['refresh'] ) { - $response['total_rows'] = (int) acf_get_metadata( $post_id, $args['field_name'] ); - } - wp_send_json_success( $response ); } } diff --git a/includes/fields/class-acf-field-taxonomy.php b/includes/fields/class-acf-field-taxonomy.php index 84eeb94c..5f990f9d 100644 --- a/includes/fields/class-acf-field-taxonomy.php +++ b/includes/fields/class-acf-field-taxonomy.php @@ -576,7 +576,7 @@ public function render_field_checkbox( $field ) { ); // checkbox saves an array. - if ( $field['field_type'] == 'checkbox' ) { + if ( 'checkbox' === $field['field_type'] ) { $field['name'] .= '[]'; } @@ -601,9 +601,18 @@ public function render_field_checkbox( $field ) { $args = apply_filters( 'acf/fields/taxonomy/wp_list_categories/name=' . $field['_name'], $args, $field ); $args = apply_filters( 'acf/fields/taxonomy/wp_list_categories/key=' . $field['key'], $args, $field ); + // Build UL attributes for accessibility and consistency. + $ul = array( + 'class' => 'acf-checkbox-list acf-bl', + 'role' => 'radio' === $field['field_type'] ? 'radiogroup' : 'group', + ); + + if ( ! empty( $field['id'] ) ) { + $ul['aria-labelledby'] = $field['id'] . '-label'; + } ?>
-
    +
      >
diff --git a/includes/forms/WC_Order.php b/includes/forms/WC_Order.php index 9eb2a6c9..77161428 100644 --- a/includes/forms/WC_Order.php +++ b/includes/forms/WC_Order.php @@ -73,7 +73,6 @@ public function add_meta_boxes( $post_type, $post ) { if ( $field_groups ) { foreach ( $field_groups as $field_group ) { $id = "acf-{$field_group['key']}"; // acf-group_123 - $title = $field_group['title']; // Group 1 $context = $field_group['position']; // normal, side, acf_after_title $priority = 'core'; // high, core, default, low @@ -104,7 +103,7 @@ public function add_meta_boxes( $post_type, $post ) { // Add the meta box. add_meta_box( $id, - esc_html( $title ), + acf_esc_html( acf_get_field_group_title( $field_group ) ), array( $this, 'render_meta_box' ), $screen, $context, diff --git a/includes/forms/form-comment.php b/includes/forms/form-comment.php index 923c72d9..63dd3321 100644 --- a/includes/forms/form-comment.php +++ b/includes/forms/form-comment.php @@ -148,7 +148,7 @@ function edit_comment( $comment ) { ?>
-

+

'; + return global.acf.parseJSX( html ); + }; + + const mockSetModalOpen = jest.fn(); + + render( + ( + + ) } + > + + + ); + + expect( screen.getByTestId( 'block-placeholder' ) ).toBeInTheDocument(); + expect( global.acf.debug ).toHaveBeenCalled(); + } ); + + test( 'renders successfully with valid HTML', () => { + global.acf.parseJSX.mockImplementation( ( html ) => { + return
; + } ); + + const ValidHTMLComponent = () => { + const html = '

This is valid HTML

'; + return global.acf.parseJSX( html ); + }; + + render( + ( + + ) } + > + + + ); + + // Should not show error placeholder + expect( + screen.queryByTestId( 'block-placeholder' ) + ).not.toBeInTheDocument(); + } ); +} ); diff --git a/tests/js/setup-tests.js b/tests/js/setup-tests.js new file mode 100644 index 00000000..81f07e0e --- /dev/null +++ b/tests/js/setup-tests.js @@ -0,0 +1,18 @@ +/** + * Jest test setup file + * Runs before all tests to set up the testing environment + */ + +// Add React to global scope +import React from 'react'; +global.React = React; + +// Mock jQuery for parseJSX tests +global.jQuery = jest.fn( ( html ) => { + if ( typeof html === 'string' ) { + // Simple mock that returns an array-like object + return [ { innerHTML: html, tagName: 'DIV' } ]; + } + return []; +} ); +global.$ = global.jQuery;