diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-20 16:37:47 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-20 16:37:47 +0300 |
commit | aee0a117a889461ce8ced6fcf73207fe017f1d99 (patch) | |
tree | 891d9ef189227a8445d83f35c1b0fc99573f4380 /app/assets/javascripts/header_search | |
parent | 8d46af3258650d305f53b819eabf7ab18d22f59e (diff) |
Add latest changes from gitlab-org/gitlab@14-6-stable-eev14.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/header_search')
9 files changed, 294 insertions, 82 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> diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue index 9bea2b280f7..9f4f4768247 100644 --- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue @@ -23,10 +23,26 @@ export default { directives: { SafeHtml, }, + props: { + currentFocusedOption: { + type: Object, + required: false, + default: () => null, + }, + }, computed: { ...mapState(['search', 'loading']), ...mapGetters(['autocompleteGroupedSearchOptions']), }, + watch: { + currentFocusedOption() { + const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el; + + if (focusedElement) { + focusedElement.scrollIntoView(false); + } + }, + }, methods: { highlightedName(val) { return highlight(val, this.search); @@ -38,6 +54,9 @@ export default { return SMALL_AVATAR_PX; }, + isOptionFocused(data) { + return this.currentFocusedOption?.html_id === data.html_id; + }, }, }; </script> @@ -49,13 +68,17 @@ export default { <gl-dropdown-divider /> <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header> <gl-dropdown-item - v-for="(data, index) in option.data" - :id="`autocomplete-${option.category}-${index}`" - :key="index" + 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"> + <div class="gl-display-flex gl-align-items-center" aria-hidden="true"> <gl-avatar v-if="data.avatar_url !== undefined" :src="data.avatar_url" diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue index 2871937ed3a..53e63bc6cca 100644 --- a/app/assets/javascripts/header_search/components/header_search_default_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue @@ -12,6 +12,13 @@ export default { GlDropdownSectionHeader, GlDropdownItem, }, + props: { + currentFocusedOption: { + type: Object, + required: false, + default: () => null, + }, + }, computed: { ...mapState(['searchContext']), ...mapGetters(['defaultSearchOptions']), @@ -23,6 +30,11 @@ export default { ); }, }, + methods: { + isOptionFocused(option) { + return this.currentFocusedOption?.html_id === option.html_id; + }, + }, }; </script> @@ -30,13 +42,17 @@ export default { <div> <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header> <gl-dropdown-item - v-for="(option, index) in defaultSearchOptions" - :id="`default-${index}`" - :key="index" + 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" > - {{ option.title }} + <span aria-hidden="true">{{ option.title }}</span> </gl-dropdown-item> </div> </template> diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue index 645eba05148..3aebee71509 100644 --- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue @@ -1,31 +1,57 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; +import { __, sprintf } from '~/locale'; export default { name: 'HeaderSearchScopedItems', components: { GlDropdownItem, }, + props: { + currentFocusedOption: { + type: Object, + required: false, + default: () => null, + }, + }, computed: { ...mapState(['search']), ...mapGetters(['scopedSearchOptions']), }, + methods: { + isOptionFocused(option) { + return this.currentFocusedOption?.html_id === option.html_id; + }, + ariaLabel(option) { + return sprintf(__('%{search} %{description} %{scope}'), { + search: this.search, + description: option.description, + scope: option.scope || '', + }); + }, + }, }; </script> <template> <div> <gl-dropdown-item - v-for="(option, index) in scopedSearchOptions" - :id="`scoped-${index}`" - :key="index" + v-for="option in scopedSearchOptions" + :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="ariaLabel(option)" tabindex="-1" :href="option.url" > - "<span class="gl-font-weight-bold">{{ search }}</span - >" {{ option.description }} - <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span> + <span aria-hidden="true"> + "<span class="gl-font-weight-bold">{{ search }}</span + >" {{ option.description }} + <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span> + </span> </gl-dropdown-item> </div> </template> diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index 2fadb1bd1ee..b2e45fcd648 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -1,20 +1,20 @@ -import { __ } from '~/locale'; +import { s__ } from '~/locale'; -export const MSG_ISSUES_ASSIGNED_TO_ME = __('Issues assigned to me'); +export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me'); -export const MSG_ISSUES_IVE_CREATED = __("Issues I've created"); +export const MSG_ISSUES_IVE_CREATED = s__("GlobalSearch|Issues I've created"); -export const MSG_MR_ASSIGNED_TO_ME = __('Merge requests assigned to me'); +export const MSG_MR_ASSIGNED_TO_ME = s__('GlobalSearch|Merge requests assigned to me'); -export const MSG_MR_IM_REVIEWER = __("Merge requests that I'm a reviewer"); +export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a reviewer"); -export const MSG_MR_IVE_CREATED = __("Merge requests I've created"); +export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created"); -export const MSG_IN_ALL_GITLAB = __('in all GitLab'); +export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|in all GitLab'); -export const MSG_IN_GROUP = __('in group'); +export const MSG_IN_GROUP = s__('GlobalSearch|in group'); -export const MSG_IN_PROJECT = __('in project'); +export const MSG_IN_PROJECT = s__('GlobalSearch|in project'); export const GROUPS_CATEGORY = 'Groups'; @@ -23,3 +23,11 @@ export const PROJECTS_CATEGORY = 'Projects'; 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_INPUT_DESCRIPTION = 'search-input-description'; + +export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description'; diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js index 2c3b1bd4c0f..0ba956f3ed1 100644 --- a/app/assets/javascripts/header_search/store/actions.js +++ b/app/assets/javascripts/header_search/store/actions.js @@ -14,6 +14,10 @@ export const fetchAutocompleteOptions = ({ commit, getters }) => { }); }; +export const clearAutocomplete = ({ commit }) => { + commit(types.CLEAR_AUTOCOMPLETE); +}; + export const setSearch = ({ commit }, value) => { commit(types.SET_SEARCH, value); }; diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js index 3f4e231ca55..a1348a8aa3f 100644 --- a/app/assets/javascripts/header_search/store/getters.js +++ b/app/assets/javascripts/header_search/store/getters.js @@ -1,3 +1,4 @@ +import { omitBy, isNil } from 'lodash'; import { objectToQuery } from '~/lib/utils/url_utility'; import { @@ -12,23 +13,29 @@ import { } from '../constants'; export const searchQuery = (state) => { - const query = { - search: state.search, - nav_source: 'navbar', - project_id: state.searchContext.project?.id, - group_id: state.searchContext.group?.id, - scope: state.searchContext.scope, - }; + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + project_id: state.searchContext.project?.id, + group_id: state.searchContext.group?.id, + scope: state.searchContext?.scope, + }, + isNil, + ); return `${state.searchPath}?${objectToQuery(query)}`; }; export const autocompleteQuery = (state) => { - const query = { - term: state.search, - project_id: state.searchContext.project?.id, - project_ref: state.searchContext.ref, - }; + const query = omitBy( + { + term: state.search, + project_id: state.searchContext.project?.id, + project_ref: state.searchContext?.ref, + }, + isNil, + ); return `${state.autocompletePath}?${objectToQuery(query)}`; }; @@ -54,22 +61,27 @@ export const defaultSearchOptions = (state, getters) => { return [ { + html_id: 'default-issues-assigned', title: MSG_ISSUES_ASSIGNED_TO_ME, url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, }, { + html_id: 'default-issues-created', title: MSG_ISSUES_IVE_CREATED, url: `${getters.scopedIssuesPath}/?author_username=${userName}`, }, { + html_id: 'default-mrs-assigned', title: MSG_MR_ASSIGNED_TO_ME, url: `${getters.scopedMRPath}/?assignee_username=${userName}`, }, { + html_id: 'default-mrs-reviewer', title: MSG_MR_IM_REVIEWER, url: `${getters.scopedMRPath}/?reviewer_username=${userName}`, }, { + html_id: 'default-mrs-created', title: MSG_MR_IVE_CREATED, url: `${getters.scopedMRPath}/?author_username=${userName}`, }, @@ -77,42 +89,43 @@ export const defaultSearchOptions = (state, getters) => { }; export const projectUrl = (state) => { - if (!state.searchContext.project || !state.searchContext.group) { - return null; - } - - const query = { - search: state.search, - nav_source: 'navbar', - project_id: state.searchContext.project.id, - group_id: state.searchContext.group.id, - scope: state.searchContext.scope, - }; + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + project_id: state.searchContext?.project?.id, + group_id: state.searchContext?.group?.id, + scope: state.searchContext?.scope, + }, + isNil, + ); return `${state.searchPath}?${objectToQuery(query)}`; }; export const groupUrl = (state) => { - if (!state.searchContext.group) { - return null; - } - - const query = { - search: state.search, - nav_source: 'navbar', - group_id: state.searchContext.group.id, - scope: state.searchContext.scope, - }; + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + group_id: state.searchContext?.group?.id, + scope: state.searchContext?.scope, + }, + isNil, + ); return `${state.searchPath}?${objectToQuery(query)}`; }; export const allUrl = (state) => { - const query = { - search: state.search, - nav_source: 'navbar', - scope: state.searchContext.scope, - }; + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + scope: state.searchContext?.scope, + }, + isNil, + ); return `${state.searchPath}?${objectToQuery(query)}`; }; @@ -122,6 +135,7 @@ export const scopedSearchOptions = (state, getters) => { if (state.searchContext.project) { options.push({ + html_id: 'scoped-in-project', scope: state.searchContext.project.name, description: MSG_IN_PROJECT, url: getters.projectUrl, @@ -130,6 +144,7 @@ export const scopedSearchOptions = (state, getters) => { if (state.searchContext.group) { options.push({ + html_id: 'scoped-in-group', scope: state.searchContext.group.name, description: MSG_IN_GROUP, url: getters.groupUrl, @@ -137,6 +152,7 @@ export const scopedSearchOptions = (state, getters) => { } options.push({ + html_id: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, url: getters.allUrl, }); @@ -165,3 +181,18 @@ export const autocompleteGroupedSearchOptions = (state) => { return results; }; + +export const searchOptions = (state, getters) => { + if (!state.search) { + return getters.defaultSearchOptions; + } + + const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce( + (options, group) => { + return [...options, ...group.data]; + }, + [], + ); + + return getters.scopedSearchOptions.concat(sortedAutocompleteOptions); +}; diff --git a/app/assets/javascripts/header_search/store/mutation_types.js b/app/assets/javascripts/header_search/store/mutation_types.js index a2358621ce6..6e65345757f 100644 --- a/app/assets/javascripts/header_search/store/mutation_types.js +++ b/app/assets/javascripts/header_search/store/mutation_types.js @@ -1,5 +1,6 @@ export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE'; export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS'; export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR'; +export const CLEAR_AUTOCOMPLETE = 'CLEAR_AUTOCOMPLETE'; export const SET_SEARCH = 'SET_SEARCH'; diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js index 175b5406540..26b4a8854fe 100644 --- a/app/assets/javascripts/header_search/store/mutations.js +++ b/app/assets/javascripts/header_search/store/mutations.js @@ -7,12 +7,17 @@ export default { }, [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) { state.loading = false; - state.autocompleteOptions = data; + state.autocompleteOptions = data.map((d, i) => { + return { html_id: `autocomplete-${d.category}-${i}`, ...d }; + }); }, [types.RECEIVE_AUTOCOMPLETE_ERROR](state) { state.loading = false; state.autocompleteOptions = []; }, + [types.CLEAR_AUTOCOMPLETE](state) { + state.autocompleteOptions = []; + }, [types.SET_SEARCH](state, value) { state.search = value; }, |