Skip to content

Draft: Create global search modal

Olena Horal-Koretska requested to merge 378542-super-sidebar-search-modal into master

What does this MR do and why?

Creates a global search modal. Most of the functionality is copied from the Header search. The MR diff shows all as new additions but to see the actual difference it would be easier to compare each file with the Header search files. I'll provide details in the next section how.

How to set up and validate locally

Prepare

  1. Enable the feature flag:
echo "Feature.enable(:super_sidebar_nav)" | rails c
  1. Enable the user setting from the user dropdown:

Screenshot_2022-12-13_at_1.32.43_PM

  1. The super sidebar should now appear on every page.
  2. The search button is in the top right corner of the navigation sidebar right before the user profile

Screenshot_2023-03-07_at_14.07.42

  1. Click on it, and the search modal will show up (or use s or / keyboard shortcuts). It should behave similar to the header search

For code review

👣 The Vuex store is a complete copy, there are some slight adjustments in mutations and getters to adjust the data structure to be consumable by the GlDisclosureDropdownGroup/Item components. Check out the current branch and run the next command to see the difference. I've provided diffs for the code changes (but not specs)

git diff --no-index app/assets/javascripts/header_search/store app/assets/javascripts/super_sidebar/components/global_search/store  
Diff of Vuex Store
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
index 3da9d2cd961f..00867c72be69 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
@@ -14,7 +14,7 @@ import {
   PROJECTS_CATEGORY,
   GROUPS_CATEGORY,
   SEARCH_SHORTCUTS_MIN_CHARACTERS,
-  DROPDOWN_ORDER,
+  SEARCH_RESULTS_ORDER,
 } from '../constants';
 
 export const searchQuery = (state) => {
@@ -56,29 +56,24 @@ export const defaultSearchOptions = (state, getters) => {
 
   return [
     {
-      html_id: 'default-issues-assigned',
-      title: MSG_ISSUES_ASSIGNED_TO_ME,
-      url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
+      text: MSG_ISSUES_ASSIGNED_TO_ME,
+      href: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
     },
     {
-      html_id: 'default-issues-created',
-      title: MSG_ISSUES_IVE_CREATED,
-      url: `${getters.scopedIssuesPath}/?author_username=${userName}`,
+      text: MSG_ISSUES_IVE_CREATED,
+      href: `${getters.scopedIssuesPath}/?author_username=${userName}`,
     },
     {
-      html_id: 'default-mrs-assigned',
-      title: MSG_MR_ASSIGNED_TO_ME,
-      url: `${getters.scopedMRPath}/?assignee_username=${userName}`,
+      text: MSG_MR_ASSIGNED_TO_ME,
+      href: `${getters.scopedMRPath}/?assignee_username=${userName}`,
     },
     {
-      html_id: 'default-mrs-reviewer',
-      title: MSG_MR_IM_REVIEWER,
-      url: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
+      text: MSG_MR_IM_REVIEWER,
+      href: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
     },
     {
-      html_id: 'default-mrs-created',
-      title: MSG_MR_IVE_CREATED,
-      url: `${getters.scopedMRPath}/?author_username=${userName}`,
+      text: MSG_MR_IVE_CREATED,
+      href: `${getters.scopedMRPath}/?author_username=${userName}`,
     },
   ];
 };
@@ -134,59 +129,59 @@ export const allUrl = (state) => {
   return `${state.searchPath}?${objectToQuery(query)}`;
 };
 
-export const scopedSearchOptions = (state, getters) => {
-  const options = [];
+export const scopedSearchGroup = (state, getters) => {
+  const group = { items: [] };
 
   if (state.searchContext?.project) {
-    options.push({
-      html_id: 'scoped-in-project',
+    group.items.push({
+      text: 'scoped-in-project',
       scope: state.searchContext.project?.name || '',
       scopeCategory: PROJECTS_CATEGORY,
       icon: ICON_PROJECT,
-      url: getters.projectUrl,
+      href: getters.projectUrl,
     });
   }
 
   if (state.searchContext?.group) {
-    options.push({
-      html_id: 'scoped-in-group',
+    group.items.push({
+      text: 'scoped-in-group',
       scope: state.searchContext.group?.name || '',
       scopeCategory: GROUPS_CATEGORY,
       icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP,
-      url: getters.groupUrl,
+      href: getters.groupUrl,
     });
   }
 
-  options.push({
-    html_id: 'scoped-in-all',
+  group.items.push({
+    text: 'scoped-in-all',
     description: MSG_IN_ALL_GITLAB,
-    url: getters.allUrl,
+    href: getters.allUrl,
   });
 
-  return options;
+  return group;
 };
 
 export const autocompleteGroupedSearchOptions = (state) => {
   const groupedOptions = {};
   const results = [];
 
-  state.autocompleteOptions.forEach((option) => {
-    const category = groupedOptions[option.category];
+  state.autocompleteOptions.forEach((item) => {
+    const group = groupedOptions[item.category];
 
-    if (category) {
-      category.data.push(option);
+    if (group) {
+      group.items.push(item);
     } else {
-      groupedOptions[option.category] = {
-        category: option.category,
-        data: [option],
+      groupedOptions[item.category] = {
+        name: item.category,
+        items: [item],
       };
 
-      results.push(groupedOptions[option.category]);
+      results.push(groupedOptions[item.category]);
     }
   });
 
   return results.sort(
-    (a, b) => DROPDOWN_ORDER.indexOf(a.category) - DROPDOWN_ORDER.indexOf(b.category),
+    (a, b) => SEARCH_RESULTS_ORDER.indexOf(a.name) - SEARCH_RESULTS_ORDER.indexOf(b.name),
   );
 };
 
@@ -196,8 +191,8 @@ export const searchOptions = (state, getters) => {
   }
 
   const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce(
-    (options, group) => {
-      return [...options, ...group.data];
+    (items, group) => {
+      return [...items, ...group.items];
     },
     [],
   );
@@ -206,5 +201,5 @@ export const searchOptions = (state, getters) => {
     return sortedAutocompleteOptions;
   }
 
-  return getters.scopedSearchOptions.concat(sortedAutocompleteOptions);
+  return (getters.scopedSearchGroup.items ?? []).concat(sortedAutocompleteOptions);
 };
diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js
index 19b4d4ec3306..b97160525351 100644
--- a/app/assets/javascripts/header_search/store/mutations.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js
@@ -9,8 +9,8 @@ export default {
   [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
     state.loading = false;
     state.autocompleteOptions = [...state.autocompleteOptions].concat(
-      data.map((d, i) => {
-        return { html_id: `autocomplete-${d.category}-${i}`, ...d };
+      data.map(({ value, label, url: href, ...item }) => {
+        return { value, label, text: value || label, href, ...item };
       }),
     );
     state.autocompleteError = false;

👣 constants.js - removed some constants related to focusing and navigation in the header dropdown, added other constants for the key codes to support navigation (the implementation copied from gitlab/ui's listbox navigation) as well as modal id, input, and search result item selectors.

git diff --no-index app/assets/javascripts/header_search/constants.js app/assets/javascripts/super_sidebar/components/global_search/constants.js
constants.js diff
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
index 76fbf664913f..ae3688136ea3 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
@@ -40,10 +40,6 @@ export const LARGE_AVATAR_PX = 32;
 
 export const SMALL_AVATAR_PX = 16;
 
-export const FIRST_DROPDOWN_INDEX = 0;
-
-export const SEARCH_BOX_INDEX = -1;
-
 export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2;
 
 export const SEARCH_INPUT_DESCRIPTION = 'search-input-description';
@@ -52,15 +48,11 @@ export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description';
 
 export const SCOPE_TOKEN_MAX_LENGTH = 36;
 
-export const INPUT_FIELD_PADDING = 52;
-
-export const HEADER_INIT_EVENTS = ['input', 'focus'];
+export const INPUT_FIELD_PADDING = 84;
 
 export const IS_SEARCHING = 'is-searching';
-export const IS_FOCUSED = 'is-focused';
-export const IS_NOT_FOCUSED = 'is-not-focused';
 
-export const DROPDOWN_ORDER = [
+export const SEARCH_RESULTS_ORDER = [
   MERGE_REQUEST_CATEGORY,
   ISSUES_CATEGORY,
   RECENT_EPICS_CATEGORY,
@@ -73,5 +65,14 @@ export const DROPDOWN_ORDER = [
 ];
 
 export const FETCH_TYPES = ['generic', 'search'];
+export const SEARCH_MODAL_ID = 'super-sidebar-search-modal';
+
+export const SEARCH_INPUT_SELECTOR = '.gl-search-box-by-type-input-borderless';
 
-export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px';
+export const SEARCH_RESULTS_ITEM_SELECTOR = '.gl-new-dropdown-item';
+// KEY Codes
+export const ARROW_DOWN = 'ArrowDown';
+export const ARROW_UP = 'ArrowUp';
+export const END = 'End';
+export const HOME = 'Home';
+export const ESC = 'Escape';

👣 global_search_modal.vue - the main component that renders a modal, a search form in it with search input, and search results. Old focusing and navigation logic was removed and new navigation logic was added instead. Removed some logic related to opening/closing the search dropdown. In the current implementation, the search results are always visible and there is no dropdown.

git diff --no-index --ignore-all-space app/assets/javascripts/header_search/components/app.vue app/assets/javascripts/super_sidebar/components/global_search/components/global_search_modal.vue
app.vue/global_search_modal.vue diff
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_modal.vue
index ace0d77c4315..013f8ccdd730 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_modal.vue
@@ -6,71 +6,58 @@ import {
   GlToken,
   GlTooltipDirective,
   GlResizeObserverDirective,
+  GlModal,
 } from '@gitlab/ui';
 import { mapState, mapActions, mapGetters } from 'vuex';
-import { debounce } from 'lodash';
-import { visitUrl } from '~/lib/utils/url_utility';
+import { debounce, clamp } from 'lodash';
 import { truncate } from '~/lib/utils/text_utility';
 import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
 import { s__, sprintf } from '~/locale';
-import Tracking from '~/tracking';
-import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
 import {
-  FIRST_DROPDOWN_INDEX,
-  SEARCH_BOX_INDEX,
   SEARCH_INPUT_DESCRIPTION,
   SEARCH_RESULTS_DESCRIPTION,
   SEARCH_SHORTCUTS_MIN_CHARACTERS,
   SCOPE_TOKEN_MAX_LENGTH,
   INPUT_FIELD_PADDING,
   IS_SEARCHING,
-  IS_FOCUSED,
-  IS_NOT_FOCUSED,
+  SEARCH_MODAL_ID,
+  ARROW_DOWN,
+  ARROW_UP,
+  END,
+  HOME,
+  ESC,
+  SEARCH_INPUT_SELECTOR,
+  SEARCH_RESULTS_ITEM_SELECTOR,
 } from '../constants';
-import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
-import HeaderSearchDefaultItems from './header_search_default_items.vue';
-import HeaderSearchScopedItems from './header_search_scoped_items.vue';
+import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue';
+import GlobalSearchDefaultItems from './global_search_default_items.vue';
+import GlobalSearchScopedItems from './global_search_scoped_items.vue';
 
 export default {
-  name: 'HeaderSearchApp',
+  name: 'GlobalSearchModal',
+  SEARCH_MODAL_ID,
   i18n: {
     searchGitlab: s__('GlobalSearch|Search GitLab'),
-    searchInputDescribeByNoDropdown: s__(
-      'GlobalSearch|Type and press the enter key to submit search.',
-    ),
-    searchInputDescribeByWithDropdown: s__(
-      'GlobalSearch|Type for new suggestions to appear below.',
-    ),
+    searchInputDescription: s__('GlobalSearch|Type for new suggestions to appear below.'),
     searchDescribedByDefault: s__(
       'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.',
     ),
     searchDescribedByUpdated: s__(
       'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.',
     ),
+    minSearchTerm: s__('GlobalSearch|The search term must be at least 3 characters long.'),
     searchResultsLoading: s__('GlobalSearch|Search results are loading'),
     searchResultsScope: s__('GlobalSearch|in %{scope}'),
-    kbdHelp: sprintf(
-      s__('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search'),
-      { kbdOpen: '<kbd>', kbdClose: '</kbd>' },
-      false,
-    ),
   },
   directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
   components: {
     GlSearchBoxByType,
-    HeaderSearchDefaultItems,
-    HeaderSearchScopedItems,
-    HeaderSearchAutocompleteItems,
-    DropdownKeyboardNavigation,
+    GlobalSearchDefaultItems,
+    GlobalSearchScopedItems,
+    GlobalSearchAutocompleteItems,
     GlIcon,
     GlToken,
-  },
-  data() {
-    return {
-      showDropdown: false,
-      isFocused: false,
-      currentFocusIndex: SEARCH_BOX_INDEX,
-    };
+    GlModal,
   },
   computed: {
     ...mapState(['search', 'loading', 'searchContext']),
@@ -83,51 +70,23 @@ export default {
         this.setSearch(value);
       },
     },
-    currentFocusedOption() {
-      return this.searchOptions[this.currentFocusIndex];
-    },
-    currentFocusedId() {
-      return this.currentFocusedOption?.html_id;
-    },
-    isLoggedIn() {
-      return Boolean(gon?.current_username);
-    },
-    showSearchDropdown() {
-      if (!this.showDropdown || !this.isLoggedIn) {
-        return false;
-      }
-      return this.searchOptions?.length > 0;
-    },
     showDefaultItems() {
       return !this.searchText;
     },
     searchTermOverMin() {
       return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
     },
-    defaultIndex() {
-      if (this.showDefaultItems) {
-        return SEARCH_BOX_INDEX;
-      }
-      return FIRST_DROPDOWN_INDEX;
-    },
-
-    searchInputDescribeBy() {
-      if (this.isLoggedIn) {
-        return this.$options.i18n.searchInputDescribeByWithDropdown;
-      }
-      return this.$options.i18n.searchInputDescribeByNoDropdown;
-    },
-    dropdownResultsDescription() {
-      if (!this.showSearchDropdown) {
-        return ''; // This allows aria-live to see register an update when the dropdown is shown
-      }
-
+    searchResultsDescription() {
       if (this.showDefaultItems) {
         return sprintf(this.$options.i18n.searchDescribedByDefault, {
           count: this.searchOptions.length,
         });
       }
 
+      if (!this.searchTermOverMin) {
+        return this.$options.i18n.minSearchTerm;
+      }
+
       return this.loading
         ? this.$options.i18n.searchResultsLoading
         : sprintf(this.$options.i18n.searchDescribedByUpdated, {
@@ -137,12 +96,10 @@ export default {
     searchBarClasses() {
       return {
         [IS_SEARCHING]: this.searchTermOverMin,
-        [IS_FOCUSED]: this.isFocused,
-        [IS_NOT_FOCUSED]: !this.isFocused,
       };
     },
     showScopeHelp() {
-      return this.searchTermOverMin && this.isFocused;
+      return this.searchTermOverMin;
     },
     searchBarItem() {
       return this.searchOptions?.[0];
@@ -161,47 +118,7 @@ export default {
   },
   methods: {
     ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
-    openDropdown() {
-      this.showDropdown = true;
-
-      // check isFocused state to avoid firing duplicate events
-      if (!this.isFocused) {
-        this.isFocused = true;
-        this.$emit('expandSearchBar', true);
-
-        Tracking.event(undefined, 'focus_input', {
-          label: 'global_search',
-          property: 'navigation_top',
-        });
-      }
-    },
-    closeDropdown() {
-      this.showDropdown = false;
-    },
-    collapseAndCloseSearchBar() {
-      // we need a delay on this method
-      // for the search bar not to remove
-      // the clear button from dom
-      // and register clicks on dropdown items
-      setTimeout(() => {
-        this.showDropdown = false;
-        this.isFocused = false;
-        this.$emit('collapseSearchBar');
-
-        Tracking.event(undefined, 'blur_input', {
-          label: 'global_search',
-          property: 'navigation_top',
-        });
-      }, 200);
-    },
-    submitSearch() {
-      if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
-        return null;
-      }
-      return visitUrl(this.currentFocusedOption?.url || this.searchQuery);
-    },
     getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
-      this.openDropdown();
       if (!searchTerm) {
         this.clearAutocomplete();
       } else {
@@ -218,71 +135,113 @@ export default {
       }
       inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`;
     },
+    getFocusableOptions() {
+      return Array.from(
+        this.$refs.resultsList?.querySelectorAll(SEARCH_RESULTS_ITEM_SELECTOR) || [],
+      );
+    },
+    onKeydown(event) {
+      const { code, target } = event;
+      const elements = this.getFocusableOptions();
+      if (elements.length < 1) return;
+
+      const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR);
+
+      if (code === HOME) {
+        this.focusItem(0, elements);
+      } else if (code === END) {
+        this.focusItem(elements.length - 1, elements);
+      } else if (code === ARROW_UP) {
+        if (isSearchInput) return;
+
+        if (elements.indexOf(target) === 0) {
+          this.focusSearchInput();
+          return;
+        }
+        this.focusNextItem(event, elements, -1);
+      } else if (code === ARROW_DOWN) {
+        this.focusNextItem(event, elements, 1);
+      } else if (code === ESC) {
+        this.$refs.searchModal.close();
+      }
+    },
+    focusSearchInput() {
+      this.$refs.searchInputBox.$el.querySelector('input').focus();
+    },
+    focusNextItem(event, elements, offset) {
+      const { target } = event;
+      const currentIndex = elements.indexOf(target);
+      const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1);
+
+      this.focusItem(nextIndex, elements);
+    },
+    focusItem(index, elements) {
+      this.nextFocusedItemIndex = index;
+
+      elements[index]?.focus();
+    },
   },
-  SEARCH_BOX_INDEX,
-  FIRST_DROPDOWN_INDEX,
   SEARCH_INPUT_DESCRIPTION,
   SEARCH_RESULTS_DESCRIPTION,
 };
 </script>
 
 <template>
+  <gl-modal
+    ref="searchModal"
+    :modal-id="$options.SEARCH_MODAL_ID"
+    hide-header
+    hide-footer
+    hide-header-close
+    scrollable
+    body-class="gl-p-0!"
+  >
     <form
-    v-outside="closeDropdown"
       role="search"
       :aria-label="$options.i18n.searchGitlab"
-    class="header-search gl-relative gl-rounded-base gl-w-full"
+      class="global-search gl-relative gl-rounded-base gl-w-full"
       :class="searchBarClasses"
-    data-testid="header-search-form"
+      data-testid="global-search-form"
     >
+      <div class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-p-1">
         <gl-search-box-by-type
           id="search"
           ref="searchInputBox"
           v-model="searchText"
           role="searchbox"
-      class="gl-z-index-1"
-      data-qa-selector="search_term_field"
+          data-testid="global-search-input"
           autocomplete="off"
           :placeholder="$options.i18n.searchGitlab"
-      :aria-activedescendant="currentFocusedId"
           :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
-      @focus="openDropdown"
-      @click="openDropdown"
-      @blur="collapseAndCloseSearchBar"
+          borderless
           @input="getAutocompleteOptions"
-      @keydown.enter.stop.prevent="submitSearch"
-      @keydown.esc.stop.prevent="closeDropdown"
+          @keydown.enter.stop.prevent
+          @keydown="onKeydown"
         />
         <gl-token
           v-if="showScopeHelp"
           v-gl-resize-observer-directive="observeTokenWidth"
-      class="in-search-scope-help"
-      :view-only="true"
+          class="in-search-scope-help gl-sm-display-block gl-display-none"
+          view-only
           :title="scopeTokenTitle"
-      ><gl-icon
+        >
+          <gl-icon
             v-if="infieldHelpIcon"
             class="gl-mr-2"
             :aria-label="infieldHelpContent"
             :name="infieldHelpIcon"
             :size="16"
-      />{{
+          />
+          {{
             getTruncatedScope(
-          sprintf($options.i18n.searchResultsScope, {
-            scope: infieldHelpContent,
-          }),
+              sprintf($options.i18n.searchResultsScope, { scope: infieldHelpContent }),
             )
           }}
         </gl-token>
-    <kbd
-      v-show="!isFocused"
-      v-gl-tooltip.bottom.hover.html
-      class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper"
-      :title="$options.i18n.kbdHelp"
-      >/</kbd
-    >
-    <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
-      searchInputDescribeBy
-    }}</span>
+        <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">
+          {{ $options.i18n.searchInputDescription }}
+        </span>
+      </div>
       <span
         role="region"
         :data-testid="$options.SEARCH_RESULTS_DESCRIPTION"
@@ -290,33 +249,45 @@ export default {
         aria-live="polite"
         aria-atomic="true"
       >
-      {{ dropdownResultsDescription }}
+        {{ searchResultsDescription }}
       </span>
       <div
-      v-if="showSearchDropdown"
-      data-testid="header-search-dropdown-menu"
-      class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3"
+        ref="resultsList"
+        data-testid="global-search-results"
+        class="global-search-results gl-overflow-y-auto gl-w-full"
+        @keydown="onKeydown"
       >
-      <div class="header-search-dropdown-content gl-py-2">
-        <dropdown-keyboard-navigation
-          v-model="currentFocusIndex"
-          :max="searchOptions.length - 1"
-          :min="$options.FIRST_DROPDOWN_INDEX"
-          :default-index="defaultIndex"
-          @tab="closeDropdown"
-        />
-        <header-search-default-items
-          v-if="showDefaultItems"
-          :current-focused-option="currentFocusedOption"
-        />
+        <div class="gl-py-2">
+          <global-search-default-items v-if="showDefaultItems" />
           <template v-else>
-          <header-search-scoped-items
-            v-if="searchTermOverMin"
-            :current-focused-option="currentFocusedOption"
-          />
-          <header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
+            <global-search-scoped-items v-if="searchTermOverMin" />
+            <global-search-autocomplete-items />
           </template>
         </div>
       </div>
+
+      <template v-if="searchContext">
+        <input
+          v-if="searchContext.group"
+          type="hidden"
+          name="group_id"
+          :value="searchContext.group.id"
+        />
+        <input
+          v-if="searchContext.project"
+          type="hidden"
+          name="project_id"
+          :value="searchContext.project.id"
+        />
+
+        <template v-if="searchContext.group || searchContext.project">
+          <input type="hidden" name="scope" :value="searchContext.scope" />
+          <input type="hidden" name="search_code" :value="searchContext.code_search" />
+        </template>
+
+        <input type="hidden" name="snippets" :value="searchContext.for_snippets" />
+        <input type="hidden" name="repository_ref" :value="searchContext.ref" />
+      </template>
     </form>
+  </gl-modal>
 </template>

👣 global_search_default_items.vue -the GlDropdownItem and GlDropdownHeader replaced with the new GlDislcosureDropdownGroup component, removed focus highlighting logic

git diff --no-index app/assets/javascripts/header_search/components/header_search_default_items.vue app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
Default Items diff
diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
index 04deaba7b0fb..60b0baf617f4 100644
--- a/app/assets/javascripts/header_search/components/header_search_default_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
@@ -1,23 +1,15 @@
 <script>
-import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+import { GlDisclosureDropdownGroup } from '@gitlab/ui';
 import { mapState, mapGetters } from 'vuex';
 import { __ } from '~/locale';
 
 export default {
-  name: 'HeaderSearchDefaultItems',
+  name: 'GlobalSearchDefaultItems',
   i18n: {
     allGitLab: __('All GitLab'),
   },
   components: {
-    GlDropdownSectionHeader,
-    GlDropdownItem,
-  },
-  props: {
-    currentFocusedOption: {
-      type: Object,
-      required: false,
-      default: () => null,
-    },
+    GlDisclosureDropdownGroup,
   },
   computed: {
     ...mapState(['searchContext']),
@@ -29,30 +21,18 @@ export default {
         this.$options.i18n.allGitLab
       );
     },
-  },
-  methods: {
-    isOptionFocused(option) {
-      return this.currentFocusedOption?.html_id === option.html_id;
+    defaultItemsGroup() {
+      return {
+        name: this.sectionHeader,
+        items: this.defaultSearchOptions,
+      };
     },
   },
 };
 </script>
 
 <template>
-  <div>
-    <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header>
-    <gl-dropdown-item
-      v-for="option in defaultSearchOptions"
-      :id="option.html_id"
-      :ref="option.html_id"
-      :key="option.html_id"
-      :class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
-      :aria-selected="isOptionFocused(option)"
-      :aria-label="option.title"
-      tabindex="-1"
-      :href="option.url"
-    >
-      <span aria-hidden="true">{{ option.title }}</span>
-    </gl-dropdown-item>
-  </div>
+  <ul class="gl-p-0 gl-m-0 gl-list-style-none">
+    <gl-disclosure-dropdown-group :group="defaultItemsGroup" />
+  </ul>
 </template>

👣 global_search_autocomplete_items.vue - same as for the default items, the GlDropdownItem and GlDropdownHeader replaced with the new GlDislcosureDropdownGroup component, removed focus highlighting logic

git diff --no-index app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
Autocomplete Items Diff
diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
index c85fb4f4158b..7025437be734 100644
--- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
@@ -1,12 +1,5 @@
 <script>
-import {
-  GlDropdownItem,
-  GlDropdownSectionHeader,
-  GlDropdownDivider,
-  GlAvatar,
-  GlAlert,
-  GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlAvatar, GlAlert, GlLoadingIcon, GlDisclosureDropdownGroup } from '@gitlab/ui';
 import { mapState, mapGetters } from 'vuex';
 import SafeHtml from '~/vue_shared/directives/safe_html';
 import { s__ } from '~/locale';
@@ -25,43 +18,25 @@ import {
 } from '../constants';
 
 export default {
-  name: 'HeaderSearchAutocompleteItems',
+  name: 'GlobalSearchAutocompleteItems',
   i18n: {
     autocompleteErrorMessage: s__(
       'GlobalSearch|There was an error fetching search autocomplete suggestions.',
     ),
   },
   components: {
-    GlDropdownItem,
-    GlDropdownSectionHeader,
-    GlDropdownDivider,
     GlAvatar,
     GlAlert,
     GlLoadingIcon,
+    GlDisclosureDropdownGroup,
   },
   directives: {
     SafeHtml,
   },
-  props: {
-    currentFocusedOption: {
-      type: Object,
-      required: false,
-      default: () => null,
-    },
-  },
   computed: {
     ...mapState(['search', 'loading', 'autocompleteError', 'searchContext']),
     ...mapGetters(['autocompleteGroupedSearchOptions']),
   },
-  watch: {
-    currentFocusedOption() {
-      const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el;
-
-      if (focusedElement) {
-        focusedElement.scrollIntoView(false);
-      }
-    },
-  },
   methods: {
     truncateNamespace(string) {
       if (string.split(' / ').length > 2) {
@@ -80,9 +55,6 @@ export default {
 
       return SMALL_AVATAR_PX;
     },
-    isOptionFocused(data) {
-      return this.currentFocusedOption?.html_id === data.html_id;
-    },
     isProjectsCategory(data) {
       return data.category === PROJECTS_CATEGORY;
     },
@@ -99,7 +71,7 @@ export default {
           return data.id;
       }
     },
-    getEntitytName(data) {
+    getEntityName(data) {
       switch (data.category) {
         case GROUPS_CATEGORY:
         case RECENT_EPICS_CATEGORY:
@@ -119,46 +91,40 @@ export default {
 
 <template>
   <div>
-    <template v-if="!loading">
-      <div v-for="(option, index) in autocompleteGroupedSearchOptions" :key="option.category">
-        <gl-dropdown-divider v-if="index > 0" />
-        <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header>
-        <gl-dropdown-item
-          v-for="data in option.data"
-          :id="data.html_id"
-          :ref="data.html_id"
-          :key="data.html_id"
-          :class="{ 'gl-bg-gray-50': isOptionFocused(data) }"
-          :aria-selected="isOptionFocused(data)"
-          :aria-label="data.label"
-          tabindex="-1"
-          :href="data.url"
-        >
-          <div class="gl-display-flex gl-align-items-center" aria-hidden="true">
+    <ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none">
+      <gl-disclosure-dropdown-group
+        v-for="(group, index) in autocompleteGroupedSearchOptions"
+        :key="group.name"
+        :group="group"
+        :bordered="index > 0"
+      >
+        <template #list-item="{ item }">
+          <div class="gl-display-flex gl-align-items-center">
             <gl-avatar
-              v-if="data.avatar_url !== undefined"
-              :src="data.avatar_url"
-              :entity-id="getEntityId(data)"
-              :entity-name="getEntitytName(data)"
-              :size="avatarSize(data)"
+              v-if="item.avatar_url !== undefined"
+              class="gl-mr-3"
+              :src="item.avatar_url"
+              :entity-id="getEntityId(item)"
+              :entity-name="getEntityName(item)"
+              :size="avatarSize(item)"
               :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+              aria-hidden="true"
             />
             <span class="gl-display-flex gl-flex-direction-column">
+              <span v-safe-html="highlightedName(item.text)" class="gl-text-gray-900"></span>
               <span
-                v-safe-html="highlightedName(data.value || data.label)"
-                class="gl-text-gray-900"
-              ></span>
-              <span
-                v-if="data.value"
-                v-safe-html="truncateNamespace(data.label)"
+                v-if="item.value"
+                v-safe-html="truncateNamespace(item.label)"
                 class="gl-font-sm gl-text-gray-500"
               ></span>
             </span>
           </div>
-        </gl-dropdown-item>
-      </div>
-    </template>
+        </template>
+      </gl-disclosure-dropdown-group>
+    </ul>
+
     <gl-loading-icon v-else size="lg" class="my-4" />
+
     <gl-alert
       v-if="autocompleteError"
       class="gl-text-body gl-mt-2"

👣 global_search_scoped_items.vue - same as for the other types of items, the GlDropdownItem and GlDropdownHeader replaced with the new GlDislcosureDropdownGroup component, removed focus highlighting logic

git diff --no-index --ignore-all-space app/assets/javascripts/header_search/components/header_search_scoped_items.vue app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
Scoped items diff
diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
index f5be1bcb7867..9b28edb31f73 100644
--- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
@@ -1,43 +1,29 @@
 <script>
-import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui';
+import { GlIcon, GlToken, GlDisclosureDropdownGroup } from '@gitlab/ui';
 import { mapState, mapGetters } from 'vuex';
 import { s__, sprintf } from '~/locale';
 import { truncate } from '~/lib/utils/text_utility';
 import { SCOPE_TOKEN_MAX_LENGTH } from '../constants';
 
 export default {
-  name: 'HeaderSearchScopedItems',
+  name: 'GlobalSearchScopedItems',
   components: {
-    GlDropdownItem,
     GlIcon,
     GlToken,
-  },
-  props: {
-    currentFocusedOption: {
-      type: Object,
-      required: false,
-      default: () => null,
-    },
+    GlDisclosureDropdownGroup,
   },
   computed: {
     ...mapState(['search']),
-    ...mapGetters(['scopedSearchOptions', 'autocompleteGroupedSearchOptions']),
-  },
-  methods: {
-    isOptionFocused(option) {
-      return this.currentFocusedOption?.html_id === option.html_id;
+    ...mapGetters(['scopedSearchGroup', 'autocompleteGroupedSearchOptions']),
+    hasResults() {
+      return this.autocompleteGroupedSearchOptions.length > 0;
     },
-    ariaLabel(option) {
-      return sprintf(s__('GlobalSearch| %{search} %{description} %{scope}'), {
-        search: this.search,
-        description: option.description || option.icon,
-        scope: option.scope || '',
-      });
   },
-    titleLabel(option) {
+  methods: {
+    titleLabel(item) {
       return sprintf(s__('GlobalSearch|in %{scope}'), {
         search: this.search,
-        scope: option.scope || option.description,
+        scope: item.scope || item.description,
       });
     },
     getTruncatedScope(scope) {
@@ -49,35 +35,29 @@ export default {
 
 <template>
   <div>
-    <gl-dropdown-item
-      v-for="option in scopedSearchOptions"
-      :id="option.html_id"
-      :ref="option.html_id"
-      :key="option.html_id"
-      class="gl-max-w-full"
-      :class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
-      :aria-selected="isOptionFocused(option)"
-      :aria-label="ariaLabel(option)"
-      tabindex="-1"
-      :href="option.url"
-      :title="titleLabel(option)"
+    <ul
+      class="gl-m-0 gl-p-0 gl-pb-2 gl-list-style-none"
+      :class="{ 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid': hasResults }"
     >
+      <gl-disclosure-dropdown-group :group="scopedSearchGroup">
+        <template #list-item="{ item }">
           <span
-        ref="token-text-content"
             class="gl-display-flex gl-justify-content-start search-text-content gl-line-height-24 gl-align-items-start gl-flex-direction-row gl-w-full"
           >
             <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-relative gl-pt-2" />
             <span class="gl-flex-grow-1 gl-relative">
               <gl-token
-            class="in-dropdown-scope-help has-icon gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right gl-mr-n3!"
-            :view-only="true"
+                class="gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right"
+                view-only
               >
-            <gl-icon v-if="option.icon" :name="option.icon" class="gl-mr-2" />
-            <span>{{ getTruncatedScope(titleLabel(option)) }}</span>
+                <gl-icon v-if="item.icon" :name="item.icon" class="gl-mr-2" />
+                <span>{{ getTruncatedScope(titleLabel(item)) }}</span>
               </gl-token>
               {{ search }}
             </span>
           </span>
-    </gl-dropdown-item>
+        </template>
+      </gl-disclosure-dropdown-group>
+    </ul>
   </div>
 </template>

Tests

🗒 I won't be adding diff for the specs, just a command to compare them

👣 Vuex.store specs

git diff --no-index spec/frontend/header_search/store spec/frontend/super_sidebar/components/global_search/store

👣 Modal. Follow-up: I have not figured out how to test new focus/navigation logic with jest dom

git diff --no-index spec/frontend/header_search/components/app_spec.js spec/frontend/super_sidebar/components/global_search/components/global_search_modal_spec.js

👣 Default items

git diff --no-index spec/frontend/header_search/components/header_search_default_items_spec.js spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js

👣 Autocomplete items

git diff --no-index spec/frontend/header_search/components/header_search_autocomplete_items_spec.js spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js

👣

git diff --no-index spec/frontend/header_search/components/header_search_scoped_items_spec.js spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js

👣 👣 👣 The rest can be rviewed as usually (small BE changes, changes in shortcuts.js as well as in user_bar.vue, super_sidebar_bundle.js and utils.js

😅

Requirements checklist

  • Add an icon button (search icon) in the header area of the left sidebar using our tertiary button styles
  • Include a tooltip for the button that reads "Search GitLab /" (ensuring the shortcut is included as a kdb element)
  • Clicking/tapping on the search button opens the global search menu similar to how our modals work (lightbox effect)
  • Clicking/tapping outside of the Global Search menu (overlay area) when it is open closes the modal/menu
  • Esc key can also be used to close the Global Search menu when it is open
  • Global Search modal/menu container should have a max-height of 100%vh of window (minus top offset), with overflow-y: auto
  • The options/autocomplete portion of the modal/menu should have a max-height of 30rem (480px), with overflow-y: auto (this makes it so the entire modal/menu can only be a total of 530px in height, if you include the search input which is 48px)
  • Global Search modal/menu container should have an initial width of 40rem (640px), with a max-width: 100%vw (minus right and left offset/padding of 1rem/16px on each side – see mobile designs)
  • Top offset/margin based on breakpoint:
    • xsmall: 3rem (48px)
    • small +: 5rem (80px)
  • Do not display the scope token that is shown within the search field on xsmall viewport

When opening the global search menu...

  • Focus the search field
  • Display the default Autosuggestion options as we do today that are based on the users location in the product (see image below)

Screen_Shot_2023-02-17_at_2.23.26_PM

Ensure all existing Global Search field functionality remains intact
  • Defaulting scope when directed to the results page based on location user searched from
  • Autocomplete options are not effected
    • Recent Issues, MRs, Epics
    • Groups, Projects
    • Users
    • Settings
    • Help
    • In this project
  • Scope is displayed as a token within the search field upon typing (this should really be shown by default, but currently doesn't show up until 3 characters are entered)
  • Clear button functions correctly
  • Tabbing order is maintained

Screenshots or screen recordings

2023-03-09_12.57.17

Related to #378542 (closed)

Edited by Olena Horal-Koretska

Merge request reports