1
|
|
|
import React from "react"; |
2
|
|
|
import { useIntl } from "react-intl"; |
3
|
|
|
import { |
4
|
|
|
focusNextItem, |
5
|
|
|
focusPreviousItem, |
6
|
|
|
getFocusableElements, |
7
|
|
|
} from "../../helpers/focus"; |
8
|
|
|
import { getLocale, localizeFieldNonNull } from "../../helpers/localize"; |
9
|
|
|
import { addOrRemove } from "../../helpers/queries"; |
10
|
|
|
import { Skill } from "../../models/types"; |
11
|
|
|
import Accordion from "../H2Components/Accordion"; |
12
|
|
|
import { searchResultsMessages as messages } from "./messages"; |
13
|
|
|
|
14
|
|
|
interface SearchResultsProps { |
15
|
|
|
/** Object holding the state (expanded or collapsed) of all dialog accordions. */ |
16
|
|
|
accordionData: { |
17
|
|
|
skills: { [id: string]: boolean }; |
18
|
|
|
parentSkillCategories: { [key: string]: boolean }; |
19
|
|
|
}; |
20
|
|
|
/** The description displayed after user chooses a skill category or searches for a skill. */ |
21
|
|
|
description: string; |
22
|
|
|
/** List of newly added skills. */ |
23
|
|
|
newSkills: Skill[]; |
24
|
|
|
/** List of previously added skills. */ |
25
|
|
|
previousSkills: Skill[]; |
26
|
|
|
/** The skills displayed after user chooses a skill category or searches for a skill. */ |
27
|
|
|
results: Skill[] | null; |
28
|
|
|
/** The title displayed after user chooses a skill category or searches for a skill. */ |
29
|
|
|
title: string; |
30
|
|
|
/** Resets the search results pane to its default state. */ |
31
|
|
|
resetResults: () => void; |
32
|
|
|
/** Callback function to set the new skills list state. */ |
33
|
|
|
setNewSkills: (value: React.SetStateAction<Skill[]>) => void; |
34
|
|
|
/** Callback function that toggles the accordion's state. */ |
35
|
|
|
toggleAccordion: (id: number, key: string, value?: boolean | null) => void; |
36
|
|
|
} |
37
|
|
|
|
38
|
|
|
const SearchResults: React.FunctionComponent<SearchResultsProps> = ({ |
39
|
|
|
accordionData, |
40
|
|
|
description, |
41
|
|
|
newSkills, |
42
|
|
|
previousSkills, |
43
|
|
|
title, |
44
|
|
|
results, |
45
|
|
|
resetResults, |
46
|
|
|
setNewSkills, |
47
|
|
|
toggleAccordion, |
48
|
|
|
}) => { |
49
|
|
|
const intl = useIntl(); |
50
|
|
|
const locale = getLocale(intl.locale); |
51
|
|
|
|
52
|
|
|
/** |
53
|
|
|
* We need to focus on the first element in the skill results section whenever the user picks a category or uses the search bar. The useEffect hook will trigger a focus any time the skillResults state changes. |
54
|
|
|
*/ |
55
|
|
|
const firstSkillResultRef = React.useRef<HTMLButtonElement | null>(null); // first element in skills results list. |
56
|
|
|
React.useEffect(() => { |
57
|
|
|
if (results && results.length !== 0 && firstSkillResultRef.current) { |
58
|
|
|
firstSkillResultRef.current.focus(); |
59
|
|
|
} |
60
|
|
|
}, [results]); |
61
|
|
|
|
62
|
|
|
const resultsRef = React.useRef<HTMLDivElement | null>(null); |
63
|
|
|
|
64
|
|
|
React.useEffect(() => { |
65
|
|
|
// Ensure results are cleared when tabbing out of the results pane. |
66
|
|
|
// Due to a keyHandler in FindSkillsDialog, tabbing will always send focus out of this component. |
67
|
|
|
// Arrow keys must be used to navigate between elements within the results pane. |
68
|
|
|
const keyHandler = (e: KeyboardEvent) => { |
69
|
|
|
if (e.key === "Tab") { |
70
|
|
|
resetResults(); |
71
|
|
|
} |
72
|
|
|
}; |
73
|
|
|
let element: HTMLElement; |
74
|
|
|
if (resultsRef.current) { |
75
|
|
|
element = resultsRef.current; |
76
|
|
|
element.addEventListener("keydown", keyHandler); |
77
|
|
|
} |
78
|
|
|
return () => { |
79
|
|
|
if (element) { |
80
|
|
|
element.removeEventListener("keydown", keyHandler); |
81
|
|
|
} |
82
|
|
|
}; |
83
|
|
|
}); |
84
|
|
|
|
85
|
|
|
const skillAccordionKeyDown = ( |
86
|
|
|
e: React.KeyboardEvent<HTMLButtonElement>, |
87
|
|
|
id: number, |
88
|
|
|
): void => { |
89
|
|
|
if (resultsRef.current) { |
90
|
|
|
const skillResultsTabList = getFocusableElements(resultsRef.current); |
91
|
|
|
|
92
|
|
|
switch (e.key) { |
93
|
|
|
case "ArrowLeft": |
94
|
|
|
resetResults(); |
95
|
|
|
break; |
96
|
|
|
case "ArrowRight": |
97
|
|
|
toggleAccordion(id, "skills"); |
98
|
|
|
break; |
99
|
|
|
case "ArrowUp": |
100
|
|
|
focusPreviousItem(skillResultsTabList); |
101
|
|
|
break; |
102
|
|
|
case "ArrowDown": |
103
|
|
|
focusNextItem(skillResultsTabList); |
104
|
|
|
break; |
105
|
|
|
default: |
106
|
|
|
// do nothing; |
107
|
|
|
} |
108
|
|
|
} |
109
|
|
|
}; |
110
|
|
|
|
111
|
|
|
const addSkillKeyDown = ( |
112
|
|
|
event: React.KeyboardEvent<HTMLInputElement>, |
113
|
|
|
id: string, |
114
|
|
|
newSkill: Skill, |
115
|
|
|
newSkillsState: Skill[], |
116
|
|
|
): void => { |
117
|
|
|
if (resultsRef.current) { |
118
|
|
|
const skillResultsTabList = getFocusableElements(resultsRef.current); |
119
|
|
|
const checkbox = document.getElementById(id) as HTMLInputElement; |
120
|
|
|
switch (event.key) { |
121
|
|
|
case "ArrowLeft": |
122
|
|
|
resetResults(); |
123
|
|
|
event.preventDefault(); |
124
|
|
|
break; |
125
|
|
|
case "ArrowRight": |
126
|
|
|
setNewSkills(addOrRemove(newSkill, newSkillsState)); |
127
|
|
|
checkbox.checked = !checkbox.checked; |
128
|
|
|
event.preventDefault(); |
129
|
|
|
break; |
130
|
|
|
case "ArrowUp": |
131
|
|
|
focusPreviousItem(skillResultsTabList); |
132
|
|
|
event.preventDefault(); |
133
|
|
|
break; |
134
|
|
|
case "ArrowDown": |
135
|
|
|
focusNextItem(skillResultsTabList); |
136
|
|
|
event.preventDefault(); |
137
|
|
|
break; |
138
|
|
|
default: |
139
|
|
|
// do nothing; |
140
|
|
|
} |
141
|
|
|
} |
142
|
|
|
}; |
143
|
|
|
|
144
|
|
|
const backBtnKeyDown = ( |
145
|
|
|
event: React.KeyboardEvent<HTMLButtonElement>, |
146
|
|
|
): void => { |
147
|
|
|
if (resultsRef.current) { |
148
|
|
|
const skillResultsTabList = getFocusableElements(resultsRef.current); |
149
|
|
|
switch (event.key) { |
150
|
|
|
case "ArrowUp": |
151
|
|
|
focusPreviousItem(skillResultsTabList); |
152
|
|
|
event.preventDefault(); |
153
|
|
|
break; |
154
|
|
|
case "ArrowDown": |
155
|
|
|
focusNextItem(skillResultsTabList); |
156
|
|
|
event.preventDefault(); |
157
|
|
|
break; |
158
|
|
|
default: |
159
|
|
|
// do nothing; |
160
|
|
|
} |
161
|
|
|
} |
162
|
|
|
}; |
163
|
|
|
|
164
|
|
|
return ( |
165
|
|
|
<div |
166
|
|
|
data-h2-grid-item="s(3of5) b(1of1)" |
167
|
|
|
data-h2-border="s(gray-2, left, solid, thin) b(gray-2, top, solid, thin)" |
168
|
|
|
> |
169
|
|
|
{results === null ? ( |
170
|
|
|
<div |
171
|
|
|
data-h2-padding="s(tb, 5) s(right, 3) s(left, 4) b(bottom, 3) b(rl, 2)" |
172
|
|
|
data-h2-container="b(center, large)" |
173
|
|
|
> |
174
|
|
|
<p |
175
|
|
|
data-h2-font-size="b(h4)" |
176
|
|
|
data-h2-font-weight="b(700)" |
177
|
|
|
data-h2-padding="b(top, 3) b(bottom, 1)" |
178
|
|
|
> |
179
|
|
|
<i data-h2-padding="b(right, .5)" className="fas fa-arrow-left" /> |
180
|
|
|
{intl.formatMessage(messages.skillsResultsHeading)} |
181
|
|
|
</p> |
182
|
|
|
<p>{intl.formatMessage(messages.skillResultsSubHeading)}</p> |
183
|
|
|
</div> |
184
|
|
|
) : ( |
185
|
|
|
<div ref={resultsRef} data-h2-padding="s(rl, 0) b(bottom, 3) b(rl, 1)"> |
186
|
|
|
{/* Back Button */} |
187
|
|
|
<button |
188
|
|
|
data-h2-button |
189
|
|
|
type="button" |
190
|
|
|
data-h2-padding="b(all, 1)" |
191
|
|
|
onClick={() => resetResults()} |
192
|
|
|
onKeyDown={backBtnKeyDown} |
193
|
|
|
> |
194
|
|
|
<p |
195
|
|
|
data-h2-button-label |
196
|
|
|
data-h2-font-weight="b(700)" |
197
|
|
|
data-h2-font-style="b(underline)" |
198
|
|
|
> |
199
|
|
|
<i |
200
|
|
|
data-h2-padding="b(right, .25)" |
201
|
|
|
className="fas fa-caret-left" |
202
|
|
|
/> |
203
|
|
|
{intl.formatMessage(messages.backButton)} |
204
|
|
|
</p> |
205
|
|
|
</button> |
206
|
|
|
|
207
|
|
|
<p |
208
|
|
|
data-h2-font-size="b(h4)" |
209
|
|
|
data-h2-font-weight="b(700)" |
210
|
|
|
data-h2-padding="b(rl, 1) b(bottom, .5)" |
211
|
|
|
> |
212
|
|
|
{title} |
213
|
|
|
</p> |
214
|
|
|
<p |
215
|
|
|
data-h2-font-size="b(small)" |
216
|
|
|
data-h2-padding="b(rl, 1) b(bottom, 2)" |
217
|
|
|
> |
218
|
|
|
{description} |
219
|
|
|
</p> |
220
|
|
|
{results && results.length > 0 ? ( |
221
|
|
|
<> |
222
|
|
|
{/* This message is for screen readers. It provides a status message to the user on how many skills they have added. */} |
223
|
|
|
<p role="status" data-h2-visibility="b(invisible)"> |
224
|
|
|
{intl.formatMessage(messages.numOfSkillsAdded, { |
225
|
|
|
numOfSkills: newSkills.length, |
226
|
|
|
})} |
227
|
|
|
</p> |
228
|
|
|
<ul |
229
|
|
|
role="menu" |
230
|
|
|
data-h2-padding="b(left, 0)" |
231
|
|
|
className="no-list-style-type" |
232
|
|
|
> |
233
|
|
|
{results.map((skill, index) => { |
234
|
|
|
const { id } = skill; |
235
|
|
|
const isAdded = newSkills.find( |
236
|
|
|
(newSkill) => newSkill.id === skill.id, |
237
|
|
|
); |
238
|
|
|
const isPreviousSkill = |
239
|
|
|
previousSkills.find( |
240
|
|
|
(previousSkill) => previousSkill.id === skill.id, |
241
|
|
|
) !== undefined; |
242
|
|
|
const checkboxId = `${id}-skill-checkbox`; |
243
|
|
|
return ( |
244
|
|
|
<li key={id} data-h2-grid="b(middle, contained, padded, 0)"> |
245
|
|
|
<Accordion |
246
|
|
|
isExpanded={accordionData.skills[id]} |
247
|
|
|
toggleAccordion={() => toggleAccordion(id, "skills")} |
248
|
|
|
data-h2-grid-item="b(3of4)" |
249
|
|
|
> |
250
|
|
|
<Accordion.Btn |
251
|
|
|
role="menuitem" |
252
|
|
|
innerRef={index === 0 ? firstSkillResultRef : null} |
253
|
|
|
onKeyDown={(e) => skillAccordionKeyDown(e, id)} |
254
|
|
|
> |
255
|
|
|
<p |
256
|
|
|
data-h2-font-weight="b(700)" |
257
|
|
|
data-h2-font-style="b(underline)" |
258
|
|
|
> |
259
|
|
|
{localizeFieldNonNull(locale, skill, "name")} |
260
|
|
|
{isAdded && ( |
261
|
|
|
<i |
262
|
|
|
data-h2-padding="b(left, .5)" |
263
|
|
|
data-h2-font-color="b(theme-1)" |
264
|
|
|
aria-hidden="true" |
265
|
|
|
className="fas fa-check" |
266
|
|
|
/> |
267
|
|
|
)} |
268
|
|
|
</p> |
269
|
|
|
</Accordion.Btn> |
270
|
|
|
<Accordion.Content> |
271
|
|
|
<p data-h2-focus> |
272
|
|
|
{localizeFieldNonNull(locale, skill, "description")} |
273
|
|
|
</p> |
274
|
|
|
</Accordion.Content> |
275
|
|
|
</Accordion> |
276
|
|
|
|
277
|
|
|
<label |
278
|
|
|
data-h2-grid-item="b(1of4)" |
279
|
|
|
className="search-result-checkbox" |
280
|
|
|
htmlFor={checkboxId} |
281
|
|
|
> |
282
|
|
|
<input |
283
|
|
|
aria-label={ |
284
|
|
|
isAdded |
285
|
|
|
? intl.formatMessage(messages.removeSkillButton, { |
286
|
|
|
skill: localizeFieldNonNull( |
287
|
|
|
locale, |
288
|
|
|
skill, |
289
|
|
|
"name", |
290
|
|
|
), |
291
|
|
|
}) |
292
|
|
|
: intl.formatMessage(messages.selectSkillButton, { |
293
|
|
|
skill: localizeFieldNonNull( |
294
|
|
|
locale, |
295
|
|
|
skill, |
296
|
|
|
"name", |
297
|
|
|
), |
298
|
|
|
}) |
299
|
|
|
} |
300
|
|
|
role="menuitem" |
301
|
|
|
id={checkboxId} |
302
|
|
|
data-h2-visibility="b(invisible)" |
303
|
|
|
data-h2-grid-item="b(1of4)" |
304
|
|
|
type="checkbox" |
305
|
|
|
name={checkboxId} |
306
|
|
|
onClick={() => { |
307
|
|
|
// If the skill has been selected then remove it. |
308
|
|
|
// Else, if the has not been selected then add it to newSkills list. |
309
|
|
|
setNewSkills(addOrRemove(skill, newSkills)); |
310
|
|
|
}} |
311
|
|
|
onKeyDown={(e) => |
312
|
|
|
addSkillKeyDown(e, checkboxId, skill, newSkills) |
313
|
|
|
} |
314
|
|
|
defaultChecked={isPreviousSkill} |
315
|
|
|
disabled={isPreviousSkill} |
316
|
|
|
/> |
317
|
|
|
{isPreviousSkill ? ( |
318
|
|
|
<span |
319
|
|
|
data-h2-font-size="b(small)" |
320
|
|
|
data-h2-font-color="b(gray-4)" |
321
|
|
|
> |
322
|
|
|
{intl.formatMessage(messages.disabledSkillButton)} |
323
|
|
|
</span> |
324
|
|
|
) : ( |
325
|
|
|
<span |
326
|
|
|
data-h2-font-weight="b(700)" |
327
|
|
|
data-h2-font-style="b(underline)" |
328
|
|
|
data-h2-font-color={`${ |
329
|
|
|
isAdded ? "b(theme-1)" : "b(black)" |
330
|
|
|
}`} |
331
|
|
|
> |
332
|
|
|
{isAdded |
333
|
|
|
? intl.formatMessage(messages.removeSkillButton, { |
334
|
|
|
skill: "", |
335
|
|
|
}) |
336
|
|
|
: intl.formatMessage(messages.selectSkillButton, { |
337
|
|
|
skill: "", |
338
|
|
|
})} |
339
|
|
|
</span> |
340
|
|
|
)} |
341
|
|
|
</label> |
342
|
|
|
</li> |
343
|
|
|
); |
344
|
|
|
})} |
345
|
|
|
</ul> |
346
|
|
|
</> |
347
|
|
|
) : ( |
348
|
|
|
<p role="status" data-h2-padding="b(rl, 1) b(bottom, .5)"> |
349
|
|
|
{intl.formatMessage(messages.noSkills)} |
350
|
|
|
</p> |
351
|
|
|
)} |
352
|
|
|
</div> |
353
|
|
|
)} |
354
|
|
|
</div> |
355
|
|
|
); |
356
|
|
|
}; |
357
|
|
|
|
358
|
|
|
export default SearchResults; |
359
|
|
|
|