From e9806e73f7d91f0810a298bc6c9139e6da138575 Mon Sep 17 00:00:00 2001
From: Rasmus Fuhse <fuhse@data-quest.de>
Date: Tue, 28 May 2024 12:58:57 +0000
Subject: [PATCH] Resolve "Barrierefreiheit: Globale Suche nicht barrierefrei
 nutzbar"

Closes #4072

Merge request studip/studip!2918
---
 .../globalsearch/GlobalSearchCourses.php      |  4 +-
 .../globalsearch/GlobalSearchCourseware.php   |  2 +-
 .../globalsearch/GlobalSearchMessages.php     |  6 +--
 .../globalsearch/GlobalSearchMyCourses.php    |  4 +-
 .../globalsearch/GlobalSearchUsers.php        |  2 +-
 .../javascripts/bootstrap/global_search.js    | 45 +++++++++++++++++++
 .../assets/javascripts/lib/global_search.js   | 14 +++---
 .../assets/stylesheets/scss/globalsearch.scss |  4 +-
 templates/globalsearch/searchbar.php          | 19 ++++++--
 9 files changed, 77 insertions(+), 23 deletions(-)

diff --git a/lib/classes/globalsearch/GlobalSearchCourses.php b/lib/classes/globalsearch/GlobalSearchCourses.php
index bf29ca04ecb..9de5535dba2 100644
--- a/lib/classes/globalsearch/GlobalSearchCourses.php
+++ b/lib/classes/globalsearch/GlobalSearchCourses.php
@@ -225,9 +225,9 @@ class GlobalSearchCourses extends GlobalSearchModule implements GlobalSearchFull
                     array_map(
                         function ($lecturer, $index) use ($search, $course) {
                             if ($index < 3) {
-                                return '<a href="' . URLHelper::getURL('dispatch.php/profile', ['username' => $lecturer->username]) . '">' . self::mark($lecturer->getUserFullname(), $search) . '</a>';
+                                return self::mark($lecturer->getUserFullname(), $search);
                             } else if ($index == 3) {
-                                return '<a href="' . URLHelper::getURL('dispatch.php/course/details/index/' . $course->id) . '">... (' . _('mehr') . ') </a>';
+                                return '... (' . _('mehr') . ')';
                             }
                         },
                         $lecturers,
diff --git a/lib/classes/globalsearch/GlobalSearchCourseware.php b/lib/classes/globalsearch/GlobalSearchCourseware.php
index d4c53b168d1..de069fe887e 100644
--- a/lib/classes/globalsearch/GlobalSearchCourseware.php
+++ b/lib/classes/globalsearch/GlobalSearchCourseware.php
@@ -142,7 +142,7 @@ class GlobalSearchCourseware extends GlobalSearchModule implements GlobalSearchF
                 'description' => $description,
                 'url' => $pageData['url'],
                 'img' => $structural_element->image ? $structural_element->getImageUrl() : Icon::create('courseware')->asImagePath(),
-                'additional' => '<a href="' . htmlReady($pageData['originUrl']) . '" title="' . htmlReady($pageData['originName']) . '">' . htmlReady($pageData['originName']) . '</a>',
+                'additional' => htmlReady($pageData['originName']),
                 'date' => $date->format('d.m.Y H:i'),
                 'structural-element-id' => $structural_element->id,
                 'expand' => null
diff --git a/lib/classes/globalsearch/GlobalSearchMessages.php b/lib/classes/globalsearch/GlobalSearchMessages.php
index d1a91a52f13..5f64b40413b 100644
--- a/lib/classes/globalsearch/GlobalSearchMessages.php
+++ b/lib/classes/globalsearch/GlobalSearchMessages.php
@@ -79,11 +79,7 @@ class GlobalSearchMessages extends GlobalSearchModule
 
             if ($user) {
                 $username = $user->getFullName();
-                $additional = sprintf(
-                    '<a href="%s">%s</a>',
-                    URLHelper::getLink('dispatch.php/profile', ['username' => $user->username]),
-                    self::mark($user->getFullName(), $search)
-                );
+                $additional = self::mark($user->getFullName(), $search);
             }
         }
 
