From 3da3e5e63d541800cebd64ff71b103001c0383d9 Mon Sep 17 00:00:00 2001
From: Benni Mack <benni@typo3.org>
Date: Tue, 24 Mar 2020 12:30:22 +0100
Subject: [PATCH] [FEATURE] Add grouping and sorting for TCA select items

Due to the deprecation of "switchable controller actions", list_type items
can now be grouped in FormEngine - as well as all other "select" fields
defined in TCA.

A new TCA option in TCA type=select is added, called "itemGroups".

In addition, all "items" now have four parts (fourth being optional)
0 => label
1 => value
2 => icon
3 => groupID

where the group belongs to an item group (defined explicitly) or taken
from a --div-- element, which then turns into an optgroup.

In order then to avoid the "itemProcFunc" of tt_content.list_type
which is used to sort items, a "sortOrders" option is added to sort
items (within a group, if grouping is enabled) by label or value.

When registering a new plugin, the groupId can be added as well as an
additional parameter, which falls back to the "default" group.

A new method ExtensionManagementUtility::addTcaSelectItemGroup()
allows to add item groups via API.

When registering extbase Plugins or pibase plugins, it is possible
to add a registered "group ID" to make use of this feature.

Resolves: #91008
Resolves: #82352
Releases: master
Change-Id: I8ad215b5cbc16f332e7c129d762fc020ade5ceeb
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/63889
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Georg Ringer <georg.ringer@gmail.com>
Tested-by: Alexander Schnitzler <git@alexanderschnitzler.de>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Georg Ringer <georg.ringer@gmail.com>
Reviewed-by: Alexander Schnitzler <git@alexanderschnitzler.de>
Reviewed-by: Benni Mack <benni@typo3.org>
---
 .../FormDataProvider/AbstractItemProvider.php |  16 +-
 .../Form/FormDataProvider/TcaSelectItems.php  | 145 +++++++++++++-
 .../FormDataProvider/TcaSelectItemsTest.php   | 188 +++++++++++++++---
 .../Utility/ExtensionManagementUtility.php    |  65 +++++-
 typo3/sysext/core/Configuration/TCA/pages.php |  41 +++-
 ...re-91008-ItemGroupingForTCASelectItems.rst | 150 ++++++++++++++
 ...ure-91008-ItemSortingForTCASelectItems.rst |  58 ++++++
 .../ExtensionManagementUtilityTest.php        | 118 ++++++++++-
 .../Classes/Utility/ExtensionUtility.php      |   9 +-
 .../TCA/Overrides/tt_content.php              |  56 ++----
 .../TCA/Overrides/tt_content.php              |   3 +-
 .../Classes/Hooks/TableColumnHooks.php        |  47 -----
 .../frontend/Configuration/TCA/tt_content.php | 102 +++++++---
 13 files changed, 832 insertions(+), 166 deletions(-)
 create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Feature-91008-ItemGroupingForTCASelectItems.rst
 create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Feature-91008-ItemSortingForTCASelectItems.rst
 delete mode 100644 typo3/sysext/frontend/Classes/Hooks/TableColumnHooks.php

diff --git a/typo3/sysext/backend/Classes/Form/FormDataProvider/AbstractItemProvider.php b/typo3/sysext/backend/Classes/Form/FormDataProvider/AbstractItemProvider.php
index 10db14be582a..5406b1fce1ae 100644
--- a/typo3/sysext/backend/Classes/Form/FormDataProvider/AbstractItemProvider.php
+++ b/typo3/sysext/backend/Classes/Form/FormDataProvider/AbstractItemProvider.php
@@ -186,7 +186,7 @@ abstract class AbstractItemProvider
                     if (!empty($helpTextArray['description'])) {
                         $helpText['description'] = $helpTextArray['description'];
                     }
-                    $items[] = [$label, $currentTable, $icon, $helpText];
+                    $items[] = [$label, $currentTable, $icon, null, $helpText];
                 }
                 break;
             case $special === 'pagetypes':
@@ -245,6 +245,7 @@ abstract class AbstractItemProvider
                         rtrim($excludeArray['origin'] === 'flexForm' ? $excludeArray['fieldLabel'] : $languageService->sL($GLOBALS['TCA'][$excludeArray['table']]['columns'][$excludeArray['fieldName']]['label']), ':') . ' (' . $excludeArray['fieldName'] . ')',
                         $excludeArray['table'] . ':' . $excludeArray['fullField'],
                         'empty-empty',
+                        null,
                         $helpText
                     ];
                 }
@@ -344,6 +345,7 @@ abstract class AbstractItemProvider
                                     $languageService->sL($itemCfg[0]),
                                     $coKey . ':' . preg_replace('/[:|,]/', '', $itemKey),
                                     $icon,
+                                    null,
                                     $helpText
                                 ];
                             }
@@ -384,7 +386,7 @@ abstract class AbstractItemProvider
                         $label .= $languageService->sL($moduleLabels['title']);
 
                         // Item configuration
-                        $items[] = [$label, $theMod, $icon, $helpText];
+                        $items[] = [$label, $theMod, $icon, null, $helpText];
                     }
                 }
                 break;
@@ -1327,18 +1329,20 @@ abstract class AbstractItemProvider
             }
             $value = strlen((string)$item[1]) > 0 ? $item[1] : '';
             $icon = !empty($item[2]) ? $item[2] : null;
+            $groupId = isset($item[3]) ? $item[3] : null;
             $helpText = null;
