Commit 890626fc authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '297396_05-gldropdown-topbar-a11y' into 'master'

Global Search - Header Search Aria Support

See merge request gitlab-org/gitlab!70222
parents fa0a296e 4ea35aae
......@@ -2,9 +2,14 @@
import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { s__, sprintf } from '~/locale';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
import { FIRST_DROPDOWN_INDEX, SEARCH_BOX_INDEX } from '../constants';
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';
......@@ -12,7 +17,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: {
......@@ -29,7 +48,7 @@ export default {
};
},
computed: {
...mapState(['search']),
...mapState(['search', 'loading']),
...mapGetters(['searchQuery', 'searchOptions']),
searchText: {
get() {
......@@ -42,6 +61,9 @@ export default {
currentFocusedOption() {
return this.searchOptions[this.currentFocusIndex];
},
currentFocusedId() {
return this.currentFocusedOption?.html_id;
},
isLoggedIn() {
return gon?.current_username;
},
......@@ -58,6 +80,30 @@ export default {
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']),
......@@ -79,22 +125,44 @@ export default {
},
},
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
v-model="searchText"
role="searchbox"
class="gl-z-index-1"
:debounce="500"
autocomplete="off"
:placeholder="$options.i18n.searchPlaceholder"
:aria-activedescendant="currentFocusedId"
:aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
@focus="openDropdown"
@click="openDropdown"
@input="getAutocompleteOptions"
@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"
......@@ -118,5 +186,5 @@ export default {
</template>
</div>
</div>
</section>
</form>
</template>
......@@ -69,13 +69,16 @@ export default {
<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">
<div class="gl-display-flex gl-align-items-center" aria-hidden="true">
<gl-avatar
v-if="data.avatar_url !== undefined"
:src="data.avatar_url"
......
......@@ -43,13 +43,16 @@ export default {
<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"
>
{{ option.title }}
<span aria-hidden="true">{{ option.title }}</span>
</gl-dropdown-item>
</div>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { __, sprintf } from '~/locale';
export default {
name: 'HeaderSearchScopedItems',
......@@ -22,6 +23,13 @@ export default {
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>
......@@ -30,15 +38,20 @@ export default {
<div>
<gl-dropdown-item
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 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>
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';
......@@ -27,3 +27,7 @@ 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';
......@@ -873,6 +873,9 @@ msgstr ""
msgid "%{scope} results for term '%{term}'"
msgstr ""
msgid "%{search} %{description} %{scope}"
msgstr ""
msgid "%{seconds}s"
msgstr ""
......@@ -16077,6 +16080,51 @@ msgstr ""
msgid "Global notification settings"
msgstr ""
msgid "GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list."
msgstr ""
msgid "GlobalSearch|Issues I've created"
msgstr ""
msgid "GlobalSearch|Issues assigned to me"
msgstr ""
msgid "GlobalSearch|Merge requests I've created"
msgstr ""
msgid "GlobalSearch|Merge requests assigned to me"
msgstr ""
msgid "GlobalSearch|Merge requests that I'm a reviewer"
msgstr ""
msgid "GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit."
msgstr ""
msgid "GlobalSearch|Search GitLab"
msgstr ""
msgid "GlobalSearch|Search or jump to..."
msgstr ""
msgid "GlobalSearch|Search results are loading"
msgstr ""
msgid "GlobalSearch|Type and press the enter key to submit search."
msgstr ""
msgid "GlobalSearch|Type for new suggestions to appear below."
msgstr ""
msgid "GlobalSearch|in all GitLab"
msgstr ""
msgid "GlobalSearch|in group"
msgstr ""
msgid "GlobalSearch|in project"
msgstr ""
msgid "Go Back"
msgstr ""
......@@ -19455,9 +19503,6 @@ msgstr ""
msgid "Issues"
msgstr ""
msgid "Issues I've created"
msgstr ""
msgid "Issues Rate Limits"
msgstr ""
......@@ -19467,9 +19512,6 @@ msgstr ""
msgid "Issues are being rebalanced at the moment, so manual reordering is disabled."
msgstr ""
msgid "Issues assigned to me"
msgstr ""
msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
msgstr ""
......@@ -21859,18 +21901,9 @@ msgstr ""
msgid "Merge requests"
msgstr ""
msgid "Merge requests I've created"
msgstr ""
msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
msgstr ""
msgid "Merge requests assigned to me"
msgstr ""
msgid "Merge requests that I'm a reviewer"
msgstr ""
msgid "Merge the branch and fix any conflicts that come up"
msgstr ""
......@@ -41231,18 +41264,9 @@ msgstr ""
msgid "in"
msgstr ""
msgid "in all GitLab"
msgstr ""
msgid "in group"
msgstr ""
msgid "in group %{link_to_group}"
msgstr ""
msgid "in project"
msgstr ""
msgid "in project %{link_to_project}"
msgstr ""
......
......@@ -6,6 +6,7 @@ import HeaderSearchApp from '~/header_search/components/app.vue';
import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue';
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION } from '~/header_search/constants';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
import { ENTER_KEY } from '~/lib/utils/keys';
import { visitUrl } from '~/lib/utils/url_utility';
......@@ -14,6 +15,7 @@ import {
MOCK_SEARCH_QUERY,
MOCK_USERNAME,
MOCK_DEFAULT_SEARCH_OPTIONS,
MOCK_SCOPED_SEARCH_OPTIONS,
} from '../mock_data';
Vue.use(Vuex);
......@@ -59,13 +61,28 @@ describe('HeaderSearchApp', () => {
const findHeaderSearchAutocompleteItems = () =>
wrapper.findComponent(HeaderSearchAutocompleteItems);
const findDropdownKeyboardNavigation = () => wrapper.findComponent(DropdownKeyboardNavigation);
const findSearchInputDescription = () => wrapper.find(`#${SEARCH_INPUT_DESCRIPTION}`);
const findSearchResultsDescription = () => wrapper.findByTestId(SEARCH_RESULTS_DESCRIPTION);
describe('template', () => {
it('always renders Header Search Input', () => {
describe('always renders', () => {
beforeEach(() => {
createComponent();
});
it('Header Search Input', () => {
expect(findHeaderSearchInput().exists()).toBe(true);
});
it('Search Input Description', () => {
expect(findSearchInputDescription().exists()).toBe(true);
});
it('Search Results Description', () => {
expect(findSearchResultsDescription().exists()).toBe(true);
});
});
describe.each`
showDropdown | username | showSearchDropdown
${false} | ${null} | ${false}
......@@ -77,7 +94,7 @@ describe('HeaderSearchApp', () => {
beforeEach(() => {
window.gon.current_username = username;
createComponent();
wrapper.setData({ showDropdown });
findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : '');
});
it(`should${showSearchDropdown ? '' : ' not'} render`, () => {
......@@ -123,6 +140,53 @@ describe('HeaderSearchApp', () => {
});
},
);
describe.each`
username | showDropdown | expectedDesc
${null} | ${false} | ${HeaderSearchApp.i18n.searchInputDescribeByNoDropdown}
${null} | ${true} | ${HeaderSearchApp.i18n.searchInputDescribeByNoDropdown}
${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.searchInputDescribeByWithDropdown}
${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.searchInputDescribeByWithDropdown}
`('Search Input Description', ({ username, showDropdown, expectedDesc }) => {
describe(`current_username is ${username} and showDropdown is ${showDropdown}`, () => {
beforeEach(() => {
window.gon.current_username = username;
createComponent();
findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : '');
});
it(`sets description to ${expectedDesc}`, () => {
expect(findSearchInputDescription().text()).toBe(expectedDesc);
});
});
});
describe.each`
username | showDropdown | search | loading | searchOptions | expectedDesc
${null} | ${true} | ${''} | ${false} | ${[]} | ${''}
${MOCK_USERNAME} | ${false} | ${''} | ${false} | ${[]} | ${''}
${MOCK_USERNAME} | ${true} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`}
${MOCK_USERNAME} | ${true} | ${''} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`}
${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`}
${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${HeaderSearchApp.i18n.searchResultsLoading}
`(
'Search Results Description',
({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => {
describe(`search is ${search}, loading is ${loading}, and showSearchDropdown is ${
Boolean(username) && showDropdown
}`, () => {
beforeEach(() => {
window.gon.current_username = username;
createComponent({ search, loading }, { searchOptions: () => searchOptions });
findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : '');
});
it(`sets description to ${expectedDesc}`, () => {
expect(findSearchResultsDescription().text()).toBe(expectedDesc);
});
});
},
);
});
describe('events', () => {
......
......@@ -110,11 +110,11 @@ describe('HeaderSearchAutocompleteItems', () => {
});
describe.each`
currentFocusedOption | isFocused
${null} | ${false}
${{ html_id: 'not-a-match' }} | ${false}
${MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0]} | ${true}
`('isOptionFocused', ({ currentFocusedOption, isFocused }) => {
currentFocusedOption | isFocused | ariaSelected
${null} | ${false} | ${undefined}
${{ html_id: 'not-a-match' }} | ${false} | ${undefined}
${MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0]} | ${true} | ${'true'}
`('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => {
describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
beforeEach(() => {
createComponent({}, {}, { currentFocusedOption });
......@@ -123,6 +123,10 @@ describe('HeaderSearchAutocompleteItems', () => {
it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused);
});
it(`sets "aria-selected to ${ariaSelected}`, () => {
expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected);
});
});
});
});
......
......@@ -83,11 +83,11 @@ describe('HeaderSearchDefaultItems', () => {
});
describe.each`
currentFocusedOption | isFocused
${null} | ${false}
${{ html_id: 'not-a-match' }} | ${false}
${MOCK_DEFAULT_SEARCH_OPTIONS[0]} | ${true}
`('isOptionFocused', ({ currentFocusedOption, isFocused }) => {
currentFocusedOption | isFocused | ariaSelected
${null} | ${false} | ${undefined}
${{ html_id: 'not-a-match' }} | ${false} | ${undefined}
${MOCK_DEFAULT_SEARCH_OPTIONS[0]} | ${true} | ${'true'}
`('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => {
describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
beforeEach(() => {
createComponent({}, { currentFocusedOption });
......@@ -96,6 +96,10 @@ describe('HeaderSearchDefaultItems', () => {
it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused);
});
it(`sets "aria-selected to ${ariaSelected}`, () => {
expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected);
});
});
});
});
......
......@@ -37,6 +37,8 @@ describe('HeaderSearchScopedItems', () => {
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text()));
const findDropdownItemAriaLabels = () =>
findDropdownItems().wrappers.map((w) => trimText(w.attributes('aria-label')));
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
describe('template', () => {
......@@ -56,6 +58,13 @@ describe('HeaderSearchScopedItems', () => {
expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
});
it('renders aria-labels correctly', () => {
const expectedLabels = MOCK_SCOPED_SEARCH_OPTIONS.map((o) =>
trimText(`${MOCK_SEARCH} ${o.description} ${o.scope || ''}`),
);
expect(findDropdownItemAriaLabels()).toStrictEqual(expectedLabels);
});
it('renders links correctly', () => {
const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url);
expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
......@@ -63,11 +72,11 @@ describe('HeaderSearchScopedItems', () => {
});
describe.each`
currentFocusedOption | isFocused
${null} | ${false}
${{ html_id: 'not-a-match' }} | ${false}
${MOCK_SCOPED_SEARCH_OPTIONS[0]} | ${true}
`('isOptionFocused', ({ currentFocusedOption, isFocused }) => {
currentFocusedOption | isFocused | ariaSelected
${null} | ${false} | ${undefined}
${{ html_id: 'not-a-match' }} | ${false} | ${undefined}
${MOCK_SCOPED_SEARCH_OPTIONS[0]} | ${true} | ${'true'}
`('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => {
describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
beforeEach(() => {
createComponent({}, { currentFocusedOption });
......@@ -76,6 +85,10 @@ describe('HeaderSearchScopedItems', () => {
it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused);
});
it(`sets "aria-selected to ${ariaSelected}`, () => {
expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected);
});
});
});
});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment