Passed
Push — dev ( 55f36e...3ef13f )
by Tristan
05:38 queued 12s
created

resources/assets/js/components/FindSkillsDialog/SearchResults.tsx   A

Complexity

Total Complexity 17
Complexity/F 0

Size

Lines of Code 359
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 17
eloc 284
dl 0
loc 359
rs 10
c 0
b 0
f 0
mnd 17
bc 17
fnc 0
bpm 0
cpm 0
noi 0
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