-            if (!empty($item[3])) {
-                if (\is_string($item[3])) {
-                    $helpText = $languageService->sL($item[3]);
+            if (!empty($item[4])) {
+                if (\is_string($item[4])) {
+                    $helpText = $languageService->sL($item[4]);
                 } else {
-                    $helpText = $item[3];
+                    $helpText = $item[4];
                 }
             }
             $itemArray[$key] = [
                 $label,
                 $value,
                 $icon,
+                $groupId,
                 $helpText
             ];
         }
diff --git a/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaSelectItems.php b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaSelectItems.php
index 3076a94bf576..50b64080c66b 100644
--- a/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaSelectItems.php
+++ b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaSelectItems.php
@@ -16,6 +16,7 @@
 namespace TYPO3\CMS\Backend\Form\FormDataProvider;
 
 use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Core\Utility\MathUtility;
 
 /**
@@ -115,6 +116,12 @@ class TcaSelectItems extends AbstractItemProvider implements FormDataProviderInt
             // Keys may contain table names, so a numeric array is created
             $fieldConfig['config']['items'] = array_values($fieldConfig['config']['items']);
 
+            $fieldConfig['config']['items'] = $this->groupAndSortItems(
+                $fieldConfig['config']['items'],
+                $fieldConfig['config']['itemGroups'] ?? [],
+                $fieldConfig['config']['sortItems'] ?? []
+            );
+
             $result['processedTca']['columns'][$fieldName] = $fieldConfig;
         }
 
@@ -158,7 +165,9 @@ class TcaSelectItems extends AbstractItemProvider implements FormDataProviderInt
         foreach ($unmatchedValues as $unmatchedValue) {
             $invalidItem = [
                 @sprintf($noMatchingLabel, $unmatchedValue),
-                $unmatchedValue
+                $unmatchedValue,
+                null,
+                'none' // put it in the very first position in the "none" group
             ];
             array_unshift($fieldConf['config']['items'], $invalidItem);
         }
@@ -176,4 +185,138 @@ class TcaSelectItems extends AbstractItemProvider implements FormDataProviderInt
     {
         return $fieldConfig['config']['renderType'] !== 'selectTree';
     }
+
+    /**
+     * Is used when --div-- elements in the item list are used, or if groups are defined via "groupItems" config array.
+     *
+     * This method takes the --div-- elements out of the list, and adds them to the group lists.
+     *
+     * A main "none" group is added, which is always on top, when items are not set to be in a group.
+     * All items without a groupId - which is defined by the fourth key of an item in the item array - are added
+     * to the "none" group, or to the last group used previously, to ensure ordering as much as possible as before.
+     *
+     * Then the found groups are iterated over the order in the [itemGroups] list,
+     * and items within a group can be sorted via "sortOrders" configuration.
+     *
+     * All grouped items are then "flattened" out and --div-- items are added for each group to keep backwards-compatibility.
+     *
+     * @param array $allItems all resolved items including the ones from foreign_table values. The group ID information can be found in fourth key [3] of an item.
+     * @param array $definedGroups [config][itemGroups]
+     * @param array $sortOrders [config][sortOrders]
+     * @return array
+     */
+    protected function groupAndSortItems(array $allItems, array $definedGroups, array $sortOrders): array
+    {
+        $groupedItems = [];
+        // Append defined groups at first, as their order is prioritized
+        $itemGroups = ['none' => ''];
+        foreach ($definedGroups as $groupId => $groupLabel) {
+            $itemGroups[$groupId] = $this->getLanguageService()->sL($groupLabel);
+        }
+        $currentGroup = 'none';
+        // Extract --div-- into itemGroups
+        foreach ($allItems as $key => $item) {
+            if ($item[1] === '--div--') {
+                // A divider is added as a group (existing groups will get their label overriden)
+                if (isset($item[3])) {
+                    $currentGroup = $item[3];
+                    $itemGroups[$currentGroup] = $item[0];
+                } else {
+                    $currentGroup = 'none';
+                }
+                continue;
+            }
+            // Put the given item in the currentGroup if no group has been given already
+            if (!isset($item[3])) {
+                $item[3] = $currentGroup;
+            }
+            $groupIdOfItem = !empty($item[3]) ? $item[3] : 'none';
+            // It is still possible to have items that have an "unassigned" group, so they are moved to the "none" group
+            if (!isset($itemGroups[$groupIdOfItem])) {
+                $itemGroups[$groupIdOfItem] = '';
+            }
+
+            // Put the item in its corresponding group (and create it if it does not exist yet)
+            if (!is_array($groupedItems[$groupIdOfItem] ?? null)) {
+                $groupedItems[$groupIdOfItem] = [];
+            }
+            $groupedItems[$groupIdOfItem][] = $item;
+        }
+        // Only "none" = no grouping used explicitly via "itemGroups" or via "--div--"
+        if (count($itemGroups) === 1) {
+            if (!empty($sortOrders)) {
+                $allItems = $this->sortItems($allItems, $sortOrders);
+            }
+            return $allItems;
+        }
+
+        // $groupedItems contains all items per group
+        // $itemGroups contains all groups in order of each group
+
+        // Let's add the --div-- items again ("unpacking")
+        // And use the group ordering given by the itemGroups
+        $finalItems = [];
+        foreach ($itemGroups as $groupId => $groupLabel) {
+            $itemsInGroup = $groupedItems[$groupId] ?? [];
+            if (empty($itemsInGroup)) {
+                continue;
+            }
+            // If sorting is defined, sort within each group now
+            if (!empty($sortOrders)) {
+                $itemsInGroup = $this->sortItems($itemsInGroup, $sortOrders);
+            }
+            // Add the --div-- if it is not the "none" default item
+            if ($groupId !== 'none') {
+                // Fall back to the groupId, if there is no label for it
+                $groupLabel = $groupLabel ?: $groupId;
+                $finalItems[] = [$groupLabel, '--div--', null, $groupId, null];
+            }
+            $finalItems = array_merge($finalItems, $itemsInGroup);
+        }
+        return $finalItems;
+    }
+
+    /**
+     * Sort given items by label or value or a custom user function built like
+     * "MyVendor\MyExtension\TcaSorter->sortItems" or a callable.
+     *
+     * @param array $items
+     * @param array $sortOrders should be something like like [label => desc]
+     * @return array the sorted items
+     */
+    protected function sortItems(array $items, array $sortOrders): array
+    {
+        foreach ($sortOrders as $order => $direction) {
+            switch ($order) {
+                case 'label':
+                    $direction = strtolower($direction);
+                    @usort(
+                        $items,
+                        function ($item1, $item2) use ($direction) {
+                            if ($direction === 'desc') {
+                                return strcasecmp($item1[0], $item2[0]) <= 0;
+                            }
+                            return strcasecmp($item1[0], $item2[0]);
+                        }
+                    );
+                    break;
+                case 'value':
+                    $direction = strtolower($direction);
+                    @usort(
+                        $items,
+                        function ($item1, $item2) use ($direction) {
+                            if ($direction === 'desc') {
+                                return strcasecmp($item1[1], $item2[1]) <= 0;
+                            }
+                            return strcasecmp($item1[1], $item2[1]);
+                        }
+                    );
+                    break;
+                default:
+                    $reference = null;
+                    GeneralUtility::callUserFunction($direction, $items, $reference);
+            }
+        }
+        return $items;
+    }
 }
diff --git a/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaSelectItemsTest.php b/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaSelectItemsTest.php
index 10fa53b1edeb..30c53d5448b8 100644
--- a/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaSelectItemsTest.php
+++ b/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaSelectItemsTest.php
@@ -274,12 +274,90 @@ class TcaSelectItemsTest extends UnitTestCase
         $expected['processedTca']['columns']['aField']['config']['items'][0][0] = 'translated';
         $expected['processedTca']['columns']['aField']['config']['items'][0][2] = null;
         $expected['processedTca']['columns']['aField']['config']['items'][0][3] = null;
+        $expected['processedTca']['columns']['aField']['config']['items'][0][4] = null;
 
         $expected['databaseRow']['aField'] = ['aValue'];
 
         self::assertSame($expected, (new TcaSelectItems())->addData($input));
     }
 
+    /**
+     * @test
+     */
+    public function addDataAddsDividersIfItemGroupsAreDefined()
+    {
+        $input = [
+            'tableName' => 'aTable',
+            'databaseRow' => [
+                'aField' => 'aValue',
+            ],
+            'processedTca' => [
+                'columns' => [
+                    'aField' => [
+                        'config' => [
+                            'type' => 'select',
+                            'renderType' => 'selectSingle',
+                            'items' => [
+                                [
+                                    'aLabel',
+                                    'aValue',
+                                    'an-icon-reference',
+                                    'non-existing-group',
+                                    null,
+                                ],
+                                [
+                                    'anotherLabel',
+                                    'anotherValue',
+                                    'an-icon-reference',
+                                    'example-group',
+                                    null,
+                                ],
+                            ],
+                            'itemGroups' => [
+                                'example-group' => 'My Example Group'
+                            ],
+                            'maxitems' => 99999,
+                        ],
+                    ],
+                ],
+            ],
+        ];
+
+        $expected = $input;
+        $expected['databaseRow']['aField'] = ['aValue'];
+        $expected['processedTca']['columns']['aField']['config']['items'] = [
+            [
+                'My Example Group',
+                '--div--',
+                null,
+                'example-group',
+                null,
+            ],
+            [
+                'anotherLabel',
+                'anotherValue',
+                'an-icon-reference',
+                'example-group',
+                null,
+            ],            [
+                'non-existing-group',
+                '--div--',
+                null,
+                'non-existing-group',
+                null,
+            ],
+            [
+                'aLabel',
+                'aValue',
+                'an-icon-reference',
+                'non-existing-group',
+                null,
+            ],
+        ];
+
+        self::assertSame($expected, (new TcaSelectItems)->addData($input));
+    }
+
     /**
      * @test
      */
@@ -302,6 +380,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'aValue',
                                     2 => 'an-icon-reference',
                                     3 => null,
