diff options
Diffstat (limited to 'app/assets/javascripts/header_search/components/app.vue')
-rw-r--r-- | app/assets/javascripts/header_search/components/app.vue | 138 |
1 files changed, 118 insertions, 20 deletions
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index c6590fd8eb3..edc6573a489 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -1,8 +1,17 @@ <script> import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; +import { debounce } from 'lodash'; import { visitUrl } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { s__, sprintf } from '~/locale'; +import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; +import { + FIRST_DROPDOWN_INDEX, + SEARCH_BOX_INDEX, + SEARCH_INPUT_DESCRIPTION, + SEARCH_RESULTS_DESCRIPTION, +} 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'; @@ -10,7 +19,21 @@ import HeaderSearchScopedItems from './header_search_scoped_items.vue'; export default { name: 'HeaderSearchApp', i18n: { - searchPlaceholder: __('Search or jump to...'), + searchPlaceholder: s__('GlobalSearch|Search or jump to...'), + searchAria: 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.', + ), + 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.', + ), + searchResultsLoading: s__('GlobalSearch|Search results are loading'), }, directives: { Outside }, components: { @@ -18,15 +41,17 @@ export default { HeaderSearchDefaultItems, HeaderSearchScopedItems, HeaderSearchAutocompleteItems, + DropdownKeyboardNavigation, }, data() { return { showDropdown: false, + currentFocusIndex: SEARCH_BOX_INDEX, }; }, computed: { - ...mapState(['search']), - ...mapGetters(['searchQuery']), + ...mapState(['search', 'loading']), + ...mapGetters(['searchQuery', 'searchOptions']), searchText: { get() { return this.search; @@ -35,15 +60,55 @@ export default { this.setSearch(value); }, }, + currentFocusedOption() { + return this.searchOptions[this.currentFocusIndex]; + }, + currentFocusedId() { + return this.currentFocusedOption?.html_id; + }, + isLoggedIn() { + return gon?.current_username; + }, showSearchDropdown() { - return this.showDropdown && gon?.current_username; + return this.showDropdown && this.isLoggedIn; }, showDefaultItems() { return !this.searchText; }, + 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 + } + + if (this.showDefaultItems) { + return sprintf(this.$options.i18n.searchDescribedByDefault, { + count: this.searchOptions.length, + }); + } + + return this.loading + ? this.$options.i18n.searchResultsLoading + : sprintf(this.$options.i18n.searchDescribedByUpdated, { + count: this.searchOptions.length, + }); + }, }, methods: { - ...mapActions(['setSearch', 'fetchAutocompleteOptions']), + ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), openDropdown() { this.showDropdown = true; }, @@ -51,44 +116,77 @@ export default { this.showDropdown = false; }, submitSearch() { - return visitUrl(this.searchQuery); + return visitUrl(this.currentFocusedOption?.url || this.searchQuery); }, - getAutocompleteOptions(searchTerm) { + getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) { if (!searchTerm) { - return; + this.clearAutocomplete(); + } else { + this.fetchAutocompleteOptions(); } - - this.fetchAutocompleteOptions(); - }, + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), }, + SEARCH_BOX_INDEX, + SEARCH_INPUT_DESCRIPTION, + SEARCH_RESULTS_DESCRIPTION, }; </script> <template> - <section v-outside="closeDropdown" class="header-search gl-relative"> + <form + v-outside="closeDropdown" + role="search" + :aria-label="$options.i18n.searchAria" + class="header-search gl-relative" + > <gl-search-box-by-type + id="search" v-model="searchText" - :debounce="500" + role="searchbox" + class="gl-z-index-1" autocomplete="off" :placeholder="$options.i18n.searchPlaceholder" + :aria-activedescendant="currentFocusedId" + :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" @focus="openDropdown" @click="openDropdown" @input="getAutocompleteOptions" - @keydown.enter="submitSearch" - @keydown.esc="closeDropdown" + @keydown.enter.stop.prevent="submitSearch" /> + <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{ + searchInputDescribeBy + }}</span> + <span + role="region" + :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" + class="gl-sr-only" + aria-live="polite" + aria-atomic="true" + > + {{ dropdownResultsDescription }} + </span> <div v-if="showSearchDropdown" data-testid="header-search-dropdown-menu" class="header-search-dropdown-menu 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" > <div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2"> - <header-search-default-items v-if="showDefaultItems" /> + <dropdown-keyboard-navigation + v-model="currentFocusIndex" + :max="searchOptions.length - 1" + :min="$options.SEARCH_BOX_INDEX" + :default-index="defaultIndex" + @tab="closeDropdown" + /> + <header-search-default-items + v-if="showDefaultItems" + :current-focused-option="currentFocusedOption" + /> <template v-else> - <header-search-scoped-items /> - <header-search-autocomplete-items /> + <header-search-scoped-items :current-focused-option="currentFocusedOption" /> + <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> </template> </div> </div> - </section> + </form> </template> |