diff --git a/lib/classes/globalsearch/GlobalSearchMyCourses.php b/lib/classes/globalsearch/GlobalSearchMyCourses.php
index f8f2f11ec69..6558f787ba5 100644
--- a/lib/classes/globalsearch/GlobalSearchMyCourses.php
+++ b/lib/classes/globalsearch/GlobalSearchMyCourses.php
@@ -162,9 +162,9 @@ class GlobalSearchMyCourses extends GlobalSearchModule
                     array_map(
                         function ($lecturer, $index) use ($search, $course) {
                             if ($index < 3) {
-                                return '<a href="' . URLHelper::getURL('dispatch.php/profile', ['username' => $lecturer->username]) . '">' . self::mark($lecturer->getUserFullname(), $search) . '</a>';
+                                return self::mark($lecturer->getUserFullname(), $search);
                             } else if ($index == 3) {
-                                return '<a href="' . URLHelper::getURL('dispatch.php/course/details/index/' . $course->id) . '">... (' . _('mehr') . ') </a>';
+                                return '... (' . _('mehr') . ')';
                             }
                         },
                         $lecturers,
diff --git a/lib/classes/globalsearch/GlobalSearchUsers.php b/lib/classes/globalsearch/GlobalSearchUsers.php
index fb8677ae626..458f0982101 100644
--- a/lib/classes/globalsearch/GlobalSearchUsers.php
+++ b/lib/classes/globalsearch/GlobalSearchUsers.php
@@ -86,7 +86,7 @@ class GlobalSearchUsers extends GlobalSearchModule implements GlobalSearchFullte
                 ['username' => $user->username],
                 true
             ),
-            'additional' => '<a href="' . URLHelper::getLink('dispatch.php/profile', ['username' => $user->username]) . '">' . self::mark($user->username, $search) . '</a>',
+            'additional' => self::mark($user->username, $search),
             'expand'     => self::getSearchURL($search),
             'img'        => Avatar::getAvatar($user->id)->getUrl(Avatar::MEDIUM),
         ];
diff --git a/resources/assets/javascripts/bootstrap/global_search.js b/resources/assets/javascripts/bootstrap/global_search.js
index 4d0738ed62d..a85f2f0d831 100644
--- a/resources/assets/javascripts/bootstrap/global_search.js
+++ b/resources/assets/javascripts/bootstrap/global_search.js
@@ -36,6 +36,51 @@ STUDIP.domReady(() => {
             return false;
         }
     });