+                                    4 => null,
                                 ],
                             ],
                             'maxitems' => 99999,
@@ -396,7 +475,8 @@ class TcaSelectItemsTest extends UnitTestCase
                 0 => 'aTitle',
                 1 => 'aTable',
                 2 => null,
-                3 => [
+                3 => null,
+                4 => [
                     'description' => 'aDescription',
                 ],
             ]
@@ -461,6 +541,7 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => 'aValue',
                 2 => null,
                 3 => null,
+                4 => null,
             ]
         ];
 
@@ -498,12 +579,14 @@ class TcaSelectItemsTest extends UnitTestCase
                         1 => '--div--',
                         2 => null,
                         3 => null,
+                        4 => null,
                     ],
                     1 => [
                         0 => 'barColumnTitle (bar)',
                         1 => 'fooTable:bar',
                         2 => 'empty-empty',
                         3 => null,
+                        4 => null,
                     ],
                 ],
             ],
@@ -533,12 +616,14 @@ class TcaSelectItemsTest extends UnitTestCase
                         1 => '--div--',
                         2 => null,
                         3 => null,
+                        4 => null,
                     ],
                     1 => [
                         0 => 'barColumnTitle (bar)',
                         1 => 'fooTable:bar',
                         2 => 'empty-empty',
                         3 => null,
+                        4 => null,
                     ],
                 ],
             ],
@@ -678,12 +763,14 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => '--div--',
                 2 => null,
                 3 => null,
+                4 => null,
             ],
             1 => [
                 0 => 'flexInputLabel (input1)',
                 1 => 'fooTable:aFlexField;dummy;sDEF;input1',
                 2 => 'empty-empty',
                 3 => null,
+                4 => null,
             ],
         ];
 
@@ -749,12 +836,14 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => '--div--',
                 2 => null,
                 3 => null,
+                4 => null,
             ],
             1 => [
                 0 => '[allowMe] anItemTitle',
                 1 => 'fooTable:aField:anItemValue:ALLOW',
                 2 => 'status-status-permission-granted',
                 3 => null,
+                4 => null,
             ],
         ];
 
@@ -820,12 +909,14 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => '--div--',
                 2 => null,
                 3 => null,
+                4 => null,
             ],
             1 => [
                 0 => '[denyMe] anItemTitle',
                 1 => 'fooTable:aField:anItemValue:DENY',
                 2 => 'status-status-permission-denied',
                 3 => null,
+                4 => null,
             ],
         ];
 
@@ -906,18 +997,21 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => '--div--',
                 2 => null,
                 3 => null,
+                4 => null,
             ],
             1 => [
                 0 => '[allowMe] aItemTitle',
                 1 => 'fooTable:aField:aItemValue:ALLOW',
                 2 => 'status-status-permission-granted',
                 3 => null,
+                4 => null,
             ],
             2 => [
                 0 => '[allowMe] cItemTitle',
                 1 => 'fooTable:aField:cItemValue:ALLOW',
                 2 => 'status-status-permission-granted',
                 3 => null,
+                4 => null,
             ],
         ];
 
@@ -998,18 +1092,21 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => '--div--',
                 2 => null,
                 3 => null,
+                4 => null,
             ],
             1 => [
                 0 => '[denyMe] aItemTitle',
                 1 => 'fooTable:aField:aItemValue:DENY',
                 2 => 'status-status-permission-denied',
                 3 => null,
+                4 => null,
             ],
             2 => [
                 0 => '[denyMe] cItemTitle',
                 1 => 'fooTable:aField:cItemValue:DENY',
                 2 => 'status-status-permission-denied',
                 3 => null,
+                4 => null,
             ],
         ];
 
@@ -1062,6 +1159,7 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => 13,
                 2 => 'flags-aFlag.gif',
                 3 => null,
+                4 => null,
             ],
         ];
 
@@ -1113,18 +1211,21 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => '--div--',
                 null,
                 null,
+                null,
             ],
             1 => [
                 0 => 'anItemTitle',
                 1 => 'aKey:anItemKey',
                 2 => 'empty-empty',
                 3 => null,
+                4 => null,
             ],
             2 => [
                 0 => 'anotherTitle',
                 1 => 'aKey:anotherKey',
                 2 => 'empty-empty',
-                3 => [ 'description' => 'aDescription' ],
+                3 => null,
+                4 => [ 'description' => 'aDescription' ],
             ],
         ];
 
@@ -1179,7 +1280,8 @@ class TcaSelectItemsTest extends UnitTestCase
                 0 => 'aModuleLabel',
                 1 => 'aModule',
                 2 => 'empty-empty',
-                3 => [
+                3 => null,
+                4 => [
                     'title' => 'aModuleTabLabel',
                     'description' => 'aModuleTabDescription',
                 ],
@@ -1230,12 +1332,14 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => 'anImage.gif',
                 2 => Environment::getVarPath() . '/' . $directory . 'anImage.gif',
                 3 => null,
+                4 => null,
             ],
             1 => [
                 0 => 'subdir/anotherImage.gif',
                 1 => 'subdir/anotherImage.gif',
                 2 => Environment::getVarPath() . '/' . $directory . 'subdir/anotherImage.gif',
                 3 => null,
+                4 => null,
             ],
         ];
 
@@ -1292,6 +1396,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'keep',
                                     null,
                                     null,
+                                    null,
                                 ],
                             ],
                             'maxitems' => 99999,
@@ -1319,6 +1424,7 @@ class TcaSelectItemsTest extends UnitTestCase
             1 => '1',
             null,
             null,
+            null,
         ];
 
         self::assertEquals($expected, (new TcaSelectItems())->addData($input));
@@ -1346,6 +1452,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'keep',
                                     null,
                                     null,
+                                    null,
                                 ],
                             ],
                             'maxitems' => 99999,
@@ -1373,6 +1480,7 @@ class TcaSelectItemsTest extends UnitTestCase
             1 => 'keep',
             null,
             null,
+            null,
         ];
 
         self::assertEquals($expected, (new TcaSelectItems())->addData($input));
@@ -1818,6 +1926,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'itemValue',
                                     2 => null,
                                     3 => null,
+                                    4 => null,
                                 ],
                             ],
                             'maxitems' => 99999,
@@ -1964,12 +2073,14 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => 1,
                 2 => null,
                 3 => null,
+                4 => null,
             ],
             1 => [
                 0 => 'aPrefix[LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.no_title]',
                 1 => 2,
                 2 => null,
                 3 => null,
+                4 => null,
             ],
         ];
 
@@ -2068,6 +2179,7 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => 1,
                 2 => null,
                 3 => null,
+                4 => null,
             ]
         ];
 
@@ -2155,6 +2267,7 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => 1,
                 2 => null,
                 3 => null,
+                4 => null,
             ],
         ];
         $expected['databaseRow']['aField'] = [];
@@ -2184,6 +2297,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'keep',
                                     null,
                                     null,
+                                    null,
                                 ],
                                 1 => [
                                     0 => 'removeMe',
@@ -2327,18 +2441,21 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => '1',
                 null,
                 null,
+                null,
             ],
             1 => [
                 0 => 'addItem #1',
                 1 => '1',
                 null,
                 null,
+                null,
             ],
             2 => [
                 0 => 'addItem #12',
                 1 => '12',
                 null,
                 null,
+                null,
             ],
         ];
 
@@ -2367,6 +2484,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'keep',
                                     null,
                                     null,
+                                    null,
                                 ],
                                 1 => [
                                     0 => 'removeMe',
@@ -2377,6 +2495,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 0,
                                     null,
                                     null,
+                                    null,
                                 ],
                             ],
                             'maxitems' => 99999,
@@ -2424,12 +2543,14 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'keep',
                                     null,
                                     null,
