Skip to content

Commit db3f1b9

Browse files
authored
feat: Search a11y updated behavior. (#1614)
* feat: Updated accesibility on courseware search modal * chore: Updated i18n to use useIntl() hook and dialog behavior updates * feat: Added close when clicking on the modal backdrop * chore: Swapped remove listeners with an AbortController * fix: Fixed tests * chore: Rolled back unintended husky script change
1 parent ec360bc commit db3f1b9

File tree

6 files changed

+151
-93
lines changed

6 files changed

+151
-93
lines changed

src/course-home/courseware-search/CoursewareSearch.jsx

Lines changed: 65 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import React, { useEffect } from 'react';
1+
import React, { useEffect, useRef } from 'react';
22
import { useParams } from 'react-router';
33
import { useDispatch } from 'react-redux';
44
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
5-
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
5+
import { useIntl } from '@edx/frontend-platform/i18n';
66
import {
77
Alert, Button, Icon, Spinner,
88
} from '@openedx/paragon';
@@ -18,7 +18,8 @@ import CoursewareSearchResultsFilterContainer from './CoursewareResultsFilter';
1818
import { updateModel, useModel } from '../../generic/model-store';
1919
import { searchCourseContent } from '../data/thunks';
2020

21-
const CoursewareSearch = ({ intl, ...sectionProps }) => {
21+
const CoursewareSearch = ({ ...sectionProps }) => {
22+
const { formatMessage } = useIntl();
2223
const { courseId } = useParams();
2324
const { query: searchKeyword, setQuery, clearSearchParams } = useCoursewareSearchParams();
2425
const dispatch = useDispatch();
@@ -29,6 +30,7 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
2930
errors,
3031
total,
3132
} = useModel('contentSearchResults', courseId);
33+
const dialogRef = useRef();
3234

3335
useLockScroll();
3436

@@ -44,7 +46,8 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
4446
searchKeyword: '',
4547
results: [],
4648
errors: undefined,
47-
loading: false,
49+
loading:
50+
false,
4851
},
4952
}));
5053
};
@@ -66,20 +69,46 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
6669
setQuery(value);
6770
};
6871

69-
useEffect(() => {
70-
handleSubmit(searchKeyword);
71-
}, []);
72-
7372
const handleOnChange = (value) => {
7473
if (value === searchKeyword) { return; }
7574
if (!value) { clearSearch(); }
7675
};
7776

78-
const handleSearchCloseClick = () => {
77+
const close = () => {
7978
clearSearch();
8079
dispatch(setShowSearch(false));
8180
};
8281

82+
const handlePopState = () => close();
83+
84+
const handleBackdropClick = function (event) {
85+
if (event.target === dialogRef.current) {
86+
dialogRef.current.close();
87+
}
88+
};
89+
90+
useEffect(() => {
91+
// We need this to keep the dialog reference when unmounting.
92+
const dialog = dialogRef.current;
93+
94+
// Open the dialog as a modal on render to confine focus within it.
95+
dialogRef.current.showModal();
96+
97+
if (searchKeyword) {
98+
handleSubmit(searchKeyword); // In case it's opened with a search link, we run the search.
99+
}
100+
101+
const controller = new AbortController();
102+
const { signal } = controller;
103+
104+
window.addEventListener('popstate', handlePopState, { signal });
105+
dialog.addEventListener('click', handleBackdropClick, { signal });
106+
107+
return () => controller.abort(); // Removes event listeners.
108+
}, []);
109+
110+
const handleSearchClose = () => close();
111+
83112
let status = 'idle';
84113
if (loading) {
85114
status = 'loading';
@@ -90,34 +119,37 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
90119
}
91120

92121
return (
93-
<section className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-section" {...sectionProps}>
94-
<div className="courseware-search__close">
95-
<Button
96-
variant="tertiary"
97-
className="p-1"
98-
aria-label={intl.formatMessage(messages.searchCloseAction)}
99-
onClick={handleSearchCloseClick}
100-
data-testid="courseware-search-close-button"
101-
><Icon src={Close} />
102-
</Button>
103-
</div>
122+
<dialog ref={dialogRef} className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-dialog" onClose={handleSearchClose} {...sectionProps}>
104123
<div className="courseware-search__outer-content">
105-
<div className="courseware-search__content">
106-
<h1 className="h2">{intl.formatMessage(messages.searchModuleTitle)}</h1>
107-
<CoursewareSearchForm
108-
searchTerm={searchKeyword}
109-
onSubmit={handleSubmit}
110-
onChange={handleOnChange}
111-
placeholder={intl.formatMessage(messages.searchBarPlaceholderText)}
112-
/>
124+
<div className="courseware-search__content" data-testid="courseware-search-content">
125+
<div className="courseware-search__form">
126+
<h1 className="h2">{formatMessage(messages.searchModuleTitle)}</h1>
127+
<CoursewareSearchForm
128+
searchTerm={searchKeyword}
129+
onSubmit={handleSubmit}
130+
onChange={handleOnChange}
131+
placeholder={formatMessage(messages.searchBarPlaceholderText)}
132+
/>
133+
<div className="courseware-search__close">
134+
<Button
135+
variant="tertiary"
136+
className="p-1"
137+
aria-label={formatMessage(messages.searchCloseAction)}
138+
onClick={() => dialogRef.current.close()}
139+
data-testid="courseware-search-close-button"
140+
><Icon src={Close} />
141+
</Button>
142+
</div>
143+
</div>
144+
113145
{status === 'loading' ? (
114146
<div className="courseware-search__spinner" data-testid="courseware-search-spinner">
115-
<Spinner animation="border" variant="light" screenReaderText={intl.formatMessage(messages.loading)} />
147+
<Spinner animation="border" variant="light" screenReaderText={formatMessage(messages.loading)} />
116148
</div>
117149
) : null}
118150
{status === 'error' && (
119151
<Alert className="mt-4" variant="danger" data-testid="courseware-search-error">
120-
{intl.formatMessage(messages.searchResultsError)}
152+
{formatMessage(messages.searchResultsError)}
121153
</Alert>
122154
)}
123155
{status === 'results' ? (
@@ -129,20 +161,16 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
129161
aria-relevant="all"
130162
aria-atomic="true"
131163
data-testid="courseware-search-summary"
132-
>{intl.formatMessage(messages.searchResultsLabel, { total, keyword: lastSearchKeyword })}
164+
>{formatMessage(messages.searchResultsLabel, { total, keyword: lastSearchKeyword })}
133165
</div>
134166
) : null}
135167
<CoursewareSearchResultsFilterContainer />
136168
</>
137169
) : null}
138170
</div>
139171
</div>
140-
</section>
172+
</dialog>
141173
);
142174
};
143175

