From f6b1acb3db5a70a82bd51efb5e16dfb772c93e05 Mon Sep 17 00:00:00 2001
From: Thomas Hackl <hackl@data-quest.de>
Date: Fri, 16 Dec 2022 07:33:40 +0000
Subject: [PATCH] =?UTF-8?q?Resolve=20"Polishing=20f=C3=BCr=20die=20neue=20?=
 =?UTF-8?q?HTML-Struktur=20und=20die=20responsive=20Ansicht"=20Teil=202?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Closes #1858, #1906, #1905, #1881, #1884, and #1882

Merge request studip/studip!1234
---
 lib/classes/ResponsiveHelper.php              |  25 +++
 .../javascripts/bootstrap/responsive.js       |   8 -
 .../assets/javascripts/lib/actionmenu.js      |   2 +-
 .../assets/stylesheets/scss/layouts.scss      |   1 +
 .../assets/stylesheets/scss/responsive.scss   |  24 ++-
 resources/assets/stylesheets/scss/wiki.scss   |   8 +
 .../responsive/ResponsiveContentBar.vue       |   7 +-
 .../responsive/ResponsiveNavigation.vue       | 194 ++++++++++--------
 templates/header.php                          |  13 +-
 templates/responsive-navigation.php           |  12 --
 10 files changed, 171 insertions(+), 123 deletions(-)
 delete mode 100644 templates/responsive-navigation.php

diff --git a/lib/classes/ResponsiveHelper.php b/lib/classes/ResponsiveHelper.php
index c2b201ef116..eea89815458 100644
--- a/lib/classes/ResponsiveHelper.php
+++ b/lib/classes/ResponsiveHelper.php
@@ -77,6 +77,31 @@ class ResponsiveHelper
         return [$navigation, $activated];
     }
 