+    $('#globalsearch-input').on('keypress', function(e) {
+        if (e.which === 13) {
+            STUDIP.GlobalSearch.doSearch();
+            return false;
+        }
+    });
+    $('#globalsearch-searchbar').on('keydown', function(e) {
+        if (e.originalEvent.code === 'ArrowDown') {
+            if ($('#globalsearch-list [role=listitem]:focus').length === 0) {
+                $('#globalsearch-list [role=listitem]:visible').first().focus();
+            } else {
+                let n = $('#globalsearch-list [role=listitem]:focus').next();
+                if (n.length > 0 && n.is('[role=listitem]:visible')) {
+                    n.focus();
+                } else {
+                    n = $('#globalsearch-list [role=listitem]:focus').parent().next().find('[role=listitem]:visible').first();
+                    if (n.length > 0) {
+                        n.focus();
+                    } else {
+                        $('#globalsearch-list [role=listitem]:visible').first().focus();
+                    }
+                }
+            }
+            return false;
+        }
+        if (e.originalEvent.code === 'ArrowUp') {
+            if ($('#globalsearch-list [role=listitem]:focus').length === 0) {
+                $('#globalsearch-list [role=listitem]:visible').last().focus();
+            } else {
+                let n = $('#globalsearch-list [role=listitem]:focus').prev();
+                if (n.length > 0 && n.is('[role=listitem]:visible')) {
+                    n.focus();
+                } else {
+                    n = $('#globalsearch-list [role=listitem]:focus').parent().prev().find('[role=listitem]:visible').last();
+                    if (n.length > 0) {
+                        n.focus();
+                    } else {
+                        $('#globalsearch-list [role=listitem]:visible').last().focus();
+                    }
+                }
+            }
+            return false;
+        }
+    });
+
 
     // Close search on click on page.
     $('#navigation-level-1, #current-page-structure, #main-footer').on('click', function() {
diff --git a/resources/assets/javascripts/lib/global_search.js b/resources/assets/javascripts/lib/global_search.js
index 05a53aa3200..394b7e30a33 100644
--- a/resources/assets/javascripts/lib/global_search.js
+++ b/resources/assets/javascripts/lib/global_search.js
@@ -9,6 +9,7 @@ const GlobalSearch = {
      */
     toggleSearchBar: function(visible, cleanup) {
         $('#globalsearch-searchbar').toggleClass('is-visible', visible);
+        $('#globalsearch-input').attr('aria-expanded', visible ? 'true' : 'false');
         $('#globalsearch-input').toggleClass('hidden-small-down', !visible);
         $('#globalsearch-icon').toggleClass('hidden-small-down', visible);
         $('#globalsearch-clear').toggleClass('hidden-small-down', !visible);
@@ -70,7 +71,7 @@ const GlobalSearch = {
             // Iterate over each result category.
             $.each(json, function(name, value) {
                 // Create an <article> for category.
-                var category = $(`<article id="globalsearch-${name}">`),
+                var category = $(`<article id="globalsearch-${name}" role="list">`),
                     header = $('<header>').appendTo(category),
                     counter = 0;
 
@@ -96,7 +97,7 @@ const GlobalSearch = {
                 // Process results and create corresponding entries.
                 $.each(value.content, function(index, result) {
                     // Create single result entry.
-                    var single = $('<section>'),
+                    var single = $(`<a href="${result.url}" role="listitem" ${dataDialog}>`),
                         data = $('<div class="globalsearch-result-data">'),
                         details = $('<div class="globalsearch-result-details">');
 
@@ -107,17 +108,17 @@ const GlobalSearch = {
                     // Which result types should be opened via dialog?
                     const openInDialog = ['GlobalSearchFiles', 'GlobalSearchMessages'];
                     var dataDialog = (openInDialog.indexOf(name) >= 0 ? dataDialog = 'data-dialog' : dataDialog = '');
-                    var link = $(`<a href="${result.url}" ${dataDialog}>`).appendTo(single);
+                    //var link = $(`<a href="${result.url}" ${dataDialog}>`).appendTo(single);
 
                     // Optional image...
                     if (result.img !== null) {
                         $(`<img src="${result.img}" alt="">`)
                             .wrap('<div class="globalsearch-result-img">')
                             .parent() // Element is now the wrapper
-                            .appendTo(link);
+                            .appendTo(single);
                     }
 
-                    link.append(data);
+                    single.append(data);
 
                     // Name/title
                     $('<div class="globalsearch-result-title">')
@@ -144,7 +145,7 @@ const GlobalSearch = {
                     if (result.date !== null) {
                         $('<div class="globalsearch-result-time">')
                             .html(result.date)
-                            .appendTo(link);
+                            .appendTo(single);
                     }
 
                     // "Expand" attribute for further, result-related search
@@ -178,6 +179,7 @@ const GlobalSearch = {
         GlobalSearch.lastSearch = null;
 
         $('#globalsearch-searchbar').removeClass('is-visible has-value');
+        $('#globalsearch-input').attr('aria-expanded', 'false');
         $('#globalsearch-input').val('');
         $('#globalsearch-results').html('');
         $('#globalsearch-input').focus();
diff --git a/resources/assets/stylesheets/scss/globalsearch.scss b/resources/assets/stylesheets/scss/globalsearch.scss
index 4faee5feabd..ef7c6e652f8 100644
--- a/resources/assets/stylesheets/scss/globalsearch.scss
+++ b/resources/assets/stylesheets/scss/globalsearch.scss
@@ -171,7 +171,7 @@
                 }
             }
 
-            section {
+            a[role=listitem] {
                 display: flex;
                 flex-direction: row;
                 flex-wrap: nowrap;
@@ -189,7 +189,7 @@
                     display: none;
                 }
 
-                & > a {
+                & > span.detail {
                     display: flex;
                     flex-direction: row;
                     flex-wrap: nowrap;
diff --git a/templates/globalsearch/searchbar.php b/templates/globalsearch/searchbar.php
index 93a8ab51224..837931cf3f1 100644
--- a/templates/globalsearch/searchbar.php
+++ b/templates/globalsearch/searchbar.php
@@ -1,6 +1,16 @@
-<div id="globalsearch-searchbar" role="search" aria-label="<?= _('Globale Suche') ?>">
-    <input class="hidden-small-down" type="text" name="globalsearchterm" id="globalsearch-input"
-           placeholder="<?= _('Was suchen Sie?') ?>" role="searchbox">
+<div id="globalsearch-searchbar"
+     role="search"
+     aria-label="<?= _('Globale Suche') ?>">
+    <input class="hidden-small-down"
+           type="text"
+           name="globalsearchterm"
+           id="globalsearch-input"
+           placeholder="<?= _('Was suchen Sie?') ?>"
+           role="combobox"
+           aria-haspopup="listbox"
+           aria-expanded="false"
+           aria-controls="globalsearch-list"
+           aria-label="Suche nach Objekten und Personen in Stud.IP">
     <?= Icon::create('decline', Icon::ROLE_INACTIVE)->asInput([
         'id'    => 'globalsearch-clear',
         'class' => 'hidden-small-down',
@@ -10,7 +20,8 @@
         'id'  => 'globalsearch-icon',
         'alt' => _('Suche starten')
     ]) ?>
-    <div id="globalsearch-list">
+    <div id="globalsearch-list"
+         role="listbox">
         <a href="#" id="globalsearch-togglehints" data-toggle-text="<?= _('Tipps ausblenden') ?>">
             <?= _('Tipps einblenden') ?>
         </a>
-- 
GitLab