144-
CoursewareSearch.propTypes = {
145-
intl: intlShape.isRequired,
146-
};
147-
148-
export default injectIntl(CoursewareSearch);
176+
export default CoursewareSearch;

src/course-home/courseware-search/CoursewareSearch.test.jsx

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { updateModel, useModel } from '../../generic/model-store';
1919

2020
jest.mock('./hooks');
2121
jest.mock('../../generic/model-store', () => ({
22+
...jest.requireActual('../../generic/model-store'),
2223
updateModel: jest.fn(),
2324
useModel: jest.fn(),
2425
}));
@@ -56,7 +57,7 @@ const defaultProps = {
5657
total: 0,
5758
};
5859

59-
const coursewareSearch = {
60+
const defaultSearchParams = {
6061
query: '',
6162
filter: '',
6263
setQuery: jest.fn(),
@@ -96,14 +97,20 @@ const mockModels = ((props = defaultProps) => {
9697
});
9798
});
9899

99-
const mockSearchParams = ((props = coursewareSearch) => {
100+
const mockSearchParams = ((params) => {
101+
const props = { ...defaultSearchParams, ...params };
100102
useCoursewareSearchParams.mockReturnValue(props);
101103
});
102104

103105
describe('CoursewareSearch', () => {
104-
beforeAll(initializeMockApp);
106+
beforeAll(() => initializeMockApp());
105107

106108
beforeEach(() => {
109+
mockModels();
110+
mockSearchParams();
111+
});
112+
113+
afterEach(() => {
107114
jest.clearAllMocks();
108115
});
109116

@@ -113,65 +120,56 @@ describe('CoursewareSearch', () => {
113120
});
114121

115122
it('should use useElementBoundingBox() and useLockScroll() hooks', () => {
116-
mockModels();
117-
mockSearchParams();
118123
renderComponent();
119124

120-
expect(useElementBoundingBox).toBeCalledTimes(1);
121-
expect(useLockScroll).toBeCalledTimes(1);
125+
expect(useElementBoundingBox).toHaveBeenCalledTimes(1);
126+
expect(useLockScroll).toHaveBeenCalledTimes(1);
122127
});
123128

124129
it('should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
125-
mockModels();
126-
mockSearchParams();
127130
renderComponent();
128131

129-
const section = screen.getByTestId('courseware-search-section');
132+
const section = screen.getByTestId('courseware-search-dialog');
130133
expect(section.style.getPropertyValue('--modal-top-position')).toBe(`${tabsTopPosition}px`);
131134
});
132135
});
133136

134137
describe('when clicking on the "Close" button', () => {
135-
it('should dispatch setShowSearch(false)', async () => {
136-
mockModels();
138+
it('should close the dialog', async () => {
137139
renderComponent();
138140

139141
await waitFor(() => {
140142
const close = screen.queryByTestId('courseware-search-close-button');
141143
fireEvent.click(close);
142144
});
143145

144-
expect(setShowSearch).toBeCalledWith(false);
146+
expect(HTMLDialogElement.prototype.close).toHaveBeenCalled();
147+
expect(setShowSearch).toHaveBeenCalledWith(false);
145148
});
146149
});
147150

148151
describe('when CourseTabsNavigation is not present', () => {
149152
it('should use "--modal-top-position: 0" if nce element is not present', () => {
150153
useElementBoundingBox.mockImplementation(() => undefined);
151154

152-
mockModels();
153-
mockSearchParams();
154155
renderComponent();
155156

156-
const section = screen.getByTestId('courseware-search-section');
157+
const section = screen.getByTestId('courseware-search-dialog');
157158
expect(section.style.getPropertyValue('--modal-top-position')).toBe('0');
158159
});
159160
});
160161

161162
describe('when passing extra props', () => {
162163
it('should pass on extra props to section element', () => {
163-
mockModels();
164-
mockSearchParams();
165164
renderComponent({ foo: 'bar' });
166165

167-
const section = screen.getByTestId('courseware-search-section');
166+
const section = screen.getByTestId('courseware-search-dialog');
168167
expect(section).toHaveAttribute('foo', 'bar');
169168
});
170169
});
171170

172171
describe('when submitting an empty search', () => {
173172
it('should clear the search by dispatch updateModel', async () => {
174-
mockModels();
175173
renderComponent();
176174

177175
await waitFor(() => {
@@ -203,7 +201,6 @@ describe('CoursewareSearch', () => {
203201
});
204202

205203
it('should call searchCourseContent', async () => {
206-
mockModels();
207204
renderComponent();
208205

209206
const searchKeyword = 'course';
@@ -259,6 +256,7 @@ describe('CoursewareSearch', () => {
259256

260257
describe('when clearing the search input', () => {
261258
it('should clear the search by dispatch updateModel', async () => {
259+
mockSearchParams({ query: 'fubar' });
262260
mockModels({
263261
searchKeyword: 'fubar',
264262
total: 2,
Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,44 @@
1-
import React from 'react';
21
import PropTypes from 'prop-types';
32
import { SearchField } from '@openedx/paragon';
4-
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
3+
import { useIntl } from '@edx/frontend-platform/i18n';
54
import messages from './messages';
65

76
const CoursewareSearchForm = ({
8-
intl,
97
searchTerm,
108
onSubmit,
119
onChange,
1210
placeholder,
13-
}) => (
14-
<SearchField.Advanced
15-
value={searchTerm}
16-
onSubmit={onSubmit}
17-
onChange={onChange}
18-
submitButtonLocation="external"
19-
className="courseware-search-form"
20-
screenReaderText={{
21-
label: intl.formatMessage(messages.searchSubmitLabel),
22-
clearButton: intl.formatMessage(messages.searchClearAction),
23-
submitButton: null, // Remove the sr-only label in the button.
24-
}}
25-
>
26-
<div className="pgn__searchfield_wrapper" data-testid="courseware-search-form">
27-
<SearchField.Label />
28-
<SearchField.Input placeholder={placeholder} autoFocus />
29-
<SearchField.ClearButton />
30-
</div>
31-
<SearchField.SubmitButton
32-
buttonText={intl.formatMessage(messages.searchSubmitLabel)}
11+
}) => {
12+
const { formatMessage } = useIntl();
13+
14+
return (
15+
<SearchField.Advanced
16+
value={searchTerm}
17+
onSubmit={onSubmit}
18+
onChange={onChange}
3319
submitButtonLocation="external"
34-
data-testid="courseware-search-form-submit"
35-
/>
36-
</SearchField.Advanced>
37-
);
20+
className="courseware-search-form"
21+
screenReaderText={{
22+
label: formatMessage(messages.searchSubmitLabel),
23+
clearButton: formatMessage(messages.searchClearAction),
24+
submitButton: null, // Remove the sr-only label in the button.
25+
}}
26+
>
27+
<div className="pgn__searchfield_wrapper" data-testid="courseware-search-form">
28+
<SearchField.Label />
29+
<SearchField.Input placeholder={placeholder} autoFocus />
30+
<SearchField.ClearButton />
31+
</div>
32+
<SearchField.SubmitButton
33+
buttonText={formatMessage(messages.searchSubmitLabel)}
34+
submitButtonLocation="external"
35+
data-testid="courseware-search-form-submit"
36+
/>
37+
</SearchField.Advanced>
38+
);
39+
};
3840

3941
CoursewareSearchForm.propTypes = {
40-
intl: intlShape.isRequired,
4142
searchTerm: PropTypes.string,
4243
onSubmit: PropTypes.func,
4344
onChange: PropTypes.func,
@@ -51,4 +52,4 @@ CoursewareSearchForm.defaultProps = {
5152
placeholder: undefined,
5253
};
5354

54-
export default injectIntl(CoursewareSearchForm);
55+
export default CoursewareSearchForm;

0 commit comments

Comments
 (0)