+    /**
+     * Returns the navigation object required for the Vue.js component.
+     *
+     * The object will always contain the currently selected navigation path.
+     * Besides that, the object may contain the whole navigation and a hash
+     * for that navigation. If a hash is passed and it matches the currently
+     * genereated hash, the navigation and hash will be omitted from the
+     * response for performance reasons. We don't want to include the large
+     * navigation object in every response.
+     *
+     * @return array
+     */
+    public static function getNavigationObject(string $stored_hash = null): array
+    {
+        [$navigation, $activated] = self::getNavigationArray();
+        $hash = md5(json_encode($navigation));
+
+        $response = compact('activated');
+        if ($stored_hash !== $hash) {
+            $response = array_merge($response, compact('navigation', 'hash'));
+        }
+
+        return $response;
+    }
+
     /**
      * Recursively build a navigation array from the subnavigation/children
      * of a navigation object.
diff --git a/resources/assets/javascripts/bootstrap/responsive.js b/resources/assets/javascripts/bootstrap/responsive.js
index 8e216d08ba8..0dd71493140 100644
--- a/resources/assets/javascripts/bootstrap/responsive.js
+++ b/resources/assets/javascripts/bootstrap/responsive.js
@@ -1,13 +1,5 @@
 // Build responsive menu on domready or resize
 STUDIP.domReady(() => {
-    const cache = STUDIP.Cache.getInstance('responsive.');
-    if (STUDIP.Navigation.navigation !== undefined) {
-        cache.set('navigation', STUDIP.Navigation.navigation);
-        STUDIP.Cookie.set('responsive-navigation-hash', STUDIP.Navigation.hash);
-    } else {
-        STUDIP.Navigation.navigation = cache.get('navigation');
-    }
-
     STUDIP.Responsive.engage();
 
     if (STUDIP.Responsive.isFullscreen()) {
diff --git a/resources/assets/javascripts/lib/actionmenu.js b/resources/assets/javascripts/lib/actionmenu.js
index dee1e3da125..ded01a7008f 100644
--- a/resources/assets/javascripts/lib/actionmenu.js
+++ b/resources/assets/javascripts/lib/actionmenu.js
@@ -3,7 +3,7 @@
  * @type {[type]}
  */
 function determineBreakpoint(element) {
-    return $(element).closest('.ui-dialog-content').length > 0 ? '.ui-dialog-content' : '#content';
+    return $(element).closest('.ui-dialog-content').length > 0 ? '.ui-dialog-content' : 'body';
 }
 
 /**
diff --git a/resources/assets/stylesheets/scss/layouts.scss b/resources/assets/stylesheets/scss/layouts.scss
index 4248cb4c02a..3c703baa17b 100644
--- a/resources/assets/stylesheets/scss/layouts.scss
+++ b/resources/assets/stylesheets/scss/layouts.scss
@@ -288,6 +288,7 @@ body {
             justify-content: space-between;
             background-color: $dark-gray-color-10;
             font-size: 0.9em;
+            height: 2.3em;
             border-bottom: 1px solid $dark-gray-color-40;
         }
 
diff --git a/resources/assets/stylesheets/scss/responsive.scss b/resources/assets/stylesheets/scss/responsive.scss
index 08483343209..a57a3672dad 100644
--- a/resources/assets/stylesheets/scss/responsive.scss
+++ b/resources/assets/stylesheets/scss/responsive.scss
@@ -75,6 +75,7 @@ $sidebarOut: -330px;
 #responsive-navigation-items {
     background-color: $base-color;
     left: 0;
+    max-height: calc(100vh - $header-bar-container-height - 5px);
     max-width: $responsive-menu-width;
     overflow-y: auto;
     padding-bottom: 5px;
@@ -308,6 +309,8 @@ $sidebarOut: -330px;
 
     #sidebar {
         background-color: $white;
+        max-height: calc(100vh - 100px);
+        overflow-y: auto;
         position: absolute;
         transform: translateX($sidebarOut);
         z-index: 100;
@@ -510,12 +513,8 @@ $sidebarOut: -330px;
             display: none;
         }
 
-        #sidebar {
-            flex: 0;
-
-            &.responsive-hide {
-                top: 110px;
-            }
+        #sidebar.responsive-hide {
+            top: 110px;
         }
 
         #content-wrapper {
@@ -528,7 +527,9 @@ $sidebarOut: -330px;
 .consuming_mode {
     display: unset;
 
-    #responsive-contentbar {
+    #main-header,
+    #sidebar,
+    #main-footer {
         display: none;
     }
 }
@@ -712,10 +713,6 @@ html:not(.responsive-display):not(.fullscreen-mode) {
         #login,
         #request_new_password,
         #web_migrate {
-            .messagebox {
-                margin: 0;
-            }
-
             #background-desktop,
             #background-mobile {
                 position: fixed;
@@ -737,6 +734,11 @@ html:not(.responsive-display):not(.fullscreen-mode) {
                 margin: 0;
                 padding: 0;
                 width: calc(100% - 10px);
+
+                .messagebox {
+                    margin: 0;
+                    width: calc(100vw - 74px);
+                }
             }
 
         }
diff --git a/resources/assets/stylesheets/scss/wiki.scss b/resources/assets/stylesheets/scss/wiki.scss
index 201d6af0e89..fdc86804d67 100644
--- a/resources/assets/stylesheets/scss/wiki.scss
+++ b/resources/assets/stylesheets/scss/wiki.scss
@@ -85,6 +85,14 @@ a.wiki-restricted {
     overflow: auto;
 }
 
+.wiki {
+    padding: 0 !important;
+
+    section {
+        padding: 0 10px;
+    }
+}
+
 $authors: (
      0: $dark-gray-color-20,
      1: $red-20,
diff --git a/resources/vue/components/responsive/ResponsiveContentBar.vue b/resources/vue/components/responsive/ResponsiveContentBar.vue
index fa5e5c9589c..fa4146fd719 100644
--- a/resources/vue/components/responsive/ResponsiveContentBar.vue
+++ b/resources/vue/components/responsive/ResponsiveContentBar.vue
@@ -81,8 +81,11 @@ export default {
                 sidebar.classList.add('responsive-show');
                 sidebar.classList.remove('responsive-hide');
                 if (html.classList.contains('responsive-display') && !html.classList.contains('fullscreen-mode')) {
-                    content.style.display = 'none';
-                    pageTitle.style.display = 'none';
+                    // Set a timeout here so that the content "disappears" after slide-in aninmation is finished.
+                    setTimeout(() => {
+                        content.style.display = 'none';
+                        pageTitle.style.display = 'none';
+                    }, 300);
                 }
                 this.sidebarOpen = true;
             }
diff --git a/resources/vue/components/responsive/ResponsiveNavigation.vue b/resources/vue/components/responsive/ResponsiveNavigation.vue
index d71152027c7..18a10a158f4 100644
--- a/resources/vue/components/responsive/ResponsiveNavigation.vue
+++ b/resources/vue/components/responsive/ResponsiveNavigation.vue
@@ -1,18 +1,20 @@
 <template>
     <div role="navigation">
         <div class="responsive-navigation-header">
-            <button v-if="menuNeeded"
-                    id="responsive-navigation-button" class="styleless"
-                    :title="showMenu ? $gettext('Navigation schließen') : $gettext('Navigation öffnen')"
-                    aria-owns="responsive-navigation-items"
-                    @click.prevent="toggleMenu"
-                    @keydown.prevent.space="toggleMenu"
-                    @keydown.prevent.enter="toggleMenu">
-                <studip-icon shape="hamburger" role="info_alt"
-                             :alt="showMenu ? $gettext('Navigation schließen') : $gettext('Navigation öffnen')"
-                             :size="iconSize" :class="showMenu ? 'menu-open' : 'menu-closed'">
-                </studip-icon>
-            </button>
+            <transition name="slide" appear>
+                <button v-if="menuNeeded"
+                        id="responsive-navigation-button" class="styleless"
+                        :title="showMenu ? $gettext('Navigation schließen') : $gettext('Navigation öffnen')"
+                        aria-owns="responsive-navigation-items"
+                        @click.prevent="toggleMenu"
+                        @keydown.prevent.space="toggleMenu"
+                        @keydown.prevent.enter="toggleMenu">
+                    <studip-icon shape="hamburger" role="info_alt"
+                                 :alt="showMenu ? $gettext('Navigation schließen') : $gettext('Navigation öffnen')"
+                                 :size="iconSize" :class="showMenu ? 'menu-open' : 'menu-closed'">
+                    </studip-icon>
+                </button>
+            </transition>
             <toggle-fullscreen v-if="!isResponsive && !isFocusMode && me.username != 'nobody'"
                                :is-fullscreen="isFullscreen"></toggle-fullscreen>
         </div>
@@ -43,7 +45,7 @@
                         </section>
                     </template>
                     <template v-else>
-                        <focus-trap active="true" :return-focus-on-deactivate="true"
+                        <focus-trap :active="true" :return-focus-on-deactivate="true"
                                     :click-outside-deactivates="true">
                             <div>
                                 <div class="close-avatarmenu">
@@ -127,23 +129,30 @@ export default {
         hasSidebar: {
             type: Boolean,
             default: true
+        },
+        navigation: {
+            type: Object,
+            required: true,
         }
     },
     data() {
+        let studipNavigation = this.sanitizeNavigation(this.navigation);
+
         return {
+            studipNavigation,
             isResponsive: false,
             isFullscreen: false,
             isFocusMode: false,
             headerMagic: false,
             iconSize: 28,
             showMenu: false,
-            activeItem: STUDIP.Navigation.activated.at(-1) ?? 'start',
-            currentNavigation: this.findItem(STUDIP.Navigation.activated.at(0) ?? 'start'),
+            activeItem: this.navigation.activated.at(-1) ?? 'start',
+            currentNavigation: this.findItem(this.navigation.activated.at(0) ?? 'start', studipNavigation),
             initialNavigation: {},
             initialTitle: '',
             isAdmin: ['root','admin'].includes(this.me.perm),
             courses: [],
-            avatarNavigation: STUDIP.Navigation.navigation.avatar,
+            avatarNavigation: studipNavigation.avatar,
             avatarMenuOpen: false,
             observer: null,
             hasSkiplinks: document.querySelector('#skiplink_list') !== null
@@ -152,22 +161,13 @@ export default {
     computed: {
         // Current navigation title, supplemented by context title if available
         currentTitle() {
-            return this.context != '' && this.currentNavigation.path.indexOf('my_courses/') != -1 ?
+            return this.context !== '' && this.currentNavigation.path.indexOf('my_courses/') !== -1 ?
                 this.context : '';
         },
         // The parent element of the current navigation item
         currentParent() {
-            return this.currentNavigation.parent != null
-                 ? this.findItem(this.currentNavigation.parent)
-                 : null;
-        },
-        /*
-         * The parent element of the current navigation item parent
-         * which is used to provide a link up
-         */
-        currentGrandparent() {
-            return this.currentParent != null && this.currentParent.parent != null
-                 ? this.findItem(this.currentParent.parent)
+            return this.currentNavigation.parent
+                 ? this.findItem(this.currentNavigation.parent, this.studipNavigation)
                  : null;
         },
         /*
@@ -210,53 +210,44 @@ export default {
          * @returns {{parent: null, path: string, visible: boolean, children, icon: null, active: boolean, title, url}|null}
          */
         findItem(path, navigation) {
-            // No navigation given, use full Stud.IP navigation hierarchy.
-            if (!navigation) {
-
-                const nav = STUDIP.Navigation.navigation;
-
-                // Some "pseudo" navigation directly at root level.
-                if (path === '/' || path === 'start') {
-                    return {
-                        active: true,
-                        children: nav,
-                        icon: null,
-                        parent: null,
-                        path: '/',
-                        title: nav.start.title,
-                        url: nav.start.url,
-                        visible: true
-                    };
-                    // Direct hit in sub navigation items.
-                } else if (nav[path]) {
-                    return nav[path];
-                    // Recurse through sub navigation items.
-                } else {
-
-                    let found = null;
-                    for (const sub in nav) {
-                        found = this.findItem(path, nav[sub]);
-                        if (found) {
-                            break;
-                        }
-                    }
-                    return found;
-
-                }
-
+            // Some "pseudo" navigation directly at root level.
+            if (path === '/' || path === 'start') {
+                return {
+                    active: true,
+                    children: navigation,
+                    icon: null,
+                    parent: null,
+                    path: '/',
+                    title: navigation.start.title,
+                    url: navigation.start.url,
+                    visible: true
+                };
             } else {
-
                 // Found requested item at current level.
                 if (navigation[path]) {
                     return navigation[path];
                 } else {
+                    // Special handling for first navigation level, we have no "children" attribute here.
+                    if (navigation.start) {
+
+                        let found = null;
+                        for (const sub in navigation) {
+                            found = this.findItem(path, navigation[sub]);
+                            if (found) {
+                                break;
+                            }
+                        }
+                        return found;
+
+                    } else if (navigation.children) {
 
-                    if (navigation.children) {
                         // Found requested item as child of current one.
                         if (navigation.children[path]) {
                             return navigation.children[path];
-                        // Recurse deeper.
+
+                            // Recurse deeper.
                         } else {
+
                             let found = null;
                             for (const sub in navigation.children) {
                                 found = this.findItem(path, navigation.children[sub]);
@@ -267,18 +258,16 @@ export default {
                             return found;
 
                         }
-                    // No children left to search through, we are doomed.
+                        // No children left to search through, we are doomed.
                     } else {
                         return null;
                     }
 
                 }
             }
-
         },
         /**
          * Open or close the navigation menu
-         * @param event
          */
         toggleMenu() {
 
@@ -299,7 +288,7 @@ export default {
         },
         /**
          * Turn fullscreen mode on or off
-         * @param event
+         * @param state
          */
         setFullscreen(state) {
             const html = document.querySelector('html');
@@ -327,11 +316,11 @@ export default {
         },
         /**
          * Move to another item in navigation structure, specified by path
-         * @param string path
+         * @param path
          */
         moveTo(path) {
             this.avatarMenuOpen = false;
-            this.currentNavigation = this.findItem(path ? path : '/');
+            this.currentNavigation = this.findItem(path ? path : '/', this.studipNavigation);
             this.$nextTick(() => {
                 const current = document.querySelector('.navigation-current') ?? document.querySelector('.navigation-item');
                 if (current) {
@@ -384,6 +373,7 @@ export default {
                     if (classList.includes('consuming_mode')) {
                         this.isFocusMode = true;
                         STUDIP.Vue.emit('consuming-mode-enabled');
+                        this.setFullscreen(false);
                     } else {
                         this.isFocusMode = false;
                         STUDIP.Vue.emit('consuming-mode-disabled');
@@ -427,12 +417,22 @@ export default {
                     }
                     break;
                 case 'HEADER':
-                    if (classList.includes('cw-ribbon-consume')) {
-                        this.isFocusMode = true;
-                    } else {
-                        this.isFocusMode = false;
-                    }
+                    this.isFocusMode = classList.includes('cw-ribbon-consume');
             }
+        },
+        sanitizeNavigation(navigation) {
+            const cache = STUDIP.Cache.getInstance('responsive.');
+
+            // No navigation object was sent, read from cache
+            if (navigation.navigation === undefined) {
+                return cache.get('navigation');
+            }
+
+            // Navigation object was sent, store in cache
+            cache.set('navigation', navigation.navigation);
+            STUDIP.Cookie.set('responsive-navigation-hash', navigation.hash);
+
+            return navigation.navigation;
         }
     },
     mounted() {
@@ -503,7 +503,6 @@ export default {
                 attributeFilter: ['class']
             })
         })
-
     },
     beforeDestroy() {
         this.observer.disconnect();
@@ -513,13 +512,40 @@ export default {
 </script>
 
 <style lang="scss">
-.appear-enter-active,
-.appear-leave-active {
-    transition: opacity .3s ease;
-}
+@media not prefers-reduced-motion {
 
-.appear-enter,
-.appear-leave-to {
-   opacity: 0;
+    .slide-enter-active,
+    .slide-leave-active {
+        transition: all .3s ease;
+    }
+
+    .slide-enter-to,
+    .slide-leave-from,
+    .slide-leave {
+        margin-left: 0;
+    }
+
+    .slide-enter,
+    .slide-enter-from,
+    .slide-leave-to {
+        margin-left: -50px;
+    }
+
+    .appear-enter-active,
+    .appear-leave-active {
+        transition: opacity .3s ease;
+    }
+
+    .appear-leave,
+    .appear-leave-from,
+    .appear-enter-to {
+        opacity: 1;
+    }
+
+    .appear-enter,
+    .appear-enter-from,
+    .appear-leave-to {
+        opacity: 0;
+    }
 }
 </style>
diff --git a/templates/header.php b/templates/header.php
index 5e4ed1029ee..c59d081627c 100644
--- a/templates/header.php
+++ b/templates/header.php
@@ -58,7 +58,6 @@ if ($navigation) {
 
     <!-- Top bar with site title, quick search and avatar menu -->
     <div id="top-bar" role="banner">
-        <?= $this->render_partial('responsive-navigation.php') ?>
         <div id="responsive-menu">
             <?
             $user = User::findCurrent();
@@ -71,15 +70,19 @@ if ($navigation) {
                     'perm' => $GLOBALS['perm']->get_perm()
                 ];
 
-                $hasSidebar = Sidebar::get()->countWidgets(NavigationWidget::class) > 0;
+                $navWidget = Sidebar::get()->countWidgets(NavigationWidget::class);
+                $allWidgets = Sidebar::get()->countWidgets();
+                $hasSidebar = $allWidgets - $navWidget > 0;
                 ?>
             <? } else {
                 $me = ['username' => 'nobody'];
                 $hasSidebar = false;
             } ?>
-            <responsive-navigation :me="<?= htmlReady(json_encode($me)) ?>" context="<?= htmlReady(Context::get() ?
-                Context::get()->getFullname() : '') ?>" :has-sidebar="<?= $hasSidebar ? 'true' : 'false' ?>">
-            </responsive-navigation>
+            <responsive-navigation :me="<?= htmlReady(json_encode($me)) ?>"
+                                   context="<?= htmlReady(Context::get() ? Context::get()->getFullname() : '') ?>"
+                                   :has-sidebar="<?= $hasSidebar ? 'true' : 'false' ?>"
+                                   :navigation="<?= htmlReady(json_encode(ResponsiveHelper::getNavigationObject($_COOKIE['responsive-navigation-hash'] ?? null))) ?>"
+            ></responsive-navigation>
         </div>
         <div id="site-title">
             <?= htmlReady(Config::get()->UNI_NAME_CLEAN) ?>
diff --git a/templates/responsive-navigation.php b/templates/responsive-navigation.php
deleted file mode 100644
index 66b18359bbe..00000000000
--- a/templates/responsive-navigation.php
+++ /dev/null
@@ -1,12 +0,0 @@
-<?php
-[$navigation, $activated] = ResponsiveHelper::getNavigationArray();
-$hash = md5(json_encode($navigation));
-
-$response = compact('activated');
-if (!isset($_COOKIE['responsive-navigation-hash']) || $_COOKIE['responsive-navigation-hash'] !== $hash) {
-    $response = array_merge($response, compact('navigation', 'hash'));
-}
-?>
-<script>
-STUDIP.Navigation = <?= json_encode($response, JSON_PARTIAL_OUTPUT_ON_ERROR) ?>;
-</script>
-- 
GitLab