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