+                                    null,
                                 ],
                                 1 => [
                                     0 => 'keepMe',
                                     1 => 'keepMe2',
                                     null,
                                     null,
+                                    null,
                                 ],
                                 2 => [
                                     0 => 'remove me',
@@ -2480,6 +2601,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'keep',
                                     null,
                                     null,
+                                    null,
                                 ],
                                 1 => [
                                     0 => 'removeMe',
@@ -2537,6 +2659,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'keep',
                                     null,
                                     null,
+                                    null,
                                 ],
                                 1 => [
                                     0 => 'removeMe',
@@ -2565,8 +2688,8 @@ class TcaSelectItemsTest extends UnitTestCase
         $expected = $input;
         $expected['databaseRow']['aField'] = [];
         $expected['processedTca']['columns']['aField']['config']['items'] = [
-            [ '[ INVALID VALUE "aValue" ]', 'aValue', null, null ],
-            [ 'keepMe', 'keep', null, null ],
+            [ '[ INVALID VALUE "aValue" ]', 'aValue', null, 'none', null ],
+            [ 'keepMe', 'keep', null, null, null ],
         ];
 
         self::assertEquals($expected, (new TcaSelectItems())->addData($input));
@@ -2595,6 +2718,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'keep',
                                     null,
                                     null,
+                                    null,
                                 ],
                                 1 => [
                                     0 => 'removeMe',
@@ -2643,6 +2767,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'keep',
                                     null,
                                     null,
+                                    null,
                                 ],
                             ],
                             'maxitems' => 99999,
@@ -2685,6 +2810,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'keep',
                                     null,
                                     null,
+                                    null,
                                 ],
                                 1 => [
                                     0 => 'removeMe',
@@ -2737,6 +2863,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                         1 => 'aValue',
                                         2 => null,
                                         3 => null,
+                                        4 => null,
                                     ],
                                 ];
                             },
@@ -2757,6 +2884,7 @@ class TcaSelectItemsTest extends UnitTestCase
                     1 => 'aValue',
                     2 => null,
                     3 => null,
+                    4 => null,
                 ],
             ],
             'maxitems' => 99999,
@@ -2838,12 +2966,14 @@ class TcaSelectItemsTest extends UnitTestCase
                 1 => 1,
                 2 => null,
                 3 => null,
+                4 => null,
             ],
             1 => [
                 0 => 'aLabel_2',
                 1 => 2,
                 2 => null,
                 3 => null,
+                4 => null,
             ],
         ];
 
@@ -2887,6 +3017,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                             $item[0],   // label
                                             $item[1],   // uid
                                             null,       // icon
+                                            null,       // groupID
                                             null        // helpText
                                         ];
                                     }
@@ -2952,6 +3083,7 @@ class TcaSelectItemsTest extends UnitTestCase
                     1 => 2,
                     2 => null,
                     3 => null,
+                    4 => null,
                 ],
             ],
             'maxitems' => 99999
@@ -2996,6 +3128,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                             $item[0],   // label
                                             $item[1],   // uid
                                             null,       // icon
+                                            null,       // groupId
                                             null        // helpText
                                         ];
                                     }
@@ -3069,6 +3202,7 @@ class TcaSelectItemsTest extends UnitTestCase
                     1 => 1,
                     2 => null,
                     3 => null,
+                    4 => null,
                 ]
             ],
             'maxitems' => 99999
@@ -3113,6 +3247,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                             $item[0],   // label
                                             $item[1],   // uid
                                             null,       // icon
+                                            null,       // groupID
                                             null        // helpText
                                         ];
                                     }
@@ -3188,12 +3323,14 @@ class TcaSelectItemsTest extends UnitTestCase
                     1 => 2,
                     2 => null,
                     3 => null,
+                    4 => null,
                 ],
                 1 => [
                     0 => 'Label of the added item',
                     1 => 12,
                     2 => null,
                     3 => null,
+                    4 => null,
                 ],
             ],
             'maxitems' => 99999
@@ -3356,6 +3493,7 @@ class TcaSelectItemsTest extends UnitTestCase
                                     1 => 'aValue',
                                     null,
                                     null,
+                                    null
                                 ],
                             ],
                             'maxitems' => 99999,
@@ -3549,7 +3687,7 @@ class TcaSelectItemsTest extends UnitTestCase
                             'foreign_table' => 'foreignTable',
                             'maxitems' => 999,
                             'items' => [
-                                ['foo', 'foo', null, null],
+                                ['foo', 'foo', null, null, null],
                             ],
                         ],
                     ],
@@ -3581,8 +3719,8 @@ class TcaSelectItemsTest extends UnitTestCase
                             'renderType' => 'selectSingle',
                             'maxitems' => 999,
                             'items' => [
-                                ['foo', 'foo', null, null],
-                                ['bar', 'bar', null, null],
+                                ['foo', 'foo', null, null, null],
+                                ['bar', 'bar', null, null, null],
                             ],
                         ],
                     ],
@@ -3647,9 +3785,9 @@ class TcaSelectItemsTest extends UnitTestCase
                             'renderType' => 'selectSingle',
                             'maxitems' => 999,
                             'items' => [
-                                ['a', '', null, null],
-                                ['b', 'b', null, null],
-                                ['c', 'c', null, null],
+                                ['a', '', null, null, null],
+                                ['b', 'b', null, null, null],
+                                ['c', 'c', null, null, null],
                             ],
                         ],
                     ],
@@ -3689,7 +3827,7 @@ class TcaSelectItemsTest extends UnitTestCase
                             'renderType' => 'selectSingle',
                             'maxitems' => 999,
                             'items' => [
-                                ['foo', 'foo', null, null],
+                                ['foo', 'foo', null, null, null],
                             ],
                         ],
                     ],
@@ -3731,7 +3869,7 @@ class TcaSelectItemsTest extends UnitTestCase
                             'renderType' => 'selectSingle',
                             'maxitems' => 99999,
                             'items' => [
-                                ['foo', 'foo', null, null],
+                                ['foo', 'foo', null, null, null],
                             ],
                         ],
                     ],
@@ -3742,10 +3880,10 @@ class TcaSelectItemsTest extends UnitTestCase
         $expected = $input;
         $expected['databaseRow']['aField'] = ['foo'];
         $expected['processedTca']['columns']['aField']['config']['items'] = [
-            ['[ INVALID VALUE "bar" ]', 'bar', null, null],
-            ['[ INVALID VALUE "2" ]', '2', null, null],
-            ['[ INVALID VALUE "1" ]', '1', null, null],
-            ['foo', 'foo', null, null],
+            ['[ INVALID VALUE "bar" ]', 'bar', null, 'none', null],
+            ['[ INVALID VALUE "2" ]', '2', null, 'none', null],
+            ['[ INVALID VALUE "1" ]', '1', null, 'none', null],
+            ['foo', 'foo', null, null, null],
         ];
         self::assertEquals($expected, (new TcaSelectItems())->addData($input));
     }
@@ -3769,10 +3907,10 @@ class TcaSelectItemsTest extends UnitTestCase
                             'multiple' => true,
                             'maxitems' => 999,
                             'items' => [
-                                ['1', '1', null, null],
-                                ['foo', 'foo', null, null],
-                                ['bar', 'bar', null, null],
-                                ['2', '2', null, null],
+                                ['1', '1', null, null, null],
+                                ['foo', 'foo', null, null, null],
+                                ['bar', 'bar', null, null, null],
+                                ['2', '2', null, null, null],
                             ],
                         ],
                     ],
@@ -3811,10 +3949,10 @@ class TcaSelectItemsTest extends UnitTestCase
                             'multiple' => false,
                             'maxitems' => 999,
                             'items' => [
-                                ['1', '1', null, null],
-                                ['foo', 'foo', null, null],
-                                ['bar', 'bar', null, null],
-                                ['2', '2', null, null],
+                                ['1', '1', null, null, null],
+                                ['foo', 'foo', null, null, null],
+                                ['bar', 'bar', null, null, null],
+                                ['2', '2', null, null, null],
                             ],
                         ],
                     ],
