diff --git a/assets/src/sass/pro/acf-pro-field-group.scss b/assets/src/sass/pro/acf-pro-field-group.scss
index d0800a96..dac30389 100644
--- a/assets/src/sass/pro/acf-pro-field-group.scss
+++ b/assets/src/sass/pro/acf-pro-field-group.scss
@@ -109,7 +109,8 @@
}
.acf-field-setting-fc_layout .acf-fc-meta .acf-fc-meta-label .acf-input-prepend,
.acf-field-setting-fc_layout .acf-fc-meta .acf-fc-meta-name .acf-input-prepend,
-.acf-field-setting-fc_layout .acf-fc-meta .acf-fc-meta-display .acf-input-prepend {
+.acf-field-setting-fc_layout .acf-fc-meta .acf-fc-meta-display .acf-input-prepend,
+.acf-field-setting-fc_layout .acf-fc-meta .acf-fc-meta-category .acf-input-prepend {
min-width: 60px;
}
.acf-field-setting-fc_layout .acf-fc_draggable,
diff --git a/assets/src/sass/pro/acf-pro-input.scss b/assets/src/sass/pro/acf-pro-input.scss
index 63df30ee..64da0af7 100644
--- a/assets/src/sass/pro/acf-pro-input.scss
+++ b/assets/src/sass/pro/acf-pro-input.scss
@@ -690,6 +690,21 @@ body:not(.block-editor-page) .acf-flexible-content .layout.active-layout>.acf-ta
background: transparent;
}
+.acf-fc-popup li.acf-fc-popup-category {
+ height: auto;
+ padding: 10px 0 4px;
+ color: #667085;
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: uppercase;
+ cursor: default;
+}
+
+.acf-fc-popup li.acf-fc-popup-category:not(:first-child) {
+ border-top: 1px solid #eaecf0;
+ margin-top: 4px;
+}
+
.acf-fc-popup a:focus {
box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
border-radius: 4px
diff --git a/docs/features/field/flexible-content/index.md b/docs/features/field/flexible-content/index.md
index 14f32322..f84f156c 100644
--- a/docs/features/field/flexible-content/index.md
+++ b/docs/features/field/flexible-content/index.md
@@ -16,3 +16,4 @@ The Flexible Content field provides a flexible content builder interface. It all
- Button Label - Customize the "Add Row" text
- Min/Max Layouts - Limit number of blocks
- Layout Settings - Configure each layout type
+- Layout Category - Optional. Layouts with the same category are grouped under a heading in the "Add Layout" menu
diff --git a/includes/fields/FlexibleContent/Render.php b/includes/fields/FlexibleContent/Render.php
index 8191cd5c..789b77b9 100644
--- a/includes/fields/FlexibleContent/Render.php
+++ b/includes/fields/FlexibleContent/Render.php
@@ -227,17 +227,30 @@ private function actions( string $which = '' ) {
* @return void
*/
private function add_layout_menu() {
- echo '';
}
diff --git a/includes/fields/class-acf-field-flexible-content.php b/includes/fields/class-acf-field-flexible-content.php
index a990170c..19608d1b 100644
--- a/includes/fields/class-acf-field-flexible-content.php
+++ b/includes/fields/class-acf-field-flexible-content.php
@@ -147,6 +147,7 @@ public function get_valid_layout( $layout = array() ) {
'sub_fields' => array(),
'min' => '',
'max' => '',
+ 'category' => '',
)
);
@@ -464,6 +465,22 @@ public function render_field_settings( $field ) {
)
);
+ ?>
+
+
+ 'text',
+ 'name' => 'category',
+ 'prefix' => $layout_prefix,
+ 'value' => $layout['category'],
+ 'prepend' => __( 'Category', 'secure-custom-fields' ),
+ 'placeholder' => __( 'Optional', 'secure-custom-fields' ),
+ )
+ );
+
?>
@@ -1575,6 +1592,10 @@ public function translate_field( $field ) {
if ( ! empty( $field['layouts'] ) ) {
foreach ( $field['layouts'] as &$layout ) {
$layout['label'] = acf_translate( $layout['label'] );
+
+ if ( ! empty( $layout['category'] ) ) {
+ $layout['category'] = acf_translate( $layout['category'] );
+ }
}
}
diff --git a/schemas/fields/v1/flexible_content.json b/schemas/fields/v1/flexible_content.json
index 27308b5c..302fbb5b 100644
--- a/schemas/fields/v1/flexible_content.json
+++ b/schemas/fields/v1/flexible_content.json
@@ -93,7 +93,7 @@
},
"layouts": {
"type": "array",
- "description": "Array of layout configuration objects. Each layout represents a different content structure that editors can choose from. Each layout object should have: \"label\" (display name), \"name\" (machine name), \"sub_fields\" (array of field objects for this layout), \"display\" (layout style: \"table\", \"block\", or \"row\"), \"min\" (minimum instances of this layout), and \"max\" (maximum instances of this layout).",
+ "description": "Array of layout configuration objects. Each layout represents a different content structure that editors can choose from. Each layout object should have: \"label\" (display name), \"name\" (machine name), \"sub_fields\" (array of field objects for this layout), \"display\" (layout style: \"table\", \"block\", or \"row\"), \"min\" (minimum instances of this layout), \"max\" (maximum instances of this layout), and optionally \"category\" (groups the layout under a heading in the add layout menu).",
"items": {
"type": "object"
},
diff --git a/tests/php/includes/fields/test-class-acf-field-flexible-content.php b/tests/php/includes/fields/test-class-acf-field-flexible-content.php
index 898d0af2..f553c17d 100644
--- a/tests/php/includes/fields/test-class-acf-field-flexible-content.php
+++ b/tests/php/includes/fields/test-class-acf-field-flexible-content.php
@@ -423,4 +423,104 @@ public function test_prepare_field_for_import() {
$this->assertIsArray( $imported );
}
+
+ /**
+ * Test get_valid_layout adds an empty category by default.
+ */
+ public function test_get_valid_layout_default_category() {
+ $layout = $this->field_instance->get_valid_layout( array( 'name' => 'custom' ) );
+
+ $this->assertArrayHasKey( 'category', $layout );
+ $this->assertSame( '', $layout['category'] );
+ }
+
+ /**
+ * Get the add layout menu HTML for a list of layouts.
+ *
+ * @param array $layouts Layouts as name => category.
+ * @return string
+ */
+ protected function get_add_layout_menu_html( $layouts ) {
+ $field = $this->get_field(
+ array(
+ 'button_label' => 'Add Row',
+ 'value' => array(),
+ 'layouts' => array(),
+ )
+ );
+
+ foreach ( $layouts as $name => $category ) {
+ $field['layouts'][] = $this->field_instance->get_valid_layout(
+ array(
+ 'name' => $name,
+ 'label' => ucfirst( $name ),
+ 'category' => $category,
+ )
+ );
+ }
+
+ $render = new \SCF\Fields\FlexibleContent\Render( $field, array() );
+ $method = new ReflectionMethod( $render, 'add_layout_menu' );
+ $method->setAccessible( true );
+
+ ob_start();
+ $method->invoke( $render );
+ return ob_get_clean();
+ }
+
+ /**
+ * Test add layout menu stays flat when no layout has a category.
+ */
+ public function test_add_layout_menu_without_categories() {
+ $html = $this->get_add_layout_menu_html(
+ array(
+ 'text' => '',
+ 'image' => '',
+ )
+ );
+
+ $this->assertStringNotContainsString( 'acf-fc-popup-category', $html );
+ $this->assertSame( 2, substr_count( $html, 'data-layout=' ) );
+ $this->assertLessThan( strpos( $html, 'data-layout="image"' ), strpos( $html, 'data-layout="text"' ) );
+ }
+
+ /**
+ * Test add layout menu groups layouts by category.
+ */
+ public function test_add_layout_menu_groups_by_category() {
+ $html = $this->get_add_layout_menu_html(
+ array(
+ 'image' => 'Media',
+ 'note' => '',
+ 'quote' => 'Text',
+ 'gallery' => 'Media',
+ )
+ );
+
+ preg_match_all( '/class="acf-fc-popup-category">([^<]*)<|data-layout="([^"]*)"/', $html, $matches, PREG_SET_ORDER );
+ $order = array_map(
+ function ( $found ) {
+ return ! empty( $found[2] ) ? $found[2] : 'category:' . $found[1];
+ },
+ $matches
+ );
+
+ $this->assertSame(
+ array( 'note', 'category:Media', 'image', 'gallery', 'category:Text', 'quote' ),
+ $order
+ );
+ }
+
+ /**
+ * Test add layout menu escapes the category heading.
+ */
+ public function test_add_layout_menu_escapes_category() {
+ $html = $this->get_add_layout_menu_html(
+ array(
+ 'text' => '
',
+ )
+ );
+
+ $this->assertStringNotContainsString( '![]()