diff --git a/Block/Adminhtml/Menu.php b/Block/Adminhtml/Menu.php
new file mode 100644
index 0000000..56a60a3
--- /dev/null
+++ b/Block/Adminhtml/Menu.php
@@ -0,0 +1,76 @@
+groupsProvider = $groupsProvider;
+ $this->serializer = $serializer;
+ }
+
+ /**
+ * Get Groups Json
+ * @return string
+ */
+ public function getGroupsJson(): string
+ {
+ $groups = [];
+ foreach ($this->groupsProvider->get() as $group) {
+ $modules = [];
+ foreach ($group['extensions'] as $moduleName) {
+ $modules[] = $this->moduleNameToPrefix($moduleName);
+ }
+ $groups[] = [
+ 'name' => $group['name'],
+ 'base' => isset($group['base']) ? $this->moduleNameToPrefix($group['base']) : null,
+ 'modules' => $modules,
+ ];
+ }
+
+ return $this->serializer->serialize($groups);
+ }
+
+ /**
+ * Convert Magento module name to data-ui-id prefix.
+ * Magefan_Seo => menu-magefan-seo-
+ * @param string $moduleName
+ * @return string
+ */
+ private function moduleNameToPrefix(string $moduleName): string
+ {
+ return 'menu-' . strtolower(str_replace('_', '-', $moduleName)) . '-';
+ }
+}
diff --git a/Block/Adminhtml/System/Config/Tabs.php b/Block/Adminhtml/System/Config/Tabs.php
new file mode 100644
index 0000000..cfa07ef
--- /dev/null
+++ b/Block/Adminhtml/System/Config/Tabs.php
@@ -0,0 +1,265 @@
+groupsProvider = $groupsProvider;
+ $this->configStructure = $configStructure;
+ $this->currentSectionId = $this->getRequest()->getParam('section');
+ }
+
+ /**
+ * @return array[]
+ */
+ public function getConfigData(): array
+ {
+ return $this->buildConfigStructure();
+ }
+
+ /**
+ * Build a flat A-Z sorted list of groups and standalone extensions.
+ * @return array[]
+ */
+ private function buildConfigStructure(): array
+ {
+ $allActive = $this->fetchMagefanSections();
+ $assignedModules = [];
+ $items = [];
+
+ foreach ($this->groupsProvider->get() as $group) {
+ list($groupItems, $baseItem, $assigned) = $this->buildGroupItems($allActive, $group);
+ $assignedModules = array_merge($assignedModules, $assigned);
+
+ if (empty($groupItems) && $baseItem === null) {
+ continue;
+ }
+
+ $this->applySortOrder($groupItems);
+
+ if ($baseItem !== null) {
+ array_unshift($groupItems, $baseItem);
+ }
+
+ $items[] = [
+ self::ITEM_TYPE => self::TYPE_GROUP,
+ self::ITEM_NAME => (string)__($group[self::ITEM_NAME]),
+ self::EXTENSIONS => $groupItems,
+ ];
+ }
+
+ foreach ($this->excludeGroupedExtensions($allActive, $assignedModules) as $ext) {
+ $ext[self::ITEM_TYPE] = self::TYPE_SINGLE;
+ $items[] = $ext;
+ }
+
+ usort($items, function ($a, $b) {
+ return strcmp($a[self::ITEM_NAME], $b[self::ITEM_NAME]);
+ });
+
+ return ['items' => $items];
+ }
+
+ /**
+ * Extract sub-items and base item for a single group config entry.
+ * Returns [groupItems, baseItem|null, assignedModuleNames].
+ * @param array $allActive
+ * @param array $group
+ * @return array
+ */
+ private function buildGroupItems(array $allActive, array $group): array
+ {
+ $groupItems = [];
+ $baseItem = null;
+ $assigned = [];
+ $baseModule = $group['base'] ?? null;
+
+ foreach ($group[self::EXTENSIONS] as $moduleName) {
+ if (!isset($allActive[$moduleName])) {
+ continue;
+ }
+
+ $item = $allActive[$moduleName];
+ $assigned[] = $moduleName;
+
+ if ($baseModule && $moduleName === $baseModule) {
+ $item[self::ITEM_NAME] = (string)__('General');
+ $baseItem = $item;
+ } else {
+ $groupItems[] = $item;
+ }
+ }
+
+ return [$groupItems, $baseItem, $assigned];
+ }
+
+ /**
+ * Filters out extensions already assigned to groups using hash map lookups
+ * @param array $allExtensions
+ * @param array $assignedModules
+ * @return array
+ */
+ private function excludeGroupedExtensions(array $allExtensions, array $assignedModules): array
+ {
+ $removeMap = array_flip(array_unique($assignedModules));
+ $filtered = [];
+
+ foreach ($allExtensions as $key => $value) {
+ if (!isset($removeMap[$key])) {
+ $filtered[] = $value;
+ }
+ }
+
+ return $filtered;
+ }
+
+ /**
+ * Map Magento sections to internal data array
+ * @return array
+ */
+ private function fetchMagefanSections(): array
+ {
+ $output = [];
+ $nodes = $this->getMagefanConfigChildrenNode();
+
+ if ($nodes) {
+ foreach ($nodes as $section) {
+ if (!$section->isVisible()) {
+ continue;
+ }
+
+ $resource = (string)$section->getAttribute('resource');
+ $moduleName = current(explode('::', $resource));
+ $output[$moduleName] = $this->mapSectionToData($section);
+ }
+ }
+
+ return $output;
+ }
+
+ /**
+ * Extract attributes from Section element
+ * @param Section $section
+ * @return array
+ */
+ private function mapSectionToData(Section $section): array
+ {
+ return [
+ self::ITEM_NAME => $this->resolveLabel($section),
+ self::ITEM_CLASS => (string)$section->getClass(),
+ self::ITEM_URL => $this->generateUrl($section),
+ self::IS_ACTIVE => $section->getId() === $this->currentSectionId,
+ self::SORT_ORDER => (int)$section->getAttribute('sortOrder'),
+ ];
+ }
+
+ /**
+ * @param Section $section
+ * @return string
+ */
+ private function resolveLabel(Section $section): string
+ {
+ $label = $section->getLabel() ? (string)__($section->getLabel()) : '';
+ return $this->_escaper->escapeHtml($label);
+ }
+
+ /**
+ * @param Section $section
+ * @return string
+ */
+ private function generateUrl(Section $section): string
+ {
+ return $this->getUrl('*/*/*', [
+ '_current' => true,
+ 'section' => $section->getId()
+ ]);
+ }
+
+ /**
+ * @param array $list
+ * @return void
+ */
+ private function applySortOrder(array &$list): void
+ {
+ usort($list, function ($a, $b) {
+ $sortA = $a[self::SORT_ORDER];
+ $sortB = $b[self::SORT_ORDER];
+
+ if ($sortA == $sortB) {
+ return 0;
+ }
+ return ($sortA < $sortB) ? -1 : 1;
+ });
+ }
+
+ /**
+ * @return null
+ */
+ public function getMagefanConfigChildrenNode()
+ {
+ $configTabs = $this->configStructure->getTabs();
+ foreach ($configTabs as $node) {
+ if ($node->getId() == 'magefan') {
+ return $node->getChildren();
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/Model/Menu/MagefanGroupsProvider.php b/Model/Menu/MagefanGroupsProvider.php
new file mode 100644
index 0000000..e5c61d3
--- /dev/null
+++ b/Model/Menu/MagefanGroupsProvider.php
@@ -0,0 +1,37 @@
+groups = $groups;
+ }
+
+ /**
+ * Returns groups config.
+ * Each group: ['name' => string, 'extensions' => [ModuleName, ...]]
+ *
+ * @return array
+ */
+ public function get(): array
+ {
+ return $this->groups;
+ }
+}
diff --git a/Plugin/Magento/Config/Block/System/Config/Tabs/AddTabs.php b/Plugin/Magento/Config/Block/System/Config/Tabs/AddTabs.php
new file mode 100644
index 0000000..ea615ca
--- /dev/null
+++ b/Plugin/Magento/Config/Block/System/Config/Tabs/AddTabs.php
@@ -0,0 +1,90 @@
+domFactory = $domFactory;
+ $this->logger = $logger;
+ }
+
+ /**
+ * @param Tabs $subject
+ * @param string $result
+ * @return string
+ */
+ public function afterToHtml(Tabs $subject, string $result): string
+ {
+ try {
+ $domDocument = $this->domFactory->create();
+ $domDocument->loadXML($result);
+
+ if ($tabElelent = $this->getMagefanTabElement($domDocument)) {
+ $fragment = $domDocument->createDocumentFragment();
+
+ $tabsHtml = $subject
+ ->getLayout()
+ ->createBlock(
+ \Magefan\Community\Block\Adminhtml\System\Config\Tabs::class,
+ 'mf_dynamic_config_tabs'
+ )->toHtml();
+
+ $fragment->appendXML($tabsHtml);
+ $tabElelent->appendChild($fragment);
+ $result = $domDocument->saveHTML();
+ }
+ } catch (\Exception $e) {
+ $this->logger->critical($e);
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param \DOMDocument $domDocument
+ * @return \DOMElement|null
+ */
+ private function getMagefanTabElement(\DOMDocument $domDocument): ?\DOMElement
+ {
+ foreach ($domDocument->getElementsByTagName('div') as $element) {
+ if (stripos($element->getAttribute('class'), self::TAB_CLASS) !== false) {
+ foreach ($element->getElementsByTagName('ul') as $ulElement) {
+ $element->removeChild($ulElement);
+ }
+
+ return $element;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/etc/adminhtml/di.xml b/etc/adminhtml/di.xml
index ed54255..8ca8fcc 100644
--- a/etc/adminhtml/di.xml
+++ b/etc/adminhtml/di.xml
@@ -12,4 +12,9 @@
type="Magefan\Community\Plugin\Magento\Backend\Model\Menu\BuilderPlugin"
/>
+
+
+
+
diff --git a/view/adminhtml/layout/default.xml b/view/adminhtml/layout/default.xml
index 0d2e6ed..c5135e8 100644
--- a/view/adminhtml/layout/default.xml
+++ b/view/adminhtml/layout/default.xml
@@ -8,7 +8,7 @@
-
+
diff --git a/view/adminhtml/templates/magefan-config-tabs.phtml b/view/adminhtml/templates/magefan-config-tabs.phtml
new file mode 100644
index 0000000..29979d9
--- /dev/null
+++ b/view/adminhtml/templates/magefan-config-tabs.phtml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
diff --git a/view/adminhtml/templates/menu-magefan.phtml b/view/adminhtml/templates/menu-magefan.phtml
index bef20cc..5063042 100644
--- a/view/adminhtml/templates/menu-magefan.phtml
+++ b/view/adminhtml/templates/menu-magefan.phtml
@@ -9,7 +9,8 @@
@@ -26,7 +27,7 @@ $script ="
script.async = true;
script.onload = function() {
- MagefanMenuManager.init();
+ MagefanMenuManager.init(" . /* @noEscape */ $block->getGroupsJson() . ");
};
script.onerror = function() {
diff --git a/view/adminhtml/web/css/source/_module.less b/view/adminhtml/web/css/source/_module.less
index 87fb64c..cd06c3e 100644
--- a/view/adminhtml/web/css/source/_module.less
+++ b/view/adminhtml/web/css/source/_module.less
@@ -158,7 +158,11 @@
> ul[role=menu] {
display: flex;
flex-direction: column;
+ overflow-y: auto;
+ max-height: ~'calc(100vh - 90px)';
margin: 0 1.5rem;
+ scrollbar-color: rgb(110, 106, 106) transparent;
+ scrollbar-width: thin;
}
}
&:hover {
@@ -228,6 +232,23 @@
}
}
+ // Group headers inside Magefan menu
+ #menu-magefan-community-elements {
+ .mf-group-header {
+ display: block;
+ font-size: 1.1rem;
+ font-weight: 700;
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ color: @submenu-link__color;
+ opacity: .5;
+ padding: .8rem 3rem .4rem;
+ margin-top: .8rem;
+ pointer-events: none;
+ list-style: none;
+ }
+ }
+
// Content
[data-ui-id^=menu-magento-].level-0 {
[data-ui-id^=menu-magefan-].level-1 {
@@ -295,7 +316,7 @@
// Store Configuration Magefan Extension
-.config-nav .magefan-tab .admin__page-nav-title:before {
+.config-nav .magefan-tab .admin__page-nav-title:not(.magefan-nav):before {
content: '\e900';
font-family: 'Magefan-Icons';
font-size: 30px;
@@ -310,9 +331,27 @@
margin-left: 40px;
}
+.config-nav .magefan-tab .admin__page-nav-title.magefan-nav {
+ text-transform: none;
+}
+
+.config-nav .magefan-tab .mf-group .admin__page-nav-item {
+ margin-left: 1.4rem;
+}
+
+.config-nav .magefan-tab .mf-group._hide .admin__page-nav-title._collapsible:after {content: '\e628';}
+
+.config-nav .magefan-tab .mf-group {
+ border-left: none;
+ border-right: none;
+ border-bottom: none;
+}
+
.accordion .form-inline .config .data-grid .magefan-section td {
padding: 1rem;
}
.accordion .form-inline .config .magefan-section tr:nth-child(even) td {
background-color: #f5f5f5;
}
+
+
diff --git a/view/adminhtml/web/js/magefan-menu-config.js b/view/adminhtml/web/js/magefan-menu-config.js
new file mode 100644
index 0000000..df6ef32
--- /dev/null
+++ b/view/adminhtml/web/js/magefan-menu-config.js
@@ -0,0 +1,32 @@
+/**
+ * Copyright © Magefan (support@magefan.com). All rights reserved.
+ * Please visit Magefan.com for license details (https://magefan.com/end-user-license-agreement).
+ */
+
+define([
+ 'uiComponent'
+], function (Component) {
+ 'use strict';
+
+ return Component.extend({
+ defaults: {
+ template: 'Magefan_Community/config/menu',
+ templates: {
+ items: 'Magefan_Community/config/items'
+ },
+ items: []
+ },
+
+ /**
+ * Is tab active
+ *
+ * @param {Array} extensions
+ * @returns {Boolean}
+ */
+ isActive: function (extensions) {
+ return extensions.some(function (item) {
+ return item.is_active;
+ });
+ }
+ });
+});
diff --git a/view/adminhtml/web/js/magefan-menu.js b/view/adminhtml/web/js/magefan-menu.js
index e281348..6e91d50 100644
--- a/view/adminhtml/web/js/magefan-menu.js
+++ b/view/adminhtml/web/js/magefan-menu.js
@@ -38,7 +38,8 @@ var MagefanMenuManager = {
defaultBgColor: '#4a4542',
searchThreshold: 16,
menuOffsetHeight: 147,
- menuLeftPosition: '88px'
+ menuLeftPosition: '88px',
+ groups: []
},
// Cache DOM elements
@@ -46,8 +47,11 @@ var MagefanMenuManager = {
/**
* Initialize the menu manager
+ *
+ * @param {Array} groups
*/
- init: function() {
+ init: function(groups) {
+ this.config.groups = groups || [];
this.elements.menu = document.querySelector(this.config.menuSelector);
if (!this.elements.menu) {
@@ -84,8 +88,18 @@ var MagefanMenuManager = {
setupMenu: function() {
var bgColor = this.getSubmenuBgColor();
- this.processLevel1Parents(bgColor);
+ // Reorganize first so group items exist before processing
this.reorganizeSubmenu();
+
+ // After reorganization, level1Parents = only direct-child items (groups + ungrouped)
+ this.elements.level1Parents = this.elements.submenuContainer
+ ? Array.from(this.elements.submenuContainer.children).filter(function(el) {
+ return el.classList.contains('level-1') && el.classList.contains('parent');
+ })
+ : [];
+
+ this.processLevel1Parents(bgColor);
+ this.processLevel2Parents(bgColor);
this.setupMenuObserver();
},
@@ -130,6 +144,69 @@ var MagefanMenuManager = {
});
},
+ /**
+ * Process level-2 module items inside group submenus (level-3 panels)
+ */
+ processLevel2Parents: function(bgColor) {
+ var self = this;
+
+ var level2Items = Array.from(
+ this.elements.menu.querySelectorAll('.level-1.parent[data-mf-level2]')
+ );
+
+ level2Items.forEach(function(parent) {
+ // Get the direct child .submenu (module options panel)
+ var submenu = null;
+ var children = parent.children;
+ for (var i = 0; i < children.length; i++) {
+ if (children[i].classList.contains('submenu')) {
+ submenu = children[i];
+ break;
+ }
+ }
+ if (!submenu) return;
+
+ submenu.style.backgroundColor = bgColor;
+
+ // Skip if already processed
+ if (submenu.querySelector('.submenu-item-title')) return;
+
+ // Convert