diff --git a/typo3/sysext/core/Classes/Utility/ExtensionManagementUtility.php b/typo3/sysext/core/Classes/Utility/ExtensionManagementUtility.php
index 10b95e75293a..972bbdd2464c 100644
--- a/typo3/sysext/core/Classes/Utility/ExtensionManagementUtility.php
+++ b/typo3/sysext/core/Classes/Utility/ExtensionManagementUtility.php
@@ -479,6 +479,66 @@ class ExtensionManagementUtility
         }
     }
 
+    /**
+     * Adds an item group to a TCA select field, allows to add a group so addTcaSelectItem() can add a groupId
+     * with a label and its position within other groups.
+     *
+     * @param string $table the table name in TCA - e.g. tt_content
+     * @param string $field the field name in TCA - e.g. CType
+     * @param string $groupId the unique identifier for a group, where all items from addTcaSelectItem() with a group ID are connected
+     * @param string $groupLabel the label e.g. LLL:my_extension/Resources/Private/Language/locallang_tca.xlf:group.mygroupId
+     * @param string|null $position e.g. "before:special", "after:default" (where the part after the colon is an existing groupId) or "top" or "bottom"
+     */
+    public static function addTcaSelectItemGroup(string $table, string $field, string $groupId, string $groupLabel, ?string $position = 'bottom'): void
+    {
+        if (!is_array($GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? null)) {
+            throw new \RuntimeException('Given select field item list was not found.', 1586728563);
+        }
+        $itemGroups = $GLOBALS['TCA'][$table]['columns'][$field]['config']['itemGroups'] ?? [];
+        // Group has been defined already, nothing to do
+        if (isset($itemGroups[$groupId])) {
+            return;
+        }
+        $position = (string)$position;
+        $positionGroupId = '';
+        if (strpos($position, ':') !== false) {
+            [$position, $positionGroupId] = explode(':', $position, 2);
+        }
+        // Referenced group was not not found, just append to the bottom
+        if (!isset($itemGroups[$positionGroupId])) {
+            $position = 'bottom';
+        }
+        switch ($position) {
+            case 'after':
+                $newItemGroups = [];
+                foreach ($itemGroups as $existingGroupId => $existingGroupLabel) {
+                    $newItemGroups[$existingGroupId] = $existingGroupLabel;
+                    if ($positionGroupId === $existingGroupId) {
+                        $newItemGroups[$groupId] = $groupLabel;
+                    }
+                }
+                $itemGroups = $newItemGroups;
+                break;
+            case 'before':
+                $newItemGroups = [];
+                foreach ($itemGroups as $existingGroupId => $existingGroupLabel) {
+                    if ($positionGroupId === $existingGroupId) {
+                        $newItemGroups[$groupId] = $groupLabel;
+                    }
+                    $newItemGroups[$existingGroupId] = $existingGroupLabel;
+                }
+                $itemGroups = $newItemGroups;
+                break;
+            case 'top':
+                $itemGroups = array_merge([$groupId => $groupLabel], $itemGroups);
+                break;
+            case 'bottom':
+            default:
+                $itemGroups[$groupId] = $groupLabel;
+        }
+        $GLOBALS['TCA'][$table]['columns'][$field]['config']['itemGroups'] = $itemGroups;
+    }
+
     /**
      * Gets the TCA configuration for a field handling (FAL) files.
      *
@@ -1177,7 +1237,7 @@ class ExtensionManagementUtility
      *
      * FOR USE IN files in Configuration/TCA/Overrides/*.php Use in ext_tables.php FILES may break the frontend.
      *
-     * @param array $itemArray Numerical array: [0] => Plugin label, [1] => Plugin identifier / plugin key, ideally prefixed with a extension-specific name (e.g. "events2_list"), [2] => Path to plugin icon relative to TYPO3_mainDir
+     * @param array $itemArray Numerical array: [0] => Plugin label, [1] => Plugin identifier / plugin key, ideally prefixed with an extension-specific name (e.g. "events2_list"), [2] => Path to plugin icon, [3] => an optional "group" ID, falls back to "default"
      * @param string $type Type (eg. "list_type") - basically a field from "tt_content" table
      * @param string $extensionKey The extension key
      * @throws \RuntimeException
@@ -1198,6 +1258,9 @@ class ExtensionManagementUtility
             // @todo do we really set $itemArray[2], even if we cannot find an icon? (as that means it's set to 'EXT:foobar/')
             $itemArray[2] = 'EXT:' . $extensionKey . '/' . static::getExtensionIcon(static::$packageManager->getPackage($extensionKey)->getPackagePath());
         }
+        if (!isset($itemArray[3])) {
+            $itemArray[3] = 'default';
+        }
         if (is_array($GLOBALS['TCA']['tt_content']['columns']) && is_array($GLOBALS['TCA']['tt_content']['columns'][$type]['config']['items'])) {
             foreach ($GLOBALS['TCA']['tt_content']['columns'][$type]['config']['items'] as $k => $v) {
                 if ((string)$v[1] === (string)$itemArray[1]) {
diff --git a/typo3/sysext/core/Configuration/TCA/pages.php b/typo3/sysext/core/Configuration/TCA/pages.php
index 71b84b43661e..edf418e8b5dc 100644
--- a/typo3/sysext/core/Configuration/TCA/pages.php
+++ b/typo3/sysext/core/Configuration/TCA/pages.php
@@ -78,57 +78,76 @@ return [
                 'items' => [
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.doktype.div.page',
-                        '--div--'
+                        '--div--',
+                        null,
+                        'default'
                     ],
                     [
                         'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:doktype.I.0',
                         (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_DEFAULT,
-                        'apps-pagetree-page-default'
+                        'apps-pagetree-page-default',
+                        'default'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.doktype.I.4',
                         (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_BE_USER_SECTION,
-                        'apps-pagetree-page-backend-users'
+                        'apps-pagetree-page-backend-users',
+                        'default'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.doktype.div.link',
-                        '--div--'
+                        '--div--',
+                        null,
+                        'link'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.doktype.I.2',
                         (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_SHORTCUT,
-                        'apps-pagetree-page-shortcut'
+                        'apps-pagetree-page-shortcut',
+                        'link'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.doktype.I.5',
                         (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_MOUNTPOINT,
-                        'apps-pagetree-page-mountpoint'
+                        'apps-pagetree-page-mountpoint',
+                        'link'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.doktype.I.8',
                         (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_LINK,
-                        'apps-pagetree-page-shortcut-external'
+                        'apps-pagetree-page-shortcut-external',
+                        'link'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.doktype.div.special',
-                        '--div--'
+                        '--div--',
+                        null,
+                        'special'
                     ],
                     [
                         'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:doktype.I.folder',
                         (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_SYSFOLDER,
-                        'apps-pagetree-folder-default'
+                        'apps-pagetree-folder-default',
+                        'special'
                     ],
                     [
                         'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:doktype.I.2',
                         (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_RECYCLER,
-                        'apps-filetree-folder-recycler'
+                        'apps-filetree-folder-recycler',
+                        'special'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.doktype.I.7',
                         (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_SPACER,
-                        'apps-pagetree-spacer'
+                        'apps-pagetree-spacer',
+                        'special'
                     ]
                 ],
+                'itemGroups' => [
+                    'default' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.doktype.div.page',
+                    'link' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.doktype.div.link',
+                    'special' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.doktype.div.special',
+                ],
                 'default' => (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_DEFAULT,
             ]
         ],
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-91008-ItemGroupingForTCASelectItems.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-91008-ItemGroupingForTCASelectItems.rst
new file mode 100644
index 000000000000..c800aedb6c88
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-91008-ItemGroupingForTCASelectItems.rst
@@ -0,0 +1,150 @@
+.. include:: ../../Includes.txt
+
+====================================================
+Feature: #91008 - Item grouping for TCA select items
+====================================================
+
+See :issue:`91008`
+
+Description
+===========
+
+The TCA column type "select" now has a clean API to group items for dropdowns
+in FormEngine. This was previously handled via placeholder "--div--" items,
+which then rendered as `<optgroup>` HTML elements in a dropdown.
+
+In larger installations or TYPO3 instances with lots of extensions, Plugins
+(`tt_content.list_type`), Content Types (`tt_content.CType`) or custom
+Page Types (`pages.doktype`), grouping can now be configured on a per-item
+basis. Custom groups can be added via an API or when defining TCA for a new table.
+
+Adding Custom Select Item Groups
+--------------------------------
+
+Registration of a select item group takes place in :php:`Configuration/TCA/tx_mytable.php`
+for new TCA tables, and in :php:`Configuration/TCA/Overrides/a_random_core_table.php`
+for modifying an existing TCA definition.
+
+The following two examples illustrate adding a new group to a field of
+type "select":
+
+.. code-block:: php
+
+   ExtensionManagementUtility:addTcaSelectItemGroup(
+       'tt_content',
+       'CType',
+       'sliders',
+       'LLL:EXT:my_slider_mixtape/Resources/Private/Language/locallang_tca.xlf:tt_content.group.sliders',
+       'after:lists'
+   );
+
+The TCA for `tt_content.CType` column configuration looks like this now:
+
+.. code-block:: php
+
+   'items' => ...
+   'itemGroups' => [
+       'default' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.standard',
+       'lists' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.lists',
+       'sliders' => 'LLL:EXT:my_slider_mixtape/Resources/Private/Language/locallang_tca.xlf:tt_content.group.sliders',
+       'menu' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.menu',
+       'forms' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.forms',
+       'special' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.special',
+    ],
+
+When adding a new select field, itemGroups should be added directly in the
+original TCA definition without using the API method. Use the API within
+`TCA/Configuration/Overrides/` files to extend an existing TCA select field with
+grouping.
+
+Attaching Select Items to Item Groups
+-------------------------------------
+
+A select item now has a fourth array key to define a "Group ID" to which group it
+belongs to. In the example above, the group ID is named "sliders" and used
+in the examples below to attach items to this group.
+
+Grouping for select items can be used via API or in TCA configuration directly.
+
+This is the example for a custom Content Type "slickslider" belonging to the
+group from above:
+
+.. code-block:: php
+
+   'items' => [
+       ...,
+       [
+           // Label
+           'LLL:my_slider_mixtape/Resources/Private/Locallang/locallang_tca.xlf:tt_content.CType.slickslider',
+           // Value written to the database
+           'slickslider',
+           // Icon for the dropdown
+           'EXT:my_slider_mixtape/Resources/Public/Icons/slickslider.png',
+           // The group ID, if not given, falls back to "none" or the last used --div-- in the item array
+           'sliders'
+       ],
+   ]
+
+
+The item can be added via API like this:
+
+.. code-block:: php
+
+   ExtensionManagementUtility::addTcaSelectItem(
+       'tt_content',
+       'CType',
+       [
+           'LLL:my_slider_mixtape/Resources/Private/Locallang/locallang_tca.xlf:tt_content.CType.slickslider',
+           'slickslider',
+           'EXT:my_slider_mixtape/Resources/Public/Icons/slickslider.png',
+           'sliders'
+       ]
+   );
+
+The same approach applies to :php:`ExtensionManagementUtility::addPlugin()` when
+adding pi-based plugins.
+
+When adding Extbase plugins, the API method now allows to specify a group ID
+directly as additional parameter. This falls back to the "default" group ID,
+which is available in `tt_content.CType` and `tt_content.list_type`.
+
+.. code-block:: php
+
+   ExtensionUtility::registerPlugin(
+       // Extension key
+       'my_slider_mixtape',
+       // Plugin value
+       'slider_from_records',
+       // Plugin label
+       'LLL:my_slider_mixtape/Resources/Private/Locallang/locallang_tca.xlf:tt_content.plugin.slider_from_records',
+       // Icon for plugin
+       'EXT:my_slider_mixtape/Resources/Public/Icons/slickslider.png',
+       // Group ID
+       'sliders'
+   );
+
+
+Impact
+======
+
+By default, Page Types (`pages.doktype`), Content Types (`tt_content.CType`) and
+Plugins (`tt_content.list_type`) now have native grouping enabled.
+
+The order of the `itemGroups` value is important when using groups, as this
+is the order of the groups rendered in the dropdown of FormEngine.
+
+The API methods can be used to build more groups without juggling with
+TCA arrays.
+
+It is possible now, and encouraged to remove the `--div--` items in custom
+selects and use itemGroups instead. TYPO3 Core keeps the `--div--` for
+backwards-compatible reasons in TYPO3 v10, but all items of the fields mentioned
+above the grouping parameter has been added already.
+
+Please note that this `--div--` is related to select items, and not the
+"showItem" definition which fields should be shown.
+
+Currently Item Groups are used in FormEngine DropDowns / single-select items
+from TYPO3 Core, but can be used in multi-select fields as well.
+
+.. index:: TCA, ext:core
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-91008-ItemSortingForTCASelectItems.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-91008-ItemSortingForTCASelectItems.rst
new file mode 100644
index 000000000000..f8bb58437abf
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-91008-ItemSortingForTCASelectItems.rst
@@ -0,0 +1,58 @@
+.. include:: ../../Includes.txt
+
+===================================================
+Feature: #91008 - Item sorting for TCA select items
+===================================================
+
+See :issue:`91008`
+
+Description
+===========
+
+A new option `sortOrders` for TCA-based select fields has been added to allow
+sorting of static TCA select items by their values or labels.
+
+This is now used in TYPO3 Core's `tt_content.list_type` whereas
+a previous `itemProcFunc` was used to sort all plugins by label
+in the FormEngine dropdown.
+
+Built-in orderings are to sort items by their labels or values. It is also possible
+to define custom `sortOrders` via custom PHP code.
+
+Examples from tt_contents' `list_type` TCA:
+
+.. code-block:: php
+
+   // Sort all items by label ("asc" or "desc" is possible)
+   $GLOBALS['TCA']['tt_content']['columns']['list_type']['config']['sortItems'] = [
+       'label' => 'asc'
+   ];
+
+   // Sort all items by value ("asc" or "desc" is possible)
+   $GLOBALS['TCA']['tt_content']['columns']['list_type']['config']['sortItems'] = [
+       'value' => 'desc'
+   ];
+
+   // Sort all items by a custom function
+   $GLOBALS['TCA']['tt_content']['columns']['list_type']['config']['sortItems'] = [
+       'My_Extension' => 'ksort'
+   ];
+
+   $GLOBALS['TCA']['tt_content']['columns']['list_type']['config']['sortItems'] = [
+       'My_Extension' => \VendorName\PackageName\TcaSorter::class . '->sortByMagic'
+   ];
+
+When using grouped select fields with "itemGroups", sorting happens on a
+per-group basis - all items within one group are sorted - as the group ordering
+is preserved.
+
+
+Impact
+======
+
+Plugins in FormEngine are now using this option in TYPO3 Core, and other TCA
+select fields can benefit from this as well.
+
+This option is solely built for display purposes in FormEngine.
+
+.. index:: TCA, ext:core
diff --git a/typo3/sysext/core/Tests/Unit/Utility/ExtensionManagementUtilityTest.php b/typo3/sysext/core/Tests/Unit/Utility/ExtensionManagementUtilityTest.php
index c09c9e960cc2..db4f02ae2279 100644
--- a/typo3/sysext/core/Tests/Unit/Utility/ExtensionManagementUtilityTest.php
+++ b/typo3/sysext/core/Tests/Unit/Utility/ExtensionManagementUtilityTest.php
@@ -1835,7 +1835,8 @@ class ExtensionManagementUtilityTest extends UnitTestCase
             [
                 'label',
                 $extKey,
-                'EXT:' . $extKey . '/Resources/Public/Icons/Extension.png'
+                'EXT:' . $extKey . '/Resources/Public/Icons/Extension.png',
+                'default'
             ]
         ];
         $GLOBALS['TCA']['tt_content']['columns']['list_type']['config']['items'] = [];
@@ -1853,4 +1854,119 @@ class ExtensionManagementUtilityTest extends UnitTestCase
 
         ExtensionManagementUtility::addPlugin('test');
     }
+
+    public function addTcaSelectItemGroupAddsGroupDataProvider()
+    {
+        return [
+            'add the first group' => [
+                'my_group',
+                'my_group_label',
+                null,
+                null,
+                [
+                    'my_group' => 'my_group_label'
+                ]
+            ],
+            'add a new group at the bottom' => [
+                'my_group',
+                'my_group_label',
+                'bottom',
+                [
+                    'default' => 'default_label'
+                ],
+                [
+                    'default' => 'default_label',
+                    'my_group' => 'my_group_label'
+                ]
+            ],
+            'add a new group at the top' => [
+                'my_group',
+                'my_group_label',
+                'top',
+                [
+                    'default' => 'default_label'
+                ],
+                [
+                    'my_group' => 'my_group_label',
+                    'default' => 'default_label'
+                ]
+            ],
+            'add a new group after an existing group' => [
+                'my_group',
+                'my_group_label',
+                'after:default',
+                [
+                    'default' => 'default_label',
+                    'special' => 'special_label'
+                ],
+                [
+                    'default' => 'default_label',
+                    'my_group' => 'my_group_label',
+                    'special' => 'special_label'
+                ]
+            ],
+            'add a new group before an existing group' => [
+                'my_group',
+                'my_group_label',
+                'before:default',
+                [
+                    'default' => 'default_label',
+                    'special' => 'special_label'
+                ],
+                [
+                    'my_group' => 'my_group_label',
+                    'default' => 'default_label',
+                    'special' => 'special_label'
+                ]
+            ],
+            'add a new group after a non-existing group moved to bottom' => [
+                'my_group',
+                'my_group_label',
+                'after:default2',
+                [
+                    'default' => 'default_label',
+                    'special' => 'special_label'
+                ],
+                [
+                    'default' => 'default_label',
+                    'special' => 'special_label',
+                    'my_group' => 'my_group_label',
+                ]
+            ],
+            'add a new group which already exists does nothing' => [
+                'my_group',
+                'my_group_label',
+                'does-not-matter',
+                [
+                    'default' => 'default_label',
+                    'my_group' => 'existing_label',
+                    'special' => 'special_label'
+                ],
+                [
+                    'default' => 'default_label',
+                    'my_group' => 'existing_label',
+                    'special' => 'special_label'
+                ]
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     * @param string $groupId
+     * @param string $groupLabel
+     * @param string $position
+     * @param array|null $existingGroups
+     * @param array $expectedGroups
+     * @dataProvider addTcaSelectItemGroupAddsGroupDataProvider
+     */
+    public function addTcaSelectItemGroupAddsGroup(string $groupId, string $groupLabel, ?string $position, ?array $existingGroups, array $expectedGroups)
+    {
+        $GLOBALS['TCA']['tt_content']['columns']['CType']['config'] = [];
+        if (is_array($existingGroups)) {
+            $GLOBALS['TCA']['tt_content']['columns']['CType']['config']['itemGroups'] = $existingGroups;
+        }
+        ExtensionManagementUtility::addTcaSelectItemGroup('tt_content', 'CType', $groupId, $groupLabel, $position);
+        self::assertEquals($expectedGroups, $GLOBALS['TCA']['tt_content']['columns']['CType']['config']['itemGroups']);
+    }
 }
