From adccdac74c3466b6872667512e9a1f4e5a5a5a2e Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 25 Oct 2026 00:25:30 +0600 Subject: [PATCH] feat(flexible-content): group layouts by category - Add an optional "Category" setting to each layout in the field group editor. - Group the "Add Layout" menu by category, with a small heading per group. - Layouts without a category stay at the top, so existing menus look the same. - Categories follow the order of their first layout, layouts keep their order. - Translate the category like the layout label. - Add PHPUnit tests, schema description and docs. Long layout lists were hard to scan because the menu was one flat list. Fixes #570 --- assets/src/sass/pro/acf-pro-field-group.scss | 3 +- assets/src/sass/pro/acf-pro-input.scss | 15 +++ docs/features/field/flexible-content/index.md | 1 + includes/fields/FlexibleContent/Render.php | 33 ++++-- .../class-acf-field-flexible-content.php | 21 ++++ schemas/fields/v1/flexible_content.json | 2 +- .../test-class-acf-field-flexible-content.php | 100 ++++++++++++++++++ 7 files changed, 163 insertions(+), 12 deletions(-) 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( '