Passed
Push — task/improve-find-skills-modal... ( 114b93 )
by Yonathan
10:33 queued 02:21
created

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

Complexity

Total Complexity 14
Complexity/F 0

Size

Lines of Code 331
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

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