diff --git a/typo3/sysext/extbase/Classes/Utility/ExtensionUtility.php b/typo3/sysext/extbase/Classes/Utility/ExtensionUtility.php
index 92f9d7ee7900..46df5a647937 100644
--- a/typo3/sysext/extbase/Classes/Utility/ExtensionUtility.php
+++ b/typo3/sysext/extbase/Classes/Utility/ExtensionUtility.php
@@ -150,9 +150,10 @@ tt_content.' . $pluginSignature . ' {
      * @param string $pluginName must be a unique id for your plugin in UpperCamelCase (the string length of the extension key added to the length of the plugin name should be less than 32!)
      * @param string $pluginTitle is a speaking title of the plugin that will be displayed in the drop down menu in the backend
      * @param string $pluginIcon is an icon identifier or file path prepended with "EXT:", that will be displayed in the drop down menu in the backend (optional)
+     * @param string $group add this plugin to a plugin group, should be something like "news" or the like, "default" as regular
      * @throws \InvalidArgumentException
      */
-    public static function registerPlugin($extensionName, $pluginName, $pluginTitle, $pluginIcon = null)
+    public static function registerPlugin($extensionName, $pluginName, $pluginTitle, $pluginIcon = null, $group = 'default')
     {
         self::checkPluginNameFormat($pluginName);
         self::checkExtensionNameFormat($extensionName);
@@ -176,8 +177,12 @@ tt_content.' . $pluginSignature . ' {
         // pluginType is usually defined by configurePlugin() in the global array. Use this or fall back to default "list_type".
         $pluginType = $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions'][$extensionName]['plugins'][$pluginName]['pluginType'] ?? 'list_type';
 
+        $itemArray = [$pluginTitle, $pluginSignature, $pluginIcon];
+        if ($group) {
+            $itemArray[3] = $group;
+        }
         \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addPlugin(
-            [$pluginTitle, $pluginSignature, $pluginIcon],
+            $itemArray,
             $pluginType,
             $extensionKey
         );
diff --git a/typo3/sysext/felogin/Configuration/TCA/Overrides/tt_content.php b/typo3/sysext/felogin/Configuration/TCA/Overrides/tt_content.php
index ec1e51ab73e9..39a097cbf5b0 100644
--- a/typo3/sysext/felogin/Configuration/TCA/Overrides/tt_content.php
+++ b/typo3/sysext/felogin/Configuration/TCA/Overrides/tt_content.php
@@ -12,10 +12,23 @@ call_user_func(static function () {
         \TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin(
             'Felogin',
             'Login',
-            'Login Form'
+            'Login Form',
+            null,
+            'forms'
         );
     } else {
         $contentTypeName = 'login';
+        // Add CType=login
+        \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTcaSelectItem(
+            'tt_content',
+            'CType',
+            [
+                'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.10',
+                'login',
+                'content-elements-login',
+                'forms'
+            ]
+        );
     }
     // Add the FlexForm
     \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addPiFlexFormValue(
@@ -25,47 +38,6 @@ call_user_func(static function () {
     );
     $GLOBALS['TCA']['tt_content']['ctrl']['typeicon_classes'][$contentTypeName] = 'mimetypes-x-content-login';
 
-    // check if there is already a forms tab and add the item after that, otherwise
-    // add the tab item as well
-    $additionalCTypeItem = [
-        'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.10',
-        $contentTypeName,
-        'content-elements-login'
-    ];
-
-    $existingCTypeItems = $GLOBALS['TCA']['tt_content']['columns']['CType']['config']['items'];
-    $groupFound = false;
-    $groupPosition = false;
-    foreach ($existingCTypeItems as $position => $item) {
-        if ($item[0] === 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.forms') {
-            $groupFound = true;
-            $groupPosition = $position;
-            break;
-        }
-    }
-
-    if ($groupFound && $groupPosition) {
-        // add the new CType item below CType
-        array_splice(
-            $GLOBALS['TCA']['tt_content']['columns']['CType']['config']['items'],
-            $groupPosition,
-            0,
-            [0 => $additionalCTypeItem]
-        );
-    } else {
-        // nothing found, add two items (group + new CType) at the bottom of the list
-        \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTcaSelectItem(
-            'tt_content',
-            'CType',
-            ['LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.forms', '--div--']
-        );
-        \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTcaSelectItem(
-            'tt_content',
-            'CType',
-            $additionalCTypeItem
-        );
-    }
-
     $GLOBALS['TCA']['tt_content']['types'][$contentTypeName]['showitem'] = '
         --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general,
             --palette--;;general,
diff --git a/typo3/sysext/form/Configuration/TCA/Overrides/tt_content.php b/typo3/sysext/form/Configuration/TCA/Overrides/tt_content.php
index b5d6584c3015..6d6a5b96ee68 100644
--- a/typo3/sysext/form/Configuration/TCA/Overrides/tt_content.php
+++ b/typo3/sysext/form/Configuration/TCA/Overrides/tt_content.php
@@ -37,6 +37,7 @@ call_user_func(function () {
         'Form',
         'Formframework',
         'Form',
-        'content-form'
+        'content-form',
+        'forms'
     );
 });
diff --git a/typo3/sysext/frontend/Classes/Hooks/TableColumnHooks.php b/typo3/sysext/frontend/Classes/Hooks/TableColumnHooks.php
deleted file mode 100644
index b2bc344d9664..000000000000
--- a/typo3/sysext/frontend/Classes/Hooks/TableColumnHooks.php
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php
-
-/*
- * This file is part of the TYPO3 CMS project.
- *
- * It is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License, either version 2
- * of the License, or any later version.
- *
- * For the full copyright and license information, please read the
- * LICENSE.txt file that was distributed with this source code.
- *
- * The TYPO3 project - inspiring people to share!
- */
-
-namespace TYPO3\CMS\Frontend\Hooks;
-
-/**
- * Hooks / manipulation data for TCA columns e.g. to sort items within itemsProcFunc
- * @internal this is a concrete TYPO3 hook implementation and solely used for EXT:frontend and not part of TYPO3's Core API.
- */
-class TableColumnHooks
-{
-    /**
-     * sort list items (used for plugins, list_type) by name
-     * @param array $parameters
-     */
-    public function sortPluginList(array &$parameters)
-    {
-        @usort(
-            $parameters['items'],
-            function ($item1, $item2) {
-                return strcasecmp($this->getLanguageService()->sL($item1[0]), $this->getLanguageService()->sL($item2[0]));
-            }
-        );
-    }
-
-    /**
-     * Returns LanguageService
-     *
-     * @return \TYPO3\CMS\Core\Localization\LanguageService
-     */
-    protected function getLanguageService()
-    {
-        return $GLOBALS['LANG'];
-    }
-}
diff --git a/typo3/sysext/frontend/Configuration/TCA/tt_content.php b/typo3/sysext/frontend/Configuration/TCA/tt_content.php
index 1dcb7909617c..1d67b9b31292 100644
--- a/typo3/sysext/frontend/Configuration/TCA/tt_content.php
+++ b/typo3/sysext/frontend/Configuration/TCA/tt_content.php
@@ -73,136 +73,174 @@ return [
                 'items' => [
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.standard',
-                        '--div--'
+                        '--div--',
+                        null,
+                        'default'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.0',
                         'header',
-                        'content-header'
+                        'content-header',
+                        'default'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.1',
                         'text',
-                        'content-text'
+                        'content-text',
+                        'default'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.2',
                         'textpic',
-                        'content-textpic'
+                        'content-textpic',
+                        'default'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.3',
                         'image',
-                        'content-image'
+                        'content-image',
+                        'default'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.textmedia',
                         'textmedia',
-                        'content-textmedia'
+                        'content-textmedia',
+                        'default'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.lists',
-                        '--div--'
+                        '--div--',
+                        null,
+                        'lists'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.4',
                         'bullets',
-                        'content-bullets'
+                        'content-bullets',
+                        'lists'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.5',
                         'table',
-                        'content-table'
+                        'content-table',
+                        'lists'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.6',
                         'uploads',
-                        'content-special-uploads'
+                        'content-special-uploads',
+                        'lists'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.menu',
-                        '--div--'
+                        '--div--',
+                        null,
+                        'menu'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.menu_abstract',
                         'menu_abstract',
-                        'content-menu-abstract'
+                        'content-menu-abstract',
+                        'menu'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.menu_categorized_content',
                         'menu_categorized_content',
-                        'content-menu-categorized'
+                        'content-menu-categorized',
+                        'menu'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.menu_categorized_pages',
                         'menu_categorized_pages',
-                        'content-menu-categorized'
+                        'content-menu-categorized',
+                        'menu'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.menu_pages',
                         'menu_pages',
-                        'content-menu-pages'
+                        'content-menu-pages',
+                        'menu'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.menu_subpages',
                         'menu_subpages',
-                        'content-menu-pages'
+                        'content-menu-pages',
+                        'menu'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.menu_recently_updated',
                         'menu_recently_updated',
-                        'content-menu-recently-updated'
+                        'content-menu-recently-updated',
+                        'menu'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.menu_related_pages',
                         'menu_related_pages',
-                        'content-menu-related'
+                        'content-menu-related',
+                        'menu'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.menu_section',
                         'menu_section',
-                        'content-menu-section'
+                        'content-menu-section',
+                        'menu'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.menu_section_pages',
                         'menu_section_pages',
-                        'content-menu-section'
+                        'content-menu-section',
+                        'menu'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.menu_sitemap',
                         'menu_sitemap',
-                        'content-menu-sitemap'
+                        'content-menu-sitemap',
+                        'menu'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.menu_sitemap_pages',
                         'menu_sitemap_pages',
-                        'content-menu-sitemap-pages'
+                        'content-menu-sitemap-pages',
+                        'menu'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.special',
-                        '--div--'
+                        '--div--',
+                        null,
+                        'special'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.13',
                         'shortcut',
-                        'content-special-shortcut'
+                        'content-special-shortcut',
+                        'special'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.14',
                         'list',
-                        'content-plugin'
+                        'content-plugin',
+                        'special'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.16',
                         'div',
-                        'content-special-div'
+                        'content-special-div',
+                        'special'
                     ],
                     [
                         'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.17',
                         'html',
-                        'content-special-html'
+                        'content-special-html',
+                        'special'
                     ]
                 ],
+                'itemGroups' => [
+                    'default' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.standard',
+                    'lists' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.lists',
+                    'menu' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.menu',
+                    'forms' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.forms',
+                    'special' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.special',
+                ],
                 'default' => 'text',
                 'authMode' => $GLOBALS['TYPO3_CONF_VARS']['BE']['explicitADmode'],
                 'authMode_enforce' => 'strict',
@@ -952,10 +990,16 @@ return [
                     [
                         '',
                         '',
-                        ''
+                        '',
+                        'none'
                     ]
                 ],
-                'itemsProcFunc' => \TYPO3\CMS\Frontend\Hooks\TableColumnHooks::class . '->sortPluginList',
+                'itemGroups' => [
+                    'default' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.default_value'
+                ],
+                'sortItems' => [
+                    'label' => 'asc'
+                ],
                 'default' => '',
                 'authMode' => $GLOBALS['TYPO3_CONF_VARS']['BE']['explicitADmode'],
                 'authMode_enforce' => 'strict'
-- 
GitLab