Commit 88fc2b49 authored by Susanne Moog's avatar Susanne Moog Committed by Anja Leichsenring
Browse files

[TASK] Move Dashboard out of web

To allow the dashboard to be positioned out of the "Web" main module,
we rebuilt the module menu to allow direct top level modules.

Additionally, the styling for the module menu was adjusted to allow
mobile and keyboard friendly navigation and layout.


Resolves: #90862
Releases: master
Change-Id: I1005bac37a214530fb9e304fec2406799fe92240
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/63718


Tested-by: Richard Haeser's avatarRichard Haeser <richard@maxserv.com>
Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Riccardo De Contardi's avatarRiccardo De Contardi <erredeco@gmail.com>
Tested-by: Benjamin Kott's avatarBenjamin Kott <benjamin.kott@outlook.com>
Tested-by: Anja Leichsenring's avatarAnja Leichsenring <aleichsenring@ab-softlab.de>
Reviewed-by: Richard Haeser's avatarRichard Haeser <richard@maxserv.com>
Reviewed-by: Benjamin Kott's avatarBenjamin Kott <benjamin.kott@outlook.com>
Reviewed-by: Anja Leichsenring's avatarAnja Leichsenring <aleichsenring@ab-softlab.de>
parent 74173377
//
// Module menu
// Module Menu
// ===========
//
// Module links
$modulemenu-item-padding-vertical: 2px;
$modulemenu-item-padding-horizontal: 4px;
//
// Component
//
.modulemenu {
margin: 0;
padding: 1em;
list-style: none;
// Module menu wrapper
.module-wrapper {
position: relative;
> ul > .modulemenu-group {
margin: 1em 0;
iframe {
border: none;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
}
// Module menu styling
.modulemenu {
/// Module menu group
.modulemenu-group-container {
clear: both;
@extend .list-unstyled;
//
// Action
//
.modulemenu-action {
display: flex;
padding: 4px;
margin-top: 1px;
color: inherit;
align-items: center;
width: 100%;
overflow: hidden;
border: none;
border-radius: 3px;
background-color: transparent;
text-align: left;
&:not(:disabled):focus,
&:not(:disabled):hover {
color: inherit;
text-decoration: none;
outline: none;
}
.modulemenu-group {
position: relative;
padding: 5px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
&:not(:disabled):focus {
background-color: rgba(255, 255, 255, 0.05);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
}
.modulemenu-group-header,
.modulemenu-item-link {
@extend .clearfix;
position: relative;
display: block;
cursor: pointer;
padding: $modulemenu-item-padding-vertical $modulemenu-item-padding-horizontal;
text-decoration: none;
&:not(:disabled):hover {
background-color: rgba(255, 255, 255, 0.2);
}
.modulemenu-group-header {
user-select: none;
text-transform: uppercase;
&.modulemenu-action-active {
background-color: rgba(255, 255, 255, 0.2);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
}
}
.modulemenu-item-link {
&:focus,
&:hover {
outline: none;
background-color: rgba(255, 255, 255, 0.1);
}
}
//
// Icon
//
.modulemenu-icon {
position: relative;
display: block;
width: 32px;
height: 32px;
border-radius: 2px;
flex-shrink: 0;
overflow: hidden;
}
//
// Name
//
.modulemenu-name {
overflow: hidden;
display: none;
flex-grow: 1;
margin-left: 1em;
white-space: nowrap;
text-overflow: ellipsis;
}
.modulemenu-item.active {
.modulemenu-item-link {
background-color: rgba(255, 255, 255, 0.2);
//
// Indicator
//
.modulemenu-group > button:not(:disabled) {
.modulemenu-indicator {
display: none;
position: relative;
flex-grow: 0;
flex-shrink: 0;
height: 16px;
width: 16px;
margin: 8px;
color: inherit;
&:before,
&:after {
content: '';
position: absolute;
top: 50%;
height: 0;
width: 6px;
border-top: 1px solid currentColor;
transition: transform 0.25s ease-in-out;
}
}
// Module menu icons
.modulemenu-icon {
float: left;
margin-right: $modulemenu-item-padding-horizontal;
&:before {
left: 3px;
transform: rotate(45deg);
}
.fa {
font-size: 0.5em;
&:after {
right: 3px;
transform: rotate(-45deg);
}
}
}
// Module menu group and item titles
.modulemenu-group-title,
.modulemenu-item-title {
white-space: nowrap;
text-overflow: ellipsis;
padding-top: 7px;
padding-left: $modulemenu-item-padding-horizontal;
display: none;
overflow: hidden;
*zoom: 1;
//
// Group
//
.modulemenu-group-container {
list-style: none;
padding: 0;
margin: 0;
> li {
width: auto !important;
}
}
.modulemenu-group-title {
padding-right: 20px;
.modulemenu-group-spacer {
margin: 1.5em 0;
border-top: 1px dashed rgba(255, 255, 255, 0.15);
}
.caret {
@include rotate(90deg);
button[aria-expanded="true"]:not(:disabled) {
.modulemenu-indicator {
&:before {
transform: rotate(-45deg);
}
position: absolute;
top: 17px;
right: 18px;
&:after {
transform: rotate(45deg);
}
}
}
// Module Group Expanded
.expanded {
.modulemenu-group-title {
.caret {
@include rotate(0deg);
}
}
//
// Dropdown
//
.dropdown-menu {
.modulemenu-name {
display: block;
}
}
// Module menu snapped
//
// Scaffold
//
.scaffold-modulemenu-expanded {
.modulemenu-group-title,
.modulemenu-item-title {
.modulemenu-action {
margin-left: 0;
}
.modulemenu-indicator {
display: block !important;
}
.modulemenu-name {
display: block;
}
}
......@@ -22,8 +22,8 @@ $scaffold-topbar-toolbar-bg: $scaffold-secondary-bg;
$scaffold-topbar-toolbar-color: $scaffold-secondary-color;
$scaffold-modulemenu-bg: lighten($scaffold-secondary-bg, 8);
$scaffold-modulemenu-color: $scaffold-secondary-color;
$scaffold-modulemenu-snapped-width: 40px;
$scaffold-modulemenu-expanded-width: 230px;
$scaffold-modulemenu-snapped-width: 64px;
$scaffold-modulemenu-expanded-width: 240px;
$scaffold-modulemenu-zindex: $zindex-navbar;
$scaffold-toolbar-bg: lighten($scaffold-secondary-bg, 8);
$scaffold-toolbar-color: $scaffold-secondary-color;
......
......@@ -140,7 +140,7 @@ class ModuleMenu {
navigationComponentId: $subModuleElement.data('navigationcomponentid'),
navigationFrameScript: $subModuleElement.data('navigationframescript'),
navigationFrameScriptParam: $subModuleElement.data('navigationframescriptparameters'),
link: $subModuleElement.find('a').data('link'),
link: $subModuleElement.data('link'),
};
}
......@@ -148,8 +148,8 @@ class ModuleMenu {
* @param {string} module
*/
private static highlightModuleMenuItem(module: string): void {
$('.modulemenu-item.active').removeClass('active');
$('#' + module).addClass('active');
$('.modulemenu-action.modulemenu-action-active').removeClass('modulemenu-action-active');
$('#' + module).addClass('modulemenu-action-active');
}
constructor() {
......@@ -162,7 +162,7 @@ class ModuleMenu {
public refreshMenu(): void {
new AjaxRequest(TYPO3.settings.ajaxUrls.modulemenu).get().then(async (response: AjaxResponse): Promise<void> => {
const result = await response.resolve();
document.getElementById('menu').outerHTML = result.menu;
document.getElementById('modulemenu').outerHTML = result.menu;
if (top.currentModuleLoaded) {
ModuleMenu.highlightModuleMenuItem(top.currentModuleLoaded);
}
......@@ -211,7 +211,7 @@ class ModuleMenu {
);
} else {
// fetch first module
const $firstModule = $('.t3js-mainmodule:first');
const $firstModule = $('.t3js-modulemenu-action[data-link]:first');
if ($firstModule.attr('id')) {
deferred = this.showModule(
$firstModule.attr('id'),
......@@ -228,31 +228,40 @@ class ModuleMenu {
}
private initializeEvents(): void {
$(document).on('click', '.modulemenu-group .modulemenu-group-header', (e: JQueryEventObject): void => {
$(document).on('click', '.t3js-modulemenu-action', (e: JQueryEventObject): void => {
const $element = $(e.currentTarget);
const $group = $(e.currentTarget).parent('.modulemenu-group');
const $groupContainer = $group.find('.modulemenu-group-container');
Viewport.NavigationContainer.cleanup();
if ($group.hasClass('expanded')) {
ModuleMenu.addCollapsedMainMenuItem($group.attr('id'));
$group.addClass('collapsed').removeClass('expanded');
$groupContainer.stop().slideUp().promise().done((): void => {
Viewport.doLayout();
if ($element.attr('aria-expanded') === 'true') {
ModuleMenu.addCollapsedMainMenuItem($element.attr('id'));
$group.addClass('modulemenu-group-collapsed').removeClass('modulemenu-group-expanded');
$element.attr('aria-expanded', 'false');
$groupContainer.attr('aria-hidden', 'true');
$groupContainer.stop().slideUp({
'complete': function() {
Viewport.doLayout();
}
});
} else {
ModuleMenu.removeCollapseMainMenuItem($group.attr('id'));
$group.addClass('expanded').removeClass('collapsed');
$groupContainer.stop().slideDown().promise().done((): void => {
Viewport.doLayout();
} else if ($element.attr('aria-expanded') === 'false') {
ModuleMenu.removeCollapseMainMenuItem($element.attr('id'));
$group.addClass('modulemenu-group-expanded').removeClass('modulemenu-group-collapsed');
$element.attr('aria-expanded', 'true');
$groupContainer.attr('aria-hidden', 'false');
$groupContainer.stop().slideDown({
'complete': function() {
Viewport.doLayout();
}
});
}
if ($element.attr('data-link')) {
e.preventDefault();
this.showModule($(e.currentTarget).attr('id'), '', e);
}
});
// register clicking on sub modules
$(document).on('click', '.modulemenu-item,.t3-menuitem-submodule', (evt: JQueryEventObject): void => {
evt.preventDefault();
this.showModule($(evt.currentTarget).attr('id'), '', evt);
});
$(document).on('click', '.t3js-topbar-button-modulemenu', (evt: JQueryEventObject): void => {
evt.preventDefault();
ModuleMenu.toggleMenu();
......
......@@ -45,6 +45,12 @@ class Router {
}
});
$(document).on('click', '.t3js-modulemenu-action', (e: JQueryEventObject): void => {
e.preventDefault();
const $me = $(e.currentTarget);
window.location.href = $me.data('link');
});
$(document).on('click', '.card .btn', (e: JQueryEventObject): void => {
e.preventDefault();
......@@ -170,7 +176,7 @@ class Router {
// Mark main module as active in standalone
if ($(this.selectorBody).data('context') !== 'backend') {
const controller = $outputContainer.data('controller');
$outputContainer.find('.t3js-mainmodule[data-controller="' + controller + '"]').addClass('active');
$outputContainer.find('.t3js-modulemenu-action[data-controller="' + controller + '"]').addClass('modulemenu-action-active');
}
this.loadCards();
} else {
......
......@@ -146,7 +146,7 @@ class BackendController
$this->initializeToolbarItems();
$this->executeHook('constructPostProcess');
$this->moduleStorage = $this->backendModuleRepository->loadAllowedModules(['user', 'help', 'dashboard']);
$this->moduleStorage = $this->backendModuleRepository->loadAllowedModules(['user', 'help']);
}
/**
......
......@@ -74,6 +74,13 @@ class BackendModule
*/
protected $collapsed = false;
/**
* Standalone modules are top-level modules without a group
*
* @var bool
*/
protected $standalone = false;
/**
* construct
*/
......@@ -293,4 +300,20 @@ class BackendModule
{
return $this->collapsed;
}
/**
* @return bool
*/
public function isStandalone(): bool
{
return $this->standalone;
}
/**
* @param bool $standalone
*/
public function setStandalone(bool $standalone): void
{
$this->standalone = $standalone;
}
}
......@@ -62,7 +62,7 @@ class BackendModuleRepository implements \TYPO3\CMS\Core\SingletonInterface
$modules = new \SplObjectStorage();
foreach ($this->moduleStorage->getEntries() as $moduleGroup) {
if (!in_array($moduleGroup->getName(), $excludeGroupNames, true)) {
if ($moduleGroup->getChildren()->count() > 0) {
if ($moduleGroup->getChildren()->count() > 0 || $moduleGroup->isStandalone()) {
$modules->attach($moduleGroup);
}
}
......@@ -183,6 +183,9 @@ class BackendModuleRepository implements \TYPO3\CMS\Core\SingletonInterface
if (!empty($module['navigationFrameScriptParam']) && is_string($module['navigationFrameScriptParam'])) {
$entry->setNavigationFrameScriptParameters($module['navigationFrameScriptParam']);
}
if (!empty($module['standalone'])) {
$entry->setStandalone((bool)$module['standalone']);
}
$moduleMenuState = json_decode($this->getBackendUser()->uc['modulemenu'] ?? '{}', true);
$entry->setCollapsed(isset($moduleMenuState[$module['name']]));
return $entry;
......@@ -252,9 +255,10 @@ class BackendModuleRepository implements \TYPO3\CMS\Core\SingletonInterface
'onclick' => 'top.goToModule(' . GeneralUtility::quoteJSvalue($moduleName) . ');',
'icon' => $this->getModuleIcon($moduleKey, $moduleData),
'link' => $moduleLink,
'description' => $moduleLabels['shortdescription']
'description' => $moduleLabels['shortdescription'],
'standalone' => (bool)$moduleData['standalone']
];
if (!is_array($moduleData['sub']) && $moduleData['script'] !== $dummyScript) {
if ((($moduleData['standalone'] ?? false) === false) && !is_array($moduleData['sub']) && $moduleData['script'] !== $dummyScript) {
// Work around for modules with own main entry, but being self the only submodule
$modules[$moduleKey]['subitems'][$moduleKey] = [
'name' => $moduleName,
......
......@@ -4,9 +4,7 @@
</div>
<f:if condition="{hasModules}">
<div class="scaffold-modulemenu t3js-scaffold-modulemenu">
<div class="modulemenu t3js-modulemenu">
<f:format.raw>{moduleMenu}</f:format.raw>
</div>
<f:format.raw>{moduleMenu}</f:format.raw>
</div>
</f:if>
<div class="scaffold-content t3js-scaffold-content {f:if(condition: '!{hasModules}', then: 'scaffold-no-modules')}">
......
<ul class="nav nav-modules" data-role="modulemenu" id="menu">
<f:for each="{modules}" as="mainModule">
<li class="modulemenu-group {f:if(condition: '{mainModule.collapsed}', then: 'collapsed', else: 'expanded')}" id="{mainModule.name}" data-modulename="{mainModule.name}" data-navigationcomponentid="{mainModule.navigationComponentId}" data-navigationframescript="{mainModule.navigationFrameScript}" data-navigationframescriptparameters="{mainModule.navigationFrameScriptParameters}">
<div class="modulemenu-group-header">
<span class="modulemenu-icon modulemenu-group-icon">
<f:if condition="{mainModule.icon}">
<f:then>
<f:format.raw>{mainModule.icon}</f:format.raw>
</f:then>
<f:else>
<core:icon identifier="actions-move-move" size="default" alternativeMarkupIdentifier="inline" />
</f:else>
</f:if>
</span>
<span class="modulemenu-group-title">
{mainModule.title} <span class="caret"></span>
</span>
</div>
<ul class="modulemenu-group-container" {f:if(condition: '{mainModule.collapsed}', then: 'style="display: none;"')}>
<f:for each="{mainModule.children}" as="subModule">
<li id="{subModule.name}" class="modulemenu-item t3js-mainmodule" data-modulename="{mainModule.name}" data-navigationcomponentid="{subModule.navigationComponentId}" data-navigationframescript="{subModule.navigationFrameScript}" data-navigationframescriptparameters="{subModule.navigationFrameScriptParameters}">
<a href="#" data-link="{subModule.link}" class="modulemenu-item-link" title="{subModule.description}">
<span class="modulemenu-icon modulemenu-item-icon">
<f:format.raw>{subModule.icon}</f:format.raw>
</span>
<span class="modulemenu-item-title">
{subModule.title}
</span>
</a>
</li>
</f:for>
</ul>
</li>
</f:for>
</ul>
<div
class="modulemenu t3js-modulemenu"
role="menubar"
data-role="modulemenu"
id="modulemenu"
>
<f:render section="Navigation" arguments="{modules: modules, currentLevel: 1}" />
</div>
<f:section name="Navigation">
<f:if condition="{modules} && {currentLevel} <= 2">
<ul
class="modulemenu-group-container"
{f:if(condition: '{parent}', then: 'aria-visible="{f:if(condition: parent.collapsed, then: \'false\', else: \'true\')}"')}
{f:if(condition: '{parent}', then: '{f:if(condition: parent.collapsed, then: \'style="display: none"\')}')}
>
<f:for each="{modules}" as="module">
<li
data-level="{currentLevel}"
{f:if(condition: '{module.children} && {currentLevel} == 1', then: 'class="modulemenu-group modulemenu-group-{f:if(condition: module.collapsed, then: \'collapsed\', else: \'expanded\')}"')}
>
<button
title="{module.description}"
class="modulemenu-action t3js-modulemenu-action"
role="menuitem"
id="{module.name}"
data-modulename="{module.name}"
{f:if(condition: module.link, then: 'data-link="{module.link}"')}
{f:if(condition: module.navigationComponentId, then: 'data-navigationcomponentid="{module.navigationComponentId}"')}
{f:if(condition: module.navigationFrameScript, then: 'data-navigationframescript="{module.navigationFrameScript}"')}
{f:if(condition: module.navigationFrameScriptParameters, then: 'data-navigationframescriptparameters="{module.navigationFrameScriptParameters}"')}
{f:if(condition: '{module.children} && {currentLevel} == 1', then: 'aria-haspopup="true" aria-expanded="{f:if(condition: module.collapsed, then: \'false\', else: \'true\')}"')}
>
<span class="modulemenu-icon" aria-hidden="true"><f:format.raw>{module.icon}</f:format.raw></span>
<span class="modulemenu-name">{module.title}</span>
<span class="modulemenu-indicator" aria-hidden="true"></span>
</button>
<f:render section="Navigation" arguments="{modules: module.children, parent: module, currentLevel: '{currentLevel + 1}'}" />
</li>
</f:for>
</ul>
</f:if>
</f:section>
......@@ -2,24 +2,28 @@
<h3 class="dropdown-headline">
{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:toolbarItems.help') -> f:format.raw()}
</h3>
<hr>
<div class="dropdown-table">
<f:for each="{modules}" as="module">
<div class="dropdown-table-row"
id="{module.name -> f:format.htmlspecialchars()}"
data-modulename="{module.name -> f:format.htmlspecialchars()}"
data-navigationcomponentid="{module.navigationFrameScript -> f:format.htmlspecialchars()}"
data-navigationframescript="{module.navigationFrameScript -> f:format.htmlspecialchars()}"
data-navigationframescriptparameters="{module.navigationFrameScriptParameters -> f:format.htmlspecialchars()}">
<div class="dropdown-table-column dropdown-table-icon">
{module.icon -> f:format.raw()}
</div>
<div class="dropdown-table-column dropdown-table-title">
<a href="#" data-link="{module.link}" title="{module.description -> f:format.htmlspecialchars()}">
{